A job that wasn't there
A worker sat idle while the dashboard insisted the queue had pending jobs, and nothing moved. The usual checks told me nothing, so I did the thing you eventually always do: opened redis-cli and started poking at the keys directly. Ten minutes later I understood the stall, and I also realized I had never really known what these libraries write to Redis. The dashboard is a rendering. Redis is the state.
That afternoon turned into a small read-only inspector I still keep around. Point it at a Redis instance and it reports what jobs exist and what state each is really in, without touching anything. Building it forced me to learn the on-disk shapes of the two queue libraries I lean on, Asynq in Go and BullMQ in Node. They solve the same problem and store it very differently.
Two shapes for the same idea
Both libraries follow one pattern. The payload and metadata for a job live in a Redis hash keyed by job id. That id then gets placed into a list or sorted set that stands for a state. A worker moves the id between those structures as the job progresses.
The differences show up when you're reading raw keys at 3am. Asynq stores the job as an opaque protobuf blob and writes the state explicitly. BullMQ stores a human-readable hash and makes you infer the state from where the id currently sits.
Asynq: a protobuf blob behind an id
Asynq's keys look like this:
asynq:{default}:pending list of task IDs
asynq:{default}:active list of task IDs
asynq:{default}:scheduled zset, score = run-at time
asynq:{default}:retry zset
asynq:{default}:archived zset
asynq:{default}:t:<taskID> hash, the actual task
The braces around {default} are not decoration. They're a Redis Cluster hash tag, so every key for one queue lands in the same slot and the Lua scripts can run atomically across them. Worth knowing before you rename a queue.
Notice the lists and zsets hold only the task id. Enqueue writes the data separately. The Lua does roughly:
HSET asynq:{default}:t:<id> msg <blob> state pending pending_since <ns>
LPUSH asynq:{default}:pending <id>
So the pending list is a list of ids, and t:<id> is a hash with three fields on enqueue: msg, state, and pending_since. The msg field is the interesting one. Asynq serializes it with Protocol Buffers via proto.Marshal, so HGET-ing it returns a binary blob rather than anything you can read directly. That blob is where the task actually lives.
To decode the blob by hand you don't even need the .proto file. Pull the raw bytes and run them through protoc:
redis-cli HGET 'asynq:{default}:t:<id>' msg > msg.bin
protoc --decode_raw < msg.bin
Non-interactive redis-cli writes the raw reply, which is what you want; the escaped interactive view is not. --decode_raw then prints field numbers and values with no schema. Map the numbers using the TaskMessage definition:
1 type 8 timeout
2 payload 9 deadline
3 id 10 unique_key
4 queue 11 last_failed_at
5 retry 12 retention
6 retried 13 completed_at
7 error_msg 14 group_key
15 headers (map)
Field 2, the payload, is itself opaque bytes: whatever your handler encoded, Asynq doesn't care. The numbering has a couple of traps. last_failed_at is field 11, nowhere near error_msg at 7, even though the two are related in spirit. And headers sits near the top of the .proto source but carries field number 15, because protobuf numbers are assigned for wire compatibility, not source order. Get one number wrong and --decode_raw quietly mislabels every field after it, so read them off the schema rather than guessing. The set of fields also grows across releases, so check it against the version you're actually running.
One nice property falls out of this: Asynq writes state into the hash and puts the id in the matching structure. Belt and suspenders. If the two disagree, something crashed mid-transition, and that disagreement is itself a signal.
BullMQ: a readable hash, a derived state
BullMQ is friendlier to eyeball. Keys under the default bull prefix:
bull:myqueue:wait list
bull:myqueue:active list
bull:myqueue:paused list
bull:myqueue:delayed zset
bull:myqueue:prioritized zset
bull:myqueue:completed zset
bull:myqueue:failed zset
bull:myqueue:<id> hash, the job
bull:myqueue:meta :id :events :marker
HGETALL on a job hash returns something you can read directly. The fields include name, data, opts, timestamp, delay, priority, attemptsMade, processedOn, finishedOn, returnvalue, failedReason, stacktrace, and progress. data and opts are JSON.stringify strings. The return-value field is spelled returnvalue, one word, and it's JSON too. No protobuf, no decode step.
The catch: there is no state field. A BullMQ job's state is derived from which structure holds its id right now. The inspector has to reproduce that logic: is the id in completed, then failed, delayed, active, wait, prioritized, paused? The exact order is defined by the library's own Lua, and the inspector has to match it rather than invent one. Get the membership check wrong and you'll confidently report the wrong state. If the id is in none of them, the job is effectively unknown or already evicted.
The delayed zset has a detail worth knowing. The score isn't the plain timestamp. BullMQ packs it as delayedTimestamp * 4096 + (counter & 4095): the run-at time in milliseconds shifted up 12 bits, plus a 12-bit insertion counter so jobs due in the same millisecond keep their order. To recover the real run time, ZSCORE the id and take floor(score / 4096). The tradeoff is that past 4095 jobs at the identical millisecond, ordering stops being guaranteed. In practice that rarely bites, but it's the kind of thing to know before you assume strict FIFO.
Last, the marker. It's a zset that workers block on with BZPOPMIN so they don't busy-loop polling the wait list. It replaced an older trick of pushing a fake job id of 0 as a wakeup. If you see a lone entry in marker, that's a signal to workers, not a job. Don't count it as one.
Reading Redis without breaking it
A read-only inspector has one rule: never run a command that mutates or consumes. That means SCAN, never KEYS, which blocks the server on a large keyspace, and HGETALL / ZSCORE / LRANGE / LPOS for lookups. The commands that actually move jobs, RPOPLPUSH and BZPOPMIN, are off limits, because running them turns your debugger into a worker that eats a job. I also cap how much of data and opts I print, since payloads get large.
The honest limits. This is a forensic tool, not an ops dashboard. It's good for "where did this job actually go", for confirming retries and backoff behave, and for spotting an active list full of ids whose workers died. I would not wire it into alerting, and I would never hand-write these keys on a live queue. The schema drifts too: field numbers and the key set change across releases. When I moved a queue's library forward, the prioritized zset and the marker key were both new since the code I first wrote against, so I re-check the shapes on every upgrade.
None of this is exotic once you've seen it. A queue is a hash for the payload plus a handful of lists and zsets that answer "what state is this in". Learn to read those directly and the dashboard stops being the only thing you can trust.

Top comments (0)