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,153 @@
|
|
|
1
|
+
"""Pure mapping from tree content to materialized rows, shared by incremental apply and rebuild.
|
|
2
|
+
|
|
3
|
+
Both paths must produce identical rows; everything that decides a column value lives here.
|
|
4
|
+
|
|
5
|
+
Lineage of a row maps each Attribute holding a value to the provenance entry of its last
|
|
6
|
+
change: the provenance maps of the element's changes are folded oldest first and the result
|
|
7
|
+
is restricted to the Attributes present in the current values. Changes that touch no
|
|
8
|
+
Attribute (deletions, schema elements) are attributed under `ELEMENT_PROVENANCE_KEY`.
|
|
9
|
+
The Revisions of those changes are folded the same way (`next_lineage_revisions`), with the
|
|
10
|
+
Revision that created the element under `CREATED_KEY`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from lorebase_core.model import Kind, SearchWeight
|
|
22
|
+
from lorebase_core.tree import LINK_KEY_SEPARATOR, PARTITION_SCHEMA
|
|
23
|
+
from lorebase_core.types import AttributeType, to_text
|
|
24
|
+
|
|
25
|
+
ELEMENT_PROVENANCE_KEY = "$element"
|
|
26
|
+
"""Provenance map key of changes that touch no Attribute value."""
|
|
27
|
+
|
|
28
|
+
CREATED_KEY = "$created"
|
|
29
|
+
"""Lineage revisions key of the Revision that last created the element."""
|
|
30
|
+
|
|
31
|
+
DEFAULT_SEARCH_WEIGHT = SearchWeight.B
|
|
32
|
+
"""Weight of searchable Attributes without an explicit `search_weight` (titles use A)."""
|
|
33
|
+
|
|
34
|
+
_KIND_PREFIX = "k:"
|
|
35
|
+
_RELATION_PREFIX = "r:"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PartitionType(StrEnum):
|
|
39
|
+
SCHEMA = "schema"
|
|
40
|
+
KIND = "kind"
|
|
41
|
+
RELATION = "relation"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class PartitionId:
|
|
46
|
+
"""A parsed partition id: its type and the Kind or Relation id it holds."""
|
|
47
|
+
|
|
48
|
+
type: PartitionType
|
|
49
|
+
element_id: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_partition(partition: str) -> PartitionId:
|
|
53
|
+
"""Inverse of `kind_partition`, `relation_partition` and `PARTITION_SCHEMA`."""
|
|
54
|
+
if partition == PARTITION_SCHEMA:
|
|
55
|
+
return PartitionId(PartitionType.SCHEMA, "")
|
|
56
|
+
if partition.startswith(_KIND_PREFIX):
|
|
57
|
+
return PartitionId(PartitionType.KIND, partition[len(_KIND_PREFIX) :])
|
|
58
|
+
if partition.startswith(_RELATION_PREFIX):
|
|
59
|
+
return PartitionId(PartitionType.RELATION, partition[len(_RELATION_PREFIX) :])
|
|
60
|
+
raise ValueError(f"unknown partition {partition!r}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def split_link_key(element_key: str) -> tuple[str, str]:
|
|
64
|
+
"""`(from_key, to_key)` of a Link element key."""
|
|
65
|
+
from_key, separator, to_key = element_key.partition(LINK_KEY_SEPARATOR)
|
|
66
|
+
if not separator:
|
|
67
|
+
raise ValueError(f"not a Link key: {element_key!r}")
|
|
68
|
+
return from_key, to_key
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def changed_attributes(old: Mapping[str, Any] | None, new: Mapping[str, Any]) -> list[str]:
|
|
72
|
+
"""Attributes whose value differs between two value maps (absent counts as a value)."""
|
|
73
|
+
previous = old or {}
|
|
74
|
+
return sorted(
|
|
75
|
+
attribute
|
|
76
|
+
for attribute in previous.keys() | new.keys()
|
|
77
|
+
if previous.get(attribute) != new.get(attribute)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def changed_provenance(
|
|
82
|
+
provenance: Mapping[str, str], old: Mapping[str, Any] | None, new: Mapping[str, Any]
|
|
83
|
+
) -> dict[str, str]:
|
|
84
|
+
"""`provenance` restricted to the Attributes whose value differs between `old` and `new`
|
|
85
|
+
(and the element key): what a change actually attributes. Merges bring the whole Lineage
|
|
86
|
+
of an element taken from the source; only the values they change are theirs."""
|
|
87
|
+
kept = {*changed_attributes(old, new), ELEMENT_PROVENANCE_KEY}
|
|
88
|
+
return {attribute: entry for attribute, entry in provenance.items() if attribute in kept}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def next_lineage(
|
|
92
|
+
previous: Mapping[str, str] | None,
|
|
93
|
+
provenance: Mapping[str, str] | None,
|
|
94
|
+
values: Mapping[str, Any],
|
|
95
|
+
) -> dict[str, str]:
|
|
96
|
+
"""Lineage after a change: previous lineage updated with the change's provenance map."""
|
|
97
|
+
merged = dict(previous or {})
|
|
98
|
+
merged.update(provenance or {})
|
|
99
|
+
return {attribute: entry for attribute, entry in sorted(merged.items()) if attribute in values}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def next_lineage_revisions(
|
|
103
|
+
previous: Mapping[str, str] | None,
|
|
104
|
+
provenance: Mapping[str, str] | None,
|
|
105
|
+
revision: bytes,
|
|
106
|
+
values: Mapping[str, Any],
|
|
107
|
+
*,
|
|
108
|
+
created: bool,
|
|
109
|
+
) -> dict[str, str]:
|
|
110
|
+
"""Lineage revisions after a change made by `revision` (hex values, see module doc).
|
|
111
|
+
|
|
112
|
+
Attributes the change attributes take `revision`; the others keep their previous entry.
|
|
113
|
+
`previous` is None when unknown (rows written before the revisions were kept): only the
|
|
114
|
+
change's own entries are known then, and readers resolve the rest from the history.
|
|
115
|
+
"""
|
|
116
|
+
merged = {} if created else dict(previous or {})
|
|
117
|
+
merged.update(dict.fromkeys(provenance or {}, revision.hex()))
|
|
118
|
+
if created:
|
|
119
|
+
merged[CREATED_KEY] = revision.hex()
|
|
120
|
+
return {
|
|
121
|
+
attribute: entry
|
|
122
|
+
for attribute, entry in sorted(merged.items())
|
|
123
|
+
if attribute in values or attribute == CREATED_KEY
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def record_title(kind: Kind | None, values: Mapping[str, Any]) -> str | None:
|
|
128
|
+
"""Text of the Kind's title Attribute, if any. A sensitive title Attribute gives no title:
|
|
129
|
+
titles are shown and searched without the clearance its values need (design 10.2)."""
|
|
130
|
+
if kind is None or kind.title_attribute is None:
|
|
131
|
+
return None
|
|
132
|
+
value = values.get(kind.title_attribute)
|
|
133
|
+
attribute = kind.attributes.get(kind.title_attribute)
|
|
134
|
+
if value is None or attribute is None or attribute.is_sensitive:
|
|
135
|
+
return None
|
|
136
|
+
if attribute.type.name in (AttributeType.JSON, AttributeType.ARRAY):
|
|
137
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
138
|
+
return to_text(attribute.type, value)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def search_weights(kind: Kind | None) -> list[tuple[str, str]]:
|
|
142
|
+
"""`(attribute_id, weight)` of the Kind's searchable Attributes, by Attribute id.
|
|
143
|
+
|
|
144
|
+
Sensitive Attributes are left out, like from embedding templates: the full-text index and
|
|
145
|
+
its snippets serve every reader of the Kind, cleared for sensitive values or not.
|
|
146
|
+
"""
|
|
147
|
+
if kind is None:
|
|
148
|
+
return []
|
|
149
|
+
return [
|
|
150
|
+
(attribute.id, str(attribute.search_weight or DEFAULT_SEARCH_WEIGHT))
|
|
151
|
+
for attribute in sorted(kind.attributes.values(), key=lambda a: a.id)
|
|
152
|
+
if attribute.searchable and not attribute.is_sensitive
|
|
153
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Sensitivity tags of the default branch, for what every branch materializes (design 10.2).
|
|
2
|
+
|
|
3
|
+
Titles and full-text vectors leave sensitive Attributes out. A branch forked before the
|
|
4
|
+
default branch tagged an Attribute still has it untagged in its own schema, so the
|
|
5
|
+
materializer, the rebuild and the verification compute titles and search vectors with the
|
|
6
|
+
effective tags (`tagged_schema`): the default branch's tags added, and carried through the
|
|
7
|
+
branch's Derivations, as reads see them.
|
|
8
|
+
The default branch's schema comes from the store's schema cache, keyed by its head Revision.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
|
|
15
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
16
|
+
|
|
17
|
+
from lorebase_core.expr.limits import ExprLimits
|
|
18
|
+
from lorebase_core.expr.sensitivity import Tags, rebased_tags, schema_tags, with_tags
|
|
19
|
+
from lorebase_core.model import Schema
|
|
20
|
+
from lorebase_store.reads import head_schema
|
|
21
|
+
from lorebase_store.repositories.domains import DomainRepository
|
|
22
|
+
from lorebase_store.runtime import StoreRuntime
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def default_branch_schema(
|
|
26
|
+
session: AsyncSession, domain_id: str, *, runtime: StoreRuntime
|
|
27
|
+
) -> Schema | None:
|
|
28
|
+
"""The schema at the head of the Domain's default branch (cached by head Revision); None
|
|
29
|
+
when the Domain or its materialized head is missing."""
|
|
30
|
+
domain = await DomainRepository(session).get(domain_id)
|
|
31
|
+
if domain is None:
|
|
32
|
+
return None
|
|
33
|
+
found = await head_schema(session, domain_id, domain.default_branch, runtime=runtime)
|
|
34
|
+
return None if found is None else found.schema
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def reference_tags(
|
|
38
|
+
session: AsyncSession, domain_id: str, branch: str, *, runtime: StoreRuntime
|
|
39
|
+
) -> dict[str, Tags]:
|
|
40
|
+
"""Tags of the default branch's schema by Attribute id; empty on the default branch
|
|
41
|
+
itself (its own schema decides) or when it is not materialized."""
|
|
42
|
+
domain = await DomainRepository(session).get(domain_id)
|
|
43
|
+
if domain is None or branch == domain.default_branch:
|
|
44
|
+
return {}
|
|
45
|
+
found = await head_schema(session, domain_id, domain.default_branch, runtime=runtime)
|
|
46
|
+
return {} if found is None else schema_tags(found.schema)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def tagged_schema(
|
|
50
|
+
schema: Schema, reference: Mapping[str, Tags], limits: ExprLimits | None = None
|
|
51
|
+
) -> Schema:
|
|
52
|
+
"""`schema` with the effective tags of its Attributes (`rebased_tags`): the tags of
|
|
53
|
+
`reference` (the default branch's) added, and carried to the derived Attributes reading
|
|
54
|
+
them. A schema that already has every tag of `reference` is returned as it is: its
|
|
55
|
+
validation made its derived Attributes carry what they read."""
|
|
56
|
+
if not reference or with_tags(schema, reference) == schema:
|
|
57
|
+
return schema
|
|
58
|
+
return rebased_tags(schema, reference, limits)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def branch_tagged_schema(
|
|
62
|
+
session: AsyncSession, domain_id: str, branch: str, schema: Schema, *, runtime: StoreRuntime
|
|
63
|
+
) -> Schema:
|
|
64
|
+
"""`tagged_schema` of the schema of `branch` with the default branch's tags."""
|
|
65
|
+
reference = await reference_tags(session, domain_id, branch, runtime=runtime)
|
|
66
|
+
return tagged_schema(schema, reference, runtime.schema.expression_limits)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Snapshots: the materialized rows of a past Revision, for reads at it (design 6.12).
|
|
2
|
+
|
|
3
|
+
A read at a Revision other than a branch head is served from the tree; filters, ordering,
|
|
4
|
+
includes, aggregates, Link summaries, traversal, search, history and health need materialized
|
|
5
|
+
rows. A snapshot provides them: a ref of kind `snapshot` named after the Revision
|
|
6
|
+
(`snapshot_name`), whose rows `materialize_revision` writes like for a branch rebuilt at that
|
|
7
|
+
Revision (content, history of its first-parent chain, lineage, derived values, path Links and
|
|
8
|
+
search vectors) with the sensitivity tags of the Revision's own schema, without per-Attribute
|
|
9
|
+
indexes and without a ref log entry. Branch listings, bundles, embeddings, index plans and the
|
|
10
|
+
garbage collector's branch guard ignore snapshot refs; the collector keeps their Revisions.
|
|
11
|
+
|
|
12
|
+
Snapshots are shared by every read of the Revision in the Domain:
|
|
13
|
+
|
|
14
|
+
- Readers take a shared transaction-scoped advisory lock on a snapshot before using it
|
|
15
|
+
(`hold_snapshot`, also before it exists), so it is not dropped while they read it.
|
|
16
|
+
- `drop_unused` (its own short transaction) deletes the oldest snapshots beyond `keep` that no
|
|
17
|
+
reader holds and that are older than a grace period, through `delete_branch` (large ones are
|
|
18
|
+
deleted by the background job).
|
|
19
|
+
- `build_snapshot` creates one under the Domain's snapshot lock, which serializes the builds
|
|
20
|
+
of a Domain and their count against the cap: refs being deleted count too, and a full Domain
|
|
21
|
+
raises `SnapshotBusyError` instead of exceeding it.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from datetime import UTC, datetime, timedelta
|
|
29
|
+
|
|
30
|
+
from sqlalchemy import func, select, text
|
|
31
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
32
|
+
|
|
33
|
+
from lorebase_core.tree import Tree
|
|
34
|
+
from lorebase_store.materialize.background import delete_branch
|
|
35
|
+
from lorebase_store.materialize.rebuild import materialize_revision
|
|
36
|
+
from lorebase_store.materialize.rows import PartitionType, parse_partition
|
|
37
|
+
from lorebase_store.models.enums import Materialization, RefKind
|
|
38
|
+
from lorebase_store.repositories.refs import REFS, RefRepository
|
|
39
|
+
from lorebase_store.repositories.revisions import RevisionRepository, RevisionRow
|
|
40
|
+
from lorebase_store.runtime import StoreRuntime
|
|
41
|
+
|
|
42
|
+
SNAPSHOT_PREFIX = "~snapshot/"
|
|
43
|
+
"""Prefix of snapshot ref names; branch and tag names may not start with it."""
|
|
44
|
+
|
|
45
|
+
SNAPSHOT_CREATOR = "lorebase:time-travel"
|
|
46
|
+
"""`created_by` of snapshot refs."""
|
|
47
|
+
|
|
48
|
+
_DOMAIN_LOCK = "~snapshot"
|
|
49
|
+
"""Ref lock name serializing the snapshot builds and drops of a Domain."""
|
|
50
|
+
|
|
51
|
+
_USE_KEY_SCOPE = b"lb1\x00snapshot-use\x00"
|
|
52
|
+
|
|
53
|
+
Clock = Callable[[], datetime]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SnapshotBusyError(Exception):
|
|
57
|
+
"""No snapshot can be built now: the Domain holds its maximum (in use, recent or being
|
|
58
|
+
deleted), or the snapshot of the Revision is still being deleted."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def snapshot_name(revision: bytes) -> str:
|
|
62
|
+
"""Ref name of the snapshot of `revision`."""
|
|
63
|
+
return SNAPSHOT_PREFIX + revision.hex()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def snapshot_entries(tree: Tree) -> int:
|
|
67
|
+
"""Records and Links a snapshot of `tree` holds (O(1), from the partition counts)."""
|
|
68
|
+
return sum(
|
|
69
|
+
ref.count
|
|
70
|
+
for partition, ref in tree.partitions.items()
|
|
71
|
+
if parse_partition(partition).type is not PartitionType.SCHEMA
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _use_key(domain_id: str, name: str) -> int:
|
|
76
|
+
digest = hashlib.sha256(
|
|
77
|
+
_USE_KEY_SCOPE + domain_id.encode("utf-8") + b"\x00" + name.encode("utf-8")
|
|
78
|
+
).digest()
|
|
79
|
+
return int.from_bytes(digest[:8], "big", signed=True)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _now() -> datetime:
|
|
83
|
+
return datetime.now(UTC)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def hold_snapshot(session: AsyncSession, domain_id: str, revision: bytes) -> bool:
|
|
87
|
+
"""Take the shared lock of the snapshot of `revision` for the session's transaction (it
|
|
88
|
+
is not dropped while held, even when it is built later) and say whether the snapshot
|
|
89
|
+
exists. Works in read-only transactions."""
|
|
90
|
+
name = snapshot_name(revision)
|
|
91
|
+
await session.execute(
|
|
92
|
+
text("SELECT pg_advisory_xact_lock_shared(:key)"), {"key": _use_key(domain_id, name)}
|
|
93
|
+
)
|
|
94
|
+
found = await session.scalar(
|
|
95
|
+
select(REFS.c.name).where(
|
|
96
|
+
REFS.c.domain_id == domain_id,
|
|
97
|
+
REFS.c.name == name,
|
|
98
|
+
REFS.c.kind == RefKind.SNAPSHOT,
|
|
99
|
+
REFS.c.deleted_at.is_(None),
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
return found is not None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def drop_unused(
|
|
106
|
+
session: AsyncSession,
|
|
107
|
+
domain_id: str,
|
|
108
|
+
*,
|
|
109
|
+
keep: int,
|
|
110
|
+
min_age_s: float,
|
|
111
|
+
runtime: StoreRuntime,
|
|
112
|
+
lock_timeout_ms: int,
|
|
113
|
+
clock: Clock | None = None,
|
|
114
|
+
) -> int:
|
|
115
|
+
"""Delete the snapshots of the Domain beyond the `keep` newest that no reader holds and
|
|
116
|
+
that were built at least `min_age_s` ago, in the caller's (short) transaction; returns how
|
|
117
|
+
many were deleted or scheduled for deletion."""
|
|
118
|
+
now = (clock or _now)()
|
|
119
|
+
await RefRepository(session, domain_id).lock(_DOMAIN_LOCK, timeout_ms=lock_timeout_ms)
|
|
120
|
+
names = (
|
|
121
|
+
await session.scalars(
|
|
122
|
+
select(REFS.c.name)
|
|
123
|
+
.where(
|
|
124
|
+
REFS.c.domain_id == domain_id,
|
|
125
|
+
REFS.c.kind == RefKind.SNAPSHOT,
|
|
126
|
+
REFS.c.deleted_at.is_(None),
|
|
127
|
+
REFS.c.created_at <= now - timedelta(seconds=min_age_s),
|
|
128
|
+
)
|
|
129
|
+
.order_by(REFS.c.created_at.desc(), REFS.c.name)
|
|
130
|
+
)
|
|
131
|
+
).all()
|
|
132
|
+
young = await _count(session, domain_id, live=True) - len(names)
|
|
133
|
+
dropped = 0
|
|
134
|
+
for name in names[max(keep - young, 0) :]:
|
|
135
|
+
free = await session.scalar(
|
|
136
|
+
text("SELECT pg_try_advisory_xact_lock(:key)"), {"key": _use_key(domain_id, name)}
|
|
137
|
+
)
|
|
138
|
+
if free:
|
|
139
|
+
await delete_branch(session, domain_id, name, runtime=runtime, clock=lambda: now)
|
|
140
|
+
dropped += 1
|
|
141
|
+
return dropped
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def build_snapshot(
|
|
145
|
+
session: AsyncSession,
|
|
146
|
+
domain_id: str,
|
|
147
|
+
revision: RevisionRow,
|
|
148
|
+
*,
|
|
149
|
+
max_snapshots: int,
|
|
150
|
+
runtime: StoreRuntime,
|
|
151
|
+
lock_timeout_ms: int,
|
|
152
|
+
clock: Clock | None = None,
|
|
153
|
+
) -> bool:
|
|
154
|
+
"""Create the snapshot of `revision` in the caller's transaction (scoped to the Domain);
|
|
155
|
+
`False` when it already exists. Raises `SnapshotBusyError` when the Domain already holds
|
|
156
|
+
`max_snapshots` snapshots (being deleted included) or this one is being deleted, and
|
|
157
|
+
`LockTimeoutError` when another build of the Domain holds the lock longer than
|
|
158
|
+
`lock_timeout_ms`."""
|
|
159
|
+
now = (clock or _now)()
|
|
160
|
+
name = snapshot_name(revision.id)
|
|
161
|
+
refs = RefRepository(session, domain_id)
|
|
162
|
+
await refs.lock(_DOMAIN_LOCK, timeout_ms=lock_timeout_ms)
|
|
163
|
+
if await refs.get(name) is not None:
|
|
164
|
+
return False
|
|
165
|
+
if await refs.get_deleting(name) is not None:
|
|
166
|
+
raise SnapshotBusyError(f"the snapshot {name!r} of Domain {domain_id!r} is being deleted")
|
|
167
|
+
held = await _count(session, domain_id, live=False)
|
|
168
|
+
if held >= max_snapshots:
|
|
169
|
+
raise SnapshotBusyError(
|
|
170
|
+
f"Domain {domain_id!r} holds {held} snapshots, the most allowed ({max_snapshots})"
|
|
171
|
+
)
|
|
172
|
+
await refs.create(
|
|
173
|
+
name,
|
|
174
|
+
revision.id,
|
|
175
|
+
created_at=now,
|
|
176
|
+
created_by=SNAPSHOT_CREATOR,
|
|
177
|
+
kind=RefKind.SNAPSHOT,
|
|
178
|
+
materialization=Materialization.FULL,
|
|
179
|
+
reason="snapshot",
|
|
180
|
+
log=False,
|
|
181
|
+
)
|
|
182
|
+
# The ref now roots the Revision for the garbage collector; one that removed the Revision
|
|
183
|
+
# before (it was unreachable) leaves nothing to build.
|
|
184
|
+
if await RevisionRepository(session, domain_id).get(revision.id) is None:
|
|
185
|
+
raise LookupError(f"Revision {revision.id.hex()} of Domain {domain_id!r} is gone")
|
|
186
|
+
await materialize_revision(
|
|
187
|
+
session,
|
|
188
|
+
domain_id,
|
|
189
|
+
name,
|
|
190
|
+
revision,
|
|
191
|
+
runtime=runtime,
|
|
192
|
+
indexes=False,
|
|
193
|
+
default_branch_tags=False,
|
|
194
|
+
)
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def _count(session: AsyncSession, domain_id: str, *, live: bool) -> int:
|
|
199
|
+
"""Snapshot refs of the Domain: live ones only, or those being deleted too."""
|
|
200
|
+
query = select(func.count()).where(
|
|
201
|
+
REFS.c.domain_id == domain_id, REFS.c.kind == RefKind.SNAPSHOT
|
|
202
|
+
)
|
|
203
|
+
if live:
|
|
204
|
+
query = query.where(REFS.c.deleted_at.is_(None))
|
|
205
|
+
return int(await session.scalar(query) or 0)
|