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

The Queue That Changed

Driving a DeltaFIFO and a RealFIFO with the identical six-event sequence, both emit six deltas — so the popular claim that RealFIFO “stopped deduplicating” is wrong about ordinary traffic. What actually changed is cross-key ordering, and one genuinely lossy case: DeltaFIFO silently discards a deletion for an object it does not already know about. RealFIFO delivers it. This chapter measures the swap, and the release note everyone cites is off by three versions.

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 — both FIFOs driven directly, in-process; feature-gate behaviour executed, not quoted

Section 01

Four parts, and only one of them moved

An informer is a fixed pipeline: a Reflector holding the network connection, a FIFO of deltas, an Indexer that is the in-memory cache, and the event handlers that fire as the cache is maintained. Three of those four have been stable for years. The FIFO was replaced.

API server watch stream Reflector List + Watch THE SWAPPED PART DeltaFIFO ↓ RealFIFO default since 1.33 Pop processDeltas 1. update store Indexer (cache) 2. notify handlers event handlers store is written before handlers run — measured in §05
Only the FIFO stage changed. The Reflector, the Indexer and the handler fan-out are the same code they have been for years. Everything this chapter measures is the consequence of replacing one queue with another that has different ordering and different deletion semantics.

Section 02

Same input, two output shapes

Both queue types are exported, so the comparison needs no cluster and no informer — construct one of each, apply an identical sequence, then drain. The sequence interleaves two objects deliberately, because that is where the behaviours separate.

demos/fifo-ordering · client-go v0.37.0rung 1 · measured
event sequence applied to BOTH queues, no Pop until fully queued:
  add alpha@rv1, add beta@rv1, update alpha@rv2, update beta@rv2, add gamma@rv1, delete gamma

DeltaFIFO  (pre-1.33 default):
  pop 1 -> 2 delta(s): [Added alpha@rv1] [Updated alpha@rv2]
  pop 2 -> 2 delta(s): [Added beta@rv1] [Updated beta@rv2]
  pop 3 -> 2 delta(s): [Added gamma@rv1] [Deleted gamma@rv1]
  TOTAL: 3 Pop(s), 6 delta(s)

RealFIFO   (default since 1.33, locked 1.36):
  pop 1 -> 1 delta(s): [Added alpha@rv1]
  pop 2 -> 1 delta(s): [Added beta@rv1]
  pop 3 -> 1 delta(s): [Updated alpha@rv2]
  pop 4 -> 1 delta(s): [Updated beta@rv2]
  pop 5 -> 1 delta(s): [Added gamma@rv1]
  pop 6 -> 1 delta(s): [Deleted gamma@rv1]
  TOTAL: 6 Pop(s), 6 delta(s)
go run ./02-informers/demos/fifo-ordering
WATCH STREAM — arrival order Added alphaAdded betaUpdated alphaUpdated betaAdded gammaDeleted gamma DeltaFIFO — 3 pops, grouped by key alpha→beta→alpha→beta interleaving is gone; all of alpha arrives before any of beta pop 1 · 2 deltasAdded + Updated alphapop 2 · 2 deltasAdded + Updated betapop 3 · 2 deltasAdded + Deleted gamma RealFIFO — 6 pops, one delta each, arrival order preserved 6 deltas in, 6 deltas out of both queues — what differs is grouping and cross-key order, not delta count pop 1 · 1 deltaAdded alphapop 2 · 1 deltaAdded betapop 3 · 1 deltaUpdated alphapop 4 · 1 deltaUpdated betapop 5 · 1 deltaAdded gammapop 6 · 1 deltaDeleted gamma
Six deltas in, six deltas out — but in two different shapes. The faint connectors show each arrival landing in its RealFIFO pop at the same horizontal position. DeltaFIFO’s three wide boxes span two arrivals each, which is exactly the interleaving being destroyed.
Finding — this corrects the usual write-up

Six deltas went in and six came out of both queues. DeltaFIFO did not discard anything here; it grouped deltas by key and returned each group in a single Pop. The widely repeated summary that RealFIFO “removed deduplication” does not describe this, the ordinary case.

What DeltaFIFO destroyed is the interleaving. The stream was alpha, beta, alpha, beta; DeltaFIFO emitted all of alpha, then all of beta. Cross-key arrival order is unrecoverable once grouping has happened — which is exactly what the gate's own name says it fixes: “deliver watch stream events in order instead of out of order.”

