Skip to content

Commit 2ebdb4b

Browse files
committed
refactor: introduce controller-based architecture for Qt UI async operations
Replace direct Python threading with DeadlineUIController singleton pattern for async AWS API operations. Adds AsyncTaskRunner for automatic cancellation of superseded requests, JobSubmissionWorker for job submission, and dedicated DeadlineThreadPool. Refactors deadline_config_dialog, submit_job_progress_dialog, and shared_job_settings_tab to use the new controller pattern, improving thread safety and simplifying cancellation handling. Signed-off-by: Justin Sawatzky <132946620+justinsaws@users.noreply.github.com>
1 parent d9e8f4d commit 2ebdb4b

31 files changed

Lines changed: 3586 additions & 691 deletions

DEVELOPMENT.md

Lines changed: 104 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,35 @@
33
This documentation provides guidance on developer workflows for working with the code in this repository.
44

55
Table of Contents:
6-
* [Development Environment Setup](#development-environment-setup)
7-
* [The Development Loop](#the-development-loop)
8-
* [Documentation](#documentation)
9-
* [Code Organization](#code-organization)
10-
* [Testing](#testing)
11-
* [Writing tests](#writing-tests)
12-
* [Unit tests](#unit-tests)
13-
* [Integration tests](#integration-tests)
14-
* [Squish GUI Submitter tests](#squish-tests)
15-
* [Changelog Guidelines](#changelog-guidelines)
16-
* [Things to Know](#things-to-know)
17-
* [Public contracts](#public-contracts)
18-
* [Library Dependencies](#dependencies)
19-
* [Qt and Calling AWS APIs](#qt-and-calling-aws-including-aws-deadline-cloud-apis)
6+
- [Development documentation](#development-documentation)
7+
- [Development Environment Setup](#development-environment-setup)
8+
- [The Development Loop](#the-development-loop)
9+
- [Documentation](#documentation)
10+
- [Code Organization](#code-organization)
11+
- [Testing](#testing)
12+
- [Writing Tests](#writing-tests)
13+
- [Unit Tests](#unit-tests)
14+
- [Running Unit Tests](#running-unit-tests)
15+
- [Running Docker-based Unit Tests](#running-docker-based-unit-tests)
16+
- [Integration Tests](#integration-tests)
17+
- [Running Integration Tests](#running-integration-tests)
18+
- [Squish GUI Submitter Tests](#squish-gui-submitter-tests)
19+
- [Running Squish GUI Submitter Tests](#running-squish-gui-submitter-tests)
20+
- [Changelog Guidelines](#changelog-guidelines)
21+
- [Things to Know](#things-to-know)
22+
- [Public Contracts](#public-contracts)
23+
- [Private Modules](#private-modules)
24+
- [Public Modules](#public-modules)
25+
- [On `import os as _os`](#on-import-os-as-_os)
26+
- [Library Dependencies](#library-dependencies)
27+
- [Why is a new dependency needed?](#why-is-a-new-dependency-needed)
28+
- [Quality of the dependency](#quality-of-the-dependency)
29+
- [Version Pinning](#version-pinning)
30+
- [Licensing](#licensing)
31+
- [Qt and Calling AWS (including AWS Deadline Cloud) APIs](#qt-and-calling-aws-including-aws-deadline-cloud-apis)
32+
- [Pattern 1: Simple Async Operations (Recommended)](#pattern-1-simple-async-operations-recommended)
33+
- [Pattern 2: Long-Running Operations with Progress](#pattern-2-long-running-operations-with-progress)
34+
- [Profiling in Deadline Cloud](#profiling-in-deadline-cloud)
2035

2136
## Development Environment Setup
2237

@@ -378,66 +393,89 @@ for a signal from the application.
378393
If interacting with the GUI can start multiple background threads, you should also track which
379394
is the latest, so the code only applies the result of the newest operation.
380395

381-
See `deadline_config_dialog.py` for some examples that do all of the above. Here's some
382-
code that was edited to show how it fits together:
396+
See `deadline_config_dialog.py` for some examples that do all of the above.
397+
398+
### Pattern 1: Simple Async Operations (Recommended)
399+
400+
For simple fetch-and-display operations, use `AsyncTaskRunner`:
383401

384402
```python
403+
from deadline.client.ui.controllers import AsyncTaskRunner
404+
385405
class MyCustomWidget(QWidget):
386-
# Signals for the widget to receive from the thread
387-
background_exception = Signal(str, BaseException)
388-
update = Signal(int, BackgroundResult)
389-
390-
def __init__(self, ...):
391-
# Save information about the thread
392-
self.__refresh_thread = None
393-
self.__refresh_id = 0
394-
395-
# Use the CancelationFlag object to decouple the cancelation value
396-
# from the window lifetime.
397-
self.canceled = CancelationFlag()
398-
self.destroyed.connect(self.canceled.set_canceled)
399-
400-
# Connect the Signals to handler functions that run on the main thread
401-
self.update.connect(self.handle_update)
402-
self.background_exception.connect(self.handle_background_exception)
403-
404-
def handle_background_exception(self, e: BaseException):
405-
# Handle the error
406-
QMessageBox.warning(...)
407-
408-
def handle_update(self, refresh_id: int, result: BackgroundResult):
409-
# Apply the refresh if it's still for the latest call
410-
if refresh_id == self.__refresh_id:
411-
# Do something with result
412-
self.result_widget.set_message(result)
406+
def __init__(self, ...):
407+
self._runner = AsyncTaskRunner(self)
408+
self._runner.task_error.connect(self._on_error, Qt.QueuedConnection)
413409

414410
def start_the_refresh(self):
415-
# This function starts the thread to run in the background
416-
417-
# Update the GUI state to reflect the update
418411
self.result_widget.set_refreshing_status(True)
419-
420-
self.__refresh_id += 1
421-
self.__refresh_thread = threading.Thread(
422-
target=self._refresh_thread_function,
423-
name=f"AWS Deadline Cloud Refresh Thread",
424-
args=(self.__refresh_id,),
412+
self._runner.run(
413+
operation_key="my_refresh",
414+
fn=self._fetch_data,
415+
on_success=self._handle_result,
416+
on_error=self._handle_error,
425417
)
426-
self.__refresh_thread.start()
427-
428-
def _refresh_thread_function(self, refresh_id: int):
429-
# This function is for the background thread
430-
try:
431-
# Call the slow operations
432-
result = boto3_client.potentially_expensive_api(...)
433-
# Only emit the result if it isn't canceled
434-
if not self.canceled:
435-
self.update.emit(refresh_id, result)
436-
except BaseException as e:
437-
# Use multiple signals for different meanings, such as handling errors.
438-
if not self.canceled:
439-
self.background_exception.emit(f"Background thread error", e)
440418

419+
def _fetch_data(self):
420+
# This runs in background thread
421+
return boto3_client.potentially_expensive_api(...)
422+
423+
def _handle_result(self, result):
424+
self.result_widget.set_refreshing_status(False)
425+
self.result_widget.set_message(result)
426+
427+
def _handle_error(self, error):
428+
self.result_widget.set_refreshing_status(False)
429+
QMessageBox.warning(self, "Error", str(error))
430+
```
431+
432+
### Pattern 2: Long-Running Operations with Progress
433+
434+
For complex operations with progress callbacks, use a `QThread` subclass:
435+
436+
```python
437+
from qtpy.QtCore import QThread, Signal, Qt
438+
439+
class MyWorker(QThread):
440+
progress = Signal(int, str) # percent, message
441+
succeeded = Signal(object)
442+
failed = Signal(BaseException)
443+
444+
def __init__(self, parent=None):
445+
super().__init__(parent)
446+
self._canceled = False
447+
448+
def cancel(self):
449+
self._canceled = True
450+
451+
def run(self):
452+
try:
453+
for i, item in enumerate(items):
454+
if self._canceled:
455+
return
456+
self.progress.emit(i * 100 // len(items), f"Processing {item}")
457+
process(item)
458+
self.succeeded.emit(result)
459+
except Exception as e:
460+
if not self._canceled:
461+
self.failed.emit(e)
462+
463+
464+
class MyCustomWidget(QWidget):
465+
def __init__(self, ...):
466+
self._worker = MyWorker(self)
467+
self._worker.progress.connect(self._on_progress, Qt.QueuedConnection)
468+
self._worker.succeeded.connect(self._on_success, Qt.QueuedConnection)
469+
self._worker.failed.connect(self._on_error, Qt.QueuedConnection)
470+
471+
def start_the_operation(self):
472+
self._worker.start()
473+
474+
def closeEvent(self, event):
475+
if self._worker.isRunning():
476+
self._worker.cancel()
477+
self._worker.wait()
478+
super().closeEvent(event)
441479
```
442480

443481
# Profiling in Deadline Cloud

requirements-testing.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ pytest-cov == 7.*; python_version > '3.8'
77
pytest-cov == 5.*; python_version <= '3.8'
88
pytest-timeout == 2.*
99
pytest-xdist == 3.*
10+
pytest-qt == 4.*
11+
# GUI testing dependencies
12+
PySide6-essentials >= 6.6,< 6.11
1013
freezegun == 1.*
1114
types-pyyaml == 6.*
1215
twine == 4.*; python_version == '3.7'

src/deadline/client/ui/_utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,14 @@ class CancelationFlag:
234234
function of the class. With this object, you can bind it
235235
to the cancelation flag's set_canceled method instead.
236236
237+
.. deprecated::
238+
This class is deprecated and will be removed in a future release.
239+
Use Qt's native threading mechanisms instead:
240+
- For simple async operations, use `AsyncTaskRunner` from
241+
`deadline.client.ui.controllers`
242+
- For complex operations with progress callbacks, use a
243+
`QThread` subclass with signals
244+
237245
Example usage:
238246
239247
class MyWidget(QWidget):
@@ -251,6 +259,15 @@ def _my_thread_function(self):
251259
"""
252260

253261
def __init__(self):
262+
import warnings
263+
264+
warnings.warn(
265+
"CancelationFlag is deprecated and will be removed in a future release. "
266+
"Use AsyncTaskRunner from deadline.client.ui.controllers for simple async operations, "
267+
"or a QThread subclass with signals for complex operations with progress callbacks.",
268+
DeprecationWarning,
269+
stacklevel=2,
270+
)
254271
self.canceled = False
255272

256273
def set_canceled(self):
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
3+
"""
4+
Controllers for Deadline Cloud UI operations.
5+
6+
This module provides the controller layer that separates business logic
7+
from UI components. All async API operations should go through these
8+
controllers to ensure proper ordering and thread safety.
9+
10+
The key components are:
11+
12+
- :class:`DeadlineUIController`: Central controller managing all async API
13+
operations. Widgets connect to its signals rather than making API calls directly.
14+
15+
- :class:`AsyncTaskRunner`: Manages background task execution with automatic
16+
cancellation of superseded operations.
17+
18+
- :class:`AsyncTask`: A QRunnable for executing callables in the thread pool
19+
with proper signal emission.
20+
21+
- :class:`DeadlineThreadPool`: Dedicated thread pool for Deadline operations,
22+
isolated from other Qt background work.
23+
24+
Example usage::
25+
26+
from deadline.client.ui.controllers import DeadlineUIController
27+
from qtpy.QtCore import Qt
28+
29+
controller = DeadlineUIController.getInstance()
30+
controller.farms_updated.connect(self._on_farms_updated, Qt.QueuedConnection)
31+
controller.refresh_farms()
32+
"""
33+
34+
from ._async_task import AsyncTask as AsyncTask
35+
from ._async_task import WorkerSignals as WorkerSignals
36+
from ._async_runner import AsyncTaskRunner as AsyncTaskRunner
37+
from ._deadline_controller import DeadlineUIController as DeadlineUIController
38+
from ._thread_pool import DeadlineThreadPool as DeadlineThreadPool
39+
40+
__all__ = [
41+
"AsyncTask",
42+
"AsyncTaskRunner",
43+
"DeadlineThreadPool",
44+
"DeadlineUIController",
45+
"WorkerSignals",
46+
]

0 commit comments

Comments
 (0)