Skip to content

Commit 730b8aa

Browse files
committed
fix(daemon): compact no-op dependency history rebuild pairs
Before #2129, native source sync rebuilt every unchanged Obsidian note on each pass. Each rebuild deleted and recreated the note's dependency edges, and the migration 050 audit triggers recorded a deleted row and a created row for every edge, growing entity_dependency_history by millions of rows. The retention worker now compacts that history through the DB owner in bounded, resumable batches. A batch removes a trigger-written deleted row only when the next event for the same dependency is a trigger-written created row in the same second with identical source, target, agent, type, and reason, and removes that created row with it. Updated rows, reason changes, real deletes, and first created or backfill rows are kept. Migration 170 adds the single-row cursor and running total. Each batch deletes, advances the cursor, and reads progress in one owner transaction. A cycle runs at most 50 batches of 5,000 rowids, yields between batches, backs off under system pressure or an unhealthy owner queue, and goes idle for the retention interval once it reaches the newest row. Assisted-by: Claude-Code:claude-opus-5-5
1 parent 6d90350 commit 730b8aa

9 files changed

Lines changed: 500 additions & 6 deletions

File tree

‎docs/knowledge-graph-control-plane.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ graph control plane. It is not a speculative product spec.
1313
| `entity_aspects` | 019, 070 | CLI/API | apply-first audited operations; proposal review for large refactors | 070 adds archive metadata and proposal provenance. Default reads should treat archived aspects as hidden. |
1414
| `entity_attributes` | 019, 059, 060, 064, 067, 070 | CLI/API | apply-first audited operations; pipeline/supersession helpers also write | Claim slots use `group_key` + `claim_key`. 070 adds first-class versions: `version`, `version_root_id`, `previous_attribute_id`. Default reads show active rows; history reads can inspect all versions. |
1515
| `entity_dependencies` | 019, 031, 036, 039, 050, 064, 067, 070 | CLI/API | apply-first audited operations; structural workers also write | Link operations create/update/archive rows here. Reason/confidence/proposal/source provenance are preserved. |
16-
| `entity_dependency_history` | 050 | hidden/internal | audit trigger | Trigger-backed history for dependency insert/update/delete. |
16+
| `entity_dependency_history` | 050, 170 | hidden/internal | audit trigger | Trigger-backed history for dependency insert/update/delete. Retention compacts identical delete/create rebuild pairs; migration 170 adds its cursor. |
1717
| `task_meta` | 019, 054 | hidden/internal | task/procedural helpers | Agent-scoped task metadata, not part of this control-plane CLI slice. |
1818
| `ontology_proposals` | 067 | CLI/API | refactor proposal queue and applied operation ledger | Pending proposals are for broad graph refactors or explicit review. Direct operations create applied rows inside the same transaction as graph mutation so provenance remains queryable. |
1919
| `epistemic_assertions` | 071, 132 | CLI/API | source-attributed assertion ledger | Records who claimed, believed, observed, decided, preferred, denied, or questioned something, with confidence, evidence, source provenance, status, and optional link to an applied claim attribute. Migration 132 adds an observer-scoped query index; the agent scope is the agent-only observer projection. Assertions do not by themselves make the statement current ontology truth. |
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { MigrationDb } from "./contract";
2+
3+
export function up(db: MigrationDb): void {
4+
db.exec(`
5+
CREATE TABLE IF NOT EXISTS dependency_history_compaction_state (
6+
id INTEGER PRIMARY KEY CHECK (id = 1),
7+
cursor_rowid INTEGER NOT NULL DEFAULT 0 CHECK (cursor_rowid >= 0),
8+
pruned_rows INTEGER NOT NULL DEFAULT 0 CHECK (pruned_rows >= 0),
9+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
10+
);
11+
INSERT OR IGNORE INTO dependency_history_compaction_state (id) VALUES (1);
12+
`);
13+
}

‎platform/core/src/migrations/index.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ import { up as dreamingPassPeakContext } from "./166-dreaming-pass-peak-context"
170170
import { up as memoriesFtsPorter } from "./167-memories-fts-porter";
171171
import { up as retireSourceParagraphClaims } from "./168-retire-source-paragraph-claims";
172172
import { up as dreamingEvidenceReviewCursor } from "./169-dreaming-evidence-review-cursor";
173+
import { up as dependencyHistoryCompactionState } from "./170-dependency-history-compaction-state";
173174

