fix(cohorts): stop non-static cohorts from freezing permanently after first compute - #432
Conversation
… compute
`cohortRefreshCronJob` and `enqueueCohortCompute` both enqueued with a fixed
`jobId: cohort-<id>`. BullMQ's `add` short-circuits while any Redis record for
that id still exists, and `removeOnComplete: { age }` is not a TTL — nothing
expires on a timer, `removeJobsByMaxAge` only runs as a side effect of some
other job in the queue finishing, and it collects predecessors only.
That closes a cycle: the record can't be collected until a job finishes, a job
can't finish until one is added, and none can be added while the record exists.
Once every non-static cohort holds a completed record the queue is permanently
dead — the cron keeps firing and every enqueue is a silent no-op. The UI
Refresh button was the only escape, and because the age-trim sweeps the whole
zset by score, one click freed every cohort for exactly one cycle, which is why
the symptom looked intermittent rather than broken.
Deduplicate on the cohort instead of pinning a jobId. With no `ttl`, the
deduplication key is released by `moveToFinished` — which calls
`removeDeduplicationKeyIfNeededOnFinalization` above the completed/failed
branching — so it collapses only a compute that is genuinely still in flight,
and finished records stop gating anything.
Verified against a real Redis (bullmq 5.63.0): 3 ticks with the old fixed jobId
process 1 job, with deduplication process 3; a second add while a job is active
does not start a second run; the key has no TTL while in flight and is gone
after both completion and terminal failure, with `failedReason` retained.
Also:
- `cohortRefreshCronJob` now calls `enqueueCohortCompute` instead of
duplicating its options, so the two can no longer drift.
- `removeCohortComputeJob` is deleted along with its two call sites. It only
existed to work around the fixed jobId, and a plain `remove()` before `add()`
would destroy the failure record that dedup preserves.
- The queue's `removeOnComplete`/`removeOnFail` gain a `count` bound, since
`age` alone only trims when another job finishes.
- Dropped two stray `console.log`s in the cohort router.
Fixes #424
Claude-Session: https://claude.ai/code/session_01ETRr6KYLYATzBVaLRZcwwk
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
💤 Files with no reviewable changes (1)
Included review availability: Your plan includes up to 8 reviews per rolling hour; 6 remain after this review. 📝 WalkthroughWalkthroughCohort computation producers now use ChangesCohort compute refresh
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to The PR changes cohort compute scheduling so completed jobs no longer permanently block future refreshes; no actionable merge-blocking risk remains beyond normal checks and review. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Fixes #424.
The bug
cohortRefreshCronJobandenqueueCohortComputeboth enqueued with a fixedjobId: cohort-<id>. BullMQ'saddshort-circuits while any Redis record for that id still exists:The key detail is that
removeOnComplete: { age }is not a TTL. Nothing expires on a timer —removeJobsByMaxAgeruns only from insidemoveToFinished/removeJobsOnFail, i.e. as a side effect of some job in that queue finishing, and it collects predecessors only.So the states form a cycle:
cohort-X's record requires some job in the queue to finish ≥1h after it,cohortComputehas only three producers — the cron, cohort create/update, and the UI Refresh — and the first two used the same blocked ids. Once every non-static cohort holds a completed record, nothing can ever be added, so nothing finishes, so nothing is ever collected. Permanent, not a 1h delay. The cron keeps firing on schedule and every enqueue is a silent no-op.This also explains why it looked intermittent:
removeCohortComputeJob(remove()thenadd()) was the only escape, and becauseremoveJobsByMaxAgetrims the whole target zset by score, one manual Refresh evicted every other cohort's hour-old record too — so the next tick worked for everyone, they all completed, all wrote fresh records, and it deadlocked again.The fix
Deduplicate on the cohort rather than pinning a jobId:
With no
ttl, the deduplication key is released bymoveToFinishedon completion or failure —removeDeduplicationKeyIfNeededOnFinalizationis called above the completed/failed branching — so it collapses only a compute that is genuinely still in flight, and finished records stop gating anything.Verification
Ran against a real Redis on
bullmq@5.63.0, comparing both enqueue shapes over three ticks with no age-trim opportunity in between:Also in this PR
Both raised in the issue as worth doing while in here:
cohortRefreshCronJobnow callsenqueueCohortComputeinstead of duplicating its options. Fixing only the helper would have missed the cron, which is the producer that actually matters.removeCohortComputeJobis deleted, along with its two call sites incohort.ts(updateandrefresh). It only existed to work around the fixed jobId; with dedup a stale record no longer blocks anything, andremove()beforeadd()would destroy the failure record that dedup deliberately preserves. Behaviour is equivalent —remove()never removed active jobs anyway, and a waiting deduped job re-reads the cohort definition when it runs.removeOnComplete/removeOnFailgain acountbound on the queue defaults, sinceagealone only trims when another job finishes.console.logs in the cohort router that were sitting on the changed lines.removeOnComplete: truewould also have fixed the deadlock (it takes the self-deleting branch) but loses completed-job visibility, so it isn't used here.Checks
tsc --noEmitreports zero errors in every touched file (cohort.service.ts,queues.ts,routers/cohort.ts,cron.cohort-refresh.ts). Remaining repo-wide errors are pre-existing innotification.service.tsandinsights/store.ts.https://claude.ai/code/session_01ETRr6KYLYATzBVaLRZcwwk
Summary by CodeRabbit