Skip to content

fix(app): cold-replica pickle-by-value + reused-process module purge (0.12.0 transport hardening)#149

Merged
nilsmechtel merged 3 commits into
mainfrom
fix/replica-redeploy-staleness
Jul 22, 2026
Merged

fix(app): cold-replica pickle-by-value + reused-process module purge (0.12.0 transport hardening)#149
nilsmechtel merged 3 commits into
mainfrom
fix/replica-redeploy-staleness

Conversation

@nilsmechtel

@nilsmechtel nilsmechtel commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

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 import app source during cloudpickle.loads (the KTH cellpose outage)

Symptom. A cold Serve replica of a real app (cellpose; composed model-runner) dies in ServeReplica.__init__:

replica.py:2671  deployment_def = cloudpickle.loads(serialized_deployment_def)
ModuleNotFoundError: No module named 'main'

Cause. 0.12.0 stopped shipping app source on Ray py_modules, so <app_dir>/source is not on the replica's sys.path when Ray runs cloudpickle.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 does import main/import entry before any bioengine code runs, so before the sys.meta_path finder that would materialise the source is installed → ModuleNotFoundError. The worker_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.app class 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, walk sys.modules and ray.cloudpickle.register_pickle_by_value every module whose __file__ is under <app_dir>/source (covers subpackages like model-runner's model_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 @bioengine wrappers 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>/source on 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 lazy import entry returns stale code. Ray reuses a warm process when the runtime_env is 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 every sys.modules entry 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

  • Unit tests (both green; full 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'; after register_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.
  • cloudpickle-layer proof — clean-interpreter load of a real @bioengine.app deployment reproduces the exact ModuleNotFoundError: No module named 'main' by-reference, and the by-value pickle loads.
  • Isolated KTH cold gate (0.12.1-dev2 on a separate test worker in a non-prod workspace, adoption-isolated from prod; prod stayed on 0.11.29 throughout):
    • Monolith cold deploy (module-level ref + lazy in-method import) → RUNNING; marker() returns the computed + lazily-imported values (layers 1 & 2).
    • Composed model-runner cold import entry → RUNNING, lazy model_cache subpackage resolved at call time (layers 1 & 2 on the composed shape).
    • In-process cutover (Fix 2): same application_id redeploy v1.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

nilsmechtel and others added 2 commits July 22, 2026 17:25
… 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
nilsmechtel marked this pull request as ready for review July 22, 2026 15:27
@nilsmechtel
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 nilsmechtel changed the title fix(app): purge stale app modules on replica init (redeploy serves old code on reused process) fix(app): cold-replica pickle-by-value + reused-process module purge (0.12.0 transport hardening) Jul 22, 2026
@nilsmechtel
nilsmechtel marked this pull request as ready for review July 22, 2026 18:06
@nilsmechtel
nilsmechtel merged commit e6ea730 into main Jul 22, 2026
2 checks passed
@nilsmechtel
nilsmechtel deleted the fix/replica-redeploy-staleness branch July 22, 2026 18:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant