taskq-py 0.1.0__py3-none-any.whl

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 (172) hide show
  1. taskq/__init__.py +147 -0
  2. taskq/_di/__init__.py +45 -0
  3. taskq/_di/_utils.py +13 -0
  4. taskq/_di/_validate.py +456 -0
  5. taskq/_di/lifecycle.py +52 -0
  6. taskq/_di/registry.py +342 -0
  7. taskq/_di/scope.py +12 -0
  8. taskq/_di/scopes.py +467 -0
  9. taskq/_di/solver.py +159 -0
  10. taskq/_di/types.py +121 -0
  11. taskq/_dsn.py +18 -0
  12. taskq/_ids.py +128 -0
  13. taskq/_json.py +105 -0
  14. taskq/_scope.py +39 -0
  15. taskq/actor.py +752 -0
  16. taskq/backend/__init__.py +69 -0
  17. taskq/backend/_cursor.py +32 -0
  18. taskq/backend/_dispatch.py +114 -0
  19. taskq/backend/_dispatch_sql.py +301 -0
  20. taskq/backend/_enqueue.py +490 -0
  21. taskq/backend/_notify.py +49 -0
  22. taskq/backend/_protocol.py +854 -0
  23. taskq/backend/_reads.py +177 -0
  24. taskq/backend/_records.py +116 -0
  25. taskq/backend/_schedules.py +226 -0
  26. taskq/backend/_sql.py +103 -0
  27. taskq/backend/_sql_templates.py +543 -0
  28. taskq/backend/_sweeps.py +487 -0
  29. taskq/backend/_terminal.py +830 -0
  30. taskq/backend/clock.py +47 -0
  31. taskq/backend/postgres.py +656 -0
  32. taskq/backend/statemachine.py +50 -0
  33. taskq/batch.py +274 -0
  34. taskq/cli.py +670 -0
  35. taskq/client/__init__.py +25 -0
  36. taskq/client/_args.py +233 -0
  37. taskq/client/_enqueuer.py +347 -0
  38. taskq/client/_handle.py +321 -0
  39. taskq/client/_jobs.py +756 -0
  40. taskq/client/_taskq.py +647 -0
  41. taskq/client/_transport.py +112 -0
  42. taskq/constants.py +152 -0
  43. taskq/context.py +171 -0
  44. taskq/contrib/__init__.py +1 -0
  45. taskq/contrib/kubernetes/__init__.py +1 -0
  46. taskq/contrib/kubernetes/prometheus_rule.yaml +125 -0
  47. taskq/contrib/prometheus/__init__.py +10 -0
  48. taskq/contrib/prometheus/_metrics.py +63 -0
  49. taskq/contrib/prometheus/rules.yaml +113 -0
  50. taskq/cron.py +332 -0
  51. taskq/di.py +7 -0
  52. taskq/exceptions.py +398 -0
  53. taskq/migrate.py +248 -0
  54. taskq/migrations/01.00.00_01_pre_initial.sql +444 -0
  55. taskq/migrations/01.00.01_01_pre_per_property_cron.sql +21 -0
  56. taskq/migrations/__init__.py +13 -0
  57. taskq/obs/__init__.py +120 -0
  58. taskq/obs/_otel.py +583 -0
  59. taskq/obs/_structlog.py +241 -0
  60. taskq/obs/error_reporter.py +120 -0
  61. taskq/progress/__init__.py +5 -0
  62. taskq/progress/_buffer.py +96 -0
  63. taskq/progress/_events.py +34 -0
  64. taskq/progress/_flush.py +140 -0
  65. taskq/progress/_publish.py +200 -0
  66. taskq/py.typed +0 -0
  67. taskq/ratelimit/__init__.py +42 -0
  68. taskq/ratelimit/_decision_log.py +38 -0
  69. taskq/ratelimit/_provider.py +90 -0
  70. taskq/ratelimit/_redis_utils.py +79 -0
  71. taskq/ratelimit/_scripts.py +265 -0
  72. taskq/ratelimit/_sliding_window_pg.py +432 -0
  73. taskq/ratelimit/_sliding_window_redis.py +398 -0
  74. taskq/ratelimit/composition.py +93 -0
  75. taskq/ratelimit/decision.py +47 -0
  76. taskq/ratelimit/refs.py +87 -0
  77. taskq/ratelimit/registry.py +678 -0
  78. taskq/ratelimit/reservation.py +593 -0
  79. taskq/ratelimit/sliding_window.py +570 -0
  80. taskq/ratelimit/token_bucket.py +754 -0
  81. taskq/retry.py +582 -0
  82. taskq/scheduler.py +59 -0
  83. taskq/settings.py +797 -0
  84. taskq/testing/__init__.py +102 -0
  85. taskq/testing/_dispatch.py +158 -0
  86. taskq/testing/_enqueue.py +225 -0
  87. taskq/testing/_reads.py +101 -0
  88. taskq/testing/_runner.py +650 -0
  89. taskq/testing/_slots.py +108 -0
  90. taskq/testing/_sweeps.py +184 -0
  91. taskq/testing/_terminal.py +666 -0
  92. taskq/testing/actor.py +322 -0
  93. taskq/testing/assertions.py +311 -0
  94. taskq/testing/asyncpg_chaos.py +157 -0
  95. taskq/testing/clock.py +49 -0
  96. taskq/testing/fixtures.py +860 -0
  97. taskq/testing/in_memory.py +773 -0
  98. taskq/testing/job_context.py +63 -0
  99. taskq/testing/jobs.py +190 -0
  100. taskq/testing/otel.py +251 -0
  101. taskq/testing/pg.py +310 -0
  102. taskq/testing/settings.py +71 -0
  103. taskq/testing/spy.py +15 -0
  104. taskq/types.py +48 -0
  105. taskq/web/__init__.py +1 -0
  106. taskq/web/admin/__init__.py +28 -0
  107. taskq/web/admin/_constants.py +26 -0
  108. taskq/web/admin/_factory.py +403 -0
  109. taskq/web/admin/_history.py +250 -0
  110. taskq/web/admin/_jsonb.py +23 -0
  111. taskq/web/admin/_listen.py +107 -0
  112. taskq/web/admin/_static.py +25 -0
  113. taskq/web/admin/auth/__init__.py +42 -0
  114. taskq/web/admin/auth/_session.py +190 -0
  115. taskq/web/admin/auth/oidc.py +299 -0
  116. taskq/web/admin/auth/saml.py +213 -0
  117. taskq/web/admin/auth/token.py +35 -0
  118. taskq/web/admin/jobs.py +555 -0
  119. taskq/web/admin/ops.py +658 -0
  120. taskq/web/admin/queues.py +197 -0
  121. taskq/web/admin/sse.py +104 -0
  122. taskq/web/admin/workers.py +105 -0
  123. taskq/web/health.py +81 -0
  124. taskq/web/progress.py +445 -0
  125. taskq/web/static/admin.css +2 -0
  126. taskq/web/static/admin.js +218 -0
  127. taskq/web/static/alpine.min.js +5 -0
  128. taskq/web/static/htmx.min.js +1 -0
  129. taskq/web/static/realtime.js +246 -0
  130. taskq/web/static/sse.min.js +1 -0
  131. taskq/web/static/tailwind.css +3 -0
  132. taskq/web/templates/_base.html +75 -0
  133. taskq/web/templates/_partials/job_card.html +97 -0
  134. taskq/web/templates/_partials/job_table.html +148 -0
  135. taskq/web/templates/_partials/sse_console.html +4 -0
  136. taskq/web/templates/_partials/table.html +37 -0
  137. taskq/web/templates/history.html +98 -0
  138. taskq/web/templates/job_detail.html +367 -0
  139. taskq/web/templates/jobs.html +193 -0
  140. taskq/web/templates/leader.html +73 -0
  141. taskq/web/templates/queue_detail.html +59 -0
  142. taskq/web/templates/queues.html +85 -0
  143. taskq/web/templates/rate_limits.html +104 -0
  144. taskq/web/templates/reservations.html +93 -0
  145. taskq/web/templates/schedules.html +76 -0
  146. taskq/web/templates/workers.html +69 -0
  147. taskq/worker/__init__.py +69 -0
  148. taskq/worker/_bootstrap.py +509 -0
  149. taskq/worker/_consumer.py +896 -0
  150. taskq/worker/_handlers.py +709 -0
  151. taskq/worker/_leader_shared.py +390 -0
  152. taskq/worker/_leader_sweeps.py +441 -0
  153. taskq/worker/actor_config.py +19 -0
  154. taskq/worker/budget.py +93 -0
  155. taskq/worker/cancel.py +438 -0
  156. taskq/worker/cron_loop.py +300 -0
  157. taskq/worker/deps.py +296 -0
  158. taskq/worker/dev.py +160 -0
  159. taskq/worker/dispatch.py +290 -0
  160. taskq/worker/health.py +330 -0
  161. taskq/worker/heartbeat.py +270 -0
  162. taskq/worker/leader.py +384 -0
  163. taskq/worker/notify.py +331 -0
  164. taskq/worker/run.py +576 -0
  165. taskq/worker/shutdown.py +287 -0
  166. taskq/worker/startup.py +214 -0
  167. taskq/worker/workgroup.py +778 -0
  168. taskq_py-0.1.0.dist-info/METADATA +364 -0
  169. taskq_py-0.1.0.dist-info/RECORD +172 -0
  170. taskq_py-0.1.0.dist-info/WHEEL +4 -0
  171. taskq_py-0.1.0.dist-info/entry_points.txt +2 -0
  172. taskq_py-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,490 @@
