@nolag/queue
Realtime job queues with lifecycle events, progress updates and load-balanced workers.
Overview
@nolag/queue distributes work across connected workers and keeps every participant informed about each job. Jobs move through a small state machine: pending, claimed, active, then completed or failed. A failed job goes back to pending until it runs out of attempts. Three roles take part in a queue: producers add jobs, workers claim and process them, and monitors watch. A wrapper instance plays one role, so run one per role. Your app owns one core NoLag client and injects it into NoLagQueue; the wrapper attaches its behaviour to that connection.
Key Features
- Job lifecycle broadcast to every participant: added, claimed, completed, failed, retrying
- Load-balanced delivery: each new job reaches one worker in the group
- Priority label on every job and immediate re-queue on failure
- Progress reporting (0 to 100) on a separate topic
- Capability routing with filters: a job tagged
gpuonly reaches workers that filter ongpu - Worker presence per queue, plus an app-wide
onlinelobby
How It Works
NoLagQueue attaches to an injected @nolag/js-sdk client. Calling joinQueue(name) returns a QueueRoom for the room name and subscribes to two topics in it: jobs for lifecycle events and _progress for progress updates.
Workers subscribe to jobs with loadBalance: true in the group queue-workers-<queue> (or the loadBalanceGroup you set). kraken delivers each message on a load-balanced subscription to exactly one member of the group: on the built-in syn broker that member is picked at random, and with BROKER_BACKEND=mqtt your MQTT broker's shared-subscription strategy picks it. kraken scopes the group name by project and app, so two apps that both use queue-workers-image-processing never share workers. Producers and monitors subscribe without load balancing and see every event. _progress is never load balanced.
Exclusivity comes from that delivery, not from a server-side lock. claimJob() is a local state change plus a jobClaimed broadcast: the worker that received jobAdded is the only worker in its group that knows about the job. claimJob() returns null only when the job is unknown locally or is not pending. A retry is immediate: failJob() records the attempt and, while attempts < maxAttempts, moves the job back to pending and emits jobRetrying on the same worker in the same call. Nothing re-dispatches the job for you; the worker that failed it claims it again from its jobRetrying handler. priority is a label carried on the job; nothing reorders delivery by it.
| Topic | Purpose |
|---|---|
jobs | Job lifecycle events: added, claimed, completed, failed, retrying |
_progress | In-flight progress updates (0 to 100) |
What the broker does not do for you. kraken holds no job store and no offline queue on its default broker. A jobAdded published while no worker in the group is connected reaches no worker at all, and a job claimed by a worker that then disconnects stays claimed in everyone else's cache; nothing hands it to another worker. Keep the source of truth for jobs in your own database if a job must not be lost. With BROKER_BACKEND=mqtt, only actors whose auth marks the session persistent get messages queued by the MQTT broker, and the static auth.json backend never does. See Quality of Service.
Every lifecycle event is published with echo: true, so a participant subscribed to jobs can receive its own event back. Producers drop their own jobAdded (the cache de-duplicates it), but a worker can see jobClaimed or jobCompleted twice for the same job when kraken happens to hand its own broadcast back to it. Keep handlers idempotent.
Topics to grant
| What | Value |
|---|---|
| App Slug | Whatever you pass as appName (default queue) |
| Rooms | One per queue name you pass to joinQueue(). The blueprint defines image-processing and email-dispatch |
| Topics in each room | jobs, _progress |
| Lobby | online |
| Permission | pubSub for producers and workers, which publish lifecycle events and progress. subscribe is enough for a monitor (presence needs no publish permission) |
Grant these either in kraken's static token file or in a @nolag/core project document. The excerpts below show one worker token and one app; see Static auth and Project document for the full formats.
{
"tokens": {
"worker-token-1": {
"actorTokenId": "worker-1",
"projectId": "demo-project",
"organizationId": "demo-org",
"actorType": "service",
"allowedTopics": [
{
"pattern": "queue/image-processing/#",
"permission": "pubSub",
"app_id": "queue-app",
"room_id": "room-image-processing",
"room_slug": "image-processing"
},
{
"pattern": "queue/email-dispatch/#",
"permission": "pubSub",
"app_id": "queue-app",
"room_id": "room-email-dispatch",
"room_slug": "email-dispatch"
}
],
"allowedLobbies": [{ "lobby_slug": "online", "lobby_id": "queue-online" }]
}
}
}Things that matter with the static file:
- Every token that takes part in the same queue needs the same
app_idandprojectId. kraken routes wildcard-matched patterns to<app_id>/<pattern>and scopes load-balancing groups by project and app, so a different value puts a token on a different queue. - Presence, and therefore
workerJoinedandworkerLeft, only works in rooms that have a rule carrying bothroom_slugandroom_id. A catch-all rule such asqueue/+/#lets a token use any queue name, but presence stays silent in rooms without their own rule. - Without the
allowedLobbiesentry the lobby subscribe is refused: the wrapper emits anerror(not_authorized) andready()only resolves after the core SDK's 10 second lobby timeout.
With @nolag/core, rooms are records: a queue name that is not a room in the app is refused with unknown_topic (42940) on the error event. Add the room to the project document (or create it in core) before a client joins it.
Installation
npm install @nolag/queue @nolag/js-sdkShared connection. One core NoLag client can back several wrapper SDKs at once, as long as each wrapper uses a distinct appName. Each wrapper attaches its handlers on construction and releases them with detach(), and never touches the socket itself. Your app owns connect() and disconnect(). One connection holds presence in one room at a time, so a wrapper (or a set of wrappers) that joins several rooms on one client is only listed as present in the room it joined last.
Quick Start
Each role is its own wrapper, typically its own process. All three join the image-processing queue. The tokens are the ones from your auth.json or the credentials @nolag/core minted on import.
import { NoLag } from '@nolag/js-sdk'
import { NoLagQueue } from '@nolag/queue'
const client = NoLag('producer-token', { url: 'ws://localhost:8080/ws' })
const producer = new NoLagQueue({
client,
appName: 'queue', // the App Slug you granted
role: 'producer',
})
await client.connect() // the app owns the connection
await producer.ready() // wrapper setup complete
const queue = producer.joinQueue('image-processing')
// Enqueue a job. `type` is required; the id is generated for you.
const job = queue.addJob({
type: 'resize',
payload: { imageId: 'img_xyz', width: 1280 },
priority: 'high', // 'low' | 'normal' | 'high' | 'critical'; a label, not an ordering
maxAttempts: 3, // default 3
})
console.log('Added job:', job.id, 'status:', job.status) // 'pending'
// Producers receive every lifecycle event
queue.on('jobCompleted', (done) => console.log(`Job ${done.id} done:`, done.result))
queue.on('jobFailed', (failed) => {
console.warn(`Job ${failed.id} failed (attempt ${failed.attempts}/${failed.maxAttempts}):`, failed.error)
})
// Later: release the wrapper, then close the socket
producer.detach()
client.disconnect()Routing jobs by capability
Tag a job with a filter value and let workers declare what they can run. Load balancing still applies among the matching workers, so one of them gets the job.
// Producer: only GPU workers should see this job
queue.addJob({ type: 'render', payload: { sceneId: 's-42' }, filter: 'gpu' })
// Worker: receive only jobs tagged gpu
const gpuQueue = worker.joinQueue('image-processing', { filters: ['gpu'] })A worker that joins without filters receives every job, including filtered ones. Filters are routing, not access control; see Filters.
Reconnects
When the core client reconnects, the wrapper re-subscribes the online lobby, re-sets its presence in every joined queue and then emits reconnected. It does not re-subscribe the jobs and _progress topics: it was written for a broker that restores subscriptions on its own. Do not rely on that with kraken (see Production); re-issue the subscriptions yourself, with the same options the wrapper used:
worker.on('reconnected', () => {
for (const room of worker.getQueues()) {
const ctx = client.setApp('queue').setRoom(room.name)
const filters = room.filters.length > 0 ? { filters: room.filters } : {}
// Workers: load balanced, in the same group the wrapper uses
// (the default below, or the loadBalanceGroup you configured)
ctx.subscribe('jobs', { loadBalance: true, loadBalanceGroup: `queue-workers-${room.name}`, ...filters })
ctx.subscribe('_progress', filters)
}
})The wrapper's message handlers stay registered on the client, so subscribing again is enough. Subscribing to a topic that is already subscribed just replaces the subscription.
The online lobby. workerOnline, workerOffline and getOnlineWorkers() depend on the app's online lobby. On kraken v0.9.0 a lobby covers the rooms in the subscribing token's own grants, and that room list is cached on each kraken node for 5 minutes after the last lobby subscribe there; after that, lobby events stop until something subscribes to the lobby again. The per-queue events (workerJoined, workerLeft) come from room presence and are not affected. See Lobbies.
API Reference
NoLagQueue
Constructor Options
| Option | Type | Description |
|---|---|---|
client | NoLagSocket | Required. The injected core NoLag client the app owns and connects. |
appName | string | The App Slug used as the first segment of every topic (default 'queue'). |
workerId | string | Stable worker ID for this client (generated if omitted). Recorded as createdBy and claimedBy on jobs. |
role | 'producer' | 'worker' | 'monitor' | Role this wrapper plays (default 'monitor'). Only workers subscribe with load balancing. |
concurrency | number | Advertised in worker presence as QueueWorker.concurrency (default 1). The wrapper does not limit claims by it. |
metadata | Record<string, unknown> | Custom data attached to worker presence. |
queues | string[] | Queues to subscribe to once the wrapper is ready. These are subscribed without setting presence, and a later joinQueue() for the same name does not add it. |
loadBalanceGroup | string | Load-balance group for workers (default 'queue-workers-<queueName>'). Set your own to partition workers. |
maxJobCache | number | Max jobs cached in memory per queue (default 1000). |
debug | boolean | Enable wrapper debug logging (default false). |
| Method / Property | Returns | Description |
|---|---|---|
ready() | Promise<void> | Resolves once wrapper setup completed. joinQueue() throws before this resolves. |
detach() | void | Release this wrapper's handlers and topics; terminal, never closes the socket. |
joinQueue(name, opts?) | QueueRoom | Subscribe to a queue and return it. Returns the existing instance if already joined (re-pointing its filters when opts.filters is given). opts.filters declares the job filter values this participant wants. |
leaveQueue(name) | void | Unsubscribe from a queue and release its resources. |
getQueues() | QueueRoom[] | All currently joined queues. |
getOnlineWorkers() | QueueWorker[] | Workers currently seen as online. |
localWorker | QueueWorker | null | This client's worker record, available once ready. |
connected | boolean | Whether the injected client is connected. |
Events
| Event | Payload | Description |
|---|---|---|
connected | none | Wrapper setup completed on a live connection. |
disconnected | reason: string | Connection closed. |
reconnecting | none | The core client is attempting to reconnect. |
reconnected | none | Setup ran again after a reconnect: lobby and presence re-applied (topics are not; see Reconnects). |
error | error: Error | A transport or broker error, for example a refused subscribe. |
workerOnline | worker: QueueWorker | A worker came online. |
workerOffline | worker: QueueWorker | A worker left the lobby. |
QueueRoom
| Method / Property | Role | Returns | Description |
|---|---|---|---|
addJob(opts) | Producer | Job | Enqueue a job. opts is { type, payload?, priority?, maxAttempts?, filter?, filters? }; type is required, the id is generated. |
claimJob(jobId) | Worker | Job | null | Move a pending job to claimed and broadcast it. null if the job is unknown locally or not pending. |
reportProgress(jobId, progress) | Worker | void | Broadcast a progress value (0 to 100) on _progress. |
completeJob(jobId, result?) | Worker | Job | null | Mark the job completed with an optional result. null if the job is unknown locally or not claimed or active. |
failJob(jobId, error?) | Worker | Job | null | Mark the job failed and increment attempts. While attempts < maxAttempts it returns to pending and jobRetrying fires immediately. |
getJob(id) | Any | Job | undefined | Read a job from the local cache. |
getJobs(filter?) | Any | Job[] | Read cached jobs, optionally filtered by { status?, type?, priority? }. Does not change what the broker sends. |
pendingCount / activeCount | Any | number | Cached jobs in the pending / active state. |
getWorkers() | Any | QueueWorker[] | Workers present in this queue. |
filters | Any | FilterValue[] | The filter values applied to this queue's subscriptions. |
setFilters(values) / addFilters(values) / removeFilters(values) | Any | void | Change which job filter values this participant receives, on both jobs and _progress. An empty set restores the unfiltered subscription. |
Events
All lifecycle events carry the full Job. Producers and monitors receive every jobAdded; within a worker group only one worker does. The later events are only emitted on clients that already hold the job in their cache.
| Event | Payload | Description |
|---|---|---|
jobAdded | Job | A new job was added. |
jobClaimed | Job | A worker claimed a job; claimedBy names it. |
jobProgress | JobProgress | Progress was reported: { jobId, progress, workerId, timestamp }. |
jobCompleted | Job | A job completed; result carries the payload. |
jobFailed | Job | A job failed; error, attempts and maxAttempts describe it. |
jobRetrying | Job | A failed job was returned to pending for another attempt. |
workerJoined | QueueWorker | A participant set presence in this queue. |
workerLeft | QueueWorker | A participant's presence left this queue. |
Types
| Type | Main fields |
|---|---|
Job | id, type, payload?, priority, status, progress, result?, error?, attempts, maxAttempts, claimedBy?, createdBy, createdAt, updatedAt, completedAt?, filter? |
JobStatus | 'pending' | 'claimed' | 'active' | 'completed' | 'failed' |
JobPriority | 'low' | 'normal' | 'high' | 'critical' (default 'normal') |
QueueWorker | workerId, actorTokenId, role, activeJobs, concurrency, metadata?, joinedAt, isLocal |