Skip to content

Commit cb75b78

Browse files
authored
Make message delivery push-only (#180)
* Make MariaDB batch restart claims precise RestartExecutions claimed via UPDATE..WHERE owner IS NULL and then SELECTed all requested rows, filtering by owner = replica. A flow claimed by an earlier call from the same replica was therefore returned to a concurrent caller as if it had just been claimed, letting two claimers (e.g. two watchdogs) restart the same flow concurrently - surfacing as UnexpectedStateException: concurrent modification. Run a locking SELECT..FOR UPDATE and the claiming UPDATE inside one transaction so the caller receives exactly the rows its own call claimed, mirroring the already-precise UPDATE..RETURNING (PostgreSQL) and UPDATE..OUTPUT (SqlServer) implementations. * Only claim postponed/suspended flows in batch restarts The batch RestartExecutions/RestartExecutionsWithoutMessages claimed any ownerless flow, so a caller targeting a flow that had meanwhile completed would resurrect it - e.g. a message arriving after its target succeeded, once message-driven restarts land. Guard the claim with status IN (Postponed, Suspended) in all stores. The singular RestartExecution (manual restart) intentionally keeps claiming completed flows. * Deliver restart in-hand messages via QueueManager.Push PrepareForReInvocation received the messages loaded by the restart claim but discarded them, so a restarted flow had to re-fetch its own messages from the store. Push them straight into the queue manager's delivery pipeline instead. Push now initializes the queue manager first so the idempotency-key state is loaded before processing. Initialize additionally seeds the fetched-positions set from the persisted delivered positions: the in-hand messages are loaded at claim time - before Initialize clears the delivered positions from the store - so without the seed a message the previous incarnation already delivered (but had not yet cleared) would be delivered again. * Push pending messages immediately on flow initialization Extract the MessageWatchdog's fetch-and-push cycle into PushOnce and invoke it when a queue manager initializes through CreateQueueClient, so a freshly started/resumed flow receives its pending messages immediately instead of waiting for the next watchdog poll. The pending-push is skipped when initialization is triggered by an incoming Push: the pusher already holds an in-flight batch and a nested fetch would stage newer messages ahead of it. Staged messages are additionally kept position-sorted so delivery order stays append order even when concurrent pushers stage batches out of order. * Hand over crashed replicas' messages to a live replica GetMessagesForReplica only returns messages assigned to the fetching replica, so messages published by a crashed replica to ownerless flows were never fetched again once the replica died. The ReplicaWatchdog now reassigns a crashed replica's message rows to itself alongside rescheduling its crashed functions - before deleting the crashed replica from the replica store, so an interrupted handover is retried on a later iteration. * Rework hand-rolled QueueManager tests to production paths Three tests constructed their own QueueManager inside the flow, which exercised the store-pull directly and duplicated runtime wiring: - QueueManagerFailsOnMessageDeserializationError now registers the exception-throwing serializer on the registry and uses workflow.Message - RegisteredTimeoutIsRemovedWhenPullingMessage now asserts through the observable postpone: a delivered message's timeout must not linger and shorten a subsequent delay's postpone - PullEnvelopeReturnsEnvelopeWithReceiverAndSender uses the flow's own queue manager via a new internal Workflow.QueueManager accessor The tests now stay valid when the queue manager's store-pull is removed and message delivery becomes push-only. * Reopen pushed positions that cannot be delivered A push whose target flow was suspended, not yet queue-manager-attached, or absent from the manager's dictionary was silently dropped while its positions stayed in the clearer's ignore-set. Later fetches then only returned higher positions, so a later-positioned duplicate consumed the idempotency key before the original message - observed on CI as MultipleIterationsWithDuplicateIdempotencyKeysProcessCorrectly delivering the second-copy values. The queue manager now attaches to its flow state at construction, and undeliverable pushes reopen their positions so they are re-fetched instead of stranded. * Reset EffectContext at the start of every flow invocation The invocation body runs on a Task that captures the scheduler's execution context. When a flow is restarted from a watchdog chain that has itself touched the ambient EffectContext, all invocations spawned from that chain inherit - and mutate - the same context instance, so implicit effect ids shift between incarnations and replay diverges: a restarted flow re-subscribes under a different effect id, filters on the wrong message and suspends, restarting endlessly. Give every invocation a fresh context at start so implicit ids are deterministic regardless of the scheduling context. * Run MessageWatchdog at the messages-pull frequency MessagesPullFrequency (default 250ms) lost its consumer when the per-flow fetch loop was removed. The MessageWatchdog is the message- delivery loop, so it should run at that frequency - the slower watchdog check frequency made every push-restarted message exchange poll-bound. * Restart not-live flows from FlowsManager.Push with in-hand messages Messages pushed to a flow that is not live are now delivered by claiming and restarting the flow (RestartExecutionsWithoutMessages) with the pushed messages handed to the new invocation - the message itself becomes the wake-up signal instead of relying on the interrupt flag. A flow-state entry whose flow has suspended counts as not live: a suspended flow's parked invocation never reaches RemoveFlow by design, and treating the lingering entry as live swallowed the wake-up. Claim failures distinguish retryable targets (reopened for the next poll) from completed/deleted flows, whose positions stay ignored so their messages are not refetched forever. A failed claim call reopens everything. Positions that reach a dying queue manager are reopened rather than dropped: pushes hitting a disposed instance, staged-but-undelivered messages at dispose, and messages for unregistered flow types. A stranded position otherwise loses the flow's wake-up once the message watchdog has marked it as pushed. The tests' thread pool is pre-sized since every test's registry now runs its message poll at 250ms, which starves the default pool growth on small CI runners. * Make QueueManager push-only: remove its message-store pull Delete FetchAndNotify and the queue manager's message-store dependency. Messages now arrive exclusively via Push - the MessageWatchdog poll, the immediate fetch at initialization and the restart hand-over - so Subscribe, Interrupt and FetchMessagesOnce only re-evaluate the already staged messages against the current subscriptions. * Stop interrupting target flows when appending messages Message appends no longer set the interrupted flag: the message row itself is the durable wake-up signal - the MessageWatchdog fetches it and either pushes to the live flow or claims and restarts the parked one with the message in hand. The public Interrupt API and the suspend-time interrupted-guard remain. A claim failure for a flow that does not exist (yet) now reopens the positions instead of stranding them: messages may legally precede their flow's creation, and the delivery must be retried once the flow is scheduled. * Remove the InterruptedWatchdog Message delivery to live flows is handled by the MessageWatchdog's push, and parked flows are woken by push-restarts - the interrupt polling loop no longer had a purpose. Remove it together with its supporting plumbing: FlowsManagers/FlowsManager Interrupt and FilterOwned, FlowExecutionState.Interrupt, QueueManager.Interrupt and the GetInterruptedFunctions/ResetInterrupted store operations. The public Interrupt API and the suspend-time interrupted-guard remain. * Remove unused FlowsManager.Schedule Message-driven flow wake-up is push-restart based; the interrupt-only scheduling helper has no callers left. * Give at-least-once tests a load-tolerant suspension delay The tests rely on workflow.Delay suspending the flow so the captured work is re-executed on restart. A 10ms delay can fully elapse before the suspension check on a loaded machine (an effect-persist roundtrip sits in between), letting the capture complete inline on the first attempt. Use 500ms so the suspension reliably happens. * Remove the immediate fetch-and-push at queue manager initialization The init-time PushOnce ran on the initializing flow's own async branch, which made restart chains executable from inside another flow's execution context - the trigger for the EffectContext id-shift bug - and coupled queue manager construction to the MessageWatchdog. Message delivery is now driven solely by the watchdog poll and the restart in-hand hand-over, simplifying the wiring (no push delegate threaded through FunctionsRegistry/InvocationHelper/QueueManager). The trade-off is latency: a flow awaiting an already-present message suspends once and is restarted by the next poll instead of receiving it inline. Tests encoding the stronger inline guarantee are adjusted: initial-state flows are awaited tolerantly via Schedule+Completion, AwaitMessageAfterAppendShouldNotCauseSuspension is removed, and WorkflowMessageIsIdempotentAcrossRestarts counts deliveries instead of delivery-timing-dependent invocations. * Wake the MessageWatchdog on message appends Add MessageWatchdog.Notify: appending a message completes a wake signal that cuts the watchdog's inter-poll sleep short, so locally appended messages are delivered immediately instead of waiting out the poll interval. The signal is re-armed before each fetch, so a notify arriving mid-push is never lost - it simply makes the next wait return at once. Notify only completes the signal: the fetch-and-push always executes on the watchdog's own loop, so - unlike the removed initialization-time push - no flow execution context is ever captured by another flow's restart. Wired into MessageWriter/MessageWriters appends and the child-to-parent completion message; reopened positions deliberately do not notify, as a persistently unclaimable flow would otherwise spin the poll loop. The faster delivery exposed a stranding hazard: a push whose message fails deserialization poisons the queue manager without staging the positions, and when that races the flow's suspension the message stayed marked forever and the flow never failed. A poisoned queue manager now reopens the batch's unstaged positions so a restarted incarnation receives the message and surfaces the failure. * Pass MessageWatchdog to InvocationHelper instead of an Action * Pass IMessageClearer to FlowExecutionState instead of an Action * wip * Pass MessageWatchdog to MessageWriter and MessageWriters instead of an Action
1 parent e18b535 commit cb75b78

49 files changed

Lines changed: 728 additions & 964 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,6 @@ public override Task ExecutingFlowIsReExecutedWhenSuspendedAfterInterrupt()
8888
public override Task InterruptSuspendedFlows()
8989
=> InterruptSuspendedFlows(FunctionStoreFactory.Create());
9090

91-
[TestMethod]
92-
public override Task AwaitMessageAfterAppendShouldNotCauseSuspension()
93-
=> AwaitMessageAfterAppendShouldNotCauseSuspension(FunctionStoreFactory.Create());
94-
9591
[TestMethod]
9692
public override Task DelayedFlowIsRestartedOnce()
9793
=> DelayedFlowIsRestartedOnce(FunctionStoreFactory.Create());

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

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,6 @@ public override Task RestartingExecutionShouldFailWhenExpectedEpochDoesNotMatch(
9898
public override Task RestartingFunctionShouldSetInterruptedToFalse()
9999
=> RestartingFunctionShouldSetInterruptedToFalse(FunctionStoreFactory.Create());
100100

101-
[TestMethod]
102-
public override Task ResetInterruptedClearsInterruptedFlag()
103-
=> ResetInterruptedClearsInterruptedFlag(FunctionStoreFactory.Create());
104-
105101
[TestMethod]
106102
public override Task MessagesCanBeFetchedAfterFunctionWithInitialMessagesHasBeenCreated()
107103
=> MessagesCanBeFetchedAfterFunctionWithInitialMessagesHasBeenCreated(FunctionStoreFactory.Create());
@@ -239,34 +235,30 @@ public override Task SucceedSetsOwnerToNull()
239235
=> SucceedSetsOwnerToNull(FunctionStoreFactory.Create());
240236

241237
[TestMethod]
242-
public override Task GetInterruptedFunctionsReturnsOnlyInterruptedFunctions()
243-
=> GetInterruptedFunctionsReturnsOnlyInterruptedFunctions(FunctionStoreFactory.Create());
244-
245-
[TestMethod]
246-
public override Task GetInterruptedFunctionsReturnsEmptyListWhenNoneExist()
247-
=> GetInterruptedFunctionsReturnsEmptyListWhenNoneExist(FunctionStoreFactory.Create());
238+
public override Task GetResultsReturnsResultsForExistingFunctions()
239+
=> GetResultsReturnsResultsForExistingFunctions(FunctionStoreFactory.Create());
248240

249241
[TestMethod]
250-
public override Task GetInterruptedFunctionsReturnsEmptyListWhenNoneFunctionsAreInterrupted()
251-
=> GetInterruptedFunctionsReturnsEmptyListWhenNoneFunctionsAreInterrupted(FunctionStoreFactory.Create());
242+
public override Task GetResultsReturnsEmptyDictionaryForEmptyInput()
243+
=> GetResultsReturnsEmptyDictionaryForEmptyInput(FunctionStoreFactory.Create());
252244

253245
[TestMethod]
254-
public override Task GetInterruptedFunctionsReturnsIdOnceWhenInterruptedMultipleTimes()
255-
=> GetInterruptedFunctionsReturnsIdOnceWhenInterruptedMultipleTimes(FunctionStoreFactory.Create());
246+
public override Task GetResultsReturnsOnlyExistingFunctionResults()
247+
=> GetResultsReturnsOnlyExistingFunctionResults(FunctionStoreFactory.Create());
256248

257249
[TestMethod]
258-
public override Task GetInterruptedFunctionsIncludesPostponedInterruptedFunction()
259-
=> GetInterruptedFunctionsIncludesPostponedInterruptedFunction(FunctionStoreFactory.Create());
250+
public override Task RestartExecutionsDoesNotReturnFlowClaimedByPreviousCall()
251+
=> RestartExecutionsDoesNotReturnFlowClaimedByPreviousCall(FunctionStoreFactory.Create());
260252

261253
[TestMethod]
262-
public override Task GetResultsReturnsResultsForExistingFunctions()
263-
=> GetResultsReturnsResultsForExistingFunctions(FunctionStoreFactory.Create());
254+
public override Task RestartExecutionsWithoutMessagesDoesNotReturnFlowClaimedByPreviousCall()
255+
=> RestartExecutionsWithoutMessagesDoesNotReturnFlowClaimedByPreviousCall(FunctionStoreFactory.Create());
264256

265257
[TestMethod]
266-
public override Task GetResultsReturnsEmptyDictionaryForEmptyInput()
267-
=> GetResultsReturnsEmptyDictionaryForEmptyInput(FunctionStoreFactory.Create());
258+
public override Task RestartExecutionsDoesNotClaimSucceededFlow()
259+
=> RestartExecutionsDoesNotClaimSucceededFlow(FunctionStoreFactory.Create());
268260

269261
[TestMethod]
270-
public override Task GetResultsReturnsOnlyExistingFunctionResults()
271-
=> GetResultsReturnsOnlyExistingFunctionResults(FunctionStoreFactory.Create());
262+
public override Task RestartExecutionsClaimsSuspendedFlow()
263+
=> RestartExecutionsClaimsSuspendedFlow(FunctionStoreFactory.Create());
272264
}

Core/Cleipnir.ResilientFunctions.Tests/InMemoryTests/WatchDogsTests/ReplicaWatchdogTests.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,8 @@ public override Task WorkIsDividedBetweenReplicas()
5353
[TestMethod]
5454
public override Task ReplicaCrashedFunctionIsTakenOverByOtherReplica()
5555
=> ReplicaCrashedFunctionIsTakenOverByOtherReplica(FunctionStoreFactory.Create());
56+
57+
[TestMethod]
58+
public override Task CrashedReplicasMessagesAreTakenOverByLiveReplica()
59+
=> CrashedReplicasMessagesAreTakenOverByLiveReplica(FunctionStoreFactory.Create());
5660
}

Core/Cleipnir.ResilientFunctions.Tests/Messaging/TestTemplates/MessageStoreTests.cs

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -637,13 +637,12 @@ protected async Task AppendedMultipleMessagesAtOnceCanBeFetchedAgain(Task<IFunct
637637
id2Msgs[0].MessageType.ShouldBe(stringType);
638638
id2Msgs[0].MessageContent.ShouldBe(msg1.ToJsonByteArray());
639639

640+
// appending messages no longer interrupts the target flows - the MessageWatchdog push delivers
640641
var sf1 = await functionStore.GetFunction(id1).ShouldNotBeNullAsync();
641-
sf1.Interrupted.ShouldBeTrue();
642-
sf1.Expires.ShouldBe(0);
643-
642+
sf1.Interrupted.ShouldBeFalse();
643+
644644
var sf2 = await functionStore.GetFunction(id2).ShouldNotBeNullAsync();
645-
sf2.Interrupted.ShouldBeTrue();
646-
sf2.Expires.ShouldBe(0);
645+
sf2.Interrupted.ShouldBeFalse();
647646
}
648647

649648
public abstract Task AppendedBatchedMessageCanBeFetchedAgain();
@@ -676,9 +675,9 @@ [new StoredMessage(msg.ToJsonByteArray(), stringType, Replica: ReplicaId.Empty,
676675
messages[0].MessageType.ShouldBe(stringType);
677676
messages[0].MessageContent.ShouldBe(msg.ToJsonByteArray());
678677

678+
// appending messages no longer interrupts the target flow - the MessageWatchdog push delivers
679679
var sf = await functionStore.GetFunction(id).ShouldNotBeNullAsync();
680-
sf.Interrupted.ShouldBeTrue();
681-
sf.Expires.ShouldBe(0);
680+
sf.Interrupted.ShouldBeFalse();
682681
}
683682

684683
public abstract Task AppendedBatchedMessagesWithPositionCanBeFetchedAgain();
@@ -739,11 +738,12 @@ await messageStore.AppendMessages(
739738
messagesId2[0].MessageType.ShouldBe(stringType);
740739
messagesId2[0].MessageContent.ToStringFromUtf8Bytes().ShouldBe(msg1String);
741740

741+
// appending messages no longer interrupts the target flows - the MessageWatchdog push delivers
742742
var sf1 = await functionStore.GetFunction(id1).ShouldNotBeNullAsync();
743-
sf1.Interrupted.ShouldBeTrue();
743+
sf1.Interrupted.ShouldBeFalse();
744744

745745
var sf2 = await functionStore.GetFunction(id2).ShouldNotBeNullAsync();
746-
sf2.Interrupted.ShouldBeTrue();
746+
sf2.Interrupted.ShouldBeFalse();
747747

748748
await messageStore.AppendMessages(
749749
[
@@ -761,8 +761,7 @@ await messageStore.AppendMessages(
761761
messagesId2[1].MessageContent.ToStringFromUtf8Bytes().ShouldBe(msg2String);
762762

763763
sf2 = await functionStore.GetFunction(id2).ShouldNotBeNullAsync();
764-
sf2.Interrupted.ShouldBeTrue();
765-
sf2.Expires.ShouldBe(0);
764+
sf2.Interrupted.ShouldBeFalse();
766765
}
767766

768767
public abstract Task MessagesForMultipleStoreIdsCanBeFetched();

Core/Cleipnir.ResilientFunctions.Tests/Messaging/TestTemplates/MessagesSubscriptionTests.cs

Lines changed: 18 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,6 @@ namespace Cleipnir.ResilientFunctions.Tests.Messaging.TestTemplates;
1818

1919
public abstract class MessagesSubscriptionTests
2020
{
21-
// These tests hand-roll a QueueManager, which delegates message deletion to IMessageClearer. They don't
22-
// assert on store cleanup, so a no-op stub suffices.
23-
private static readonly IMessageClearer StubMessageClearer = new NoopMessageClearer();
24-
private static readonly ClusterInfo StubClusterInfo = new(ReplicaId.NewId());
25-
26-
private sealed class NoopMessageClearer : IMessageClearer
27-
{
28-
public Task Clear(IReadOnlyList<long> positions) => Task.CompletedTask;
29-
public void ReopenPositions(IEnumerable<long> positions) { }
30-
}
31-
3221
public abstract Task EventsSubscriptionSunshineScenario();
3322
protected async Task EventsSubscriptionSunshineScenario(Task<IFunctionStore> functionStoreTask)
3423
{
@@ -558,42 +547,17 @@ protected async Task QueueManagerFailsOnMessageDeserializationError(Task<IFuncti
558547
{
559548
var functionStore = await functionStoreTask;
560549
var unhandledExceptionCatcher = new UnhandledExceptionCatcher();
561-
var unhandledExceptionHandler = new UnhandledExceptionHandler(unhandledExceptionCatcher.Catch);
562550
var exceptionThrowingSerializer = new ExceptionThrowingEventSerializer(typeof(BadMessage));
563551
using var functionsRegistry = new FunctionsRegistry(
564552
functionStore,
565-
new Settings(unhandledExceptionCatcher.Catch)
553+
new Settings(unhandledExceptionCatcher.Catch, serializer: exceptionThrowingSerializer)
566554
);
567555

568556
var rFunc = functionsRegistry.RegisterFunc(
569557
nameof(QueueManagerFailsOnMessageDeserializationError),
570558
inner: async Task<string> (string _, Workflow workflow) =>
571559
{
572-
573-
var flowTimeouts = new FlowTimeouts();
574-
var flowsManager = new FlowsManager(functionStore, StubMessageClearer, StubClusterInfo);
575-
var flowState = flowsManager.CreateFlowState(workflow.StoredId, flowTimeouts, completed: ForeverTask.Instance);
576-
var queueManager = new QueueManager(
577-
workflow.FlowId,
578-
workflow.StoredId,
579-
functionStore.MessageStore,
580-
exceptionThrowingSerializer,
581-
workflow.Effect,
582-
flowState,
583-
unhandledExceptionHandler,
584-
flowTimeouts,
585-
() => DateTime.UtcNow,
586-
SettingsWithDefaults.Default,
587-
StubMessageClearer
588-
);
589-
590-
var queueClient = await queueManager.CreateQueueClient();
591-
592-
var message = await queueClient.Pull<GoodMessage>(
593-
workflow,
594-
workflow.Effect.CreateNextImplicitId()
595-
);
596-
560+
var message = await workflow.Message<GoodMessage>();
597561
return message.Value;
598562
}
599563
);
@@ -622,61 +586,36 @@ protected async Task RegisteredTimeoutIsRemovedWhenPullingMessage(Task<IFunction
622586
{
623587
var functionStore = await functionStoreTask;
624588
var unhandledExceptionCatcher = new UnhandledExceptionCatcher();
625-
var unhandledExceptionHandler = new UnhandledExceptionHandler(unhandledExceptionCatcher.Catch);
626589
using var functionsRegistry = new FunctionsRegistry(
627590
functionStore,
628591
new Settings(unhandledExceptionCatcher.Catch)
629592
);
630593

631-
StoredId? storedId = null;
632594
var rFunc = functionsRegistry.RegisterFunc(
633595
nameof(RegisteredTimeoutIsRemovedWhenPullingMessage),
634596
inner: async Task<string> (string _, Workflow workflow) =>
635597
{
636-
storedId = workflow.StoredId;
637-
var minimumTimeout = new FlowTimeouts();
638-
var flowsManager = new FlowsManager(functionStore, StubMessageClearer, StubClusterInfo);
639-
var flowState = flowsManager.CreateFlowState(workflow.StoredId, minimumTimeout, completed: ForeverTask.Instance);
640-
var queueManager = new QueueManager(
641-
workflow.FlowId,
642-
workflow.StoredId,
643-
functionStore.MessageStore,
644-
DefaultSerializer.Instance,
645-
workflow.Effect,
646-
flowState,
647-
unhandledExceptionHandler,
648-
minimumTimeout,
649-
() => DateTime.UtcNow,
650-
SettingsWithDefaults.Default,
651-
StubMessageClearer
652-
);
653-
654-
655-
var queueClient = await queueManager.CreateQueueClient();
656-
657-
// Verify timeout is not set before pull
658-
minimumTimeout.MinimumTimeout.ShouldBeNull();
659-
660-
var message = await queueClient.Pull<string>(
661-
workflow,
662-
workflow.Effect.CreateNextImplicitId(),
663-
timeout: TimeSpan.FromMinutes(5)
664-
);
665-
666-
// Verify timeout is removed after successful pull
667-
minimumTimeout.MinimumTimeout.ShouldBeNull();
668-
598+
// Pull a message with a 1-hour timeout (which registers the timeout), then suspend on a 1-day delay.
599+
// Once the message is delivered the 1-hour timeout must be removed, leaving the 1-day delay as the
600+
// only (minimum) timeout - so the flow is postponed ~1 day out. If the message timeout lingered the
601+
// postpone would instead be ~1 hour.
602+
var message = await workflow.Message<string>(TimeSpan.FromHours(1));
603+
await workflow.Delay(TimeSpan.FromDays(1));
669604
return message!;
670605
}
671606
);
672607

673-
var scheduled = await rFunc.Schedule("instanceId", "");
608+
await rFunc.Schedule("instanceId", "");
674609
var messageWriter = rFunc.MessageWriters.For("instanceId".ToFlowInstance());
675610
await messageWriter.AppendMessage("test message");
676611

677-
var result = await scheduled.Completion(timeout: TimeSpan.FromSeconds(5));
678-
result.ShouldBe("test message");
612+
var controlPanel = await rFunc.ControlPanel("instanceId").ShouldNotBeNullAsync();
613+
await controlPanel.BusyWaitUntil(
614+
c => c.Status == Status.Postponed && c.PostponedUntil > DateTime.UtcNow + TimeSpan.FromHours(12),
615+
maxWait: TimeSpan.FromSeconds(10)
616+
);
679617

618+
controlPanel.PostponedUntil!.Value.ShouldBeGreaterThan(DateTime.UtcNow + TimeSpan.FromHours(23));
680619
unhandledExceptionCatcher.ShouldNotHaveExceptions();
681620
}
682621

@@ -685,7 +624,6 @@ protected async Task PullEnvelopeReturnsEnvelopeWithReceiverAndSender(Task<IFunc
685624
{
686625
var functionStore = await functionStoreTask;
687626
var unhandledExceptionCatcher = new UnhandledExceptionCatcher();
688-
var unhandledExceptionHandler = new UnhandledExceptionHandler(unhandledExceptionCatcher.Catch);
689627
using var functionsRegistry = new FunctionsRegistry(
690628
functionStore,
691629
new Settings(unhandledExceptionCatcher.Catch)
@@ -695,27 +633,9 @@ protected async Task PullEnvelopeReturnsEnvelopeWithReceiverAndSender(Task<IFunc
695633
nameof(PullEnvelopeReturnsEnvelopeWithReceiverAndSender),
696634
inner: async Task<string> (string _, Workflow workflow) =>
697635
{
698-
699-
var flowTimeouts = new FlowTimeouts();
700-
var flowsManager = new FlowsManager(functionStore, StubMessageClearer, StubClusterInfo);
701-
var flowState = flowsManager.CreateFlowState(workflow.StoredId, flowTimeouts, completed: ForeverTask.Instance);
702-
var queueManager = new QueueManager(
703-
workflow.FlowId,
704-
workflow.StoredId,
705-
functionStore.MessageStore,
706-
DefaultSerializer.Instance,
707-
workflow.Effect,
708-
flowState,
709-
unhandledExceptionHandler,
710-
flowTimeouts,
711-
() => DateTime.UtcNow,
712-
SettingsWithDefaults.Default,
713-
StubMessageClearer
714-
);
715-
716-
var queueClient = await queueManager.CreateQueueClient();
717-
718-
// Pull envelope for specific receiver
636+
// Use the flow's own (production) queue manager - not a hand-rolled instance - to pull the envelope
637+
// and read its receiver/sender metadata.
638+
var queueClient = await workflow.QueueManager.CreateQueueClient();
719639
var envelope = await queueClient.PullEnvelope<string>(
720640
workflow,
721641
workflow.Effect.CreateNextImplicitId(),
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
using System.Runtime.CompilerServices;
2+
using System.Threading;
13
using Microsoft.VisualStudio.TestTools.UnitTesting;
24

35
[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)]
6+
7+
internal static class TestModuleInitializer
8+
{
9+
[ModuleInitializer]
10+
public static void Init()
11+
{
12+
// Every test spins up FunctionsRegistry instances each running several Task.Delay-based watchdogs. Under a
13+
// small-core CI runner the thread pool grows only ~1 thread/sec, so these continuations - and the tests' own
14+
// timeout timers - queue behind pool growth, spiking message-delivery latency. Pre-size the pool so work is
15+
// serviced immediately instead of waiting for the pool to grow.
16+
ThreadPool.GetMinThreads(out _, out var completionPortThreads);
17+
ThreadPool.SetMinThreads(workerThreads: 200, completionPortThreads);
18+
}
19+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ async Task<string>(string param, Workflow workflow) =>
6464
counter.Increment();
6565
if (counter.Current == 1)
6666
{
67-
await workflow.Delay(TimeSpan.FromMilliseconds(10));
67+
await workflow.Delay(TimeSpan.FromMilliseconds(500));
6868
return "nothing";
6969
}
7070

@@ -105,7 +105,7 @@ async Task<Person>(string param, Workflow workflow) =>
105105
counter.Increment();
106106
if (counter.Current == 1)
107107
{
108-
await workflow.Delay(TimeSpan.FromMilliseconds(10));
108+
await workflow.Delay(TimeSpan.FromMilliseconds(500));
109109
return null!;
110110
}
111111

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ await workflow.Effect
3131
{
3232
counter.Increment();
3333
if (counter.Current == 1)
34-
await workflow.Delay(TimeSpan.FromMilliseconds(10));
34+
await workflow.Delay(TimeSpan.FromMilliseconds(500));
3535
}
3636
);
3737
});
@@ -65,7 +65,7 @@ await workflow.Effect
6565
{
6666
counter.Increment();
6767
if (counter.Current == 1)
68-
await workflow.Delay(TimeSpan.FromMilliseconds(10));
68+
await workflow.Delay(TimeSpan.FromMilliseconds(500));
6969
return Task.CompletedTask;
7070
}
7171
);

0 commit comments

Comments
 (0)