Skip to content

Commit c8169a5

Browse files
committed
Remove message hand-over from restarts - the MessageWatchdog is the sole message-fetch path
RestartExecution returns effects only; the with-messages RestartExecutions, StoredFlowWithEffectsAndMessages and the RestartFunction delegate are removed. Completed flows' undeliverable positions are parked per flow (park-then-check ordering to avoid stranding them when a concurrent explicit restart reopens parked positions) and released again on explicit re-invocation. ExistingMessages stamps appended messages with the publisher replica so the watchdog routes them; QueueManager reopens delivered-but-not-deleted positions on unwind.
1 parent a2248d2 commit c8169a5

29 files changed

Lines changed: 184 additions & 698 deletions

File tree

Core/Cleipnir.ResilientFunctions.Tests/InMemoryTests/StoreCrudTests.cs

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,25 +56,13 @@ public override Task StateCanBeUpdatedOnExistingFunction()
5656
public override Task ParameterAndStateAreNotUpdatedWhenEpochDoesNotMatch()
5757
=> ParameterAndStateAreNotUpdatedWhenEpochDoesNotMatch(FunctionStoreFactory.Create());
5858

59-
[TestMethod]
60-
public override Task RestartExecutionsRestartsMultipleUnownedFlows()
61-
=> RestartExecutionsRestartsMultipleUnownedFlows(FunctionStoreFactory.Create());
6259

6360
[TestMethod]
64-
public override Task RestartExecutionsReturnsEmptyDictionaryWhenNoFlowsAreEligible()
65-
=> RestartExecutionsReturnsEmptyDictionaryWhenNoFlowsAreEligible(FunctionStoreFactory.Create());
61+
public override Task RestartExecutionsWithoutMessagesReturnsEmptyDictionaryWhenNoFlowsAreEligible()
62+
=> RestartExecutionsWithoutMessagesReturnsEmptyDictionaryWhenNoFlowsAreEligible(FunctionStoreFactory.Create());
6663

67-
[TestMethod]
68-
public override Task RestartExecutionsRestartsOnlyUnownedFlows()
69-
=> RestartExecutionsRestartsOnlyUnownedFlows(FunctionStoreFactory.Create());
7064

71-
[TestMethod]
72-
public override Task RestartExecutionsReturnsEmptyDictionaryForEmptyInput()
73-
=> RestartExecutionsReturnsEmptyDictionaryForEmptyInput(FunctionStoreFactory.Create());
7465

75-
[TestMethod]
76-
public override Task RestartExecutionsIncludesExistingEffectsAndMessages()
77-
=> RestartExecutionsIncludesExistingEffectsAndMessages(FunctionStoreFactory.Create());
7866

7967
[TestMethod]
8068
public override Task RestartExecutionsWithoutMessagesRestartsMultipleUnownedFlows()

Core/Cleipnir.ResilientFunctions.Tests/InMemoryTests/StoreTests.cs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,12 @@ public override Task FunctionCanBeCreatedWithEffectsOnly()
171171
=> FunctionCanBeCreatedWithEffectsOnly(FunctionStoreFactory.Create());
172172

173173
[TestMethod]
174-
public override Task RestartExecutionReturnsEffectsAndMessages()
175-
=> RestartExecutionReturnsEffectsAndMessages(FunctionStoreFactory.Create());
174+
public override Task RestartExecutionReturnsEffects()
175+
=> RestartExecutionReturnsEffects(FunctionStoreFactory.Create());
176176

177177
[TestMethod]
178-
public override Task RestartExecutionWorksWithEmptyEffectsAndMessages()
179-
=> RestartExecutionWorksWithEmptyEffectsAndMessages(FunctionStoreFactory.Create());
178+
public override Task RestartExecutionWorksWithEmptyEffects()
179+
=> RestartExecutionWorksWithEmptyEffects(FunctionStoreFactory.Create());
180180

181181
[TestMethod]
182182
public override Task FunctionOwnedByReplicaIsPostponedAfterRescheduleFunctionsInvocation()
@@ -210,9 +210,6 @@ public override Task GetResultsReturnsEmptyDictionaryForEmptyInput()
210210
public override Task GetResultsReturnsOnlyExistingFunctionResults()
211211
=> GetResultsReturnsOnlyExistingFunctionResults(FunctionStoreFactory.Create());
212212

213-
[TestMethod]
214-
public override Task RestartExecutionsDoesNotReturnFlowClaimedByPreviousCall()
215-
=> RestartExecutionsDoesNotReturnFlowClaimedByPreviousCall(FunctionStoreFactory.Create());
216213

217214
[TestMethod]
218215
public override Task RestartExecutionsWithoutMessagesDoesNotReturnFlowClaimedByPreviousCall()

Core/Cleipnir.ResilientFunctions.Tests/TestTemplates/FunctionTests/MessagingTests.cs

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Linq;
23
using System.Threading.Tasks;
34
using Cleipnir.ResilientFunctions.CoreRuntime.Invocation;
45
using Cleipnir.ResilientFunctions.CoreRuntime.Serialization;
@@ -236,23 +237,20 @@ public async Task EmptyMessageIsNotDeliveredWhenFlowIsRestartedViaControlPanel(T
236237

237238
var flowId = TestFlowId.Create();
238239
var unhandledExceptionHandler = new UnhandledExceptionCatcher();
239-
// Watchdogs are disabled so the pending messages can only reach the flow via the control panel restart's
240-
// hand-over (the RestartExecution route) - not via the MessageWatchdog route.
241240
using var functionsRegistry = new FunctionsRegistry(
242241
store,
243-
new Settings(unhandledExceptionHandler.Catch, enableWatchdogs: false)
242+
new Settings(unhandledExceptionHandler.Catch, messagesPullFrequency: TimeSpan.FromMilliseconds(100))
244243
);
245244

245+
var awaitMessage = new SyncedFlag();
246246
var registration = functionsRegistry.RegisterFunc(
247247
flowId.Type,
248-
inner: async Task<string> (string _, Workflow workflow) => await workflow.Message<string>()
248+
inner: async Task<string> (string _, Workflow workflow) =>
249+
awaitMessage.IsRaised ? await workflow.Message<string>() : "no message awaited"
249250
);
250251

251-
await registration.Schedule(flowId.Instance.Value, "");
252-
253-
var controlPanel = await registration.ControlPanel(flowId.Instance);
254-
controlPanel.ShouldNotBeNull();
255-
await controlPanel.BusyWaitUntil(c => c.Status == Status.Suspended);
252+
// Complete the flow without it consuming any messages.
253+
await registration.Run(flowId.Instance.Value, "");
256254

257255
var storedId = registration.MapToStoredId(flowId.Instance);
258256
var replicaId = functionsRegistry.ClusterInfo.ReplicaId;
@@ -270,16 +268,29 @@ await store.MessageStore.AppendMessages([
270268
)
271269
]);
272270

273-
await controlPanel.ScheduleRestart();
271+
// The pending messages must not resurrect the completed flow.
272+
await Task.Delay(500);
273+
var controlPanel = await registration.ControlPanel(flowId.Instance);
274+
controlPanel.ShouldNotBeNull();
275+
controlPanel.Status.ShouldBe(Status.Succeeded);
276+
controlPanel.Result.ShouldBe("no message awaited");
274277

275-
// The restarted flow receives only the non-empty message.
278+
// An explicit restart does not pull messages itself - the MessageWatchdog delivers the pending non-empty
279+
// message to the restarted flow, while the empty message is never delivered.
280+
awaitMessage.Raise();
281+
await controlPanel.ScheduleRestart();
276282
await controlPanel.WaitForCompletion(allowPostponeAndSuspended: true);
277283
await controlPanel.Refresh();
278284
controlPanel.Status.ShouldBe(Status.Succeeded);
279285
controlPanel.Result.ShouldBe("hello world");
280286

281-
// The empty message is deleted by the restart hand-over; the delivered one is deleted after delivery.
282-
await BusyWait.Until(async () => (await store.MessageStore.GetMessages(storedId)).Count == 0);
287+
// The delivered message is deleted after delivery. The empty message is either consumed by a watchdog
288+
// restart along the way (deleted) or remains parked for the completed flow - but is never delivered.
289+
await BusyWait.Until(async () =>
290+
{
291+
var remaining = await store.MessageStore.GetMessages(storedId);
292+
return remaining.All(m => m.IsEmpty);
293+
});
283294

284295
unhandledExceptionHandler.ShouldNotHaveExceptions();
285296
}

Core/Cleipnir.ResilientFunctions.Tests/TestTemplates/StoreCrudTests.cs

Lines changed: 3 additions & 212 deletions
Original file line numberDiff line numberDiff line change
@@ -312,63 +312,8 @@ await store.SetParameters(
312312
sf.Parameter.ShouldNotBeNull();
313313
}
314314

315-
public abstract Task RestartExecutionsRestartsMultipleUnownedFlows();
316-
public async Task RestartExecutionsRestartsMultipleUnownedFlows(Task<IFunctionStore> storeTask)
317-
{
318-
var store = await storeTask;
319-
var storedId1 = TestStoredId.Create();
320-
var storedId2 = TestStoredId.Create();
321-
var storedId3 = TestStoredId.Create();
322-
var owner = ReplicaId.NewId();
323-
324-
// Create 3 functions without owners (postponed)
325-
await store.CreateFunction(
326-
storedId1,
327-
"instance1",
328-
Param.ToUtf8Bytes(),
329-
postponeUntil: DateTime.UtcNow.Ticks,
330-
timestamp: DateTime.UtcNow.Ticks,
331-
parent: null,
332-
owner: null
333-
);
334-
await store.CreateFunction(
335-
storedId2,
336-
"instance2",
337-
Param.ToUtf8Bytes(),
338-
postponeUntil: DateTime.UtcNow.Ticks,
339-
timestamp: DateTime.UtcNow.Ticks,
340-
parent: null,
341-
owner: null
342-
);
343-
await store.CreateFunction(
344-
storedId3,
345-
"instance3",
346-
Param.ToUtf8Bytes(),
347-
postponeUntil: DateTime.UtcNow.Ticks,
348-
timestamp: DateTime.UtcNow.Ticks,
349-
parent: null,
350-
owner: null
351-
);
352-
353-
// Restart all three
354-
var result = await store.RestartExecutions([storedId1, storedId2, storedId3], owner);
355-
356-
// All three should be restarted
357-
result.Count.ShouldBe(3);
358-
result.ContainsKey(storedId1).ShouldBeTrue();
359-
result.ContainsKey(storedId2).ShouldBeTrue();
360-
result.ContainsKey(storedId3).ShouldBeTrue();
361-
362-
result[storedId1].StoredFlow.OwnerId.ShouldBe(owner);
363-
result[storedId1].StoredFlow.Status.ShouldBe(Status.Executing);
364-
result[storedId2].StoredFlow.OwnerId.ShouldBe(owner);
365-
result[storedId2].StoredFlow.Status.ShouldBe(Status.Executing);
366-
result[storedId3].StoredFlow.OwnerId.ShouldBe(owner);
367-
result[storedId3].StoredFlow.Status.ShouldBe(Status.Executing);
368-
}
369-
370-
public abstract Task RestartExecutionsReturnsEmptyDictionaryWhenNoFlowsAreEligible();
371-
public async Task RestartExecutionsReturnsEmptyDictionaryWhenNoFlowsAreEligible(Task<IFunctionStore> storeTask)
315+
public abstract Task RestartExecutionsWithoutMessagesReturnsEmptyDictionaryWhenNoFlowsAreEligible();
316+
public async Task RestartExecutionsWithoutMessagesReturnsEmptyDictionaryWhenNoFlowsAreEligible(Task<IFunctionStore> storeTask)
372317
{
373318
var store = await storeTask;
374319
var storedId1 = TestStoredId.Create();
@@ -397,165 +342,11 @@ await store.CreateFunction(
397342
);
398343

399344
// Try to restart - should return empty dictionary
400-
var result = await store.RestartExecutions([storedId1, storedId2], newOwner);
345+
var result = await store.RestartExecutionsWithoutMessages([storedId1, storedId2], newOwner);
401346

402347
result.Count.ShouldBe(0);
403348
}
404349

405-
public abstract Task RestartExecutionsRestartsOnlyUnownedFlows();
406-
public async Task RestartExecutionsRestartsOnlyUnownedFlows(Task<IFunctionStore> storeTask)
407-
{
408-
var store = await storeTask;
409-
var storedId1 = TestStoredId.Create();
410-
var storedId2 = TestStoredId.Create();
411-
var storedId3 = TestStoredId.Create();
412-
var existingOwner = ReplicaId.NewId();
413-
var newOwner = ReplicaId.NewId();
414-
415-
// Create 2 functions without owners and 1 with owner
416-
await store.CreateFunction(
417-
storedId1,
418-
"instance1",
419-
Param.ToUtf8Bytes(),
420-
postponeUntil: DateTime.UtcNow.Ticks,
421-
timestamp: DateTime.UtcNow.Ticks,
422-
parent: null,
423-
owner: null
424-
);
425-
await store.CreateFunction(
426-
storedId2,
427-
"instance2",
428-
Param.ToUtf8Bytes(),
429-
postponeUntil: null,
430-
timestamp: DateTime.UtcNow.Ticks,
431-
parent: null,
432-
owner: existingOwner
433-
);
434-
await store.CreateFunction(
435-
storedId3,
436-
"instance3",
437-
Param.ToUtf8Bytes(),
438-
postponeUntil: DateTime.UtcNow.Ticks,
439-
timestamp: DateTime.UtcNow.Ticks,
440-
parent: null,
441-
owner: null
442-
);
443-
444-
// Restart all three - only 1 and 3 should succeed
445-
var result = await store.RestartExecutions([storedId1, storedId2, storedId3], newOwner);
446-
447-
result.Count.ShouldBe(2);
448-
result.ContainsKey(storedId1).ShouldBeTrue();
449-
result.ContainsKey(storedId2).ShouldBeFalse();
450-
result.ContainsKey(storedId3).ShouldBeTrue();
451-
452-
result[storedId1].StoredFlow.OwnerId.ShouldBe(newOwner);
453-
result[storedId3].StoredFlow.OwnerId.ShouldBe(newOwner);
454-
}
455-
456-
public abstract Task RestartExecutionsReturnsEmptyDictionaryForEmptyInput();
457-
public async Task RestartExecutionsReturnsEmptyDictionaryForEmptyInput(Task<IFunctionStore> storeTask)
458-
{
459-
var store = await storeTask;
460-
var owner = ReplicaId.NewId();
461-
462-
var result = await store.RestartExecutions([], owner);
463-
464-
result.Count.ShouldBe(0);
465-
}
466-
467-
public abstract Task RestartExecutionsIncludesExistingEffectsAndMessages();
468-
public async Task RestartExecutionsIncludesExistingEffectsAndMessages(Task<IFunctionStore> storeTask)
469-
{
470-
var store = await storeTask;
471-
var storedId1 = TestStoredId.Create();
472-
var storedId2 = TestStoredId.Create();
473-
var owner = ReplicaId.NewId();
474-
475-
// Create effects
476-
var effect1 = new StoredEffect(
477-
EffectId: "effect1".GetHashCode().ToEffectId(),
478-
WorkStatus: WorkStatus.Completed,
479-
Result: "result1".ToUtf8Bytes(),
480-
StoredException: null,
481-
Alias: null
482-
);
483-
var effect2 = new StoredEffect(
484-
EffectId: "effect2".GetHashCode().ToEffectId(),
485-
WorkStatus: WorkStatus.Completed,
486-
Result: "result2".ToUtf8Bytes(),
487-
StoredException: null,
488-
Alias: null
489-
);
490-
491-
// Create messages
492-
var message1 = new StoredMessage(
493-
MessageContent: "message1".ToUtf8Bytes(),
494-
MessageType: "Type1".ToUtf8Bytes(),
495-
Position: 0,
496-
Replica: ReplicaId.Empty,
497-
IdempotencyKey: null
498-
);
499-
var message2 = new StoredMessage(
500-
MessageContent: "message2".ToUtf8Bytes(),
501-
MessageType: "Type2".ToUtf8Bytes(),
502-
Position: 1,
503-
Replica: ReplicaId.Empty,
504-
IdempotencyKey: null
505-
);
506-
507-
// Create 2 functions with effects and messages
508-
await store.CreateFunction(
509-
storedId1,
510-
"instance1",
511-
Param.ToUtf8Bytes(),
512-
postponeUntil: DateTime.UtcNow.Ticks,
513-
timestamp: DateTime.UtcNow.Ticks,
514-
parent: null,
515-
owner: null,
516-
effects: [effect1],
517-
messages: [message1]
518-
);
519-
await store.CreateFunction(
520-
storedId2,
521-
"instance2",
522-
Param.ToUtf8Bytes(),
523-
postponeUntil: DateTime.UtcNow.Ticks,
524-
timestamp: DateTime.UtcNow.Ticks,
525-
parent: null,
526-
owner: null,
527-
effects: [effect2],
528-
messages: [message2]
529-
);
530-
531-
// Restart both
532-
var result = await store.RestartExecutions([storedId1, storedId2], owner);
533-
534-
// Verify both flows returned with their effects and messages
535-
result.Count.ShouldBe(2);
536-
537-
result.ContainsKey(storedId1).ShouldBeTrue();
538-
result.ContainsKey(storedId2).ShouldBeTrue();
539-
540-
// Verify flow 1 has correct effects and messages
541-
var flow1 = result[storedId1];
542-
flow1.Effects.Count.ShouldBe(1);
543-
flow1.Effects[0].EffectId.ShouldBe("effect1".GetHashCode().ToEffectId());
544-
flow1.Effects[0].Result.ShouldBe("result1".ToUtf8Bytes());
545-
flow1.Messages.Count.ShouldBe(1);
546-
flow1.Messages[0].MessageContent.ShouldBe("message1".ToUtf8Bytes());
547-
flow1.Messages[0].MessageType.ShouldBe("Type1".ToUtf8Bytes());
548-
549-
// Verify flow 2 has correct effects and messages
550-
var flow2 = result[storedId2];
551-
flow2.Effects.Count.ShouldBe(1);
552-
flow2.Effects[0].EffectId.ShouldBe("effect2".GetHashCode().ToEffectId());
553-
flow2.Effects[0].Result.ShouldBe("result2".ToUtf8Bytes());
554-
flow2.Messages.Count.ShouldBe(1);
555-
flow2.Messages[0].MessageContent.ShouldBe("message2".ToUtf8Bytes());
556-
flow2.Messages[0].MessageType.ShouldBe("Type2".ToUtf8Bytes());
557-
}
558-
559350
public abstract Task RestartExecutionsWithoutMessagesRestartsMultipleUnownedFlows();
560351
public async Task RestartExecutionsWithoutMessagesRestartsMultipleUnownedFlows(Task<IFunctionStore> storeTask)
561352
{

0 commit comments

Comments
 (0)