feat(webapp): an import counts what a file holds before it builds anything (#1105) #1112

Merged
claude-bot merged 4 commits from feat/1105-import-row-bounds into main 2026-10-05 23:01:29 +00:00
Collaborator

Closes #1105. Records D288 (Q161, #1096) and D289 (Q162).

What

  • Counted before anything is built. app/services/import_bounds.py holds one BOUNDS table. Every list the importer turns into rows is counted from the parsed file, before Campaign(...) is constructed and before any database access; a file over a bound for any kind is refused with a 422 that names the kind, the count and the bound: "This file has 80,000 sessions; the most taken in one import is 5,000. Nothing was imported."
  • The owner's bounds (D289): 5,000 sessions, 50,000 wiki pages (live and binned together), 500 members, 500,000 transcript lines. The other kinds are of the same order; the full table is in docs/OPERATIONS.md ("How much one import takes"). Transcript segments never travel in an export, so "transcript lines" are the text lines of sessions[].transcript, summed over the file.
  • Counter and builder read the same thing: the same accessor or the same plan object. _held_to_its_count compares rows built per model with the count; a mismatch raises in tests and logs an error in production. A source-reading test fails if the importer gains a model with no bound.
  • The 32,767-argument 500: wiki_links.load_names listed every page id in one IN; it joins on the campaign now.
  • Quadratic page indexing on import removed (index_page(..., fresh=True)): 10,000 pages took 103 s before; 50,000 take about 30 s now.
  • Session numbers are taken in one statement.
  • Rows are flushed as the import goes (Written, every 2,000).

Measured (worker task body, 1 GB container, PostgreSQL 16)

File Growth Time
5,000 sessions 25 MB 4 s
50,000 pages 212 MB 25 to 34 s
500,000 transcript lines 57 to 64 MB 3 to 7 s
All four owner bounds at once (23.2 MB) 230 MB 34 s
The dearest in-bounds file built 342 MB 252 s
80,000 sessions 21 MB refused in 0.5 s

The memory stop (400 MB growth) did not fire for any file inside the bounds, and stays as the backstop. The full table is in docs/OPERATIONS.md; scripts/measure_import.py is the harness.

Tests

tests/export/test_import_bounds.py (new): each kind at its bound and one over; refusal through the route with no campaign, rows or media left; dict and string shapes in place of lists; duplicate keys; batching; numbering. Run before this PR by the builder: ruff, checks, 2,704 backend tests around import and export, vitest on the four import files. The full backend suite ran once before the last small edits (2 failures then, both passing on the committed code). CI is the full check.

Known and not in this PR

  • A campaign near 50,000 pages may not export again: the export still has IN lists with one bind per page. Read from the code, not run. Follow-up issue to be filed.
  • wiki_links.parse is quadratic in memory for one huge block of link lines (pre-existing; the memory stop covers it).
  • A count includes rows the builder would skip, so it can never undercount.

An independent review is in progress; this does not merge before it reports.

🤖 Generated with Claude Code

Closes #1105. Records D288 (Q161, #1096) and D289 (Q162). ## What - **Counted before anything is built.** `app/services/import_bounds.py` holds one `BOUNDS` table. Every list the importer turns into rows is counted from the parsed file, before `Campaign(...)` is constructed and before any database access; a file over a bound for any kind is refused with a 422 that names the kind, the count and the bound: "This file has 80,000 sessions; the most taken in one import is 5,000. Nothing was imported." - **The owner's bounds (D289):** 5,000 sessions, 50,000 wiki pages (live and binned together), 500 members, 500,000 transcript lines. The other kinds are of the same order; the full table is in `docs/OPERATIONS.md` ("How much one import takes"). Transcript segments never travel in an export, so "transcript lines" are the text lines of `sessions[].transcript`, summed over the file. - **Counter and builder read the same thing**: the same accessor or the same plan object. `_held_to_its_count` compares rows built per model with the count; a mismatch raises in tests and logs an error in production. A source-reading test fails if the importer gains a model with no bound. - **The 32,767-argument 500**: `wiki_links.load_names` listed every page id in one `IN`; it joins on the campaign now. - **Quadratic page indexing** on import removed (`index_page(..., fresh=True)`): 10,000 pages took 103 s before; 50,000 take about 30 s now. - **Session numbers** are taken in one statement. - Rows are flushed as the import goes (`Written`, every 2,000). ## Measured (worker task body, 1 GB container, PostgreSQL 16) | File | Growth | Time | |---|---|---| | 5,000 sessions | 25 MB | 4 s | | 50,000 pages | 212 MB | 25 to 34 s | | 500,000 transcript lines | 57 to 64 MB | 3 to 7 s | | All four owner bounds at once (23.2 MB) | 230 MB | 34 s | | The dearest in-bounds file built | 342 MB | 252 s | | 80,000 sessions | 21 MB | refused in 0.5 s | The memory stop (400 MB growth) did not fire for any file inside the bounds, and stays as the backstop. The full table is in `docs/OPERATIONS.md`; `scripts/measure_import.py` is the harness. ## Tests `tests/export/test_import_bounds.py` (new): each kind at its bound and one over; refusal through the route with no campaign, rows or media left; dict and string shapes in place of lists; duplicate keys; batching; numbering. Run before this PR by the builder: ruff, checks, 2,704 backend tests around import and export, vitest on the four import files. The full backend suite ran once before the last small edits (2 failures then, both passing on the committed code). CI is the full check. ## Known and not in this PR - **A campaign near 50,000 pages may not export again**: the export still has `IN` lists with one bind per page. Read from the code, not run. Follow-up issue to be filed. - `wiki_links.parse` is quadratic in memory for one huge block of link lines (pre-existing; the memory stop covers it). - A count includes rows the builder would skip, so it can never undercount. An independent review is in progress; this does not merge before it reports. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The limits on data.json bound what parsing costs, not what an import then
builds: 80,000 two-field sessions are 2 MB of file and took a worker past
900 MB, and the only answer was the memory stop's "holds more than this
server can read", once the memory was spent.

An import now counts every list it makes rows from, through the accessor
the building loop itself uses, and refuses a file over a bound for any kind
before the campaign's own row is constructed or a statement sent:

    This file has 80,000 sessions; the most taken in one import is 5,000.
    Nothing was imported.

The bounds are the owner's (D289): 5,000 sessions, 50,000 wiki pages, 500
members, 500,000 transcript lines, and the same order for the smaller
things. All of them are in one table, app/services/import_bounds.py.

- A dict or a string where a list is expected is counted as the loop would
  take it (by its keys, by its characters), since the loops iterate them.
- A page's links and footnotes are rows made from its text: counted from
  the text as it will be stored, by the scan that reads them out of it.
- ROWS_BUILT says which counts bound each ORM model an import constructs.
  A test reads the importer's source for the models it constructs, and
  every import any test runs is held to its count (it is logged, not
  failed, in production).

So that a file at the bounds fits and finishes:

- Reading a campaign's page names sent every page's id in one statement,
  and a statement takes 32,767 arguments: about 44,000 pages was a 500, on
  import and on any save of a page. They are read by campaign now.
- An import's pages had their links cleared a page at a time, two ORM
  DELETEs that each looked through every row the session held: 10,000
  pages took 103 seconds, 30,000 over a quarter of an hour. Not sent for a
  page made in the same transaction.
- Sessions take their numbers in one statement, with the same numbers.
- Rows are written as the import goes, 2,000 at a time, and pins, cards and
  map markers are remembered by id once written.

Measured in the worker's own task, 1 GB container: 50,000 pages grow the
process by 212 MB in about 30 seconds; the owner's four bounds at once
(23.2 MB of data.json) by 230 MB in 34 seconds; the dearest file made
inside every bound by 342 MB of the 400 MB stop, which stays as the
backstop. docs/OPERATIONS.md has the table; scripts/measure_import.py is
how it was taken.

Also records D288 (#1096) and D289 (#1105) in the catalogue's decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs(design): D288 and D289 carry their question codes (#1105)
Some checks failed
CI / Does this change touch the recording path? (pull_request) Successful in 21s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Bot/backend version sync (pull_request) Successful in 26s
CI / Backend lint (ruff) (pull_request) Successful in 31s
CI / Feedback site tests (pull_request) Successful in 42s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m44s
CI / Docker image build (pull_request) Successful in 2m37s
CI / Bot tests and audit (pull_request) Successful in 2m51s
CI / Frontend (shard 1/2) (pull_request) Successful in 7m59s
CI / Backend migration, tests, and audit (pull_request) Has been cancelled
CI / End-to-end journeys (desktop) (pull_request) Has been cancelled
CI / End-to-end journeys (keyboard) (pull_request) Has been cancelled
CI / Frontend (shard 2/2) (pull_request) Has been cancelled
CI / End-to-end journeys (phone) (pull_request) Has been cancelled
6f2e319913
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs(import): say which kinds are written as the import goes (#1105)
All checks were successful
CI / Does this change touch the recording path? (pull_request) Successful in 18s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Bot/backend version sync (pull_request) Successful in 28s
CI / Backend lint (ruff) (pull_request) Successful in 38s
CI / Feedback site tests (pull_request) Successful in 53s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m39s
CI / Bot tests and audit (pull_request) Successful in 2m10s
CI / Docker image build (pull_request) Successful in 2m21s
CI / Frontend (shard 2/2) (pull_request) Successful in 11m29s
CI / Frontend (shard 1/2) (pull_request) Successful in 13m29s
CI / End-to-end journeys (phone) (pull_request) Successful in 11m40s
CI / End-to-end journeys (keyboard) (pull_request) Successful in 12m37s
CI / End-to-end journeys (desktop) (pull_request) Successful in 12m43s
CI / Backend migration, tests, and audit (pull_request) Successful in 50m27s
01d6f55604
The review of PR #1112 found the module docstring claimed more than the
code does: several smaller kinds are still added whole and flushed once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Collaborator

Independent review (read-only, by reading both sides; it ran nothing)

Nothing blocking. It found no uncounted row-building loop, no way round the count, no early commit, and no write, statement or network read before the check.

Acted on:

  • The module docstring overclaimed ("what is waiting to be written never grows with the file"): notes, milestones, sheets, party members, attachments and link targets are still added whole and flushed once. All are bounded, so it is wording, not safety. Corrected in 01d6f556.

Recorded, not changed here:

  • Plan-built kinds are counted after their plans exist (markers, attachments, party members, quest pages). No ORM object or statement precedes the check, but a 24 MB file of, say, 240,000 minimal markers builds an estimated 100 MB of plan objects before it is refused, and in the extreme meets the memory stop's message, not the sentence naming the kind. Cheap hardening: count those lists in the first refuse_over block.
  • The static registry test walks only the listed builder modules; a model built through a helper that takes the class as a variable (text_versions.record, sheet_service.record_version) is caught only by the dynamic check, and a Core insert() would be seen by neither. None exists on the import path today.
  • lines() counts only \n; no rows come from transcripts, so it bounds reading only.
  • index_page(..., fresh=True) duplicates links if a later caller passes it for a page that is not new; one caller today, safe.
  • Unchanged from main: malformed shapes ("members": null, sessions as a dict) end as the job's generic failure, not a 422.

Not verified by the review: foreign-key order at the 2,000-row flush points (CI's suite runs every import under the strict count), the memory figures, the docs.

## Independent review (read-only, by reading both sides; it ran nothing) **Nothing blocking.** It found no uncounted row-building loop, no way round the count, no early commit, and no write, statement or network read before the check. Acted on: - **The module docstring overclaimed** ("what is waiting to be written never grows with the file"): notes, milestones, sheets, party members, attachments and link targets are still added whole and flushed once. All are bounded, so it is wording, not safety. Corrected in 01d6f556. Recorded, not changed here: - **Plan-built kinds are counted after their plans exist** (markers, attachments, party members, quest pages). No ORM object or statement precedes the check, but a 24 MB file of, say, 240,000 minimal markers builds an estimated 100 MB of plan objects before it is refused, and in the extreme meets the memory stop's message, not the sentence naming the kind. Cheap hardening: count those lists in the first `refuse_over` block. - **The static registry test** walks only the listed builder modules; a model built through a helper that takes the class as a variable (`text_versions.record`, `sheet_service.record_version`) is caught only by the dynamic check, and a Core `insert()` would be seen by neither. None exists on the import path today. - `lines()` counts only `\n`; no rows come from transcripts, so it bounds reading only. - `index_page(..., fresh=True)` duplicates links if a later caller passes it for a page that is not new; one caller today, safe. - Unchanged from main: malformed shapes (`"members": null`, `sessions` as a dict) end as the job's generic failure, not a 422. Not verified by the review: foreign-key order at the 2,000-row flush points (CI's suite runs every import under the strict count), the memory figures, the docs.
claude-bot deleted branch feat/1105-import-row-bounds 2026-10-05 23:01:30 +00:00
Sign in to join this conversation.
No description provided.