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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue