mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
* fix(persistence): fsync state writes so a rename is actually durable
`Store` wrote `orca-data.json` to a temp file and renamed it. rename() is
atomic for readers but says nothing about durability: without an fsync the
directory entry can reach disk before the data does. After power loss or a
hard crash the file can come back holding the previous state or, worse,
zero bytes — and `JSON.parse('')` throws, so an empty file takes the
full corrupt-file path rather than degrading.
This is the same empty-file symptom as #1158 from a different cause. That
issue fixed a logic path that persisted empty state and added the .bak ring
as a safety net; the ring also catches this, which is why it went unnoticed.
Recovery costs up to an hour of tabs/layouts/session state (backups are
throttled to >=1h spacing), and a user in their first hour has no backup
slot yet, so they land on defaults indistinguishable from a fresh install.
Both write paths now fsync the temp file *before* the rename, then fsync
the containing directory. Directory fsync is best-effort by design: Windows
cannot open a directory for fsync and some filesystems reject it, so it is
swallowed. The file fsync is the load-bearing part and works everywhere.
Measured cost on a 3 MB payload: ~0.2 ms per write, against a 1s debounce.
The async path does not block the main thread.
The syscall-order test mocks `node:fs` and counts fsync targets at the
module boundary, asserting ['file', 'directory'] — proving the ordering
rather than inferring it from reading the implementation, since a fsync
after the rename would still pass every content assertion.
* test(persistence): make the syscall proof platform-aware and actually prove the order
Two problems, both found from CodeRabbit's Windows observation.
The assertion hardcoded ['file', 'directory']. Directory fsync is
deliberately best-effort — Windows cannot open a directory for fsync and
some filesystems reject it — so on Windows the helper swallows the failure,
only the file fsync is observed, and the test fails. The expectation now
probes the real platform instead of assuming, keeping the guarantee tight
where directory fsync works rather than dropping it everywhere.
Worse, the test did not prove what its name claimed. Moving the fsync to
*after* the rename still passes: the file is fsynced either way, and only
fsyncs were recorded, so the correct and broken orders produced an identical
log. Mutation-testing the "before rename" claim is what surfaced this — the
mutation passed.
The rename is now recorded in the same sequence, since it is the boundary
the ordering is defined against. Re-running the same mutation fails, so the
ordering claim is now backed by the test rather than asserted in a comment.
---------
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
88 lines
3.2 KiB
TypeScript
88 lines
3.2 KiB
TypeScript
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { writeFileDurable, writeFileDurableSync } from './durable-file-write'
|
|
|
|
describe('durable file write', () => {
|
|
let dir: string
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), 'orca-durable-'))
|
|
})
|
|
|
|
afterEach(() => {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
})
|
|
|
|
for (const [label, write] of [
|
|
['async', (t: string, f: string, p: string) => writeFileDurable(t, f, p)],
|
|
[
|
|
'sync',
|
|
(t: string, f: string, p: string) => {
|
|
writeFileDurableSync(t, f, p)
|
|
return Promise.resolve()
|
|
}
|
|
]
|
|
] as const) {
|
|
describe(label, () => {
|
|
it('publishes the payload at the final path', async () => {
|
|
const final = join(dir, 'state.json')
|
|
await write(`${final}.tmp`, final, '{"a":1}')
|
|
expect(readFileSync(final, 'utf-8')).toBe('{"a":1}')
|
|
})
|
|
|
|
it('replaces existing content atomically', async () => {
|
|
const final = join(dir, 'state.json')
|
|
writeFileSync(final, 'stale', 'utf-8')
|
|
await write(`${final}.tmp`, final, 'fresh')
|
|
expect(readFileSync(final, 'utf-8')).toBe('fresh')
|
|
})
|
|
|
|
it('leaves no temp file behind on success', async () => {
|
|
const final = join(dir, 'state.json')
|
|
const tmp = `${final}.tmp`
|
|
await write(tmp, final, 'x')
|
|
expect(() => readFileSync(tmp, 'utf-8')).toThrow()
|
|
})
|
|
|
|
it('round-trips a multi-megabyte payload without truncation', async () => {
|
|
// Why: the real orca-data.json is large; a partial fsync would surface here.
|
|
const final = join(dir, 'big.json')
|
|
const payload = JSON.stringify({ blob: 'x'.repeat(4 * 1024 * 1024) })
|
|
await write(`${final}.tmp`, final, payload)
|
|
expect(readFileSync(final, 'utf-8')).toHaveLength(payload.length)
|
|
})
|
|
|
|
it('preserves exact bytes for multibyte and escape-sensitive content', async () => {
|
|
const final = join(dir, 'utf8.json')
|
|
const payload = JSON.stringify({ s: 'emoji 🚀 + 日本語 + \u0000 + "quotes"' })
|
|
await write(`${final}.tmp`, final, payload)
|
|
expect(readFileSync(final, 'utf-8')).toBe(payload)
|
|
})
|
|
|
|
it('surfaces an unwritable temp path instead of silently succeeding', async () => {
|
|
const final = join(dir, 'state.json')
|
|
const tmp = join(dir, 'missing-subdir', 'state.json.tmp')
|
|
// The sync variant throws synchronously and the async one rejects; both must fail loudly
|
|
// and neither may publish a partial file.
|
|
let failed = false
|
|
try {
|
|
await write(tmp, final, 'x')
|
|
} catch {
|
|
failed = true
|
|
}
|
|
expect(failed).toBe(true)
|
|
expect(() => readFileSync(final, 'utf-8')).toThrow()
|
|
})
|
|
})
|
|
}
|
|
|
|
it('keeps the last writer when async and sync paths target one file', async () => {
|
|
const final = join(dir, 'state.json')
|
|
await writeFileDurable(`${final}.a.tmp`, final, 'from-async')
|
|
writeFileDurableSync(`${final}.b.tmp`, final, 'from-sync')
|
|
expect(readFileSync(final, 'utf-8')).toBe('from-sync')
|
|
})
|
|
})
|