Skip to content

Commit e295c50

Browse files
antiguruclaude
andcommitted
fixup: a handle's physical hold is not the frontier it reports
A handle carries two physical frontiers, and they are not interchangeable. The one it reports through `get_physical_compaction` is seeded at the published `since`, because a reported frontier may never lead the chain coverage. The one it holds is seeded at that coverage, because a merge spanning the coverage destroys the boundary the reader was seeded with. `Clone` and the setter both wrote the reported frontier into the hold, which silently lowers it. Since the accumulation is a meet, one such registration is a floor under every other hold, so the published spine stops merging: batches pile up in `Spine::pending`, one per seal, for as long as that registration lives. The cost is unbounded rather than constant, since retractions in stranded batches never consolidate and every `cursor_through` builds a `CursorList` over all of them. An import's read hold hit this on both counts. It is a clone, so it registered at `since`, and it advanced only on the logical axis, so nothing ever raised it. Measured against an unimported control over 40 seals: 39 batches against 5. Keep the two frontiers in separate fields, have `Clone` inherit the hold, have the setter join into both, and advance the import's hold on both axes. `acknowledged` is the right value for the physical axis too: it is exactly the frontier below which that import will never cut again. Reported by the QA LLM review on #38386. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6d01cc0 commit e295c50

2 files changed

Lines changed: 202 additions & 9 deletions

File tree

src/compute/src/shared_trace.rs

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,16 @@ where
373373
state.logical_holds.values().cloned().collect()
374374
}
375375

376+
/// The physical holds currently registered against this publication point.
377+
///
378+
/// Test-only. A handle reports a weaker physical frontier than it holds, so the hold cannot be
379+
/// read back through `TraceReader::get_physical_compaction`.
380+
#[cfg(test)]
381+
pub(crate) fn physical_holds(&self) -> Vec<Antichain<Tr::Time>> {
382+
let state = self.shared.state.lock().expect("shared trace poisoned");
383+
state.physical_holds.values().cloned().collect()
384+
}
385+
376386
/// The number of batches in the published chain.
377387
///
378388
/// Test-only. Counting through a handle would register a physical hold and perturb the merge
@@ -453,9 +463,21 @@ pub struct SharedTraceHandle<Tr: TraceReader> {
453463
/// This handle's own logical frontier, mirrored into `logical_holds[id]`. Kept locally so
454464
/// `get_logical_compaction` can return a borrow.
455465
logical: Antichain<Tr::Time>,
456-
/// This handle's own physical frontier, mirrored into `physical_holds[id]`. Kept locally so
466+
/// The physical frontier this handle *reports*, seeded at the published `since`. Kept locally so
457467
/// `get_physical_compaction` can return a borrow.
458468
physical: Antichain<Tr::Time>,
469+
/// The physical frontier this handle *holds*, seeded at the chain coverage and mirrored into
470+
/// `physical_holds[id]`.
471+
///
472+
/// Distinct from `physical`, which is the weaker of the two on registration and must stay so:
473+
/// a reported frontier may never lead the chain coverage (see `Self::register_at`), while the
474+
/// hold has to start AT that coverage or a merge can eat the boundary this reader was seeded
475+
/// with. Overwriting the hold with the reported value silently lowers it, which stops the spine
476+
/// merging for the life of the handle.
477+
///
478+
/// The logical axis needs no such split: both registrations install the same frontier a handle
479+
/// reports.
480+
physical_hold: Antichain<Tr::Time>,
459481
}
460482

461483
impl<Tr: TraceReader> SharedTraceHandle<Tr>
@@ -464,7 +486,7 @@ where
464486
{
465487
/// Registers a fresh hold at the current published `since` and returns a handle for it.
466488
fn register(shared: SharedTraceRef<Tr>) -> Self {
467-
let (id, since) = {
489+
let (id, since, coverage) = {
468490
let mut state = shared.state.lock().expect("shared trace poisoned");
469491
let id = state.next_id;
470492
state.next_id += 1;
@@ -473,13 +495,14 @@ where
473495
// The cut floor starts at the chain coverage, NOT at `since`. See `register_at`.
474496
let coverage = seed_frontier::<Tr>(&state.chain, &state.upper);
475497
state.set_physical_hold(id, &coverage);
476-
(id, since)
498+
(id, since, coverage)
477499
};
478500
Self {
479501
shared,
480502
id,
481503
logical: since.clone(),
482504
physical: since,
505+
physical_hold: coverage,
483506
}
484507
}
485508

@@ -489,7 +512,7 @@ where
489512
shared: SharedTraceRef<Tr>,
490513
as_of: &Antichain<Tr::Time>,
491514
) -> Result<Self, Antichain<Tr::Time>> {
492-
let (id, since) = {
515+
let (id, since, coverage) = {
493516
let mut state = shared.state.lock().expect("shared trace poisoned");
494517
if !timely::PartialOrder::less_equal(&state.since, as_of) {
495518
return Err(state.since.clone());
@@ -516,7 +539,7 @@ where
516539
// will get, and permit a merge across the very first frontier that reader cuts at.
517540
let coverage = seed_frontier::<Tr>(&state.chain, &state.upper);
518541
state.set_physical_hold(id, &coverage);
519-
(id, since)
542+
(id, since, coverage)
520543
};
521544
Ok(Self {
522545
shared,
@@ -535,6 +558,7 @@ where
535558
// publisher forwards the published `since` as its physical target, so this reports the
536559
// grant.
537560
physical: since,
561+
physical_hold: coverage,
538562
})
539563
}
540564

@@ -601,10 +625,10 @@ where
601625
state.set_logical_hold(self.id, &self.logical);
602626
}
603627

604-
/// Mirrors this handle's physical frontier into the publication point.
628+
/// Mirrors this handle's physical hold into the publication point.
605629
fn update_physical_hold(&self) {
606630
let mut state = self.shared.state.lock().expect("shared trace poisoned");
607-
state.set_physical_hold(self.id, &self.physical);
631+
state.set_physical_hold(self.id, &self.physical_hold);
608632
}
609633
}
610634

@@ -622,14 +646,19 @@ where
622646
let id = state.next_id;
623647
state.next_id += 1;
624648
state.set_logical_hold(id, &self.logical);
625-
state.set_physical_hold(id, &self.physical);
649+
// The clone inherits this handle's HOLD, not the frontier it reports. Registering the
650+
// reported frontier would install a hold below the coverage the source was seeded with,
651+
// and since the accumulation is a meet, one such clone stops the spine merging for as
652+
// long as it lives.
653+
state.set_physical_hold(id, &self.physical_hold);
626654
id
627655
};
628656
Self {
629657
shared: Arc::clone(&self.shared),
630658
id,
631659
logical: self.logical.clone(),
632660
physical: self.physical.clone(),
661+
physical_hold: self.physical_hold.clone(),
633662
}
634663
}
635664
}
@@ -705,7 +734,12 @@ where
705734
}
706735

707736
fn set_physical_compaction(&mut self, frontier: AntichainRef<'_, Tr::Time>) {
708-
self.physical = self.physical.join(&frontier.to_owned());
737+
let frontier = frontier.to_owned();
738+
self.physical = self.physical.join(&frontier);
739+
// Join, never assign: the hold starts at the chain coverage, which leads what this handle
740+
// reports, and a request below that coverage must not pull the hold down to it. `join` with
741+
// the empty antichain is absorbing, which is what still lets an empty request release.
742+
self.physical_hold = self.physical_hold.join(&frontier);
709743
self.update_physical_hold();
710744
}
711745

@@ -1165,6 +1199,12 @@ where
11651199
// slower of the two wins.
11661200
if let Some(hold) = hold.as_mut() {
11671201
hold.set_logical_compaction(acknowledged.borrow());
1202+
// Both axes. `acknowledged` is exactly the frontier below which
1203+
// this import will never cut again, which is what the physical
1204+
// hold expresses. Without this the hold pins the spine at the
1205+
// coverage it registered at and the chain grows one batch per
1206+
// seal for the life of the import.
1207+
hold.set_physical_compaction(acknowledged.borrow());
11681208
}
11691209
// Bound the read at `until`: once the trace's frontier reaches it, drop
11701210
// the capability so a single-time read completes. Otherwise track the

src/compute/src/shared_trace/tests.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -877,6 +877,159 @@ fn reader_floor_bounds_merges_and_no_reader_merges_freely() {
877877
});
878878
}
879879

880+
/// A clone inherits its source's physical hold, not the weaker frontier its source reports.
881+
///
882+
/// `register`/`register_at` seed the hold at the chain coverage while reporting the published
883+
/// `since`, which is weaker. Registering the reported frontier for a clone would silently install a
884+
/// hold below the coverage the source was seeded with, and since the accumulation is a meet, that
885+
/// clone becomes a floor under every other hold for as long as it lives.
886+
#[mz_ore::test]
887+
fn clone_inherits_the_hold_not_the_reported_frontier() {
888+
timely::execute_directly(move |worker| {
889+
let (published, mut input) = worker.dataflow::<Timestamp, _, _>(|scope| {
890+
let (input, collection) = scope.new_collection::<(Row, Row), Diff>();
891+
let arranged = collection.mz_arrange::<
892+
ColumnationChunker<_>,
893+
RowRowBatcher<_, _>,
894+
RowRowBuilder<_, _>,
895+
RowRowSpine<_, _>,
896+
>("clone oks");
897+
(adopt_fresh(&arranged), input)
898+
});
899+
for t in 0..4u64 {
900+
tick(
901+
worker,
902+
&mut input,
903+
Timestamp::from(t),
904+
Timestamp::from(t + 1),
905+
);
906+
}
907+
908+
// The chain now covers 4, while the published `since` is still the minimum, so the two
909+
// frontiers a handle carries are distinguishable.
910+
let handle = published.handle();
911+
let (since, _upper) = handle.frontiers();
912+
let coverage = Antichain::from_elem(Timestamp::from(4_u64));
913+
assert_eq!(
914+
since,
915+
Antichain::from_elem(Timestamp::MIN),
916+
"no compaction was requested, so `since` should still be the minimum"
917+
);
918+
919+
let clone = handle.clone();
920+
drop(handle);
921+
let holds = published.physical_holds();
922+
assert_eq!(
923+
holds,
924+
vec![coverage],
925+
"the clone's hold is not the coverage its source was seeded with, so it sits below \
926+
every boundary the source still needed"
927+
);
928+
drop(clone);
929+
});
930+
}
931+
932+
/// A live import does not pin the published spine's physical compaction.
933+
///
934+
/// The import's own read hold is the only registration a consumer that keeps the stream leaves
935+
/// behind, so if that hold never rises the accumulated physical frontier never rises either, and
936+
/// the spine stops merging for the life of the import. The cost is unbounded rather than constant:
937+
/// one stranded batch per seal, whose retractions never consolidate, and a `CursorList` over all of
938+
/// them on every `cursor_through`.
939+
///
940+
/// Same two-arm shape as [`reader_floor_bounds_merges_and_no_reader_merges_freely`], and for the
941+
/// same reason: the observable is the difference between the arms' chain lengths, which is immune to
942+
/// the spine's particular merge policy.
943+
#[mz_ore::test]
944+
fn live_import_does_not_pin_merging() {
945+
timely::execute_directly(move |worker| {
946+
let (imported, control, mut imported_input, mut control_input) = worker
947+
.dataflow::<Timestamp, _, _>(|scope| {
948+
let (imported_input, imported_collection) =
949+
scope.new_collection::<(Row, Row), Diff>();
950+
let imported_arranged = imported_collection.mz_arrange::<
951+
ColumnationChunker<_>,
952+
RowRowBatcher<_, _>,
953+
RowRowBuilder<_, _>,
954+
RowRowSpine<_, _>,
955+
>("imported oks");
956+
let (control_input, control_collection) =
957+
scope.new_collection::<(Row, Row), Diff>();
958+
let control_arranged = control_collection.mz_arrange::<
959+
ColumnationChunker<_>,
960+
RowRowBatcher<_, _>,
961+
RowRowBuilder<_, _>,
962+
RowRowSpine<_, _>,
963+
>("control oks");
964+
(
965+
adopt_fresh(&imported_arranged),
966+
adopt_fresh(&control_arranged),
967+
imported_input,
968+
control_input,
969+
)
970+
});
971+
972+
// Seal a few times so the chain the import seeds from is non-empty.
973+
for t in 0..4u64 {
974+
tick(
975+
worker,
976+
&mut imported_input,
977+
Timestamp::from(t),
978+
Timestamp::from(t + 1),
979+
);
980+
tick(
981+
worker,
982+
&mut control_input,
983+
Timestamp::from(t),
984+
Timestamp::from(t + 1),
985+
);
986+
}
987+
988+
// A live import: empty `until`, so it never completes and its read hold lives as long as
989+
// the dataflow. Keep only the stream and drop the trace, which is what `as_collection` and
990+
// the reduce path do during construction, leaving the import's own hold as the only
991+
// registration.
992+
let handle = imported.handle();
993+
worker.dataflow::<Timestamp, _, _>(|scope| {
994+
let arranged = handle.import_snapshot_at(
995+
scope.clone(),
996+
"live import",
997+
Antichain::from_elem(Timestamp::from(4_u64)),
998+
Antichain::new(),
999+
);
1000+
drop(arranged.trace);
1001+
});
1002+
// Drop the minting handle, as `crate::render::import_shared_index` does: the import owns
1003+
// its own clone, and a live mint would pin the floor at its own registration coverage and
1004+
// mask what this test is about.
1005+
drop(handle);
1006+
1007+
for t in 4..40u64 {
1008+
tick(
1009+
worker,
1010+
&mut imported_input,
1011+
Timestamp::from(t),
1012+
Timestamp::from(t + 1),
1013+
);
1014+
tick(
1015+
worker,
1016+
&mut control_input,
1017+
Timestamp::from(t),
1018+
Timestamp::from(t + 1),
1019+
);
1020+
}
1021+
1022+
let imported_len = imported.chain_len();
1023+
let control_len = control.chain_len();
1024+
assert!(
1025+
imported_len <= control_len + 2,
1026+
"imported chain {imported_len} against unimported {control_len}: the live import's \
1027+
read hold is not following the stream on the physical axis, so the spine cannot merge \
1028+
for as long as the import lives"
1029+
);
1030+
});
1031+
}
1032+
8801033
/// A fresh importer is seeded with the frontier its seeded chain covers, not the stream frontier
8811034
/// that lags it.
8821035
///

0 commit comments

Comments
 (0)