@nolag/queue

Real-time job queues with lifecycle tracking, progress updates, and worker management.

Overview

Distribute work across real-time workers with full lifecycle visibility. Jobs follow a state machine: pending, claimed, active, then completed or failed. Every state transition is broadcast to queue participants. A failed job goes back to pending until maxAttempts is exhausted. Three roles participate in a queue: producers add jobs, workers claim and process them, and monitors observe queue state. A wrapper plays one role; run one wrapper per role. Your app owns one core NoLag client and injects it into NoLagQueue; the wrapper attaches its behaviour to that connection.

Key Features

  • State machine lifecycle: pending, claimed, active, completed, failed
  • Load-balanced delivery: each new job reaches exactly one worker in the group
  • Priority label on every job and automatic re-queue on failure
  • Real-time progress reporting (0-100) broadcast to all participants
  • Ephemeral progress channel keeps durable storage lean
  • Worker online/offline presence via lobby

How It Works

NoLagQueue attaches to an injected @nolag/js-sdk client and maintains a lobby for worker presence. Calling joinQueue(name) returns a QueueRoom that subscribes to two topics: 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), so the broker hands each job event to exactly one worker in the group. 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 claim. claimJob() is a local state transition plus a broadcast of jobClaimed: the worker that received jobAdded is the only worker in its group that holds the job, and 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 it moves the job back to pending and emits jobRetrying in the same call, with no delay or backoff. Nothing re-dispatches the job for you; the worker that failed it claims it again from its jobRetrying handler. priority is a label stored on the job and carried in every event; nothing reorders delivery by it. The app owns the socket lifecycle; the wrapper never opens or closes it.

TopicPurpose
jobsJob lifecycle events: added, claimed, completed, failed, retrying
_progressIn-flight progress updates (0-100), not persisted

Because workers subscribe with loadBalance: true, a worker group whose actors hold a persistent session (agent or orchestrator) is the one case where the hosted broker replays what was dispatched while the group was away; see Replay for the exact conditions. Replay progress is reported by the core client (client.on('replay:start'), client.on('replay:end')), not by the wrapper. Producers and monitors never trigger replay.

Before you start. Create an app from the nolag-queue-sdk blueprint. That seeds the rooms this SDK expects (image-processing, email-dispatch) and the online lobby, and returns an app slug with a random suffix. That slug is the appName you pass to the wrapper. You can also do this in the portal: Apps, New App, pick the blueprint.

import { NoLagApi } from "@nolag/js-sdk";

const api = new NoLagApi(process.env.NOLAG_API_KEY); // nlg_live_...
const app = await api.apps.create({ name: "My App", blueprintId: "nolag-queue-sdk" });
console.log(app.slug); // e.g. "my-app-a3f9": this is your appName

const actor = await api.actors.create({ name: "web-client", actorType: "user" });
console.log(actor.accessToken); // shown once; keep it

Installation

npm install @nolag/queue @nolag/js-sdk

Shared connection. One core NoLag client can back several wrapper SDKs at once, for example a job queue, a dashboard, and notify on a single socket, 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().

Quick Start

Each role is its own wrapper, typically its own process. All three join the seeded image-processing queue.

import { NoLag } from '@nolag/js-sdk'
import { NoLagQueue } from '@nolag/queue'

const client = NoLag(PRODUCER_TOKEN)
const producer = new NoLagQueue({
  client,
  appName: APP_SLUG, // the slug returned when you created the app
  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)
})

producer.detach()
client.disconnect()

API Reference

NoLagQueue

Constructor Options

