Skip to content

Fix concurrent bootstrap output progress - #6849

Merged
ryzhyk merged 3 commits into
mainfrom
fix-concurrent-bootstrap-output-progress
Aug 16, 2026
Merged

Fix concurrent bootstrap output progress#6849
ryzhyk merged 3 commits into
mainfrom
fix-concurrent-bootstrap-output-progress

Conversation

@ryzhyk

@ryzhyk ryzhyk commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes concurrent bootstrapping test failure.

Describe Manual Test Plan

Checklist

  • Unit tests added/updated
  • Integration tests added/updated
  • Documentation updated
  • Changelog updated

Breaking Changes?

Mark if you think the answer is yes for any of these components:

Describe Incompatible Changes

@ryzhyk
ryzhyk requested a review from blp August 12, 2026 17:28
@ryzhyk ryzhyk added the connectors Issues related to the adapters/connectors crate label Aug 12, 2026
@blp

blp commented Aug 12, 2026

Copy link
Copy Markdown
Member

Claude claims that this re-introduces the same bug, just in enqueue_latest_snapshot, because that function does this:

        let processed_records = processed_records.or(Some(ProcessedRecords {
            total_processed_input_records: self.status.num_total_processed_records(),
            total_processed_steps: self.status.global_metrics.total_completed_steps(),
        }));

and that it should be more like this:

let processed_records = if owed_reemission {
    None // withheld — must not be backfilled; leave the counter untouched
} else {
    processed_records.or(Some(ProcessedRecords {
        total_processed_input_records: self.status.num_total_processed_records(),
        total_processed_steps: self.status.global_metrics.total_completed_steps(),
    }))
};

It says:

Two distinct meanings of None are colliding.

  • Pre-existing meaning (mid-transaction): "no override supplied; the last fully-committed count is still accurate, so stamp the endpoint with the current global count." That's what .or(Some(ProcessedRecords { total_processed_input_records: self.status.num_total_processed_records(), .. })) at controller.rs:7972 correctly does — and it's a case enqueue_latest_snapshot can genuinely see safely, because it's only used mid-transaction, when the global count truly hasn't moved past reality.
  • New meaning (owed re-emission): "a real batch was produced, but reporting it would claim output the endpoint hasn't received yet — leave the counter alone." For the ordinary delta path, output_batch/OutputBuffer::insert already do exactly that (skip the store on None). But enqueue_latest_snapshot doesn't know this None means "skip" — it only knows the old meaning, so it invents a fresh stamp instead of skipping.

What correct behavior looks like: when enqueue_latest_snapshot delivers the stale, pre-cutover snapshot to a freshly-pending endpoint on a relation still owed a re-emission, the progress counter should stay wherever it was (0, for a brand-new endpoint) — not jump to the pipeline's current input count. The stale snapshot is fine to send (the cutover's delta will correct it later, at Finalizing, via the normal delta path); it's only the counter that must wait, exactly as it does for every other endpoint under owed_reemission. Once the real cutover delta lands at Finalizing, that ordinary delta path already stamps the correct, by-then-accurate count — no extra logic needed there.

Frankly, I don't understand the different kinds of bootstrapping well enough to judge whether it is right.

ryzhyk added 3 commits August 14, 2026 19:06
The in-process server tests build their circuit from a Rust closure, so the
pipeline config carries no program information and `compute_pipeline_diff` finds
none to compare. `start_test_server_with_state` papered over that with a
`with_program_ir` flag that injected one fixed empty IR, which only ever
produces an empty diff.

Take the IR itself, so a test can also inject IRs that differ across a restart
and exercise the paths a recompiled program takes. `test_program_ir` builds one
for the test circuit's single view, keyed on the view's persistent id.

Signed-off-by: Leonid Ryzhyk <ryzhyk@gmail.com>
`total_processed_input_records` promises that an output endpoint's output equals
the circuit's output after that many input records. A concurrent bootstrap broke
the promise for the endpoints whose relations it rebuilds: the pre-existing views
stay live, so the primary circuit keeps stepping throughout the backfill, and
`push_output` tagged each of those steps' empty batches with the pipeline's full
record count. The endpoint stored it, claiming it had delivered output that the
cutover had not yet re-emitted. A reader waiting on the counter to learn the sink
is caught up -- `wait_for_output_progress` in the platform tests -- then read a
transmitted count missing the whole re-emission.

Withhold the progress figure from an endpoint the bootstrap still owes a
re-emission, so the counter reaches the pipeline's count on the step that carries
that output, not before.

Fixes: feldera/cloud#1832

Signed-off-by: Leonid Ryzhyk <ryzhyk@gmail.com>
Review caught the withheld progress figure coming back to life one branch over:
`enqueue_latest_snapshot` replaced a `None` with the pipeline's current count, so
a connector configured with `send_snapshot: true` on a relation the bootstrap
rebuilds reported full progress while it was still owed the re-emission -- the
same defect as on the delta path, reached through a different door.

`None` on the batch queue means "leave the counter alone" everywhere else, and it
now means that here too. The caller supplies the figure it wants reported,
including the one this function used to synthesize: a snapshot reflects the last
committed transaction, so an endpoint receiving one is up to date with the
pipeline's count even mid-transaction, where the delta path has nothing to report.

The regression test now runs both paths.

Reported-by: Ben Pfaff <blp@feldera.com>
Signed-off-by: Leonid Ryzhyk <ryzhyk@gmail.com>
@ryzhyk
ryzhyk force-pushed the fix-concurrent-bootstrap-output-progress branch from 2c185d6 to 7c7ba06 Compare August 15, 2026 02:13
@ryzhyk

ryzhyk commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Claude claims that this re-introduces the same bug, just in enqueue_latest_snapshot, because that function does this:

        let processed_records = processed_records.or(Some(ProcessedRecords {
            total_processed_input_records: self.status.num_total_processed_records(),
            total_processed_steps: self.status.global_metrics.total_completed_steps(),
        }));

and that it should be more like this:

let processed_records = if owed_reemission {
    None // withheld — must not be backfilled; leave the counter untouched
} else {
    processed_records.or(Some(ProcessedRecords {
        total_processed_input_records: self.status.num_total_processed_records(),
        total_processed_steps: self.status.global_metrics.total_completed_steps(),
    }))
};

It says:

Two distinct meanings of None are colliding.

  • Pre-existing meaning (mid-transaction): "no override supplied; the last fully-committed count is still accurate, so stamp the endpoint with the current global count." That's what .or(Some(ProcessedRecords { total_processed_input_records: self.status.num_total_processed_records(), .. })) at controller.rs:7972 correctly does — and it's a case enqueue_latest_snapshot can genuinely see safely, because it's only used mid-transaction, when the global count truly hasn't moved past reality.
  • New meaning (owed re-emission): "a real batch was produced, but reporting it would claim output the endpoint hasn't received yet — leave the counter alone." For the ordinary delta path, output_batch/OutputBuffer::insert already do exactly that (skip the store on None). But enqueue_latest_snapshot doesn't know this None means "skip" — it only knows the old meaning, so it invents a fresh stamp instead of skipping.

What correct behavior looks like: when enqueue_latest_snapshot delivers the stale, pre-cutover snapshot to a freshly-pending endpoint on a relation still owed a re-emission, the progress counter should stay wherever it was (0, for a brand-new endpoint) — not jump to the pipeline's current input count. The stale snapshot is fine to send (the cutover's delta will correct it later, at Finalizing, via the normal delta path); it's only the counter that must wait, exactly as it does for every other endpoint under owed_reemission. Once the real cutover delta lands at Finalizing, that ordinary delta path already stamps the correct, by-then-accurate count — no extra logic needed there.

Frankly, I don't understand the different kinds of bootstrapping well enough to judge whether it is right.

This was a good catch, thank you.

@blp blp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@ryzhyk
ryzhyk added this pull request to the merge queue Aug 15, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 15, 2026
@ryzhyk

ryzhyk commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

The CI failure is unrelated; I will work on it separately.

@ryzhyk
ryzhyk added this pull request to the merge queue Aug 15, 2026
Merged via the queue into main with commit 3b1fc8e Aug 16, 2026
1 check passed
@ryzhyk
ryzhyk deleted the fix-concurrent-bootstrap-output-progress branch August 16, 2026 00:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

connectors Issues related to the adapters/connectors crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants