fix(poller): drain stale backlogs, and stop reporting health that isn't (v0.23.19-beta)
v0.23.17 stopped the poller creating an unbounded backlog but did nothing about
the one already there. A reporter with sixty devices upgraded and still had
752,318 jobs queued, nine stale devices, and an endpoint cheerfully reporting
1.59x headroom. Fair to call that not fixed.
Three faults, all mine.
**Retention never touched pending work.** cleanupJobHistory cleaned 'completed'
and 'failed' only. The queue of outstanding polls — the part the operator
actually feels — was out of scope, so an upgrade left it draining for days.
Stale pending jobs are now swept, at startup and every five minutes. The rule is
that a periodic poll which has waited longer than its own cadence is worthless:
running it yields a reading the next cycle would have taken anyway, while
occupying a worker the live fleet is waiting on. Discarding queued work is safe
precisely because it is periodic — the schedule re-enqueues within one interval.
**Deduplication failed in the case it existed for.** The in-flight claim was a
Redis key with a 135-second TTL. Under a large backlog a job waits hours, so the
claim lapsed long before it ran and the scheduler re-enqueued every cycle: the
guard worked only on a queue that was already short.
Replaced with a deterministic job id, which cannot lapse. While a job for a
device and kind exists in any state, adding it again is a no-op. That requires
finished jobs to be removed immediately or the id would stay taken and the
device would never be polled again — which costs nothing now that
device_poll_stats records every attempt, duration and error.
**The headroom metric lied.** It describes flow and is blind to accumulated
work, so it read "healthy" beside three-quarters of a million queued jobs. It
now reports backlog and drain ETA alongside, plus a single status verdict that
cannot say ok while a backlog exists. Verified by injecting 15,125 jobs: status
went to 'draining' with headroom still at 85x, which is exactly the case the old
metric got wrong.
Also added, because the reporter asked and was right to: an operator-triggered
drain (POST /api/system/poller/drain), and a Polling tab in Settings showing
queue depth, headroom, backlog, per-device staleness and the drain control. It
was all buried in an API response before. A dashboard insight fires when the
poller falls behind, so a starved poller announces itself rather than presenting
as a fleet of broken devices.
Two corrections found by testing rather than reasoning:
* BullMQ rejects ':' in a custom job id. `fast:2` threw on every enqueue and
killed the scheduler outright — three of four devices went unpolled for
eight minutes before it was caught. Hyphens now, and the dedup is exercised
directly: 500 enqueues of one device yield one queued job.
* Redis maxmemory-policy moves from volatile-lru to noeviction. I had reasoned
that only TTL-bearing keys are evicted and BullMQ job hashes carry none —
true, but its *control* keys do. Job locks and stalled-check markers both
expire, and evicting one causes duplicate processing or a stalled-job storm.
BullMQ warns about this on connect and was right.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
deb71363c9
commit
745a7cda93
@@ -18,6 +18,7 @@ import { redis } from './config/redis';
|
||||
import { runMigrations } from './db/migrate';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import { PollerService } from './services/PollerService';
|
||||
import { setSharedPollerService } from './services/pollerRef';
|
||||
import {
|
||||
setBulkAddPollerService,
|
||||
startBulkAddWorker,
|
||||
@@ -374,6 +375,7 @@ async function start(): Promise<void> {
|
||||
pollerService.setSocketServer(io);
|
||||
setDevicesPoller(pollerService);
|
||||
setSystemPoller(pollerService);
|
||||
setSharedPollerService(pollerService);
|
||||
setBulkAddPollerService(pollerService);
|
||||
setTopologyPoller(pollerService);
|
||||
setClientsPoller(pollerService);
|
||||
@@ -384,6 +386,12 @@ async function start(): Promise<void> {
|
||||
// hold up startup, and nothing depends on it having finished.
|
||||
void pollerService.cleanupJobHistory().catch((e) =>
|
||||
console.error('[Poller] Job history cleanup failed:', e));
|
||||
|
||||
// Pending work accumulated before dedup existed is dropped too, not just
|
||||
// finished-job history. An upgrade that leaves hundreds of thousands of stale
|
||||
// polls queued has not actually fixed anything the operator can see (#114).
|
||||
void pollerService.dropStalePending().catch((e) =>
|
||||
console.error('[Poller] Stale-pending sweep failed:', e));
|
||||
await startBulkAddWorker();
|
||||
|
||||
// NetFlow/IPFIX collector (binds its UDP socket only when netflow_enabled)
|
||||
|
||||
Reference in New Issue
Block a user