Skip to content

join: do not drop in-flight batches overtaken by advance_upper (#801) - #802

Merged
frankmcsherry merged 4 commits into
TimelyDataflow:masterfrom
frankmcsherry:dd-801-join-fix
Jul 15, 2026
Merged

join: do not drop in-flight batches overtaken by advance_upper (#801)#802
frankmcsherry merged 4 commits into
TimelyDataflow:masterfrom
frankmcsherry:dd-801-join-fix

Conversation

@frankmcsherry

Copy link
Copy Markdown
Member

The bug

join_with_tactic maintains per-input acknowledged frontiers and skips arriving
stream batches unless acknowledged ≤ batch.lower(), on the theory that anything
below acknowledged is "pre-loaded data" already joined at operator start-up.

But acknowledged is also advanced by TraceReader::advance_upper, which walks the
shared trace's current batches — a merged, logically compacted view. A batch region
whose updates consolidate to zero under compaction (e.g. an add/remove pair whose
distinct times collapse to one once the compaction frontier passes both) reads as an
empty batch there, so advance_upper leaps it — while the raw, non-empty stream
batches for that region are still in flight. On arrival they fail the guard and were
silently dropped, and their cross-products against the other input were never computed.

Dropping them is sound only for consumers reading at or beyond the compaction
frontier. Iterative dataflows read finer times than that: in propagate_core's
rotated loop, the reduce accumulates proposals at (outer, round) coordinates that
remain open long after the join's inputs' frontiers (which drive compaction) have
moved past them. A dropped batch there leaves the loop's state inconsistent with its
own history; the self-correcting reduce then chases the discrepancy around the loop.
When the chase damps, the run terminates with output that disagrees with a sequential
oracle at a few (late) times; when it does not damp, it becomes a period-2 limit cycle
that never converges — pending "interesting times" pile up across every live outer
round, each retire gets more expensive, and the computation livelocks. These are the
"wrong-complete" and "hang" modes of #801, one bug.

The trigger requires only batch-boundary placement, not multi-worker races: a
single-worker deterministic simulation reproduces both modes. Under real execution it
surfaces as schedule-dependence (CPU contention perturbing delivery), hence the
apparent nondeterminism.

The fix

  • Snapshot preload_upper1/2 after the start-up loading loops; arriving batches
    wholly at-or-before them are the replayed pre-loaded data, and are still skipped.
  • Every other non-empty arriving batch is joined against the opposing trace through
    its current acknowledged, even when this input's acknowledged has overtaken it.
  • acknowledged is never regressed: it advances via received uppers when they are
    beyond it, and via advance_upper as before.

Each batch is still joined exactly once: acknowledged can only overtake an
in-flight batch via advance_upper, which requires the region to have consolidated
to empty in the trace — so every opposing work item that consulted the trace saw zero
contribution from that region, regardless of interleaving, and processing the raw
batch on arrival contributes each cross-product exactly once.

Validation

  • strongly_connected_at (rotated propagate_at) SCC tests, 3 workers, under heavy
    CPU contention: previously ~15-40% wrong-output/hang per run; now clean across
    40+ contended executions of the full test binary.
  • Deterministic-simulation harness (single-thread multi-worker with schedule-controlled
    message delivery, atop the timely Simulation work): previously 100% of seeds
    livelocked at 1000 input rounds and diverged (updates at SCC iteration 100+,
    propagate round 1400+, on a 10-node graph); now all seeds complete and match the
    sequential oracle, including the chaos-free schedules that previously diverged.
  • Full differential-dataflow and dogsdogsdogs test suites pass.

Notes

  • The reduce driver (reduce_with_tactic) tolerates late batches by construction —
    its retirement upper joins in the input frontier, which in-flight messages hold
    back, so it cannot skip content the same way. There is a theoretical path to a
    transiently malformed retirement interval via late empty batches regressing
    upper_limit when merges misalign with advance_upper's exact-boundary check;
    not observed, flagged for a follow-up audit.
  • count/threshold use advance_upper only to advance compaction, and process
    every received batch; they do not have the skip guard.

Fixes #801.

🤖 Generated with Claude Code

frankmcsherry and others added 3 commits July 14, 2026 21:40
Bump @babel/core to 7.29.7 and brace-expansion to 5.0.7, clearing the
open dependabot advisories. vite was already at the patched 8.0.16.
npm audit reports 0 vulnerabilities; the console builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
join_with_tactic skipped arriving batches unless `acknowledged <= batch.lower()`,
treating everything below `acknowledged` as pre-loaded data already joined at
start-up. But `acknowledged` also advances via `advance_upper`, which walks the
shared trace's merged, compacted batches: a region whose updates consolidate to
zero once logical compaction equates their times reads as empty there, so
`acknowledged` could leap over batches still in flight on the input stream. Their
raw, non-empty forms then failed the guard on arrival and were silently dropped,
and their cross-products against the opposing trace were never computed.

The trace's emptiness over such a region is a valid summary only for readers at
or beyond the compaction frontier. Iterative dataflows read finer times than
that: in propagate_core's rotated loop the reduce accumulates proposals at
coordinates that remain open long after the join's input frontiers have moved
past them, so the dropped cross-products left the loop inconsistent with its own
history. The loop's self-correction then either damped, terminating with output
that disagrees with a sequential oracle, or entered an undamped correction cycle
that never converges, with interesting-times work growing until livelock. These
are the wrong-output and hang modes of TimelyDataflow#801; the trigger is batch-boundary
placement racing trace merges, which contention perturbs, hence the apparent
scheduling nondeterminism (a single worker with delayed self-delivery suffices).

Instead: snapshot the acknowledged frontiers after start-up loading, and skip
only batches wholly at or before them (the pre-loaded data replay). Every other
non-empty arriving batch is joined against the opposing trace, even when
`acknowledged` has overtaken it, and `acknowledged` never regresses. Each batch
is still joined exactly once: `acknowledged` can only overtake an in-flight
batch via `advance_upper`, which requires the region to have consolidated to
empty in the trace, so every opposing work item saw zero contribution from that
region regardless of interleaving.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reinstates the prioritized SCC variant removed in anticipation of exactly the
propagate_core nondeterminism fixed in the previous commit, and routes the scc_at
tests through the library's strongly_connected_at against the sequential oracle.
Under multi-worker execution with CPU contention these tests exercised the
dropped-batch bug at a 15-40% failure rate per run (wrong output and hangs);
with the fix they pass reliably.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both frontiers an arriving batch is compared against are drawn from the lattice
of stream batch boundaries: `preload_upper` is the trace upper at construction,
and `acknowledged` holds received batch uppers or `advance_upper` targets, which
are uppers of trace merges of whole stream batches (physical compaction held at
`acknowledged` keeps merges from crossing it). A batch spanning either admits no
safe continuation: the preload case has already partially double-counted at
start-up, and the acknowledged case is mis-accounted by whichever branch it
takes. Hard asserts rather than debug: the checks are two small antichain
comparisons per batch, and continuing with known-incorrect accounting is worse
than aborting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
antiguru added a commit to antiguru/materialize that referenced this pull request Jul 15, 2026
`mz_join_core` skipped incoming batches by testing `batch.lower()`
against the mutable `acknowledged` frontier. `advance_upper` can move
`acknowledged` past an in-flight batch when trace merges consolidate the
batch's updates away, for example an add/remove pair collapsing once
logical compaction equates their times. The trace is legitimately empty
there only for readers at or beyond the compaction frontier, but our
consumers may read finer times where the batch's updates are still real
and unaccounted for. Testing against `acknowledged` therefore dropped
those batches and silently lost updates.

Capture fixed `preload_upper` frontiers at start-up and gate the skip on
them instead, and only advance `acknowledged` forward when a batch sits
at or beyond it. This mirrors the upstream fix in
TimelyDataflow/differential-dataflow#802 (issue #801), which our fork of
`JoinCore::join_core` had inherited.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@frankmcsherry
frankmcsherry changed the base branch from master-next to master July 15, 2026 20:47
@frankmcsherry
frankmcsherry merged commit 6868c6c into TimelyDataflow:master Jul 15, 2026
6 checks passed
antiguru added a commit to MaterializeInc/materialize that referenced this pull request Jul 15, 2026
Our `mz_join_core` is a fork of differential's `JoinCore::join_core` and
inherited a correctness bug fixed upstream in
TimelyDataflow/differential-dataflow#802 (issue #801).

The operator skipped incoming batches by testing `batch.lower()` against
the mutable `acknowledged` frontier. `advance_upper` can advance
`acknowledged` past an in-flight batch when trace merges consolidate the
batch's updates away, e.g. an add/remove pair collapsing once logical
compaction equates their times. The trace is legitimately empty there
only for readers at or beyond the compaction frontier, while our
consumers may read finer times where the batch's updates are still real.
Testing against `acknowledged` therefore dropped such batches and
silently lost updates.

The fix captures fixed `preload_upper` frontiers at start-up and gates
the skip on them, and advances `acknowledged` only when a batch sits at
or beyond it. This is a one-to-one port of the upstream change.

No regression test: deterministically reproducing the race requires
trace merges to consolidate an add/remove pair while a batch is in
flight and `advance_upper` to then jump ahead, which is timing-dependent
and not reliable in testdrive/sqllogictest. Upstream #802 added no
direct join test either. Correctness rests on mirroring the upstream
reasoning.

Note: the alternate `DifferentialDataflow` join implementation calls
upstream `join_core` from crates.io `differential-dataflow` 0.24.0,
which still carries the bug and will be fixed by a future dependency
bump.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
frankmcsherry added a commit to frankmcsherry/differential-dataflow that referenced this pull request Jul 23, 2026
…at proxy joins

Audit of whether the int-proxy join carries the bug fixed in TimelyDataflow#802
(in-flight batches overtaken by advance_upper, silently dropped):

* The bug lived in join_with_tactic's driver — the acknowledged-
  frontier admission guard — which both tactics share, and this branch
  is based past the fix (59dfd3d, f8f1645); its only diffs to
  join.rs are a derive and module visibility. The proxy path inherits
  the fix.
* The tactic-level assumptions survive the fix's new admission rule:
  `meet` is the timely message capability, which lower-bounds the
  fresh batch's times on the overtaken path exactly as elsewhere
  (overtaking changes which batches are admitted and what the opposing
  trace reads through, not the capability relationship), so the
  backend's advance-by-lower algebra and the exactly-once argument
  (driver-level, tactic-agnostic) are unaffected.
* Empirically: SCC_STAGGER=1 switches both the proxy trim and the
  oracle to the prioritized strongly_connected_at shape (labels
  introduced in rounds — the TimelyDataflow#801 trigger), and the oracle-validated
  runs at the regression suite's sizes (10/20 x 1000 single-edge
  rounds, 100/200 x 10, 100/2000) with three workers all pass: exact
  agreement at every time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

propagate_at / strongly_connected_at: nondeterministic (wrong output + hangs) under multi-worker contention

1 participant