Skip to content

Commit 58b63d2

Browse files
committed
Capture received messages as per-message child effects in QueueManager
Each message a running flow receives is now durably captured as its own child effect (under a dedicated ReceivedMessagesRoot id) the moment it is staged in ProcessMessages, and deleted again when the message is delivered or idempotency-deduped. The write uses the new FlushlessCreateNextChild primitive and is flushless, so it costs no I/O for the running flow and dies with an equally-unflushed delivery - recovery stays store-backed and at-least-once. Initialize rebuilds the position->child map from GetChildren and re-stages the children a prior incarnation left behind; a child whose position was already delivered is pruned rather than re-delivered. A dedicated reserved id ([-1,2]) is used rather than PendingMessages.EffectId because FlushlessClear cascades to children, so sharing the parent would let the completed-flow blob's clear delete these children. The completed-flow inline blob path is left untouched; the two carriers coexist.
1 parent 9ff8cb3 commit 58b63d2

2 files changed

Lines changed: 59 additions & 7 deletions

File tree

Core/Cleipnir.ResilientFunctions/Queuing/PendingMessages.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public static List<StoredMessage> Decode(byte[] bytes)
3232
.Select(messageBytes => DecodeMessage(messageBytes!))
3333
.ToList();
3434

35-
private static byte[] EncodeMessage(StoredMessage message)
35+
public static byte[] EncodeMessage(StoredMessage message)
3636
=> BinaryPacker.Pack(
3737
message.MessageContent,
3838
message.MessageType,
@@ -42,7 +42,7 @@ private static byte[] EncodeMessage(StoredMessage message)
4242
message.Receiver?.ToUtf8Bytes()
4343
);
4444

45-
private static StoredMessage DecodeMessage(byte[] bytes)
45+
public static StoredMessage DecodeMessage(byte[] bytes)
4646
{
4747
var parts = BinaryPacker.Split(bytes, expectedPieces: 6);
4848
return new StoredMessage(

Core/Cleipnir.ResilientFunctions/Queuing/QueueManager.cs

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ internal class QueueManager : IDisposable
2121
private const int ReservedIdPrefix = -1;
2222
private static readonly EffectId DeliveredPositionsId = new([ReservedIdPrefix, 0]);
2323
private static readonly EffectId IdempotencyKeysRoot = new([ReservedIdPrefix, -1]);
24+
// Parent of the per-message received-message children (see _pendingMessageChildren). A dedicated id - not
25+
// PendingMessages.EffectId - because FlushlessClear cascades to children, and the completed-flow blob lives
26+
// at (and is cleared via) PendingMessages.EffectId; keeping the carriers on separate ids stops the blob's
27+
// clear from deleting these children.
28+
private static readonly EffectId ReceivedMessagesRoot = new([ReservedIdPrefix, 2]);
2429

2530
private readonly FlowId _flowId;
2631
private readonly StoredId _storedId;
@@ -44,6 +49,10 @@ internal class QueueManager : IDisposable
4449
// Messages inlined into the pending-messages effect while the flow was completed - staged at initialization
4550
// and pruned from the durable entry as they are delivered (see PruneDeliveredPendingMessage).
4651
private readonly Dictionary<long, StoredMessage> _pendingInlinedMessages = new();
52+
// Received messages captured as individual child effects under ReceivedMessagesRoot (position -> child id).
53+
// A child is created the moment a message is staged in ProcessMessages and deleted again when the message is
54+
// delivered or deduped - the running-flow analogue of _pendingInlinedMessages' completed-flow blob.
55+
private readonly Dictionary<long, EffectId> _pendingMessageChildren = new();
4756
private volatile Exception? _thrownException;
4857
private bool _initialized;
4958
private volatile bool _disposed;
@@ -105,6 +114,32 @@ private async Task Initialize()
105114
_effect.FlushlessUpsert(DeliveredPositionsId, positions, alias: null);
106115
}
107116

117+
// Re-stage the received-message children a prior incarnation left behind: each message it had staged
118+
// but not yet delivered persists as its own child effect. A child whose position was already
119+
// delivered (replayed above) is pruned rather than re-delivered - the analogue of the delivered-
120+
// positions store-row clear above.
121+
var stagedChildren = new List<StoredMessage>();
122+
foreach (var childId in _effect.GetChildren(ReceivedMessagesRoot))
123+
{
124+
var message = PendingMessages.DecodeMessage(_effect.Get<byte[]>(childId));
125+
126+
bool alreadyDelivered;
127+
lock (_lock)
128+
alreadyDelivered = _fetchedPositions.Contains(message.Position);
129+
if (alreadyDelivered)
130+
{
131+
_effect.FlushlessClear(childId);
132+
continue;
133+
}
134+
135+
// Pre-register the child so ProcessMessages re-stages it without creating a second child.
136+
lock (_lock)
137+
_pendingMessageChildren[message.Position] = childId;
138+
stagedChildren.Add(message);
139+
}
140+
if (stagedChildren.Count > 0)
141+
ProcessMessages(stagedChildren);
142+
108143
// Stage messages that were inlined into the effect state while the flow was completed (their store
109144
// rows are deleted, so this entry is their only carrier). ProcessMessages dedups them against the
110145
// replayed delivered positions and the persisted idempotency keys; running it here without the fetch
@@ -260,8 +295,10 @@ public async Task Push(IReadOnlyList<StoredMessage> messages)
260295
// position (so pushes are idempotent), and stages messages for delivery.
261296
private void ProcessMessages(IReadOnlyList<StoredMessage> messages)
262297
{
263-
foreach (var (messageContent, messageType, position, _, idempotencyKey, sender, receiver) in messages)
298+
foreach (var message in messages)
264299
{
300+
var (messageContent, messageType, position, _, idempotencyKey, sender, receiver) = message;
301+
265302
bool alreadyFetched;
266303
lock (_lock)
267304
alreadyFetched = _fetchedPositions.Contains(position);
@@ -302,6 +339,15 @@ private void ProcessMessages(IReadOnlyList<StoredMessage> messages)
302339
else
303340
_toDeliver.Insert(insertAt, messageData);
304341
_fetchedPositions.Add(position);
342+
343+
// Durably capture the received message as its own child effect the moment it is staged; it is
344+
// deleted again when the message is delivered or idempotency-deduped (PruneDeliveredPendingMessage).
345+
// Flushless, so it costs no I/O and dies with an equally-unflushed delivery - recovery then stays
346+
// store-backed and at-least-once. Skipped when the position already has a child (re-staged from an
347+
// existing child at initialization).
348+
if (!_pendingMessageChildren.ContainsKey(position))
349+
_pendingMessageChildren[position] =
350+
_effect.FlushlessCreateNextChild(ReceivedMessagesRoot, PendingMessages.EncodeMessage(message));
305351
}
306352
}
307353
catch (Exception e)
@@ -387,12 +433,18 @@ private void DeliverMessages()
387433
}
388434
}
389435

390-
// Caller must hold _lock. Removes a delivered (or idempotency-deduped) message from the durable
391-
// pending-messages entry so a later incarnation does not re-stage it after the delivered-positions dedup
392-
// state has been cleared. Flushless on purpose: dying with an unflushed prune replays the message together
393-
// with the equally unflushed delivery - at-least-once, exactly like a store-resident message.
436+
// Caller must hold _lock. Removes a delivered (or idempotency-deduped) message from its durable carrier - the
437+
// per-message child effect (running flow) and/or the completed-flow inline blob - so a later incarnation does
438+
// not re-stage it after the delivered-positions dedup state has been cleared. Flushless on purpose: dying with
439+
// an unflushed prune replays the message together with the equally unflushed delivery - at-least-once, exactly
440+
// like a store-resident message.
394441
private void PruneDeliveredPendingMessage(long position)
395442
{
443+
// Running-flow carrier: the message was captured as its own child effect - delete just that child.
444+
if (_pendingMessageChildren.Remove(position, out var childId))
445+
_effect.FlushlessClear(childId);
446+
447+
// Completed-flow carrier: the message came from the inline blob - rewrite the blob without it.
396448
if (!_pendingInlinedMessages.Remove(position))
397449
return;
398450

0 commit comments

Comments
 (0)