A minimal, production-grade job queue system that runs entirely via the command line. Workers execute shell commands as background jobs with automatic retries, exponential backoff, a Dead Letter Queue (DLQ), and persistent SQLite storage.
Built for a backend internship assignment. Tech stack: Node.js, better-sqlite3, commander.
git clone <repo-url> queuectl
cd queuectl
npm installRun commands directly:
node bin/queuectl.js --helpOr link globally for queuectl on your PATH:
npm link
queuectl --helpnode bin/queuectl.js enqueue '{"id":"demo","command":"echo hello world"}'Output (to stderr):
Job demo enqueued (pending)
Start a single worker in the foreground (blocks until stopped):
node bin/queuectl.js worker startStart multiple concurrent poll loops in one process:
node bin/queuectl.js worker start --count 3From a separate terminal (no shared memory):
node bin/queuectl.js worker stopOutput:
Sent SIGTERM to worker 63175
node bin/queuectl.js statuspending 0
processing 0
completed 2
failed 0
dead 1
active workers 0
Human-readable table:
node bin/queuectl.js list --state completedj1 echo a completed 0 /3 2026-07-11T21:11:08
j2 echo b completed 0 /3 2026-07-11T21:11:08
JSON output (stdout-only, pipes cleanly through jq):
node bin/queuectl.js list --state pending --json | jq .[
{
"id": "j3",
"command": "sleep 10",
"state": "pending",
"attempts": 1,
"max_retries": 3,
"created_at": "2026-07-11T21:11:08.732Z"
}
]List dead jobs:
node bin/queuectl.js dlq listdlq-demo attempts=2/2 error="exit code 1" updated=2026-07-11T20:57:58.316Z
Retry a dead job (resets attempts to 0):
node bin/queuectl.js dlq retry dlq-demoJob dlq-demo retried (pending)
node bin/queuectl.js config set max-retries 5
node bin/queuectl.js config set backoff-base 2
node bin/queuectl.js config listmax_retries=5
backoff_base=2
stale_timeout_seconds=15
poll_interval_ms=2000
Config keys accept hyphens or underscores interchangeably (e.g. max-retries ↔ max_retries).
To distribute queuectl as a globally installable command-line tool via the npm registry:
Ensure your package.json specifies the binary name and path under the "bin" field, and includes only the necessary directory files:
{
"name": "queuectl", // Note: you might need to use a scoped name like "@username/queuectl" if the name is taken
"version": "1.0.0",
"bin": {
"queuectl": "bin/queuectl.js"
},
"files": [
"bin",
"src"
]
}- Register for an npm account on npmjs.com.
- Log in from your terminal:
npm login
- Publish your package:
# If using a standard name: npm publish # If using a scoped name (e.g., @username/queuectl): npm publish --access public
Once published, users can install it globally from anywhere:
npm install -g queuectl
queuectl --helpqueuectl/
├── bin/queuectl.js CLI entrypoint (commander)
├── src/
│ ├── db.js SQLite connection + schema
│ ├── config.js Key-value config backed by config table
│ ├── queue.js Queue operations (enqueue, DLQ, list, status)
│ ├── worker.js Worker poll loop, claim, exec, sweep
│ └── commands/ Reserved for future command modules
├── test/ Test suite (6 tests, run via npm test)
├── .queuectl/ Runtime data (auto-created)
│ ├── queuectl.db SQLite database
│ └── workers/ PID files for worker discovery
├── .gitignore
├── package.json
├── README.md
└── DECISIONS.md
┌──────────┐
│ pending │◄────────────────────────────┐
└────┬─────┘ │
│ claim (atomic UPDATE) │
▼ │
┌──────────┐ │
│processing│ │
└────┬─────┘ │
┌──┴──┐ │
▼ ▼ │
┌────────┐ ┌──────┐ │
│completed│ │failed│──retry (backoff)─────────┘
└────────┘ └──┬───┘ ◄── alive again
│ exhausted
▼
┌──────┐
│ dead │ (DLQ)
└──────┘
A crashed (SIGKILL'd) worker leaves a job stuck in processing. The stale-job sweep (sweepStaleJobs) resets it to pending after stale_timeout_seconds without incrementing attempts.
SQLite via better-sqlite3, WAL mode for concurrent reads. Two tables:
jobs—id,command,state,attempts,max_retries,next_attempt_at,claimed_at,worker_pid,last_error, timestampsconfig—key,value
The database file lives at .queuectl/queuectl.db and survives process restarts.
--count Nspawns N concurrent async poll loops within a single process (one signal handler, one PID registration)- Real cross-process safety (multiple
worker startterminals) is guaranteed by the atomic claim query, which SQLite serializes via file-level write locks - Workers must be stopped gracefully via
worker stop; SIGKILL is handled by crash recovery
On SIGTERM or SIGINT (Ctrl+C):
- Worker sets a
shuttingDownflag - Current in-flight
execchild continues to completion - Worker deregisters its PID file
- Poll loop detects flag and calls
process.exit(0)
No new job is claimed after the flag is set.
sweepStaleJobs() runs on every worker startup and every poll iteration. It finds jobs in processing where claimed_at is older than stale_timeout_seconds and resets them to pending. attempts is NOT incremented — a crashed worker is not a failed command.
SQLite provides atomic transactions, concurrent access (WAL mode), and built-in indexing — all essential for correct multi-worker behavior. JSON file storage would require implementing our own locking and atomic writes. The cost: a native binary dependency (better-sqlite3).
If two enqueues use the same id, the second is rejected with an error. Silently overwriting could destroy a pending job the user didn't intend to replace. Since job IDs are user-chosen, the user can pick a different ID. This is at-most-once semantics on create.
max_retries: captured per-job at enqueue time. A job created under the old config keeps its own limit. Changing the config doesn't retroactively affect already-enqueued jobs.backoff_base: read live from config at each retry. This is a global timing policy, not a per-job contract. Changing it affects the next scheduled retry of every in-flight job.
A crashed worker (SIGKILL) didn't run the command — we literally don't know if it would have succeeded or failed. Incrementing attempts would unfairly consume the retry budget. The job is re-queued as if never picked up.
A manual retry from the DLQ is an explicit human decision to "try this job from scratch." Leaving attempts at the exhausted value would immediately re-exhaust it on the next failure, making the retry pointless.
PID files (worker stop → kill(pid, SIGTERM)) have a fundamental limitation: if a worker crashes and its PID is reassigned by the OS to an unrelated process, worker stop would signal the wrong process. Acceptable for a local development tool. Production systems use socket-based process supervision (e.g. systemd).
npm testRuns 6 tests sequentially:
| Test | What it covers |
|---|---|
unit-queue.js |
Enqueue validation, claim atomicity, state transitions, DLQ retry |
e2e-basic.js |
A successful job completes with attempts=0 |
e2e-retry-dlq.js |
Failing job retries with backoff, reaches DLQ, dlq retry works |
e2e-concurrency.js |
3 real separate OS processes process 30 jobs exactly once |
e2e-crash-recovery.js |
kill -9 mid-job → new worker recovers within 30s |
e2e-restart.js |
Job data survives full process restart |
Scenario 1 — Basic job:
node bin/queuectl.js enqueue '{"id":"test1","command":"echo hello"}'
node bin/queuectl.js worker start
# Ctrl+C after job completes
node bin/queuectl.js list --state completedScenario 2 — Retry + DLQ:
node bin/queuectl.js enqueue '{"id":"test2","command":"exit 1","max_retries":2}'
node bin/queuectl.js worker start
# After ~10s Ctrl+C
node bin/queuectl.js dlq list
# Should show 2/2 attempts
node bin/queuectl.js dlq retry test2Scenario 3 — Parallel workers (separate terminals):
Terminal A: node bin/queuectl.js worker start
Terminal B: node bin/queuectl.js worker start
Terminal C: node bin/queuectl.js enqueue '{"id":"p1","command":"sleep 2"}'
node bin/queuectl.js enqueue '{"id":"p2","command":"sleep 2"}'
# Both process concurrently without overlap
Scenario 4 — Crash recovery:
# Terminal A
node bin/queuectl.js enqueue '{"id":"crash1","command":"sleep 20"}'
node bin/queuectl.js worker start
# Terminal B: kill -9 <PID of terminal A's worker>
# Terminal A again
node bin/queuectl.js worker start
# Job completes within ~22sScenario 5 — Persistence:
node bin/queuectl.js enqueue '{"id":"p1","command":"echo survived"}'
# Kill the entire process
node bin/queuectl.js worker start # Job is still there, gets processed2026-07-13.12-17-21.mp4
MIT