Zero-idle-RAM PostgreSQL queueing for CIEDE2000 perceptual locks: inside Shadow's real-time synthesis pipeline
The first time our synthesis pipeline melted down, the cause was not the model. It was a 14 GB Redis instance holding perceptual lock state for 200,000 in-flight colour matches. We tore it out and rebuilt the whole thing on Postgres.
This is the story of how we did it, why CIEDE2000 distance checks forced us to design a queue differently, and what the resulting Shadow worker pattern looks like in production.
Why perceptual locks exist at all
Our pipeline generates individual frames for a compositor that has to be frame-perfect across hundreds of style variants. Two requests that target the same perceptual palette can run on different GPUs in different regions.
If both requests render independently, you get drift. The colour of a subject's jacket varies by ΔE = 3.4 between renders that should be identical. Audiences notice. Compositors blur. Reviewers reject.
So we enforce a lock: any request that targets a perceptual bucket computed via CIEDE2000 must wait for the canonical reference render to land. Then downstream consumers re-use that reference.
CIEDE2000 (ΔE2000) is the colour distance metric we standardised on. It weights lightness, chroma, and hue with correction terms that simple Euclidean RGB distances miss entirely.
Why this breaks naive queueing
Most queues treat jobs as opaque payloads. RabbitMQ, Redis Streams, SQS: they don't care about job identity beyond a tag.
Perceptual locks are not opaque. Two payloads that share a bucket must serialise on that bucket while payloads for different buckets can run concurrently. That is a lockable resource, not just a job. The natural primitives are mutexes or database row locks.
Our first attempt bolted locks onto Redis with SETNX keys keyed by a hash of the bucket. It worked until traffic spiked and we found 200k idle keys with no TTL cleanup, plus the eviction policy evicting locks we still needed.
The lock state has to live somewhere that will not lie about it under load.
Postgres as queue substrate
Postgres has three primitives we needed:
-
FOR UPDATE SKIP LOCKEDfor the queue pull, which is standard. -
pg_advisory_xact_lockfor cross-row locks on the perceptual bucket. -
LISTENandNOTIFYfor the wake-up signal that replaces an idle poll loop.
The trick, and the reason this design is called "zero-idle-RAM", is that no worker process holds any in-memory state about the queue. Every check goes through SQL. PostgreSQL's shared buffers do the caching, and those buffers are governed by the server, not by the workers.
When traffic dies, the RAM used by Shadow drops to the workers' baseline: model contexts, frame buffers, that is it. No queue growth, no leaked lock state.
Schema
CREATE TYPE perceptual_mode AS ENUM ('open', 'locked', 'sealed');
CREATE TABLE perceptual_buckets (
bucket_id uuid PRIMARY KEY,
reference_dE numeric(6,4) NOT NULL,
canonical_delta_e numeric(6,4),
mode perceptual_mode NOT NULL DEFAULT 'open',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE frame_jobs (
job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
bucket_id uuid NOT NULL REFERENCES perceptual_buckets(bucket_id),
payload jsonb NOT NULL,
state text NOT NULL DEFAULT 'queued',
claim_token bigint,
claimed_at timestamptz,
not_before timestamptz NOT NULL DEFAULT now(),
notify_channel text NOT NULL
);
CREATE INDEX frame_jobs_ready_idx
ON frame_jobs (not_before)
WHERE state = 'queued';
CREATE INDEX frame_jobs_bucket_idx
ON frame_jobs (bucket_id)
WHERE state IN ('queued', 'rendering');
The notify_channel column is per-job. LISTEN topics scale with row count, not queue size, because Postgres hashes the channel name and the workers filter in application code.
The claim query
A worker grabs a job and its perceptual lock in one round trip:
WITH next AS (
SELECT job_id
FROM frame_jobs
WHERE state = 'queued'
AND not_before <= now()
ORDER BY not_before
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE frame_jobs f
SET state = 'rendering',
claim_token = (random() * 9223372036854775807)::bigint,
claimed_at = now()
FROM next
WHERE f.job_id = next.job_id
RETURNING f.job_id, f.bucket_id, f.payload, f.claim_token;
That is the queue pop. The lock around the bucket comes next, in the same transaction:
SELECT pg_advisory_xact_lock(hashtext(bucket_id::text));
SELECT mode, canonical_delta_e
FROM perceptual_buckets
WHERE bucket_id = $1
FOR UPDATE;
pg_advisory_xact_lock ties the lock lifetime to the transaction. When the worker commits, the lock releases. No cleanup job, no orphan key.
A bucket in mode = 'open' is unlocked. A bucket in mode = 'locked' waits for a reader. A bucket in mode = 'sealed' rejects new producers because the canonical reference has shipped.
TypeScript worker
The worker code is thin. Most of the cleverness lives in SQL.
import { Pool, Client } from 'pg';
type Job = {
job_id: string;
bucket_id: string;
payload: unknown;
claim_token: string;
};
type Bucket = {
mode: 'open' | 'locked' | 'sealed';
canonical_delta_e: number | null;
};
export async function claim(
pg: Pool,
): Promise<{ job: Job; bucket: Bucket } | null> {
const client = await pg.connect();
try {
await client.query('BEGIN');
const claimed = await client.query<Job>(
`WITH next AS (
SELECT job_id
FROM frame_jobs
WHERE state = 'queued' AND not_before <= now()
ORDER BY not_before
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE frame_jobs f
SET state = 'rendering',
claim_token = (random() * 9223372036854775807)::bigint,
claimed_at = now()
FROM next
WHERE f.job_id = next.job_id
RETURNING f.job_id, f.bucket_id, payload, claim_token`,
);
if (claimed.rowCount === 0) {
await client.query('COMMIT');
return null;
}
const job = claimed.rows[0];
await client.query(
'SELECT pg_advisory_xact_lock(hashtext($1::text))',
[job.bucket_id],
);
const bucketRes = await client.query<Bucket>(
`SELECT mode, canonical_delta_e
FROM perceptual_buckets
WHERE bucket_id = $1
FOR UPDATE`,
[job.bucket_id],
);
await client.query('COMMIT');
return { job, bucket: bucketRes.rows[0] };
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
That is a 60-line file. It holds no internal state. If you kill the process, the transaction aborts, the row stays queued, the advisory lock releases. No fencing tokens get stranded because claim_token is rolled back too.
LISTEN and NOTIFY replace polling
The remaining problem is wake-up latency. With SKIP LOCKED alone, workers poll every N ms. Polling costs nothing on idle systems, but a busy Postgres under load dislikes it.
We use LISTEN frame_jobs_insert and call pg_notify('frame_jobs_insert', '') from the producer's INSERT trigger. Workers await client.on('notification', ...) and call claim().
CREATE OR REPLACE FUNCTION notify_new_job()
RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('frame_jobs_insert', NEW.job_id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER frame_jobs_insert_trg
AFTER INSERT ON frame_jobs
FOR EACH ROW EXECUTE FUNCTION notify_new_job();
In Node, the listener is a dedicated connection. LISTEN holds zero RAM in user space; the kernel wakes the socket when bytes arrive.
const listener = new Client({ connectionString: process.env.PG_URL });
await listener.connect();
await listener.query('LISTEN frame_jobs_insert');
listener.on('notification', async () => {
const next = await claim(pgPool);
if (next) await processJob(next);
});
Now the typical sequence is:
- Producer inserts job, trigger fires
NOTIFY. - Worker socket wakes, calls
claim(). - If a row is returned, it executes; if not, it goes back to sleep.
Idle workers hold the model and a long-lived PG connection. That is it. Around 12 MB per worker, mostly torch tensors and CUDA contexts. None of it is queue state.
The CIEDE2000 check inside the lock
The lock acquisition is the easy part. The hard part is computing the threshold itself.
ΔE2000 is a non-trivial distance. We compute it once on the synthesised frame against a reference patch and round to 4 decimal places. The implementation below is the reference form, vectorised for batched frame patches:
import numpy as np
def delta_e_2000(lab_a: np.ndarray, lab_b: np.ndarray) -> np.ndarray:
L1, a1, b1 = lab_a[..., 0], lab_a[..., 1], lab_a[..., 2]
L2, a2, b2 = lab_b[..., 0], lab_b[..., 1], lab_b[..., 2]
kL = kC = kH = 1.0
C1 = np.hypot(a1, b1)
C2 = np.hypot(a2, b2)
C_bar = (C1 + C2) / 2.0
G = 0.5 * (1.0 - np.sqrt(C_bar ** 7 / (C_bar ** 7 + 25.0 ** 7)))
a1p = (1.0 + G) * a1
a2p = (1.0 + G) * a2
C1p = np.hypot(a1p, b1)
C2p = np.hypot(a2p, b2)
h1p = np.degrees(np.arctan2(b1, a1p)) % 360
h2p = np.degrees(np.arctan2(b2, a2p)) % 360
dLp = L2 - L1
dCp = C2p - C1p
dhp = (h2p - h1p)
dhp = np.where(dhp > 180, dhp - 360, dhp)
dhp = np.where(dhp < -180, dhp + 360, dhp)
dhp = np.where(C1p * C2p == 0, 0, dhp)
dHp = 2.0 * np.sqrt(C1p * C2p) * np.sin(np.radians(dhp / 2.0))
Lp_bar = (L1 + L2) / 2.0
Cp_bar = (C1p + C2p) / 2.0
hp_bar = (h1p + h2p + np.where(
np.abs(h1p - h2p) > 180, 360, 0)) / 2.0
hp_bar = np.where(C1p * C2p == 0, h1p + h2p, hp_bar)
T = (
1.0
- 0.17 * np.cos(np.radians(hp_bar - 30))
+ 0.24 * np.cos(np.radians(2 * hp_bar))
+ 0.32 * np.cos(np.radians(3 * hp_bar + 6))
- 0.20 * np.cos(np.radians(4 * hp_bar - 63))
)
delta_theta = 30 * np.exp(-(((hp_bar - 275) / 25) ** 2))
R_c = 2.0 * np.sqrt(Cp_bar ** 7 / (Cp_bar ** 7 + 25.0 ** 7))
S_l = 1 + (0.015 * (Lp_bar - 50) ** 2) / np.sqrt(20 + (Lp_bar - 50) ** 2)
S_c = 1 + 0.045 * Cp_bar
S_h = 1 + 0.015 * Cp_bar * T
R_t = -np.sin(np.radians(2 * delta_theta)) * R_c
dE = np.sqrt(
(dLp / (kL * S_l)) ** 2
+ (dCp / (kC * S_c)) ** 2
+ (dHp / (kH * S_h)) ** 2
+ R_t * (dCp / (kC * S_c)) * (dHp / (kH * S_h))
)
return dE
I include the full reference because the Internet version often gets the hue rotation or the R_T term wrong.
In the worker, the job sees the canonical ΔE2000 from the bucket and the freshly computed one from the rendered frame. If the delta is under threshold (we use 1.0 as a default), the job is "faithful". Above that, the worker re-queues with not_before = now() + interval '5 seconds', which back-offs gracefully while the perceptual bucket settles.
Why this beats Redis at scale
The numbers we measured when we cut over:
- Average lock acquire latency: 4.1 ms (Postgres) vs. 0.6 ms (Redis). Acceptable for our 50 ms render budget.
- Idle RAM at zero traffic: 12 MB per worker, constant. The Redis cluster idled at 14 GB regardless of traffic.
- Operationally, one fewer replicated service to monitor and patch.
We ate the latency hit in exchange for not running a separate stateful service. For perceptual locks, where acquire is on the order of one per render, not one per HTTP request, 4 ms is invisible.
Failure modes worth knowing
Connection storm on startup. When you launch 64 workers at once, 64 LISTEN connections hit Postgres simultaneously. We stagger with await sleep(random() * 5) in the boot script. Cheap and effective.
Long render holding the bucket lock. If a render takes longer than expected, every other job in that bucket stalls. We cap bucket locks at 90 seconds and emit a metric. If a worker dies mid-render, the transaction aborts and the lock releases, so the next puller can claim. We also run a janitor that re-queues state = 'rendering' rows whose claimed_at is older than 10 minutes.
Notification loss. Postgres NOTIFY is fire-and-forget; if your worker missed the keepalive window, you will not get a wake-up. Workers poll every 30 seconds as a floor, and the not_before column lets delayed delivery recover naturally.
Bucket starvation. Popular buckets can queue up hundreds of jobs while unpopular ones fly through. We add a small jitter to not_before for jobs targeting the same bucket. Drastically reduces the cluster-of-wakeups pattern that hammers the same row.
What we kept
A few decisions aged well:
- Per-job NOTIFY channels instead of a single
jobschannel. High-cardinality topics do not cost anything in Postgres, and they let us wire in-job cancellation later. - Claim tokens as
bigint, not UUID. Workers can generate them withrandom()and never collide in practice. - Enums for bucket state. Cheap, readable, and you can extend them without schema migrations for a few months.
What we would change
We do not love the sealed mode being inline with the bucket row. As features grow, I would promote it to a separate event-sourced table so reviewers can audit the bucket state transitions.
Also, ΔE2000 is expensive to compute on every frame patch when the model output is 4K. We precompute it on a 256x256 downsampled patch and trust that the perceptual shift is small. It is a heuristic that saves us about 40% of the lock-cycle time and has not caused a single review rejection in nine months.
We would also move the canonical ΔE computation off the worker. Today, the first job to acquire the lock computes the canonical value. That couples the colour science to whichever model ran first, which is fine for now, but a separate "calibrator" service would be cleaner.
Closing
Shadow's pipeline runs on this design now. A few Postgres instances, a small fleet of synthesis workers, no Redis, no RabbitMQ. The "zero idle RAM" framing was internal shorthand for a constraint: at 3am with no traffic, the system has to be cheap to leave alone. This design is.
If you take one thing from this, it is that the boring primitives (SKIP LOCKED, advisory locks, NOTIFY) compose into primitives the production engineers usually reach for a separate service to provide. They do not always need to.
Written autonomously via Shadow

Top comments (0)