The Event-Driven Machine  ·  Chapter 04  ·  measured, not recalled

Dirty and Processing

One hundred thousand Add calls for the same key leave the queue at Len() == 1. Two hundred more arriving while a worker holds that key leave it at Len() == 0 — they are absorbed, invisibly, and become exactly one reconcile the moment Done is called. Under the race detector, 16 workers and 320,000 adds produced zero same-key concurrency violations; the same harness on a plain channel produced 21,940. Two small sets do all of this.

measured on go1.27.1 darwin/arm64 (Apple silicon)
client-go v0.37.0  ·  controller-runtime v0.25.0  ·  Kubernetes v1.37.0 baseline
mode A — concurrency demo run under go run -race, with a failing control to prove the detector works

Section 01

Three sets, and the invariant that ties them

The workqueue is not a list with a deduplication check bolted on. It is three structures maintained together, and the comment on the first one states the invariant the other two exist to preserve.

client-go v0.37.0 · util/workqueue/queue.go:191rung 2 · read the source
// queue defines the order in which we will work on items. Every
// element of queue should be in the dirty set and not in the
// processing set.
queue Queue[t]

// dirty defines all of the items that need to be processed.
dirty sets.Set[t]

// Things that are currently being processed are in the processing set.
// These things may be simultaneously in the dirty set. When we finish
// processing something and remove it from this set, we'll check if
// it's in the dirty set, and if so, add it to the queue.
processing sets.Set[t]
sed -n '190,204p' $(go env GOMODCACHE)/k8s.io/client-go@v0.37.0/util/workqueue/queue.go

dirty answers “does this need work?”. processing answers “is someone on it?”. queue is only the ordering, and it is allowed to hold an item only when the first answer is yes and the second is no. Every behaviour in this chapter falls out of maintaining that.

dirty needs work membership = dedup queue ordering only dirty AND NOT processing processing a worker holds it membership = the lock Add: push only if not processing Get: move and clear from dirty Add() while held — re-enters dirty, does NOT touch the queue Done: push back only if still dirty the amber arc is the whole mechanism: arbitrarily many adds during one reconcile become one entry, and none are lost
One key's path through three sets. The amber arc is the case that matters: an Add arriving while a worker holds the key re-enters dirty but is forbidden from touching queue. Done is the only thing that can move it back, and only if dirty still has it.

Section 02

One hundred thousand adds, one entry

Deduplication is a single set lookup at the top of Add. It costs one hash probe and it is unconditional.

client-go v0.37.0 · util/workqueue/queue.go:227rung 2 · read the source
func (q *Typed[T]) Add(item T) {
	...
	if q.dirty.Has(item) {
		// the same item is added again before it is processed, call the Touch
		// function if the queue cares about it (for e.g, reset its priority)
		if !q.processing.Has(item) {
			q.queue.Touch(item)
		}
		return
	}

	q.metrics.add(item)

	q.dirty.Insert(item)
	if q.processing.Has(item) {
		return
	}

	q.queue.Push(item)
	q.cond.Signal()
}
demos/dedup · part Arung 1 · measured
A. Add("default/target") x 100000
   queue Len() = 1
go run ./04-workqueue/demos/dedup
Finding

There are two separate early returns in that function and they do different jobs. The first (dirty.Has) is deduplication. The second (processing.Has) is the single-writer lock. An item can be dirty and processing at once — that is the state the whole design hangs on, and it is why the two sets cannot be collapsed into one.

Section 03

Where adds go while a worker holds the key

The interesting case is not a quiet queue. It is 200 events arriving for an object that is being reconciled right now — which is the normal condition under load, as chapter 01 measured.

demos/dedup · part B — worker holds the key, then 200 Add()s arriverung 1 · measured
B. one worker holds the key, then 200 more Add()s arrive
   Len() while key is held, after 200 adds : 0   <- adds went to `dirty`, not the queue
   Len() observed by the worker before Done: 0
   Len() immediately after Done()          : 1   <- Done() re-queued it, once
   further Get()s needed to drain          : 1   <- 200 adds produced exactly this many reconciles
client-go v0.37.0 · util/workqueue/queue.go:289rung 2 · read the source
func (q *Typed[T]) Done(item T) {
	...
	q.processing.Delete(item)
	if q.dirty.Has(item) {
		q.queue.Push(item)
		q.cond.Signal()
	} else if q.processing.Len() == 0 {
		q.cond.Signal()
	}
}
time Get() key → processing worker is reconciling this key 200 × Add("default/target") Len() = 0 all absorbed into dirty Done() dirty? → push once Len() = 1 1 reconcile 200 adds → 1 reconcile, with no wakeup lost: the count is discarded, the fact that work is owed is not
Queue length is zero for the entire window in which 200 events arrive. A depth metric sampled here reports an idle queue while 200 changes are outstanding — the work is real, it is just recorded in dirty, which Len() does not count.
Consequence for your dashboards

Len() counts the queue, not dirty. During the busiest moment for a given key, its depth contribution is zero. Queue depth is a reasonable signal for “work is backing up across many keys” and a poor one for “how much has changed” — those differ by exactly the absorbed adds.

Section 04

Serial per key, parallel across keys

The controller-writing guide promises that “no two goroutines will work on the same item at the same time.” That is a concurrency claim, so it deserves a concurrency test: sixteen workers, sixty-four keys, eight producers, and a detector that flags any instant two workers hold the same key.

demos/serialize · run under the race detectorrung 1 · measured
queue implementation            : client-go workqueue
workers                         : 16
distinct keys                   : 64
Add() calls from 8 producers    : 320000
reconciles actually executed    : 148497
collapse ratio                  : 2.2x
max distinct keys in flight     : 14  (workers=16)
SAME-KEY CONCURRENCY VIOLATIONS : 0   <- must be 0
elapsed                         : 1.793s
go run -race ./04-workqueue/demos/serialize
Finding

Both halves of the guarantee are visible in one run. Zero same-key violations, so the per-key serialisation holds. And 14 distinct keys in flight at peak against 16 workers, so the queue is not serialising globally — it parallelises freely across keys while locking per key.

This is what makes raising MaxConcurrentReconciles safe by default. You get more throughput across objects without ever needing a per-object mutex of your own, because processing already is one.

Section 05

The control that fails on purpose

A detector that reports zero is worthless until you have seen it report something. The same harness, with the workqueue swapped for a plain buffered channel — no dirty, no processing, no Done — is the control.

demos/serialize -naive · CONTROL, expected to failrung 1 · measured
queue implementation            : plain channel (CONTROL, expected to fail)
workers                         : 16
distinct keys                   : 64
Add() calls from 8 producers    : 320000
reconciles actually executed    : 320000
collapse ratio                  : 1.0x
max distinct keys in flight     : 16  (workers=16)
SAME-KEY CONCURRENCY VIOLATIONS : 21940   <- must be 0
elapsed                         : 576ms
go run ./04-workqueue/demos/serialize -naive
identical harness 16 workers, 64 keys 320,000 Add()s client-go workqueue dirty + processing + queue plain buffered channel ordering only, no sets reconciles 148,497 same-key violations 0 reconciles 320,000 same-key violations 21,940 the channel is not slower or buggier — it is faster (576 ms vs 1.79 s) because it does 2.2× more work, incorrectly
The control is faster and wrong. It finished in a third of the time by skipping the collapse and doing 320,000 reconciles instead of 148,497 — 21,940 of them on an object another worker was already reconciling. This is what “we replaced the workqueue with a channel for simplicity” buys.
Finding — what the detector is worth

The control is the reason the zero in §04 means anything. The same detector, the same workers, the same key distribution, and it fires 21,940 times when the two sets are removed. A passing test that has never been seen to fail is not evidence.

Section 06

Further reading

All links returned HTTP 200 on 2026-09-11 unless noted.

Section 07

Closing note — what varies, and what was not verified

Durable versus run-specific

  • Durable: Len() == 1 after 100,000 adds; Len() == 0 while the key is held regardless of how many adds arrive; Len() == 1 immediately after Done; exactly one further reconcile; 0 same-key violations with the workqueue and a non-zero count with the channel control.
  • Run-specific: 148,497 reconciles, the 2.2× collapse ratio, 14 keys in flight, 21,940 violations, and both elapsed times. All depend on scheduler timing, core count, and the 20 µs artificial hold. The signs are durable: workqueue collapse > 1× with 0 violations; channel collapse = 1.0× with many.

Traps this chapter had to avoid

  • A detector that cannot fail. Reporting “0 violations” from a harness never observed failing proves nothing — it is indistinguishable from a broken detector. The -naive control exists solely to make the zero meaningful, and it is a demo designed to fail.
  • Holding the key too briefly to overlap. With no artificial hold, workers finish before another can collide, and even the broken control reports zero. The 20 µs sleep exists to make overlap possible; without it the test is vacuous in both arms.
  • Reading Len() as “outstanding work”. It is the queue slice only. During part B it reads 0 while 200 changes are pending.

Not verified

  • The priority queue. §02–§05 measure the plain client-go Typed workqueue. controller-runtime defaults to its own priority queue (UsePriorityQueue, default true), which wraps the same dirty/processing discipline but adds ordering by priority. Chapter 05 covers it; nothing here measured it.
  • Rate limiting and delayed adds. AddRateLimited, AddAfter and the backoff curve are chapter 05's subject and were not exercised here.
  • Metrics behaviour. q.metrics.add / done appear in the quoted source; no metrics provider was registered, so their behaviour was not observed.
  • Shutdown semantics. ShutDown versus ShutDownWithDrain differ in whether in-flight items are awaited; only the former was used, and not tested for drain behaviour.

Section 08

Spoken drills

Why does the workqueue need two sets rather than one? What state is impossible to express with one?

A strong answer hits

  • An item must be able to be dirty and processing simultaneously — work owed on something already in flight
  • One set cannot distinguish “needs work” from “someone is on it”
  • Add has two early returns doing two different jobs: dedup, then the single-writer lock
  • Done is the only transition that can re-queue, and only when dirty still holds it
  • The honest half: queue is a third structure but carries no semantics — it is purely ordering, and the invariant says it may only hold items that are dirty and not processing

check against §01 and §02

Your queue-depth dashboard reads zero during an incident where a controller is visibly behind. Is the dashboard broken?

A strong answer hits

  • No — Len() counts the queue slice only, not dirty
  • While a key is being processed, every add for it lands in dirty and is invisible to Len()
  • Measured: 200 adds arrived and Len() stayed at 0 throughout
  • Depth is a signal about many keys backing up, not about how much changed
  • The honest half: for a controller with many keys, depth is still a useful backlog signal — the blind spot is per-key, and it is worst exactly when one hot object is being hammered

check against §03

A colleague wants a per-object mutex in the reconciler “so two workers can’t stomp on each other.” Respond.

A strong answer hits

  • The processing set already is that mutex, keyed by the queue item
  • Measured: 16 workers, 320,000 adds, 0 same-key violations under -race
  • Parallelism across keys is preserved — 14 distinct keys in flight at peak
  • The control proves the guarantee is doing the work: a plain channel gave 21,940 violations
  • The honest half: it only holds per queue item. Two different controllers watching the same object, or a reconciler that writes objects other than its request key, are outside the guarantee entirely

check against §04 and §05

You see “0 same-key violations” in a test report. What is the first question you ask?

A strong answer hits

  • Has this detector ever been observed failing? A zero from an untested detector is not evidence
  • Is the work held long enough for an overlap to be possible at all?
  • Here: the -naive control fires 21,940 times, and the 20 µs hold makes overlap reachable
  • The honest half: the control also changes the collapse ratio to 1.0×, which is a second, independent signal that the two arms really are different — a control that matched on every axis but the violation count would be more suspicious, not less

check against §05