Skip to content

Batch entry inserts so large category imports finish within the request timeout - #623

Merged
lgelauff merged 3 commits into
masterfrom
fix/large-import-timeout-618
Oct 4, 2026
Merged

lgelauff merged 3 commits into
masterfrom
fix/large-import-timeout-618

Conversation

@lgelauff

@lgelauff lgelauff commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #620, the immediate cause of #618. Not a final fix for large imports: that is the background import job (#621 backend, #622 frontend).

Why

On the buildservice deploy, gunicorn's 30 s worker timeout kills large category imports. On montage-beta, a ~21.5k-file category was aborted at 30.5 s and left an empty round (details in #618).

The time goes into the database writes. Every imported file costs two single-row INSERTs, one into entries and one into round_entries, so 21.5k files mean ~43k round trips inside one request. Lookups have been chunked since 2016, but inserts have been one row at a time since then. The old webservice had no 30 s limit, so this only started failing after the move to the buildservice.

What changes

  • CoordinatorDAO.add_entries: new entries in each 200-entry chunk are inserted with one multi-row INSERT, then read back by name to get their ids.
  • CoordinatorDAO.add_round_entries: all round entries are inserted with one executemany. The round's round_entries collection is then expired so total_entries is not stale.
  • Everything still runs in the request's transaction, so a failure rolls back exactly as before.

For a 4,000-file import (local SQLite), the number of statements drops from 8,048 to 89. On MariaDB, pymysql turns both executemany calls into multi-row INSERT statements: a test checks that the compiled statements match the pattern pymysql batches, but the suite does not run against MariaDB. The real-database evidence is the montage-dev run below.

Tests

  • New montage/tests/test_import_entries.py, with synthetic data only:
    • Parity: the same scenario runs through the previous per-row code and the new code, and both must leave identical rows and identical API responses. The scenario mixes seeded and new entries over three chunks, with non-ASCII, quoted, %, emoji and space-containing names, reuploads, missing file_id, and every autodisqualify reason.
    • Edge cases: chunk boundaries (1/199/200/201/401), an empty category, exact and case-variant duplicates, re-importing into the same round, and entries shared between campaigns.
    • Failure: an error after the inserts leaves nothing behind.
    • Activation: creates quorum × eligible tasks.
    • Stale count: total_entries is right when the round's entries were already loaded.
    • MySQL batching: pymysql batches both INSERTs.
  • test_web_basic.py: a statement-count regression test (450 files must need at most 3 INSERTs per table).
  • The new tests fail when the change is deliberately broken (a dropped column, a missing expire, or the old per-row code put back).
  • pytest montage/tests/ → 29 passed, 1 xfailed, also with network access blocked.
  • conftest.py: Wikimedia user-id lookups are now mocked for the whole suite. App creation used to call the live Commons API, and the extra tests made CI hit Commons often enough to get throttled.

Verified on montage-dev

Deployed 3bd7eed to montage-dev (default gunicorn 30 s timeout) and imported Images from Wiki Loves Monuments 2025 in Russia into a new round:

  • POST /v1/admin/round/130/import succeeded in 26.7 s, with no WORKER TIMEOUT or traceback in the webservice log.
  • The round shows 21,521 files from 386 uploaders. The Commons category page lists 21,527; the 6-file difference has not been investigated (the import skips deleted files and case-variant duplicate names, the same as before this change).
  • On montage-beta, the same import was killed at 30.5 s before this change.

Known temporary limitation

The gunicorn timeout stays at the 30 s default; we are deliberately not raising it. The margin is small (26.7 s for ~21.5k files), and most of the remaining time is no longer the inserts. Categories somewhat larger than ~21.5k files, or a slow wikireplica, can still exceed 30 s and fail as in #618. This is accepted as temporary until imports run in the background (#621, #622).

Not in this PR

Refs #618, #621, #622

Written with Claude (Claude Code) on behalf of @lgelauff.

🤖 Generated with Claude Code

lgelauff and others added 3 commits October 1, 2026 05:55
Category imports inserted two rows per file one statement at a time
(entries via ORM add + autoflush, then round_entries), so a ~21.5k-file
category made ~43k round trips to the tools DB inside one request and
was killed by gunicorn's 30 s worker timeout on montage-beta.

add_entries now inserts each chunk's new entries with one multi-row
INSERT and reads them back by name for their ids; add_round_entries
inserts all round entries with one executemany. total_entries is
counted in SQL because the core insert bypasses the session.

For a 4,000-file import this drops the statement count from 8,048 to 89.

Refs #618

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds montage/tests/test_import_entries.py:
- parity: the same scenario (seeded entries, three chunks, non-ASCII,
  quotes, percent signs, emoji, reuploads, missing file_id, every
  autodisqualify reason) run through the previous per-row
  implementation and the batched one must leave identical rows and
  responses
- chunk boundaries (1, 199, 200, 201, 401), empty category, exact and
  case-variant duplicates, re-import into the same round, entries
  shared between campaigns
- a failure after the inserts rolls everything back
- activation assigns quorum x eligible tasks for imported entries
- total_entries is right when the round's entries were already loaded
- pymysql turns both INSERTs into multi-row statements

Also expires the round's round_entries after the batched insert:
without it, a round whose entries were loaded earlier in the request
reports a stale total_entries (caught by the new test).

Refs #618

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create_app() bootstraps the maintainers through get_mw_userid() before
any responses mock is active, so every app a test created called the
live Commons API. The new import tests create many apps; on PR #623 CI
the Commons API started returning non-JSON partway through the run and
every later fixture setup failed with JSONDecodeError.

An autouse fixture now replaces montage.rdb.get_mw_userid with an
offline lookup: Slaporte keeps their real id (the devtest session
cookie carries it), everyone else gets a stable crc32-based id.

With network access blocked, master gives 5 passed / 9 errors; this
branch gives 29 passed, 1 xfailed.

Refs #620

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

lgelauff commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Tested on montage-dev in synchronous import mode (the way production runs), on 2026-10-02, on an integration branch with #623, #626, #628, #630 and #631:

  • Images from Wiki Loves Monuments 2026 in Russia: 15,676 files in 8.1 s.
  • Images from Wiki Loves Monuments 2025 in Russia: 21,521 files in 15.0 s (26.7 s with Batch entry inserts so large category imports finish within the request timeout #623 alone).
  • A file list of 1,503 names (79 KB): 1,499 files in 1.3 s, with a warning for each of the 3 names that do not exist (the 1,500 real names hold 1,499 that are unique ignoring case).

Activating the 21.5k-file round afterwards got the webservice OOMKilled. That is activation, not changed by this PR; it is fixed in #632, with which the same round activated in 4.5 s.

Written with Claude (Claude Code) on behalf of @lgelauff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Category import writes entries one row at a time

1 participant