@@ -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