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>
This commit is contained in:
Steve Biedermann 2026-11-02 12:45:02 +01:00
parent 42a5884f45
commit bdc34d037c
6 changed files with 144 additions and 9 deletions

View File

@ -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. installation, plus `mise run run` to launch the local executable.
- Cooperative actor pause/resume between scheduler ticks, preserving active - Cooperative actor pause/resume between scheduler ticks, preserving active
invocations and emitting lifecycle events. 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. - Execution-model decision and Phase 1 runtime documentation.
- Lifecycle tests proving failed-message retry from committed state and - Lifecycle tests proving failed-message retry from committed state and
stop/restart preservation of reserved-first FIFO order. stop/restart preservation of reserved-first FIFO order.

View File

@ -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, state `15`. The CLI also supports publishing Rune source files as named,
versioned behaviors, viewing history, rolling back a behavior reference, versioned behaviors, viewing history, rolling back a behavior reference,
installing the current version on an idle actor, and cooperatively pausing and installing the current version on an idle actor, and cooperatively pausing and
resuming actors between scheduler ticks. Use `help` in the session for the resuming actors between scheduler ticks. Runtime users can also supply an
complete command list. 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 See [the feasibility report](docs/rune-feasibility.md) for verified Rune
capabilities and limitations, and [the runtime guide](docs/runtime.md) for capabilities and limitations, and [the runtime guide](docs/runtime.md) for
the runtime contract. the runtime contract.

View File

@ -46,4 +46,5 @@ pub enum Event {
Paused(ActorId), Paused(ActorId),
Resumed(ActorId), Resumed(ActorId),
BehaviorInstalled(ActorId, ImplementationId), BehaviorInstalled(ActorId, ImplementationId),
StateMigrated(ActorId, i64, i64),
} }

View File

@ -185,20 +185,41 @@ impl Runtime {
&mut self, &mut self,
id: ActorId, id: ActorId,
implementation: ImplementationId, 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<i64>,
) -> ExperimentResult<()> { ) -> ExperimentResult<()> {
if !self.implementations.contains_key(&implementation) { if !self.implementations.contains_key(&implementation) {
return Err("unknown implementation".into()); return Err("unknown implementation".into());
} }
let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; let current = self.actors.get(&id).ok_or("unknown actor")?;
if actor.active.is_some() if current.active.is_some()
|| actor.snapshot.reserved.is_some() || current.snapshot.reserved.is_some()
|| actor.snapshot.status != Status::Idle || current.snapshot.status != Status::Idle
{ {
return Err("behavior can only be installed at an idle handler boundary".into()); 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; actor.snapshot.implementation = implementation;
self.events self.events
.push(Event::BehaviorInstalled(id, implementation)); .push(Event::BehaviorInstalled(id, implementation));
if migrated_state != previous_state {
self.events
.push(Event::StateMigrated(id, previous_state, migrated_state));
}
Ok(()) Ok(())
} }
@ -662,6 +683,99 @@ mod tests {
Ok(()) 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] #[test]
fn restart_retries_failed_reservation_from_last_committed_state() -> ExperimentResult<()> { fn restart_retries_failed_reservation_from_last_committed_state() -> ExperimentResult<()> {
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;

View File

@ -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. - Safe-point behavior upgrades, invalid-source rollback, state migration policy.
- Pause/resume/cancel and initial execution inspection, subject to Rune feasibility. - 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 ### Phase 5 — Image API
- Headless server, versioned protocol, actor and mailbox operations, source editing, events. - Headless server, versioned protocol, actor and mailbox operations, source editing, events.

View File

@ -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 `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 takes `new` for a first revision or the expected current revision for an
update. `rollback` requires the expected current revision and a target update. `rollback` requires the expected current revision and a target
historical revision. `install` selects a named behavior's current historical revision. `install` selects a named behavior's current implementation and fails if the
implementation and fails if the actor is not at an idle handler boundary. actor is not at an idle handler boundary.
`spawn` accepts an optional behavior name and otherwise uses `counter`. All `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 commands operate on the same process-local runtime and code store. This CLI is
for local development, not a versioned external API. 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 messages may remain; they will use the newly installed implementation. Active
or awaiting handlers keep their original implementation, and installation or awaiting handlers keep their original implementation, and installation
attempts during execution fail explicitly so the caller can retry at the next 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 `pause` prevents an actor from being scheduled on subsequent worker ticks and
retains its active invocation, reserved message, and prior `Running`, `Awaiting`, retains its active invocation, reserved message, and prior `Running`, `Awaiting`,