@gmickel/gno 2.9.0 → 2.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v2.9.0.zip → gno-browser-clipper-v2.9.2.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.9.2.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +3 -0
- package/spec/output-schemas/collection-list.schema.json +5 -0
- package/src/cli/commands/daemon.ts +9 -0
- package/src/cli/detach.ts +42 -5
- package/src/cli/program.ts +11 -2
- package/src/config/types.ts +3 -0
- package/src/core/project-profile-apply-state.ts +3 -0
- package/src/core/resident-shutdown-handler.ts +23 -0
- package/src/core/sighup-disposition.ts +66 -0
- package/src/core/time-slice.ts +34 -0
- package/src/ingestion/graph-reconciliation.ts +18 -4
- package/src/ingestion/sync.ts +70 -4
- package/src/serve/parent-lifeline.ts +44 -0
- package/src/serve/resident-runtime.ts +24 -1
- package/src/serve/resident-shutdown.ts +4 -0
- package/src/serve/server.ts +22 -1
- package/src/serve/watch-reconciliation-shared.ts +12 -1
- package/src/serve/watch-reconciliation.ts +136 -1
- package/src/serve/watch-service-events.ts +43 -9
- package/src/serve/watch-service-flush-generation.ts +77 -3
- package/src/serve/watch-service-hosts.ts +12 -2
- package/src/serve/watch-service-lifecycle.ts +8 -0
- package/src/serve/watch-service-run-flush.ts +13 -2
- package/src/serve/watch-service.ts +37 -4
- package/src/sessions/import-child.ts +79 -11
- package/src/store/migrations/035-vector-length-index.ts +30 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +4 -1
- package/src/store/vector/freshness.ts +13 -6
- package/browser-extension/artifacts/gno-browser-clipper-v2.9.0.zip.sha256 +0 -1
package/src/serve/server.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { RequestPeerServer } from "./request-locality";
|
|
|
3
3
|
import type { ResidentRuntime } from "./resident-runtime";
|
|
4
4
|
import type { ContextHolder } from "./routes/api";
|
|
5
5
|
|
|
6
|
+
import { markResidentShutdownHandlerInstalled } from "../core/resident-shutdown-handler";
|
|
7
|
+
import { sighupHandlerAllowed } from "../core/sighup-disposition";
|
|
6
8
|
import { getActivePreset } from "../llm/registry";
|
|
7
9
|
import {
|
|
8
10
|
isHttpGatewayLoopbackBind,
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
* @module src/serve/server
|
|
26
28
|
*/
|
|
27
29
|
import { withRemoteHostPathRedaction } from "./host-path-redaction";
|
|
30
|
+
import { watchParentLifeline } from "./parent-lifeline";
|
|
28
31
|
import { PDFJS_ASSET_CACHE_CONTROL } from "./pdfjs-assets";
|
|
29
32
|
// HTML import - Bun handles bundling TSX/CSS automatically via routes
|
|
30
33
|
import homepage from "./public/index.html";
|
|
@@ -401,7 +404,7 @@ export function loopbackHttpOrigin(host: string, port: number): string {
|
|
|
401
404
|
|
|
402
405
|
/**
|
|
403
406
|
* Start the web server.
|
|
404
|
-
* Opens DB once, closes on SIGINT/SIGTERM.
|
|
407
|
+
* Opens DB once, closes on SIGINT/SIGTERM/SIGHUP.
|
|
405
408
|
*/
|
|
406
409
|
export async function startServer(
|
|
407
410
|
options: ServeOptions = {},
|
|
@@ -478,9 +481,22 @@ export async function startServer(
|
|
|
478
481
|
|
|
479
482
|
process.once("SIGINT", shutdown);
|
|
480
483
|
process.once("SIGTERM", shutdown);
|
|
484
|
+
// A closed terminal or logout: shut down cleanly, as on SIGTERM, unless
|
|
485
|
+
// SIGHUP was ignored at start (nohup), which must keep working.
|
|
486
|
+
const handleSighup = sighupHandlerAllowed();
|
|
487
|
+
if (handleSighup) process.once("SIGHUP", shutdown);
|
|
488
|
+
markResidentShutdownHandlerInstalled();
|
|
489
|
+
// A launcher that owns this process (the desktop shell) closes our stdin
|
|
490
|
+
// when it exits or is killed; shut down instead of running orphaned.
|
|
491
|
+
const stopLifeline = watchParentLifeline(() => {
|
|
492
|
+
console.log("\nParent process gone.");
|
|
493
|
+
shutdown();
|
|
494
|
+
});
|
|
481
495
|
const removeShutdownHandlers = (): void => {
|
|
482
496
|
process.off("SIGINT", shutdown);
|
|
483
497
|
process.off("SIGTERM", shutdown);
|
|
498
|
+
if (handleSighup) process.off("SIGHUP", shutdown);
|
|
499
|
+
stopLifeline();
|
|
484
500
|
};
|
|
485
501
|
|
|
486
502
|
// Start server with try/catch for port-in-use etc.
|
|
@@ -1778,6 +1794,11 @@ export async function startServer(
|
|
|
1778
1794
|
await dependencies.waitForShutdown(shutdownController.signal);
|
|
1779
1795
|
} else {
|
|
1780
1796
|
await new Promise<void>((resolve) => {
|
|
1797
|
+
// The lifeline can fire while the listener is still starting.
|
|
1798
|
+
if (shutdownController.signal.aborted) {
|
|
1799
|
+
resolve();
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1781
1802
|
shutdownController.signal.addEventListener("abort", () => resolve(), {
|
|
1782
1803
|
once: true,
|
|
1783
1804
|
});
|
|
@@ -42,9 +42,20 @@ export const WATCHER_MAX_DIRTY_HINTS = 4_096;
|
|
|
42
42
|
/** Cap on application-write suppression history entries. */
|
|
43
43
|
export const WATCHER_MAX_SUPPRESSION_ENTRIES = 4_096;
|
|
44
44
|
|
|
45
|
-
/**
|
|
45
|
+
/** Retry delay after the first failed classification/sync of a collection. */
|
|
46
46
|
export const WATCHER_RETRY_BACKOFF_MS = 500;
|
|
47
47
|
|
|
48
|
+
/** Ceiling for the retry delay while a collection keeps failing. */
|
|
49
|
+
export const WATCHER_MAX_RETRY_BACKOFF_MS = 5 * 60_000;
|
|
50
|
+
|
|
51
|
+
/** Delay before the retry that follows `failures` consecutive failed flushes. */
|
|
52
|
+
export function watcherRetryDelayMs(failures: number): number {
|
|
53
|
+
return Math.min(
|
|
54
|
+
WATCHER_RETRY_BACKOFF_MS * 2 ** Math.max(0, failures - 1),
|
|
55
|
+
WATCHER_MAX_RETRY_BACKOFF_MS
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
/** Retry delay while another writer holds the shared writer lease. */
|
|
49
60
|
export const WATCHER_LEASE_RETRY_MS = 5_000;
|
|
50
61
|
|
|
@@ -10,8 +10,13 @@
|
|
|
10
10
|
import type { Collection } from "../config/types";
|
|
11
11
|
import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
12
12
|
|
|
13
|
+
import { relPathUnderAnyPrefix } from "../ingestion";
|
|
13
14
|
import { WATCHER_ACTIVE_SOURCE_PATH_MAX } from "../store/types";
|
|
14
15
|
import { fallbackClassifyDirtyHints } from "./watch-reconciliation-fallback";
|
|
16
|
+
import {
|
|
17
|
+
fallbackFs,
|
|
18
|
+
inspectNoFollowPresence,
|
|
19
|
+
} from "./watch-reconciliation-fallback-disk";
|
|
15
20
|
import {
|
|
16
21
|
filterEligiblePaths,
|
|
17
22
|
type ClassificationResult,
|
|
@@ -28,6 +33,7 @@ export {
|
|
|
28
33
|
WATCHER_MAX_DIRTY_HINTS,
|
|
29
34
|
WATCHER_MAX_EXACT_PATHS,
|
|
30
35
|
WATCHER_MAX_FLUSH_DELAY_MS,
|
|
36
|
+
WATCHER_MAX_RETRY_BACKOFF_MS,
|
|
31
37
|
WATCHER_MAX_SUPPRESSION_ENTRIES,
|
|
32
38
|
WATCHER_RETRY_BACKOFF_MS,
|
|
33
39
|
addToCappedSet,
|
|
@@ -39,6 +45,7 @@ export {
|
|
|
39
45
|
mergeSyncPathBatch,
|
|
40
46
|
pruneSuppressionMap,
|
|
41
47
|
successfulChangedPaths,
|
|
48
|
+
watcherRetryDelayMs,
|
|
42
49
|
widenVanishedExactPaths,
|
|
43
50
|
type ClassificationResult,
|
|
44
51
|
type ExactPathKind,
|
|
@@ -46,6 +53,112 @@ export {
|
|
|
46
53
|
type WatcherEventClassification,
|
|
47
54
|
} from "./watch-reconciliation-shared";
|
|
48
55
|
|
|
56
|
+
type StoreOnlyRemovals =
|
|
57
|
+
| { status: "ok"; paths: string[] }
|
|
58
|
+
| { status: "overflow" }
|
|
59
|
+
| { status: "error"; cause: unknown; stage: "scan" | "store" };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Store-known active paths under the dirty hints that are gone from disk.
|
|
63
|
+
* The snapshot only knows what it scanned: a note indexed from its own exact
|
|
64
|
+
* event after the baseline is in the store but not the snapshot, and when
|
|
65
|
+
* its directory is then moved or replaced (one directory event) the snapshot
|
|
66
|
+
* diff alone would leave it active (fn-209).
|
|
67
|
+
*
|
|
68
|
+
* A path the fresh snapshot lists as a file or symlink is present. Paths in
|
|
69
|
+
* subtrees the snapshot left unproven are left alone. Every other path gets
|
|
70
|
+
* the fallback classifier's no-follow, availability-aware proof: missing or
|
|
71
|
+
* no longer a regular source is a removal; an inconclusive check is a scan
|
|
72
|
+
* error, so the watcher keeps the work and retries rather than infer a
|
|
73
|
+
* delete. The root hint (a watcher event without a filename) uses the
|
|
74
|
+
* bounded whole-collection inventory.
|
|
75
|
+
*/
|
|
76
|
+
async function storeOnlyRemovals(options: {
|
|
77
|
+
store: SqliteAdapter;
|
|
78
|
+
collection: Collection;
|
|
79
|
+
rootAbs: string;
|
|
80
|
+
dirtyHints: readonly string[];
|
|
81
|
+
next: WatcherSnapshot | null;
|
|
82
|
+
sourcePathMax: number;
|
|
83
|
+
snapshotOptions?: WatcherSnapshotOptions;
|
|
84
|
+
}): Promise<StoreOnlyRemovals> {
|
|
85
|
+
const { store, collection, rootAbs, next, sourcePathMax } = options;
|
|
86
|
+
const hints = [...new Set(options.dirtyHints)].sort();
|
|
87
|
+
const storePaths: string[] = [];
|
|
88
|
+
if (hints.includes("")) {
|
|
89
|
+
if (typeof store.listActiveSourcePaths !== "function") {
|
|
90
|
+
return { status: "ok", paths: [] };
|
|
91
|
+
}
|
|
92
|
+
const listed = await store.listActiveSourcePaths(
|
|
93
|
+
collection.name,
|
|
94
|
+
sourcePathMax
|
|
95
|
+
);
|
|
96
|
+
if (!listed.ok) {
|
|
97
|
+
if (listed.error.code === "OVERFLOW") return { status: "overflow" };
|
|
98
|
+
return {
|
|
99
|
+
status: "error",
|
|
100
|
+
cause: new Error(listed.error.message),
|
|
101
|
+
stage: "store",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
storePaths.push(...listed.value);
|
|
105
|
+
} else {
|
|
106
|
+
if (typeof store.listActiveDescendantSourcePaths !== "function") {
|
|
107
|
+
return { status: "ok", paths: [] };
|
|
108
|
+
}
|
|
109
|
+
// A hint under another hint is covered by the outer descendant lookup.
|
|
110
|
+
const roots = hints.filter(
|
|
111
|
+
(hint, index) =>
|
|
112
|
+
!hints.slice(0, index).some((outer) => hint.startsWith(`${outer}/`))
|
|
113
|
+
);
|
|
114
|
+
for (const dir of roots) {
|
|
115
|
+
const listed = await store.listActiveDescendantSourcePaths(
|
|
116
|
+
collection.name,
|
|
117
|
+
dir,
|
|
118
|
+
Math.max(1, sourcePathMax - storePaths.length)
|
|
119
|
+
);
|
|
120
|
+
if (!listed.ok) {
|
|
121
|
+
if (listed.error.code === "OVERFLOW") return { status: "overflow" };
|
|
122
|
+
// Not a directory path the store can scope (e.g. escapes the root).
|
|
123
|
+
if (listed.error.code === "INVALID_INPUT") continue;
|
|
124
|
+
return {
|
|
125
|
+
status: "error",
|
|
126
|
+
cause: new Error(listed.error.message),
|
|
127
|
+
stage: "store",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
storePaths.push(...listed.value);
|
|
131
|
+
if (storePaths.length > sourcePathMax) return { status: "overflow" };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const unproven = [...(next?.unprovenSubtrees ?? [])];
|
|
136
|
+
const fs = options.snapshotOptions?.fs ?? fallbackFs();
|
|
137
|
+
const removals: string[] = [];
|
|
138
|
+
for (const path of storePaths) {
|
|
139
|
+
if (relPathUnderAnyPrefix(path, unproven)) continue;
|
|
140
|
+
const slash = path.lastIndexOf("/");
|
|
141
|
+
const parent = slash === -1 ? "" : path.slice(0, slash);
|
|
142
|
+
const kind = next?.directories
|
|
143
|
+
.get(parent)
|
|
144
|
+
?.get(path.slice(slash + 1))?.kind;
|
|
145
|
+
if (kind === "file" || kind === "symlink") continue;
|
|
146
|
+
const presence = await inspectNoFollowPresence(
|
|
147
|
+
rootAbs,
|
|
148
|
+
path,
|
|
149
|
+
fs,
|
|
150
|
+
options.snapshotOptions?.directoryAvailability
|
|
151
|
+
);
|
|
152
|
+
if (presence.status === "error") {
|
|
153
|
+
return { status: "error", cause: presence.cause, stage: "scan" };
|
|
154
|
+
}
|
|
155
|
+
if (presence.status === "missing" || !presence.indexable) {
|
|
156
|
+
removals.push(path);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { status: "ok", paths: removals };
|
|
160
|
+
}
|
|
161
|
+
|
|
49
162
|
/**
|
|
50
163
|
* Snapshot-first classification of dirty hints. On overflow/scan/metadata
|
|
51
164
|
* failure, uses bounded store + disk enumeration without inferring deletes
|
|
@@ -94,10 +207,32 @@ export async function classifyDirtyHints(options: {
|
|
|
94
207
|
snapshotOptions
|
|
95
208
|
);
|
|
96
209
|
if (diff.status === "ok") {
|
|
210
|
+
const storeRemovals = await storeOnlyRemovals({
|
|
211
|
+
store,
|
|
212
|
+
collection,
|
|
213
|
+
rootAbs,
|
|
214
|
+
dirtyHints,
|
|
215
|
+
next: diff.nextSnapshot,
|
|
216
|
+
sourcePathMax,
|
|
217
|
+
snapshotOptions,
|
|
218
|
+
});
|
|
219
|
+
if (storeRemovals.status === "overflow") {
|
|
220
|
+
return { status: "full_reconcile", reason: "budget_overflow" };
|
|
221
|
+
}
|
|
222
|
+
if (storeRemovals.status === "error") {
|
|
223
|
+
return {
|
|
224
|
+
status: "error",
|
|
225
|
+
cause: storeRemovals.cause,
|
|
226
|
+
stage: storeRemovals.stage,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
97
229
|
return {
|
|
98
230
|
status: "ok",
|
|
99
231
|
candidates: filterEligiblePaths(diff.candidates, collection),
|
|
100
|
-
removals: filterEligiblePaths(
|
|
232
|
+
removals: filterEligiblePaths(
|
|
233
|
+
[...new Set([...diff.removals, ...storeRemovals.paths])],
|
|
234
|
+
collection
|
|
235
|
+
),
|
|
101
236
|
nextSnapshot: diff.nextSnapshot,
|
|
102
237
|
usedFallback: false,
|
|
103
238
|
};
|
|
@@ -12,8 +12,8 @@ import type { Collection } from "../config/types";
|
|
|
12
12
|
import {
|
|
13
13
|
WATCHER_FLUSH_DEBOUNCE_MS,
|
|
14
14
|
WATCHER_MAX_FLUSH_DELAY_MS,
|
|
15
|
-
WATCHER_RETRY_BACKOFF_MS,
|
|
16
15
|
classifyWatcherFilename,
|
|
16
|
+
watcherRetryDelayMs,
|
|
17
17
|
} from "./watch-reconciliation";
|
|
18
18
|
import {
|
|
19
19
|
applyPendingForceFlags,
|
|
@@ -25,6 +25,12 @@ import {
|
|
|
25
25
|
type PendingForceFlags,
|
|
26
26
|
} from "./watch-service-state";
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Why an explicit retry timer is armed: "backoff" after a failed flush, "fixed"
|
|
30
|
+
* for an explicit delay such as writer-lease contention (R3: stays at 5 s).
|
|
31
|
+
*/
|
|
32
|
+
export type WatcherRetryKind = "backoff" | "fixed";
|
|
33
|
+
|
|
28
34
|
export interface WatchEventHost {
|
|
29
35
|
disposed: () => boolean;
|
|
30
36
|
findCollection: (collectionName: string) => Collection | undefined;
|
|
@@ -45,8 +51,12 @@ export interface WatchQueueHost {
|
|
|
45
51
|
pendingByCollection: Map<string, CollectionPending>;
|
|
46
52
|
flushDeadlineAt: Map<string, number>;
|
|
47
53
|
timers: Map<string, ReturnType<typeof setTimeout>>;
|
|
48
|
-
/** Collections with an explicit retry timer; finally must not bypass. */
|
|
49
|
-
retryScheduled:
|
|
54
|
+
/** Collections with an explicit retry timer, by kind; finally must not bypass. */
|
|
55
|
+
retryScheduled: Map<string, WatcherRetryKind>;
|
|
56
|
+
/** Consecutive failed flushes per collection; cleared by a successful flush. */
|
|
57
|
+
retryFailures: Map<string, number>;
|
|
58
|
+
/** Collections with watcher events queued since their last flush took its work. */
|
|
59
|
+
freshEvents: Set<string>;
|
|
50
60
|
snapshotReady: Map<string, boolean>;
|
|
51
61
|
inFlightSyncs: Set<Promise<void>>;
|
|
52
62
|
runFlush: (collectionName: string) => Promise<void>;
|
|
@@ -105,6 +115,7 @@ export function enqueueExactPath(
|
|
|
105
115
|
collectionName,
|
|
106
116
|
queueExactPath(pending, relPath, host.maxExactPaths)
|
|
107
117
|
);
|
|
118
|
+
host.freshEvents.add(collectionName);
|
|
108
119
|
scheduleFlush(host, collectionName);
|
|
109
120
|
}
|
|
110
121
|
|
|
@@ -123,6 +134,7 @@ export function enqueueDirtyHint(
|
|
|
123
134
|
collectionName,
|
|
124
135
|
queueDirtyHint(pending, hint, host.maxDirtyHints)
|
|
125
136
|
);
|
|
137
|
+
host.freshEvents.add(collectionName);
|
|
126
138
|
scheduleFlush(host, collectionName);
|
|
127
139
|
}
|
|
128
140
|
|
|
@@ -152,9 +164,17 @@ export function scheduleFlush(
|
|
|
152
164
|
if (host.disposed()) {
|
|
153
165
|
return;
|
|
154
166
|
}
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
167
|
+
const retryKind = host.retryScheduled.get(collectionName);
|
|
168
|
+
if (retryKind !== undefined) {
|
|
169
|
+
// Explicit retry owns the single timer; do not replace it with debounce.
|
|
170
|
+
if (retryKind === "fixed" || !host.freshEvents.has(collectionName)) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// A fresh event never waits out a failure backoff: drop the retry timer and
|
|
174
|
+
// flush on the normal debounce. The failure count stays until a flush succeeds.
|
|
175
|
+
// A fixed (lease) retry keeps its timer so a held lease is not probed per event.
|
|
176
|
+
host.retryScheduled.delete(collectionName);
|
|
177
|
+
host.flushDeadlineAt.delete(collectionName);
|
|
158
178
|
}
|
|
159
179
|
const schedule = computeFlushDelay({
|
|
160
180
|
nowMs: host.clock(),
|
|
@@ -213,6 +233,8 @@ export function startFlush(host: WatchQueueHost, collectionName: string): void {
|
|
|
213
233
|
* Re-arm pending work after a file-level failure with retry backoff.
|
|
214
234
|
* At most one retry timer per collection; finally must not startFlush while set.
|
|
215
235
|
* forceFallback/overflow survive until forced classification + sync succeed.
|
|
236
|
+
* Without an explicit delay, each consecutive failure doubles the wait (capped),
|
|
237
|
+
* so work that fails the same way every time cannot spin the resident.
|
|
216
238
|
*/
|
|
217
239
|
export function requeueAfterFailure(
|
|
218
240
|
host: WatchQueueHost,
|
|
@@ -220,7 +242,7 @@ export function requeueAfterFailure(
|
|
|
220
242
|
exact: string[],
|
|
221
243
|
dirty: string[],
|
|
222
244
|
forceFlags?: PendingForceFlags,
|
|
223
|
-
delayMs
|
|
245
|
+
delayMs?: number
|
|
224
246
|
): void {
|
|
225
247
|
queueWithoutSchedule(host, collectionName, exact, dirty, forceFlags);
|
|
226
248
|
if (host.disposed()) {
|
|
@@ -230,7 +252,19 @@ export function requeueAfterFailure(
|
|
|
230
252
|
// Pending already merged; existing retry timer remains the sole attempt.
|
|
231
253
|
return;
|
|
232
254
|
}
|
|
233
|
-
host.retryScheduled.
|
|
255
|
+
host.retryScheduled.set(
|
|
256
|
+
collectionName,
|
|
257
|
+
delayMs === undefined ? "backoff" : "fixed"
|
|
258
|
+
);
|
|
259
|
+
let retryDelayMs = delayMs;
|
|
260
|
+
if (retryDelayMs === undefined) {
|
|
261
|
+
const failures = (host.retryFailures.get(collectionName) ?? 0) + 1;
|
|
262
|
+
host.retryFailures.set(collectionName, failures);
|
|
263
|
+
// Events that arrived while the failed flush ran flush on the debounce.
|
|
264
|
+
retryDelayMs = host.freshEvents.has(collectionName)
|
|
265
|
+
? host.flushDebounceMs
|
|
266
|
+
: watcherRetryDelayMs(failures);
|
|
267
|
+
}
|
|
234
268
|
const existingTimer = host.timers.get(collectionName);
|
|
235
269
|
if (existingTimer) {
|
|
236
270
|
clearTimeout(existingTimer);
|
|
@@ -242,7 +276,7 @@ export function requeueAfterFailure(
|
|
|
242
276
|
host.timers.delete(collectionName);
|
|
243
277
|
host.retryScheduled.delete(collectionName);
|
|
244
278
|
startFlush(host, collectionName);
|
|
245
|
-
},
|
|
279
|
+
}, retryDelayMs)
|
|
246
280
|
);
|
|
247
281
|
}
|
|
248
282
|
|
|
@@ -7,18 +7,65 @@
|
|
|
7
7
|
// node:path — Bun has no path utilities
|
|
8
8
|
import { normalize } from "node:path";
|
|
9
9
|
|
|
10
|
+
import type { CollectionSyncResult } from "../ingestion";
|
|
10
11
|
import type {
|
|
11
12
|
FlushCollectionInput,
|
|
12
13
|
FlushCollectionOutcome,
|
|
13
14
|
} from "./watch-service-flush";
|
|
14
15
|
|
|
15
|
-
import { defaultSyncService } from "../ingestion";
|
|
16
|
+
import { defaultSyncService, isSourceAvailabilitySkip } from "../ingestion";
|
|
16
17
|
import { hasFileLevelSyncError } from "./watch-reconciliation";
|
|
17
18
|
import {
|
|
18
19
|
contentChangedPaths,
|
|
19
20
|
notifyCompletedSync,
|
|
20
21
|
} from "./watch-service-flush-helpers";
|
|
21
22
|
|
|
23
|
+
/** Failure codes from the store side of a sync: transient, worth a retry. */
|
|
24
|
+
const STORE_SIDE_FAILURE_CODES = new Set(["QUERY_FAILED", "STORE_ERROR"]);
|
|
25
|
+
const SOURCE_AVAILABILITY_PREFIX = "SOURCE_AVAILABILITY_";
|
|
26
|
+
|
|
27
|
+
interface ReconcileFailures {
|
|
28
|
+
/** A collection-level failure (no path): the reconcile itself is incomplete. */
|
|
29
|
+
unscoped: boolean;
|
|
30
|
+
/** Every named failed path, from file receipts and walker errors alike. */
|
|
31
|
+
named: string[];
|
|
32
|
+
/** Named paths that failed in the store, not on the file's content. */
|
|
33
|
+
storeSide: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Classify the failures of a completed full-collection sync. */
|
|
37
|
+
function classifyReconcileFailures(
|
|
38
|
+
result: CollectionSyncResult
|
|
39
|
+
): ReconcileFailures {
|
|
40
|
+
const named = new Set<string>();
|
|
41
|
+
const storeSide = new Set<string>();
|
|
42
|
+
let unscoped = false;
|
|
43
|
+
for (const file of result.files ?? []) {
|
|
44
|
+
if (file.status !== "error") continue;
|
|
45
|
+
named.add(file.relPath);
|
|
46
|
+
if (STORE_SIDE_FAILURE_CODES.has(file.errorCode ?? ""))
|
|
47
|
+
storeSide.add(file.relPath);
|
|
48
|
+
}
|
|
49
|
+
// A collection whose source availability cannot be proven fails the same
|
|
50
|
+
// way on every attempt until its config or volume changes (a config edit
|
|
51
|
+
// starts a new reconcile): record it once, like a content failure.
|
|
52
|
+
let unavailable = false;
|
|
53
|
+
// Walker failures (TOO_LARGE, PERMISSION...) appear only here.
|
|
54
|
+
for (const entry of result.errors) {
|
|
55
|
+
if (isSourceAvailabilitySkip(entry.code)) continue;
|
|
56
|
+
if (!entry.relPath) {
|
|
57
|
+
if (entry.code.startsWith(SOURCE_AVAILABILITY_PREFIX)) unavailable = true;
|
|
58
|
+
else unscoped = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
named.add(entry.relPath);
|
|
62
|
+
if (STORE_SIDE_FAILURE_CODES.has(entry.code)) storeSide.add(entry.relPath);
|
|
63
|
+
}
|
|
64
|
+
// A failure count with nothing named is collection-level too.
|
|
65
|
+
if (named.size === 0 && !unavailable) unscoped = true;
|
|
66
|
+
return { unscoped, named: [...named], storeSide: [...storeSide] };
|
|
67
|
+
}
|
|
68
|
+
|
|
22
69
|
/**
|
|
23
70
|
* When collection generation advanced during/before flush, run full
|
|
24
71
|
* syncCollection. Failures leave durable generation work and never advance
|
|
@@ -96,11 +143,38 @@ export async function runGenerationReconcile(
|
|
|
96
143
|
contentChangedPaths(result);
|
|
97
144
|
|
|
98
145
|
if (hasFileLevelSyncError(result)) {
|
|
99
|
-
// Notify completed sync once; keep durable generation work.
|
|
100
|
-
notifyCompletedSync(input, operationPaths, result, ownership);
|
|
101
146
|
const error = new Error(
|
|
102
147
|
"One or more paths failed during watcher generation reconcile"
|
|
103
148
|
);
|
|
149
|
+
const failures = classifyReconcileFailures(result);
|
|
150
|
+
// The walk completed: the collection is reconciled except for the
|
|
151
|
+
// named failed paths (fn-211). Requeuing the whole generation
|
|
152
|
+
// re-walked the collection every backoff step for as long as one file
|
|
153
|
+
// kept failing. Store-side failures are transient and can leave a
|
|
154
|
+
// stale document, so only those paths are retried (exact paths,
|
|
155
|
+
// failure backoff). Content failures (unreadable, corrupt, too
|
|
156
|
+
// large...) are recorded once, as `gno update` and the daemon's
|
|
157
|
+
// initial sync do; the next edit to the file retries it. Keep the
|
|
158
|
+
// whole generation when any failure is collection-level (e.g. the
|
|
159
|
+
// document inventory failed) or this flush no longer owns the
|
|
160
|
+
// collection (its requeue would be dropped).
|
|
161
|
+
if (
|
|
162
|
+
!failures.unscoped &&
|
|
163
|
+
input.getCurrentGeneration() === input.ownerGeneration
|
|
164
|
+
) {
|
|
165
|
+
input.invalidateSnapshot(stillCurrent);
|
|
166
|
+
notifyCompletedSync(input, operationPaths, result, ownership);
|
|
167
|
+
input.onSyncError(failures.named, error);
|
|
168
|
+
if (failures.storeSide.length > 0) {
|
|
169
|
+
input.requeue(failures.storeSide, []);
|
|
170
|
+
return { status: "failed", error };
|
|
171
|
+
}
|
|
172
|
+
completedGeneration = currentGeneration;
|
|
173
|
+
needsWork = input.getCurrentGeneration() !== completedGeneration;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// Notify completed sync once; keep durable generation work.
|
|
177
|
+
notifyCompletedSync(input, operationPaths, result, ownership);
|
|
104
178
|
input.onSyncError([], error);
|
|
105
179
|
input.requeueGeneration();
|
|
106
180
|
return { status: "failed", error };
|
|
@@ -8,7 +8,11 @@ import type { FSWatcher } from "node:fs";
|
|
|
8
8
|
|
|
9
9
|
import type { Collection } from "../config/types";
|
|
10
10
|
import type { SyncOptions } from "../ingestion";
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
WatchEventHost,
|
|
13
|
+
WatchQueueHost,
|
|
14
|
+
WatcherRetryKind,
|
|
15
|
+
} from "./watch-service-events";
|
|
12
16
|
import type { WatchLifecycleHost } from "./watch-service-lifecycle";
|
|
13
17
|
import type { CollectionPending } from "./watch-service-state";
|
|
14
18
|
import type { WatcherSnapshot } from "./watch-snapshot";
|
|
@@ -46,7 +50,9 @@ export interface WatchServiceHostState {
|
|
|
46
50
|
maxDirtyHints: number;
|
|
47
51
|
flushDeadlineAt: Map<string, number>;
|
|
48
52
|
timers: Map<string, ReturnType<typeof setTimeout>>;
|
|
49
|
-
retryScheduled:
|
|
53
|
+
retryScheduled: Map<string, WatcherRetryKind>;
|
|
54
|
+
retryFailures: Map<string, number>;
|
|
55
|
+
freshEvents: Set<string>;
|
|
50
56
|
inFlightSyncs: Set<Promise<void>>;
|
|
51
57
|
runFlush: (collectionName: string) => Promise<void>;
|
|
52
58
|
}
|
|
@@ -71,6 +77,8 @@ export function buildLifecycleHost(
|
|
|
71
77
|
snapshotInit: state.snapshotInit,
|
|
72
78
|
syncing: state.syncing,
|
|
73
79
|
pendingByCollection: state.pendingByCollection,
|
|
80
|
+
retryFailures: state.retryFailures,
|
|
81
|
+
freshEvents: state.freshEvents,
|
|
74
82
|
clearCollectionRuntimeState: state.clearCollectionRuntimeState,
|
|
75
83
|
beginSnapshotInit: state.beginSnapshotInit,
|
|
76
84
|
watchFactory: state.watchFactory,
|
|
@@ -102,6 +110,8 @@ export function buildQueueHost(state: WatchServiceHostState): WatchQueueHost {
|
|
|
102
110
|
flushDeadlineAt: state.flushDeadlineAt,
|
|
103
111
|
timers: state.timers,
|
|
104
112
|
retryScheduled: state.retryScheduled,
|
|
113
|
+
retryFailures: state.retryFailures,
|
|
114
|
+
freshEvents: state.freshEvents,
|
|
105
115
|
snapshotReady: state.snapshotReady,
|
|
106
116
|
inFlightSyncs: state.inFlightSyncs,
|
|
107
117
|
runFlush: state.runFlush,
|
|
@@ -38,6 +38,10 @@ export interface WatchLifecycleHost {
|
|
|
38
38
|
* forceFallback + durable generation reconcile so a new baseline cannot absorb it.
|
|
39
39
|
*/
|
|
40
40
|
pendingByCollection: Map<string, CollectionPending>;
|
|
41
|
+
/** Consecutive failed flushes per collection (failure backoff input). */
|
|
42
|
+
retryFailures: Map<string, number>;
|
|
43
|
+
/** Collections whose queued work is new user action, not a failure retry. */
|
|
44
|
+
freshEvents: Set<string>;
|
|
41
45
|
clearCollectionRuntimeState: (collectionName: string) => void;
|
|
42
46
|
beginSnapshotInit: (collection: Collection) => void;
|
|
43
47
|
watchFactory: (
|
|
@@ -186,6 +190,10 @@ export function applyCollectionUpdate(
|
|
|
186
190
|
pending.forceFallback = true;
|
|
187
191
|
}
|
|
188
192
|
host.pendingByCollection.set(collection.name, pending);
|
|
193
|
+
// A config edit is user action that may fix the failure cause: it must
|
|
194
|
+
// not wait out an armed failure backoff, and the count starts over.
|
|
195
|
+
host.freshEvents.add(collection.name);
|
|
196
|
+
host.retryFailures.delete(collection.name);
|
|
189
197
|
}
|
|
190
198
|
if (host.watchers.has(collection.name)) {
|
|
191
199
|
host.beginSnapshotInit(collection);
|
|
@@ -10,7 +10,7 @@ import { normalize } from "node:path";
|
|
|
10
10
|
import type { Collection } from "../config/types";
|
|
11
11
|
import type { CollectionSyncResult, SyncOptions } from "../ingestion";
|
|
12
12
|
import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
13
|
-
import type { WatchQueueHost } from "./watch-service-events";
|
|
13
|
+
import type { WatchQueueHost, WatcherRetryKind } from "./watch-service-events";
|
|
14
14
|
import type { CollectionPending } from "./watch-service-state";
|
|
15
15
|
import type { WatcherSnapshot, WatcherSnapshotFs } from "./watch-snapshot";
|
|
16
16
|
|
|
@@ -36,7 +36,7 @@ export interface RunFlushContext {
|
|
|
36
36
|
pendingByCollection: Map<string, CollectionPending>;
|
|
37
37
|
flushDeadlineAt: Map<string, number>;
|
|
38
38
|
syncing: Set<string>;
|
|
39
|
-
retryScheduled:
|
|
39
|
+
retryScheduled: Map<string, WatcherRetryKind>;
|
|
40
40
|
collectionGenerations: Map<string, number>;
|
|
41
41
|
snapshots: Map<string, WatcherSnapshot>;
|
|
42
42
|
snapshotReady: Map<string, boolean>;
|
|
@@ -125,6 +125,7 @@ export async function runOwnedCollectionFlush(
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
// Events that arrived while the lease was being taken join this flush.
|
|
128
|
+
ctx.queueHost.freshEvents.delete(collectionName);
|
|
128
129
|
const taken = takePending(
|
|
129
130
|
ctx.pendingByCollection.get(collectionName) ?? pending
|
|
130
131
|
);
|
|
@@ -148,6 +149,9 @@ export async function runOwnedCollectionFlush(
|
|
|
148
149
|
);
|
|
149
150
|
};
|
|
150
151
|
|
|
152
|
+
// A flush can requeue failed work (e.g. a dirty classification error) and
|
|
153
|
+
// still settle as synced/idle; that must not reset the failure backoff.
|
|
154
|
+
let requeuedFailure = false;
|
|
151
155
|
try {
|
|
152
156
|
const outcome = await flushCollectionOnce({
|
|
153
157
|
collection,
|
|
@@ -218,6 +222,7 @@ export async function runOwnedCollectionFlush(
|
|
|
218
222
|
if (!stillOwner()) {
|
|
219
223
|
return;
|
|
220
224
|
}
|
|
225
|
+
requeuedFailure = true;
|
|
221
226
|
requeueAfterFailure(
|
|
222
227
|
ctx.queueHost,
|
|
223
228
|
collectionName,
|
|
@@ -236,6 +241,12 @@ export async function runOwnedCollectionFlush(
|
|
|
236
241
|
requeueGenerationReconcile(ctx.queueHost, collectionName);
|
|
237
242
|
},
|
|
238
243
|
});
|
|
244
|
+
if (
|
|
245
|
+
(outcome.status === "synced" || outcome.status === "idle") &&
|
|
246
|
+
!requeuedFailure
|
|
247
|
+
) {
|
|
248
|
+
ctx.queueHost.retryFailures.delete(collectionName);
|
|
249
|
+
}
|
|
239
250
|
if (
|
|
240
251
|
outcome.status === "failed" &&
|
|
241
252
|
outcome.error &&
|