queueflow 0.2.2__tar.gz → 0.4.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 (140) hide show
  1. {queueflow-0.2.2/queueflow.egg-info → queueflow-0.4.0}/PKG-INFO +98 -9
  2. {queueflow-0.2.2 → queueflow-0.4.0}/README.md +97 -8
  3. {queueflow-0.2.2 → queueflow-0.4.0}/pyproject.toml +1 -1
  4. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/__init__.py +11 -2
  5. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/api/cron_api.py +35 -1
  6. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/api/dlq_api.py +314 -1
  7. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/api/health_api.py +1 -1
  8. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/api/jobs_api.py +317 -1
  9. queueflow-0.4.0/queueflow/api/system_api.py +1551 -0
  10. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/api/worker_api.py +1 -1
  11. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/api/workflows_api.py +35 -1
  12. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/api_client.py +2 -2
  13. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/configuration.py +3 -3
  14. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/exceptions.py +1 -1
  15. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/facade.py +305 -41
  16. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/__init__.py +10 -1
  17. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/backoff_strategy.py +1 -1
  18. queueflow-0.4.0/queueflow/models/cancel_jobs_request.py +128 -0
  19. queueflow-0.4.0/queueflow/models/cancel_jobs_response.py +88 -0
  20. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/complete_job_request.py +1 -1
  21. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/create_batch_jobs_request.py +1 -1
  22. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/create_batch_jobs_response.py +1 -1
  23. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/create_cron_request.py +1 -1
  24. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/create_cron_response.py +1 -1
  25. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/create_job_request.py +1 -1
  26. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/create_job_response.py +1 -1
  27. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/create_workflow_request.py +1 -1
  28. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/create_workflow_response.py +1 -1
  29. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/cron_schedule.py +1 -1
  30. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/dead_letter.py +1 -1
  31. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/error_body.py +1 -1
  32. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/fail_job_request.py +1 -1
  33. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/health_status.py +1 -1
  34. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/heartbeat_request.py +1 -1
  35. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/heartbeat_response.py +1 -1
  36. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/job.py +1 -1
  37. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/job_config.py +1 -1
  38. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/job_config_request.py +1 -1
  39. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/job_status.py +1 -1
  40. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/lease_jobs_request.py +1 -1
  41. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/lease_jobs_response.py +1 -1
  42. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/leased_job.py +1 -1
  43. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/list_crons_response.py +1 -1
  44. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/list_dead_letters_response.py +1 -1
  45. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/list_jobs_response.py +1 -1
  46. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/list_workflows_response.py +1 -1
  47. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/on_failure.py +1 -1
  48. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/on_success.py +1 -1
  49. queueflow-0.4.0/queueflow/models/queue_control_response.py +94 -0
  50. queueflow-0.4.0/queueflow/models/queue_stats.py +103 -0
  51. queueflow-0.4.0/queueflow/models/queues_response.py +95 -0
  52. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/ready_status.py +1 -1
  53. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/replay_dead_letter_response.py +1 -1
  54. queueflow-0.4.0/queueflow/models/replay_dead_letters_request.py +121 -0
  55. queueflow-0.4.0/queueflow/models/replay_dead_letters_response.py +98 -0
  56. queueflow-0.4.0/queueflow/models/replayed_dead_letter.py +101 -0
  57. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/stats_snapshot.py +1 -1
  58. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/step_status.py +1 -1
  59. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/tasks_response.py +1 -1
  60. queueflow-0.4.0/queueflow/models/who_am_i_response.py +89 -0
  61. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/workflow.py +1 -1
  62. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/workflow_diagram_response.py +1 -1
  63. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/workflow_status.py +1 -1
  64. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/workflow_step.py +1 -1
  65. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/workflow_step_state.py +1 -1
  66. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/models/workflow_step_states_response.py +1 -1
  67. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/rest.py +1 -1
  68. {queueflow-0.2.2 → queueflow-0.4.0/queueflow.egg-info}/PKG-INFO +98 -9
  69. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow.egg-info/SOURCES.txt +18 -0
  70. {queueflow-0.2.2 → queueflow-0.4.0}/setup.py +3 -3
  71. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_backoff_strategy.py +1 -1
  72. queueflow-0.4.0/test/test_cancel_jobs_request.py +56 -0
  73. queueflow-0.4.0/test/test_cancel_jobs_response.py +52 -0
  74. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_complete_job_request.py +1 -1
  75. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_create_batch_jobs_request.py +1 -1
  76. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_create_batch_jobs_response.py +1 -1
  77. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_create_cron_request.py +1 -1
  78. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_create_cron_response.py +1 -1
  79. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_create_job_request.py +1 -1
  80. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_create_job_response.py +1 -1
  81. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_create_workflow_request.py +1 -1
  82. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_create_workflow_response.py +1 -1
  83. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_cron_api.py +1 -1
  84. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_cron_schedule.py +1 -1
  85. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_dead_letter.py +1 -1
  86. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_dlq_api.py +7 -1
  87. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_error_body.py +1 -1
  88. queueflow-0.4.0/test/test_facade.py +826 -0
  89. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_fail_job_request.py +1 -1
  90. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_health_api.py +1 -1
  91. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_health_status.py +1 -1
  92. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_heartbeat_request.py +1 -1
  93. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_heartbeat_response.py +1 -1
  94. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_job.py +1 -1
  95. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_job_config.py +1 -1
  96. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_job_config_request.py +1 -1
  97. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_job_status.py +1 -1
  98. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_jobs_api.py +7 -1
  99. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_lease_jobs_request.py +1 -1
  100. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_lease_jobs_response.py +1 -1
  101. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_leased_job.py +1 -1
  102. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_list_crons_response.py +1 -1
  103. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_list_dead_letters_response.py +1 -1
  104. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_list_jobs_response.py +1 -1
  105. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_list_workflows_response.py +1 -1
  106. queueflow-0.4.0/test/test_live.py +372 -0
  107. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_on_failure.py +1 -1
  108. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_on_success.py +1 -1
  109. queueflow-0.4.0/test/test_queue_control_response.py +58 -0
  110. queueflow-0.4.0/test/test_queue_stats.py +60 -0
  111. queueflow-0.4.0/test/test_queues_response.py +68 -0
  112. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_ready_status.py +1 -1
  113. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_replay_dead_letter_response.py +1 -1
  114. queueflow-0.4.0/test/test_replay_dead_letters_request.py +55 -0
  115. queueflow-0.4.0/test/test_replay_dead_letters_response.py +64 -0
  116. queueflow-0.4.0/test/test_replayed_dead_letter.py +54 -0
  117. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_stats_snapshot.py +1 -1
  118. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_step_status.py +1 -1
  119. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_system_api.py +25 -1
  120. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_tasks_response.py +1 -1
  121. queueflow-0.4.0/test/test_who_am_i_response.py +54 -0
  122. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_worker_api.py +1 -1
  123. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_workflow.py +1 -1
  124. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_workflow_diagram_response.py +1 -1
  125. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_workflow_status.py +1 -1
  126. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_workflow_step.py +1 -1
  127. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_workflow_step_state.py +1 -1
  128. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_workflow_step_states_response.py +1 -1
  129. {queueflow-0.2.2 → queueflow-0.4.0}/test/test_workflows_api.py +1 -1
  130. queueflow-0.2.2/queueflow/api/system_api.py +0 -523
  131. queueflow-0.2.2/test/test_facade.py +0 -426
  132. queueflow-0.2.2/test/test_live.py +0 -167
  133. {queueflow-0.2.2 → queueflow-0.4.0}/LICENSE +0 -0
  134. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/api/__init__.py +0 -0
  135. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/api_response.py +0 -0
  136. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow/py.typed +0 -0
  137. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow.egg-info/dependency_links.txt +0 -0
  138. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow.egg-info/requires.txt +0 -0
  139. {queueflow-0.2.2 → queueflow-0.4.0}/queueflow.egg-info/top_level.txt +0 -0
  140. {queueflow-0.2.2 → queueflow-0.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queueflow
3
- Version: 0.2.2
3
+ Version: 0.4.0
4
4
  Summary: Python client for QueueFlow, a PostgreSQL-native distributed job queue and workflow engine.
5
5
  Home-page: https://github.com/elision-labs/queueflow-sdk-python
6
6
  Author: QueueFlow
@@ -93,9 +93,12 @@ qf = QueueFlow(
93
93
  base_url, # required, e.g. "http://localhost:8000"
94
94
  token, # required: tenant bearer token (any non-empty token on the dev server)
95
95
  worker_token=None, # credential for qf.worker and run_worker (defaults to token; dev mode only)
96
+ timeout=30.0, # default per-request timeout in seconds; a (connect, read) pair or None also work
96
97
  )
97
98
  ```
98
99
 
100
+ Any generated api method accepts `_request_timeout=` to override the default for one call.
101
+
99
102
  The generated per-tag clients hang off the facade for anything the helpers do not cover:
100
103
  `qf.jobs`, `qf.workflows`, `qf.worker`, `qf.cron`, `qf.dlq`, `qf.system`, `qf.health_api`.
101
104
 
@@ -106,8 +109,9 @@ The generated per-tag clients hang off the facade for anything the helpers do no
106
109
  | `qf.create_job(task, payload=None, priority=None, max_retries=None, timeout=None, queue=None, retry_backoff=None, retry_delay_secs=None, retry_max_delay_secs=None, jitter_factor=None, idempotency_key=None, run_at=None)` | Enqueue a job and return the created `Job`. |
107
110
  | `qf.wait_for_job(job_id, timeout=60.0, interval=0.5)` | Poll until `completed` / `failed` / `cancelled`; raises `WaitTimeout`. |
108
111
  | `qf.jobs.get_job(id)` | Fetch a job. |
109
- | `qf.jobs.list_jobs(status=..., queue=..., limit=..., offset=..., order_by=..., include_total=..., cursor=..., created_after=..., created_before=...)` | List jobs. |
112
+ | `qf.jobs.list_jobs(status=..., queue=..., task_name=..., limit=..., offset=..., order_by=..., include_total=..., cursor=..., created_after=..., created_before=...)` | List jobs. |
110
113
  | `qf.jobs.cancel_job(id)` | Cancel a job. |
114
+ | `qf.cancel_jobs(queue=None, task_name=None, status=None, created_after=None, created_before=None, limit=None)` | Cancel every matching non-terminal job of your tenant, oldest first, up to `limit` (default 100); returns the count. |
111
115
  | `qf.jobs.create_batch_jobs(CreateBatchJobsRequest(...))` | Enqueue up to 1000 jobs at once. |
112
116
 
113
117
  `idempotency_key` is sent as the `Idempotency-Key` header: re-submitting the same key returns the
@@ -116,6 +120,16 @@ original job instead of creating a duplicate. `run_at` (a `datetime`) delays the
116
120
  List responses carry `next_cursor` when there are more pages; pass it back as `cursor` for keyset
117
121
  pagination (cheaper than deep `offset`).
118
122
 
123
+ `cancel_jobs` takes optional filters that combine: `queue`, `task_name`, `status` (`pending`,
124
+ `retrying` or `running`; all three when omitted, a terminal status is a 400) and the half-open
125
+ `datetime` window `[created_after, created_before)`. A count below `limit` means the matching set is
126
+ exhausted, so loop until then to drain a large backlog:
127
+
128
+ ```python
129
+ while qf.cancel_jobs(queue="orders", task_name="send_email", limit=500) == 500:
130
+ pass
131
+ ```
132
+
119
133
  ### Workflows
120
134
 
121
135
  | Method | Description |
@@ -168,16 +182,50 @@ def transcode(job, ctx):
168
182
  return {"ok": True}
169
183
 
170
184
  stop = threading.Event() # set it (e.g. from a signal handler) to drain
171
- qf.run_worker("orders", {"send_email": send_email, "transcode": transcode}, stop=stop)
185
+ result = qf.run_worker(
186
+ "orders",
187
+ {"send_email": send_email, "transcode": transcode},
188
+ stop=stop,
189
+ concurrency=4,
190
+ )
191
+ for job in result.unfinished: # handlers still running when the drain gave up
192
+ print("abandoned", job.id)
172
193
  ```
173
194
 
174
- `run_worker(queue, handlers, lease_secs=30, wait_secs=20, stop=None, on_error=None)` leases one
175
- job at a time, heartbeats each in-flight job at half the lease interval, stops reporting when the
195
+ ```python
196
+ run_worker(
197
+ queue, handlers,
198
+ lease_secs=30, # lease per job; each in-flight job is heartbeated at half this
199
+ wait_secs=20, # long-poll wait when the queue is empty
200
+ stop=None, # threading.Event: set it to stop
201
+ on_error=None, # called on transient lease errors (default: throttled log)
202
+ concurrency=1, # handlers running at once, on a ThreadPoolExecutor
203
+ poll_wait_secs=5, # cap on wait_secs while `stop` is provided (bounds stop latency)
204
+ drain_timeout=30.0, # seconds to wait for in-flight handlers after stop
205
+ ) -> WorkerRunResult # .unfinished: jobs still running when the drain gave up
206
+ ```
207
+
208
+ `run_worker` heartbeats each in-flight job at half the lease interval, stops reporting when the
176
209
  lease is lost, and applies the server's retry policy on errors. Handlers take `(job)` or
177
210
  `(job, ctx)` and return the result dict (or `None` for `{}`). Delivery is at-least-once, so make
178
211
  handlers idempotent. The low-level calls (`qf.worker.lease_jobs`, `heartbeat_job`, `complete_job`,
179
212
  `fail_job`) are also exposed.
180
213
 
214
+ Concurrency: up to `concurrency` handlers run at once. Each lease asks for as many jobs as there
215
+ are free slots, and no lease is issued while every slot is busy.
216
+
217
+ Stop and drain: set `stop`. The generated urllib3 client cannot cancel an in-flight long poll, so
218
+ while `stop` is provided each lease waits at most `poll_wait_secs` (never more than `wait_secs`),
219
+ which bounds stop latency to about that long; without `stop`, the full `wait_secs` is used. After
220
+ the loop exits, handlers already running get up to `drain_timeout` seconds to finish and report.
221
+ Any handler still running after that is abandoned: its `ctx.cancelled` flips, nothing is reported
222
+ (the lease expires and the server redelivers), and the job is listed in the returned
223
+ `WorkerRunResult.unfinished`. Python cannot kill a thread, so an abandoned handler keeps its pool
224
+ thread until it returns; long handlers should check `ctx.cancelled`.
225
+
226
+ Timeouts: every request uses the client-wide `timeout` (30s by default, see Client), except the
227
+ lease long poll, which gets its own timeout of the effective wait plus 35 seconds.
228
+
181
229
  Rules the runtime enforces, and that any hand-rolled loop over `qf.worker` must honour too:
182
230
 
183
231
  1. Worker routes authenticate with the **worker token**, not a tenant token. Pass it as
@@ -201,18 +249,58 @@ Rules the runtime enforces, and that any hand-rolled loop over `qf.worker` must
201
249
 
202
250
  | Method | Description |
203
251
  | --- | --- |
204
- | `qf.dlq.list_dead_letters(...)` / `get_dead_letter(id)` | Inspect terminally-failed jobs. |
252
+ | `qf.dlq.list_dead_letters(task_name=..., replayed=..., limit=..., offset=..., order_by=..., include_total=..., cursor=..., created_after=..., created_before=...)` / `get_dead_letter(id)` | Inspect terminally-failed jobs. `replayed=False` lists only entries not yet replayed, `True` only the replayed ones. |
205
253
  | `qf.replay_dead_letter(id)` | Re-run one as a fresh job and return it (at most once; a second replay is a 409). |
254
+ | `qf.replay_dead_letters(queue=None, task_name=None, created_after=None, created_before=None, limit=None)` | Replay every unreplayed entry matching the filter, oldest first, up to `limit` (default 100). Returns a `ReplayedDeadLetter` per entry: `job_id` on success, otherwise `error`. |
255
+
256
+ ```python
257
+ for r in qf.replay_dead_letters(queue="orders", task_name="send_email"):
258
+ if r.error:
259
+ print("dead letter", r.dead_letter_id, "not replayed:", r.error)
260
+ else:
261
+ print("dead letter", r.dead_letter_id, "replayed as", r.job_id)
262
+ ```
263
+
264
+ Already-replayed entries are never considered again, so repeating the call walks a large backlog;
265
+ an empty list means nothing was left to replay.
206
266
 
207
267
  ### System
208
268
 
209
269
  ```python
270
+ qf.whoami() # the tenant this token is scoped to and its access level
210
271
  qf.system.get_stats() # engine counters
211
272
  qf.system.list_tasks() # registered task handler names
273
+ qf.queues() # live per-queue backlog: pending, scheduled, running, oldest pending age, paused
274
+ qf.pause_queue(name) # stop claims of your jobs on a queue; running jobs finish
275
+ qf.resume_queue(name) # let claims flow again
212
276
  qf.health_api.get_health()
213
277
  qf.health_api.get_ready()
214
278
  ```
215
279
 
280
+ `qf.whoami()` (`GET /api/v1/whoami`) returns a `WhoAmIResponse` with `tenant` and `access`
281
+ (`read_write`, or `read_only` for a read-only key), handy for validating a credential at startup.
282
+
283
+ `qf.queues()` (`GET /api/v1/queues`) returns a `QueueStats` per queue so you can decide whether to
284
+ add workers. A 0.5.0 or later server lists every queue your tenant has enqueued to (drained ones
285
+ with zero counts); older servers list only queues with non-terminal jobs. Its `paused` flag says
286
+ whether claims are paused for your tenant. `qf.system.list_queues()` is the raw call.
287
+
288
+ `qf.pause_queue(name)` / `qf.resume_queue(name)` (`POST /api/v1/queues/{queue}/pause` and
289
+ `/resume`) gate workers' claims of your tenant's jobs on that queue. Pausing lets running jobs
290
+ finish while pending ones wait, so the queue is drained once the returned `running` count reaches
291
+ zero; resuming wakes parked workers immediately. Both are idempotent and return a
292
+ `QueueControlResponse` (`queue`, `paused`, `running`, `pending`).
293
+
294
+ ```python
295
+ import time
296
+
297
+ qf.pause_queue("orders")
298
+ while qf.pause_queue("orders").running > 0: # idempotent, so it doubles as a status poll
299
+ time.sleep(1)
300
+ deploy_new_handlers()
301
+ qf.resume_queue("orders")
302
+ ```
303
+
216
304
  ## Authentication
217
305
 
218
306
  Every request carries `Authorization: Bearer <token>`. Two credentials exist:
@@ -256,9 +344,10 @@ closes it (terminal status or the 15-minute cap). Use `wait_for_job` (polling) i
256
344
  `test/test_live.py` runs the facade against a real QueueFlow server (started with the default
257
345
  `queueflow serve`, i.e. `--mode all`, so the built-in `echo` handler is registered). It creates
258
346
  and waits on an `echo` job, checks idempotent re-creation, runs a two-step workflow, exercises the
259
- cron and dead-letter endpoints, reads stats, and runs `run_worker` against a dedicated queue. The
260
- tests are marked `live` and skip themselves unless `QUEUEFLOW_URL` is set, so plain `pytest` and
261
- CI stay offline.
347
+ cron and dead-letter endpoints, reads stats, runs `run_worker` against a dedicated queue, and
348
+ exercises `whoami`, bulk cancel, queue pause/resume and bulk dead-letter replay, each scoped to a
349
+ queue unique to the run. The tests are marked `live` and skip themselves unless `QUEUEFLOW_URL` is
350
+ set, so plain `pytest` and CI stay offline.
262
351
 
263
352
  ```bash
264
353
  QUEUEFLOW_URL=http://localhost:8000 \
@@ -52,9 +52,12 @@ qf = QueueFlow(
52
52
  base_url, # required, e.g. "http://localhost:8000"
53
53
  token, # required: tenant bearer token (any non-empty token on the dev server)
54
54
  worker_token=None, # credential for qf.worker and run_worker (defaults to token; dev mode only)
55
+ timeout=30.0, # default per-request timeout in seconds; a (connect, read) pair or None also work
55
56
  )
56
57
  ```
57
58
 
59
+ Any generated api method accepts `_request_timeout=` to override the default for one call.
60
+
58
61
  The generated per-tag clients hang off the facade for anything the helpers do not cover:
59
62
  `qf.jobs`, `qf.workflows`, `qf.worker`, `qf.cron`, `qf.dlq`, `qf.system`, `qf.health_api`.
60
63
 
@@ -65,8 +68,9 @@ The generated per-tag clients hang off the facade for anything the helpers do no
65
68
  | `qf.create_job(task, payload=None, priority=None, max_retries=None, timeout=None, queue=None, retry_backoff=None, retry_delay_secs=None, retry_max_delay_secs=None, jitter_factor=None, idempotency_key=None, run_at=None)` | Enqueue a job and return the created `Job`. |
66
69
  | `qf.wait_for_job(job_id, timeout=60.0, interval=0.5)` | Poll until `completed` / `failed` / `cancelled`; raises `WaitTimeout`. |
67
70
  | `qf.jobs.get_job(id)` | Fetch a job. |
68
- | `qf.jobs.list_jobs(status=..., queue=..., limit=..., offset=..., order_by=..., include_total=..., cursor=..., created_after=..., created_before=...)` | List jobs. |
71
+ | `qf.jobs.list_jobs(status=..., queue=..., task_name=..., limit=..., offset=..., order_by=..., include_total=..., cursor=..., created_after=..., created_before=...)` | List jobs. |
69
72
  | `qf.jobs.cancel_job(id)` | Cancel a job. |
73
+ | `qf.cancel_jobs(queue=None, task_name=None, status=None, created_after=None, created_before=None, limit=None)` | Cancel every matching non-terminal job of your tenant, oldest first, up to `limit` (default 100); returns the count. |
70
74
  | `qf.jobs.create_batch_jobs(CreateBatchJobsRequest(...))` | Enqueue up to 1000 jobs at once. |
71
75
 
72
76
  `idempotency_key` is sent as the `Idempotency-Key` header: re-submitting the same key returns the
@@ -75,6 +79,16 @@ original job instead of creating a duplicate. `run_at` (a `datetime`) delays the
75
79
  List responses carry `next_cursor` when there are more pages; pass it back as `cursor` for keyset
76
80
  pagination (cheaper than deep `offset`).
77
81
 
82
+ `cancel_jobs` takes optional filters that combine: `queue`, `task_name`, `status` (`pending`,
83
+ `retrying` or `running`; all three when omitted, a terminal status is a 400) and the half-open
84
+ `datetime` window `[created_after, created_before)`. A count below `limit` means the matching set is
85
+ exhausted, so loop until then to drain a large backlog:
86
+
87
+ ```python
88
+ while qf.cancel_jobs(queue="orders", task_name="send_email", limit=500) == 500:
89
+ pass
90
+ ```
91
+
78
92
  ### Workflows
79
93
 
80
94
  | Method | Description |
@@ -127,16 +141,50 @@ def transcode(job, ctx):
127
141
  return {"ok": True}
128
142
 
129
143
  stop = threading.Event() # set it (e.g. from a signal handler) to drain
130
- qf.run_worker("orders", {"send_email": send_email, "transcode": transcode}, stop=stop)
144
+ result = qf.run_worker(
145
+ "orders",
146
+ {"send_email": send_email, "transcode": transcode},
147
+ stop=stop,
148
+ concurrency=4,
149
+ )
150
+ for job in result.unfinished: # handlers still running when the drain gave up
151
+ print("abandoned", job.id)
131
152
  ```
132
153
 
133
- `run_worker(queue, handlers, lease_secs=30, wait_secs=20, stop=None, on_error=None)` leases one
134
- job at a time, heartbeats each in-flight job at half the lease interval, stops reporting when the
154
+ ```python
155
+ run_worker(
156
+ queue, handlers,
157
+ lease_secs=30, # lease per job; each in-flight job is heartbeated at half this
158
+ wait_secs=20, # long-poll wait when the queue is empty
159
+ stop=None, # threading.Event: set it to stop
160
+ on_error=None, # called on transient lease errors (default: throttled log)
161
+ concurrency=1, # handlers running at once, on a ThreadPoolExecutor
162
+ poll_wait_secs=5, # cap on wait_secs while `stop` is provided (bounds stop latency)
163
+ drain_timeout=30.0, # seconds to wait for in-flight handlers after stop
164
+ ) -> WorkerRunResult # .unfinished: jobs still running when the drain gave up
165
+ ```
166
+
167
+ `run_worker` heartbeats each in-flight job at half the lease interval, stops reporting when the
135
168
  lease is lost, and applies the server's retry policy on errors. Handlers take `(job)` or
136
169
  `(job, ctx)` and return the result dict (or `None` for `{}`). Delivery is at-least-once, so make
137
170
  handlers idempotent. The low-level calls (`qf.worker.lease_jobs`, `heartbeat_job`, `complete_job`,
138
171
  `fail_job`) are also exposed.
139
172
 
173
+ Concurrency: up to `concurrency` handlers run at once. Each lease asks for as many jobs as there
174
+ are free slots, and no lease is issued while every slot is busy.
175
+
176
+ Stop and drain: set `stop`. The generated urllib3 client cannot cancel an in-flight long poll, so
177
+ while `stop` is provided each lease waits at most `poll_wait_secs` (never more than `wait_secs`),
178
+ which bounds stop latency to about that long; without `stop`, the full `wait_secs` is used. After
179
+ the loop exits, handlers already running get up to `drain_timeout` seconds to finish and report.
180
+ Any handler still running after that is abandoned: its `ctx.cancelled` flips, nothing is reported
181
+ (the lease expires and the server redelivers), and the job is listed in the returned
182
+ `WorkerRunResult.unfinished`. Python cannot kill a thread, so an abandoned handler keeps its pool
183
+ thread until it returns; long handlers should check `ctx.cancelled`.
184
+
185
+ Timeouts: every request uses the client-wide `timeout` (30s by default, see Client), except the
186
+ lease long poll, which gets its own timeout of the effective wait plus 35 seconds.
187
+
140
188
  Rules the runtime enforces, and that any hand-rolled loop over `qf.worker` must honour too:
141
189
 
142
190
  1. Worker routes authenticate with the **worker token**, not a tenant token. Pass it as
@@ -160,18 +208,58 @@ Rules the runtime enforces, and that any hand-rolled loop over `qf.worker` must
160
208
 
161
209
  | Method | Description |
162
210
  | --- | --- |
163
- | `qf.dlq.list_dead_letters(...)` / `get_dead_letter(id)` | Inspect terminally-failed jobs. |
211
+ | `qf.dlq.list_dead_letters(task_name=..., replayed=..., limit=..., offset=..., order_by=..., include_total=..., cursor=..., created_after=..., created_before=...)` / `get_dead_letter(id)` | Inspect terminally-failed jobs. `replayed=False` lists only entries not yet replayed, `True` only the replayed ones. |
164
212
  | `qf.replay_dead_letter(id)` | Re-run one as a fresh job and return it (at most once; a second replay is a 409). |
213
+ | `qf.replay_dead_letters(queue=None, task_name=None, created_after=None, created_before=None, limit=None)` | Replay every unreplayed entry matching the filter, oldest first, up to `limit` (default 100). Returns a `ReplayedDeadLetter` per entry: `job_id` on success, otherwise `error`. |
214
+
215
+ ```python
216
+ for r in qf.replay_dead_letters(queue="orders", task_name="send_email"):
217
+ if r.error:
218
+ print("dead letter", r.dead_letter_id, "not replayed:", r.error)
219
+ else:
220
+ print("dead letter", r.dead_letter_id, "replayed as", r.job_id)
221
+ ```
222
+
223
+ Already-replayed entries are never considered again, so repeating the call walks a large backlog;
224
+ an empty list means nothing was left to replay.
165
225
 
166
226
  ### System
167
227
 
168
228
  ```python
229
+ qf.whoami() # the tenant this token is scoped to and its access level
169
230
  qf.system.get_stats() # engine counters
170
231
  qf.system.list_tasks() # registered task handler names
232
+ qf.queues() # live per-queue backlog: pending, scheduled, running, oldest pending age, paused
233
+ qf.pause_queue(name) # stop claims of your jobs on a queue; running jobs finish
234
+ qf.resume_queue(name) # let claims flow again
171
235
  qf.health_api.get_health()
172
236
  qf.health_api.get_ready()
173
237
  ```
174
238
 
239
+ `qf.whoami()` (`GET /api/v1/whoami`) returns a `WhoAmIResponse` with `tenant` and `access`
240
+ (`read_write`, or `read_only` for a read-only key), handy for validating a credential at startup.
241
+
242
+ `qf.queues()` (`GET /api/v1/queues`) returns a `QueueStats` per queue so you can decide whether to
243
+ add workers. A 0.5.0 or later server lists every queue your tenant has enqueued to (drained ones
244
+ with zero counts); older servers list only queues with non-terminal jobs. Its `paused` flag says
245
+ whether claims are paused for your tenant. `qf.system.list_queues()` is the raw call.
246
+
247
+ `qf.pause_queue(name)` / `qf.resume_queue(name)` (`POST /api/v1/queues/{queue}/pause` and
248
+ `/resume`) gate workers' claims of your tenant's jobs on that queue. Pausing lets running jobs
249
+ finish while pending ones wait, so the queue is drained once the returned `running` count reaches
250
+ zero; resuming wakes parked workers immediately. Both are idempotent and return a
251
+ `QueueControlResponse` (`queue`, `paused`, `running`, `pending`).
252
+
253
+ ```python
254
+ import time
255
+
256
+ qf.pause_queue("orders")
257
+ while qf.pause_queue("orders").running > 0: # idempotent, so it doubles as a status poll
258
+ time.sleep(1)
259
+ deploy_new_handlers()
260
+ qf.resume_queue("orders")
261
+ ```
262
+
175
263
  ## Authentication
176
264
 
177
265
  Every request carries `Authorization: Bearer <token>`. Two credentials exist:
@@ -215,9 +303,10 @@ closes it (terminal status or the 15-minute cap). Use `wait_for_job` (polling) i
215
303
  `test/test_live.py` runs the facade against a real QueueFlow server (started with the default
216
304
  `queueflow serve`, i.e. `--mode all`, so the built-in `echo` handler is registered). It creates
217
305
  and waits on an `echo` job, checks idempotent re-creation, runs a two-step workflow, exercises the
218
- cron and dead-letter endpoints, reads stats, and runs `run_worker` against a dedicated queue. The
219
- tests are marked `live` and skip themselves unless `QUEUEFLOW_URL` is set, so plain `pytest` and
220
- CI stay offline.
306
+ cron and dead-letter endpoints, reads stats, runs `run_worker` against a dedicated queue, and
307
+ exercises `whoami`, bulk cancel, queue pause/resume and bulk dead-letter replay, each scoped to a
308
+ queue unique to the run. The tests are marked `live` and skip themselves unless `QUEUEFLOW_URL` is
309
+ set, so plain `pytest` and CI stay offline.
221
310
 
222
311
  ```bash
223
312
  QUEUEFLOW_URL=http://localhost:8000 \
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "queueflow"
7
- version = "0.2.2"
7
+ version = "0.4.0"
8
8
  description = "Python client for QueueFlow, a PostgreSQL-native distributed job queue and workflow engine."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -7,14 +7,14 @@
7
7
 
8
8
  REST API for QueueFlow, a PostgreSQL-native distributed job queue and workflow engine.
9
9
 
10
- The version of the OpenAPI document: 1.0.0
10
+ The version of the OpenAPI document: 0.5.0
11
11
  Generated by OpenAPI Generator (https://openapi-generator.tech)
12
12
 
13
13
  Do not edit the class manually.
14
14
  """ # noqa: E501
15
15
 
16
16
 
17
- __version__ = "0.2.2"
17
+ __version__ = "0.4.0"
18
18
 
19
19
  # import apis into sdk package
20
20
  from queueflow.api.cron_api import CronApi
@@ -38,6 +38,8 @@ from queueflow.exceptions import ApiException
38
38
 
39
39
  # import models into sdk package
40
40
  from queueflow.models.backoff_strategy import BackoffStrategy
41
+ from queueflow.models.cancel_jobs_request import CancelJobsRequest
42
+ from queueflow.models.cancel_jobs_response import CancelJobsResponse
41
43
  from queueflow.models.complete_job_request import CompleteJobRequest
42
44
  from queueflow.models.create_batch_jobs_request import CreateBatchJobsRequest
43
45
  from queueflow.models.create_batch_jobs_response import CreateBatchJobsResponse
@@ -67,11 +69,18 @@ from queueflow.models.list_jobs_response import ListJobsResponse
67
69
  from queueflow.models.list_workflows_response import ListWorkflowsResponse
68
70
  from queueflow.models.on_failure import OnFailure
69
71
  from queueflow.models.on_success import OnSuccess
72
+ from queueflow.models.queue_control_response import QueueControlResponse
73
+ from queueflow.models.queue_stats import QueueStats
74
+ from queueflow.models.queues_response import QueuesResponse
70
75
  from queueflow.models.ready_status import ReadyStatus
71
76
  from queueflow.models.replay_dead_letter_response import ReplayDeadLetterResponse
77
+ from queueflow.models.replay_dead_letters_request import ReplayDeadLettersRequest
78
+ from queueflow.models.replay_dead_letters_response import ReplayDeadLettersResponse
79
+ from queueflow.models.replayed_dead_letter import ReplayedDeadLetter
72
80
  from queueflow.models.stats_snapshot import StatsSnapshot
73
81
  from queueflow.models.step_status import StepStatus
74
82
  from queueflow.models.tasks_response import TasksResponse
83
+ from queueflow.models.who_am_i_response import WhoAmIResponse
75
84
  from queueflow.models.workflow import Workflow
76
85
  from queueflow.models.workflow_diagram_response import WorkflowDiagramResponse
77
86
  from queueflow.models.workflow_status import WorkflowStatus
@@ -5,7 +5,7 @@
5
5
 
6
6
  REST API for QueueFlow, a PostgreSQL-native distributed job queue and workflow engine.
7
7
 
8
- The version of the OpenAPI document: 1.0.0
8
+ The version of the OpenAPI document: 0.5.0
9
9
  Generated by OpenAPI Generator (https://openapi-generator.tech)
10
10
 
11
11
  Do not edit the class manually.
@@ -862,6 +862,8 @@ class CronApi:
862
862
  self,
863
863
  status: Annotated[Optional[StrictStr], Field(description="Filter by status (e.g. `pending`, `completed`).")] = None,
864
864
  queue: Annotated[Optional[StrictStr], Field(description="Filter by queue name (jobs only).")] = None,
865
+ task_name: Annotated[Optional[StrictStr], Field(description="Filter by task name (jobs and dead letters).")] = None,
866
+ replayed: Annotated[Optional[StrictBool], Field(description="Dead letters only: `false` lists entries not yet replayed, `true` the replayed ones; both when omitted.")] = None,
865
867
  limit: Annotated[Optional[StrictInt], Field(description="Page size, 1..=100 (default 50).")] = None,
866
868
  offset: Annotated[Optional[StrictInt], Field(description="Number of records to skip (default 0).")] = None,
867
869
  order_by: Annotated[Optional[StrictStr], Field(description="`created_at ASC` or `created_at DESC` (default DESC).")] = None,
@@ -889,6 +891,10 @@ class CronApi:
889
891
  :type status: str
890
892
  :param queue: Filter by queue name (jobs only).
891
893
  :type queue: str
894
+ :param task_name: Filter by task name (jobs and dead letters).
895
+ :type task_name: str
896
+ :param replayed: Dead letters only: `false` lists entries not yet replayed, `true` the replayed ones; both when omitted.
897
+ :type replayed: bool
892
898
  :param limit: Page size, 1..=100 (default 50).
893
899
  :type limit: int
894
900
  :param offset: Number of records to skip (default 0).
@@ -928,6 +934,8 @@ class CronApi:
928
934
  _param = self._list_crons_serialize(
929
935
  status=status,
930
936
  queue=queue,
937
+ task_name=task_name,
938
+ replayed=replayed,
931
939
  limit=limit,
932
940
  offset=offset,
933
941
  order_by=order_by,
@@ -961,6 +969,8 @@ class CronApi:
961
969
  self,
962
970
  status: Annotated[Optional[StrictStr], Field(description="Filter by status (e.g. `pending`, `completed`).")] = None,
963
971
  queue: Annotated[Optional[StrictStr], Field(description="Filter by queue name (jobs only).")] = None,
972
+ task_name: Annotated[Optional[StrictStr], Field(description="Filter by task name (jobs and dead letters).")] = None,
973
+ replayed: Annotated[Optional[StrictBool], Field(description="Dead letters only: `false` lists entries not yet replayed, `true` the replayed ones; both when omitted.")] = None,
964
974
  limit: Annotated[Optional[StrictInt], Field(description="Page size, 1..=100 (default 50).")] = None,
965
975
  offset: Annotated[Optional[StrictInt], Field(description="Number of records to skip (default 0).")] = None,
966
976
  order_by: Annotated[Optional[StrictStr], Field(description="`created_at ASC` or `created_at DESC` (default DESC).")] = None,
@@ -988,6 +998,10 @@ class CronApi:
988
998
  :type status: str
989
999
  :param queue: Filter by queue name (jobs only).
990
1000
  :type queue: str
1001
+ :param task_name: Filter by task name (jobs and dead letters).
1002
+ :type task_name: str
1003
+ :param replayed: Dead letters only: `false` lists entries not yet replayed, `true` the replayed ones; both when omitted.
1004
+ :type replayed: bool
991
1005
  :param limit: Page size, 1..=100 (default 50).
992
1006
  :type limit: int
993
1007
  :param offset: Number of records to skip (default 0).
@@ -1027,6 +1041,8 @@ class CronApi:
1027
1041
  _param = self._list_crons_serialize(
1028
1042
  status=status,
1029
1043
  queue=queue,
1044
+ task_name=task_name,
1045
+ replayed=replayed,
1030
1046
  limit=limit,
1031
1047
  offset=offset,
1032
1048
  order_by=order_by,
@@ -1060,6 +1076,8 @@ class CronApi:
1060
1076
  self,
1061
1077
  status: Annotated[Optional[StrictStr], Field(description="Filter by status (e.g. `pending`, `completed`).")] = None,
1062
1078
  queue: Annotated[Optional[StrictStr], Field(description="Filter by queue name (jobs only).")] = None,
1079
+ task_name: Annotated[Optional[StrictStr], Field(description="Filter by task name (jobs and dead letters).")] = None,
1080
+ replayed: Annotated[Optional[StrictBool], Field(description="Dead letters only: `false` lists entries not yet replayed, `true` the replayed ones; both when omitted.")] = None,
1063
1081
  limit: Annotated[Optional[StrictInt], Field(description="Page size, 1..=100 (default 50).")] = None,
1064
1082
  offset: Annotated[Optional[StrictInt], Field(description="Number of records to skip (default 0).")] = None,
1065
1083
  order_by: Annotated[Optional[StrictStr], Field(description="`created_at ASC` or `created_at DESC` (default DESC).")] = None,
@@ -1087,6 +1105,10 @@ class CronApi:
1087
1105
  :type status: str
1088
1106
  :param queue: Filter by queue name (jobs only).
1089
1107
  :type queue: str
1108
+ :param task_name: Filter by task name (jobs and dead letters).
1109
+ :type task_name: str
1110
+ :param replayed: Dead letters only: `false` lists entries not yet replayed, `true` the replayed ones; both when omitted.
1111
+ :type replayed: bool
1090
1112
  :param limit: Page size, 1..=100 (default 50).
1091
1113
  :type limit: int
1092
1114
  :param offset: Number of records to skip (default 0).
@@ -1126,6 +1148,8 @@ class CronApi:
1126
1148
  _param = self._list_crons_serialize(
1127
1149
  status=status,
1128
1150
  queue=queue,
1151
+ task_name=task_name,
1152
+ replayed=replayed,
1129
1153
  limit=limit,
1130
1154
  offset=offset,
1131
1155
  order_by=order_by,
@@ -1154,6 +1178,8 @@ class CronApi:
1154
1178
  self,
1155
1179
  status,
1156
1180
  queue,
1181
+ task_name,
1182
+ replayed,
1157
1183
  limit,
1158
1184
  offset,
1159
1185
  order_by,
@@ -1191,6 +1217,14 @@ class CronApi:
1191
1217
 
1192
1218
  _query_params.append(('queue', queue))
1193
1219
 
1220
+ if task_name is not None:
1221
+
1222
+ _query_params.append(('task_name', task_name))
1223
+
1224
+ if replayed is not None:
1225
+
1226
+ _query_params.append(('replayed', replayed))
1227
+
1194
1228
  if limit is not None:
1195
1229
 
1196
1230
  _query_params.append(('limit', limit))