Skip to content

fix: batch account-maintenance per cycle + reclaim stuck-Running tasks#1161

Merged
frol merged 1 commit into
NEAR-DevHub:mainfrom
frol-ai:fix-account-maintenance-batch-and-reclaim
Jul 21, 2026
Merged

fix: batch account-maintenance per cycle + reclaim stuck-Running tasks#1161
frol merged 1 commit into
NEAR-DevHub:mainfrom
frol-ai:fix-account-maintenance-batch-and-reclaim

Conversation

@frol-ai

@frol-ai frol-ai commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Fixes the "multiple account-maintenance tasks Running at once" observed on the board. They were not concurrent — one worker, concurrency(1), heartbeating fine — but orphaned Running rows, one appearing every ~30 min (= the handler job_timeout).

Root cause

run_maintenance_cycle processed every enabled account each tick, so the total time was O(accounts). Once that exceeded the 30-min job_timeout, the handler was cancelled mid-cycle and the timed-out task was left in Running — its ack to mark it terminal never landed (DB instability / cancelled handler). apalis's own orphan-reclaim only fires when the worker's heartbeat goes stale, but the worker was alive, so these phantom Running rows were never reclaimed or pruned and just piled up.

Fixes

  1. Batch the cycle (ACCOUNT_MAINTENANCE_BATCH_SIZE, default 25). Dirty accounts sort first (never starved); the rest rotate by last_synced_at across cycles (cron fires every 60s). Worst-case cycle time = batch / concurrency × per_account_timeout ≈ 25/4×120s ≈ 12.5 min — comfortably under job_timeout, so cycles finish and ack cleanly.
  2. Reclaim orphaned locks in apalis_prune: flip tasks stuck Running past a threshold to Killed (then removed by the retention sweep). Threshold is at least job_timeout (APALIS_STUCK_RUNNING_RECLAIM_SECONDS), so a genuinely-running task — bounded by job_timeout — is never touched.

Testing

cargo fmt --check, cargo clippy --lib -- -D warnings, cargo check: clean. Validated the batch ordering and the reclaim UPDATE against a live apalis schema (a 2h-stuck Running row → Killed; a 2-min-old one left untouched).

Note: with #1156's leader election now merged, only the elected leader runs these — the batching keeps each leader's maintenance cycle bounded, and the reclaimer cleans up any locks orphaned during a leadership transition or DB blip.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FRWEKUFYCcmGGhwdDYd471

…ng tasks

Two related fixes for the phantom "10 account-maintenance tasks Running at once"
(they were not concurrent — one worker, concurrency(1) — but orphaned Running
rows, one per ~30 min = the handler `job_timeout`).

1. Batch the cycle. `run_maintenance_cycle` processed *every* enabled account,
   so total time was O(accounts); once it exceeded 30 min the handler timed out
   mid-cycle and the timed-out task was left `Running` (ack never landed). Now it
   processes at most `ACCOUNT_MAINTENANCE_BATCH_SIZE` (default 25) accounts,
   dirty first (never starved) then least-recently-synced (rotate across
   cycles). Worst-case cycle time is batch/concurrency × per-account timeout ≈
   12.5 min with defaults — comfortably under `job_timeout`.

2. Reclaim orphaned locks. apalis's orphan-reclaim only fires on a stale
   *worker* heartbeat, so a task stuck `Running` under a live worker (failed
   ack) is never reclaimed or pruned. `apalis_prune` now flips tasks stuck
   `Running` past a threshold (>= 2× `job_timeout`, so a real running task is
   never touched; `APALIS_STUCK_RUNNING_RECLAIM_SECONDS`) to `Killed`, which the
   retention sweep then removes.

Validated the batch ordering and the reclaim UPDATE against a live schema
(stuck row -> Killed, fresh row untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRWEKUFYCcmGGhwdDYd471
frol pushed a commit that referenced this pull request Jul 21, 2026
…1162) (#1163)

Follow-up to your point on #1162 — instead of hiding the slow-statement
warning by raising the threshold, actually make the board queries fast.

## Root cause: table bloat, not row count
Measured on testenv: `TOTAL_JOBS` ≈ **70k** live rows, but `DB_SIZE` ≈
**371 MB** — ~5 KB/row, i.e. the table is almost all **dead tuples**.
The queues churn ~1M rows/day (insert → Running → Done → pruned), and
Postgres's default autovacuum only kicks in at 20% dead
(`autovacuum_vacuum_scale_factor = 0.2`), which can't keep up. So the
board's full-table aggregates (`SUM(CASE WHEN status …)`, `COUNT(*)`)
scan hundreds of MB of mostly-dead pages → ~1.5s.

## Fix (attack the bloat)
- **Aggressive autovacuum on `apalis.jobs`** (`tune_apalis_autovacuum`,
run in `setup_apalis`): `vacuum_scale_factor = 0.05`,
`autovacuum_vacuum_insert_scale_factor = 0.05` (the table is
insert-heavy, so insert-triggered vacuums keep the visibility map
fresh), lower thresholds, small cost delay. Keeps dead tuples low
(compact heap) and the VM fresh (so the aggregates can use **index-only
scans** over the narrow indexes already added, bypassing the heap
entirely).
- **`VACUUM (ANALYZE) apalis.jobs`** at the end of the hourly
`apalis_prune`: reclaims the space the deletes leave + refreshes
VM/stats. Plain `VACUUM` (not `FULL`) takes only a SHARE UPDATE
EXCLUSIVE lock, so workers keep polling.

## Relationship to the other PRs
- **Supersedes #1162** (slow-statement threshold bump) for these queries
— recommend closing #1162, or keeping only as a minor defensive default.
This PR makes them genuinely fast.
- Complements #1161 (retention + stuck-`Running` reclaimer): fewer rows
*and* no bloat.
- **Ops, one-time**: `VACUUM FULL apalis.jobs` reclaims the existing
~370 MB immediately (brief exclusive lock); the settings here prevent it
re-bloating.

## Testing
`cargo fmt --check`, `cargo clippy --lib -- -D warnings`, `cargo check`:
clean. Validated the `ALTER TABLE … SET (autovacuum_*)` and `VACUUM
(ANALYZE)` statements against Postgres.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01FRWEKUFYCcmGGhwdDYd471

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@frol
frol merged commit 5f6de49 into NEAR-DevHub:main Jul 21, 2026
2 checks passed
@akorchyn akorchyn added this to the v1.22.0 milestone Jul 22, 2026
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.

3 participants