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.
This commit is contained in:
David Snelling 2026-07-10 16:43:48 -07:00
parent 4af8fb31e2
commit a3467e1f9b
13 changed files with 890 additions and 57 deletions

View file

@ -869,6 +869,148 @@ export abstract class BaseStorage extends BaseStorageAdapter {
this.generationBumpHook = hook
}
// ==========================================================================
// Temporal-blob contract (the GenerationStorage optional methods)
//
// Content blobs join the Model-B immutability model through these hooks:
// the generation store counts a history reference per before-image record
// that carries a content hash, and compaction — the ONE reclamation point —
// releases those references and physically deletes bytes only at zero live
// AND zero history references. Crash ordering is over-count-only (record
// BEFORE the record-set persists, release AFTER it is deleted), so a crash
// can leak bytes until the scrub recounts but can never reclaim bytes a
// retained generation still needs.
// ==========================================================================
/** Set when the open-time backfill/scrub could not verify history reference
* counts. While true, the temporal-blob hooks stop mutating counts and
* compaction stops reclaiming blob bytes pure leak-safe mode until a
* successful {@link scrubBlobHistoryRefCounts} restores exactness. */
private blobHistoryRefsUnverified = false
/**
* @description Extract the content-blob hashes a generation record-set
* references a pure MULTISET extraction (one entry per referencing record
* occurrence), no side effects. Only entity records can reference VFS
* content (`metadata.storage.type === 'blob'`).
* @param records - The record-set's before-image records.
* @returns The referenced hashes, duplicates preserved.
*/
public extractBlobHashesFromRecords(
records: Array<{ kind: string; metadata: unknown }>
): string[] {
const hashes: string[] = []
for (const record of records) {
if (record.kind !== 'noun') continue
const storage = (record.metadata as { storage?: { type?: string; hash?: unknown } } | null)
?.storage
if (storage?.type === 'blob' && typeof storage.hash === 'string') {
hashes.push(storage.hash)
}
}
return hashes
}
/**
* @description Record one history reference per hash occurrence (see the
* contract note above called BEFORE the referencing record-set persists).
* No-op without a blob store or while counts are unverified.
* @param hashes - Hash multiset from {@link extractBlobHashesFromRecords}.
*/
public async recordHistoryBlobReferences(hashes: string[]): Promise<void> {
if (!this.blobStorage || this.blobHistoryRefsUnverified || hashes.length === 0) return
for (const hash of hashes) {
await this.blobStorage.recordHistoryReference(hash)
}
}
/**
* @description Release one history reference per hash occurrence and
* physically reclaim any hash left with zero live AND zero history
* references compaction's blob-reclamation step (called AFTER the
* referencing record-set is deleted). No-op without a blob store or while
* counts are unverified (leak-safe: nothing is reclaimed on guesses).
* @param hashes - Hash multiset recorded when the record-set was persisted.
*/
public async releaseHistoryBlobReferences(hashes: string[]): Promise<void> {
if (!this.blobStorage || this.blobHistoryRefsUnverified || hashes.length === 0) return
for (const hash of hashes) {
await this.blobStorage.releaseHistoryReference(hash)
}
for (const hash of new Set(hashes)) {
await this.blobStorage.reclaimIfUnreferenced(hash)
}
}
/**
* @description One-time (marker-gated) backfill of blob history reference
* counts for stores whose generation history predates the temporal-blob
* contract. Runs the scrub, then stamps `_system/blob-history-refs.json` so
* later opens skip the walk. On scrub failure the store enters leak-safe
* mode (counts untouched, reclamation disabled) rather than risking a
* premature delete on wrong counts.
*/
public async backfillBlobHistoryRefCountsIfNeeded(): Promise<void> {
if (!this.blobStorage) return
const MARKER = '_system/blob-history-refs.json'
try {
const marker = (await this.readObjectFromPath(MARKER)) as { version?: number } | null
if (marker?.version === 1) return
} catch {
// no marker — proceed to scrub
}
try {
await this.scrubBlobHistoryRefCounts()
await this.writeObjectToPath(MARKER, { version: 1, verifiedAt: new Date().toISOString() })
} catch (err) {
this.blobHistoryRefsUnverified = true
console.error(
'[Brainy] blob history-reference backfill failed — temporal-blob ' +
'reclamation disabled for this session (leak-safe); history reads ' +
'are unaffected. Re-open to retry.',
err
)
}
}
/**
* @description Recount every blob's history references from the actual
* generation record-sets and set the counts ABSOLUTELY (uncounted blobs are
* zeroed) the idempotent repair that restores exactness after any crash
* that over-counted. O(history records + stored blobs).
* @returns Blobs counted and records walked, for observability.
*/
public async scrubBlobHistoryRefCounts(): Promise<{ blobs: number; records: number }> {
if (!this.blobStorage) return { blobs: 0, records: 0 }
const counts = new Map<string, number>()
let records = 0
let paths: string[] = []
try {
paths = await this.listObjectsUnderPath('_generations')
} catch {
paths = [] // no history yet
}
for (const p of paths) {
if (!p.includes('/prev/')) continue
const record = (await this.readObjectFromPath(p)) as
| { kind?: string; metadata?: unknown }
| null
if (!record) continue
records++
for (const hash of this.extractBlobHashesFromRecords([
{ kind: record.kind ?? '', metadata: record.metadata }
])) {
counts.set(hash, (counts.get(hash) ?? 0) + 1)
}
}
const allHashes = await this.blobStorage.listHashes()
for (const hash of allHashes) {
await this.blobStorage.setHistoryRefCount(hash, counts.get(hash) ?? 0)
}
this.blobHistoryRefsUnverified = false
return { blobs: allHashes.length, records }
}
/**
* Read a raw object at a storage-root-relative path. Bypasses the write
* cache (record-layer files are written through

View file

@ -48,8 +48,19 @@ export interface BlobMetadata {
compression: 'none' | 'zstd'
/** Creation timestamp (epoch ms). */
createdAt: number
/** Number of logical references to this blob (deduplicated writes). */
/** Number of LIVE logical references to this blob (deduplicated writes). */
refCount: number
/**
* Number of persisted generation record-sets (Model-B before-images) that
* reference this hash the blob's membership in the temporal history.
* Bytes are physically reclaimed only when BOTH counts are zero, and only
* by history compaction: live references protect the present, history
* references protect every `asOf` read inside the retention window (pins
* ride generation pinning, which compaction already respects). Absent on
* metas written before the temporal contract existed (treated as 0; the
* one-time open-time backfill makes legacy stores exact).
*/
historyRefCount?: number
}
/**
@ -148,7 +159,9 @@ interface CacheEntry {
* @example
* const hash = await blobStorage.write(buffer, { mimeType: 'image/png' })
* const bytes = await blobStorage.read(hash) // verified against the hash
* await blobStorage.delete(hash) // decrements refCount first
* await blobStorage.release(hash) // drop one LIVE reference
* // bytes are physically reclaimed only by history compaction, once no
* // live reference AND no in-window generation references the hash
*/
export class BlobStorage {
private adapter: BlobStoreAdapter
@ -356,31 +369,107 @@ export class BlobStorage {
}
/**
* @description Drop one reference to the blob. The stored bytes and
* metadata are physically deleted only when the reference count reaches
* zero deduplicated content shared by other writers survives.
* @description Drop one LIVE reference to the blob. Never deletes bytes
* blob content is immutable under the temporal model, exactly like every
* other record: a past generation's `asOf` read may still need these bytes
* even when no live file references them. Physical reclamation happens in
* ONE place only history compaction via {@link reclaimIfUnreferenced},
* once no live reference AND no retained generation references the hash.
*
* @param hash - The blob's SHA-256 hash.
*/
async delete(hash: string): Promise<void> {
// Decrement-then-maybe-remove must be atomic per hash: without the lock,
// a concurrent write() could re-reference the content between our
// reaching zero and the physical delete — removing bytes a live
// reference still needs.
async release(hash: string): Promise<void> {
await this.hashLocks.runExclusive(hash, async () => {
const refCount = await this.decrementRefCount(hash)
await this.decrementRefCount(hash)
})
}
// Only delete if no references remain
if (refCount > 0) {
/**
* @description Record that one persisted generation record-set references
* this hash (called by the commit path BEFORE the record-set is written
* a crash between the two can only over-count, which leaks until the scrub
* recounts; it can never under-count, which would risk premature deletion).
* A missing meta (bytes never stored or already gone) is skipped with a
* warning counting it could not make its bytes readable.
* @param hash - The blob's SHA-256 hash.
*/
async recordHistoryReference(hash: string): Promise<void> {
await this.hashLocks.runExclusive(hash, async () => {
const metadata = await this.getMetadata(hash)
if (!metadata) {
console.warn(
`[BlobStorage] history reference recorded for absent blob ${hash} — skipped`
)
return
}
metadata.historyRefCount = (metadata.historyRefCount ?? 0) + 1
await this.adapter.put(`blob-meta:${hash}`, Buffer.from(JSON.stringify(metadata)))
})
}
/**
* @description Drop one history reference (called by compaction AFTER the
* referencing generation record-set is deleted the safe ordering: a crash
* between the two over-counts, never under-counts). Floored at zero.
* @param hash - The blob's SHA-256 hash.
*/
async releaseHistoryReference(hash: string): Promise<void> {
await this.hashLocks.runExclusive(hash, async () => {
const metadata = await this.getMetadata(hash)
if (!metadata) return
metadata.historyRefCount = Math.max(0, (metadata.historyRefCount ?? 0) - 1)
await this.adapter.put(`blob-meta:${hash}`, Buffer.from(JSON.stringify(metadata)))
})
}
/**
* @description Physically delete the blob's bytes + metadata IFF nothing
* references it: zero live references AND zero history references. The one
* reclamation point in the system, invoked by history compaction after it
* releases the reclaimed generations' references. Atomic per hash.
* @param hash - The blob's SHA-256 hash.
* @returns `true` when the bytes were reclaimed.
*/
async reclaimIfUnreferenced(hash: string): Promise<boolean> {
return this.hashLocks.runExclusive(hash, async () => {
const metadata = await this.getMetadata(hash)
if (!metadata) return false
if ((metadata.refCount ?? 0) > 0 || (metadata.historyRefCount ?? 0) > 0) {
return false
}
await this.adapter.delete(`blob:${hash}`)
await this.adapter.delete(`blob-meta:${hash}`)
this.removeFromCache(hash)
return true
})
}
/**
* @description Set the history reference count to an absolute value the
* backfill/scrub primitive (recounts derived from the actual generation
* records replace whatever the incremental counters hold). Idempotent.
* @param hash - The blob's SHA-256 hash.
* @param count - The exact history reference count.
*/
async setHistoryRefCount(hash: string, count: number): Promise<void> {
await this.hashLocks.runExclusive(hash, async () => {
const metadata = await this.getMetadata(hash)
if (!metadata) return
metadata.historyRefCount = Math.max(0, count)
await this.adapter.put(`blob-meta:${hash}`, Buffer.from(JSON.stringify(metadata)))
})
}
/**
* @description Enumerate every stored blob hash (from the metadata keys)
* the backfill/scrub walk. O(stored blobs).
* @returns All hashes with a stored metadata record.
*/
async listHashes(): Promise<string[]> {
const keys = await this.adapter.list('blob-meta:')
return keys.map((k) => k.slice('blob-meta:'.length))
}
/**
* @description Read a blob's metadata without reading its bytes.
* @param hash - The blob's SHA-256 hash.