axon-inference Model Hot Reload (0.6.0 P0 Stage 6 closure)¶
Version:
axon-inferencev0.6.0+ Status: Implemented (Stage 6 closure) Plan ref:docs/superpowers/plans/2026-07-18-axon-quant-0.6.0.mdWorkflow C
ModelHotReloader provides model weight hot-reload without interrupting inference. Goals:
- Zero-downtime: during
reload(), the old session keeps serving inference requests. Onlyreplace_sessionis a brief exclusive window (< 1ms). - Atomicity: if the new session fails to build, the old session is left untouched.
- Observability: every reload fires
watch::Sender+ an optional Python callback with version + sha256 checksum.
Design: Two-step atomic path¶
reload()
│
├─ ① compute_sha256(path) — verify the new file
│
├─ ② backend.read() — acquire READ lock
│ .build_session(path) — pre-build new session in backend context
│ ─→ on failure: return Err, old session untouched
│
├─ ③ backend.write() — acquire WRITE lock
│ .replace_session(new) — atomic swap
│ ─→ on failure: return Err, old session still active
│
└─ ④ version.fetch_add(1) — bump version + notify subscribers
The build_session step holds only a read lock, so the old session remains usable for concurrent inference. replace_session is &mut self and executes instantly (no I/O).
Trait abstraction¶
pub trait InferenceEngine: Send + Sync {
fn load(&mut self, path: &Path) -> Result<(), InferenceError>;
fn infer(&self, observation: &Observation) -> Result<Action, InferenceError>;
fn infer_batch(&self, observations: &[Observation]) -> Result<Vec<Action>, InferenceError>;
/// Pre-build a new session in the backend context (does NOT swap)
fn build_session(&self, path: &Path)
-> Result<Box<dyn Any + Send + Sync>, InferenceError>;
/// Atomically replace the current backend session
fn replace_session(&mut self, new_session: Box<dyn Any + Send + Sync>)
-> Result<(), InferenceError>;
}
Per-backend build_session / replace_session:
| Backend | build_session returns | replace_session downcast | Note |
|---|---|---|---|
OnnxBackend | Box<ort::session::Session> (committed) | Box<ort::session::Session> | truly pre-built, IO done upfront |
TchBackend | Box<tch::CModule> (loaded) | Box<tch::CModule> | same as Onnx |
CandleBackend | Box<CandleReloadState { path }> | Box<CandleReloadState> → self.load(&state.path) | Candle models tightly bound to self.device / self.config; wraps the path and rebuilds on swap |
Python bindings¶
Construction¶
from axon_quant import (
InferenceEngine, ModelConfig, Device, InferenceBackend, ModelHotReloader,
)
cfg = ModelConfig(
path="/models/policy_v1.onnx",
backend=InferenceBackend.Onnx,
device=Device.cpu(),
input_shape=(1, 64, 128),
output_dim=3,
)
eng = InferenceEngine(cfg)
eng.load("/models/policy_v1.onnx") # load first, then pass to reloader
reloader = ModelHotReloader(eng) # after Stage 6 closure: real impl, not RuntimeError
assert reloader.version() == 0
assert reloader.model_path() == "/models/policy_v1.onnx"
Manual reload¶
# Suppose the training process produced /models/policy_v2.onnx
# Business code atomically replaces it (use os.replace for atomicity)
import os
os.replace("/models/policy_v2.onnx", "/models/policy_v1.onnx")
# Trigger hot reload
new_version = reloader.reload()
assert new_version == 1
Subscribe to reload events¶
events = []
def on_reload(path, version):
events.append((path, version))
print(f"model {path} reloaded to v{version}")
reloader.subscribe(on_reload)
# ... trigger reload ...
assert len(events) == 1
assert events[0] == ("/models/policy_v1.onnx", 1)
reloader.unsubscribe()
Rust-side watch::Receiver subscription¶
use axon_inference::hot_reload::ModelHotReloader;
let mut rx = reloader.subscribe(); // watch::Receiver<u64>
tokio::spawn(async move {
while rx.changed().await.is_ok() {
let v = *rx.borrow();
tracing::info!(version = v, "model hot reloaded");
}
});
Caveats¶
build_sessionfailure is non-destructive: sha256 / ONNX load failures returnErrimmediately; the old session is untouched.replace_sessiontype mismatch returns a backend error: passing aBox<dyn Any>of the wrong type surfaces a clearOnnx(...)/Tch(...)/Candle(...)error containing the expected type name.CandleBackendhas the longest blocking window:replace_sessioninternally callsself.load(&state.path)to fully rebuild; for large models this can take 1–3 seconds. Onnx/Tch pre-construct, so the swap is instantaneous.build_session/replace_sessionare NOT exposed to Python: onlyModelHotReloader.reloadis, to prevent direct state mutation.- Real ONNX model loading is the user's responsibility:
test_inference_e2e.pydeliberately avoids real model files (CI doesn't fetch them). Rust unit tests + Python API-contract tests cover the integration surface.
Acceptance¶
- Rust unit tests:
cargo test -p axon-inference --features "onnx candle-backend python" --lib— 57/57 passing - Python E2E (
pytest python/tests/test_inference_e2e.py -v) includes: test_reloader_new_succeeds_with_engine— verifies__new__succeeds +version() == 0+ non-emptymodel_pathtest_reloader_subscribe_and_unsubscribe— verifies thesubscribe/unsubscribe/has_callbacktriotest_reloader_model_path_matches_engine— verifies path consistency- The old Stage 6 stub test
test_reloader_new_returns_runtime_error_in_stage6was removed; the Rust counterpartreloader_new_returns_runtime_erroris replaced withreloader_new_succeeds_with_engine.