Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion changelog.d/quiesce-writes-first.fixed.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
- `quiesce()` and `resume()` now answer only once the host-mode record agrees with the host, or fail, apart from calls that overlap.
- **`quiesce()`** writes the record before it marks the host quiesced. When that write failed, `quiesce()` rejected with the host quiesced in memory while its record said it was serving, so a restart booted serving, and a second `quiesce()` resolved without writing the record. Now a failed write leaves the host serving, as it was, and the next `quiesce()` quiesces and writes the record.
- **`resume()`** still serves before it clears the record, so a failed clear leaves the record quiesced. A second `resume()` used to resolve without clearing it, so the next restart booted quiesced; it now clears the record. While a surgery holds the store, that clear is refused as `resume()` is on a quiesced host, and a `resume()` arriving while another is still flushing leaves the clear to that one.
- **Without a recovery file,** the store is the record, and a failed sync of the store after the write was only logged, so `quiesce()` resolved before its record was durable. It now fails `quiesce()` or `resume()`. The write is already in the store's log, though, so after a `quiesce()` that failed this way the record can still reach disk, and a restart can boot quiesced unless a `resume()` clears it first. With a recovery file, the file is the record, and a failed sync is still only logged.
- **Without a recovery file,** the store is the record, and a failed sync of the store after the write was only logged, so `quiesce()` resolved before its record was durable. It now fails `quiesce()` or `resume()`. The write is already in the store's log, though, so after a `quiesce()` that failed this way the record can still reach disk, and a restart can boot quiesced unless a `resume()` clears it first. With a recovery file, the file is the record, and a failed sync of the store is still only logged.
1 change: 1 addition & 0 deletions changelog.d/recovery-file-durability.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- The host-mode and deferred-writes recovery files are now synced to disk before they replace the record, and their directory and its parent after the rename. On Windows, and where the filesystem refuses a directory's sync with `EINVAL` or `EBADF`, only the file is synced. A write that fails before the rename removes its `.tmp` file instead of leaving it beside the record, logs one it can't remove, and fails with an error that names the record and says it was left as it was. A directory sync that fails for any other reason raises an ops alert naming the record, since the new record is already in place.
76 changes: 70 additions & 6 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogu
import { dirname, join } from 'node:path';
import { INLINE_WITHHELD_TEXT, REFERENCE_LIMITS, classifyBlock, isInlineContradiction, isReferenceExpired, referenceRegistry, referenceStubOrNull, referenceTypeLabel, sanitizeLabel, unfetchableReason } from './mcpl/references.js';
import { ReferenceFetcher, DEFAULT_FETCH_MAX_BYTES, EAGER_FETCH_TIMEOUT_MS } from './mcpl/reference-fetcher.js';
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import {
appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync,
writeFileSync,
} from 'node:fs';
import { JsStore } from '@animalabs/chronicle';
import type { Membrane, ContentBlock, NormalizedRequest, NormalizedResponse, YieldingStream, DetailedUsage, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
import { MembraneError } from '@animalabs/membrane';
Expand Down Expand Up @@ -5353,13 +5356,74 @@ export class AgentFramework {
return landed;
}

/** Atomic JSON write for the recovery files (tmp + rename, 0600) — the
* same idiom as the Discord awareness outbox. */
/** Atomic, durable JSON write for the recovery files. The document goes to
* a temp file at 0600, which is synced and renamed over the record, so
* the new record's data is asked to reach the disk before the rename
* publishes it. Then the directory is synced, for the record's entry in
* it, and its parent, for the directory's own entry, which is new when a
* write, this one or an earlier one, had to make the directory; levels
* above are left to the filesystem. Directories aren't synced on win32,
* which syncs the file alone, or where the filesystem can't sync one
* (syncRecoveryDirectory). A failure before the rename removes the temp
* file, if it was made, and throws an error saying the record was left as
* it was, with the original as its cause and its code kept. A directory
* sync that fails, other than where the filesystem can't sync one, is
* raised as an ops alert, not thrown: the new record is in place, and the
* callers go on to record it as written. */
private writeRecoveryFile(path: string, document: unknown): void {
mkdirSync(dirname(path), { recursive: true });
const directory = dirname(path);
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 });
renameSync(temporary, path);
let opened = false;
try {
mkdirSync(directory, { recursive: true });
const fd = openSync(temporary, 'w', 0o600);
opened = true;
try {
writeFileSync(fd, `${JSON.stringify(document, null, 2)}\n`);
fsyncSync(fd);
} finally {
closeSync(fd);
}
renameSync(temporary, path);
} catch (err) {
if (opened) {
try {
unlinkSync(temporary);
} catch (unlinkErr) {
console.error(`[host-mode] couldn't remove ${temporary} after a failed write:`, unlinkErr);
}
}
const cause = err as NodeJS.ErrnoException;
throw Object.assign(new Error(`${path} was left as it was: ${cause.message}`, { cause }), cause.code ? { code: cause.code } : {});
}
this.syncRecoveryDirectory(directory, path);
this.syncRecoveryDirectory(dirname(directory), path);
}

/** Syncs the directory a recovery file is in, or that directory's parent,
* so its entries reach the disk. win32 is left out. So is a sync refused
* with EINVAL or EBADF, the answer where the filesystem or system can't
* sync a directory: that refusal would come again on every write, and
* PostgreSQL's fsync_fname_ext lets the same two go. Any other failure is
* raised as an ops alert naming the record. */
private syncRecoveryDirectory(directory: string, record: string): void {
if (process.platform === 'win32') return;
try {
const fd = openSync(directory, 'r');
try {
fsyncSync(fd);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'EINVAL' && code !== 'EBADF') throw err;
} finally {
closeSync(fd);
}
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
this.opsAlert('recovery-sync', 'framework',
`syncing ${directory} failed, so ${record} may not survive a crash: ${reason}`,
{ data: { record, directory, code: (err as NodeJS.ErrnoException).code } });
}
}

private readRecoveryFile(path: string): unknown {
Expand Down
2 changes: 1 addition & 1 deletion test/host-quiesce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ test("a resume() during another resume()'s flush leaves the record to it, and it
}
});

test("store only, a host-mode change whose sync fails rejects; with a recovery file, the file is the record and a failed sync is only logged", async () => {
test("store only, a host-mode change whose sync fails rejects; with a recovery file, the file is the record and a failed sync of the store is only logged", async () => {
const logged = captureErrors();
try {
const slot = await hostModeFixture('store only');
Expand Down
Loading
Loading