Tiered fair-share dispatcher: the architecture of barista-rs

November 15, 2026

On this page

A multi-tenant SaaS application submits jobs. The jobs are CPU bound, run for minutes, cannot be split and cannot be paused. They run in containers of three sizes, and there are never enough containers. Some tenants pay more than others. One tenant must not be able to starve the rest, and a Gold tenant should get more of the machine than a Free one, but not all of it.

barista-rs is my answer to that, written in Rust. This post walks through the architecture as it stands in the sixth version of the design board: what the pieces are, why they look the way they do, and what is still open. The core of the scheduler exists and is tested. The persistence and the gRPC runtime are designed and not yet written. I say which is which as we go.

The whole board

I design on an Excalidraw board and keep every version. The left part is the original brainstorm, the middle is the lease and fencing study, and the right columns are the v5 and v6 decision panels. Red notes on the old parts point to the panel that superseded them.

The complete v6 architecture board: brainstorm on the left, lease and fencing in the middle, v5 and v6 panels on the right

The board is downloadable as an Excalidraw file if you want to zoom around it. The crops below come from it, plus two diagrams I drew for this post.

The v6 architecture in one diagram

SaaS application submits to dispatcher replicas, which use PostgreSQL as the source of truth and hand jobs to sm, md and lg worker containers over gRPC streams

Four kinds of parts:

  • The SaaS application submits jobs with a size, an estimated cost and an idempotency key, and gets status updates back.
  • Dispatcher replicas, any number of them. Each one runs the client API, the dispatch loop, a reaper and an outbox relay. There is no leader.
  • PostgreSQL, the only source of truth. Tenants, jobs, the virtual clock and the outbox all live there. Leases and fencing tokens are columns, not a separate service.
  • Workers, one per container. A container runs one job at a time, so a worker is one execution slot. Workers come in sm, md and lg with different memory limits.

The rest of the post is the reasoning behind each arrow.

The problem in one picture

This is the first drawing on the board, and it still holds.

Brainstorm: SaaS application, a shared queue of jobs, the scheduler and dispatcher, and three consumer groups of pull-based workers

Three facts about the jobs drive everything:

  • They are CPU bound and long. Minutes, not milliseconds. Fairness has to work across jobs, because nothing can be made fair inside one.
  • They are non-preemptive. Once a job holds a container it keeps it until it finishes, fails or is cancelled. The scheduler only ever decides what runs next.
  • They have a size. A job asks for sm, md or lg. A bigger container can run a smaller job, but not the other way round.

And three facts about the machine:

  • Compute is limited, and it is the thing tenants compete for.
  • The containers are not interchangeable. An lg container is scarce and expensive.
  • Many tenants, with different subscription tiers, push into the same system at once.

Decisions that shaped everything

Most of the architecture falls out of a handful of decisions. The board lists twelve. These are the ones that matter most.

One shared queue, not one queue per container size. With one lane, a free md container can pick up an sm job when nothing of its own size is waiting. With one lane, a per-tenant concurrency cap can be enforced across all sizes instead of per size. Both would be impossible with a lane per consumer group.

Start-time Fair Queuing over Deficit Round Robin. SFQ gives each tenant a virtual timestamp and always runs the tenant that is furthest behind. It handles weights naturally, it does not need to know when a job finishes to decide what runs next, and its state is one number per tenant. DRR is in the backlog as a configurable alternative, but it is not the default.

Charge CPU seconds, not wall seconds. A job that takes 40 wall seconds on sm might take 10 on lg. If I billed wall time, running on a big container would be a discount. The cgroup reports CPU time consumed, and that is the cost unit everywhere: in the tags, in the usage memory, in billing.

One container runs one job. Each container already has its own cgroup, so there is nothing extra to set up per job. A kill is docker stop by container ID, never a signal to a raw PID. A job that runs hot only hits its own limit.

gRPC with one bidirectional stream per worker. The dispatcher must be able to tell a busy worker to stop. With a stream, heartbeats go up and Cancel comes down on the same connection, right away. With plain HTTP the worker would have to poll, or a cancel flag would ride on the heartbeat response and arrive a full heartbeat interval late.

PostgreSQL, not Kafka, as the source of truth. Once a job is on a Kafka topic it cannot be cancelled, reordered or re-prioritised. The whole point here is the ordering decision, so the queue has to be queryable and mutable. A database is.

