A registry of Go method values built at startup ignores every config reload — why, and how do you fix it?
answer
- the registry holds snapshots, not references
- look at the receiver, not the reload path
- one-character change flips the behaviour
- pointer receivers trade staleness for a race
- rebuild and swap keeps a generation consistent
basics
~20 sEach entry was built from a value-receiver method, so it saved a copy of the runner struct as it was at startup. Reload mutates the live struct the copies are disconnected from. Fix it with pointer receivers, or rebuild the registry.
solid answer
~50 sBuilding `mix["burst"] = runner.Burst` at startup evaluates the receiver right there. If `Burst` has a value receiver, each map entry carries its own frozen copy of the runner struct, so a reload that writes new rates into the live struct changes nothing the registry can see. Three fixes are defensible. Declare the workload methods with pointer receivers, so each entry saves `&runner` and reads current fields — but reload now writes while workload goroutines read, a race you must close with an atomic swap of an immutable config. Or keep value receivers and rebuild the map on reload, swapping it in as a unit, which keeps each generation internally consistent. Or store the receiver instead of method values and pick the method at call time. Nothing static catches this, so the regression test binds, mutates, then calls.
go deeper
The takeaway to hold on to is that a stored method value keeps the receiver it had when it was created, so a table of them built at startup can silently describe the world as it was then.
Be able to trace it precisely: the map literal evaluated each receiver expression, a value receiver copied the struct, and the reload wrote to a variable none of the entries point at.
An interviewer wants the fix and its consequences — pointer receivers introduce a race with the reload, rebuilding the registry keeps each generation consistent — plus the test shape and review convention that stop it recurring.
Own the invariant rather than the incident: decide whether configuration in your services is mutable state or a value that gets republished, since that single call removes this whole bug class instead of patching one registry.
## The bug A load generator drives a mix of named workloads. At startup it builds a registry: ```go runner := Runner{rate: cfg.Rate} mix := map[string]func(){ "warmup": runner.Warmup, "burst": runner.Burst, "soak": runner.Soak, } ``` A SIGHUP handler reloads config and writes `runner.rate = newCfg.Rate`. The gauge showing the configured rate updates. The traffic does not. The cause is in the receiver declaration, not in the reload path. If `Burst` is declared `func (r Runner) Burst()`, then `runner.Burst` evaluated the receiver at map-construction time and saved a **copy** of the whole `Runner` struct with the function value. The registry does not hold three references to one runner; it holds three private snapshots. The reload updates a fourth thing — the original variable — and every workload keeps running against a struct frozen at process start. Two things make this hard to spot in review. First, both receiver forms compile and the map's value type is just `func()`, so the signature reveals nothing about what was captured. Second, the same code with a pointer receiver behaves perfectly, so the diff that introduces the bug can be a one-character change to a receiver in a file nobody looked at. ## Fix one: pointer receivers, plus the race you just created ```go func (r *Runner) Burst() { ... } ``` Now `runner.Burst` saves `&runner`, and every call reads the struct's current fields. That closes the staleness — and opens a data race, because reload writes the fields while workload goroutines read them. The race detector will find it if your tests exercise a reload concurrently with load; a clean `-race` run on a test that never reloads proves nothing. The usual shape is to stop mutating fields in place. Make the config value immutable, keep a pointer to it, and have reload publish a new one atomically; workloads read the pointer once per iteration. The mutation then becomes a single pointer swap rather than a scatter of field writes. ## Fix two: rebuild the registry Keep the value receivers, and treat the whole registry as derived state: ```go func buildMix(r Runner) map[string]func() { return map[string]func(){"warmup": r.Warmup, "burst": r.Burst} } ``` Reload constructs a fresh `Runner`, calls `buildMix`, and swaps the map in as a unit. This has a real advantage over fix one: every workload in a generation sees one internally consistent snapshot, instead of possibly reading a half-updated struct. If your workloads must not observe a torn config, this is the better answer, and being able to say why is what separates the senior answer from the middle one. ## Fix three: do not store method values Store `*Runner` in the registry and select the method at call time (`reg[name].run(...)`, or a small interface). This makes the receiver relationship explicit in the types and removes the trap entirely, at the cost of a less convenient `func()` API. ## Detection Nothing static finds it. The compiler is content, `go vet` has no check for it, and a test that binds and immediately calls passes under either receiver form. The test that actually catches it is written in the shape of the failure — bind, mutate the receiver, then call: ```go for _, tc := range cases { r := Runner{rate: tc.start} f := r.Burst r.rate = tc.reloaded if got := f(); got != tc.reloaded { t.Errorf("%s: workload used rate %d after reload, want %d", tc.name, got, tc.reloaded) } } ``` If you have adopted the rebuild fix instead, the equivalent test reloads and asserts the *new* registry is in use, which is a healthier assertion because it tests the behaviour you promised rather than a language detail. ## The review rule this leaves behind Two lines worth enforcing. Pick one receiver form per type and keep to it, so nobody has to open the method declaration to know what a method value captured. And treat any long-lived collection of method values as derived state with an explicit rebuild step, because a function value that has quietly captured a snapshot of your configuration is not something a reader of the call site can see.
- You switch the workload methods to pointer receivers. What new failure mode should you expect?A data race: reload writes the runner's fields while workload goroutines read them through the saved pointer. Run the reload path under `-race`, and prefer publishing a new immutable config value with an atomic pointer swap over mutating fields in place.
- Why might rebuilding the registry beat switching to pointer receivers?Because it swaps a whole generation at once. With shared pointers, one workload can read a field written before reload and another a field written after, so a run observes a torn config. Rebuilding gives every entry in a generation the same consistent snapshot.
- Would go vet or the type system have caught the original bug?No. Both receiver forms compile, and the registry's value type is `func()`, which reveals nothing about what was captured. The only reliable detection is a test that mutates the receiver between binding and calling, plus a review convention on receiver consistency.
- How do you keep this from recurring across a team?Make it a convention rather than a memory exercise: one receiver form per type, and any long-lived table of method values documented as derived state with a named rebuild function. Reviewers can then check the rebuild call, which is visible, instead of the receiver, which is not.
saying these in an interview costs you the question
- Blames the reload path instead of the receiver declaration
- Switches to pointer receivers without mentioning the resulting race
- Suggests go vet or the compiler would have flagged it
- Claims re-reading the map re-evaluates the receiver
- Writes a test that binds and calls immediately, which passes either way