Skip to content

fix(indexer): report cancelled join tasks as cancelled, not panicked - #1844

Merged
bplatz merged 3 commits into
mainfrom
fix/join-cancelled-reported-as-panic
Sep 16, 2026
Merged

bplatz merged 3 commits into
mainfrom
fix/join-cancelled-reported-as-panic

Conversation

@bplatz

@bplatz bplatz commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Thirteen call sites across the indexer and import turned any tokio::task::JoinError into an error reading "… task panicked: {e}". A JoinError is either a panic or a cancellation, so a cancellation came out as:

Storage write error: index build task panicked: task 3528 was cancelled

Nothing panicked. The orchestrator then logged Indexing failed, will retry and armed a backoff for a process that was exiting. Solo's nightly smoke-data-adopt fails on any log line containing panicked, and it hit this intermittently when stopping a file-mode server mid-build.

Root cause

The cancellation comes from runtime shutdown. Once shutdown begins, the blocking pool refuses a new spawn_blocking and completes its handle as Cancelled immediately. The full rebuild runs its pipeline under spawn_blocking, so the awaiting orchestrator sees the error without yielding. That is why the warning and the retry line land back to back as the process exits.

Fix

  • fluree_db_core::task::TaskFailure classifies a JoinError as Cancelled or Panicked(message), and keeps the panic payload text for &str and String payloads. Only the panic arm of describe() contains "panicked". panic_message moves here from fluree-db-consensus, which now imports it.
  • IndexerError::Cancelled(String) is built through IndexerError::from_join at the three indexer build sites. A real panic stays a StorageWrite error, so its handling is unchanged apart from the label and payload.
  • The orchestrator does not retry a cancelled build. It logs at info, resolves waiters as Cancelled, and sets the ledger idle with no backoff and no last_error. The next process start re-derives pending work from commit_t > index_t. Every other build error keeps the existing warn-and-retry path.
  • Import's ten join sites use TaskFailure::describe, so the wording is correct there too. They remain the same ImportError variants.

A panicking build still becomes an error rather than being re-raised with resume_unwind. That keeps the orchestrator's current isolation behavior.

Tests

  • fluree-db-core task::tests: an aborted task, a spawn_blocking issued after runtime shutdown (the production shape), and &str, String, and non-string panic payloads.
  • fluree-db-indexer orchestrator: cancelled_build_does_not_schedule_retry and its counterpart failed_build_schedules_retry. With the cancelled branch disabled, the first test fails.

Thirteen call sites mapped every tokio JoinError to "... task panicked: {e}".
A JoinError is a panic or a cancellation, and the common cancellation is a
spawn_blocking refused by the blocking pool during runtime shutdown. It
logged as "index build task panicked: task N was cancelled", tripping log
checks keyed on "panicked", and the orchestrator warned and scheduled a
retry for a process that was exiting.

- fluree-db-core: task::TaskFailure classifies a JoinError and keeps the
  panic payload text; panic_message moves here from the consensus crate.
- fluree-db-indexer: IndexerError::Cancelled via IndexerError::from_join at
  the three build sites. The orchestrator resolves waiters as Cancelled and
  goes idle instead of backing off; startup re-derives pending work.
- fluree-db-api: import's ten join sites describe the failure faithfully.
@bplatz bplatz added bug Something isn't working as expected area:storage Storage backends, nameservice, ledger lifecycle, index storage, caches labels Sep 15, 2026
@bplatz
bplatz requested review from aaj3f and zonotope September 15, 2026 11:18

@aaj3f aaj3f 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.

✅ Approve with nits.

This looks great, @bplatz — putting the classification in fluree_db_core::task::TaskFailure and letting the indexer, import and consensus each decide what a cancellation means for them is the right altitude, and the orchestrator half is careful in exactly the way it needs to be: a real panic still takes the StorageWrite path it always did, and only a genuine cancellation skips the backoff. I went looking for a way Cancelled could reach the orchestrator while the process is still alive (an abort() on a build handle, a per-ledger cancel, the rebuild's dedicated runtime being torn down) and couldn't construct one: the three handles are spawn_blocking temporaries awaited in place, TriggerHandle::cancel explicitly doesn't abort in-progress builds, the dedicated rebuild runtime is a never-dropped static used only for the inner block_on, and the tokio source at the pinned 1.49.0 confirms the shutdown-spawn shape the description names. The three new cancellation strings can't trip solo's grep "panicked" smoke gates, and solo's origin/main already pins a descendant of this commit.

One thing to sort before merging, which is about the PR's base rather than its code: because it targets #1839's branch, no ci.yml job ran on it (only the release plan job) — and the diff has no code dependency on #1839 at all (the file sets are disjoint and merge-tree onto main is clean). Retargeting to main and pushing (a base edit alone triggers nothing) gets the real clippy --all-features --all-targets, nextest and wasm jobs on it; I ran the equivalents on every touched crate locally and they're clean, but the workspace-wide gates are still unverified until CI sees it. If you'd rather keep the stack order, landing #1839 first and then pushing this one rebased achieves the same thing. The only other note is the optional dedupe of the "settle as cancelled" block at orchestrator.rs:1897, which is pre-existing duplication this PR extends by one.

Adherence to repo commitments:

  • Patterns/abstractions: ✔ One shared TaskFailure in core consumed by three crates; panic_message consolidated rather than duplicated; IndexerError::from_join is the single seam between classification and orchestrator policy.
  • Performance (speed first, memory second): ✔ JoinError paths only (build failure, import failure, shutdown); no hot path touched. No performance-degradation risk.
  • Testing: ✔ Eight core tests including the production-shape shutdown spawn, plus the paired orchestrator tests; all run by name under cargo test -p fluree-db-core --features native / -p fluree-db-indexer --lib, and both go red under a one-line mutation. ⚠️ No CI ran because of the base.
  • Conventions: ✔ Single commit with a full body; fmt clean; clippy -D warnings clean on core (native,credential), indexer, consensus (raft), api (--lib); consensus also builds without raft; wasm32 fluree-db-api --no-default-features builds; no Cargo.toml change, so core gains no dependency.

Verified locally at branch HEAD (ed59542b3): the tests above by name, two mutations (both red, restored), cargo fmt --all -- --check, package-scoped clippy on every touched crate with CI's feature set, cargo check -p fluree-db-consensus without raft, and cargo check --target wasm32-unknown-unknown -p fluree-db-api --no-default-features.

Approving so you can merge when ready — just be sure to get CI on it first (retarget to main and push, or land #1839 and rebase), since green here right now is absence rather than passage.

/// shutting down is refused by the blocking pool and its handle resolves
/// `Cancelled` immediately — nothing panicked.
#[test]
fn spawn_blocking_during_shutdown_is_cancelled() {

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.

fluree-db-core/src/task.rs:99 — praise. This is the right test for the production shape rather than a generic abort(): it builds a runtime, shutdown_background()s it, and spawns through the cloned Handle, which is exactly the path where tokio's BlockingPool::spawn_task sees shared.shutdown, calls task.shutdown() and hands back an already-cancelled handle (runtime/blocking/pool.rs:393-404 at the pinned 1.49.0). Flipping the Err(_) arm to Panicked fails this test and aborted_task_is_cancelled_not_panicked together, so the classification is pinned from both sides.

);
let mut states = self.states.lock().await;
if let Some(state) = states.get_mut(ledger_id) {
state.resolve_waiters_below(i64::MAX, IndexOutcome::Cancelled);

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.

fluree-db-indexer/src/orchestrator.rs:1897 — optional. Your code didn't introduce this, but this block — resolve waiters Cancelled, clear pending_min_t and next_retry_at, set Idle — is now the sixth copy of the same shape in this file (:775-778, :800-803, :1266-1269, :1318-1321, here, and :1919-1922 in schedule_retry's cancelled branch), and they've already drifted a little (the panic-isolation copy also clears cancelled; this one leaves a stale last_error from an earlier real failure in place, which the doc comment's "no last_error" slightly overstates since the test starts from a fresh state). I wonder if a tiny LedgerIndexState::settle_cancelled() (or similar) would let all six call one thing, e.g.

impl LedgerIndexState {
  /// Resolve every waiter as cancelled and return the ledger to idle.
  fn settle_cancelled(&mut self) {
      self.resolve_waiters_below(i64::MAX, IndexOutcome::Cancelled);
      self.pending_min_t = None;
      self.next_retry_at = None;
      self.phase = IndexPhase::Idle;
  }
}

Not hot-path, not a correctness issue, and I recognize it's pre-existing debt this PR only extends — but if you agree it's right, I'd rather see it folded in now than lost in the backlog.

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.

Addressed in 99a4f43, though only the part of this note I could justify.

The last_error drift you spotted is now a real fix: the cancelled branch clears it, so the doc comment and the admin status API agree. The existing assertion could not have caught that — the test built a fresh state whose last_error was already None — so the test now seeds the state a real cancellation follows (an earlier error plus an armed backoff). With the clearing removed it fails on exactly that assertion.

I left the settle_cancelled() dedupe alone. Reading all six sites, they are not quite one shape: cancel (:775) and cancel_all (:800) set phase = Idle only when the phase is Pending, deliberately leaving an in-progress build running, and :1318 is guarded by has_pending_work(). A helper that sets Idle unconditionally would mark a ledger idle while its build is still in flight. Only :1897 and :1919 are identical. The dedupe that would work is narrower — resolve waiters, clear pending_min_t, clear next_retry_at, with each caller keeping its own phase decision — and I would rather do that deliberately than fold it in here. Happy to file it.

Base automatically changed from investigate/propose-relay-retry to main September 16, 2026 00:22
The cancelled branch documented "no last_error" but never cleared one. A
ledger that failed a build, entered backoff, and was then cancelled by
runtime shutdown settled Idle while still carrying the earlier failure —
which the admin status API reports and the trigger-wait warnings log, so
the stale string outlived the failure it described.

The existing assertion could not catch this: the test built a fresh state
whose last_error was already None. It now seeds the failure state a real
cancellation follows (an earlier error plus an armed backoff), so the
assertion fails if the clearing is removed.
@bplatz

bplatz commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 51a2010 — CI now runs on this PR, and it is green.

#1839 landed, so GitHub retargeted this PR to main on its own; that merge commit is the push that gave the base an actual workflow run. Full ci.yml is green on 99a4f43: the workspace nextest job, clippy --all-features --all-targets, fmt, wasm32 and the sparql suite. Green here is now passage rather than absence.

One thing worth explaining, since the run history shows a red CI on 51a2010. That failure was it_cached_handle_cow_cancel::cancelled_commit_never_exposes_the_empty_cache_slot hitting its 10 s in-test deadline — not this PR's code. That test builds Fluree with .without_indexing(), so indexing_config is None, the builder takes the IndexingMode::Disabled branch, and BackgroundIndexerWorker is never constructed: nothing this PR changes can execute there. It normally runs in 0.047–0.051 s, sat at exactly the 10.029 s deadline once, and passes 6/6 locally including under a saturated CPU. main's own CI was red in the same ten-minute window for a different test that blew nextest's 360 s hard kill, which points at a degraded runner rather than two test bugs.

#1852 raises that deadline along with the other short ones in the suite, and #1851 tracks the 360 s hang, which is deliberately left untuned.

@bplatz
bplatz merged commit 95cfaca into main Sep 16, 2026
16 checks passed
@bplatz
bplatz deleted the fix/join-cancelled-reported-as-panic branch September 16, 2026 01:36
giodamelio pushed a commit to giodamelio/db that referenced this pull request Sep 29, 2026
Two CI failures in the same ten-minute window were timing, not defects:
cancelled_commit_never_exposes_the_empty_cache_slot hit its 10 s deadline
on PR fluree#1844 (it normally runs in 0.05 s and was green on re-run), and a
time-travel test reached nextest's hard kill on main.

An in-test deadline should bound a broken path, while the harness bounds
hangs: nextest runs slow-timeout = 120s with terminate-after = 3, killing
a hung test at 360 s. A 1-10 s in-test deadline is 36-360x less patient
than the supervisor behind it, so a stalled runner reads as a failed
assertion rather than a slow pass. These deadlines only elapse on
failure, so raising them costs nothing on a green run.

Covers both idioms present in the suite — an Instant deadline polled in a
loop, and tokio::time::timeout around a future — plus the two reqwest
connect_timeouts in the raft cluster harness. The cluster client's 10 s
request timeout stays as it is, because the SSE test documents a
dependency on that read timeout being short.

Also widens the elapsed bound in propose_connect_timeout_is_retryable,
which measured 5.212 s against a 7 s bound on CI. Ten seconds still
proves the 5 s connect budget rather than the 15 s request timeout ended
the wait, with room for a loaded runner.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:storage Storage backends, nameservice, ledger lifecycle, index storage, caches bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants