Why do loop-registered callbacks in a 17-stage genomics pipeline all read one stale value?
answer
- Deferred execution is the trigger
- All handlers agree on the last item
- Registration loop, not the cache
- Check two callbacks, not one
- Bind the stage at registration time
basics
~20 sBecause each callback was built in a loop and looks up the loop variable when it fires, long after the loop finished. Every callback therefore resolves to the last stage, so all seventeen read that one stage's cached record.
solid answer
~50 sThe callbacks closed over the loop **variable** rather than its value, and they run later — after the loop ended and the variable settled on the final stage — so all seventeen read the same cache entry and every result looks stale. Confirm it in a minute: call two registered callbacks that must differ and check whether they return the same thing, or have each report the stage it resolves at call time. The fix is eager binding at registration: pass the stage in as a parameter, via `functools.partial(check, stage)` or a factory function that returns the inner callback, so each callable carries its own value. Then invalidate whatever the cache memoized while the bug was live, because those entries are wrong rather than merely old. To keep it fixed, make the registration API take the per-iteration value as an argument so the loop body has nothing to close over.
code
python · 7 linescache = {"gene": 1, "exon": 2, "cds": 3}
checks = []
for stage in cache:
checks.append(lambda: cache[stage])
print([c() for c in checks]) # [3, 3, 3] - every check reads the last stagego deeper
Recognize the symptom: functions created in a loop and called later all act on the last item. Being able to point at the registration loop as the suspect is what matters here.
Explain the mechanism end to end — the callback resolves the loop variable at fire time — and write the corrected registration using a factory or functools.partial without prompting.
Demonstrate the diagnosis under pressure: compare two registered callbacks, prove they share one variable, fix the binding, and then deal with the wrong cache entries the bug already produced.
Frame it as an API defect rather than a coding slip. Registration functions that take the per-iteration value as a parameter make the mistake unwritable, and that is what you standardize across pipelines instead of relying on review vigilance.
This is late binding meeting deferred execution, and the pipeline's cache is the amplifier, not the cause. ### The shape of the bug Somewhere in the registration code there is a loop like this: ```python stages = ["gene", "exon", "cds"] # ...17 of them in the real pipeline checks = [] for stage in stages: checks.append(lambda record: validate(record, cache[stage])) ``` Each `lambda` creates a function object but does not run its body. The name `stage` inside the body is a free variable, resolved **when the callback fires**, in the scope that was enclosing the lambda. By the time anything fires, the loop has finished and `stage` holds `"cds"`. All seventeen callbacks therefore read the same cache entry — the last stage's — and the pipeline reports one stale value everywhere it should have reported seventeen different ones. Nothing raises. Every callback is registered, every callback is called, every callback returns something. They simply all agree. ### Why it survives so long in production * **Single-item paths hide it.** A pipeline run with one stage is correct. So is a unit test that registers one handler and asserts on it. The bug needs two or more registrations *and* a deferred call to appear. * **The cache makes it plausible.** Once a memoized result is stored under the wrong stage, the wrong answer becomes consistent and reproducible, which reads like a data problem rather than a code problem. Teams chase invalidation logic for days. * **The last stage is often the least surprising.** If the final stage is the broadest or most permissive check, every record passes and the pipeline just gets quietly less strict. ### Confirming it in minutes You do not need heavy tooling. Call two registered callbacks that must differ and compare their results — identical output for different stages is the tell. Or have each callback report the value it resolves at call time: ```python print([c("sample") for c in checks]) # all seventeen agree ``` If they all report the same stage, the callbacks are reading one shared variable. A second confirmation: build the registrations in a loop of length one and watch the bug disappear. ### The fix Bind the value at registration time so the callable carries its own: ```python from functools import partial def make_check(stage): def check(record): return validate(record, cache[stage]) return check checks = [make_check(stage) for stage in stages] ``` or `partial(validate_stage, stage)` over a plain named function. The default-parameter trick (`lambda record, stage=stage: ...`) also works and is fine for a throwaway lambda, but on a registered callback it adds a public parameter that a dispatcher could fill, so it is the weakest of the three here. Then invalidate the cache: whatever it memoized while the bug was live is keyed correctly but computed from the wrong stage, so the entries are wrong rather than merely stale, and a normal TTL expiry will not clear them on the timescale you need. ### Making it structurally impossible The senior move is not the one-line fix, it is removing the shape. Give the registration API a parameter for the per-iteration value: ```python def register(stage, handler): # handler receives the stage ... ``` so the loop body has nothing to close over. Then the loop reads `register(stage, validate_stage)` and the eager binding is a property of the API rather than a discipline anyone has to remember. Add one test that registers **two** handlers and asserts they differ — that single test would have caught seventeen callbacks agreeing. ### Where else this shape appears Anywhere a function is built in a loop and invoked later: signal and event handlers, dispatch tables built from a list of command names, retry or timing wrappers applied per item, callbacks handed to a scheduler, per-column validators, and partially-applied functions stored in a registry. The common factor is deferred execution, not the container. If a function defined inside a loop body mentions the loop variable and is not called in that same iteration, treat it as a defect until proven otherwise — that is the whole review heuristic, and it is cheap to apply. ### Distributing the work does not change the cause If the seventeen stage callbacks are later spread across worker processes, the failure mode changes shape but not origin: a lambda closure cannot be pickled, so it fails at submission instead of quietly reading the wrong stage. On Python 3.14 the default `multiprocessing` start method on Unix other than macOS is `forkserver` (macOS and Windows use `spawn`), so you get that failure by default rather than inheriting parent state through `fork`. Useful to know when the symptom moves — but the fix is still eager binding.
- Why did this survive the test suite?Because the bug needs two or more registrations plus a deferred call to show itself. A test that registers a single handler and asserts on it passes, and so does a pipeline run with one stage. The cheap guard is a test that registers at least two handlers and asserts they return different results — one assertion that would have caught seventeen callbacks agreeing.
- Once fixed, why can you not just let the cache expire on its own?Because the entries are not stale, they are wrong: each one was computed from the last stage's data but stored under its own stage's key. Normal expiry would serve incorrect answers until the TTL runs out, and any downstream store that copied them keeps the error. Invalidate the affected keys explicitly as part of the fix.
- Where else does this shape show up besides callback registration?Anywhere a function is built in a loop and called later: dispatch tables built from a list of command names, per-column validators, retry or timing wrappers applied per item, and work handed to a scheduler. The common factor is deferred execution rather than the container, so the review heuristic is simple — a function defined in a loop that mentions the loop variable and is not called in that iteration is suspect.
saying these in an interview costs you the question
- Blames the cache instead of the registration loop
- Says a race between the callbacks caused it
- Claims each callback received its own copy of the stage
- Adds a sleep or re-registers in reverse order
- Concludes the callbacks are never being called
- Fixes one callback and leaves the loop pattern in place