Skip to content

Commit 8b7269d

Browse files
committed
fix(sqlite): preserve migrations and reindex safety
1 parent f324f7e commit 8b7269d

14 files changed

Lines changed: 521 additions & 32 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
b7ec57a4f38bf44677870fd9a8347be83f3f23a25a73d97931406f0eff572181 config-baseline.json
2-
99d506f05de601e5b45c98f302650c8608d1e2bb3dcea11bf97881c1263659ac config-baseline.core.json
1+
823613bb0103db76f108f940572824ab961c19e94d5d09885669066e8dbbfdbd config-baseline.json
2+
28e51c1f60f46897d7b10635dd401d08ed6b6bc080178648c9df8aaf3fbfc171 config-baseline.core.json
33
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
44
a973af69b02a27b097b54e49886dd57dbebbc95e2ab29b0c7e222a9f35a105d8 config-baseline.plugin.json
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
194d38783bc97cf2c47804d2380e8606022558d3309c62d18379077d7b6ec11d plugin-sdk-api-baseline.json
2-
612407270d5b049bf0e1fb94ab8bcc50c676eb4005e5fbf476135c73eb32ab29 plugin-sdk-api-baseline.jsonl
1+
a3caab2a62548f8ca139f8024a147bf09d6cb031b024ccd79d6dba943db90965 plugin-sdk-api-baseline.json
2+
0aafec9dec2ec5767ad0a0355bf9a609e2bb4f7e58ae4dcbd5e1d1cd1567f320 plugin-sdk-api-baseline.jsonl

docs/plugins/sdk-subpaths.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ usage endpoint failed or returned no usable usage data.
248248
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
249249
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
250250
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
251-
| `plugin-sdk/sqlite-runtime` | Focused SQLite database open/path helpers for first-party runtime and migration tests |
251+
| `plugin-sdk/sqlite-runtime` | Focused SQLite database open/path/transaction helpers for first-party runtime and migration tests |
252252
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
253253
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
254254
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types plus centralized connection pragma and WAL maintenance setup for plugin-owned databases |
@@ -307,7 +307,7 @@ usage endpoint failed or returned no usable usage data.
307307
| `plugin-sdk/response-limit-runtime` | Bounded response-body reader without the broad media runtime surface |
308308
| `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores |
309309
| `plugin-sdk/session-store-runtime` | Session-store helpers without broad config writes/maintenance imports |
310-
| `plugin-sdk/sqlite-runtime` | Focused SQLite database helpers without session-row helper imports |
310+
| `plugin-sdk/sqlite-runtime` | Focused SQLite database and transaction helpers without session-row helper imports |
311311
| `plugin-sdk/context-visibility-runtime` | Context visibility resolution and supplemental context filtering without broad config/security imports |
312312
| `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports |
313313
| `plugin-sdk/host-runtime` | Hostname and SCP host normalization helpers |

extensions/matrix/src/matrix/client/storage.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,11 @@ describe("matrix client storage paths", () => {
304304
).register("current", value);
305305
}
306306

307+
function seedLegacyStorageMeta(rootDir: string, value: Record<string, unknown>): void {
308+
fs.mkdirSync(rootDir, { recursive: true });
309+
writeJson(rootDir, "storage-meta.json", value);
310+
}
311+
307312
it("records a learned deviceId in SQLite storage metadata", () => {
308313
const stateDir = setupStateDir();
309314
const storagePaths = resolveMatrixStoragePaths({
@@ -680,6 +685,32 @@ describe("matrix client storage paths", () => {
680685
expect(rotatedStoragePaths.storagePath).toBe(oldStoragePaths.storagePath);
681686
});
682687

688+
it("reads legacy storage metadata until doctor migrates it to SQLite", () => {
689+
setupStateDir();
690+
const oldStoragePaths = resolveDefaultStoragePaths({
691+
accessToken: "secret-token-old",
692+
deviceId: "DEVICE123",
693+
});
694+
seedLegacyStorageMeta(oldStoragePaths.rootDir, {
695+
homeserver: defaultStorageAuth.homeserver,
696+
userId: defaultStorageAuth.userId,
697+
accountId: "default",
698+
accessTokenHash: oldStoragePaths.tokenHash,
699+
deviceId: "DEVICE123",
700+
currentTokenStateClaimed: true,
701+
});
702+
703+
const rotatedStoragePaths = resolveDefaultStoragePaths({
704+
accessToken: "secret-token-new",
705+
deviceId: "DEVICE123",
706+
});
707+
708+
expect(rotatedStoragePaths.rootDir).toBe(oldStoragePaths.rootDir);
709+
expect(fs.existsSync(path.join(oldStoragePaths.rootDir, "state", "openclaw.sqlite"))).toBe(
710+
false,
711+
);
712+
});
713+
683714
it("prefers claimed current-token state over an empty new-token metadata root", () => {
684715
const stateDir = setupStateDir();
685716
const oldStoragePaths = seedCanonicalStorageRoot({

extensions/matrix/src/matrix/client/storage.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import os from "node:os";
44
import path from "node:path";
55
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
66
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
7+
import { loadJsonFile } from "openclaw/plugin-sdk/json-store";
78
import type {
89
PluginStateKeyedStore,
910
PluginStateSyncKeyedStore,
@@ -33,6 +34,7 @@ import type { MatrixAuth } from "./types.js";
3334
import type { MatrixStoragePaths } from "./types.js";
3435

3536
const DEFAULT_ACCOUNT_KEY = "default";
37+
const STORAGE_META_FILENAME = "storage-meta.json";
3638
const STORAGE_META_NAMESPACE = "storage-meta";
3739
const STORAGE_META_STATE_KEY = "current";
3840
const STORAGE_META_MAX_ENTRIES = 10;
@@ -167,19 +169,21 @@ export async function writeMatrixStorageMetaStateToStore(params: {
167169
}
168170

169171
function readStoredRootMetadata(rootDir: string): MatrixStorageMetadata {
170-
if (!fs.existsSync(path.join(rootDir, "state", "openclaw.sqlite"))) {
171-
return {};
172-
}
173-
try {
174-
return (
175-
normalizeMatrixStorageMetadata(
172+
if (fs.existsSync(path.join(rootDir, "state", "openclaw.sqlite"))) {
173+
try {
174+
const stored = normalizeMatrixStorageMetadata(
176175
openStorageMetaStore(rootDir).lookup(STORAGE_META_STATE_KEY),
177-
) ?? {}
178-
);
179-
} catch {
180-
// Root selection remains best-effort; a write path will surface SQLite failures.
181-
return {};
176+
);
177+
if (stored) {
178+
return stored;
179+
}
180+
} catch {
181+
// Root selection remains best-effort; a write path will surface SQLite failures.
182+
}
182183
}
184+
return (
185+
normalizeMatrixStorageMetadata(loadJsonFile(path.join(rootDir, STORAGE_META_FILENAME))) ?? {}
186+
);
183187
}
184188

185189
function isCompatibleStorageRoot(params: {

extensions/memory-core/src/memory/manager-db.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Memory Core plugin module implements manager db behavior.
2+
import fs from "node:fs";
23
import path from "node:path";
34
import type { DatabaseSync } from "node:sqlite";
45
import {
@@ -7,7 +8,117 @@ import {
78
ensureDir,
89
requireNodeSqlite,
910
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
10-
import { ensureOpenClawAgentDatabaseSchema } from "openclaw/plugin-sdk/sqlite-runtime";
11+
import {
12+
ensureOpenClawAgentDatabaseSchema,
13+
runSqliteImmediateTransactionSync,
14+
} from "openclaw/plugin-sdk/sqlite-runtime";
15+
16+
const MEMORY_REINDEX_SCHEMA = "memory_reindex";
17+
const MEMORY_DATABASE_FILE_SUFFIXES = ["", "-wal", "-shm", "-journal"] as const;
18+
19+
function tableExists(db: DatabaseSync, schema: string, tableName: string): boolean {
20+
const row = db
21+
.prepare(`SELECT 1 AS ok FROM ${schema}.sqlite_master WHERE type = 'table' AND name = ?`)
22+
.get(tableName) as { ok?: unknown } | undefined;
23+
return row?.ok === 1;
24+
}
25+
26+
function readTableSql(db: DatabaseSync, schema: string, tableName: string): string | null {
27+
const row = db
28+
.prepare(`SELECT sql FROM ${schema}.sqlite_master WHERE type = 'table' AND name = ?`)
29+
.get(tableName) as { sql?: unknown } | undefined;
30+
return typeof row?.sql === "string" && row.sql.trim() ? row.sql : null;
31+
}
32+
33+
function replaceVirtualTable(params: {
34+
db: DatabaseSync;
35+
tableName: "chunks_fts" | "chunks_vec";
36+
columns: string;
37+
dropWhenSourceMissing?: boolean;
38+
}): void {
39+
const { db, tableName, columns } = params;
40+
const createSql = readTableSql(db, MEMORY_REINDEX_SCHEMA, tableName);
41+
if (!createSql) {
42+
if (params.dropWhenSourceMissing !== false) {
43+
db.exec(`DROP TABLE IF EXISTS main.${tableName}`);
44+
}
45+
return;
46+
}
47+
db.exec(`DROP TABLE IF EXISTS main.${tableName}`);
48+
db.exec(createSql);
49+
db.exec(
50+
`INSERT INTO main.${tableName} (${columns}) ` +
51+
`SELECT ${columns} FROM ${MEMORY_REINDEX_SCHEMA}.${tableName}`,
52+
);
53+
}
54+
55+
/** Publish a completed shadow memory index without replacing the shared agent database file. */
56+
export function publishMemoryDatabaseTables(params: {
57+
targetDb: DatabaseSync;
58+
sourcePath: string;
59+
metaKey: string;
60+
}): void {
61+
params.targetDb.prepare(`ATTACH DATABASE ? AS ${MEMORY_REINDEX_SCHEMA}`).run(params.sourcePath);
62+
try {
63+
runSqliteImmediateTransactionSync(params.targetDb, () => {
64+
params.targetDb.prepare("DELETE FROM main.meta WHERE key = ?").run(params.metaKey);
65+
params.targetDb
66+
.prepare(
67+
`INSERT INTO main.meta (key, value)
68+
SELECT key, value FROM ${MEMORY_REINDEX_SCHEMA}.meta WHERE key = ?`,
69+
)
70+
.run(params.metaKey);
71+
72+
params.targetDb.exec(`
73+
DELETE FROM main.files;
74+
INSERT INTO main.files (path, source, hash, mtime, size)
75+
SELECT path, source, hash, mtime, size FROM ${MEMORY_REINDEX_SCHEMA}.files;
76+
77+
DELETE FROM main.chunks;
78+
INSERT INTO main.chunks (
79+
id, path, source, start_line, end_line, hash, model, text, embedding, updated_at
80+
)
81+
SELECT
82+
id, path, source, start_line, end_line, hash, model, text, embedding, updated_at
83+
FROM ${MEMORY_REINDEX_SCHEMA}.chunks;
84+
`);
85+
86+
if (tableExists(params.targetDb, MEMORY_REINDEX_SCHEMA, "embedding_cache")) {
87+
params.targetDb.exec(`
88+
DELETE FROM main.embedding_cache;
89+
INSERT INTO main.embedding_cache (
90+
provider, model, provider_key, hash, embedding, dims, updated_at
91+
)
92+
SELECT provider, model, provider_key, hash, embedding, dims, updated_at
93+
FROM ${MEMORY_REINDEX_SCHEMA}.embedding_cache;
94+
`);
95+
}
96+
97+
replaceVirtualTable({
98+
db: params.targetDb,
99+
tableName: "chunks_fts",
100+
columns: "text, id, path, source, model, start_line, end_line",
101+
});
102+
replaceVirtualTable({
103+
db: params.targetDb,
104+
tableName: "chunks_vec",
105+
columns: "id, embedding",
106+
// A vector-disabled connection may not have sqlite-vec loaded and cannot
107+
// drop an old virtual table. It is unused and can remain until vec loads.
108+
dropWhenSourceMissing: false,
109+
});
110+
});
111+
} finally {
112+
params.targetDb.exec(`DETACH DATABASE ${MEMORY_REINDEX_SCHEMA}`);
113+
}
114+
}
115+
116+
/** Remove one closed shadow memory database and its journal-mode sidecars. */
117+
export function removeMemoryDatabaseFiles(dbPath: string): void {
118+
for (const suffix of MEMORY_DATABASE_FILE_SUFFIXES) {
119+
fs.rmSync(`${dbPath}${suffix}`, { force: true });
120+
}
121+
}
11122

12123
export function openMemoryDatabaseAtPath(
13124
dbPath: string,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Memory Core plugin module serializes full memory reindex builds across processes.
2+
import type { DatabaseSync } from "node:sqlite";
3+
import { requireNodeSqlite } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
4+
5+
export type MemoryReindexLockHandle = {
6+
release: () => void;
7+
};
8+
9+
export function resolveMemoryReindexLockPath(dbPath: string): string {
10+
return `${dbPath}.reindex-lock.sqlite`;
11+
}
12+
13+
function isSqliteBusyError(err: unknown): boolean {
14+
const code = (err as { code?: unknown }).code;
15+
if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") {
16+
return true;
17+
}
18+
const message = err instanceof Error ? err.message : String(err);
19+
return /SQLITE_(?:BUSY|LOCKED)|database is locked/i.test(message);
20+
}
21+
22+
function openMemoryLockDatabase(lockPath: string): DatabaseSync {
23+
const { DatabaseSync } = requireNodeSqlite();
24+
const lockDb = new DatabaseSync(lockPath);
25+
try {
26+
lockDb.exec("PRAGMA busy_timeout = 0");
27+
return lockDb;
28+
} catch (err) {
29+
try {
30+
lockDb.close();
31+
} catch {}
32+
throw err;
33+
}
34+
}
35+
36+
/** Acquire an exclusive build lock without locking readers of the live agent database. */
37+
export function acquireMemoryReindexLock(dbPath: string): MemoryReindexLockHandle {
38+
const lockPath = resolveMemoryReindexLockPath(dbPath);
39+
const lockDb = openMemoryLockDatabase(lockPath);
40+
try {
41+
lockDb.exec("BEGIN EXCLUSIVE");
42+
} catch (err) {
43+
lockDb.close();
44+
if (isSqliteBusyError(err)) {
45+
throw Object.assign(
46+
new Error(`Memory reindex lock is held at ${lockPath}; another reindex is active.`),
47+
{ code: "SQLITE_BUSY" },
48+
);
49+
}
50+
throw err;
51+
}
52+
return {
53+
release: () => {
54+
let releaseError: unknown;
55+
try {
56+
lockDb.exec("ROLLBACK");
57+
} catch (err) {
58+
releaseError = err;
59+
}
60+
try {
61+
lockDb.close();
62+
} catch (err) {
63+
releaseError ??= err;
64+
}
65+
if (releaseError) {
66+
throw new Error("Failed to release memory reindex lock", { cause: releaseError });
67+
}
68+
},
69+
};
70+
}

0 commit comments

Comments
 (0)