Skip to content

Commit 03713b6

Browse files
authored
fix(sources): never end a run early and call it Completed (ADR D72) (#264)
* fix(sources): never end a run early and call it Completed (ADR D72) The runner's stuck-cursor guard (#263) bounds a source that makes no progress. This is the opposite failure direction, which the §6 audit found in four places: a source that stops early and reports the run Completed with rows missing. Nothing downstream can tell that apart from a genuinely complete run — no exception, no partial capture, no warning — which makes it the worst outcome the pipeline can produce. - Elasticsearch answers a partial search with HTTP 200: it sets `timed_out`, or reports failed shards under `_shards`, and returns what the responsive shards had. Neither field was inspected, so the short page simply ended pagination. Both are now checked before the hits are read, matching what GraphQL (D63) does with a 200 carrying `errors` and this source's own "full page with no sort values" guard. - `records.Count == pageSize` is no longer how "is there more?" is decided in OData's `Skip` and the HTTP source's `Page`/`Offset`. Inferring it from a full page is wrong whenever the service caps the page below what was requested — Dynamics, SAP Gateway and Business Central all clamp, and many REST APIs silently reduce an over-max limit, so against any of them the FIRST page comes back short and the run stopped there. They now page until a response comes back empty: one extra request per run, and this class of truncation becomes structurally impossible. NextLink and the cursor strategies are unaffected, since they follow a real token. - HubSpot and Airtable clamp the page size to the 100 their APIs accept rather than sending the engine's 1000 default and failing the first request (maintainer decision, D72). Safe because both derive HasMore from the server's own continuation token, so clamping only means more requests — had they inferred it from a full page, clamping alone would have been unsafe, which is exactly the bug above. Also clears CodeQL cs/linq/missed-select (alert 292), opened on master by the Link-header parsing in #262: the loop guards its split on the way in instead of assigning then checking, which is what read as a missed `.Select`. Eight tests, each verified to fail against the unfixed code. Three existing tests encoded the old full-page semantics and are updated rather than deleted; a new one covers the actual defect — a service that caps the page no longer truncates the report. * refactor(sources): drop the page-size argument BuildPageResult no longer uses
1 parent 32475d0 commit 03713b6

12 files changed

Lines changed: 257 additions & 26 deletions

File tree

DECISIONS.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,3 +1149,38 @@ special cases.
11491149
the stuck-source tests, and reverting *only* the adapter (keeping the guard) fails the streaming
11501150
test. The echoing fake throws past 50 reads on purpose — without the guard it loops forever, and a
11511151
hanging test is worse than a failing one, both for CI and for anyone bisecting later.
1152+
1153+
### D72, continued — the source-level half: never truncate quietly
1154+
1155+
The runner's guard bounds a source that makes no progress. It cannot help with the opposite failure
1156+
direction, which the §6 audit found in four places: a source that **stops early** and reports the
1157+
run as `Completed` with rows missing. Nothing downstream can tell that apart from a genuinely
1158+
complete run — no exception, no partial-artifact capture, no warning — so it is the worst outcome
1159+
the pipeline can produce. All four are resolved the same way: fail, or keep going, but never
1160+
silently deliver less than was asked for.
1161+
1162+
**Elasticsearch partial searches now fail.** A partial search is HTTP **200** with fewer hits than
1163+
the shards hold: the cluster sets `timed_out`, or reports failed shards under `_shards`, and returns
1164+
what the responsive shards had. Neither field was inspected, so the short page ended pagination.
1165+
This matches what GraphQL (D63) already does with a 200 carrying `errors`, and this source's own
1166+
"full page with no sort values" guard. It does turn a previously-silent success into a hard failure
1167+
— that is the point; a report missing an unknown number of rows is not a success.
1168+
1169+
**`records.Count == pageSize` is no longer how "is there more?" is decided.** OData's `Skip` and the
1170+
HTTP source's `Page`/`Offset` strategies have no server token to follow, so it can only be inferred
1171+
— but inferring it from a *full* page is wrong whenever the service caps the page below what was
1172+
requested. Dynamics, SAP Gateway and Business Central all clamp `$top`/`limit`, and many REST APIs
1173+
silently reduce an over-max value; against any of them the **first** page comes back short and the
1174+
run stopped there. These now page until a response comes back **empty**. The cost is one extra
1175+
request at the end of a run; the benefit is that this class of truncation is structurally impossible
1176+
rather than merely unlikely. (`NextLink` and the cursor strategies are unaffected — they follow a
1177+
real token.)
1178+
1179+
**HubSpot and Airtable clamp the page size instead of failing.** Both cap at 100 while the engine
1180+
defaults to 1000, so a source built with defaults failed its very first request until the author
1181+
happened to call `.PageSize(100)` — a default configuration that could not work.
1182+
**Decision (maintainer, 2026-08-05): clamp.** A report author should not have to know each
1183+
provider's ceiling, and a page size is a throughput hint, not a promise about how many rows arrive
1184+
at once. Both derive `hasMore` from the server's own continuation token, so clamping only means more
1185+
requests — it cannot truncate. (Had they inferred it from a full page, clamping alone would have
1186+
been unsafe; that is precisely the bug fixed in the paragraph above.)

docs/STATUS-AND-BACKLOG.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -199,14 +199,19 @@ rather than decided:
199199
emitted a constant cursor — every file-backed source would have failed at page 2 — so the adapter
200200
now emits its page count rather than the runner special-casing a sentinel. Original description:
201201
no page cap, no "the cursor did not change" guard, no "zero rows but still more" guard.
202-
- **`records.Count == pageSize` ends a run early when the server caps the page.** OData's `Skip`
202+
- ~~**`records.Count == pageSize` ends a run early when the server caps the page.**~~ **FIXED
203+
(ADR D72)**`Skip`/`Page`/`Offset` now page until a response comes back **empty**, so this class
204+
of truncation is structurally impossible rather than merely unlikely. Costs one extra request per
205+
run. Original description: OData's `Skip`
203206
strategy (`ODataBatchSource`) and the HTTP source's `Page`/`Offset` strategies infer "more data"
204207
from a full page. Services that clamp `$top`/`limit` below the engine's 1000 default (Dynamics, SAP
205208
Gateway, Business Central; many REST APIs silently reduce an over-max `limit`) return a short first
206209
page → the run stops there and reports **`Completed`** with partial data. `Skip` is opt-in and
207210
`NextLink` is the default, which limits blast radius. A fix means either honouring `@odata.nextLink`
208211
in `Skip` mode too, or paging until a page returns zero rows — both change termination semantics.
209-
- **Elasticsearch treats a partially-failed search as a short page.** ES returns **HTTP 200** with
212+
- ~~**Elasticsearch treats a partially-failed search as a short page.**~~ **FIXED (ADR D72)** — both
213+
fields are inspected before the hits are read, and a partial search now fails loudly. Original
214+
description: ES returns **HTTP 200** with
210215
`timed_out: true` / `_shards.failed > 0` and fewer hits; neither field is inspected, so the report
211216
silently ends early as `Completed`. GraphQL already fails loudly on 200-with-`errors`; the ES
212217
equivalent would be consistent, but it turns today's silent success into a hard failure.
@@ -252,11 +257,12 @@ rather than decided:
252257
index, so a misconfigured `headerRow` produces N rows of all-nulls reported as success instead of
253258
failing loudly; (c) an interior blank row is returned as `[]` and materialized as a phantom
254259
all-default row. (a) is the clearest and most contained.
255-
- **HubSpot and Airtable default to a page size their API rejects.** Both send the engine's 1000
256-
default as `limit`/`pageSize`, but both providers cap at 100 (recorded in `DECISIONS.md`), so a
260+
- ~~**HubSpot and Airtable default to a page size their API rejects.**~~ **DECIDED AND FIXED
261+
(ADR D72)** — the maintainer chose **clamping**: an author should not need to know each provider's
262+
ceiling. Safe because both derive `hasMore` from the server's continuation token, so clamping only
263+
means more requests. Original description: Both send the engine's 1000
264+
default as `limit`/`pageSize`, but both providers cap at 100, so a
257265
source built with defaults fails its very first request until the author calls `.PageSize(100)`.
258-
Loud, but the default configuration is non-functional. Clamping vs. failing with a clear message is
259-
a product call.
260266
- ~~**API: `POST /reports/{name}/preview` is the one data-plane endpoint that doesn't scrub driver
261267
exceptions.**~~ **FIXED** (routes through `SchemaProblem` like its siblings). Original description: It catches only `ConfigurationException`, so a bad filter value surfaces the raw
262268
`SqlException`/`PostgresException` (host, port, database) as a 500 — its siblings all route through

