fix(workspace): make deletion recoverable
This commit is contained in:
@@ -89,7 +89,7 @@ async function storageContext(pool: MemoryMediaPool, backend: StorageBackend = n
|
||||
/** Backend wrapper that injects one selected bootstrap write failure. */
|
||||
function selectiveFailureBackend(
|
||||
pool: MemoryMediaPool,
|
||||
failure: { putAt?: number; deleteAt?: number; globalAt?: number },
|
||||
failure: { putAt?: number; deleteAt?: number; globalAt?: number | readonly number[] },
|
||||
): StorageBackend {
|
||||
const inner = new MemoryStorageBackend(pool)
|
||||
let puts = 0
|
||||
@@ -113,7 +113,8 @@ function selectiveFailureBackend(
|
||||
},
|
||||
setGlobal: async (value) => {
|
||||
globals += 1
|
||||
if (globals === failure.globalAt) throw new Error('selected bootstrap marker failure')
|
||||
const failAt = Array.isArray(failure.globalAt) ? failure.globalAt : [failure.globalAt]
|
||||
if (failAt.includes(globals)) throw new Error('selected bootstrap marker failure')
|
||||
await unit.setGlobal(value)
|
||||
},
|
||||
close: () => unit.close(),
|
||||
@@ -394,19 +395,34 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
|
||||
it('rolls back the provisional cache when the record write fails', async () => {
|
||||
const dir = await makeDir('write-failure')
|
||||
const result = await harness()
|
||||
result.pool.failNextWrites = 1
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/injected/)
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { putAt: 1 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap put failure/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
expect(await result.registry.create(dir)).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not publish a Workspace when its pending marker cannot be written', async () => {
|
||||
const dir = await makeDir('pending-marker-write-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/selected bootstrap marker failure/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')?.size ?? 0).toBe(0)
|
||||
})
|
||||
|
||||
it('rolls back a record when registry-order persistence fails', async () => {
|
||||
const dir = await makeDir('order-write-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2 }),
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 3 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toThrow(/marker failure/)
|
||||
expect(result.registry.list()).toEqual([])
|
||||
@@ -418,12 +434,38 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 2, deleteAt: 1 }),
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 3, deleteAt: 1 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(pool.media.get('workspace')!.tables.get('workspaces')!.size).toBe(1)
|
||||
})
|
||||
|
||||
it('reports a record write and pending-marker rollback failure together', async () => {
|
||||
const dir = await makeDir('record-marker-rollback-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { putAt: 1, globalAt: 3 }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(storedState(pool)).toMatchObject({
|
||||
pendingMutation: { operation: 'create' },
|
||||
})
|
||||
})
|
||||
|
||||
it('reports an order write and pending-marker rollback failure together', async () => {
|
||||
const dir = await makeDir('order-marker-rollback-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: [3, 4] }),
|
||||
})
|
||||
await expect(result.registry.create(dir)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(storedState(pool)).toMatchObject({
|
||||
pendingMutation: { operation: 'create' },
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes only the registration and leaves its directory and session headers untouched', async () => {
|
||||
const dir = await makeDir('delete-registration')
|
||||
const result = await harness({ sessions: [header('kept-session', dir)] })
|
||||
@@ -440,6 +482,11 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
expect(result.list).toHaveBeenCalledTimes(1)
|
||||
expect(result.load).not.toHaveBeenCalled()
|
||||
expect(result.inspect).not.toHaveBeenCalled()
|
||||
|
||||
const reregistered = await result.registry.create(dir)
|
||||
expect(reregistered.id).not.toBe(workspace.id)
|
||||
expect(reregistered.path).toBe(dir)
|
||||
expect(reregistered.sessionIds).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls registry order and cache back when record deletion fails', async () => {
|
||||
@@ -458,11 +505,58 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
expect(storedRecord(pool, workspace.id)).toMatchObject({ path: dir })
|
||||
})
|
||||
|
||||
it('commits deletion and leaves a recoverable marker when marker cleanup fails', async () => {
|
||||
const dir = await makeDir('delete-marker-cleanup')
|
||||
const pool = new MemoryMediaPool()
|
||||
const first = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { globalAt: 5 }),
|
||||
})
|
||||
const workspace = await first.registry.create(dir)
|
||||
|
||||
await expect(first.registry.delete(workspace.id)).resolves.toBe(true)
|
||||
expect(first.registry.list()).toEqual([])
|
||||
expect(storedState(pool)).toEqual({
|
||||
initialized: true,
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'delete', workspaceId: workspace.id },
|
||||
})
|
||||
const reregistered = await first.registry.create(dir)
|
||||
expect(reregistered.id).not.toBe(workspace.id)
|
||||
expect(storedState(pool)).toEqual({
|
||||
initialized: true,
|
||||
workspaceIds: [reregistered.id],
|
||||
})
|
||||
await first.fiber.dispose()
|
||||
|
||||
const restarted = await harness({ pool })
|
||||
expect(restarted.registry.list().map(item => item.id)).toEqual([reregistered.id])
|
||||
})
|
||||
|
||||
it('keeps the failed deletion unpublished when record and order rollback both fail', async () => {
|
||||
const dir = await makeDir('delete-double-failure')
|
||||
const pool = new MemoryMediaPool()
|
||||
const result = await harness({
|
||||
pool,
|
||||
backend: selectiveFailureBackend(pool, { deleteAt: 1, globalAt: 5 }),
|
||||
})
|
||||
const workspace = await result.registry.create(dir)
|
||||
|
||||
await expect(result.registry.delete(workspace.id)).rejects.toBeInstanceOf(AggregateError)
|
||||
expect(result.registry.get(workspace.id)).toBeUndefined()
|
||||
expect(storedState(pool)).toMatchObject({
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'delete', workspaceId: workspace.id },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects table access before the registry has started', async () => {
|
||||
const dir = await makeDir('unstarted')
|
||||
const registry = new WorkspaceRegistry(new Context())
|
||||
await expect(registry.create(dir)).rejects.toThrow(/not started/)
|
||||
expect(() => registry.list()).toThrow(/not started/)
|
||||
const internals = registry as unknown as { requireTable(): unknown }
|
||||
expect(() => internals.requireTable()).toThrow(/not started/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -650,6 +744,49 @@ describe('header-validated membership projection', () => {
|
||||
internals.entities.delete(workspace.id)
|
||||
expect(() => result.registry.list()).toThrow(/references missing workspace/)
|
||||
})
|
||||
|
||||
it('recovers only an explicitly marked interrupted create or delete', async () => {
|
||||
const createDir = await makeDir('pending-create')
|
||||
const deleteDir = await makeDir('pending-delete')
|
||||
const createId = WorkspaceId('00000000-0000-4000-8000-000000000004')
|
||||
const deleteId = WorkspaceId('00000000-0000-4000-8000-000000000005')
|
||||
|
||||
const interruptedCreate = storedPool(
|
||||
[[createId, record(createDir, [])]],
|
||||
{
|
||||
initialized: true,
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'create', workspaceId: createId },
|
||||
},
|
||||
)
|
||||
const createRecovery = await harness({ pool: interruptedCreate })
|
||||
expect(createRecovery.registry.list()).toEqual([])
|
||||
expect(interruptedCreate.media.get('workspace')!.tables.get('workspaces')!.has(createId)).toBe(false)
|
||||
expect(storedState(interruptedCreate)).toEqual({ initialized: true, workspaceIds: [] })
|
||||
|
||||
const interruptedDelete = storedPool(
|
||||
[[deleteId, record(deleteDir, [])]],
|
||||
{
|
||||
initialized: true,
|
||||
workspaceIds: [],
|
||||
pendingMutation: { operation: 'delete', workspaceId: deleteId },
|
||||
},
|
||||
)
|
||||
const deleteRecovery = await harness({ pool: interruptedDelete })
|
||||
expect(deleteRecovery.registry.list()).toEqual([])
|
||||
expect(interruptedDelete.media.get('workspace')!.tables.get('workspaces')!.has(deleteId)).toBe(false)
|
||||
expect(storedState(interruptedDelete)).toEqual({ initialized: true, workspaceIds: [] })
|
||||
|
||||
const corruptPending = storedPool(
|
||||
[[deleteId, record(deleteDir, [])]],
|
||||
{
|
||||
initialized: true,
|
||||
workspaceIds: [deleteId],
|
||||
pendingMutation: { operation: 'delete', workspaceId: deleteId },
|
||||
},
|
||||
)
|
||||
await expect(harness({ pool: corruptPending })).rejects.toThrow(/still present in registry order/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace mutation and status', () => {
|
||||
|
||||
Reference in New Issue
Block a user