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.
- taskq/__init__.py +147 -0
- taskq/_di/__init__.py +45 -0
- taskq/_di/_utils.py +13 -0
- taskq/_di/_validate.py +456 -0
- taskq/_di/lifecycle.py +52 -0
- taskq/_di/registry.py +342 -0
- taskq/_di/scope.py +12 -0
- taskq/_di/scopes.py +467 -0
- taskq/_di/solver.py +159 -0
- taskq/_di/types.py +121 -0
- taskq/_dsn.py +18 -0
- taskq/_ids.py +128 -0
- taskq/_json.py +105 -0
- taskq/_scope.py +39 -0
- taskq/actor.py +752 -0
- taskq/backend/__init__.py +69 -0
- taskq/backend/_cursor.py +32 -0
- taskq/backend/_dispatch.py +114 -0
- taskq/backend/_dispatch_sql.py +301 -0
- taskq/backend/_enqueue.py +490 -0
- taskq/backend/_notify.py +49 -0
- taskq/backend/_protocol.py +854 -0
- taskq/backend/_reads.py +177 -0
- taskq/backend/_records.py +116 -0
- taskq/backend/_schedules.py +226 -0
- taskq/backend/_sql.py +103 -0
- taskq/backend/_sql_templates.py +543 -0
- taskq/backend/_sweeps.py +487 -0
- taskq/backend/_terminal.py +830 -0
- taskq/backend/clock.py +47 -0
- taskq/backend/postgres.py +656 -0
- taskq/backend/statemachine.py +50 -0
- taskq/batch.py +274 -0
- taskq/cli.py +670 -0
- taskq/client/__init__.py +25 -0
- taskq/client/_args.py +233 -0
- taskq/client/_enqueuer.py +347 -0
- taskq/client/_handle.py +321 -0
- taskq/client/_jobs.py +756 -0
- taskq/client/_taskq.py +647 -0
- taskq/client/_transport.py +112 -0
- taskq/constants.py +152 -0
- taskq/context.py +171 -0
- taskq/contrib/__init__.py +1 -0
- taskq/contrib/kubernetes/__init__.py +1 -0
- taskq/contrib/kubernetes/prometheus_rule.yaml +125 -0
- taskq/contrib/prometheus/__init__.py +10 -0
- taskq/contrib/prometheus/_metrics.py +63 -0
- taskq/contrib/prometheus/rules.yaml +113 -0
- taskq/cron.py +332 -0
- taskq/di.py +7 -0
- taskq/exceptions.py +398 -0
- taskq/migrate.py +248 -0
- taskq/migrations/01.00.00_01_pre_initial.sql +444 -0
- taskq/migrations/01.00.01_01_pre_per_property_cron.sql +21 -0
- taskq/migrations/__init__.py +13 -0
- taskq/obs/__init__.py +120 -0
- taskq/obs/_otel.py +583 -0
- taskq/obs/_structlog.py +241 -0
- taskq/obs/error_reporter.py +120 -0
- taskq/progress/__init__.py +5 -0
- taskq/progress/_buffer.py +96 -0
- taskq/progress/_events.py +34 -0
- taskq/progress/_flush.py +140 -0
- taskq/progress/_publish.py +200 -0
- taskq/py.typed +0 -0
- taskq/ratelimit/__init__.py +42 -0
- taskq/ratelimit/_decision_log.py +38 -0
- taskq/ratelimit/_provider.py +90 -0
- taskq/ratelimit/_redis_utils.py +79 -0
- taskq/ratelimit/_scripts.py +265 -0
- taskq/ratelimit/_sliding_window_pg.py +432 -0
- taskq/ratelimit/_sliding_window_redis.py +398 -0
- taskq/ratelimit/composition.py +93 -0
- taskq/ratelimit/decision.py +47 -0
- taskq/ratelimit/refs.py +87 -0
- taskq/ratelimit/registry.py +678 -0
- taskq/ratelimit/reservation.py +593 -0
- taskq/ratelimit/sliding_window.py +570 -0
- taskq/ratelimit/token_bucket.py +754 -0
- taskq/retry.py +582 -0
- taskq/scheduler.py +59 -0
- taskq/settings.py +797 -0
- taskq/testing/__init__.py +102 -0
- taskq/testing/_dispatch.py +158 -0
- taskq/testing/_enqueue.py +225 -0
- taskq/testing/_reads.py +101 -0
- taskq/testing/_runner.py +650 -0
- taskq/testing/_slots.py +108 -0
- taskq/testing/_sweeps.py +184 -0
- taskq/testing/_terminal.py +666 -0
- taskq/testing/actor.py +322 -0
- taskq/testing/assertions.py +311 -0
- taskq/testing/asyncpg_chaos.py +157 -0
- taskq/testing/clock.py +49 -0
- taskq/testing/fixtures.py +860 -0
- taskq/testing/in_memory.py +773 -0
- taskq/testing/job_context.py +63 -0
- taskq/testing/jobs.py +190 -0
- taskq/testing/otel.py +251 -0
- taskq/testing/pg.py +310 -0
- taskq/testing/settings.py +71 -0
- taskq/testing/spy.py +15 -0
- taskq/types.py +48 -0
- taskq/web/__init__.py +1 -0
- taskq/web/admin/__init__.py +28 -0
- taskq/web/admin/_constants.py +26 -0
- taskq/web/admin/_factory.py +403 -0
- taskq/web/admin/_history.py +250 -0
- taskq/web/admin/_jsonb.py +23 -0
- taskq/web/admin/_listen.py +107 -0
- taskq/web/admin/_static.py +25 -0
- taskq/web/admin/auth/__init__.py +42 -0
- taskq/web/admin/auth/_session.py +190 -0
- taskq/web/admin/auth/oidc.py +299 -0
- taskq/web/admin/auth/saml.py +213 -0
- taskq/web/admin/auth/token.py +35 -0
- taskq/web/admin/jobs.py +555 -0
- taskq/web/admin/ops.py +658 -0
- taskq/web/admin/queues.py +197 -0
- taskq/web/admin/sse.py +104 -0
- taskq/web/admin/workers.py +105 -0
- taskq/web/health.py +81 -0
- taskq/web/progress.py +445 -0
- taskq/web/static/admin.css +2 -0
- taskq/web/static/admin.js +218 -0
- taskq/web/static/alpine.min.js +5 -0
- taskq/web/static/htmx.min.js +1 -0
- taskq/web/static/realtime.js +246 -0
- taskq/web/static/sse.min.js +1 -0
- taskq/web/static/tailwind.css +3 -0
- taskq/web/templates/_base.html +75 -0
- taskq/web/templates/_partials/job_card.html +97 -0
- taskq/web/templates/_partials/job_table.html +148 -0
- taskq/web/templates/_partials/sse_console.html +4 -0
- taskq/web/templates/_partials/table.html +37 -0
- taskq/web/templates/history.html +98 -0
- taskq/web/templates/job_detail.html +367 -0
- taskq/web/templates/jobs.html +193 -0
- taskq/web/templates/leader.html +73 -0
- taskq/web/templates/queue_detail.html +59 -0
- taskq/web/templates/queues.html +85 -0
- taskq/web/templates/rate_limits.html +104 -0
- taskq/web/templates/reservations.html +93 -0
- taskq/web/templates/schedules.html +76 -0
- taskq/web/templates/workers.html +69 -0
- taskq/worker/__init__.py +69 -0
- taskq/worker/_bootstrap.py +509 -0
- taskq/worker/_consumer.py +896 -0
- taskq/worker/_handlers.py +709 -0
- taskq/worker/_leader_shared.py +390 -0
- taskq/worker/_leader_sweeps.py +441 -0
- taskq/worker/actor_config.py +19 -0
- taskq/worker/budget.py +93 -0
- taskq/worker/cancel.py +438 -0
- taskq/worker/cron_loop.py +300 -0
- taskq/worker/deps.py +296 -0
- taskq/worker/dev.py +160 -0
- taskq/worker/dispatch.py +290 -0
- taskq/worker/health.py +330 -0
- taskq/worker/heartbeat.py +270 -0
- taskq/worker/leader.py +384 -0
- taskq/worker/notify.py +331 -0
- taskq/worker/run.py +576 -0
- taskq/worker/shutdown.py +287 -0
- taskq/worker/startup.py +214 -0
- taskq/worker/workgroup.py +778 -0
- taskq_py-0.1.0.dist-info/METADATA +364 -0
- taskq_py-0.1.0.dist-info/RECORD +172 -0
- taskq_py-0.1.0.dist-info/WHEEL +4 -0
- taskq_py-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|
taskq/backend/_notify.py
ADDED
|
@@ -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)
|