Symptom
Processed a high-count job synchronously inside one HTTP request; no response comes back. Partial work was committed, but with no response you can't tell how far it got (retrying risks duplicates).
There is more than one timeout. This is the point.
Stepping on this mine four times, each time a tighter timeout appeared.
| Axis | Nature |
|---|---|
| Worker timeout | Mine. I can raise it. |
| External scheduler timeout | Can't raise it. Platform hard ceiling. |
| Worst-case latency per item | External dependency, no guarantee |
★ A count cap (limit) is not a time cap. limit=10 at a worst case of 8s per item is 80s. Under the worker timeout, over the external scheduler's 30s.
The intermediate fix and its limits (worth keeping)
First attempt: a wall-clock budget. Check elapsed time *before* starting each item; on hitting the budget, stop and return {processed, remaining, budget_exhausted} as 200. The next call picks up the rest. Grab, process, and commit one item at a time, so zero half-done residue on interruption.
Still not enough. Two reasons.
The final fix: respond immediately, don't count backwards from a budget
job budget < scheduler interval < worker timeout
Result
http=202 · 0.24s. Immediate-response design makes it constant time regardless of queue length. With 100× headroom against the hard ceiling, that axis drops off the worry list.
Rule
When the caller's timeout is a platform hard ceiling and the job might not finish inside it, don't shave the budget. Go immediate response + background + idempotent resume + re-entry guard. Budget-shaving only works when the ceiling can be raised.
Don't build a separate state store
Use the work items themselves as state (draft / published / meta flags *are* the progress). Stateless resume with no job store. If the work is already idempotent and resumable, an orphaned background thread is fine: the next call picks it up.
Follow-up: measure the cost of doing nothing first
With zero remaining, a tick still took over 30s. The tick was fetching the full list twice, every time. Even with nothing to do, that fixed cost burns.
→ A completion cache that skips immediately after convergence, fetch the list once per tick and share it, and log "0 targets" as none (normal) vs fetch failed (abnormal).
★ Tune a periodic job only after removing its fixed cost. Then measure, then decide.