150 lines
7 KiB
TypeScript
150 lines
7 KiB
TypeScript
|
|
/**
|
||
|
|
* @module tests/integration/vfs-cow-blob-adoption
|
||
|
|
* @description P0 recovery: a 7→8 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()
|
||
|
|
})
|
||
|
|
})
|