Why the reconciler still did not care

Chapter 01 established that a reconciler reads current state and tolerates reordering. That is still true, and RealFIFO does not change it. The consumers that needed ordering are the cache and the event handlers — code that applies a sequence of mutations rather than converging on a level.

Section 03

The deletion that vanishes

If ordinary traffic survives both queues intact, where is the loss the doc comment advertises? It is in Delete, and it is conditional. Reading the implementation first:

client-go v0.37.0 · tools/cache/delta_fifo.go:408rung 2 · read the source
func (f *DeltaFIFO) Delete(obj interface{}) error {
	...
	} else {
		_, exists, err := f.knownObjects.GetByKey(id)
		_, itemsExist := f.items[id]
		if err == nil && !exists && !itemsExist {
			// Presumably, this was deleted when a relist happened.
			// Don't provide a second report of the same deletion.
			return nil
		}
	}

RealFIFO’s equivalent has no guard at all — it appends the delta unconditionally:

client-go v0.37.0 · tools/cache/the_real_fifo.go:363rung 2 · read the source
func (f *RealFIFO) Delete(obj interface{}) error {
	f.lock.Lock()
	defer f.lock.Unlock()

	f.populated = true
	f.checkSynced_locked()
	retErr := f.addToItems_locked(Deleted, false, obj)

	return retErr
}
grep -n "func (f \*RealFIFO) Delete" -A 9 $(go env GOMODCACHE)/k8s.io/client-go@v0.37.0/tools/cache/the_real_fifo.go

Running both cases makes the divergence concrete.

demos/fifo-lost-deletesrung 1 · measured
CASE 1 — Delete() an object that is in neither the queue nor knownObjects
         (what a relist-while-deleted race produces)
  DeltaFIFO yields: []
  RealFIFO  yields: [Deleted ghost]

CASE 2 — Add() then Delete() twice (duplicate deletion report)
  DeltaFIFO yields: [Added twice Deleted twice]
  RealFIFO  yields: [Added twice Deleted twice Deleted twice]
go run ./02-informers/demos/fifo-lost-deletes
Delete(ghost) not in knownObjects DeltaFIFO.Delete !exists && !itemsExist → return nil delta never queued yields [ ] RealFIFO.Delete no guard addToItems_locked(Deleted) delta queued yields [Deleted ghost] the guard exists to suppress a second report after a relist — its cost is suppressing the only report when the cache never saw the object
One conditional branch is the entire difference. DeltaFIFO’s guard was written to avoid double-reporting a deletion already handled by a relist. The failure mode is the symmetric one: if the object was created and deleted inside a single relist window, the cache never knew it existed, the guard fires, and the deletion is reported to nobody.
Finding

This is what “delivers notifications for items that have been deleted” means in the RealFIFO doc comment, and it is narrower than it sounds. Ordinary deletions were always delivered by both. The gap is specifically an object the informer’s cache has no record of — a create-then-delete that fits between relists, or a deletion arriving during initial sync.

For a level-triggered reconciler this was survivable: the object is gone, there is nothing to converge toward. For anything that counts events, emits metrics per deletion, or runs cleanup keyed on a delete callback, it was a silent hole.

Section 04

The version everyone cites is off by three

Secondary sources place this change at 1.36. The selection logic reads a gate, and the gate registry is unambiguous — it is worth reading rather than trusting a summary.

client-go v0.37.0 · features/known_features.go:127rung 2 · read the source
InOrderInformers: {
	{Version: version.MustParse("1.33"), Default: true, PreRelease: Beta},
	{Version: version.MustParse("1.36"), Default: true, PreRelease: GA, LockToDefault: true},
},
grep -n "InOrderInformers: {" -A 3 $(go env GOMODCACHE)/k8s.io/client-go@v0.37.0/features/known_features.go

Default-on since 1.33. What 1.36 added was LockToDefault. That is not a documentation nicety — it is executable, and it can be tested.

demos/gate-locked · attempting to opt outrung 1 · measured
$ go run ./02-informers/demos/gate-locked
KUBE_FEATURE_InOrderInformers env = ""
InOrderInformers enabled = true

$ KUBE_FEATURE_InOrderInformers=false go run ./02-informers/demos/gate-locked
KUBE_FEATURE_InOrderInformers env = "false"
E0911 16:54:16.390789  envvar.go:179] "Could not set feature gate, feature is locked"
  feature="InOrderInformers" desiredState="false" lockedState=true
InOrderInformers enabled = true
Finding

The gate refuses the override and logs an error. On client-go v0.37.0 there is no supported way to put DeltaFIFO back on the informer path. The code still exists in the tree and remains directly constructible — which is how the comparison in §02 was built — but nothing you set at runtime will route a SharedInformer through it.

The practical consequence: if your controller depended on DeltaFIFO’s grouping — for example by assuming one Pop per key, or by measuring queue depth in keys rather than deltas — that assumption broke at 1.33, not 1.36, and quietly.

1.301.311.321.331.341.351.361.37WatchListClient1.30 beta, off1.35 beta, ONInformerResourceVersion1.30 alpha1.35 GAInOrderInformers1.33 beta, ON1.36 GA + lockedInOrderInformersBatchProcess1.35 beta, ONAtomicFIFO1.36 beta, ONUnlockWhileProcessingFIFO1.36 beta, ON shaded: releases where InOrderInformers can no longer be disabled
The gate timeline, read from the registry rather than from release notes. InOrderInformers is already on at 1.33; the 1.36 marker is where it stops being optional. Two adjacent FIFO gates arrive only at 1.36 and remain Beta.

Section 05

Store before notify, measured

Chapter 01 rested on a guarantee: when a handler runs, the cache already holds at least the version that triggered it. That is what makes it safe for the handler to throw the object away and enqueue only a key. The ordering is visible in processDeltas — store mutation precedes the callback in every branch — but it is worth confirming empirically rather than by reading.

client-go v0.37.0 · tools/cache/controller.go · processDeltasrung 2 · read the source
case Sync, Replaced, Added, Updated:
	if old, exists, err := clientState.Get(obj); err == nil && exists {
		if err := clientState.Update(obj); err != nil { return err }
		handler.OnUpdate(old, obj)
	} else {
		if err := clientState.Add(obj); err != nil { return err }
		handler.OnAdd(obj, isInInitialList)
	}
demos/store-before-notify · handler reads the cache and compares resourceVersionsrung 1 · measured
handler invocations         : 101
cache HIT at handler time   : 101
cache MISS at handler time  : 0
cache STALE (data older than evt): 0   <- must be 0
cache already FRESHER (ahead)   : 0

-- proof the comparison has teeth --
events carrying a parsed value > 0 : 100
highest value seen by a handler    : 100
go run ./02-informers/demos/store-before-notify
time within one Pop clientState.Update(obj) cache now holds rv=N handler.OnUpdate(old, obj) lister.Get(key) observes rv ≥ N — always no window in which the cache is behind the event 101 of 101 handler invocations found the object; 0 stale, 0 missing
The ordering is what licenses the key-only queue. Reverse these two statements and enqueuing a bare key becomes a race: the worker could read a version older than the event that woke it, conclude no work was needed, and stop. The measurement found zero stale reads across 101 invocations.
A trap this demo fell into first

The first version compared resourceVersion between the event object and the cached one. It reported a perfect result — which was vacuous: the fake clientset never sets resourceVersion at all, so both sides parsed to zero and every comparison trivially matched. The fix was to compare the monotonically increasing data payload, which the fake genuinely does update, and to print evidence that the parsed values were real. The last two lines exist so the test can be seen to have teeth: 100 events carried values up to 100, and none of them found a stale cache.

Scope of this measurement

The batched path, processDeltasInBatch — reached when InOrderInformersBatchProcess is on, which is the default from 1.35 — uses a TransactionStore when the store implements one. That path was not traced here. The guarantee above is measured for the non-batched processDeltas and for the behaviour this fake-clientset informer actually exercised.

Section 06

The list step is now a stream

One more change sits upstream of the FIFO. The Reflector no longer necessarily performs a paged LIST to prime the cache; it can open a watch stream that replays initial state as synthetic events, then signals completion with a bookmark.

client-go v0.37.0 · tools/cache/reflector.gorung 2 · read the source
// useWatchList if turned on instructs the reflector to open a stream to bring data
// from the API server.
useWatchList bool
...
r.useWatchList = clientfeatures.FeatureGates().Enabled(clientfeatures.WatchListClient)
if r.useWatchList && watchlist.DoesClientNotSupportWatchListSemantics(lw) {
	... r.useWatchList = false      // automatic fallback
}

The gate flipped default recently: WatchListClient was Beta and off at 1.30, and Beta and on from 1.35. The enhancement is KEP-3157, whose title is the clearest one-line description of the feature: “Allow informers for getting a stream of data instead of chunking.” There is an automatic fallback when the server does not support watch-list semantics, so this is not a hard compatibility break.

Not verified here

Nothing in this chapter measured the streaming list. The fake clientset does not implement watch-list semantics, so every run above took the fallback path. The gate values and the fallback condition are quoted from source (rung 2); the runtime behaviour of a real streaming initial sync was not executed.

Section 07

Further reading

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

Section 08

Closing note — what varies, and what was not verified

Durable versus run-specific

  • Durable: the pop counts (3 vs 6), the grouping behaviour, the empty result in Case 1, the double-delete asymmetry, and the locked-gate refusal. These are deterministic — they involve no timing and no goroutines, and should reproduce byte-for-byte on client-go v0.37.0.
  • Run-specific: the timestamp in the gate error log, and the 101 handler invocations in §05 (101 rather than 100 because the initial Add from cache sync counts).

What was not verified

  • The batched delta path. processDeltasInBatch and its TransactionStore were read but not exercised. Everything in §05 covers the non-batched path.
  • The streaming list. See §06 — fallback was taken in every run here.
  • Which release first contained the_real_fifo.go. The file header says 2025 and the gate says 1.33, which are consistent, but the first tagged release containing the file was not established.
  • Real resourceVersion semantics. The fake clientset leaves resourceVersion empty on every object, so nothing here exercises version-based comparison, conflict detection, or watch resumption. The §05 ordering result rests on payload ordering instead.
  • Atomic events. AtomicFIFO and UnlockWhileProcessingFIFO are Beta as of 1.36 and were not exercised; RealFIFOOptions encodes constraints between them (atomic events require KnownObjects == nil) that were read, not tested.

A note on the comparison’s fairness

Both queues were given a KnownObjects store that was kept in sync by the driver, which is what an informer does. Constructing DeltaFIFO with knownObjects == nil takes a different branch in Delete that drops the notification under a different condition. The Case 1 result is specific to the configuration an informer actually uses.

Section 09

Spoken drills

Someone says “RealFIFO replaced DeltaFIFO in 1.36 and removed deduplication.” Two things are wrong. What are they?

A strong answer hits

  • Version: the gate defaults to true at 1.33; 1.36 is GA plus LockToDefault
  • Behaviour: six deltas in, six out of both — ordinary traffic is grouped, not deduplicated away
  • What actually changed is cross-key ordering and pop granularity
  • The honest half: there is a real dedup difference, but it is narrow — consecutive deletions, and deletes of objects the cache never knew

check against §02 and §04

Describe a concrete sequence where DeltaFIFO reported a deletion to nobody.

A strong answer hits

  • Object created and deleted within a single relist window, so the cache never held it
  • Delete checks knownObjects and items; both miss, so it returns nil
  • Measured: DeltaFIFO yields [], RealFIFO yields [Deleted ghost]
  • Harmless for a converging reconciler; not harmless for delete-keyed cleanup or per-deletion metrics
  • The honest half: the guard was correct for its purpose — suppressing a duplicate report after relist. It is a tradeoff that was re-decided, not a bug someone missed

check against §03

Why is it safe for an event handler to discard the object and enqueue only a key?

A strong answer hits

  • processDeltas writes the store before invoking the handler, in every branch
  • So a cache read from the handler, or from the reconcile it triggers, sees at least that version
  • Measured: 101 invocations, 0 stale, 0 misses
  • Reverse the order and the key-only queue becomes a race
  • The honest half: it can be fresher than the triggering event, which is fine for level-triggering but means you must never assume the object you read is the one that woke you

check against §05

A controller measured queue depth and alarmed on it. It upgraded from 1.32 to 1.33 and the alarm started firing. Explain.

A strong answer hits

  • DeltaFIFO depth counted keys with pending deltas; RealFIFO counts individual deltas
  • Same cluster activity now reports a larger number — the threshold no longer means what it meant
  • The change landed at 1.33 by default, which is why it looked like an unrelated upgrade regression
  • The honest half: the alarm was not wrong before and is not wrong now; the unit changed, so the fix is re-baselining, not reverting — and after 1.36 reverting is not available anyway

check against §02 and §04