174175
export type { Migration, MigrationArtifacts, MigrationDb } from "./contract";
175176
export const MIGRATIONS: readonly Migration[] = [
@@ -1539,6 +1540,14 @@ export const MIGRATIONS: readonly Migration[] = [
15391540
],
15401541
},
15411542
},
1543+
{
1544+
version: 170,
1545+
name: "dependency-history-compaction-state",
1546+
up: dependencyHistoryCompactionState,
1547+
artifacts: {
1548+
tables: ["dependency_history_compaction_state"],
1549+
},
1550+
},
15421551
];
15431552
function checksum(m: Migration): string {
15441553
let h = 0;

‎platform/core/src/migrations/migrations.test.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ describe("migration framework", () => {
415415
runMigrations(db);
416416

417417
const applied = db.query("SELECT MAX(version) AS version FROM schema_migrations").get() as { version: number };
418-
expect(applied.version).toBe(169);
418+
expect(applied.version).toBe(170);
419419
expect(
420420
db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'vector_repair_checkpoints'").get(),
421421
).toEqual({ name: "vector_repair_checkpoints" });
@@ -2350,6 +2350,21 @@ describe("migration framework", () => {
23502350
).toThrow();
23512351
});
23522352

2353+
test("migration 170 seeds one dependency history compaction cursor", () => {
2354+
db = createFreshDb();
2355+
runMigrations(db);
2356+
db.exec("DROP TABLE dependency_history_compaction_state");
2357+
db.prepare("DELETE FROM schema_migrations WHERE version = 170").run();
2358+
runMigrations(db);
2359+
runMigrations(db);
2360+
2361+
expect(db.query("SELECT id, cursor_rowid, pruned_rows FROM dependency_history_compaction_state").all()).toEqual([
2362+
{ id: 1, cursor_rowid: 0, pruned_rows: 0 },
2363+
]);
2364+
expect(() => db.exec("INSERT INTO dependency_history_compaction_state (id) VALUES (2)")).toThrow();
2365+
expect(() => db.exec("UPDATE dependency_history_compaction_state SET cursor_rowid = -1")).toThrow();
2366+
});
2367+
23532368
test("migration 063 limits memories_fts updates to content changes", () => {
23542369
db = createFreshDb();
23552370
runMigrations(db);
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { Database } from "bun:sqlite";
2+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { runMigrations } from "../../core/src/migrations";
7+
import { createDbOwnerClient, type DbOwnerClient } from "./db-owner-client";
8+
import { compactDependencyHistoryBatch, startDependencyHistoryCompaction } from "./dependency-history-compaction";
9+
10+
interface HistoryEvent {
11+
readonly dep: string;
12+
readonly event: "created" | "deleted" | "updated" | "backfill";
13+
readonly at: string;
14+
readonly reason?: string;
15+
readonly previousReason?: string;
16+
readonly changedBy?: string;
17+
readonly agentId?: string;
18+
readonly target?: string;
19+
}
20+
21+
describe("dependency history compaction", () => {
22+
let dir = "";
23+
let path = "";
24+
let owner: DbOwnerClient | null = null;
25+
26+
beforeEach(() => {
27+
dir = mkdtempSync(join(tmpdir(), "signet-dependency-history-"));
28+
path = join(dir, "memories.db");
29+
const db = new Database(path);
30+
runMigrations(db as never);
31+
db.close();
32+
});
33+
34+
afterEach(async () => {
35+
await owner?.close();
36+
owner = null;
37+
rmSync(dir, { recursive: true, force: true });
38+
});
39+
40+
function append(events: readonly HistoryEvent[]): void {
41+
const db = new Database(path);
42+
try {
43+
const insert = db.prepare(
44+
`INSERT INTO entity_dependency_history
45+
(id, dependency_id, source_entity_id, target_entity_id, agent_id, dependency_type, event,
46+
changed_by, reason, previous_reason, metadata, created_at)
47+
VALUES (lower(hex(randomblob(16))), ?, ?, ?, ?, 'wiki_link', ?, ?, ?, ?, '{}', ?)`,
48+
);
49+
for (const e of events) {
50+
insert.run(
51+
e.dep,
52+
`source-${e.dep}`,
53+
e.target ?? `target-${e.dep}`,
54+
e.agentId ?? "default",
55+
e.event,
56+
e.changedBy ?? "db-trigger",
57+
e.reason ?? `link in ${e.dep}`,
58+
e.previousReason ?? null,
59+
e.at,
60+
);
61+
}
62+
} finally {
63+
db.close();
64+
}
65+
}
66+
67+
function remaining(): string[] {
68+
const db = new Database(path, { readonly: true });
69+
try {
70+
return (
71+
db
72+
.prepare("SELECT dependency_id, event, reason, created_at FROM entity_dependency_history ORDER BY rowid")
73+
.all() as Array<{ dependency_id: string; event: string; reason: string; created_at: string }>
74+
).map((row) => `${row.dependency_id} ${row.event} ${row.created_at.slice(-2)} ${row.reason}`);
75+
} finally {
76+
db.close();
77+
}
78+
}
79+
80+
function state(): { cursor_rowid: number; pruned_rows: number } {
81+
const db = new Database(path, { readonly: true });
82+
try {
83+
return db
84+
.prepare("SELECT cursor_rowid, pruned_rows FROM dependency_history_compaction_state WHERE id = 1")
85+
.all()[0] as { cursor_rowid: number; pruned_rows: number };
86+
} finally {
87+
db.close();
88+
}
89+
}
90+
91+
async function startOwner(): Promise<DbOwnerClient> {
92+
owner = createDbOwnerClient({ dbPath: path });
93+
await owner.start();
94+
return owner;
95+
}
96+
97+
const t0 = "2026-10-05 07:25:00";
98+
const t1 = "2026-10-05 07:25:01";
99+
const t2 = "2026-10-05 07:25:02";
100+
101+
it("removes only delete-then-create pairs that record no change and keeps every meaningful event", async () => {
102+
append([
103+
{ dep: "rebuilt", event: "created", at: t0 },
104+
{ dep: "reason-changed", event: "created", at: t0 },
105+
{ dep: "really-deleted", event: "created", at: t0 },
106+
{ dep: "split-second", event: "created", at: t0 },
107+
{ dep: "interleaved", event: "created", at: t0 },
108+
{ dep: "touched", event: "created", at: t0 },
109+
{ dep: "backfilled", event: "backfill", at: t0, changedBy: "migration-050" },
110+
{ dep: "rebuilt-twice", event: "created", at: t0 },
111+
{ dep: "foreign-writer", event: "created", at: t0 },
112+
{ dep: "retargeted", event: "created", at: t0 },
113+
{ dep: "rebuilt", event: "deleted", at: t1 },
114+
{ dep: "reason-changed", event: "deleted", at: t1 },
115+
{ dep: "really-deleted", event: "deleted", at: t1 },
116+
{ dep: "split-second", event: "deleted", at: t1 },
117+
{ dep: "interleaved", event: "deleted", at: t1 },
118+
{ dep: "interleaved", event: "updated", at: t1, previousReason: "link in interleaved" },
119+
{ dep: "touched", event: "updated", at: t1, previousReason: "link in touched" },
120+
{ dep: "backfilled", event: "deleted", at: t1 },
121+
{ dep: "rebuilt-twice", event: "deleted", at: t1 },
122+
{ dep: "foreign-writer", event: "deleted", at: t1, changedBy: "manual-repair" },
123+
{ dep: "retargeted", event: "deleted", at: t1 },
124+
{ dep: "rebuilt", event: "created", at: t1 },
125+
{ dep: "reason-changed", event: "created", at: t1, reason: "link in reason-changed, renamed" },
126+
{ dep: "split-second", event: "created", at: t2 },
127+
{ dep: "interleaved", event: "created", at: t1 },
128+
{ dep: "backfilled", event: "created", at: t1 },
129+
{ dep: "rebuilt-twice", event: "created", at: t1 },
130+
{ dep: "foreign-writer", event: "created", at: t1 },
131+
{ dep: "retargeted", event: "created", at: t1, target: "another-target" },
132+
{ dep: "rebuilt-twice", event: "deleted", at: t2 },
133+
{ dep: "rebuilt-twice", event: "created", at: t2 },
134+
]);
135+
const client = await startOwner();
136+
137+
let batches = 0;
138+
for (;;) {
139+
const batch = await compactDependencyHistoryBatch(client, { windowRows: 4 });
140+
batches += 1;
141+
if (batch.caughtUp) break;
142+
expect(batches).toBeLessThan(50);
143+
}
144+
145+
expect(batches).toBeGreaterThan(1);
146+
expect(remaining()).toEqual([
147+
"rebuilt created 00 link in rebuilt",
148+
"reason-changed created 00 link in reason-changed",
149+
"really-deleted created 00 link in really-deleted",
150+
"split-second created 00 link in split-second",
151+
"interleaved created 00 link in interleaved",
152+
"touched created 00 link in touched",
153+
"backfilled backfill 00 link in backfilled",
154+
"rebuilt-twice created 00 link in rebuilt-twice",
155+
"foreign-writer created 00 link in foreign-writer",
156+
"retargeted created 00 link in retargeted",
157+
"reason-changed deleted 01 link in reason-changed",
158+
"really-deleted deleted 01 link in really-deleted",
159+
"split-second deleted 01 link in split-second",
160+
"interleaved deleted 01 link in interleaved",
161+
"interleaved updated 01 link in interleaved",
162+
"touched updated 01 link in touched",
163+
"foreign-writer deleted 01 link in foreign-writer",
164+
"retargeted deleted 01 link in retargeted",
165+
"reason-changed created 01 link in reason-changed, renamed",
166+
"split-second created 02 link in split-second",
167+
"interleaved created 01 link in interleaved",
168+
"foreign-writer created 01 link in foreign-writer",
169+
"retargeted created 01 link in retargeted",
170+
]);
171+
expect(state().pruned_rows).toBe(8);
172+
});
173+
174+
it("drains in bounded cycles, terminates when caught up, and resumes for rows appended later", async () => {
175+
const rebuild = (dep: string, at: string): HistoryEvent[] => [
176+
{ dep, event: "deleted", at },
177+
{ dep, event: "created", at },
178+
];
179+
const deps = Array.from({ length: 6 }, (_, i) => `dep-${i}`);
180+
append(deps.map((dep) => ({ dep, event: "created" as const, at: t0 })));
181+
append(deps.flatMap((dep) => rebuild(dep, t1)));
182+
const client = await startOwner();
183+
const worker = startDependencyHistoryCompaction(client, {
184+
windowRows: 3,
185+
maxBatchesPerCycle: 2,
186+
startDelayMs: 24 * 60 * 60 * 1000,
187+
yieldMs: 0,
188+
});
189+
try {
190+
const first = await worker.runCycle();
191+
expect(first).toMatchObject({ batches: 2, caughtUp: false });
192+
193+
let cycles = 1;
194+
let last = first;
195+
while (!last.caughtUp) {
196+
last = await worker.runCycle();
197+
cycles += 1;
198+
expect(last.batches).toBeLessThanOrEqual(2);
199+
expect(cycles).toBeLessThan(20);
200+
}
201+
expect(remaining()).toEqual(deps.map((dep) => `${dep} created 00 link in ${dep}`));
202+
expect(state().pruned_rows).toBe(12);
203+
204+
const idle = await worker.runCycle();
205+
expect(idle).toMatchObject({ batches: 1, pruned: 0, caughtUp: true });
206+
207+
append(rebuild("dep-0", t2));
208+
append([{ dep: "dep-1", event: "deleted", at: t2 }]);
209+
const resumed = await worker.runCycle();
210+
expect(resumed).toMatchObject({ pruned: 2, caughtUp: true });
211+
expect(remaining()).toEqual([
212+
...deps.map((dep) => `${dep} created 00 link in ${dep}`),
213+
"dep-1 deleted 02 link in dep-1",
214+
]);
215+
expect(state().pruned_rows).toBe(14);
216+
} finally {
217+
await worker.stop();
218+
}
219+
expect(worker.running).toBe(false);
220+
});
221+
});

0 commit comments

Comments
 (0)