@aigne/afs-index 1.12.0-beta.5
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/LICENSE.md +26 -0
- package/dist/_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.cjs +11 -0
- package/dist/_virtual/_@oxc-project_runtime@0.108.0/helpers/decorate.mjs +10 -0
- package/dist/backend/d1-index-backend.cjs +656 -0
- package/dist/backend/d1-index-backend.d.cts +149 -0
- package/dist/backend/d1-index-backend.d.cts.map +1 -0
- package/dist/backend/d1-index-backend.d.mts +149 -0
- package/dist/backend/d1-index-backend.d.mts.map +1 -0
- package/dist/backend/d1-index-backend.mjs +657 -0
- package/dist/backend/d1-index-backend.mjs.map +1 -0
- package/dist/backend/d1-vector-backend.cjs +83 -0
- package/dist/backend/d1-vector-backend.d.cts +21 -0
- package/dist/backend/d1-vector-backend.d.cts.map +1 -0
- package/dist/backend/d1-vector-backend.d.mts +21 -0
- package/dist/backend/d1-vector-backend.d.mts.map +1 -0
- package/dist/backend/d1-vector-backend.mjs +84 -0
- package/dist/backend/d1-vector-backend.mjs.map +1 -0
- package/dist/backend/memory-backend.cjs +304 -0
- package/dist/backend/memory-backend.d.cts +66 -0
- package/dist/backend/memory-backend.d.cts.map +1 -0
- package/dist/backend/memory-backend.d.mts +66 -0
- package/dist/backend/memory-backend.d.mts.map +1 -0
- package/dist/backend/memory-backend.mjs +305 -0
- package/dist/backend/memory-backend.mjs.map +1 -0
- package/dist/backend/prefix-range.cjs +25 -0
- package/dist/backend/prefix-range.mjs +25 -0
- package/dist/backend/prefix-range.mjs.map +1 -0
- package/dist/backend/schema.cjs +187 -0
- package/dist/backend/schema.mjs +188 -0
- package/dist/backend/schema.mjs.map +1 -0
- package/dist/backend/sqlite-backend.cjs +889 -0
- package/dist/backend/sqlite-backend.d.cts +96 -0
- package/dist/backend/sqlite-backend.d.cts.map +1 -0
- package/dist/backend/sqlite-backend.d.mts +96 -0
- package/dist/backend/sqlite-backend.d.mts.map +1 -0
- package/dist/backend/sqlite-backend.mjs +890 -0
- package/dist/backend/sqlite-backend.mjs.map +1 -0
- package/dist/backend/types.d.cts +209 -0
- package/dist/backend/types.d.cts.map +1 -0
- package/dist/backend/types.d.mts +209 -0
- package/dist/backend/types.d.mts.map +1 -0
- package/dist/backend/upstash-vector-backend.cjs +98 -0
- package/dist/backend/upstash-vector-backend.d.cts +21 -0
- package/dist/backend/upstash-vector-backend.d.cts.map +1 -0
- package/dist/backend/upstash-vector-backend.d.mts +21 -0
- package/dist/backend/upstash-vector-backend.d.mts.map +1 -0
- package/dist/backend/upstash-vector-backend.mjs +98 -0
- package/dist/backend/upstash-vector-backend.mjs.map +1 -0
- package/dist/backend/vector-backend.d.cts +29 -0
- package/dist/backend/vector-backend.d.cts.map +1 -0
- package/dist/backend/vector-backend.d.mts +29 -0
- package/dist/backend/vector-backend.d.mts.map +1 -0
- package/dist/dirty-queue.d.cts +41 -0
- package/dist/dirty-queue.d.cts.map +1 -0
- package/dist/dirty-queue.d.mts +41 -0
- package/dist/dirty-queue.d.mts.map +1 -0
- package/dist/embed.cjs +45 -0
- package/dist/embed.d.cts +20 -0
- package/dist/embed.d.cts.map +1 -0
- package/dist/embed.d.mts +20 -0
- package/dist/embed.d.mts.map +1 -0
- package/dist/embed.mjs +46 -0
- package/dist/embed.mjs.map +1 -0
- package/dist/extraction/defaults.cjs +242 -0
- package/dist/extraction/defaults.d.cts +14 -0
- package/dist/extraction/defaults.d.cts.map +1 -0
- package/dist/extraction/defaults.d.mts +14 -0
- package/dist/extraction/defaults.d.mts.map +1 -0
- package/dist/extraction/defaults.mjs +242 -0
- package/dist/extraction/defaults.mjs.map +1 -0
- package/dist/extraction/field-map.cjs +162 -0
- package/dist/extraction/field-map.d.cts +70 -0
- package/dist/extraction/field-map.d.cts.map +1 -0
- package/dist/extraction/field-map.d.mts +70 -0
- package/dist/extraction/field-map.d.mts.map +1 -0
- package/dist/extraction/field-map.mjs +157 -0
- package/dist/extraction/field-map.mjs.map +1 -0
- package/dist/extraction/manager.cjs +74 -0
- package/dist/extraction/manager.d.cts +47 -0
- package/dist/extraction/manager.d.cts.map +1 -0
- package/dist/extraction/manager.d.mts +47 -0
- package/dist/extraction/manager.d.mts.map +1 -0
- package/dist/extraction/manager.mjs +74 -0
- package/dist/extraction/manager.mjs.map +1 -0
- package/dist/extraction/path-anchors.cjs +86 -0
- package/dist/extraction/path-anchors.d.cts +72 -0
- package/dist/extraction/path-anchors.d.cts.map +1 -0
- package/dist/extraction/path-anchors.d.mts +72 -0
- package/dist/extraction/path-anchors.d.mts.map +1 -0
- package/dist/extraction/path-anchors.mjs +86 -0
- package/dist/extraction/path-anchors.mjs.map +1 -0
- package/dist/follower-scheduler.cjs +92 -0
- package/dist/follower-scheduler.d.cts +58 -0
- package/dist/follower-scheduler.d.cts.map +1 -0
- package/dist/follower-scheduler.d.mts +58 -0
- package/dist/follower-scheduler.d.mts.map +1 -0
- package/dist/follower-scheduler.mjs +92 -0
- package/dist/follower-scheduler.mjs.map +1 -0
- package/dist/follower.cjs +577 -0
- package/dist/follower.d.cts +241 -0
- package/dist/follower.d.cts.map +1 -0
- package/dist/follower.d.mts +241 -0
- package/dist/follower.d.mts.map +1 -0
- package/dist/follower.mjs +571 -0
- package/dist/follower.mjs.map +1 -0
- package/dist/index-provider.cjs +1166 -0
- package/dist/index-provider.d.cts +175 -0
- package/dist/index-provider.d.cts.map +1 -0
- package/dist/index-provider.d.mts +175 -0
- package/dist/index-provider.d.mts.map +1 -0
- package/dist/index-provider.mjs +1167 -0
- package/dist/index-provider.mjs.map +1 -0
- package/dist/index.cjs +53 -0
- package/dist/index.d.cts +22 -0
- package/dist/index.d.mts +22 -0
- package/dist/index.mjs +20 -0
- package/dist/keys/exact.cjs +20 -0
- package/dist/keys/exact.mjs +20 -0
- package/dist/keys/exact.mjs.map +1 -0
- package/dist/keys/location.cjs +13 -0
- package/dist/keys/location.mjs +13 -0
- package/dist/keys/location.mjs.map +1 -0
- package/dist/keys/normalized.cjs +62 -0
- package/dist/keys/normalized.mjs +63 -0
- package/dist/keys/normalized.mjs.map +1 -0
- package/dist/keys/person.cjs +15 -0
- package/dist/keys/person.mjs +15 -0
- package/dist/keys/person.mjs.map +1 -0
- package/dist/keys/registry.cjs +45 -0
- package/dist/keys/registry.d.cts +15 -0
- package/dist/keys/registry.d.cts.map +1 -0
- package/dist/keys/registry.d.mts +15 -0
- package/dist/keys/registry.d.mts.map +1 -0
- package/dist/keys/registry.mjs +46 -0
- package/dist/keys/registry.mjs.map +1 -0
- package/dist/keys/time.cjs +128 -0
- package/dist/keys/time.mjs +128 -0
- package/dist/keys/time.mjs.map +1 -0
- package/dist/keys/topic.cjs +23 -0
- package/dist/keys/topic.mjs +23 -0
- package/dist/keys/topic.mjs.map +1 -0
- package/dist/manifest-index.cjs +29 -0
- package/dist/manifest-index.d.cts +24 -0
- package/dist/manifest-index.d.cts.map +1 -0
- package/dist/manifest-index.d.mts +24 -0
- package/dist/manifest-index.d.mts.map +1 -0
- package/dist/manifest-index.mjs +28 -0
- package/dist/manifest-index.mjs.map +1 -0
- package/dist/offline-rebuild.cjs +48 -0
- package/dist/offline-rebuild.d.cts +57 -0
- package/dist/offline-rebuild.d.cts.map +1 -0
- package/dist/offline-rebuild.d.mts +57 -0
- package/dist/offline-rebuild.d.mts.map +1 -0
- package/dist/offline-rebuild.mjs +49 -0
- package/dist/offline-rebuild.mjs.map +1 -0
- package/dist/query/engine.cjs +433 -0
- package/dist/query/engine.d.cts +34 -0
- package/dist/query/engine.d.cts.map +1 -0
- package/dist/query/engine.d.mts +34 -0
- package/dist/query/engine.d.mts.map +1 -0
- package/dist/query/engine.mjs +433 -0
- package/dist/query/engine.mjs.map +1 -0
- package/dist/query/limits.cjs +12 -0
- package/dist/query/limits.mjs +12 -0
- package/dist/query/limits.mjs.map +1 -0
- package/dist/text/cjk.cjs +30 -0
- package/dist/text/cjk.d.cts +24 -0
- package/dist/text/cjk.d.cts.map +1 -0
- package/dist/text/cjk.d.mts +24 -0
- package/dist/text/cjk.d.mts.map +1 -0
- package/dist/text/cjk.mjs +30 -0
- package/dist/text/cjk.mjs.map +1 -0
- package/dist/text/fts.cjs +244 -0
- package/dist/text/fts.mjs +245 -0
- package/dist/text/fts.mjs.map +1 -0
- package/dist/text/host.cjs +39 -0
- package/dist/text/host.mjs +38 -0
- package/dist/text/host.mjs.map +1 -0
- package/dist/types.cjs +44 -0
- package/dist/types.d.cts +393 -0
- package/dist/types.d.cts.map +1 -0
- package/dist/types.d.mts +393 -0
- package/dist/types.d.mts.map +1 -0
- package/dist/types.mjs +42 -0
- package/dist/types.mjs.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
//#region src/follower-scheduler.ts
|
|
2
|
+
function clampSpent(value, remaining) {
|
|
3
|
+
if (!Number.isFinite(value ?? 0) || (value ?? 0) <= 0) return 0;
|
|
4
|
+
return Math.min(value ?? 0, remaining);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Fairly spend one index-follower tick across a selected scope set.
|
|
8
|
+
*
|
|
9
|
+
* Each scope receives a small bounded slice first; scopes that still report
|
|
10
|
+
* `hasMore` are appended to the tail and can receive another slice only after
|
|
11
|
+
* the other selected scopes had their chance. The helper is intentionally
|
|
12
|
+
* storage/runtime-agnostic so Cloudflare and Node share the same starvation
|
|
13
|
+
* semantics while keeping different budget sizes.
|
|
14
|
+
*/
|
|
15
|
+
async function runIndexFollowerRoundRobin(opts) {
|
|
16
|
+
let remainingProcessed = Math.max(0, opts.maxProcessed);
|
|
17
|
+
let remainingNoop = Math.max(0, opts.maxNoop);
|
|
18
|
+
let remainingScan = Math.max(0, opts.maxScan);
|
|
19
|
+
const perScopeMaxProcessed = Math.max(0, opts.perScopeMaxProcessed ?? 1);
|
|
20
|
+
const perScopeMaxNoop = Math.max(0, opts.perScopeMaxNoop ?? 2);
|
|
21
|
+
const perScopeMaxScan = Math.max(1, opts.perScopeMaxScan ?? 50);
|
|
22
|
+
const maxRuns = opts.maxRuns ?? opts.scopes.length + Math.ceil((remainingProcessed || 0) / Math.max(1, perScopeMaxProcessed || 1)) + Math.ceil((remainingNoop || 0) / Math.max(1, perScopeMaxNoop || 1)) + Math.ceil((remainingScan || 0) / Math.max(1, perScopeMaxScan));
|
|
23
|
+
const queue = [...opts.scopes];
|
|
24
|
+
const runs = [];
|
|
25
|
+
let indexedThisTick = 0;
|
|
26
|
+
let deletedThisTick = 0;
|
|
27
|
+
let noopedThisTick = 0;
|
|
28
|
+
let ackRaceThisTick = 0;
|
|
29
|
+
let stoppedBy = "drained";
|
|
30
|
+
let sawBudgetBlockedScope = false;
|
|
31
|
+
while (queue.length > 0) {
|
|
32
|
+
if (opts.shouldStop?.()) {
|
|
33
|
+
stoppedBy = "time";
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
if (runs.length >= maxRuns) {
|
|
37
|
+
stoppedBy = "max_runs";
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
if (remainingScan <= 0 || remainingProcessed <= 0 && remainingNoop <= 0) {
|
|
41
|
+
stoppedBy = "budget";
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
const scope = queue.shift();
|
|
45
|
+
const limits = {
|
|
46
|
+
maxProcessed: Math.min(perScopeMaxProcessed, remainingProcessed),
|
|
47
|
+
maxNoop: Math.min(perScopeMaxNoop, remainingNoop),
|
|
48
|
+
maxScan: Math.min(perScopeMaxScan, remainingScan)
|
|
49
|
+
};
|
|
50
|
+
if (limits.maxScan <= 0 || limits.maxProcessed <= 0 && limits.maxNoop <= 0) {
|
|
51
|
+
stoppedBy = "budget";
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
const result = await opts.runScope(scope, limits);
|
|
55
|
+
runs.push({
|
|
56
|
+
scope,
|
|
57
|
+
limits,
|
|
58
|
+
result
|
|
59
|
+
});
|
|
60
|
+
remainingProcessed -= clampSpent(result.processed, remainingProcessed);
|
|
61
|
+
remainingNoop -= clampSpent(result.noopProcessed, remainingNoop);
|
|
62
|
+
remainingScan -= clampSpent(result.scanned, remainingScan);
|
|
63
|
+
indexedThisTick += result.indexed;
|
|
64
|
+
deletedThisTick += result.deleted;
|
|
65
|
+
noopedThisTick += result.nooped;
|
|
66
|
+
if (result.ackRace) ackRaceThisTick++;
|
|
67
|
+
if (!result.advanced && result.hasMore && [
|
|
68
|
+
"processed_budget",
|
|
69
|
+
"noop_budget",
|
|
70
|
+
"scan_budget"
|
|
71
|
+
].includes(result.stopReason ?? "")) sawBudgetBlockedScope = true;
|
|
72
|
+
if (result.advanced && result.hasMore && !result.aborted && remainingScan > 0 && (remainingProcessed > 0 || remainingNoop > 0)) queue.push(scope);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
runs,
|
|
76
|
+
processedThisTick: opts.maxProcessed - remainingProcessed,
|
|
77
|
+
noopThisTick: opts.maxNoop - remainingNoop,
|
|
78
|
+
indexedThisTick,
|
|
79
|
+
deletedThisTick,
|
|
80
|
+
noopedThisTick,
|
|
81
|
+
scannedThisTick: opts.maxScan - remainingScan,
|
|
82
|
+
ackRaceThisTick,
|
|
83
|
+
stoppedBy: stoppedBy === "drained" && sawBudgetBlockedScope ? "budget" : stoppedBy,
|
|
84
|
+
remainingProcessed,
|
|
85
|
+
remainingNoop,
|
|
86
|
+
remainingScan
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
//#endregion
|
|
91
|
+
export { runIndexFollowerRoundRobin };
|
|
92
|
+
//# sourceMappingURL=follower-scheduler.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"follower-scheduler.mjs","names":[],"sources":["../src/follower-scheduler.ts"],"sourcesContent":["import type { IndexFollowerBatchResult } from \"./follower.js\";\n\n// `maxScan` / `remainingScan` / `scannedThisTick` here count DIRTY ROWS fetched,\n// not changelog rows (the §13 cutover deleted the changelog scan). The `maxScan`\n// field key is kept as a stable public name across the budget chain — see\n// DEFAULT_MAX_SCAN_PER_TICK in follower.ts (§B: doc over public-API rename).\nexport interface IndexFollowerRunLimits {\n maxProcessed: number;\n maxNoop: number;\n maxScan: number;\n}\n\nexport interface IndexFollowerRoundRobinOptions<T> {\n scopes: T[];\n maxProcessed: number;\n maxNoop: number;\n maxScan: number;\n perScopeMaxProcessed?: number;\n perScopeMaxNoop?: number;\n perScopeMaxScan?: number;\n maxRuns?: number;\n shouldStop?: () => boolean;\n runScope: (scope: T, limits: IndexFollowerRunLimits) => Promise<IndexFollowerBatchResult>;\n}\n\nexport interface IndexFollowerRoundRobinRun<T> {\n scope: T;\n limits: IndexFollowerRunLimits;\n result: IndexFollowerBatchResult;\n}\n\nexport interface IndexFollowerRoundRobinResult<T> {\n runs: IndexFollowerRoundRobinRun<T>[];\n processedThisTick: number;\n noopThisTick: number;\n indexedThisTick: number;\n deletedThisTick: number;\n noopedThisTick: number;\n scannedThisTick: number;\n /**\n * Drain RUNS that lost the ack race-guard this tick (healthy concurrent-write\n * churn, not failures). Counted per `runScope` call, so a single hot scope\n * requeued within the tick can contribute more than once — this is a run\n * occurrence count, not a distinct-scope count.\n */\n ackRaceThisTick: number;\n stoppedBy: \"drained\" | \"budget\" | \"max_runs\" | \"time\";\n remainingProcessed: number;\n remainingNoop: number;\n remainingScan: number;\n}\n\nfunction clampSpent(value: number | undefined, remaining: number): number {\n if (!Number.isFinite(value ?? 0) || (value ?? 0) <= 0) return 0;\n return Math.min(value ?? 0, remaining);\n}\n\n/**\n * Fairly spend one index-follower tick across a selected scope set.\n *\n * Each scope receives a small bounded slice first; scopes that still report\n * `hasMore` are appended to the tail and can receive another slice only after\n * the other selected scopes had their chance. The helper is intentionally\n * storage/runtime-agnostic so Cloudflare and Node share the same starvation\n * semantics while keeping different budget sizes.\n */\nexport async function runIndexFollowerRoundRobin<T>(\n opts: IndexFollowerRoundRobinOptions<T>,\n): Promise<IndexFollowerRoundRobinResult<T>> {\n let remainingProcessed = Math.max(0, opts.maxProcessed);\n let remainingNoop = Math.max(0, opts.maxNoop);\n let remainingScan = Math.max(0, opts.maxScan);\n const perScopeMaxProcessed = Math.max(0, opts.perScopeMaxProcessed ?? 1);\n const perScopeMaxNoop = Math.max(0, opts.perScopeMaxNoop ?? 2);\n const perScopeMaxScan = Math.max(1, opts.perScopeMaxScan ?? 50);\n const maxRuns =\n opts.maxRuns ??\n opts.scopes.length +\n Math.ceil((remainingProcessed || 0) / Math.max(1, perScopeMaxProcessed || 1)) +\n Math.ceil((remainingNoop || 0) / Math.max(1, perScopeMaxNoop || 1)) +\n Math.ceil((remainingScan || 0) / Math.max(1, perScopeMaxScan));\n const queue = [...opts.scopes];\n const runs: IndexFollowerRoundRobinRun<T>[] = [];\n let indexedThisTick = 0;\n let deletedThisTick = 0;\n let noopedThisTick = 0;\n let ackRaceThisTick = 0;\n let stoppedBy: IndexFollowerRoundRobinResult<T>[\"stoppedBy\"] = \"drained\";\n let sawBudgetBlockedScope = false;\n\n while (queue.length > 0) {\n if (opts.shouldStop?.()) {\n stoppedBy = \"time\";\n break;\n }\n if (runs.length >= maxRuns) {\n stoppedBy = \"max_runs\";\n break;\n }\n if (remainingScan <= 0 || (remainingProcessed <= 0 && remainingNoop <= 0)) {\n stoppedBy = \"budget\";\n break;\n }\n\n const scope = queue.shift()!;\n const limits: IndexFollowerRunLimits = {\n maxProcessed: Math.min(perScopeMaxProcessed, remainingProcessed),\n maxNoop: Math.min(perScopeMaxNoop, remainingNoop),\n maxScan: Math.min(perScopeMaxScan, remainingScan),\n };\n if (limits.maxScan <= 0 || (limits.maxProcessed <= 0 && limits.maxNoop <= 0)) {\n stoppedBy = \"budget\";\n break;\n }\n\n const result = await opts.runScope(scope, limits);\n runs.push({ scope, limits, result });\n\n remainingProcessed -= clampSpent(result.processed, remainingProcessed);\n remainingNoop -= clampSpent(result.noopProcessed, remainingNoop);\n remainingScan -= clampSpent(result.scanned, remainingScan);\n indexedThisTick += result.indexed;\n deletedThisTick += result.deleted;\n noopedThisTick += result.nooped;\n if (result.ackRace) ackRaceThisTick++;\n if (\n !result.advanced &&\n result.hasMore &&\n [\"processed_budget\", \"noop_budget\", \"scan_budget\"].includes(result.stopReason ?? \"\")\n ) {\n sawBudgetBlockedScope = true;\n }\n\n if (\n result.advanced &&\n result.hasMore &&\n !result.aborted &&\n remainingScan > 0 &&\n (remainingProcessed > 0 || remainingNoop > 0)\n ) {\n queue.push(scope);\n }\n }\n\n return {\n runs,\n processedThisTick: opts.maxProcessed - remainingProcessed,\n noopThisTick: opts.maxNoop - remainingNoop,\n indexedThisTick,\n deletedThisTick,\n noopedThisTick,\n scannedThisTick: opts.maxScan - remainingScan,\n ackRaceThisTick,\n stoppedBy: stoppedBy === \"drained\" && sawBudgetBlockedScope ? \"budget\" : stoppedBy,\n remainingProcessed,\n remainingNoop,\n remainingScan,\n };\n}\n"],"mappings":";AAoDA,SAAS,WAAW,OAA2B,WAA2B;AACxE,KAAI,CAAC,OAAO,SAAS,SAAS,EAAE,KAAK,SAAS,MAAM,EAAG,QAAO;AAC9D,QAAO,KAAK,IAAI,SAAS,GAAG,UAAU;;;;;;;;;;;AAYxC,eAAsB,2BACpB,MAC2C;CAC3C,IAAI,qBAAqB,KAAK,IAAI,GAAG,KAAK,aAAa;CACvD,IAAI,gBAAgB,KAAK,IAAI,GAAG,KAAK,QAAQ;CAC7C,IAAI,gBAAgB,KAAK,IAAI,GAAG,KAAK,QAAQ;CAC7C,MAAM,uBAAuB,KAAK,IAAI,GAAG,KAAK,wBAAwB,EAAE;CACxE,MAAM,kBAAkB,KAAK,IAAI,GAAG,KAAK,mBAAmB,EAAE;CAC9D,MAAM,kBAAkB,KAAK,IAAI,GAAG,KAAK,mBAAmB,GAAG;CAC/D,MAAM,UACJ,KAAK,WACL,KAAK,OAAO,SACV,KAAK,MAAM,sBAAsB,KAAK,KAAK,IAAI,GAAG,wBAAwB,EAAE,CAAC,GAC7E,KAAK,MAAM,iBAAiB,KAAK,KAAK,IAAI,GAAG,mBAAmB,EAAE,CAAC,GACnE,KAAK,MAAM,iBAAiB,KAAK,KAAK,IAAI,GAAG,gBAAgB,CAAC;CAClE,MAAM,QAAQ,CAAC,GAAG,KAAK,OAAO;CAC9B,MAAM,OAAwC,EAAE;CAChD,IAAI,kBAAkB;CACtB,IAAI,kBAAkB;CACtB,IAAI,iBAAiB;CACrB,IAAI,kBAAkB;CACtB,IAAI,YAA2D;CAC/D,IAAI,wBAAwB;AAE5B,QAAO,MAAM,SAAS,GAAG;AACvB,MAAI,KAAK,cAAc,EAAE;AACvB,eAAY;AACZ;;AAEF,MAAI,KAAK,UAAU,SAAS;AAC1B,eAAY;AACZ;;AAEF,MAAI,iBAAiB,KAAM,sBAAsB,KAAK,iBAAiB,GAAI;AACzE,eAAY;AACZ;;EAGF,MAAM,QAAQ,MAAM,OAAO;EAC3B,MAAM,SAAiC;GACrC,cAAc,KAAK,IAAI,sBAAsB,mBAAmB;GAChE,SAAS,KAAK,IAAI,iBAAiB,cAAc;GACjD,SAAS,KAAK,IAAI,iBAAiB,cAAc;GAClD;AACD,MAAI,OAAO,WAAW,KAAM,OAAO,gBAAgB,KAAK,OAAO,WAAW,GAAI;AAC5E,eAAY;AACZ;;EAGF,MAAM,SAAS,MAAM,KAAK,SAAS,OAAO,OAAO;AACjD,OAAK,KAAK;GAAE;GAAO;GAAQ;GAAQ,CAAC;AAEpC,wBAAsB,WAAW,OAAO,WAAW,mBAAmB;AACtE,mBAAiB,WAAW,OAAO,eAAe,cAAc;AAChE,mBAAiB,WAAW,OAAO,SAAS,cAAc;AAC1D,qBAAmB,OAAO;AAC1B,qBAAmB,OAAO;AAC1B,oBAAkB,OAAO;AACzB,MAAI,OAAO,QAAS;AACpB,MACE,CAAC,OAAO,YACR,OAAO,WACP;GAAC;GAAoB;GAAe;GAAc,CAAC,SAAS,OAAO,cAAc,GAAG,CAEpF,yBAAwB;AAG1B,MACE,OAAO,YACP,OAAO,WACP,CAAC,OAAO,WACR,gBAAgB,MACf,qBAAqB,KAAK,gBAAgB,GAE3C,OAAM,KAAK,MAAM;;AAIrB,QAAO;EACL;EACA,mBAAmB,KAAK,eAAe;EACvC,cAAc,KAAK,UAAU;EAC7B;EACA;EACA;EACA,iBAAiB,KAAK,UAAU;EAChC;EACA,WAAW,cAAc,aAAa,wBAAwB,WAAW;EACzE;EACA;EACA;EACD"}
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
const require_field_map = require('./extraction/field-map.cjs');
|
|
2
|
+
let _aigne_afs = require("@aigne/afs");
|
|
3
|
+
let ufo = require("ufo");
|
|
4
|
+
|
|
5
|
+
//#region src/follower.ts
|
|
6
|
+
/**
|
|
7
|
+
* IndexFollower — DRAINS the source-side dirty queue (`ds_dirty_path`, the merged
|
|
8
|
+
* current-state projection of the changelog) and incrementally indexes file
|
|
9
|
+
* writes/deletes into the index backend. Dirty drain is the SOLE mechanism —
|
|
10
|
+
* the changelog `/.changelog` replay was deleted at the §13 cutover; a follower
|
|
11
|
+
* with no `dirtyQueue` is inert.
|
|
12
|
+
*
|
|
13
|
+
* Two entry points:
|
|
14
|
+
* runCatchUp() — inline drain; called from execQuery so a query always sees a
|
|
15
|
+
* fresh index (processes whatever dirty rows are pending).
|
|
16
|
+
* runBatch() — bounded per-tick drain (maxProcessed/maxNoop/maxScan caps);
|
|
17
|
+
* called from the cron task.
|
|
18
|
+
*
|
|
19
|
+
* Cursor: a `consumer_id` row in `idx_index_cursor` registers the follower and
|
|
20
|
+
* carries its instanceDid + domains snapshot for durable cron discovery. Its
|
|
21
|
+
* `last_seq` is a DEAD column post-cutover (nothing advances it during drain —
|
|
22
|
+
* §4.2); scheduling fairness rides `served_at` (markCursorServed). All domains a
|
|
23
|
+
* SINGLE blocklet declares share one cursor; DIFFERENT blocklets under the same
|
|
24
|
+
* caller each get their own (their `/user` data lives in separate instance
|
|
25
|
+
* spaces / dirty-queue prefixes).
|
|
26
|
+
*
|
|
27
|
+
* Consumer-id identity (design.md §4.4 decision-lock addendum, owner
|
|
28
|
+
* 2026-07-05; per-instance fix): a single shared backend hosts MULTIPLE
|
|
29
|
+
* `(scope, instanceDid)` pairs at once — Node's one-shared-SQLite-file rework
|
|
30
|
+
* of #936 P4, and CF's one-shared-D1 model. So the bare `FOLLOWER_CONSUMER_ID`
|
|
31
|
+
* isn't enough to identify ONE follower's cursor row;
|
|
32
|
+
* `deriveFollowerConsumerId(scope, instanceDid)` folds BOTH in
|
|
33
|
+
* (`index:{scope}:{instanceDid}`), the same compositing technique already used
|
|
34
|
+
* for domain-name isolation (`{declared name}:{scope}`). Keying by scope ALONE
|
|
35
|
+
* (the pre-fix behavior) let a caller's SECOND index-blocklet collide on the
|
|
36
|
+
* first's cursor row (`INSERT OR IGNORE` → dropped) so its changelog was never
|
|
37
|
+
* followed. D1 additionally isolates via its own `scope` column (PRIMARY KEY
|
|
38
|
+
* (scope, consumer_id)); both runtimes derive the consumer_id the same way so a
|
|
39
|
+
* persisted row means the same thing everywhere (`IndexBackend.listCursorScopes`
|
|
40
|
+
* matches on the `{FOLLOWER_CONSUMER_ID}:` PREFIX for exactly this reason — see
|
|
41
|
+
* d1-index-backend.ts).
|
|
42
|
+
*/
|
|
43
|
+
const log = (0, _aigne_afs.makeNsLog)("provider:index");
|
|
44
|
+
const FOLLOWER_CONSUMER_ID = "index";
|
|
45
|
+
/**
|
|
46
|
+
* Derive a follower consumer_id that isolates per `(scope, instanceDid)` —
|
|
47
|
+
* `"index:{scope}:{instanceDid}"`. A single shared backend (Node's `/user/index`
|
|
48
|
+
* file, CF's D1) hosts many callers AND many blocklets per caller; the cursor
|
|
49
|
+
* row must be unique per (caller scope × blocklet instance) because each
|
|
50
|
+
* blocklet follows its OWN instance-space changelog. `instanceDid` is optional
|
|
51
|
+
* for backward compatibility (bare `index:{scope}` for pre-fix rows / callers
|
|
52
|
+
* that don't supply it); passing the bare `FOLLOWER_CONSUMER_ID` is only safe
|
|
53
|
+
* for single-scope callers (tests, standalone use).
|
|
54
|
+
*/
|
|
55
|
+
function deriveFollowerConsumerId(scope, instanceDid) {
|
|
56
|
+
return instanceDid ? `${FOLLOWER_CONSUMER_ID}:${scope}:${instanceDid}` : `${FOLLOWER_CONSUMER_ID}:${scope}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Memory-map key for a caller's per-blocklet index resources (the AFSIndex
|
|
60
|
+
* handle, its in-flight build promise, its IndexFollower). Isolation is
|
|
61
|
+
* per-`(scope, instanceDid)`: the SAME caller (scope) gets a SEPARATE index per
|
|
62
|
+
* blocklet (instanceDid). Keying these isolate-lived maps by scope ALONE let a
|
|
63
|
+
* caller's second index-blocklet reuse the FIRST's handle/follower (wrong bound
|
|
64
|
+
* domains + wrong instance-space changelog) — the in-memory twin of the cursor
|
|
65
|
+
* collision `deriveFollowerConsumerId` guards against.
|
|
66
|
+
*/
|
|
67
|
+
function userIndexKey(scope, instanceDid) {
|
|
68
|
+
return instanceDid ? `${scope}::${instanceDid}` : scope;
|
|
69
|
+
}
|
|
70
|
+
const DEFAULT_MAX_PROCESSED_PER_TICK = 4;
|
|
71
|
+
const DEFAULT_MAX_NOOP_PER_TICK = 12;
|
|
72
|
+
const DEFAULT_MAX_SCAN_PER_TICK = 400;
|
|
73
|
+
/** Preferred alias for {@link DEFAULT_MAX_SCAN_PER_TICK} in dirty-drain call sites. */
|
|
74
|
+
const DEFAULT_MAX_DIRTY_ROWS_PER_TICK = DEFAULT_MAX_SCAN_PER_TICK;
|
|
75
|
+
const DIRTY_RETRY_BACKOFF_MS = 6e4;
|
|
76
|
+
function toAbsolute(path) {
|
|
77
|
+
return (0, ufo.joinURL)("/", path);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Internal signal thrown by `indexEntry` / the dirty-drain remove path when a
|
|
81
|
+
* read/stat/index failed on content that is STILL current (issue #935 BLOCK
|
|
82
|
+
* fix #3 / sync-engine parity): `processDirty` catches this and BACKS OFF the
|
|
83
|
+
* dirty row (marks it, doesn't ack) so the next tick retries it instead of
|
|
84
|
+
* silently losing the write. (Historically named for the changelog replay that
|
|
85
|
+
* the dirty-path cutover deleted; kept as the dirty-drain retry signal.)
|
|
86
|
+
*/
|
|
87
|
+
var ChangelogAbortError = class extends Error {
|
|
88
|
+
constructor(originalPath, reason = "transient read failure") {
|
|
89
|
+
super(`${reason} for "${originalPath}" — retry next run`);
|
|
90
|
+
this.name = "ChangelogAbortError";
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
var IndexFollower = class {
|
|
94
|
+
spaceAfs;
|
|
95
|
+
backend;
|
|
96
|
+
domains;
|
|
97
|
+
consumerId;
|
|
98
|
+
embed;
|
|
99
|
+
catchUpLimits;
|
|
100
|
+
dirtyQueue;
|
|
101
|
+
constructor(opts) {
|
|
102
|
+
this.spaceAfs = opts.spaceAfs;
|
|
103
|
+
this.backend = opts.backend;
|
|
104
|
+
this.domains = opts.domains;
|
|
105
|
+
this.consumerId = opts.consumerId ?? FOLLOWER_CONSUMER_ID;
|
|
106
|
+
this.embed = opts.embed;
|
|
107
|
+
this.dirtyQueue = opts.dirtyQueue;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Override the inline `runCatchUp()` budget — the per-invocation caps on
|
|
111
|
+
* matched PUT attempts, matched delete/remove rows, and dirty rows FETCHED
|
|
112
|
+
* (`maxScan`). Injected by the runtime from the SAME env group the cron uses
|
|
113
|
+
* (`AFS_INDEX_MAX_*_PER_TICK`) so both indexing paths share one budget group.
|
|
114
|
+
* Unset → provider defaults (standalone/tests).
|
|
115
|
+
*/
|
|
116
|
+
setCatchUpLimits(limits) {
|
|
117
|
+
this.catchUpLimits = limits;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Wire (or replace) the passage embedder used at index time — semantic recall
|
|
121
|
+
* (Epic #932). Called by `AFSIndex.setFollower` (query-path follower) and by
|
|
122
|
+
* `rebuildFollowerFromCursor` (CF cron-path follower) so BOTH indexing paths
|
|
123
|
+
* write embeddings; without this a changelog-indexed entry has only FTS +
|
|
124
|
+
* anchors and cross-lingual / semantic queries can't match it. A no-op
|
|
125
|
+
* embedder (or a backend without `writeEmbedding`) degrades cleanly to
|
|
126
|
+
* FTS+anchor.
|
|
127
|
+
*/
|
|
128
|
+
setEmbedder(embed) {
|
|
129
|
+
this.embed = embed;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Lightweight catch-up: drain any ready dirty rows so a query sees a fresh
|
|
133
|
+
* index without waiting for the next cron tick. Called from execQuery.
|
|
134
|
+
*
|
|
135
|
+
* Dirty drain is the SOLE catch-up mechanism (index-follower-dirty-path §13
|
|
136
|
+
* cutover — the changelog `/.changelog` replay was deleted). Freshness = "are
|
|
137
|
+
* there ready dirty rows under my prefix", so there is no `last_seq` probe: a
|
|
138
|
+
* query sees a fresh index by processing whatever dirty paths are pending
|
|
139
|
+
* (design §4.2, §8). A follower with no dirty queue (standalone/legacy) is a
|
|
140
|
+
* no-op here — it has no source to drain.
|
|
141
|
+
*/
|
|
142
|
+
async runCatchUp() {
|
|
143
|
+
if (!this.dirtyQueue) return;
|
|
144
|
+
await this.processDirty({
|
|
145
|
+
maxProcessed: this.catchUpLimits?.maxProcessed ?? DEFAULT_MAX_PROCESSED_PER_TICK,
|
|
146
|
+
maxNoop: this.catchUpLimits?.maxNoop ?? DEFAULT_MAX_NOOP_PER_TICK,
|
|
147
|
+
maxDirtyRows: this.catchUpLimits?.maxScan ?? DEFAULT_MAX_DIRTY_ROWS_PER_TICK
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Bounded per-tick drain of the dirty queue — attempt up to `maxProcessed`
|
|
152
|
+
* matched PUT rows, up to `maxNoop` matched delete/remove/noop rows, fetching
|
|
153
|
+
* at most `maxScan` (a.k.a. `maxDirtyRows`) dirty rows. Called from the cron.
|
|
154
|
+
*
|
|
155
|
+
* Dirty drain is the SOLE mechanism (index-follower-dirty-path §13 cutover —
|
|
156
|
+
* changelog replay was deleted). A follower with no dirty queue has no source
|
|
157
|
+
* to drain, so it is idle. Separate budgets are intentional: matched PUT rows
|
|
158
|
+
* are the expensive read+embed+write axis, delete/noop rows are cheaper.
|
|
159
|
+
*/
|
|
160
|
+
async runBatch(limits) {
|
|
161
|
+
if (!this.dirtyQueue) return this.idleResult();
|
|
162
|
+
return this.processDirty({
|
|
163
|
+
maxProcessed: limits?.maxProcessed ?? DEFAULT_MAX_PROCESSED_PER_TICK,
|
|
164
|
+
maxNoop: limits?.maxNoop ?? DEFAULT_MAX_NOOP_PER_TICK,
|
|
165
|
+
maxDirtyRows: limits?.maxDirtyRows ?? limits?.maxScan ?? DEFAULT_MAX_DIRTY_ROWS_PER_TICK
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/** Empty batch result (no dirty queue wired / nothing to drain). */
|
|
169
|
+
idleResult() {
|
|
170
|
+
return {
|
|
171
|
+
scanned: 0,
|
|
172
|
+
processed: 0,
|
|
173
|
+
noopProcessed: 0,
|
|
174
|
+
indexed: 0,
|
|
175
|
+
deleted: 0,
|
|
176
|
+
nooped: 0,
|
|
177
|
+
advanced: false,
|
|
178
|
+
hasMore: false,
|
|
179
|
+
ackRace: false,
|
|
180
|
+
stopReason: "exhausted",
|
|
181
|
+
fromSeq: 0,
|
|
182
|
+
toSeq: 0,
|
|
183
|
+
matched: 0,
|
|
184
|
+
unmatched: 0,
|
|
185
|
+
skipped: 0,
|
|
186
|
+
failed: 0,
|
|
187
|
+
aborted: false
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Cold rebuild of an EXPLICIT list of space-relative paths — the enumeration
|
|
192
|
+
* is done by the caller (an external driver listing the live tree via D1),
|
|
193
|
+
* NOT in this process. Reads CURRENT live state per path and writes the
|
|
194
|
+
* derived index layers, independent of any queue — so it is the seam the
|
|
195
|
+
* offline reset-and-rebuild (`offlineRebuildScope`) uses to rebuild from live
|
|
196
|
+
* `ds_entries`. Keeping the per-call cost to just `paths.length`
|
|
197
|
+
* read+embed+writes with ZERO in-Worker tree enumeration is what keeps a small
|
|
198
|
+
* batch inside the CF free-tier per-request CPU/subrequest budget (an in-Worker
|
|
199
|
+
* `maxDepth:-1` enumeration pushed cold isolates over that budget into 503s).
|
|
200
|
+
* Each path is matched against the declared domains and indexed through the
|
|
201
|
+
* same `indexEntry` as the drain path (byte-identical output). `paths` are
|
|
202
|
+
* space-relative, e.g. `"items/foo.json"` (leading slash tolerated).
|
|
203
|
+
*/
|
|
204
|
+
async reindexPaths(paths, onProgress) {
|
|
205
|
+
let indexed = 0;
|
|
206
|
+
let failed = 0;
|
|
207
|
+
let skipped = 0;
|
|
208
|
+
let done = 0;
|
|
209
|
+
for (const raw of paths) {
|
|
210
|
+
const rel = raw.replace(/^\/+/, "");
|
|
211
|
+
let matched = false;
|
|
212
|
+
for (const domain of this.domains) {
|
|
213
|
+
if (!this.pathMatchesDomain(rel, domain)) continue;
|
|
214
|
+
matched = true;
|
|
215
|
+
try {
|
|
216
|
+
if (await this.indexEntry(domain, rel, this.makeEntryPath(domain, rel)) === "indexed") indexed++;
|
|
217
|
+
} catch (err) {
|
|
218
|
+
failed++;
|
|
219
|
+
log.warn(`reindex failed: ${rel}`, err instanceof Error ? err.message : err);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!matched) skipped++;
|
|
223
|
+
done++;
|
|
224
|
+
if (onProgress && (done % 50 === 0 || done === paths.length)) onProgress(done, paths.length);
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
indexed,
|
|
228
|
+
failed,
|
|
229
|
+
skipped
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Dirty drain (index-follower-dirty-path §8) — the O(unique dirty paths)
|
|
234
|
+
* replacement for changelog replay. Fetches ready dirty rows under this
|
|
235
|
+
* follower's prefix, and for each: domain-matches the stripped `originalPath`,
|
|
236
|
+
* reads CURRENT live state, and indexes / removes accordingly, then acks with
|
|
237
|
+
* the `latestSeq` race guard. A transient failure marks the row with a backoff
|
|
238
|
+
* (so a poison row can't refill the tick) instead of acking.
|
|
239
|
+
*
|
|
240
|
+
* Dirty mode NEVER writes `idx_index_cursor.last_seq` (design §4.2 — it is a
|
|
241
|
+
* dead column post-cutover); scheduling reads `advanced/hasMore/scanned` +
|
|
242
|
+
* the dirty count. The `dirtyQueue.listDirtyPaths({ prefix: "" })` call assumes
|
|
243
|
+
* a fragment-scoped queue (PrefixedTreeIndex), so `row.path` is already the
|
|
244
|
+
* stripped `originalPath` the domain globs + `spaceAfs.read` expect.
|
|
245
|
+
*/
|
|
246
|
+
async processDirty(limits) {
|
|
247
|
+
const result0 = () => ({
|
|
248
|
+
scanned: 0,
|
|
249
|
+
processed: 0,
|
|
250
|
+
noopProcessed: 0,
|
|
251
|
+
indexed: 0,
|
|
252
|
+
deleted: 0,
|
|
253
|
+
nooped: 0,
|
|
254
|
+
advanced: false,
|
|
255
|
+
hasMore: false,
|
|
256
|
+
ackRace: false,
|
|
257
|
+
stopReason: "exhausted",
|
|
258
|
+
fromSeq: 0,
|
|
259
|
+
toSeq: 0,
|
|
260
|
+
matched: 0,
|
|
261
|
+
unmatched: 0,
|
|
262
|
+
skipped: 0,
|
|
263
|
+
failed: 0,
|
|
264
|
+
aborted: false
|
|
265
|
+
});
|
|
266
|
+
const dirtyQueue = this.dirtyQueue;
|
|
267
|
+
if (!dirtyQueue) return result0();
|
|
268
|
+
const maxDirtyRows = limits.maxDirtyRows > 0 ? limits.maxDirtyRows : 0;
|
|
269
|
+
if (maxDirtyRows <= 0 || limits.maxProcessed <= 0 && limits.maxNoop <= 0) return result0();
|
|
270
|
+
let processedRemaining = limits.maxProcessed;
|
|
271
|
+
let noopRemaining = limits.maxNoop;
|
|
272
|
+
let scanned = 0;
|
|
273
|
+
let processed = 0;
|
|
274
|
+
let noopProcessed = 0;
|
|
275
|
+
let indexed = 0;
|
|
276
|
+
let deleted = 0;
|
|
277
|
+
let nooped = 0;
|
|
278
|
+
let matched = 0;
|
|
279
|
+
let unmatched = 0;
|
|
280
|
+
let failed = 0;
|
|
281
|
+
let advanced = false;
|
|
282
|
+
let hasMore = false;
|
|
283
|
+
let ackRace = false;
|
|
284
|
+
let stopReason;
|
|
285
|
+
const now = Date.now();
|
|
286
|
+
const rows = await dirtyQueue.listDirtyPaths({
|
|
287
|
+
prefix: "",
|
|
288
|
+
limit: maxDirtyRows,
|
|
289
|
+
now
|
|
290
|
+
});
|
|
291
|
+
for (const row of rows) {
|
|
292
|
+
scanned++;
|
|
293
|
+
const matchedDomains = this.domains.filter((d) => this.pathMatchesDomain(row.path, d));
|
|
294
|
+
if (matchedDomains.length === 0) {
|
|
295
|
+
if (noopRemaining <= 0) {
|
|
296
|
+
hasMore = true;
|
|
297
|
+
stopReason = "noop_budget";
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
unmatched++;
|
|
301
|
+
noopRemaining--;
|
|
302
|
+
noopProcessed++;
|
|
303
|
+
if (await dirtyQueue.ackDirtyPath(row.path, row.latestSeq)) advanced = true;
|
|
304
|
+
else {
|
|
305
|
+
hasMore = true;
|
|
306
|
+
ackRace = true;
|
|
307
|
+
}
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const isDelete = row.op === "delete";
|
|
311
|
+
if (isDelete) {
|
|
312
|
+
if (noopRemaining <= 0) {
|
|
313
|
+
hasMore = true;
|
|
314
|
+
stopReason = "noop_budget";
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
} else if (processedRemaining <= 0) {
|
|
318
|
+
hasMore = true;
|
|
319
|
+
stopReason = "processed_budget";
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
let didIndex = 0;
|
|
324
|
+
let didRemove = 0;
|
|
325
|
+
if (isDelete) {
|
|
326
|
+
await this.removeDirty(matchedDomains, row.path);
|
|
327
|
+
didRemove = 1;
|
|
328
|
+
} else {
|
|
329
|
+
const r = await this.indexPutDirty(matchedDomains, row.path);
|
|
330
|
+
didIndex = r.indexed;
|
|
331
|
+
didRemove = r.removed;
|
|
332
|
+
}
|
|
333
|
+
matched++;
|
|
334
|
+
if (isDelete) {
|
|
335
|
+
noopRemaining--;
|
|
336
|
+
noopProcessed++;
|
|
337
|
+
deleted++;
|
|
338
|
+
} else if (didIndex > 0) {
|
|
339
|
+
processedRemaining--;
|
|
340
|
+
processed++;
|
|
341
|
+
indexed++;
|
|
342
|
+
} else if (didRemove > 0) {
|
|
343
|
+
noopProcessed++;
|
|
344
|
+
deleted++;
|
|
345
|
+
} else nooped++;
|
|
346
|
+
if (await dirtyQueue.ackDirtyPath(row.path, row.latestSeq)) advanced = true;
|
|
347
|
+
else {
|
|
348
|
+
advanced = true;
|
|
349
|
+
hasMore = true;
|
|
350
|
+
ackRace = true;
|
|
351
|
+
}
|
|
352
|
+
} catch (err) {
|
|
353
|
+
failed++;
|
|
354
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
355
|
+
await dirtyQueue.markDirtyPathError(row.path, row.latestSeq, msg, now + DIRTY_RETRY_BACKOFF_MS);
|
|
356
|
+
log.warn(`dirty drain failed (backed off): ${row.path}`, msg);
|
|
357
|
+
}
|
|
358
|
+
if (processedRemaining <= 0 && noopRemaining <= 0) {
|
|
359
|
+
hasMore = true;
|
|
360
|
+
stopReason = "processed_budget";
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (!stopReason) {
|
|
365
|
+
if (rows.length >= maxDirtyRows && maxDirtyRows > 0) {
|
|
366
|
+
if ((await dirtyQueue.listDirtyPaths({
|
|
367
|
+
prefix: "",
|
|
368
|
+
limit: 1,
|
|
369
|
+
now
|
|
370
|
+
})).length > 0) hasMore = true;
|
|
371
|
+
}
|
|
372
|
+
stopReason = hasMore ? "scan_budget" : "exhausted";
|
|
373
|
+
}
|
|
374
|
+
log.debug("dirty drain processed", {
|
|
375
|
+
consumerId: this.consumerId,
|
|
376
|
+
scanned,
|
|
377
|
+
processed,
|
|
378
|
+
noopProcessed,
|
|
379
|
+
indexed,
|
|
380
|
+
deleted,
|
|
381
|
+
nooped,
|
|
382
|
+
matched,
|
|
383
|
+
unmatched,
|
|
384
|
+
failed,
|
|
385
|
+
advanced,
|
|
386
|
+
hasMore,
|
|
387
|
+
ackRace,
|
|
388
|
+
stopReason
|
|
389
|
+
});
|
|
390
|
+
return {
|
|
391
|
+
scanned,
|
|
392
|
+
processed,
|
|
393
|
+
noopProcessed,
|
|
394
|
+
indexed,
|
|
395
|
+
deleted,
|
|
396
|
+
nooped,
|
|
397
|
+
advanced,
|
|
398
|
+
hasMore,
|
|
399
|
+
ackRace,
|
|
400
|
+
stopReason,
|
|
401
|
+
fromSeq: 0,
|
|
402
|
+
toSeq: 0,
|
|
403
|
+
matched,
|
|
404
|
+
unmatched,
|
|
405
|
+
skipped: 0,
|
|
406
|
+
failed,
|
|
407
|
+
aborted: false
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/** Remove the indexed doc(s) for a delete/removed dirty row across matched domains. */
|
|
411
|
+
async removeDirty(matchedDomains, originalPath) {
|
|
412
|
+
for (const domain of matchedDomains) await this.removeIndexed(this.makeEntryPath(domain, originalPath), originalPath);
|
|
413
|
+
}
|
|
414
|
+
/** Shared index-removal (delete branch) — throws ChangelogAbortError on failure so the row backs off. */
|
|
415
|
+
async removeIndexed(entryPath, originalPath) {
|
|
416
|
+
try {
|
|
417
|
+
if (this.backend.removeIndexedDocument) await this.backend.removeIndexedDocument(entryPath);
|
|
418
|
+
else {
|
|
419
|
+
await this.backend.removeAnchors(entryPath);
|
|
420
|
+
await this.backend.removeSummary(entryPath);
|
|
421
|
+
await this.backend.removeEntry(entryPath);
|
|
422
|
+
}
|
|
423
|
+
} catch (err) {
|
|
424
|
+
throw new ChangelogAbortError(originalPath, `index delete failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Handle a `put` dirty row against CURRENT live state (design §4.3 / §8 step 6):
|
|
429
|
+
* - live exists → indexEntry (writes FTS/anchor/summary/embedding).
|
|
430
|
+
* - live missing (authoritative 404) → remove the indexed doc.
|
|
431
|
+
* - stat throws a NON-404 error → transient → ChangelogAbortError (retry).
|
|
432
|
+
* This is what makes a stale put whose file was deleted converge to "removed",
|
|
433
|
+
* while a replica-lag / D1 blip is NEVER mistaken for a deletion.
|
|
434
|
+
*/
|
|
435
|
+
async indexPutDirty(matchedDomains, originalPath) {
|
|
436
|
+
let live;
|
|
437
|
+
try {
|
|
438
|
+
live = (await this.spaceAfs.stat?.(toAbsolute(originalPath)))?.data;
|
|
439
|
+
} catch (err) {
|
|
440
|
+
if (err instanceof _aigne_afs.AFSNotFoundError) live = void 0;
|
|
441
|
+
else throw new ChangelogAbortError(originalPath, `stat failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
442
|
+
}
|
|
443
|
+
if (!live) {
|
|
444
|
+
await this.removeDirty(matchedDomains, originalPath);
|
|
445
|
+
return {
|
|
446
|
+
indexed: 0,
|
|
447
|
+
removed: 1
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
let indexed = 0;
|
|
451
|
+
for (const domain of matchedDomains) if (await this.indexEntry(domain, originalPath, this.makeEntryPath(domain, originalPath)) === "indexed") indexed++;
|
|
452
|
+
return {
|
|
453
|
+
indexed,
|
|
454
|
+
removed: 0
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Does `originalPath` STILL EXIST (via `stat`)? Used to classify a `read()`
|
|
459
|
+
* failure (issue #935 BLOCK fix #3 / sync-engine parity):
|
|
460
|
+
* - stat 404 (or throws) → the file is gone (later delete, or never existed
|
|
461
|
+
* at this position) → the row is stale, safe to skip.
|
|
462
|
+
* - stat succeeds → the file is live; the read failure was transient (R2/D1
|
|
463
|
+
* blip) and must be retried, not lost.
|
|
464
|
+
* Post-cutover the drain only ever indexes live state (cid is never compared —
|
|
465
|
+
* supersession is caught by the ack race-guard), so existence is the only
|
|
466
|
+
* question left here.
|
|
467
|
+
*/
|
|
468
|
+
async entryStillCurrent(originalPath) {
|
|
469
|
+
try {
|
|
470
|
+
return !!(await this.spaceAfs.stat?.(toAbsolute(originalPath)))?.data;
|
|
471
|
+
} catch {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
pathMatchesDomain(originalPath, domain) {
|
|
476
|
+
if (!domain.indexable || domain.indexable.length === 0) return true;
|
|
477
|
+
return domain.indexable.some((pat) => matchGlob(originalPath, pat));
|
|
478
|
+
}
|
|
479
|
+
async indexEntry(domain, originalPath, entryPath) {
|
|
480
|
+
let content;
|
|
481
|
+
try {
|
|
482
|
+
const raw = (await this.spaceAfs.read?.(toAbsolute(originalPath)))?.data?.content;
|
|
483
|
+
if (typeof raw !== "string" || raw.length === 0) return "noop";
|
|
484
|
+
try {
|
|
485
|
+
content = JSON.parse(raw);
|
|
486
|
+
} catch {
|
|
487
|
+
content = raw;
|
|
488
|
+
}
|
|
489
|
+
} catch {
|
|
490
|
+
if (await this.entryStillCurrent(originalPath)) throw new ChangelogAbortError(originalPath);
|
|
491
|
+
return "noop";
|
|
492
|
+
}
|
|
493
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
494
|
+
const anchors = [];
|
|
495
|
+
if (domain.fields && typeof content === "object" && content !== null) {
|
|
496
|
+
const fieldValues = require_field_map.applyFieldMap(content, domain.fields);
|
|
497
|
+
for (const [fieldName, values] of Object.entries(fieldValues)) for (const value of values) if (fieldName === "time") anchors.push({
|
|
498
|
+
type: "time",
|
|
499
|
+
confidence: 1,
|
|
500
|
+
timestamp: value
|
|
501
|
+
});
|
|
502
|
+
else anchors.push({
|
|
503
|
+
type: fieldName,
|
|
504
|
+
confidence: 1,
|
|
505
|
+
value
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
const entry = {
|
|
509
|
+
entryPath,
|
|
510
|
+
sourcePath: originalPath,
|
|
511
|
+
domain: domain.name,
|
|
512
|
+
state: "active",
|
|
513
|
+
indexedAt: now,
|
|
514
|
+
updatedAt: now,
|
|
515
|
+
anchorCount: anchors.length
|
|
516
|
+
};
|
|
517
|
+
let summary;
|
|
518
|
+
if (domain.summary) {
|
|
519
|
+
summary = require_field_map.extractSummaryText(content, domain.summary);
|
|
520
|
+
if (domain.enrich && typeof content === "object" && content !== null) summary = await require_field_map.applyEnrichContentFile({
|
|
521
|
+
afs: this.spaceAfs,
|
|
522
|
+
itemJson: content,
|
|
523
|
+
baseSummary: summary,
|
|
524
|
+
enrich: domain.enrich,
|
|
525
|
+
scope: domain.scope
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
if (this.backend.writeIndexedDocument) await this.backend.writeIndexedDocument({
|
|
530
|
+
entry,
|
|
531
|
+
anchors,
|
|
532
|
+
summary
|
|
533
|
+
});
|
|
534
|
+
else {
|
|
535
|
+
await this.backend.writeEntry(entry);
|
|
536
|
+
await this.backend.writeAnchors(entryPath, anchors);
|
|
537
|
+
if (summary) await this.backend.writeSummary(entryPath, summary);
|
|
538
|
+
}
|
|
539
|
+
} catch (err) {
|
|
540
|
+
throw new ChangelogAbortError(originalPath, `index write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
541
|
+
}
|
|
542
|
+
if (summary) {
|
|
543
|
+
if (this.embed && this.backend.writeEmbedding) try {
|
|
544
|
+
const vector = await this.embed(summary);
|
|
545
|
+
if (vector) await this.backend.writeEmbedding(entryPath, vector, domain.name);
|
|
546
|
+
else log.debug("embed skipped (embedder returned null)", { entryPath });
|
|
547
|
+
} catch (err) {
|
|
548
|
+
log.debug("embed failed (entry kept, FTS/anchor only)", {
|
|
549
|
+
entryPath,
|
|
550
|
+
error: err instanceof Error ? err.message : String(err)
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return "indexed";
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Build the stable index entry path for a file. Uses the domain scope
|
|
558
|
+
* (default "/user") as a prefix so query-scope filtering works.
|
|
559
|
+
*/
|
|
560
|
+
makeEntryPath(domain, originalPath) {
|
|
561
|
+
return (0, ufo.joinURL)(domain.scope ?? "/user", originalPath);
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
function matchGlob(path, pattern) {
|
|
565
|
+
const regexStr = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "⧫").replace(/\*/g, "[^/]*").replace(/⧫/g, ".*").replace(/\?/g, "[^/]");
|
|
566
|
+
return (/* @__PURE__ */ new RegExp(`(^|/)${regexStr}$`)).test(path);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
//#endregion
|
|
570
|
+
exports.DEFAULT_MAX_DIRTY_ROWS_PER_TICK = DEFAULT_MAX_DIRTY_ROWS_PER_TICK;
|
|
571
|
+
exports.DEFAULT_MAX_NOOP_PER_TICK = DEFAULT_MAX_NOOP_PER_TICK;
|
|
572
|
+
exports.DEFAULT_MAX_PROCESSED_PER_TICK = DEFAULT_MAX_PROCESSED_PER_TICK;
|
|
573
|
+
exports.DEFAULT_MAX_SCAN_PER_TICK = DEFAULT_MAX_SCAN_PER_TICK;
|
|
574
|
+
exports.FOLLOWER_CONSUMER_ID = FOLLOWER_CONSUMER_ID;
|
|
575
|
+
exports.IndexFollower = IndexFollower;
|
|
576
|
+
exports.deriveFollowerConsumerId = deriveFollowerConsumerId;
|
|
577
|
+
exports.userIndexKey = userIndexKey;
|