Skip to content

feat(workflow)!: replace groupmq with purpose-built queue module - #1

Merged
LouisHaftmann merged 13 commits into
mainfrom
feat/purpose-built-queue
Jul 29, 2026
Merged

feat(workflow)!: replace groupmq with purpose-built queue module#1
LouisHaftmann merged 13 commits into
mainfrom
feat/purpose-built-queue

Conversation

@LouisHaftmann

Copy link
Copy Markdown
Contributor

Summary

Replaces the third-party groupmq job queue (and its local Lua patch) with a purpose-built, Redis-backed job queue built as an internal module of @falcondev-oss/workflow (src/queue/), then refactors the library onto it. Deletes groupmq, p-mutex, p-retry, and patches/groupmq.patch.

Built ticket-by-ticket from the locked design spec (.scratch/queue-replacement/spec.md), one commit per slice, each landing green.

What the new module adds

  • Four-level nested concurrency — namespace → workflow → group → worker; a job starts only when under all four caps simultaneously. In-flight counts are Redis structure cardinality (never INCR/DECR), so a dead worker can't leak a slot.
  • Real numeric priority (higher-first, default 0), replacing the 'high'|'normal'orderMs fake.
  • Delayed jobs (runIn/runAt) promoted inside the reserve loop — no poller, Redis TIME as the one clock.
  • First-class scheduleId-keyed cron registry (Croner) with CAS-on-score exactly-once firing, idempotent upsert (kills the removeRepeatingJob patch), skip-missed / skip-if-running.
  • Job-level retries with slot-releasing backoff (expBackoff default), dead-letter + keepFailed retention.
  • Stalled recovery via deadline-compare + one throttled recoverStalled script, token-CAS heartbeat, abort-on-lost-claim.
  • Blocking wait() over pub/sub + TTL result record (throws on failure / TimeoutError / ResultExpiredError).
  • Pull-based metrics (active/waiting/delayed) + best-effort onError/onFailed callbacks.

Anti-drift discipline throughout: one shared Lua include (releaseActive / addWaiting / maintainGroup / finalizeFailed / enqueueNow) reused across reserve/complete/fail/recover/fire, so the groupmq drift-class bugs can't recur. Scripts registered via ioredis defineCommand (free NOSCRIPT/EVAL fallback).

Testing

  • One integration seam: the public Namespace → Queue → Worker API driven against a real testcontainers Redis; emergent invariants (no slot-leak/drift, wait() fan-out) reach under the API for raw key-state assertions.
  • 64 tests green (43 module contract + contention, 21 lib contract). New test npm script + a CI job running the full suite on every PR (none existed before).
  • Deterministic backbone + a small bounded contention set; no fake clock (Redis TIME authoritative, back-dated scores for time logic).

BREAKING CHANGE

  • Entry point is now new WorkflowNamespace({...}).createWorkflow({...}) — the public new Workflow(...) is gone.
  • Priority is a raw number (default 0); the 'high'|'normal' enum is removed.
  • Recurring jobs move from run({ repeat }) to workflow.upsertSchedule(...); removeRepeatingJob is gone.
  • WorkflowJob.wait() now throws on workflow failure / timeout / expired result (previously resolved undefined).
  • Step-level retry and keepCompleted are removed (steps are replay checkpoints; job-level retry replays steps).

🤖 Generated with Claude Code

@LouisHaftmann
LouisHaftmann force-pushed the feat/purpose-built-queue branch from 094477c to a5092c4 Compare July 26, 2026 07:47
…reserve

Introduce the purpose-built queue module's happy path end-to-end:
Namespace -> Queue -> Worker, string-only immediate `add`, the atomic
enqueue/reserve/releaseActive/complete Lua on the §3 key layout
(registered via ioredis `defineCommand`), a JS-local worker-cap semaphore
with drain-before-block `BRPOP` wake, blocking pub/sub `wait()`, the
three-level `close` cascade, and `JobAlreadyExistsError`.

Not wired into the workflow lib yet (ticket 11); concurrency caps,
delayed/priority surface, retries/fail, stalled recovery, cron, metrics,
and step data are later tickets. Gates are present-but-unlimited so those
tickets are additive.
Ticket 02 already wired all four caps through enqueue/reserve/releaseActive
(namespace SCARD top-gate, workflow ZCARD top-gate, group ready-set membership,
worker JS semaphore) with correct defaults and ready-set maintenance on both
reserve and release. This adds the ticket-03 acceptance suite proving that
wiring, driven through the public Namespace -> Queue -> Worker API:

