Skip to content

Commit f87a4fc

Browse files
authored
fix(jobs): report a timed-out source as Failed, keep a deadline as Cancelled (#240)
* fix(jobs): report a timed-out source as Failed, keep a deadline as Cancelled 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). * test(core): cover the atomic-write cleanup path 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. * style(jobs): address the analyzer findings this PR introduced 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.
1 parent 21b9adc commit f87a4fc

11 files changed

Lines changed: 273 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ The `NeoReports.Abstractions` contract follows SemVer strictly.
3636
dropped column auto-fit. (`AdjustToContents` can't stream.)
3737
- **Opt-in `AddNeoReportsStartupValidation()`** compiles config-driven reports at host startup so a
3838
malformed document fails fast at boot rather than on the first request.
39+
- **`ReportDeadlineExceededException`** (in `NeoReports.Core.Pipeline`), thrown when a run exceeds its
40+
configured `Deadline`. It derives from `OperationCanceledException`, so every existing catch site is
41+
unaffected and a deadline still surfaces as a cancelled run — the distinct type exists because the
42+
caller's own token is *not* cancelled on a deadline, which left it indistinguishable from an
43+
unrelated `OperationCanceledException` (an `HttpClient.Timeout`, say — a genuine failure).
3944
- **Parameterless `ReportBuilder<T>.Retry()`** enabling a sensible production default (3 attempts,
4045
exponential backoff from 1s, jitter). Retries remain **off by default** — this lowers the barrier
4146
to turning them on and the docs now flag the recommendation for production network sources.
@@ -46,6 +51,16 @@ The `NeoReports.Abstractions` contract follows SemVer strictly.
4651
the host — D20 — this is a nudge, not a behaviour change).
4752

4853
### Fixed
54+
- A job whose source times out is now recorded as **Failed**, not "Cancelled". The worker caught
55+
every `OperationCanceledException`, including the `TaskCanceledException` an `HttpClient.Timeout`
56+
raises (its token is not the run's), so a real failure was labelled as an operator-initiated
57+
cancellation with its error detail discarded — and, because that path does not rethrow, Hangfire
58+
recorded the run as succeeded. Only a cancellation from the run's own token takes that path now.
59+
- Concurrent saves of the same report's schedule override — or of the same report config — no longer
60+
collide: both file stores staged every write through a temp path derived only from the report name,
61+
so two overlapping saves shared it (sharing violation, or a `FileNotFound` when the second move
62+
found the first had taken it). Both now share `AtomicFileWrite`, whose temp name is unique per
63+
write and cleaned up if the save fails.
4964
- XLSX output no longer corrupts on edge-case cell values: an XML-illegal control character in a
5065
string is stripped (previously it threw and aborted the whole file), `NaN`/`Infinity` are written
5166
as text (previously an un-openable number cell), `byte[]` is Base64 (previously `"System.Byte[]"`),

docs/STATUS-AND-BACKLOG.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,56 @@ ones are **fixed** (PR pending/merged); two representation tradeoffs are recorde
136136
before 1899-12-30. Both are inherent to the OADate/no-tz cell model; revisit only if a real report
137137
needs sub-day-offset fidelity or pre-1900 dates.
138138

139+
A third hunt covered the destination (upload) and job/scheduling layers. Two fixes shipped; the rest
140+
need a decision.
141+
- **Job/schedule robustness — FIXED.** (a) The worker's unfiltered `catch (OperationCanceledException)`
142+
recorded an `HttpClient.Timeout` (`TaskCanceledException`, foreign token) as "Cancelled." and did
143+
not rethrow, so a genuine failure looked operator-initiated and Hangfire saw success — now filtered
144+
on the run's own token. Because a **deadline** also cancels through a linked token (the run's own
145+
token is not cancelled either), the runner now reports it as `ReportDeadlineExceededException` — an
146+
`OperationCanceledException` subclass — so the worker keeps recording a deadline as `Cancelled`
147+
(now with a reason saying so) while everything else becomes `Failed`. Both directions are covered by
148+
regression tests, each verified to fail without its fix. (b) `FileScheduleOverrideStore` **and**
149+
`FileReportConfigStore` staged every save through a fixed `{name}.json.tmp`, so concurrent saves for
150+
one name collided — both now use the shared `AtomicFileWrite` (unique temp name, deleted if the save
151+
fails). Verified correct in the same pass: `InMemoryJobStore` thread-safety,
152+
`EffectiveSchedule.Resolve` (override/tombstone/fallback), `ScheduleReconciliationHostedService`
153+
(add/update/remove, no duplicate), `CronValidation` (UTC, Cronos, no off-by-one), and the whole
154+
Local/S3 upload path for stream position, disposal, failure mapping and atomicity.
155+
- **⚠️ S3 key templating does not guard caller-controlled parameters (highest open security item).**
156+
`LocalDestination` passes `LocalPathSegment.EnsureSafe` to `PathTemplate.Expand` (the WP2 guard);
157+
`S3Destination` passes **none** — deliberately, since `/` is a legitimate key separator. But that
158+
reasoning covers the author's template, not `{param}` values, which come from the run request body.
159+
With a key template like `reports/{tenant}/{name}.{ext}`, a caller posting `tenant = "other"` (or a
160+
value containing `/`) steers the object into another prefix — a **cross-tenant write** where a
161+
shared bucket relies on prefix isolation. Not an OS traversal (S3 keys are literal, `..` is not
162+
collapsed) and harmless in a single-tenant bucket. The fix is a decision because the safe version
163+
(reject `/` in substituted **values** while keeping it in template literals) would break anyone
164+
intentionally passing a hierarchy fragment as a parameter. **Recommended:** adopt that guard and
165+
note it as breaking, or document that S3 key templates must not interpolate untrusted parameters.
166+
- **Upload swallows `OperationCanceledException` into a `Fail` result (deferred — semantics).** Both
167+
destinations' `catch (Exception)` also catch a cancellation, so a deadline firing mid-upload is
168+
reported as a destination error rather than a cancellation. The run still ends Failed, so this is
169+
attribution accuracy; rethrowing would also change multi-destination behaviour (today the loop
170+
continues and reports per-destination results).
171+
- **Hangfire applies its default 10-attempt `AutomaticRetry` (deferred — decision).** The invoker
172+
carries no `[AutomaticRetry(Attempts = 0)]` and nothing configures `GlobalJobFilters`, so a
173+
deterministically failing job (bad credentials, unreachable source) is re-run up to 10× — re-reading
174+
the whole dataset each time and flapping the stored status Failed→Running→Failed. Output integrity
175+
holds (temp-dir staging is idempotent), but it contradicts the "a job is atomic, one attempt"
176+
model (rule 6). Decide whether NeoReports should pin `Attempts = 0` or leave retries to the host.
177+
- **`InMemoryJobScheduler.RegisterRecurringAsync` remove-then-add isn't atomic (deferred — narrow).**
178+
Two concurrent registrations for one report can both start a loop; the loser is overwritten in the
179+
dictionary without its CTS being cancelled, so it keeps firing untracked for the process lifetime.
180+
Reachable only by racing two schedule updates (or one against startup reconciliation); the Hangfire
181+
path is safe (`AddOrUpdate` is idempotent). A lock around register/remove would fix it.
182+
- **The in-memory recurring loop has no catch-all (deferred — narrow).** Any non-cancellation throw
183+
faults the fire-and-forget loop and the schedule silently stops for the process lifetime, unlogged.
184+
- **`CompletedPartial` surfaces as a `Completed` job (by design, flagged).** A run that skipped
185+
batches maps to `ReportJobStatus.Completed`; the skip is visible only in `Stats.SkippedBatches`.
186+
There is no `Partial` job status. Worth confirming this is still the intent, since silent partial
187+
data reads as a green job.
188+
139189
---
140190

141191
## Where the fuller context lives

src/Jobs/NeoReports.Jobs/ReportJobWorker.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,31 @@ public async Task RunAsync(
6969
"Job {JobId} for report {Report} finished with status {Status} (read={Read}, written={Written}).",
7070
jobId, reportName, status, result.Stats.RecordsRead, result.Stats.RecordsWritten);
7171
}
72-
catch (OperationCanceledException)
72+
// A cancellation is either one caused by OUR token (a caller-requested cancel) or a run that
73+
// exceeded its configured deadline — the runner reports the latter as
74+
// ReportDeadlineExceededException precisely because the caller's token is not cancelled for
75+
// it. An unfiltered catch also swallowed an OperationCanceledException raised by anything
76+
// else — most commonly HttpClient.Timeout, which throws TaskCanceledException carrying its
77+
// own internal token, and which the runner deliberately lets through (its batch handlers
78+
// filter on `ex is not OperationCanceledException`). Those are genuine failures: labelling
79+
// them "Cancelled." discarded the real error and, because this path does not rethrow, made
80+
// Hangfire record the run as succeeded.
81+
catch (OperationCanceledException ex)
82+
when (cancellationToken.IsCancellationRequested || ex is ReportDeadlineExceededException)
7383
{
7484
// Record the event before flipping the store status, so any observer that polls the
7585
// store and sees Cancelled is guaranteed to already find the event on lookup — not a
7686
// race where the status update wins and the event append hasn't landed yet.
7787
await TryEmitCancelledEventAsync(jobId).ConfigureAwait(false);
88+
// A deadline expiry is still a cancellation, but say so: "Cancelled." alone gives an
89+
// operator no way to tell a caller-requested cancel from a run that ran out of time. The
90+
// message is ours (curated, secret-free), so it is safe to persist verbatim.
91+
var reason = ex is ReportDeadlineExceededException deadline ? deadline.Message : "Cancelled.";
7892
// Use CancellationToken.None for the store update — the cancelling token is already
7993
// tripped and we still need to record the terminal state.
80-
await _store.UpdateStatusAsync(jobId, ReportJobStatus.Cancelled, "Cancelled.", CancellationToken.None)
94+
await _store.UpdateStatusAsync(jobId, ReportJobStatus.Cancelled, reason, CancellationToken.None)
8195
.ConfigureAwait(false);
82-
_logger.LogInformation("Job {JobId} for report {Report} was cancelled.", jobId, reportName);
96+
_logger.LogInformation(ex, "Job {JobId} for report {Report} was cancelled ({Reason}).", jobId, reportName, reason);
8397
}
8498
catch (Exception ex)
8599
{
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
namespace NeoReports.Core;
2+
3+
/// <summary>
4+
/// Writes a file atomically: the content goes to a temp file first and is then moved over the final
5+
/// path, so a reader never observes a half-written document and a crash mid-write leaves the previous
6+
/// version intact.
7+
/// </summary>
8+
internal static class AtomicFileWrite
9+
{
10+
/// <summary>
11+
/// Writes <paramref name="content"/> to <paramref name="finalPath"/> through a unique temp file
12+
/// in the same directory (so the move stays on one volume and therefore atomic).
13+
/// </summary>
14+
/// <param name="finalPath">The destination path.</param>
15+
/// <param name="content">The text to write.</param>
16+
/// <param name="cancellationToken">Cancellation token.</param>
17+
public static async Task WriteAsync(string finalPath, string content, CancellationToken cancellationToken)
18+
{
19+
// The temp name must be unique per write, not derived from the destination alone: two
20+
// concurrent saves of the same document would otherwise open and move the SAME temp file,
21+
// failing with a sharing violation or a FileNotFound when the second move finds the first
22+
// already took it. The ".tmp" suffix also keeps it out of the "*.json" listings these stores do.
23+
string tempPath = $"{finalPath}.{Guid.NewGuid():N}.tmp";
24+
try
25+
{
26+
await File.WriteAllTextAsync(tempPath, content, cancellationToken).ConfigureAwait(false);
27+
File.Move(tempPath, finalPath, overwrite: true);
28+
}
29+
catch
30+
{
31+
// A unique temp name can't be reclaimed by the next attempt the way a fixed one was, so
32+
// clean it up here rather than leaving an orphan behind on every failed save.
33+
TryDelete(tempPath);
34+
throw;
35+
}
36+
}
37+
38+
private static void TryDelete(string path)
39+
{
40+
try
41+
{
42+
if (File.Exists(path))
43+
File.Delete(path);
44+
}
45+
catch (IOException)
46+
{
47+
// Best-effort cleanup: a leftover temp file must never mask the original failure.
48+
}
49+
catch (UnauthorizedAccessException)
50+
{
51+
// Same as above.
52+
}
53+
}
54+
}

src/NeoReports.Core/Configuration/FileReportConfigStore.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,7 @@ public async Task SaveAsync(string name, string configDocument, CancellationToke
2525
ArgumentNullException.ThrowIfNull(configDocument);
2626

2727
Directory.CreateDirectory(_directory);
28-
string finalPath = GetPath(name);
29-
string tempPath = finalPath + ".tmp";
30-
await File.WriteAllTextAsync(tempPath, configDocument, cancellationToken).ConfigureAwait(false);
31-
File.Move(tempPath, finalPath, overwrite: true);
28+
await AtomicFileWrite.WriteAsync(GetPath(name), configDocument, cancellationToken).ConfigureAwait(false);
3229
}
3330

3431
/// <inheritdoc />
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
namespace NeoReports.Core.Pipeline;
2+
3+
/// <summary>
4+
/// Thrown when a run exceeds the whole-job deadline configured with
5+
/// <c>ReportBuilder&lt;T&gt;.Deadline(TimeSpan)</c>.
6+
/// <para>
7+
/// It derives from <see cref="OperationCanceledException"/> because a deadline <b>is</b> a
8+
/// cooperative cancellation — every existing <c>catch (OperationCanceledException)</c> keeps working
9+
/// and the run still surfaces as cancelled. The distinct type exists so a caller can tell a deadline
10+
/// apart from an <see cref="OperationCanceledException"/> raised by something else: the run's own
11+
/// token is not cancelled in either case, so the token alone cannot discriminate (an
12+
/// <c>HttpClient.Timeout</c>, for instance, throws a <see cref="TaskCanceledException"/> carrying its
13+
/// own already-cancelled internal token, which is a genuine failure rather than a cancellation).
14+
/// </para>
15+
/// </summary>
16+
public sealed class ReportDeadlineExceededException : OperationCanceledException
17+
{
18+
/// <summary>Creates the exception.</summary>
19+
/// <param name="reportName">The report whose deadline elapsed.</param>
20+
/// <param name="deadline">The configured deadline.</param>
21+
/// <param name="innerException">The cancellation that unwound the run.</param>
22+
public ReportDeadlineExceededException(string reportName, TimeSpan deadline, Exception? innerException = null)
23+
: base($"Report '{reportName}' exceeded its {deadline} deadline and was cancelled.", innerException)
24+
{
25+
ReportName = reportName;
26+
Deadline = deadline;
27+
}
28+
29+
/// <summary>The report whose deadline elapsed.</summary>
30+
public string ReportName { get; }
31+
32+
/// <summary>The configured deadline.</summary>
33+
public TimeSpan Deadline { get; }
34+
}

src/NeoReports.Core/Pipeline/ReportRunner.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,18 @@ public async Task<ReportRunResult> RunAsync(
6767
{
6868
return await ExecuteAsync(report, execution, _services, runToken).ConfigureAwait(false);
6969
}
70-
catch (OperationCanceledException) when (deadlineCts is { IsCancellationRequested: true } && !cancellationToken.IsCancellationRequested)
70+
catch (OperationCanceledException ex) when (deadlineCts is { IsCancellationRequested: true } && !cancellationToken.IsCancellationRequested)
7171
{
7272
logger.LogWarning(
73+
ex,
7374
"Report {Report} (job {JobId}) exceeded its {Deadline} deadline and was cancelled.",
7475
report.Name, jobId, report.Deadline);
75-
throw;
76+
// Rethrown as a deadline-specific OperationCanceledException: the caller's own token is
77+
// NOT cancelled here, so a plain OCE is indistinguishable from one raised by something
78+
// else (an HttpClient.Timeout, say — a genuine failure). The job worker needs that
79+
// distinction to keep recording a deadline as Cancelled while reporting the rest as
80+
// Failed. Still an OperationCanceledException, so existing catch sites are unaffected.
81+
throw new ReportDeadlineExceededException(report.Name, report.Deadline!.Value, ex);
7682
}
7783
}
7884

src/NeoReports.Core/Scheduling/FileScheduleOverrideStore.cs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ namespace NeoReports.Core.Scheduling;
66
/// <summary>
77
/// File-backed <see cref="IScheduleOverrideStore"/>: one <c>{reportName}.json</c> file per override
88
/// in a configured directory, containing the serialized <see cref="ScheduleOverrideEntry"/> (a null
9-
/// <c>cron</c> is the tombstone). Writes go through a temp file plus an atomic move, the same
10-
/// pattern <see cref="FileReportConfigStore"/> uses.
9+
/// <c>cron</c> is the tombstone). Writes go through <see cref="AtomicFileWrite"/> (unique temp file
10+
/// plus an atomic move), shared with <see cref="FileReportConfigStore"/>.
1111
/// </summary>
1212
public sealed class FileScheduleOverrideStore : IScheduleOverrideStore
1313
{
@@ -30,11 +30,8 @@ public async Task SaveAsync(string reportName, ScheduleOverrideEntry entry, Canc
3030
ArgumentNullException.ThrowIfNull(entry);
3131

3232
Directory.CreateDirectory(_directory);
33-
string finalPath = GetPath(reportName);
34-
string tempPath = finalPath + ".tmp";
3533
string document = JsonSerializer.Serialize(entry, Json);
36-
await File.WriteAllTextAsync(tempPath, document, cancellationToken).ConfigureAwait(false);
37-
File.Move(tempPath, finalPath, overwrite: true);
34+
await AtomicFileWrite.WriteAsync(GetPath(reportName), document, cancellationToken).ConfigureAwait(false);
3835
}
3936

4037
/// <inheritdoc />

tests/NeoReports.Core.UnitTests/Scheduling/FileScheduleOverrideStoreTests.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,41 @@ await Should.ThrowAsync<ArgumentException>(
9999
() => store.SaveAsync(invalidName, new ScheduleOverrideEntry("0 6 * * 1"), CancellationToken.None));
100100
}
101101

102+
[Fact]
103+
public async Task A_successful_save_leaves_no_temp_file_behind()
104+
{
105+
var store = new FileScheduleOverrideStore(_directory);
106+
107+
await store.SaveAsync("alpha", new ScheduleOverrideEntry("0 6 * * 1"), CancellationToken.None);
108+
109+
// The write is staged through a unique temp file and moved into place; nothing may linger
110+
// (a stray temp would also have to stay out of ListAsync's "*.json" enumeration).
111+
Directory.EnumerateFiles(_directory, "*.tmp").ShouldBeEmpty();
112+
(await store.ListAsync(CancellationToken.None)).ShouldHaveSingleItem().ReportName.ShouldBe("alpha");
113+
}
114+
115+
[Fact]
116+
public async Task A_failed_save_cleans_up_its_temp_file_and_surfaces_the_error()
117+
{
118+
var store = new FileScheduleOverrideStore(_directory);
119+
Directory.CreateDirectory(_directory);
120+
// A directory sitting where the store wants its file makes the final move fail *after* the
121+
// temp file has been written — the one path that must not leave an orphan behind.
122+
Directory.CreateDirectory(Path.Join(_directory, "alpha.json"));
123+
124+
// The exact type is the OS's business (Windows raises UnauthorizedAccessException here, other
125+
// platforms an IOException); what matters is that the failure surfaces rather than being
126+
// swallowed by the cleanup.
127+
await Should.ThrowAsync<Exception>(
128+
() => store.SaveAsync("alpha", new ScheduleOverrideEntry("0 6 * * 1"), CancellationToken.None));
129+
130+
Directory.EnumerateFiles(_directory, "*.tmp").ShouldBeEmpty();
131+
}
132+
102133
public void Dispose()
103134
{
104135
if (Directory.Exists(_directory))
105136
Directory.Delete(_directory, recursive: true);
137+
GC.SuppressFinalize(this);
106138
}
107139
}

0 commit comments

Comments
 (0)