Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d18f1b4
Add ewoksorange.gui.concurrency.EwoksExecutor module that contains ex…
payno Jun 29, 2026
e3555ab
executor: add process executor
payno Jun 29, 2026
56e7348
ProcessCallable: remove pop of the `progress`. Doesn't matter in sub-…
payno Jun 29, 2026
f6a817e
ProcessCallable: add doc about _Var namedtuple.
payno Jun 29, 2026
8c1c146
EwoksExecutor: create '_get_manager' function to simplify code.
payno Jun 29, 2026
1fcbaab
isort
payno Jun 29, 2026
c412d8c
ProcessCallable: remove usage of `_Var`
payno Jul 1, 2026
4852010
ewoksorange.gui.concurrency.executor: add `EwoksWorkerBase` base clas…
payno Jul 1, 2026
f324b1e
EwoksWorkerBase: remove unused 'has_task'
payno Jul 1, 2026
2eae2f4
_EwoksProcessWorker: add 'aborted_event'
payno Jul 1, 2026
e9d5528
EwoksExecutor: attempt to group `_submit_thread` and `_submit_process`
payno Jul 1, 2026
309c769
EwoksExecutor: reorder a bit the function to ease understanding
payno Jul 2, 2026
f7c0941
ewoksorange.gui.concurrency.executor.EwoksExecutor: emit an "aborted"…
payno Jul 2, 2026
2c738b7
EwoksWorkerBase: simplify base class.
payno Jul 2, 2026
a6be6fb
EwoksExecutor: improve doc.
payno Jul 2, 2026
b440926
remove unused "namedtupled"
payno Jul 2, 2026
afc7b2b
EwoksExecutor: add comment near setting 'holder'
payno Jul 2, 2026
38811a5
remove unused import
payno Jul 2, 2026
f889b93
TaskFuture: make 'abort' return a boolean.
payno Jun 30, 2026
cc9e4a9
EwoksThreadWorker: catch back again the already existing exception du…
payno Jun 30, 2026
9447e7c
EwoksThreadWorker: add comment
payno Jun 30, 2026
b6b8870
TaskFuture: improve typing
payno Jul 3, 2026
ca92156
EwoksThreadWorker: remove try / except when calling task.execute
payno Jul 13, 2026
212cd14
rename:
payno Jul 14, 2026
9de12ed
ProcessCallable: add watcher thread cleanup
woutdenolf Jul 16, 2026
dbfc867
expose public type
woutdenolf Jul 16, 2026
22cdb0b
add test script
woutdenolf Jul 16, 2026
5589cbd
ProcessCallable: add comment before setting the "abort_event"
payno Jul 17, 2026
273197f
Merge pull request #411 from ewoks-kit/387-owewokswidgetwithtaskstack…
woutdenolf Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions scripts/ewoks_executor_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import logging
import sys
from concurrent.futures import ProcessPoolExecutor
from concurrent.futures import ThreadPoolExecutor
from functools import partial

from AnyQt.QtWidgets import QApplication
from AnyQt.QtWidgets import QGridLayout
from AnyQt.QtWidgets import QHBoxLayout
from AnyQt.QtWidgets import QLabel
from AnyQt.QtWidgets import QListWidget
from AnyQt.QtWidgets import QListWidgetItem
from AnyQt.QtWidgets import QPushButton
from AnyQt.QtWidgets import QTextEdit
from AnyQt.QtWidgets import QVBoxLayout
from AnyQt.QtWidgets import QWidget
from ewokscore.tests.examples.tasks.sumtask import SumTask

from ewoksorange.gui.concurrency.executor import EwoksExecutor
from ewoksorange.gui.concurrency.executor import SubmitPolicy


class Window(QWidget):
def __init__(self):
super().__init__()

self.setWindowTitle("EwoksExecutor Demo")

self.log = QTextEdit(readOnly=True)

self.pending = QListWidget()
self.pending_items = {}

self.executors = {
"T DROP": EwoksExecutor(
ThreadPoolExecutor(max_workers=1),
SubmitPolicy.DROP_IF_BUSY,
),
"T QUEUE": EwoksExecutor(
ThreadPoolExecutor(max_workers=1),
SubmitPolicy.ALWAYS,
),
"T PARALLEL": EwoksExecutor(
ThreadPoolExecutor(max_workers=4),
SubmitPolicy.ALWAYS,
),
"P DROP": EwoksExecutor(
ProcessPoolExecutor(max_workers=1),
SubmitPolicy.DROP_IF_BUSY,
),
"P QUEUE": EwoksExecutor(
ProcessPoolExecutor(max_workers=1),
SubmitPolicy.ALWAYS,
),
"P PARALLEL": EwoksExecutor(
ProcessPoolExecutor(max_workers=4),
SubmitPolicy.ALWAYS,
),
}

for name, executor in self.executors.items():
executor.submitted.connect(partial(self.on_submitted, name))
executor.ignored.connect(partial(self.on_ignored, name))
executor.succeeded.connect(partial(self.on_succeeded, name))
executor.failed.connect(partial(self.on_failed, name))

layout = QVBoxLayout(self)
grid = QGridLayout()

buttons = [
("Submit x5 (Thread Drop)", "T DROP"),
("Submit x5 (Process Drop)", "P DROP"),
("Submit x5 (Thread Queue)", "T QUEUE"),
("Submit x5 (Process Queue)", "P QUEUE"),
("Submit x5 (Thread Parallel)", "T PARALLEL"),
("Submit x5 (Process Parallel)", "P PARALLEL"),
]

for i, (text, key) in enumerate(buttons):
button = QPushButton(text)
button.clicked.connect(partial(self.on_button_clicked, key))
grid.addWidget(button, i // 2, i % 2)

layout.addLayout(grid)

body = QHBoxLayout()

log_box = QVBoxLayout()
log_box.addWidget(QLabel("Logs"))
log_box.addWidget(self.log)
body.addLayout(log_box, 2)

pending_box = QVBoxLayout()
pending_box.addWidget(QLabel("Pending jobs"))
pending_box.addWidget(self.pending)
body.addLayout(pending_box, 1)

layout.addLayout(body)

self.counter = 1

def on_submitted(self, name, future):
if future is None:
self.log.append(f"[{name}] Submitted -> None")
else:
self.log.append(f"[{name}] Submitted -> {id(future)}")
item = QListWidgetItem(f"[{name}] {id(future)}")
self.pending.addItem(item)
self.pending_items[id(future)] = item

def on_ignored(self, name):
self.log.append(f"[{name}] ⚠ Ignored")

def on_succeeded(self, name, future, result):
values = {k: v.value for k, v in result.items()}
self.log.append(f"[{name}] ✅ {id(future)} -> {values}")
self.remove_pending(future)

def on_failed(self, name, future, exc):
self.log.append(f"[{name}] ❌ {id(future)} -> {exc}")
self.remove_pending(future)

def remove_pending(self, future):
item = self.pending_items.pop(id(future), None)
if item is not None:
self.pending.takeItem(self.pending.row(item))

def on_button_clicked(self, key, _checked=False):
self.submit_many(self.executors[key])

def submit_many(self, executor):
for _ in range(5):
executor.submit_task(
SumTask, inputs={"a": self.counter, "b": self.counter, "delay": 2}
)
self.counter += 1

def closeEvent(self, event):
for executor in self.executors.values():
executor.shutdown()
super().closeEvent(event)


if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)

app = QApplication(sys.argv)

window = Window()
window.resize(900, 500)
window.show()

sys.exit(app.exec())
214 changes: 214 additions & 0 deletions src/ewoksorange/gui/concurrency/executor/EwoksExecutor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import logging
import multiprocessing
import multiprocessing.managers
import threading
import weakref
from concurrent.futures import Executor
from concurrent.futures import Future
from concurrent.futures import ProcessPoolExecutor
from enum import Enum
from enum import auto
from threading import Event
from threading import Lock
from typing import Optional
from typing import Type

from AnyQt.QtCore import QObject
from AnyQt.QtCore import Signal
from ewokscore.task import Task

from ._EwoksProcessHandle import EwoksProcessHandle as _EwoksProcessHandle
from ._EwoksThreadHandle import EwoksThreadHandle as _EwoksThreadHandle
from ._ProcessCallable import ProcessCallable as _ProcessCallable
from .TaskFuture import TaskFuture

_logger = logging.getLogger(__name__)


class SubmitPolicy(Enum):
ALWAYS = auto()
DROP_IF_BUSY = auto()


class EwoksExecutor(QObject):
"""Qt-aware executor for ewoks tasks.

Wraps a concurrent.futures.Executor (thread or process) and emits Qt
signals on task lifecycle events. Returns TaskFuture objects that support
both native future cancellation (cancel()) and ewoks task abort (abort()).

The `started` signal is always emitted in the main thread regardless of
the executor type.

Warning: the `ProcessPoolExecutor` implementation is a beta-version.
"""

submitted = Signal(object)
"""Emitted when a task is submitted. Argument is the TaskFuture."""
started = Signal(object)
"""Emitted when a task starts executing. Argument is the TaskFuture."""
succeeded = Signal(object, object)
"""Emitted when a task finishes successfully. Arguments are the TaskFuture and the result dict."""
failed = Signal(object, object)
"""Emitted when a task raises an exception. Arguments are the TaskFuture and the exception."""
ignored = Signal()
"""Emitted when a task submission is ignored due to the DROP_IF_BUSY policy."""
aborted = Signal(object)
"""Emitted when a task was aborted. Argument is the TaskFuture."""
finished = Signal(object)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative could be to return the finished with a state (succeeded or failed) but this won't make sense to return the results or exception.

"""Emitted when a task finishes (success, failure, or abort). Argument is the TaskFuture."""

def __init__(
self,
executor: Executor,
policy: SubmitPolicy = SubmitPolicy.ALWAYS,
):
super().__init__()
self._executor = executor
self._policy = policy
self._running = 0
self._lock = Lock()
self._is_process = isinstance(executor, ProcessPoolExecutor)
# Manager is started lazily on first process submission and provides
# picklable proxy Queue / Event objects for IPC.
self._manager: Optional[multiprocessing.managers.SyncManager] = None

def submit_task(
self,
task_class: Type[Task],
**task_kwargs,
) -> Optional[TaskFuture]:
"""Submit an ewoks task for execution.

task_kwargs are forwarded directly to the task class constructor
(e.g. ``inputs={"a": 1}``). Returns a TaskFuture, or None if the
policy dropped the submission.
"""
with self._lock:
if self._policy is SubmitPolicy.DROP_IF_BUSY and self._running:
_logger.warning("Submission ignored: executor busy")
self.ignored.emit()
return None
self._running += 1

if self._is_process:
return self._submit_process(task_class, task_kwargs)
return self._submit_thread(task_class, task_kwargs)

def _submit_thread(self, task_class, task_kwargs) -> TaskFuture:
worker = _EwoksThreadHandle(task_class, **task_kwargs)
self_ref = weakref.ref(self)

# _ready gates the worker until task_future is assigned so that
# `started` is never emitted with a None reference.
_ready: Event = Event()
_holder: list = [None]

def _run():
_ready.wait()
self._emit_started(self_ref, _holder)
return worker()

raw_future = self._executor.submit(_run)
return self._finalize_submission(
raw_future, worker, self_ref, _holder, ready=_ready
)

def _submit_process(self, task_class, task_kwargs) -> TaskFuture:
manager = self._get_manager()
started_queue = manager.Queue()
abort_event = manager.Event()
Comment on lines +119 to +120

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the time each time we submit a task we will need the creation of those two objects (manager.Queue and manager.Event) that from what I have on mind can take a bit of time.
I think in general as written on the doc the ProcessPool is more a prototype in order to 'show the way'.

aborted_event = manager.Event()

callable_obj = _ProcessCallable(
task_class, task_kwargs, started_queue, abort_event, aborted_event
)
worker = _EwoksProcessHandle(abort_event, aborted_event)
self_ref = weakref.ref(self)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

used to emit the requested signal downstream

_holder: list = [None]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small hack to get the task future defined downstream and needed at execution time.


# A daemon thread blocks on the queue and relays "started" to the
# Qt main thread once the subprocess signals it.
def _relay_started():
try:
msg = started_queue.get(timeout=300)
if msg == "started":
self._emit_started(self_ref, _holder)
except Exception:
_logger.debug("started relay timed out or failed", exc_info=True)

threading.Thread(target=_relay_started, daemon=True).start()

raw_future = self._executor.submit(callable_obj)
return self._finalize_submission(raw_future, worker, self_ref, _holder)

def _finalize_submission(
self,
raw_future: Future,
worker,
self_ref,
holder: list,
ready: Optional[Event] = None,
) -> TaskFuture:
task_future = TaskFuture(raw_future, worker)
# Store the TaskFuture in a mutable list so the _run() closure can see it after _ready is set.
holder[0] = task_future
if ready is not None:
ready.set()

# submitted fires before add_done_callback so it is always the first
# signal — even when the task finishes so fast that add_done_callback
# calls the callback synchronously in this thread.
self.submitted.emit(task_future)
raw_future.add_done_callback(
lambda f: self._done_callback(self_ref, f, task_future)
)
return task_future

def _get_manager(self):
if self._manager is None:
# SyncManager server process provides proxy objects that are
# picklable and safe to pass through ProcessPoolExecutor's pickle
# serialisation. A plain multiprocessing.Queue is NOT picklable.
self._manager = multiprocessing.Manager()
return self._manager

def _handle_done(self, raw_future: Future, task_future: TaskFuture) -> None:
with self._lock:
self._running -= 1

if raw_future.cancelled():
return

if task_future.aborted():
self.aborted.emit(task_future)

try:
result = raw_future.result()
except Exception as exc:
self.failed.emit(task_future, exc)
else:
self.succeeded.emit(task_future, result)
finally:
self.finished.emit(task_future)
Comment on lines +186 to +193

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this part could be simplified by only emitting finished and the future. From this future we can retrieve the exception or result.


def shutdown(self, wait: bool = False) -> None:
self._executor.shutdown(wait=wait)
if self._manager is not None:
self._manager.shutdown()
self._manager = None

@staticmethod
def _done_callback(
executor_ref, raw_future: Future, task_future: TaskFuture
) -> None:
executor = executor_ref()
if executor is None:
return
executor._handle_done(raw_future, task_future)

@staticmethod
def _emit_started(self_ref, holder: list) -> None:
exe = self_ref()
if exe is not None:
exe.started.emit(holder[0])
Loading
Loading