memorix 1.8.9 → 1.9.0
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/CHANGELOG.md +18 -0
- package/README.md +7 -0
- package/README.zh-CN.md +6 -0
- package/dist/cli/index.js +3261 -1348
- package/dist/index.js +324 -187
- package/dist/maintenance-runner.js +285 -103
- package/dist/media-audio-runner.js +285 -103
- package/dist/media-video-runner.js +285 -103
- package/dist/memcode-runtime/CHANGELOG.md +18 -0
- package/dist/sdk.js +323 -186
- package/dist/vector-backfill-runner.d.ts +4 -1
- package/dist/vector-backfill-runner.js +288 -104
- package/docs/API_REFERENCE.md +14 -0
- package/docs/CONFIGURATION.md +46 -0
- package/docs/DEVELOPMENT.md +2 -2
- package/docs/MULTI-DEVICE-SYNC-RESEARCH.md +344 -0
- package/docs/PERFORMANCE.md +32 -0
- package/docs/README.md +4 -2
- package/llms-full.txt +1 -0
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/plugins/antigravity/memorix/plugin.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/hermes/memorix/plugin.yaml +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/cli/command-guide.ts +9 -1
- package/src/cli/commands/serve-http.ts +11 -0
- package/src/cli/commands/sync-store.ts +191 -0
- package/src/cli/commands/sync.ts +8 -0
- package/src/memory/observations.ts +15 -2
- package/src/runtime/maintenance-targets.ts +6 -0
- package/src/runtime/vector-backfill-runner.ts +185 -7
- package/src/store/orama-store.ts +85 -102
- package/src/sync/adapters/fs.ts +179 -0
- package/src/sync/adapters/github.ts +258 -0
- package/src/sync/adapters/object-store.ts +267 -0
- package/src/sync/adapters/postgres.ts +269 -0
- package/src/sync/engine.ts +353 -0
- package/src/sync/journal.ts +162 -0
- package/src/sync/merge.ts +116 -0
- package/src/sync/namespace.ts +15 -0
- package/src/sync/paging.ts +45 -0
- package/src/sync/policy.ts +37 -0
- package/src/sync/remote-factory.ts +49 -0
- package/src/sync/store-port.ts +288 -0
- package/src/sync/types.ts +183 -0
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { defineCommand } from 'citty';
|
|
23
23
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
24
|
+
import { getHeapStatistics } from 'node:v8';
|
|
24
25
|
import type { ObservationStore } from '../../store/obs-store.js';
|
|
25
26
|
import { resolveToolProfile } from '../../server/tool-profile.js';
|
|
26
27
|
import { scopeKnowledgeGraphToProject } from '../../memory/graph-scope.js';
|
|
@@ -1714,6 +1715,8 @@ export default defineCommand({
|
|
|
1714
1715
|
// This is the readiness signal for background start / status.
|
|
1715
1716
|
if (url.pathname === '/health') {
|
|
1716
1717
|
const { getEmbeddingRuntimeHealth } = await import('../../embedding/provider.js');
|
|
1718
|
+
const memory = process.memoryUsage();
|
|
1719
|
+
const heap = getHeapStatistics();
|
|
1717
1720
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1718
1721
|
res.end(JSON.stringify({
|
|
1719
1722
|
status: 'ok',
|
|
@@ -1725,6 +1728,14 @@ export default defineCommand({
|
|
|
1725
1728
|
projectId: defaultProject?.id ?? '__unresolved__',
|
|
1726
1729
|
projectName: defaultProject?.name ?? null,
|
|
1727
1730
|
readyAt: httpReadyAt,
|
|
1731
|
+
memory: {
|
|
1732
|
+
rss: memory.rss,
|
|
1733
|
+
heapUsed: memory.heapUsed,
|
|
1734
|
+
heapTotal: memory.heapTotal,
|
|
1735
|
+
external: memory.external,
|
|
1736
|
+
arrayBuffers: memory.arrayBuffers,
|
|
1737
|
+
heapLimit: heap.heap_size_limit,
|
|
1738
|
+
},
|
|
1728
1739
|
embedding: getEmbeddingRuntimeHealth(),
|
|
1729
1740
|
}));
|
|
1730
1741
|
return;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `memorix sync store <push|pull|status>` — multi-device store replication.
|
|
3
|
+
*
|
|
4
|
+
* Separated from `memorix sync rules|workspace` (cross-agent rules) so the two
|
|
5
|
+
* meanings of "sync" never collide. Store sync is opt-in and provider-agnostic;
|
|
6
|
+
* the remote is selected by `MEMORIX_SYNC_PROVIDER`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { defineCommand } from 'citty';
|
|
10
|
+
|
|
11
|
+
import { getCliProjectContext, emitError, emitResult } from './operator-shared.js';
|
|
12
|
+
import { getObservationStore, initObservationStore } from '../../store/obs-store.js';
|
|
13
|
+
import { runSync } from '../../sync/engine.js';
|
|
14
|
+
import { createSqliteSyncStore } from '../../sync/store-port.js';
|
|
15
|
+
import { resolveSyncConfig, createRemote } from '../../sync/remote-factory.js';
|
|
16
|
+
import type { SyncReport } from '../../sync/types.js';
|
|
17
|
+
|
|
18
|
+
function renderReport(report: SyncReport): string {
|
|
19
|
+
const lines = [
|
|
20
|
+
`Store sync (${report.remote}) — device ${report.deviceId}${report.dryRun ? ' [dry-run]' : ''}`,
|
|
21
|
+
`- Pushed changes: ${report.pushed}`,
|
|
22
|
+
`- Pulled batches: ${report.pulledBatches}`,
|
|
23
|
+
`- Applied upserts: ${report.applied}`,
|
|
24
|
+
`- Applied deletes: ${report.tombstoned}`,
|
|
25
|
+
`- Skipped (older): ${report.skipped}`,
|
|
26
|
+
`- Excluded locally: ${report.excluded}`,
|
|
27
|
+
`- Conflicts recorded: ${report.conflicts}`,
|
|
28
|
+
`- Pending outbox: ${report.pending}`,
|
|
29
|
+
];
|
|
30
|
+
if (report.dryRun && report.decisions.length > 0) {
|
|
31
|
+
lines.push('', 'Decisions:');
|
|
32
|
+
for (const d of report.decisions) {
|
|
33
|
+
lines.push(` ${d.syncKey} ${d.kind} -> ${d.outcome} (${d.reason})`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return lines.join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default defineCommand({
|
|
40
|
+
meta: {
|
|
41
|
+
name: 'sync-store',
|
|
42
|
+
description: 'Replicate the local observation store across your devices (opt-in, provider-agnostic)',
|
|
43
|
+
},
|
|
44
|
+
args: {
|
|
45
|
+
dry: { type: 'boolean', description: 'Preview changes without writing anything', default: false },
|
|
46
|
+
json: { type: 'boolean', description: 'Emit JSON', default: false },
|
|
47
|
+
'push-only': { type: 'boolean', description: 'Only push local changes', default: false },
|
|
48
|
+
'pull-only': { type: 'boolean', description: 'Only pull remote changes', default: false },
|
|
49
|
+
through: { type: 'string', description: 'Compaction cutoff: device=sequence,device=sequence' },
|
|
50
|
+
yes: { type: 'boolean', description: 'Confirm destructive remote compaction', default: false },
|
|
51
|
+
},
|
|
52
|
+
run: async ({ args }) => {
|
|
53
|
+
const asJson = Boolean(args.json);
|
|
54
|
+
const action = (args._ as string[])?.[0] || 'status';
|
|
55
|
+
if (action === 'device') {
|
|
56
|
+
const { project, dataDir } = await getCliProjectContext();
|
|
57
|
+
await initObservationStore(dataDir);
|
|
58
|
+
const syncStore = createSqliteSyncStore(dataDir, getObservationStore(), project.id);
|
|
59
|
+
const subaction = (args._ as string[])?.[1] || '';
|
|
60
|
+
if (subaction !== 'rotate') {
|
|
61
|
+
emitError('expected "memorix sync store device rotate"', asJson);
|
|
62
|
+
process.exitCode = 2;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const deviceId = syncStore.rotateDevice();
|
|
66
|
+
emitResult({ project: project.id, deviceId, rotated: true }, `Sync device identity rotated: ${deviceId}`, asJson);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (action !== 'push' && action !== 'pull' && action !== 'status' && action !== 'compact') {
|
|
70
|
+
emitError(`unknown action "${action}" (expected push|pull|status|compact|device rotate)`, asJson);
|
|
71
|
+
process.exitCode = 2;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let config;
|
|
76
|
+
try {
|
|
77
|
+
config = resolveSyncConfig();
|
|
78
|
+
} catch (err) {
|
|
79
|
+
emitError((err as Error).message, asJson);
|
|
80
|
+
process.exitCode = 2;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!config.enabled || !config.provider) {
|
|
85
|
+
emitResult(
|
|
86
|
+
{ enabled: false },
|
|
87
|
+
'Store sync is disabled. Set MEMORIX_SYNC_PROVIDER=fs|github|s3|postgres to enable it.',
|
|
88
|
+
asJson,
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const { project, dataDir } = await getCliProjectContext();
|
|
94
|
+
await initObservationStore(dataDir);
|
|
95
|
+
const obsStore = getObservationStore();
|
|
96
|
+
const syncStore = createSqliteSyncStore(dataDir, obsStore, project.id);
|
|
97
|
+
|
|
98
|
+
if (action === 'compact') {
|
|
99
|
+
if (!args.yes && !args.dry) {
|
|
100
|
+
emitError('remote compaction deletes relay events; repeat with --yes after specifying --through device=sequence', asJson);
|
|
101
|
+
process.exitCode = 2;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const raw = String(args.through ?? '');
|
|
105
|
+
const through: Record<string, number> = {};
|
|
106
|
+
for (const item of raw.split(',').map((value) => value.trim()).filter(Boolean)) {
|
|
107
|
+
const [device, sequence] = item.split('=', 2);
|
|
108
|
+
const parsed = Number(sequence);
|
|
109
|
+
if (!device || !Number.isSafeInteger(parsed) || parsed < 0) {
|
|
110
|
+
emitError('invalid --through; expected device=sequence[,device=sequence]', asJson);
|
|
111
|
+
process.exitCode = 2;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
through[device] = parsed;
|
|
115
|
+
}
|
|
116
|
+
if (Object.keys(through).length === 0) {
|
|
117
|
+
emitError('--through is required for compaction', asJson);
|
|
118
|
+
process.exitCode = 2;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
let remote;
|
|
122
|
+
try {
|
|
123
|
+
remote = await createRemote(config.provider, syncStore.namespace());
|
|
124
|
+
} catch (err) {
|
|
125
|
+
emitError((err as Error).message, asJson);
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await remote.init({ create: false });
|
|
131
|
+
const result = await remote.compact(through, { dryRun: Boolean(args.dry) });
|
|
132
|
+
emitResult({ project: project.id, through, ...result, dryRun: Boolean(args.dry) }, `Compaction candidates: ${result.candidates}; deleted: ${result.deleted}`, asJson);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
emitError((err as Error).message, asJson);
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
} finally {
|
|
137
|
+
await remote.close();
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (action === 'status') {
|
|
143
|
+
let remote;
|
|
144
|
+
try {
|
|
145
|
+
remote = await createRemote(config.provider, syncStore.namespace());
|
|
146
|
+
} catch (err) {
|
|
147
|
+
emitError((err as Error).message, asJson);
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const report = await runSync(syncStore, remote, {
|
|
153
|
+
deviceId: syncStore.deviceId(),
|
|
154
|
+
dryRun: true,
|
|
155
|
+
push: true,
|
|
156
|
+
pull: true,
|
|
157
|
+
});
|
|
158
|
+
emitResult({ project: project.id, ...report }, renderReport(report), asJson);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
emitError((err as Error).message, asJson);
|
|
161
|
+
process.exitCode = 1;
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const doPush = action === 'push' || !args['pull-only'];
|
|
167
|
+
const doPull = action === 'pull' || !args['push-only'];
|
|
168
|
+
|
|
169
|
+
let remote;
|
|
170
|
+
try {
|
|
171
|
+
remote = await createRemote(config.provider, syncStore.namespace());
|
|
172
|
+
} catch (err) {
|
|
173
|
+
emitError((err as Error).message, asJson);
|
|
174
|
+
process.exitCode = 1;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const report = await runSync(syncStore, remote, {
|
|
180
|
+
deviceId: syncStore.deviceId(),
|
|
181
|
+
dryRun: Boolean(args.dry),
|
|
182
|
+
push: doPush,
|
|
183
|
+
pull: doPull,
|
|
184
|
+
});
|
|
185
|
+
emitResult({ project: project.id, ...report }, renderReport(report), asJson);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
emitError((err as Error).message, asJson);
|
|
188
|
+
process.exitCode = 1;
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
});
|
package/src/cli/commands/sync.ts
CHANGED
|
@@ -51,6 +51,14 @@ export default defineCommand({
|
|
|
51
51
|
},
|
|
52
52
|
run: async ({ args }) => {
|
|
53
53
|
const section = (args._ as string[])?.[0] || '';
|
|
54
|
+
|
|
55
|
+
// Store replication is a distinct concern from rules/workspace sync.
|
|
56
|
+
if (section === 'store') {
|
|
57
|
+
const storeCmd = (await import('./sync-store.js')).default;
|
|
58
|
+
const rest = (args._ as string[]).slice(1);
|
|
59
|
+
await storeCmd.run!({ args: { ...args, _: rest } } as never);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
54
62
|
const asJson = !!args.json;
|
|
55
63
|
|
|
56
64
|
if (section === 'rules' || section === 'workspace') {
|
|
@@ -83,6 +83,7 @@ export function resetObservationRuntime(): void {
|
|
|
83
83
|
embeddingWorkerProjectRoot = undefined;
|
|
84
84
|
vectorMissingIds.clear();
|
|
85
85
|
vectorSchemaUpgradePromise = null;
|
|
86
|
+
embeddingFailureLogTimestamps.clear();
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
// ── Vector-missing tracking ──────────────────────────────────────
|
|
@@ -100,11 +101,17 @@ let lastVectorBackfill: {
|
|
|
100
101
|
} | null = null;
|
|
101
102
|
const embeddingFailureLogTimestamps = new Map<string, number>();
|
|
102
103
|
const EMBEDDING_FAILURE_LOG_COOLDOWN_MS = 30_000;
|
|
104
|
+
const MAX_EMBEDDING_FAILURE_KEYS = 1_024;
|
|
103
105
|
|
|
104
106
|
function logEmbeddingFailureOnce(key: string, message: string): void {
|
|
105
107
|
const now = Date.now();
|
|
106
108
|
const last = embeddingFailureLogTimestamps.get(key) ?? 0;
|
|
107
109
|
if (now - last < EMBEDDING_FAILURE_LOG_COOLDOWN_MS) return;
|
|
110
|
+
if (!embeddingFailureLogTimestamps.has(key) && embeddingFailureLogTimestamps.size >= MAX_EMBEDDING_FAILURE_KEYS) {
|
|
111
|
+
const oldest = embeddingFailureLogTimestamps.keys().next().value;
|
|
112
|
+
if (oldest !== undefined) embeddingFailureLogTimestamps.delete(oldest);
|
|
113
|
+
}
|
|
114
|
+
embeddingFailureLogTimestamps.delete(key);
|
|
108
115
|
embeddingFailureLogTimestamps.set(key, now);
|
|
109
116
|
console.error(message);
|
|
110
117
|
}
|
|
@@ -149,13 +156,19 @@ function queueVectorBackfill(
|
|
|
149
156
|
if (!dataDir) return;
|
|
150
157
|
void import('../runtime/maintenance-jobs.js')
|
|
151
158
|
.then(({ MaintenanceJobStore }) => {
|
|
152
|
-
new MaintenanceJobStore(dataDir).enqueue({
|
|
159
|
+
const job = new MaintenanceJobStore(dataDir).enqueue({
|
|
153
160
|
projectId,
|
|
154
161
|
kind: 'vector-backfill',
|
|
155
162
|
dedupeKey: 'vector-backfill',
|
|
156
163
|
payload: { limit: 12 },
|
|
157
164
|
});
|
|
158
|
-
|
|
165
|
+
// A retrying job is intentionally in cooldown. Starting a new detached
|
|
166
|
+
// process for every hook event would turn one provider outage into a
|
|
167
|
+
// process storm even though the durable queue already deduplicated it.
|
|
168
|
+
// Keep compatibility with lightweight test/embedding adapters that only
|
|
169
|
+
// implement enqueue and return no job receipt.
|
|
170
|
+
const due = !job || (job.status === 'pending' && job.runAfter <= Date.now());
|
|
171
|
+
if (due && options.detachedWorker && options.projectRoot) {
|
|
159
172
|
void import('../runtime/vector-backfill-runner.js')
|
|
160
173
|
.then(({ launchDetachedVectorBackfill }) => {
|
|
161
174
|
launchDetachedVectorBackfill({
|
|
@@ -56,4 +56,10 @@ export class MaintenanceTargetStore {
|
|
|
56
56
|
`).get(projectId);
|
|
57
57
|
return row ? rowToTarget(row) : undefined;
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
list(): MaintenanceTarget[] {
|
|
61
|
+
return (this.db.prepare(`
|
|
62
|
+
SELECT * FROM maintenance_targets ORDER BY updated_at DESC
|
|
63
|
+
`).all() as any[]).map(rowToTarget);
|
|
64
|
+
}
|
|
59
65
|
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
statSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
3
12
|
import path from 'node:path';
|
|
4
13
|
import { fileURLToPath } from 'node:url';
|
|
5
14
|
|
|
@@ -11,8 +20,12 @@ import { initObservationStore } from '../store/obs-store.js';
|
|
|
11
20
|
import { getDeferredCachedVectorHydration } from '../store/orama-store.js';
|
|
12
21
|
import { closeAllDatabases } from '../store/sqlite-db.js';
|
|
13
22
|
import { MaintenanceJobStore, MaintenanceJobWorker } from './maintenance-jobs.js';
|
|
23
|
+
import { MaintenanceTargetStore, type MaintenanceTarget } from './maintenance-targets.js';
|
|
14
24
|
import { createProjectMaintenanceHandler } from './project-maintenance.js';
|
|
15
25
|
|
|
26
|
+
const VECTOR_BACKFILL_LOCK_FILE = '.memorix-vector-backfill.lock';
|
|
27
|
+
const STALE_LOCK_RECOVERY_MS = 24 * 60 * 60 * 1_000;
|
|
28
|
+
|
|
16
29
|
export interface VectorBackfillRequest {
|
|
17
30
|
projectId: string;
|
|
18
31
|
projectRoot: string;
|
|
@@ -23,6 +36,20 @@ export interface VectorBackfillLauncherOptions {
|
|
|
23
36
|
runnerPath?: string;
|
|
24
37
|
exists?: (path: string) => boolean;
|
|
25
38
|
spawn?: typeof spawn;
|
|
39
|
+
lockPath?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface VectorBackfillLock {
|
|
43
|
+
parentPid: number;
|
|
44
|
+
childPid?: number;
|
|
45
|
+
startedAt: number;
|
|
46
|
+
projectId: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface VectorBackfillLockHandle {
|
|
50
|
+
path: string;
|
|
51
|
+
setChildPid(pid: number): void;
|
|
52
|
+
release(): void;
|
|
26
53
|
}
|
|
27
54
|
|
|
28
55
|
function isNonEmptyString(value: unknown): value is string {
|
|
@@ -68,6 +95,111 @@ export function parseVectorBackfillRequest(raw: string): VectorBackfillRequest {
|
|
|
68
95
|
};
|
|
69
96
|
}
|
|
70
97
|
|
|
98
|
+
export function vectorBackfillLockPath(dataDir: string): string {
|
|
99
|
+
return path.join(dataDir, VECTOR_BACKFILL_LOCK_FILE);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function processIsAlive(pid: number | undefined): boolean {
|
|
103
|
+
if (!Number.isSafeInteger(pid) || pid == null || pid <= 0) return false;
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0);
|
|
106
|
+
return true;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// EPERM means the process exists but this process cannot signal it.
|
|
109
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readVectorBackfillLock(lockPath: string): VectorBackfillLock | undefined {
|
|
114
|
+
try {
|
|
115
|
+
const value = JSON.parse(readFileSync(lockPath, 'utf8')) as Partial<VectorBackfillLock>;
|
|
116
|
+
if (
|
|
117
|
+
typeof value.parentPid !== 'number'
|
|
118
|
+
|| !Number.isSafeInteger(value.parentPid)
|
|
119
|
+
|| typeof value.startedAt !== 'number'
|
|
120
|
+
|| !Number.isFinite(value.startedAt)
|
|
121
|
+
|| typeof value.projectId !== 'string'
|
|
122
|
+
) return undefined;
|
|
123
|
+
return {
|
|
124
|
+
parentPid: value.parentPid,
|
|
125
|
+
...(typeof value.childPid === 'number' ? { childPid: value.childPid } : {}),
|
|
126
|
+
startedAt: value.startedAt,
|
|
127
|
+
projectId: value.projectId,
|
|
128
|
+
};
|
|
129
|
+
} catch {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function writeVectorBackfillLock(lockPath: string, value: VectorBackfillLock): void {
|
|
135
|
+
writeFileSync(lockPath, `${JSON.stringify(value)}\n`, 'utf8');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function acquireVectorBackfillLock(
|
|
139
|
+
dataDir: string,
|
|
140
|
+
projectId: string,
|
|
141
|
+
preferredPath?: string,
|
|
142
|
+
): VectorBackfillLockHandle | undefined {
|
|
143
|
+
const lockPath = preferredPath ?? vectorBackfillLockPath(dataDir);
|
|
144
|
+
mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
145
|
+
const initial: VectorBackfillLock = {
|
|
146
|
+
parentPid: process.pid,
|
|
147
|
+
startedAt: Date.now(),
|
|
148
|
+
projectId,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
152
|
+
try {
|
|
153
|
+
const fd = openSync(lockPath, 'wx');
|
|
154
|
+
try {
|
|
155
|
+
writeFileSync(fd, `${JSON.stringify(initial)}\n`, 'utf8');
|
|
156
|
+
} finally {
|
|
157
|
+
closeSync(fd);
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
path: lockPath,
|
|
161
|
+
setChildPid(pid: number) {
|
|
162
|
+
try {
|
|
163
|
+
writeVectorBackfillLock(lockPath, { ...initial, childPid: pid });
|
|
164
|
+
} catch {
|
|
165
|
+
// The child can still release the lock on normal shutdown; a dead
|
|
166
|
+
// owner is recoverable on the next launch.
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
release() {
|
|
170
|
+
try { unlinkSync(lockPath); } catch { /* already released */ }
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') return undefined;
|
|
175
|
+
const existing = readVectorBackfillLock(lockPath);
|
|
176
|
+
const ownerAlive = existing && (processIsAlive(existing.childPid) || processIsAlive(existing.parentPid));
|
|
177
|
+
if (ownerAlive) return undefined;
|
|
178
|
+
|
|
179
|
+
// A malformed lock is not deleted immediately: another process may be
|
|
180
|
+
// between create and write. A dead, well-formed owner is safe to reap;
|
|
181
|
+
// an unreadable lock needs a long recovery window so it cannot cause a
|
|
182
|
+
// second heavy worker during a slow filesystem operation.
|
|
183
|
+
let stale = false;
|
|
184
|
+
try {
|
|
185
|
+
stale = Date.now() - statSync(lockPath).mtimeMs >= STALE_LOCK_RECOVERY_MS;
|
|
186
|
+
} catch {
|
|
187
|
+
stale = false;
|
|
188
|
+
}
|
|
189
|
+
if (!existing && !stale) return undefined;
|
|
190
|
+
try { unlinkSync(lockPath); } catch { return undefined; }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function releaseVectorBackfillLock(lockPath = process.env.MEMORIX_VECTOR_BACKFILL_LOCK): void {
|
|
197
|
+
if (!lockPath) return;
|
|
198
|
+
const lock = readVectorBackfillLock(lockPath);
|
|
199
|
+
if (lock && lock.childPid !== process.pid) return;
|
|
200
|
+
try { unlinkSync(lockPath); } catch { /* already released */ }
|
|
201
|
+
}
|
|
202
|
+
|
|
71
203
|
/**
|
|
72
204
|
* Start a detached one-shot worker. The caller has already persisted the
|
|
73
205
|
* observation and durable vector job, so failure to start is recoverable by a
|
|
@@ -81,6 +213,9 @@ export function launchDetachedVectorBackfill(
|
|
|
81
213
|
const exists = options.exists ?? existsSync;
|
|
82
214
|
if (!exists(runnerPath)) return false;
|
|
83
215
|
|
|
216
|
+
const lock = acquireVectorBackfillLock(request.dataDir, request.projectId, options.lockPath);
|
|
217
|
+
if (!lock) return false;
|
|
218
|
+
|
|
84
219
|
try {
|
|
85
220
|
const child = (options.spawn ?? spawn)(process.execPath, [runnerPath], {
|
|
86
221
|
cwd: request.projectRoot,
|
|
@@ -90,13 +225,24 @@ export function launchDetachedVectorBackfill(
|
|
|
90
225
|
stdio: 'ignore',
|
|
91
226
|
// The request contains only local project metadata, never credentials.
|
|
92
227
|
// Environment transport avoids a live stdin pipe keeping the CLI alive.
|
|
93
|
-
env: {
|
|
228
|
+
env: {
|
|
229
|
+
...process.env,
|
|
230
|
+
MEMORIX_VECTOR_BACKFILL_REQUEST: JSON.stringify(request),
|
|
231
|
+
MEMORIX_VECTOR_BACKFILL_LOCK: lock.path,
|
|
232
|
+
},
|
|
94
233
|
windowsHide: true,
|
|
95
234
|
}) as ChildProcess;
|
|
235
|
+
if (child.pid) lock.setChildPid(child.pid);
|
|
236
|
+
else lock.release();
|
|
96
237
|
child.once?.('error', () => {});
|
|
238
|
+
child.once?.('close', () => lock.release());
|
|
239
|
+
// Test doubles and unusual spawn adapters may not expose ChildProcess
|
|
240
|
+
// events. Do not leave a lock behind when there is no lifecycle to own it.
|
|
241
|
+
if (typeof child.once !== 'function') lock.release();
|
|
97
242
|
child.unref();
|
|
98
|
-
return
|
|
243
|
+
return Boolean(child.pid);
|
|
99
244
|
} catch {
|
|
245
|
+
lock.release();
|
|
100
246
|
return false;
|
|
101
247
|
}
|
|
102
248
|
}
|
|
@@ -110,12 +256,43 @@ export async function executeVectorBackfill(request: VectorBackfillRequest) {
|
|
|
110
256
|
await prepareSearchIndex();
|
|
111
257
|
await getDeferredCachedVectorHydration()?.catch(() => {});
|
|
112
258
|
|
|
259
|
+
const queue = new MaintenanceJobStore(request.dataDir);
|
|
260
|
+
const targets = new MaintenanceTargetStore(request.dataDir);
|
|
261
|
+
let preparedRoot = request.projectRoot;
|
|
262
|
+
const prepareTarget = async (target: MaintenanceTarget): Promise<void> => {
|
|
263
|
+
if (target.projectRoot === preparedRoot) return;
|
|
264
|
+
initProjectRoot(target.projectRoot);
|
|
265
|
+
loadDotenv(target.projectRoot);
|
|
266
|
+
preparedRoot = target.projectRoot;
|
|
267
|
+
// The data directory is shared across projects. The in-process index is
|
|
268
|
+
// intentionally global, so switching the target only changes the config
|
|
269
|
+
// and the job scope; it does not create another corpus/index copy.
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const handler = async (job: Parameters<ReturnType<typeof createProjectMaintenanceHandler>>[0]) => {
|
|
273
|
+
const target = targets.get(job.projectId)
|
|
274
|
+
?? (job.projectId === request.projectId
|
|
275
|
+
? { projectId: request.projectId, projectRoot: request.projectRoot, dataDir: request.dataDir, updatedAt: Date.now() }
|
|
276
|
+
: undefined);
|
|
277
|
+
if (!target) return { action: 'reschedule' as const, delayMs: 30_000 };
|
|
278
|
+
await prepareTarget(target);
|
|
279
|
+
return createProjectMaintenanceHandler(job.projectId, target.dataDir, target.projectRoot)(job);
|
|
280
|
+
};
|
|
281
|
+
|
|
113
282
|
const worker = new MaintenanceJobWorker(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
{
|
|
283
|
+
queue,
|
|
284
|
+
handler,
|
|
285
|
+
{ kinds: ['vector-backfill'] },
|
|
117
286
|
);
|
|
118
|
-
|
|
287
|
+
// Drain currently due vector jobs while this one process owns the lock. A
|
|
288
|
+
// hook storm therefore creates at most one heavy process, and pending jobs
|
|
289
|
+
// for other projects are picked up without requiring another hook event.
|
|
290
|
+
let result: Awaited<ReturnType<typeof worker.runOnce>> = { state: 'idle' };
|
|
291
|
+
while (true) {
|
|
292
|
+
result = await worker.runOnce();
|
|
293
|
+
if (result.state === 'idle') break;
|
|
294
|
+
}
|
|
295
|
+
return result;
|
|
119
296
|
}
|
|
120
297
|
|
|
121
298
|
async function readStdin(): Promise<string> {
|
|
@@ -137,6 +314,7 @@ export async function main(): Promise<void> {
|
|
|
137
314
|
process.stderr.write(`[memorix] vector backfill worker failed: ${detail}\n`);
|
|
138
315
|
process.exitCode = 1;
|
|
139
316
|
} finally {
|
|
317
|
+
releaseVectorBackfillLock();
|
|
140
318
|
closeAllDatabases();
|
|
141
319
|
}
|
|
142
320
|
}
|