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

@@ -109,6 +109,7 @@ export class WorkspaceRegistry extends Service {
this.global = domain.global
this.state = domain.global.get()
await this.recoverPendingMutation()
this.validateStoredState(this.state)
if (!this.state.initialized) {
const headers = await this.ctx.sessionPersistence.list()
@@ -218,10 +219,28 @@ export class WorkspaceRegistry extends Service {
}
const entity = new WorkspaceEntity(this.host, id, record)
this.entities.set(id, entity)
const pendingState: WorkspaceDomainState = {
...state,
pendingMutation: { operation: 'create', workspaceId: id },
}
try {
await this.setState(pendingState)
} catch (error) {
this.entities.delete(id)
throw error
}
try {
await table.put(id, record)
} catch (error) {
this.entities.delete(id)
try {
await this.setState(state)
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
`workspace '${id}' record write and pending-marker rollback both failed`,
)
}
throw error
}
@@ -232,10 +251,17 @@ export class WorkspaceRegistry extends Service {
try {
await table.delete(id)
} catch (rollbackError) {
this.entities.set(id, entity)
throw new AggregateError(
[error, rollbackError],
`workspace '${id}' was stored but its registry order and rollback both failed`,
`workspace '${id}' order write and record rollback both failed; the pending marker remains recoverable`,
)
}
try {
await this.setState(state)
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
`workspace '${id}' order write and pending-marker rollback both failed`,
)
}
throw error
@@ -251,7 +277,10 @@ export class WorkspaceRegistry extends Service {
initialized: true,
workspaceIds: state.workspaceIds.filter(workspaceId => workspaceId !== id),
}
await this.setState(nextState)
await this.setState({
...nextState,
pendingMutation: { operation: 'delete', workspaceId: id },
})
this.entities.delete(id)
try {
await this.requireTable().delete(id)
@@ -260,6 +289,10 @@ export class WorkspaceRegistry extends Service {
try {
await this.setState(state)
} catch (rollbackError) {
// The durable marker still says to finish deletion, so the cache must
// agree with that recoverable direction rather than republish a row
// absent from the persisted order.
this.entities.delete(id)
throw new AggregateError(
[error, rollbackError],
`workspace '${id}' record deletion and registry-order rollback both failed`,
@@ -267,9 +300,38 @@ export class WorkspaceRegistry extends Service {
}
throw error
}
try {
await this.setState(nextState)
} catch (error) {
// The deletion committed at the table write and was already published
// to Host streams. Keep the durable marker for startup recovery rather
// than reporting failure after the requested state became true.
this.ctx.logger.warn(
`workspace '${id}' was deleted but its pending marker could not be cleared: ${String(error)}`,
)
}
return true
}
/**
* Complete the one mutation explicitly named by durable state. Unexplained
* order/table divergence still reaches {@link validateStoredState} and
* fails loud; this path never infers provenance from shape alone.
*/
private async recoverPendingMutation(): Promise<void> {
const state = this.requireState()
const pending = state.pendingMutation
if (pending === undefined) return
if (state.workspaceIds.includes(pending.workspaceId)) {
throw new Error(
`workspace domain is inconsistent: pending ${pending.operation} workspace `
+ `'${pending.workspaceId}' is still present in registry order`,
)
}
await this.requireTable().delete(pending.workspaceId)
await this.setState({ initialized: state.initialized, workspaceIds: state.workspaceIds })
}
private async bootstrap(headers: readonly SessionHeader[]): Promise<void> {
const table = this.requireTable()
const state = this.requireState()
@@ -493,7 +555,12 @@ export class WorkspaceRegistry extends Service {
}
private enqueueOperation<T>(operation: () => Promise<T>): Promise<T> {
const result = this.operationTail.then(operation)
const result = this.operationTail.then(async () => {
// A committed delete may leave only its marker cleanup pending. Retry
// recovery before another create/delete can overwrite that provenance.
await this.recoverPendingMutation()
return await operation()
})
this.operationTail = result.then(() => {}, () => {})
return result
}