1
+ """Enqueue operations for PostgresBackend.
2
+
3
+ ``enqueue``, ``enqueue_with_conn``, ``enqueue_batch``, and
4
+ ``enqueue_batch_fast`` live here as module-level functions taking
5
+ ``(pool, sql: SqlTemplates, schema, clock, ...)`` parameters.
6
+ :class:`~taskq.backend.postgres.PostgresBackend` methods are thin
7
+ wrappers that delegate.
8
+ """
9
+
10
+ from datetime import datetime
11
+ from typing import TYPE_CHECKING
12
+ from uuid import UUID
13
+
14
+ import structlog
15
+ from asyncpg.exceptions import UniqueViolationError
16
+
17
+ from taskq._json import dumps_str
18
+ from taskq.backend._protocol import (
19
+ ConnLike,
20
+ EnqueueArgs,
21
+ JobRow,
22
+ )
23
+ from taskq.backend._records import (
24
+ _job_row_from_record,
25
+ jsonb_param,
26
+ )
27
+ from taskq.backend._sql_templates import SqlTemplates
28
+ from taskq.backend.clock import Clock
29
+ from taskq.constants import wake_channel
30
+ from taskq.exceptions import (
31
+ MaxPendingExceededError,
32
+ SingletonCollisionError,
33
+ )
34
+ from taskq.obs import (
35
+ get_logger,
36
+ record_backpressure_error,
37
+ )
38
+
39
+ if TYPE_CHECKING:
40
+ import asyncpg
41
+
42
+ __all__ = [
43
+ "_enqueue",
44
+ "_enqueue_batch",
45
+ "_enqueue_batch_fast",
46
+ "_enqueue_on_conn",
47
+ "_enqueue_with_conn",
48
+ ]
49
+
50
+ logger: structlog.stdlib.BoundLogger = get_logger(__name__)
51
+
52
+ _SINGLETON_CONSTRAINT_NAME = "jobs_singleton_uniq"
53
+
54
+
55
+ async def _enqueue_on_conn(
56
+ conn: ConnLike,
57
+ sql: SqlTemplates,
58
+ schema: str,
59
+ clock: Clock,
60
+ args: EnqueueArgs,
61
+ ) -> JobRow:
62
+ """Core enqueue logic running on *conn*.
63
+
64
+ Includes unique_for preflight, singleton preflight, max_pending
65
+ count, INSERT, idempotency-key SELECT on conflict, and pg_notify.
66
+ Does NOT acquire from ``worker_pool`` and does NOT open a
67
+ transaction — the caller is responsible for both.
68
+ """
69
+ if args.unique_for is not None and args.identity_key is not None:
70
+ existing_rec = await conn.fetchrow(
71
+ sql.enqueue_unique_for_preflight,
72
+ args.actor,
73
+ args.identity_key,
74
+ list(args.unique_states),
75
+ args.unique_for,
76
+ )
77
+ if existing_rec is not None:
78
+ row = _job_row_from_record(existing_rec)
79
+ logger.info(
80
+ "enqueue_deduplicated",
81
+ kind="enqueue_deduplicated",
82
+ job_id=str(row.id),
83
+ actor=row.actor,
84
+ queue=row.queue,
85
+ identity_key=row.identity_key,
86
+ idempotency_key=None,
87
+ existing_job_id=str(row.id),
88
+ dedup_reason="unique_for",
89
+ )
90
+ return row
91
+
92
+ if args.metadata.get("singleton") is True:
93
+ preflight_rec = await conn.fetchrow(sql.singleton_preflight, args.actor)
94
+ if preflight_rec is not None:
95
+ blocking_id: UUID = preflight_rec["id"]
96
+ schedule_to_close: datetime | None = preflight_rec["schedule_to_close"]
97
+ retry_after = None
98
+ if schedule_to_close is not None:
99
+ now_utc = clock.now()
100
+ remaining = schedule_to_close - now_utc
101
+ if remaining.total_seconds() > 0:
102
+ retry_after = remaining
103
+ logger.info(
104
+ "singleton-collision",
105
+ actor=args.actor,
106
+ blocking_job_id=blocking_id,
107
+ detection_path="preflight_check",
108
+ )
109
+ raise SingletonCollisionError(
110
+ actor=args.actor,
111
+ blocking_job_id=blocking_id,
112
+ retry_after=retry_after,
113
+ )
114
+
115
+ if args.max_pending is not None:
116
+ count_rec = await conn.fetchval(
117
+ sql.enqueue_max_pending_count,
118
+ args.actor,
119
+ )
120
+ current_count: int = int(count_rec)
121
+ if current_count >= args.max_pending:
122
+ logger.warning(
123
+ "max-pending-exceeded",
124
+ actor=args.actor,
125
+ current_count=current_count,
126
+ max_pending=args.max_pending,
127
+ )
128
+ record_backpressure_error(args.actor, kind="max_pending")
129
+ raise MaxPendingExceededError(
130
+ actor=args.actor,
131
+ current_count=current_count,
132
+ max_pending=args.max_pending,
133
+ )
134
+
135
+ is_new = False
136
+ use_interval = args.schedule_to_close_interval is not None
137
+ sql_stmt = sql.enqueue_with_interval if use_interval else sql.enqueue
138
+ param_12: object = args.schedule_to_close_interval if use_interval else args.schedule_to_close
139
+ enqueue_now = clock.now()
140
+ scheduled_at_param: datetime | None = (
141
+ args.scheduled_at if args.scheduled_at > enqueue_now else None
142
+ )
143
+ result_expires_at: datetime | None = None
144
+ if args.result_ttl is not None:
145
+ result_expires_at = enqueue_now + args.result_ttl
146
+
147
+ try:
148
+ rec = await conn.fetchrow(
149
+ sql_stmt,
150
+ args.id,
151
+ args.actor,
152
+ args.queue,
153
+ args.identity_key,
154
+ args.fairness_key,
155
+ jsonb_param(args.payload),
156
+ args.payload_schema_ver,
157
+ args.priority,
158
+ args.max_attempts,
159
+ args.retry_kind,
160
+ param_12,
161
+ args.start_to_close,
162
+ args.heartbeat_timeout,
163
+ scheduled_at_param,
164
+ args.idempotency_key,
165
+ args.trace_id,
166
+ args.span_id,
167
+ jsonb_param(args.metadata),
168
+ result_expires_at,
169
+ list(args.tags),
170
+ )
171
+ except UniqueViolationError as exc:
172
+ if exc.constraint_name == _SINGLETON_CONSTRAINT_NAME:
173
+ logger.info(
174
+ "singleton-collision",
175
+ actor=args.actor,
176
+ blocking_job_id=None,
177
+ detection_path="unique_violation_catch",
178
+ )
179
+ raise SingletonCollisionError(
180
+ actor=args.actor,
181
+ blocking_job_id=None,
182
+ retry_after=None,
183
+ ) from exc
184
+ raise
185
+ if rec is not None:
186
+ is_new = True
187
+ else:
188
+ rec = await conn.fetchrow(
189
+ sql.enqueue_select_by_key,
190
+ args.idempotency_key,
191
+ )
192
+ if rec is None:
193
+ raise RuntimeError(
194
+ "enqueue ON CONFLICT fired but follow-up SELECT "
195
+ f"found no row for idempotency_key={args.idempotency_key!r}"
196
+ )
197
+
198
+ row = _job_row_from_record(rec)
199
+
200
+ if is_new:
201
+ await conn.execute(
202
+ sql.enqueue_notify,
203
+ wake_channel(schema),
204
+ )
205
+ logger.info(
206
+ "enqueue",
207
+ kind="enqueue",
208
+ job_id=str(row.id),
209
+ actor=row.actor,
210
+ queue=row.queue,
211
+ idempotency_key=row.idempotency_key,
212
+ )
213
+ else:
214
+ logger.info(
215
+ "enqueue_deduplicated",
216
+ kind="enqueue_deduplicated",
217
+ job_id=str(row.id),
218
+ actor=row.actor,
219
+ queue=row.queue,
220
+ identity_key=row.identity_key,
221
+ idempotency_key=row.idempotency_key,
222
+ existing_job_id=str(row.id),
223
+ dedup_reason="idempotency_key",
224
+ )
225
+
226
+ return row
227
+
228
+
229
+ async def _enqueue_with_conn(
230
+ conn: ConnLike,
231
+ sql: SqlTemplates,
232
+ schema: str,
233
+ clock: Clock,
234
+ args: EnqueueArgs,
235
+ ) -> JobRow:
236
+ return await _enqueue_on_conn(conn, sql, schema, clock, args)
237
+
238
+
239
+ async def _enqueue(
240
+ pool: "asyncpg.Pool",
241
+ sql: SqlTemplates,
242
+ schema: str,
243
+ clock: Clock,
244
+ args: EnqueueArgs,
245
+ ) -> JobRow:
246
+ async with pool.acquire() as conn:
247
+ async with conn.transaction():
248
+ return await _enqueue_on_conn(conn, sql, schema, clock, args)
249
+
250
+
251
+ async def _enqueue_batch(
252
+ pool: "asyncpg.Pool",
253
+ sql: SqlTemplates,
254
+ schema: str,
255
+ clock: Clock,
256
+ args_list: list[EnqueueArgs],
257
+ *,
258
+ connection: "asyncpg.Connection | None" = None,
259
+ ) -> list[JobRow]:
260
+ if not args_list:
261
+ raise ValueError("args_list must not be empty")
262
+
263
+ ids: list[UUID] = []
264
+ actors: list[str] = []
265
+ queues: list[str] = []
266
+ identity_keys: list[str | None] = []
267
+ fairness_keys: list[str | None] = []
268
+ payloads: list[str] = []
269
+ payload_schema_vers: list[int] = []
270
+ priorities: list[int] = []
271
+ max_attempts_list: list[int] = []
272
+ retry_kinds: list[str] = []
273
+ schedule_to_closes: list[object] = []
274
+ start_to_closes: list[object] = []
275
+ heartbeat_timeouts: list[object] = []
276
+ scheduled_ats: list[datetime | None] = []
277
+ metadatas: list[str] = []
278
+ idempotency_keys: list[str | None] = []
279
+ trace_ids: list[str | None] = []
280
+ span_ids: list[str | None] = []
281
+ result_expires_ats: list[datetime | None] = []
282
+ tag_jsons: list[str] = []
283
+
284
+ batch_now = clock.now()
285
+
286
+ for args in args_list:
287
+ ids.append(UUID(bytes=args.id.bytes))
288
+ actors.append(args.actor)
289
+ queues.append(args.queue)
290
+ identity_keys.append(str(args.identity_key) if args.identity_key is not None else None)
291
+ fairness_keys.append(args.fairness_key)
292
+ payloads.append(jsonb_param(args.payload) or "{}")
293
+ payload_schema_vers.append(args.payload_schema_ver)
294
+ priorities.append(args.priority)
295
+ max_attempts_list.append(args.max_attempts)
296
+ retry_kinds.append(args.retry_kind)
297
+ if args.schedule_to_close_interval is not None:
298
+ schedule_to_closes.append(args.scheduled_at + args.schedule_to_close_interval)
299
+ else:
300
+ schedule_to_closes.append(args.schedule_to_close)
301
+ start_to_closes.append(args.start_to_close)
302
+ heartbeat_timeouts.append(args.heartbeat_timeout)
303
+ scheduled_ats.append(args.scheduled_at if args.scheduled_at > batch_now else None)
304
+ metadatas.append(jsonb_param(args.metadata) or "{}")
305
+ idempotency_keys.append(
306
+ str(args.idempotency_key) if args.idempotency_key is not None else None
307
+ )
308
+ trace_ids.append(args.trace_id)
309
+ span_ids.append(args.span_id)
310
+ result_expires_at: datetime | None = None
311
+ if args.result_ttl is not None:
312
+ result_expires_at = batch_now + args.result_ttl
313
+ result_expires_ats.append(result_expires_at)
314
+ tag_jsons.append(dumps_str(list(args.tags)))
315
+
316
+ async def _enqueue_batch_on_conn(conn: ConnLike) -> list[JobRow]:
317
+ returning_recs = await conn.fetch(
318
+ sql.enqueue_batch,
319
+ ids,
320
+ actors,
321
+ queues,
322
+ identity_keys,
323
+ fairness_keys,
324
+ payloads,
325
+ payload_schema_vers,
326
+ priorities,
327
+ max_attempts_list,
328
+ retry_kinds,
329
+ schedule_to_closes,
330
+ start_to_closes,
331
+ heartbeat_timeouts,
332
+ scheduled_ats,
333
+ metadatas,
334
+ idempotency_keys,
335
+ trace_ids,
336
+ span_ids,
337
+ result_expires_ats,
338
+ tag_jsons,
339
+ )
340
+
341
+ inserted_ids: set[UUID] = {rec["id"] for rec in returning_recs}
342
+ if inserted_ids:
343
+ await conn.execute(
344
+ sql.enqueue_notify,
345
+ wake_channel(schema),
346
+ )
347
+
348
+ new_rows_by_id: dict[UUID, object] = {rec["id"]: rec for rec in returning_recs}
349
+
350
+ collision_keys: list[str] = []
351
+ for args in args_list:
352
+ if args.idempotency_key is not None and UUID(bytes=args.id.bytes) not in inserted_ids:
353
+ collision_keys.append(str(args.idempotency_key))
354
+
355
+ new_item_ids = list(inserted_ids)
356
+ full_new_recs: dict[UUID, object] = {}
357
+ if new_item_ids:
358
+ recs = await conn.fetch(
359
+ sql.enqueue_batch_fetch_by_ids,
360
+ new_item_ids,
361
+ )
362
+ for rec in recs:
363
+ full_new_recs[UUID(bytes=rec["id"].bytes)] = rec
364
+
365
+ existing_by_idem: dict[str, object] = {}
366
+ if collision_keys:
367
+ recs = await conn.fetch(
368
+ sql.enqueue_batch_fetch_existing,
369
+ collision_keys,
370
+ )
371
+ for rec in recs:
372
+ idem_key = rec["idempotency_key"]
373
+ existing_by_idem[idem_key] = rec
374
+
375
+ result: list[JobRow] = []
376
+ for args in args_list:
377
+ arg_uuid = UUID(bytes=args.id.bytes)
378
+ if arg_uuid in full_new_recs:
379
+ result.append(_job_row_from_record(full_new_recs[arg_uuid])) # type: ignore[arg-type] # Why: asyncpg Record is duck-typed; _job_row_from_record accepts asyncpg.Record at runtime
380
+ elif args.idempotency_key is not None and str(args.idempotency_key) in existing_by_idem:
381
+ result.append(_job_row_from_record(existing_by_idem[str(args.idempotency_key)])) # type: ignore[arg-type] # Why: asyncpg Record is duck-typed; _job_row_from_record accepts asyncpg.Record at runtime
382
+ else:
383
+ partial = new_rows_by_id.get(arg_uuid)
384
+ if partial is not None:
385
+ result.append(_job_row_from_record(partial)) # type: ignore[arg-type] # Why: asyncpg Record is duck-typed; _job_row_from_record accepts asyncpg.Record at runtime
386
+ else:
387
+ raise RuntimeError(
388
+ f"enqueue_batch: no row found for args.id={args.id!r} "
389
+ f"after INSERT; this is a bug"
390
+ )
391
+ return result
392
+
393
+ if connection is not None:
394
+ return await _enqueue_batch_on_conn(connection)
395
+ async with pool.acquire() as conn:
396
+ async with conn.transaction():
397
+ return await _enqueue_batch_on_conn(conn)
398
+
399
+
400
+ async def _enqueue_batch_fast(
401
+ pool: "asyncpg.Pool",
402
+ sql: SqlTemplates,
403
+ schema: str,
404
+ clock: Clock,
405
+ args_list: list[EnqueueArgs],
406
+ *,
407
+ connection: "asyncpg.Connection | None" = None,
408
+ ) -> int:
409
+ if not args_list:
410
+ raise ValueError("args_list must not be empty")
411
+
412
+ batch_now = clock.now()
413
+
414
+ records: list[tuple[object, ...]] = []
415
+ for args in args_list:
416
+ is_scheduled = args.scheduled_at > batch_now
417
+ status = "scheduled" if is_scheduled else "pending"
418
+ scheduled_at = args.scheduled_at if args.scheduled_at > batch_now else batch_now
419
+
420
+ resolved_stc: datetime | None
421
+ if args.schedule_to_close_interval is not None:
422
+ resolved_stc = args.scheduled_at + args.schedule_to_close_interval
423
+ else:
424
+ resolved_stc = args.schedule_to_close
425
+
426
+ result_expires_at: datetime | None = None
427
+ if args.result_ttl is not None:
428
+ result_expires_at = batch_now + args.result_ttl
429
+
430
+ records.append(
431
+ (
432
+ UUID(bytes=args.id.bytes),
433
+ args.actor,
434
+ args.queue,
435
+ str(args.identity_key) if args.identity_key is not None else None,
436
+ args.fairness_key,
437
+ jsonb_param(args.payload) or "{}",
438
+ args.payload_schema_ver,
439
+ status,
440
+ args.priority,
441
+ 0,
442
+ args.max_attempts,
443
+ args.retry_kind,
444
+ resolved_stc,
445
+ args.start_to_close,
446
+ args.heartbeat_timeout,
447
+ batch_now,
448
+ scheduled_at,
449
+ None,
450
+ None,
451
+ None,
452
+ None,
453
+ None,
454
+ None,
455
+ 0,
456
+ None,
457
+ None,
458
+ None,
459
+ "{}",
460
+ 0,
461
+ None,
462
+ None,
463
+ result_expires_at,
464
+ str(args.idempotency_key) if args.idempotency_key is not None else None,
465
+ args.trace_id,
466
+ args.span_id,
467
+ jsonb_param(args.metadata) or "{}",
468
+ list(args.tags),
469
+ )
470
+ )
471
+
472
+ async def _copy_on_conn(conn: ConnLike) -> int:
473
+ result = await conn.copy_records_to_table(
474
+ "jobs",
475
+ records=records,
476
+ columns=sql.copy_from_columns,
477
+ schema_name=schema,
478
+ )
479
+ count = int(result.split()[-1])
480
+ await conn.execute(
481
+ sql.enqueue_notify,
482
+ wake_channel(schema),
483
+ )
484
+ return count
485
+
486
+ if connection is not None:
487
+ return await _copy_on_conn(connection)
488
+ async with pool.acquire() as conn:
489
+ async with conn.transaction():
490
+ return await _copy_on_conn(conn)
@@ -0,0 +1,49 @@
1
+ """Shared async context manager for wake/cancel subscriber registration.
2
+
3
+ Both :class:`~taskq.backend.postgres.PostgresBackend` and
4
+ :class:`~taskq.testing.in_memory.InMemoryBackend` expose
5
+ ``subscribe_wake`` / ``subscribe_cancel_wake`` as async context managers
6
+ that register an :class:`asyncio.Event` on a subscriber set for the
7
+ duration of the ``async with`` block. The Postgres backend guards the
8
+ add/remove with an :class:`asyncio.Lock` for cross-coroutine safety; the
9
+ in-memory backend is single-threaded by contract and passes no lock.
10
+ """
11
+
12
+ import asyncio
13
+
14
+ __all__ = ["_SubscriberContext"]
15
+
16
+
17
+ class _SubscriberContext:
18
+ """Async context manager that adds/removes an event on a subscriber set.
19
+
20
+ When *lock* is provided, the add (on enter) and discard (on exit) run
21
+ under the lock — matching the Postgres backend's cross-coroutine
22
+ safety. When *lock* is ``None`` (in-memory backend), the operations
23
+ are unsynchronised per the single-threaded contract.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ event: asyncio.Event,
29
+ subscribers: set[asyncio.Event],
30
+ lock: asyncio.Lock | None = None,
31
+ ) -> None:
32
+ self._event = event
33
+ self._subscribers = subscribers
34
+ self._lock = lock
35
+
36
+ async def __aenter__(self) -> asyncio.Event:
37
+ if self._lock is not None:
38
+ async with self._lock:
39
+ self._subscribers.add(self._event)
40
+ else:
41
+ self._subscribers.add(self._event)
42
+ return self._event
43
+
44
+ async def __aexit__(self, *exc: object) -> None:
45
+ if self._lock is not None:
46
+ async with self._lock:
47
+ self._subscribers.discard(self._event)
48
+ else:
49
+ self._subscribers.discard(self._event)