fix(serve): the watcher read three things out of the recipe it parsed at startup - #425
Merged
Merged
Conversation
… at startup `_RecipeWatchState.recipe` was the body parsed when the process started. Nothing refreshed it: the only two assignments sat inside YAML-error-recovery branches, so a recipe that always parsed cleanly kept its startup body for the life of the process. #417 fixed the drift warning by adding a second field alongside it; three other readers were left on the startup body and are fixed here by making the one field mean "the recipe as it is on disk now". item_metadata. `_build_entry` joins the response against the table the recipe names. Repoint `item_metadata.path` and retrain, and the new artifact hot-swaps in while the join still uses the old table -- and nothing warns, because the artifact and the recipe now legitimately agree. The watcher cannot reconstruct the body an artifact was trained from; it holds only that body's `recipe_hash`. The body on disk is the only one it can name, and it is the one `app.py` already uses on the startup load path, so a hot-swap and a restart now build the same entry. output.path. Captured once into `state.artifact_path` at discovery. An edited output path was ignored until restart, silently: the drift warning is emitted from the load path, and no load ever happens at the new location. The rescan now follows it, dropping the marker, digest, sidecar contents and backoff that described the previous file. The serving model is left in place, so repointing at a path that has not been trained yet degrades /v1/health/details rather than taking the recipe out of rotation, which is the contract every other missing artifact gets. The .sha256 sidecar suppression latch. Three unreadable sidecar reads set `sidecar_unsupported` so a broken sidecar cannot drive a reload every tick. The documented escape -- clear it when the recipe changes -- was reached through `getattr(state.recipe, "_yaml_path", None) or getattr(..., "yaml_path", None)`. `Recipe` is a pydantic model with `extra="forbid"` and no private attributes, so that chain has answered `None` for every recipe that has ever existed and the latch was permanent. The clearing moves to where the watcher already decides the YAML changed, and the three dead chains and the mtime they compared against are removed. The existing test for that recovery passed against a `MagicMock` carrying a `_yaml_path` attribute -- the only object for which the code worked; it is replaced by one that pins the real split. All three shipped in 2.0.0: `git show v2.0.0:src/recotem/serving/watcher.py` contains all three `getattr` chains verbatim, and `_build_entry(name, state.recipe, ...)` and `_RecipeWatchState.artifact_path` both come from 8395a81, an ancestor of the v2.0.0 tag. `latest_recipe` and the `drift_recipe` parameter #417 added are removed: they existed only because `recipe` was deliberately stale, and it no longer is. The drift warning's docstring claimed the response is "of a piece" while the warning fires; it is not, and it now says which half comes from where.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
_RecipeWatchState.recipewas the body parsed when the process started. Nothingrefreshed it — the only two assignments to it sat inside YAML-error-recovery
branches — so for a recipe that always parses cleanly it stayed the startup body
for the life of the process.
#417 fixed one reader of that field (the recipe-drift warning) by adding a
second field,
latest_recipe, refreshed on every rescan. Three more readerswere left on the startup body. This PR fixes all three by making the one field
mean what its name says: the recipe as it is on disk now.
1.
item_metadata— a retrain silently produced a mixed response_build_entryjoins the response against the table the recipe names. Repointitem_metadata.pathand retrain, and the new artifact hot-swaps in while thejoin still reads the table the startup body named. Nothing warns, because the
artifact and the recipe on disk now legitimately agree — the drift warning is
silent by design in exactly this case.
Which body it reads now, and why that one. The watcher cannot reconstruct
the body an artifact was trained from; the header carries only that body's
recipe_hash, not the body. So "the trained-from body" is not an option thatexists — the startup body was only ever a proxy for it, and it stops being one
at the first hot-swap: after an edit and a retrain, the startup body is neither
the body on disk nor the body the new artifact was trained from. The body on
disk is the only one the watcher can name, and it is already the one
app.pyuses on the startup load path, so a hot-swap and a restart now build the same
entry from the same bytes. That parity is the same reason the recipe-hash gate
was mirrored onto both load paths in #270.
When the two bodies do differ — an edit with no retrain — the drift warning is
already firing, and this PR corrects that warning's docstring, which claimed the
response is "of a piece" while it fires. It is not: what comes out of the
artifact is the older recipe, while
item_metadatais read from the recipe ondisk at load time. The docstring now says which half is which.
2.
output.path— an edit was ignored until restartstate.artifact_pathwas taken fromrecipe.output.pathonce, at discovery. Anedited output path was ignored for the life of the process, and silently: the
drift warning is emitted from the load path, and no load ever happens at the new
location, so nothing anywhere says the watcher is polling a file the recipe no
longer names.
The rescan now follows it. A re-point drops everything the watcher remembers
about the previous file — change marker, payload digest,
.sha256sidecarcontents, load backoff, repeat-suppression signatures — because none of it says
anything about the new path. Dropping
last_sha256matters in particular: a newpath holding byte-identical content would otherwise take the "unchanged bytes"
short-circuit and never rebuild the entry, leaving the served entry naming the
old path. One INFO line,
recipe_output_path_changed, carriesprevious_pathand
path.A path that does not exist yet — the ordinary case, an operator who edited
the recipe and has not retrained — degrades rather than evicting. The model in
the registry keeps serving, the next poll records "artifact missing or
unreadable" against the entry and
/v1/health/detailsgoes degraded. That isthe same M-2 contract every other missing artifact already gets, and there is a
test for it.
3. The
.sha256sidecar suppression latch could never be releasedThree consecutive unreadable sidecar reads set
sidecar_unsupportedso a brokensidecar cannot drive a full reload on every tick (m7). The documented escape —
clear it when the recipe changes (C4) — was reached through:
Recipeis a pydantic model withextra="forbid"and no private attributes, soit has never carried either name. That chain has answered
Nonefor everyrecipe that has ever existed, the guard it feeds (
yaml_mtime is None or ...)always declined, and the recovery has never run once in a live server. The latch
was permanent, and the sidecar is a real backstop: it is what catches a change
the
(mtime, size)marker cannot discriminate.Chosen fix: make the recovery work, rather than delete it. Deleting the dead
chain would have been a pure no-op — the latch is already permanent in practice
— and would have made "no recovery at all" the design. Instead the clearing
moves to
_scan_recipes_dir, where the watcher already decides that the YAMLchanged (
_yaml_mtime_cachemiss). That is a signal the watcher genuinely has,it removes a second, independently-drifting mtime comparison, and it is now
load-bearing for a reason the old placement did not have: with fix 2 above, a
recipe edit can move
output.path, and the sidecar path isartifact_path + ".sha256", so an edit really can move the sidecar out fromunder a latch set on the old one.
The three dead
getattrchains and thesidecar_unsupported_at_mtimefieldthey compared against are removed.
The test for this recovery passed, and proved nothing.
test_sidecar_unsupported_clears_on_yaml_mtime_changebuilt the state around aMagicMockwithrecipe._yaml_path = yaml_path— the only kind of object inexistence for which the code worked. It is replaced by
test_sidecar_unsupported_is_never_cleared_by_the_sidecar_check_itself, whichuses a real
Recipeand pins the other half of the split, so a reintroducedmtime probe in
_check_sidecar_changedfails there.Pre-existing since 2.0.0 — evidence
None of these is a regression from #415, #417, #418 or #362.
item_metadatafrom the startup body_build_entry(name, state.recipe, ...)and_RecipeWatchStatecome from8395a811(2026-05-14, the 2.0 rewrite);git merge-base --is-ancestor 8395a811 v2.0.0succeedsoutput.path_RecipeWatchState.artifact_pathsidecar_unsupportedrecoverygit show v2.0.0:src/recotem/serving/watcher.py | grep -c '_yaml_path", None'→ 3, the same count as onmain; the chains are at lines 1245 / 1277 / 1338 of the 2.0.0 file, andgit show v2.0.0:src/recotem/recipe/models.pyhas noyaml_pathA note on the dating, because the obvious command misleads:
git log -S '_yaml_path' -- src/recotem/serving/watcher.pyreturnsf15ebbd6(2026-09-03, #167), which is not inv2.0.0and reads like "new in 2.1.0".-Sreports commits that changed the count of the string, and #167's only_yaml_pathline is a docstring mention of the unrelated_yaml_path_to_namemap. Thegetattrchains themselves are untouched since 2.0.0.Tests
Each behaviour has a test that fails on unmodified
mainon its assertion,not on an import or a signature error. New file
tests/unit/test_watcher_live_recipe_body.py.On
main(696d55c), before the fix — 4 failed, 1 passed:The one that passes on
mainis the control,test_item_metadata_is_not_reloaded_without_a_hot_swap: an edit with noretrain must leave the old model and the old table in place. Without it,
"the join followed the edit" could as easily mean "the join re-reads every
tick", which would be a different behaviour.
After the fix:
5 passed.Each fix is load-bearing — single-call-site mutations
Each mutation rewrites one expression and leaves the statement in place (never a
deletion, so a presence check cannot score as a behaviour check):
existing_state.recipe = recipe→ assign the field back to itselftest_item_metadata_follows_the_edited_recipe_after_a_retrainand both #417 drift tests fail — one site now carries both fixes_repoint_artifact(..., recipe.output.path)→ passexisting_state.artifact_path, so the call still runs and is a no-opoutput.pathtests failexisting_state.sidecar_unsupported = False→ assign the field back to itselfM1 failing both #417 tests is the check that removing
latest_recipedid notquietly drop #417's fix.
Suite
mainbaseline: 3157 passed, 1 skipped, 4 deselected.This branch: 3162 passed, 1 skipped, 4 deselected (+5 = the new file).
ruff check src testsandruff format --check src testsclean.uv run bash tests/e2e/run.shpasses.Concurrent watcher work
fix/watcher-pointer-content-marker(the pointer-content change marker) is inflight against the same file. The two touch disjoint hunks, so this opens
against
mainrather than stacking. Merged together locally the tree is cleanand green: 3165 passed, 1 skipped, 4 deselected, ruff clean — no conflict
and no cross-failure. That branch narrows, but does not remove, the reason the
sidecar backstop matters: it folds a digest into the marker only for
pointer-sized files, so
versioning: always_overwrite(a real artifact at thewatched path) still relies on
(mtime, size), and the sidecar is still its onlybackstop.
Deliberately not changed
item_metadataedit alone does not trigger a reload. Metadata is readwhere the model is built, so an edit with no retrain leaves the old model and
the old table — which is coherent, and is what the drift warning describes.
Making a metadata edit its own hot-swap trigger is a new trigger, not this fix.
ModelEntry.artifact_pathis not updated eagerly on a re-point. That fielddocuments "the path last successfully loaded", which stays true; the
recipe_output_path_changedlog line ties the old and new paths together._stat_markeris untouched — that is the other branch's lane.Docs follow-up (separate repo)
https://recotem.org/2.1/docs/operations.htmlneeds two edits that are not inthis repo:
output.pathis now followed at the next rescan, and thatitem_metadataisre-read from the recipe on disk at the next hot-swap;
recipe_output_path_changed(INFO).Also pre-existing and unrelated to this PR, but worth correcting while that page
is open: it states that during a drift warning "
/v1/recipes/{name}reports thecurrent recipe's
algorithms,metricandcutoff". It reports theartifact's —
routes.pynever reads aRecipeobject; every field on thatendpoint comes from
entry.header.