Report reschedules keep the task queue readable #46

Merged
jcoffey-dev merged 1 commits from fix/report-reschedule into main 2026-09-25 01:30:35 +00:00
Owner

Setting deliverAt on an internal DMARC or TLS report (x:DmarcInternalReport/set, x:TlsInternalReport/set) wedged the task queue on every node. This is in production (2026.9.24.3).

Cause

InternalReportIndex::reschedule_ops (crates/smtp/src/reporting/index.rs) wrote the new task queue row with object_id.serialize(): the report's object type (0x21 DmarcInternalReport, 0x6e TlsInternalReport), not the task type schedule_task_with_id writes (7 DmarcReport, 8 TlsReport). It also left the task row at its old due, so x:Task/get showed a stale due.

process_tasks (crates/services/src/task_manager/manager.rs) turned that row into store.data-corruption with a ? inside the iterate closure, which ended the whole scan: "Failed to iterate over task queue", and every task due after the row stayed stuck, cluster-wide.

Change

  • Reschedule (index.rs, jmap/.../mapping/report.rs): the new row goes through schedule_task_with_id, so it carries the task type and the task row gets the new due. The old row is removed under the key the task is actually queued at (the task's own due, which differs from deliverAt once the task has been retried), plus any row an earlier reschedule left at deliverAt. The set locks the report's task while it moves it, as x:Task/set does, refuses while the report is being sent, releases its locks however the request ends (a held lock is renewed, so a leaked one would block the task for good), and wakes the task manager.
  • Scanner (manager.rs): a row whose type can't be read is logged (store.data-corruption, "Unreadable task queue row skipped", with id, due, key and value) and skipped; the scan carries on. The row is then repaired from its task row: rewritten with the task's type, or removed if no task is behind it. A report task's row that holds the report's object type is exactly what the old reschedule wrote, so the task is moved to that row's time, as the admin asked, and its old row is removed. The task manager looks again once a row is repaired; a repair that fails is logged and tried on the next scan, without a busy loop.
  • x:Task/query by type (jmap/.../mapping/task.rs) skips an unreadable row instead of failing.

Existing broken rows: production may already hold one if anyone rescheduled a report. Nothing needs doing by hand. When the row comes due, the scanner logs it, rewrites it as the DmarcReport/TlsReport task at the rescheduled time, and the report goes out. Tasks due after it run either way. Until the row comes due, x:Task/get still shows the old due; x:Task/query no longer fails on it.

Tests

tests/src/smtp/reporting/reschedule.rs (new), single node, task manager running:

  • Reschedules a DMARC and a TLS report over JMAP to a few seconds out. x:Task/get shows the new due, and the queue row holds the task type. Both reports go out at the new time, and a task due after them still runs.
  • Writes a row the way the old reschedule did (report object type, task row left at its old due) and an unreadable row with no task. Both are logged, the report goes out, a later task runs, and all three queue rows are gone.
  • x:Task/query filtered by type succeeds with an unreadable row in the queue.
nstest3.sh <wt> RocksDb report_reschedule                          # ok
nstest3.sh <wt> PostgreSql --features postgres report_reschedule   # ok; log shows both rows skipped

On main it fails: x:Task/get shows the old due. With that assertion removed, main logs "Failed to iterate over task queue" and neither report nor the later task ever runs ("tasks [...] never ran").

Also run: smtp:: on RocksDB (75 pass; lmtp_delivery failed once in the full run and passed twice alone; it's timing and doesn't touch this code), and cluster::live_roles, cluster::task_roles, cluster::stress, store::task_locks on PostgreSQL. cluster::broadcast needs COORDINATOR and wasn't run.

Release

The fix is self-contained. Cherry-picked onto 2026.9.24.3 (e223f7d), only manager.rs conflicts, in one hunk: the release has the older inline role match where main calls task_enabled. Keep the release's match and take the new type decoding above it. Every API the change calls exists at e223f7d. publish.yml only publishes tags that are on main, so a release still has to be cut from main (by tag, after this merges).

Setting `deliverAt` on an internal DMARC or TLS report (`x:DmarcInternalReport/set`, `x:TlsInternalReport/set`) wedged the task queue on every node. This is in production (2026.9.24.3). ## Cause `InternalReportIndex::reschedule_ops` (`crates/smtp/src/reporting/index.rs`) wrote the new task queue row with `object_id.serialize()`: the report's object type (0x21 DmarcInternalReport, 0x6e TlsInternalReport), not the task type `schedule_task_with_id` writes (7 DmarcReport, 8 TlsReport). It also left the task row at its old due, so `x:Task/get` showed a stale due. `process_tasks` (`crates/services/src/task_manager/manager.rs`) turned that row into `store.data-corruption` with a `?` inside the iterate closure, which ended the whole scan: "Failed to iterate over task queue", and every task due after the row stayed stuck, cluster-wide. ## Change - **Reschedule** (`index.rs`, `jmap/.../mapping/report.rs`): the new row goes through `schedule_task_with_id`, so it carries the task type and the task row gets the new due. The old row is removed under the key the task is actually queued at (the task's own due, which differs from `deliverAt` once the task has been retried), plus any row an earlier reschedule left at `deliverAt`. The set locks the report's task while it moves it, as `x:Task/set` does, refuses while the report is being sent, releases its locks however the request ends (a held lock is renewed, so a leaked one would block the task for good), and wakes the task manager. - **Scanner** (`manager.rs`): a row whose type can't be read is logged (`store.data-corruption`, "Unreadable task queue row skipped", with id, due, key and value) and skipped; the scan carries on. The row is then repaired from its task row: rewritten with the task's type, or removed if no task is behind it. A report task's row that holds the report's object type is exactly what the old reschedule wrote, so the task is moved to that row's time, as the admin asked, and its old row is removed. The task manager looks again once a row is repaired; a repair that fails is logged and tried on the next scan, without a busy loop. - **`x:Task/query` by type** (`jmap/.../mapping/task.rs`) skips an unreadable row instead of failing. **Existing broken rows:** production may already hold one if anyone rescheduled a report. Nothing needs doing by hand. When the row comes due, the scanner logs it, rewrites it as the DmarcReport/TlsReport task at the rescheduled time, and the report goes out. Tasks due after it run either way. Until the row comes due, `x:Task/get` still shows the old due; x:Task/query no longer fails on it. ## Tests `tests/src/smtp/reporting/reschedule.rs` (new), single node, task manager running: - Reschedules a DMARC and a TLS report over JMAP to a few seconds out. `x:Task/get` shows the new due, and the queue row holds the task type. Both reports go out at the new time, and a task due after them still runs. - Writes a row the way the old reschedule did (report object type, task row left at its old due) and an unreadable row with no task. Both are logged, the report goes out, a later task runs, and all three queue rows are gone. - `x:Task/query` filtered by type succeeds with an unreadable row in the queue. ``` nstest3.sh <wt> RocksDb report_reschedule # ok nstest3.sh <wt> PostgreSql --features postgres report_reschedule # ok; log shows both rows skipped ``` On main it fails: `x:Task/get` shows the old due. With that assertion removed, main logs "Failed to iterate over task queue" and neither report nor the later task ever runs ("tasks [...] never ran"). Also run: `smtp::` on RocksDB (75 pass; `lmtp_delivery` failed once in the full run and passed twice alone; it's timing and doesn't touch this code), and `cluster::live_roles`, `cluster::task_roles`, `cluster::stress`, `store::task_locks` on PostgreSQL. `cluster::broadcast` needs `COORDINATOR` and wasn't run. ## Release The fix is self-contained. Cherry-picked onto 2026.9.24.3 (e223f7d), only `manager.rs` conflicts, in one hunk: the release has the older inline role `match` where main calls `task_enabled`. Keep the release's `match` and take the new type decoding above it. Every API the change calls exists at e223f7d. `publish.yml` only publishes tags that are on main, so a release still has to be cut from main (by tag, after this merges).
jcoffey-dev added 1 commit 2026-09-25 01:15:57 +00:00
Report reschedules keep the task queue readable
ci / fork-checks (pull_request) Successful in 52s
ci / build (pull_request) Successful in 4m0s
1a7859a8cc
Setting deliverAt on an internal DMARC or TLS report wrote the new task
queue row with the report's object type (0x21, 0x6e) instead of the task
type (7, 8), and left the task row at its old due. The task manager's scan
failed on that row with store.data-corruption ("Failed to iterate over task
queue"), and because the error ended the whole scan, every task due after
the row stopped running on every node.

- reschedule_ops writes the new queue row through schedule_task_with_id, so
  it carries the task type and the task row gets the new due. It removes
  the row the task is actually queued under (the task's due, which differs
  from deliverAt once the task has been retried) and any row an earlier
  reschedule left at deliverAt.
- x:DmarcInternalReport/set and x:TlsInternalReport/set lock the report's
  task while they move it, as x:Task/set does, refuse while the report is
  being sent, release the locks however the request ends, and wake the task
  manager.
- The task manager logs a queue row it can't read (id, due, key, value) and
  skips it instead of ending the scan. It then repairs the row from its task:
  the row is rewritten with the task's type, and a row with no task behind
  it is removed. A row holding a report's object type for a report task is
  what the old reschedule wrote: the task is moved to that row's time, as
  the reschedule intended, and its old queue row is removed. Stores that
  already hold such a row recover on their own once it comes due.
- x:Task/query with a type filter skips an unreadable row instead of
  failing.

Test: smtp::reporting::reschedule (RocksDB and PostgreSQL). It fails on
main: x:Task/get shows the old due, and with that check removed, neither
report nor a later task ever runs.
jcoffey-dev merged commit 9cc9951428 into main 2026-09-25 01:30:35 +00:00
jcoffey-dev deleted branch fix/report-reschedule 2026-09-25 01:30:36 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: inbuxa/inbuxa-server#46