Skip to content

Return full CSV search results up to 100,000 rows; cap JSON limit at 5,000 - #20

Merged
matthewberry merged 6 commits into
mainfrom
mjberry/csv-stream-all
Oct 22, 2026
Merged

matthewberry merged 6 commits into
mainfrom
mjberry/csv-stream-all

Conversation

@matthewberry

@matthewberry matthewberry commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Closes #18.

Problem

A CSV search without limit was cut at AUTO_PAGINATION_THRESHOLD (5000) with no total and no next link, so the file looked complete when it wasn't. In production ec_number=1.1.1.1 alone matches 36,682 records. Every CSV request also ran the COUNT query and discarded the result.

Changes

  • No limit: CSV now returns every matching row, up to MAX_SEARCH_LIMIT (100,000), the same bound Bound limit and offset on search and typeahead #25 put on limit. If the query matches more than that, it returns a 400 asking the caller to narrow the filters or page with limit/offset; it never returns a partial file. The export requests cap + 1 rows and write_csv returns the 400 if the extra row arrives, so the cap is checked against the same rows that are written, in one query.
  • Spooling: rows are read through a server-side cursor (Database.iterate, prefetch 1000) into a SpooledTemporaryFile (in memory up to 8 MB, then on disk). The DB connection is released before the response starts, so a slow client never holds it; the file is closed by a BackgroundTask, which also runs when the client disconnects. The row generators are closed with aclosing, so stopping early (a 400 or an error) releases the cursor's connection immediately. A DB error now returns a 500 instead of a truncated file.
  • Explicit limit: returned as before; Bound limit and offset on search and typeahead #25 already rejects one above 100,000 with a 422.
  • JSON limit capped at the page size: an explicit JSON limit above AUTO_PAGINATION_THRESHOLD (5,000) now gets a 422, shaped like Bound limit and offset on search and typeahead #25's le= error. JSON is paginated, so nothing becomes unreachable; CSV, the bulk download, keeps 100,000. This updates Bound limit and offset on search and typeahead #25's boundary test (JSON accepts 5,000 and rejects 5,001). The frontend only sends 10, 20, or 50; API clients sending a larger JSON limit will get a 422.
  • No COUNT for CSV. The JSON path is unchanged.
  • get_filtered_data's query building is pulled into build_data_query, shared with the new iter_filtered_data.
  • HTTPException now passes through the endpoint's catch-all instead of being rewrapped as a 500.
  • The endpoint docstring and the API description in main.py explain how CSV behaves.

Testing

  • tests/test_search_csv.py: 9 tests with the DB calls mocked, covering the cap, the explicit-limit path, the header-only empty result, the row generators being closed when the cap is passed (both fail if their aclosing is removed), a DB error partway through the rows (500), output large enough to spill to disk, and that CSV skips get_total_count and get_filtered_data. Full suite (with Bound limit and offset on search and typeahead #25's tests): 25 passed.
  • Ran the app against a throwaway Postgres 16 container with a minimal copy of the cleandb schema (250 seeded records, and 100,001 for the cap):
    • exactly 100,000 matches returns 100,000 rows (about 2 s); 100,001 returns 400; with offset=1 the 100,000 remaining rows are returned
    • offset at the bigint maximum, and at maximum − 99,999, returns an empty CSV (an earlier version of the cap check returned a 500 "bigint out of range" here)
    • 15 over-cap exports in parallel all returned 400, and afterwards all 10 pool connections were idle
    • limit=100001 gets Bound limit and offset on search and typeahead #25's 422
    • explicit limit/offset, the EC wildcard filter with ordering, an empty result, and the JSON path all behaved correctly
    • 15 slow clients (curl --limit-rate 2k, more than the pool's 10 connections) downloading 20 MB responses (with an earlier configurable cap set to 100): all DB connections stayed idle and a JSON request answered in 12 ms. Each download had one spill file open, and all 15 closed within 2 s of the clients disconnecting, with no other traffic
  • Not tested on production data. I don't know how long a 100,000-row export (or rejecting an over-cap one, which costs the same) takes there. With real sequences a full-cap file could be tens of MB, and each in-flight CSV download holds that much disk until it finishes.

Trade-off

The first byte is sent only after the query has finished, since the whole CSV is written before the response starts. That is what keeps client speed from deciding how long a DB connection is held.

An earlier version of this PR streamed straight from the cursor. With 10 slow clients connected, all 10 pool connections sat idle in transaction and a JSON request got no response within 15 s.

Rejecting an over-cap query reads 100,001 rows before answering (1.6 s locally), the same cost as a legitimate limit=100000 export. Large requests are CPU-bound on the event loop: a JSON request sent during a burst of 15 parallel limit=100000 CSV exports took 6.9 s, and during 15 JSON limit=100000 requests it took 30.4 s, which the JSON cap above removes. Limiting how many large /search requests run at once is #26.

Without an explicit limit, a CSV search was cut at
AUTO_PAGINATION_THRESHOLD (5000) with no sign it was incomplete. CSV now
streams every match through a server-side cursor, up to CSV_MAX_ROWS
(default 100,000). A query that matches more, or an explicit limit above
the cap, is rejected with a 400 instead of returning a partial file. CSV
no longer runs the COUNT query, whose result it never used.

Closes #18
Streaming straight from the cursor held a pooled DB connection, inside a
transaction, for as long as the client took to read the response. Ten
slow clients exhausted the pool and stalled every endpoint. Write the
CSV to a SpooledTemporaryFile (in memory up to 8 MB, then on disk) and
release the connection before sending. A DB error now returns a 500
instead of a truncated file. The file is closed in a BackgroundTask,
which also runs when the client disconnects.

@matthewberry matthewberry left a comment

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.

The spooling change fixes the connection-holding problem, and errors now come back as JSON 500s. All 8 tests pass at c990f02. I also checked that the background close runs on disconnect with what the Dockerfile ships: uvicorn 0.34.2 reports ASGI spec 2.3, so Starlette takes the task-group path, which still runs background (starlette/responses.py:262-273).

Three comments below belong in this PR. The fourth one, on write_csv, doesn't block it. The rich.print debug output on this path predates the PR, so it's filed separately as #22.

One open question I can't check from here: no bytes are sent until the full query and the whole file are done, so a proxy with a read timeout in front of production (nginx defaults to 60s) could cut off a slow full-cap export before anything is sent. Worth knowing the ingress timeout before this ships.

Comment thread app/db/queries.py Outdated
yield record


async def has_more_rows_than(db: Database, params: CLEANSearchQueryParams, n: int) -> bool:

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.

The cap check and the export are still two separate queries. If the data changes between has_more_rows_than and the cursor opening (for example, a materialized view refresh), the export's LIMIT cap can silently drop rows, which is the problem #18 is about. Every result under the cap is also scanned twice (36k matches for ec_number=1.1.1.1).

Spooling makes this cheap to fix: nothing is sent until write_csv returns, so the cap can be enforced during the write. Query LIMIT cap + 1, and if row cap+1 arrives, close the file and raise the 400. That removes has_more_rows_than, the race, and the second scan.

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.

Agreed, and done in 12eb4f0. When no limit is given, the export now requests cap + 1 rows and write_csv returns the 400 if the extra row arrives, so the cap is checked against the same rows that get written. has_more_rows_than is gone, and so is the OFFSET clamp it needed after #25.

Both row generators are now closed with contextlib.aclosing, so a 400 halfway through releases the cursor's connection right away instead of when the garbage collector runs. test_write_csv_closes_rows_when_over_cap and test_closing_iter_filtered_data_closes_the_cursor each fail if their aclosing is removed.

On Postgres 16 with 100,001 seeded rows: exactly 100,000 matches returns all of them; 100,001 returns the 400; 15 rejected exports in parallel all returned 400, and afterwards all 10 pool connections were idle.

Trade-off: rejecting an over-cap query now costs a full read of 100,001 rows (1.6 s locally) instead of the old 0.12 s EXISTS check. That's the same cost as a legitimate limit=100000 export, so it doesn't add a new kind of expensive request (numbers in the write_csv thread).

Comment thread app/routers/search.py Outdated
detail=f"Query matches more than {max_rows} records, the most a CSV "
"response can contain. Narrow the filters, or page with limit and offset.",
)
elif params.limit > max_rows:

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.

A negative limit or offset returns a 500 containing the raw Postgres error ("LIMIT must not be negative"). Only limit > max_rows is rejected here. Adding ge=0 to the limit/offset Query(...) declarations in parse_query_params would turn these into 422 validation errors for both CSV and JSON. main behaves the same way, but this PR adds the validation branch, so it fits here.

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.

Fixed by #25, now merged into this branch (120dd4e): limit has ge=1 and offset has ge=0 (plus upper bounds), so negative values get a 422 for both CSV and JSON before any SQL runs. tests/test_pagination_bounds.py covers it.

Comment thread app/routers/search.py Outdated
output = StringIO()
max_rows = settings.CSV_MAX_ROWS
if params.limit is None:
params.limit = max_rows

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.

This overwrites the caller's limit with the cap. After this line, "no limit given" and "limit=100000 given" look the same, which is the same pattern as the old AUTO_PAGINATION_THRESHOLD default that made a capped file look complete. If the cap moves into the write (see the queries.py:194 comment), passing the cap to iter_filtered_data as its own argument would keep params as the caller sent it.

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.

Agreed, done in 12eb4f0. The CSV branch no longer assigns to params.limit; it passes the export params.model_copy(update={"limit": MAX_SEARCH_LIMIT + 1}), so the caller's params keep their limit of None.

Comment thread app/routers/search.py Outdated
output, fieldnames=[c.value for c in CLEANColumn], extrasaction="ignore"
)
writer.writeheader()
async for row in rows:

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.

