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
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())
|