From bdc34d037c51a03bc126d3a492a7da7817aabc15 Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Mon, 2 Nov 2026 12:45:02 +0100 Subject: [PATCH] feat: support atomic state migration on upgrade Add a safe-point runtime API for explicit state migration during implementation installation. Migration failures preserve both committed actor state and the current implementation; successful changes emit a migration event. Document the current i64-only contract and leave schema-aware or Rune-authored migrations as future work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 + README.md | 6 +- crates/runa-actor/src/lib.rs | 1 + crates/runa-runtime/src/lib.rs | 122 +++++++++++++++++++++++++++++++-- docs/architecture.md | 6 ++ docs/runtime.md | 16 ++++- 6 files changed, 144 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f5c6e..aa0d9f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ All notable changes to this project will be documented in this file. installation, plus `mise run run` to launch the local executable. - Cooperative actor pause/resume between scheduler ticks, preserving active invocations and emitting lifecycle events. +- Explicit safe-point state migration during implementation installation, with + atomic failure behavior and migration events. - Execution-model decision and Phase 1 runtime documentation. - Lifecycle tests proving failed-message retry from committed state and stop/restart preservation of reserved-first FIFO order. diff --git a/README.md b/README.md index 833a6c8..a2fc6ae 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,10 @@ The sender commits state `1` and sends a message to actor 2, which commits state `15`. The CLI also supports publishing Rune source files as named, versioned behaviors, viewing history, rolling back a behavior reference, installing the current version on an idle actor, and cooperatively pausing and -resuming actors between scheduler ticks. Use `help` in the session for the -complete command list. +resuming actors between scheduler ticks. Runtime users can also supply an +explicit state migration closure during installation; the CLI's install +command preserves state as-is. Use `help` in the session for the complete +command list. See [the feasibility report](docs/rune-feasibility.md) for verified Rune capabilities and limitations, and [the runtime guide](docs/runtime.md) for the runtime contract. diff --git a/crates/runa-actor/src/lib.rs b/crates/runa-actor/src/lib.rs index fcc7761..f093bae 100644 --- a/crates/runa-actor/src/lib.rs +++ b/crates/runa-actor/src/lib.rs @@ -46,4 +46,5 @@ pub enum Event { Paused(ActorId), Resumed(ActorId), BehaviorInstalled(ActorId, ImplementationId), + StateMigrated(ActorId, i64, i64), } diff --git a/crates/runa-runtime/src/lib.rs b/crates/runa-runtime/src/lib.rs index aa002f3..336c6a2 100644 --- a/crates/runa-runtime/src/lib.rs +++ b/crates/runa-runtime/src/lib.rs @@ -185,20 +185,41 @@ impl Runtime { &mut self, id: ActorId, implementation: ImplementationId, + ) -> ExperimentResult<()> { + self.install_with_migration(id, implementation, Ok) + } + + /// Migrate committed state and install an implementation as one safe-point + /// operation. A migration error leaves both unchanged. + pub fn install_with_migration( + &mut self, + id: ActorId, + implementation: ImplementationId, + migrate: impl FnOnce(i64) -> ExperimentResult, ) -> ExperimentResult<()> { if !self.implementations.contains_key(&implementation) { return Err("unknown implementation".into()); } - let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; - if actor.active.is_some() - || actor.snapshot.reserved.is_some() - || actor.snapshot.status != Status::Idle + let current = self.actors.get(&id).ok_or("unknown actor")?; + if current.active.is_some() + || current.snapshot.reserved.is_some() + || current.snapshot.status != Status::Idle { return Err("behavior can only be installed at an idle handler boundary".into()); } + let previous_state = current.snapshot.state; + let migrated_state = + migrate(previous_state).map_err(|error| format!("state migration failed: {error}"))?; + + let actor = self.actors.get_mut(&id).expect("actor checked above"); + actor.snapshot.state = migrated_state; actor.snapshot.implementation = implementation; self.events .push(Event::BehaviorInstalled(id, implementation)); + if migrated_state != previous_state { + self.events + .push(Event::StateMigrated(id, previous_state, migrated_state)); + } Ok(()) } @@ -662,6 +683,99 @@ mod tests { Ok(()) } + #[test] + fn behavior_installation_migrates_state_atomically() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let old_implementation = publish( + &mut runtime, + "pub fn handle(state, kind, value) { (state + value, []) }", + )?; + let new_implementation = publish( + &mut runtime, + "pub fn handle(state, kind, value) { (state * value, []) }", + )?; + let actor = runtime.spawn(old_implementation, 7, capacity(2))?; + + runtime.install_with_migration(actor, new_implementation, |state| { + Ok(state.checked_mul(10).ok_or("state migration overflow")?) + })?; + + let migrated = runtime.inspect(actor)?; + assert_eq!(migrated.implementation, new_implementation); + assert_eq!(migrated.state, 70); + assert!( + runtime + .drain_events() + .iter() + .any(|event| matches!(event, Event::StateMigrated(id, 7, 70) if *id == actor)) + ); + Ok(()) + } + + #[test] + fn failed_state_migration_preserves_actor_state_and_implementation() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let old_implementation = publish( + &mut runtime, + "pub fn handle(state, kind, value) { (state + value, []) }", + )?; + let new_implementation = publish( + &mut runtime, + "pub fn handle(state, kind, value) { (state * value, []) }", + )?; + let actor = runtime.spawn(old_implementation, 7, capacity(2))?; + + assert!( + runtime + .install_with_migration(actor, new_implementation, |_| { + Err("unsupported state version".into()) + }) + .is_err() + ); + + let unchanged = runtime.inspect(actor)?; + assert_eq!(unchanged.implementation, old_implementation); + assert_eq!(unchanged.state, 7); + assert!( + !runtime + .drain_events() + .iter() + .any(|event| matches!(event, Event::BehaviorInstalled(id, _) if *id == actor)) + ); + Ok(()) + } + + #[test] + fn migration_is_not_run_outside_an_idle_handler_boundary() -> ExperimentResult<()> { + use std::cell::Cell; + + let mut runtime = Runtime::default(); + let old_implementation = publish( + &mut runtime, + "pub fn handle(state, kind, value) { (state + value, []) }", + )?; + let new_implementation = publish( + &mut runtime, + "pub fn handle(state, kind, value) { (state * value, []) }", + )?; + let actor = runtime.spawn(old_implementation, 7, capacity(2))?; + runtime.send(actor, Message { kind: 0, value: 1 })?; + runtime.tick(capacity(1)); + let called = Cell::new(false); + + assert!( + runtime + .install_with_migration(actor, new_implementation, |state| { + called.set(true); + Ok(state + 100) + }) + .is_err() + ); + assert!(!called.get()); + assert_eq!(runtime.inspect(actor)?.implementation, old_implementation); + Ok(()) + } + #[test] fn restart_retries_failed_reservation_from_last_committed_state() -> ExperimentResult<()> { use std::sync::atomic::AtomicBool; diff --git a/docs/architecture.md b/docs/architecture.md index e3c4d1d..5a1add0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -408,6 +408,12 @@ This is a **target layout**, not a request to generate empty crates. For Phase 0 - Safe-point behavior upgrades, invalid-source rollback, state migration policy. - Pause/resume/cancel and initial execution inspection, subject to Rune feasibility. +The current runtime prototype supports implementation installation only at an +idle handler boundary, cooperative pause/resume between scheduler ticks, and an +explicit host-provided migration closure for its temporary `i64` actor state. +Structured state schemas, version compatibility checks, and Rune-authored +migration functions remain future work. + ### Phase 5 — Image API - Headless server, versioned protocol, actor and mailbox operations, source editing, events. diff --git a/docs/runtime.md b/docs/runtime.md index 92362cb..92e8dc2 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -12,8 +12,8 @@ behavior. Commands include `spawn`, `send`, `tick`, `inspect`, `actors`, `history`, `rollback`, and `install`; enter `help` to see usage. `publish` reads a Rune source file and takes `new` for a first revision or the expected current revision for an update. `rollback` requires the expected current revision and a target -historical revision. `install` selects a named behavior's current -implementation and fails if the actor is not at an idle handler boundary. +historical revision. `install` selects a named behavior's current implementation and fails if the +actor is not at an idle handler boundary. `spawn` accepts an optional behavior name and otherwise uses `counter`. All commands operate on the same process-local runtime and code store. This CLI is for local development, not a versioned external API. @@ -68,7 +68,17 @@ when it is idle, has no active invocation, and has no reserved input. Queued messages may remain; they will use the newly installed implementation. Active or awaiting handlers keep their original implementation, and installation attempts during execution fail explicitly so the caller can retry at the next -handler boundary. +handler boundary. `install` preserves the current `i64` state unchanged. + +Runtime callers that need a state transformation can use +`install_with_migration`, which receives the committed state and computes its +replacement before changing either state or implementation. The operation +requires the same idle safe point; if the migration returns an error, neither +value changes and no installation event is emitted. A successful changed +state emits `StateMigrated` as well as `BehaviorInstalled`. This prototype +accepts a host-provided migration closure only: it does not infer schema +compatibility or execute migration code from Rune source. Its state contract +is still `i64`, so structured schema/version migration remains future work. `pause` prevents an actor from being scheduled on subsequent worker ticks and retains its active invocation, reserved message, and prior `Running`, `Awaiting`,