Skip to content

Commit 9af42a4

Browse files
committed
Strip empty messages when building the restart hand-over instead of in QueueManager
1 parent 4e0bc7a commit 9af42a4

7 files changed

Lines changed: 91 additions & 22 deletions

File tree

Core/Cleipnir.ResilientFunctions.Tests/InMemoryTests/RFunctionTests/MessagingTests.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,8 @@ public override Task EmptyMessagesRestartSuspendedFlowsWithoutDeliveryAndAreRemo
2929
[TestMethod]
3030
public override Task EmptyMessageIsNotDeliveredToRestartedFlowWhileNonEmptyMessageIs()
3131
=> EmptyMessageIsNotDeliveredToRestartedFlowWhileNonEmptyMessageIs(FunctionStoreFactory.Create());
32+
33+
[TestMethod]
34+
public override Task EmptyMessageIsNotDeliveredWhenFlowIsRestartedViaControlPanel()
35+
=> EmptyMessageIsNotDeliveredWhenFlowIsRestartedViaControlPanel(FunctionStoreFactory.Create());
3236
}

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,59 @@ await store.MessageStore.AppendMessages([
228228

229229
unhandledExceptionHandler.ShouldNotHaveExceptions();
230230
}
231+
232+
public abstract Task EmptyMessageIsNotDeliveredWhenFlowIsRestartedViaControlPanel();
233+
public async Task EmptyMessageIsNotDeliveredWhenFlowIsRestartedViaControlPanel(Task<IFunctionStore> functionStore)
234+
{
235+
var store = await functionStore;
236+
237+
var flowId = TestFlowId.Create();
238+
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.
241+
using var functionsRegistry = new FunctionsRegistry(
242+
store,
243+
new Settings(unhandledExceptionHandler.Catch, enableWatchdogs: false)
244+
);
245+
246+
var registration = functionsRegistry.RegisterFunc(
247+
flowId.Type,
248+
inner: async Task<string> (string _, Workflow workflow) => await workflow.Message<string>()
249+
);
250+
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);
256+
257+
var storedId = registration.MapToStoredId(flowId.Instance);
258+
var replicaId = functionsRegistry.ClusterInfo.ReplicaId;
259+
var serializer = DefaultSerializer.Instance;
260+
await store.MessageStore.AppendMessages([
261+
new StoredIdAndMessage(storedId, StoredMessage.CreateEmpty(replicaId)),
262+
new StoredIdAndMessage(
263+
storedId,
264+
new StoredMessage(
265+
serializer.Serialize("hello world", typeof(string)),
266+
serializer.SerializeType(typeof(string)),
267+
Position: 0,
268+
Replica: replicaId
269+
)
270+
)
271+
]);
272+
273+
await controlPanel.ScheduleRestart();
274+
275+
// The restarted flow receives only the non-empty message.
276+
await controlPanel.WaitForCompletion(allowPostponeAndSuspended: true);
277+
await controlPanel.Refresh();
278+
controlPanel.Status.ShouldBe(Status.Succeeded);
279+
controlPanel.Result.ShouldBe("hello world");
280+
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);
283+
284+
unhandledExceptionHandler.ShouldNotHaveExceptions();
285+
}
231286
}

Core/Cleipnir.ResilientFunctions/CoreRuntime/Invocation/InvocationHelper.cs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -221,15 +221,25 @@ public async Task PublishCompletionMessageToParent(StoredId? parent, FlowId chil
221221
flowId,
222222
_replicaId
223223
);
224+
if (restarted == null)
225+
return null;
224226

225-
return restarted != null
226-
? new RestartedFunction(
227-
restarted.StoredFlow,
228-
restarted.Effects,
229-
restarted.Messages,
230-
restarted.StorageSession
231-
)
232-
: null;
227+
// The message snapshot comes straight from the store and can contain empty messages - restart-pokes with
228+
// nothing to deliver. The restart they were appended to force is happening right now, so delete them and
229+
// hand over only the deliverable messages.
230+
var messages = restarted.Messages;
231+
if (messages.Any(m => m.IsEmpty))
232+
{
233+
await _messageClearer.Clear(messages.Where(m => m.IsEmpty).Select(m => m.Position).ToList());
234+
messages = messages.Where(m => !m.IsEmpty).ToList();
235+
}
236+
237+
return new RestartedFunction(
238+
restarted.StoredFlow,
239+
restarted.Effects,
240+
messages,
241+
restarted.StorageSession
242+
);
233243
}
234244

235245
public async Task<PreparedReInvocation> PrepareForReInvocation(StoredId storedId, RestartedFunction restartedFunction)

Core/Cleipnir.ResilientFunctions/Queuing/QueueManager.cs

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ public Task FetchMessagesOnce()
133133
/// Pushes messages fetched elsewhere (the MessageWatchdog, or the in-hand messages handed over on restart)
134134
/// straight into the delivery pipeline, avoiding a per-flow re-fetch. Ensures the queue manager is initialized
135135
/// first so the idempotency-key state is loaded before the messages are processed. Idempotent: positions
136-
/// already processed are skipped by ProcessMessages.
136+
/// already processed are skipped by ProcessMessages. Both routes strip empty (restart-poke) messages before
137+
/// handing over, so every message arriving here carries a deliverable payload.
137138
///
138139
/// A push that hits a disposed (dying) instance reopens its positions instead of dropping them: the
139140
/// MessageWatchdog already marked them as pushed, so a silent drop would strand the messages in the
@@ -163,19 +164,6 @@ public async Task Push(IReadOnlyList<StoredMessage> messages)
163164
}
164165
}
165166

166-
// Empty messages exist only to force a restart and carry nothing to deliver (deserializing one would
167-
// poison the queue manager). The only push that can contain them is a restart hand-over - the watchdog
168-
// route strips them before delivery - so the restart they were appended to force has happened: delete
169-
// them instead of delivering.
170-
if (messages.Any(m => m.IsEmpty))
171-
{
172-
var emptyPositions = messages.Where(m => m.IsEmpty).Select(m => m.Position).ToList();
173-
messages = messages.Where(m => !m.IsEmpty).ToList();
174-
await _messageClearer.Clear(emptyPositions);
175-
if (messages.Count == 0)
176-
return;
177-
}
178-
179167
await _fetchSemaphore.WaitAsync();
180168
try
181169
{

Stores/MariaDB/Cleipnir.ResilientFunctions.MariaDB.Tests/RFunctionTests/MessagingTests.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,8 @@ public override Task EmptyMessagesRestartSuspendedFlowsWithoutDeliveryAndAreRemo
2424
[TestMethod]
2525
public override Task EmptyMessageIsNotDeliveredToRestartedFlowWhileNonEmptyMessageIs()
2626
=> EmptyMessageIsNotDeliveredToRestartedFlowWhileNonEmptyMessageIs(FunctionStoreFactory.Create());
27+
28+
[TestMethod]
29+
public override Task EmptyMessageIsNotDeliveredWhenFlowIsRestartedViaControlPanel()
30+
=> EmptyMessageIsNotDeliveredWhenFlowIsRestartedViaControlPanel(FunctionStoreFactory.Create());
2731
}

Stores/PostgreSQL/Cleipnir.ResilientFunctions.PostgreSQL.Tests/RFunctionTests/MessagingTests.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,8 @@ public override Task EmptyMessagesRestartSuspendedFlowsWithoutDeliveryAndAreRemo
2525
[TestMethod]
2626
public override Task EmptyMessageIsNotDeliveredToRestartedFlowWhileNonEmptyMessageIs()
2727
=> EmptyMessageIsNotDeliveredToRestartedFlowWhileNonEmptyMessageIs(FunctionStoreFactory.Create());
28+
29+
[TestMethod]
30+
public override Task EmptyMessageIsNotDeliveredWhenFlowIsRestartedViaControlPanel()
31+
=> EmptyMessageIsNotDeliveredWhenFlowIsRestartedViaControlPanel(FunctionStoreFactory.Create());
2832
}

Stores/SqlServer/Cleipnir.ResilientFunctions.SqlServer.Tests/RFunctionTests/MessagingTests.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,8 @@ public override Task EmptyMessagesRestartSuspendedFlowsWithoutDeliveryAndAreRemo
2525
[TestMethod]
2626
public override Task EmptyMessageIsNotDeliveredToRestartedFlowWhileNonEmptyMessageIs()
2727
=> EmptyMessageIsNotDeliveredToRestartedFlowWhileNonEmptyMessageIs(FunctionStoreFactory.Create());
28+
29+
[TestMethod]
30+
public override Task EmptyMessageIsNotDeliveredWhenFlowIsRestartedViaControlPanel()
31+
=> EmptyMessageIsNotDeliveredWhenFlowIsRestartedViaControlPanel(FunctionStoreFactory.Create());
2832
}

0 commit comments

Comments
 (0)