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
|
||||
retention, a Rune compile-input fingerprint, and an in-memory compiled
|
||||
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.
|
||||
- Lifecycle tests proving failed-message retry from committed state and
|
||||
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
|
||||
single-worker actor demonstration with bounded mailboxes and explicit message
|
||||
effects. Source and compiled implementation identities are BLAKE3 content
|
||||
addresses with an in-memory compile cache. Persistence, behavior editing,
|
||||
canonicalization, the graphical world, and development API are not implemented
|
||||
yet.
|
||||
addresses with an in-memory compile cache. Persistence, interactive behavior
|
||||
editing, canonicalization, the graphical world, and development API are not
|
||||
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
|
||||
slices. Native callbacks are not preemptible.
|
||||
|
|
|
|||
|
|
@ -42,4 +42,5 @@ pub enum Event {
|
|||
Failed(ActorId, String),
|
||||
Stopped(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)]
|
||||
pub struct CodeStore {
|
||||
sources: BTreeMap<SourceId, Arc<str>>,
|
||||
artifacts: BTreeMap<ImplementationId, Arc<CompiledArtifact>>,
|
||||
behaviors: BTreeMap<String, Vec<BehaviorVersion>>,
|
||||
}
|
||||
|
||||
impl CodeStore {
|
||||
|
|
@ -139,6 +154,115 @@ impl CodeStore {
|
|||
pub fn artifact_count(&self) -> usize {
|
||||
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 {
|
||||
|
|
@ -235,4 +359,70 @@ mod tests {
|
|||
assert_ne!(first.implementation_id, second.implementation_id);
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
|
@ -514,6 +536,45 @@ mod tests {
|
|||
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]
|
||||
fn restart_retries_failed_reservation_from_last_committed_state() -> ExperimentResult<()> {
|
||||
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,
|
||||
snapshots, statuses and lifecycle events.
|
||||
- `runa-core` owns stable source and implementation identity types.
|
||||
- `runa-code` preserves original source, derives identities, and caches
|
||||
compiled artifacts for the process lifetime.
|
||||
- `runa-code` preserves original source, derives identities, caches compiled
|
||||
artifacts, and provides an in-memory named behavior registry with revisions.
|
||||
- `runa-rune` owns compilation and typed bounded invocation execution.
|
||||
- `runa-runtime` owns implementation registration, queues, private committed
|
||||
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
|
||||
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
|
||||
`runa-source:v1` domain. Whitespace/comment canonicalization is intentionally
|
||||
not attempted; textually different source has a different identity. The
|
||||
|
|
@ -132,8 +150,10 @@ targets, aggregate capacity, and successful message effects.
|
|||
|
||||
## Limits
|
||||
|
||||
This is not yet a full image: no persistent mailboxes, transactional outbox,
|
||||
supervisors, hot upgrades, API server, GUI, or automatic image bootstrap.
|
||||
This is not yet a full image: no persistent behavior registry or mailboxes,
|
||||
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!`.
|
||||
Messages are not accepted by stopped/failed actors.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue