Skip to content

feat(api): bulk upload guards — concurrency cap, stall abort, DB timeouts (#2362)#2370

Open
jh-RLI wants to merge 10 commits into
developfrom
feature-2362-bulk-upload-guards
Open

feat(api): bulk upload guards — concurrency cap, stall abort, DB timeouts (#2362)#2370
jh-RLI wants to merge 10 commits into
developfrom
feature-2362-bulk-upload-guards

Conversation

@jh-RLI

@jh-RLI jh-RLI commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary of the discussion

This PR stacks ontop of #2364 and #2366 and #2367 and
#2368 and
#2369 wait until both are merged and rebase this PR.

Part of #2362 (Slice 7 — guards). The synchronous bulk upload path is
protected by guards rather than infrastructure (ADR 0002). Three commits,
one guard each:

  1. Concurrency guard (c4689e84): new module api/bulk_upload_guard.py
    — at most one running upload per user plus a global cap
    (BULK_UPLOAD_MAX_CONCURRENT, default 2). Excess requests → 429 with
    Retry-After
    , no event (busy rejections are cheap pre-work denials).
    Slots always release, including on error paths (50-failure leak test).
    Guard state is per worker process — a multi-worker deployment bounds
    concurrency per process (workers × limit), documented in the module.
  2. Stall guard (400f0481): uploads averaging below
    BULK_UPLOAD_MIN_BYTES_PER_SECOND (default 10 KiB/s) after a grace
    period (default 30 s) → 408, rolled back, recorded as a stall
    event (migration 0051). Catches trickling clients; fully hung
    connections are the web server's and DB timeouts' job.
  3. DB session timeouts (3b10d788): the upload's transaction runs
    under SET LOCAL statement_timeout (default 1 h) and
    idle_in_transaction_session_timeout (default 60 s) — transaction-
    scoped, so pooled connections stay clean; fallbacks fail closed.

The guard module is the design's one below-HTTP seam: 9 direct unit tests
with an injectable clock (concurrency and wall-clock can't be exercised
through the synchronous test client), plus HTTP-seam tests for the wiring
(per-user 429, global-cap 429, stall abort with rollback + event, timeouts
verified on the session). 48 tests across both modules; full suite green.

Known minor gap (accepted): a client above the stall floor but slow enough
to hit statement_timeout (e.g. 500 MB at 100 KiB/s ≈ 1.4 h) surfaces as a
generic copy-error naming the postgres timeout message, not a dedicated
status. The observability slice's outcome vocabulary can refine this.

Workflow checklist

Automation

Closes #

PR-Assignee

Reviewer

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

jh-RLI and others added 10 commits July 3, 2026 18:51
Adds POST /api/v0/tables/<table>/bulk-upload - the tracer bullet of the
bulk upload path (slice 2 of #2362):

- The request body IS the CSV (text/csv); the server streams it into
  PostgreSQL COPY FROM STDIN without buffering the file in memory.
- Append-only, one transaction per request: a malformed row anywhere
  rolls back the entire upload.
- The delimiter is a required, whitelisted parameter (comma, semicolon,
  tab) - never inferred from metadata or content.
- The CSV header (required) maps columns by name; header names are
  whitelisted against the table's actual columns and quoted, so no
  unvalidated identifier ever reaches the SQL.
- Same authorization chain as the row API: token auth, write
  permission, embargo check, table-registry resolution (internal
  tables are unreachable by construction).
- Deliberately bypasses the edit-journal meta tables: bulk-loaded rows
  have no per-row change history. This trades the (currently unread)
  per-row provenance for order-of-magnitude ingestion speed; an audit
  event record follows in a later slice.
- COPY is FROM STDIN only; no code path for COPY FROM file/PROGRAM.

New HTTP-seam test module api/tests/test_bulk_upload.py (14 tests)
covers the happy path per delimiter, auth/permission/embargo denials,
all-or-nothing rollback, journal bypass, and target-table containment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slice 3 of #2362, on top of the bulk upload tracer bullet:

- Header preflight before the body streams: reject duplicate column
  names, names not in the table, and missing required columns
  (NOT NULL without default), each with a 400 naming the offenders.
- Strip a UTF-8 BOM from the header (Excel exports).
- FORCE_NULL on all uploaded columns: an empty field is NULL whether
  quoted or not - a deliberate deviation from COPY's native CSV rule,
  because many writers quote every field and would silently store
  empty strings instead of NULLs.
- Sanitized failure responses: the database's data-level message plus
  the CSV line number and column (header-adjusted), never raw SQL,
  server context dumps, or internal paths - including the no-diagnostics
  fallback (lost connection), which stays generic.

Test module grows to 20 HTTP-seam tests covering each contract rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#2362)

Slice 4 of #2362. Clients may include or omit the id column:

- Omitted: the table's id sequence assigns ids as usual.
- Included: after COPY, still inside the upload's transaction, the id
  sequence is advanced to the table's max(id) via setval(GREATEST(...)),
  so a subsequent row-API insert cannot hit a duplicate key. GREATEST
  plus a pg_advisory_xact_lock on the sequence keep the sequence from
  ever moving backwards, including under concurrent uploads (setval is
  non-transactional, so racing reads could otherwise regress it).
- Uploads that RAISE the table's max(id) above a generous sanity bound
  (2^48) are rejected and rolled back, so a single upload cannot
  exhaust the id sequence for every writer of a shared table. The
  bound only judges ids introduced by the upload itself: a pre-existing
  high id (the row API enforces no bound) does not poison the table
  for future bulk uploads.

Test module grows to 25 HTTP-seam tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slice 5 of #2362. Bulk uploads bypass the per-row edit journal; the new
BulkLoadEvent model is their provenance:

- Every authenticated, authorized attempt - successful or failed - is
  recorded: table, user, timestamp, status (success / validation-error /
  copy-error / embargo), sanitized error message, and bytes received.
  Decorator-level denials (401/403/404) deliberately create no events so
  anonymous requests cannot write database rows.
- Successes additionally record the row count and the exact id range of
  the loaded rows (found via xmin = current transaction, so it is
  correct for both explicit and sequence-assigned ids); the response
  references the event id. The id range is the forensics handle for
  block-deleting a mistaken or malicious upload.
- Events live in the Django database, so a failure event survives the
  data transaction's rollback by construction. Event creation is
  best-effort: a failure to write the audit record is logged but never
  masks the upload's actual outcome.
- Events are listed, filterable, and immutable in the Django admin.

Test module grows to 30 HTTP-seam tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slice 6 of #2362, part 1/3. Content-Encoding: gzip (and legacy x-gzip)
request bodies are decompressed in streaming fashion straight into
COPY FROM STDIN - gzip.GzipFile pulls compressed chunks on demand, so
the whole body is never materialized in memory. Corrupt or truncated
gzip aborts with a sanitized 400 (validation-error event) and full
rollback, whether it fails at the header or mid-COPY.

Tests: gzip round-trip vs plain twin, invalid gzip, truncated gzip
mid-body.
Slice 6 of #2362, part 2/3. Adds the size-cap status to BulkLoadEvent
(with migration) so uploads rejected by the decompressed size cap are
distinguishable in the audit trail and admin filters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slice 6 of #2362, part 3/3. A configurable cap on DECOMPRESSED bytes
per request (settings.BULK_UPLOAD_MAX_BYTES, env-overridable, default
10 GiB) rejects oversized uploads with HTTP 413 naming the cap and a
size-cap Bulk Load Event. Counting after decompression is what
neutralises gzip bombs: a few-KB body that inflates past the cap is
cut off mid-stream, not expanded to disk or RAM. The cap is a backstop
- clients are expected to split large datasets into several uploads.

psycopg2 surfaces exceptions raised inside COPY's read() as a generic
Error, so the stream flags the overflow and the error handler
dispatches 413 size-cap vs 400 copy-error on that flag.

Tests: plain-body cap breach (413, names the cap, rolled back, event
recorded) and gzip bomb cut off at the cap. Module at 35 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ap (#2362)

Slice 7 of #2362, part 1/3. New module api/bulk_upload_guard.py: at
most one running bulk upload per user plus a configurable global cap
(BULK_UPLOAD_MAX_CONCURRENT, default 2). Excess requests get HTTP 429
with Retry-After and create no event - busy rejections are cheap
pre-work denials. Slots are always released, including on error paths.
The guard state is per worker process (documented: a multi-worker
deployment bounds concurrency per process, still capping the damage).

The guard module is deliberately its own seam with direct unit tests -
concurrency cannot be exercised through the synchronous test client.
HTTP-seam tests cover the wiring: per-user 429 and global-cap 429,
both with Retry-After.
Slice 7 of #2362, part 2/3. Uploads whose average transfer rate falls
below BULK_UPLOAD_MIN_BYTES_PER_SECOND (default 10 KiB/s) after a
grace period (BULK_UPLOAD_STALL_GRACE_SECONDS, default 30 s) are
aborted with HTTP 408, fully rolled back, and recorded as a stall
Bulk Load Event (migration 0051) - a trickling client must not pin a
synchronous worker and an open transaction.

The StallDetector lives in the guard module with an injectable clock
for its unit tests; it only catches trickle (each read eventually
returns) - fully hung connections are bounded by the web server's
timeouts and the DB session timeouts (next commit). Like the size cap,
the abort surfaces through psycopg2 as a generic error, so a flag on
the detector decides the failure class.
Slice 7 of #2362, part 3/3. The upload's transaction runs under
SET LOCAL statement_timeout (BULK_UPLOAD_STATEMENT_TIMEOUT_MS, default
1 h) and idle_in_transaction_session_timeout
(BULK_UPLOAD_IDLE_TX_TIMEOUT_MS, default 60 s), so a client that goes
silent mid-protocol cannot hold an open transaction and its locks.
SET LOCAL scopes both to the transaction, keeping the pooled
connection clean for its next user; the in-code fallbacks mirror the
settings defaults and fail closed (never 0/disabled).

Together with the concurrency and stall guards this completes the
worker-protection story: guard slot -> transfer rate -> statement and
idle timeouts, each bounding a different way to pin a worker.

Includes the changelog entry for the complete guards feature.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant