brainy/tests/integration/vfs-cow-blob-adoption.test.ts

150 lines
7 KiB
TypeScript
Raw Normal View History

fix: recover VFS content blobs stranded by a 7→8 upgrade, in place on open The 7.x branch system stored Virtual Filesystem content as blobs in its copy-on-write area (`_cow/`). 8.0 removed that system and stores content blobs in the content-addressed store (`_cas/`), but the one-time layout migration only moves entities — it never adopted the `_cow/` content blobs. A store that used the VFS was left with every VFS-backed read throwing "Blob metadata not found" and its pages 500ing, with no first-party recovery and the pre-upgrade backup already removed on entity-migration "success". Add an on-open recovery pass (`autoAdoptLegacyVfsBlobsIfNeeded`) that runs right after the layout migration and adopts every orphaned `_cow/` blob into `_cas/` — copying both the bytes and the metadata via the raw object primitives, idempotently and non-destructively (the `_cow/` originals are never deleted). It is a separate phase, not folded into the migration, so it also heals a store already upgraded by an earlier 8.0.x that stranded the blobs (whose layout- migration marker is stamped): it is gated on the presence of `_cow/` and its own `_system/vfs-blob-adoption.json` marker. Filesystem-only; native-8.0 and non-VFS stores no-op on a cheap existence check. - `BaseStorage.adoptLegacyCowBlobs()` — the scan/copy primitive, returning `{ cowBlobs, adopted, alreadyPresent, incomplete }`; skips (does not half-adopt) a blob missing its bytes or metadata. - `brain.vfs.adoptOrphanedBlobs()` — the explicit force path; self-initializes. - Backup retention: the automatic pre-upgrade backup is now kept if any blob can't be fully adopted (`incomplete > 0`), instead of being removed on entity-migration success alone — a blob-parity gate the migration signal cannot provide. Adds an end-to-end integration test (storage-level adopt with idempotency and incomplete handling; self-heal on reopen; the explicit API) and an upgrade guide.
2026-07-07 12:23:36 -07:00
/**
* @module tests/integration/vfs-cow-blob-adoption
* @description P0 recovery: a 78 upgrade left VFS content blobs in the removed
* branch system's copy-on-write area (`_cow/`) instead of the 8.0
* content-addressed store (`_cas/`), so a VFS read of a stranded blob throws
* "Blob metadata not found" and every page backed by it 500s.
*
* Proves the cure end to end:
* - `storage.adoptLegacyCowBlobs()` copies each orphaned `_cow/` blob (bytes +
* metadata) into `_cas/` verbatim, idempotently, non-destructively, and
* reports an `incomplete` blob (bytes without metadata) rather than
* half-adopting it.
* - a real brain that upgraded into this state SELF-HEALS on its next open
* (auto-adoption) the stranded VFS file reads correctly again with no
* operator action.
* - `brain.vfs.adoptOrphanedBlobs()` is the explicit equivalent.
*/
import { describe, it, expect, afterEach } 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 { FileSystemStorage } from '../../src/storage/adapters/fileSystemStorage.js'
const tmpDirs: string[] = []
function mkTmp(): string {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-cow-adopt-'))
tmpDirs.push(d)
return d
}
/** Release a raw FileSystemStorage: flush, stop its timer, drop the writer lock
* so a subsequent open of the same directory is not blocked. */
async function teardownRawStorage(s: any): Promise<void> {
try { await s.flush?.() } catch { /* best effort */ }
try { s.stopFlushRequestWatcher?.() } catch { /* best effort */ }
try { await s.releaseWriterLock?.() } catch { /* best effort */ }
}
afterEach(() => {
for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true })
})
/** Move every `_cas/blob*` object into `_cow/` and delete the `_cas/` copy
* reproducing the exact on-disk state a 7.x brain leaves after the 8.0 layout
* migration collapses entities but does not adopt the copy-on-write blobs. */
async function strandCasBlobsIntoCow(dir: string): Promise<number> {
const s: any = new FileSystemStorage(dir)
await s.init()
const casKeys = (await s.listRawObjects('_cas/')).filter((k: string) =>
/(^|\/)(blob:|blob-meta:)/.test(k)
)
let moved = 0
for (const full of casKeys) {
const key = full.replace(/^_cas\//, '')
const obj = await s.readRawObject(`_cas/${key}`)
if (obj === null) continue
await s.writeRawObject(`_cow/${key}`, obj)
await s.deleteRawObject(`_cas/${key}`)
moved++
}
await teardownRawStorage(s)
return moved
}
describe('VFS _cow/ blob adoption (P0 7→8 recovery)', () => {
it('adoptLegacyCowBlobs copies bytes + metadata into _cas/, verbatim, idempotent, non-destructive', async () => {
const dir = mkTmp()
const storage: any = new FileSystemStorage(dir)
await storage.init()
const hash = 'a'.repeat(64)
const bytes = Buffer.from('the homepage payload')
// 7.x layout: the pair lives ONLY under _cow/ (blob-meta carries the refCount).
await storage.writeRawObject(`_cow/blob:${hash}`, { _binary: true, data: bytes.toString('base64') })
await storage.writeRawObject(`_cow/blob-meta:${hash}`, { hash, size: bytes.length, refCount: 3, mimeType: 'application/json' })
// Pre-adoption: _cas/ has neither → this is the "Blob metadata not found" state.
expect(await storage.readRawObject(`_cas/blob-meta:${hash}`)).toBeNull()
const r = await storage.adoptLegacyCowBlobs()
expect(r).toMatchObject({ cowBlobs: 1, adopted: 1, alreadyPresent: 0, incomplete: 0 })
// Post: _cas/ has both, metadata verbatim (refCount preserved), bytes intact.
const casMeta = await storage.readRawObject(`_cas/blob-meta:${hash}`)
expect(casMeta).toMatchObject({ hash, refCount: 3, size: bytes.length })
const casBlob = await storage.readRawObject(`_cas/blob:${hash}`)
expect(Buffer.from(casBlob.data, 'base64').toString()).toBe('the homepage payload')
// Idempotent: a second run adopts nothing, counts the pair present.
expect(await storage.adoptLegacyCowBlobs()).toMatchObject({ adopted: 0, alreadyPresent: 1 })
// Non-destructive: the _cow/ original is still there (rollback stays possible).
expect(await storage.readRawObject(`_cow/blob:${hash}`)).not.toBeNull()
// Incomplete: bytes without metadata are reported, not half-adopted.
await storage.writeRawObject(`_cow/blob:${'b'.repeat(64)}`, { _binary: true, data: 'x' })
const r3 = await storage.adoptLegacyCowBlobs()
expect(r3.incomplete).toBe(1)
expect(await storage.readRawObject(`_cas/blob:${'b'.repeat(64)}`)).toBeNull() // not adopted
await teardownRawStorage(storage)
})
it('a brain that upgraded into the stranded state self-heals on next open — the VFS page reads again', async () => {
const dir = mkTmp()
// 1. Author a VFS page (its content blob lands in _cas/).
let brain: any = new Brainy({ storage: { type: 'filesystem', path: dir }, plugins: [], silent: true })
await brain.init()
await brain.vfs.writeFile('/pages/homepage/page.json', '{"title":"Homepage"}')
expect((await brain.vfs.readFile('/pages/homepage/page.json')).toString()).toContain('Homepage')
await brain.close()
// 2. Reproduce the post-7→8 stranding: the blob is now only under _cow/.
const moved = await strandCasBlobsIntoCow(dir)
expect(moved).toBeGreaterThan(0)
// 3. Reopen: the on-open auto-adoption heals it with no operator action.
brain = new Brainy({ storage: { type: 'filesystem', path: dir }, plugins: [], silent: true })
await brain.init()
const healed = await brain.vfs.readFile('/pages/homepage/page.json')
expect(healed.toString()).toContain('Homepage')
// A subsequent open records the adoption marker and does not re-scan-heal.
await brain.close()
brain = new Brainy({ storage: { type: 'filesystem', path: dir }, plugins: [], silent: true })
await brain.init()
expect((await brain.vfs.readFile('/pages/homepage/page.json')).toString()).toContain('Homepage')
await brain.close()
})
it('brain.vfs.adoptOrphanedBlobs() is the explicit recovery equivalent', async () => {
const dir = mkTmp()
const body = JSON.stringify({ page: 'about', body: 'x'.repeat(400) })
let brain: any = new Brainy({ storage: { type: 'filesystem', path: dir }, plugins: [], silent: true })
await brain.init()
await brain.vfs.writeFile('/about.json', body)
await brain.close()
await strandCasBlobsIntoCow(dir)
// The explicit API self-initializes (like other VFS methods); init's
// auto-heal adopts, so the explicit call sees the pair present. Either way
// it reports the _cow/ blobs it accounted for, and the read is healed.
brain = new Brainy({ storage: { type: 'filesystem', path: dir }, plugins: [], silent: true })
const r = await brain.vfs.adoptOrphanedBlobs()
expect(r.cowBlobs).toBeGreaterThan(0)
expect(r.adopted + r.alreadyPresent).toBe(r.cowBlobs)
expect((await brain.vfs.readFile('/about.json')).toString()).toContain('about')
await brain.close()
})
})