6969
7070log = logging .getLogger (__name__ )
7171
72+ # Per-cycle read budget per cursor group. Bounds how many CDC chunks one
73+ # group may read before the poll cycle moves to the next group — the fairness
74+ # valve that keeps a deeply-lagging (or flush-failing, position-resetting)
75+ # destination from monopolizing the poll thread while healthy peers starve.
76+ # 4 chunks × cdc_chunk_snapshots=50 = 200 snapshots per group per cycle,
77+ # ~7× the source's per-cycle arrival rate — groups drain lag while every
78+ # other group still gets a turn each cycle.
79+ _MAX_CHUNKS_PER_GROUP_PER_CYCLE = 4
80+
7281
7382def _start_progress_heartbeat (
7483 label : str ,
@@ -806,14 +815,22 @@ def _poll_cycle(src_table, delivery, dest_pool, router, cfg, assigned_ids, rv_to
806815 epochs = {d : epoch for d , (_pos , epoch ) in plan .items ()}
807816 groups = _group_by_cursor (positions , assigned_ids )
808817
809- # Iterate lowest cursor first: the most-lagging group reads before
810- # a caught-up peer's turn can trip the buffer watermark. Under the
811- # prior insertion-order iteration, a destination whose cursor was
812- # further behind was silently starved whenever the first-iterated
813- # group filled the buffer mid-chunk — the outer loop moved to the
814- # next group and immediately hit `should_pause_reads()`, breaking
815- # out without reading anything. Sorting flips the priority so the
816- # laggiest destination gets first shot at each poll's read budget.
818+ # Iterate lowest cursor first — the most-lagging group gets the
819+ # first turn each cycle — but cap the chunks each group may read
820+ # per cycle so a deeply-lagging group cannot monopolize the poll
821+ # thread. Both halves are load-bearing:
822+ # - Insertion-order iteration (pre-sort) starved a lagging
823+ # destination whose config index was after a caught-up peer:
824+ # the peer's read filled the buffer and the lagging group's
825+ # first watermark check bounced it out with zero reads.
826+ # - Sorted-but-uncapped iteration (the first fix) starved the
827+ # HEALTHY destination instead: the lagging group's chunk loop
828+ # ran to head — or forever, when its flushes kept failing and
829+ # the position kept resetting — so the caught-up group never
830+ # got a read. Observed on portola: team-2's cursor frozen for
831+ # 1.5h while team-50689 relitigated the same range.
832+ # The cap turns strict priority into a round-robin with a
833+ # lagging-first bias: every group makes progress every cycle.
817834 for start_snap , dest_ids in sorted (groups .items ()):
818835 if start_snap >= current_id :
819836 continue # already read through the current snapshot
@@ -828,7 +845,9 @@ def _poll_cycle(src_table, delivery, dest_pool, router, cfg, assigned_ids, rv_to
828845 chunk_size = cfg .poll .cdc_chunk_snapshots
829846 chunk_start = start_snap
830847 routing_error = False
831- while chunk_start < current_id :
848+ chunks_this_group = 0
849+ while chunk_start < current_id and chunks_this_group < _MAX_CHUNKS_PER_GROUP_PER_CYCLE :
850+ chunks_this_group += 1
832851 if delivery .should_pause_reads ():
833852 _log_watermark_paused ("mid-chunk" )
834853 break
0 commit comments