Skip to content

fix(queue): make queue:work actually process jobs, and say what it did - #8

Merged
3m1n3nc3 merged 2 commits into
mainfrom
fix/queue/worker-job-resolution
Sep 2, 2026
Merged

fix(queue): make queue:work actually process jobs, and say what it did#8
3m1n3nc3 merged 2 commits into
mainfrom
fix/queue/worker-job-resolution

Conversation

@3m1n3nc3

@3m1n3nc3 3m1n3nc3 commented Sep 2, 2026

Copy link
Copy Markdown
Member

Why

A dedicated queue:work process could not run most of an application's jobs, and gave no sign of it. Found while investigating an app whose Redis queues had grown to 950 jobs, every one of them still at attempts: 0.

Four defects compounded into "the queue is never processed":

  1. A worker can't reconstruct jobs. JobRegistry is populated in the Job constructor, and a worker process constructs none of the app's jobs — so its registry is empty and every payload it pops throws Job "X" is not registered. Worse, handleFailure resolves the payload again to reach the failed hook, which throws too, so the job is deleted after its tries with nothing recorded.
  2. Failures were silent. Worker.process swallowed the error, the daemon printed nothing per job, and --once reported Processed one job. even when the job failed.
  3. --stop-when-empty and --max-jobs did nothing. Musket hands over commander's parsed options, which are camelCased, so this.option('stop-when-empty') always read undefined. A drained queue kept the daemon spinning.
  4. The banner was misleading. It printed the literal word default regardless of the resolved connection, and never named the queue.

What changed

  • @arkstack/jobs gains loadJobs(), which imports the job modules under src/app/jobs (or the build output) and registers every concrete class. queue:work calls it before working. Detection tests for handle on the prototype rather than instanceof Job — a module loaded through jiti carries its own copy of the base class — and abstract bases are skipped.
  • JobRegistry is backed by a global symbol, so a job module evaluated more than once registers into the one map the worker resolves against.
  • Worker gains on() handlers (onProcessing/onProcessed/onFailed, the last saying whether the job is being retried or has failed for good); queue:work prints each outcome.
  • Both option spellings are accepted for multi-word flags; the banner names the resolved connection and the queue; bootArkorm() now covers the --once path too.
  • QueueContract.getDefaultQueue() exposes a connection's configured queue.

Verification

Staged the built packages into the affected app and ran against an isolated Redis db:

Loaded 14 job class(es).
ℹ Processing jobs from the [redis] connection on the [probe] queue.
✖  Failed permanently  GhostJob — Job "GhostJob" is not registered.
EXIT=0        ← --stop-when-empty now stops

loadJobs() against that app's build output discovers all 14 concrete job classes and skips its abstract AppJob base; previously only the 2 that its bootstrap happened to import were resolvable.

40 tests pass across queue, jobs and scheduler, including new coverage for loadJobs (discovery, abstract/non-job/unloadable modules, missing directory) and for worker outcome reporting (retry → retry → permanent). Lint clean. The one tsc error in packages/jobs/src/Job.ts:51 is pre-existing, confirmed with these changes stashed.

A job class only reaches the JobRegistry when it is constructed, and a
dedicated `queue:work` process constructs none of the application's jobs.
Its registry is therefore empty, every payload it pops throws "Job X is
not registered", and — because the failure path resolves the payload again
to reach the `failed` hook — the job is deleted after exhausting its tries
with nothing recorded anywhere.

Add `loadJobs()`, which imports the job modules under `src/app/jobs` (or
the build output) and registers every concrete class it finds. Detection
tests for `handle` on the prototype rather than `instanceof Job`: a module
loaded through jiti carries its own copy of the base class, so an identity
check would reject every job it found. An abstract base declares no
`handle` of its own and is skipped.

Back the registry with a global symbol for the same reason, so a job
module evaluated more than once registers into the one map the worker
resolves against.
A worker swallowed every job failure: nothing was logged, `--once`
reported "Processed one job." whether or not the job succeeded, and the
daemon printed nothing at all. A queue whose jobs all fail was therefore
indistinguishable from an idle one.

- Worker gains `on()` handlers (onProcessing/onProcessed/onFailed), and
  `queue:work` prints each outcome — including whether a failed job is
  going back for another attempt or has failed permanently.
- `--stop-when-empty` and `--max-jobs` never took effect: musket hands
  over commander's parsed options, which are camelCased, so the
  kebab-case keys always read as undefined. Both spellings are accepted
  now.
- The banner claimed `[default] connection` regardless of which
  connection was resolved, and never named the queue being worked.
- Load the application's job classes before working, so a payload can be
  turned back into a job.
- Boot the ORM for `--once` too, not only for the daemon.
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

@3m1n3nc3
3m1n3nc3 merged commit c4e2560 into main Sep 2, 2026
4 checks passed
@3m1n3nc3
3m1n3nc3 deleted the fix/queue/worker-job-resolution branch September 2, 2026 18:06
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