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.
Files changed (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. 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)