Skip to content

Commit 8f52831

Browse files
committed
fix(ai): recover orphaned .draining file from SIGKILL edge case (#10153)
Gemini 3.1 Pro's Depth Floor §7.1 review on PR #10171 surfaced a legitimate edge case: if the Node process is terminated via SIGKILL (OOM, hardware failure) between `rename(queuePath, drainingPath)` and the try/catch restore, the `.draining` file is orphaned and those queued edges wait for manual recovery. The catch-block restore doesn't execute under signal-based termination. Fix: `LazyEdgeDrainer.recoverOrphanedDraining(queuePath, drainingPath)` runs at the start of every `drainQueue` invocation. If a `.draining` file exists (from a prior crashed run), its content is merged into the live queue and the orphan deleted. Idempotent — subsequent recovery-on-boot finds no orphan and no-ops. Stats shape extended with `orphanRecovered: Boolean` so operators can observe recovery events via the drain-cycle logs. Test coverage: +1 Playwright test in LazyEdgeDrainer.spec covering the orphan-recovery path (synthesizes a .draining file + fresh queue, asserts both drain in a single run and the orphan is cleaned up). 8/8 green.
1 parent ecd88d6 commit 8f52831

2 files changed

Lines changed: 83 additions & 1 deletion

File tree

ai/daemons/services/LazyEdgeDrainer.mjs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,20 @@ class LazyEdgeDrainer extends Base {
8383
const
8484
queuePath = filePath || aiConfig.lazyEdgesQueuePath,
8585
drainingPath = queuePath + '.draining',
86-
stats = {totalLines: 0, processed: 0, succeeded: 0, failed: 0, skippedMalformed: 0, queueRotated: false};
86+
stats = {totalLines: 0, processed: 0, succeeded: 0, failed: 0, skippedMalformed: 0, queueRotated: false, orphanRecovered: false};
8787

8888
if (!queuePath) {
8989
logger.warn('[LazyEdgeDrainer] No queue path configured (aiConfig.lazyEdgesQueuePath); nothing to drain.');
9090
return stats;
9191
}
9292

93+
// Recovery-on-boot for orphaned `.draining` files — addresses the SIGKILL edge case
94+
// where a prior drain crashed between `rename` and the `try`/`catch` restore path
95+
// (the catch only runs on thrown exceptions; `SIGKILL` / `SIGSTOP` / process exit
96+
// bypass it). Merging orphan content back into the live queue is idempotent and
97+
// preserves the "failures retained for retry" contract.
98+
stats.orphanRecovered = await this.recoverOrphanedDraining(queuePath, drainingPath);
99+
93100
try {
94101
if (dryRun) {
95102
// Dry-run: read from the live queue without rotating, so concurrent producers
@@ -142,6 +149,51 @@ class LazyEdgeDrainer extends Base {
142149
return stats;
143150
}
144151

152+
/**
153+
* Recovers an orphaned `.draining` file from a prior drain cycle that crashed between
154+
* `rename(queuePath, drainingPath)` and the catch-block restore. Such orphans occur under
155+
* process termination signals that bypass `try`/`catch` (`SIGKILL`, OOM kills, hardware
156+
* failure). Without recovery, the orphaned edges would remain on disk but never be
157+
* retried until manual intervention.
158+
*
159+
* Resolution: merge the orphan's content into the live queue (prepended so orphaned
160+
* edges are processed first, preserving FIFO semantics for the entries that have been
161+
* waiting longest), then delete the orphan. The merge is idempotent — a subsequent
162+
* recovery-on-boot finds no orphan and no-ops.
163+
*
164+
* @param {String} queuePath The live queue path
165+
* @param {String} drainingPath The `.draining` orphan path
166+
* @returns {Promise<Boolean>} `true` if an orphan was recovered; `false` if none existed.
167+
* @protected
168+
*/
169+
async recoverOrphanedDraining(queuePath, drainingPath) {
170+
if (!fs.existsSync(drainingPath)) {
171+
return false;
172+
}
173+
174+
try {
175+
const orphanContent = await fs.promises.readFile(drainingPath, 'utf8');
176+
177+
if (orphanContent.length > 0) {
178+
const existingContent = fs.existsSync(queuePath)
179+
? await fs.promises.readFile(queuePath, 'utf8')
180+
: '';
181+
182+
// Normalize trailing newline on the orphan so the concat doesn't produce
183+
// adjacent JSON objects on a single line.
184+
const merged = orphanContent.endsWith('\n') ? orphanContent + existingContent : orphanContent + '\n' + existingContent;
185+
await fs.promises.writeFile(queuePath, merged, 'utf8');
186+
}
187+
188+
await fs.promises.unlink(drainingPath);
189+
logger.warn(`[LazyEdgeDrainer] Recovered orphaned ${drainingPath} from prior run and merged into live queue.`);
190+
return true;
191+
} catch (e) {
192+
logger.error(`[LazyEdgeDrainer] Failed to recover orphaned ${drainingPath}:`, e);
193+
return false;
194+
}
195+
}
196+
145197
/**
146198
* Processes the raw JSONL content — one edge per line. Parses, validates, attempts
147199
* resolution via `GraphService.linkNodesAsync`, and accumulates stats + failures.

test/playwright/unit/ai/daemons/services/LazyEdgeDrainer.spec.mjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,36 @@ test.describe('Neo.ai.daemons.services.LazyEdgeDrainer', () => {
212212
expect(remaining).toContain('memory:dryrun-m');
213213
});
214214

215+
test('drainQueue recovers orphaned .draining file from a prior-run SIGKILL', async () => {
216+
// Simulate a prior drain that was SIGKILL'd between rename and completion: the
217+
// `.draining` file exists with queued edges but the live queue may or may not have
218+
// fresh appends from a concurrent producer.
219+
const drainingPath = queuePath + '.draining';
220+
const orphanedEdge = {source: 'CONCEPT:orphan', target: 'memory:orphan', relationship: 'MENTIONED_IN'};
221+
const freshEdge = {source: 'CONCEPT:fresh', target: 'memory:fresh', relationship: 'DISCUSSED_IN'};
222+
223+
fs.writeFileSync(drainingPath, JSON.stringify(orphanedEdge) + '\n');
224+
fs.writeFileSync(queuePath, JSON.stringify(freshEdge) + '\n');
225+
226+
const processedTargets = [];
227+
GraphService.linkNodesAsync = async (source, target) => {
228+
processedTargets.push(target);
229+
return true;
230+
};
231+
232+
const stats = await LazyEdgeDrainer.drainQueue({filePath: queuePath});
233+
234+
expect(stats.orphanRecovered).toBe(true);
235+
// Both the orphaned and the fresh edge should have been drained in this single run.
236+
expect(stats.succeeded).toBe(2);
237+
expect(processedTargets).toContain('memory:orphan');
238+
expect(processedTargets).toContain('memory:fresh');
239+
// Orphan file removed after successful recovery.
240+
expect(fs.existsSync(drainingPath)).toBe(false);
241+
// Live queue fully drained (no failures to retain).
242+
expect(fs.existsSync(queuePath)).toBe(false);
243+
});
244+
215245
test('drainQueue handles thrown exceptions from linkNodesAsync as failures', async () => {
216246
fs.writeFileSync(queuePath, JSON.stringify({source: 'a', target: 'memory:throw', relationship: 'RELATES_TO'}) + '\n');
217247

0 commit comments

Comments
 (0)