fix: 7→8 migration preserves branch-scoped non-entity state instead of deleting it
The one-time 7→8 layout migration rescues the head branch's entities (branches/<head>/entities/* → entities/*), then drained the entire branches/<head>/ directory. Any durable state a 7.x engine wrote under the head branch OUTSIDE entities/ — a branch-scoped index, blob area, or field registry — would be silently deleted by that drain, the same failure class as the VFS content blobs a 7.x store kept in _cow/. Guard it: after the entity move, list what remains under branches/<head>/ and exclude entities/. If anything survives, it is non-entity durable state, so PRESERVE the branch (skip removeRawPrefix) and warn loudly with the leftover keys — recoverable, not lost. A clean branch (only the moved entities) still drains exactly as before. Adds a PARITY GUARD test to the 7→8 migration suite.
This commit is contained in:
parent
9d5eb33c97
commit
a93bb4e85f
2 changed files with 61 additions and 4 deletions
|
|
@ -13014,16 +13014,38 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// FINALIZE: mark migrated (makes re-open a no-op), then drain the head
|
// FINALIZE: mark migrated (makes re-open a no-op), then drain the head
|
||||||
// branch (its entities moved; the rest is stale 7.x derived state).
|
// branch. Non-head branches under branches/ are 7.x version history 8.0's
|
||||||
// Non-head branches under branches/ are 7.x version history 8.0's MVCC does
|
// MVCC does not import — they are left in place.
|
||||||
// not import — they are left in place.
|
|
||||||
await probe.writeRawObject('_system/migration-layout.json', {
|
await probe.writeRawObject('_system/migration-layout.json', {
|
||||||
layout: 'flat-v8',
|
layout: 'flat-v8',
|
||||||
version: 8,
|
version: 8,
|
||||||
fromBranch: head,
|
fromBranch: head,
|
||||||
entitiesMoved: moved
|
entitiesMoved: moved
|
||||||
})
|
})
|
||||||
await probe.removeRawPrefix(`branches/${head}`)
|
|
||||||
|
// Parity guard (defense-in-depth; the VFS `_cow/` stranding lesson). The
|
||||||
|
// entity move deleted every `branches/<head>/entities/*` key, so anything
|
||||||
|
// STILL under `branches/<head>/` is non-entity durable state a 7.x engine
|
||||||
|
// wrote outside `entities/` (branch-scoped blobs, an index dir, a field
|
||||||
|
// registry). Draining it unconditionally would silently DELETE it — the
|
||||||
|
// same failure class as the VFS content blobs stranded in `_cow/`. So
|
||||||
|
// drain only a clean branch (nothing but the moved entities); if any
|
||||||
|
// non-entity object survives, PRESERVE the branch (skip the drain) so the
|
||||||
|
// state stays recoverable, and surface it loudly.
|
||||||
|
const residual = (
|
||||||
|
await probe.listRawObjects(`branches/${head}`)
|
||||||
|
).filter((k: string) => !k.startsWith(`branches/${head}/entities/`))
|
||||||
|
if (residual.length === 0) {
|
||||||
|
await probe.removeRawPrefix(`branches/${head}`)
|
||||||
|
} else if (!this.config.silent) {
|
||||||
|
const sample = residual.slice(0, 8).join(', ')
|
||||||
|
console.warn(
|
||||||
|
`[brainy] 7→8 migration preserved ${residual.length} non-entity object(s) under ` +
|
||||||
|
`branches/${head}/ — branch-scoped durable state written outside entities/. The ` +
|
||||||
|
`branch was NOT drained, so this state is recoverable; inspect it before removing ` +
|
||||||
|
`branches/ manually. Keys: ${sample}${residual.length > 8 ? ', …' : ''}`
|
||||||
|
)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (canLock && typeof lockable.releaseWriterLock === 'function') {
|
if (canLock && typeof lockable.releaseWriterLock === 'function') {
|
||||||
await lockable.releaseWriterLock()
|
await lockable.releaseWriterLock()
|
||||||
|
|
|
||||||
|
|
@ -191,4 +191,39 @@ describe('7.x → 8.0 layout migration', () => {
|
||||||
expect(await captureReference(reopened)).toEqual(ref)
|
expect(await captureReference(reopened)).toEqual(ref)
|
||||||
expect(fs.existsSync(path.join(dir, 'branches'))).toBe(false)
|
expect(fs.existsSync(path.join(dir, 'branches'))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('PARITY GUARD: branch-scoped non-entity state survives migration (not silently drained)', async () => {
|
||||||
|
const dir = freshLegacyDir()
|
||||||
|
|
||||||
|
// Simulate a 7.x engine that wrote durable state under the HEAD branch
|
||||||
|
// OUTSIDE entities/ (a branch-scoped index/blob/registry). The migration
|
||||||
|
// rescues entities/ only; the guard must PRESERVE the rest rather than let
|
||||||
|
// removeRawPrefix silently delete it — the VFS `_cow/` stranding lesson.
|
||||||
|
const raw: any = new FileSystemStorage(dir)
|
||||||
|
await raw.init()
|
||||||
|
await raw.writeRawObject('branches/main/_legacy_engine/state', { keep: 'me', n: 42 })
|
||||||
|
await raw.flush?.()
|
||||||
|
raw.stopFlushRequestWatcher?.()
|
||||||
|
await raw.releaseWriterLock?.()
|
||||||
|
|
||||||
|
// Migrate (entities collapse to the root as usual).
|
||||||
|
const brain = await openBrain(dir)
|
||||||
|
brains.push(brain)
|
||||||
|
await brain.init()
|
||||||
|
expect(await captureReference(brain)).toEqual(reference)
|
||||||
|
await brain.close()
|
||||||
|
brains.splice(brains.indexOf(brain), 1)
|
||||||
|
|
||||||
|
// The non-entity branch-scoped state is PRESERVED (branch NOT drained) —
|
||||||
|
// recoverable, not lost.
|
||||||
|
const check: any = new FileSystemStorage(dir)
|
||||||
|
await check.init()
|
||||||
|
expect(await check.readRawObject('branches/main/_legacy_engine/state')).toMatchObject({
|
||||||
|
keep: 'me',
|
||||||
|
n: 42
|
||||||
|
})
|
||||||
|
await check.flush?.()
|
||||||
|
check.stopFlushRequestWatcher?.()
|
||||||
|
await check.releaseWriterLock?.()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue