Skip to content

dbsp: Give every stream in a nested circuit an id of its own - #6853

Merged
mihaibudiu merged 1 commit into
mainfrom
fix-nested-circuit-stream-ids
Aug 14, 2026
Merged

dbsp: Give every stream in a nested circuit an id of its own#6853
mihaibudiu merged 1 commit into
mainfrom
fix-nested-circuit-stream-ids

Conversation

@ryzhyk

@ryzhyk ryzhyk commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Stream ids are documented as globally unique, and Edges bookkeeping, the
replay machinery and several circuit caches key on them. They are not unique.

A child circuit receives a copy of the parent's counter rather than the counter:

fn last_stream_id(&self) -> RefCell<StreamId> {
    self.inner().last_stream_id.clone()   // clones the StreamId inside the cell
}

so the child hands out ids that the parent then hands out again. One of the ids
a child allocates belongs to a stream in the parent:
FeedbackOutputNode::with_export allocates the id of the stream a scope exports
from inside the child.

What it broke

CircuitHandle::complete_replay ends a bootstrap by deleting the replay edges
by stream id:

self.circuit.edges_mut().delete_stream(*stream_id);

On 0.291.1 (the 6765 fixes backported, so operators inside recursive scopes
checkpoint), a recursive pipeline resumed from a checkpoint panicked at
circuit_builder.rs:144, Option::unwrap() on None in StreamValue::peek,
at 2, 4 and 8 workers, immediately after "Bootstrap complete".

Instrumenting the schedule showed why. Each of the program's 12 recursive scopes
exported a stream whose id had also gone to a root level stream created later,
in every case the replay stream of the accumulate trace that consumes the
export:

StreamId(568) produced by ['579', '621']       # 579 Subcircuit, 621 Z1 (trace)
  n579 -> n580 stream=Some(StreamId(568))      # the live export edge
  n621 -> n620 stream=Some(StreamId(568))      # the replay edge

Deleting the replay edges took n579 -> n580 with them, so the scheduler
prepared Consolidate [580] with no predecessors:

 before:  n579 preds=[…] succs=["n580"]     n580 preds=["n579"] succs=[…]
 after:   n579 preds=[…] succs=[]           n580 preds=[]       succs=[…]

From then on the Consolidate ran in the first batch of every step, ahead of the
scope, and read an export stream that nothing had written:

MISSING STREAM VALUE: consumer="[580] Consolidate" tokens=0 consumers=1
 || COMPLETE_REPLAY
 || TRANSACTION
   [40] Z1 (trace)  … 6 more Z1 (trace)
   [580] Consolidate            <- no [579] Subcircuit ahead of it

Whether a program collides depends on how its ids fall, which is why main has
not tripped over it: the same program bootstraps cleanly here. The defect is the
same.

The fix

Share the counter, as the comment on last_stream_id always claimed. Stream ids
exist only at runtime; nothing on disk changes, and no fingerprint or persistent
id derives from them.

Testing

  • New unit test asserting the invariant that broke: no two producers may share a
    stream id. It fails without the fix, on this branch and on main:
    stream s5 is produced by both n1 and n5.
  • cargo test -p dbsp --lib, 435 tests including the replay tests: pass (run in
    the 0.291.1 tree where the fix originated).
  • On the customer's program at 4 workers, the upgrade that panicked now
    completes: bootstrap finishes and result matches a reference run row for row
    after each of the following input batches.

🤖 Generated with Claude Code

A child circuit receives a copy of the parent's stream id counter rather than
the counter itself: `last_stream_id()` returns `RefCell<StreamId>` by value, and
cloning a `RefCell` clones the value inside it.  The child then hands out ids
that the parent hands out again, so ids are not unique, contrary to what
`allocate_stream_id` and the comment on `last_stream_id` promise.

One of the ids a child allocates belongs to a stream in the *parent*:
`FeedbackOutputNode::with_export` allocates the id of the stream a scope exports
from inside the child.  When that id has also gone to a stream in the parent,
edge bookkeeping becomes ambiguous, and `complete_replay` deletes edges by
stream id:

    self.circuit.edges_mut().delete_stream(*stream_id);

A recursive pipeline hit exactly that on 0.291.1, where the fix for #6765 makes
operators inside recursive scopes checkpoint: each of the program's 12 scopes
exported a stream whose id had also gone to the replay stream of the accumulate
trace consuming it, so ending a bootstrap deleted `Subcircuit -> Consolidate`,
the live edge carrying the scope's output.  With no predecessor left, that
Consolidate ran in the first batch of every following step, ahead of the scope,
and `StreamValue::peek` unwrapped `None` on the export stream that nothing had
written.  Whether a program collides this way depends on how its ids fall, so
the same defect is latent here.

Share the counter.  Stream ids exist only at runtime, so nothing on disk
changes.

The new test asserts the invariant that broke: no two producers may share a
stream id.  Without the fix it reports "stream s5 is produced by both n1 and
n5".

Signed-off-by: Leonid Ryzhyk <leonid@feldera.com>
@ryzhyk
ryzhyk requested a review from mihaibudiu August 13, 2026 14:58
@ryzhyk ryzhyk added the DBSP core Related to the core DBSP library label Aug 13, 2026
@ryzhyk
ryzhyk added this pull request to the merge queue Aug 13, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 13, 2026
@mihaibudiu
mihaibudiu added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 2984e88 Aug 14, 2026
1 check passed
@mihaibudiu
mihaibudiu deleted the fix-nested-circuit-stream-ids branch August 14, 2026 00:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DBSP core Related to the core DBSP library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants