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,266 @@
|
|
|
1
|
+
"""Links of a branch by endpoint, for Relations and path Derivations (design 5.8).
|
|
2
|
+
|
|
3
|
+
`links_of` reads the Links of a Relation from near keys: stored Links, or the Records holding
|
|
4
|
+
the key of a foreign-key Relation. `path_links_of` reads the Links of a path Derivation the
|
|
5
|
+
same way, composed from the Links of its steps (`lorebase_core.model.paths`: every Link of
|
|
6
|
+
the first step, at most `fanout` Links, smallest far keys first, per intermediate Record
|
|
7
|
+
later). Read from its far end, a path is walked backwards and each intermediate Record is
|
|
8
|
+
checked to contribute the far key within its bound, so both directions give the same pairs.
|
|
9
|
+
|
|
10
|
+
`path_affected` lists the Records whose pairs a changeset can change, from the steps followed
|
|
11
|
+
from them: the start Records for the forward direction, the end Records for the inverse one
|
|
12
|
+
(`reverse_steps`, where a changed Link of an intermediate Record also marks the far keys it
|
|
13
|
+
can push in or out of that Record's bound).
|
|
14
|
+
|
|
15
|
+
All reads go through `MaterializedRepository`, so overlays read their resolved rows.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from collections.abc import Mapping, Sequence
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
from lorebase_core.model import LinkDirection, PathRelation, Relation, RelationBacking
|
|
25
|
+
from lorebase_core.model.paths import PathHop, bounded
|
|
26
|
+
from lorebase_store.repositories.materialized import MaterializedRepository
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from lorebase_store.materialize.derived import AppliedChanges
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def reverse(direction: LinkDirection) -> LinkDirection:
|
|
33
|
+
return LinkDirection.INVERSE if direction is LinkDirection.FORWARD else LinkDirection.FORWARD
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def reverse_steps(steps: Sequence[PathHop]) -> tuple[PathHop, ...]:
|
|
37
|
+
"""The steps of a path walked from its far end."""
|
|
38
|
+
return tuple(PathHop(step.relation, reverse(step.direction)) for step in reversed(steps))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def links_of(
|
|
42
|
+
repository: MaterializedRepository,
|
|
43
|
+
relation: Relation,
|
|
44
|
+
direction: LinkDirection,
|
|
45
|
+
keys: Sequence[str],
|
|
46
|
+
*,
|
|
47
|
+
limit: int | None = None,
|
|
48
|
+
) -> dict[str, list[tuple[str, Any]]]:
|
|
49
|
+
"""`near key -> [(far key, link values)]` of `relation` followed in `direction` from
|
|
50
|
+
`keys`: stored Links, or the Records holding the key of a foreign-key Relation; at most
|
|
51
|
+
`limit` Links in all when set."""
|
|
52
|
+
forward = direction is LinkDirection.FORWARD
|
|
53
|
+
out: dict[str, list[tuple[str, Any]]] = defaultdict(list)
|
|
54
|
+
if relation.backing is RelationBacking.FOREIGN_KEY:
|
|
55
|
+
attribute = relation.foreign_key_attribute
|
|
56
|
+
if attribute is None:
|
|
57
|
+
return out
|
|
58
|
+
pairs = await repository.foreign_key_links(
|
|
59
|
+
relation.from_kind, attribute, keys=keys, by_target=not forward, limit=limit
|
|
60
|
+
)
|
|
61
|
+
for from_key, to_key in pairs:
|
|
62
|
+
near, far = (from_key, to_key) if forward else (to_key, from_key)
|
|
63
|
+
out[near].append((far, {}))
|
|
64
|
+
return out
|
|
65
|
+
found = await repository.linked_keys(
|
|
66
|
+
relation.id, near="from_key" if forward else "to_key", keys=keys, limit=limit
|
|
67
|
+
)
|
|
68
|
+
for near, far, values in found:
|
|
69
|
+
out[near].append((far, values))
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PathCapExceededError(Exception):
|
|
74
|
+
"""The first step of a path read with a cap (`path_links_of`) has that many Links."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _fars(
|
|
78
|
+
repository: MaterializedRepository,
|
|
79
|
+
step: PathHop,
|
|
80
|
+
keys: Sequence[str],
|
|
81
|
+
cap: int | None = None,
|
|
82
|
+
) -> dict[str, list[str]]:
|
|
83
|
+
"""Far keys of `keys` along a step; with `cap`, raises `PathCapExceededError` when they have
|
|
84
|
+
`cap` Links or more (reading no more than that)."""
|
|
85
|
+
found = (
|
|
86
|
+
await links_of(repository, step.relation, step.direction, keys, limit=cap) if keys else {}
|
|
87
|
+
)
|
|
88
|
+
if cap is not None and sum(len(items) for items in found.values()) >= cap:
|
|
89
|
+
raise PathCapExceededError(cap)
|
|
90
|
+
return {near: [far for far, _ in items] for near, items in found.items()}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def path_pairs_from(
|
|
94
|
+
repository: MaterializedRepository,
|
|
95
|
+
path: PathRelation,
|
|
96
|
+
starts: Sequence[str],
|
|
97
|
+
fanout: int,
|
|
98
|
+
cap: int | None = None,
|
|
99
|
+
) -> dict[str, set[str]]:
|
|
100
|
+
"""Far keys reached from each start Record (`lorebase_core.model.paths`). With `cap`,
|
|
101
|
+
the first step is read up to `cap` Links (`_fars`)."""
|
|
102
|
+
reached: dict[str, set[str]] = {start: {start} for start in starts}
|
|
103
|
+
for index, step in enumerate(path.steps):
|
|
104
|
+
found = await _fars(
|
|
105
|
+
repository,
|
|
106
|
+
step,
|
|
107
|
+
sorted({k for keys in reached.values() for k in keys}),
|
|
108
|
+
cap if index == 0 else None,
|
|
109
|
+
)
|
|
110
|
+
nexts = {
|
|
111
|
+
near: (fars if index == 0 else bounded(fars, fanout)) for near, fars in found.items()
|
|
112
|
+
}
|
|
113
|
+
reached = {
|
|
114
|
+
start: {far for near in keys for far in nexts.get(near, ())}
|
|
115
|
+
for start, keys in reached.items()
|
|
116
|
+
}
|
|
117
|
+
return reached
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def path_pairs_to(
|
|
121
|
+
repository: MaterializedRepository,
|
|
122
|
+
path: PathRelation,
|
|
123
|
+
ends: Sequence[str],
|
|
124
|
+
fanout: int,
|
|
125
|
+
cap: int | None = None,
|
|
126
|
+
) -> dict[str, set[str]]:
|
|
127
|
+
"""Start keys reaching each end Record: the steps walked backwards, keeping an
|
|
128
|
+
intermediate Record only when the key it leads to is within its bound. With `cap`, the
|
|
129
|
+
first step walked is read up to `cap` Links (`_fars`)."""
|
|
130
|
+
reached: dict[str, set[str]] = {end: {end} for end in ends}
|
|
131
|
+
last = len(path.steps) - 1
|
|
132
|
+
for index in reversed(range(len(path.steps))):
|
|
133
|
+
step = path.steps[index]
|
|
134
|
+
frontier = sorted({k for keys in reached.values() for k in keys})
|
|
135
|
+
back = await _fars(
|
|
136
|
+
repository,
|
|
137
|
+
PathHop(step.relation, reverse(step.direction)),
|
|
138
|
+
frontier,
|
|
139
|
+
cap if index == last else None,
|
|
140
|
+
)
|
|
141
|
+
if index > 0:
|
|
142
|
+
nears = sorted({near for nears in back.values() for near in nears})
|
|
143
|
+
allowed = {
|
|
144
|
+
near: set(bounded(fars, fanout))
|
|
145
|
+
for near, fars in (await _fars(repository, step, nears)).items()
|
|
146
|
+
}
|
|
147
|
+
back = {
|
|
148
|
+
far: [near for near in nears if far in allowed.get(near, ())]
|
|
149
|
+
for far, nears in back.items()
|
|
150
|
+
}
|
|
151
|
+
reached = {
|
|
152
|
+
end: {near for far in keys for near in back.get(far, ())}
|
|
153
|
+
for end, keys in reached.items()
|
|
154
|
+
}
|
|
155
|
+
return reached
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def path_links_of(
|
|
159
|
+
repository: MaterializedRepository,
|
|
160
|
+
path: PathRelation,
|
|
161
|
+
direction: LinkDirection,
|
|
162
|
+
keys: Sequence[str],
|
|
163
|
+
fanout: int,
|
|
164
|
+
*,
|
|
165
|
+
cap: int | None = None,
|
|
166
|
+
) -> dict[str, list[tuple[str, Any]]]:
|
|
167
|
+
"""`near key -> [(far key, {})]` of a path Derivation followed in `direction` from
|
|
168
|
+
`keys`, far keys in order (the shape of `links_of`). With `cap`, raises
|
|
169
|
+
`PathCapExceededError` when the first step walked has `cap` Links or more, read no further."""
|
|
170
|
+
if direction is LinkDirection.FORWARD:
|
|
171
|
+
reached = await path_pairs_from(repository, path, keys, fanout, cap)
|
|
172
|
+
else:
|
|
173
|
+
reached = await path_pairs_to(repository, path, keys, fanout, cap)
|
|
174
|
+
return {near: [(far, {}) for far in sorted(fars)] for near, fars in reached.items() if fars}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def path_affected(
|
|
178
|
+
repository: MaterializedRepository,
|
|
179
|
+
steps: Sequence[PathHop],
|
|
180
|
+
applied: AppliedChanges,
|
|
181
|
+
*,
|
|
182
|
+
fanout: int | None = None,
|
|
183
|
+
inverse: bool = False,
|
|
184
|
+
) -> set[str]:
|
|
185
|
+
"""Records at the start of `steps` whose pairs a changeset can change (a superset): a
|
|
186
|
+
Link of step `i` that changed marks its near Record at level `i`; walking the steps
|
|
187
|
+
backwards without bound gives the start Records reaching a marked Record.
|
|
188
|
+
|
|
189
|
+
`inverse` says `steps` are a path walked from its far end (`reverse_steps`): the bound
|
|
190
|
+
of an intermediate Record then sits at the far side of a step, and a Link added or
|
|
191
|
+
removed there can push another far key in or out of that Record's bounded set
|
|
192
|
+
(`_displaced`), which marks it too."""
|
|
193
|
+
starts: set[str] = set()
|
|
194
|
+
for level, step in enumerate(steps):
|
|
195
|
+
marked = await _marked(repository, step, applied)
|
|
196
|
+
if inverse and fanout is not None and level < len(steps) - 1:
|
|
197
|
+
marked |= await _displaced(repository, step, applied, fanout)
|
|
198
|
+
for back in reversed(steps[:level]):
|
|
199
|
+
if not marked:
|
|
200
|
+
break
|
|
201
|
+
found = await links_of(
|
|
202
|
+
repository, back.relation, reverse(back.direction), sorted(marked)
|
|
203
|
+
)
|
|
204
|
+
marked = {far for items in found.values() for far, _ in items}
|
|
205
|
+
starts |= marked
|
|
206
|
+
return starts
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def _displaced(
|
|
210
|
+
repository: MaterializedRepository, step: PathHop, applied: AppliedChanges, fanout: int
|
|
211
|
+
) -> set[str]:
|
|
212
|
+
"""Near Records of a step walked backwards whose membership in the bounded set of an
|
|
213
|
+
intermediate Record (at the step's far side) a changeset can change. With `n` changed
|
|
214
|
+
Links at an intermediate Record holding `c` Links now, its bounded set before and after
|
|
215
|
+
the change lies within its `fanout + n` smallest far keys once `c + n` exceeds
|
|
216
|
+
`fanout`: those are marked (the changed ones are marked by `_marked`)."""
|
|
217
|
+
hop = PathHop(step.relation, reverse(step.direction))
|
|
218
|
+
changes = await _changes(repository, hop, applied)
|
|
219
|
+
if not changes:
|
|
220
|
+
return set()
|
|
221
|
+
current = await _fars(repository, hop, sorted(changes))
|
|
222
|
+
displaced: set[str] = set()
|
|
223
|
+
for near, count in changes.items():
|
|
224
|
+
fars = sorted(set(current.get(near, ())))
|
|
225
|
+
if len(fars) + count > fanout:
|
|
226
|
+
displaced.update(fars[: fanout + count])
|
|
227
|
+
return displaced
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
async def _marked(
|
|
231
|
+
repository: MaterializedRepository, step: PathHop, applied: AppliedChanges
|
|
232
|
+
) -> set[str]:
|
|
233
|
+
"""Near Records of a step whose Links along it changed."""
|
|
234
|
+
return set(await _changes(repository, step, applied))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def _changes(
|
|
238
|
+
repository: MaterializedRepository, step: PathHop, applied: AppliedChanges
|
|
239
|
+
) -> dict[str, int]:
|
|
240
|
+
"""Near Records of a step whose Links along it changed, with how many of their Links
|
|
241
|
+
may have changed (an upper bound)."""
|
|
242
|
+
relation = step.relation
|
|
243
|
+
forward = step.direction is LinkDirection.FORWARD
|
|
244
|
+
counts: dict[str, int] = defaultdict(int)
|
|
245
|
+
for f, t in applied.links.get(relation.id, ()):
|
|
246
|
+
counts[f if forward else t] += 1
|
|
247
|
+
attribute = relation.foreign_key_attribute
|
|
248
|
+
if relation.backing is not RelationBacking.FOREIGN_KEY or attribute is None:
|
|
249
|
+
return counts
|
|
250
|
+
source = relation.from_kind
|
|
251
|
+
holders = set(applied.written.get(source, ())) | set(applied.removed.get(source, ()))
|
|
252
|
+
if forward:
|
|
253
|
+
for key in holders:
|
|
254
|
+
counts[key] += 1
|
|
255
|
+
return counts
|
|
256
|
+
previous: Mapping[tuple[str, str], Mapping[str, Any] | None] = applied.previous
|
|
257
|
+
for key in holders: # targets before the change, then after it
|
|
258
|
+
before = previous.get((source, key))
|
|
259
|
+
if before is not None and isinstance(before.get(attribute), str):
|
|
260
|
+
counts[before[attribute]] += 1
|
|
261
|
+
if holders:
|
|
262
|
+
now = await links_of(repository, relation, LinkDirection.FORWARD, sorted(holders))
|
|
263
|
+
for items in now.values():
|
|
264
|
+
for far, _ in items:
|
|
265
|
+
counts[far] += 1
|
|
266
|
+
return counts
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Overlay branches (design 6.11 mode `overlay`, AC-16; store schema notes).
|
|
2
|
+
|
|
3
|
+
An overlay starts with no Record or Link rows: creating it inserts its ref (base branch, fork
|
|
4
|
+
Revision and generation) and a copy of the base's compiled schema row, and queues its
|
|
5
|
+
per-Attribute indexes (`lorebase_store.indexes`). Its commits write only the rows they change
|
|
6
|
+
(`MaterializedRepository`), and reads resolve it over the base (`lorebase_store.layout`).
|
|
7
|
+
|
|
8
|
+
An overlay is promoted to a full copy ("materialized"):
|
|
9
|
+
- when a commit leaves it with more than `store.materialize.overlay_max_rows` rows of its own,
|
|
10
|
+
or when its base has moved more than `overlay_max_base_lag_revisions` generations past its
|
|
11
|
+
fork point (checked by its own commits, unless a background copy of it is queued or running;
|
|
12
|
+
a base commit that pushes an overlay past the lag queues a `store.materialize` job for it,
|
|
13
|
+
which copies it in batches, `materialize.background`). Over a base holding more than
|
|
14
|
+
`async_copy_min_rows` rows, the overlay's own commit queues that job too;
|
|
15
|
+
- before a commit or merge that changes its schema (whole-Kind rewrites and index changes
|
|
16
|
+
belong to full branches): inline over a small base; over a large one the copy job takes
|
|
17
|
+
the change over (`apply.defer_schema_change`);
|
|
18
|
+
- before its base is deleted or rebuilt (`promote_overlays_of`): inline over a small base;
|
|
19
|
+
over a large one it is detached and rebuilt from its tree in the background;
|
|
20
|
+
- on request (`promote`).
|
|
21
|
+
|
|
22
|
+
After an overlay is merged into its base, `refork` moves it to the base head and empties it,
|
|
23
|
+
which costs as much as creating it.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from datetime import UTC, datetime
|
|
31
|
+
|
|
32
|
+
from sqlalchemy import delete
|
|
33
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
34
|
+
|
|
35
|
+
from lorebase_store.indexes import BranchIndexes
|
|
36
|
+
from lorebase_store.jobs import MATERIALIZE_JOB, enqueue_job
|
|
37
|
+
from lorebase_store.layout import MAT_HISTORY, MAT_LINKS, MAT_RECORDS
|
|
38
|
+
from lorebase_store.models.enums import Materialization
|
|
39
|
+
from lorebase_store.repositories.materializations import (
|
|
40
|
+
BranchMaterializationRepository,
|
|
41
|
+
copy_fence,
|
|
42
|
+
)
|
|
43
|
+
from lorebase_store.repositories.materialized import MaterializedRepository
|
|
44
|
+
from lorebase_store.repositories.refs import RefMovedError, RefRepository, RefRow
|
|
45
|
+
from lorebase_store.repositories.revisions import RevisionRepository
|
|
46
|
+
from lorebase_store.repositories.scope import scope_to_domain
|
|
47
|
+
from lorebase_store.runtime import StoreRuntime, default_runtime
|
|
48
|
+
from lorebase_store.settings import MaterializeSettings
|
|
49
|
+
from lorebase_store.storage import constraints_only
|
|
50
|
+
|
|
51
|
+
log = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
REFORK_REASON = "branch.refork"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class OverlayError(Exception):
|
|
57
|
+
"""An overlay operation does not apply to the branch."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def wants_overlay(name: str, settings: MaterializeSettings) -> bool:
|
|
61
|
+
"""Whether a branch created without an explicit mode defaults to an overlay."""
|
|
62
|
+
if settings.branch_mode == "overlay":
|
|
63
|
+
return True
|
|
64
|
+
return any(name.startswith(prefix) for prefix in settings.overlay_branch_prefixes)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def promote(
|
|
68
|
+
session: AsyncSession,
|
|
69
|
+
domain_id: str,
|
|
70
|
+
branch: str,
|
|
71
|
+
*,
|
|
72
|
+
runtime: StoreRuntime | None = None,
|
|
73
|
+
locked: bool = False,
|
|
74
|
+
) -> bool:
|
|
75
|
+
"""Turn the overlay `branch` into a full copy; `False` when it is not an overlay.
|
|
76
|
+
|
|
77
|
+
Takes the branch lock unless the caller holds it (`locked`). The head and ref version do
|
|
78
|
+
not change, so a commit of the caller's transaction still moves the ref afterwards. A
|
|
79
|
+
background copy of the branch (`materialize.background`) is marked done.
|
|
80
|
+
"""
|
|
81
|
+
runtime = runtime or default_runtime()
|
|
82
|
+
refs = RefRepository(session, domain_id)
|
|
83
|
+
if not locked:
|
|
84
|
+
await refs.lock(branch, timeout_ms=runtime.settings.commit.lock_timeout_ms)
|
|
85
|
+
ref = await refs.get(branch)
|
|
86
|
+
if ref is None or ref.materialization is not Materialization.OVERLAY:
|
|
87
|
+
return False
|
|
88
|
+
await copy_fence(session, domain_id, branch)
|
|
89
|
+
repository = MaterializedRepository(
|
|
90
|
+
session, domain_id, branch, writes=runtime.settings.writes, layout=ref.layout
|
|
91
|
+
)
|
|
92
|
+
await repository.complete_overlay()
|
|
93
|
+
await refs.set_materialization(branch, Materialization.FULL)
|
|
94
|
+
states = BranchMaterializationRepository(session, domain_id)
|
|
95
|
+
if await states.get(branch) is not None:
|
|
96
|
+
await states.finish(branch, at=datetime.now(UTC))
|
|
97
|
+
schema = await repository.schema()
|
|
98
|
+
if schema is not None:
|
|
99
|
+
# Imported here: the commit package imports the materializer.
|
|
100
|
+
from lorebase_store.commit.schema import load_compiled
|
|
101
|
+
|
|
102
|
+
await BranchIndexes(
|
|
103
|
+
session,
|
|
104
|
+
domain_id,
|
|
105
|
+
branch,
|
|
106
|
+
settings=runtime.settings.indexes,
|
|
107
|
+
constraints_only=constraints_only(runtime.settings),
|
|
108
|
+
maintenance=runtime.index_maintenance,
|
|
109
|
+
).sync(load_compiled(schema.compiled))
|
|
110
|
+
log.info("promoted overlay branch %s of Domain %s to a full copy", branch, domain_id)
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def base_is_large(
|
|
115
|
+
session: AsyncSession, domain_id: str, base: str, *, runtime: StoreRuntime
|
|
116
|
+
) -> bool:
|
|
117
|
+
"""Whether copying the rows of `base` into an overlay is a job for the background: it
|
|
118
|
+
holds more than `store.materialize.async_copy_min_rows` rows."""
|
|
119
|
+
limit = runtime.settings.materialize.async_copy_min_rows
|
|
120
|
+
rows = MaterializedRepository(session, domain_id, base, writes=runtime.settings.writes)
|
|
121
|
+
return await rows.own_rows(limit=limit) > limit
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
async def promote_overlays_of(
|
|
125
|
+
session: AsyncSession, domain_id: str, base: str, *, runtime: StoreRuntime | None = None
|
|
126
|
+
) -> list[str]:
|
|
127
|
+
"""Make every overlay based on `base` independent of it (before deleting or rebuilding
|
|
128
|
+
it): promoted to a full copy in this transaction when `base` is small, otherwise
|
|
129
|
+
detached and rebuilt from its tree by a background job (`background.detach_to_build`).
|
|
130
|
+
The caller holds the lock of `base`; each overlay's lock is taken after it. Returns the
|
|
131
|
+
overlays handled."""
|
|
132
|
+
runtime = runtime or default_runtime()
|
|
133
|
+
refs = RefRepository(session, domain_id)
|
|
134
|
+
overlays = await refs.overlays_of(base)
|
|
135
|
+
if not overlays:
|
|
136
|
+
return []
|
|
137
|
+
large = await base_is_large(session, domain_id, base, runtime=runtime)
|
|
138
|
+
handled = []
|
|
139
|
+
for ref in overlays:
|
|
140
|
+
if large:
|
|
141
|
+
# Imported here: the background jobs import this module.
|
|
142
|
+
from lorebase_store.materialize.background import detach_to_build
|
|
143
|
+
|
|
144
|
+
done = await detach_to_build(
|
|
145
|
+
session, domain_id, ref.name, runtime=runtime, at=datetime.now(UTC)
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
done = await promote(session, domain_id, ref.name, runtime=runtime)
|
|
149
|
+
if done:
|
|
150
|
+
handled.append(ref.name)
|
|
151
|
+
return handled
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def check_overlay(
|
|
155
|
+
repository: MaterializedRepository, *, generation: int, runtime: StoreRuntime
|
|
156
|
+
) -> bool:
|
|
157
|
+
"""After a commit wrote `repository`'s branch at `generation`: promote it when it is an
|
|
158
|
+
overlay over its row or lag limits; queue promotions of overlays based on it that the
|
|
159
|
+
commit pushed past the lag limit. Returns whether the branch itself was promoted."""
|
|
160
|
+
settings = runtime.settings.materialize
|
|
161
|
+
layout = await repository.layout()
|
|
162
|
+
session = repository.session
|
|
163
|
+
if layout.overlay:
|
|
164
|
+
assert layout.base is not None
|
|
165
|
+
if await BranchMaterializationRepository(session, layout.domain_id).active(layout.branch):
|
|
166
|
+
# Its background copy makes it a full copy without a long transaction.
|
|
167
|
+
return False
|
|
168
|
+
refs = RefRepository(session, layout.domain_id)
|
|
169
|
+
base = await refs.get(layout.base)
|
|
170
|
+
base_generation = None
|
|
171
|
+
if base is not None:
|
|
172
|
+
found = await RevisionRepository(session, layout.domain_id).generations([base.head])
|
|
173
|
+
base_generation = found.get(base.head)
|
|
174
|
+
lagging = (
|
|
175
|
+
base_generation is not None
|
|
176
|
+
and layout.base_generation is not None
|
|
177
|
+
and base_generation - layout.base_generation > settings.overlay_max_base_lag_revisions
|
|
178
|
+
)
|
|
179
|
+
if lagging or await repository.own_rows(limit=settings.overlay_max_rows) > (
|
|
180
|
+
settings.overlay_max_rows
|
|
181
|
+
):
|
|
182
|
+
if await base_is_large(session, layout.domain_id, layout.base, runtime=runtime):
|
|
183
|
+
# Imported here: the background jobs import this module.
|
|
184
|
+
from lorebase_store.materialize.background import queue_materialization
|
|
185
|
+
|
|
186
|
+
ref = await refs.get(layout.branch)
|
|
187
|
+
if ref is not None:
|
|
188
|
+
await queue_materialization(
|
|
189
|
+
session, layout.domain_id, ref, at=datetime.now(UTC)
|
|
190
|
+
)
|
|
191
|
+
return False
|
|
192
|
+
promoted = await promote(
|
|
193
|
+
session, layout.domain_id, layout.branch, runtime=runtime, locked=True
|
|
194
|
+
)
|
|
195
|
+
repository.forget_layout()
|
|
196
|
+
return promoted
|
|
197
|
+
return False
|
|
198
|
+
for name, base_generation in await repository.dependents():
|
|
199
|
+
if generation - base_generation > settings.overlay_max_base_lag_revisions:
|
|
200
|
+
await enqueue_job(
|
|
201
|
+
session,
|
|
202
|
+
MATERIALIZE_JOB,
|
|
203
|
+
domain_id=layout.domain_id,
|
|
204
|
+
payload={"branch": name},
|
|
205
|
+
unique=True,
|
|
206
|
+
)
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def refork(
|
|
211
|
+
session: AsyncSession,
|
|
212
|
+
domain_id: str,
|
|
213
|
+
branch: str,
|
|
214
|
+
*,
|
|
215
|
+
actor: str,
|
|
216
|
+
base: str | None = None,
|
|
217
|
+
runtime: StoreRuntime | None = None,
|
|
218
|
+
clock: Callable[[], datetime] | None = None,
|
|
219
|
+
) -> RefRow:
|
|
220
|
+
"""Move `branch` to the head of its base (default: its current base) as an empty overlay.
|
|
221
|
+
|
|
222
|
+
The branch head must be an ancestor of the base head (merged into it), so the move is a
|
|
223
|
+
fast-forward and loses nothing. Rows of the branch are dropped; the base is locked first,
|
|
224
|
+
then the branch.
|
|
225
|
+
"""
|
|
226
|
+
runtime = runtime or default_runtime()
|
|
227
|
+
now = clock or (lambda: datetime.now(UTC))
|
|
228
|
+
timeout = runtime.settings.commit.lock_timeout_ms
|
|
229
|
+
await scope_to_domain(session, domain_id)
|
|
230
|
+
refs = RefRepository(session, domain_id)
|
|
231
|
+
ref = await refs.require(branch)
|
|
232
|
+
base_name = base or ref.base_branch
|
|
233
|
+
if base_name is None:
|
|
234
|
+
raise OverlayError(f"branch {branch!r} has no base; name one")
|
|
235
|
+
await refs.lock(base_name, timeout_ms=timeout)
|
|
236
|
+
await refs.lock(branch, timeout_ms=timeout)
|
|
237
|
+
await copy_fence(session, domain_id, branch)
|
|
238
|
+
ref = await refs.require(branch)
|
|
239
|
+
base_ref = await refs.require(base_name)
|
|
240
|
+
if base_ref.materialization is not Materialization.FULL:
|
|
241
|
+
raise OverlayError(f"base branch {base_name!r} is not fully materialized")
|
|
242
|
+
if await refs.overlays_of(branch):
|
|
243
|
+
raise OverlayError(f"branch {branch!r} is the base of other overlays")
|
|
244
|
+
revisions = RevisionRepository(session, domain_id)
|
|
245
|
+
ahead, _ = await revisions.ahead_behind(ref.head, base_ref.head)
|
|
246
|
+
if ahead:
|
|
247
|
+
raise OverlayError(
|
|
248
|
+
f"branch {branch!r} has {ahead} Revisions that {base_name!r} does not contain; "
|
|
249
|
+
"merge it first"
|
|
250
|
+
)
|
|
251
|
+
head = await revisions.require(base_ref.head)
|
|
252
|
+
writes = runtime.settings.writes
|
|
253
|
+
for table in (MAT_RECORDS, MAT_LINKS, MAT_HISTORY):
|
|
254
|
+
await session.execute(
|
|
255
|
+
delete(table).where(table.c.domain_id == domain_id, table.c.branch == branch)
|
|
256
|
+
)
|
|
257
|
+
schema = await MaterializedRepository(session, domain_id, base_name, writes=writes).schema()
|
|
258
|
+
if schema is not None:
|
|
259
|
+
await MaterializedRepository(session, domain_id, branch, writes=writes).set_schema(schema)
|
|
260
|
+
if ref.head != head.id:
|
|
261
|
+
try:
|
|
262
|
+
await refs.compare_and_swap(
|
|
263
|
+
branch,
|
|
264
|
+
expected_head=ref.head,
|
|
265
|
+
expected_version=ref.version,
|
|
266
|
+
new_head=head.id,
|
|
267
|
+
actor=actor,
|
|
268
|
+
at=now(),
|
|
269
|
+
reason=REFORK_REASON,
|
|
270
|
+
)
|
|
271
|
+
except RefMovedError as error:
|
|
272
|
+
raise OverlayError(f"branch {branch!r} moved meanwhile") from error
|
|
273
|
+
await refs.set_materialization(
|
|
274
|
+
branch,
|
|
275
|
+
Materialization.OVERLAY,
|
|
276
|
+
base_branch=base_name,
|
|
277
|
+
base_revision=head.id,
|
|
278
|
+
base_generation=head.generation,
|
|
279
|
+
)
|
|
280
|
+
if schema is not None:
|
|
281
|
+
# Now an overlay: the indexes of the base's schema, none unique, queued.
|
|
282
|
+
from lorebase_store.commit.schema import load_compiled
|
|
283
|
+
|
|
284
|
+
await BranchIndexes(
|
|
285
|
+
session,
|
|
286
|
+
domain_id,
|
|
287
|
+
branch,
|
|
288
|
+
settings=runtime.settings.indexes,
|
|
289
|
+
constraints_only=constraints_only(runtime.settings),
|
|
290
|
+
maintenance=runtime.index_maintenance,
|
|
291
|
+
).sync(load_compiled(schema.compiled))
|
|
292
|
+
return await refs.require(branch)
|