11import assert from 'node:assert/strict' ;
22import fs from 'node:fs' ;
33import path from 'node:path' ;
4- import { test } from 'vitest' ;
4+ import { test , vi } from 'vitest' ;
55import 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' ;
1821import {
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+
131194test ( '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+
401489test ( '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 ( ) => {
0 commit comments