Start-time Fair Queuing with tags per tenant

SFQ keeps a virtual clock, virtual_now, and a finish_tag for each tenant. A job gets its tag at dispatch, not at submission:

start_tag          = GREATEST(virtual_now, tenant.finish_tag)
tenant.finish_tag  = start_tag + est_cost / weight(tier)
virtual_now        = GREATEST(virtual_now, start_tag)

The tenant with the lowest start_tag runs next. Inside a tenant, jobs run in submission order, because all the queued jobs of one tenant share the same next tag and the tie is broken by enqueue_seq.

Weight comes from the tier, multiplied by an optional per-tenant multiplier. The defaults double at each step: Free 1, Bronze 2, Silver 4, Gold 8, Diamond 16. A job does not carry a weight of its own.

A worked example

This is one of the replay tests in barista-core. A Gold tenant (weight 8) and a Free tenant (weight 1) each queue twenty jobs that cost one CPU second. Each Gold job advances Gold's tag by 125 virtual milliseconds, each Free job advances Free's by 1000.

dispatch  gold tag  free tag  runs   why
   1          0         0     gold   tie: higher tier first
   2        125         0     free   lowest tag
   3        125      1000     gold
   4        250      1000     gold
   ...
  10       1000      1000     gold   tie again, tier wins
  11       1125      1000     free

Over eighteen dispatches Gold runs sixteen times and Free twice: an eight to one ratio, exactly the weight ratio. Free never starves, because its tag stays put while Gold's keeps climbing past it.

Ties are broken by higher tier, then by larger job size, then by lower enqueue_seq. There is no random choice anywhere, which is what makes the replay tests exact.

Why the tags are per tenant

Earlier versions gave every queued job its own provisional tag. The problem shows up at completion: the estimate was wrong, and now every queued job of that tenant has a stale tag that has to be rewritten while new dispatches are reading them. With tags per tenant, a cost correction touches one value:

tenant.finish_tag += (actual_cpu - est_cost) / weight

No queued job is updated, so there is no race. The estimate itself comes from the submission for now, and an exponential moving average per job type is the plan for phase 5.

The virtual clock

virtual_now is one row in scheduler_state. It only ever moves forward. When the system is idle it equals the highest finish tag seen so far, so a tenant that comes back after a quiet week starts at "now", not at the tag it had a week ago. That is what stops an idle tenant from building up credit it could spend in a burst.

VirtualTime is a fixed-point integer, not a float, so two replicas and the test suite always agree on the value. Overflow is a checked error, and the plan if the values ever get large is to rebase every tag.

Database schema

Four tables carry the whole system. This is the schema that the transactions in the next sections assume. It belongs to phase 3, so it is designed and not yet migrated, but every column below is named on the board or in the plan.

CREATE TYPE tier           AS ENUM ('free', 'bronze', 'silver', 'gold', 'diamond');
CREATE TYPE resource_class AS ENUM ('sm', 'md', 'lg');
CREATE TYPE job_state      AS ENUM ('queued', 'assigned', 'running',
                                    'completed', 'failed', 'cancelled', 'timed_out');

-- One row per tenant. The scheduler state of a tenant is three numbers:
-- running, finish_tag and usage. Everything else is configuration.
CREATE TABLE tenants (
  id                bigint PRIMARY KEY,
  tier              tier        NOT NULL,
  multiplier        integer     NOT NULL DEFAULT 1 CHECK (multiplier >= 1),
  cap               integer     NOT NULL CHECK (cap >= 1),       -- concurrency cap
  running           integer     NOT NULL DEFAULT 0
                                CHECK (running >= 0 AND running <= cap),
  finish_tag        bigint      NOT NULL DEFAULT 0,  -- VirtualTime, fixed point
  usage             bigint      NOT NULL DEFAULT 0,  -- CPU ms, decayed lazily (v6)
  usage_updated_at  timestamptz NOT NULL DEFAULT now()
);

