@@ -25,6 +25,7 @@ import {
2525 EntityAddress ,
2626 EntityId ,
2727 EntityType ,
28+ type Message ,
2829 MessageStorage ,
2930 RunnerHealth ,
3031 Runners ,
@@ -220,6 +221,127 @@ describe.concurrent("ClusterWorkflowEngine", () => {
220221 } ) . pipe ( Effect . provide ( layer ( ) ) )
221222 } ) )
222223
224+ it . effect ( "shuts down without waiting for a queued activity and replays it after restart" , ( ) =>
225+ Effect . gen ( function * ( ) {
226+ const workflow = Workflow . make ( "QueuedActivityShutdown" , {
227+ payload : { } ,
228+ idempotencyKey : ( ) => "one"
229+ } )
230+ let executions = 0
231+ const step = Activity . make ( {
232+ name : "step" ,
233+ execute : Effect . sync ( ( ) => {
234+ executions ++
235+ } )
236+ } )
237+ const storage = yield * makeSharedStorage
238+ const queued = yield * Latch . make ( )
239+ const isActivity = ( message : Message . Incoming < any > ) =>
240+ message . envelope . _tag === "Request" && message . envelope . tag === "activity"
241+ // The activity request is persisted, but the runner never reads it back.
242+ const queuedStorage = Layer . succeed ( MessageStorage . MessageStorage , {
243+ ...storage ,
244+ saveRequest : ( message ) =>
245+ Effect . tap ( storage . saveRequest ( message ) , ( ) => message . rpc . _tag === "activity" ? queued . open : Effect . void ) ,
246+ unprocessedMessages : ( shardIds , options ) =>
247+ Effect . map (
248+ storage . unprocessedMessages ( shardIds , options ) ,
249+ ( messages ) => messages . filter ( ( m ) => ! isActivity ( m ) )
250+ ) ,
251+ unprocessedMessagesById : ( ids ) =>
252+ Effect . map ( storage . unprocessedMessagesById ( ids ) , ( messages ) => messages . filter ( ( m ) => ! isActivity ( m ) ) )
253+ } )
254+ const layer = ( storageLayer : Layer . Layer < MessageStorage . MessageStorage > ) =>
255+ workflow . toLayer ( ( ) => step ) . pipe (
256+ Layer . provideMerge ( makeTestWorkflowEngine ( {
257+ storageLayer,
258+ config : { entityTerminationTimeout : Duration . minutes ( 1 ) }
259+ } ) )
260+ )
261+
262+ const scope = yield * Scope . make ( )
263+ const context = yield * Layer . buildWithScope ( layer ( queuedStorage ) , scope )
264+ const executionId = yield * workflow . execute ( { } , { discard : true } ) . pipe ( Effect . provide ( context ) )
265+ yield * queued . await
266+ const shutdown = yield * Effect . forkChild ( Scope . close ( scope , Exit . void ) , { startImmediately : true } )
267+ for ( let i = 0 ; i < 100 && shutdown . pollUnsafe ( ) === undefined ; i ++ ) yield * TestClock . adjust ( 10 )
268+ const closedBeforeTimeout = shutdown . pollUnsafe ( ) !== undefined
269+ // Let a stalled shutdown finish, so the runner is released before asserting.
270+ yield * TestClock . adjust ( Duration . minutes ( 1 ) )
271+ yield * Fiber . join ( shutdown )
272+ assert . isTrue ( closedBeforeTimeout , "shutdown must not wait for entityTerminationTimeout" )
273+ assert . strictEqual ( executions , 0 )
274+
275+ yield * Effect . gen ( function * ( ) {
276+ assert . deepStrictEqual (
277+ yield * pollUntil ( workflow , executionId , "Complete" ) ,
278+ new Workflow . Complete ( { exit : Exit . void } )
279+ )
280+ assert . strictEqual ( executions , 1 )
281+ } ) . pipe ( Effect . provide ( layer ( Layer . succeed ( MessageStorage . MessageStorage , storage ) ) ) )
282+ } ) )
283+
284+ it . effect ( "shutdown waits for a running activity before finalizing its workflow" , ( ) =>
285+ Effect . gen ( function * ( ) {
286+ const workflow = Workflow . make ( "RunningActivityShutdown" , {
287+ payload : { } ,
288+ idempotencyKey : ( ) => "one"
289+ } )
290+ const events : Array < string > = [ ]
291+ const started = yield * Latch . make ( )
292+ const release = yield * Latch . make ( )
293+ const step = Activity . make ( {
294+ name : "step" ,
295+ execute : Effect . gen ( function * ( ) {
296+ events . push ( "activity:start" )
297+ yield * started . open
298+ yield * release . await
299+ events . push ( "activity:end" )
300+ } )
301+ } )
302+ const storage = yield * makeSharedStorage
303+ const layer = workflow . toLayer ( ( ) =>
304+ Effect . gen ( function * ( ) {
305+ yield * Effect . addFinalizer ( ( ) =>
306+ Effect . sync ( ( ) => {
307+ events . push ( "finalized" )
308+ } )
309+ ) . pipe ( Workflow . provideScope )
310+ yield * step
311+ } )
312+ ) . pipe (
313+ Layer . provideMerge ( makeTestWorkflowEngine ( {
314+ storageLayer : Layer . succeed ( MessageStorage . MessageStorage , storage ) ,
315+ config : { entityTerminationTimeout : Duration . minutes ( 1 ) }
316+ } ) )
317+ )
318+
319+ const scope = yield * Scope . make ( )
320+ const context = yield * Layer . buildWithScope ( layer , scope )
321+ const executionId = yield * workflow . execute ( { } , { discard : true } ) . pipe ( Effect . provide ( context ) )
322+ yield * advanceUntil ( ( ) => started . isOpen ( ) , "activity must start" )
323+ const shutdown = yield * Effect . forkChild ( Scope . close ( scope , Exit . void ) , { startImmediately : true } )
324+ for ( let i = 0 ; i < 100 ; i ++ ) yield * TestClock . adjust ( 10 )
325+ const pendingWhileRunning = shutdown . pollUnsafe ( ) === undefined
326+ yield * release . open
327+ for ( let i = 0 ; i < 100 && shutdown . pollUnsafe ( ) === undefined ; i ++ ) yield * TestClock . adjust ( 10 )
328+ const closedBeforeTimeout = shutdown . pollUnsafe ( ) !== undefined
329+ // Let a stalled shutdown finish, so the runner is released before asserting.
330+ yield * TestClock . adjust ( Duration . minutes ( 1 ) )
331+ yield * Fiber . join ( shutdown )
332+ assert . isTrue ( pendingWhileRunning , "shutdown must wait for the running activity" )
333+ assert . isTrue ( closedBeforeTimeout , "shutdown must finish once the activity completes" )
334+ assert . deepStrictEqual ( events , [ "activity:start" , "activity:end" , "finalized" ] )
335+
336+ yield * Effect . gen ( function * ( ) {
337+ assert . deepStrictEqual (
338+ yield * pollUntil ( workflow , executionId , "Complete" ) ,
339+ new Workflow . Complete ( { exit : Exit . void } )
340+ )
341+ } ) . pipe ( Effect . provide ( layer ) )
342+ assert . deepStrictEqual ( events , [ "activity:start" , "activity:end" , "finalized" ] )
343+ } ) )
344+
223345 it . effect ( "retries an early activity registered while its Suspended reply is still being persisted" , ( ) =>
224346 Effect . gen ( function * ( ) {
225347 const workflow = Workflow . make ( "EarlyActivity" , {
0 commit comments