fix(workspace): make deletion recoverable

This commit is contained in:
NI0317
2026-07-27 14:37:04 +08:00
parent 187cf6f804
commit 7bd96af5eb
13 changed files with 294 additions and 20 deletions

View File

@@ -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', () => {