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.
- lorebase_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- 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())
|