Skip to content

fix(jobs): report a timed-out source as Failed, keep a deadline as Cancelled - #240

Merged
thiagoluga merged 3 commits into
masterfrom
fix/job-cancel-classification
Jul 30, 2026
Merged

fix(jobs): report a timed-out source as Failed, keep a deadline as Cancelled#240
thiagoluga merged 3 commits into
masterfrom
fix/job-cancel-classification

Conversation

@thiagoluga

Copy link
Copy Markdown
Owner

What

ReportJobWorker's catch (OperationCanceledException) was unfiltered, so it also swallowed an OCE raised by something other than the run's own token — most commonly HttpClient.Timeout, whose TaskCanceledException carries an internal token, and which the runner deliberately lets past its batch handlers (they filter on ex is not OperationCanceledException).

Consequences for any of the 7 shipped HTTP-family sources:

  • the job was recorded Cancelled with the message "Cancelled." — indistinguishable from an operator-initiated cancel, real error detail discarded;
  • that path does not rethrow, so Hangfire recorded the background job as succeeded (no retry), unlike every other failure.

The subtlety this PR had to solve

Filtering on cancellationToken.IsCancellationRequested alone — the obvious fix — would have broken the documented Deadline contract. A deadline cancels through a linked token (ReportRunner.RunAsync), so the caller's own token isn't cancelled for it either. A deadline would have been downgraded to Failed, which rethrows, handing it to Hangfire's default 10-attempt AutomaticRetry — re-running the whole report up to ten more times, each burning a full deadline. Three shipped doc comments state a deadline "surfaces as a cancelled run".

Widening the filter to ex.CancellationToken.IsCancellationRequested does not work either: HttpClient builds its timeout exception with its own already-cancelled token, which would readmit the original bug.

So the runner now reports a deadline as ReportDeadlineExceededException — an OperationCanceledException subclass, so every existing catch site and DeadlineTests are unaffected — and the worker treats that plus its own token as cancellation, recording a reason that says which (a bare "Cancelled." gave an operator no way to tell a cancel from a run that ran out of time).

Also fixed

Both file stores (FileScheduleOverrideStore, FileReportConfigStore) staged every write through a temp path derived only from the name, so two concurrent saves of the same document shared {name}.json.tmp → sharing violation, or a FileNotFound when the second move found the first had taken it. Both now use a shared AtomicFileWrite with a unique temp name, deleted if the save fails. (ListAsync enumerates *.json, so .tmp files are never picked up.)

Verification

  • Jobs.UnitTests 34 passed, Core.UnitTests 296 passed, full solution builds clean.
  • Both new tests were verified to fail without their fix: A_source_timeout_is_reported_as_failed_not_cancelled yields Cancelled without the filter; An_expired_deadline_is_still_recorded_as_cancelled yields Failed without the discriminator. (The deadline path had no worker-level test at all before — which is exactly why the naive fix would have shipped silently.)

Found by a bug hunt over the job/scheduling layer; the remaining findings (Hangfire's default 10× retry vs. the atomic-job model, the in-memory recurring-registration race, and the S3 key template not guarding caller-controlled parameters — the highest open security item) are recorded in docs/STATUS-AND-BACKLOG.md §5 as they need a decision.

…ncelled

The worker's `catch (OperationCanceledException)` was unfiltered, so it also
swallowed an OCE raised by something other than the run's own token — most
commonly HttpClient.Timeout, whose TaskCanceledException carries an internal
token and which the runner deliberately lets past its batch handlers (they
filter on `ex is not OperationCanceledException`). A genuine failure was
recorded as "Cancelled." with its error detail discarded and, because that path
does not rethrow, Hangfire recorded the background job as succeeded.

Filtering on `cancellationToken.IsCancellationRequested` alone would have broken
the documented Deadline contract: a deadline cancels through a LINKED token, so
the caller's token is not cancelled for it either, and the job would have been
downgraded to Failed — which rethrows, handing it to Hangfire's automatic retry
to re-run the whole report up to ten more times. The runner therefore now reports
a deadline as ReportDeadlineExceededException (an OperationCanceledException
subclass, so every existing catch site is unaffected) and the worker treats that,
plus its own token, as cancellation — recording a reason that says which.

Also fixes the same fixed-temp-name collision in both file stores: concurrent
saves of one report's schedule override (or config) shared `{name}.json.tmp` and
failed with a sharing violation or a FileNotFound. Both now go through the shared
AtomicFileWrite, whose temp name is unique per write and is cleaned up on failure.

Both regressions are covered by tests that were each verified to FAIL without
their fix (timeout → Cancelled; deadline → Failed).
The Sonar gate flagged new_coverage (60.7%): AtomicFileWrite's failure branch —
the one that deletes the unique temp file so a failed save leaves no orphan —
had no test. Exercise it through the public store API: a directory planted where
the store wants its file makes the final move fail after the temp is written.
Also assert a successful save leaves no temp behind.
S6667 (log in a catch should pass the caught exception) fired on the two
cancellation logs — the deadline warning now has the exception in scope, so pass
it; it also records where the run was when it was cut off. CA1861 (constant array
argument) on the new store assertion. The remaining Sonar issues on
ReportRunner.cs (S107/S125/IDE0008) sit on pre-existing lines this PR never
touched and are left alone.
@sonarqubecloud

Copy link
Copy Markdown

@sonarqubecloud

Copy link
Copy Markdown

@thiagoluga
thiagoluga merged commit f87a4fc into master Jul 30, 2026
5 checks passed
@thiagoluga
thiagoluga deleted the fix/job-cancel-classification branch July 30, 2026 23:27
thiagoluga added a commit that referenced this pull request Aug 5, 2026
…load a destination error (ADR D78) (#275)

* fix(core): give FailureRate a sample, and stop calling a cancelled upload a destination error (ADR D78)

Two §5 items filed as "semantics choices". Reading the code closely made the first look
less like a choice than a threshold that never worked.

ReportRunner increments batches AND totalFailures before computing totalFailures/batches,
so a failure in the very first batch always yields exactly 1.0 — tripping every FailureRate
below 1, whatever it was configured to. The second batch failing gives 0.5. Three batches
is not a rate. FailureRate now evaluates only after FailureRateMinimumBatches (default 10)
batches have been seen; aborting earlier is what ConsecutiveFailures and TotalFailures are
for, and both are untouched.

FailureRateMinimumBatches is an init-only PROPERTY, not a fourth positional parameter on
AbortThresholdConfig: that record is in the frozen Abstractions ABI (rule 7) and adding a
parameter would change its primary constructor signature, while a new property is additive.
ThresholdContext gained the batch count the same way, sourced from
BatchFailureContext.PageNumber, which the runner increments in lockstep with batches.

Separately, both destinations' catch (Exception) swallowed OperationCanceledException into
UploadResult.Fail, so a deadline firing mid-upload was attributed to S3 or the filesystem
with the real reason replaced by a provider-shaped message. Cancellation now rethrows,
filtered on the caller's own token exactly as ReportJobWorker has done since #240 — an OCE
carrying someone else's token (an SDK timeout) stays a genuine transport failure, which is
also what leaves the multi-destination loop free to keep collecting per-destination results.

One existing test encoded the old FailureRate behaviour on a three-batch fixture; it now
sets the minimum to 3 explicitly so it keeps measuring the ratio arithmetic it was written
for rather than the guard in front of it. That is the third test this sweep found asserting
a defect as if it were the spec.

Five tests. The S3 ones fail against the unfixed code; the Core ones cannot even compile
without the new property, which is its own proof they are not vacuous.

Core 306, S3 8, Local 14, Jobs 44.

* test(local): cover the destination's cancellation path

I wrote the cancellation tests for S3 and forgot the identical branch in LocalDestination —
Sonar's new-code coverage caught it at 72.7%. That is the same lapse I named two PRs ago:
when a guard is added for an edge case, its test belongs in the same pass, not after a gate
points at it.

Two tests, mirroring the S3 pair: a cancelled write rethrows rather than becoming a
destination error, and an OperationCanceledException carrying someone else's token stays a
transport failure.

The first also asserts no file survives. The target DIRECTORY is created up front and
legitimately remains — my first assertion demanded it be gone and failed for the wrong
reason, which is worth noting because an over-specified assertion looks exactly like a real
defect until you read it.

Local 16/16, verified to fail with the cancellation catch removed.
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.

1 participant