brainy/tests/integration/ifabsent-upsert-blob-concurrency.test.ts

213 lines
8.5 KiB
TypeScript
Raw Normal View History

fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
/**
* @module tests/integration/ifabsent-upsert-blob-concurrency
* @description The 8.0.15 CAS fix's fast-follow: the two remaining
* check-then-act races of the same class, found in the post-fix sweep.
*
* 1. `add({ ifAbsent })` / `add({ upsert })` the absence check ran before
* the commit mutex, so N concurrent same-id creates could ALL pass it and
* all write (the second overwriting the first, violating ifAbsent's
* "no overwrite" contract and upsert's "merge, never clobber" contract).
* Fixed with the same conditional-commit primitive as `ifRev`: the insert
* leg carries a must-be-absent precondition; a loser resolves to skip
* (ifAbsent) or merge (upsert) instead of overwriting.
*
* 2. `BlobStorage` reference counts `write()`'s dedup decision and
* `delete()`'s decrement-then-remove were unserialized read-modify-writes
* over `blob-meta:<hash>`; concurrent same-content writes could lose
* references, turning a later delete into premature removal of bytes
* another file still needs. Fixed with a per-hash mutex.
*
* Deterministic concurrency assertions:
* - ifAbsent storm: the generation counter advances by EXACTLY 1
* (pre-fix: one generation per losing writer), `_rev` stays 1.
* - upsert storm: final `_rev === N` (1 create + N1 merges, each with an
* honest bump; pre-fix a late insert reset `_rev` to 1 and clobbered).
* - blob storm: N same-content writes refCount === N; N deletes gone,
* with the bytes readable until the last reference drops.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { Brainy } from '../../src/brainy.js'
import { NounType } from '../../src/types/graphTypes.js'
import { BlobStorage } from '../../src/storage/blobStorage.js'
describe('ifAbsent/upsert insert race + blob refCount (conditional commit fast-follow)', () => {
let dir: string
let brain: any
let seq = 0
const freshId = (): string =>
`00000000-0000-4000-8000-${(++seq).toString(16).padStart(12, '0')}`
beforeAll(async () => {
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-upsert-race-'))
brain = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
dimensions: 384,
silent: true
})
await brain.init()
})
afterAll(async () => {
await brain.close()
fs.rmSync(dir, { recursive: true, force: true })
})
it('N concurrent add({ifAbsent}) → exactly ONE write (generation +1), no overwrite', async () => {
const id = freshId()
const genBefore = brain.generationStore.generation()
const ids = await Promise.all(
Array.from({ length: 8 }, (_, i) =>
brain.add({
id,
ifAbsent: true,
data: `contender ${i}`,
type: NounType.Thing,
metadata: { writer: i }
})
)
)
// Every caller resolves to the same canonical id.
expect(new Set(ids).size).toBe(1)
// THE contract: exactly one write landed. Pre-fix every losing caller
// also wrote (its own generation), silently overwriting the winner.
expect(brain.generationStore.generation()).toBe(genBefore + 1)
const after = await brain.get(id)
expect(after._rev).toBe(1)
expect(typeof after.metadata.writer).toBe('number')
})
it('sequential ifAbsent semantics unchanged: existing entity is returned untouched', async () => {
const id = freshId()
await brain.add({ id, data: 'original', type: NounType.Thing, metadata: { keep: true } })
const returned = await brain.add({
id,
ifAbsent: true,
data: 'impostor',
type: NounType.Thing,
metadata: { keep: false }
})
expect(returned).toBe(id)
const after = await brain.get(id)
expect(after.metadata.keep).toBe(true)
expect(after.data).toBe('original')
})
it('N concurrent add({upsert}) on an absent id → 1 create + N-1 merges (final _rev === N)', async () => {
const id = freshId()
await Promise.all(
Array.from({ length: 8 }, (_, i) =>
brain.add({
id,
upsert: true,
data: 'shared upsert target',
type: NounType.Thing,
metadata: { [`k${i}`]: true }
})
)
)
const after = await brain.get(id)
// Exactly one insert (rev 1) + seven merging update()s, each with an
// honest monotonic bump. Pre-fix a losing insert restamped _rev to 1 and
// destroyed every merge that had already applied.
expect(after._rev).toBe(8)
// The entity survived as ONE identity: createdAt from the single create,
// and at least the last-applied merge's key present.
expect(Object.keys(after.metadata).filter((k) => k.startsWith('k')).length)
.toBeGreaterThanOrEqual(1)
})
it('sequential upsert semantics unchanged: existing → merge, absent → create', async () => {
const id = freshId()
await brain.add({ id, upsert: true, data: 'v1', type: NounType.Thing, metadata: { a: 1 } })
expect((await brain.get(id))._rev).toBe(1) // created
// add() requires data or vector even on the merge path — supply a vector
// and no data, so `data` preservation is still observable.
const vec = Array.from({ length: 384 }, (_, i) => (i % 7) / 7 - 0.5)
await brain.add({ id, upsert: true, vector: vec, type: NounType.Thing, metadata: { b: 2 } })
const after = await brain.get(id)
expect(after._rev).toBe(2) // merged, not overwritten
expect(after.metadata.a).toBe(1)
expect(after.metadata.b).toBe(2)
expect(after.data).toBe('v1') // unsupplied field preserved
})
})
describe('BlobStorage refCount is exact under concurrency (per-hash mutex)', () => {
/** Minimal in-memory adapter — the real interface, no mocks of behavior. */
function memAdapter() {
const kv = new Map<string, Buffer>()
return {
get: async (k: string) => kv.get(k),
put: async (k: string, v: Buffer) => void kv.set(k, v),
delete: async (k: string) => void kv.delete(k),
list: async (prefix: string) =>
[...kv.keys()].filter((k) => k.startsWith(prefix))
}
}
it('N concurrent writes of IDENTICAL content → refCount === N (no lost references)', async () => {
const blobs = new BlobStorage(memAdapter() as any)
const payload = Buffer.from('identical content stored by N concurrent writers')
const hashes = await Promise.all(
Array.from({ length: 10 }, () => blobs.write(payload))
)
expect(new Set(hashes).size).toBe(1)
const meta = await blobs.getMetadata(hashes[0])
// Pre-fix: concurrent writers raced the dedup check — several wrote
// refCount:1 over each other and increments were lost.
expect(meta?.refCount).toBe(10)
})
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
it('references drop exactly; bytes survive live-zero (temporal immutability) until reclaim', async () => {
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
const blobs = new BlobStorage(memAdapter() as any)
const payload = Buffer.from('shared bytes, two referencing files')
const hash = await blobs.write(payload)
await blobs.write(payload) // second reference (concurrent-equivalent path)
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
await blobs.release(hash) // drop one reference
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
expect((await blobs.read(hash)).toString()).toBe(payload.toString()) // still readable
expect((await blobs.getMetadata(hash))?.refCount).toBe(1)
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
await blobs.release(hash) // last LIVE reference — bytes still exist (history may need them)
expect(await blobs.has(hash)).toBe(true)
expect((await blobs.getMetadata(hash))?.refCount).toBe(0)
// Reclamation is compaction's job: zero-zero → physically removed.
expect(await blobs.reclaimIfUnreferenced(hash)).toBe(true)
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
expect(await blobs.has(hash)).toBe(false)
await expect(blobs.read(hash)).rejects.toThrow()
})
it('interleaved write/delete storm converges to an exact count', async () => {
const blobs = new BlobStorage(memAdapter() as any)
const payload = Buffer.from('storm payload')
const hash = BlobStorage.hash(payload)
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
// 12 writes and 5 releases racing: net 7 references, blob alive.
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
await Promise.all([
...Array.from({ length: 12 }, () => blobs.write(payload)),
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
...Array.from({ length: 5 }, () => blobs.release(hash))
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
])
const meta = await blobs.getMetadata(hash)
feat: temporal VFS — file content joins the Model-B immutability model The temporal model had a hole exactly where files were concerned: every entity write is an immutable generation with before-images, but VFS content BYTES lived under an eager refCount GC left over from the pre-8.0 design — unlink could physically destroy bytes that in-window history still referenced, and overwrite never released the old hash at all (an unbounded silent leak whose accidental byproduct was the only thing "preserving" history). Reading the past could therefore return a stale field, a dangling hash, or nothing, depending on luck. Fix: blob reclamation becomes a HISTORY decision instead of a LIVENESS decision. Each blob's metadata now carries historyRefCount alongside the live refCount: - The commit seam counts one history reference per persisted before-image record carrying a content hash (commitTransaction staging and the group-commit flush), recorded BEFORE the record-set persists and carried in the generation delta (blobHashes — always present on new deltas, so compaction only falls back to reading records for pre-contract generations). An aborted transaction compensates best-effort. - unlink/rmdir/overwrite drop ONLY the live reference (BlobStorage.delete → release; overwrite finally releases the superseded hash — cancelling the dedup increment on same-content rewrites and closing the leak), and only AFTER the canonical mutation commits, so a failed delete can never leave a live file whose bytes compaction might reclaim. - History compaction is the ONE reclamation point: after deleting a generation's record-set it releases that set's references and physically reclaims any hash at zero live AND zero history references. Pins are exempt automatically. Crash ordering is over-count-only in every path (record before persist, release after delete), so a crash can leak until the scrub recounts but can never reclaim bytes a retained generation needs. scrubBlobHistoryRefCounts() restores exactness; existing stores get a one-time marker-gated backfill on open, failing into leak-safe mode (reclamation disabled) rather than guessing. On top of the protected history, the temporal API the generational model always implied: - vfs.readFile(path, { asOf }) — the exact bytes as of a generation or Date, materialized from the history (pinned view released so compaction is never blocked by a read). - vfs.history(path) — FileVersion[] ascending ({ generation, timestamp, hash, size, mimeType? }), the newest entry being the live state. - Overwrites now refresh the file entity's data/embedding text — semantic search and the data field previously served the FIRST version's text forever (the stale-field defect a consumer's incident recovery depended on by luck). Integration suite (temporal-vfs.test.ts): per-version exact reads + history listing, leak-fix + history protection on overwrite, rm keeps bytes readable, compaction reclaims past-window bytes and preserves in-window (including the cross-file dedup case where an old file's history and a newer file's removal share one hash), data freshness, and scrub exactness.
2026-07-10 16:43:48 -07:00
// Releases against a not-yet-written hash floor at zero, so the net can
// only be >= 12 - 5. The exactness we require: counts are never LOST
// (each landed write is represented).
fix: atomic ifAbsent/upsert inserts + exact blob reference counts under concurrency The fast-follow to the ifRev CAS fix: the sweep for the same check-then-act class found two more instances, both now closed with the same discipline — the decision runs at the serialization point that guards the apply. add({ifAbsent}) / add({upsert}): the absence check ran before the commit mutex, so N concurrent same-id creates could all pass it and all write — the second silently overwriting the first, violating ifAbsent's "return the existing id WITHOUT writing" contract and upsert's merge-never-clobber contract. The insert leg now carries a must-be-absent commit precondition (the same conditional-commit primitive ifRev uses), verified under the commit mutex against the authoritative before-image. A losing caller takes its documented resolution instead of overwriting: ifAbsent returns the existing id with zero writes; upsert merges into the now-existing entity via update() (shared param mapping in upsertMergeParams so the planning-time branch and the conflict retry can never drift), with a bounded retry if a concurrent delete lands between the conflict and the merge. The planning-time checks remain as fast-fails. transact()'s batch-level ifAbsent/upsert keep planning-time semantics (documented, converges to a valid entity). BlobStorage: write()'s dedup decision (exists → increment refCount, absent → create at 1) and delete()'s decrement-then-remove were unserialized read-modify-writes over blob-meta:<hash>. Concurrent writes of identical content could lose references, so a later delete removed bytes another file still referenced (data loss), or leaked unreferenced blobs. All reference-count-bearing mutations now serialize through a per-hash InMemoryMutex — distinct content never contends; incrementRefCount/ decrementRefCount are documented lock-assumed internals. Both fixes are complete by construction in-process: storage enforces single-writer-per-directory, so the process is the whole concurrency domain. Tests (tests/integration/ifabsent-upsert-blob-concurrency.test.ts): 8-way ifAbsent storm advances the store by exactly one generation with _rev 1; 8-way upsert storm on an absent id yields one create + seven merges (_rev === 8); sequential ifAbsent/upsert semantics pinned unchanged; N identical concurrent blob writes → refCount === N; the blob survives until the true last reference drops; interleaved write/delete storm never loses a landed reference.
2026-07-08 15:13:26 -07:00
expect(meta?.refCount).toBeGreaterThanOrEqual(7)
expect(await blobs.has(hash)).toBe(true)
})
})