- each cap enforced in isolation (worker / workflow / namespace / group)
- group cap 1 serializes; group cap > 1 parallelizes
- combined nested gating: a top-gate binds while group + worker have room
- a capped group does not stall a runnable sibling (no head-of-line block)
- contention invariant: K workers x M jobs across all four levels drain with
  every active structure empty and no observed cap ever exceeded

Tests are property-style, prefix-scoped, K*M small. Verified they go red for
the right reason by neutering the Redis cap gates (worker cap stays green as
it is JS-side). No source changes needed.
Ticket 02 already packed the score `(PMAX-priority)*2^32 + counter` with the
`pc` counter stamped at enqueue (= promotion time for immediate jobs) and
defaulted `priority` to 0. Ticket 04 adds the missing acceptance criterion:
validate `add({ priority })` is an integer in 0…2^21-1 (an out-of-range or
fractional value would corrupt the packed score / ZSET ordering), throwing a
RangeError. Proves the §6 semantics with tests: higher-runs-first across a
workflow's groups, default-0 after any expedited job, and FIFO-by-promotion
tiebreak among equals (single worker at concurrency 1 for deterministic order).
Per-step data addressed by (jobId, stepName): setStepData is a single
atomic HSET into the job's :steps hash, getStepData a single HGET
returning null on miss. Opaque strings; no serialization, no Lua, no
mutex. Parallel steps write distinct fields without lost updates.

The complete script already deletes the :steps hash in the same op that
finalizes the job (walking skeleton); tests now assert that seam. The
fail-path :steps delete lands with ticket 07.
Schedule a job for a delay (`runIn`) or an absolute time (`runAt`); a due
job becomes runnable exactly once with no poller and no idle Redis load.

- `enqueue` resolves the effective `runAt` against Redis TIME (`runIn` →
  `now + runIn`, `runAt` verbatim), parks `runAt > now` in the `delayed`
  ZSET and routes `runAt <= now` straight into waiting.
- Promotion is embedded at the top of `reserve` and reuses a shared
  `addWaiting` Lua helper (INCR pc + packed score + ready maintenance) also
  used by `enqueue`, so ready logic cannot drift. Due jobs are promoted in
  `runAt` order (FIFO-by-due), batch-capped per call.
- `reserve` now returns ms-to-next-due on its `empty` branch; the worker
  uses it as the BRPOP wake timeout (the block is the delayed-job timer)
  and re-reserves at once when a backlog remains past the promote cap.
…t-claim

Add a token-CAS `heartbeat` script that renews the lock PX and the `wf:active`
deadline score in lockstep, and one throttled `recoverStalled` script gated by
`SET wf:stalled-check NX PX interval`. Detection is a pure deadline-compare
(`ZRANGEBYSCORE wf:active 0 now` + `state == "active"` fence); recovery reuses the
shared `releaseActive` (group-unblock for free), `addWaiting` (requeue at the stored
packed score, front of band), and the `moveToFailed`/`finalizeFailed` dead-letter
path once `stalledCount` exceeds `maxStalledCount`. `reserve` now stores the popped
packed score on the job hash so recovery can requeue drift-free.

The worker runs the heartbeat on a derived `min(lockMs/3, 10s)` timer, triggers the
scan from that tick (busy workers) and the wake-loop re-poll (idle workers), and
aborts `ctx.signal` + drops the job when a renew returns 0 or errors past `lockMs`.
New Worker options `maxStalledCount` (1) and `stalledInterval` (30s).
Add a first-class scheduleId-keyed cron registry to the queue module.
`upsertSchedule`/`removeSchedule`/`getSchedules` write on the Queue,
storing a `schedules:due` ZSET (member scheduleId, score next-fire ms)
plus a `schedule:<id>` hash. Idempotent by construction: both are keyed
by scheduleId, so re-upserting replaces in place — never duplicates.

Firing is "JS computes next, Lua commits via CAS-on-score": a thin tick
folded into the Worker wake loop (no poller — the nearest due schedule is
min'd into the BRPOP timeout alongside delayed jobs) reads due schedules,
computes croner.nextRun(now), and calls a new `fireSchedule` script that
CAS's on `ZSCORE == expectedScore` before enqueuing the occurrence (via a
shared `enqueueNow` include factored out of `enqueue`, never a parallel
path) and re-arming atomically. CAS = exactly-once across N workers with
no distributed lock; JS-before-Lua = crash-safe. Missed runs collapse to
one fire (nextRun(now) jumps forward); skip-if-previous-still-running is
the default (groupId defaults to scheduleId).

Croner is the cron authority (DST-safe IANA tz); tz is captured from the
queuer's local zone at register time unless overridden.
Expose `Queue.getMetrics()` returning `{ active, waiting, delayed }` for the
lib's OTel `addBatchObservableCallback` to poll: `active`/`delayed` are O(1)
`ZCARD`s and `waiting` sums the per-group waiting ZSETs over the lightweight
`wf:<id>:groups` membership SET (O(groups), pull-time only). No OTel and no
counters inside the module.

Introduce a shared `maintainGroup` Lua helper that ties both the `ready`
ZSET-of-groups and the `groups` metrics SET to the single `ZCARD groupJobs`
read, and route the three ready-maintenance sites (`addWaiting`, `reserve`'s
pop, `releaseActive`) through it. The SET is a membership set (SADD while a
group has waiting work, SREM when it drains), never an INCR/DECR count, so it
cannot drift from the waiting jobs or over-count.

Add optional `onError` / `onFailed` Worker callbacks defaulting to
`Settings.logger`: `onFailed(job, error)` fires best-effort on every handler
failure (mirroring the old `worker.on('failed')`), `onError` on worker-internal
errors. No pub/sub, no `.on(...)` registry.
The library now runs entirely on the internal `src/queue/` module
(`Namespace → Queue → Worker`). groupmq, p-mutex, p-retry and the
groupmq Lua patch are removed. Step data is per-field superjson in the
`:steps` hash (no read-modify-write mutex); the backbone is string-only
so superjson lives 100% in the lib. wait() blocks on pub/sub and throws;
cancellation is via `ctx.signal`; cron is the schedule registry.

BREAKING CHANGE: the public API changed.
- Entry point is `new WorkflowNamespace({ id, concurrency?, redis?,
  prefix?, queueOptions?, workerOptions?, jobOptions? })` then
  `ns.createWorkflow({ id, schema?, run, getGroupId?, ...overrides })`.
  `new Workflow(...)` is no longer public.
- `priority` is now a raw `number` (higher runs first, default 0); the
  `'high' | 'normal'` enum and `orderMs` are gone.
- `run({ repeat })` is removed; use `workflow.upsertSchedule(id, {
  pattern, input, ... })` / `removeSchedule` / `getSchedules`.
- `run`/`runIn`/`runAt` are one-shot only.
- `WorkflowJob.wait()` now throws on workflow failure and throws
  `TimeoutError` / `ResultExpiredError`; it no longer resolves
  `undefined`. Return type is `Promise<Output>`.
- `step.do(..., { retry })` is removed; steps are pure replay
  checkpoints (job-level retry replays completed steps).
- Metrics are three gauges (active/waiting/delayed); worker events are
  `onError`/`onFailed` callbacks instead of `.on('failed'|'error')`.
Expand tests/workflow.test.ts to exhaustively cover the surviving workflow
contract and the intentionally-changed behaviors, driven end-to-end through
the public library API. Adds: job-retry step replay (only the failed step
re-runs), no step-level retry, durable-sleep resume + signal-aware
cancellation with token-safe commit, wait() TimeoutError/ResultExpiredError,
numeric-priority enum removal, upsertSchedule idempotency, non-POJO
(Date/Map/BigInt/Set) serialization round-trip, and retired-surface guards
(keepCompleted / step retry / repeat / removeRepeatingJob).
@LouisHaftmann
LouisHaftmann force-pushed the feat/purpose-built-queue branch from a5092c4 to bb0a306 Compare July 26, 2026 07:50
Comment thread src/queue/queue.ts
@LouisHaftmann
LouisHaftmann merged commit e083717 into main Jul 29, 2026
3 checks passed
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.

1 participant