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:
parent
42a5884f45
commit
bdc34d037c
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -46,4 +46,5 @@ pub enum Event {
|
|||
Paused(ActorId),
|
||||
Resumed(ActorId),
|
||||
BehaviorInstalled(ActorId, ImplementationId),
|
||||
StateMigrated(ActorId, i64, i64),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<i64>,
|
||||
) -> 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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue