-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathingress.go
More file actions
1381 lines (1176 loc) · 47.1 KB
/
Copy pathingress.go
File metadata and controls
1381 lines (1176 loc) · 47.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package serverconn
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/lightninglabs/wavelength/baselib/actor"
mailboxconn "github.com/lightninglabs/wavelength/mailbox/conn"
mailboxpb "github.com/lightninglabs/wavelength/mailbox/pb"
mailboxrpc "github.com/lightninglabs/wavelength/mailbox/rpc"
"github.com/lightninglabs/wavelength/serverconn/mailboxpull"
)
// redriveState is what the ingress loop remembers across the re-pulls of one
// backpressure episode. Both fields exist to keep a redrive from repeating work
// the previous cycle already did: the loop re-pulls the same window every
// backoff cycle for as long as a target stays wedged, and the pre-transaction
// half of a folded dispatch is not idempotent from the operator's point of
// view.
type redriveState struct {
// deferredSeq is the event_seq of the envelope a full mailbox turned
// away last cycle, or zero when no deferral is outstanding. It clamps
// the next redrive to the envelopes at or before it: nothing past it
// can commit until that one is delivered, so handling the rest again is
// pure duplicate effort.
deferredSeq uint64
// servedNonTxSeq is the highest event_seq of a hoisted request this
// loop has already answered over the network. A request beyond the
// cursor a deferred cycle commits is served optimistically — the send
// has to precede the commit, or a crash in between would advance the
// cursor past a request nobody answered — and it then stays in the pull
// window until the cursor reaches it. This watermark is what makes that
// one serve instead of one per cycle.
servedNonTxSeq uint64
}
// ingressLoop is the main pull-dispatch-ack loop. It runs in its own
// goroutine, started from ServerConnectionActor.StartIngress. The loop:
//
// 1. Loads persisted ack watermark state from the checkpoint store.
// 2. Continuously pulls envelopes from the remote mailbox.
// 3. Dispatches each envelope to the appropriate local actor or response
// waiter.
// 4. Advances the ack watermark only after durable dispatch commits.
// 5. Calls AckUpTo on the remote mailbox to release processed envelopes.
//
// On transient failures, the loop backs off with exponential delay and
// jitter to prevent busy-spinning.
func (a *ServerConnectionActor) ingressLoop(ctx context.Context,
state AckState) {
defer a.wg.Done()
a.log.InfoS(ctx, "Ingress loop starting",
slog.String("mailbox_id", a.cfg.LocalMailboxID),
)
var failCount int
// pullFailCount scopes alert suppression to one remote pull outage.
// failCount is shared by every transport and checkpoint backoff, so it
// cannot identify whether a pull failure is the first in its episode.
var pullFailCount int
// When the delivery store supports transactions, each pulled batch is
// dispatched and checkpointed in ONE write transaction below. The
// ack watermark then rides along with the next dispatch checkpoint
// instead of paying its own commit; ackDirty tracks the in-memory
// advance until some checkpoint persists it.
txStore, txOK := a.cfg.Store.(actor.TxAwareDeliveryStore)
var ackDirty bool
// episode tracks an open backpressure episode, so a target that has
// stopped draining is logged on an interval rather than on every
// re-pull. It lives here, on the single ingress goroutine's stack,
// because that is the only place that sees every dispatch outcome in
// order.
var episode deferralEpisode
// deferFailCount is the redrive schedule for backpressure, kept apart
// from the transport failCount above because the two failures want
// opposite cadences. A black-holed connection should be retried slowly;
// a full local mailbox should be redriven as soon as it might have
// room, because the redrive is the only thing draining the client's
// inbound backlog. Sharing one counter made a recovering target drain
// at one pull per RetryMaxDelay.
var deferFailCount int
// redrive is what this loop remembers about a deferred cycle so the
// next one does not repeat its work. It lives here for the same reason
// as episode: only this goroutine sees every dispatch outcome in order.
// A fresh ingressLoop starts with a fresh one, which is the right scope
// — after a restart the cursor is the only surviving state and
// redelivery is the documented behaviour.
var redrive redriveState
for {
select {
case <-ctx.Done():
a.logIngressExit(ctx)
return
default:
}
// Step 1: Ack pending dispatches before pulling more so the
// remote mailbox can garbage-collect committed envelopes.
if exit, retry := a.ackPhase(
ctx, &state, &ackDirty, &failCount, txOK,
); exit {
return
} else if retry {
continue
}
// Step 2: Pull a batch of envelopes from the remote mailbox.
envelopes, nextCursor, exit, retry := a.pullPhase(
ctx, &state, &ackDirty, &failCount, &pullFailCount,
)
if exit {
return
} else if retry {
continue
}
// Step 3 (transactional path): deliver in-memory responses
// outside the transaction, then fold the durable dispatches
// and the advanced watermark into one commit.
if txOK {
newState, foldErr := a.runFoldedDispatch(
ctx, txStore, envelopes, nextCursor, state,
&redrive,
)
if foldErr != nil {
// A full target mailbox is backpressure, not a
// failure. The commit still covered the prefix
// that was delivered, so the advanced state is
// adopted and the re-pull resumes at the
// undelivered envelope instead of redelivering
// everything ahead of it.
deferral := a.noteDispatchDeferral(
ctx, foldErr, &episode,
)
if deferral != nil {
// A cursor that moved means the last
// redrive delivered part of the
// backlog, which is progress and not a
// failure to back off from: the
// schedule starts over so a draining
// target is redriven promptly instead
// of at the ceiling. It also means
// events ARE getting through, so the
// traffic gauge is stamped for the
// partial commit.
if newState.PullCursor >
state.PullCursor {
deferFailCount = 0
markIngressEvent()
}
state = newState
ackDirty = false
redrive.deferredSeq = deferral.eventSeq
a.sleepDeferralBackoff(
ctx, &deferFailCount,
)
continue
}
// A permanent inbound version mismatch is
// terminal: stop the loop WITHOUT advancing the
// cursor so the offending envelope is preserved
// and never acknowledged, matching the legacy
// dispatch path below. The production store is
// transactional, so this is the path a real
// daemon takes.
if a.checkPermanentStatus(ctx, foldErr) {
return
}
a.log.WarnS(ctx,
"Transactional dispatch failed",
foldErr,
slog.Uint64(
"cursor", state.PullCursor,
))
a.sleepBackoff(ctx, &failCount)
continue
}
// The commit covered the dispatch watermark and any
// pending ack advance together.
state = newState
ackDirty = false
failCount = 0
deferFailCount = 0
redrive.deferredSeq = 0
a.clearDispatchDeferral(ctx, &episode)
markIngressEvent()
continue
}
// Step 3 (legacy path): dispatch the batch. On partial
// failure, the committed cursor reflects only the
// successfully dispatched portion.
committedCursor, dispatchErr := a.dispatchBatch(
ctx, envelopes, nextCursor,
)
if dispatchErr != nil {
// A full target mailbox is backpressure rather than a
// dispatch failure, and it gets its own throttled log
// instead of one line per re-pull. The partial advance
// below is the same in either case: the cursor stops at
// the undelivered envelope.
//
// This path needs no deferredSeq clamp. It dispatches
// the batch strictly in order and stops at the
// deferral, so nothing past the undelivered envelope is
// ever touched to begin with.
deferred := a.noteDispatchDeferral(
ctx, dispatchErr, &episode,
) != nil
// A permanent inbound version mismatch is terminal:
// stop the loop WITHOUT advancing the cursor so the
// offending envelope is preserved for a future
// compatible restart, and never acknowledged.
if !deferred && a.checkPermanentStatus(
ctx, dispatchErr,
) {
return
}
if !deferred {
a.log.WarnS(ctx, "Dispatch failed",
dispatchErr,
slog.Uint64(
"committed_to", committedCursor,
),
)
}
// Even on partial failure, advance state past the
// last committed envelope so we don't re-dispatch
// it. dispatchBatch returns the inclusive event_seq
// of the last successfully dispatched envelope, so
// we add 1 to get the exclusive next-pull position,
// consistent with batchNextCursor on the success
// path.
nextCursor := committedCursor + 1
progressed := committedCursor > 0 &&
nextCursor > state.PullCursor
if progressed {
state.AdvanceDispatch(nextCursor)
state.PullCursor = nextCursor
if cpErr := a.saveCheckpoint(
ctx, state,
); cpErr != nil {
a.log.WarnS(ctx,
"Failed to save checkpoint "+
"after partial dispatch",
cpErr)
}
}
// Backpressure gets the short redrive schedule, and a
// redrive that delivered part of the backlog resets it;
// a real dispatch failure keeps the transport schedule.
// The reasoning is the same as on the folded path
// above.
if deferred {
if progressed {
deferFailCount = 0
markIngressEvent()
}
a.sleepDeferralBackoff(ctx, &deferFailCount)
continue
}
a.sleepBackoff(ctx, &failCount)
continue
}
// Step 4: Full batch dispatched successfully. Advance state
// and persist checkpoint.
state.AdvanceDispatch(committedCursor)
state.PullCursor = committedCursor
if err := a.saveCheckpoint(ctx, state); err != nil {
a.log.WarnS(
ctx,
"Failed to save checkpoint after dispatch",
err,
)
a.sleepBackoff(ctx, &failCount)
continue
}
failCount = 0
deferFailCount = 0
a.clearDispatchDeferral(ctx, &episode)
markIngressEvent()
}
}
// ackPhase acks any pending dispatches before the next pull so the remote
// mailbox can garbage-collect committed envelopes. It mutates state, ackDirty,
// and failCount in place and returns two loop-control booleans, (exit, retry):
// exit is true when the loop must stop (local shutdown or a permanent version
// error), and retry is true when the caller should back off and continue. On
// the transactional path the advanced watermark is left dirty for the next
// dispatch checkpoint (or idle flush) to persist; the legacy path checkpoints
// inline.
func (a *ServerConnectionActor) ackPhase(ctx context.Context, state *AckState,
ackDirty *bool, failCount *int, txOK bool) (bool, bool) {
if !state.NeedsAck() {
return false, false
}
if err := a.ackRemote(ctx, state.AckTarget); err != nil {
if isIngressShutdownErr(ctx, err) {
a.logIngressExit(ctx)
return true, false
}
// A permanent version error is terminal: stop the loop rather
// than retrying forever.
if a.checkPermanentStatus(ctx, err) {
return true, false
}
a.log.WarnS(ctx, "AckUpTo failed, retrying",
err,
slog.Uint64("ack_target", state.AckTarget),
)
a.sleepBackoff(ctx, failCount)
return false, true
}
state.AdvanceAck()
// On the transactional path the advanced watermark is persisted by the
// next dispatch checkpoint (or the idle flush); losing it to a crash
// only costs one redundant idempotent AckUpTo on restart. The legacy
// path keeps the immediate checkpoint.
if txOK {
*ackDirty = true
*failCount = 0
return false, false
}
if err := a.saveCheckpoint(ctx, *state); err != nil {
a.log.WarnS(
ctx, "Failed to save checkpoint after ack", err,
)
// Don't reset failCount — if the checkpoint store is
// persistently down, we want backoff to apply on subsequent
// iterations rather than spinning at full speed.
a.sleepBackoff(ctx, failCount)
return false, true
}
*failCount = 0
return false, false
}
// pullPhase pulls the next batch of envelopes from the remote mailbox and
// absorbs the two outcomes that are not a batch to dispatch: a failed pull and
// an empty long-poll. It mutates state, ackDirty, the shared backoff counter,
// and the pull-only failure counter in place. It returns (envelopes,
// nextCursor, exit, retry) on the same convention as ackPhase — exit is true
// when the loop must stop (local shutdown or a permanent version error), and
// retry is true when the caller should continue without dispatching. Both
// booleans are false only when envelopes holds a non-empty batch, and any
// backoff a retry needs has already been slept here.
func (a *ServerConnectionActor) pullPhase(ctx context.Context, state *AckState,
ackDirty *bool, failCount, pullFailCount *int) ([]*mailboxpb.Envelope,
uint64, bool, bool) {
envelopes, nextCursor, err := a.pullBatch(ctx, state.PullCursor)
if err != nil {
if isIngressShutdownErr(ctx, err) {
a.logIngressExit(ctx)
return nil, 0, true, false
}
// A permanent version error is terminal: stop the loop rather
// than retrying forever.
if a.checkPermanentStatus(ctx, err) {
return nil, 0, true, false
}
if *pullFailCount == 0 {
a.log.WarnS(ctx, "Pull failed, retrying",
err,
slog.Uint64("cursor", state.PullCursor),
)
} else {
a.log.DebugS(ctx, "Pull retry failed",
slog.Any("err", err),
slog.Uint64("cursor", state.PullCursor),
slog.Int(
"consecutive_failures",
*pullFailCount+1,
),
)
}
*pullFailCount++
a.sleepBackoff(ctx, failCount)
return nil, 0, false, true
}
*pullFailCount = 0
// The pull returned, so the one goroutine that consumes the remote
// mailbox is still running its loop. Stamping here rather than after
// dispatch is what lets a staleness alert separate a parked ingress
// goroutine from a client that simply has no traffic: an idle client
// keeps this gauge fresh at the long-poll cadence, and a parked one
// stops updating it immediately.
markIngressPoll()
if len(envelopes) == 0 {
// Long-poll returned empty. Flush a dirty ack watermark while
// the connection is idle so a restart does not re-ack forever.
if *ackDirty {
if err := a.saveCheckpoint(ctx, *state); err != nil {
// Back off on a failing checkpoint store rather
// than retrying at the bare long-poll cadence,
// mirroring the ack-path policy in ackPhase.
// ackDirty stays set so the next attempt
// re-flushes.
a.log.WarnS(ctx,
"Failed to flush ack checkpoint "+
"while idle", err)
a.sleepBackoff(ctx, failCount)
return nil, 0, false, true
}
*ackDirty = false
}
// Reset fail count and loop again immediately — the long-poll
// timeout already provides the delay.
*failCount = 0
return nil, 0, false, true
}
a.log.TraceS(
ctx, "Pulled envelopes",
slog.Int("count", len(envelopes)),
slog.Uint64("cursor", state.PullCursor),
slog.Uint64("next_cursor", nextCursor),
)
return envelopes, nextCursor, false, false
}
// logIngressExit emits the common ingress shutdown log line.
func (a *ServerConnectionActor) logIngressExit(ctx context.Context) {
a.log.InfoS(ctx, "Ingress loop exiting",
slog.String("mailbox_id", a.cfg.LocalMailboxID),
)
}
// isIngressShutdownErr reports whether err is an expected result of shutting
// down the ingress loop. Only local loop-context cancellation is terminal; a
// remote transport cancellation can be transient and must stay retryable.
func isIngressShutdownErr(ctx context.Context, err error) bool {
if err == nil {
return false
}
return ctx.Err() != nil
}
// pullBatch calls Edge.Pull and returns the envelopes and next cursor.
func (a *ServerConnectionActor) pullBatch(ctx context.Context, cursor uint64) (
[]*mailboxpb.Envelope, uint64, error) {
waitMs := uint32(a.cfg.PullWaitTimeout.Milliseconds())
resp, err := a.cfg.Edge.Pull(ctx, &mailboxpb.PullRequest{
MailboxId: a.cfg.LocalMailboxID,
MaxEnvelopes: a.cfg.PullMaxEnvelopes,
WaitTimeoutMs: waitMs,
Cursor: cursor,
})
if sErr := edgeResponseError("Pull", resp, err); sErr != nil {
return nil, 0, sErr
}
return resp.Envelopes, resp.NextCursor, nil
}
// dispatchBatch iterates envelopes and routes each one to the correct
// destination:
//
// - KIND_RESPONSE: delivered to the response registry (unary waiters), or
// durably dispatched via the configured dispatch table when no waiter is
// registered for the correlation ID.
// - KIND_REQUEST/KIND_EVENT: dispatched to a local actor via the configured
// dispatch table.
//
// On success, returns the exclusive batch-next cursor (one past the last
// envelope). On partial failure, returns the inclusive event_seq of the
// last successfully dispatched envelope along with the error. The caller
// must add 1 to the error-path return value to get the exclusive cursor.
func (a *ServerConnectionActor) dispatchBatch(ctx context.Context,
envelopes []*mailboxpb.Envelope, batchNextCursor uint64) (uint64,
error) {
// Track the cursor of the last successfully dispatched envelope.
// Start with the current pull cursor as the base.
lastCommitted := uint64(0)
for _, env := range envelopes {
// Validate the envelope's version pair against the runtime
// binding before delivering it to any waiter or dispatcher. A
// mismatch is a permanent compatibility failure: stop the batch
// without advancing the ack cursor so the envelope is preserved
// for a future compatible restart, and never acknowledge or
// dispatch it.
if err := a.validateInboundEnvelope(env); err != nil {
return lastCommitted, err
}
if env.Rpc == nil {
a.log.WarnS(
ctx,
"Skipping envelope without RPC metadata",
nil,
slog.Uint64("event_seq", env.EventSeq),
)
continue
}
// A marked route's dispatcher answers the operator over the
// network, and the hoist gate has already pulled every
// KIND_REQUEST on such a route out of the fold. Anything else
// arriving on one is mislabeled by the sender, and the mux
// bridge does not look at the kind: it would serve the
// envelope as a request anyway and put that round trip back
// under the write transaction, which is the exact stall the
// split exists to remove. Skip it the way the table skips any
// other envelope it cannot route, so the batch still makes
// progress. Returning an error instead would be worse than
// the stall: a dispatch failure is not permanent, so the loop
// would back off and re-pull the same envelope forever.
if a.resolvesToNonTxDispatcher(env) {
a.log.WarnS(
ctx,
"Skipping non-request envelope on a "+
"non-transactional route",
nil,
slog.String("service", env.Rpc.Service),
slog.String("method", env.Rpc.Method),
slog.Int("kind", int(env.Rpc.Kind)),
slog.Uint64("event_seq", env.EventSeq),
)
continue
}
switch env.Rpc.Kind {
case mailboxpb.RpcMeta_KIND_RESPONSE:
// Prefer unary waiters for low-latency RPC
// callers. When no in-memory waiter is registered,
// fall back to the durable dispatch table so
// actor-driven unary flows can treat the response
// like any other ingress event.
corrID := CorrelationID(env.Rpc.CorrelationId)
if corrID == "" {
a.log.WarnS(ctx,
"Response envelope missing "+
"correlation ID",
nil,
slog.Uint64("event_seq",
env.EventSeq))
continue
}
delivery := a.deliverResponse(corrID, env)
if delivery == mailboxconn.DeliveryWaiter {
break
}
svcMethod := mailboxrpc.ServiceMethod{
Service: env.Rpc.Service,
Method: env.Rpc.Method,
}
dispatcher, ok := a.cfg.Dispatchers[svcMethod]
if !ok {
a.log.WarnS(ctx,
"Failed to deliver response "+
"envelope",
nil,
slog.String(
"delivery_result",
fmt.Sprintf("%d", delivery),
),
slog.String("service", env.Rpc.Service),
slog.String("method", env.Rpc.Method),
slog.String(
"correlation_id",
string(corrID),
),
slog.Uint64("event_seq",
env.EventSeq))
break
}
if err := dispatcher(ctx, env); err != nil {
return lastCommitted, err
}
if delivery == mailboxconn.DeliveryBuffered {
a.removePendingResponse(corrID)
}
case mailboxpb.RpcMeta_KIND_REQUEST,
mailboxpb.RpcMeta_KIND_EVENT:
// Dispatch to local actor via the dispatch table.
// The dispatcher is a closure that does
// serviceKey.Ref(system).Tell(ctx, msg). A nil error
// means the target durable actor persisted the
// message.
key := mailboxrpc.ServiceMethod{
Service: env.Rpc.Service,
Method: env.Rpc.Method,
}
dispatcher, ok := a.cfg.Dispatchers[key]
if !ok {
a.log.WarnS(ctx,
"No dispatcher for service method",
nil,
slog.String("service",
env.Rpc.Service),
slog.String("method",
env.Rpc.Method),
slog.Uint64("event_seq",
env.EventSeq))
continue
}
if err := dispatcher(ctx, env); err != nil {
// Dispatch failed. Stop processing the
// batch and return the last committed
// cursor.
return lastCommitted, err
}
default:
a.log.WarnS(
ctx,
"Skipping envelope with unknown RPC kind",
nil,
slog.Int("kind", int(env.Rpc.Kind)),
slog.Uint64("event_seq", env.EventSeq),
)
continue
}
// Track the event_seq of the last processed envelope. The
// batch next cursor is the authoritative cursor to advance
// to after the full batch succeeds.
if env.EventSeq > lastCommitted {
lastCommitted = env.EventSeq
}
}
// All envelopes dispatched successfully. Return the batch next cursor
// which represents the position after all envelopes in this batch.
if batchNextCursor > lastCommitted {
lastCommitted = batchNextCursor
}
return lastCommitted, nil
}
// ackRemote calls Edge.AckUpTo with the given cursor.
func (a *ServerConnectionActor) ackRemote(
ctx context.Context, cursor uint64,
) error {
resp, err := a.cfg.Edge.AckUpTo(ctx, &mailboxpb.AckUpToRequest{
MailboxId: a.cfg.LocalMailboxID,
Cursor: cursor,
})
return edgeResponseError("AckUpTo", resp, err)
}
// loadCheckpoint restores the AckState from the checkpoint store on startup.
// Returns a zero-value AckState if no checkpoint exists.
func (a *ServerConnectionActor) loadCheckpoint(ctx context.Context) (AckState,
error) {
actorID := DurableActorID(a.cfg.LocalMailboxID)
checkpoint, err := a.cfg.Store.LoadCheckpoint(ctx, actorID)
if err != nil {
return AckState{}, err
}
if checkpoint == nil {
return AckState{}, nil
}
var state AckState
stateReader := bytes.NewReader(checkpoint.StateData)
if err := state.Decode(stateReader); err != nil {
return AckState{}, err
}
a.log.InfoS(ctx, "Loaded ack checkpoint",
slog.String("actor_id", actorID),
slog.Uint64("pull_cursor", state.PullCursor),
slog.Uint64("dispatch_committed_to",
state.DispatchCommittedTo),
slog.Uint64("ack_target", state.AckTarget),
slog.Uint64("ack_committed_to", state.AckCommittedTo))
return state, nil
}
// runFoldedDispatch runs a pulled batch's two non-transactional kinds of
// delivery BEFORE the write transaction, then folds the durable dispatches and
// the advanced AckState checkpoint into ONE commit.
//
// The first pre-transaction kind is waiter-backed responses. Waiter delivery is
// in-memory and at-most-once, cannot be rolled back, and must never wait in
// the single-writer queue: unary callers sit blocked on these with RPC
// deadlines, so gating them on the writer lock turns write contention into
// payment-wide timeout collapse.
//
// The second is the NonTxRoutes requests. Those dispatchers serve an inbound
// KIND_REQUEST through the local mux and put the KIND_RESPONSE back on the
// wire with Edge.Send, so they block on a network round trip and touch no
// local durable mailbox. Running them under the transaction would pin the
// SQLite global writer lock (production opens with _txlock=immediate) or a
// SERIALIZABLE Postgres snapshot across that round trip, stalling or aborting
// every other writer in the process for as long as the operator takes to
// answer. Nothing they do belongs in the fold, so they are hoisted out of it.
//
// Every remaining dispatcher Tell joins the ambient transaction via the
// context (DurableMailbox.Send flows it into EnqueueMessage), so a batch of k
// durable envelopes costs one commit instead of k+1 and the cursor can never
// run ahead of the enqueues: any failure rolls back both, leaves the returned
// state untouched, and the batch is re-pulled intact.
//
// Ordering is pre-transaction work first, commit second, which is what
// at-least-once requires. A crash between a hoisted send and the commit
// re-pulls the batch and redelivers, and the operator absorbs the duplicate
// KIND_RESPONSE by correlation ID exactly as it does for the legacy
// non-transactional dispatch path. Committing first and sending after would
// invert that into at-most-once: a crash in the window would advance the
// cursor past a request that was never answered, and the caller would only
// ever see its own RPC deadline.
//
// The split-time waiter peek is only a hint: a waiter can vanish (RPC
// deadline cancel or TTL prune) between the peek and the actual delivery
// below. The pre-transaction step therefore delivers to LIVE waiters only
// and folds any straggler whose waiter disappeared back into the durable
// transaction, so a durable response enqueue never commits outside the
// cursor fold even if the peek was stale.
//
// redrive carries what the previous cycle of a backpressure episode already
// did. It matters because the pre-transaction work runs over the WHOLE pulled
// batch: with the cursor stalled at a wedged target, every hoisted request in
// the window would otherwise be served again on every redrive, forever. See
// redriveState.
func (a *ServerConnectionActor) runFoldedDispatch(ctx context.Context,
txStore actor.TxAwareDeliveryStore, envelopes []*mailboxpb.Envelope,
nextCursor uint64, state AckState, redrive *redriveState) (AckState,
error) {
// Validate the whole pulled batch against the bound version pair up
// front. Only the durable partition is validated inside dispatchBatch,
// and by then the pre-transaction steps below have already delivered
// to waiters and answered inbound requests, neither of which can be
// taken back. Checking every envelope first means one permanently
// incompatible envelope anywhere in the batch stops the loop before
// any of the batch is acted on.
for _, env := range envelopes {
if err := a.validateInboundEnvelope(env); err != nil {
return state, err
}
}
// Validation covers the pulled batch; everything after it works on the
// clamped one, so a redrive repeats no pre-transaction work it already
// did.
envelopes, nextCursor = clampToDeferred(
envelopes, nextCursor, redrive.deferredSeq,
)
responses, nonTx, durables := splitIngressEnvelopes(
envelopes, a.hasResponseWaiter, a.isNonTxRequest,
)
// Deliver the waiter-backed responses to their live waiters outside
// the transaction. Any whose waiter vanished since the split peek come
// back as stragglers and fold into the durable batch in event_seq
// order, so their enqueue commits inside the cursor fold, never ahead
// of it. This runs before the request dispatch below because it is
// in-memory and instant, while a request costs a full round trip.
if stragglers := a.deliverWaiterResponses(
responses,
); len(stragglers) > 0 {
durables = mergeEnvelopesByEventSeq(durables, stragglers)
}
// Serve the hoisted inbound requests with no transaction open. A
// failure here returns before the fold, so the cursor does not move and
// the batch is re-pulled; the requests already answered are not served
// again, because the watermark that records them advanced before the
// failure.
if err := a.dispatchNonTxRequests(ctx, nonTx, redrive); err != nil {
return state, err
}
// The durable partition's in-memory half (a TryTell into a bounded
// mailbox) is the one delivery below that a rolled-back transaction
// does not undo, and the production store replays its body on a
// retryable error. The record is created out here, OUTSIDE the closure,
// so a replay can see what the previous attempt already handed over and
// skip it. Everything else in the closure derives from the caller's
// state and is safe to redo.
ctx = withDeliveredOutsideTx(ctx)
var (
newState AckState
deferral *deferredDispatchError
)
err := txStore.ExecTx(ctx, false, func(txCtx context.Context,
store actor.DeliveryStore) error {
// Derive both outputs from the caller's state on every attempt,
// so a store that runs the closure more than once cannot fold a
// previous attempt's advance into this one.
newState = state
deferral = nil
cursor := nextCursor
if len(durables) > 0 {
_, dispatchErr := a.dispatchBatch(
txCtx, durables, nextCursor,
)
// A full target mailbox is backpressure, not a failed
// batch: the envelope is intact on the remote mailbox
// and the loop re-pulls it. Commit up to the
// undelivered envelope and stop there, because acking
// past an event that never reached its actor is how
// backpressure would turn into a lost round event.
//
// Nothing to adjust when the whole partition went out:
// the cursor already covers the batch.
switch {
case errors.As(dispatchErr, &deferral):
cursor = deferredCursor(
deferral.eventSeq, state.PullCursor,
)
case dispatchErr != nil:
return dispatchErr
}
}
newState.AdvanceDispatch(cursor)
newState.PullCursor = cursor
return a.saveCheckpointTo(txCtx, store, newState)
})
if err != nil {
return state, err
}
// The prefix and the watermark committed together; the deferral is
// reported so the loop backs off instead of pulling straight into the
// same full mailbox.
if deferral != nil {
return newState, deferral
}
return newState, nil
}
// deferredCursor returns the cursor to commit when a full mailbox stopped a
// batch partway. deferredSeq is the undelivered envelope's own event_seq, and
// that is exactly where the exclusive cursor belongs: everything ahead of it in
// the batch has been fully handled by the time the deferral is raised — waiter
// responses delivered and hoisted requests served before the transaction
// opened, the durable prefix enqueued inside it, unroutable envelopes
// skip-warned — while the deferred envelope itself has not been delivered and
// must be re-pulled. Committing here therefore acks everything before it and
// nothing at or after it, and the next pull starts on the envelope that has to
// be retried.
//
// Stopping one past the last DELIVERED envelope instead would be safe but not
// sufficient: any hoisted request sitting between it and the deferred envelope
// would come back in every redrive's pull window and be served again on each
// one.
//
// The cursor never goes backwards, which keeps a re-pull of already-committed
// envelopes from rewinding it. A zero deferredSeq is impossible — the mailbox
// assigns event_seq from 1, and zero is the never-acked cursor sentinel — but
// it is treated as "do not move" rather than trusted, because reading it as a
// cursor is the one arithmetic here that could ack an undelivered envelope.
func deferredCursor(deferredSeq, pullCursor uint64) uint64 {
if deferredSeq == 0 || deferredSeq <= pullCursor {
return pullCursor
}
return deferredSeq
}
// clampToDeferred restricts a redriven batch to the envelopes at or before the
// event_seq that a full mailbox turned away last cycle, returning the batch to
// process and the exclusive cursor that covers it.
//
// The clamp is what keeps a redrive from repeating work. The pre-transaction
// steps — waiter delivery and the hoisted request round trips — run over the
// whole batch before the transaction opens, but the cursor stops at the
// deferred envelope, so everything behind it stays inside the pull window for
// as long as the target stays wedged. Without the clamp each of those envelopes
// is re-handled once per backoff cycle, indefinitely: for a hoisted request
// that is a duplicate local serve plus a duplicate response sent to the
// operator, and the routes waiting to be hoisted next are state-changing ones
// where a duplicate is not free.
//
// A batch with nothing at or before deferredSeq is returned whole. That means
// the envelope the last cycle could not deliver is no longer on the mailbox, so
// there is nothing left to protect, and clamping to an empty batch would
// instead let the caller advance the cursor over envelopes it never dispatched.
func clampToDeferred(envelopes []*mailboxpb.Envelope, nextCursor,
deferredSeq uint64) ([]*mailboxpb.Envelope, uint64) {
if deferredSeq == 0 {
return envelopes, nextCursor
}
clamped := make([]*mailboxpb.Envelope, 0, len(envelopes))
for _, env := range envelopes {
if env.EventSeq <= deferredSeq {
clamped = append(clamped, env)
}
}
if len(clamped) == 0 || len(clamped) == len(envelopes) {
return envelopes, nextCursor
}
return clamped, deferredSeq + 1
}