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,444 @@
|
|
|
1
|
+
"""Titles and search vectors a branch materialized before the default branch tagged an
|
|
2
|
+
Attribute (design 10.2): their rewrite in batches.
|
|
3
|
+
|
|
4
|
+
A branch forked before the default branch tagged a title or searchable Attribute keeps the
|
|
5
|
+
titles and search vectors it computed without the tag: Keeper keeps them out of responses and
|
|
6
|
+
lexical search for callers without the clearance, and verification reports the titles as
|
|
7
|
+
`sensitivity_stale`. The materializer adds the default branch's tags whenever it writes a
|
|
8
|
+
Record (`materialize.sensitivity`), so only rows written before the tag are stale, in the
|
|
9
|
+
Kinds whose title or search weights the tags change (`stale_kinds`).
|
|
10
|
+
|
|
11
|
+
**Rewrite.** `refresh_stale_rows` recomputes the titles and search vectors of those Kinds as
|
|
12
|
+
the materializer computes them now, `materialize.job_batch_rows` Records per transaction in
|
|
13
|
+
`(Kind id, key)` order. Each batch holds the branch lock, like a commit, and writes through
|
|
14
|
+
`MaterializedRepository` (rows an overlay reads from its base are copied up, rows overlays
|
|
15
|
+
based on the branch read are copied into them first, stamped with the generation of the
|
|
16
|
+
branch head). Only titles that differ are written, so a batch run twice writes nothing new.
|
|
17
|
+
Nothing is committed: titles and search vectors are not part of the tree.
|
|
18
|
+
|
|
19
|
+
Tags are the effective ones (`materialize.sensitivity.tagged_schema`): a derived title or
|
|
20
|
+
searchable Attribute reading a tagged Attribute is stale too.
|
|
21
|
+
|
|
22
|
+
**Job.** `store.sensitivity_refresh` (payload `{"branch"}`) runs the rewrite for one branch.
|
|
23
|
+
A batch that waits longer than `commit.lock_timeout_ms` for the branch lock, three times, queues
|
|
24
|
+
the job again after `materialize.job_retry_seconds`, with `resume` set to the last key
|
|
25
|
+
written. When it is done, the job queues itself for the overlays based on the branch: the
|
|
26
|
+
copies it made into them hold the old rows, and their own rows are theirs to rewrite. A job
|
|
27
|
+
is queued unless an identical one is still queued; a running one does not count, since it
|
|
28
|
+
may already be past the Kinds a new tag makes stale.
|
|
29
|
+
|
|
30
|
+
**Automatic.** With `materialize.refresh_branches_on_tag`, a commit or merge on the default
|
|
31
|
+
branch that adds a tag (to any Attribute, new ones included) queues the job, in one statement
|
|
32
|
+
of its transaction, for every other branch that has stale Kinds by its schema, except
|
|
33
|
+
overlays whose base is queued (the base's job queues them). Creating a copy of a branch with
|
|
34
|
+
stale Kinds queues the copy (an overlay's source is queued instead), and so does the end of a
|
|
35
|
+
background copy (`queue_if_stale`). The branches' schemas are not changed:
|
|
36
|
+
`POST .../branches/{b}/rebase-tags` (Keeper) commits the tags to a branch's schema, and that
|
|
37
|
+
schema change recomputes the titles and search vectors of the Kinds it changes in its own
|
|
38
|
+
transaction, as every schema change does.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import asyncio
|
|
44
|
+
import json
|
|
45
|
+
import logging
|
|
46
|
+
import time
|
|
47
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
from datetime import UTC, datetime, timedelta
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from sqlalchemy import text
|
|
53
|
+
from sqlalchemy.exc import DBAPIError
|
|
54
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
55
|
+
|
|
56
|
+
from lorebase_core.expr.sensitivity import raised_tags, schema_tags
|
|
57
|
+
from lorebase_core.model import Kind, Schema
|
|
58
|
+
from lorebase_store.auth.scope import rls_scope
|
|
59
|
+
from lorebase_store.commit.schema import load_compiled
|
|
60
|
+
from lorebase_store.jobs import SENSITIVITY_REFRESH_JOB, JobRow, enqueue_job
|
|
61
|
+
from lorebase_store.materialize.rows import record_title, search_weights
|
|
62
|
+
from lorebase_store.materialize.sensitivity import branch_tagged_schema, tagged_schema
|
|
63
|
+
from lorebase_store.models.enums import Materialization, RefKind
|
|
64
|
+
from lorebase_store.repositories.domains import DomainRepository
|
|
65
|
+
from lorebase_store.repositories.materialized import MaterializedRepository
|
|
66
|
+
from lorebase_store.repositories.refs import LockTimeoutError, RefRepository, sqlstate
|
|
67
|
+
from lorebase_store.repositories.revisions import RevisionRepository
|
|
68
|
+
from lorebase_store.repositories.scope import scope_to_domain
|
|
69
|
+
from lorebase_store.runtime import StoreRuntime, default_runtime
|
|
70
|
+
from lorebase_store.telemetry import BRANCH_JOB_BATCH_DURATION, BRANCH_JOB_ROWS, BRANCH_JOB_RUNS
|
|
71
|
+
|
|
72
|
+
log = logging.getLogger(__name__)
|
|
73
|
+
|
|
74
|
+
JOB_NAME = "sensitivity_refresh"
|
|
75
|
+
"""Value of the `job` attribute of the branch job metrics."""
|
|
76
|
+
_RETRYABLE = frozenset({"40P01", "40001"})
|
|
77
|
+
_ATTEMPTS = 3
|
|
78
|
+
"""Runs of a batch that hits a retryable failure or a busy branch before the job queues
|
|
79
|
+
itself again."""
|
|
80
|
+
|
|
81
|
+
Cursor = tuple[str, str]
|
|
82
|
+
"""`(Kind id, key)` of the last Record a batch read."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _now() -> datetime:
|
|
86
|
+
return datetime.now(UTC)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _title_hidden(kind: Kind) -> bool:
|
|
90
|
+
attribute = kind.attributes.get(kind.title_attribute or "")
|
|
91
|
+
return attribute is not None and attribute.is_sensitive
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def stale_kinds(own: Schema, tagged: Schema) -> list[str]:
|
|
95
|
+
"""Kinds of `own` whose titles or search vectors `tagged` (`own` with the default branch's
|
|
96
|
+
tags added) computes differently: a title or searchable Attribute it tags."""
|
|
97
|
+
stale = []
|
|
98
|
+
for kind_id, kind in own.kinds.items():
|
|
99
|
+
other = tagged.kinds.get(kind_id)
|
|
100
|
+
if other is None:
|
|
101
|
+
continue
|
|
102
|
+
if _title_hidden(other) != _title_hidden(kind) or search_weights(other) != search_weights(
|
|
103
|
+
kind
|
|
104
|
+
):
|
|
105
|
+
stale.append(kind_id)
|
|
106
|
+
return sorted(stale)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class StaleRefresh:
|
|
111
|
+
"""What a rewrite did: the stale Kinds, Records read, titles rewritten, and where it
|
|
112
|
+
stopped when it was deferred (`resume`)."""
|
|
113
|
+
|
|
114
|
+
branch: str
|
|
115
|
+
status: str = "done"
|
|
116
|
+
kinds: list[str] = field(default_factory=list)
|
|
117
|
+
batches: int = 0
|
|
118
|
+
rows: int = 0
|
|
119
|
+
titles: int = 0
|
|
120
|
+
vectors: int = 0
|
|
121
|
+
resume: Cursor | None = None
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def rewrote(self) -> bool:
|
|
125
|
+
return bool(self.titles or self.vectors)
|
|
126
|
+
|
|
127
|
+
def as_dict(self) -> dict[str, Any]:
|
|
128
|
+
return {
|
|
129
|
+
"branch": self.branch,
|
|
130
|
+
"status": self.status,
|
|
131
|
+
"kinds": list(self.kinds),
|
|
132
|
+
"batches": self.batches,
|
|
133
|
+
"rows": self.rows,
|
|
134
|
+
"titles": self.titles,
|
|
135
|
+
"vectors": self.vectors,
|
|
136
|
+
"resume": None if self.resume is None else list(self.resume),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def refresh_payload(branch: str, resume: Cursor | None = None) -> dict[str, Any]:
|
|
141
|
+
"""Payload of a `store.sensitivity_refresh` job."""
|
|
142
|
+
payload: dict[str, Any] = {"branch": branch}
|
|
143
|
+
if resume is not None:
|
|
144
|
+
payload["resume"] = list(resume)
|
|
145
|
+
return payload
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def queue_stale_refresh(
|
|
149
|
+
session: AsyncSession,
|
|
150
|
+
domain_id: str,
|
|
151
|
+
branch: str,
|
|
152
|
+
*,
|
|
153
|
+
resume: Cursor | None = None,
|
|
154
|
+
run_after: datetime | None = None,
|
|
155
|
+
) -> int:
|
|
156
|
+
"""Queue the rewrite of a branch's stale rows (an identical job that has not started yet
|
|
157
|
+
is reused)."""
|
|
158
|
+
return await enqueue_job(
|
|
159
|
+
session,
|
|
160
|
+
SENSITIVITY_REFRESH_JOB,
|
|
161
|
+
domain_id=domain_id,
|
|
162
|
+
payload=refresh_payload(branch, resume),
|
|
163
|
+
run_after=run_after,
|
|
164
|
+
unique_queued=True,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def queue_if_stale(
|
|
169
|
+
session: AsyncSession, domain_id: str, branch: str, schema: Schema, *, runtime: StoreRuntime
|
|
170
|
+
) -> int | None:
|
|
171
|
+
"""With `materialize.refresh_branches_on_tag`, queue the rewrite of `branch` (whose
|
|
172
|
+
schema is `schema`) when the default branch's tags make some of its Kinds stale."""
|
|
173
|
+
if not runtime.settings.materialize.refresh_branches_on_tag:
|
|
174
|
+
return None
|
|
175
|
+
tagged = await branch_tagged_schema(session, domain_id, branch, schema, runtime=runtime)
|
|
176
|
+
if not stale_kinds(schema, tagged):
|
|
177
|
+
return None
|
|
178
|
+
return await queue_stale_refresh(session, domain_id, branch)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
_BRANCH_SCHEMAS = text(
|
|
182
|
+
"""
|
|
183
|
+
SELECT r.name, r.materialization::text, r.base_branch, s.schema_hash
|
|
184
|
+
FROM refs r JOIN mat_schema s ON s.domain_id = r.domain_id AND s.branch = r.name
|
|
185
|
+
WHERE r.domain_id = :domain AND r.kind = 'branch' AND r.deleted_at IS NULL
|
|
186
|
+
AND r.name <> :default
|
|
187
|
+
"""
|
|
188
|
+
)
|
|
189
|
+
_COMPILED = text(
|
|
190
|
+
"""
|
|
191
|
+
SELECT DISTINCT ON (schema_hash) schema_hash, compiled FROM mat_schema
|
|
192
|
+
WHERE domain_id = :domain AND schema_hash = ANY(CAST(:hashes AS bytea[]))
|
|
193
|
+
"""
|
|
194
|
+
)
|
|
195
|
+
_ENQUEUE_MISSING = text(
|
|
196
|
+
"""
|
|
197
|
+
INSERT INTO jobs (type, domain_id, payload, priority, run_after)
|
|
198
|
+
SELECT CAST(:type AS text), CAST(:domain AS text), e.payload, 0, CAST(:now AS timestamptz)
|
|
199
|
+
FROM jsonb_array_elements(CAST(:payloads AS jsonb)) AS e(payload)
|
|
200
|
+
WHERE NOT EXISTS (
|
|
201
|
+
SELECT 1 FROM jobs j
|
|
202
|
+
WHERE j.type = CAST(:type AS text) AND j.domain_id = CAST(:domain AS text)
|
|
203
|
+
AND j.status::text = 'queued' AND j.payload = e.payload
|
|
204
|
+
)
|
|
205
|
+
RETURNING id
|
|
206
|
+
"""
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def queue_after_tagging(
|
|
211
|
+
session: AsyncSession,
|
|
212
|
+
domain_id: str,
|
|
213
|
+
branch: str,
|
|
214
|
+
old: Schema,
|
|
215
|
+
new: Schema,
|
|
216
|
+
*,
|
|
217
|
+
runtime: StoreRuntime,
|
|
218
|
+
) -> list[int]:
|
|
219
|
+
"""In the transaction of a commit or merge that changed the schema of `branch` from `old`
|
|
220
|
+
to `new`: when `branch` is the default branch, `new` adds tags and
|
|
221
|
+
`materialize.refresh_branches_on_tag` is on, queue the rewrite, in one statement, for
|
|
222
|
+
every other branch that has stale Kinds by its schema and `new`'s tags, except overlays
|
|
223
|
+
whose base is queued too. Returns the job ids."""
|
|
224
|
+
if not runtime.settings.materialize.refresh_branches_on_tag or not raised_tags(old, new):
|
|
225
|
+
return []
|
|
226
|
+
domain = await DomainRepository(session).get(domain_id)
|
|
227
|
+
if domain is None or branch != domain.default_branch:
|
|
228
|
+
return []
|
|
229
|
+
found = (await session.execute(_BRANCH_SCHEMAS, {"domain": domain_id, "default": branch})).all()
|
|
230
|
+
hashes = sorted({bytes(row[3]) for row in found})
|
|
231
|
+
compiled = {
|
|
232
|
+
bytes(digest): load_compiled(body)
|
|
233
|
+
for digest, body in (
|
|
234
|
+
await session.execute(_COMPILED, {"domain": domain_id, "hashes": hashes})
|
|
235
|
+
).all()
|
|
236
|
+
}
|
|
237
|
+
reference = schema_tags(new)
|
|
238
|
+
limits = runtime.schema.expression_limits
|
|
239
|
+
stale_by_hash = {
|
|
240
|
+
digest: bool(stale_kinds(schema, tagged_schema(schema, reference, limits)))
|
|
241
|
+
for digest, schema in compiled.items()
|
|
242
|
+
}
|
|
243
|
+
stale = {name for name, _, _, digest in found if stale_by_hash.get(bytes(digest))}
|
|
244
|
+
queued = sorted(
|
|
245
|
+
name
|
|
246
|
+
for name, materialization, base, _ in found
|
|
247
|
+
if name in stale
|
|
248
|
+
and not (materialization == Materialization.OVERLAY.value and base in stale)
|
|
249
|
+
)
|
|
250
|
+
if not queued:
|
|
251
|
+
return []
|
|
252
|
+
payloads = json.dumps([refresh_payload(name) for name in queued])
|
|
253
|
+
async with rls_scope(session, bypass=True):
|
|
254
|
+
result = await session.execute(
|
|
255
|
+
_ENQUEUE_MISSING,
|
|
256
|
+
{
|
|
257
|
+
"type": SENSITIVITY_REFRESH_JOB,
|
|
258
|
+
"domain": domain_id,
|
|
259
|
+
"payloads": payloads,
|
|
260
|
+
"now": _now(),
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
return [int(job_id) for (job_id,) in result.all()]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def refresh_stale_rows(
|
|
267
|
+
engine: AsyncEngine,
|
|
268
|
+
domain_id: str,
|
|
269
|
+
branch: str,
|
|
270
|
+
*,
|
|
271
|
+
runtime: StoreRuntime,
|
|
272
|
+
resume: Cursor | None = None,
|
|
273
|
+
) -> StaleRefresh:
|
|
274
|
+
"""Rewrite the stale titles and search vectors of a branch in batches, each in its own
|
|
275
|
+
transaction (module doc), from `resume` when given. The report's status is `done`,
|
|
276
|
+
`deferred` (the branch stayed locked; `resume` says where to go on) or `cancelled` (the
|
|
277
|
+
branch is gone or not materialized)."""
|
|
278
|
+
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
|
279
|
+
settings = runtime.settings
|
|
280
|
+
report = StaleRefresh(branch)
|
|
281
|
+
cursor = resume
|
|
282
|
+
failures = 0
|
|
283
|
+
while True:
|
|
284
|
+
started = time.perf_counter()
|
|
285
|
+
try:
|
|
286
|
+
async with sessions() as session, session.begin():
|
|
287
|
+
batch = await _refresh_batch(session, domain_id, branch, cursor, runtime, report)
|
|
288
|
+
except LockTimeoutError:
|
|
289
|
+
failures += 1
|
|
290
|
+
if failures >= _ATTEMPTS:
|
|
291
|
+
report.status, report.resume = "deferred", cursor
|
|
292
|
+
return report
|
|
293
|
+
await asyncio.sleep(min(settings.materialize.job_retry_seconds, 1.0))
|
|
294
|
+
continue
|
|
295
|
+
except DBAPIError as error:
|
|
296
|
+
failures += 1
|
|
297
|
+
if sqlstate(error) not in _RETRYABLE or failures >= _ATTEMPTS:
|
|
298
|
+
raise
|
|
299
|
+
log.info("sensitivity refresh of branch %s: batch retried after %s", branch, error)
|
|
300
|
+
continue
|
|
301
|
+
BRANCH_JOB_BATCH_DURATION.record(time.perf_counter() - started, {"job": JOB_NAME})
|
|
302
|
+
failures = 0
|
|
303
|
+
if batch is None:
|
|
304
|
+
return report
|
|
305
|
+
cursor = batch
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def _refresh_batch(
|
|
309
|
+
session: AsyncSession,
|
|
310
|
+
domain_id: str,
|
|
311
|
+
branch: str,
|
|
312
|
+
cursor: Cursor | None,
|
|
313
|
+
runtime: StoreRuntime,
|
|
314
|
+
report: StaleRefresh,
|
|
315
|
+
) -> Cursor | None:
|
|
316
|
+
"""Rewrite the next batch after `cursor`; returns the new cursor, None when done."""
|
|
317
|
+
settings = runtime.settings
|
|
318
|
+
await scope_to_domain(session, domain_id)
|
|
319
|
+
refs = RefRepository(session, domain_id)
|
|
320
|
+
await refs.lock(branch, timeout_ms=settings.commit.lock_timeout_ms)
|
|
321
|
+
ref = await refs.get(branch)
|
|
322
|
+
if ref is None or ref.kind is not RefKind.BRANCH:
|
|
323
|
+
report.status = "cancelled"
|
|
324
|
+
return None
|
|
325
|
+
generation = (await RevisionRepository(session, domain_id).generations([ref.head]))[ref.head]
|
|
326
|
+
repository = MaterializedRepository(
|
|
327
|
+
session,
|
|
328
|
+
domain_id,
|
|
329
|
+
branch,
|
|
330
|
+
writes=settings.writes,
|
|
331
|
+
generation=generation,
|
|
332
|
+
lock_timeout_ms=settings.commit.lock_timeout_ms,
|
|
333
|
+
)
|
|
334
|
+
stored = await repository.schema()
|
|
335
|
+
if stored is None:
|
|
336
|
+
report.status = "cancelled"
|
|
337
|
+
return None
|
|
338
|
+
own = load_compiled(stored.compiled)
|
|
339
|
+
tagged = await branch_tagged_schema(session, domain_id, branch, own, runtime=runtime)
|
|
340
|
+
report.kinds = stale_kinds(own, tagged)
|
|
341
|
+
for kind_id in report.kinds:
|
|
342
|
+
if cursor is not None and kind_id < cursor[0]:
|
|
343
|
+
continue
|
|
344
|
+
after = cursor[1] if cursor is not None and cursor[0] == kind_id else None
|
|
345
|
+
page = await repository.record_page(
|
|
346
|
+
kind_id, after=after, limit=settings.materialize.job_batch_rows
|
|
347
|
+
)
|
|
348
|
+
if not page:
|
|
349
|
+
continue
|
|
350
|
+
kind = tagged.kinds[kind_id]
|
|
351
|
+
titles = [
|
|
352
|
+
(row.key, title)
|
|
353
|
+
for row in page
|
|
354
|
+
if (title := record_title(kind, row.values)) != row.title
|
|
355
|
+
]
|
|
356
|
+
if titles:
|
|
357
|
+
await repository.update_titles(kind_id, titles)
|
|
358
|
+
weights = search_weights(kind)
|
|
359
|
+
config = settings.materialize.text_search_config
|
|
360
|
+
# Vectors hold the title at weight A: a changed title changes the vector too. Other
|
|
361
|
+
# vectors are rewritten only when they differ from what the weights compute now.
|
|
362
|
+
keys = {key for key, _ in titles}
|
|
363
|
+
if weights != search_weights(own.kinds[kind_id]):
|
|
364
|
+
others = [row.key for row in page if row.key not in keys]
|
|
365
|
+
keys.update(
|
|
366
|
+
await repository.stale_search_keys(kind_id, weights, config=config, keys=others)
|
|
367
|
+
)
|
|
368
|
+
if keys:
|
|
369
|
+
await repository.refresh_search(kind_id, weights, config=config, keys=sorted(keys))
|
|
370
|
+
report.batches += 1
|
|
371
|
+
report.rows += len(page)
|
|
372
|
+
report.titles += len(titles)
|
|
373
|
+
report.vectors += len(keys)
|
|
374
|
+
BRANCH_JOB_ROWS.add(len(page), {"job": JOB_NAME})
|
|
375
|
+
return kind_id, page[-1].key
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
async def run_sensitivity_refresh_job(
|
|
380
|
+
engine: AsyncEngine,
|
|
381
|
+
job: JobRow,
|
|
382
|
+
*,
|
|
383
|
+
runtime: StoreRuntime | None = None,
|
|
384
|
+
clock: Callable[[], datetime] | None = None,
|
|
385
|
+
) -> Mapping[str, Any]:
|
|
386
|
+
"""Job handler of `store.sensitivity_refresh` (module doc)."""
|
|
387
|
+
runtime = runtime or default_runtime()
|
|
388
|
+
now = clock or _now
|
|
389
|
+
if job.domain_id is None:
|
|
390
|
+
raise ValueError(f"a {job.type} job needs a Domain")
|
|
391
|
+
branch = job.payload.get("branch")
|
|
392
|
+
if not isinstance(branch, str) or not branch:
|
|
393
|
+
raise ValueError(f"a {job.type} job needs a `branch`")
|
|
394
|
+
resume = _cursor(job.payload.get("resume"))
|
|
395
|
+
try:
|
|
396
|
+
report = await refresh_stale_rows(
|
|
397
|
+
engine, job.domain_id, branch, runtime=runtime, resume=resume
|
|
398
|
+
)
|
|
399
|
+
except Exception:
|
|
400
|
+
BRANCH_JOB_RUNS.add(1, {"job": JOB_NAME, "outcome": "failed"})
|
|
401
|
+
raise
|
|
402
|
+
BRANCH_JOB_RUNS.add(1, {"job": JOB_NAME, "outcome": report.status})
|
|
403
|
+
result = report.as_dict()
|
|
404
|
+
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
|
405
|
+
if report.status == "deferred":
|
|
406
|
+
delay = timedelta(seconds=runtime.settings.materialize.job_retry_seconds)
|
|
407
|
+
async with sessions() as session, session.begin():
|
|
408
|
+
result["again"] = await queue_stale_refresh(
|
|
409
|
+
session, job.domain_id, branch, resume=report.resume, run_after=now() + delay
|
|
410
|
+
)
|
|
411
|
+
elif report.status == "done":
|
|
412
|
+
async with sessions() as session, session.begin():
|
|
413
|
+
await scope_to_domain(session, job.domain_id)
|
|
414
|
+
overlays = await RefRepository(session, job.domain_id).overlays_of(branch)
|
|
415
|
+
result["overlays"] = [
|
|
416
|
+
await queue_stale_refresh(session, job.domain_id, ref.name) for ref in overlays
|
|
417
|
+
]
|
|
418
|
+
return result
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _cursor(value: Any) -> Cursor | None:
|
|
422
|
+
if value is None:
|
|
423
|
+
return None
|
|
424
|
+
if (
|
|
425
|
+
not isinstance(value, Sequence)
|
|
426
|
+
or isinstance(value, str)
|
|
427
|
+
or len(value) != 2
|
|
428
|
+
or not all(isinstance(part, str) for part in value)
|
|
429
|
+
):
|
|
430
|
+
raise ValueError("`resume` must be [kind id, key]")
|
|
431
|
+
return value[0], value[1]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
__all__ = [
|
|
435
|
+
"JOB_NAME",
|
|
436
|
+
"StaleRefresh",
|
|
437
|
+
"queue_after_tagging",
|
|
438
|
+
"queue_if_stale",
|
|
439
|
+
"queue_stale_refresh",
|
|
440
|
+
"refresh_payload",
|
|
441
|
+
"refresh_stale_rows",
|
|
442
|
+
"run_sensitivity_refresh_job",
|
|
443
|
+
"stale_kinds",
|
|
444
|
+
]
|