-- One row per job, for its whole life. Terminal rows are kept for status
-- queries and billing, then removed by a retention job.
CREATE TABLE jobs (
  id                 bigint PRIMARY KEY,
  tenant_id          bigint         NOT NULL REFERENCES tenants (id),
  idempotency_key    text           NOT NULL,
  job_type           text           NOT NULL,     -- key of the cost estimate (phase 5)
  size               resource_class NOT NULL,
  est_cost           bigint         NOT NULL CHECK (est_cost > 0),   -- CPU ms
  payload            bytea          NOT NULL,
  state              job_state      NOT NULL DEFAULT 'queued',
  enqueue_seq        bigint         NOT NULL,     -- FIFO inside a tenant, never reset
  attempt            integer        NOT NULL DEFAULT 0,   -- fencing token
  failures           integer        NOT NULL DEFAULT 0,   -- compared with max_attempts
  start_tag          bigint,                      -- set at dispatch
  weight_at_dispatch integer,                     -- divisor for the cost correction
  worker_id          text,
  ack_deadline       timestamptz,                 -- Assigned: Started must arrive by then
  started_at         timestamptz,
  lease_expires_at   timestamptz,                 -- Running: moved by each heartbeat
  timeout_at         timestamptz,                 -- Running: started_at + job_timeout
  cancel_requested   boolean        NOT NULL DEFAULT false,
  cancel_reason      text,                        -- 'requested' or 'timeout'
  cpu_ms             bigint,                      -- actual cost, from Finished
  created_at         timestamptz    NOT NULL DEFAULT now(),
  finished_at        timestamptz,
  UNIQUE (tenant_id, idempotency_key),
  CHECK (failures <= attempt)
);

-- Exactly one row. The FOR UPDATE on it serialises every dispatch.
CREATE TABLE scheduler_state (
  id          integer PRIMARY KEY CHECK (id = 1),
  virtual_now bigint  NOT NULL DEFAULT 0
);
INSERT INTO scheduler_state (id) VALUES (1);

-- Status updates for the SaaS application, written in the same transaction
-- as the state change and delivered by a relay on each replica.
CREATE TABLE outbox (
  id          bigserial PRIMARY KEY,
  job_id      bigint      NOT NULL,
  tenant_id   bigint      NOT NULL,
  attempt     integer     NOT NULL,
  event       text        NOT NULL,   -- 'started', 'completed', 'failed', ...
  body        jsonb       NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  sent_at     timestamptz
);

The indexes follow the queries, one per hot path:

-- the dispatch pick: queued jobs of a size, in FIFO order
CREATE INDEX jobs_queued ON jobs (size, enqueue_seq) WHERE state = 'queued';

-- the reaper, one partial index per deadline it scans
CREATE INDEX jobs_ack      ON jobs (ack_deadline)     WHERE state = 'assigned';
CREATE INDEX jobs_lease    ON jobs (lease_expires_at) WHERE state = 'running';
CREATE INDEX jobs_timeout  ON jobs (timeout_at)       WHERE state = 'running';

-- the client API: a tenant's jobs by state
CREATE INDEX jobs_tenant   ON jobs (tenant_id, state, created_at);

-- the outbox relay
CREATE INDEX outbox_unsent ON outbox (id) WHERE sent_at IS NULL;

A few things the schema is deliberately doing:

  • The queued index is small. It only contains queued rows, so the dispatch pick touches a few hundred rows at most, however many million jobs have finished. Terminal rows age out through a retention policy.
  • Fencing is a column, not a service. attempt on the job row is the token, worker_id binds the lease to one worker, and every worker write carries both in its WHERE.
  • Idempotent submission is a constraint. The same (tenant_id, idempotency_key) cannot be inserted twice. The API returns the existing job when the payload matches and rejects the call when it does not.
  • Tenant isolation is in every query. The client API includes tenant_id in each WHERE for submit, cancel and status, so one tenant cannot read or cancel another's job even with a guessed ID.
  • Every time is a database time. The columns are timestamptz, written with now() and compared with now(). No replica clock is involved. The database session gets statement_timeout, lock_timeout and idle_in_transaction_session_timeout, so a paused replica cannot hold the scheduler_state lock.
  • The virtual clock is an integer. finish_tag, start_tag and virtual_now are bigint fixed point, the same representation as VirtualTime in the core, so the Rust tests and the SQL agree to the last digit.

The two SQL functions that v6 adds, decayed_usage(tenant, now()) and weight_of(tenant, now()), read the row and apply the same integer half_power table that the core uses. They are pure, so they can be called inside the dispatch pick without side effects.

One dispatch is one transaction

Every dispatch is a single PostgreSQL transaction. This is the v6 version from the board, which only differs from v5 by reading the weight through the usage decay:

BEGIN;
-- 1. singleton lock: one dispatch at a time, across all replicas
SELECT virtual_now FROM scheduler_state WHERE id = 1 FOR UPDATE;

-- 2. pick: Prefer-Fit, then lowest tenant tag, then tier, then FIFO
SELECT j.id, j.tenant_id, j.est_cost,
       GREATEST($virtual_now, t.finish_tag) AS start_tag,
       weight_of(t, now())                  AS weight
FROM   jobs j JOIN tenants t ON t.id = j.tenant_id
WHERE  j.state = 'queued' AND j.size <= $worker_size
  AND  t.running < t.cap                    -- a cap unit is free
ORDER  BY (j.size = $worker_size) DESC,     -- Prefer-Fit
          start_tag, t.tier DESC, j.enqueue_seq
LIMIT  1;

-- 3. claim the cap unit, decay and charge the usage, advance the tag
UPDATE tenants t
SET    running          = running + 1,
       usage            = decayed_usage(t, now()) + $est_cost,
       usage_updated_at = now(),
       finish_tag       = $start_tag + $est_cost / $weight
WHERE  id = $tenant AND running < cap;      -- 0 rows -> ROLLBACK

-- 4. hand the job out; it is not Running until the worker says Started
UPDATE jobs
SET    state = 'assigned', attempt = attempt + 1,
       start_tag = $start_tag, weight_at_dispatch = $weight,
       worker_id = $worker, ack_deadline = now() + assign_ack_timeout
WHERE  id = $job AND state = 'queued';

-- 5. the virtual clock never goes back
UPDATE scheduler_state SET virtual_now = GREATEST(virtual_now, $start_tag);
COMMIT;   -- then send Assign{job, attempt} on the worker stream

The first line is the whole concurrency story. Every replica locks the same single row before it picks a job, so only one dispatch runs at a time. One replica waits for the other to commit. That is the mutual exclusion a leader election would otherwise provide, and it is why there is no leader and no leader lease. If a replica dies mid-transaction, the database drops the lock and rolls back on its own.

An earlier version used FOR UPDATE SKIP LOCKED so that replicas could pick different jobs in parallel. It is the standard Postgres-as-a-queue trick and the board still shows it.

The earlier dispatch design with SKIP LOCKED, the single scheduler_state row, and the list of dispatcher crash and pause scenarios

I dropped it for a reason that is specific to SFQ: virtual_now must advance in tag order. Two replicas picking two jobs concurrently would each read the clock, each compute a start tag, and each try to advance it, and the lower tag could land second. Serialising dispatch costs some throughput, and with jobs that run for minutes that throughput was never the bottleneck.

The list of dispatcher failure cases in that crop is still the checklist I use:

  1. The dispatcher crashes mid-transaction. Rollback, nothing happened.
  2. It crashes after commit but before the Assign reaches the worker. The job sits in Assigned with nobody working on it until ack_deadline passes, and the reaper puts it back in the queue.
  3. It pauses mid-transaction and holds the row lock while asleep. lock_timeout and idle_in_transaction_session_timeout end that.
  4. It pauses after commit and the Assign arrives late. Either the ack deadline already passed and the attempt is stale, or it did not and the job simply starts late.
  5. A late reply over the network. Same as 4.
  6. The replica running the reaper is down. The reaper runs on every replica and is idempotent, so two of them running at once is harmless.

Prefer-Fit: when a large container steals a small job

A free lg container with no lg job waiting should not sit idle while sm jobs queue up. But if it always took the lowest tag regardless of size, a stream of small jobs could keep the big containers busy and an lg job would wait behind them.

The board has two policies, both behind a StealPolicy trait:

  • Prefer-Fit, the default. A container first looks for the lowest tag among jobs of exactly its size. Only when there are none does it steal a smaller job. This is the (j.size = $worker_size) DESC term in the ORDER BY above.
  • Reserve, optional. Steal a smaller job only if its tag is below the lowest same-size tag by more than a margin. More faithful to the global tag order, more tuning.

"Large jobs take precedence" means an lg job gets the next free lg container. It never means preemption. A low-tier job that holds a container keeps it, and that priority inversion is a listed risk, not a bug.

The burst that Prefer-Fit does not solve

Four big jobs from four tenants arrive at once, fill all four slots for 60 seconds and make the small jobs of tenant A wait; the desired schedule spreads them out

If four tenants each submit one big job at the same moment and the four slots are free, all four slots take a big job and everyone else waits a full minute. SFQ is doing exactly what it should, because at time zero those four tenants do have the lowest tags. The per-tenant concurrency cap does not help either, since each tenant is only running one job.

The direction on the board is a cap in CPU seconds in flight per tenant, not just a job count, and eventually a two-dimensional fair queue (2DFQ) that spreads large jobs over time. Both are marked out of scope for the current implementation. I would rather ship the simple version and measure how often this burst actually happens.

Leases, heartbeats and fencing

This is the part of the board I spent the most time on, because it is where distributed systems bite. A worker goes silent. Why?

Cause                               Worker       Container
Worker crashed                      dead         dead too
Worker paused (GC, VM stop, swap)   alive        may still run
Network partition                   alive        still runs
Worker is slow, heartbeat is late   alive        still runs

Only the first case is safe to act on. In the other three the dispatcher cannot tell the difference, and if it hands the job to a second worker, two containers are now working on the same job and both will try to write the result. That is the whole reason for fencing.

Leases

The textbook shape is a lease server that hands out leases and fencing tokens, like ZooKeeper or etcd.

A node gets a lease and a fencing token from a lease server, renews it, and includes the token in every write to storage

barista-rs does not need a separate lease server, because the database is both the lease server and the storage. A lease is a row with lease_expires_at, set when the worker sends Started and moved forward by each heartbeat. The reaper finds leases nobody renewed:

-- every few seconds, on every replica, idempotent
-- 1. Assign never acknowledged -> back to the queue, not a failure
UPDATE jobs SET state = 'queued', worker_id = NULL
WHERE  state = 'assigned' AND ack_deadline < now()           RETURNING tenant_id;
-- 2. lease expired and a cancel was requested -> Cancelled
UPDATE jobs SET state = 'cancelled'
WHERE  state = 'running' AND lease_expires_at < now()
  AND  cancel_requested                                       RETURNING tenant_id;
-- 3. lease expired -> retry or fail
UPDATE jobs SET failures = failures + 1, worker_id = NULL,
       state = CASE WHEN failures + 1 < max_attempts THEN 'queued' ELSE 'failed' END
WHERE  state = 'running' AND lease_expires_at < now()
  AND  NOT cancel_requested                                   RETURNING tenant_id;
-- 4. the job ran too long -> ask the worker to stop (final state TimedOut)
UPDATE jobs SET cancel_requested = true, cancel_reason = 'timeout'
WHERE  state = 'running' AND timeout_at < now() AND NOT cancel_requested;
-- for 1 to 3, in the same transaction: tenants.running - 1 for each returned row

All of the times are database timestamps. The replicas never compare their own clocks with anything. The database evaluates now() both when it writes a lease and when it checks one, so a single clock measures both ends and there is no skew between replicas. It is a wall clock, not a monotonic one, so lease_timeout has to be much larger than any clock step the database host might see.

Two different timeouts measure two different things:

Setting        Measures                      Renewed by          Event
lease_timeout  time since the last heartbeat every job heartbeat LeaseExpired
job_timeout    time since the job started    never, hard limit   TimedOut

Fencing with the attempt number

The fencing token is attempt. It goes up by one at every dispatch, and the dispatcher sends it to the worker inside Assign. The worker echoes it in every message. The store accepts a write only when the attempt is equal to the current one, the worker matches, and the job is in the expected state.

The worker calls heartbeat, complete and fail with the attempt; the database accepts attempt 2 and rejects a call with the stale attempt 1

Say job 17 was dispatched to worker A as attempt 1, A went quiet, the lease expired, and the job went to worker B as attempt 2. When A wakes up and sends Finished with attempt 1, the UPDATE ... WHERE attempt = 1 changes zero rows. The database rejects the stale write by itself. Nothing on the dispatcher has to remember that A existed.

Two details that took a while to get right:

  • Equal, not "greater than or equal". The classic fencing rule accepts any token that is at least the latest. Here a job has exactly one live attempt, so only that one is valid.
  • Side effects run only when the row changed. Releasing the cap unit, correcting the finish tag, sending a status update: each of them happens in the same transaction and only when the fenced update reports one row. A stale Finished does none of them. Its CPU time is still charged to the tenant for billing, because zombie CPU is not free.

Why the worker also kills itself

Fencing protects the database. It does not stop a zombie container from burning CPU for the next ten minutes. So the worker has a rule of its own: if it has had no contact with the dispatcher for worker_self_kill_after (25 seconds), it stops its container. That is deliberately shorter than lease_timeout (30 seconds), so a partitioned worker is dead before the job is handed to anyone else. A worker that receives Reject does the same and discards its result.

A broken gRPC stream is a warning, not a verdict. The connection stopped, which says nothing about the job process. The dispatcher marks the worker's leases as suspect and waits for lease_timeout. If the worker reconnects first, its Hello lists the jobs it is still running and the leases resume.

heartbeat_interval       5 s     cheap, finds problems fast enough
lease_timeout            30 s    six missed heartbeats
worker_self_kill_after   25 s    strictly less than lease_timeout
assign_ack_timeout       10 s    time to send Started after Assign
cancel_grace             10 s    between SIGTERM and SIGKILL
gRPC keepalive           10 s ping, 5 s timeout: finds a dead TCP connection

The job lifecycle

All of the above lands in one state machine. I drew it fresh, because on the board it is a block of text.

Job state machine: Queued to Assigned to Running, then Completed, Failed, Cancelled or TimedOut, with back edges to Queued on ack timeout and lease expiry

Three things about it that are easy to miss:

Assigned is not Running. A job is Assigned from the dispatch commit until the worker sends Started. If Started never comes, the job goes back to the queue and the tenant's failures counter is untouched. A lost Assign is the dispatcher's problem, not the job's.

attempt and failures are different counters. attempt counts dispatches and is the fencing token. failures counts runs that were lost or failed, and is what max_attempts is compared against. Two numbers, because an unacknowledged Assign has to bump the first and not the second.

A retry is not a refund. A requeued job keeps its enqueue_seq, so it is first in line inside its tenant. But the tenant paid est_cost in its finish tag at dispatch and keeps paying for the lost run. Refunding would let a tenant with flaky jobs eat more than its share.

Cancellation is written down before it is sent. cancel_requested goes into the row first, then Cancel goes down the stream, and it is sent again on every heartbeat and on reconnect until the worker confirms. The final state is Cancelled when the worker confirms, or when the lease expires, whichever comes first. A timeout is a cancel with a different reason and a different final state, TimedOut, and it is never retried.

In code the state machine is one pure function, apply(state, event), that returns either Changed(new_state) or Unchanged for a duplicate event. Everything with side effects keys off that result.

The worker protocol

One gRPC bidirectional stream per worker, with these messages:

Worker -> Dispatcher                      Dispatcher -> Worker
Hello{worker_id, class, running[]}        Welcome{heartbeat_interval, self_kill_after,
Ready{}                   (1 credit)              pending_cancels[]}
Started{job, attempt}                     Assign{job, attempt, timeout, payload}
Heartbeat{job, attempt, cpu_ms}           Cancel{job, attempt, reason, grace}
Finished{job, attempt, outcome, cpu_ms}   Reject{job, attempt}   -> stop the container

The rule that matters most is no prefetch. The dispatcher sends Assign only after the worker sent Ready, and a worker has at most one credit because it runs one job. So the SFQ pick happens at the last possible moment, when a container is actually free, with the freshest tags. A scheduler that hands out jobs ahead of time is making decisions on stale information.

A heartbeat from an idle worker is a slot heartbeat: the container is up. A heartbeat with a job is a lease renewal. Both go on the same stream.

v6: fairness with a memory

Everything up to here is v5, and it is fair in the moment: at any instant, the tenant furthest behind its share runs next. It has no memory. A Gold tenant that hammered the system all of yesterday has exactly the same claim this morning as a Gold tenant that did nothing. Slurm calls the fix fair-share with a usage half-life, and v6 adds the same idea.

Decay model: each tenant keeps a usage value and a timestamp; on every read the usage is multiplied by two to the power of minus elapsed over half-life, then the new job's cost is added

Each tenant keeps one number, usage in CPU milliseconds, and the time it was last updated. Nothing runs on a timer. Whenever the scheduler reads the tenant it decays the value for the time that passed, then charges the new job:

elapsed          = now - usage_updated_at
usage            = usage * 2^(-elapsed / half_life)   -- forget, smoothly
usage            = usage + est_cost                    -- charge the new job
usage_updated_at = now

