lorebase-store 0.2.3__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 (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
lorebase_store/jobs.py ADDED
@@ -0,0 +1,651 @@
1
+ """Job queue helpers over the `jobs` table (design 7.4, store schema notes).
2
+
3
+ Workers claim `queued` rows with `FOR UPDATE SKIP LOCKED` (served by `ix_jobs_claimable`), run
4
+ them outside any transaction and record the outcome. A job that raises is requeued with a delay
5
+ until `jobs.max_attempts` is reached, then marked `failed`.
6
+
7
+ Delivery is at least once: a worker refreshes the lock time (`locked_at`) of its running jobs
8
+ with `touch_jobs` (a heartbeat), and a `running` job whose last heartbeat is older than
9
+ `jobs.stale_after_seconds` is taken for lost (its worker was killed or cut off) and reclaimed
10
+ by `requeue_stale`: queued again, or failed when it has used its `max_attempts`. Handlers must
11
+ therefore tolerate running a job again. A worker that stops puts its jobs back with
12
+ `release_jobs`; outcomes are recorded only by the worker that still holds the lock, so a worker
13
+ that lost a job to a reclaim cannot overwrite what the next one records.
14
+
15
+ Claiming spans Domains, so it runs with the row-level security bypass.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import asyncio
22
+ import json
23
+ import logging
24
+ import os
25
+ import socket
26
+ import sys
27
+ from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
28
+ from dataclasses import dataclass
29
+ from datetime import UTC, datetime, timedelta
30
+ from typing import Any
31
+
32
+ from sqlalchemy import ARRAY, Text, bindparam, text
33
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
34
+
35
+ from lorebase_store.auth.scope import rls_scope
36
+ from lorebase_store.settings import JobSettings
37
+ from lorebase_store.telemetry import JOBS_CLAIMED, JOBS_RECLAIMED, JOBS_RELEASED
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+ GC_JOB = "gc"
42
+ """Job type of a garbage collection (see `lorebase_store.gc.run_gc_job`)."""
43
+ SOURCE_SYNC_JOB = "source_sync"
44
+ """Job type of a Source synchronization run by Scribe; payload `{"source": id, "dry_run"}`."""
45
+
46
+ EXTRACTION_JOB = "extraction"
47
+ """Job type of a document extraction run by Scribe; payload from `extraction_payload`."""
48
+
49
+ INDEX_JOB = "store.index"
50
+ """Job type of a per-Attribute index build or drop (see `lorebase_store.indexes`)."""
51
+ MATERIALIZE_JOB = "store.materialize"
52
+ """Job type of an overlay turned into a full copy in batches; payload `{"branch": name}`
53
+ (see `lorebase_store.materialize.background`)."""
54
+ BRANCH_DELETE_JOB = "store.branch_delete"
55
+ """Job type of the batched deletion of a hidden branch's rows; payload `{"branch": name}`
56
+ (see `lorebase_store.materialize.background`)."""
57
+ VAULT_ROTATE_JOB = "vault.rotate"
58
+ """Job type of a vault key rotation pass over every Domain; payload `{"after": [domain, token]}`
59
+ or empty (see `lorebase_store.vault.rotation`)."""
60
+ SENSITIVITY_REFRESH_JOB = "store.sensitivity_refresh"
61
+ """Job type of the batched rewrite of the titles and search vectors a branch materialized
62
+ before the default branch tagged an Attribute; payload `{"branch": name}`, plus `"resume":
63
+ [kind id, key]` for a continuation (see `lorebase_store.materialize.stale`)."""
64
+ LINEAGE_BACKFILL_JOB = "store.lineage_backfill"
65
+ """Job type of the backfill of `mat_records.lineage_revisions` for rows materialized before
66
+ migration 0032; payload `{}` (no Domain: queue one per branch holding such rows) or
67
+ `{"branch": name}` (see `lorebase_store.materialize.lineage_backfill`)."""
68
+ EMBEDDINGS_JOB = "embeddings"
69
+ """Job type of an embedding pass over a Domain's branches run by Scribe; payload from
70
+ `embeddings_payload`."""
71
+
72
+ _COLUMNS = "id, type, domain_id, payload, priority, attempts, run_after, locked_by, created_at"
73
+
74
+ _CLAIM = text(
75
+ f"""
76
+ UPDATE jobs SET status = 'running', locked_by = :worker, locked_at = :now,
77
+ attempts = attempts + 1
78
+ WHERE id IN (
79
+ SELECT id FROM jobs
80
+ WHERE status = 'queued' AND run_after <= :now
81
+ AND (CAST(:types AS text[]) IS NULL OR type = ANY(CAST(:types AS text[])))
82
+ ORDER BY priority DESC, run_after, id
83
+ LIMIT :limit
84
+ FOR UPDATE SKIP LOCKED
85
+ )
86
+ RETURNING {_COLUMNS}
87
+ """
88
+ ).bindparams(bindparam("types", type_=ARRAY(Text())))
89
+
90
+ _ENQUEUE = text(
91
+ """
92
+ INSERT INTO jobs (type, domain_id, payload, priority, run_after)
93
+ VALUES (:type, :domain, CAST(:payload AS jsonb), :priority, :run_after)
94
+ RETURNING id
95
+ """
96
+ )
97
+
98
+ _CANCEL_QUEUED = text(
99
+ """
100
+ UPDATE jobs SET status = 'cancelled', result = CAST(:result AS jsonb)
101
+ WHERE status = 'queued' AND type = :type AND domain_id = :domain
102
+ AND payload @> CAST(:match AS jsonb)
103
+ """
104
+ )
105
+
106
+ _PENDING = text(
107
+ """
108
+ SELECT id FROM jobs
109
+ WHERE type = :type AND domain_id IS NOT DISTINCT FROM CAST(:domain AS text)
110
+ AND status::text = ANY(CAST(:statuses AS text[])) AND payload = CAST(:payload AS jsonb)
111
+ ORDER BY id LIMIT 1
112
+ """
113
+ ).bindparams(bindparam("statuses", type_=ARRAY(Text())))
114
+
115
+ # Outcomes are written only while the claiming worker still holds the job.
116
+ _HELD = "id = :id AND status = 'running' AND locked_by IS NOT DISTINCT FROM CAST(:worker AS text)"
117
+
118
+ _COMPLETE = text(
119
+ "UPDATE jobs SET status = 'succeeded', result = CAST(:result AS jsonb), "
120
+ f"locked_by = NULL, locked_at = NULL WHERE {_HELD}"
121
+ )
122
+
123
+ _RETRY = text(
124
+ "UPDATE jobs SET status = 'queued', run_after = :run_after, "
125
+ f"result = CAST(:result AS jsonb), locked_by = NULL, locked_at = NULL WHERE {_HELD}"
126
+ )
127
+
128
+ _FAIL = text(
129
+ "UPDATE jobs SET status = 'failed', result = CAST(:result AS jsonb), "
130
+ f"locked_by = NULL, locked_at = NULL WHERE {_HELD}"
131
+ )
132
+
133
+ _TOUCH = text(
134
+ "UPDATE jobs SET locked_at = :now "
135
+ "WHERE id = ANY(CAST(:ids AS bigint[])) AND locked_by = :worker AND status = 'running'"
136
+ )
137
+
138
+ _RELEASE = text(
139
+ "UPDATE jobs SET status = 'queued', run_after = :now, attempts = greatest(attempts - 1, 0), "
140
+ "locked_by = NULL, locked_at = NULL "
141
+ "WHERE id = ANY(CAST(:ids AS bigint[])) AND locked_by = :worker AND status = 'running' "
142
+ "RETURNING type"
143
+ )
144
+
145
+ _STALE = text(
146
+ """
147
+ UPDATE jobs SET
148
+ status = CASE WHEN attempts >= :max_attempts THEN 'failed' ELSE 'queued' END::job_status,
149
+ run_after = CASE WHEN attempts >= :max_attempts THEN run_after ELSE :now END,
150
+ result = CASE WHEN attempts >= :max_attempts
151
+ THEN jsonb_build_object('error', CAST(:error AS text), 'attempt', attempts)
152
+ ELSE result END,
153
+ locked_by = NULL, locked_at = NULL
154
+ WHERE status = 'running' AND (locked_at IS NULL OR locked_at < :before)
155
+ RETURNING id, type, CAST(status AS text) AS status, attempts
156
+ """
157
+ )
158
+
159
+ _DEPTH = text(
160
+ """
161
+ SELECT type,
162
+ CASE WHEN status = 'queued' AND run_after > :now THEN 'retrying'
163
+ ELSE CAST(status AS text) END,
164
+ count(*)
165
+ FROM jobs WHERE status IN ('queued', 'running')
166
+ GROUP BY 1, 2
167
+ """
168
+ )
169
+
170
+
171
+ @dataclass(frozen=True, slots=True)
172
+ class JobRow:
173
+ """A claimed job."""
174
+
175
+ id: int
176
+ type: str
177
+ domain_id: str | None
178
+ payload: dict[str, Any]
179
+ priority: int
180
+ attempts: int
181
+ run_after: datetime
182
+ locked_by: str | None
183
+ created_at: datetime
184
+
185
+
186
+ JobHandler = Callable[[JobRow], Awaitable[Mapping[str, Any]]]
187
+ """Runs a job and returns its JSON result; an exception fails the attempt."""
188
+
189
+
190
+ def source_sync_payload(source_id: str, *, dry_run: bool = False) -> dict[str, Any]:
191
+ """Payload of a `source_sync` job. Everything that queues one (Keeper's trigger, Scribe's
192
+ scheduler) builds it here, so identical requests are recognized by `enqueue_job(unique)`."""
193
+ return {"source": source_id, "dry_run": dry_run}
194
+
195
+
196
+ def extraction_payload(
197
+ document_ids: Sequence[str], *, tiers: Sequence[int] | None = None, requested_by: str | None
198
+ ) -> dict[str, Any]:
199
+ """Payload of an `extraction` job: the documents, the tiers (`None`: Scribe's
200
+ `scribe.extraction.tiers`) and who asked, recorded on the extraction job."""
201
+ return {
202
+ "documents": list(document_ids),
203
+ "tiers": None if tiers is None else sorted(set(tiers)),
204
+ "requested_by": requested_by,
205
+ }
206
+
207
+
208
+ def embeddings_payload(
209
+ *,
210
+ branches: Sequence[str] | None = None,
211
+ reindex: bool = False,
212
+ resume: tuple[str, str, str] | None = None,
213
+ ) -> dict[str, Any]:
214
+ """Payload of an `embeddings` job: the branches (`None`: every materialized branch),
215
+ whether every text is rendered again (`reindex`) and, for a continuation, the branch,
216
+ Kind id and key to resume after. The scheduler queues the default, `{}`, so identical
217
+ scheduled passes are recognized by `enqueue_job(unique)`."""
218
+ payload: dict[str, Any] = {}
219
+ if branches is not None:
220
+ payload["branches"] = sorted(set(branches))
221
+ if reindex:
222
+ payload["reindex"] = True
223
+ if resume is not None:
224
+ payload["resume"] = list(resume)
225
+ return payload
226
+
227
+
228
+ def default_worker_id() -> str:
229
+ return f"{socket.gethostname()}:{os.getpid()}"
230
+
231
+
232
+ def _now() -> datetime:
233
+ return datetime.now(UTC)
234
+
235
+
236
+ async def enqueue_job(
237
+ session: AsyncSession,
238
+ job_type: str,
239
+ *,
240
+ domain_id: str | None = None,
241
+ payload: Mapping[str, Any] | None = None,
242
+ priority: int = 0,
243
+ run_after: datetime | None = None,
244
+ unique: bool = False,
245
+ unique_queued: bool = False,
246
+ ) -> int:
247
+ """Queue a job and return its id. With `unique`, an identical queued or running job of the
248
+ same type, Domain (or no Domain) and payload is returned instead of adding a duplicate;
249
+ with `unique_queued`, only an identical job that has not started yet (a running one may
250
+ already be past what the new one is queued for)."""
251
+ body = json.dumps(dict(payload or {}), sort_keys=True)
252
+ async with rls_scope(session, bypass=True):
253
+ if unique or unique_queued:
254
+ statuses = ["queued"] if unique_queued else ["queued", "running"]
255
+ found = (
256
+ await session.execute(
257
+ _PENDING,
258
+ {"type": job_type, "domain": domain_id, "payload": body, "statuses": statuses},
259
+ )
260
+ ).scalar_one_or_none()
261
+ if found is not None:
262
+ return int(found)
263
+ result = await session.execute(
264
+ _ENQUEUE,
265
+ {
266
+ "type": job_type,
267
+ "domain": domain_id,
268
+ "payload": body,
269
+ "priority": priority,
270
+ "run_after": run_after or _now(),
271
+ },
272
+ )
273
+ return int(result.scalar_one())
274
+
275
+
276
+ async def cancel_queued_jobs(
277
+ session: AsyncSession,
278
+ job_type: str,
279
+ *,
280
+ domain_id: str,
281
+ match: Mapping[str, Any],
282
+ reason: str,
283
+ ) -> int:
284
+ """Cancel the `queued` jobs of `job_type` in `domain_id` whose payload contains `match`;
285
+ returns how many. Runs in the caller's transaction, in the Domain's row-level security
286
+ scope. A job a worker has claimed is `running` and untouched: its handler decides."""
287
+ result = await session.execute(
288
+ _CANCEL_QUEUED,
289
+ {
290
+ "type": job_type,
291
+ "domain": domain_id,
292
+ "match": json.dumps(dict(match), sort_keys=True),
293
+ "result": json.dumps({"detail": reason}),
294
+ },
295
+ )
296
+ return int(getattr(result, "rowcount", 0) or 0)
297
+
298
+
299
+ async def claim_jobs(
300
+ session: AsyncSession,
301
+ worker_id: str,
302
+ *,
303
+ types: Sequence[str] | None = None,
304
+ limit: int = 1,
305
+ now: datetime | None = None,
306
+ ) -> list[JobRow]:
307
+ """Claim up to `limit` due jobs for `worker_id`; concurrent workers never get the same job."""
308
+ async with rls_scope(session, bypass=True):
309
+ result = await session.execute(
310
+ _CLAIM,
311
+ {
312
+ "worker": worker_id,
313
+ "now": now or _now(),
314
+ "types": None if types is None else list(types),
315
+ "limit": limit,
316
+ },
317
+ )
318
+ claimed = [JobRow(**row) for row in result.mappings()]
319
+ for job in claimed:
320
+ JOBS_CLAIMED.add(1, {"type": job.type})
321
+ return claimed
322
+
323
+
324
+ async def queue_depth(
325
+ session: AsyncSession, *, now: datetime | None = None
326
+ ) -> dict[tuple[str, str], int]:
327
+ """Jobs counted by (type, state) across Domains: `queued` (claimable now), `retrying`
328
+ (queued with a `run_after` still ahead, such as a retry delay) or `running`."""
329
+ async with rls_scope(session, bypass=True):
330
+ rows = (await session.execute(_DEPTH, {"now": now or _now()})).all()
331
+ return {(job_type, status): int(count) for job_type, status, count in rows}
332
+
333
+
334
+ async def finish_job(
335
+ session: AsyncSession,
336
+ job: JobRow,
337
+ *,
338
+ result: Mapping[str, Any] | None = None,
339
+ error: str | None = None,
340
+ settings: JobSettings | None = None,
341
+ now: datetime | None = None,
342
+ ) -> str:
343
+ """Record the outcome of a claimed job; returns the new status.
344
+
345
+ With `error`, the job is requeued after `retry_delay_seconds` unless it has used its
346
+ `max_attempts`, in which case it is marked failed. Nothing is written, and `"lost"` is
347
+ returned, when the job no longer runs under the worker that claimed it (`job.locked_by`):
348
+ its lock went stale and `requeue_stale` reclaimed it.
349
+ """
350
+ settings = settings or JobSettings()
351
+ held: dict[str, Any] = {"id": job.id, "worker": job.locked_by}
352
+ async with rls_scope(session, bypass=True):
353
+ if error is None:
354
+ statement, params, status = (
355
+ _COMPLETE,
356
+ {**held, "result": json.dumps(dict(result or {}))},
357
+ "succeeded",
358
+ )
359
+ else:
360
+ body = json.dumps({"error": error, "attempt": job.attempts})
361
+ if job.attempts < settings.max_attempts:
362
+ run_after = (now or _now()) + timedelta(seconds=settings.retry_delay_seconds)
363
+ statement, params, status = (
364
+ _RETRY,
365
+ {**held, "run_after": run_after, "result": body},
366
+ "queued",
367
+ )
368
+ else:
369
+ statement, params, status = _FAIL, {**held, "result": body}, "failed"
370
+ written = await session.execute(statement, params)
371
+ if not getattr(written, "rowcount", 0):
372
+ log.warning(
373
+ "job outcome not recorded: the job was reclaimed from this worker",
374
+ extra={"job": job.id, "type": job.type, "worker": job.locked_by, "status": status},
375
+ )
376
+ return "lost"
377
+ return status
378
+
379
+
380
+ async def touch_jobs(
381
+ session: AsyncSession, worker_id: str, job_ids: Sequence[int], *, now: datetime | None = None
382
+ ) -> int:
383
+ """Heartbeat: refresh the lock time of running jobs held by `worker_id`, so that long jobs
384
+ are not taken for lost by `requeue_stale`. Returns the number of jobs touched."""
385
+ if not job_ids:
386
+ return 0
387
+ async with rls_scope(session, bypass=True):
388
+ result = await session.execute(
389
+ _TOUCH, {"ids": list(job_ids), "worker": worker_id, "now": now or _now()}
390
+ )
391
+ return int(getattr(result, "rowcount", 0) or 0)
392
+
393
+
394
+ async def release_jobs(
395
+ session: AsyncSession,
396
+ worker_id: str,
397
+ job_ids: Sequence[int],
398
+ *,
399
+ now: datetime | None = None,
400
+ ) -> int:
401
+ """Put jobs `worker_id` still holds back in the queue, due at once and without counting the
402
+ attempt (worker shutdown). Returns the number of jobs released."""
403
+ if not job_ids:
404
+ return 0
405
+ async with rls_scope(session, bypass=True):
406
+ result = await session.execute(
407
+ _RELEASE, {"ids": list(job_ids), "worker": worker_id, "now": now or _now()}
408
+ )
409
+ types = [job_type for (job_type,) in result.all()]
410
+ for job_type in types:
411
+ JOBS_RELEASED.add(1, {"type": job_type})
412
+ return len(types)
413
+
414
+
415
+ async def release_job(session: AsyncSession, job: JobRow, *, now: datetime | None = None) -> None:
416
+ """Put a claimed job back in the queue without counting the attempt (worker shutdown)."""
417
+ if job.locked_by is not None:
418
+ await release_jobs(session, job.locked_by, [job.id], now=now)
419
+
420
+
421
+ async def requeue_stale(
422
+ session: AsyncSession, *, settings: JobSettings | None = None, now: datetime | None = None
423
+ ) -> int:
424
+ """Reclaim running jobs whose last heartbeat (`locked_at`) is older than
425
+ `stale_after_seconds`: their worker is gone. A job with attempts left is queued again, due
426
+ at once; one that used its `max_attempts` is failed (a job that keeps killing its worker
427
+ is not run forever). Returns the number of jobs reclaimed, counted by type and outcome in
428
+ `lorebase.jobs.reclaimed`."""
429
+ settings = settings or JobSettings()
430
+ now = now or _now()
431
+ before = now - timedelta(seconds=settings.stale_after_seconds)
432
+ error = f"lost: no heartbeat for {settings.stale_after_seconds:g}s (worker gone)"
433
+ async with rls_scope(session, bypass=True):
434
+ result = await session.execute(
435
+ _STALE,
436
+ {
437
+ "before": before,
438
+ "now": now,
439
+ "max_attempts": settings.max_attempts,
440
+ "error": error,
441
+ },
442
+ )
443
+ reclaimed = list(result.mappings())
444
+ for row in reclaimed:
445
+ outcome = "requeued" if row["status"] == "queued" else "failed"
446
+ JOBS_RECLAIMED.add(1, {"type": row["type"], "outcome": outcome})
447
+ log.warning(
448
+ "stale job reclaimed",
449
+ extra={
450
+ "job": row["id"],
451
+ "type": row["type"],
452
+ "attempt": row["attempts"],
453
+ "status": row["status"],
454
+ },
455
+ )
456
+ return len(reclaimed)
457
+
458
+
459
+ async def run_jobs(
460
+ sessions: async_sessionmaker[AsyncSession],
461
+ handlers: Mapping[str, JobHandler],
462
+ *,
463
+ worker_id: str | None = None,
464
+ settings: JobSettings | None = None,
465
+ limit: int | None = None,
466
+ ) -> int:
467
+ """Claim and run due jobs of the handled types one by one until none is due (or `limit`
468
+ jobs ran). Returns the number of jobs run."""
469
+ worker = worker_id or default_worker_id()
470
+ settings = settings or JobSettings()
471
+ types = list(handlers)
472
+ ran = 0
473
+ while limit is None or ran < limit:
474
+ async with sessions() as session, session.begin():
475
+ claimed = await claim_jobs(session, worker, types=types, limit=1)
476
+ if not claimed:
477
+ return ran
478
+ job = claimed[0]
479
+ ran += 1
480
+ beat = asyncio.create_task(heartbeat(sessions, worker, job.id, settings))
481
+ try:
482
+ result = await handlers[job.type](job)
483
+ error = None
484
+ except asyncio.CancelledError:
485
+ async with sessions() as session, session.begin():
486
+ await release_jobs(session, worker, [job.id])
487
+ raise
488
+ except Exception as exc:
489
+ log.exception("job %s (%s) failed", job.id, job.type)
490
+ result, error = None, f"{type(exc).__name__}: {exc}"
491
+ finally:
492
+ beat.cancel()
493
+ await asyncio.gather(beat, return_exceptions=True)
494
+ async with sessions() as session, session.begin():
495
+ await finish_job(session, job, result=result, error=error, settings=settings)
496
+ return ran
497
+
498
+
499
+ async def heartbeat(
500
+ sessions: async_sessionmaker[AsyncSession], worker: str, job_id: int, settings: JobSettings
501
+ ) -> None:
502
+ """Refresh the lock of a running job three times per `stale_after_seconds`."""
503
+ while True:
504
+ await asyncio.sleep(settings.stale_after_seconds / 3)
505
+ try:
506
+ async with sessions() as session, session.begin():
507
+ await touch_jobs(session, worker, [job_id])
508
+ except Exception:
509
+ log.exception("job heartbeat failed")
510
+
511
+
512
+ def _parser() -> argparse.ArgumentParser:
513
+ parser = argparse.ArgumentParser(
514
+ prog="python -m lorebase_store.jobs", description="Run queued store jobs."
515
+ )
516
+ commands = parser.add_subparsers(dest="command", required=True)
517
+ work = commands.add_parser("work", help="Run due jobs until the queue is empty.")
518
+ work.add_argument("--limit", type=int, default=None, help="Stop after this many jobs.")
519
+ work.add_argument(
520
+ "--types",
521
+ nargs="+",
522
+ default=[
523
+ GC_JOB,
524
+ INDEX_JOB,
525
+ MATERIALIZE_JOB,
526
+ BRANCH_DELETE_JOB,
527
+ SENSITIVITY_REFRESH_JOB,
528
+ LINEAGE_BACKFILL_JOB,
529
+ ],
530
+ help=f"Job types to run (default: {GC_JOB} {INDEX_JOB} {MATERIALIZE_JOB} "
531
+ f"{BRANCH_DELETE_JOB} {SENSITIVITY_REFRESH_JOB} {LINEAGE_BACKFILL_JOB}).",
532
+ )
533
+ lineage = commands.add_parser(
534
+ "lineage-backfill",
535
+ help=f"Queue a {LINEAGE_BACKFILL_JOB} job: every branch, or one with --domain and "
536
+ "--branch.",
537
+ )
538
+ lineage.add_argument("--domain", default=None, help="Domain id (with --branch).")
539
+ lineage.add_argument("--branch", default=None, help="Branch name (with --domain).")
540
+ lineage.add_argument(
541
+ "--recompute",
542
+ action="store_true",
543
+ help="Also fold again the rows whose lineage revisions name a merge Revision.",
544
+ )
545
+ return parser
546
+
547
+
548
+ async def _queue_lineage_backfill(domain: str | None, branch: str | None, recompute: bool) -> int:
549
+ from lorebase_platform.settings import load_settings
550
+ from lorebase_store.db import create_engine
551
+ from lorebase_store.materialize.lineage_backfill import backfill_payload
552
+ from lorebase_store.settings import database_settings
553
+
554
+ engine = create_engine(database_settings(load_settings()), pooled=False)
555
+ try:
556
+ async with async_sessionmaker(engine)() as session, session.begin():
557
+ return await enqueue_job(
558
+ session,
559
+ LINEAGE_BACKFILL_JOB,
560
+ domain_id=domain,
561
+ payload=backfill_payload(branch, recompute=recompute),
562
+ unique_queued=True,
563
+ )
564
+ finally:
565
+ await engine.dispose()
566
+
567
+
568
+ async def _work(limit: int | None, types: Iterable[str]) -> int:
569
+ from lorebase_platform.settings import load_settings
570
+ from lorebase_store.db import create_engine
571
+ from lorebase_store.gc import run_gc_job
572
+ from lorebase_store.indexes import run_index_job
573
+ from lorebase_store.materialize.background import run_branch_delete_job, run_materialize_job
574
+ from lorebase_store.materialize.lineage_backfill import (
575
+ queue_lineage_sweep,
576
+ run_lineage_backfill_job,
577
+ )
578
+ from lorebase_store.materialize.paths import run_path_fanout_sweep
579
+ from lorebase_store.materialize.stale import run_sensitivity_refresh_job
580
+ from lorebase_store.runtime import StoreRuntime
581
+ from lorebase_store.settings import database_settings
582
+ from lorebase_store.storage import constraints_only
583
+
584
+ settings = load_settings()
585
+ database = database_settings(settings)
586
+ runtime = StoreRuntime.from_loaded(settings)
587
+ engine: AsyncEngine = create_engine(database, pooled=False, statement_timeout_ms=0)
588
+ # Index DDL needs a role that owns mat_records: the maintenance role when configured.
589
+ maintenance: AsyncEngine | None = None
590
+ if database.maintenance_dsn is not None:
591
+ maintenance = create_engine(
592
+ database,
593
+ url=database.maintenance_dsn.get_secret_value(),
594
+ pooled=False,
595
+ statement_timeout_ms=0,
596
+ )
597
+ ddl = maintenance or engine
598
+ known: dict[str, JobHandler] = {
599
+ GC_JOB: lambda job: run_gc_job(engine, job, runtime=runtime),
600
+ INDEX_JOB: lambda job: run_index_job(
601
+ ddl, job, constraints_only=constraints_only(runtime.settings)
602
+ ),
603
+ MATERIALIZE_JOB: lambda job: run_materialize_job(engine, job, runtime=runtime),
604
+ BRANCH_DELETE_JOB: lambda job: run_branch_delete_job(engine, job, runtime=runtime),
605
+ SENSITIVITY_REFRESH_JOB: lambda job: run_sensitivity_refresh_job(
606
+ engine, job, runtime=runtime
607
+ ),
608
+ LINEAGE_BACKFILL_JOB: lambda job: run_lineage_backfill_job(engine, job, runtime=runtime),
609
+ }
610
+ unknown = [t for t in types if t not in known]
611
+ if unknown:
612
+ raise SystemExit(f"no handler for job type(s): {', '.join(unknown)}")
613
+ # The path fan-out sweep runs next to the jobs, never ahead of them.
614
+ sweep = (
615
+ asyncio.create_task(run_path_fanout_sweep(async_sessionmaker(engine), runtime=runtime))
616
+ if MATERIALIZE_JOB in types
617
+ else None
618
+ )
619
+ try:
620
+ if LINEAGE_BACKFILL_JOB in types:
621
+ await queue_lineage_sweep(async_sessionmaker(engine, expire_on_commit=False))
622
+ return await run_jobs(
623
+ async_sessionmaker(engine, expire_on_commit=False),
624
+ {t: known[t] for t in types},
625
+ settings=runtime.settings.jobs,
626
+ limit=limit,
627
+ )
628
+ finally:
629
+ if sweep is not None:
630
+ await sweep
631
+ await engine.dispose()
632
+ if maintenance is not None:
633
+ await maintenance.dispose()
634
+
635
+
636
+ def main(argv: Sequence[str] | None = None) -> int:
637
+ parser = _parser()
638
+ args = parser.parse_args(argv)
639
+ if args.command == "lineage-backfill":
640
+ if (args.domain is None) != (args.branch is None):
641
+ parser.error("--domain and --branch go together")
642
+ queued = asyncio.run(_queue_lineage_backfill(args.domain, args.branch, args.recompute))
643
+ sys.stdout.write(json.dumps({"job": queued}) + "\n")
644
+ return 0
645
+ ran = asyncio.run(_work(args.limit, args.types))
646
+ sys.stdout.write(json.dumps({"jobs_run": ran}) + "\n")
647
+ return 0
648
+
649
+
650
+ if __name__ == "__main__":
651
+ raise SystemExit(main())