Skip to content

Chain: keep a snapshot request alive until it answers its caller - #554

Merged
heifner merged 4 commits into
masterfrom
fix/snapshot-request-dropped-before-firing-height
Aug 10, 2026
Merged

Chain: keep a snapshot request alive until it answers its caller#554
heifner merged 4 commits into
masterfrom
fix/snapshot-request-dropped-before-firing-height

Conversation

@heifner

@heifner heifner commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

/v1/producer/create_snapshot can close its connection without a response, hanging the caller. Seen in CI as a snapshot_api_test failure on the ubsan shard — http.client.RemoteDisconnected on the second snapshot, with nodeop alive and exiting cleanly afterwards.

The request that endpoint schedules is a one-time request anchored at head + 1, and snapshot_scheduler::on_start_block runs it at start_block_num + 1. unschedule_snapshot_requests collected the request as soon as irreversibility reached start_block_num. Irreversibility never runs ahead of the applied head, so committing block start_block_num lands LIB exactly on the deletion threshold one block before the firing height is applied. The request is erased unexecuted, and destroying the stored completion callback releases the last reference to the caller's HTTP session. Under normal production irreversibility trails the head by two blocks, so the firing height is applied long before the threshold is reached; the window opens for any node applying a run of blocks back to back — catching up after a stall, syncing, or switching forks. In the CI failure the node's application thread was starved for 8.6s while blocks 224 through 232 accumulated, then applied them in one batch.

Request lifetime. One-time requests are collected at lib_height > start_block_num, a threshold first observed while the firing height is being applied, so it can never precede that block's block_start signal. end_block_num no longer bounds them at all: scheduling accepts end_block_num == start_block_num — the shape /v1/producer/schedule_snapshot builds when a caller pins a snapshot to one block, and what Node.scheduleSnapshotAt sends — and expiring on it collected the request one block before it could ever fire, losing it to the same ordering for exactly the requests most specific about which block they want. Recurring requests expire on their end block as before.

Callback lifetime. A request's caller is answered exactly once, and something always answers it. The callback lives in one consumable slot owned by the request and shared with everything that can reach the caller: the handler on each pending snapshot the request produces, and the removal path. Answering moves the callback out of that slot, so whoever gets there first is the only one — which matters because a request outlives its own snapshot in several ways. A snapshot that is forked out is discarded unfinalized and the still-scheduled request regenerates it on the adopted branch; a spent request stays scheduled until irreversibility passes its firing height; and a request can be cancelled at any point, including while a snapshot from it is pending.

Removal. remove_request is the only way a request leaves the container. Both expiry and explicit cancellation through /v1/producer/unschedule_snapshot go through it, and a caller still waiting is resolved with a snapshot_execution_exception rather than having its callback destroyed. An outstanding create_snapshot request is listed by get_snapshot_requests, so its id is available to cancel.

Delivery. The completion handler settles the scheduler's own bookkeeping before answering, and logs and drops what the callback throws. A caller failing on its own account — writing to a client that has gone away — previously escaped into the snapshot pipeline: on_irreversible_block skipped notify_snapshot_finalized for a snapshot that had finalized, which is how provider mode learns to vote on it, and create_snapshot re-entered the handler through CATCH_AND_CALL to report the caller's own failure back to it.

tests/test_snapshot_scheduler.cpp gains cases for each of these, every one failing with only its own fix reverted: the catch-up ordering for the open-ended and exact-range request shapes, a forked-out snapshot regenerating and answering the original caller, a throwing callback resolved once, and cancellation before execution, while a snapshot is in flight, and after delivery. tests/nodeop_snapshot_forked_test.py already issued a create_snapshot across a fork but discarded the result, so an unanswered request left a dead RPC thread rather than a failure; it now asserts the call came back with a snapshot.

A one-time snapshot request runs from on_start_block at start_block_num + 1, but unschedule_snapshot_requests collected it as soon as irreversibility reached start_block_num. Irreversibility never runs ahead of the applied head, so on a node applying a run of blocks back to back -- catching up after a stall, syncing, or switching forks -- committing block start_block_num lands LIB exactly on the deletion threshold, one block before the firing height is applied. The request is erased unexecuted and takes the stored completion callback with it; destroying that callback releases the last reference to the caller's HTTP session, so the connection closes with no response and the client hangs. Under normal production irreversibility trails the head by two blocks, so the two never collide.

Collect one-time requests at lib_height > start_block_num instead. That threshold is first observed while the firing height is being applied, and so never before that block's block_start signal fires.

Guard the callback as well. on_start_block now clears the callback when it hands a one-time request to the execution path, so a request that still holds one has provably never run; collecting such a request resolves it with a snapshot_execution_exception rather than destroying it. An expiry the scheduler gives up on reaches the caller as an error instead of a dropped connection.
@heifner
heifner requested review from a team and huangminghuang August 8, 2026 15:33

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two inline findings from the delegated OCR review.

Comment thread libraries/chain/snapshot_scheduler.cpp Outdated
Comment on lines +39 to +48
if(onetime) {
// The request has handed its completion callback to the execution path and owes the caller
// nothing further: the result is delivered synchronously by create_snapshot() in irreversible
// read mode, otherwise by the pending snapshot once its block becomes irreversible. Drop the
// copy held by the request so unschedule_snapshot_requests() cannot later mistake this for a
// request that expired without ever running and report a spurious failure to a caller that has
// already been answered. Recurring requests keep their callback -- they can fire again.
auto& snapshot_by_id = _snapshot_requests.get<by_snapshot_id>();
if(auto it = snapshot_by_id.find(srid); it != snapshot_by_id.end())
snapshot_by_id.modify(it, [](snapshot_schedule_information& req) { req.next = {}; });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Preserve the callback across fork retries

Clearing req.next when execution merely starts breaks the intentional fork-regeneration path. If the snapshotted block is forked out, the same one-time request runs again when the adopted branch reapplies start_block_num + 1, but the regenerated pending snapshot now receives an empty HTTP callback. If the losing pending snapshot is invalidated when LIB lands at that height, its finalization exception is logged and dropped, so /v1/producer/create_snapshot is never answered. Please retain the callback until the execution completion wrapper actually resolves it, then clear it there; the existing nodeop_snapshot_forked_test.py flow should also assert this timing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e6c7f5a — confirmed, and the mechanism is exactly as you describe. pending_snapshot::finalize throws when the snapshotted block is forked out, on_irreversible_block swallows it with FC_LOG_AND_DROP, so the completion wrapper never runs and the caller is not answered. The regeneration on the adopted branch was the only thing left that could answer it, and it was handed an empty callback.

on_start_block no longer touches the callback. execute_snapshot's completion handler drops the request's copy immediately after http_next resolves, via clear_delivered_request_callback. Clearing at delivery rather than never also matters in the other direction: a next_function permits exactly one invocation across all of its copies, and a spent request stays scheduled until irreversibility passes its firing height, so a further fork at start_block_num + 1 could otherwise reach the same callback a second time.

New case forked_out_snapshot_regenerates_and_still_answers_caller drives it: fire, invalidate the pending snapshot with a competing block id at the snapshotted height, refire, promote. It fails with only this half reverted — check outcome.successes == 1u has failed [0 != 1].

On nodeop_snapshot_forked_test.py — good catch that it should assert this. It already issues the create_snapshot across the fork, but discarded the result, so a dropped callback showed up only as a silently dead RPC thread and the test could still pass. It now records the outcome, joins with a timeout, and asserts a snapshot_name came back.

Comment thread libraries/chain/snapshot_scheduler.cpp Outdated
// blocks back to back (catching up after a stall, syncing, or switching forks): committing
// block start_block_num lands LIB exactly on the threshold, one block before the request's
// firing height is applied.
bool marked_for_deletion = (!req.block_spacing && lib_height > req.start_block_num) ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Keep exact-range one-shot requests through the firing signal

The lib_height >= req.end_block_num arm still deletes a valid one-time request when end_block_num == start_block_num and LIB reaches that height before on_start_block(start_block_num + 1). That accepted shape is already used by the scheduler tests, so the catch-up ordering still loses those requests; only the new end_block_num == UINT32_MAX test is fixed. Please special-case one-time expiry so equality at start_block_num cannot remove it, and add the exact-range ordering as a regression test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in e6c7f5a — confirmed, and it is reachable beyond the scheduler tests: Node.scheduleSnapshotAt() sends {start_block_num: n, end_block_num: n}, and nodeop_snapshot_diff_test.py uses it against a node that is replaying and then syncing to catch up, which is the same back-to-back ordering.

One-time requests no longer consult end_block_num at all:

bool marked_for_deletion = req.block_spacing ? lib_height >= req.end_block_num
                                             : lib_height > req.start_block_num;

Scheduling enforces end_block_num >= start_block_num, so dropping that arm can only delay collection, and only in the end == start case — a one-time request is still always collected once irreversibility passes start_block_num, never left scheduled indefinitely.

New case exact_range_onetime_request_survives_lib_reaching_its_start_block covers the ordering for that shape and fails with only this half reverted — critical check f.scheduler.get_snapshot_requests().snapshot_requests.size() == 1u has failed [0 != 1].

Executing a one-time request is not delivering its snapshot, but on_start_block cleared the request's completion callback the moment it handed the request to the execution path. Outside irreversible read mode the snapshot is only pending then, and is discarded unfinalized if its block is forked out. The request stays scheduled and runs again when the adopted branch reapplies the firing height, but that regenerated snapshot now carries an empty callback: neither snapshot answers the caller of /v1/producer/create_snapshot, and unschedule_snapshot_requests finds no callback to report the removal through either.

Clear the callback where it is actually resolved. execute_snapshot's completion handler drops the request's copy right after answering the caller, so the first delivery -- and only the first -- reaches it, which is also all a next_function permits across its copies. The invariant unschedule_snapshot_requests depends on then holds in both directions: a request still carrying a callback is one whose caller is still waiting.

Stop expiring one-time requests on end_block_num. Scheduling accepts end_block_num equal to start_block_num, which is what a caller pinning a snapshot to one block asks for, and lib_height >= end_block_num collects such a request one block before it can ever fire -- losing it to the same back-to-back block application the firing-height threshold was raised to survive. A one-time request now expires only once irreversibility passes its firing height; recurring requests expire on their end block as before.

nodeop_snapshot_forked_test.py already issues a create_snapshot across a fork but discarded the result, so an unanswered request left a dead thread rather than a failure. It now asserts the call came back with a snapshot.
@heifner
heifner requested a review from huangminghuang August 10, 2026 12:58

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two additional inline findings from the delegated OCR review of the current head.

Comment thread libraries/chain/snapshot_scheduler.cpp Outdated
// The caller now has its answer, so the request must stop carrying the callback: another
// snapshot from the same request would otherwise resolve it a second time, and collecting
// the request would report an undelivered failure over an answer already sent.
clear_delivered_request_callback(srid);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Clear the stored callback even when the response handler throws

http_next(...) runs before this clear. If it throws (for example while writing to a disconnected HTTP client), on_irreversible_block() logs and swallows the exception, then unschedule_snapshot_requests() still sees req.next and invokes the same next_function again. next_function permits exactly one invocation across all copies, so the second call is undefined behavior. Please make clearing exception-safe—clear before invocation or use a scope guard—and cover a throwing completion callback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 72575a1 — confirmed. Chasing it turned up a second instance of the same hazard on a different route: in irreversible read mode create_snapshot invokes the completion handler inside the region guarded by CATCH_AND_CALL(next), so a throwing delivery re-enters that same handler and calls the caller's next_function again directly, without unschedule_snapshot_requests being involved at all.

Three changes, one per path:

  • The caller's callback now lives in a shared_ptr slot the handler moves out of before use, so the first delivery is the only one whichever copy of the handler runs — that closes the CATCH_AND_CALL re-entry.
  • clear_delivered_request_callback runs before the call rather than after, so a throwing callback cannot leave req.next set for the removal path to resolve again — the case you reported.
  • The delivery itself is wrapped in FC_LOG_AND_DROP. A caller failing on its own account should not propagate into the snapshot pipeline, and letting it escape had a second cost: on_irreversible_block skipped notify_snapshot_finalized for a snapshot that had in fact finalized, which is how provider mode learns to vote on it.

I also moved the delivery below the handler's own bookkeeping, so a failing caller cannot leave the request's pending_snapshots half-pruned.

throwing_completion_callback_is_resolved_once covers it: the callback counts its invocations and throws, and a single irreversible block both delivers the snapshot and carries LIB past the firing height, which is the ordering that resolved it twice. With the clear moved back after the call it fails — check invocations == 1u has failed [2 != 1].

// (or execution error) when a snapshot produced by this request completes
// next (may be empty) is stored on the request and resolved exactly once: with the
// snapshot_information (or execution error) of the first snapshot this request delivers, or
// with a snapshot_execution_exception if the request is removed without ever delivering one

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Resolve callbacks when a request is explicitly unscheduled

This new contract says a request removed before delivery resolves its callback with snapshot_execution_exception, but public unschedule_snapshot() still erases the entry without invoking next. An outstanding /v1/producer/create_snapshot request is visible through get_snapshot_requests(), so explicitly unscheduling that id can still destroy the last HTTP-session reference and reproduce the unanswered connection this PR fixes. Please route explicit cancellation and automatic expiry through a common callback-resolving removal path, with a regression test for manual unscheduling.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 72575a1 — confirmed, and reachable exactly as you describe: get_snapshot_requests() lists an outstanding create_snapshot request like any other, so its id can go straight to /v1/producer/unschedule_snapshot.

Removal now has a single path. A private remove_request(sri, undelivered_reason) takes the callback out, erases the entry, and resolves the callback with a snapshot_execution_exception; unschedule_snapshot and unschedule_snapshot_requests both go through it. The reason is an fc::log_message built at each call site, so the expiry case keeps its irreversible-block height and each keeps its own log context.

Two tests: unscheduling_an_outstanding_request_reports_failure_to_caller, which fails against the old unschedule_snapshot with check outcome.errors == 1u has failed [0 != 1]; and unscheduling_a_delivered_request_does_not_re_resolve_caller, which pins the other direction — a request that has already answered its caller stays scheduled until irreversibility passes its firing height, and cancelling it in that window must not reach the callback again.

A completion callback can throw -- a handler writing to a client that has gone away, say -- and the clear that marks the request answered sat after the call, so it was skipped. on_irreversible_block logged and dropped the exception, unschedule_snapshot_requests then found req.next still set, and the same next_function was invoked a second time, which it does not permit. The irreversible-read-mode path had the same hazard by a different route: create_snapshot invokes the completion handler inside the region guarded by CATCH_AND_CALL, so a throwing delivery re-entered that handler with its own failure.

Hold the caller's callback in a slot the handler empties before use, so the first delivery is the only one whichever copy of the handler runs it, clear the request's copy before the call rather than after, and log and drop what the callback throws. A caller failing on its own account is not the snapshot's problem: letting it escape also cost a snapshot that had finalized its notify_snapshot_finalized, which is how provider mode learns to vote on it. The handler now settles its own bookkeeping before answering, so a failing caller cannot leave the request's pending snapshot list half-pruned either.

Route every removal through one path. unschedule_snapshot erased the entry outright, and an outstanding /v1/producer/create_snapshot is a scheduled request listed by get_snapshot_requests, so cancelling one by id destroyed the callback holding its HTTP session and reproduced the unanswered connection this branch set out to fix. Both explicit cancellation and expiry now go through remove_request, which takes the callback out before erasing and resolves it with a snapshot_execution_exception carrying the reason built at the call site.
@heifner
heifner requested a review from huangminghuang August 10, 2026 14:04
Comment thread libraries/chain/snapshot_scheduler.cpp Outdated
// callback rather than destroying it: dropping it releases the last reference to the caller's
// HTTP session, which closes the connection with no response at all and hangs the client until
// it times out.
next_function<snapshot_information> undelivered = existing->next;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Share callback consumption with in-flight execution handlers

remove_request() invokes this copy of req.next, but execute_snapshot() has already copied the same next_function into a separate caller_slot captured by the pending snapshot. If the request is unscheduled after on_start_block() creates that pending snapshot but before it becomes irreversible, this path answers the caller with an error; when the snapshot later finalizes, its still-populated slot invokes the already-consumed callback again, which violates next_function’s exactly-once contract and is undefined behavior. The new tests cover cancellation before execution and after delivery, but not this in-flight window. Please make the consumable slot request-wide/shared with removal (or invalidate the pending handler) and add a schedule → start → unschedule → finalize regression test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b4a3109 — confirmed. The two slots were independent by construction: execute_snapshot built its own from a copy of req.next, so neither side could see the other's consumption. The same crossing existed between two snapshots in flight from one request, which a fork can produce — the second execute_snapshot call made a second independent slot from a req.next that was still populated.

Taking your first option: the slot is now the request's and is shared rather than copied. snapshot_schedule_information holds a shared_ptr<next_function<snapshot_information>>, on_start_block passes that pointer to execute_snapshot instead of a copy of what it points at, and both the completion handler and remove_request go through take_pending_answer, which moves the callback out if one is still waiting. Whoever gets there first empties the slot for everyone else. clear_delivered_request_callback is gone with it — consuming the shared slot is the clear, and it no longer has to find the request by id to do it.

A null slot means "scheduled with no caller" and stays distinct from an emptied one, so requests restored from snapshot-schedule.json need no special case.

unscheduling_an_in_flight_request_answers_caller_once is the schedule → start → unschedule → finalize case: cancellation answers the caller, the pending snapshot then finalizes and notifies its subscribers as usual but does not answer again. Against the previous commit it fails with check outcome.successes == 0u has failed [1 != 0].

Two places could answer the same caller through their own copy of its callback. execute_snapshot copied the request's next_function into a slot captured by the pending snapshot, while the request kept a copy of its own for the removal path, and neither side could see the other's consumption. Cancelling a request between on_start_block creating the pending snapshot and that snapshot becoming irreversible answered the caller with the cancellation error, and then the snapshot finalized and answered it again through a slot that still looked full -- a second invocation of a next_function, which is undefined. Two snapshots in flight from the same request, which a fork can produce, crossed the same way.

Make the slot the request's own and share it. snapshot_schedule_information holds a shared_ptr to the callback rather than the callback itself, on_start_block hands that pointer to execute_snapshot instead of a copy of what it points at, and both the completion handler and remove_request go through take_pending_answer, which moves the callback out if one is still waiting there. Whoever reaches the caller first empties the slot for everyone else, so the answer is exclusive however many handlers exist and however the request ends. A null slot means a request scheduled with no caller, which keeps it distinct from one already answered, and the separate clear that reached into the container by request id is gone.
@heifner
heifner requested a review from huangminghuang August 10, 2026 15:11

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved — the latest shared callback-slot change resolves the in-flight cancellation/double-invocation issue, and the full diff is clean.

Non-blocking request before merge: please refresh the PR description. It still describes clearing the callback in on_start_block and only the original two changes/tests, while the current PR instead uses a request-wide consumable callback slot and now includes fork-retry preservation, throwing-callback hardening, explicit/in-flight cancellation handling, and the additional regression coverage.

@heifner heifner changed the title Chain: keep a snapshot request until its firing height is applied Chain: keep a snapshot request alive until it answers its caller Aug 10, 2026
@heifner
heifner merged commit 6be1447 into master Aug 10, 2026
13 checks passed
@heifner
heifner deleted the fix/snapshot-request-dropped-before-firing-height branch August 10, 2026 16:23
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.

2 participants