After one half-life, half the usage is left. Two steps of 7 and 14 days give exactly the same result as one step of 21, so the lazy update is exact. No cron job, no daily cliff, and nothing a tenant can game by timing its submissions around a reset.

The usage lowers the weight, and the weight enters SFQ in the one place it always did:

Updated weight: factor equals two to the power of minus usage over norm; weight equals tier weight times multiplier times factor; with the integer form using a Q16 half_power table

norm is the usage that halves the weight. With Gold at 8 and a norm of 10000 CPU seconds, a Gold tenant that has burned 10000 seconds recently is scheduled like an idle Silver, and at 20000 like an idle Bronze. The weight can never go above the tier weight, so this is purely a penalty, and the tag formula is unchanged, so virtual_now stays monotonic.

Recovery example: a Gold tenant with a 20000 CPU second burst on day 0 recovers from weight 2 to weight 7.7 over five weeks with a 7 day half-life

The penalty follows the load, not the calendar. A tenant that keeps running keeps adding usage and does not recover. A tenant that stops recovers over a few half-lives.

Three implementation choices:

  • Integers only. half_power(n, d) approximates 2^(-n/d) in Q16 fixed point: shift by the whole part, look up the fraction in a table. No floats, so the replay tests stay exact on every machine, and the same table is used by the SQL functions and by the Rust core.
  • The weight used at dispatch is stored on the job. The cost correction at Finished divides by weight_at_dispatch, not by a fresh weight, because the tag step was taken with that one.
  • It is optional. decay is an Option<DecayConfig> on the scheduler config. None gives the v5 behaviour, so the phase 1 tests do not change.

And a free consequence: a tier change while jobs are queued needs no special handling, because the weight is calculated again at every dispatch.

What exists today

The repository is a Cargo workspace with a library crate, barista-core, and a thin binary. The core has no async code, no I/O and no clock. The caller passes in every timestamp, which is what makes the replay tests possible: feed a scripted sequence of submissions and events, assert the exact dispatch order.

ids, time         identifiers, Timestamp, CpuTime, VirtualTime
tenant, resource  tiers, weights, sm/md/lg
lifecycle         JobState, JobEvent, apply(state, event) -> Changed | Unchanged
job               a job and its lifecycle
sfq               start_tag, finish_tag, advance_virtual_now
scheduler         the in-memory scheduler: enqueue, dispatch(resource, now), apply

That is phase 1 of six, and it is done. The phases after it:

2  policies and property tests   StealPolicy with PreferFit and Reserve; proptest:
                                 no starvation, bounded service gap, cap never exceeded,
                                 virtual_now never decreases, duplicates are Unchanged
3  persistence                   barista-store: Store trait, in-memory and PostgreSQL
                                 (sqlx), the dispatch transaction, the reaper, outbox
4  runtime                       barista-proto (prost + protox), the worker stream,
                                 the client API, the lease reaper
5  cost correction               correct finish_tag with actual CPU; EMA per job type
6  later                         decay (now designed in v6), preemption, DRR,
                                 a CPU-seconds cap

The crate is held to a strict lint set: no unwrap or expect, no panics, no indexing, no lock held across an await, every suppression needs a written reason. I wrote about that setup separately in Rust code hygiene and harness in the age of AI.

Still open

The board keeps its own list of what is not decided, and I would rather show it than pretend the design is finished.

  • Preemption is "optional" on the feature list and undefined everywhere else. The honest default is none.
  • The burst of big jobs from the 2DFQ section. First step is a cap in CPU seconds in flight per tenant.
  • Where the cost estimate comes from. The submission, or a moving average per job type. Probably both, with the average winning once it has data.
  • One norm for every tier bites heavy tiers more than Free. Whether that is a feature or a bug needs numbers from a real workload.
  • Client transport. gRPC all the way, or an HTTP and JSON gateway for callers outside the cluster.
  • PostgreSQL is a single point of failure. A synchronous replica and failover, with lease_timeout longer than the failover time. Leases live in rows, so they survive it.
  • Self-reported CPU. A worker that reports its own cpu_ms can bill any tenant any amount. Reading cgroup values on the host side is the fix, and it is not designed yet.

If you want to poke at the design, the v6 board, the lifecycle diagram and the deployment diagram all open in Excalidraw.