feat: add versioned behavior registry
Add optimistic behavior publishing, immutable revision history and rollback, plus runtime implementation installation at idle handler boundaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
c04a7ed12f
commit
3be18f2f53
|
|
@ -18,6 +18,9 @@ All notable changes to this project will be documented in this file.
|
||||||
- Versioned BLAKE3 source and implementation identities, original-source
|
- Versioned BLAKE3 source and implementation identities, original-source
|
||||||
retention, a Rune compile-input fingerprint, and an in-memory compiled
|
retention, a Rune compile-input fingerprint, and an in-memory compiled
|
||||||
artifact cache.
|
artifact cache.
|
||||||
|
- In-memory named behavior revisions with optimistic publish checks, immutable
|
||||||
|
history, rollback revisions, and runtime installation at idle handler
|
||||||
|
boundaries.
|
||||||
- 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.
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@ experiment using Rune 0.14.2: script execution, native calls, async suspension,
|
||||||
instruction budgets, stepping, and compiled-code sharing. It also runs a minimal
|
instruction budgets, stepping, and compiled-code sharing. It also runs a minimal
|
||||||
single-worker actor demonstration with bounded mailboxes and explicit message
|
single-worker actor demonstration with bounded mailboxes and explicit message
|
||||||
effects. Source and compiled implementation identities are BLAKE3 content
|
effects. Source and compiled implementation identities are BLAKE3 content
|
||||||
addresses with an in-memory compile cache. Persistence, behavior editing,
|
addresses with an in-memory compile cache. Persistence, interactive behavior
|
||||||
canonicalization, the graphical world, and development API are not implemented
|
editing, canonicalization, the graphical world, and development API are not
|
||||||
yet.
|
implemented yet. The code library now supports an in-memory versioned behavior
|
||||||
|
registry, and the runtime can install an implementation at an idle handler
|
||||||
|
boundary; these operations are not yet exposed in the CLI.
|
||||||
|
|
||||||
The prototype uses worker-local invocation stacks and cooperative instruction
|
The prototype uses worker-local invocation stacks and cooperative instruction
|
||||||
slices. Native callbacks are not preemptible.
|
slices. Native callbacks are not preemptible.
|
||||||
|
|
|
||||||
|
|
@ -42,4 +42,5 @@ pub enum Event {
|
||||||
Failed(ActorId, String),
|
Failed(ActorId, String),
|
||||||
Stopped(ActorId),
|
Stopped(ActorId),
|
||||||
Restarted(ActorId),
|
Restarted(ActorId),
|
||||||
|
BehaviorInstalled(ActorId, ImplementationId),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,10 +66,25 @@ impl CompiledArtifact {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct BehaviorVersion {
|
||||||
|
pub revision: u64,
|
||||||
|
pub implementation_id: ImplementationId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct BehaviorSnapshot {
|
||||||
|
pub name: String,
|
||||||
|
pub revision: u64,
|
||||||
|
pub current: ImplementationId,
|
||||||
|
pub history: Vec<BehaviorVersion>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct CodeStore {
|
pub struct CodeStore {
|
||||||
sources: BTreeMap<SourceId, Arc<str>>,
|
sources: BTreeMap<SourceId, Arc<str>>,
|
||||||
artifacts: BTreeMap<ImplementationId, Arc<CompiledArtifact>>,
|
artifacts: BTreeMap<ImplementationId, Arc<CompiledArtifact>>,
|
||||||
|
behaviors: BTreeMap<String, Vec<BehaviorVersion>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CodeStore {
|
impl CodeStore {
|
||||||
|
|
@ -139,6 +154,115 @@ impl CodeStore {
|
||||||
pub fn artifact_count(&self) -> usize {
|
pub fn artifact_count(&self) -> usize {
|
||||||
self.artifacts.len()
|
self.artifacts.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compile and publish only if the caller's expected revision is current.
|
||||||
|
///
|
||||||
|
/// A new behavior requires `expected_revision == None`; updating an
|
||||||
|
/// existing behavior requires its current revision.
|
||||||
|
pub fn publish_behavior(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
expected_revision: Option<u64>,
|
||||||
|
source: &str,
|
||||||
|
) -> ExperimentResult<BehaviorSnapshot> {
|
||||||
|
validate_behavior_name(name)?;
|
||||||
|
let history = self.behaviors.get(name);
|
||||||
|
let current_revision = history.and_then(|versions| versions.last().map(|v| v.revision));
|
||||||
|
if current_revision != expected_revision {
|
||||||
|
return Err(format!(
|
||||||
|
"behavior revision conflict: expected {expected_revision:?}, current {current_revision:?}"
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let artifact = self.compile(source)?;
|
||||||
|
let revision = current_revision
|
||||||
|
.unwrap_or(0)
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or("behavior revision exhausted")?;
|
||||||
|
let history = self.behaviors.entry(name.to_owned()).or_default();
|
||||||
|
history.push(BehaviorVersion {
|
||||||
|
revision,
|
||||||
|
implementation_id: artifact.implementation_id,
|
||||||
|
});
|
||||||
|
Ok(snapshot(name, history))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the behavior reference to a prior immutable implementation.
|
||||||
|
/// Rollback creates a new revision; history is never rewritten.
|
||||||
|
pub fn rollback_behavior(
|
||||||
|
&mut self,
|
||||||
|
name: &str,
|
||||||
|
expected_revision: u64,
|
||||||
|
target_revision: u64,
|
||||||
|
) -> ExperimentResult<BehaviorSnapshot> {
|
||||||
|
validate_behavior_name(name)?;
|
||||||
|
let history = self.behaviors.get_mut(name).ok_or("unknown behavior")?;
|
||||||
|
let current = history.last().ok_or("behavior has no revisions")?;
|
||||||
|
if current.revision != expected_revision {
|
||||||
|
return Err(format!(
|
||||||
|
"behavior revision conflict: expected {expected_revision}, current {}",
|
||||||
|
current.revision
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let target = history
|
||||||
|
.iter()
|
||||||
|
.find(|version| version.revision == target_revision)
|
||||||
|
.ok_or("unknown target behavior revision")?;
|
||||||
|
let implementation_id = target.implementation_id;
|
||||||
|
let revision = current
|
||||||
|
.revision
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or("behavior revision exhausted")?;
|
||||||
|
history.push(BehaviorVersion {
|
||||||
|
revision,
|
||||||
|
implementation_id,
|
||||||
|
});
|
||||||
|
Ok(snapshot(name, history))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn behavior(&self, name: &str) -> Option<BehaviorSnapshot> {
|
||||||
|
self.behaviors
|
||||||
|
.get(name)
|
||||||
|
.map(|history| snapshot(name, history))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn behaviors(&self) -> Vec<BehaviorSnapshot> {
|
||||||
|
self.behaviors
|
||||||
|
.iter()
|
||||||
|
.map(|(name, history)| snapshot(name, history))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn behavior_history(&self, name: &str) -> Option<&[BehaviorVersion]> {
|
||||||
|
self.behaviors.get(name).map(Vec::as_slice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_behavior_name(name: &str) -> ExperimentResult<()> {
|
||||||
|
if name.is_empty()
|
||||||
|
|| name.len() > 128
|
||||||
|
|| !name
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b'/'))
|
||||||
|
|| name
|
||||||
|
.split('/')
|
||||||
|
.any(|part| part.is_empty() || part == "." || part == "..")
|
||||||
|
{
|
||||||
|
return Err("invalid behavior name".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(name: &str, history: &[BehaviorVersion]) -> BehaviorSnapshot {
|
||||||
|
let current = history.last().expect("published behavior has history");
|
||||||
|
BehaviorSnapshot {
|
||||||
|
name: name.to_owned(),
|
||||||
|
revision: current.revision,
|
||||||
|
current: current.implementation_id,
|
||||||
|
history: history.to_vec(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn source_identity(original_source: &str) -> SourceId {
|
pub fn source_identity(original_source: &str) -> SourceId {
|
||||||
|
|
@ -235,4 +359,70 @@ mod tests {
|
||||||
assert_ne!(first.implementation_id, second.implementation_id);
|
assert_ne!(first.implementation_id, second.implementation_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn behavior_publish_checks_revisions_and_keeps_immutable_history() -> ExperimentResult<()> {
|
||||||
|
let mut store = CodeStore::default();
|
||||||
|
let first = store.publish_behavior("actors/counter", None, "pub fn add(a, b) { a + b }")?;
|
||||||
|
assert_eq!(first.revision, 1);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.publish_behavior("actors/counter", None, "pub fn add(a, b) { a - b }")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let second =
|
||||||
|
store.publish_behavior("actors/counter", Some(1), "pub fn add(a, b) { a - b }")?;
|
||||||
|
assert_eq!(second.revision, 2);
|
||||||
|
assert_ne!(first.current, second.current);
|
||||||
|
assert_eq!(store.behavior_history("actors/counter").unwrap().len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
store.behavior("actors/counter").unwrap().current,
|
||||||
|
second.current
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_compile_does_not_change_behavior_reference() -> ExperimentResult<()> {
|
||||||
|
let mut store = CodeStore::default();
|
||||||
|
let first = store.publish_behavior("counter", None, "pub fn add(a, b) { a + b }")?;
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.publish_behavior("counter", Some(1), "pub fn broken( {")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let current = store.behavior("counter").unwrap();
|
||||||
|
assert_eq!(current.revision, first.revision);
|
||||||
|
assert_eq!(current.current, first.current);
|
||||||
|
assert_eq!(current.history.len(), 1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rollback_appends_new_revision_and_conflicts_are_rejected() -> ExperimentResult<()> {
|
||||||
|
let mut store = CodeStore::default();
|
||||||
|
let first = store.publish_behavior("counter", None, "pub fn add(a, b) { a + b }")?;
|
||||||
|
let second = store.publish_behavior("counter", Some(1), "pub fn add(a, b) { a - b }")?;
|
||||||
|
assert!(store.rollback_behavior("counter", 1, 1).is_err());
|
||||||
|
assert!(store.rollback_behavior("counter", 2, 99).is_err());
|
||||||
|
let rolled_back = store.rollback_behavior("counter", 2, 1)?;
|
||||||
|
assert_eq!(rolled_back.revision, 3);
|
||||||
|
assert_eq!(rolled_back.current, first.current);
|
||||||
|
assert_eq!(rolled_back.history.len(), 3);
|
||||||
|
assert_eq!(rolled_back.history[1].implementation_id, second.current);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn behavior_names_are_validated() {
|
||||||
|
let mut store = CodeStore::default();
|
||||||
|
for invalid in ["", "../escape", "a//b", ".", "a b", "a\\b"] {
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.publish_behavior(invalid, None, "pub fn add(a, b) { a + b }")
|
||||||
|
.is_err(),
|
||||||
|
"accepted invalid behavior name: {invalid}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,28 @@ impl Runtime {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Install an immutable implementation between handler invocations.
|
||||||
|
pub fn install(
|
||||||
|
&mut self,
|
||||||
|
id: ActorId,
|
||||||
|
implementation: ImplementationId,
|
||||||
|
) -> 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
|
||||||
|
{
|
||||||
|
return Err("behavior can only be installed at an idle handler boundary".into());
|
||||||
|
}
|
||||||
|
actor.snapshot.implementation = implementation;
|
||||||
|
self.events
|
||||||
|
.push(Event::BehaviorInstalled(id, implementation));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn drain_events(&mut self) -> Vec<Event> {
|
pub fn drain_events(&mut self) -> Vec<Event> {
|
||||||
std::mem::take(&mut self.events)
|
std::mem::take(&mut self.events)
|
||||||
}
|
}
|
||||||
|
|
@ -514,6 +536,45 @@ mod tests {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn behavior_installation_waits_for_handler_boundary() -> ExperimentResult<()> {
|
||||||
|
let mut code = CodeStore::default();
|
||||||
|
let first = code.compile("pub fn handle(state, kind, value) { (state + value, []) }")?;
|
||||||
|
let second =
|
||||||
|
code.compile("pub fn handle(state, kind, value) { (state + value * 10, []) }")?;
|
||||||
|
let first_id = first.implementation_id;
|
||||||
|
let second_id = second.implementation_id;
|
||||||
|
let mut runtime = Runtime::default();
|
||||||
|
runtime.publish(first)?;
|
||||||
|
runtime.publish(second)?;
|
||||||
|
let actor = runtime.spawn(first_id, 0, capacity(2))?;
|
||||||
|
runtime.send(actor, Message { kind: 0, value: 1 })?;
|
||||||
|
runtime.tick(capacity(1));
|
||||||
|
let active = runtime.inspect(actor)?;
|
||||||
|
assert_eq!(active.implementation, first_id);
|
||||||
|
assert!(runtime.install(actor, second_id).is_err());
|
||||||
|
for _ in 0..100 {
|
||||||
|
runtime.tick(capacity(10));
|
||||||
|
}
|
||||||
|
assert_eq!(runtime.inspect(actor)?.state, 1);
|
||||||
|
runtime.send(actor, Message { kind: 0, value: 2 })?;
|
||||||
|
runtime.install(actor, second_id)?;
|
||||||
|
assert_eq!(runtime.inspect(actor)?.implementation, second_id);
|
||||||
|
for _ in 0..100 {
|
||||||
|
runtime.tick(capacity(10));
|
||||||
|
}
|
||||||
|
let updated = runtime.inspect(actor)?;
|
||||||
|
assert_eq!(updated.state, 21);
|
||||||
|
assert_eq!(updated.implementation, second_id);
|
||||||
|
assert!(
|
||||||
|
runtime
|
||||||
|
.drain_events()
|
||||||
|
.iter()
|
||||||
|
.any(|event| matches!(event, Event::BehaviorInstalled(id, version) if *id == actor && *version == second_id))
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ The default per-actor slice budget is 32 instructions.
|
||||||
- `runa-actor` owns actor, mailbox and implementation IDs, structured messages,
|
- `runa-actor` owns actor, mailbox and implementation IDs, structured messages,
|
||||||
snapshots, statuses and lifecycle events.
|
snapshots, statuses and lifecycle events.
|
||||||
- `runa-core` owns stable source and implementation identity types.
|
- `runa-core` owns stable source and implementation identity types.
|
||||||
- `runa-code` preserves original source, derives identities, and caches
|
- `runa-code` preserves original source, derives identities, caches compiled
|
||||||
compiled artifacts for the process lifetime.
|
artifacts, and provides an in-memory named behavior registry with revisions.
|
||||||
- `runa-rune` owns compilation and typed bounded invocation execution.
|
- `runa-rune` owns compilation and typed bounded invocation execution.
|
||||||
- `runa-runtime` owns implementation registration, queues, private committed
|
- `runa-runtime` owns implementation registration, queues, private committed
|
||||||
state, scheduling, message effects and lifecycle coordination.
|
state, scheduling, message effects and lifecycle coordination.
|
||||||
|
|
@ -47,6 +47,24 @@ implementation. Implementation IDs use source content and the compile
|
||||||
fingerprint; actor and mailbox IDs remain process-local counters. Actor IDs and
|
fingerprint; actor and mailbox IDs remain process-local counters. Actor IDs and
|
||||||
incarnation counters use checked increments.
|
incarnation counters use checked increments.
|
||||||
|
|
||||||
|
`CodeStore::publish_behavior` compiles source before appending a behavior
|
||||||
|
revision. Creation expects no existing revision; updates require the caller's
|
||||||
|
expected current revision, so stale edits fail without changing the behavior
|
||||||
|
reference. Failed compilation also leaves the reference and history unchanged
|
||||||
|
(the exact source remains available for diagnostics). Rollback points the
|
||||||
|
behavior at an earlier immutable implementation and appends a new monotonically
|
||||||
|
increasing revision; it does not rewrite history. Behavior names are
|
||||||
|
case-sensitive paths limited to ASCII letters, digits, `_`, `-`, `.`, and `/`,
|
||||||
|
with empty, `.` and `..` path components rejected.
|
||||||
|
|
||||||
|
The runtime's `install` operation switches an actor's implementation only
|
||||||
|
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. This is an in-memory library capability; the interactive CLI
|
||||||
|
does not yet expose behavior publishing, history, rollback, or installation.
|
||||||
|
|
||||||
The source identity hashes the exact original UTF-8 bytes with the versioned
|
The source identity hashes the exact original UTF-8 bytes with the versioned
|
||||||
`runa-source:v1` domain. Whitespace/comment canonicalization is intentionally
|
`runa-source:v1` domain. Whitespace/comment canonicalization is intentionally
|
||||||
not attempted; textually different source has a different identity. The
|
not attempted; textually different source has a different identity. The
|
||||||
|
|
@ -132,8 +150,10 @@ targets, aggregate capacity, and successful message effects.
|
||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
|
|
||||||
This is not yet a full image: no persistent mailboxes, transactional outbox,
|
This is not yet a full image: no persistent behavior registry or mailboxes,
|
||||||
supervisors, hot upgrades, API server, GUI, or automatic image bootstrap.
|
transactional outbox, supervisors, API server, GUI, or automatic image
|
||||||
|
bootstrap. The initial implementation installation API is process-local and
|
||||||
|
requires an idle handler boundary; it does not upgrade active execution.
|
||||||
The example default script is bundled through `include_str!`.
|
The example default script is bundled through `include_str!`.
|
||||||
Messages are not accepted by stopped/failed actors.
|
Messages are not accepted by stopped/failed actors.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue