Skip to content

add ewoksorange.gui.concurrency.executor module#405

Merged
payno merged 29 commits into
mainfrom
387-owewokswidgetwithtaskstack-provide-api-to-cancel-a-task-3
Jul 17, 2026
Merged

add ewoksorange.gui.concurrency.executor module#405
payno merged 29 commits into
mainfrom
387-owewokswidgetwithtaskstack-provide-api-to-cancel-a-task-3

Conversation

@payno

@payno payno commented Jun 29, 2026

Copy link
Copy Markdown
Member

This PR; done with Claude support add a gui.concurrency.executor module.

This module include mechanism for execution in the context of ewoks tasks.

The goal is to replace the existing widget executors by this module and add a "demo" for the multi-process. Update of existing will be done in another dedicated PR.

Preparatory PR for #406

AI disclosure: Done using Claude

@payno payno self-assigned this Jun 29, 2026
@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

(ThreadPoolExecutor, 4, SubmitPolicy.ALWAYS), id="thread-4-parallel"
),
pytest.param(
(ProcessPoolExecutor, 2, SubmitPolicy.ALWAYS), id="process-2-parallel"

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.

I only added one test with the ProcessPoolExecutor as mentioned in the code this is more some code to show the way to go but most likely some time will be needed to check possible bottlenecks.

Comment on lines +130 to +131
started_queue = manager.Queue()
abort_event = manager.Event()

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'.

task_class, task_kwargs, started_queue, abort_event
)
worker = _EwoksProcessWorker(abort_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

)
worker = _EwoksProcessWorker(abort_event)
self_ref = weakref.ref(self)
_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.

payno added 4 commits June 29, 2026 17:17
* split src.ewoksorange.gui.concurrency.EwoksExecutor to a 'executor' module
* add:

  * ProcessCallable: Top-level picklable callable submitted to ProcessPoolExecutor.
  * EwoksProcessWorker: worker for the process
@payno
payno force-pushed the 387-owewokswidgetwithtaskstack-provide-api-to-cancel-a-task-3 branch from 9b20747 to 8c1c146 Compare June 29, 2026 15:17
@payno payno changed the title Draft: 387 owewokswidgetwithtaskstack provide api to cancel a task 3 Draft: add ewoksorange.gui.concurrency.executor module Jun 30, 2026
self._task_class = task_class
self._task_kwargs = task_kwargs
self._task: Optional[Task] = None
self._lock = Lock()

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.

It makes sure there is no conflict when abort is called (if _task is not created yet in call).

_Var = namedtuple("_Var", ["value"])


class ProcessCallable:

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.

This part is needed for submitting process only.

self._started_queue = started_queue
self._abort_event = abort_event

def __call__(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.

This is the implementation proposed by Claude. The combination of thread / process seems a bit heavy.

An alternative could be to say that processes are not cancelled to not use the task.cancel and fill directly the process (using process.kill).

But again I think this submission to process is more a demo at the moment and to be modified by people concerned about this use case as they will probably have specific use cases.

succeeded = Signal(object, object)
failed = Signal(object, object)
ignored = Signal()
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.

Comment on lines +172 to +179
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)

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.

@payno payno changed the title Draft: add ewoksorange.gui.concurrency.executor module add ewoksorange.gui.concurrency.executor module Jun 30, 2026
@payno
payno requested a review from a team June 30, 2026 15:08
@woutdenolf

woutdenolf commented Jun 30, 2026

Copy link
Copy Markdown
Member

Oef, much more complex than my proposal #403 (comment)

In the spirit of "separation of concerns" could be make the executor Ewoks agnostic?

Can you list the pro's of this approach with respect to #403 (comment) ?

If we can't use the existing

from concurrent.futures import ProcessPoolExecutor
from concurrent.futures import ThreadPoolExecutor

can we at least derive executors from

from concurrent.futures import Executor

so we don't have to invent things like TaskFuture?

Could you also add a standalone example similar to this one in the scripts folder?

#403 (comment)

@payno

payno commented Jun 30, 2026

Copy link
Copy Markdown
Member Author

Well I think the key point is that we want to be able to call the ewoks task cancel function in order to be able to abort it (sorry for misleading names).

And this is why we have this TaskFuture and the EwoksExecutor which is wrapping the a concurrent.futures.Executor.

Behind it we use it as:

        EwoksExecutor(
            ThreadPoolExecutor(max_workers=self._MAX_WORKERS),  # or ProcessPoolExecutor
            self._SUBMIT_POLICY,
        )

Then the Process iteration also add some complexity because it requires the ProcessCallable. Even if on this one (as mentioned in the PR) maybe there is a way to simplify things by calling process.kill directly.

Here is the 'gui' updated with the code you had on your PR.

import logging
import sys
from concurrent.futures import ProcessPoolExecutor
from concurrent.futures import ThreadPoolExecutor

from AnyQt.QtWidgets import QApplication
from AnyQt.QtWidgets import QGridLayout
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, SubmitPolicy


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

        self.setWindowTitle("EwoksExecutor Demo")

        self.log = QTextEdit(readOnly=True)

        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(
                lambda future, n=name: self.on_submitted(n, future)
            )
            executor.ignored.connect(lambda n=name: self.log.append(f"[{n}] ⚠ Ignored"))
            executor.succeeded.connect(
                lambda future, result, n=name: self.log.append(
                    f"[{n}] ✅ {id(future)} -> { {k: v.value for k, v in result.items()} }"
                )
            )
            executor.failed.connect(
                lambda future, exc, n=name: self.log.append(
                    f"[{n}] ❌ {id(future)} -> {exc}"
                )
            )

        layout = QVBoxLayout(self)
        grid = QGridLayout()

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

        for i, (text, key) in enumerate(buttons):
            button = QPushButton(text)
            button.clicked.connect(
                lambda checked=False, k=key: self.submit_many(self.executors[k])
            )
            grid.addWidget(button, i // 2, i % 2)

        layout.addLayout(grid)
        layout.addWidget(self.log)

        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)}")

    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())

@payno

payno commented Jun 30, 2026

Copy link
Copy Markdown
Member Author

We can remove the execution on Process. This is what add the most complexity (see commit e3555ab)
Without it this simplify a lot. But as this was more or less a target to have at one point I wanted to have an idea of how we could do this.

@woutdenolf

Copy link
Copy Markdown
Member

Ok my bad, I saw a raw threading. Thread and queue and thought we were re-implementing the executor logic from python.

Comment on lines +31 to +35
except Exception as exc:
# ewokscore wraps run() exceptions in RuntimeError; re-raise the
# original so callers see the same type as the nothread path.
original = task.exception
raise original if original is not None else exc

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Remove try-except. The raises exception is task.exception. We should fix TaskExecutor accordingly. I can do that in a follow-up.

@woutdenolf woutdenolf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the hard work. Perhaps best to discuss in person because there are some tricky things in the current implementation.

Script

Could you make a scripts folder and add the example?

Task execution logic

When executing a task without cancel/abort, this happens:

  • submit task to executor
  • emit "submitted" Qt signal with future are argument
  • emit "started" Qt signal with future are argument
  • emit either
    • "failed" Qt signal with future and exception as argument
    • "succeeded" Qt signal with future and task.output_variables as arguments
  • emit "finished" with future as argument

The execution state is wrapped with a EwoksWorker. It is intended to be executed once.

The TaskFuture has a reference to the native future and the EwoksWorker.

The EwoksWorker exposes abort() which is exposed in turn by TaskFuture.

When calling TaskFuture.cancel() the task gets dropped from the native executor but we don't has a Qt signal for that. Also the implementation of the submision blocks until the task is actually running. That should be fixed.

Also TaskFuture.abort() returns True when the task is reached and aborted. This is not the case for threads.

Also we need to handle the case where the task gets aborted before it even gets scheduled but the native executor.

Is TaskFuture.abort() supposed to be a blocking call? If not how can it return True or False, unless there is an "_aborted" state?

Harmonization

Both EwoksExecutor._submit_thread and EwoksExecutor._submit_process achieve
the same thing but it is very hard to know from reading the code.

  • EwoksThreadWorker -> ProcessCallable + EwoksProcessWorker
  • run -> relay_started + ProcessCallable

The logic is valid (except that it blocks until the task is running which needs fixing) but needs refactoring for readability and harmony.

That sounds like an ideal AI job.

If I would do it manually I would start by

  • making an abstract EwoksWorker
  • rename "worker" to something that reflects what it actually does (this will illustrate what needs to be refactored)
  • use that abstract type as a type annotation in TaskFuture.__init__
  • then the refactoring

Comment thread src/ewoksorange/gui/concurrency/executor/_EwoksThreadWorker.py Outdated
Comment thread src/ewoksorange/gui/concurrency/executor/_EwoksProcessWorker.py Outdated
Comment thread src/ewoksorange/gui/concurrency/executor/_ProcessCallable.py Outdated
Comment thread src/ewoksorange/gui/concurrency/executor/TaskFuture.py Outdated
Comment thread src/ewoksorange/gui/concurrency/executor/_EwoksProcessWorker.py
@payno
payno force-pushed the 387-owewokswidgetwithtaskstack-provide-api-to-cancel-a-task-3 branch from 57e77b8 to e9d5528 Compare July 1, 2026 15:25
@payno
payno requested a review from woutdenolf July 2, 2026 11:48
@payno
payno force-pushed the 387-owewokswidgetwithtaskstack-provide-api-to-cancel-a-task-3 branch from 2288dc5 to b6b8870 Compare July 3, 2026 06:55
@payno
payno force-pushed the 387-owewokswidgetwithtaskstack-provide-api-to-cancel-a-task-3 branch from 4fd5344 to ca92156 Compare July 14, 2026 08:04
payno and others added 4 commits July 14, 2026 10:24
1. 'EwoksTaskHandle' to 'EwoksTaskHandle'
2. 'EwoksThreadWorker' to 'EwoksThreadHandle'

@woutdenolf woutdenolf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I made a PR to this PR to add a thread cleanup fix script and an example: #411.

I'm still wondering whether the split of _EwoksThreadHandle into _EwoksProcessHandle and _ProcessCallable cannot be done in a cleaner way. Perhaps split _EwoksThreadHandle in the same way for consistency reasons. But this is internal API and can be changed at any time. Functionally everything is sound now. LGTM after #411.

Thanks for the huge effort!

payno and others added 2 commits July 17, 2026 13:04
…-provide-api-to-cancel-a-task-3-add_example

387 owewokswidgetwithtaskstack provide api to cancel a task 3 add example
@payno
payno merged commit eec506f into main Jul 17, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants