Skip to content

Commit 7073315

Browse files
committed
fix: harden allocation journal recovery
1 parent 236be86 commit 7073315

12 files changed

Lines changed: 266 additions & 45 deletions

docs/adr/0021-host-simlock-managed-device-allocation.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,12 @@ authorization and attribution around the same boundary. Plain local device selec
110110

111111
Before acquisition, agent-device durably records a non-authoritative allocation operation: the
112112
logical requester, idempotency key, immutable shape request, deadline, and Host attribution when
113-
applicable. After Simlock responds, it records the allocator handle/outcome and whether Host
114-
published or cleaned it. This journal exists only to recover the Host-to-Simlock handoff. It never
115-
mirrors Simlock's queue, provisioning, lease, cleanup, health, or capacity states, and it never
116-
decides whether a device is reusable.
113+
applicable. After Simlock responds, it records the allocator handle/outcome. Before invoking an
114+
external Host binding publisher, it durably records a pending publication; publication success is
115+
then recorded separately, and recovery conservatively cleans a pending or uncertain binding before
116+
releasing the allocator lease. This journal exists only to recover the Host-to-Simlock handoff. It
117+
never mirrors Simlock's queue, provisioning, lease, cleanup, health, or capacity states, and it
118+
never decides whether a device is reusable.
117119

118120
Each logical requester is a restart-stable allocation lane; concurrent leases use distinct lanes.
119121
Replaying the same attempt key returns the same durable outcome, including a refusal. Disconnect,

src/daemon/__tests__/allocation-operation-decision.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,19 @@ test('an allocator-unknown record never gets an implicit second request', () =>
5959
test('grants publish first, and release cleans the binding before calling the allocator', () => {
6060
const record = granted();
6161
assert.equal(decideAllocationAction(record, 'continue').kind, 'publish');
62-
const published = applyAllocationTransition(record, { kind: 'binding-published' }, NOW + 3);
62+
const publishPending = applyAllocationTransition(
63+
record,
64+
{ kind: 'binding-publish-pending' },
65+
NOW + 2,
66+
);
67+
assert.equal(publishPending.status, 'applied');
68+
assert.equal(decideAllocationAction(publishPending.record, 'continue').kind, 'blocked');
69+
assert.equal(decideAllocationAction(publishPending.record, 'recover').kind, 'cleanup');
70+
const published = applyAllocationTransition(
71+
publishPending.record,
72+
{ kind: 'binding-published' },
73+
NOW + 3,
74+
);
6375
assert.equal(published.status, 'applied');
6476
const cleanup = decideAllocationAction(published.record, 'release');
6577
assert.equal(cleanup.kind, 'cleanup');

src/daemon/__tests__/allocation-operation-journal.test.ts

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from 'node:assert/strict';
22
import fs from 'node:fs';
33
import path from 'node:path';
4-
import { test } from 'vitest';
4+
import { test, vi } from 'vitest';
55
import type {
66
LeaseRequestInput,
77
ManagedDeviceAllocatorPort,
@@ -14,7 +14,10 @@ import {
1414
createAllocationOperationJournal,
1515
type AllocationBindingHooks,
1616
} from '../allocation-operation-journal.ts';
17-
import { createAllocationOperationStore } from '../allocation-operation-store.ts';
17+
import {
18+
createAllocationOperationStore,
19+
type AllocationOperationStore,
20+
} from '../allocation-operation-store.ts';
1821
import {
1922
ALLOCATION_GRANTED_STATUS,
2023
ALLOCATION_PENDING_STATUS,
@@ -76,7 +79,7 @@ function bindingHooks(
7679
const read = journal.read(binding.operation);
7780
assert.equal(read.status, 'found');
7881
assert.equal(read.record.phase.status, 'granted');
79-
assert.equal(read.record.binding, 'unpublished');
82+
assert.equal(read.record.binding, 'publish-pending');
8083
}
8184
published.push(binding.lease.id);
8285
},
@@ -108,7 +111,7 @@ test('persists intent before request and the allocator outcome before publishing
108111
const read = store.read(binding.operation);
109112
assert.equal(read.status, 'found');
110113
assert.equal(read.record.phase.status, 'granted');
111-
assert.equal(read.record.binding, 'unpublished');
114+
assert.equal(read.record.binding, 'publish-pending');
112115
},
113116
async cleanup() {},
114117
};
@@ -128,6 +131,66 @@ test('persists intent before request and the allocator outcome before publishing
128131
);
129132
});
130133

134+
test('cleans a binding after publish succeeds but its durable publication state is lost', async () => {
135+
const root = mkdtempForTestSync('allocation-operation-publish-recovery-');
136+
const baseStore = createAllocationOperationStore({
137+
allocationsDir: path.join(root, 'allocations'),
138+
});
139+
let failPublicationStateWrite = true;
140+
const store: AllocationOperationStore = Object.freeze({
141+
...baseStore,
142+
async transition(ref, expectedFence, transitionInput, nowMs) {
143+
if (failPublicationStateWrite && transitionInput.kind === 'binding-published') {
144+
failPublicationStateWrite = false;
145+
throw new Error('binding publication state write lost');
146+
}
147+
return baseStore.transition(ref, expectedFence, transitionInput, nowMs);
148+
},
149+
});
150+
const events: string[] = [];
151+
const hooks: AllocationBindingHooks = {
152+
async publish() {
153+
events.push('publish');
154+
},
155+
async cleanup() {
156+
events.push('cleanup');
157+
},
158+
};
159+
const scriptedAllocator = createScriptedManagedDeviceAllocator({
160+
instanceId: 'allocator-1',
161+
script: { requestLease: [ALLOCATION_GRANTED_STATUS], releaseLease: [undefined] },
162+
});
163+
const allocator: ManagedDeviceAllocatorPort = {
164+
...scriptedAllocator,
165+
async releaseLease(input) {
166+
events.push('release');
167+
return scriptedAllocator.releaseLease(input);
168+
},
169+
};
170+
const journal = createAllocationOperationJournal({
171+
store,
172+
allocator,
173+
binding: hooks,
174+
now: () => NOW,
175+
});
176+
177+
const publishResult = await journal.allocate(ALLOCATION_REQUEST);
178+
assert.equal(publishResult.status, 'blocked');
179+
assert.equal(
180+
publishResult.status === 'blocked' ? publishResult.reason : undefined,
181+
'persistence-failed',
182+
);
183+
assert.equal(foundRecord(journal).binding, 'publish-pending');
184+
185+
const released = await journal.release(ALLOCATION_REQUEST);
186+
assert.equal(released.status, 'released');
187+
assert.deepEqual(events, ['publish', 'cleanup', 'release']);
188+
assert.deepEqual(
189+
scriptedAllocator.calls.map((call) => call.method),
190+
['requestLease', 'releaseLease'],
191+
);
192+
});
193+
131194
test('reconciles a lost response by lookup after reconstructing the journal', async () => {
132195
const first = setup({ requestLease: [new Error('connection closed')] });
133196
const uncertain = await first.journal.allocate(ALLOCATION_REQUEST);
@@ -398,6 +461,31 @@ test('corrupt state is retained as unreadable evidence and never implies an allo
398461
assert.equal(first.allocator.calls.length, 0);
399462
});
400463

464+
test('root journal enumeration failure blocks a new allocator attempt', async () => {
465+
const setupResult = setup({
466+
requestLease: [new Error('response lost'), ALLOCATION_GRANTED_STATUS],
467+
});
468+
assert.equal((await setupResult.journal.allocate(ALLOCATION_REQUEST)).status, 'uncertain');
469+
470+
const readdir = vi.spyOn(fs, 'readdirSync').mockImplementationOnce(() => {
471+
throw Object.assign(new Error('allocation journal root is unreadable'), { code: 'EACCES' });
472+
});
473+
474+
try {
475+
const result = await setupResult.journal.allocate(
476+
input({ attemptKey: 'attempt-2', requestGeneration: 2 }),
477+
);
478+
assert.equal(result.status, 'unreadable');
479+
assert.equal(result.status === 'unreadable' ? result.reason : undefined, 'corrupt');
480+
assert.deepEqual(
481+
setupResult.allocator.calls.map((call) => call.method),
482+
['requestLease'],
483+
);
484+
} finally {
485+
readdir.mockRestore();
486+
}
487+
});
488+
401489
test('cleanup uncertainty blocks release until cleanup is retried, then allocator release is retryable', async () => {
402490
const failing = setup({ requestLease: [ALLOCATION_GRANTED_STATUS] });
403491
const hooks = bindingHooks(null, async () => {

src/daemon/__tests__/allocation-operation-record.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,12 @@ test('exactly replaying a local transition is idempotent while a stale fence is
8686
() => applyAllocationTransition(dispatched, { kind: 'binding-published' }, NOW + 3),
8787
(error: unknown) => error instanceof AppError && error.details?.reason === 'transition-invalid',
8888
);
89-
const published = apply(granted, { kind: 'binding-published' });
89+
assert.throws(
90+
() => applyAllocationTransition(granted, { kind: 'binding-published' }, NOW + 3),
91+
(error: unknown) => error instanceof AppError && error.details?.reason === 'transition-invalid',
92+
);
93+
const publishPending = apply(granted, { kind: 'binding-publish-pending' });
94+
const published = apply(publishPending, { kind: 'binding-published' });
9095
assert.equal(
9196
applyAllocationTransition(published, { kind: 'binding-published' }, NOW + 4).status,
9297
'already-applied',
@@ -102,7 +107,8 @@ test('cleanup and allocator release are ordered and remain retryable', () => {
102107
identityIncarnationId: 'incarnation-1',
103108
},
104109
});
105-
const published = apply(granted, { kind: 'binding-published' });
110+
const publishPending = apply(granted, { kind: 'binding-publish-pending' });
111+
const published = apply(publishPending, { kind: 'binding-published' });
106112
const pendingCleanup = apply(published, {
107113
kind: 'binding-cleanup-pending',
108114
message: 'binding teardown was not confirmed',

src/daemon/__tests__/allocation-operation-store.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from 'node:assert/strict';
22
import fs from 'node:fs';
33
import path from 'node:path';
4-
import { test } from 'vitest';
4+
import { test, vi } from 'vitest';
55
import { acquireProcessLock } from '@agent-device/host-kit/file';
66
import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process';
77
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
@@ -156,6 +156,28 @@ test('does not follow a symbolic-link lane directory', () => {
156156
assert.deepEqual(fs.readdirSync(outsideLane), []);
157157
});
158158

159+
test('retains an unreadable lane enumeration instead of omitting its operations', () => {
160+
const { store, record } = fixture();
161+
assert.equal(store.create(record).status, 'created');
162+
const lanePath = path.dirname(store.resolvePath(record));
163+
const originalReaddirSync = fs.readdirSync;
164+
const readdir = vi.spyOn(fs, 'readdirSync').mockImplementation((directory, options) => {
165+
if (directory.toString() === lanePath) {
166+
throw Object.assign(new Error('allocation lane is unreadable'), { code: 'EACCES' });
167+
}
168+
return originalReaddirSync(directory, options);
169+
});
170+
171+
try {
172+
const listed = store.list();
173+
assert.equal(listed.length, 1);
174+
assert.equal(listed[0]?.status, 'unreadable');
175+
assert.equal(listed[0]?.status === 'unreadable' ? listed[0].reason : undefined, 'corrupt');
176+
} finally {
177+
readdir.mockRestore();
178+
}
179+
});
180+
159181
test('a terminal allocator outcome is retained rather than deleted', async () => {
160182
const { store, record } = fixture();
161183
store.create(record);

src/daemon/allocation-operation-decision.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@ function decideGrantedCleanup(
9292
mode: AllocationDecisionMode,
9393
binding: AllocationBinding,
9494
): AllocationAction | undefined {
95-
const recoveryCleanup = mode === 'recover' && record.binding === 'cleanup-pending';
95+
const recoveryCleanup =
96+
mode === 'recover' &&
97+
(record.binding === 'publish-pending' || record.binding === 'cleanup-pending');
9698
if ((mode !== 'release' && !recoveryCleanup) || record.binding === 'cleaned') return undefined;
9799
return record.release === 'not-requested' ? { kind: 'cleanup', binding } : undefined;
98100
}
@@ -111,6 +113,12 @@ function decideGrantedBinding(
111113
): AllocationAction {
112114
if (record.binding === 'unpublished') return { kind: 'publish', binding };
113115
if (record.binding === 'published') return { kind: 'terminal' };
116+
if (record.binding === 'publish-pending') {
117+
return blocked(
118+
'not-releasable',
119+
'allocation binding publication is uncertain and requires explicit release recovery',
120+
);
121+
}
114122
if (record.binding === 'cleanup-pending') {
115123
return blocked('not-releasable', 'allocation binding requires explicit release recovery');
116124
}

src/daemon/allocation-operation-journal-action-binding.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@ export async function publishBinding(
2121
return blocked('binding-unavailable', 'managed binding publisher is not configured', record);
2222
}
2323
if (record.binding !== 'unpublished') return options.project(record);
24+
const pending = await transition(options, record, { kind: 'binding-publish-pending' });
25+
if (pending.status !== 'stored') return pending;
26+
if (signal?.aborted) return abandoned(pending.record);
2427
try {
2528
await options.binding.publish(action.binding);
2629
} catch (error) {
27-
return persistCleanupFailure(options, record, error);
30+
return persistCleanupFailure(options, pending.record, error);
2831
}
29-
const published = await transition(options, record, { kind: 'binding-published' });
32+
const published = await transition(options, pending.record, { kind: 'binding-published' });
3033
if (published.status !== 'stored') return published;
3134
return signal?.aborted ? abandoned(published.record) : options.project(published.record);
3235
}

src/daemon/allocation-operation-record-codec-state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ function decodeRefusal(value: unknown): LeaseRefusal | null {
143143

144144
export function decodeBinding(value: unknown): AllocationOperationRecord['binding'] | null {
145145
return value === 'unpublished' ||
146+
value === 'publish-pending' ||
146147
value === 'published' ||
147148
value === 'cleanup-pending' ||
148149
value === 'cleaned' ||

src/daemon/allocation-operation-record-transition.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,18 @@ function isAllocatorUncertaintyTransition(
6161
return transition.kind === 'allocator-unknown' || transition.kind === 'allocator-ambiguous';
6262
}
6363

64-
function isBindingTransition(
65-
transition: ResolvedAllocationTransition,
66-
): transition is Extract<
64+
function isBindingTransition(transition: ResolvedAllocationTransition): transition is Extract<
6765
ResolvedAllocationTransition,
68-
{ kind: 'binding-published' | 'binding-cleanup-pending' | 'binding-cleaned' }
66+
{
67+
kind:
68+
| 'binding-publish-pending'
69+
| 'binding-published'
70+
| 'binding-cleanup-pending'
71+
| 'binding-cleaned';
72+
}
6973
> {
7074
return (
75+
transition.kind === 'binding-publish-pending' ||
7176
transition.kind === 'binding-published' ||
7277
transition.kind === 'binding-cleanup-pending' ||
7378
transition.kind === 'binding-cleaned'
@@ -116,22 +121,42 @@ function applyBindingTransition(
116121
record: AllocationOperationRecord,
117122
transition: Extract<
118123
ResolvedAllocationTransition,
119-
{ kind: 'binding-published' | 'binding-cleanup-pending' | 'binding-cleaned' }
124+
{
125+
kind:
126+
| 'binding-publish-pending'
127+
| 'binding-published'
128+
| 'binding-cleanup-pending'
129+
| 'binding-cleaned';
130+
}
120131
>,
121132
nowMs: number,
122133
): AllocationTransitionResult {
123134
if (record.phase.status !== 'granted') return terminalOrInvalid(record, transition.kind);
135+
if (transition.kind === 'binding-publish-pending') {
136+
return applyPublishPendingBinding(record, nowMs);
137+
}
124138
if (transition.kind === 'binding-published') return applyPublishedBinding(record, nowMs);
125139
if (transition.kind === 'binding-cleaned') return applyCleanedBinding(record, nowMs);
126140
return applyCleanupPendingBinding(record, nowMs);
127141
}
128142

143+
function applyPublishPendingBinding(
144+
record: AllocationOperationRecord,
145+
nowMs: number,
146+
): AllocationTransitionResult {
147+
if (record.binding === 'publish-pending') return alreadyApplied(record);
148+
if (record.binding !== 'unpublished' || record.release !== 'not-requested') {
149+
return transitionInvalid('binding-publish-pending', record.binding);
150+
}
151+
return applied(record, { binding: 'publish-pending' }, nowMs);
152+
}
153+
129154
function applyPublishedBinding(
130155
record: AllocationOperationRecord,
131156
nowMs: number,
132157
): AllocationTransitionResult {
133158
if (record.binding === 'published') return alreadyApplied(record);
134-
if (record.binding !== 'unpublished')
159+
if (record.binding !== 'publish-pending')
135160
return transitionInvalid('binding-published', record.binding);
136161
return applied(record, { binding: 'published' }, nowMs);
137162
}
@@ -146,6 +171,9 @@ function applyCleanupPendingBinding(
146171
if (record.binding === 'cleaned' || record.release !== 'not-requested') {
147172
return transitionInvalid('binding-cleanup-pending', record.binding);
148173
}
174+
if (record.binding !== 'publish-pending' && record.binding !== 'published') {
175+
return transitionInvalid('binding-cleanup-pending', record.binding);
176+
}
149177
return applied(record, { binding: 'cleanup-pending' }, nowMs);
150178
}
151179

@@ -154,7 +182,9 @@ function applyCleanedBinding(
154182
nowMs: number,
155183
): AllocationTransitionResult {
156184
if (record.binding === 'cleaned') return alreadyApplied(record);
157-
if (!['unpublished', 'published', 'cleanup-pending'].includes(record.binding)) {
185+
if (
186+
!['unpublished', 'publish-pending', 'published', 'cleanup-pending'].includes(record.binding)
187+
) {
158188
return transitionInvalid('binding-cleaned', record.binding);
159189
}
160190
return applied(record, { binding: 'cleaned' }, nowMs);

src/daemon/allocation-operation-record.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type AllocationOperationRef = Readonly<{
2020

2121
export type AllocationOperationBinding =
2222
| 'unpublished'
23+
| 'publish-pending'
2324
| 'published'
2425
| 'cleanup-pending'
2526
| 'cleaned'
@@ -83,6 +84,7 @@ export type AllocationTransition =
8384
| Readonly<{ kind: 'allocator-status'; status: LeaseRequestStatus }>
8485
| Readonly<{ kind: 'allocator-unknown'; message: string }>
8586
| Readonly<{ kind: 'allocator-ambiguous'; message: string }>
87+
| Readonly<{ kind: 'binding-publish-pending' }>
8688
| Readonly<{ kind: 'binding-published' }>
8789
| Readonly<{ kind: 'binding-cleanup-pending'; message?: string }>
8890
| Readonly<{ kind: 'binding-cleaned' }>

0 commit comments

Comments
 (0)