src/Sources/NeoReports.Sources.Airtable/AirtableBatchSource.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,19 @@ public async Task<BatchResult<T>> ReadBatchAsync(BatchContext context, Cancellat
7878
return new BatchResult<T>(records, cursor, hasMore);
7979
}
8080

81+
/// <summary>
82+
/// The largest page Airtable accepts. The engine's own default is 1000, and sending that made the
83+
/// very first request fail until the author happened to call <c>.PageSize(100)</c> — a default
84+
/// configuration that could not work. The maintainer chose clamping over failing (ADR D72): a
85+
/// report author should not have to know each provider's ceiling, and a page size is a
86+
/// throughput hint, not a promise about how many rows arrive at once.
87+
/// </summary>
88+
private const int MaxPageSize = 100;
89+
8190
private Uri BuildRequestUri(AirtableCursorState state, int pageSize)
8291
{
83-
var queryParams = new List<(string Key, string Value)> { ("pageSize", pageSize.ToString(CultureInfo.InvariantCulture)) };
92+
int effectivePageSize = Math.Min(pageSize, MaxPageSize);
93+
var queryParams = new List<(string Key, string Value)> { ("pageSize", effectivePageSize.ToString(CultureInfo.InvariantCulture)) };
8494

8595
if (state.Offset is { Length: > 0 } offset)
8696
queryParams.Add(("offset", offset));

src/Sources/NeoReports.Sources.Elasticsearch/ElasticsearchBatchSource.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ public async Task<BatchResult<T>> ReadBatchAsync(BatchContext context, Cancellat
8383
{
8484
using JsonDocument document = await JsonDocument.ParseAsync(responseBody, cancellationToken: cancellationToken).ConfigureAwait(false);
8585

86+
EnsureSearchWasComplete(document.RootElement);
87+
8688
JsonElement hits = JsonRecords.GetArray(document.RootElement, "hits.hits");
8789
var records = new List<T>(context.PageSize);
8890
JsonElement? lastHit = null;
@@ -154,4 +156,43 @@ private byte[] BuildSearchBody(ElasticsearchCursorState state, int pageSize)
154156

155157
return stream.ToArray();
156158
}
159+
160+
/// <summary>
161+
/// Rejects a search that Elasticsearch answered only partially.
162+
/// <para>
163+
/// A partial search is <b>HTTP 200</b> with fewer hits than the shards actually hold: the cluster
164+
/// sets <c>timed_out</c>, or reports failed shards under <c>_shards</c>, and returns whatever the
165+
/// responsive shards had. Read as an ordinary short page, that ends pagination and the report is
166+
/// delivered as <c>Completed</c> while silently missing rows — the worst outcome available, since
167+
/// nothing downstream can tell it apart from a genuinely complete run.
168+
/// </para>
169+
/// <para>
170+
/// This is the same posture GraphQL (D63) already takes toward a 200 carrying <c>errors</c>, and
171+
/// the same direction as this source's own "full page with no sort values" guard: fail loudly
172+
/// rather than truncate quietly (ADR D72).
173+
/// </para>
174+
/// </summary>
175+
private static void EnsureSearchWasComplete(JsonElement root)
176+
{
177+
if (JsonRecords.TryGetField(root, "timed_out", out JsonElement timedOut)
178+
&& timedOut.ValueKind == JsonValueKind.True)
179+
{
180+
throw new HttpSourceException(null, null,
181+
"Elasticsearch reported 'timed_out': the search returned only the hits it had gathered " +
182+
"before the timeout, so the report would silently be missing rows. Raise the search " +
183+
"timeout, or narrow the query.");
184+
}
185+
186+
if (JsonRecords.TryGetField(root, "_shards.failed", out JsonElement failed)
187+
&& failed.ValueKind == JsonValueKind.Number
188+
&& failed.TryGetInt32(out int failedShards)
189+
&& failedShards > 0)
190+
{
191+
JsonRecords.TryGetField(root, "_shards.total", out JsonElement total);
192+
string totalText = total.ValueKind == JsonValueKind.Number ? total.GetRawText() : "?";
193+
throw new HttpSourceException(null, null,
194+
$"Elasticsearch reported {failedShards} of {totalText} shards failed, so the response " +
195+
"covers only part of the index and the report would silently be missing rows.");
196+
}
197+
}
157198
}

src/Sources/NeoReports.Sources.Http/HttpBatchSource.cs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ public async Task<BatchResult<T>> ReadBatchAsync(BatchContext context, Cancellat
9797
{
9898
HttpPaginationStrategy.LinkHeader => BuildLinkHeaderResult(records, response, requestUri),
9999
HttpPaginationStrategy.Cursor => BuildCursorResult(records, document.RootElement, state),
100-
HttpPaginationStrategy.Page => BuildPageResult(records, state, context.PageSize),
101-
HttpPaginationStrategy.Offset => BuildOffsetResult(records, state, context.PageSize),
100+
HttpPaginationStrategy.Page => BuildPageResult(records, state),
101+
HttpPaginationStrategy.Offset => BuildOffsetResult(records, state),
102102
_ => throw new InvalidOperationException($"Unsupported pagination strategy '{_options.PaginationStrategy}'."),
103103
};
104104
}
@@ -211,8 +211,11 @@ private static IEnumerable<string> SplitLinkValues(string headerValue)
211211
{
212212
foreach (string link in SplitLinkValues(headerValue))
213213
{
214-
string[] parts = link.Split(';');
215-
if (parts.Length < 2)
214+
// Guarded on the way in rather than assigned then checked: a bare `var parts =
215+
// link.Split(...)` as the loop's first statement reads to CodeQL as a map that should
216+
// have been a .Select, which this loop cannot be — it has two guards and an early
217+
// return (alert cs/linq/missed-select, opened by the Link-parsing fix in #262).
218+
if (link.Split(';') is not { Length: >= 2 } parts)
216219
continue;
217220

218221
string urlPart = parts[0].Trim();
@@ -269,18 +272,25 @@ private BatchResult<T> BuildCursorResult(List<T> records, JsonElement responseRo
269272
return new BatchResult<T>(records, cursor, hasMore);
270273
}
271274

272-
private BatchResult<T> BuildPageResult(List<T> records, HttpCursorState state, int pageSize)
275+
// Page and Offset have no next-page token to follow, so "is there more?" can only be inferred.
276+
// Inferring it from a FULL page is wrong whenever the service caps the page below what was asked
277+
// for — Dynamics, SAP Gateway and Business Central all clamp, and many REST APIs silently reduce
278+
// an over-max limit. The short first page then reads as the last one and the run reports
279+
// Completed with a fraction of the data. Paging until a page comes back EMPTY costs one extra
280+
// request at the end of a run and cannot truncate (ADR D72).
281+
private BatchResult<T> BuildPageResult(List<T> records, HttpCursorState state)
273282
{
274283
int currentPage = state.Page ?? _options.StartPage;
275-
bool hasMore = records.Count == pageSize;
284+
bool hasMore = records.Count > 0;
276285
string? cursor = hasMore ? HttpPagination.Encode(new HttpCursorState(Page: currentPage + 1)) : null;
277286
return new BatchResult<T>(records, cursor, hasMore);
278287
}
279288

280-
private BatchResult<T> BuildOffsetResult(List<T> records, HttpCursorState state, int pageSize)
289+
/// <inheritdoc cref="BuildPageResult"/>
290+
private static BatchResult<T> BuildOffsetResult(List<T> records, HttpCursorState state)
281291
{
282292
int currentOffset = state.Offset ?? 0;
283-
bool hasMore = records.Count == pageSize;
293+
bool hasMore = records.Count > 0;
284294
string? cursor = hasMore ? HttpPagination.Encode(new HttpCursorState(Offset: currentOffset + records.Count)) : null;
285295
return new BatchResult<T>(records, cursor, hasMore);
286296
}

src/Sources/NeoReports.Sources.HubSpot/HubSpotBatchSource.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,19 @@ public async Task<BatchResult<T>> ReadBatchAsync(BatchContext context, Cancellat
7878
return new BatchResult<T>(records, cursor, hasMore);
7979
}
8080

81+
/// <summary>
82+
/// The largest page HubSpot accepts. The engine's own default is 1000, and sending that made the
83+
/// very first request fail until the author happened to call <c>.PageSize(100)</c> — a default
84+
/// configuration that could not work. The maintainer chose clamping over failing (ADR D72): a
85+
/// report author should not have to know each provider's ceiling, and a page size is a
86+
/// throughput hint, not a promise about how many rows arrive at once.
87+
/// </summary>
88+
private const int MaxPageSize = 100;
89+
8190
private Uri BuildRequestUri(HubSpotCursorState state, int pageSize)
8291
{
83-
var queryParams = new List<(string Key, string Value)> { ("limit", pageSize.ToString(CultureInfo.InvariantCulture)) };
92+
int effectivePageSize = Math.Min(pageSize, MaxPageSize);
93+
var queryParams = new List<(string Key, string Value)> { ("limit", effectivePageSize.ToString(CultureInfo.InvariantCulture)) };
8494

8595
if (state.After is { Length: > 0 } after)
8696
queryParams.Add(("after", after));

src/Sources/NeoReports.Sources.OData/ODataBatchSource.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ public async Task<BatchResult<T>> ReadBatchAsync(BatchContext context, Cancellat
9797
return _options.PaginationStrategy switch
9898
{
9999
ODataPaginationStrategy.NextLink => BuildNextLinkResult(records, root, requestUri),
100-
ODataPaginationStrategy.Skip => BuildSkipResult(records, state, context.PageSize),
100+
ODataPaginationStrategy.Skip => BuildSkipResult(records, state),
101101
_ => throw new InvalidOperationException($"Unsupported pagination strategy '{_options.PaginationStrategy}'."),
102102
};
103103
}
@@ -156,11 +156,15 @@ private static BatchResult<T> BuildNextLinkResult(List<T> records, JsonElement r
156156
return new BatchResult<T>(records, cursor, hasMore);
157157
}
158158

159-
private BatchResult<T> BuildSkipResult(List<T> records, ODataCursorState state, int pageSize)
159+
// Skip has no server token to follow, so "is there more?" can only be inferred. Inferring it
160+
// from a page that filled $top is wrong whenever the service caps the page below what was asked
161+
// for — Dynamics, SAP Gateway and Business Central all clamp $top — because the short first page
162+
// then reads as the last one and the run reports Completed with a fraction of the data. Paging
163+
// until a page comes back EMPTY costs one extra request at the end and cannot truncate (D72).
164+
private static BatchResult<T> BuildSkipResult(List<T> records, ODataCursorState state)
160165
{
161166
int currentSkip = state.Skip ?? 0;
162-
int top = _options.TopValue ?? pageSize;
163-
bool hasMore = records.Count == top;
167+
bool hasMore = records.Count > 0;
164168
string? cursor = hasMore ? ODataPagination.Encode(new ODataCursorState(Skip: currentSkip + records.Count)) : null;
165169
return new BatchResult<T>(records, cursor, hasMore);
166170
}

tests/NeoReports.Sources.Airtable.UnitTests/AirtableBatchSourceTests.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,23 @@ private static async Task<List<T>> CollectAsync<T>(IBatchSource<T> source, int p
4545
private static HttpResponseMessage JsonResponse(string json, HttpStatusCode status = HttpStatusCode.OK) =>
4646
new(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
4747

48+
[Fact]
49+
public async Task The_engines_default_page_size_is_clamped_to_what_Airtable_accepts()
50+
{
51+
// The engine defaults to 1000, Airtable rejects anything over 100. Sending the default made
52+
// the very first request fail until the author happened to call .PageSize(100) — a default
53+
// configuration that could not work. The maintainer chose clamping over failing (ADR D72).
54+
HttpClient client = StubHttpMessageHandler.CreateClient(
55+
_ => JsonResponse("""{"records":[{"id":"rec1","fields":{"name":"Alpha","done":false}}]}"""),
56+
out StubHttpMessageHandler handler);
57+
58+
var source = Source.Airtable("appXXX", "Projects", "token123", client).As<Project>();
59+
60+
await source.ReadBatchAsync(new BatchContext(Exec(), 1000, null, 1), CancellationToken.None);
61+
62+
handler.Requests[0].RequestUri!.ToString().ShouldBe("https://api.airtable.com/v0/appXXX/Projects?pageSize=100");
63+
}
64+
4865
[Fact]
4966
public async Task Paginates_via_offset_until_absent()
5067
{

0 commit comments

Comments
 (0)