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

Forget Is Not Delete

Call Forget and skip Done, and the object stops reconciling permanently — measured here as 100 further change events producing a queue length of zero and a Get that never returns. No error, no log line, no metric moves. Separately: a reconciler that keeps failing reaches the retry ceiling at failure #19, after which every attempt is 16 minutes 40 seconds apart. Both facts come from two functions whose names suggest they do the same thing.

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 — backoff curve read off the real rate limiter; stall reproduced with a timed Get

Section 01

The loop as it is written today

The shape most tutorials teach is: Get, reconcile, then Forget+Done on success or AddRateLimited+Done on failure. The semantics are right. The spelling in current controller-runtime differs in three ways that matter.

controller-runtime v0.25.0 · pkg/internal/controller/controller.go:422rung 2 · read the source
func (c *Controller[request]) processNextWorkItem(ctx context.Context) bool {
	obj, priority, shutdown := c.Queue.GetWithPriority()
	if shutdown {
		return false
	}

	// We call Done here so the workqueue knows we have finished
	// processing this item. ...
	defer c.Queue.Done(obj)

	ctrlmetrics.ActiveWorkers.WithLabelValues(c.Name).Add(1)
	defer ctrlmetrics.ActiveWorkers.WithLabelValues(c.Name).Add(-1)

	c.reconcileHandler(ctx, obj, priority)
	return true
}
  • Done is deferred, registered before any reconciling happens. It runs on success, on error, and on panic. It is not a branch of the switch.
  • The queue is a priority queueGetWithPriority, not Get. UsePriorityQueue defaults to true.
  • There is a TerminalError path that suppresses the retry entirely, which the classic shape has no equivalent for.

Section 02

The four-way switch

Everything the controller decides after your Reconcile returns happens in one switch. The asymmetry worth noticing is which arms call Forget.

Reconcile returnedQueue actionForget?Why
error wrapping TerminalErrornonenoRetrying cannot fix it; metrics only
any other errorAddWithOpts{RateLimited}noPreserving the failure count is what grows the backoff
RequeueAfter > 0AddWithOpts{After}yesA success — reset backoff, then schedule
successnoneyesNext failure starts from 5ms again

TerminalError is detected with errors.Is, which means it survives wrapping — a practical detail worth confirming rather than assuming.

demos/terminal-error · the exact test reconcileHandler performs (controller.go:487)rung 1 · measured
plain error                      errors.Is(err, TerminalError(nil)) = false   requeued? true
TerminalError                    errors.Is(err, TerminalError(nil)) = true    requeued? false
TerminalError wrapped with %w    errors.Is(err, TerminalError(nil)) = true    requeued? false
nil                              errors.Is(err, TerminalError(nil)) = false   requeued? false

TerminalError message : terminal error: spec.replicas must be >= 0
Unwrap()              : spec.replicas must be >= 0
go run ./05-workers/demos/terminal-error
One more arm, visible only in logs

Returning both a non-nil error and a RequeueAfter is not a fifth case — the error wins, the requeue hint is silently discarded, and the controller logs a warning saying so. If you have ever written return ctrl.Result{RequeueAfter: time.Minute}, err expecting a one-minute retry, you got exponential backoff instead.

Section 03

The retry curve a failing object actually experiences

With the priority queue enabled — the default — the rate limiter is NewTypedItemExponentialFailureRateLimiter(5ms, 1000s). Reading the delays straight off it:

demos/backoff · successive When() calls for one keyrung 1 · measured
failure             delay       cumulative
1                     5ms               0s
2                    10ms               0s
4                    40ms               0s
8                   640ms               1s
10                  2.56s               5s
12                 10.24s              20s
14                 40.96s            1m22s
16               2m43.84s            5m28s
18              10m55.36s           21m51s
19                 16m40s           38m31s
20                 16m40s           55m11s
22                 16m40s         1h28m31s

first failure hitting the 1000s cap: #19
go run ./05-workers/demos/backoff
5ms50ms500ms5s50s16m40s cap reached at failure #19 — every retry now 16m40s → failure 1: 5msfailure 2: 10msfailure 3: 20msfailure 4: 40msfailure 5: 80msfailure 6: 160msfailure 7: 320msfailure 8: 640msfailure 9: 1.28sfailure 10: 2.56sfailure 11: 5.12sfailure 12: 10.24sfailure 13: 20.48sfailure 14: 40.96sfailure 15: 1m21sfailure 16: 2m43sfailure 17: 5m27sfailure 18: 10m55sfailure 19: 16m40sfailure 20: 16m40sfailure 21: 16m40sfailure 22: 16m40s13579111315171921 5ms 10.24s consecutive failures for one key · log scale · NewTypedItemExponentialFailureRateLimiter(5ms, 1000s) delay
Fast to the ceiling, slow once there. Teal points double; amber points are capped. The shaded region is where a broken object lives: 19 failures to get there, then 16m40s between every subsequent attempt, forever, until one reconcile succeeds and calls Forget.
Finding — the operational consequence

Reaching the ceiling is fast: nineteen consecutive failures, and the whole journey takes well under an hour of wall-clock time. Staying there is slow: once capped, a broken object is retried every 16 minutes 40 seconds, indefinitely.

So if a dependency is down for twenty minutes and then recovers, the objects that were failing throughout do not recover promptly with it — each waits out up to a further 16m40s before anyone tries again. The controller looks wedged long after the actual fault is gone.

The reset is Forget, and it is total: measured at 16m40s before, 5ms after. Any successful reconcile for that key returns it to the bottom of the curve.

The other default

With the priority queue disabled you get DefaultTypedControllerRateLimiter, which is the maximum of the same per-item exponential curve and an overall token bucket (10 qps, 100 burst). Measured, its first six delays are identical (5ms, 10ms, 20ms, 40ms, 80ms, 160ms) because the bucket has burst left; the bucket only asserts itself once a controller is failing across many keys at once.

Section 04

What Forget actually touches

The name is the problem. Forget sounds like removal. The doc comment is explicit that it is not.

client-go v0.37.0 · util/workqueue/rate_limiting_queue.gorung 2 · read the source
// Forget indicates that an item is finished being retried.  Doesn't matter whether it's for perm failing
// or for success, we'll stop the rate limiter from tracking it.  This only clears the `rateLimiter`, you
// still have to call `Done` on the queue.
Forget(item T)
demos/forget-vs-done · part 1rung 1 · measured
1. Forget() does not remove anything from the queue
   after Add("a")            Len()=1
   after Forget("a")         Len()=1   <- still queued
   after Get()+Done()        Len()=0
Forget(key) Done(key) rateLimiter failure count 16m40s → 5ms (measured) does not touch processing set remove the key queue re-push if still dirty affects only how long the next retry waits affects whether there is a next retry at all this is why Done is deferred and unconditional, while Forget is called on only two of the four switch arms
They act on different structures entirely. Forget reaches one counter. Done reaches the two sets from chapter 04 — and is the only call that can return a key to the queue. Neither substitutes for the other, and only one of them is safe to omit.

Section 05

The silent stall

The failure mode follows directly. Get puts the key in processing. Only Done takes it out. Miss it, and every future event for that object lands in dirty and stays there — because, as chapter 04 measured, an add for a key that is currently processing is forbidden from touching the queue.

demos/forget-vs-done · parts 2 and 3rung 1 · measured
2. CORRECT: Get -> Forget -> Done. The key can be processed again.
   re-added and retrieved: "obj"  <- healthy

3. BUG: Get -> Forget, but Done() is never called.
   100 further Add("obj") calls, then Len()=0
   Get() timed out after 500ms: the key is NEVER returned again
   => this object has silently stopped reconciling. No error. No log.
go run ./05-workers/demos/forget-vs-done
100 × Add("obj") dirty { obj } blocked: key is in processing Add() refuses to push processing { obj } — forever queue Len() = 0 only Done() crosses here never called Get() blocks indefinitely queue depth 0, no errors, no failing reconciles — every health signal reads normal while this object is dead
Every observable says healthy. Depth is zero, no reconcile is failing, no error is logged, and the rate limiter is not tracking anything. The only symptom is that one object stops converging — which surfaces as a user report, not an alert.
Finding — why defer is the whole defence

This is precisely why controller-runtime registers defer c.Queue.Done(obj) immediately after Get, before any work happens. An early return added to a reconcile path years later cannot skip it, and neither can a panic. The symmetrical mistake — calling Done and forgetting Forget — is far more benign: the rate limiter tracks that key's failures forever, which wastes memory and inflates later backoff, but the object keeps reconciling.

One of these two omissions is a memory leak. The other is an object that silently leaves the control loop. They are not symmetric, and only one of them is protected by the language.

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: the backoff doubling from 5 ms; the cap first reached at failure #19; Forget resetting the delay to 5 ms; Len() unchanged by Forget; the stall in part 3. All are deterministic arithmetic or set operations with no timing dependence.
  • Run-specific: only the 500 ms timeout used to demonstrate the stall — the stall itself is permanent, and the timeout is just how long the demo is willing to wait before declaring it.

Traps this chapter had to avoid

  • A demo that hangs instead of reporting. Part 3 deliberately creates a queue from which Get never returns. Calling Get directly would hang the program forever and look like a broken demo rather than a finding. It is wrapped in a goroutine with a timeout so the stall is reported.
  • Reading the wrong rate limiter. controller-runtime picks a different default depending on UsePriorityQueue. Measuring DefaultTypedControllerRateLimiter and presenting it as “the default” would be wrong for a stock controller, which uses the plain exponential limiter. Both are measured in §03.
  • Assuming TerminalError breaks under wrapping. It is detected with errors.Is, so %w is safe — worth testing rather than guessing, since the opposite would be a common and silent bug.

Not verified

  • The priority queue's ordering behaviour. GetWithPriority, AddWithOpts and the priority semantics were read in source but not exercised; §03 measures the rate limiter controller-runtime pairs with it, not the queue itself.
  • End-to-end through a manager. No Manager, Controller or envtest was started. The switch in §02 is quoted from source (rung 2); only the TerminalError predicate inside it was executed.
  • Metrics. ReconcileTotal, ReconcileErrors, TerminalReconcileErrors and ActiveWorkers appear in the quoted code; none were registered or observed.
  • Panic behaviour. The claim that deferred Done also runs on panic follows from Go semantics and RecoverPanic exists as an option, but no panicking reconciler was run here.

Section 08

Spoken drills

What is the difference between Forget and Done, and which one is safe to forget?

A strong answer hits

  • Forget clears only the rate limiter's failure count — it does not dequeue and does not mark work complete
  • Done removes the key from processing and re-queues it if still dirty
  • Missing Done is fatal: measured as 100 adds, Len()==0, Get never returns
  • Missing Forget is a leak: failures tracked forever, later backoff inflated, but the object keeps reconciling
  • The honest half: they are not symmetric, which is exactly why controller-runtime defers Done unconditionally and calls Forget on only two of four arms

check against §04 and §05

A dependency was down for 20 minutes. It is back, but your controller still is not converging objects. Nothing is erroring now. Explain, with numbers.

A strong answer hits

  • Consecutive failures drove each key up the exponential curve, 5 ms doubling
  • The 1000 s cap is reached at failure #19 — comfortably inside a 20-minute outage
  • Each affected key now waits up to 16m40s before its next attempt
  • Only a successful reconcile calls Forget and resets it to 5 ms
  • The honest half: this is working as designed — the backoff is what stopped you hammering a dead dependency. If recovery time matters more than protection, that is an argument for a custom rate limiter with a lower ceiling, not for removing the backoff

check against §03

Why does the error arm deliberately not call Forget?

A strong answer hits

  • The failure count is what makes the next delay longer than the last
  • Forget on the error path would pin every retry at 5 ms — an unthrottled hot loop
  • The RequeueAfter arm does call it, because that return is a success, not a failure
  • The honest half: it means a key that alternates success and failure never accumulates backoff, which is usually right but does let a flapping object retry aggressively forever

check against §02 and §03

You return ctrl.Result{RequeueAfter: time.Minute}, err with a non-nil err. What happens?

A strong answer hits

  • The error arm wins; the RequeueAfter is discarded entirely
  • You get rate-limited exponential backoff, not a one-minute retry
  • A warning is logged saying exactly this — it is the only visible symptom
  • If you want a fixed retry interval, return a nil error and set RequeueAfter
  • The honest half: returning the error is usually still the right call, because it is what surfaces in ReconcileErrors — the fix is to stop expecting the interval, not to swallow the error

check against §02