Queue and schedule prints across a printer farm
What a fleet queue does: the three routing strategies, what makes a printer eligible, drain and maintenance windows, and the /api/v1/fleet endpoints in full.
Enqueue a G-code revision once and let the scheduler pick the machine. PrintStash keeps every slice as a revision of its source model, then runs one queue over every printer you have added, with manual, default-printer, or least-busy routing, and reorder, reroute, cancel, and retry on anything still waiting. It is one Docker Compose service with no Redis and no separate worker container. The database holds the queue, and an in-process task queue exists only to wake the dispatcher faster than its two-second poll would.
This is the right tool when the farm’s bottleneck is getting a trusted file onto a free machine. It is the wrong tool if what you actually want is a camera wall, spaghetti detection, or order tracking, which belong to a farm dashboard and are covered in the Klipper farm software comparison. Everything below describes v0.11.4. The queue shipped in v0.11.0 and the per-printer access control it enforces shipped in v0.11.1.
The /api/v1/fleet endpoints further down are written out because the API reference does not list them yet. They are the same routes the web UI calls.
From the library to a machine
The daily loop is three steps in the web UI, and none of them need the API.
- Add each printer under Printers -> Add printer with its address and credential. Connecting PrintStash to multiple Klipper printers covers the Moonraker side, usually port
7125, one machine at a time. - Open the model, pick the G-code revision you trust, and enqueue it instead of sending it straight to a machine. Choose manual, default, or least-busy when you do.
- Watch the queue. The scheduler claims the first unblocked job and dispatches it. Anything still queued can be reordered, rerouted, or cancelled.
What you queue is a revision with parsed slicer metadata and an outcome attached, not a filename you hope is current. Tracking known-good G-code revisions is the other half of that.
What the three strategies do
manual targets one printer by id and fails the request without one. default sends to whichever printer is flagged as the fleet default, and the database allows exactly one live default at a time, so flagging a new one clears the old flag. least_busy sorts the eligible printers by the number of jobs currently active on each, then by the oldest last_seen_at, then by the lowest id. The middle tiebreak is worth knowing about: when two machines are equally idle, the one you have heard from least recently wins, so work spreads instead of piling onto whichever printer polls fastest.
Only a superuser can enqueue with default or least_busy. A regular account with print rights on a machine can queue to that machine and nothing else, so the routing strategies are an administrator’s tool in practice. That matches how the v0.11.0 notes describe it.
“Active” here means a job in queued, uploading, started, printing, or paused. Terminal states are completed, cancelled, and failed. A queue read returns every active job in position order plus a page of terminal history, twenty rows by default and a hundred at most.
Whichever strategy you pick, one queued job lands on exactly one printer. Running the same part on six machines means six queued jobs, which sending G-code to multiple printers from one app works through.
What makes a printer eligible
A printer is a routing candidate only when it is not deleted, not in drain mode, reporting status ready, outside any active maintenance window, and running a provider that declares both upload and start. All five current providers declare those two, so the capability gate excludes nothing today. What takes a machine out of the pool in practice is its reported status, drain mode, or a window you set.
Enqueueing when nothing is eligible does not fail. The job is created in queued with a blocked_reason recorded on it, such as no_eligible_printer or printer_unavailable, and the fleet summary counts it under attention_jobs. The scheduler then re-resolves routing on every pass, using a fresh snapshot of printer status, drain flags, maintenance windows, and active job counts, and it sorts unblocked jobs ahead of blocked ones. A least-busy job queued while the whole farm is printing is therefore not stuck with the assignment it got at enqueue time. The next pass assigns it somewhere better once a machine frees up.
The same pass also re-checks the permissions of whoever queued the job, which is easy to miss. If that account loses print access to the chosen printer, or edit access to the model’s collection, or is deactivated outright, the job is marked blocked with printer_access_revoked, collection_access_revoked, or requester_access_revoked instead of dispatching on stale authority.
What routing never looks at is the hardware fit: loaded filament, nozzle diameter, plate type. Least-busy will put an ABS slice on a machine spooled with PLA without hesitating. Scoring jobs that way is not a project goal, so picking the right revision for the machine stays your job, and keeping fleet builds reproducible covers the revision labels that make that workable on a heterogeneous farm.
Draining and maintenance windows
A machine that needs a nozzle swap comes out of rotation without being unplugged. Drain mode stops new work landing on it and leaves whatever it is printing alone. A maintenance window does the same for a fixed start and end, and the scheduler routes around it until the window closes. Either way the currently running job finishes.
There is also a per-printer maintenance log, separate from the windows, where each entry carries a category, a note, and optionally a counter value with a unit, so “belt tensioned at 412 hours” is a row rather than a sticky note. The fleet summary reports how many printers are draining and how many are inside a window.
You do not have to be at a desk to run any of it: managing the farm from a phone covers the installed PWA and the tunnel that keeps the same screens working off-network.
The fleet queue API
All of it is exposed under /api/v1/fleet. Authenticate first: an API key is a login credential, not a Bearer token, so exchange the username and key for a short-lived access token and send that. On a default Compose install the API is reached through the frontend origin on port 3000, because the api service only publishes 8000 on the internal network.
# 1. Exchange an API key (created under Settings -> Access) for an access tokencurl -s -X POST http://localhost:3000/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"username": "automation", "api_key": "<api-key>"}'# -> {"access_token": "..."}# 2. Enqueue a job and let PrintStash pick the least-busy eligible printerTOKEN="<access-token>"curl -X POST http://localhost:3000/api/v1/fleet/queue \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"file_id": 123, "strategy": "least_busy"}'Manual routing needs the target id: {"file_id": 123, "strategy": "manual", "printer_id": 5}. The file_id has to be a G-code artifact; a .bgcode file is rejected with binary_gcode_not_printable, because the compressed body is not decoded for sending. Every fleet payload forbids unknown fields, so a typo in a key is a 422 rather than a silently ignored setting.
The rest of the surface:
GET /api/v1/fleet/queue?history_limit=20&history_offset=0lists active jobs plus a page of terminal history.PATCH /api/v1/fleet/queue/{job_id}reorders (queue_position, 1-based) or reroutes (strategy,printer_id). Passexpected_updated_atand you get a 409queue_job_changedinstead of overwriting someone else’s edit.DELETE /api/v1/fleet/queue/{job_id}cancels a job. Both this and the PATCH only accept a job still inqueued; anything already uploading or printing returns 409queue_job_not_editable, and you stop that print from the printer’s own controls.POST /api/v1/fleet/queue/{job_id}/retryrequeues a failed job, but only one flagged retryable.PATCH /api/v1/fleet/printers/{printer_id}/routingtakes{"drain_mode": true, "drain_reason": "..."}or{"is_default": true}.GET,POST,PATCH, andDELETEon/api/v1/fleet/printers/{printer_id}/maintenance-windowsmanage windows, withstarts_at,ends_at, and an optionalreason. The same four verbs on.../maintenance-logmanage log entries.GET /api/v1/fleet/summaryreturnstotal_printers,queued_jobs,active_jobs,draining_printers,maintenance_printers, andattention_jobs, scoped to the printers the caller can see.
Roles are enforced in the handlers, not by hiding buttons. Queue writes need print on the target printer plus edit on the model’s collection. Routing changes and maintenance writes need admin on the printer. Reading windows and the log needs view.
What a restart does to work in flight
Upload and start are two remote calls that are not transactional with the local database, so a transport error can arrive after the printer has already accepted the file. A dispatch interrupted between the provider call and the final database write is reconciled on startup into failed, with the error dispatch_outcome_unknown and the retryable flag off, which means the retry endpoint refuses it. The row stays visible so an operator can go look at the machine and decide. Ordinary provider failures, a printer that was not idle or a storage read that failed, are marked retryable and requeue with one call.
That costs you a little manual reconciliation after a bad restart, and buys the guarantee that no automatic process starts the same physical print twice. On a farm running unattended overnight, I would take that trade every time.
On a mixed fleet
The queue does not care which protocol a printer speaks. Moonraker, OctoPrint, PrusaLink, Bambu LAN, and Elegoo Centauri all join the same library and the same queue, and all five can receive a queued job. What differs is what comes back afterwards, and what else the machine will let you do. Moonraker is the stable provider with the full capability set; the other four are beta with narrower ones. Managing print files across a mixed Klipper and OctoPrint fleet has the per-provider workflow, and the compatibility matrix has the grid that moves with releases.
There is one number to be careful with on a mixed farm. Measured filament consumption comes back from Moonraker only and everything else falls back to the slicer’s estimate, so per-print cost is not comparable across providers. Measured elapsed duration is a separate field and does come back from PrusaLink, OctoPrint, and Elegoo Centauri too, though only Moonraker’s has been checked against real prints. Compare within a provider, and treat anything across providers as indicative.
Questions that come up
Can I cancel a job that has already started printing?
Not from the fleet queue. Both the cancel and the edit endpoint accept a job only while it is still in queued; once the scheduler has claimed it the call returns 409 queue_job_not_editable, and the same applies to reordering and rerouting. Stopping a live print is the printer’s own job, which means Mainsail, Fluidd, or OctoPrint, and PrintStash records the outcome that comes back. Running PrintStash alongside OctoPrint, Fluidd, and Mainsail covers where that line sits.
Can I take a printer out of rotation without stopping what it is printing?
Yes, and this is the difference between refusing new work and pulling a plug. Drain mode marks the printer ineligible so the scheduler stops assigning to it, and a one-off maintenance window does the same between a start and end time you set. In both cases the job currently on the bed runs to completion. Drain mode carries an optional reason string, which is worth filling in when more than one person operates the farm.
Why is a job sitting in the queue when printers look free?
Check its blocked_reason first, because a job that could not be placed is still created rather than rejected. A machine has to be reporting status ready, out of drain mode, and outside any maintenance window to be a candidate, so a printer that is merely reachable is not necessarily eligible. The other cause is authority rather than hardware: the scheduler re-checks the permissions of whoever queued the job on every pass, and revoked print access to the printer or edit access to the model’s collection blocks it with printer_access_revoked or collection_access_revoked. Blocked jobs sort behind unblocked ones and the fleet summary counts them under attention_jobs.
Who can queue with default-printer or least-busy routing?
Only a superuser. A regular account can enqueue with manual and an explicit printer_id, on a printer where it holds the print role, and the API returns 403 for anything else, so the routing strategies are in practice an administrator’s tool. The same limit applies when rerouting an existing job. That is worth planning around if you intended to hand operators a self-service queue: they can queue to their own machines, but spreading work across the farm stays with an admin account or an API key owned by one.
Could a failed dispatch start the same print twice?
Not automatically, no. Upload and start are non-transactional remote calls, so a dispatch interrupted between the provider accepting the request and the final database write is recorded as dispatch_outcome_unknown and marked non-retryable. The retry endpoint will not touch it and no background process replays it, which leaves you to check the machine and reconcile by hand. Failures where the outcome is known, such as a printer that was not idle when the job arrived, are marked retryable and requeue in one call.
Do I need Redis or a separate worker to run the queue?
Neither one. The database is the source of truth for queue state, and the scheduler runs inside the API process, claiming one job per pass. An in-memory task queue wakes it as soon as something is enqueued, and a two-second timeout poll picks the work up anyway if that in-memory notification is ever lost, which is also how queued jobs survive a restart. There is a caveat in the other direction: the supported topology is one API process per vault, and startup claims a lock and fails fast if another is already running, so do not scale the API container to two replicas expecting two dispatchers.
Sources
- PrintStash v0.11.0 release notes for the fleet queue, routing strategies, maintenance-aware scheduling, and restart-safe dispatch, and v0.11.1 for per-printer access control.
- Capabilities and the compatibility matrix for the per-provider grid.
- Known limitations for the routing behavior that is deliberately out of scope.
- The API reference for the login flow and the endpoints it does list.
- API behavior verified on 2026-08-18 against
mainin the PrintStash repository:backend/app/api/v1/fleet.py,app/services/fleet.py,app/services/printer_jobs.py,app/schemas/fleet.py, andapp/services/printer_provider.py.