navree
Documentation

Batches and operations

Job batches with then, catch and finally callbacks, progress reports from jobs, and operations: long work your users follow through an SSE stream or a private channel, cancel, and a watchdog brings back when it stalls.

On this page

Introduction

Featurequeue Some work is too big for one job and too slow for a request: importing 50,000 contacts, exporting a year of deals, re-sending a campaign. Two tools in QueueModule handle it:

  • A batch is a group of queued jobs that run as one unit of work. It counts how many have finished and failed, stops the rest when one fails, and runs a then, catch or finally job when it is done.
  • An operation is what your users see of that work: a row with an owner, a status, progress, a result or an error. Its job (or batch) moves it along, the user follows it with GET /api/operations/{id}, an SSE stream or a private channel, and can cancel it. A watchdog finishes or restarts operations whose worker died.

Both live in the database, next to the jobs: the queue migration set creates job_batches and operations. Operations always need DatabaseModule, even with the memory queue driver. Batches need the database or memory driver; operations also run on the Redis driver.

Batches

Dispatching a batch

Build a Batch with a name, add its jobs and callbacks, and dispatch it:

src/modules/contacts/services/import.rs
  • add takes any iterator of one job type; push adds a single job. Call them as often as you like to mix job types.
  • dispatch_in(&queue, &uow) stores the batch and its jobs in the unit of work: nothing is queued if it rolls back, and workers see the jobs after the commit. dispatch(&queue) joins the current unit of work the same way a plain queue.dispatch does, and stores at once outside one. The same methods exist on the queue: queue.dispatch_batch(batch) and queue.dispatch_batch_in(&uow, batch).
  • Every job type in the batch, callbacks included, must be registered with m.job::<J>(). If one isn't, dispatching fails with QueueError::UnknownJob and nothing is queued.
  • Jobs in a batch are never unique: unique_for_secs is ignored, so every job counts.

The jobs are ordinary jobs. Each one runs on its own queue with its own options, and any number of workers run them side by side.

Callbacks

A batch finishes when each of its jobs has succeeded, failed (used its attempts) or been skipped. Its callbacks are jobs too, queued when they become due:

CallbackQueued when
thenthe batch finishes with no failed job and without being cancelled
catchthe first job fails (at once, while the others may still run)
finallythe batch finishes, whatever happened

A batch with no jobs finishes as it is dispatched: its then and finally are queued with it. Give a callback the ids it needs (import_id above) and look the rest up when it runs.

Each job goes to the queue its type names (JobOptions::queue). .on_queue("imports") puts every job of the batch, and its callbacks, on one queue instead, the jobs added before the call and after it, so a large batch can run on workers of its own.

Failures and cancellation

By default the first failed job cancels the batch: the jobs that haven't started yet are skipped instead of run. Jobs that are already running keep going until they next report progress (see stopping a cancelled job). finally still runs; then doesn't.

When the jobs don't depend on each other, keep going after a failure:

Rust

With allow_failures, every job runs; catch still runs on the first failure, and then still only when no job failed.

Cancel a batch yourself with its id:

Rust

A finished batch stays as it is. A skipped or cancelled job counts as finished, not as failed, and doesn't move to the failed jobs. WorkStats counts these jobs in cancelled, and their log line has outcome = "cancelled".

Following a batch

queue.find_batch(&id) returns the batch as it is now, as a JobBatch:

Field or methodMeaning
id, nameThe batch id (a UUID v7 string) and the name given to Batch::new.
total_jobs, pending_jobs, failed_jobsIts jobs: in all, not finished yet, failed.
failed_job_idsThe failed jobs' ids, as queue:failed lists them.
processed_jobs()Jobs that finished: succeeded, failed or skipped.
progress()processed_jobs() as a whole percentage (100 for an empty batch).
finished(), cancelled(), has_failures()Whether every job finished, whether it was cancelled, whether a job failed.
allow_failures, operation_idIts settings.
created_at, cancelled_at, finished_atWhen it was dispatched, cancelled and finished.

queue.batches(20) lists the most recent batches, newest first, and queue:batches prints them:

Shell
Text

When the last job and every callback of a batch have finished, a batch finished line is logged at target navree::job with batch_id, name, total_jobs, failed_jobs and cancelled.

Reporting progress from a job

A job reports how far it got with ctx.progress(done, total, note):

src/modules/contacts/jobs/import_chunk.rs
  • When the job is an operation's work, the report becomes the operation's progress, and the user sees it. In a batch, the operation counts the batch's finished jobs, and the job's report only updates its note.
  • Reports are written at most once every queue.operations.progress_interval_ms (500 ms) per job, so calling it for every row is fine. A report with done == total is always written.
  • Failing to record progress is never an error: it is logged, and the job carries on. In an app without QueueModule the call does nothing.

Stopping a cancelled job

progress returns Err(JobCancelled) once the job's batch or operation has been cancelled. With ?, the job stops at its next report. A worker that gets JobCancelled back from a job finishes it as cancelled: it isn't retried and isn't a failure.

Between steps that don't report progress, ask directly:

Rust

Operations

Starting an operation

Inject Arc<Operations>, start an operation in the request's unit of work, dispatch its work, and answer at once. This handler starts an import:

src/modules/contacts/controller.rs

Operation::new(kind) describes it:

MethodMeaning
Operation::new("contacts.import")Its kind, for people and front ends to tell operations apart.
.owner(user_id)The user who started it. They can always see and cancel it.
.workspace(workspace_id)The workspace it belongs to, for access.
.total(n)The units of work, when you know them up front.
.note("...")The first progress note.
.idempotent()Its job can safely run again from the start, so the watchdog restarts it instead of failing it.

start stores the operation as pending and returns it. Then queue its work, in the same unit of work, in one of two ways:

  • operations.dispatch(&uow, &op, job) queues one job. The job's progress reports become the operation's, and the operation finishes when the job does.
  • operations.dispatch_batch(&uow, &op, batch) dispatches a batch. The operation's progress counts the batch's finished jobs (its total becomes the number of jobs), and it finishes once the batch and all its callbacks have run.

The response is the operation, with its id for the client to follow. The Operation and Operations names come with the prelude.

What moves an operation along

StatusWhen
pendingStarted, its work not begun yet.
runningA worker started its job, or a job reported progress.
succeededIts job succeeded, or its batch finished without a failed job.
failedIts job used its attempts ("The operation failed."), its batch had a failed job ("1 of 4 jobs failed.") or a failed callback, or the watchdog found it stalled.
cancelledIt was cancelled, or its batch was cancelled with no failed job.

A finished operation never changes again, so the first finish wins. Your jobs can finish it themselves through Operations:

src/modules/contacts/jobs/finish_import.rs
  • complete(id, result) finishes it as succeeded with a JSON result: a download URL, counts, whatever the front end shows. A job that calls complete and then succeeds keeps that result.
  • fail(id, message) finishes it as failed with a message for people.
  • progress(id, done, total, note) records progress from code outside a job, and heartbeat(id) tells the watchdog that work running outside the queue is still alive.

While a job of an operation runs, its worker renews the operation's heartbeat_at every queue.operations.heartbeat_secs (30 s).

Following an operation

Every change of an operation reaches the client three ways.

The routes. QueueModule mounts these under {http.api_prefix} (default /api), tagged Operations in the OpenAPI document. Every route needs a signed-in user.

RouteAnswer
GET /api/operations?limit=20{"data": [...]}: the signed-in user's own operations, newest first (limit 1 to 100).
GET /api/operations/{id}The operation.
GET /api/operations/{id}/eventsA server-sent event stream of the operation.
DELETE /api/operations/{id}Cancels it and returns it.

An operation looks like this:

200 OK

The routes mount when queue.operations.routes is true (the default) and the app has DatabaseModule and an authenticator (AuthModule, or TestApp's in tests). Turn them off to serve operations from your own routes with the Operations methods.

The SSE stream. GET /api/operations/{id}/events answers text/event-stream: an operation event with the operation now, then one after every change. The event's id is the operation's version. The stream ends after the event with the finished state, so a client reads it to the end and has the result.

Shell
Text

Changes made in the same process arrive at once. Changes made by workers in other processes are picked up by re-reading the operation every queue.operations.stream_poll_ms (1000 ms). An idle stream sends a keep-alive comment every 15 seconds. The browser's EventSource can't send an Authorization header, so read the stream with fetch when you authenticate with bearer tokens, or let a backend-for-frontend add the header.

The private channel. With broadcasting set up (an app with a dyn Broadcaster, such as RealtimeModule), every change is also broadcast as operation.updated on the private channel private-operations.{id}, with the operation as its data. The module authorizes the channel for the users who may see the operation. A change made in a unit of work is broadcast after it commits.

web/import-progress.ts

OperationRecord::channel() gives the channel name in Rust.

Who may see an operation

An operation is visible to:

  • its owner;
  • users who hold the operations.view permission, app-wide or in the operation's workspace.

Everyone else gets a 404, as for an operation that doesn't exist, and the private channel refuses them. Cancelling needs the owner or operations.cancel the same way; a user who can see the operation but not cancel it gets a 403.

Navree asks every module that answers permission questions through navree::contracts::permitted:

  • AccessModule answers with the user's app-wide roles and permissions, checked the way a route's permissions declaration checks them: operations.view, operations.* and the super-admin role grant it, in every workspace.
  • WorkspaceModule answers with the user's role in the operation's workspace. The owner role has every permission; give other roles the permission in configuration:
config/app.toml

To use other permission names, set queue.operations.view_permission and cancel_permission. In your own code, operations.can_view(&principal, &op) and can_cancel answer the same questions.

Cancelling an operation

DELETE /api/operations/{id}, or operations.cancel(id) in code, cancels an operation that hasn't finished:

  • it becomes cancelled at once;
  • its batch is cancelled, and its jobs that haven't started are skipped;
  • a running job stops at its next ctx.progress (or check_cancelled), which returns JobCancelled.

Cancelling is cooperative: a job that never reports progress runs to the end, though the operation stays cancelled. Cancelling a finished operation over HTTP answers 409 Conflict, "The operation has already finished.", with the code operation_finished.

The watchdog

A worker can die mid-job: a deploy, a crash, kill -9. The queue takes the job over once its reservation expires, but some work isn't in the queue any more, and an operation would then stay running forever. The watchdog, operations.sweep(), looks at every pending or running operation with no sign of life (heartbeat, start or creation) for queue.operations.stale_after_secs (300 s):

  1. A job of it is still in the queue: the queue owns the work, so it is left alone.
  2. Its batch has finished: the operation finishes the way the batch did.
  3. It is idempotent and was started with operations.dispatch: its job is dispatched again, at most queue.operations.max_redispatches (3) times. Its note reads "Restarted after it stopped responding".
  4. Otherwise it fails with "The operation stopped responding.".

Each step first claims the operation on its row, so sweeps that overlap (two servers, or a manual run during the scheduled one) restart or fail an operation once, and a heartbeat that arrives during the sweep keeps it alive.

Scheduling it. In an app with ScheduleModule, QueueModule schedules the watchdog for you: task queue.watchdog, every minute, without overlapping. schedule:list shows it, and queue.operations.watchdog = false turns it off. Without ScheduleModule, run the command every minute from system cron:

Shell
Text

Pruning

Finished batches and operations stay in their tables until you delete them. queue:prune deletes the batches whose jobs and callbacks all finished, and the operations that finished, more than --hours ago (default 168, one week):

Shell

Schedule it daily with s.command("queue:prune", &[]).daily_at("04:00"). In code: queue.prune_batches(older_than) and operations.prune(older_than).

Configuration

Operations read the queue.operations section. Every key has a default:

config/app.toml
KeyDefaultMeaning
routestrueMounts the /api/operations routes (they also need DatabaseModule and an authenticator).
watchdogtrueSchedules task queue.watchdog every minute in apps with ScheduleModule.
heartbeat_secs30How often a worker renews a running operation's heartbeat. At least 1.
stale_after_secs300When a pending or running operation counts as stalled. More than heartbeat_secs.
max_redispatches3How often the watchdog restarts an idempotent operation's job before failing it.
progress_interval_ms500How often ctx.progress writes, per job.
stream_poll_ms1000How often an SSE stream re-reads its operation. At least 1.
view_permissionoperations.viewThe permission that shows other users' operations. Not empty.
cancel_permissionoperations.cancelThe permission that cancels them. Not empty.

As environment variables, nest with double underscores: APP_QUEUE__OPERATIONS__ROUTES=false, APP_QUEUE__OPERATIONS__STALE_AFTER_SECS=600. An invalid value fails the boot.

Tables

The queue migration set adds two tables and three columns of jobs. Times are Unix milliseconds.

TableColumns
job_batchesid, name, total_jobs, pending_jobs, failed_jobs, failed_job_ids, callbacks, callbacks_pending, callbacks_failed, allow_failures, operation_id, created_at, cancelled_at, finished_at
operationsid, kind, status, owner_id, workspace_id, progress_done, progress_total, progress_note, result, error, batch_id, job, idempotent, redispatches, version, created_at, started_at, heartbeat_at, finished_at, updated_at
jobs (added)batch_id, batch_callback, operation_id

A batch's counters change in the same transaction that finishes each of its jobs, so a batch finishes, and its callbacks are queued, exactly once however many workers run its jobs.

Testing

Faking batches

QueueFake records batches apart from single jobs: a batch's jobs are not in dispatched().

tests/import.rs
  • batches() returns each RecordedBatch: batch (the JobBatch as a driver stores it at dispatch), jobs, then, catch and finally. jobs_of::<J>(), then_of::<J>(), catch_of::<J>() and finally_of::<J>() decode them.
  • assert_batched(pred) and assert_nothing_batched() assert on them; clear() forgets batches too.
  • A batch dispatched with dispatch_in is recorded when its unit of work commits, and not at all if it rolls back. find_batch, batches and cancel_batch work on the recorded batches.
  • The operation itself is stored as usual, since operations live in the database.

Running batches and operations

To test what the work does, keep a real queue and run it with run_pending: batch jobs, then their callbacks, run in order, and the operation finishes. Read it back with Operations::find, or through the routes:

Rust

To test the watchdog, give the app a FixedClock, move it past stale_after_secs and call operations.sweep(), which returns a SweepReport (failed, redispatched, finished).