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
lorebase_store/gc.py ADDED
@@ -0,0 +1,970 @@
1
+ """Garbage collection of one Domain: mark and sweep (design 6.13).
2
+
3
+ Marking. Roots are the heads of all refs (branches and tags), the fork points of overlay
4
+ branches, the Revisions and merge attempts of open Proposals, and every Revision younger than
5
+ `gc.grace_seconds`. Materialized rows are not swept: an overlay's copies of base rows go with
6
+ its ref, and the base history it reads stays because its fork point does. Ancestors of a root are
7
+ reachable; so are the tree nodes of every reachable Revision root and the objects at the leaves
8
+ of those nodes. Marks live in temporary tables of the collecting connection, so memory stays
9
+ bounded by one tree level while shared subtrees are visited once (a node that is already marked
10
+ is not descended into).
11
+
12
+ Sweeping. Unmarked Revisions (with their parents, changesets and history rows), orphaned
13
+ provenance entries, tree nodes and objects are deleted in batches, newest Revisions first so a
14
+ child never outlives its parent. Vectors (`embeddings`) are kept while some materialized
15
+ branch holds a Record of their object hash whose Kind's embedding template, in that branch's
16
+ schema, has their fingerprint; the others (orphaned object hashes, templates no branch uses any
17
+ more, any model) are deleted. Before each batch of deletions the sweep takes, without
18
+ waiting, the advisory locks of every ref of the Domain (a commit holds its branch lock until
19
+ it ends) and marks again from the current refs: a commit that landed after the first marking is
20
+ protected, and a commit still in flight makes the batch wait. The grace period covers commits
21
+ that are visible but are not (or no longer) reachable from a ref.
22
+
23
+ Assumption: a commit writes its objects, nodes and Revision in one transaction (design 6.6), so
24
+ a stored object or node is never ahead of the Revision that reaches it. Rows written by other
25
+ means before their Revision is stored are not protected by the grace period.
26
+
27
+ One collection per Domain runs at a time (a session advisory lock distinct from the branch
28
+ locks); a second one fails with `GcBusyError`.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import asyncio
35
+ import hashlib
36
+ import json
37
+ import logging
38
+ import sys
39
+ import time
40
+ from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
41
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
42
+ from dataclasses import asdict, dataclass
43
+ from datetime import UTC, datetime, timedelta
44
+ from functools import partial
45
+ from typing import Any
46
+
47
+ from sqlalchemy import ARRAY, BigInteger, LargeBinary, Text, bindparam, text
48
+ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession
49
+
50
+ from lorebase_core.tree import BranchNode, LeafNode, NodeType, RootNode, decode_node
51
+ from lorebase_store.bulk import chunks
52
+ from lorebase_store.commit.schema import load_compiled
53
+ from lorebase_store.embeddings import template_fingerprints
54
+ from lorebase_store.jobs import JobRow
55
+ from lorebase_store.repositories.domains import DomainNotFoundError
56
+ from lorebase_store.repositories.refs import advisory_lock_key
57
+ from lorebase_store.repositories.scope import DOMAIN_IDS_SETTING
58
+ from lorebase_store.runtime import StoreRuntime, default_runtime
59
+ from lorebase_store.settings import GcSettings
60
+ from lorebase_store.telemetry import GC_FREED_BYTES, GC_RUNS
61
+
62
+ log = logging.getLogger(__name__)
63
+
64
+ Executor = AsyncSession | AsyncConnection
65
+ """Anything that can run statements; marking works inside a session transaction or on a
66
+ dedicated connection."""
67
+
68
+ Step = Callable[[], AbstractAsyncContextManager[None]]
69
+ """Opens the transaction of one unit of work (or does nothing inside an open transaction)."""
70
+
71
+ PhaseHook = Callable[[str], Awaitable[None]]
72
+ """Called between phases, outside any transaction; for progress reporting and tests."""
73
+
74
+ _LOCK_SCOPE = b"lb1\x00gc-lock\x00"
75
+ _ROOT_NODE_TYPE = int(NodeType.ROOT)
76
+ _NODE_TYPE_OFFSET = 1
77
+ _OBJECT_MARK_CHUNK = 20000
78
+
79
+ PHASE_MARKED = "marked"
80
+ PHASE_REVISIONS_SWEPT = "revisions-swept"
81
+ PHASE_NODES_SWEPT = "nodes-swept"
82
+ PHASE_OBJECTS_SWEPT = "objects-swept"
83
+ PHASE_VECTORS_SWEPT = "vectors-swept"
84
+
85
+
86
+ class GcError(Exception):
87
+ """A collection could not run to completion."""
88
+
89
+
90
+ class GcBusyError(GcError):
91
+ """Another collection of the same Domain is running."""
92
+
93
+
94
+ class _BranchBusyError(Exception):
95
+ """A branch lock is held by a commit in flight."""
96
+
97
+
98
+ def gc_lock_key(domain_id: str) -> int:
99
+ """Signed 64-bit advisory lock key of a Domain's collection (distinct from branch locks)."""
100
+ digest = hashlib.sha256(_LOCK_SCOPE + domain_id.encode("utf-8")).digest()
101
+ return int.from_bytes(digest[:8], "big", signed=True)
102
+
103
+
104
+ def _bytea_array(name: str) -> Any:
105
+ return bindparam(name, type_=ARRAY(LargeBinary()))
106
+
107
+
108
+ # --------------------------------------------------------------------------------------
109
+ # Temporary mark tables
110
+
111
+
112
+ _MARK_TABLES = (
113
+ "lb_gc_roots",
114
+ "lb_gc_revs",
115
+ "lb_gc_nodes",
116
+ "lb_gc_objects",
117
+ "lb_gc_dead",
118
+ "lb_gc_prov",
119
+ "lb_gc_vectors",
120
+ "lb_gc_schemas",
121
+ "lb_gc_templates",
122
+ )
123
+
124
+ _CREATE_MARK_TABLES = (
125
+ "CREATE TEMP TABLE IF NOT EXISTS lb_gc_roots (id bytea PRIMARY KEY)",
126
+ "CREATE TEMP TABLE IF NOT EXISTS lb_gc_revs (id bytea PRIMARY KEY, root bytea NOT NULL)",
127
+ "CREATE TEMP TABLE IF NOT EXISTS lb_gc_nodes (hash bytea PRIMARY KEY)",
128
+ "CREATE TEMP TABLE IF NOT EXISTS lb_gc_objects (hash bytea PRIMARY KEY)",
129
+ "CREATE TEMP TABLE IF NOT EXISTS lb_gc_dead (id bytea PRIMARY KEY, generation bigint NOT NULL)",
130
+ "CREATE INDEX IF NOT EXISTS lb_gc_dead_order ON lb_gc_dead (generation DESC, id DESC)",
131
+ "CREATE TEMP TABLE IF NOT EXISTS lb_gc_prov (id text PRIMARY KEY)",
132
+ "CREATE TEMP TABLE IF NOT EXISTS lb_gc_vectors "
133
+ "(hash bytea NOT NULL, fingerprint text NOT NULL, PRIMARY KEY (hash, fingerprint))",
134
+ "CREATE TEMP TABLE IF NOT EXISTS lb_gc_schemas (schema_hash bytea PRIMARY KEY)",
135
+ "CREATE TEMP TABLE IF NOT EXISTS lb_gc_templates "
136
+ "(schema_hash bytea NOT NULL, kind_id text NOT NULL, fingerprint text NOT NULL, "
137
+ "PRIMARY KEY (schema_hash, kind_id))",
138
+ )
139
+
140
+ _ROOTS_REFS = text(
141
+ "INSERT INTO lb_gc_roots (id) SELECT head FROM refs WHERE domain_id = :d "
142
+ "UNION SELECT base_revision FROM refs WHERE domain_id = :d AND base_revision IS NOT NULL "
143
+ "ON CONFLICT DO NOTHING"
144
+ )
145
+ """Ref heads, and the fork points of overlay branches: the base rows an overlay reads are
146
+ those of its fork point (an ancestor of its head, kept anyway; named here so the guarantee
147
+ does not rest on that)."""
148
+ _ROOTS_YOUNG = text(
149
+ "INSERT INTO lb_gc_roots (id) SELECT id FROM revisions "
150
+ "WHERE domain_id = :d AND created_at >= :cutoff ON CONFLICT DO NOTHING"
151
+ )
152
+ _ATTEMPT_HASHES = """
153
+ SELECT unnest(ARRAY[m.base, m.ours, m.theirs]) AS h
154
+ FROM merge_attempts m
155
+ JOIN proposals p ON p.domain_id = m.domain_id AND p.id = m.proposal_id
156
+ WHERE m.domain_id = :d AND p.status = 'open' AND m.status NOT IN ('merged', 'abandoned')
157
+ """
158
+ _ROOTS_ATTEMPTS = text(
159
+ f"INSERT INTO lb_gc_roots (id) SELECT DISTINCT h FROM ({_ATTEMPT_HASHES}) a "
160
+ "ON CONFLICT DO NOTHING"
161
+ )
162
+ _ATTEMPT_TREES = text(
163
+ f"""
164
+ SELECT DISTINCT n.hash FROM ({_ATTEMPT_HASHES}) a
165
+ JOIN tree_nodes n ON n.domain_id = :d AND n.hash = a.h
166
+ WHERE get_byte(n.body, {_NODE_TYPE_OFFSET}) = {_ROOT_NODE_TYPE}
167
+ """
168
+ )
169
+ _WALK = text(
170
+ """
171
+ WITH RECURSIVE walk(id) AS (
172
+ SELECT r.id FROM lb_gc_roots r
173
+ WHERE NOT EXISTS (SELECT 1 FROM lb_gc_revs m WHERE m.id = r.id)
174
+ UNION
175
+ SELECT p.parent_id FROM walk
176
+ JOIN revision_parents p ON p.domain_id = :d AND p.revision_id = walk.id
177
+ WHERE NOT EXISTS (SELECT 1 FROM lb_gc_revs m WHERE m.id = p.parent_id)
178
+ )
179
+ INSERT INTO lb_gc_revs (id, root)
180
+ SELECT rev.id, rev.root FROM walk
181
+ JOIN revisions rev ON rev.domain_id = :d AND rev.id = walk.id
182
+ ON CONFLICT DO NOTHING
183
+ RETURNING root
184
+ """
185
+ )
186
+ _MARK_NODES = text(
187
+ "INSERT INTO lb_gc_nodes (hash) SELECT h FROM unnest(CAST(:hs AS bytea[])) AS t(h) "
188
+ "ON CONFLICT DO NOTHING RETURNING hash"
189
+ ).bindparams(_bytea_array("hs"))
190
+ _MARK_OBJECTS = text(
191
+ "INSERT INTO lb_gc_objects (hash) SELECT h FROM unnest(CAST(:hs AS bytea[])) AS t(h) "
192
+ "ON CONFLICT DO NOTHING"
193
+ ).bindparams(_bytea_array("hs"))
194
+ _READ_NODES = text(
195
+ "SELECT hash, body FROM tree_nodes WHERE domain_id = :d AND hash = ANY(CAST(:hs AS bytea[]))"
196
+ ).bindparams(_bytea_array("hs"))
197
+
198
+
199
+ @dataclass(slots=True)
200
+ class MarkStats:
201
+ """What marking reached."""
202
+
203
+ revisions: int = 0
204
+ nodes: int = 0
205
+ objects: int = 0
206
+ missing_nodes: int = 0
207
+
208
+
209
+ @asynccontextmanager
210
+ async def _open_transaction() -> AsyncIterator[None]:
211
+ yield
212
+
213
+
214
+ class Marker:
215
+ """Marks the Revisions, tree nodes and objects reachable from a set of roots.
216
+
217
+ Marks accumulate in temporary tables (`lb_gc_revs`, `lb_gc_nodes`, `lb_gc_objects`) of the
218
+ executor's connection. `step` brackets each unit of work; pass a function that opens a
219
+ transaction when the executor has none open, or one that does nothing inside a transaction
220
+ the caller manages.
221
+ """
222
+
223
+ def __init__(
224
+ self,
225
+ executor: Executor,
226
+ domain_id: str,
227
+ *,
228
+ batch: int,
229
+ step: Step = _open_transaction,
230
+ ) -> None:
231
+ self._ex = executor
232
+ self._domain = domain_id
233
+ self._batch = batch
234
+ self._step = step
235
+ self.missing_nodes = 0
236
+
237
+ async def prepare(self) -> None:
238
+ """Create the mark tables (if needed) and empty them."""
239
+ async with self._step():
240
+ for statement in _CREATE_MARK_TABLES:
241
+ await self._ex.execute(text(statement))
242
+ await self._ex.execute(text("TRUNCATE " + ", ".join(_MARK_TABLES)))
243
+
244
+ async def drop(self) -> None:
245
+ async with self._step():
246
+ await self._ex.execute(text("DROP TABLE IF EXISTS " + ", ".join(_MARK_TABLES)))
247
+
248
+ async def mark(self, *, cutoff: datetime | None = None, attempts: bool = True) -> None:
249
+ """Mark from the current ref heads, from the retained merge attempts when `attempts`,
250
+ and from Revisions created at or after `cutoff` when given.
251
+
252
+ Already marked Revisions and nodes are not visited again, so calling this again marks
253
+ only what became reachable since.
254
+ """
255
+ async with self._step():
256
+ domain = {"d": self._domain}
257
+ await self._ex.execute(text("DELETE FROM lb_gc_roots"))
258
+ await self._ex.execute(_ROOTS_REFS, domain)
259
+ if attempts:
260
+ await self._ex.execute(_ROOTS_ATTEMPTS, domain)
261
+ if cutoff is not None:
262
+ await self._ex.execute(_ROOTS_YOUNG, {**domain, "cutoff": cutoff})
263
+ new_roots = list((await self._ex.execute(_WALK, domain)).scalars())
264
+ if attempts:
265
+ new_roots.extend((await self._ex.execute(_ATTEMPT_TREES, domain)).scalars())
266
+ await self._mark_trees(list(dict.fromkeys(new_roots)))
267
+
268
+ async def stats(self) -> MarkStats:
269
+ async with self._step():
270
+ counts = [
271
+ int((await self._ex.execute(text(f"SELECT count(*) FROM {table}"))).scalar_one())
272
+ for table in ("lb_gc_revs", "lb_gc_nodes", "lb_gc_objects")
273
+ ]
274
+ return MarkStats(counts[0], counts[1], counts[2], self.missing_nodes)
275
+
276
+ async def _mark_trees(self, roots: list[bytes]) -> None:
277
+ """Mark the nodes below `roots` level by level, in batched reads."""
278
+ frontier = roots
279
+ while frontier:
280
+ following: list[bytes] = []
281
+ for chunk in chunks(frontier, self._batch):
282
+ following.extend(await self._mark_nodes(list(chunk)))
283
+ frontier = list(dict.fromkeys(following))
284
+
285
+ async def _mark_nodes(self, hashes: list[bytes]) -> list[bytes]:
286
+ async with self._step():
287
+ fresh = list((await self._ex.execute(_MARK_NODES, {"hs": hashes})).scalars())
288
+ if not fresh:
289
+ return []
290
+ rows = (await self._ex.execute(_READ_NODES, {"d": self._domain, "hs": fresh})).all()
291
+ self.missing_nodes += len(fresh) - len(rows)
292
+ children: list[bytes] = []
293
+ objects: list[bytes] = []
294
+ for _, body in rows:
295
+ node = decode_node(bytes(body))
296
+ if isinstance(node, LeafNode):
297
+ objects.extend(digest for _, digest in node.entries)
298
+ elif isinstance(node, BranchNode):
299
+ children.extend(child for _, child in node.children)
300
+ elif isinstance(node, RootNode):
301
+ children.extend(ref.root for _, ref in node.partitions)
302
+ for chunk in chunks(objects, _OBJECT_MARK_CHUNK):
303
+ await self._ex.execute(_MARK_OBJECTS, {"hs": list(chunk)})
304
+ return children
305
+
306
+
307
+ # --------------------------------------------------------------------------------------
308
+ # Report
309
+
310
+
311
+ @dataclass(slots=True)
312
+ class GcReport:
313
+ """Outcome of a collection. With `dry_run`, the counts are what a collection would delete."""
314
+
315
+ domain_id: str
316
+ dry_run: bool
317
+ cutoff: str
318
+ marked_revisions: int = 0
319
+ marked_nodes: int = 0
320
+ marked_objects: int = 0
321
+ missing_nodes: int = 0
322
+ revisions: int = 0
323
+ changesets: int = 0
324
+ history_rows: int = 0
325
+ provenance: int = 0
326
+ nodes: int = 0
327
+ node_bytes: int = 0
328
+ objects: int = 0
329
+ object_bytes: int = 0
330
+ embeddings: int = 0
331
+ mark_seconds: float = 0.0
332
+ sweep_seconds: float = 0.0
333
+
334
+ def as_dict(self) -> dict[str, Any]:
335
+ return asdict(self)
336
+
337
+
338
+ # --------------------------------------------------------------------------------------
339
+ # Sweep SQL
340
+
341
+ _DEAD_FILL = text(
342
+ """
343
+ INSERT INTO lb_gc_dead (id, generation)
344
+ SELECT r.id, r.generation FROM revisions r
345
+ WHERE r.domain_id = :d AND NOT EXISTS (SELECT 1 FROM lb_gc_revs m WHERE m.id = r.id)
346
+ """
347
+ )
348
+ _DEAD_FIRST = text(
349
+ "SELECT id, generation FROM lb_gc_dead ORDER BY generation DESC, id DESC LIMIT :n"
350
+ )
351
+ _DEAD_NEXT = text(
352
+ "SELECT id, generation FROM lb_gc_dead WHERE (generation, id) < (:g, :i) "
353
+ "ORDER BY generation DESC, id DESC LIMIT :n"
354
+ )
355
+ _DEAD_STILL = text(
356
+ "SELECT t.id FROM unnest(CAST(:ids AS bytea[])) AS t(id) "
357
+ "WHERE NOT EXISTS (SELECT 1 FROM lb_gc_revs m WHERE m.id = t.id)"
358
+ ).bindparams(_bytea_array("ids"))
359
+ _PAIRS = "CASE WHEN jsonb_typeof(c.provenance) = 'object' THEN c.provenance ELSE '{}'::jsonb END"
360
+ """The provenance map of a changeset row `c`, empty when the column is NULL or a JSON null."""
361
+ _PROV_COLLECT = text(
362
+ f"""
363
+ INSERT INTO lb_gc_prov (id)
364
+ SELECT e.value FROM changesets c, LATERAL jsonb_each_text({_PAIRS}) e
365
+ WHERE c.domain_id = :d AND c.revision_id = ANY(CAST(:ids AS bytea[]))
366
+ ON CONFLICT DO NOTHING
367
+ """
368
+ ).bindparams(_bytea_array("ids"))
369
+ _HISTORY_DELETE = text(
370
+ "DELETE FROM mat_history WHERE domain_id = :d AND revision_id = ANY(CAST(:ids AS bytea[]))"
371
+ ).bindparams(_bytea_array("ids"))
372
+ _PARENTS_DELETE = text(
373
+ "DELETE FROM revision_parents WHERE domain_id = :d AND revision_id = ANY(CAST(:ids AS bytea[]))"
374
+ ).bindparams(_bytea_array("ids"))
375
+ _CHANGESETS_DELETE = text(
376
+ "DELETE FROM changesets WHERE domain_id = :d AND revision_id = ANY(CAST(:ids AS bytea[]))"
377
+ ).bindparams(_bytea_array("ids"))
378
+ _REVISIONS_DELETE = text(
379
+ "DELETE FROM revisions WHERE domain_id = :d AND id = ANY(CAST(:ids AS bytea[]))"
380
+ ).bindparams(_bytea_array("ids"))
381
+ _PROV_USED = (
382
+ f"SELECT e.value FROM changesets c, LATERAL jsonb_each_text({_PAIRS}) e WHERE c.domain_id = :d"
383
+ )
384
+ _PROV_DELETE = text(
385
+ f"DELETE FROM provenance p WHERE p.domain_id = :d AND p.id IN "
386
+ f"(SELECT id FROM lb_gc_prov EXCEPT {_PROV_USED})"
387
+ )
388
+ _PROV_DRY = text(
389
+ f"""
390
+ SELECT count(*) FROM provenance p WHERE p.domain_id = :d AND p.id IN (
391
+ SELECT e.value FROM changesets c
392
+ JOIN lb_gc_dead d ON d.id = c.revision_id, LATERAL jsonb_each_text({_PAIRS}) e
393
+ WHERE c.domain_id = :d
394
+ EXCEPT
395
+ {_PROV_USED} AND NOT EXISTS (SELECT 1 FROM lb_gc_dead d WHERE d.id = c.revision_id))
396
+ """
397
+ )
398
+ _DRY_CHANGESETS = text(
399
+ "SELECT count(*) FROM changesets c JOIN lb_gc_dead d ON d.id = c.revision_id "
400
+ "WHERE c.domain_id = :d"
401
+ )
402
+ _DRY_HISTORY = text(
403
+ "SELECT count(*) FROM mat_history h JOIN lb_gc_dead d ON d.id = h.revision_id "
404
+ "WHERE h.domain_id = :d"
405
+ )
406
+ _REF_NAMES = text("SELECT name FROM refs WHERE domain_id = :d AND kind <> 'snapshot' ORDER BY name")
407
+ """Refs whose commits the guard holds off; snapshot refs take no commits (their Revisions are
408
+ roots like every ref head)."""
409
+ _TRY_LOCKS = text(
410
+ "SELECT coalesce(bool_and(l), true) FROM (SELECT pg_try_advisory_xact_lock(k) AS l "
411
+ "FROM unnest(CAST(:ks AS bigint[])) AS t(k) ORDER BY k) s"
412
+ ).bindparams(bindparam("ks", type_=ARRAY(BigInteger())))
413
+
414
+
415
+ @dataclass(frozen=True, slots=True)
416
+ class _Content:
417
+ """A content-addressed table swept by hash."""
418
+
419
+ table: str
420
+ marks: str
421
+ size: str
422
+
423
+
424
+ _NODES = _Content("tree_nodes", "lb_gc_nodes", "octet_length(t.body)")
425
+ _OBJECTS = _Content("objects", "lb_gc_objects", "t.size")
426
+
427
+
428
+ def _unmarked_page(content: _Content) -> Any:
429
+ return text(
430
+ f"SELECT t.hash FROM {content.table} t WHERE t.domain_id = :d AND t.hash > :last "
431
+ f"AND NOT EXISTS (SELECT 1 FROM {content.marks} m WHERE m.hash = t.hash) "
432
+ f"ORDER BY t.hash LIMIT :n"
433
+ )
434
+
435
+
436
+ def _unmarked_total(content: _Content) -> Any:
437
+ return text(
438
+ f"SELECT count(*), coalesce(sum({content.size}), 0) FROM {content.table} t "
439
+ f"WHERE t.domain_id = :d "
440
+ f"AND NOT EXISTS (SELECT 1 FROM {content.marks} m WHERE m.hash = t.hash)"
441
+ )
442
+
443
+
444
+ def _delete_unmarked(content: _Content) -> Any:
445
+ return text(
446
+ f"WITH gone AS (DELETE FROM {content.table} t WHERE t.domain_id = :d "
447
+ f"AND t.hash = ANY(CAST(:hs AS bytea[])) "
448
+ f"AND NOT EXISTS (SELECT 1 FROM {content.marks} m WHERE m.hash = t.hash) "
449
+ f"RETURNING {content.size} AS size) "
450
+ f"SELECT count(*), coalesce(sum(size), 0) FROM gone"
451
+ ).bindparams(_bytea_array("hs"))
452
+
453
+
454
+ _SCHEMA_HASHES = text("SELECT DISTINCT schema_hash FROM mat_schema WHERE domain_id = :d")
455
+ _SCHEMA_DOCUMENT = text(
456
+ "SELECT compiled FROM mat_schema WHERE domain_id = :d AND schema_hash = :h LIMIT 1"
457
+ )
458
+ _SCHEMA_KNOWN = text("INSERT INTO lb_gc_schemas (schema_hash) VALUES (:h) ON CONFLICT DO NOTHING")
459
+ _TEMPLATE_FILL = text(
460
+ "INSERT INTO lb_gc_templates (schema_hash, kind_id, fingerprint) "
461
+ "SELECT :h, k, f FROM unnest(CAST(:kinds AS text[]), CAST(:fps AS text[])) AS t(k, f) "
462
+ "ON CONFLICT DO NOTHING"
463
+ ).bindparams(bindparam("kinds", type_=ARRAY(Text())), bindparam("fps", type_=ARRAY(Text())))
464
+ _VECTORS_LIVE = text(
465
+ "INSERT INTO lb_gc_vectors (hash, fingerprint) "
466
+ "SELECT DISTINCT m.object_hash, t.fingerprint FROM mat_records m "
467
+ "JOIN mat_schema s ON s.domain_id = m.domain_id AND s.branch = m.branch "
468
+ "JOIN lb_gc_templates t ON t.schema_hash = s.schema_hash AND t.kind_id = m.kind_id "
469
+ "WHERE m.domain_id = :d ON CONFLICT DO NOTHING"
470
+ )
471
+ """Object hashes of the Records at the heads of the materialized branches, with the template
472
+ fingerprint of their Kind in the branch's schema: the vectors a search can reach. An overlay's
473
+ inherited rows belong to its base branch, which is listed too."""
474
+ _VECTORS_PAGE = text(
475
+ "SELECT e.object_hash, e.fingerprint FROM embeddings e WHERE e.domain_id = :d "
476
+ "AND (e.object_hash, e.fingerprint) > (:last, :last_fingerprint) "
477
+ "AND NOT EXISTS (SELECT 1 FROM lb_gc_vectors v "
478
+ "WHERE v.hash = e.object_hash AND v.fingerprint = e.fingerprint) "
479
+ "GROUP BY e.object_hash, e.fingerprint ORDER BY e.object_hash, e.fingerprint LIMIT :n"
480
+ )
481
+ _VECTORS_TOTAL = text(
482
+ "SELECT count(*) FROM embeddings e WHERE e.domain_id = :d "
483
+ "AND NOT EXISTS (SELECT 1 FROM lb_gc_vectors v "
484
+ "WHERE v.hash = e.object_hash AND v.fingerprint = e.fingerprint)"
485
+ )
486
+ _VECTORS_DELETE = text(
487
+ "DELETE FROM embeddings e "
488
+ "USING unnest(CAST(:hs AS bytea[]), CAST(:fs AS text[])) AS p(hash, fingerprint) "
489
+ "WHERE e.domain_id = :d AND e.object_hash = p.hash AND e.fingerprint = p.fingerprint "
490
+ "AND NOT EXISTS (SELECT 1 FROM lb_gc_vectors v "
491
+ "WHERE v.hash = e.object_hash AND v.fingerprint = e.fingerprint) "
492
+ "AND NOT EXISTS (SELECT 1 FROM mat_records m "
493
+ "LEFT JOIN mat_schema s ON s.domain_id = m.domain_id AND s.branch = m.branch "
494
+ "WHERE m.domain_id = :d AND m.object_hash = e.object_hash "
495
+ "AND (s.schema_hash IS NULL "
496
+ "OR NOT EXISTS (SELECT 1 FROM lb_gc_schemas k WHERE k.schema_hash = s.schema_hash) "
497
+ "OR EXISTS (SELECT 1 FROM lb_gc_templates t WHERE t.schema_hash = s.schema_hash "
498
+ "AND t.kind_id = m.kind_id AND t.fingerprint = e.fingerprint)))"
499
+ ).bindparams(_bytea_array("hs"), bindparam("fs", type_=ARRAY(Text())))
500
+ """Deletes the given vectors unless a Record holding their object hash was written since the
501
+ marking under a schema whose templates were not read (or under a template of their
502
+ fingerprint): such a Record may be waiting for exactly that vector."""
503
+
504
+
505
+ class _Steps:
506
+ """Transactions of a collection on one connection; nested use joins the open one."""
507
+
508
+ def __init__(self, connection: AsyncConnection, domain_id: str) -> None:
509
+ self._connection = connection
510
+ self._domain = domain_id
511
+ self._open = False
512
+
513
+ @asynccontextmanager
514
+ async def __call__(self) -> AsyncIterator[None]:
515
+ if self._open:
516
+ yield
517
+ return
518
+ self._open = True
519
+ try:
520
+ async with self._connection.begin():
521
+ await scope_connection(self._connection, self._domain)
522
+ yield
523
+ finally:
524
+ self._open = False
525
+
526
+
527
+ async def scope_connection(connection: AsyncConnection, domain_id: str) -> None:
528
+ """Make the rows of `domain_id` visible to the current transaction (row-level security)."""
529
+ if "," in domain_id:
530
+ raise ValueError("Domain ids cannot contain commas")
531
+ await connection.execute(
532
+ text("SELECT set_config(:name, :domain, true)"),
533
+ {"name": DOMAIN_IDS_SETTING, "domain": domain_id},
534
+ )
535
+
536
+
537
+ class _Collector:
538
+ def __init__(
539
+ self,
540
+ connection: AsyncConnection,
541
+ domain_id: str,
542
+ settings: GcSettings,
543
+ *,
544
+ dry_run: bool,
545
+ cutoff: datetime,
546
+ hook: PhaseHook | None,
547
+ ) -> None:
548
+ self._conn = connection
549
+ self._domain = domain_id
550
+ self._cfg = settings
551
+ self._dry = dry_run
552
+ self._cutoff = cutoff
553
+ self._hook = hook
554
+ self._step = _Steps(connection, domain_id)
555
+ self._marker = Marker(connection, domain_id, batch=settings.mark_batch, step=self._step)
556
+ self.report = GcReport(domain_id, dry_run, cutoff.isoformat())
557
+
558
+ async def run(self) -> GcReport:
559
+ report = self.report
560
+ started = time.monotonic()
561
+ await self._marker.prepare()
562
+ await self._marker.mark(cutoff=self._cutoff)
563
+ stats = await self._marker.stats()
564
+ report.marked_revisions = stats.revisions
565
+ report.marked_nodes = stats.nodes
566
+ report.marked_objects = stats.objects
567
+ report.missing_nodes = stats.missing_nodes
568
+ report.mark_seconds = time.monotonic() - started
569
+ if report.missing_nodes:
570
+ log.warning(
571
+ "Domain %s: %d reachable tree nodes are missing from the store",
572
+ self._domain,
573
+ report.missing_nodes,
574
+ )
575
+ await self._phase(PHASE_MARKED)
576
+ swept = time.monotonic()
577
+ await self._sweep_revisions()
578
+ await self._phase(PHASE_REVISIONS_SWEPT)
579
+ await self._sweep_content(_NODES)
580
+ await self._phase(PHASE_NODES_SWEPT)
581
+ await self._sweep_content(_OBJECTS)
582
+ await self._phase(PHASE_OBJECTS_SWEPT)
583
+ await self._sweep_vectors()
584
+ await self._phase(PHASE_VECTORS_SWEPT)
585
+ report.sweep_seconds = time.monotonic() - swept
586
+ return report
587
+
588
+ async def _phase(self, name: str) -> None:
589
+ log.info("gc %s: %s", self._domain, name)
590
+ if self._hook is not None:
591
+ await self._hook(name)
592
+
593
+ # Revisions --------------------------------------------------------------------
594
+
595
+ async def _sweep_revisions(self) -> None:
596
+ domain = {"d": self._domain}
597
+ async with self._step():
598
+ await self._conn.execute(_DEAD_FILL, domain)
599
+ if self._dry:
600
+ await self._count_revisions()
601
+ return
602
+ page = self._cfg.sweep_batch_rows
603
+ after: tuple[int, bytes] | None = None
604
+ while True:
605
+ async with self._step():
606
+ query = _DEAD_FIRST if after is None else _DEAD_NEXT
607
+ params: dict[str, Any] = {"n": page}
608
+ if after is not None:
609
+ params.update(g=after[0], i=after[1])
610
+ rows = (await self._conn.execute(query, params)).all()
611
+ if not rows:
612
+ break
613
+ after = (int(rows[-1][1]), bytes(rows[-1][0]))
614
+ await self._guarded(partial(self._delete_revisions, [bytes(row[0]) for row in rows]))
615
+ async with self._step():
616
+ result = await self._conn.execute(_PROV_DELETE, domain)
617
+ self.report.provenance = int(result.rowcount)
618
+
619
+ async def _count_revisions(self) -> None:
620
+ domain = {"d": self._domain}
621
+ report = self.report
622
+ async with self._step():
623
+ report.revisions = int(
624
+ (await self._conn.execute(text("SELECT count(*) FROM lb_gc_dead"))).scalar_one()
625
+ )
626
+ if not report.revisions:
627
+ return
628
+ report.changesets = int(
629
+ (await self._conn.execute(_DRY_CHANGESETS, domain)).scalar_one()
630
+ )
631
+ report.history_rows = int((await self._conn.execute(_DRY_HISTORY, domain)).scalar_one())
632
+ report.provenance = int((await self._conn.execute(_PROV_DRY, domain)).scalar_one())
633
+
634
+ async def _delete_revisions(self, ids: list[bytes]) -> None:
635
+ """Delete the still unmarked Revisions among `ids` (inside a guarded transaction)."""
636
+ domain = {"d": self._domain}
637
+ doomed = [bytes(r) for r in (await self._conn.execute(_DEAD_STILL, {"ids": ids})).scalars()]
638
+ if not doomed:
639
+ return
640
+ params = {**domain, "ids": doomed}
641
+ await self._conn.execute(_PROV_COLLECT, params)
642
+ report = self.report
643
+ report.history_rows += int((await self._conn.execute(_HISTORY_DELETE, params)).rowcount)
644
+ await self._conn.execute(_PARENTS_DELETE, params)
645
+ report.changesets += int((await self._conn.execute(_CHANGESETS_DELETE, params)).rowcount)
646
+ report.revisions += int((await self._conn.execute(_REVISIONS_DELETE, params)).rowcount)
647
+
648
+ # Nodes and objects ------------------------------------------------------------
649
+
650
+ async def _sweep_content(self, content: _Content) -> None:
651
+ domain = {"d": self._domain}
652
+ if self._dry:
653
+ async with self._step():
654
+ count, size = (await self._conn.execute(_unmarked_total(content), domain)).one()
655
+ self._add(content, int(count), int(size))
656
+ return
657
+ page_query = _unmarked_page(content)
658
+ delete_query = _delete_unmarked(content)
659
+ last = b""
660
+ while True:
661
+ async with self._step():
662
+ page = [
663
+ bytes(h)
664
+ for h in (
665
+ await self._conn.execute(
666
+ page_query, {**domain, "last": last, "n": self._cfg.sweep_batch_rows}
667
+ )
668
+ ).scalars()
669
+ ]
670
+ if not page:
671
+ break
672
+ last = page[-1]
673
+
674
+ async def delete(hashes: list[bytes] = page) -> None:
675
+ count, size = (
676
+ await self._conn.execute(delete_query, {**domain, "hs": hashes})
677
+ ).one()
678
+ self._add(content, int(count), int(size))
679
+
680
+ await self._guarded(delete)
681
+
682
+ # Vectors ----------------------------------------------------------------------
683
+
684
+ async def _sweep_vectors(self) -> None:
685
+ """Delete the embeddings no branch head references (any model): those whose object hash
686
+ no branch holds, and those whose template fingerprint is not the one of the Kind of any
687
+ Record holding it, in that Record's branch schema.
688
+
689
+ The templates of each distinct schema and the set of referenced `(object hash,
690
+ fingerprint)` pairs are read once; each batch is deleted inside the guard (branch locks
691
+ held, so no commit changes `mat_records` or `mat_schema`) after checking the pairs
692
+ against `mat_records` again, so a Record written since is never affected.
693
+ """
694
+ domain = {"d": self._domain}
695
+ async with self._step():
696
+ hashes = [
697
+ bytes(h) for h in (await self._conn.execute(_SCHEMA_HASHES, domain)).scalars()
698
+ ]
699
+ for schema_hash in hashes:
700
+ compiled = (
701
+ await self._conn.execute(_SCHEMA_DOCUMENT, {**domain, "h": schema_hash})
702
+ ).scalar_one_or_none()
703
+ if compiled is None:
704
+ continue
705
+ fingerprints = template_fingerprints(load_compiled(compiled).kinds.values())
706
+ await self._conn.execute(_SCHEMA_KNOWN, {"h": schema_hash})
707
+ if fingerprints:
708
+ await self._conn.execute(
709
+ _TEMPLATE_FILL,
710
+ {
711
+ "h": schema_hash,
712
+ "kinds": list(fingerprints),
713
+ "fps": list(fingerprints.values()),
714
+ },
715
+ )
716
+ await self._conn.execute(_VECTORS_LIVE, domain)
717
+ if self._dry:
718
+ async with self._step():
719
+ self.report.embeddings = int(
720
+ (await self._conn.execute(_VECTORS_TOTAL, domain)).scalar_one()
721
+ )
722
+ return
723
+ last: tuple[bytes, str] = (b"", "")
724
+ while True:
725
+ async with self._step():
726
+ page = [
727
+ (bytes(h), str(f))
728
+ for h, f in (
729
+ await self._conn.execute(
730
+ _VECTORS_PAGE,
731
+ {
732
+ **domain,
733
+ "last": last[0],
734
+ "last_fingerprint": last[1],
735
+ "n": self._cfg.sweep_batch_rows,
736
+ },
737
+ )
738
+ ).all()
739
+ ]
740
+ if not page:
741
+ break
742
+ last = page[-1]
743
+
744
+ async def delete(pairs: list[tuple[bytes, str]] = page) -> None:
745
+ result = await self._conn.execute(
746
+ _VECTORS_DELETE,
747
+ {**domain, "hs": [h for h, _ in pairs], "fs": [f for _, f in pairs]},
748
+ )
749
+ self.report.embeddings += int(result.rowcount)
750
+
751
+ await self._guarded(delete)
752
+
753
+ def _add(self, content: _Content, count: int, size: int) -> None:
754
+ if content is _NODES:
755
+ self.report.nodes += count
756
+ self.report.node_bytes += size
757
+ else:
758
+ self.report.objects += count
759
+ self.report.object_bytes += size
760
+
761
+ # Guard ------------------------------------------------------------------------
762
+
763
+ async def _guarded(self, body: Callable[[], Awaitable[None]]) -> None:
764
+ """Run `body` in a transaction holding every branch lock, after marking again.
765
+
766
+ Waits (retrying without holding anything) while a commit holds a branch lock.
767
+ """
768
+ deadline = time.monotonic() + self._cfg.guard_wait_seconds
769
+ while True:
770
+ try:
771
+ async with self._step():
772
+ await self._lock_branches()
773
+ await self._marker.mark(attempts=True)
774
+ await body()
775
+ return
776
+ except _BranchBusyError:
777
+ if time.monotonic() >= deadline:
778
+ raise GcError(
779
+ f"Domain {self._domain!r}: commits held branch locks for "
780
+ f"{self._cfg.guard_wait_seconds} s; collection stopped, "
781
+ "progress so far is kept"
782
+ ) from None
783
+ await asyncio.sleep(self._cfg.guard_retry_ms / 1000)
784
+
785
+ async def _lock_branches(self) -> None:
786
+ if not self._cfg.guard_branch_locks:
787
+ return
788
+ locked: set[str] = set()
789
+ for _ in range(3):
790
+ names = [
791
+ str(n)
792
+ for n in (await self._conn.execute(_REF_NAMES, {"d": self._domain})).scalars()
793
+ ]
794
+ if len(names) > self._cfg.max_guarded_branches:
795
+ raise GcError(
796
+ f"Domain {self._domain!r} has {len(names)} refs, more than "
797
+ f"gc.max_guarded_branches ({self._cfg.max_guarded_branches})"
798
+ )
799
+ fresh = [n for n in names if n not in locked]
800
+ if not fresh:
801
+ return
802
+ keys = sorted(advisory_lock_key(self._domain, n) for n in fresh)
803
+ if not (await self._conn.execute(_TRY_LOCKS, {"ks": keys})).scalar_one():
804
+ raise _BranchBusyError
805
+ locked.update(fresh)
806
+ raise _BranchBusyError # refs keep appearing; try again
807
+
808
+
809
+ # --------------------------------------------------------------------------------------
810
+ # Entry points
811
+
812
+
813
+ async def collect_garbage(
814
+ engine: AsyncEngine,
815
+ domain_id: str,
816
+ *,
817
+ runtime: StoreRuntime | None = None,
818
+ dry_run: bool = False,
819
+ grace_seconds: int | None = None,
820
+ now: datetime | None = None,
821
+ hook: PhaseHook | None = None,
822
+ ) -> GcReport:
823
+ """Collect the garbage of one Domain, or report what would go with `dry_run`.
824
+
825
+ Revisions created less than `grace_seconds` (default: `store.gc.grace_seconds`) before
826
+ `now` (default: the current time) are kept with everything they reach. Raises
827
+ `DomainNotFoundError`, `GcBusyError` when another collection of the Domain runs, and
828
+ `GcError` when commits hold branch locks for too long.
829
+ """
830
+ settings = (runtime or default_runtime()).settings.gc
831
+ grace = settings.grace_seconds if grace_seconds is None else grace_seconds
832
+ cutoff = (now or datetime.now(UTC)) - timedelta(seconds=grace)
833
+ key = gc_lock_key(domain_id)
834
+ async with engine.connect() as connection:
835
+ async with connection.begin():
836
+ found = await connection.execute(
837
+ text("SELECT 1 FROM domains WHERE id = :d"), {"d": domain_id}
838
+ )
839
+ if found.first() is None:
840
+ raise DomainNotFoundError(domain_id)
841
+ acquired = await connection.execute(text("SELECT pg_try_advisory_lock(:k)"), {"k": key})
842
+ if not acquired.scalar_one():
843
+ raise GcBusyError(f"a collection of Domain {domain_id!r} is already running")
844
+ collector = _Collector(
845
+ connection, domain_id, settings, dry_run=dry_run, cutoff=cutoff, hook=hook
846
+ )
847
+ try:
848
+ report = await collector.run()
849
+ finally:
850
+ if connection.in_transaction():
851
+ await connection.rollback()
852
+ async with connection.begin():
853
+ await connection.execute(text("DROP TABLE IF EXISTS " + ", ".join(_MARK_TABLES)))
854
+ await connection.execute(text("SELECT pg_advisory_unlock(:k)"), {"k": key})
855
+ _record(report)
856
+ return report
857
+
858
+
859
+ def _record(report: GcReport) -> None:
860
+ GC_RUNS.add(1, {"dry_run": str(report.dry_run).lower()})
861
+ if not report.dry_run:
862
+ GC_FREED_BYTES.add(report.node_bytes, {"content": "nodes"})
863
+ GC_FREED_BYTES.add(report.object_bytes, {"content": "objects"})
864
+
865
+
866
+ async def run_gc_job(
867
+ engine: AsyncEngine, job: JobRow, *, runtime: StoreRuntime | None = None
868
+ ) -> dict[str, Any]:
869
+ """Run a `gc` job: payload `{"dry_run": bool, "grace_seconds": int}` (both optional)."""
870
+ if job.domain_id is None:
871
+ raise GcError("a gc job needs a domain_id")
872
+ report = await collect_garbage(
873
+ engine,
874
+ job.domain_id,
875
+ runtime=runtime,
876
+ dry_run=bool(job.payload.get("dry_run", False)),
877
+ grace_seconds=job.payload.get("grace_seconds"),
878
+ )
879
+ return report.as_dict()
880
+
881
+
882
+ async def enqueue_gc(
883
+ session: AsyncSession,
884
+ domain_id: str,
885
+ *,
886
+ dry_run: bool = False,
887
+ grace_seconds: int | None = None,
888
+ ) -> int:
889
+ """Queue a `gc` job for a Domain; an identical queued or running job is reused."""
890
+ from lorebase_store.jobs import GC_JOB, enqueue_job
891
+
892
+ payload: dict[str, Any] = {"dry_run": dry_run}
893
+ if grace_seconds is not None:
894
+ payload["grace_seconds"] = grace_seconds
895
+ return await enqueue_job(session, GC_JOB, domain_id=domain_id, payload=payload, unique=True)
896
+
897
+
898
+ # --------------------------------------------------------------------------------------
899
+ # Command line
900
+
901
+
902
+ def _parser() -> argparse.ArgumentParser:
903
+ parser = argparse.ArgumentParser(
904
+ prog="python -m lorebase_store.gc",
905
+ description="Garbage collection of unreachable Revisions, tree nodes and objects. "
906
+ "Reads the database and store.gc settings from the Lorebase configuration.",
907
+ )
908
+ parser.add_argument("--domain", action="append", default=[], help="Domain id (repeatable).")
909
+ parser.add_argument("--all", action="store_true", help="Every Domain of the installation.")
910
+ parser.add_argument("--dry-run", action="store_true", help="Report what would be deleted.")
911
+ parser.add_argument(
912
+ "--grace-seconds", type=int, default=None, help="Override store.gc.grace_seconds."
913
+ )
914
+ parser.add_argument(
915
+ "--enqueue", action="store_true", help="Queue a gc job per Domain instead of running."
916
+ )
917
+ return parser
918
+
919
+
920
+ async def _main(args: argparse.Namespace) -> int:
921
+ from sqlalchemy.ext.asyncio import async_sessionmaker
922
+
923
+ from lorebase_platform.settings import load_settings
924
+ from lorebase_store.db import create_engine
925
+ from lorebase_store.repositories.domains import DomainRepository
926
+ from lorebase_store.settings import database_settings
927
+
928
+ settings = load_settings()
929
+ runtime = StoreRuntime.from_loaded(settings)
930
+ engine = create_engine(database_settings(settings), pooled=False, statement_timeout_ms=0)
931
+ status = 0
932
+ try:
933
+ domains: list[str] = list(args.domain)
934
+ if args.all:
935
+ async with async_sessionmaker(engine)() as session:
936
+ domains = [row.id for row in await DomainRepository(session).list(limit=100000)]
937
+ if not domains:
938
+ raise SystemExit("name a Domain with --domain, or use --all")
939
+ for domain in domains:
940
+ if args.enqueue:
941
+ async with async_sessionmaker(engine)() as session, session.begin():
942
+ job = await enqueue_gc(
943
+ session, domain, dry_run=args.dry_run, grace_seconds=args.grace_seconds
944
+ )
945
+ sys.stdout.write(json.dumps({"domain_id": domain, "job_id": job}) + "\n")
946
+ continue
947
+ try:
948
+ report = await collect_garbage(
949
+ engine,
950
+ domain,
951
+ runtime=runtime,
952
+ dry_run=args.dry_run,
953
+ grace_seconds=args.grace_seconds,
954
+ )
955
+ except (GcError, DomainNotFoundError) as error:
956
+ sys.stderr.write(f"{domain}: {error}\n")
957
+ status = 1
958
+ continue
959
+ sys.stdout.write(json.dumps(report.as_dict()) + "\n")
960
+ finally:
961
+ await engine.dispose()
962
+ return status
963
+
964
+
965
+ def main(argv: Sequence[str] | None = None) -> int:
966
+ return asyncio.run(_main(_parser().parse_args(argv)))
967
+
968
+
969
+ if __name__ == "__main__":
970
+ raise SystemExit(main())