Skip to content

Commit f2c81b4

Browse files
authored
fix(sources): reserve the engine's cursor/pageSize bind names from run-time parameters (#253)
Run-time (execution) parameters were bound BEFORE the engine's own @cursor and @pageSize, and AddParameter skips a name that is already bound — so a caller passing a parameter named "cursor" pinned the keyset cursor to their value for every page. A standard keyset query (WHERE (@cursor IS NULL OR Id > @cursor) ORDER BY Id) then returns the same first page forever: the runner's page loop only stops on HasMore=false, so the run never terminates and keeps appending to its temp file. Reachable from one report-run request. Bind the engine's reserved names first, so the existing first-wins rule protects them; run-time parameters still beat the source's static ones for every other name. Applied to both the read and the row-count query. Covered by a Sqlite integration test verified to fail without the fix (page 2 repeated page 1).
1 parent 86749f2 commit f2c81b4

3 files changed

Lines changed: 44 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ The `NeoReports.Abstractions` contract follows SemVer strictly.
5858
the host — D20 — this is a nudge, not a behaviour change).
5959

6060
### Fixed
61+
- **A run-time parameter can no longer take over the engine's keyset cursor.** Execution parameters
62+
were bound before the engine's own `@cursor`/`@pageSize`, and the binder keeps whichever name was
63+
bound first — so a report run supplying a parameter called `cursor` pinned it for every page. The
64+
keyset query then returned the same first page indefinitely and the run never terminated (the page
65+
loop only stops when a source reports no more data), growing its staging file the whole time. The
66+
engine's reserved names are now bound first; run-time parameters still override the source's static
67+
ones for every other name.
6168
- A job whose source times out is now recorded as **Failed**, not "Cancelled". The worker caught
6269
every `OperationCanceledException`, including the `TaskCanceledException` an `HttpClient.Timeout`
6370
raises (its token is not the run's), so a real failure was labelled as an operator-initiated

src/Sources/NeoReports.Sources.Common/AdoKeysetSource.cs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -90,20 +90,23 @@ public async Task<BatchResult<T>> ReadBatchAsync(BatchContext context, Cancellat
9090
command.CommandText = _sql;
9191
_configureCommand?.Invoke(command);
9292

93+
// The engine's own bind names go FIRST so nothing can take them over: AddParameter skips a
94+
// name that is already bound, so whichever is added first wins. A run-time parameter called
95+
// "cursor" would otherwise pin the keyset cursor to a caller-supplied value for every page —
96+
// the query keeps returning the same first page and the run never terminates. "pageSize" is
97+
// reserved for the same reason (it caps rows per page without requiring TOP/OFFSET/LIMIT/
98+
// ROWNUM in the user's SQL, and drives the has-more decision).
99+
AddParameter(command, "cursor", DecodeCursor(context.Cursor), _parameterPrefix);
100+
AddParameter(command, "pageSize", _pageSize, _parameterPrefix);
101+
93102
// Merge run-time (execution) parameters with the source's static parameters. Run-time values
94103
// are added FIRST so that when a report supplies a per-run override of a name the author also
95-
// set statically (a tenant id, a date range), the run-time value wins — AddParameter skips a
96-
// name that is already bound, so whichever is added first takes precedence.
104+
// set statically (a tenant id, a date range), the run-time value wins — same first-wins rule.
97105
foreach (KeyValuePair<string, object?> kvp in context.Execution.Parameters)
98106
AddParameter(command, kvp.Key, kvp.Value, _parameterPrefix);
99107
foreach (KeyValuePair<string, object?> kvp in _parameters)
100108
AddParameter(command, kvp.Key, kvp.Value, _parameterPrefix);
101109

102-
AddParameter(command, "cursor", DecodeCursor(context.Cursor), _parameterPrefix);
103-
104-
// Cap rows per page without requiring TOP/OFFSET/LIMIT/ROWNUM in the user's SQL.
105-
AddParameter(command, "pageSize", _pageSize, _parameterPrefix);
106-
107110
var records = new List<T>(_pageSize);
108111
string? lastKey = null;
109112
var read = 0;
@@ -152,16 +155,17 @@ public async Task<BatchResult<T>> ReadBatchAsync(BatchContext context, Cancellat
152155
command.CommandText = $"SELECT COUNT(*) FROM (\n{innerSql}{_countInnerSuffix}\n) q";
153156
_configureCommand?.Invoke(command);
154157

155-
// Same run-time-wins precedence as ReadBatchAsync (execution parameters added first), so the
158+
// Same ordering as ReadBatchAsync: the engine's reserved names first (so a run-time parameter
159+
// can't take them over), then run-time parameters, then the source's static ones — so the
156160
// progress count reads with the exact values the real read will use.
161+
AddParameter(command, "cursor", null, _parameterPrefix);
162+
AddParameter(command, "pageSize", _pageSize, _parameterPrefix);
163+
157164
foreach (KeyValuePair<string, object?> kvp in execution.Parameters)
158165
AddParameter(command, kvp.Key, kvp.Value, _parameterPrefix);
159166
foreach (KeyValuePair<string, object?> kvp in _parameters)
160167
AddParameter(command, kvp.Key, kvp.Value, _parameterPrefix);
161168

162-
AddParameter(command, "cursor", null, _parameterPrefix);
163-
AddParameter(command, "pageSize", _pageSize, _parameterPrefix);
164-
165169
object? result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
166170
return Convert.ToInt64(result, CultureInfo.InvariantCulture);
167171
}

tests/NeoReports.Sources.Sqlite.IntegrationTests/SqliteKeysetSourceTests.cs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,28 @@ public class SqliteKeysetSourceTests : IClassFixture<SqliteFileFixture>
1717
"SELECT Id, Customer, Amount, Date FROM Sales " +
1818
"WHERE (@cursor IS NULL OR Id > @cursor) ORDER BY Id";
1919

20-
private static ReportExecutionContext Exec() =>
21-
new("job", "sales", null, NullLogger.Instance, CancellationToken.None);
20+
private static ReportExecutionContext Exec(IReadOnlyDictionary<string, object?>? parameters = null) =>
21+
new("job", "sales", parameters, NullLogger.Instance, CancellationToken.None);
22+
23+
[Fact]
24+
public async Task A_run_time_parameter_cannot_take_over_the_engine_cursor()
25+
{
26+
// Regression: run-time parameters used to be bound BEFORE the engine's own @cursor, and
27+
// AddParameter skips an already-bound name — so a caller passing "cursor" pinned it for every
28+
// page. The keyset query then kept returning the same first page and the run never advanced
29+
// (an unbounded loop writing to disk, since the page loop only stops on HasMore=false).
30+
var hijack = new Dictionary<string, object?> { ["cursor"] = null };
31+
var source = Source.Sqlite(_fixture.ConnectionString, Sql).Keyset<Sale, long>(v => v.Id, pageSize: 10);
32+
33+
var first = await source.ReadBatchAsync(new BatchContext(Exec(hijack), 10, null, 1), CancellationToken.None);
34+
var second = await source.ReadBatchAsync(
35+
new BatchContext(Exec(hijack), 10, first.NextCursor, 2), CancellationToken.None);
36+
37+
first.Records.Count.ShouldBe(10);
38+
second.Records.Count.ShouldBe(10);
39+
// The second page must move past the first — not repeat it.
40+
second.Records[0].Id.ShouldBeGreaterThan(first.Records[^1].Id);
41+
}
2242

2343
[Fact]
2444
public async Task Reads_all_pages_in_order_without_gaps_or_duplicates()

0 commit comments

Comments
 (0)