queueflow 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {queueflow-0.1.0 → queueflow-0.2.0}/PKG-INFO +2 -2
- {queueflow-0.1.0 → queueflow-0.2.0}/README.md +1 -1
- {queueflow-0.1.0 → queueflow-0.2.0}/pyproject.toml +1 -1
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/__init__.py +1 -1
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api_client.py +1 -1
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/configuration.py +1 -1
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/facade.py +211 -1
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow.egg-info/PKG-INFO +2 -2
- {queueflow-0.1.0 → queueflow-0.2.0}/setup.py +2 -2
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_facade.py +159 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/__init__.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/cron_api.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/dlq_api.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/health_api.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/jobs_api.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/system_api.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/worker_api.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/workflows_api.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/exceptions.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/__init__.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/backoff_strategy.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/complete_job_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_batch_jobs_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_batch_jobs_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_cron_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_cron_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_job_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_job_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_workflow_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_workflow_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/cron_schedule.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/dead_letter.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/error_body.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/fail_job_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/health_status.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/heartbeat_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/heartbeat_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/job.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/job_config.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/job_config_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/job_status.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/lease_jobs_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/lease_jobs_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/leased_job.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/list_crons_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/list_dead_letters_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/list_jobs_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/list_workflows_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/on_failure.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/on_success.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/ready_status.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/replay_dead_letter_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/stats_snapshot.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/step_status.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/tasks_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/workflow.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/workflow_diagram_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/workflow_status.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/workflow_step.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/workflow_step_state.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/workflow_step_states_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/py.typed +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/rest.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow.egg-info/SOURCES.txt +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow.egg-info/dependency_links.txt +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow.egg-info/requires.txt +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/queueflow.egg-info/top_level.txt +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/setup.cfg +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_backoff_strategy.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_complete_job_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_batch_jobs_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_batch_jobs_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_cron_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_cron_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_job_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_job_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_workflow_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_workflow_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_cron_api.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_cron_schedule.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_dead_letter.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_dlq_api.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_error_body.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_fail_job_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_health_api.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_health_status.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_heartbeat_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_heartbeat_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_job.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_job_config.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_job_config_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_job_status.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_jobs_api.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_lease_jobs_request.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_lease_jobs_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_leased_job.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_list_crons_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_list_dead_letters_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_list_jobs_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_list_workflows_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_on_failure.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_on_success.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_ready_status.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_replay_dead_letter_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_stats_snapshot.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_step_status.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_system_api.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_tasks_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_worker_api.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_workflow.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_workflow_diagram_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_workflow_status.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_workflow_step.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_workflow_step_state.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_workflow_step_states_response.py +0 -0
- {queueflow-0.1.0 → queueflow-0.2.0}/test/test_workflows_api.py +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queueflow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: QueueFlow API
|
|
5
|
-
Home-page: https://github.com/
|
|
5
|
+
Home-page: https://github.com/elision-labs/queueflow-sdk-python
|
|
6
6
|
Author: QueueFlow
|
|
7
7
|
Author-email: team@queueflow.dev
|
|
8
8
|
License: MIT
|
|
@@ -4,7 +4,7 @@ REST API for QueueFlow, a PostgreSQL-native distributed job queue and workflow e
|
|
|
4
4
|
This Python package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
|
5
5
|
|
|
6
6
|
- API version: 1.0.0
|
|
7
|
-
- Package version: 0.
|
|
7
|
+
- Package version: 0.2.0
|
|
8
8
|
- Generator version: 7.10.0
|
|
9
9
|
- Build package: org.openapitools.codegen.languages.PythonClientCodegen
|
|
10
10
|
For more information, please visit [https://queueflow.dev](https://queueflow.dev)
|
|
@@ -90,7 +90,7 @@ class ApiClient:
|
|
|
90
90
|
self.default_headers[header_name] = header_value
|
|
91
91
|
self.cookie = cookie
|
|
92
92
|
# Set default User-Agent.
|
|
93
|
-
self.user_agent = 'OpenAPI-Generator/0.
|
|
93
|
+
self.user_agent = 'OpenAPI-Generator/0.2.0/python'
|
|
94
94
|
self.client_side_validation = configuration.client_side_validation
|
|
95
95
|
|
|
96
96
|
def __enter__(self):
|
|
@@ -504,7 +504,7 @@ class Configuration:
|
|
|
504
504
|
"OS: {env}\n"\
|
|
505
505
|
"Python Version: {pyversion}\n"\
|
|
506
506
|
"Version of the API: 1.0.0\n"\
|
|
507
|
-
"SDK Package Version: 0.
|
|
507
|
+
"SDK Package Version: 0.2.0".\
|
|
508
508
|
format(env=sys.platform, pyversion=sys.version)
|
|
509
509
|
|
|
510
510
|
def get_host_settings(self) -> List[HostSetting]:
|
|
@@ -8,8 +8,11 @@ the per-tag api clients come from the generated modules and are never edited.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import inspect
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
11
14
|
import time
|
|
12
|
-
from typing import Any, Dict, List, Optional
|
|
15
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
13
16
|
|
|
14
17
|
from .api_client import ApiClient
|
|
15
18
|
from .configuration import Configuration
|
|
@@ -20,7 +23,12 @@ from .api.system_api import SystemApi
|
|
|
20
23
|
from .api.health_api import HealthApi
|
|
21
24
|
from .api.cron_api import CronApi
|
|
22
25
|
from .api.dlq_api import DlqApi
|
|
26
|
+
from .exceptions import ApiException
|
|
27
|
+
from .models.complete_job_request import CompleteJobRequest
|
|
23
28
|
from .models.create_job_request import CreateJobRequest
|
|
29
|
+
from .models.fail_job_request import FailJobRequest
|
|
30
|
+
from .models.heartbeat_request import HeartbeatRequest
|
|
31
|
+
from .models.lease_jobs_request import LeaseJobsRequest
|
|
24
32
|
from .models.job_config_request import JobConfigRequest
|
|
25
33
|
from .models.create_workflow_request import CreateWorkflowRequest
|
|
26
34
|
from .models.create_cron_request import CreateCronRequest
|
|
@@ -40,6 +48,35 @@ class WorkflowValidationError(ValueError):
|
|
|
40
48
|
"""Raised by WorkflowBuilder.build() for a structurally invalid DAG."""
|
|
41
49
|
|
|
42
50
|
|
|
51
|
+
class NonRetryableError(Exception):
|
|
52
|
+
"""Raise from a worker handler to mark the failure PERMANENT: the job
|
|
53
|
+
skips its remaining retries and dead-letters immediately (e.g. invalid
|
|
54
|
+
input). Any raised exception carrying ``retryable = False`` behaves the
|
|
55
|
+
same; this class is the convenient spelling."""
|
|
56
|
+
|
|
57
|
+
retryable = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class WorkerContext:
|
|
61
|
+
"""Per-job context handed to two-argument worker handlers.
|
|
62
|
+
|
|
63
|
+
``cancelled`` flips to True when the job's lease is lost (the job was
|
|
64
|
+
cancelled mid-run, or the lease expired and was reclaimed). From then on
|
|
65
|
+
the server owns the outcome: long handlers should check it periodically
|
|
66
|
+
and stop, because any further work is discarded.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self._lost = threading.Event()
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def cancelled(self) -> bool:
|
|
74
|
+
return self._lost.is_set()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_logger = logging.getLogger("queueflow.worker")
|
|
78
|
+
|
|
79
|
+
|
|
43
80
|
class QueueFlow:
|
|
44
81
|
"""Ergonomic client wrapping the generated api clients.
|
|
45
82
|
|
|
@@ -159,6 +196,179 @@ class QueueFlow:
|
|
|
159
196
|
replayed = self.dlq.replay_dead_letter(dead_letter_id)
|
|
160
197
|
return self.jobs.get_job(replayed.job_id)
|
|
161
198
|
|
|
199
|
+
# --- worker runtime -------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def run_worker(
|
|
202
|
+
self,
|
|
203
|
+
queue: str,
|
|
204
|
+
handlers: Dict[str, Callable[..., Optional[Dict[str, Any]]]],
|
|
205
|
+
lease_secs: int = 30,
|
|
206
|
+
wait_secs: int = 20,
|
|
207
|
+
stop: Optional["threading.Event"] = None,
|
|
208
|
+
on_error: Optional[Callable[[Exception], None]] = None,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Run a worker loop: lease jobs from ``queue``, dispatch to
|
|
211
|
+
``handlers`` by task name, heartbeat while a handler runs, and report
|
|
212
|
+
the outcome. The engine owns retries, backoff, the dead-letter queue,
|
|
213
|
+
and workflow advancement.
|
|
214
|
+
|
|
215
|
+
Semantics (matching the Node SDK's ``qf.worker.run``):
|
|
216
|
+
|
|
217
|
+
* Delivery is **at-least-once** — make handlers idempotent.
|
|
218
|
+
* A handler returns the job's result dict (or None for ``{}``).
|
|
219
|
+
Raising fails the job through the engine's retry policy; raise
|
|
220
|
+
:class:`NonRetryableError` (or any exception with
|
|
221
|
+
``retryable = False``) to dead-letter immediately.
|
|
222
|
+
* Each in-flight job is heartbeated at half the lease interval. A
|
|
223
|
+
heartbeat showing the job is no longer running (cancelled mid-run,
|
|
224
|
+
or the lease was reclaimed) abandons reporting — the server owns
|
|
225
|
+
the outcome — and flips ``ctx.cancelled`` so a two-argument handler
|
|
226
|
+
``(job, ctx)`` can stop early.
|
|
227
|
+
* A failed *report* is never re-reported as a job failure: the lease
|
|
228
|
+
expires and the engine redelivers.
|
|
229
|
+
* Lease errors with HTTP 401/403 raise (a wrong or missing
|
|
230
|
+
``worker_token`` cannot heal by retrying); other lease errors go to
|
|
231
|
+
``on_error`` (or a throttled log) with a 1s backoff.
|
|
232
|
+
|
|
233
|
+
Blocks until ``stop`` (a ``threading.Event``) is set.
|
|
234
|
+
"""
|
|
235
|
+
_logger.info("worker leasing %r (tasks: %s)", queue, ", ".join(sorted(handlers)))
|
|
236
|
+
failures = 0
|
|
237
|
+
while stop is None or not stop.is_set():
|
|
238
|
+
try:
|
|
239
|
+
leased = self.worker.lease_jobs(
|
|
240
|
+
queue,
|
|
241
|
+
LeaseJobsRequest(max_jobs=1, lease_secs=lease_secs, wait_secs=wait_secs),
|
|
242
|
+
).jobs
|
|
243
|
+
failures = 0
|
|
244
|
+
except ApiException as err:
|
|
245
|
+
if err.status in (401, 403):
|
|
246
|
+
raise
|
|
247
|
+
failures += 1
|
|
248
|
+
self._lease_failed(err, failures, on_error)
|
|
249
|
+
if self._sleep_interruptibly(1.0, stop):
|
|
250
|
+
break
|
|
251
|
+
continue
|
|
252
|
+
except Exception as err: # noqa: BLE001 - transport-level failure
|
|
253
|
+
failures += 1
|
|
254
|
+
self._lease_failed(err, failures, on_error)
|
|
255
|
+
if self._sleep_interruptibly(1.0, stop):
|
|
256
|
+
break
|
|
257
|
+
continue
|
|
258
|
+
for lease in leased:
|
|
259
|
+
self._run_one(lease, handlers, lease_secs)
|
|
260
|
+
_logger.info("worker stopped")
|
|
261
|
+
|
|
262
|
+
def _lease_failed(self, err, failures, on_error) -> None:
|
|
263
|
+
if on_error is not None:
|
|
264
|
+
on_error(err)
|
|
265
|
+
elif failures == 1 or failures % 30 == 0:
|
|
266
|
+
_logger.warning("lease failed (%d consecutive): %s", failures, err)
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def _sleep_interruptibly(seconds: float, stop: Optional["threading.Event"]) -> bool:
|
|
270
|
+
"""Sleep; returns True if ``stop`` fired."""
|
|
271
|
+
if stop is None:
|
|
272
|
+
time.sleep(seconds)
|
|
273
|
+
return False
|
|
274
|
+
return stop.wait(seconds)
|
|
275
|
+
|
|
276
|
+
def _run_one(self, lease, handlers, lease_secs: int) -> None:
|
|
277
|
+
job = lease.job
|
|
278
|
+
handler = handlers.get(job.task_name)
|
|
279
|
+
if handler is None:
|
|
280
|
+
self._report(
|
|
281
|
+
job.id,
|
|
282
|
+
FailJobRequest(
|
|
283
|
+
lease_token=lease.lease_token,
|
|
284
|
+
error="no worker handler for task '" + job.task_name + "'",
|
|
285
|
+
retryable=False,
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
ctx = WorkerContext()
|
|
291
|
+
hb_done = threading.Event()
|
|
292
|
+
hb = threading.Thread(
|
|
293
|
+
target=self._heartbeat_loop,
|
|
294
|
+
args=(job.id, lease.lease_token, lease_secs, ctx, hb_done),
|
|
295
|
+
daemon=True,
|
|
296
|
+
)
|
|
297
|
+
hb.start()
|
|
298
|
+
|
|
299
|
+
# Handler outcome and outcome *reporting* are separate concerns: a
|
|
300
|
+
# reporting error must never be re-reported as a job failure.
|
|
301
|
+
outcome_error: Optional[BaseException] = None
|
|
302
|
+
result: Dict[str, Any] = {}
|
|
303
|
+
try:
|
|
304
|
+
result = self._call_handler(handler, job, ctx) or {}
|
|
305
|
+
except Exception as err: # noqa: BLE001 - the engine applies policy
|
|
306
|
+
outcome_error = err
|
|
307
|
+
finally:
|
|
308
|
+
hb_done.set()
|
|
309
|
+
hb.join(timeout=5)
|
|
310
|
+
|
|
311
|
+
if ctx.cancelled:
|
|
312
|
+
return # the server owns the outcome
|
|
313
|
+
|
|
314
|
+
if outcome_error is None:
|
|
315
|
+
self._report(job.id, CompleteJobRequest(lease_token=lease.lease_token, result=result))
|
|
316
|
+
else:
|
|
317
|
+
retryable = getattr(outcome_error, "retryable", True) is not False
|
|
318
|
+
self._report(
|
|
319
|
+
job.id,
|
|
320
|
+
FailJobRequest(
|
|
321
|
+
lease_token=lease.lease_token,
|
|
322
|
+
error=str(outcome_error) or type(outcome_error).__name__,
|
|
323
|
+
retryable=retryable,
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
@staticmethod
|
|
328
|
+
def _call_handler(handler, job, ctx):
|
|
329
|
+
"""Call ``handler(job)`` or ``handler(job, ctx)`` by arity."""
|
|
330
|
+
try:
|
|
331
|
+
params = [
|
|
332
|
+
p
|
|
333
|
+
for p in inspect.signature(handler).parameters.values()
|
|
334
|
+
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
|
|
335
|
+
]
|
|
336
|
+
wants_ctx = len(params) >= 2
|
|
337
|
+
except (TypeError, ValueError):
|
|
338
|
+
wants_ctx = False
|
|
339
|
+
return handler(job, ctx) if wants_ctx else handler(job)
|
|
340
|
+
|
|
341
|
+
def _report(self, job_id: str, request) -> None:
|
|
342
|
+
try:
|
|
343
|
+
if isinstance(request, CompleteJobRequest):
|
|
344
|
+
self.worker.complete_job(job_id, request)
|
|
345
|
+
else:
|
|
346
|
+
self.worker.fail_job(job_id, request)
|
|
347
|
+
except Exception as err: # noqa: BLE001 - lease expiry redelivers
|
|
348
|
+
_logger.warning(
|
|
349
|
+
"failed to report job %s outcome; the lease will expire and the engine redelivers: %s",
|
|
350
|
+
job_id,
|
|
351
|
+
err,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def _heartbeat_loop(self, job_id, lease_token, lease_secs, ctx, done) -> None:
|
|
355
|
+
interval = max(1.0, lease_secs / 2.0)
|
|
356
|
+
while not done.wait(interval):
|
|
357
|
+
try:
|
|
358
|
+
status = self.worker.heartbeat_job(
|
|
359
|
+
job_id,
|
|
360
|
+
HeartbeatRequest(lease_token=lease_token, extend_secs=lease_secs),
|
|
361
|
+
).status
|
|
362
|
+
if status != "running":
|
|
363
|
+
ctx._lost.set()
|
|
364
|
+
return
|
|
365
|
+
except ApiException as err:
|
|
366
|
+
if err.status == 409:
|
|
367
|
+
ctx._lost.set()
|
|
368
|
+
return
|
|
369
|
+
except Exception: # noqa: BLE001 - transient; next tick retries
|
|
370
|
+
pass
|
|
371
|
+
|
|
162
372
|
# --- internal -----------------------------------------------------------
|
|
163
373
|
|
|
164
374
|
@staticmethod
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queueflow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: QueueFlow API
|
|
5
|
-
Home-page: https://github.com/
|
|
5
|
+
Home-page: https://github.com/elision-labs/queueflow-sdk-python
|
|
6
6
|
Author: QueueFlow
|
|
7
7
|
Author-email: team@queueflow.dev
|
|
8
8
|
License: MIT
|
|
@@ -21,7 +21,7 @@ from setuptools import setup, find_packages # noqa: H301
|
|
|
21
21
|
# prerequisite: setuptools
|
|
22
22
|
# http://pypi.python.org/pypi/setuptools
|
|
23
23
|
NAME = "queueflow"
|
|
24
|
-
VERSION = "0.
|
|
24
|
+
VERSION = "0.2.0"
|
|
25
25
|
PYTHON_REQUIRES = ">= 3.8"
|
|
26
26
|
REQUIRES = [
|
|
27
27
|
"urllib3 >= 1.25.3, < 3.0.0",
|
|
@@ -36,7 +36,7 @@ setup(
|
|
|
36
36
|
description="QueueFlow API",
|
|
37
37
|
author="QueueFlow",
|
|
38
38
|
author_email="team@queueflow.dev",
|
|
39
|
-
url="https://github.com/
|
|
39
|
+
url="https://github.com/elision-labs/queueflow-sdk-python",
|
|
40
40
|
keywords=["OpenAPI", "OpenAPI-Generator", "QueueFlow API"],
|
|
41
41
|
install_requires=REQUIRES,
|
|
42
42
|
packages=find_packages(exclude=["test", "tests"]),
|
|
@@ -265,3 +265,162 @@ class TestWorkflows(unittest.TestCase):
|
|
|
265
265
|
|
|
266
266
|
if __name__ == "__main__":
|
|
267
267
|
unittest.main()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# --- worker runtime ----------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
import threading
|
|
273
|
+
import time as _time
|
|
274
|
+
|
|
275
|
+
from queueflow.exceptions import ApiException
|
|
276
|
+
from queueflow.facade import NonRetryableError
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def lease(task: str = "echo", job_id: str = "j1", token: str = "tok-1") -> SimpleNamespace:
|
|
280
|
+
return SimpleNamespace(
|
|
281
|
+
job=SimpleNamespace(id=job_id, task_name=task, payload={}), lease_token=token
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class FakeWorkerApi:
|
|
286
|
+
"""Serves a scripted list of leases, then sets `stop` so run_worker exits."""
|
|
287
|
+
|
|
288
|
+
def __init__(
|
|
289
|
+
self,
|
|
290
|
+
leases: Optional[List[SimpleNamespace]] = None,
|
|
291
|
+
heartbeat_status: str = "running",
|
|
292
|
+
lease_error: Optional[Exception] = None,
|
|
293
|
+
complete_error: Optional[Exception] = None,
|
|
294
|
+
) -> None:
|
|
295
|
+
self.pending = list(leases or [])
|
|
296
|
+
self.heartbeat_status = heartbeat_status
|
|
297
|
+
self.lease_error = lease_error
|
|
298
|
+
self.complete_error = complete_error
|
|
299
|
+
self.completes: List[Any] = []
|
|
300
|
+
self.fails: List[Any] = []
|
|
301
|
+
self.heartbeats = 0
|
|
302
|
+
self.stop = threading.Event()
|
|
303
|
+
|
|
304
|
+
def lease_jobs(self, queue: str, req: Any) -> Any:
|
|
305
|
+
if self.lease_error is not None:
|
|
306
|
+
err, self.lease_error = self.lease_error, None
|
|
307
|
+
raise err
|
|
308
|
+
if not self.pending:
|
|
309
|
+
self.stop.set()
|
|
310
|
+
return SimpleNamespace(jobs=[])
|
|
311
|
+
return SimpleNamespace(jobs=[self.pending.pop(0)])
|
|
312
|
+
|
|
313
|
+
def heartbeat_job(self, job_id: str, req: Any) -> Any:
|
|
314
|
+
self.heartbeats += 1
|
|
315
|
+
return SimpleNamespace(status=self.heartbeat_status)
|
|
316
|
+
|
|
317
|
+
def complete_job(self, job_id: str, req: Any) -> None:
|
|
318
|
+
if self.complete_error is not None:
|
|
319
|
+
raise self.complete_error
|
|
320
|
+
self.completes.append((job_id, req))
|
|
321
|
+
|
|
322
|
+
def fail_job(self, job_id: str, req: Any) -> None:
|
|
323
|
+
self.fails.append((job_id, req))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def worker_client(api: FakeWorkerApi) -> QueueFlow:
|
|
327
|
+
qf = QueueFlow("http://qf.test", "test-token")
|
|
328
|
+
qf.worker = api
|
|
329
|
+
return qf
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class TestWorkerRuntime(unittest.TestCase):
|
|
333
|
+
def run_until_drained(self, api: FakeWorkerApi, handlers: Dict[str, Any], **kw: Any) -> None:
|
|
334
|
+
worker_client(api).run_worker("q", handlers, stop=api.stop, wait_secs=0, **kw)
|
|
335
|
+
|
|
336
|
+
def test_happy_path_completes_with_the_handler_result(self) -> None:
|
|
337
|
+
api = FakeWorkerApi([lease()])
|
|
338
|
+
self.run_until_drained(api, {"echo": lambda job: {"ok": job.id}})
|
|
339
|
+
self.assertEqual(len(api.completes), 1)
|
|
340
|
+
job_id, req = api.completes[0]
|
|
341
|
+
self.assertEqual(job_id, "j1")
|
|
342
|
+
self.assertEqual(req.lease_token, "tok-1")
|
|
343
|
+
self.assertEqual(req.result, {"ok": "j1"})
|
|
344
|
+
self.assertEqual(api.fails, [])
|
|
345
|
+
|
|
346
|
+
def test_none_result_becomes_an_empty_dict(self) -> None:
|
|
347
|
+
api = FakeWorkerApi([lease()])
|
|
348
|
+
self.run_until_drained(api, {"echo": lambda job: None})
|
|
349
|
+
self.assertEqual(api.completes[0][1].result, {})
|
|
350
|
+
|
|
351
|
+
def test_a_raising_handler_fails_retryably(self) -> None:
|
|
352
|
+
def boom(job):
|
|
353
|
+
raise RuntimeError("gateway 503")
|
|
354
|
+
|
|
355
|
+
api = FakeWorkerApi([lease()])
|
|
356
|
+
self.run_until_drained(api, {"echo": boom})
|
|
357
|
+
self.assertEqual(api.completes, [])
|
|
358
|
+
_, req = api.fails[0]
|
|
359
|
+
self.assertIn("gateway 503", req.error)
|
|
360
|
+
self.assertNotEqual(req.retryable, False)
|
|
361
|
+
|
|
362
|
+
def test_non_retryable_error_dead_letters(self) -> None:
|
|
363
|
+
def reject(job):
|
|
364
|
+
raise NonRetryableError("fraud")
|
|
365
|
+
|
|
366
|
+
api = FakeWorkerApi([lease()])
|
|
367
|
+
self.run_until_drained(api, {"echo": reject})
|
|
368
|
+
_, req = api.fails[0]
|
|
369
|
+
self.assertEqual(req.retryable, False)
|
|
370
|
+
|
|
371
|
+
def test_unknown_task_fails_non_retryably(self) -> None:
|
|
372
|
+
api = FakeWorkerApi([lease(task="ghost")])
|
|
373
|
+
self.run_until_drained(api, {"echo": lambda job: {}})
|
|
374
|
+
_, req = api.fails[0]
|
|
375
|
+
self.assertEqual(req.retryable, False)
|
|
376
|
+
self.assertIn("ghost", req.error)
|
|
377
|
+
|
|
378
|
+
def test_auth_errors_raise_instead_of_spinning(self) -> None:
|
|
379
|
+
api = FakeWorkerApi(lease_error=ApiException(status=403, reason="forbidden"))
|
|
380
|
+
with self.assertRaises(ApiException):
|
|
381
|
+
self.run_until_drained(api, {})
|
|
382
|
+
|
|
383
|
+
def test_transient_lease_errors_back_off_and_continue(self) -> None:
|
|
384
|
+
api = FakeWorkerApi([lease()], lease_error=RuntimeError("connection reset"))
|
|
385
|
+
seen: List[Exception] = []
|
|
386
|
+
self.run_until_drained(api, {"echo": lambda job: {}}, on_error=seen.append)
|
|
387
|
+
self.assertEqual(len(seen), 1)
|
|
388
|
+
self.assertEqual(len(api.completes), 1)
|
|
389
|
+
|
|
390
|
+
def test_two_argument_handlers_receive_a_context(self) -> None:
|
|
391
|
+
contexts: List[Any] = []
|
|
392
|
+
|
|
393
|
+
def handler(job, ctx):
|
|
394
|
+
contexts.append(ctx)
|
|
395
|
+
return {}
|
|
396
|
+
|
|
397
|
+
api = FakeWorkerApi([lease()])
|
|
398
|
+
self.run_until_drained(api, {"echo": handler})
|
|
399
|
+
self.assertEqual(len(contexts), 1)
|
|
400
|
+
self.assertFalse(contexts[0].cancelled)
|
|
401
|
+
|
|
402
|
+
def test_lost_lease_abandons_reporting_and_cancels_the_context(self) -> None:
|
|
403
|
+
observed = {"cancelled_seen": False}
|
|
404
|
+
|
|
405
|
+
def slow(job, ctx):
|
|
406
|
+
# Outlive one 1s heartbeat tick (lease_secs=2); the fake reports
|
|
407
|
+
# the job cancelled, which must flip ctx.cancelled.
|
|
408
|
+
deadline = _time.monotonic() + 4.0
|
|
409
|
+
while _time.monotonic() < deadline:
|
|
410
|
+
if ctx.cancelled:
|
|
411
|
+
observed["cancelled_seen"] = True
|
|
412
|
+
return {}
|
|
413
|
+
_time.sleep(0.05)
|
|
414
|
+
return {}
|
|
415
|
+
|
|
416
|
+
api = FakeWorkerApi([lease()], heartbeat_status="cancelled")
|
|
417
|
+
self.run_until_drained(api, {"echo": slow}, lease_secs=2)
|
|
418
|
+
self.assertTrue(observed["cancelled_seen"])
|
|
419
|
+
self.assertGreaterEqual(api.heartbeats, 1)
|
|
420
|
+
self.assertEqual(api.completes, [])
|
|
421
|
+
self.assertEqual(api.fails, [])
|
|
422
|
+
|
|
423
|
+
def test_a_failed_report_is_never_converted_into_a_job_failure(self) -> None:
|
|
424
|
+
api = FakeWorkerApi([lease()], complete_error=RuntimeError("engine restarting"))
|
|
425
|
+
self.run_until_drained(api, {"echo": lambda job: {"ok": True}})
|
|
426
|
+
self.assertEqual(api.fails, []) # lease expiry redelivers; no fail() call
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|