OptionTypeDescription
clientNoLagSocketRequired. The injected core NoLag client the app owns and connects.
appNamestringThe app slug used as the topic prefix. Pass the suffixed slug returned when you created the app; the default 'queue' will not match a hosted app.
workerIdstringStable worker ID for this client (auto-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.
concurrencynumberAdvertised in worker presence as QueueWorker.concurrency (default 1). The wrapper does not limit claims by it.
metadataRecord<string, unknown>Optional custom data attached to worker presence.
queuesstring[]Queues to join once the wrapper is ready.
loadBalanceGroupstringSubscribe-level load-balance group for workers (default 'queue-workers-<queueName>'). Set a custom group to partition workers by region or capability.
maxJobCachenumberMax jobs cached in memory per queue (default 1000).
debugbooleanEnable wrapper debug logging (default false).
MethodReturnsDescription
ready()Promise<void>Resolves once wrapper setup completed. Join methods throw before this resolves.
detach()voidRelease this wrapper's handlers and topics; terminal, never closes the socket.
joinQueue(name, opts?)QueueRoomSubscribe to a named queue and return it. Synchronous; returns the existing instance if already joined. opts.filters declares the job filter values this participant wants (for a worker, its capabilities).
leaveQueue(name)voidUnsubscribe from a queue and release its resources.
getQueues()QueueRoom[]All currently joined queues.
getOnlineWorkers()QueueWorker[]Workers currently present in the online lobby.

NoLagQueue Events

EventPayloadDescription
connectednoneWrapper setup completed on a live connection.
disconnectedreason: stringConnection closed.
reconnectingnoneThe core client is attempting to reconnect.
reconnectednoneConnection restored; queue membership and presence are restored automatically.
errorerror: ErrorA transport or protocol error occurred.
workerOnlineworker: QueueWorkerA worker joined the lobby.
workerOfflineworker: QueueWorkerA worker left the lobby.

QueueRoom

MethodRoleReturnsDescription
addJob(opts)ProducerJobEnqueue a job. opts is { type, payload?, priority?, maxAttempts?, filter?, filters? }; type is required, the id is generated.
claimJob(jobId)WorkerJob | nullMove a pending job to claimed and broadcast it. null if the job is unknown locally or not pending.
reportProgress(jobId, progress)WorkervoidBroadcast a progress value (0-100) on _progress.
completeJob(jobId, result?)WorkerJob | nullMark the job completed with an optional result. null if the job is unknown locally or not in claimed or active.
failJob(jobId, error?)WorkerJob | nullMark the job failed and increment attempts. While attempts < maxAttempts it returns to pending and jobRetrying fires immediately.
getJob(id)AnyJob | undefinedRead a job from the local cache.
getJobs(filter?)AnyJob[]Read cached jobs, optionally filtered by { status?, type?, priority? }. Does not change what the server sends.
pendingCountAnynumberGetter: cached jobs in the pending state.
activeCountAnynumberGetter: cached jobs in the active state.
getWorkers()AnyQueueWorker[]Workers present in this queue.
setFilters(values) / addFilters(values) / removeFilters(values)AnyvoidChange which job filter values this participant receives. Filters compose with load balancing: one matching worker gets each job.

QueueRoom 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 emitted only on clients that already hold the job in their cache.

EventPayloadDescription
jobAddedJobA new job was added. Delivered to one worker per group, and to every producer and monitor.
jobClaimedJobA worker claimed a job; claimedBy names it.
jobProgressJobProgressA worker reported progress: { jobId, progress, workerId, timestamp }.
jobCompletedJobA job completed; result carries the payload.
jobFailedJobA job failed; error, attempts and maxAttempts describe the state.
jobRetryingJobA failed job was returned to pending for another attempt.
workerJoinedworker: QueueWorkerA worker joined this queue.
workerLeftworker: QueueWorkerA worker left this queue.

Types

TypeShape
Job{ id, type, payload?, priority, status, progress, result?, error?, attempts, maxAttempts, claimedBy?, createdBy, createdAt, updatedAt, completedAt?, filter?, isReplay }
JobStatus'pending' | 'claimed' | 'active' | 'completed' | 'failed'
JobPriority'low' | 'normal' | 'high' | 'critical' (default 'normal')
QueueWorker{ workerId, actorTokenId, role, activeJobs, concurrency, metadata?, joinedAt, isLocal }