Non-blocking: once the output passes CSV_SPOOL_MAX_MEMORY and moves to disk, each writer.writerow is a blocking file write inside async code. The cursor hands control back to the event loop every 1000 rows, so the impact is small. If large exports turn out to slow down other requests, move the write into a thread (run_in_threadpool) or write in batches.

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.

Declining the threadpool change here, but this matters more than I first thought. I measured it on Postgres 16 with 100,001 seeded rows, sending a JSON request 2 s into a burst of 15 parallel requests:

Burst of 15 JSON request during burst
CSV limit=100000 6.9 s
CSV with no limit, over the cap (400) 10.4 s
JSON limit=100000 (allowed on main since #25) 30.4 s

Most of that time is Python CPU (building rows and formatting the CSV, or Pydantic models for JSON) on the event loop, not file writes, so moving the write into a thread wouldn't help much because of the GIL. JSON is the worst case and isn't touched by this PR. The fix I'd suggest is a limit on how many large /search requests run at once, for JSON as well as CSV; I'll follow up on that separately.

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.

Follow-up: eda98e6 caps a JSON limit at 5,000 (the auto-pagination page size), which removes the 30 s JSON case. The concurrency limit for the remaining large requests is #26.

Replace the configurable CSV_MAX_ROWS with MAX_SEARCH_LIMIT, the same
bound #25 put on limit. An explicit limit above it already gets a 422,
so drop the CSV-only 400 for that case and its test.

Clamp the cap check's OFFSET to MAX_BIGINT: with offset allowed up to
the bigint maximum, offset + cap overflowed and Postgres returned a 500.
The cap check and the export were two queries, so data changing between
them could let the export's LIMIT silently drop rows, and every result
under the cap was scanned twice. Fetch cap + 1 rows and return the 400
from write_csv if the extra row arrives. This removes has_more_rows_than
and its OFFSET clamp, and leaves the caller's params unchanged.

Close the row generators explicitly with aclosing, so stopping early
releases the cursor's connection immediately rather than whenever the
garbage collector runs.
@matthewberry

Copy link
Copy Markdown
Contributor Author

Thanks, and replies are inline. Since this review, main (including #25) is merged in, CSV_MAX_ROWS is replaced by one non-configurable MAX_SEARCH_LIMIT (100,000) shared with #25's limit bound, and 12eb4f0 moves the cap into the write.

Proxy timeout: the ingress is Traefik (className: traefik in both values files), not nginx, and the chart sets no timeout annotations. My understanding is that Traefik doesn't time out waiting for a backend's response headers by default, but that's from memory, and the cluster's Traefik static config isn't in this repo, so it needs checking by someone with cluster access. Locally a 100,000-row export takes 1–2 s; production rows are larger, so I don't have a production number.

#22: already closed.

JSON is paginated, so an explicit JSON limit above AUTO_PAGINATION_THRESHOLD
(5000) now gets a 422 shaped like the le= error on limit. CSV, the bulk
download, keeps the 100,000 cap. A JSON request builds a Pydantic object
per row and keeps the whole response in memory, so 100,000-row JSON
requests were the most expensive /search requests.
@matthewberry matthewberry changed the title Stream full CSV search results up to CSV_MAX_ROWS Return full CSV search results up to 100,000 rows; cap JSON limit at 5,000 Oct 22, 2026
@matthewberry
matthewberry merged commit 6524640 into main Oct 22, 2026
1 check passed
@matthewberry
matthewberry deleted the mjberry/csv-stream-all branch October 22, 2026 17:48
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.

CSV search export silently truncates at AUTO_PAGINATION_THRESHOLD

1 participant