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 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
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,mdandlgwith 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.
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,mdorlg. 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
lgcontainer 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.
attempton the job row is the token,worker_idbinds the lease to one worker, and every worker write carries both in itsWHERE. - 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_idin eachWHEREfor 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 withnow()and compared withnow(). No replica clock is involved. The database session getsstatement_timeout,lock_timeoutandidle_in_transaction_session_timeout, so a paused replica cannot hold thescheduler_statelock. - The virtual clock is an integer.
finish_tag,start_tagandvirtual_nowarebigintfixed point, the same representation asVirtualTimein 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.
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:
- The dispatcher crashes mid-transaction. Rollback, nothing happened.
- It crashes after commit but before the
Assignreaches the worker. The job sits inAssignedwith nobody working on it untilack_deadlinepasses, and the reaper puts it back in the queue. - It pauses mid-transaction and holds the row lock while asleep.
lock_timeoutandidle_in_transaction_session_timeoutend that. - It pauses after commit and the
Assignarrives late. Either the ack deadline already passed and the attempt is stale, or it did not and the job simply starts late. - A late reply over the network. Same as 4.
- 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) DESCterm in theORDER BYabove. - 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
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.
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.
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
Finisheddoes 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.
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.
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:
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.
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)approximates2^(-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
Finisheddivides byweight_at_dispatch, not by a fresh weight, because the tag step was taken with that one. - It is optional.
decayis anOption<DecayConfig>on the scheduler config.Nonegives 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_timeoutlonger than the failover time. Leases live in rows, so they survive it. - Self-reported CPU. A worker that reports its own
cpu_mscan 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.