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,1184 @@
|
|
|
1
|
+
"""Large branches in the background: copies, builds and deletions in batches (store schema
|
|
2
|
+
notes, "Large branches").
|
|
3
|
+
|
|
4
|
+
**Copy.** A copy branch whose source holds more than `store.materialize.async_copy_min_rows`
|
|
5
|
+
rows is created as an overlay of its source (`create_branch`): readable and writable at once,
|
|
6
|
+
with the semantics of an overlay. `queue_materialization` records its progress row
|
|
7
|
+
(`branch_materializations`, status `pending`, strategy `base`) and queues a
|
|
8
|
+
`store.materialize` job, which `run_materialize_job` runs in batches, each in its own
|
|
9
|
+
transaction:
|
|
10
|
+
|
|
11
|
+
1. `records`, `links`, `history`: copy the next base rows the overlay reads (revision
|
|
12
|
+
generation at or before its fork point; history of Revisions up to it) in primary key
|
|
13
|
+
order, from the cursor saved by the previous batch, with `INSERT ... ON CONFLICT DO
|
|
14
|
+
NOTHING`.
|
|
15
|
+
2. `switch`: drop the overlay's tombstones, set `materialization` to `full` (head and version
|
|
16
|
+
unchanged) and queue the per-Attribute indexes of a full branch.
|
|
17
|
+
3. When the overlay's schema changed during the copy (`apply.apply_changeset` records it in
|
|
18
|
+
`reconcile` instead of refusing the change), `prune`, `derived`, `paths` and `search`
|
|
19
|
+
recompute in batches what a schema change recomputes on a full branch (`build`).
|
|
20
|
+
|
|
21
|
+
Every batch holds the branch lock, so commits on the branch and copies on write from its base
|
|
22
|
+
wait for at most one batch (their inserts of the same keys could otherwise deadlock with it).
|
|
23
|
+
Batches are sized by the time they hold that lock (`_Pace`): from `job_batch_min_rows` up to
|
|
24
|
+
`job_batch_rows`, aiming at `job_batch_target_ms`, or at `job_batch_contended_ms` once a
|
|
25
|
+
commit waited for the lock. Between batches commits run freely, and the copy stays correct
|
|
26
|
+
because a copied row is the row the overlay already reads, and every row it shadows is kept:
|
|
27
|
+
- base rows written at or before the fork never change afterwards: a base commit that changes
|
|
28
|
+
or removes one first copies it into the overlay (copy on write), and stamps what it writes
|
|
29
|
+
with a later generation, which the copy skips;
|
|
30
|
+
- a commit on the branch first copies a base row up, then changes it, and a delete leaves a
|
|
31
|
+
tombstone: a batch that meets such a row finds it held and keeps it (`DO NOTHING`), and a
|
|
32
|
+
commit that meets a row a batch copied changes that copy.
|
|
33
|
+
So reads see the same rows before, during and after the copy (the overlay's rows win over the
|
|
34
|
+
base's, history included), and a batch run twice (a retry, a resumed job) copies nothing new.
|
|
35
|
+
A promotion, re-fork or rebuild, which delete rows of the branch, hold the copy fence
|
|
36
|
+
(`copy_fence`) against batches; a re-fork (another fork point) restarts the copy from the first
|
|
37
|
+
row. A batch that waits longer than `commit.lock_timeout_ms` for the branch lock, three times,
|
|
38
|
+
queues the job again after `job_retry_seconds`.
|
|
39
|
+
|
|
40
|
+
**Build.** A branch whose rows cannot come from an overlay's base (a large copy of an overlay
|
|
41
|
+
or of an older Revision, an overlay detached from its base) is `building`: its rows are
|
|
42
|
+
written from the tree of its head (strategy `tree`: `clear`, `tree_history`, `tree_rows`,
|
|
43
|
+
`derived`, `paths`, then `switch` to `full`). Meanwhile reads of its head are served from the
|
|
44
|
+
tree, and commits fail with `BranchMaterializingError` (retry when it is done).
|
|
45
|
+
|
|
46
|
+
**Failures.** A copy whose job fails `store.jobs.max_attempts` times is queued again after a
|
|
47
|
+
delay doubling from `job_retry_seconds` (at most `copy_retry_max_seconds`); after
|
|
48
|
+
`copy_max_failures` failed jobs, an overlay is detached from its base and built from its tree
|
|
49
|
+
instead (outcome `fallback`), so it no longer holds up the deletion of its base. A build that
|
|
50
|
+
fails as often is marked `failed` (outcome `given_up`); queuing it again resumes it.
|
|
51
|
+
|
|
52
|
+
**Delete.** `delete_branch` deletes a branch holding at most `async_delete_min_rows` rows in
|
|
53
|
+
the caller's transaction (promoting overlays based on it first). A larger one is hidden at once
|
|
54
|
+
(`refs.deleted_at`): reads, listings and commits no longer find it, its queued index builds and
|
|
55
|
+
copies are cancelled, and a `store.branch_delete` job deletes its rows in batches, then the
|
|
56
|
+
ref. The name stays taken until then. Overlays based on it keep reading its rows: their copies
|
|
57
|
+
are queued, and the deletion waits for them before it deletes a row.
|
|
58
|
+
|
|
59
|
+
At most `job_concurrency` of these jobs run at once across workers (session advisory locks);
|
|
60
|
+
a job that finds every slot taken, or that waits, queues itself again after
|
|
61
|
+
`job_retry_seconds` and succeeds.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import asyncio
|
|
67
|
+
import json
|
|
68
|
+
import logging
|
|
69
|
+
import time
|
|
70
|
+
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
|
71
|
+
from contextlib import asynccontextmanager
|
|
72
|
+
from dataclasses import dataclass, replace
|
|
73
|
+
from datetime import UTC, datetime, timedelta
|
|
74
|
+
from enum import StrEnum
|
|
75
|
+
from typing import Any
|
|
76
|
+
|
|
77
|
+
from sqlalchemy import Table, text
|
|
78
|
+
from sqlalchemy.exc import DBAPIError
|
|
79
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
80
|
+
|
|
81
|
+
from lorebase_core.tree import PARTITION_SCHEMA
|
|
82
|
+
from lorebase_store.indexes import BranchIndexes, sql_literal
|
|
83
|
+
from lorebase_store.jobs import BRANCH_DELETE_JOB, MATERIALIZE_JOB, JobRow, enqueue_job
|
|
84
|
+
from lorebase_store.layout import MAT_HISTORY, MAT_LINKS, MAT_RECORDS
|
|
85
|
+
from lorebase_store.materialize import build
|
|
86
|
+
from lorebase_store.materialize.overlay import promote_overlays_of
|
|
87
|
+
from lorebase_store.materialize.stale import queue_if_stale
|
|
88
|
+
from lorebase_store.models.enums import Materialization
|
|
89
|
+
from lorebase_store.nodestore import NodeRepository
|
|
90
|
+
from lorebase_store.repositories.materializations import (
|
|
91
|
+
BranchMaterializationRepository,
|
|
92
|
+
MaterializationPhase,
|
|
93
|
+
MaterializationState,
|
|
94
|
+
MaterializationStatus,
|
|
95
|
+
MaterializationStrategy,
|
|
96
|
+
copy_fence,
|
|
97
|
+
)
|
|
98
|
+
from lorebase_store.repositories.materialized import MaterializedRepository
|
|
99
|
+
from lorebase_store.repositories.refs import (
|
|
100
|
+
LockTimeoutError,
|
|
101
|
+
RefRepository,
|
|
102
|
+
RefRow,
|
|
103
|
+
advisory_lock_key,
|
|
104
|
+
sqlstate,
|
|
105
|
+
)
|
|
106
|
+
from lorebase_store.repositories.revisions import RevisionRepository, RevisionRow
|
|
107
|
+
from lorebase_store.repositories.scope import scope_to_domain
|
|
108
|
+
from lorebase_store.runtime import StoreRuntime, default_runtime
|
|
109
|
+
from lorebase_store.settings import MaterializeSettings
|
|
110
|
+
from lorebase_store.storage import constraints_only
|
|
111
|
+
from lorebase_store.telemetry import (
|
|
112
|
+
BRANCH_JOB_BATCH_DURATION,
|
|
113
|
+
BRANCH_JOB_BATCH_ROWS,
|
|
114
|
+
BRANCH_JOB_ROWS,
|
|
115
|
+
BRANCH_JOB_RUNS,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
log = logging.getLogger(__name__)
|
|
119
|
+
|
|
120
|
+
Clock = Callable[[], datetime]
|
|
121
|
+
|
|
122
|
+
_RETRYABLE = frozenset({"40P01", "40001"})
|
|
123
|
+
"""Deadlock and serialization failures: the batch is run again."""
|
|
124
|
+
_ATTEMPTS = 3
|
|
125
|
+
"""Runs of a batch that hits a retryable failure, or of a switch that finds the branch busy,
|
|
126
|
+
before the job queues itself again."""
|
|
127
|
+
_CONTENDED_FRACTION = 10
|
|
128
|
+
"""A batch that waited for the branch lock longer than `job_batch_contended_ms` divided by
|
|
129
|
+
this found a commit holding it (an uncontended lock is taken in well under that)."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _now() -> datetime:
|
|
133
|
+
return datetime.now(UTC)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class BranchDeletion(StrEnum):
|
|
137
|
+
"""How `delete_branch` deleted a branch."""
|
|
138
|
+
|
|
139
|
+
DELETED = "deleted"
|
|
140
|
+
"""Ref and rows are gone."""
|
|
141
|
+
SCHEDULED = "scheduled"
|
|
142
|
+
"""The branch is hidden; a `store.branch_delete` job deletes its rows."""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True, slots=True)
|
|
146
|
+
class _Copied:
|
|
147
|
+
"""A table a background copy reads, in primary key order after (domain, branch)."""
|
|
148
|
+
|
|
149
|
+
phase: MaterializationPhase
|
|
150
|
+
table: Table
|
|
151
|
+
key: tuple[str, ...]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
_COPIED = (
|
|
155
|
+
_Copied(MaterializationPhase.RECORDS, MAT_RECORDS, ("kind_id", "key")),
|
|
156
|
+
_Copied(MaterializationPhase.LINKS, MAT_LINKS, ("relation_id", "from_key", "to_key")),
|
|
157
|
+
_Copied(MaterializationPhase.HISTORY, MAT_HISTORY, ("partition", "element_key", "revision_id")),
|
|
158
|
+
)
|
|
159
|
+
_NEXT = {
|
|
160
|
+
MaterializationPhase.RECORDS: MaterializationPhase.LINKS,
|
|
161
|
+
MaterializationPhase.LINKS: MaterializationPhase.HISTORY,
|
|
162
|
+
MaterializationPhase.HISTORY: MaterializationPhase.SWITCH,
|
|
163
|
+
}
|
|
164
|
+
_NEXT_BUILD = {
|
|
165
|
+
MaterializationPhase.CLEAR: MaterializationPhase.TREE_HISTORY,
|
|
166
|
+
MaterializationPhase.TREE_HISTORY: MaterializationPhase.TREE_ROWS,
|
|
167
|
+
MaterializationPhase.TREE_ROWS: MaterializationPhase.DERIVED,
|
|
168
|
+
MaterializationPhase.DERIVED: MaterializationPhase.PATHS,
|
|
169
|
+
MaterializationPhase.PATHS: MaterializationPhase.SWITCH,
|
|
170
|
+
}
|
|
171
|
+
_NEXT_RECONCILE = {
|
|
172
|
+
MaterializationPhase.PRUNE: MaterializationPhase.DERIVED,
|
|
173
|
+
MaterializationPhase.DERIVED: MaterializationPhase.PATHS,
|
|
174
|
+
MaterializationPhase.PATHS: MaterializationPhase.SEARCH,
|
|
175
|
+
MaterializationPhase.SEARCH: MaterializationPhase.DONE,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# Queueing ---------------------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def queue_materialization(
|
|
183
|
+
session: AsyncSession, domain_id: str, ref: RefRow, *, at: datetime
|
|
184
|
+
) -> MaterializationState:
|
|
185
|
+
"""Queue the background copy of the overlay `ref` (in the caller's transaction): its
|
|
186
|
+
progress row starts `pending` (a failed copy of the same fork point keeps its cursor)
|
|
187
|
+
unless a copy is already queued or running, and a `store.materialize` job is queued unless
|
|
188
|
+
one is."""
|
|
189
|
+
if ref.base_branch is None or ref.base_generation is None:
|
|
190
|
+
raise ValueError(f"branch {ref.name!r} is not an overlay")
|
|
191
|
+
states = BranchMaterializationRepository(session, domain_id)
|
|
192
|
+
state = await states.get(ref.name, for_update=True)
|
|
193
|
+
same_fork = (
|
|
194
|
+
state is not None
|
|
195
|
+
and state.strategy is MaterializationStrategy.BASE
|
|
196
|
+
and (state.base_branch, state.base_generation) == (ref.base_branch, ref.base_generation)
|
|
197
|
+
)
|
|
198
|
+
if state is not None and same_fork and state.status is MaterializationStatus.FAILED:
|
|
199
|
+
await states.update(ref.name, at=at, status=MaterializationStatus.PENDING)
|
|
200
|
+
state = await states.get(ref.name)
|
|
201
|
+
assert state is not None
|
|
202
|
+
elif state is None or not same_fork or not state.active:
|
|
203
|
+
state = await states.start(
|
|
204
|
+
ref.name,
|
|
205
|
+
base_branch=ref.base_branch,
|
|
206
|
+
base_generation=ref.base_generation,
|
|
207
|
+
status=MaterializationStatus.PENDING,
|
|
208
|
+
at=at,
|
|
209
|
+
)
|
|
210
|
+
await enqueue_job(
|
|
211
|
+
session, MATERIALIZE_JOB, domain_id=domain_id, payload={"branch": ref.name}, unique=True
|
|
212
|
+
)
|
|
213
|
+
return state
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def queue_build(
|
|
217
|
+
session: AsyncSession,
|
|
218
|
+
domain_id: str,
|
|
219
|
+
branch: str,
|
|
220
|
+
*,
|
|
221
|
+
source: str,
|
|
222
|
+
head: RevisionRow,
|
|
223
|
+
at: datetime,
|
|
224
|
+
failures: int = 0,
|
|
225
|
+
unique: bool = True,
|
|
226
|
+
) -> MaterializationState:
|
|
227
|
+
"""Queue the build of the `building` branch `branch` from the tree of its head (in the
|
|
228
|
+
caller's transaction); `source` is recorded as where it came from."""
|
|
229
|
+
state = await BranchMaterializationRepository(session, domain_id).start(
|
|
230
|
+
branch,
|
|
231
|
+
base_branch=source,
|
|
232
|
+
base_generation=head.generation,
|
|
233
|
+
status=MaterializationStatus.PENDING,
|
|
234
|
+
strategy=MaterializationStrategy.TREE,
|
|
235
|
+
failures=failures,
|
|
236
|
+
at=at,
|
|
237
|
+
)
|
|
238
|
+
await enqueue_job(
|
|
239
|
+
session, MATERIALIZE_JOB, domain_id=domain_id, payload={"branch": branch}, unique=unique
|
|
240
|
+
)
|
|
241
|
+
return state
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def detach_to_build(
|
|
245
|
+
session: AsyncSession,
|
|
246
|
+
domain_id: str,
|
|
247
|
+
branch: str,
|
|
248
|
+
*,
|
|
249
|
+
runtime: StoreRuntime,
|
|
250
|
+
at: datetime,
|
|
251
|
+
failures: int = 0,
|
|
252
|
+
unique: bool = True,
|
|
253
|
+
) -> bool:
|
|
254
|
+
"""Detach the overlay `branch` from its base, in the caller's transaction: it becomes
|
|
255
|
+
`building` (no base, head and version unchanged) and a job writes its rows from the tree
|
|
256
|
+
of its head (`queue_build`). Its base no longer waits for it. Takes the branch lock (after
|
|
257
|
+
the base's, when the caller holds it) and the copy fence. `False` when it is not an
|
|
258
|
+
overlay."""
|
|
259
|
+
refs = RefRepository(session, domain_id)
|
|
260
|
+
await refs.lock(branch, timeout_ms=runtime.settings.commit.lock_timeout_ms)
|
|
261
|
+
await copy_fence(session, domain_id, branch)
|
|
262
|
+
ref = await refs.get(branch)
|
|
263
|
+
if ref is None or ref.materialization is not Materialization.OVERLAY:
|
|
264
|
+
return False
|
|
265
|
+
head = await RevisionRepository(session, domain_id).require(ref.head)
|
|
266
|
+
await refs.set_materialization(branch, Materialization.BUILDING)
|
|
267
|
+
await queue_build(
|
|
268
|
+
session,
|
|
269
|
+
domain_id,
|
|
270
|
+
branch,
|
|
271
|
+
source=ref.base_branch or branch,
|
|
272
|
+
head=head,
|
|
273
|
+
at=at,
|
|
274
|
+
failures=failures,
|
|
275
|
+
unique=unique,
|
|
276
|
+
)
|
|
277
|
+
log.warning(
|
|
278
|
+
"overlay branch %s of Domain %s detached from its base %s; it is rebuilt from its tree",
|
|
279
|
+
branch,
|
|
280
|
+
domain_id,
|
|
281
|
+
ref.base_branch,
|
|
282
|
+
)
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def delete_branch(
|
|
287
|
+
session: AsyncSession,
|
|
288
|
+
domain_id: str,
|
|
289
|
+
name: str,
|
|
290
|
+
*,
|
|
291
|
+
runtime: StoreRuntime | None = None,
|
|
292
|
+
clock: Clock | None = None,
|
|
293
|
+
) -> BranchDeletion:
|
|
294
|
+
"""Delete branch `name` (see the module doc) in the caller's transaction, after taking its
|
|
295
|
+
lock. Raises `RefNotFoundError` when it does not exist (or is already being deleted)."""
|
|
296
|
+
runtime = runtime or default_runtime()
|
|
297
|
+
now = (clock or _now)()
|
|
298
|
+
settings = runtime.settings.materialize
|
|
299
|
+
refs = RefRepository(session, domain_id)
|
|
300
|
+
await refs.lock(name, timeout_ms=runtime.settings.commit.lock_timeout_ms)
|
|
301
|
+
ref = await refs.require(name)
|
|
302
|
+
rows = MaterializedRepository(
|
|
303
|
+
session, domain_id, name, writes=runtime.settings.writes, layout=ref.layout
|
|
304
|
+
)
|
|
305
|
+
if await rows.own_rows(limit=settings.async_delete_min_rows) <= settings.async_delete_min_rows:
|
|
306
|
+
await promote_overlays_of(session, domain_id, name, runtime=runtime)
|
|
307
|
+
await refs.delete(name)
|
|
308
|
+
return BranchDeletion.DELETED
|
|
309
|
+
overlays = await refs.overlays_of(name)
|
|
310
|
+
await refs.mark_deleted(name, at=now)
|
|
311
|
+
for overlay in overlays:
|
|
312
|
+
await queue_materialization(session, domain_id, overlay, at=now)
|
|
313
|
+
await enqueue_job(
|
|
314
|
+
session, BRANCH_DELETE_JOB, domain_id=domain_id, payload={"branch": name}, unique=True
|
|
315
|
+
)
|
|
316
|
+
log.info(
|
|
317
|
+
"branch %s of Domain %s hidden; its rows are deleted in the background", name, domain_id
|
|
318
|
+
)
|
|
319
|
+
return BranchDeletion.SCHEDULED
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# Jobs -------------------------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass
|
|
326
|
+
class _Pace:
|
|
327
|
+
"""Rows of the next batch, adapted to the time batches hold the branch lock: halved
|
|
328
|
+
toward the target when a batch held it too long, doubled when it held it for less than
|
|
329
|
+
half the target. The target is `job_batch_contended_ms` after a commit waited for the
|
|
330
|
+
lock (before or during the batch), `job_batch_target_ms` otherwise."""
|
|
331
|
+
|
|
332
|
+
low: int
|
|
333
|
+
high: int
|
|
334
|
+
target_s: float
|
|
335
|
+
contended_s: float
|
|
336
|
+
size: int
|
|
337
|
+
|
|
338
|
+
@classmethod
|
|
339
|
+
def of(cls, settings: MaterializeSettings) -> _Pace:
|
|
340
|
+
low = min(settings.job_batch_min_rows, settings.job_batch_rows)
|
|
341
|
+
return cls(
|
|
342
|
+
low=low,
|
|
343
|
+
high=settings.job_batch_rows,
|
|
344
|
+
target_s=settings.job_batch_target_ms / 1000,
|
|
345
|
+
contended_s=settings.job_batch_contended_ms / 1000,
|
|
346
|
+
size=low,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
def contended(self, waited_s: float, waiters: int) -> bool:
|
|
350
|
+
return waiters > 0 or waited_s > self.contended_s / _CONTENDED_FRACTION
|
|
351
|
+
|
|
352
|
+
def next(self, held_s: float, *, contended: bool) -> None:
|
|
353
|
+
aim = self.contended_s if contended else self.target_s
|
|
354
|
+
if held_s > aim:
|
|
355
|
+
self.size = max(self.low, int(self.size * aim / held_s))
|
|
356
|
+
elif held_s < aim / 2:
|
|
357
|
+
self.size = min(self.high, self.size * 2)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@dataclass
|
|
361
|
+
class _Run:
|
|
362
|
+
"""What one job run did, for its result."""
|
|
363
|
+
|
|
364
|
+
job: str
|
|
365
|
+
branch: str
|
|
366
|
+
pace: _Pace
|
|
367
|
+
batches: int = 0
|
|
368
|
+
rows: int = 0
|
|
369
|
+
promoted: bool = False
|
|
370
|
+
|
|
371
|
+
def done(self, outcome: str, **extra: Any) -> dict[str, Any]:
|
|
372
|
+
BRANCH_JOB_RUNS.add(1, {"job": self.job, "outcome": outcome})
|
|
373
|
+
return {
|
|
374
|
+
"branch": self.branch,
|
|
375
|
+
"status": outcome,
|
|
376
|
+
"batches": self.batches,
|
|
377
|
+
"rows": self.rows,
|
|
378
|
+
**extra,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@dataclass(frozen=True, slots=True)
|
|
383
|
+
class _Step:
|
|
384
|
+
"""One batch transaction: rows it read, or the job's result when it was the last; how
|
|
385
|
+
long it waited for the branch lock and whether a commit waited for it."""
|
|
386
|
+
|
|
387
|
+
rows: int = 0
|
|
388
|
+
finished: dict[str, Any] | None = None
|
|
389
|
+
again: bool = False
|
|
390
|
+
waited_s: float = 0.0
|
|
391
|
+
contended: bool = False
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _payload_branch(job: JobRow) -> tuple[str, str]:
|
|
395
|
+
if job.domain_id is None:
|
|
396
|
+
raise ValueError(f"a {job.type} job needs a Domain")
|
|
397
|
+
branch = job.payload.get("branch")
|
|
398
|
+
if not isinstance(branch, str) or not branch:
|
|
399
|
+
raise ValueError(f"a {job.type} job needs a `branch`")
|
|
400
|
+
return job.domain_id, branch
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class _Slot(StrEnum):
|
|
404
|
+
ACQUIRED = "acquired"
|
|
405
|
+
BUSY = "busy"
|
|
406
|
+
"""Every one of `job_concurrency` slots is taken."""
|
|
407
|
+
RUNNING = "running"
|
|
408
|
+
"""Another job works on the same branch."""
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
_TRY_LOCK = text("SELECT pg_try_advisory_lock(:key)")
|
|
412
|
+
_UNLOCK_ALL = text("SELECT pg_advisory_unlock_all()")
|
|
413
|
+
_WAITERS = text(
|
|
414
|
+
"SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND NOT granted "
|
|
415
|
+
"AND classid = CAST((CAST(:key AS bigint) >> 32) & 4294967295 AS oid) "
|
|
416
|
+
"AND objid = CAST(CAST(:key AS bigint) & 4294967295 AS oid) AND objsubid = 1"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@asynccontextmanager
|
|
421
|
+
async def _slot(
|
|
422
|
+
engine: AsyncEngine, domain_id: str, branch: str, settings: MaterializeSettings
|
|
423
|
+
) -> AsyncIterator[_Slot]:
|
|
424
|
+
"""Hold, on a connection of its own, the branch's job lock and one of the
|
|
425
|
+
`job_concurrency` slots shared by every worker (session advisory locks: a worker that
|
|
426
|
+
dies releases them with its connection)."""
|
|
427
|
+
async with engine.connect() as connection:
|
|
428
|
+
auto = await connection.execution_options(isolation_level="AUTOCOMMIT")
|
|
429
|
+
try:
|
|
430
|
+
branch_key = advisory_lock_key(domain_id, f"\x00branch-job\x00{branch}")
|
|
431
|
+
if not (await auto.execute(_TRY_LOCK, {"key": branch_key})).scalar_one():
|
|
432
|
+
yield _Slot.RUNNING
|
|
433
|
+
return
|
|
434
|
+
for slot in range(settings.job_concurrency):
|
|
435
|
+
key = advisory_lock_key("", f"\x00branch-job-slot\x00{slot}")
|
|
436
|
+
if (await auto.execute(_TRY_LOCK, {"key": key})).scalar_one():
|
|
437
|
+
yield _Slot.ACQUIRED
|
|
438
|
+
return
|
|
439
|
+
yield _Slot.BUSY
|
|
440
|
+
finally:
|
|
441
|
+
await auto.execute(_UNLOCK_ALL)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def _again(
|
|
445
|
+
engine: AsyncEngine,
|
|
446
|
+
job: JobRow,
|
|
447
|
+
settings: MaterializeSettings,
|
|
448
|
+
clock: Clock,
|
|
449
|
+
*,
|
|
450
|
+
delay_s: float | None = None,
|
|
451
|
+
) -> int:
|
|
452
|
+
"""Queue the job once more after `job_retry_seconds` (or `delay_s`); the running one then
|
|
453
|
+
succeeds (or fails)."""
|
|
454
|
+
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
|
455
|
+
delay = settings.job_retry_seconds if delay_s is None else delay_s
|
|
456
|
+
async with sessions() as session, session.begin():
|
|
457
|
+
return await enqueue_job(
|
|
458
|
+
session,
|
|
459
|
+
job.type,
|
|
460
|
+
domain_id=job.domain_id,
|
|
461
|
+
payload=job.payload,
|
|
462
|
+
priority=job.priority,
|
|
463
|
+
run_after=clock() + timedelta(seconds=delay),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
async def _lock_branch(
|
|
468
|
+
session: AsyncSession, refs: RefRepository, branch: str, runtime: StoreRuntime
|
|
469
|
+
) -> float:
|
|
470
|
+
"""Take the branch lock; returns how long it took."""
|
|
471
|
+
started = time.perf_counter()
|
|
472
|
+
await refs.lock(branch, timeout_ms=runtime.settings.commit.lock_timeout_ms)
|
|
473
|
+
return time.perf_counter() - started
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
async def _waiters(session: AsyncSession, domain_id: str, branch: str) -> int:
|
|
477
|
+
"""Transactions waiting for the branch lock this transaction holds (commits)."""
|
|
478
|
+
key = advisory_lock_key(domain_id, branch)
|
|
479
|
+
return int((await session.execute(_WAITERS, {"key": key})).scalar_one())
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
async def _batches(
|
|
483
|
+
engine: AsyncEngine,
|
|
484
|
+
job: JobRow,
|
|
485
|
+
run: _Run,
|
|
486
|
+
step: Callable[[AsyncSession], Any],
|
|
487
|
+
settings: MaterializeSettings,
|
|
488
|
+
clock: Clock,
|
|
489
|
+
) -> dict[str, Any]:
|
|
490
|
+
"""Run `step` in one transaction after another until it finishes the job, sizing each
|
|
491
|
+
batch by the time the previous one held the branch lock (`_Pace`)."""
|
|
492
|
+
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
|
493
|
+
failures = 0
|
|
494
|
+
while True:
|
|
495
|
+
started = time.perf_counter()
|
|
496
|
+
try:
|
|
497
|
+
async with sessions() as session, session.begin():
|
|
498
|
+
outcome: _Step = await step(session)
|
|
499
|
+
except LockTimeoutError:
|
|
500
|
+
failures += 1
|
|
501
|
+
run.pace.next(float("inf"), contended=True)
|
|
502
|
+
if failures >= _ATTEMPTS:
|
|
503
|
+
queued = await _again(engine, job, settings, clock)
|
|
504
|
+
return run.done("deferred", detail="the branch stayed locked", again=queued)
|
|
505
|
+
await asyncio.sleep(min(settings.job_retry_seconds, 1.0))
|
|
506
|
+
continue
|
|
507
|
+
except DBAPIError as error:
|
|
508
|
+
failures += 1
|
|
509
|
+
if sqlstate(error) not in _RETRYABLE or failures >= _ATTEMPTS:
|
|
510
|
+
raise
|
|
511
|
+
log.info("%s job on branch %s: batch retried after %s", run.job, run.branch, error)
|
|
512
|
+
continue
|
|
513
|
+
elapsed = time.perf_counter() - started
|
|
514
|
+
BRANCH_JOB_BATCH_DURATION.record(elapsed, {"job": run.job})
|
|
515
|
+
failures = 0
|
|
516
|
+
if outcome.rows:
|
|
517
|
+
BRANCH_JOB_BATCH_ROWS.record(run.pace.size, {"job": run.job})
|
|
518
|
+
run.batches += 1
|
|
519
|
+
run.rows += outcome.rows
|
|
520
|
+
BRANCH_JOB_ROWS.add(outcome.rows, {"job": run.job})
|
|
521
|
+
run.pace.next(max(elapsed - outcome.waited_s, 0.0), contended=outcome.contended)
|
|
522
|
+
if outcome.finished is not None:
|
|
523
|
+
if outcome.again:
|
|
524
|
+
outcome.finished["again"] = await _again(engine, job, settings, clock)
|
|
525
|
+
return outcome.finished
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
async def run_materialize_job(
|
|
529
|
+
engine: AsyncEngine,
|
|
530
|
+
job: JobRow,
|
|
531
|
+
*,
|
|
532
|
+
runtime: StoreRuntime | None = None,
|
|
533
|
+
clock: Clock | None = None,
|
|
534
|
+
) -> Mapping[str, Any]:
|
|
535
|
+
"""Job handler of `store.materialize`: make the branch named by the payload a full copy
|
|
536
|
+
in batches, from its base (an overlay) or from its tree (a `building` branch), then
|
|
537
|
+
reconcile schema changes made meanwhile (module doc). Resumes where a previous run
|
|
538
|
+
stopped.
|
|
539
|
+
|
|
540
|
+
The result has `promoted` (whether this run made the branch a full copy) and `status`:
|
|
541
|
+
`done`, `deferred` (no free slot; queued again), `skipped` (another job copies it) or
|
|
542
|
+
`cancelled` (the branch is gone or being deleted).
|
|
543
|
+
"""
|
|
544
|
+
runtime = runtime or default_runtime()
|
|
545
|
+
now = clock or _now
|
|
546
|
+
domain_id, branch = _payload_branch(job)
|
|
547
|
+
settings = runtime.settings.materialize
|
|
548
|
+
run = _Run("materialize", branch, _Pace.of(settings))
|
|
549
|
+
async with _slot(engine, domain_id, branch, settings) as slot:
|
|
550
|
+
if slot is _Slot.RUNNING:
|
|
551
|
+
return run.done("skipped", promoted=False, detail="another job copies the branch")
|
|
552
|
+
if slot is _Slot.BUSY:
|
|
553
|
+
queued = await _again(engine, job, settings, now)
|
|
554
|
+
return run.done("deferred", promoted=False, detail="no free slot", again=queued)
|
|
555
|
+
|
|
556
|
+
async def step(session: AsyncSession) -> _Step:
|
|
557
|
+
return await _materialize_step(session, domain_id, branch, run, runtime, now)
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
return await _batches(engine, job, run, step, settings, now)
|
|
561
|
+
except Exception as error:
|
|
562
|
+
BRANCH_JOB_RUNS.add(1, {"job": run.job, "outcome": "failed"})
|
|
563
|
+
final = job.attempts >= runtime.settings.jobs.max_attempts
|
|
564
|
+
await _record_failure(engine, job, error, final=final, runtime=runtime, clock=now)
|
|
565
|
+
raise
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
async def _record_failure(
|
|
569
|
+
engine: AsyncEngine,
|
|
570
|
+
job: JobRow,
|
|
571
|
+
error: Exception,
|
|
572
|
+
*,
|
|
573
|
+
final: bool,
|
|
574
|
+
runtime: StoreRuntime,
|
|
575
|
+
clock: Clock,
|
|
576
|
+
) -> None:
|
|
577
|
+
"""Keep the error on the progress row. Once the job has no attempt left: queue the copy
|
|
578
|
+
again with backoff while it has failed fewer than `copy_max_failures` times; then detach
|
|
579
|
+
an overlay and build it from its tree; a build is marked `failed`."""
|
|
580
|
+
domain_id, branch = _payload_branch(job)
|
|
581
|
+
settings = runtime.settings.materialize
|
|
582
|
+
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
|
583
|
+
message = f"{type(error).__name__}: {error}"[:2000]
|
|
584
|
+
try:
|
|
585
|
+
async with sessions() as session, session.begin():
|
|
586
|
+
await scope_to_domain(session, domain_id)
|
|
587
|
+
states = BranchMaterializationRepository(session, domain_id)
|
|
588
|
+
state = await states.get(branch, for_update=True)
|
|
589
|
+
if state is None:
|
|
590
|
+
return
|
|
591
|
+
if not final:
|
|
592
|
+
await states.update(
|
|
593
|
+
branch, at=clock(), status=MaterializationStatus.PENDING, error=message
|
|
594
|
+
)
|
|
595
|
+
return
|
|
596
|
+
failures = state.failures + 1
|
|
597
|
+
fallback = failures >= settings.copy_max_failures
|
|
598
|
+
if not fallback:
|
|
599
|
+
delay = min(
|
|
600
|
+
settings.job_retry_seconds * 2**failures, settings.copy_retry_max_seconds
|
|
601
|
+
)
|
|
602
|
+
await states.update(
|
|
603
|
+
branch,
|
|
604
|
+
at=clock(),
|
|
605
|
+
status=MaterializationStatus.PENDING,
|
|
606
|
+
error=message,
|
|
607
|
+
failures=failures,
|
|
608
|
+
)
|
|
609
|
+
await enqueue_job(
|
|
610
|
+
session,
|
|
611
|
+
MATERIALIZE_JOB,
|
|
612
|
+
domain_id=domain_id,
|
|
613
|
+
payload=job.payload,
|
|
614
|
+
priority=job.priority,
|
|
615
|
+
run_after=clock() + timedelta(seconds=delay),
|
|
616
|
+
)
|
|
617
|
+
BRANCH_JOB_RUNS.add(1, {"job": "materialize", "outcome": "retried"})
|
|
618
|
+
log.warning(
|
|
619
|
+
"copy of branch %s of Domain %s failed (%d of %d): retried in %.0f s",
|
|
620
|
+
branch,
|
|
621
|
+
domain_id,
|
|
622
|
+
failures,
|
|
623
|
+
settings.copy_max_failures,
|
|
624
|
+
delay,
|
|
625
|
+
)
|
|
626
|
+
return
|
|
627
|
+
# Only a copy from the base can fall back to a build; a build, or the
|
|
628
|
+
# reconciliation of a branch already full, has nothing else to try.
|
|
629
|
+
detach = (
|
|
630
|
+
state.strategy is MaterializationStrategy.BASE
|
|
631
|
+
and state.phase not in _NEXT_RECONCILE
|
|
632
|
+
)
|
|
633
|
+
status = MaterializationStatus.PENDING if detach else MaterializationStatus.FAILED
|
|
634
|
+
await states.update(branch, at=clock(), status=status, error=message, failures=failures)
|
|
635
|
+
if detach:
|
|
636
|
+
await _fall_back(engine, job, failures=failures, runtime=runtime, clock=clock)
|
|
637
|
+
return
|
|
638
|
+
BRANCH_JOB_RUNS.add(1, {"job": "materialize", "outcome": "given_up"})
|
|
639
|
+
log.error(
|
|
640
|
+
"build of branch %s of Domain %s failed %d times; queue a store.materialize job "
|
|
641
|
+
"for it again once the cause is fixed: %s",
|
|
642
|
+
branch,
|
|
643
|
+
domain_id,
|
|
644
|
+
failures,
|
|
645
|
+
message,
|
|
646
|
+
)
|
|
647
|
+
except Exception:
|
|
648
|
+
log.exception("could not record the failure of the copy of branch %s", branch)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
async def _fall_back(
|
|
652
|
+
engine: AsyncEngine, job: JobRow, *, failures: int, runtime: StoreRuntime, clock: Clock
|
|
653
|
+
) -> None:
|
|
654
|
+
"""Detach the overlay whose copy kept failing (`detach_to_build`); when its lock stays
|
|
655
|
+
taken, queue the copy again so that its next failure tries once more."""
|
|
656
|
+
domain_id, branch = _payload_branch(job)
|
|
657
|
+
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
|
658
|
+
try:
|
|
659
|
+
async with sessions() as session, session.begin():
|
|
660
|
+
await scope_to_domain(session, domain_id)
|
|
661
|
+
detached = await detach_to_build(
|
|
662
|
+
session,
|
|
663
|
+
domain_id,
|
|
664
|
+
branch,
|
|
665
|
+
runtime=runtime,
|
|
666
|
+
at=clock(),
|
|
667
|
+
failures=failures,
|
|
668
|
+
unique=False,
|
|
669
|
+
)
|
|
670
|
+
except LockTimeoutError:
|
|
671
|
+
await _again(engine, job, runtime.settings.materialize, clock)
|
|
672
|
+
return
|
|
673
|
+
if not detached:
|
|
674
|
+
# No longer an overlay (made full or deleted meanwhile): nothing left to fall back from.
|
|
675
|
+
async with sessions() as session, session.begin():
|
|
676
|
+
await scope_to_domain(session, domain_id)
|
|
677
|
+
states = BranchMaterializationRepository(session, domain_id)
|
|
678
|
+
state = await states.get(branch, for_update=True)
|
|
679
|
+
if state is not None and state.active:
|
|
680
|
+
await states.update(branch, at=clock(), status=MaterializationStatus.FAILED)
|
|
681
|
+
BRANCH_JOB_RUNS.add(1, {"job": "materialize", "outcome": "given_up"})
|
|
682
|
+
return
|
|
683
|
+
BRANCH_JOB_RUNS.add(1, {"job": "materialize", "outcome": "fallback"})
|
|
684
|
+
log.error(
|
|
685
|
+
"copy of overlay branch %s of Domain %s failed %d times: detached from its base and "
|
|
686
|
+
"rebuilt from its tree",
|
|
687
|
+
branch,
|
|
688
|
+
domain_id,
|
|
689
|
+
failures,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
async def _materialize_step(
|
|
694
|
+
session: AsyncSession,
|
|
695
|
+
domain_id: str,
|
|
696
|
+
branch: str,
|
|
697
|
+
run: _Run,
|
|
698
|
+
runtime: StoreRuntime,
|
|
699
|
+
clock: Clock,
|
|
700
|
+
) -> _Step:
|
|
701
|
+
await scope_to_domain(session, domain_id)
|
|
702
|
+
refs = RefRepository(session, domain_id)
|
|
703
|
+
# The branch lock: no commit on the branch, and no copy on write into it from its base,
|
|
704
|
+
# runs during the batch (their inserts of the same keys could deadlock with it). Then the
|
|
705
|
+
# fence, before reading the ref: a promotion, re-fork or rebuild in progress ends first,
|
|
706
|
+
# and this batch sees what it left.
|
|
707
|
+
waited = await _lock_branch(session, refs, branch, runtime)
|
|
708
|
+
await copy_fence(session, domain_id, branch, shared=True)
|
|
709
|
+
# KEY SHARE: the ref (and the rows that reference it) cannot be deleted meanwhile.
|
|
710
|
+
ref = await refs.get_deleting(branch, key_share=True)
|
|
711
|
+
if ref is None or ref.deleted_at is not None:
|
|
712
|
+
return _Step(
|
|
713
|
+
finished=run.done("cancelled", promoted=False, detail="the branch no longer exists")
|
|
714
|
+
)
|
|
715
|
+
states = BranchMaterializationRepository(session, domain_id)
|
|
716
|
+
state = await states.get(branch, for_update=True)
|
|
717
|
+
if ref.materialization is Materialization.BUILDING:
|
|
718
|
+
outcome = await _build_step(session, domain_id, ref, state, run, runtime, clock)
|
|
719
|
+
elif ref.materialization is Materialization.OVERLAY and ref.base_branch is not None:
|
|
720
|
+
outcome = await _copy_step(session, domain_id, ref, state, run, runtime, clock)
|
|
721
|
+
elif state is not None and state.active and state.phase in _NEXT_RECONCILE:
|
|
722
|
+
outcome = await _reconcile_step(session, domain_id, ref, state, run, runtime, clock)
|
|
723
|
+
else:
|
|
724
|
+
if state is not None and state.status is not MaterializationStatus.DONE:
|
|
725
|
+
await states.finish(branch, at=clock())
|
|
726
|
+
outcome = _Step(finished=run.done("done", promoted=run.promoted))
|
|
727
|
+
if outcome.finished is not None:
|
|
728
|
+
return outcome
|
|
729
|
+
waiters = await _waiters(session, domain_id, branch)
|
|
730
|
+
return replace(outcome, waited_s=waited, contended=run.pace.contended(waited, waiters))
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
async def _copy_step(
|
|
734
|
+
session: AsyncSession,
|
|
735
|
+
domain_id: str,
|
|
736
|
+
ref: RefRow,
|
|
737
|
+
state: MaterializationState | None,
|
|
738
|
+
run: _Run,
|
|
739
|
+
runtime: StoreRuntime,
|
|
740
|
+
clock: Clock,
|
|
741
|
+
) -> _Step:
|
|
742
|
+
"""One batch of the copy of an overlay from its base (strategy `base`)."""
|
|
743
|
+
branch = ref.name
|
|
744
|
+
assert ref.base_branch is not None
|
|
745
|
+
assert ref.base_generation is not None
|
|
746
|
+
states = BranchMaterializationRepository(session, domain_id)
|
|
747
|
+
now = clock()
|
|
748
|
+
if (
|
|
749
|
+
state is None
|
|
750
|
+
or state.strategy is not MaterializationStrategy.BASE
|
|
751
|
+
or (state.base_branch, state.base_generation) != (ref.base_branch, ref.base_generation)
|
|
752
|
+
):
|
|
753
|
+
# First run, or re-forked since: start from the first row of the new fork point.
|
|
754
|
+
state = await states.start(
|
|
755
|
+
branch,
|
|
756
|
+
base_branch=ref.base_branch,
|
|
757
|
+
base_generation=ref.base_generation,
|
|
758
|
+
status=MaterializationStatus.RUNNING,
|
|
759
|
+
failures=0 if state is None else state.failures,
|
|
760
|
+
reconcile=None if state is None else state.reconcile,
|
|
761
|
+
at=now,
|
|
762
|
+
)
|
|
763
|
+
if state.rows_total is None:
|
|
764
|
+
total = await _base_rows(session, domain_id, ref.base_branch)
|
|
765
|
+
await states.update(branch, at=now, rows_total=total)
|
|
766
|
+
if state.status is not MaterializationStatus.RUNNING:
|
|
767
|
+
await states.update(branch, at=now, status=MaterializationStatus.RUNNING)
|
|
768
|
+
if state.phase is MaterializationPhase.SWITCH:
|
|
769
|
+
# The total was an estimate (`_base_rows`); every row has been read now.
|
|
770
|
+
await states.update(branch, at=now, rows_total=state.rows_done)
|
|
771
|
+
await _switch(session, domain_id, branch, runtime)
|
|
772
|
+
run.promoted = True
|
|
773
|
+
log.info("branch %s of Domain %s is now a full copy", branch, domain_id)
|
|
774
|
+
if state.reconcile is not None:
|
|
775
|
+
# Its schema changed while it was copied: recompute in batches what the change
|
|
776
|
+
# recomputes on a full branch.
|
|
777
|
+
await states.update(branch, at=clock(), phase=MaterializationPhase.PRUNE, cursor=None)
|
|
778
|
+
return _Step()
|
|
779
|
+
await states.finish(branch, at=clock())
|
|
780
|
+
return _Step(finished=run.done("done", promoted=True))
|
|
781
|
+
copied = next((c for c in _COPIED if c.phase is state.phase), None)
|
|
782
|
+
if copied is None:
|
|
783
|
+
# A phase of another strategy: start over from the base.
|
|
784
|
+
await states.update(branch, at=now, phase=MaterializationPhase.RECORDS, cursor=None)
|
|
785
|
+
return _Step()
|
|
786
|
+
read, cursor = await _copy_batch(
|
|
787
|
+
session,
|
|
788
|
+
copied,
|
|
789
|
+
domain_id=domain_id,
|
|
790
|
+
branch=branch,
|
|
791
|
+
base=ref.base_branch,
|
|
792
|
+
generation=ref.base_generation,
|
|
793
|
+
after=_decode(copied, state.cursor),
|
|
794
|
+
limit=run.pace.size,
|
|
795
|
+
)
|
|
796
|
+
if cursor is None:
|
|
797
|
+
await states.update(
|
|
798
|
+
branch, at=now, phase=_NEXT[state.phase], cursor=None, rows_done=state.rows_done + read
|
|
799
|
+
)
|
|
800
|
+
else:
|
|
801
|
+
await states.update(
|
|
802
|
+
branch, at=now, cursor=_encode(cursor), rows_done=state.rows_done + read
|
|
803
|
+
)
|
|
804
|
+
return _Step(rows=read)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
async def _tree_entries(
|
|
808
|
+
session: AsyncSession, domain_id: str, head: RevisionRow, runtime: StoreRuntime
|
|
809
|
+
) -> int:
|
|
810
|
+
"""Records and Links in the tree of `head`."""
|
|
811
|
+
settings = runtime.settings
|
|
812
|
+
nodes = NodeRepository(
|
|
813
|
+
session, domain_id, writes=settings.writes, batch=settings.nodes.prefetch_batch
|
|
814
|
+
)
|
|
815
|
+
tree = await nodes.load_tree(head.root)
|
|
816
|
+
return sum(ref.count for name, ref in tree.partitions.items() if name != PARTITION_SCHEMA)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
async def _build_step(
|
|
820
|
+
session: AsyncSession,
|
|
821
|
+
domain_id: str,
|
|
822
|
+
ref: RefRow,
|
|
823
|
+
state: MaterializationState | None,
|
|
824
|
+
run: _Run,
|
|
825
|
+
runtime: StoreRuntime,
|
|
826
|
+
clock: Clock,
|
|
827
|
+
) -> _Step:
|
|
828
|
+
"""One batch of the build of a `building` branch from the tree of its head (strategy
|
|
829
|
+
`tree`)."""
|
|
830
|
+
branch = ref.name
|
|
831
|
+
states = BranchMaterializationRepository(session, domain_id)
|
|
832
|
+
now = clock()
|
|
833
|
+
head = await RevisionRepository(session, domain_id).require(ref.head)
|
|
834
|
+
if state is None or state.strategy is not MaterializationStrategy.TREE:
|
|
835
|
+
state = await states.start(
|
|
836
|
+
branch,
|
|
837
|
+
base_branch=branch if state is None else state.base_branch,
|
|
838
|
+
base_generation=head.generation,
|
|
839
|
+
status=MaterializationStatus.RUNNING,
|
|
840
|
+
strategy=MaterializationStrategy.TREE,
|
|
841
|
+
failures=0 if state is None else state.failures,
|
|
842
|
+
at=now,
|
|
843
|
+
)
|
|
844
|
+
if state.rows_total is None:
|
|
845
|
+
await states.update(
|
|
846
|
+
branch, at=now, rows_total=await _tree_entries(session, domain_id, head, runtime)
|
|
847
|
+
)
|
|
848
|
+
if state.status is not MaterializationStatus.RUNNING:
|
|
849
|
+
await states.update(branch, at=now, status=MaterializationStatus.RUNNING)
|
|
850
|
+
limit = run.pace.size
|
|
851
|
+
phase = state.phase
|
|
852
|
+
if phase is MaterializationPhase.CLEAR:
|
|
853
|
+
batch = await build.clear_batch(session, domain_id, branch, limit=limit)
|
|
854
|
+
elif phase is MaterializationPhase.TREE_HISTORY:
|
|
855
|
+
batch = await build.history_batch(
|
|
856
|
+
session, domain_id, branch, head, state.cursor, limit=limit, runtime=runtime
|
|
857
|
+
)
|
|
858
|
+
elif phase is MaterializationPhase.TREE_ROWS:
|
|
859
|
+
batch = await build.rows_batch(
|
|
860
|
+
session, domain_id, branch, head, state.cursor, limit=limit, runtime=runtime
|
|
861
|
+
)
|
|
862
|
+
elif phase is MaterializationPhase.DERIVED:
|
|
863
|
+
batch = await build.derived_batch(
|
|
864
|
+
session,
|
|
865
|
+
domain_id,
|
|
866
|
+
branch,
|
|
867
|
+
head,
|
|
868
|
+
state.cursor,
|
|
869
|
+
limit=limit,
|
|
870
|
+
runtime=runtime,
|
|
871
|
+
expression_limits=runtime.schema.expression_limits,
|
|
872
|
+
)
|
|
873
|
+
elif phase is MaterializationPhase.PATHS:
|
|
874
|
+
batch = await build.paths_batch(
|
|
875
|
+
session, domain_id, branch, state.cursor, limit=limit, runtime=runtime
|
|
876
|
+
)
|
|
877
|
+
elif phase is MaterializationPhase.SWITCH:
|
|
878
|
+
await _finish_build(session, domain_id, branch, runtime)
|
|
879
|
+
await states.finish(branch, at=clock())
|
|
880
|
+
run.promoted = True
|
|
881
|
+
log.info("branch %s of Domain %s is built from its tree", branch, domain_id)
|
|
882
|
+
return _Step(finished=run.done("done", promoted=True))
|
|
883
|
+
else:
|
|
884
|
+
# A phase of another strategy: start the build over.
|
|
885
|
+
await states.update(branch, at=now, phase=MaterializationPhase.CLEAR, cursor=None)
|
|
886
|
+
return _Step()
|
|
887
|
+
done = state.rows_done + (batch.rows if phase is MaterializationPhase.TREE_ROWS else 0)
|
|
888
|
+
if batch.cursor is None:
|
|
889
|
+
await states.update(branch, at=now, phase=_NEXT_BUILD[phase], cursor=None, rows_done=done)
|
|
890
|
+
else:
|
|
891
|
+
await states.update(branch, at=now, cursor=batch.cursor, rows_done=done)
|
|
892
|
+
return _Step(rows=max(batch.rows, 1))
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
async def _finish_build(
|
|
896
|
+
session: AsyncSession, domain_id: str, branch: str, runtime: StoreRuntime
|
|
897
|
+
) -> None:
|
|
898
|
+
"""Make the built branch `full` and queue its per-Attribute indexes."""
|
|
899
|
+
await RefRepository(session, domain_id).set_materialization(branch, Materialization.FULL)
|
|
900
|
+
await _sync_indexes(session, domain_id, branch, runtime)
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
async def _reconcile_step(
|
|
904
|
+
session: AsyncSession,
|
|
905
|
+
domain_id: str,
|
|
906
|
+
ref: RefRow,
|
|
907
|
+
state: MaterializationState,
|
|
908
|
+
run: _Run,
|
|
909
|
+
runtime: StoreRuntime,
|
|
910
|
+
clock: Clock,
|
|
911
|
+
) -> _Step:
|
|
912
|
+
"""One batch of the reconciliation of schema changes made while the branch was copied."""
|
|
913
|
+
branch = ref.name
|
|
914
|
+
states = BranchMaterializationRepository(session, domain_id)
|
|
915
|
+
now = clock()
|
|
916
|
+
limit = run.pace.size
|
|
917
|
+
phase = state.phase
|
|
918
|
+
if phase is MaterializationPhase.PRUNE:
|
|
919
|
+
batch = await build.prune_batch(
|
|
920
|
+
session,
|
|
921
|
+
domain_id,
|
|
922
|
+
branch,
|
|
923
|
+
state.cursor,
|
|
924
|
+
limit=limit,
|
|
925
|
+
runtime=runtime,
|
|
926
|
+
expression_limits=runtime.schema.expression_limits,
|
|
927
|
+
)
|
|
928
|
+
elif phase is MaterializationPhase.DERIVED:
|
|
929
|
+
head = await RevisionRepository(session, domain_id).require(ref.head)
|
|
930
|
+
batch = await build.derived_batch(
|
|
931
|
+
session,
|
|
932
|
+
domain_id,
|
|
933
|
+
branch,
|
|
934
|
+
head,
|
|
935
|
+
state.cursor,
|
|
936
|
+
limit=limit,
|
|
937
|
+
runtime=runtime,
|
|
938
|
+
expression_limits=runtime.schema.expression_limits,
|
|
939
|
+
)
|
|
940
|
+
elif phase is MaterializationPhase.PATHS:
|
|
941
|
+
batch = await build.paths_batch(
|
|
942
|
+
session, domain_id, branch, state.cursor, limit=limit, runtime=runtime
|
|
943
|
+
)
|
|
944
|
+
else:
|
|
945
|
+
kinds = (state.reconcile or {}).get("refresh_kinds", [])
|
|
946
|
+
batch = await build.search_batch(
|
|
947
|
+
session, domain_id, branch, kinds, state.cursor, limit=limit, runtime=runtime
|
|
948
|
+
)
|
|
949
|
+
if batch.cursor is not None:
|
|
950
|
+
await states.update(branch, at=now, cursor=batch.cursor)
|
|
951
|
+
return _Step(rows=max(batch.rows, 1))
|
|
952
|
+
following = _NEXT_RECONCILE[phase]
|
|
953
|
+
if following is MaterializationPhase.DONE:
|
|
954
|
+
await states.finish(branch, at=now)
|
|
955
|
+
log.info("branch %s of Domain %s reconciled with its schema", branch, domain_id)
|
|
956
|
+
return _Step(finished=run.done("done", promoted=run.promoted, reconciled=True))
|
|
957
|
+
await states.update(branch, at=now, phase=following, cursor=None)
|
|
958
|
+
return _Step(rows=max(batch.rows, 1))
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
async def _base_rows(session: AsyncSession, domain_id: str, base: str) -> int:
|
|
962
|
+
"""Rows a copy reads (the base branch's Records, Links and history entries), as the
|
|
963
|
+
planner estimates them from the table statistics: counting them reads every row of the
|
|
964
|
+
base, which at 10M Records takes longer than the statement timeout. The total feeds the
|
|
965
|
+
progress report only, and is set to the rows read when the copy switches. Rows written
|
|
966
|
+
after the fork point, which the copy skips, are few."""
|
|
967
|
+
total = 0
|
|
968
|
+
for copied in _COPIED:
|
|
969
|
+
# Literals rather than parameters: the estimate is of a plan for these values.
|
|
970
|
+
plan = await session.execute(
|
|
971
|
+
text(
|
|
972
|
+
f"EXPLAIN (FORMAT JSON) SELECT 1 FROM {copied.table.name} "
|
|
973
|
+
f"WHERE domain_id = {sql_literal(domain_id)} AND branch = {sql_literal(base)}"
|
|
974
|
+
)
|
|
975
|
+
)
|
|
976
|
+
found = plan.scalar_one()
|
|
977
|
+
found = json.loads(found) if isinstance(found, str) else found
|
|
978
|
+
total += int(found[0]["Plan"]["Plan Rows"])
|
|
979
|
+
return total
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _quoted(column: str) -> str:
|
|
983
|
+
return f'"{column}"'
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _tuple(prefix: str, size: int) -> str:
|
|
987
|
+
return "(" + ", ".join(f":{prefix}{i}" for i in range(size)) + ")"
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _bind(prefix: str, values: Sequence[Any]) -> dict[str, Any]:
|
|
991
|
+
return {f"{prefix}{i}": value for i, value in enumerate(values)}
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _encode(key: Sequence[Any]) -> list[Any]:
|
|
995
|
+
return [value.hex() if isinstance(value, bytes) else value for value in key]
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _decode(copied: _Copied, cursor: Sequence[Any] | None) -> tuple[Any, ...] | None:
|
|
999
|
+
if not cursor:
|
|
1000
|
+
return None
|
|
1001
|
+
values = list(cursor)
|
|
1002
|
+
if copied.table is MAT_HISTORY:
|
|
1003
|
+
values[-1] = bytes.fromhex(values[-1])
|
|
1004
|
+
return tuple(values)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
async def _copy_batch(
|
|
1008
|
+
session: AsyncSession,
|
|
1009
|
+
copied: _Copied,
|
|
1010
|
+
*,
|
|
1011
|
+
domain_id: str,
|
|
1012
|
+
branch: str,
|
|
1013
|
+
base: str,
|
|
1014
|
+
generation: int,
|
|
1015
|
+
after: tuple[Any, ...] | None,
|
|
1016
|
+
limit: int,
|
|
1017
|
+
) -> tuple[int, tuple[Any, ...] | None]:
|
|
1018
|
+
"""Copy into the overlay the next `limit` base rows after `after`, in key order, that it
|
|
1019
|
+
reads, keeping the rows it holds. One statement: the batch is read along the primary key
|
|
1020
|
+
(`ORDER BY ... LIMIT`), inserted, and its last key returned. Returns the base rows read
|
|
1021
|
+
and the last key, `None` when none is left."""
|
|
1022
|
+
table = copied.table.name
|
|
1023
|
+
key = ", ".join(f"s.{c}" for c in copied.key)
|
|
1024
|
+
params: dict[str, Any] = {
|
|
1025
|
+
"domain": domain_id,
|
|
1026
|
+
"base": base,
|
|
1027
|
+
"branch": branch,
|
|
1028
|
+
"generation": generation,
|
|
1029
|
+
"limit": limit,
|
|
1030
|
+
}
|
|
1031
|
+
start = ""
|
|
1032
|
+
if after is not None:
|
|
1033
|
+
start = f" AND ({key}) > {_tuple('a', len(after))}"
|
|
1034
|
+
params.update(_bind("a", after))
|
|
1035
|
+
if copied.table is MAT_HISTORY:
|
|
1036
|
+
# OFFSET 0: a primary key probe of revisions per row, not a join the planner may
|
|
1037
|
+
# turn into a scan of every Revision of the Domain per row.
|
|
1038
|
+
visible = (
|
|
1039
|
+
" AND EXISTS (SELECT 1 FROM revisions r WHERE r.domain_id = :domain "
|
|
1040
|
+
"AND r.id = s.revision_id AND r.generation <= :generation OFFSET 0)"
|
|
1041
|
+
)
|
|
1042
|
+
else:
|
|
1043
|
+
visible = " AND s.revision_generation <= :generation"
|
|
1044
|
+
columns = [c.name for c in copied.table.columns]
|
|
1045
|
+
selected = ", ".join(
|
|
1046
|
+
f"CAST(:branch AS text) AS {_quoted(c)}"
|
|
1047
|
+
if c == "branch"
|
|
1048
|
+
else f"false AS {_quoted(c)}"
|
|
1049
|
+
if c == "deleted"
|
|
1050
|
+
else f"s.{_quoted(c)}"
|
|
1051
|
+
for c in columns
|
|
1052
|
+
)
|
|
1053
|
+
names = ", ".join(_quoted(c) for c in columns)
|
|
1054
|
+
last = ", ".join(copied.key)
|
|
1055
|
+
descending = ", ".join(f"{c} DESC" for c in copied.key)
|
|
1056
|
+
found = (
|
|
1057
|
+
await session.execute(
|
|
1058
|
+
text(
|
|
1059
|
+
f"WITH batch AS MATERIALIZED (SELECT {selected} FROM {table} s "
|
|
1060
|
+
f"WHERE s.domain_id = :domain AND s.branch = :base{start}{visible} "
|
|
1061
|
+
f"ORDER BY {key} LIMIT :limit), "
|
|
1062
|
+
f"copied AS (INSERT INTO {table} ({names}) SELECT {names} FROM batch "
|
|
1063
|
+
"ON CONFLICT DO NOTHING) "
|
|
1064
|
+
f"SELECT (SELECT count(*) FROM batch), {last} FROM batch "
|
|
1065
|
+
f"ORDER BY {descending} LIMIT 1"
|
|
1066
|
+
),
|
|
1067
|
+
params,
|
|
1068
|
+
)
|
|
1069
|
+
).one_or_none()
|
|
1070
|
+
if found is None:
|
|
1071
|
+
return 0, None
|
|
1072
|
+
read = int(found[0])
|
|
1073
|
+
return read, (tuple(found[1:]) if read >= limit else None)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
async def _switch(
|
|
1077
|
+
session: AsyncSession, domain_id: str, branch: str, runtime: StoreRuntime
|
|
1078
|
+
) -> None:
|
|
1079
|
+
"""Make the overlay `branch`, whose base rows are all copied, a full copy. The caller
|
|
1080
|
+
holds the branch lock and read the ref after taking it."""
|
|
1081
|
+
refs = RefRepository(session, domain_id)
|
|
1082
|
+
await copy_fence(session, domain_id, branch)
|
|
1083
|
+
for table in (MAT_RECORDS, MAT_LINKS):
|
|
1084
|
+
await session.execute(
|
|
1085
|
+
text(
|
|
1086
|
+
f"DELETE FROM {table.name} WHERE domain_id = :domain AND branch = :branch "
|
|
1087
|
+
"AND deleted"
|
|
1088
|
+
),
|
|
1089
|
+
{"domain": domain_id, "branch": branch},
|
|
1090
|
+
)
|
|
1091
|
+
await refs.set_materialization(branch, Materialization.FULL)
|
|
1092
|
+
await _sync_indexes(session, domain_id, branch, runtime)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
async def _sync_indexes(
|
|
1096
|
+
session: AsyncSession, domain_id: str, branch: str, runtime: StoreRuntime
|
|
1097
|
+
) -> None:
|
|
1098
|
+
"""Queue the per-Attribute indexes of a branch that just became full (none is built in
|
|
1099
|
+
the caller's transaction)."""
|
|
1100
|
+
schema = await MaterializedRepository(
|
|
1101
|
+
session, domain_id, branch, writes=runtime.settings.writes
|
|
1102
|
+
).schema()
|
|
1103
|
+
if schema is not None:
|
|
1104
|
+
# Imported here: the commit package imports the materializer.
|
|
1105
|
+
from lorebase_store.commit.schema import load_compiled
|
|
1106
|
+
|
|
1107
|
+
compiled = load_compiled(schema.compiled)
|
|
1108
|
+
await BranchIndexes(
|
|
1109
|
+
session,
|
|
1110
|
+
domain_id,
|
|
1111
|
+
branch,
|
|
1112
|
+
settings=runtime.settings.indexes,
|
|
1113
|
+
constraints_only=constraints_only(runtime.settings),
|
|
1114
|
+
maintenance=runtime.index_maintenance,
|
|
1115
|
+
defer=True,
|
|
1116
|
+
).sync(compiled)
|
|
1117
|
+
# Rows copied before the source rewrote them are stale.
|
|
1118
|
+
await queue_if_stale(session, domain_id, branch, compiled, runtime=runtime)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
async def run_branch_delete_job(
|
|
1122
|
+
engine: AsyncEngine,
|
|
1123
|
+
job: JobRow,
|
|
1124
|
+
*,
|
|
1125
|
+
runtime: StoreRuntime | None = None,
|
|
1126
|
+
clock: Clock | None = None,
|
|
1127
|
+
) -> Mapping[str, Any]:
|
|
1128
|
+
"""Job handler of `store.branch_delete`: delete the rows of the hidden branch named by
|
|
1129
|
+
the payload in batches, then its ref (module doc).
|
|
1130
|
+
|
|
1131
|
+
The result `status` is `done`, `waiting` (overlays still read the branch; queued again),
|
|
1132
|
+
`deferred` (no free slot; queued again), `skipped` (another job works on the branch) or
|
|
1133
|
+
`cancelled` (the branch is not being deleted).
|
|
1134
|
+
"""
|
|
1135
|
+
runtime = runtime or default_runtime()
|
|
1136
|
+
now = clock or _now
|
|
1137
|
+
domain_id, branch = _payload_branch(job)
|
|
1138
|
+
settings = runtime.settings.materialize
|
|
1139
|
+
run = _Run("delete", branch, _Pace.of(settings))
|
|
1140
|
+
async with _slot(engine, domain_id, branch, settings) as slot:
|
|
1141
|
+
if slot is _Slot.RUNNING:
|
|
1142
|
+
return run.done("skipped", detail="another job works on the branch")
|
|
1143
|
+
if slot is _Slot.BUSY:
|
|
1144
|
+
queued = await _again(engine, job, settings, now)
|
|
1145
|
+
return run.done("deferred", detail="no free slot", again=queued)
|
|
1146
|
+
|
|
1147
|
+
async def step(session: AsyncSession) -> _Step:
|
|
1148
|
+
return await _delete_step(session, domain_id, branch, run, settings, now)
|
|
1149
|
+
|
|
1150
|
+
try:
|
|
1151
|
+
return await _batches(engine, job, run, step, settings, now)
|
|
1152
|
+
except Exception:
|
|
1153
|
+
BRANCH_JOB_RUNS.add(1, {"job": run.job, "outcome": "failed"})
|
|
1154
|
+
raise
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
async def _delete_step(
|
|
1158
|
+
session: AsyncSession,
|
|
1159
|
+
domain_id: str,
|
|
1160
|
+
branch: str,
|
|
1161
|
+
run: _Run,
|
|
1162
|
+
settings: MaterializeSettings,
|
|
1163
|
+
clock: Clock,
|
|
1164
|
+
) -> _Step:
|
|
1165
|
+
await scope_to_domain(session, domain_id)
|
|
1166
|
+
refs = RefRepository(session, domain_id)
|
|
1167
|
+
ref = await refs.get_deleting(branch)
|
|
1168
|
+
if ref is None:
|
|
1169
|
+
return _Step(finished=run.done("done"))
|
|
1170
|
+
if ref.deleted_at is None:
|
|
1171
|
+
return _Step(finished=run.done("cancelled", detail="the branch is not being deleted"))
|
|
1172
|
+
waiting = await refs.dependents(branch)
|
|
1173
|
+
if waiting:
|
|
1174
|
+
# Their reads go through this branch's rows until they are full copies.
|
|
1175
|
+
for overlay in waiting:
|
|
1176
|
+
if overlay.deleted_at is None and overlay.materialization is Materialization.OVERLAY:
|
|
1177
|
+
await queue_materialization(session, domain_id, overlay, at=clock())
|
|
1178
|
+
return _Step(finished=run.done("waiting", overlays=[r.name for r in waiting]), again=True)
|
|
1179
|
+
batch = await build.clear_batch(session, domain_id, branch, limit=run.pace.size)
|
|
1180
|
+
if batch.rows:
|
|
1181
|
+
return _Step(rows=batch.rows)
|
|
1182
|
+
await refs.delete(branch)
|
|
1183
|
+
log.info("deleted the rows and ref of branch %s of Domain %s", branch, domain_id)
|
|
1184
|
+
return _Step(finished=run.done("done"))
|