fix: transact durability barrier — committed transactions are durable on return
A committed transact() reported success while its canonical entity writes were still only in the OS page cache (tmp+rename, not fsync'd), even though the generation counter and manifest were fsync'd. A hard kill in that window could leave the durable counter ahead of the persisted entity bytes — phantom progress for any generation-based consumer resuming from the counter. Reported from a downstream migration's crash-lifecycle forensics. Add an optional transaction durability barrier to GenerationStorage (beginWriteBarrier / flushWriteBarrier). FileSystemStorage implements it: writeObjectToPath records each successful canonical write and deleteObjectFromPath records each delete's parent dir, between begin and flush; flush fsyncs every recorded write (contents + rename dir entries, via syncRawObjects) and the parent dir of every delete. commitTransaction opens the barrier before running the planned operations and flushes it after, BEFORE persisting the counter and manifest — so the batch's entire canonical footprint is durable before the generation stamp advances. The barrier is optional: the generation store calls it through optional chaining, so in-memory and durable-per-call (cloud object-PUT) adapters treat it as a no-op. The single-op group-commit path is unchanged (deferred durability is the Model-B design that avoids a 3-5x per-write fsync regression), but its durability contract is now documented explicitly on commitSingleOp: transact = durable on return; single-op = durable at the next flush()/close(), with the counter buffered alongside the data so a crash loses both together (never a torn counter-ahead-of-state store). Regression (tests/integration/transact-durability-barrier.test.ts): the entity writes fsync in an earlier syncRawObjects batch than the manifest for single-op and multi-op (add+relate) transactions; a precommit-rejected batch opens no barrier and advances nothing; MemoryStorage exposes no barrier (optional-chain no-op).
This commit is contained in:
parent
a9fc1f3f9b
commit
3b8fa51161
4 changed files with 270 additions and 0 deletions
|
|
@ -119,6 +119,18 @@ export class FileSystemStorage extends BaseStorage {
|
|||
private compressionEnabled: boolean = true // Enable gzip compression by default for 60-80% disk savings
|
||||
private compressionLevel: number = 6 // zlib compression level (1-9, default: 6 = balanced)
|
||||
|
||||
// Transaction durability barrier (see GenerationStorage.beginWriteBarrier).
|
||||
// Non-null ONLY between beginWriteBarrier() and flushWriteBarrier(). Because
|
||||
// canonical writes are tmp+rename (durable only in the page cache until an
|
||||
// fsync), the generation store fsyncs everything a transaction wrote before
|
||||
// it advances the generation counter. `writeBarrierPaths` collects the
|
||||
// root-relative object paths written; `writeBarrierDeleteDirs` the parent
|
||||
// dirs of deleted objects (an unlink is durable only once its directory is
|
||||
// fsync'd). Both are null outside a transaction, so the tracking `add`s below
|
||||
// are free on the single-op and non-transactional write paths.
|
||||
private writeBarrierPaths: Set<string> | null = null
|
||||
private writeBarrierDeleteDirs: Set<string> | null = null
|
||||
|
||||
/**
|
||||
* Initialize the storage adapter
|
||||
* @param rootDirectory The root directory for storage
|
||||
|
|
@ -373,6 +385,12 @@ export class FileSystemStorage extends BaseStorage {
|
|||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Transaction durability barrier: record the write so the generation store
|
||||
// can fsync it before advancing the counter. Reached only on a successful
|
||||
// rename; the logical (non-`.gz`) path is stored — flushWriteBarrier's
|
||||
// syncRawObjects resolves the compressed variant.
|
||||
this.writeBarrierPaths?.add(pathStr)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -467,6 +485,11 @@ export class FileSystemStorage extends BaseStorage {
|
|||
if (deletedCount === 0) {
|
||||
// File doesn't exist - this is fine
|
||||
}
|
||||
|
||||
// Transaction durability barrier: an unlink is durable only once its parent
|
||||
// directory is fsync'd. Record the dir (root-relative; '.' for a top-level
|
||||
// object) so flushWriteBarrier can sync it before the counter advances.
|
||||
this.writeBarrierDeleteDirs?.add(path.dirname(pathStr))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -630,6 +653,61 @@ export class FileSystemStorage extends BaseStorage {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin a transaction durability barrier: start recording every canonical
|
||||
* object write and delete so {@link flushWriteBarrier} can fsync them before
|
||||
* the generation counter advances. Resets unconditionally, discarding any
|
||||
* tracking left by a transaction that aborted without flushing.
|
||||
*
|
||||
* @see GenerationStorage.beginWriteBarrier
|
||||
*/
|
||||
public beginWriteBarrier(): void {
|
||||
this.writeBarrierPaths = new Set()
|
||||
this.writeBarrierDeleteDirs = new Set()
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the transaction durability barrier: fsync every canonical write since
|
||||
* {@link beginWriteBarrier} (file contents AND the rename directory entries,
|
||||
* via {@link syncRawObjects}), then fsync the parent directory of every
|
||||
* canonical delete so the unlinks are durable too. Clears the tracking. After
|
||||
* this resolves, the transaction's entire canonical footprint is on disk, so
|
||||
* the generation counter/manifest can be advanced without risking a
|
||||
* counter-ahead-of-state torn store on a hard kill.
|
||||
*
|
||||
* @see GenerationStorage.flushWriteBarrier
|
||||
*/
|
||||
public async flushWriteBarrier(): Promise<void> {
|
||||
const paths = this.writeBarrierPaths
|
||||
const deleteDirs = this.writeBarrierDeleteDirs
|
||||
this.writeBarrierPaths = null
|
||||
this.writeBarrierDeleteDirs = null
|
||||
|
||||
if (paths && paths.size > 0) {
|
||||
// syncRawObjects fsyncs each file and its parent directory.
|
||||
await this.syncRawObjects([...paths])
|
||||
}
|
||||
|
||||
if (deleteDirs && deleteDirs.size > 0) {
|
||||
for (const relDir of deleteDirs) {
|
||||
const dirPath = path.join(this.rootDir, relDir)
|
||||
let handle: any
|
||||
try {
|
||||
handle = await fs.promises.open(dirPath, 'r')
|
||||
} catch {
|
||||
continue // directory vanished or platform disallows opening dirs
|
||||
}
|
||||
try {
|
||||
await handle.sync()
|
||||
} catch {
|
||||
// Some platforms/filesystems reject directory fsync — best effort.
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one line to `_system/tx-log.jsonl`. Plain `appendFile` — the
|
||||
* tx-log is the one append-in-place file in the store (and is byte-copied,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue