Skip to content

Background import job, Phase 1: imports run in a worker, round shows import status - #624

Closed
lgelauff wants to merge 11 commits into
fix/large-import-timeout-618from
feat/background-import-621
Closed

lgelauff wants to merge 11 commits into
fix/large-import-timeout-618from
feat/background-import-621

Conversation

@lgelauff

@lgelauff lgelauff commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

Phase 1 of #621 (backend) and #622 (frontend). Builds on #623 (batched inserts); the base branch is fix/large-import-timeout-618, so this diff shows only the background-import work.

What changes

With MONTAGE_IMPORT_MODE=worker, importing entries into a round no longer happens inside the HTTP request:

  • Enqueue. POST /admin/round/<id>/import validates the request, records an import_jobs row, and returns at once with the job.
  • Worker. montage/import_worker.py runs as a Toolforge continuous job, with exactly one replica. It claims the oldest queued job with a compare-and-set UPDATE and fetches the files with no transaction open. It then runs the whole import (entries, round entries, round source, autodisqualify, audit log) in one transaction. The UPDATE that marks the job succeeded is fenced and comes last, so "finished" only appears once the data is committed. A failure is recorded separately, with a readable reason.
  • Stuck jobs. At startup the worker fails jobs still running (a restart or deploy interrupted them), and it fails jobs running longer than 60 minutes. Fetches now have timeouts: HTTP is (15 s, 600 s); the Commons database is 30 s to connect and 45 min per query. A hung fetch therefore fails its job and the worker moves on.
  • Activation gate. A paused round cannot be activated while any of its imports is queued or running, or while its latest import failed. There is a non-locking pre-check, then a round lock and a locking re-check. The API returns 400 with a specific message.
  • UI.
    • The round-creation dialog closes at once with "Import started… refresh to see when it has finished".
    • The round view, the round header and the campaign cards show the import state whenever they load: queued, running, finished (with counts and warnings), or failed (with the reason).
    • Activate is disabled with an explanation. Until Phase 2, a failed import's hint is "cancel this round and create it again".
    • There is no polling. All new strings are in en.json and qqq.json.
  • Rollback switch. MONTAGE_IMPORT_MODE defaults to sync outside dev and devtest. In sync mode the import runs in the request as before (no locks are held across the fetch) and a succeeded job row is recorded. Deploying this code changes nothing until worker mode is switched on.
  • Deploy.
    • New import_jobs table: tools/migrate_import_jobs.sql, with tools/revert_import_jobs.sql to undo it.
    • tools/deploy.sh runs a schema pre-flight before restarting the webservice, then restarts the worker job.
    • deployment.md covers the worker job, the migration, rollback, and a hung worker.

Tests

  • pytest montage/tests/ gives 73 passed, 1 xfailed, also with network access blocked.
  • New test_import_jobs.py covers:
    • enqueue, the claim compare-and-set, the fenced success and fenced failure;
    • the startup and 60-minute sweeps;
    • the activation gate, the payloads (admin yes, juror no) and 400s on bad requests;
    • timeouts;
    • sync mode, including a rollback check.
  • The existing import tests drain the queue and assert the job outcome.
  • A mutation pass removed the safety checks one at a time (the fence, the gate, the claim compare-and-set, both sweeps, sync-mode locking, the timeouts); the suite catches each of them.
  • Not covered on SQLite: the locked re-checks in activation and enqueue (SQLite ignores FOR UPDATE). Those need MariaDB.
  • Frontend: npx prettier --check src/, npx eslint src/ and npm run build all pass.

Not verified yet

  • Nothing has run on MariaDB or Toolforge yet. Still to check on beta:
    • that the migration runs;
    • that the env vars and ~/replica.my.cnf reach the job pod;
    • the exit code of toolforge jobs run --wait;
    • the worker's memory on the largest category.
  • The UI journeys have not been clicked through. Local login on the dev box fails, so this needs montage-dev.

Known limitations (Phase 2)

  • No heartbeat, so a deploy fails a running import ("interrupted by a worker restart").
  • A queued job never times out if no worker is running.
  • No Retry/Dismiss: a failed import blocks activation until the round is recreated.
  • Lock and deadlock errors in activation and enqueue give a 500, not a friendly message.

Refs #621, #622, #618

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

🤖 Generated with Claude Code

lgelauff and others added 11 commits October 1, 2026 08:30
POST /admin/round/<id>/import now validates the request, records a
queued import_jobs row and returns at once; a Toolforge continuous job
(python -m montage.import_worker, Procfile "import-worker", one replica)
claims it with a compare-and-set UPDATE, fetches the files with no
transaction open, and runs the existing import code in one transaction
whose last statement marks the job succeeded (fenced on a claim token).
Failures roll back and are recorded as failed jobs with a sanitised
reason and an import_failed audit entry.

- ImportJob model + LongJSONEncodedDict (MEDIUMTEXT on MySQL);
  tools/migrate_import_jobs.sql / revert_import_jobs.sql
- import_state_for(): admin payloads (/admin, /admin/campaigns/all,
  /admin/campaign/<id>, /admin/round/<id>) carry import_state
- activate_round refuses while any job is queued/running or the latest
  failed (non-locking pre-check, round lock, locking re-check)
- GET /admin/round/<id>/imports and /admin/round/<id>/import/<job_id>
- stuck-job rule: running jobs failed at worker startup and after 60 min
- MONTAGE_IMPORT_MODE=sync keeps the synchronous import as a rollback
  switch (default sync outside devtest until the worker runs)
- 400 instead of 500 for missing import keys; the selected-import
  warning is a dict again (was a set literal)
- make_engine(config) extracted from create_app for the worker

Refs #621, #618

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g tests

- conftest.run_import_jobs(app): run queued jobs in-process
  (import_worker.run_once) while external APIs are mocked
- test_web_basic: drain after each import (imports are queued now);
  test_category_import_batches_inserts reads counts from the job
- test_import_entries: runs in import_mode 'sync' (asserts on the
  synchronous response; same run_import code path as the worker)
- new test_import_jobs.py: enqueue without external calls, worker
  success/failure/rollback, CAS claim, fenced commit, startup and
  60-min stuck-job sweeps, loop survives DB errors, activation gate,
  import_state in admin (not juror) payloads, 400 on missing keys,
  sync rollback mode, schema check, MEDIUMTEXT columns

Refs #621

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- RoundNew: after a queued import, close the form at once with
  "Import started; ... Refresh the page" (no waiting, no polling); the
  old result dialog stays for the sync rollback mode
- RoundImportStatus (new, in RoundInfo): queued since / running since /
  finished with counts and warnings / failed with the reason and the
  "cancel this round and create it again" hint; one details fetch on
  load, no polling
- RoundInfo: Activate disabled with the reason while the import blocks
  activation (state read from the campaign payload, so from first render)
- RoundView header and CoordinatorCampaignCard: "importing…" /
  "import failed" badge
- adminService.getImportJobs/getImportJob; utils.getImportBadgeKey,
  formatUtcDateTime (server dates are naive UTC)
- new strings in en.json + qqq.json only

Refs #622, #621

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- deploy.sh: check the schema inside the new image with a one-off
  schema-check job (tools/check_schema.py) and abort before restarting
  if a migration is missing; restart the import-worker continuous job
  after the webservice (or print the command to create it)
- deployment.md: MONTAGE_IMPORT_MODE; tools/migrate_import_jobs.sql;
  new step 7b (create the import-worker job, exactly one replica);
  worker logs; "Imports stay queued" troubleshooting with the rollback
  SQL; missing import_jobs table at startup

The Toolforge behaviour the script relies on (envvars in job pods,
jobs run --wait exit status and working directory, jobs restart pulling
:latest, ~/replica.my.cnf in a job pod) is still to be verified on
montage-beta.

Refs #621

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With db_echo the worker logged the SQL of every 5-second poll; it now
only logs its own lines (seen on the hague dev setup).

Refs #621

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rt-621

Brings in fdfa606 (offline Wikimedia user-id lookups in the test suite),
so the Phase 1 tests run without the live Commons API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
check_no_active_import took the round row lock and a FOR UPDATE read on
import_jobs, then the same request fetched and imported everything. On
MariaDB that read's gap lock is held for the whole import, so imports
into other new rounds wait or deadlock (1205/1213) and get a 500. Sync
mode (the rollback switch, and the default) now only does the plain
active-job pre-check, which restores the pre-#621 lock footprint. The
residual race (an enqueue from a worker-mode process during a mode flip)
is documented in the docstring.

Refs #621

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dead wikireplica connection or a stalling CSV/API host blocked the
single import worker for ever, so every later import stayed queued.

- requests_get/requests_post: default timeout (15 s connect, 600 s per
  read), overridable with timeout=
- labs.fetchall_from_commonswiki: connect_timeout 30 s, read_timeout
  45 min (one query, under the worker's 60-min cap), write_timeout 60 s;
  MONTAGE_LABS_CONNECT_TIMEOUT / MONTAGE_LABS_READ_TIMEOUT override
- worker: readable reasons for fetch timeouts and wikireplica errors;
  a failing rollback no longer skips marking the job failed
- check_schema closes its session and disposes its engine (the worker
  kept an idle connection open for its lifetime)
- deployment.md: "hung worker" recovery note

Refs #621

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RoundImportStatus fetched job details only on mount; a campaign reload
reuses the component, so a job that had just finished or failed showed
no warnings or reason until a full page reload. Watch job id + status
(immediate) instead.

Refs #622

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the PR check's mutation run (M3, M7, M8, M10 survived):
- main() runs the startup sweep before the first loop iteration
- run_loop_iteration runs the 60-min sweep
- a failure after a sweep is fenced (keeps the sweep's error, one audit)
- claim CAS goes through claim_next_job with a racing writer between
  its SELECT and UPDATE (replaces the hand-written UPDATE test)
Also: sync mode takes no lock before the fetch; default HTTP and
wikireplica timeouts; a timed-out fetch fails its job readably and the
next job runs; a failing rollback still marks the job failed; the sync
failure test checks entries/round_entries/round_sources roll back; every
run_import_jobs call in test_web_basic asserts its outcome.

Refs #621

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The import worker runs as a Toolforge job, where HOME is / and not the
tool's home, so ~/replica.my.cnf did not exist and pymysql connected
without a password: on montage-dev every worker category import failed
with 1045 "Access denied ... (using password: NO)".

replica_credentials() now uses TOOL_REPLICA_USER/TOOL_REPLICA_PASSWORD
when Toolforge sets them, else replica.my.cnf in TOOL_DATA_DIR, else ~
(the old behaviour), and raises a clear error if none exists. Checked
in a montage-dev job pod: HOME=/, TOOL_DATA_DIR set, replica.my.cnf
present there, TOOL_REPLICA_* set.

Refs #621

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

lgelauff commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #630 (backend) and #631 (frontend), which split this PR and add Phase 2. Closing.

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

@lgelauff lgelauff closed this Oct 2, 2026
@lgelauff
lgelauff removed the request for review from ItsAbhinavM October 2, 2026 19:45
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