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
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Lineage revisions of rows materialized before migration `0032`: their backfill in batches.
|
|
2
|
+
|
|
3
|
+
`mat_records.lineage_revisions` names the Revision behind each value of a Record and the one
|
|
4
|
+
that created it (`materialize.rows.next_lineage_revisions`). Rows written before the column
|
|
5
|
+
existed hold null, and rows of those Records updated since hold only the entries of their
|
|
6
|
+
later changes (no `$created`); readers fall back to the branch history for what is missing,
|
|
7
|
+
within `read.history.lineage_scan_changes` changes (Keeper `reading.record_history`).
|
|
8
|
+
|
|
9
|
+
**Backfill.** `backfill_lineage_revisions` folds the branch history of those rows as a rebuild
|
|
10
|
+
does (`materialize.rebuild.fold_history`) and writes the result, `materialize.job_batch_rows`
|
|
11
|
+
rows per transaction in `(Kind id, key)` order. A row is written only while it still holds
|
|
12
|
+
what the batch read, so a commit that changed it meanwhile wins; a Record with no history row
|
|
13
|
+
gets an empty map, so a batch run twice writes nothing new. Rebuilds and branch copies fold
|
|
14
|
+
the history themselves and leave nothing to backfill.
|
|
15
|
+
|
|
16
|
+
**Jobs.** `store.lineage_backfill` with payload `{}` (no Domain) is the sweep: it finds the
|
|
17
|
+
branches holding such rows, in one scan of `mat_records`, and queues the job with payload
|
|
18
|
+
`{"branch"}` for each in its Domain, which runs the backfill of that branch. A starting job
|
|
19
|
+
worker that runs the type queues the sweep once (`queue_lineage_sweep`): not again once one
|
|
20
|
+
is queued, running or done.
|
|
21
|
+
|
|
22
|
+
**Recompute.** Merges made before their changesets were restricted to the values they change
|
|
23
|
+
recorded the source's whole Lineage, so their Revision was named for values they left as they
|
|
24
|
+
were. With `"recompute": true` (in the sweep's payload, which then covers every branch with
|
|
25
|
+
materialized Records, or in a branch's), the job also folds again the rows whose lineage
|
|
26
|
+
revisions name a merge Revision; the fold attributes a merge step only the values that differ
|
|
27
|
+
from the branch's previous value (`rebuild.fold_history`). `python -m lorebase_store.jobs
|
|
28
|
+
lineage-backfill [--domain D --branch B] [--recompute]` queues it.
|
|
29
|
+
|
|
30
|
+
Recompute also corrects what fast-forwards wrote before they named the source's Revisions: one
|
|
31
|
+
history row per element at the source head, and rows naming the source head for every value
|
|
32
|
+
they changed. Under the branch lock, each history row of the branch's own that no changeset
|
|
33
|
+
holds (same element, Revision and previous object) is such a row; the fast-forward's range
|
|
34
|
+
(from the ref log: the head it moved from) is written again one Revision at a time, as a
|
|
35
|
+
rebuild writes it, within
|
|
36
|
+
`materialize.fast_forward_history_max_revisions` (a longer range is left as it is). The
|
|
37
|
+
Records last updated by those fast-forwards are then folded again like the others, their
|
|
38
|
+
lineage, lineage revisions and update Revision included, and so is the lineage of the Links
|
|
39
|
+
those fast-forwards wrote. Finding those rows reads the
|
|
40
|
+
branch's whole history once.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import logging
|
|
47
|
+
from collections import defaultdict
|
|
48
|
+
from collections.abc import Mapping
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from sqlalchemy import TextClause, text
|
|
53
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
54
|
+
|
|
55
|
+
from lorebase_core.tree import LINK_KEY_SEPARATOR, kind_partition
|
|
56
|
+
from lorebase_store.auth.scope import rls_scope
|
|
57
|
+
from lorebase_store.jobs import LINEAGE_BACKFILL_JOB, JobRow, enqueue_job
|
|
58
|
+
from lorebase_store.materialize.rebuild import fold_history
|
|
59
|
+
from lorebase_store.materialize.rows import (
|
|
60
|
+
CREATED_KEY,
|
|
61
|
+
PartitionType,
|
|
62
|
+
next_lineage,
|
|
63
|
+
parse_partition,
|
|
64
|
+
split_link_key,
|
|
65
|
+
)
|
|
66
|
+
from lorebase_store.objectstore import PgObjectStore
|
|
67
|
+
from lorebase_store.repositories.materialized import MERGE_ORIGIN_TYPE, MaterializedRepository
|
|
68
|
+
from lorebase_store.repositories.refs import RefRepository
|
|
69
|
+
from lorebase_store.repositories.scope import scope_to_domain
|
|
70
|
+
from lorebase_store.runtime import StoreRuntime, default_runtime
|
|
71
|
+
|
|
72
|
+
log = logging.getLogger(__name__)
|
|
73
|
+
|
|
74
|
+
SWEEP_PAYLOAD: Mapping[str, Any] = {}
|
|
75
|
+
"""Payload of the sweep job."""
|
|
76
|
+
|
|
77
|
+
_REVISIONS = "lb_lineage(m.domain_id, m.lineage_revisions)"
|
|
78
|
+
"""The lineage revisions of row `m`, a shared map resolved (revision 0036)."""
|
|
79
|
+
|
|
80
|
+
_INCOMPLETE = f"(m.lineage_revisions IS NULL OR ({_REVISIONS} ->> '{CREATED_KEY}') IS NULL)"
|
|
81
|
+
|
|
82
|
+
_BRANCHES = text(
|
|
83
|
+
f"""
|
|
84
|
+
SELECT DISTINCT m.domain_id, m.branch FROM mat_records m
|
|
85
|
+
WHERE NOT m.deleted AND {_INCOMPLETE}
|
|
86
|
+
ORDER BY m.domain_id, m.branch
|
|
87
|
+
"""
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Serializes workers starting together, so they queue one sweep between them.
|
|
91
|
+
_SWEEP_LOCK = text("SELECT pg_advisory_xact_lock(hashtext('lorebase.store.lineage_backfill'))")
|
|
92
|
+
|
|
93
|
+
_SWEEP_SEEN = text(
|
|
94
|
+
"""
|
|
95
|
+
SELECT 1 FROM jobs
|
|
96
|
+
WHERE type = :type AND domain_id IS NULL AND payload = CAST(:payload AS jsonb)
|
|
97
|
+
AND status::text IN ('queued', 'running', 'succeeded')
|
|
98
|
+
LIMIT 1
|
|
99
|
+
"""
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
_ALL_BRANCHES = text(
|
|
103
|
+
"""
|
|
104
|
+
SELECT DISTINCT m.domain_id, m.branch FROM mat_records m
|
|
105
|
+
WHERE NOT m.deleted
|
|
106
|
+
ORDER BY m.domain_id, m.branch
|
|
107
|
+
"""
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Rows whose lineage revisions name a merge Revision.
|
|
111
|
+
_NAMES_A_MERGE = f"""EXISTS (
|
|
112
|
+
SELECT 1
|
|
113
|
+
FROM jsonb_each_text(CASE WHEN jsonb_typeof(m.lineage_revisions) = 'object'
|
|
114
|
+
THEN {_REVISIONS} ELSE '{{}}'::jsonb END) AS e
|
|
115
|
+
JOIN revisions r ON r.domain_id = m.domain_id AND r.id = decode(e.value, 'hex')
|
|
116
|
+
WHERE r.origin ->> 'type' = '{MERGE_ORIGIN_TYPE}')"""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _page(condition: str) -> TextClause:
|
|
120
|
+
return text(
|
|
121
|
+
f"""
|
|
122
|
+
SELECT m.kind_id, m.key, {_REVISIONS} AS lineage_revisions, m."values",
|
|
123
|
+
lb_lineage(m.domain_id, m.lineage) AS lineage, m.updated_revision
|
|
124
|
+
FROM mat_records m
|
|
125
|
+
WHERE m.domain_id = :domain AND m.branch = :branch AND NOT m.deleted AND {condition}
|
|
126
|
+
AND (m.kind_id, m.key) > (:kind, :key)
|
|
127
|
+
ORDER BY m.kind_id, m.key
|
|
128
|
+
LIMIT :limit
|
|
129
|
+
"""
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
_PAGE = _page(_INCOMPLETE)
|
|
134
|
+
_RECOMPUTE_PAGE = _page(
|
|
135
|
+
f"({_INCOMPLETE} OR {_NAMES_A_MERGE} "
|
|
136
|
+
"OR m.updated_revision = ANY(CAST(:fast_forwards AS bytea[])))"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Fast-forward history rows: the branch's own history rows that no changeset holds (a
|
|
140
|
+
# fast-forward's row starts from the branch's previous object, not the source's), by
|
|
141
|
+
# Revision, with the head the branch moved from to it (ref log).
|
|
142
|
+
_FAST_FORWARDS = text(
|
|
143
|
+
"""
|
|
144
|
+
SELECT DISTINCT h.revision_id,
|
|
145
|
+
(SELECT l.old_head FROM ref_log l
|
|
146
|
+
WHERE l.domain_id = h.domain_id AND l.ref_name = h.branch
|
|
147
|
+
AND l.new_head = h.revision_id
|
|
148
|
+
ORDER BY l.seq DESC LIMIT 1) AS old_head
|
|
149
|
+
FROM mat_history h
|
|
150
|
+
WHERE h.domain_id = :domain AND h.branch = :branch
|
|
151
|
+
AND NOT EXISTS (
|
|
152
|
+
SELECT 1 FROM changesets c
|
|
153
|
+
WHERE c.domain_id = h.domain_id AND c.revision_id = h.revision_id
|
|
154
|
+
AND c.partition = h.partition AND c.element_key = h.element_key
|
|
155
|
+
AND c.old_hash IS NOT DISTINCT FROM h.old_hash)
|
|
156
|
+
"""
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
_ELEMENTS_AT = text(
|
|
160
|
+
"SELECT partition, element_key FROM mat_history "
|
|
161
|
+
"WHERE domain_id = :domain AND branch = :branch AND revision_id = :revision"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
_LINK_LINEAGE = text(
|
|
165
|
+
"""
|
|
166
|
+
UPDATE mat_links SET lineage = CAST(:lineage AS jsonb)
|
|
167
|
+
WHERE domain_id = :domain AND branch = :branch AND relation_id = :relation
|
|
168
|
+
AND from_key = :from_key AND to_key = :to_key
|
|
169
|
+
AND lb_lineage(domain_id, lineage) IS DISTINCT FROM CAST(:lineage AS jsonb)
|
|
170
|
+
"""
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
_DROP_HISTORY_AT = text(
|
|
174
|
+
"DELETE FROM mat_history WHERE domain_id = :domain AND branch = :branch "
|
|
175
|
+
"AND revision_id = :revision"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
_REFOLD = text(
|
|
179
|
+
"""
|
|
180
|
+
UPDATE mat_records m
|
|
181
|
+
SET lineage_revisions = v.revs, lineage = v.lineage,
|
|
182
|
+
updated_revision = decode(v.updated, 'hex'), updated_at = v.updated_at
|
|
183
|
+
FROM jsonb_to_recordset(CAST(:rows AS jsonb)) AS v(kind_id text, key text, revs jsonb,
|
|
184
|
+
before jsonb, lineage jsonb,
|
|
185
|
+
updated text, updated_at timestamptz)
|
|
186
|
+
WHERE m.domain_id = :domain AND m.branch = :branch
|
|
187
|
+
AND m.kind_id = v.kind_id AND m.key = v.key
|
|
188
|
+
AND lb_lineage(m.domain_id, m.lineage_revisions) IS NOT DISTINCT FROM v.before
|
|
189
|
+
"""
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
_WRITE = text(
|
|
193
|
+
"""
|
|
194
|
+
UPDATE mat_records m SET lineage_revisions = v.revs
|
|
195
|
+
FROM jsonb_to_recordset(CAST(:rows AS jsonb)) AS v(kind_id text, key text, revs jsonb,
|
|
196
|
+
before jsonb)
|
|
197
|
+
WHERE m.domain_id = :domain AND m.branch = :branch
|
|
198
|
+
AND m.kind_id = v.kind_id AND m.key = v.key
|
|
199
|
+
AND lb_lineage(m.domain_id, m.lineage_revisions) IS NOT DISTINCT FROM v.before
|
|
200
|
+
"""
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class LineageBackfill:
|
|
206
|
+
"""What a backfill did: rows read and rows written, in how many batches."""
|
|
207
|
+
|
|
208
|
+
branch: str
|
|
209
|
+
batches: int = 0
|
|
210
|
+
rows: int = 0
|
|
211
|
+
written: int = 0
|
|
212
|
+
fast_forwards: int = 0
|
|
213
|
+
links: int = 0
|
|
214
|
+
|
|
215
|
+
def as_dict(self) -> dict[str, Any]:
|
|
216
|
+
return {
|
|
217
|
+
"branch": self.branch,
|
|
218
|
+
"fast_forwards": self.fast_forwards,
|
|
219
|
+
"links": self.links,
|
|
220
|
+
"batches": self.batches,
|
|
221
|
+
"rows": self.rows,
|
|
222
|
+
"written": self.written,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def backfill_payload(branch: str | None = None, *, recompute: bool = False) -> dict[str, Any]:
|
|
227
|
+
"""Payload of a `store.lineage_backfill` job: the sweep without `branch`."""
|
|
228
|
+
payload: dict[str, Any] = {} if branch is None else {"branch": branch}
|
|
229
|
+
if recompute:
|
|
230
|
+
payload["recompute"] = True
|
|
231
|
+
return payload
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def queue_lineage_sweep(sessions: async_sessionmaker[AsyncSession]) -> int | None:
|
|
235
|
+
"""Queue the sweep unless one is queued, running or done; returns its id when queued.
|
|
236
|
+
Workers starting together wait for one another (a transaction advisory lock)."""
|
|
237
|
+
payload = json.dumps(dict(SWEEP_PAYLOAD))
|
|
238
|
+
async with sessions() as session, session.begin():
|
|
239
|
+
await session.execute(_SWEEP_LOCK)
|
|
240
|
+
async with rls_scope(session, bypass=True):
|
|
241
|
+
seen = await session.execute(
|
|
242
|
+
_SWEEP_SEEN, {"type": LINEAGE_BACKFILL_JOB, "payload": payload}
|
|
243
|
+
)
|
|
244
|
+
if seen.first() is not None:
|
|
245
|
+
return None
|
|
246
|
+
return await enqueue_job(session, LINEAGE_BACKFILL_JOB, payload=SWEEP_PAYLOAD)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def queue_branch_backfills(session: AsyncSession, *, recompute: bool = False) -> list[int]:
|
|
250
|
+
"""Queue the backfill of every branch holding rows without full lineage revisions, or
|
|
251
|
+
with `recompute` of every branch with materialized Records (an identical job not started
|
|
252
|
+
yet is reused)."""
|
|
253
|
+
async with rls_scope(session, bypass=True):
|
|
254
|
+
found = (await session.execute(_ALL_BRANCHES if recompute else _BRANCHES)).all()
|
|
255
|
+
return [
|
|
256
|
+
await enqueue_job(
|
|
257
|
+
session,
|
|
258
|
+
LINEAGE_BACKFILL_JOB,
|
|
259
|
+
domain_id=domain_id,
|
|
260
|
+
payload=backfill_payload(branch, recompute=recompute),
|
|
261
|
+
unique_queued=True,
|
|
262
|
+
)
|
|
263
|
+
for domain_id, branch in found
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def backfill_lineage_revisions(
|
|
268
|
+
engine: AsyncEngine,
|
|
269
|
+
domain_id: str,
|
|
270
|
+
branch: str,
|
|
271
|
+
*,
|
|
272
|
+
runtime: StoreRuntime,
|
|
273
|
+
recompute: bool = False,
|
|
274
|
+
) -> LineageBackfill:
|
|
275
|
+
"""Write the lineage revisions of the branch's rows that lack them (with `recompute`,
|
|
276
|
+
also of those naming a merge Revision), in batches, each in its own transaction (module
|
|
277
|
+
doc)."""
|
|
278
|
+
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
|
279
|
+
report = LineageBackfill(branch)
|
|
280
|
+
fast_forwards: list[bytes] = []
|
|
281
|
+
if recompute:
|
|
282
|
+
async with sessions() as session, session.begin():
|
|
283
|
+
fast_forwards = await _repair_fast_forwards(session, domain_id, branch, runtime, report)
|
|
284
|
+
report.fast_forwards = len(fast_forwards)
|
|
285
|
+
cursor = ("", "")
|
|
286
|
+
while True:
|
|
287
|
+
async with sessions() as session, session.begin():
|
|
288
|
+
last = await _backfill_batch(
|
|
289
|
+
session,
|
|
290
|
+
domain_id,
|
|
291
|
+
branch,
|
|
292
|
+
cursor,
|
|
293
|
+
runtime,
|
|
294
|
+
report,
|
|
295
|
+
recompute=recompute,
|
|
296
|
+
fast_forwards=fast_forwards,
|
|
297
|
+
)
|
|
298
|
+
if last is None:
|
|
299
|
+
return report
|
|
300
|
+
cursor = last
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def _repair_fast_forwards(
|
|
304
|
+
session: AsyncSession,
|
|
305
|
+
domain_id: str,
|
|
306
|
+
branch: str,
|
|
307
|
+
runtime: StoreRuntime,
|
|
308
|
+
report: LineageBackfill,
|
|
309
|
+
) -> list[bytes]:
|
|
310
|
+
"""Write the history of the branch's earlier fast-forwards one Revision at a time and
|
|
311
|
+
fold again the lineage of the Links they wrote (module doc); returns the fast-forward
|
|
312
|
+
targets it rewrote, whose Records need folding again (a range it leaves as it is keeps
|
|
313
|
+
its rows)."""
|
|
314
|
+
settings = runtime.settings
|
|
315
|
+
await scope_to_domain(session, domain_id)
|
|
316
|
+
await RefRepository(session, domain_id).lock(branch, timeout_ms=settings.commit.lock_timeout_ms)
|
|
317
|
+
found = (await session.execute(_FAST_FORWARDS, {"domain": domain_id, "branch": branch})).all()
|
|
318
|
+
repository = MaterializedRepository(session, domain_id, branch, writes=settings.writes)
|
|
319
|
+
targets: list[bytes] = []
|
|
320
|
+
links: dict[str, set[str]] = {}
|
|
321
|
+
for revision, old_head in found:
|
|
322
|
+
target = bytes(revision)
|
|
323
|
+
if old_head is None:
|
|
324
|
+
continue
|
|
325
|
+
forwarded = await repository.first_parent_range(
|
|
326
|
+
target,
|
|
327
|
+
bytes(old_head),
|
|
328
|
+
limit=settings.materialize.fast_forward_history_max_revisions,
|
|
329
|
+
)
|
|
330
|
+
if forwarded is None:
|
|
331
|
+
continue
|
|
332
|
+
at = {"domain": domain_id, "branch": branch, "revision": target}
|
|
333
|
+
for partition, key in (await session.execute(_ELEMENTS_AT, at)).all():
|
|
334
|
+
if parse_partition(partition).type is PartitionType.RELATION:
|
|
335
|
+
links.setdefault(partition, set()).add(key)
|
|
336
|
+
await session.execute(_DROP_HISTORY_AT, at)
|
|
337
|
+
await repository.insert_chain_history(forwarded)
|
|
338
|
+
targets.append(target)
|
|
339
|
+
report.links += await _refold_links(session, repository, links, runtime)
|
|
340
|
+
return targets
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
async def _refold_links(
|
|
344
|
+
session: AsyncSession,
|
|
345
|
+
repository: MaterializedRepository,
|
|
346
|
+
links: Mapping[str, set[str]],
|
|
347
|
+
runtime: StoreRuntime,
|
|
348
|
+
) -> int:
|
|
349
|
+
"""Lineage of the branch's own Links among `links` (keys by partition) folded again
|
|
350
|
+
from the history, as a rebuild writes it (`rebuild.link_row`); returns how many changed."""
|
|
351
|
+
settings = runtime.settings
|
|
352
|
+
objects = PgObjectStore(
|
|
353
|
+
session,
|
|
354
|
+
repository.domain_id,
|
|
355
|
+
writes=settings.writes,
|
|
356
|
+
batch=settings.materialize.read_batch_rows,
|
|
357
|
+
)
|
|
358
|
+
written = 0
|
|
359
|
+
for partition, keys in sorted(links.items()):
|
|
360
|
+
relation_id = parse_partition(partition).element_id
|
|
361
|
+
folded = await fold_history(
|
|
362
|
+
repository,
|
|
363
|
+
partition,
|
|
364
|
+
batch=settings.materialize.read_batch_rows,
|
|
365
|
+
objects=objects,
|
|
366
|
+
keys=sorted(keys),
|
|
367
|
+
)
|
|
368
|
+
rows = await repository.links(relation_id, [split_link_key(key) for key in keys])
|
|
369
|
+
for (from_key, to_key), row in sorted(rows.items()):
|
|
370
|
+
found = folded.get(f"{from_key}{LINK_KEY_SEPARATOR}{to_key}")
|
|
371
|
+
lineage = next_lineage(None, None if found is None else found.provenance, row.values)
|
|
372
|
+
result = await session.execute(
|
|
373
|
+
_LINK_LINEAGE,
|
|
374
|
+
{
|
|
375
|
+
"domain": repository.domain_id,
|
|
376
|
+
"branch": repository.branch,
|
|
377
|
+
"relation": relation_id,
|
|
378
|
+
"from_key": from_key,
|
|
379
|
+
"to_key": to_key,
|
|
380
|
+
"lineage": json.dumps(lineage),
|
|
381
|
+
},
|
|
382
|
+
)
|
|
383
|
+
written += int(getattr(result, "rowcount", 0) or 0)
|
|
384
|
+
return written
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
async def _backfill_batch(
|
|
388
|
+
session: AsyncSession,
|
|
389
|
+
domain_id: str,
|
|
390
|
+
branch: str,
|
|
391
|
+
cursor: tuple[str, str],
|
|
392
|
+
runtime: StoreRuntime,
|
|
393
|
+
report: LineageBackfill,
|
|
394
|
+
*,
|
|
395
|
+
recompute: bool,
|
|
396
|
+
fast_forwards: list[bytes],
|
|
397
|
+
) -> tuple[str, str] | None:
|
|
398
|
+
settings = runtime.settings
|
|
399
|
+
await scope_to_domain(session, domain_id)
|
|
400
|
+
params = {
|
|
401
|
+
"domain": domain_id,
|
|
402
|
+
"branch": branch,
|
|
403
|
+
"kind": cursor[0],
|
|
404
|
+
"key": cursor[1],
|
|
405
|
+
"limit": settings.materialize.job_batch_rows,
|
|
406
|
+
"fast_forwards": fast_forwards,
|
|
407
|
+
}
|
|
408
|
+
page = (await session.execute(_RECOMPUTE_PAGE if recompute else _PAGE, params)).all()
|
|
409
|
+
if not page:
|
|
410
|
+
return None
|
|
411
|
+
repository = MaterializedRepository(session, domain_id, branch, writes=settings.writes)
|
|
412
|
+
objects = PgObjectStore(
|
|
413
|
+
session, domain_id, writes=settings.writes, batch=settings.materialize.read_batch_rows
|
|
414
|
+
)
|
|
415
|
+
by_kind: dict[str, list[Any]] = defaultdict(list)
|
|
416
|
+
for row in page:
|
|
417
|
+
by_kind[row.kind_id].append(row)
|
|
418
|
+
rows: list[dict[str, Any]] = []
|
|
419
|
+
refolded: list[dict[str, Any]] = []
|
|
420
|
+
for kind_id, entries in by_kind.items():
|
|
421
|
+
folded = await fold_history(
|
|
422
|
+
repository,
|
|
423
|
+
kind_partition(kind_id),
|
|
424
|
+
batch=settings.materialize.read_batch_rows,
|
|
425
|
+
objects=objects,
|
|
426
|
+
keys=[entry.key for entry in entries],
|
|
427
|
+
)
|
|
428
|
+
for entry in entries:
|
|
429
|
+
key, before = entry.key, entry.lineage_revisions
|
|
430
|
+
found = folded.get(key)
|
|
431
|
+
revisions = {} if found is None else dict(sorted(found.revisions.items()))
|
|
432
|
+
if recompute and found is not None:
|
|
433
|
+
# As a rebuild writes the row (`rebuild.record_row`).
|
|
434
|
+
lineage = next_lineage(None, found.provenance, entry.values or {})
|
|
435
|
+
if (revisions, lineage, found.revision_id) != (
|
|
436
|
+
before,
|
|
437
|
+
entry.lineage,
|
|
438
|
+
bytes(entry.updated_revision),
|
|
439
|
+
):
|
|
440
|
+
refolded.append(
|
|
441
|
+
{
|
|
442
|
+
"kind_id": kind_id,
|
|
443
|
+
"key": key,
|
|
444
|
+
"revs": revisions,
|
|
445
|
+
"before": before,
|
|
446
|
+
"lineage": lineage,
|
|
447
|
+
"updated": found.revision_id.hex(),
|
|
448
|
+
"updated_at": found.at.isoformat(),
|
|
449
|
+
}
|
|
450
|
+
)
|
|
451
|
+
continue
|
|
452
|
+
if before is not None and not revisions:
|
|
453
|
+
continue
|
|
454
|
+
if revisions != before:
|
|
455
|
+
rows.append({"kind_id": kind_id, "key": key, "revs": revisions, "before": before})
|
|
456
|
+
if refolded:
|
|
457
|
+
result = await session.execute(
|
|
458
|
+
_REFOLD, {"domain": domain_id, "branch": branch, "rows": json.dumps(refolded)}
|
|
459
|
+
)
|
|
460
|
+
report.written += int(getattr(result, "rowcount", 0) or 0)
|
|
461
|
+
if rows:
|
|
462
|
+
result = await session.execute(
|
|
463
|
+
_WRITE, {"domain": domain_id, "branch": branch, "rows": json.dumps(rows)}
|
|
464
|
+
)
|
|
465
|
+
report.written += int(getattr(result, "rowcount", 0) or 0)
|
|
466
|
+
report.batches += 1
|
|
467
|
+
report.rows += len(page)
|
|
468
|
+
return str(page[-1][0]), str(page[-1][1])
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
async def run_lineage_backfill_job(
|
|
472
|
+
engine: AsyncEngine, job: JobRow, *, runtime: StoreRuntime | None = None
|
|
473
|
+
) -> Mapping[str, Any]:
|
|
474
|
+
"""Job handler of `store.lineage_backfill` (module doc)."""
|
|
475
|
+
runtime = runtime or default_runtime()
|
|
476
|
+
branch = job.payload.get("branch")
|
|
477
|
+
recompute = job.payload.get("recompute", False)
|
|
478
|
+
if not isinstance(recompute, bool):
|
|
479
|
+
raise ValueError("`recompute` must be a boolean")
|
|
480
|
+
if branch is None and job.domain_id is None:
|
|
481
|
+
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
|
482
|
+
async with sessions() as session, session.begin():
|
|
483
|
+
queued = await queue_branch_backfills(session, recompute=recompute)
|
|
484
|
+
return {"queued": queued, "recompute": recompute}
|
|
485
|
+
if job.domain_id is None or not isinstance(branch, str) or not branch:
|
|
486
|
+
raise ValueError(f"a {job.type} job needs a Domain and a `branch`, or neither")
|
|
487
|
+
report = await backfill_lineage_revisions(
|
|
488
|
+
engine, job.domain_id, branch, runtime=runtime, recompute=recompute
|
|
489
|
+
)
|
|
490
|
+
log.info("lineage revisions backfilled", extra=report.as_dict())
|
|
491
|
+
return {**report.as_dict(), "recompute": recompute}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
__all__ = [
|
|
495
|
+
"SWEEP_PAYLOAD",
|
|
496
|
+
"LineageBackfill",
|
|
497
|
+
"backfill_lineage_revisions",
|
|
498
|
+
"backfill_payload",
|
|
499
|
+
"queue_branch_backfills",
|
|
500
|
+
"queue_lineage_sweep",
|
|
501
|
+
"run_lineage_backfill_job",
|
|
502
|
+
]
|