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.
Files changed (117) hide show
  1. {queueflow-0.1.0 → queueflow-0.2.0}/PKG-INFO +2 -2
  2. {queueflow-0.1.0 → queueflow-0.2.0}/README.md +1 -1
  3. {queueflow-0.1.0 → queueflow-0.2.0}/pyproject.toml +1 -1
  4. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/__init__.py +1 -1
  5. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api_client.py +1 -1
  6. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/configuration.py +1 -1
  7. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/facade.py +211 -1
  8. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow.egg-info/PKG-INFO +2 -2
  9. {queueflow-0.1.0 → queueflow-0.2.0}/setup.py +2 -2
  10. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_facade.py +159 -0
  11. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/__init__.py +0 -0
  12. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/cron_api.py +0 -0
  13. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/dlq_api.py +0 -0
  14. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/health_api.py +0 -0
  15. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/jobs_api.py +0 -0
  16. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/system_api.py +0 -0
  17. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/worker_api.py +0 -0
  18. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api/workflows_api.py +0 -0
  19. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/api_response.py +0 -0
  20. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/exceptions.py +0 -0
  21. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/__init__.py +0 -0
  22. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/backoff_strategy.py +0 -0
  23. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/complete_job_request.py +0 -0
  24. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_batch_jobs_request.py +0 -0
  25. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_batch_jobs_response.py +0 -0
  26. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_cron_request.py +0 -0
  27. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_cron_response.py +0 -0
  28. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_job_request.py +0 -0
  29. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_job_response.py +0 -0
  30. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_workflow_request.py +0 -0
  31. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/create_workflow_response.py +0 -0
  32. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/cron_schedule.py +0 -0
  33. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/dead_letter.py +0 -0
  34. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/error_body.py +0 -0
  35. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/fail_job_request.py +0 -0
  36. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/health_status.py +0 -0
  37. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/heartbeat_request.py +0 -0
  38. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/heartbeat_response.py +0 -0
  39. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/job.py +0 -0
  40. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/job_config.py +0 -0
  41. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/job_config_request.py +0 -0
  42. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/job_status.py +0 -0
  43. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/lease_jobs_request.py +0 -0
  44. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/lease_jobs_response.py +0 -0
  45. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/leased_job.py +0 -0
  46. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/list_crons_response.py +0 -0
  47. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/list_dead_letters_response.py +0 -0
  48. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/list_jobs_response.py +0 -0
  49. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/list_workflows_response.py +0 -0
  50. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/on_failure.py +0 -0
  51. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/on_success.py +0 -0
  52. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/ready_status.py +0 -0
  53. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/replay_dead_letter_response.py +0 -0
  54. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/stats_snapshot.py +0 -0
  55. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/step_status.py +0 -0
  56. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/tasks_response.py +0 -0
  57. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/workflow.py +0 -0
  58. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/workflow_diagram_response.py +0 -0
  59. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/workflow_status.py +0 -0
  60. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/workflow_step.py +0 -0
  61. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/workflow_step_state.py +0 -0
  62. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/models/workflow_step_states_response.py +0 -0
  63. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/py.typed +0 -0
  64. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow/rest.py +0 -0
  65. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow.egg-info/SOURCES.txt +0 -0
  66. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow.egg-info/dependency_links.txt +0 -0
  67. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow.egg-info/requires.txt +0 -0
  68. {queueflow-0.1.0 → queueflow-0.2.0}/queueflow.egg-info/top_level.txt +0 -0
  69. {queueflow-0.1.0 → queueflow-0.2.0}/setup.cfg +0 -0
  70. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_backoff_strategy.py +0 -0
  71. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_complete_job_request.py +0 -0
  72. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_batch_jobs_request.py +0 -0
  73. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_batch_jobs_response.py +0 -0
  74. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_cron_request.py +0 -0
  75. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_cron_response.py +0 -0
  76. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_job_request.py +0 -0
  77. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_job_response.py +0 -0
  78. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_workflow_request.py +0 -0
  79. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_create_workflow_response.py +0 -0
  80. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_cron_api.py +0 -0
  81. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_cron_schedule.py +0 -0
  82. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_dead_letter.py +0 -0
  83. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_dlq_api.py +0 -0
  84. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_error_body.py +0 -0
  85. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_fail_job_request.py +0 -0
  86. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_health_api.py +0 -0
  87. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_health_status.py +0 -0
  88. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_heartbeat_request.py +0 -0
  89. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_heartbeat_response.py +0 -0
  90. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_job.py +0 -0
  91. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_job_config.py +0 -0
  92. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_job_config_request.py +0 -0
  93. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_job_status.py +0 -0
  94. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_jobs_api.py +0 -0
  95. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_lease_jobs_request.py +0 -0
  96. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_lease_jobs_response.py +0 -0
  97. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_leased_job.py +0 -0
  98. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_list_crons_response.py +0 -0
  99. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_list_dead_letters_response.py +0 -0
  100. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_list_jobs_response.py +0 -0
  101. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_list_workflows_response.py +0 -0
  102. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_on_failure.py +0 -0
  103. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_on_success.py +0 -0
  104. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_ready_status.py +0 -0
  105. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_replay_dead_letter_response.py +0 -0
  106. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_stats_snapshot.py +0 -0
  107. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_step_status.py +0 -0
  108. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_system_api.py +0 -0
  109. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_tasks_response.py +0 -0
  110. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_worker_api.py +0 -0
  111. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_workflow.py +0 -0
  112. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_workflow_diagram_response.py +0 -0
  113. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_workflow_status.py +0 -0
  114. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_workflow_step.py +0 -0
  115. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_workflow_step_state.py +0 -0
  116. {queueflow-0.1.0 → queueflow-0.2.0}/test/test_workflow_step_states_response.py +0 -0
  117. {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.1.0
3
+ Version: 0.2.0
4
4
  Summary: QueueFlow API
5
- Home-page: https://github.com/queueflow/queueflow-sdk-python
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.1.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)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "queueflow"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "QueueFlow API"
5
5
  authors = ["QueueFlow"]
6
6
  license = "MIT"
@@ -14,7 +14,7 @@
14
14
  """ # noqa: E501
15
15
 
16
16
 
17
- __version__ = "0.1.0"
17
+ __version__ = "0.2.0"
18
18
 
19
19
  # import apis into sdk package
20
20
  from queueflow.api.cron_api import CronApi
@@ -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.1.0/python'
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.1.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.1.0
3
+ Version: 0.2.0
4
4
  Summary: QueueFlow API
5
- Home-page: https://github.com/queueflow/queueflow-sdk-python
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.1.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/queueflow/queueflow-sdk-python",
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