Skip to content

Add a committed, re-runnable Bulk Upload benchmark harness - #2415

Open
jh-RLI wants to merge 2 commits into
developfrom
feature-2362-bulk-upload-benchmark-harness
Open

Add a committed, re-runnable Bulk Upload benchmark harness#2415
jh-RLI wants to merge 2 commits into
developfrom
feature-2362-bulk-upload-benchmark-harness

Conversation

@jh-RLI

@jh-RLI jh-RLI commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary of the discussion

Every throughput number in the Bulk Upload PRD came from a Django test client against a local Postgres, produced by api/tests/bench_apply.py and api/tests/bench_bulk.py - which were never committed and survive only as stale .pyc files. The numbers cannot be reproduced today. This adds the harness back, in the repository, so the next release re-measures instead of re-deriving.

benchmarks/ is a new top-level package rather than scripts/, because scripts/ is gitignored (.gitignore:117) and therefore cannot hold anything meant to be re-run per release. The harness is standard library only and imports no Django: a benchmark has to run from the machine that has the uplink and the reference data, not only from one that can boot the platform.

What it does per rung: slice a rung-sized CSV out of a reference file, create a fresh sandbox table, upload it gzipped, time the phases client-side, append a result row, drop the table and confirm it is gone.

The slicer is the part that can silently poison every measurement. The fat reference file carries a ~111 KB quoted JSON array per row, full of commas, so a byte slice splits a record, COPY fails on the truncated line and the run measures a rollback rather than a load. Records are therefore found with a quote-aware byte scanner and copied whole; the target size is a target, while the actual bytes and rows are measured. Verified against the real files: the scanner reproduces the stdlib CSV reader's record count on both, conserves every byte of the 2,040,220,631-byte fat file, and a full 1.95 GB slice re-parses as 17,727 rows of exactly five fields.

Failures are findings, not absences. A rung refused mid-body records the status, who answered, the bytes that actually left and the response body, then stops the staircase. Who answered is decided by response body format, never by status code alone: Apache emits its own HTML 408 on an idle request body and the platform's stall guard emits a JSON 408. A failed rung reports no throughput at all, because dividing a fraction of the payload by a fraction of the time invents a headline number out of a refusal.

Column definitions are configuration, not code, and are replaceable from a JSON file: typing the fat file's series as text measures raw ingest while json/jsonb measures validated-JSON ingest, and those are different benchmarks. Each result row carries a fingerprint of the exact table definition it was loaded into, so rungs measured under different typings are never silently compared.

The credential is read only from OEP_BENCH_TOKEN and appears in no source file (there is a test for that). Production is write-protected behind an explicit flag, and --insecure is refused against production outright.

Workflow checklist

Automation

Closes #

PR-Assignee

Reviewer

  • 🐙 Follow the
    Reviewer Guidelines
  • 🐙 Provided feedback and show sufficient appreciation for the work done

Every throughput number in the Bulk Upload PRD came from a Django test
client against a local Postgres, produced by api/tests/bench_apply.py and
api/tests/bench_bulk.py - which were never committed and survive only as
stale .pyc files. The numbers cannot be reproduced today. This adds the
harness back, in the repository, so the next release re-measures instead
of re-deriving.

benchmarks/ is a new top-level package rather than scripts/, because
scripts/ is gitignored (.gitignore:117) and therefore cannot hold
anything meant to be re-run per release. The harness is standard library
only and imports no Django: a benchmark has to run from the machine that
has the uplink and the reference data, not only from one that can boot
the platform.

What it does per rung: slice a rung-sized CSV out of a reference file,
create a fresh sandbox table, upload it gzipped, time the phases
client-side, append a result row, drop the table and confirm it is gone.

The slicer is the part that can silently poison every measurement. The
fat reference file carries a ~111 KB quoted JSON array per row, full of
commas, so a byte slice splits a record, COPY fails on the truncated line
and the run measures a rollback rather than a load. Records are therefore
found with a quote-aware byte scanner and copied whole; the target size
is a target, while the actual bytes and rows are measured. Verified
against the real files: the scanner reproduces the stdlib CSV reader's
record count on both, conserves every byte of the 2,040,220,631-byte fat
file, and a full 1.95 GB slice re-parses as 17,727 rows of exactly five
fields.

Failures are findings, not absences. A rung refused mid-body records the
status, who answered, the bytes that actually left and the response body,
then stops the staircase. Who answered is decided by response body
format, never by status code alone: Apache emits its own HTML 408 on an
idle request body and the platform's stall guard emits a JSON 408. A
failed rung reports no throughput at all, because dividing a fraction of
the payload by a fraction of the time invents a headline number out of a
refusal.

Column definitions are configuration, not code, and are replaceable from
a JSON file: typing the fat file's `series` as text measures raw ingest
while json/jsonb measures validated-JSON ingest, and those are different
benchmarks. Each result row carries a fingerprint of the exact table
definition it was loaded into, so rungs measured under different typings
are never silently compared.

The credential is read only from OEP_BENCH_TOKEN and appears in no source
file (there is a test for that). Production is write-protected behind an
explicit flag, and --insecure is refused against production outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jh-RLI jh-RLI self-assigned this Aug 10, 2026
The harness could not upload anything larger than 1 MiB. Against
production, a 4,224,952-byte gzipped payload timed out after 240 s and
left the table with 0 rows, while curl sent the identical payload in
1.13 s.

Root cause: the send loop treated a readable socket as "the server has
answered". On TLS 1.3 the server sends a NewSessionTicket immediately
after the handshake, so select() reports the fd readable with no HTTP
response behind it. Measured on production: chunk 1 readable with
SSLWantReadError, chunks 2-5 not readable. The loop broke after one
1 MiB chunk with 3.18 MB unsent, then blocked in getresponse() while the
server waited for the rest of the declared Content-Length.

That also explains the threshold exactly: a body that fits in one
SEND_CHUNK is fully sent before the false positive fires, so small rungs
passed and every larger one hung.

Fix: confirm readability with the SSL layer before believing it.
peek_early_response() does a non-blocking recv and treats
SSLWantReadError/BlockingIOError as "TLS bookkeeping, keep sending".
A genuine early rejection is still honoured -- those bytes are already
off the socket, so _finish_from_bytes() parses the response from them
rather than calling getresponse().

Also: document that send_seconds measures buffer fill rather than wire
time for payloads that fit in the socket buffers (444 KB reported
0.091 s; 4.2 MB reports 1.472 s, which is real).

Adds arms_wf05.json, the decided target-table schemas, so runs use the
schema of record rather than the provisional built-ins.

Verified: 47 tests green; the 10 MB rung now lands on production in
1.76 s (201, 80 rows) against curl's 1.13 s.
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