Skip to content

Scheduler and tiers

Workers share themselves between tenants by weighted fair share over service tiers. You choose the tiers, their names and numbers; Paprika makes sure every tenant gets its share and none can crowd out the rest.

Tiers are set in PAPRIKA_TIERS, the same for the API and every worker, as JSON. The first tier is the default:

Terminal window
PAPRIKA_TIERS={"standard": {"weight": 1}, "priority": {"weight": 4, "maxRunning": 20, "ratePerMinute": 600}}
Setting Means
weight Its share of the workers: with weights 4 and 1, work starts 4 to 1 when both tiers are waiting
maxRunning The most of one tenant’s work running at once, across every worker
ratePerMinute The most synchronous renders and job and batch submissions one tenant may make a minute

Unset, there is one tier, standard, with no limits. A malformed value stops the process at start, saying why.

Give a tenant its tier with the admin API (tenants); a tenant with none has the default:

Terminal window
curl -X PATCH -H "Authorization: Bearer $PAPRIKA_ADMIN_TOKEN" -H "Content-Type: application/json" -d '{"tier": "priority"}' "$PAPRIKA_URL/v1/admin/tenants/fabrikam"
{
"id": "fabrikam",
"name": "Fabrikam",
"tier": "priority",
"retentionDays": 90,
"createdAt": "2026-11-10T15:51:56.007Z",
"suspendedAt": null,
"deletedAt": null
}

A tier that isn’t configured is refused:

Terminal window
curl -X PATCH -H "Authorization: Bearer $PAPRIKA_ADMIN_TOKEN" -H "Content-Type: application/json" -d '{"tier": "gold"}' "$PAPRIKA_URL/v1/admin/tenants/fabrikam"
{
"code": "bad-request",
"message": "Bad request: tier is one of standard, priority, or null",
"requestId": "69788ef8"
}

A tier removed from PAPRIKA_TIERS later falls back to the default. Queued work takes its tenant’s tier when a worker claims it, so an upgrade speeds up work already waiting.

A key can have a tier of its own, overriding its tenant’s for the work it submits: urgent work, or one department of an organisation that is a single tenant. Only the admin API gives one:

Terminal window
curl -H "Authorization: Bearer $PAPRIKA_ADMIN_TOKEN" -H "Content-Type: application/json" -d '{"name": "urgent", "scopes": ["render"], "tier": "priority"}' "$PAPRIKA_URL/v1/admin/tenants/fabrikam/keys"

The work queued is grouped by tenant and tier. A free worker takes from the group with the lowest share used: its work running and started in the last minute, divided by its tier’s weight. Ties go to the group waiting longest. So shares hold however few workers there are, every tenant counts on its own, and a lower tier always gets its share, more slowly.

Within a group, single jobs go first: they are someone waiting. Then batches take turns, the batch with the fewest records running first, each batch’s records in order.

Synchronous renders don’t queue: the API renders them in its own threads (PAPRIKA_WORKERS), answering 503 busy with Retry-After when they are all taken.

A worker given PAPRIKA_WORKER_TIERS claims only those tiers’ work, so they always have workers of their own. Run it as a second worker service beside the one serving every tier:

worker-priority:
image: ${PAPRIKA_IMAGE:-paprika:local}
command: worker
environment:
PORT: 4180
PAPRIKA_DATABASE_URL: postgres://…
PAPRIKA_TIERS: ${PAPRIKA_TIERS:-}
PAPRIKA_WORKER_TIERS: priority
volumes:
- data:/data

A PAPRIKA_WORKER_TIERS naming a tier that isn’t configured stops the worker at start.

Past its tier’s ratePerMinute, a tenant’s renders and submissions are refused with 429 rate-limited, Retry-After (the seconds to the next minute) and limit. The count is kept in PostgreSQL, so every API counts alike.

A tenant’s queued job says how many of its own single jobs are ahead of it (queue.ahead). Other tenants’ work is never counted or shown.

Read each tier’s settings, its work queued and running now, and over the last minutes the work started (with its wait) and done (with its render time):

Terminal window
curl -H "Authorization: Bearer $PAPRIKA_ADMIN_TOKEN" "$PAPRIKA_URL/v1/admin/scheduler?minutes=60"
{
"minutes": 60,
"tiers": [
{
"tier": "standard",
"name": "standard",
"weight": 1,
"maxRunning": null,
"ratePerMinute": null,
"queued": 0,
"running": 0,
"started": 12,
"done": 12,
"waitSeconds": { "p50": 0.2, "p95": 1.4 },
"renderSeconds": { "p50": 0.3, "p95": 0.9 }
},
{
"tier": "priority",
"name": "priority",
"weight": 4,
"maxRunning": 20,
"ratePerMinute": 600,
"queued": 0,
"running": 0,
"started": 40,
"done": 40,
"waitSeconds": { "p50": 0.1, "p95": 0.4 },
"renderSeconds": { "p50": 0.3, "p95": 0.8 }
}
]
}

Each worker also logs every claim with its tier and how long the work waited.