superlocalmemory 4.0.10 → 4.1.2
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.
- package/.claude-plugin/marketplace.json +12 -2
- package/CHANGELOG.md +244 -0
- package/README.md +40 -75
- package/package.json +6 -3
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +4 -4
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +2 -2
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +5 -5
- package/plugin/skills/slm-recall/SKILL.md +102 -15
- package/plugin/skills/slm-remember/SKILL.md +35 -3
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +29 -3
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +16 -8
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +2 -2
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -5
- package/plugin-src/skills/slm-recall/SKILL.md +102 -15
- package/plugin-src/skills/slm-remember/SKILL.md +35 -3
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +29 -3
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +357 -18
- package/src/superlocalmemory/cli/daemon.py +30 -0
- package/src/superlocalmemory/cli/db_migrate.py +71 -1
- package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
- package/src/superlocalmemory/cli/main.py +24 -2
- package/src/superlocalmemory/code_graph/database.py +44 -0
- package/src/superlocalmemory/compliance/gdpr.py +449 -39
- package/src/superlocalmemory/core/admission.py +231 -11
- package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
- package/src/superlocalmemory/core/config.py +90 -11
- package/src/superlocalmemory/core/consolidation_engine.py +34 -0
- package/src/superlocalmemory/core/engine.py +140 -11
- package/src/superlocalmemory/core/graph_analyzer.py +76 -112
- package/src/superlocalmemory/core/graph_metrics.py +597 -0
- package/src/superlocalmemory/core/graph_pruner.py +121 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/projection_drain.py +380 -0
- package/src/superlocalmemory/core/recall_pipeline.py +390 -3
- package/src/superlocalmemory/core/recall_worker.py +6 -3
- package/src/superlocalmemory/core/scale_autopromote.py +196 -0
- package/src/superlocalmemory/core/scale_engine.py +16 -2
- package/src/superlocalmemory/core/score_contract.py +21 -1
- package/src/superlocalmemory/core/session_identity.py +85 -0
- package/src/superlocalmemory/core/status_contract.py +108 -0
- package/src/superlocalmemory/core/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
- package/src/superlocalmemory/encoding/context_generator.py +1 -1
- package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
- package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
- package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
- package/src/superlocalmemory/encoding/type_router.py +12 -12
- package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
- package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
- package/src/superlocalmemory/graph/cozo_backend.py +103 -138
- package/src/superlocalmemory/hooks/portable_kit.py +10 -2
- package/src/superlocalmemory/learning/bandit.py +43 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
- package/src/superlocalmemory/learning/database.py +60 -3
- package/src/superlocalmemory/learning/entity_compiler.py +21 -58
- package/src/superlocalmemory/learning/feedback.py +3 -1
- package/src/superlocalmemory/learning/outcomes.py +47 -16
- package/src/superlocalmemory/learning/pattern_miner.py +28 -3
- package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
- package/src/superlocalmemory/learning/pcos.py +291 -0
- package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
- package/src/superlocalmemory/learning/reward_proxy.py +100 -10
- package/src/superlocalmemory/learning/signal_kinds.py +79 -0
- package/src/superlocalmemory/mcp/profiles.py +14 -2
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +31 -3
- package/src/superlocalmemory/mcp/tools_v28.py +20 -1
- package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/server/api.py +24 -5
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_health.py +87 -10
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/behavioral.py +75 -10
- package/src/superlocalmemory/server/routes/compliance.py +98 -18
- package/src/superlocalmemory/server/routes/config_api.py +186 -4
- package/src/superlocalmemory/server/routes/evolution.py +178 -0
- package/src/superlocalmemory/server/routes/ingest.py +8 -0
- package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
- package/src/superlocalmemory/server/routes/memories.py +49 -7
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +191 -15
- package/src/superlocalmemory/server/ui.py +20 -4
- package/src/superlocalmemory/server/unified_daemon.py +241 -7
- package/src/superlocalmemory/storage/_migration_internals.py +54 -2
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +477 -59
- package/src/superlocalmemory/storage/embedding_codec.py +71 -0
- package/src/superlocalmemory/storage/lineage_retention.py +236 -0
- package/src/superlocalmemory/storage/logical_edges.py +43 -2
- package/src/superlocalmemory/storage/migration_runner.py +119 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
- package/src/superlocalmemory/storage/migrations.py +18 -2
- package/src/superlocalmemory/storage/models.py +40 -1
- package/src/superlocalmemory/storage/projection_outbox.py +346 -0
- package/src/superlocalmemory/storage/retention_policy.py +860 -0
- package/src/superlocalmemory/storage/schema.py +35 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +9 -18
- package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
- package/src/superlocalmemory/ui/js/od-health.js +28 -6
- package/src/superlocalmemory/ui/js/od-memories.js +19 -0
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""What bounds every table that only ever grows.
|
|
6
|
+
|
|
7
|
+
WHY A REGISTRY RATHER THAN FOUR MORE PRUNERS
|
|
8
|
+
--------------------------------------------
|
|
9
|
+
Three tables already had a pruner and each was written separately, wired
|
|
10
|
+
separately, and configured separately. The fourth unbounded table was found by
|
|
11
|
+
someone looking at a disk-usage report, and the fifth by someone looking at the
|
|
12
|
+
fourth. That is not a maintenance strategy, it is a sequence of accidents.
|
|
13
|
+
|
|
14
|
+
So the policy is declared here, per table, and one pass enforces all of them. A
|
|
15
|
+
table that appends without a policy is a defect the test suite can see, which is
|
|
16
|
+
the property none of the individual pruners could give us.
|
|
17
|
+
|
|
18
|
+
MEASURED, ON TWO REAL STORES
|
|
19
|
+
----------------------------
|
|
20
|
+
The live store is small enough that nothing looks urgent. The larger one shows
|
|
21
|
+
where each table is going:
|
|
22
|
+
|
|
23
|
+
table live rows larger store what it was doing
|
|
24
|
+
derivation_lineage 260,346 22,020 48.8 rows per memory,
|
|
25
|
+
690 for one of them
|
|
26
|
+
temporal_events 16,837 118,210 70.7% pointing at
|
|
27
|
+
memories already deleted
|
|
28
|
+
fact_access_log 5,388 90,759 34% older than 90 days
|
|
29
|
+
consolidation_log 4,318 29,743 99.5% older than 30 days
|
|
30
|
+
tool_events 2,002 39,444 28 MB, no policy at all
|
|
31
|
+
schema_version 3,496 234,348 SEVEN distinct versions
|
|
32
|
+
|
|
33
|
+
That last row is not a retention problem and a TTL would be the wrong fix for
|
|
34
|
+
it: the table has no unique constraint, so the six ``INSERT OR IGNORE`` call
|
|
35
|
+
sites that all believe they are idempotent append a duplicate every time. It is
|
|
36
|
+
registered here as ``NONE`` with that reason, and repaired by a migration.
|
|
37
|
+
|
|
38
|
+
WHAT EACH KIND MEANS, AND WHY THE CONJUNCTION EXISTS
|
|
39
|
+
----------------------------------------------------
|
|
40
|
+
``TTL_AND_CAP`` deletes a row only when it is BOTH older than the window AND
|
|
41
|
+
beyond the per-key cap. Either rule alone is unsafe here. A pure TTL on
|
|
42
|
+
``fact_access_log`` would delete the access history of a memory nobody has
|
|
43
|
+
touched for months -- which is precisely the memory whose history the decay
|
|
44
|
+
dynamics need. A pure cap would delete this morning's accesses on a busy
|
|
45
|
+
memory. The conjunction cannot destroy recent signal and cannot leave a key
|
|
46
|
+
unbounded, because time eventually satisfies the first clause for every row.
|
|
47
|
+
|
|
48
|
+
``CAP_PER_KEY`` without a window is right where old rows are *superseded* rather
|
|
49
|
+
than historical: re-deriving an object writes its lineage again, and the tenth
|
|
50
|
+
re-derivation of the same object tells you nothing the newest one does not.
|
|
51
|
+
|
|
52
|
+
``ORPHAN`` is for a table with no insertion timestamp. ``temporal_events`` has
|
|
53
|
+
``observation_date`` and ``referenced_date``, which are when the event happened,
|
|
54
|
+
not when the row was written -- so a TTL against either would delete a memory of
|
|
55
|
+
something long ago that was recorded this morning. Its bound is that the memory
|
|
56
|
+
it describes must still exist.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import logging
|
|
62
|
+
import sqlite3
|
|
63
|
+
import time
|
|
64
|
+
from dataclasses import dataclass
|
|
65
|
+
from enum import Enum
|
|
66
|
+
from typing import Any
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RetentionKind(Enum):
|
|
72
|
+
"""How a table is kept from growing without limit."""
|
|
73
|
+
|
|
74
|
+
TTL_AND_CAP = "ttl_and_cap"
|
|
75
|
+
TTL = "ttl"
|
|
76
|
+
CAP_PER_KEY = "cap_per_key"
|
|
77
|
+
ORPHAN = "orphan"
|
|
78
|
+
#: Already pruned by a named module. Registered so the gate can see it is
|
|
79
|
+
#: covered, and so the module is discoverable from here.
|
|
80
|
+
EXTERNAL = "external"
|
|
81
|
+
#: Bounded by construction, or a growth defect that a policy would mask. The
|
|
82
|
+
#: reason is mandatory and is the whole value of the entry.
|
|
83
|
+
NONE = "none"
|
|
84
|
+
#: Measured growing faster than the store and the right rule is NOT yet
|
|
85
|
+
#: known. This exists so the registry can be honest: writing NONE here would
|
|
86
|
+
#: silence the gate on a table we know is a problem, and inventing a TTL for
|
|
87
|
+
#: data whose lifecycle nobody has established is how a cleanup job deletes
|
|
88
|
+
#: something load-bearing. An entry of this kind must carry its measurement.
|
|
89
|
+
UNRESOLVED = "unresolved"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class RetentionPolicy:
|
|
94
|
+
"""One table's bound, with the reasoning attached to it."""
|
|
95
|
+
|
|
96
|
+
table: str
|
|
97
|
+
kind: RetentionKind
|
|
98
|
+
reason: str
|
|
99
|
+
ttl_days: int | None = None
|
|
100
|
+
timestamp_column: str | None = None
|
|
101
|
+
key_column: str | None = None
|
|
102
|
+
cap_per_key: int | None = None
|
|
103
|
+
#: ``(local_column, "referent_table.referent_column")`` for ORPHAN.
|
|
104
|
+
referent: tuple[str, str] | None = None
|
|
105
|
+
pruned_by: str | None = None
|
|
106
|
+
#: Extra columns that, with ``key_column``, name one key. A cap counts
|
|
107
|
+
#: "the newest N per key", so a key that is not unique across the whole
|
|
108
|
+
#: table counts other rows' entries against a key's allowance. ``profile_id``
|
|
109
|
+
#: is added automatically wherever the table has it and does not need to be
|
|
110
|
+
#: listed here -- see ``_partition_columns``.
|
|
111
|
+
also_partition_by: tuple[str, ...] = ()
|
|
112
|
+
|
|
113
|
+
def __post_init__(self) -> None:
|
|
114
|
+
if not self.reason.strip():
|
|
115
|
+
raise ValueError(f"{self.table}: a policy without a reason is not a policy")
|
|
116
|
+
if self.kind in (RetentionKind.TTL, RetentionKind.TTL_AND_CAP):
|
|
117
|
+
if not self.ttl_days or not self.timestamp_column:
|
|
118
|
+
raise ValueError(f"{self.table}: a time rule needs a window and a column")
|
|
119
|
+
if self.kind in (RetentionKind.CAP_PER_KEY, RetentionKind.TTL_AND_CAP):
|
|
120
|
+
if not self.cap_per_key or not self.key_column:
|
|
121
|
+
raise ValueError(f"{self.table}: a cap rule needs a key and a limit")
|
|
122
|
+
if self.kind is RetentionKind.ORPHAN and not self.referent:
|
|
123
|
+
raise ValueError(f"{self.table}: an orphan rule needs a referent")
|
|
124
|
+
if self.kind is RetentionKind.EXTERNAL and not self.pruned_by:
|
|
125
|
+
raise ValueError(f"{self.table}: say which module prunes it")
|
|
126
|
+
if self.kind is RetentionKind.UNRESOLVED and not any(
|
|
127
|
+
character.isdigit() for character in self.reason
|
|
128
|
+
):
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"{self.table}: an unresolved entry must carry the measurement "
|
|
131
|
+
f"that made it unresolved"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
REGISTERED_POLICIES: dict[str, RetentionPolicy] = {
|
|
136
|
+
policy.table: policy
|
|
137
|
+
for policy in (
|
|
138
|
+
RetentionPolicy(
|
|
139
|
+
table="derivation_lineage",
|
|
140
|
+
kind=RetentionKind.CAP_PER_KEY,
|
|
141
|
+
key_column="object_id",
|
|
142
|
+
also_partition_by=("object_type",),
|
|
143
|
+
cap_per_key=10,
|
|
144
|
+
timestamp_column="created_at",
|
|
145
|
+
reason=(
|
|
146
|
+
"Provenance of a derived object. Re-deriving it appends another "
|
|
147
|
+
"row, so the older ones are superseded rather than historical -- "
|
|
148
|
+
"one object had 690. No time window: the newest ten answer every "
|
|
149
|
+
"question the table is asked, at any age. The orphan half of this "
|
|
150
|
+
"table's upkeep is lineage_retention.prune_orphan_lineage. The "
|
|
151
|
+
"key is the object's id together with its type and workspace, "
|
|
152
|
+
"because the id alone is unique in none of those directions."
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
RetentionPolicy(
|
|
156
|
+
table="fact_access_log",
|
|
157
|
+
kind=RetentionKind.TTL_AND_CAP,
|
|
158
|
+
ttl_days=90,
|
|
159
|
+
timestamp_column="accessed_at",
|
|
160
|
+
key_column="fact_id",
|
|
161
|
+
cap_per_key=100,
|
|
162
|
+
reason=(
|
|
163
|
+
"Feeds tier demotion and the outcome signal, which both read "
|
|
164
|
+
"recent access. Bounded by the conjunction so neither rule can "
|
|
165
|
+
"take the signal on its own: a memory untouched for months keeps "
|
|
166
|
+
"its history, and a busy memory keeps today's."
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
RetentionPolicy(
|
|
170
|
+
table="consolidation_log",
|
|
171
|
+
kind=RetentionKind.TTL,
|
|
172
|
+
ttl_days=30,
|
|
173
|
+
timestamp_column="timestamp",
|
|
174
|
+
reason=(
|
|
175
|
+
"Read only as recent history -- the dashboard timeline and "
|
|
176
|
+
"get_consolidation_history, which takes the newest 50. The column "
|
|
177
|
+
"is `timestamp`; a rule written against `logged_at` would match "
|
|
178
|
+
"nothing and quietly do nothing."
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
RetentionPolicy(
|
|
182
|
+
table="tool_events",
|
|
183
|
+
kind=RetentionKind.TTL,
|
|
184
|
+
ttl_days=180,
|
|
185
|
+
timestamp_column="created_at",
|
|
186
|
+
reason=(
|
|
187
|
+
"Input to the assertion miner and the evolution triggers, which "
|
|
188
|
+
"read recent activity. The window is long because no reader "
|
|
189
|
+
"declares one and the cost of guessing short is a lost signal, "
|
|
190
|
+
"not a bigger file."
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
RetentionPolicy(
|
|
194
|
+
table="temporal_events",
|
|
195
|
+
kind=RetentionKind.ORPHAN,
|
|
196
|
+
referent=("fact_id", "atomic_facts.fact_id"),
|
|
197
|
+
reason=(
|
|
198
|
+
"No insertion timestamp: observation_date and referenced_date are "
|
|
199
|
+
"when the event happened, so a time rule would delete a memory of "
|
|
200
|
+
"something long ago that was recorded today. Its bound is that the "
|
|
201
|
+
"memory it describes still exists -- 70.7% of the rows on the "
|
|
202
|
+
"larger store point at memories already deleted."
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
RetentionPolicy(
|
|
206
|
+
table="provenance",
|
|
207
|
+
kind=RetentionKind.ORPHAN,
|
|
208
|
+
referent=("fact_id", "atomic_facts.fact_id"),
|
|
209
|
+
reason=(
|
|
210
|
+
"Where a memory came from. 45.8% of the rows on the larger store "
|
|
211
|
+
"(9,124 of 19,914) name a memory that no longer exists, so the "
|
|
212
|
+
"cascade does not reach here. Bound is the memory's existence: "
|
|
213
|
+
"provenance of a deleted memory is not provenance."
|
|
214
|
+
),
|
|
215
|
+
),
|
|
216
|
+
RetentionPolicy(
|
|
217
|
+
table="fact_context",
|
|
218
|
+
kind=RetentionKind.ORPHAN,
|
|
219
|
+
referent=("fact_id", "atomic_facts.fact_id"),
|
|
220
|
+
reason=(
|
|
221
|
+
"Generated description and keywords per memory. Tracks the memory "
|
|
222
|
+
"count (2.94x against 2.27x more memories) but leaks on delete: "
|
|
223
|
+
"3.9% orphaned on the larger store, 0.1% on the live one."
|
|
224
|
+
),
|
|
225
|
+
),
|
|
226
|
+
RetentionPolicy(
|
|
227
|
+
table="memory_events",
|
|
228
|
+
kind=RetentionKind.TTL,
|
|
229
|
+
ttl_days=180,
|
|
230
|
+
timestamp_column="created_at",
|
|
231
|
+
reason=(
|
|
232
|
+
"An event log: 0% orphaned, so nothing leaks, but it grew 13.9x "
|
|
233
|
+
"between two stores whose memory counts differ by 2.27x -- it "
|
|
234
|
+
"tracks activity, not content. The window is long because no "
|
|
235
|
+
"reader declares one."
|
|
236
|
+
),
|
|
237
|
+
),
|
|
238
|
+
RetentionPolicy(
|
|
239
|
+
table="action_outcomes",
|
|
240
|
+
kind=RetentionKind.NONE,
|
|
241
|
+
reason=(
|
|
242
|
+
"MUST NOT be pruned. This is the feedback that teaches the ranker "
|
|
243
|
+
"which answers were useful, and it is already sparse -- two rows "
|
|
244
|
+
"in the whole store carry an informative signal, against the fifty "
|
|
245
|
+
"needed to retrain. It grows 35.8x faster than the memory count, "
|
|
246
|
+
"so it will need a rule eventually; taking one now would delete "
|
|
247
|
+
"the evidence the ranker is waiting for."
|
|
248
|
+
),
|
|
249
|
+
),
|
|
250
|
+
RetentionPolicy(
|
|
251
|
+
table="compliance_audit",
|
|
252
|
+
kind=RetentionKind.NONE,
|
|
253
|
+
reason=(
|
|
254
|
+
"The erasure audit trail. Two rows today. Its whole purpose is to "
|
|
255
|
+
"outlive the data it describes, so bounding it by age would "
|
|
256
|
+
"destroy the only record that an erasure happened."
|
|
257
|
+
),
|
|
258
|
+
),
|
|
259
|
+
RetentionPolicy(
|
|
260
|
+
table="atomic_facts",
|
|
261
|
+
kind=RetentionKind.NONE,
|
|
262
|
+
reason=(
|
|
263
|
+
"These ARE the memories. What bounds them is the forgetting curve, "
|
|
264
|
+
"tier demotion and erasure on request -- decisions about meaning, "
|
|
265
|
+
"made elsewhere and visible to the user. A retention job here "
|
|
266
|
+
"would delete someone's memories on a timer."
|
|
267
|
+
),
|
|
268
|
+
),
|
|
269
|
+
RetentionPolicy(
|
|
270
|
+
table="memories",
|
|
271
|
+
kind=RetentionKind.NONE,
|
|
272
|
+
reason=(
|
|
273
|
+
"The raw records the memories are extracted from, and user data on "
|
|
274
|
+
"the same footing. Grew 7.53x against 2.27x more memories because "
|
|
275
|
+
"it tracks conversations rather than extracted facts, but the same "
|
|
276
|
+
"argument holds: this is content, not bookkeeping."
|
|
277
|
+
),
|
|
278
|
+
),
|
|
279
|
+
RetentionPolicy(
|
|
280
|
+
table="embedding_metadata",
|
|
281
|
+
kind=RetentionKind.NONE,
|
|
282
|
+
reason=(
|
|
283
|
+
"Exactly one row per memory -- 5,338 for 5,338 and 12,102 for "
|
|
284
|
+
"12,102 on the two stores, and 0% orphaned. Bounded by the memory "
|
|
285
|
+
"count by construction, and removed with the memory."
|
|
286
|
+
),
|
|
287
|
+
),
|
|
288
|
+
RetentionPolicy(
|
|
289
|
+
table="fact_entity_associations",
|
|
290
|
+
kind=RetentionKind.NONE,
|
|
291
|
+
reason=(
|
|
292
|
+
"Which subjects each memory mentions. 3.16x against 2.27x more "
|
|
293
|
+
"memories, and 0% orphaned on both stores, so it is bounded by the "
|
|
294
|
+
"memory count and the cascade reaches it."
|
|
295
|
+
),
|
|
296
|
+
),
|
|
297
|
+
RetentionPolicy(
|
|
298
|
+
table="core_memory_blocks",
|
|
299
|
+
kind=RetentionKind.NONE,
|
|
300
|
+
reason=(
|
|
301
|
+
"The always-present context blocks: 10 rows on one store and 20 on "
|
|
302
|
+
"the other. Bounded by the number of block categories, which is a "
|
|
303
|
+
"number we choose."
|
|
304
|
+
),
|
|
305
|
+
),
|
|
306
|
+
RetentionPolicy(
|
|
307
|
+
table="memory_scenes",
|
|
308
|
+
kind=RetentionKind.UNRESOLVED,
|
|
309
|
+
reason=(
|
|
310
|
+
"Grew 27.15x between two stores whose memory counts differ by "
|
|
311
|
+
"2.27x: 422 rows against 11,459. Keyed by a JSON list of memory "
|
|
312
|
+
"ids rather than a column, so no single-column rule reaches it, "
|
|
313
|
+
"and whether an old scene is superseded by a newer one or is "
|
|
314
|
+
"history worth keeping has not been established. Naming that "
|
|
315
|
+
"here rather than inventing a window for it."
|
|
316
|
+
),
|
|
317
|
+
),
|
|
318
|
+
RetentionPolicy(
|
|
319
|
+
table="mesh_events",
|
|
320
|
+
kind=RetentionKind.UNRESOLVED,
|
|
321
|
+
reason=(
|
|
322
|
+
"Grew 65.83x between the two stores -- 6 rows against 395 -- the "
|
|
323
|
+
"fastest of any table measured. Small in absolute terms because "
|
|
324
|
+
"the mesh has never run with a second node, which is also why its "
|
|
325
|
+
"real growth rate is unknown. A window guessed from 395 rows "
|
|
326
|
+
"would be a guess."
|
|
327
|
+
),
|
|
328
|
+
),
|
|
329
|
+
RetentionPolicy(
|
|
330
|
+
table="soft_prompt_templates",
|
|
331
|
+
kind=RetentionKind.CAP_PER_KEY,
|
|
332
|
+
key_column="category",
|
|
333
|
+
cap_per_key=5,
|
|
334
|
+
timestamp_column="created_at",
|
|
335
|
+
reason=(
|
|
336
|
+
"Versioned: storing a prompt deactivates the previous one for its "
|
|
337
|
+
"category and inserts a new row, so the count rises by one per "
|
|
338
|
+
"category per consolidation cycle and never falls. 44 rows on the "
|
|
339
|
+
"live store, of which 2 are active -- two categories times "
|
|
340
|
+
"twenty-two cycles. Keeping five versions leaves room to see what "
|
|
341
|
+
"changed without keeping every cycle forever."
|
|
342
|
+
),
|
|
343
|
+
),
|
|
344
|
+
RetentionPolicy(
|
|
345
|
+
table="rbac_sessions",
|
|
346
|
+
kind=RetentionKind.TTL,
|
|
347
|
+
ttl_days=30,
|
|
348
|
+
timestamp_column="created_at",
|
|
349
|
+
reason=(
|
|
350
|
+
"Login sessions. 0 rows today because company mode has never run "
|
|
351
|
+
"with users, which is exactly why it needs a rule before it does. "
|
|
352
|
+
"Expired sessions are also purged once when the daemon starts, "
|
|
353
|
+
"which is not a bound: a daemon that has been up for 25 hours "
|
|
354
|
+
"accumulates for 25 hours. This is the recurring half."
|
|
355
|
+
),
|
|
356
|
+
),
|
|
357
|
+
RetentionPolicy(
|
|
358
|
+
table="skill_evolution_log",
|
|
359
|
+
kind=RetentionKind.TTL,
|
|
360
|
+
ttl_days=365,
|
|
361
|
+
timestamp_column="created_at",
|
|
362
|
+
reason=(
|
|
363
|
+
"The record of automatic changes to an agent's own instructions. "
|
|
364
|
+
"0 rows today. A year is deliberately long: this is the audit "
|
|
365
|
+
"trail for a mutation a person approved, and it should outlive "
|
|
366
|
+
"any question about why the behaviour changed."
|
|
367
|
+
),
|
|
368
|
+
),
|
|
369
|
+
RetentionPolicy(
|
|
370
|
+
table="ccq_audit_log",
|
|
371
|
+
kind=RetentionKind.NONE,
|
|
372
|
+
reason=(
|
|
373
|
+
"An audit log, 0 rows today. Its purpose is to outlive what it "
|
|
374
|
+
"describes, so bounding it by age would destroy the record rather "
|
|
375
|
+
"than the storage cost."
|
|
376
|
+
),
|
|
377
|
+
),
|
|
378
|
+
RetentionPolicy(
|
|
379
|
+
table="projection_obligations",
|
|
380
|
+
kind=RetentionKind.NONE,
|
|
381
|
+
reason=(
|
|
382
|
+
"Outstanding projection work: 1,305 rows on the live store and a "
|
|
383
|
+
"row is removed when the obligation is discharged. Bounded by work "
|
|
384
|
+
"in flight, and a rule here would drop unfinished work exactly the "
|
|
385
|
+
"way a timeout on the outbox would."
|
|
386
|
+
),
|
|
387
|
+
),
|
|
388
|
+
RetentionPolicy(
|
|
389
|
+
table="projection_tombstones",
|
|
390
|
+
kind=RetentionKind.NONE,
|
|
391
|
+
reason=(
|
|
392
|
+
"One row per deletion still to be applied to a projection -- 1 on "
|
|
393
|
+
"the live store. Bounded by pending deletions, and dropping one "
|
|
394
|
+
"would leave a deleted memory in a projection."
|
|
395
|
+
),
|
|
396
|
+
),
|
|
397
|
+
RetentionPolicy(
|
|
398
|
+
table="completion_manifests",
|
|
399
|
+
kind=RetentionKind.NONE,
|
|
400
|
+
reason=(
|
|
401
|
+
"One per completed ingestion batch, 435 on the live store, removed "
|
|
402
|
+
"with the batch it describes. Bounded by ingestion in flight."
|
|
403
|
+
),
|
|
404
|
+
),
|
|
405
|
+
RetentionPolicy(
|
|
406
|
+
table="fact_consolidations",
|
|
407
|
+
kind=RetentionKind.NONE,
|
|
408
|
+
reason=(
|
|
409
|
+
"Which memories were merged into which: 2,432 rows against 5,338 "
|
|
410
|
+
"memories. Bounded by the memory count, and the row is the only "
|
|
411
|
+
"record that a merge happened, so age is the wrong axis."
|
|
412
|
+
),
|
|
413
|
+
),
|
|
414
|
+
RetentionPolicy(
|
|
415
|
+
table="consolidated_summaries",
|
|
416
|
+
kind=RetentionKind.NONE,
|
|
417
|
+
reason=(
|
|
418
|
+
"One summary per group of memories, 1,137 on the live store. "
|
|
419
|
+
"Bounded by the number of groups, which is bounded by the memory "
|
|
420
|
+
"count; a summary is replaced rather than appended."
|
|
421
|
+
),
|
|
422
|
+
),
|
|
423
|
+
RetentionPolicy(
|
|
424
|
+
table="ingestion_operations",
|
|
425
|
+
kind=RetentionKind.NONE,
|
|
426
|
+
reason=(
|
|
427
|
+
"742 rows on the live store and 117 on the larger one -- it went "
|
|
428
|
+
"DOWN as the store grew, so something already collects it. "
|
|
429
|
+
"Bounded, and adding a second rule would race the first."
|
|
430
|
+
),
|
|
431
|
+
),
|
|
432
|
+
RetentionPolicy(
|
|
433
|
+
table="correction_cases",
|
|
434
|
+
kind=RetentionKind.NONE,
|
|
435
|
+
reason=(
|
|
436
|
+
"One per correction a person proposed, 90 on the live store. This "
|
|
437
|
+
"is the ledger of who changed what and why; deleting an old case "
|
|
438
|
+
"removes the answer to a question about a memory that still exists."
|
|
439
|
+
),
|
|
440
|
+
),
|
|
441
|
+
RetentionPolicy(
|
|
442
|
+
table="behavioral_assertions",
|
|
443
|
+
kind=RetentionKind.NONE,
|
|
444
|
+
reason=(
|
|
445
|
+
"What the system has concluded about how it is used: 9 rows. "
|
|
446
|
+
"Reinforced in place rather than appended, so bounded by the number "
|
|
447
|
+
"of distinct conclusions."
|
|
448
|
+
),
|
|
449
|
+
),
|
|
450
|
+
RetentionPolicy(
|
|
451
|
+
table="feedback_records",
|
|
452
|
+
kind=RetentionKind.NONE,
|
|
453
|
+
reason=(
|
|
454
|
+
"Explicit feedback on an answer, 1 row. Sparse and load-bearing "
|
|
455
|
+
"for ranking quality; the same argument as the outcome feedback "
|
|
456
|
+
"above, at an even smaller count."
|
|
457
|
+
),
|
|
458
|
+
),
|
|
459
|
+
RetentionPolicy(
|
|
460
|
+
table="profiles",
|
|
461
|
+
kind=RetentionKind.NONE,
|
|
462
|
+
reason=(
|
|
463
|
+
"2 rows. Bounded by the number of workspaces a person creates, and "
|
|
464
|
+
"removed by erasure rather than by age."
|
|
465
|
+
),
|
|
466
|
+
),
|
|
467
|
+
RetentionPolicy(
|
|
468
|
+
table="polar_embeddings",
|
|
469
|
+
kind=RetentionKind.NONE,
|
|
470
|
+
reason=(
|
|
471
|
+
"0 rows; an alternate vector representation, at most one per "
|
|
472
|
+
"memory when written. Bounded by the memory count by construction."
|
|
473
|
+
),
|
|
474
|
+
),
|
|
475
|
+
RetentionPolicy(
|
|
476
|
+
table="embedding_quantization_metadata",
|
|
477
|
+
kind=RetentionKind.NONE,
|
|
478
|
+
reason=(
|
|
479
|
+
"0 rows; at most one per memory when quantization runs. Bounded by "
|
|
480
|
+
"the memory count by construction."
|
|
481
|
+
),
|
|
482
|
+
),
|
|
483
|
+
RetentionPolicy(
|
|
484
|
+
table="ccq_consolidated_blocks",
|
|
485
|
+
kind=RetentionKind.NONE,
|
|
486
|
+
reason=(
|
|
487
|
+
"0 rows; a compiled block is replaced rather than appended, so it "
|
|
488
|
+
"is bounded by the number of block categories."
|
|
489
|
+
),
|
|
490
|
+
),
|
|
491
|
+
RetentionPolicy(
|
|
492
|
+
table="backup_destinations",
|
|
493
|
+
kind=RetentionKind.NONE,
|
|
494
|
+
reason=(
|
|
495
|
+
"0 rows; one per configured backup target. Bounded by a number the "
|
|
496
|
+
"user chooses, and a destination does not expire."
|
|
497
|
+
),
|
|
498
|
+
),
|
|
499
|
+
RetentionPolicy(
|
|
500
|
+
table="retention_rules",
|
|
501
|
+
kind=RetentionKind.NONE,
|
|
502
|
+
reason=(
|
|
503
|
+
"0 rows; per-memory retention rules for compliance, one per rule a "
|
|
504
|
+
"person writes. Bounded by that, and a rule that expired would "
|
|
505
|
+
"silently stop being enforced."
|
|
506
|
+
),
|
|
507
|
+
),
|
|
508
|
+
RetentionPolicy(
|
|
509
|
+
table="mesh_messages",
|
|
510
|
+
kind=RetentionKind.UNRESOLVED,
|
|
511
|
+
reason=(
|
|
512
|
+
"0 rows, because the mesh has never run with a second node -- so "
|
|
513
|
+
"its growth rate is unmeasured, exactly like mesh_events at 65.83x "
|
|
514
|
+
"between two stores. A message is presumably consumed and "
|
|
515
|
+
"removable, but presumably is not a rule."
|
|
516
|
+
),
|
|
517
|
+
),
|
|
518
|
+
RetentionPolicy(
|
|
519
|
+
table="mesh_sent_ops",
|
|
520
|
+
kind=RetentionKind.UNRESOLVED,
|
|
521
|
+
reason=(
|
|
522
|
+
"0 rows, same reason as the other mesh tables: it records "
|
|
523
|
+
"operations already sent, which sounds collectable once "
|
|
524
|
+
"acknowledged, but with 0 rows and no second node there is nothing "
|
|
525
|
+
"to measure an acknowledgement window against."
|
|
526
|
+
),
|
|
527
|
+
),
|
|
528
|
+
RetentionPolicy(
|
|
529
|
+
table="rbac_users",
|
|
530
|
+
kind=RetentionKind.NONE,
|
|
531
|
+
reason=(
|
|
532
|
+
"0 rows; one per member of a workspace. Bounded by team size, and "
|
|
533
|
+
"removed when a person is removed, not when their row gets old."
|
|
534
|
+
),
|
|
535
|
+
),
|
|
536
|
+
RetentionPolicy(
|
|
537
|
+
table="graph_edges",
|
|
538
|
+
kind=RetentionKind.EXTERNAL,
|
|
539
|
+
pruned_by="core.graph_pruner.prune_graph",
|
|
540
|
+
reason="Orphans, duplicates, weak edges and hub degree, every cycle.",
|
|
541
|
+
),
|
|
542
|
+
RetentionPolicy(
|
|
543
|
+
table="association_edges",
|
|
544
|
+
kind=RetentionKind.EXTERNAL,
|
|
545
|
+
pruned_by="core.graph_pruner.prune_graph",
|
|
546
|
+
reason="Orphan sweep in the same pass as graph_edges.",
|
|
547
|
+
),
|
|
548
|
+
RetentionPolicy(
|
|
549
|
+
table="activation_cache",
|
|
550
|
+
kind=RetentionKind.EXTERNAL,
|
|
551
|
+
pruned_by="storage.database.DatabaseManager.cleanup_activation_cache",
|
|
552
|
+
reason="Expiry-based GC once per maintenance cycle.",
|
|
553
|
+
),
|
|
554
|
+
RetentionPolicy(
|
|
555
|
+
table="schema_version",
|
|
556
|
+
kind=RetentionKind.NONE,
|
|
557
|
+
reason=(
|
|
558
|
+
"Should hold one row per schema version and holds thousands -- "
|
|
559
|
+
"3,496 rows for 7 versions on one store, 234,348 on another. The "
|
|
560
|
+
"table has no unique constraint, so the six INSERT OR IGNORE call "
|
|
561
|
+
"sites that believe they are idempotent each append a duplicate. "
|
|
562
|
+
"A retention rule here would hide a correctness bug behind a "
|
|
563
|
+
"cleanup job; the unique index is the fix."
|
|
564
|
+
),
|
|
565
|
+
),
|
|
566
|
+
RetentionPolicy(
|
|
567
|
+
table="migration_log",
|
|
568
|
+
kind=RetentionKind.NONE,
|
|
569
|
+
reason=(
|
|
570
|
+
"One row per migration, and there are 48. Bounded by the number "
|
|
571
|
+
"of migrations ever written, which is a number we control."
|
|
572
|
+
),
|
|
573
|
+
),
|
|
574
|
+
RetentionPolicy(
|
|
575
|
+
table="projection_outbox",
|
|
576
|
+
kind=RetentionKind.NONE,
|
|
577
|
+
reason=(
|
|
578
|
+
"Keyed on fact_id, so it cannot exceed the fact count, and a row "
|
|
579
|
+
"is deleted when the projection accepts it. Bounded by design; a "
|
|
580
|
+
"TTL here would silently drop unprojected work."
|
|
581
|
+
),
|
|
582
|
+
),
|
|
583
|
+
)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
|
588
|
+
row = conn.execute(
|
|
589
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
|
590
|
+
).fetchone()
|
|
591
|
+
return row is not None
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
|
595
|
+
"""Column names, or empty when the table cannot be introspected.
|
|
596
|
+
|
|
597
|
+
A virtual table raises rather than answering when its module is not loaded
|
|
598
|
+
on this connection -- ``sqlite_vec`` is loaded per connection, so a
|
|
599
|
+
maintenance connection that never asked for it gets "no such module: vec0"
|
|
600
|
+
from a plain PRAGMA. An unreadable table is one this pass leaves alone,
|
|
601
|
+
which is the right answer for a vector index anyway: it is derived from a
|
|
602
|
+
base table and bounded by writing to it.
|
|
603
|
+
"""
|
|
604
|
+
try:
|
|
605
|
+
return {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
|
|
606
|
+
except sqlite3.Error as exc:
|
|
607
|
+
logger.debug("retention: cannot introspect %s: %s", table, exc)
|
|
608
|
+
return set()
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def apply_policy(
|
|
612
|
+
conn: sqlite3.Connection,
|
|
613
|
+
policy: RetentionPolicy,
|
|
614
|
+
*,
|
|
615
|
+
dry_run: bool = False,
|
|
616
|
+
limit: int | None = None,
|
|
617
|
+
) -> int:
|
|
618
|
+
"""Enforce one policy. Returns rows deleted, or would-be deleted.
|
|
619
|
+
|
|
620
|
+
A policy whose table or column is absent is a no-op, not an error: these
|
|
621
|
+
tables arrive with migrations and a store may predate any of them.
|
|
622
|
+
|
|
623
|
+
``limit`` caps how many rows one call removes, so a caller that has to hand
|
|
624
|
+
the write lock back can do this in bounded pieces. Without it a single call
|
|
625
|
+
deleted 123,888 rows in 1,480 ms on a 1 GB store, holding the lock for the
|
|
626
|
+
whole of it -- and a memory being saved in that window waits behind it.
|
|
627
|
+
"""
|
|
628
|
+
# A kind with no rule of its own returns before any SQL is built. Without
|
|
629
|
+
# UNRESOLVED in this set it fell through to the cap branch with every field
|
|
630
|
+
# None and emitted "no such column: None" on two real stores -- and the test
|
|
631
|
+
# meant to catch that passed anyway, because run_retention swallows a
|
|
632
|
+
# per-table error and the table was simply absent from the result either way.
|
|
633
|
+
if policy.kind in (
|
|
634
|
+
RetentionKind.EXTERNAL, RetentionKind.NONE, RetentionKind.UNRESOLVED,
|
|
635
|
+
):
|
|
636
|
+
return 0
|
|
637
|
+
if not _table_exists(conn, policy.table):
|
|
638
|
+
return 0
|
|
639
|
+
present = _columns(conn, policy.table)
|
|
640
|
+
needed = [
|
|
641
|
+
column
|
|
642
|
+
for column in (policy.timestamp_column, policy.key_column)
|
|
643
|
+
if column is not None
|
|
644
|
+
]
|
|
645
|
+
if policy.referent is not None:
|
|
646
|
+
needed.append(policy.referent[0])
|
|
647
|
+
missing = [column for column in needed if column not in present]
|
|
648
|
+
if missing:
|
|
649
|
+
logger.debug(
|
|
650
|
+
"retention: %s has no %s; skipping", policy.table, ", ".join(missing)
|
|
651
|
+
)
|
|
652
|
+
return 0
|
|
653
|
+
|
|
654
|
+
if policy.kind is RetentionKind.ORPHAN:
|
|
655
|
+
local, target = policy.referent # type: ignore[misc]
|
|
656
|
+
referent_table, referent_column = target.split(".", 1)
|
|
657
|
+
if not _table_exists(conn, referent_table):
|
|
658
|
+
return 0
|
|
659
|
+
where = (
|
|
660
|
+
f"{local} IS NOT NULL AND {local} NOT IN "
|
|
661
|
+
f"(SELECT {referent_column} FROM {referent_table})"
|
|
662
|
+
)
|
|
663
|
+
params: tuple[Any, ...] = ()
|
|
664
|
+
elif policy.kind is RetentionKind.TTL:
|
|
665
|
+
where = f"{policy.timestamp_column} < datetime('now', ?)"
|
|
666
|
+
params = (f"-{policy.ttl_days} days",)
|
|
667
|
+
else:
|
|
668
|
+
# Beyond the cap AND (for TTL_AND_CAP) older than the window. The window
|
|
669
|
+
# function ranks newest-first per key so "beyond the cap" means "not one
|
|
670
|
+
# of the newest N", which is the only reading that keeps recent rows.
|
|
671
|
+
age = ""
|
|
672
|
+
params = ()
|
|
673
|
+
if policy.kind is RetentionKind.TTL_AND_CAP:
|
|
674
|
+
age = f" AND {policy.timestamp_column} < datetime('now', ?)"
|
|
675
|
+
params = (f"-{policy.ttl_days} days",)
|
|
676
|
+
partition = ", ".join(_partition_columns(conn, policy))
|
|
677
|
+
where = (
|
|
678
|
+
f"rowid IN (SELECT rowid FROM (SELECT rowid, ROW_NUMBER() OVER ("
|
|
679
|
+
f"PARTITION BY {partition} "
|
|
680
|
+
f"ORDER BY {policy.timestamp_column} DESC, rowid DESC) AS rn "
|
|
681
|
+
f"FROM {policy.table}) WHERE rn > {int(policy.cap_per_key or 0)})"
|
|
682
|
+
f"{age}"
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
if dry_run:
|
|
686
|
+
row = conn.execute(
|
|
687
|
+
f"SELECT COUNT(*) FROM {policy.table} WHERE {where}", params
|
|
688
|
+
).fetchone()
|
|
689
|
+
return int(row[0] if row else 0)
|
|
690
|
+
|
|
691
|
+
if limit is not None and limit > 0:
|
|
692
|
+
# Two statements rather than DELETE ... LIMIT, which needs a compile
|
|
693
|
+
# option SQLite is not built with everywhere.
|
|
694
|
+
cursor = conn.execute(
|
|
695
|
+
f"DELETE FROM {policy.table} WHERE rowid IN ("
|
|
696
|
+
f"SELECT rowid FROM {policy.table} WHERE {where} LIMIT ?)",
|
|
697
|
+
(*params, int(limit)),
|
|
698
|
+
)
|
|
699
|
+
return int(cursor.rowcount or 0)
|
|
700
|
+
cursor = conn.execute(f"DELETE FROM {policy.table} WHERE {where}", params)
|
|
701
|
+
return int(cursor.rowcount or 0)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _partition_columns(
|
|
705
|
+
conn: sqlite3.Connection, policy: RetentionPolicy
|
|
706
|
+
) -> tuple[str, ...]:
|
|
707
|
+
"""Every column that has to match for two rows to be under the same cap.
|
|
708
|
+
|
|
709
|
+
A cap keeps the newest N rows per key, so anything the key does not
|
|
710
|
+
distinguish gets counted against somebody else's allowance. Two workspaces
|
|
711
|
+
are the case that matters: a shared table's ids are unique per workspace,
|
|
712
|
+
not across the store, so partitioning on the id alone lets one workspace's
|
|
713
|
+
rows evict another's. Nothing on a single-workspace store notices, which is
|
|
714
|
+
why this has to be structural rather than something a policy author
|
|
715
|
+
remembers.
|
|
716
|
+
|
|
717
|
+
``profile_id`` is added wherever the table has it, checked against the live
|
|
718
|
+
schema rather than assumed, because these policies also run on stores old
|
|
719
|
+
enough to predate the column.
|
|
720
|
+
"""
|
|
721
|
+
columns: list[str] = []
|
|
722
|
+
if _has_column(conn, policy.table, "profile_id"):
|
|
723
|
+
columns.append("profile_id")
|
|
724
|
+
for extra in policy.also_partition_by:
|
|
725
|
+
if _has_column(conn, policy.table, extra) and extra not in columns:
|
|
726
|
+
columns.append(extra)
|
|
727
|
+
if policy.key_column and policy.key_column not in columns:
|
|
728
|
+
columns.append(policy.key_column)
|
|
729
|
+
return tuple(columns)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
|
733
|
+
"""Whether ``table`` has ``column`` on this store."""
|
|
734
|
+
try:
|
|
735
|
+
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
|
736
|
+
except sqlite3.Error:
|
|
737
|
+
return False
|
|
738
|
+
return any(str(row[1]) == column for row in rows)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
#: Rows one piece removes before handing the write lock back. Chosen from
|
|
742
|
+
#: measurement on a 1 GB store, sweeping the same 123,888 rows every time, with
|
|
743
|
+
#: the page cache warm because that is the state a running store is in:
|
|
744
|
+
#:
|
|
745
|
+
#: unbounded 1,291 ms in a single hold
|
|
746
|
+
#: 2,000/piece 255 ms longest hold, 8,425 ms total
|
|
747
|
+
#: 8,000/piece 216 ms longest hold, 2,925 ms total, median hold 0.5 ms
|
|
748
|
+
#: 20,000/piece 349 ms longest hold, 2,160 ms total
|
|
749
|
+
#:
|
|
750
|
+
#: Smaller is not automatically shorter, which is the non-obvious part: a
|
|
751
|
+
#: piece costs mostly the scan that finds the rows, not the removing of them,
|
|
752
|
+
#: so halving the piece nearly doubles the number of scans and barely moves the
|
|
753
|
+
#: hold. 8,000 is where both numbers are at their best.
|
|
754
|
+
#:
|
|
755
|
+
#: The total goes up. That is the trade being made on purpose -- this is a
|
|
756
|
+
#: background sweep, and the number that matters is how long someone saving a
|
|
757
|
+
#: memory has to wait for the lock, not how long the sweep takes.
|
|
758
|
+
BOUNDED_BATCH = 8000
|
|
759
|
+
|
|
760
|
+
#: Seconds between pieces. Long enough for a waiting writer to be handed the
|
|
761
|
+
#: lock by the OS rather than losing the race back to this loop every time.
|
|
762
|
+
BOUNDED_YIELD_S = 0.002
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def run_retention_bounded(
|
|
766
|
+
open_connection: Any,
|
|
767
|
+
*,
|
|
768
|
+
batch_size: int = BOUNDED_BATCH,
|
|
769
|
+
yield_seconds: float = BOUNDED_YIELD_S,
|
|
770
|
+
max_batches_per_table: int = 500,
|
|
771
|
+
) -> dict[str, int]:
|
|
772
|
+
"""The same sweep, in pieces, releasing the write lock between each.
|
|
773
|
+
|
|
774
|
+
``open_connection`` is a context manager factory -- typically the database
|
|
775
|
+
manager's ``raw_connection`` -- and is entered once per piece. That is the
|
|
776
|
+
point: entering it is what takes the process write lock, so a caller that
|
|
777
|
+
enters it once for the whole sweep holds the lock for the whole sweep,
|
|
778
|
+
however carefully the sweep batches inside.
|
|
779
|
+
|
|
780
|
+
``max_batches_per_table`` is a stop, not a target. A policy that keeps
|
|
781
|
+
reporting deletions forever is a bug in that policy, and this pass declining
|
|
782
|
+
to loop on it indefinitely is how the rest of the tables still get swept.
|
|
783
|
+
"""
|
|
784
|
+
removed: dict[str, int] = {}
|
|
785
|
+
for policy in REGISTERED_POLICIES.values():
|
|
786
|
+
table_total = 0
|
|
787
|
+
try:
|
|
788
|
+
for _ in range(max_batches_per_table):
|
|
789
|
+
with open_connection() as conn:
|
|
790
|
+
count = apply_policy(conn, policy, limit=batch_size)
|
|
791
|
+
if count <= 0:
|
|
792
|
+
break
|
|
793
|
+
table_total += count
|
|
794
|
+
if count < batch_size:
|
|
795
|
+
break
|
|
796
|
+
time.sleep(yield_seconds)
|
|
797
|
+
else:
|
|
798
|
+
logger.warning(
|
|
799
|
+
"retention: %s still had rows to remove after %d pieces; "
|
|
800
|
+
"stopping here and continuing with the next table",
|
|
801
|
+
policy.table, max_batches_per_table,
|
|
802
|
+
)
|
|
803
|
+
except Exception as exc: # noqa: BLE001
|
|
804
|
+
logger.warning("retention: %s skipped (%s)", policy.table, exc)
|
|
805
|
+
continue
|
|
806
|
+
if table_total:
|
|
807
|
+
removed[policy.table] = table_total
|
|
808
|
+
return removed
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def run_retention(
|
|
812
|
+
conn: sqlite3.Connection, *, dry_run: bool = False
|
|
813
|
+
) -> dict[str, int]:
|
|
814
|
+
"""Enforce every policy that has one. Returns rows removed per table.
|
|
815
|
+
|
|
816
|
+
One statement per table, each committed by the caller's connection. A table
|
|
817
|
+
that fails does not stop the others: an unbounded table is a slow problem and
|
|
818
|
+
stopping the pass would leave every table after it in the dict unbounded too.
|
|
819
|
+
"""
|
|
820
|
+
removed: dict[str, int] = {}
|
|
821
|
+
for policy in REGISTERED_POLICIES.values():
|
|
822
|
+
try:
|
|
823
|
+
count = apply_policy(conn, policy, dry_run=dry_run)
|
|
824
|
+
except sqlite3.Error as exc:
|
|
825
|
+
logger.warning("retention: %s failed: %s", policy.table, exc)
|
|
826
|
+
continue
|
|
827
|
+
if count:
|
|
828
|
+
removed[policy.table] = count
|
|
829
|
+
return removed
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def undeclared_growing_tables(conn: sqlite3.Connection) -> list[str]:
|
|
833
|
+
"""Tables that look append-shaped and have no policy.
|
|
834
|
+
|
|
835
|
+
"Append-shaped" is taken from the schema, not from a hand-written list, so a
|
|
836
|
+
table added next release shows up here without anyone remembering to add it:
|
|
837
|
+
it carries a timestamp-ish column and is not a registered policy, an FTS
|
|
838
|
+
shadow, or SQLite's own bookkeeping.
|
|
839
|
+
"""
|
|
840
|
+
candidates: list[str] = []
|
|
841
|
+
rows = conn.execute(
|
|
842
|
+
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
|
843
|
+
).fetchall()
|
|
844
|
+
for (name,) in rows:
|
|
845
|
+
if name in REGISTERED_POLICIES or name.startswith("sqlite_"):
|
|
846
|
+
continue
|
|
847
|
+
# FTS5 external-content shadow tables are derived from their base table
|
|
848
|
+
# and are pruned by writing to it.
|
|
849
|
+
if any(
|
|
850
|
+
name.endswith(suffix)
|
|
851
|
+
for suffix in ("_data", "_idx", "_content", "_docsize", "_config")
|
|
852
|
+
):
|
|
853
|
+
continue
|
|
854
|
+
columns = _columns(conn, name)
|
|
855
|
+
if not columns & {
|
|
856
|
+
"created_at", "accessed_at", "occurred_at", "timestamp", "logged_at",
|
|
857
|
+
}:
|
|
858
|
+
continue
|
|
859
|
+
candidates.append(name)
|
|
860
|
+
return candidates
|