fix(app): cold-replica pickle-by-value + reused-process module purge (0.12.0 transport hardening)#149
Merged
Merged
Conversation
… served old code After moving app source off Ray-GCS py_modules to per-file Hypha sync (0.12.0), a redeploy that lands a fresh replica on a REUSED Ray worker process serves the prior deploy's code: the per-file sync refreshes <app_dir>/source on disk, but the reused interpreter still has the old app modules in sys.modules, so a lazy `import entry` inside a method returns stale code. The app reports the new version and looks healthy while running old code (hit by model-runner's tagging fix on KTH). Process reuse is forced when the runtime_env is byte-identical across deploys — which happens for PUBLIC artifacts (no per-deploy download token) on a warm (GPU) worker process. Env-var changes (e.g. BIOENGINE_ARTIFACT_VERSION) do not force Ray to recycle the warm process, so this must be fixed replica-side, not by perturbing the runtime_env. Fix: `_purge_stale_app_modules` drops every sys.modules entry whose file lives under <app_dir>/source, run per replica instance in `_setup_replica` before the user's __init__ — so the next import re-reads the freshly-synced source. The old GCS py_module (content-hashed) used to force a fresh process on a source change, which incidentally masked this; the purge restores correctness regardless of whether the worker process is reused. Harmless when the process is already fresh (nothing to purge). Framework / stdlib / site-packages modules are untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nilsmechtel
marked this pull request as ready for review
July 22, 2026 15:27
nilsmechtel
marked this pull request as draft
July 22, 2026 15:31
…licas don't import source 0.12.0 moved app source off Ray py_modules to per-file Hypha sync, so a Serve replica no longer has <app_dir>/source on sys.path when Ray runs cloudpickle.loads(serialized_deployment_def) in ServeReplica.__init__. When the entry class references a module-level symbol of its own source module (helper funcs/classes — a monolith like cellpose has many), cloudpickle pickles that reference by name, so the cold replica does `import main`/`import entry` inside the unpickle — before any bioengine code runs, hence before the meta_path finder is installed — and dies with ModuleNotFoundError: No module named 'main'. The worker_process_setup_hook is not honoured before a Serve replica's unpickle, so it cannot bridge this; apps only survived on 0.12.0 as warm-adopted replicas. Fix: in build_and_run_application, after binding, walk sys.modules and register_pickle_by_value every module whose __file__ is under <app_dir>/source (covers subpackages). The deployment then serializes by value and the replica reconstructs it without importing user modules; lazy in-method imports still resolve via the finder, installed when the reconstructed class's @BioEngine wrappers import bioengine on load. Reproduced + fixed at the ray.cloudpickle layer in a clean-interpreter test (tests/_app/test_pickle_by_value_cold_replica.py). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
nilsmechtel
marked this pull request as ready for review
July 22, 2026 18:06
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.
Two replica-side follow-ups to 0.12.0's move of app source off Ray-GCS onto per-file Hypha sync (#148). Both are gated on that transport and were surfaced by it. Ships as 0.12.1.
Fix 1 — cold replica must not
importapp source duringcloudpickle.loads(the KTH cellpose outage)Symptom. A cold Serve replica of a real app (cellpose; composed model-runner) dies in
ServeReplica.__init__:Cause. 0.12.0 stopped shipping app source on Ray
py_modules, so<app_dir>/sourceis not on the replica'ssys.pathwhen Ray runscloudpickle.loads. Ray Serve pickles a deployment's class by name (ray.cloudpickle) whenever that class's methods reference a module-level symbol of their own source module — a helper function/class, of which any monolith like cellpose has many. Unpickling such a reference doesimport main/import entrybefore any bioengine code runs, so before thesys.meta_pathfinder that would materialise the source is installed →ModuleNotFoundError. Theworker_process_setup_hook(the intended pre-import bridge) is not honoured before a Serve replica's unpickle, confirmed on KTH: the runtime_env carried the hook, yet it produced no output before the crash. Both prod apps only survived 0.12.0 as warm-adopted replicas (never re-pickle-loaded); the first genuine cold pickle-load failed. A trivial@bioengine.appclass with no module-level self-references pickles fully by value and loads cold fine — which is why simple apps and the pre-merge risk-A test never surfaced it.Fix (
bioengine/_app/bootstrap.py,build_and_run_application). After binding, walksys.modulesandray.cloudpickle.register_pickle_by_valueevery module whose__file__is under<app_dir>/source(covers subpackages like model-runner'smodel_cache/). The deployment then serializes by value and the replica reconstructs it without importing user modules. Lazy in-method imports still resolve via the finder — installed when the reconstructed class's@bioenginewrappers import bioengine on load.Fix 2 — reused warm worker process serves the previous deploy's code
Symptom. A redeploy that lands a fresh replica on a reused Ray worker process serves the previous deploy's code even though
<app_dir>/sourceon disk is fresh (model-runner's tagging fix looked deployed but ran old logic on KTH).Cause. Per-file Hypha sync refreshes the source on disk, but the reused interpreter still has the old app modules in
sys.modules, so a lazyimport entryreturns stale code. Ray reuses a warm process when theruntime_envis byte-identical across deploys — true for public artifacts (no per-deploy download token → identical env → warm GPU process reused). Private apps mint a fresh token each deploy, so they dodge it. Env-var changes (e.g.BIOENGINE_ARTIFACT_VERSION) do not force Ray to recycle a warm process, so this must be fixed replica-side. The old content-hashed GCS py_module incidentally forced a fresh process on any source change, masking this until 0.12.0 removed it.Fix (
bioengine/_app/mixin.py,_purge_stale_app_modules). Run per replica instance in_setup_replica, before the user's__init__: drop everysys.modulesentry whose file lives under<app_dir>/source, so the next import re-reads the freshly-synced source. No-op on a genuinely fresh process; framework/stdlib untouched. Pairs with the sync's fresh mtimes (forces a real recompile).Validation
tests/_app/= 87):tests/_app/test_pickle_by_value_cold_replica.py— a source module whose entry class references a module-level symbol, loaded in a clean subprocess (cold replica): by-reference →No module named 'entry'; afterregister_pickle_by_value→ loads + constructs.tests/_app/test_replica_module_purge.py— stale app module + fresh source on disk → purge drops it → re-import reads new code; framework modules untouched.@bioengine.appdeployment reproduces the exactModuleNotFoundError: No module named 'main'by-reference, and the by-value pickle loads.0.12.1-dev2on a separate test worker in a non-prod workspace, adoption-isolated from prod; prod stayed on 0.11.29 throughout):marker()returns the computed + lazily-imported values (layers 1 & 2).import entry→ RUNNING, lazymodel_cachesubpackage resolved at call time (layers 1 & 2 on the composed shape).application_idredeployv1.15.24 → v1.15.26, no stop / no restart, on the warm-public-process condition → serves the new tagging code. The exact prod failure mode, proven fixed without a restart.Rollout
bioengine/**→ PR + dev-image workflow. Version bumped to 0.12.1 on this branch. After merge + canonical publish, KTH is upgraded 0.11.29 → 0.12.1 (restoring the Hypha transport) and the model-runner 1.15.26 in-process cutover proceeds. Never merge — left to the maintainer.🤖 Generated with Claude Code