Skip to main content

cratefield_core/ports/
actor.rs

1//! The `Actors` port (issue #583): per-key serialized state with
2//! transactional storage and an alarm, the Durable Object pattern behind a
3//! runtime-neutral trait.
4//!
5//! Every call to one `(kind, key)` runs **one at a time** — the host
6//! serializes them — so a handler needs no locking to keep a counter, a
7//! cursor, or a small state machine consistent. The writes one call makes
8//! are **all-or-nothing**: they accumulate in memory and reach the store in
9//! a single [`ActorStore::commit`], and a handler that returns an error, or
10//! a reply over [`MAX_ACTOR_REPLY_BYTES`], commits nothing. Each actor has
11//! **one alarm**, a single point in time it asks to be woken at, re-armed by
12//! a handler or cancelled by not re-arming.
13//!
14//! A module declares the actor kinds it owns (`Module::actor_kinds`) and a
15//! handler per kind (`HarnessBuilder::actor`); the harness wraps the host in
16//! a [`ScopedActors`] that refuses an undeclared kind, the actor equivalent
17//! of the table-ownership rule. See `docs/ACTORS.md` for the host contract.
18
19use std::collections::BTreeMap;
20use std::sync::Arc;
21use std::time::Duration;
22
23use async_trait::async_trait;
24use thiserror::Error;
25use time::OffsetDateTime;
26
27use super::Clock;
28use super::clock::timeout;
29
30/// Hard ceiling on one stored value. An actor holds small coordination
31/// state, not a store: past this bound the state belongs in the database,
32/// reached through `Ports::db`.
33pub const MAX_ACTOR_VALUE_BYTES: usize = 1024 * 1024;
34
35/// Hard ceiling on one message sent to an actor.
36pub const MAX_ACTOR_MESSAGE_BYTES: usize = 1024 * 1024;
37
38/// Hard ceiling on one reply an actor returns.
39pub const MAX_ACTOR_REPLY_BYTES: usize = 1024 * 1024;
40
41/// The longest an actor key or a storage key may be. Both are bounded by the
42/// same constant, the way they are addressed (`"<kind>:<key>"`).
43pub const MAX_ACTOR_KEY_BYTES: usize = 512;
44
45/// The longest an actor kind may be. Kind names are `[a-z0-9_-]`, so
46/// `"<kind>:<key>"` splits unambiguously.
47pub const MAX_ACTOR_KIND_BYTES: usize = 64;
48
49/// How long one [`Actors::call`] may take before the caller gives up. The
50/// host keeps serializing the actor; the caller just stops waiting.
51pub const ACTOR_CALL_TIMEOUT: Duration = Duration::from_secs(30);
52
53/// Actor failures.
54#[derive(Debug, Clone, PartialEq, Eq, Error)]
55pub enum ActorError {
56    /// No actor host is wired, or it has no handler for this kind.
57    #[error("no actor host or handler is configured")]
58    NotConfigured,
59    /// A message, reply, key or value over its bound.
60    #[error("actor rejected by size: {0}")]
61    TooLarge(String),
62    /// A call did not return within [`ACTOR_CALL_TIMEOUT`].
63    #[error("actor call timed out")]
64    Timeout,
65    /// The handler returned an error. Nothing it staged was committed.
66    #[error("actor handler failed: {0}")]
67    Handler(String),
68    /// A storage, transport or validation failure.
69    #[error("actor operation failed: {0}")]
70    Operation(String),
71}
72
73/// Rejects an actor kind that is not `1..=MAX_ACTOR_KIND_BYTES` of
74/// `[a-z0-9_-]`, so `"<kind>:<key>"` is unambiguous.
75///
76/// # Errors
77///
78/// [`ActorError::Operation`] for an empty, over-long, or otherwise invalid
79/// kind.
80pub fn validate_actor_kind(kind: &str) -> Result<(), ActorError> {
81    let valid = !kind.is_empty()
82        && kind.len() <= MAX_ACTOR_KIND_BYTES
83        && kind.bytes().all(|byte| {
84            byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-'
85        });
86    if valid {
87        return Ok(());
88    }
89    Err(ActorError::Operation(format!(
90        "actor kind `{kind}` must be 1..={MAX_ACTOR_KIND_BYTES} bytes of [a-z0-9_-]"
91    )))
92}
93
94/// Rejects an actor key or storage key that is empty or longer than
95/// [`MAX_ACTOR_KEY_BYTES`].
96///
97/// # Errors
98///
99/// [`ActorError::Operation`] for an empty key, [`ActorError::TooLarge`] for
100/// one over the bound.
101pub fn validate_actor_key(key: &str) -> Result<(), ActorError> {
102    if key.is_empty() {
103        return Err(ActorError::Operation(
104            "an actor key cannot be empty".to_owned(),
105        ));
106    }
107    if key.len() > MAX_ACTOR_KEY_BYTES {
108        return Err(ActorError::TooLarge(format!(
109            "actor key of {} bytes exceeds the {MAX_ACTOR_KEY_BYTES}-byte bound",
110            key.len()
111        )));
112    }
113    Ok(())
114}
115
116/// Validates the `(kind, key, message)` one call carries: the kind, the key
117/// and the message size. Shared by both runners, the scoped view and the
118/// Cloudflare host, so all of them refuse a call the same way — the message
119/// bound is checked here rather than only inside a handler.
120///
121/// # Errors
122///
123/// [`ActorError::TooLarge`] for a message or key over its bound,
124/// [`ActorError::Operation`] for an invalid kind or an empty key.
125pub fn check_actor_call(kind: &str, key: &str, message: &[u8]) -> Result<(), ActorError> {
126    validate_actor_kind(kind)?;
127    validate_actor_key(key)?;
128    if message.len() > MAX_ACTOR_MESSAGE_BYTES {
129        return Err(ActorError::TooLarge(format!(
130            "actor message of {} bytes exceeds the {MAX_ACTOR_MESSAGE_BYTES}-byte bound",
131            message.len()
132        )));
133    }
134    Ok(())
135}
136
137/// Rejects a `list_prefix` prefix over [`MAX_ACTOR_KEY_BYTES`]. Unlike a key,
138/// a prefix may be empty — that lists everything in the actor.
139fn check_prefix(prefix: &str) -> Result<(), ActorError> {
140    if prefix.len() > MAX_ACTOR_KEY_BYTES {
141        return Err(ActorError::TooLarge(format!(
142            "actor key prefix of {} bytes exceeds the {MAX_ACTOR_KEY_BYTES}-byte bound",
143            prefix.len()
144        )));
145    }
146    Ok(())
147}
148
149/// An actor host: it owns serialization and durable storage for every actor
150/// instance on the runtime. Implemented by the runtime adapters (a Durable
151/// Object namespace on Cloudflare, an in-process registry natively, a fake
152/// in tests); a module only ever reaches a [`ScopedActors`].
153#[async_trait]
154pub trait Actors: Send + Sync {
155    /// Sends `message` to the actor named `key` of `kind` and returns its
156    /// reply.
157    ///
158    /// Calls to the same `(kind, key)` are serialized: one runs at a time.
159    async fn call(&self, kind: &str, key: &str, message: &[u8]) -> Result<Vec<u8>, ActorError>;
160}
161
162/// The protocol for one kind of actor. A module implements it and registers
163/// it with [`HarnessBuilder::actor`](crate::HarnessBuilder::actor); the host
164/// owns the serialization and calls these.
165///
166/// Implementations must be **stateless** — all state lives in the actor and
167/// is reached through [`ActorContext`] — so a host that drops the handler
168/// between events (a hibernating Durable Object) can reconstruct it.
169#[async_trait]
170pub trait ActorHandler: Send + Sync {
171    /// A message arrived. Writes staged on `ctx` commit only if this returns
172    /// `Ok` with a reply within [`MAX_ACTOR_REPLY_BYTES`].
173    async fn on_message(
174        &self,
175        ctx: &mut dyn ActorContext,
176        message: &[u8],
177    ) -> Result<Vec<u8>, ActorError>;
178
179    /// The alarm fired. Writes staged on `ctx` commit only if this returns
180    /// `Ok`; a handler that does not re-arm leaves no alarm set.
181    async fn on_alarm(&self, ctx: &mut dyn ActorContext) -> Result<(), ActorError>;
182}
183
184/// The operations an [`ActorHandler`] performs on its actor while handling a
185/// message or an alarm. Writes are staged and committed together by the
186/// [`run_actor_message`] / [`run_actor_alarm`] runners.
187#[async_trait]
188pub trait ActorContext: Send {
189    /// The actor's kind.
190    fn kind(&self) -> &str;
191    /// The actor's key.
192    fn key(&self) -> &str;
193    /// Wall time, from the runtime's [`Clock`].
194    fn now(&self) -> OffsetDateTime;
195    /// Fetches the value at `key`, seeing this call's staged writes first.
196    async fn get(&mut self, key: &str) -> Result<Option<Vec<u8>>, ActorError>;
197    /// Stages a write of `value` at `key`, replacing any existing value.
198    async fn put(&mut self, key: &str, value: Vec<u8>) -> Result<(), ActorError>;
199    /// Stages a delete of `key`. Idempotent: deleting a missing key is `Ok`.
200    async fn delete(&mut self, key: &str) -> Result<(), ActorError>;
201    /// Stages a delete of every key in this actor. Also drops writes staged
202    /// before it, so `delete_all` followed by nothing leaves the actor empty.
203    async fn delete_all(&mut self) -> Result<(), ActorError>;
204    /// Every `(key, value)` whose key starts with `prefix`, sorted by key,
205    /// seeing this call's staged writes first.
206    async fn list_prefix(&mut self, prefix: &str) -> Result<Vec<(String, Vec<u8>)>, ActorError>;
207    /// Sets the actor's one alarm to fire at `at`, replacing any pending one.
208    async fn set_alarm(&mut self, at: OffsetDateTime) -> Result<(), ActorError>;
209    /// The pending alarm, or `None` if none is set. Sees a staged alarm
210    /// first.
211    async fn alarm(&mut self) -> Result<Option<OffsetDateTime>, ActorError>;
212    /// Clears the pending alarm.
213    async fn cancel_alarm(&mut self) -> Result<(), ActorError>;
214}
215
216/// The writes one handler call staged, applied by [`ActorStore::commit`].
217///
218/// `clear_all` runs first, then `entries` in key order (`Some` puts, `None`
219/// deletes); `alarm` is applied last. An empty set ([`Self::is_empty`]) means
220/// the host skips the commit entirely.
221#[derive(Debug, Clone, Default, PartialEq, Eq)]
222pub struct ActorWrites {
223    /// Delete every key in the actor before applying `entries`.
224    pub clear_all: bool,
225    /// Staged writes: `Some` is a put, `None` a delete.
226    pub entries: BTreeMap<String, Option<Vec<u8>>>,
227    /// The alarm to apply: `Some(Some(at))` sets, `Some(None)` cancels,
228    /// `None` leaves it untouched.
229    pub alarm: Option<Option<OffsetDateTime>>,
230}
231
232impl ActorWrites {
233    /// Whether there is nothing to apply.
234    #[must_use]
235    pub fn is_empty(&self) -> bool {
236        !self.clear_all && self.entries.is_empty() && self.alarm.is_none()
237    }
238}
239
240/// The backing store for **one** actor instance. An adapter implements this
241/// per instance; core owns the atomicity, so an implementation need only
242/// make [`commit`](ActorStore::commit) apply its whole set or none of it.
243#[async_trait]
244pub trait ActorStore: Send + Sync {
245    /// The value at `key`, or `None` if there is none.
246    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, ActorError>;
247    /// Every `(key, value)` whose key starts with `prefix`, sorted by key.
248    async fn list_prefix(&self, prefix: &str) -> Result<Vec<(String, Vec<u8>)>, ActorError>;
249    /// The pending alarm, or `None`.
250    async fn alarm(&self) -> Result<Option<OffsetDateTime>, ActorError>;
251    /// Applies every write in `writes`, or none.
252    async fn commit(&self, writes: ActorWrites) -> Result<(), ActorError>;
253}
254
255/// A handler's view of one actor, staged over the store: reads see the writes
256/// this call has staged first, writes only accumulate in an [`ActorWrites`].
257/// A private type — callers hold an `&mut dyn ActorContext`.
258struct StagedContext<'a> {
259    store: &'a dyn ActorStore,
260    clock: &'a dyn Clock,
261    kind: &'a str,
262    key: &'a str,
263    writes: ActorWrites,
264}
265
266impl<'a> StagedContext<'a> {
267    fn new(store: &'a dyn ActorStore, clock: &'a dyn Clock, kind: &'a str, key: &'a str) -> Self {
268        Self {
269            store,
270            clock,
271            kind,
272            key,
273            writes: ActorWrites::default(),
274        }
275    }
276
277    fn into_writes(self) -> ActorWrites {
278        self.writes
279    }
280}
281
282#[async_trait]
283impl ActorContext for StagedContext<'_> {
284    fn kind(&self) -> &str {
285        self.kind
286    }
287
288    fn key(&self) -> &str {
289        self.key
290    }
291
292    fn now(&self) -> OffsetDateTime {
293        self.clock.now()
294    }
295
296    async fn get(&mut self, key: &str) -> Result<Option<Vec<u8>>, ActorError> {
297        validate_actor_key(key)?;
298        if let Some(value) = self.writes.entries.get(key) {
299            return Ok(value.clone());
300        }
301        if self.writes.clear_all {
302            return Ok(None);
303        }
304        self.store.get(key).await
305    }
306
307    async fn put(&mut self, key: &str, value: Vec<u8>) -> Result<(), ActorError> {
308        validate_actor_key(key)?;
309        if value.len() > MAX_ACTOR_VALUE_BYTES {
310            return Err(ActorError::TooLarge(format!(
311                "actor value of {} bytes exceeds the {MAX_ACTOR_VALUE_BYTES}-byte bound",
312                value.len()
313            )));
314        }
315        self.writes.entries.insert(key.to_owned(), Some(value));
316        Ok(())
317    }
318
319    async fn delete(&mut self, key: &str) -> Result<(), ActorError> {
320        validate_actor_key(key)?;
321        self.writes.entries.insert(key.to_owned(), None);
322        Ok(())
323    }
324
325    async fn delete_all(&mut self) -> Result<(), ActorError> {
326        self.writes.clear_all = true;
327        self.writes.entries.clear();
328        Ok(())
329    }
330
331    async fn list_prefix(&mut self, prefix: &str) -> Result<Vec<(String, Vec<u8>)>, ActorError> {
332        check_prefix(prefix)?;
333        let mut merged: BTreeMap<String, Vec<u8>> = if self.writes.clear_all {
334            BTreeMap::new()
335        } else {
336            self.store.list_prefix(prefix).await?.into_iter().collect()
337        };
338        for (key, value) in &self.writes.entries {
339            if key.starts_with(prefix) {
340                match value {
341                    Some(value) => {
342                        merged.insert(key.clone(), value.clone());
343                    }
344                    None => {
345                        merged.remove(key);
346                    }
347                }
348            }
349        }
350        Ok(merged.into_iter().collect())
351    }
352
353    async fn set_alarm(&mut self, at: OffsetDateTime) -> Result<(), ActorError> {
354        self.writes.alarm = Some(Some(at));
355        Ok(())
356    }
357
358    async fn alarm(&mut self) -> Result<Option<OffsetDateTime>, ActorError> {
359        match &self.writes.alarm {
360            Some(pending) => Ok(*pending),
361            None => self.store.alarm().await,
362        }
363    }
364
365    async fn cancel_alarm(&mut self) -> Result<(), ActorError> {
366        self.writes.alarm = Some(None);
367        Ok(())
368    }
369}
370
371/// Runs one message: validates the kind, key and message size, runs the
372/// handler against a staged context, checks the reply size, and only then
373/// commits the staged writes. A handler error or an oversized reply commits
374/// nothing.
375///
376/// # Errors
377///
378/// [`ActorError::TooLarge`] for a message or reply over its bound (or a key
379/// over [`MAX_ACTOR_KEY_BYTES`]), [`ActorError::Operation`] for an invalid
380/// kind or empty key, and whatever the handler returns.
381pub async fn run_actor_message(
382    handler: &dyn ActorHandler,
383    store: &dyn ActorStore,
384    clock: &dyn Clock,
385    kind: &str,
386    key: &str,
387    message: &[u8],
388) -> Result<Vec<u8>, ActorError> {
389    check_actor_call(kind, key, message)?;
390    let mut ctx = StagedContext::new(store, clock, kind, key);
391    let reply = handler.on_message(&mut ctx, message).await?;
392    if reply.len() > MAX_ACTOR_REPLY_BYTES {
393        return Err(ActorError::TooLarge(format!(
394            "actor reply of {} bytes exceeds the {MAX_ACTOR_REPLY_BYTES}-byte bound",
395            reply.len()
396        )));
397    }
398    commit_if_any(store, ctx.into_writes()).await?;
399    Ok(reply)
400}
401
402/// Commits `writes` unless there is nothing to commit — the shared tail of
403/// both runners, so an empty staged set never reaches the store.
404async fn commit_if_any(store: &dyn ActorStore, writes: ActorWrites) -> Result<(), ActorError> {
405    if writes.is_empty() {
406        return Ok(());
407    }
408    store.commit(writes).await
409}
410
411/// Runs a fired alarm. The staged context starts with the alarm cancelled, so
412/// a handler that does not re-arm leaves none set; a handler error commits
413/// nothing and returns the error, and the host retries.
414///
415/// # Errors
416///
417/// [`ActorError::Operation`] for an invalid kind or empty key, and whatever
418/// the handler returns.
419pub async fn run_actor_alarm(
420    handler: &dyn ActorHandler,
421    store: &dyn ActorStore,
422    clock: &dyn Clock,
423    kind: &str,
424    key: &str,
425) -> Result<(), ActorError> {
426    validate_actor_kind(kind)?;
427    validate_actor_key(key)?;
428    let mut ctx = StagedContext::new(store, clock, kind, key);
429    ctx.writes.alarm = Some(None);
430    handler.on_alarm(&mut ctx).await?;
431    commit_if_any(store, ctx.into_writes()).await?;
432    Ok(())
433}
434
435/// The handlers a venture registered, by kind. Built from the
436/// [`HarnessBuilder::actor`](crate::HarnessBuilder::actor) registrations so an
437/// in-process actor host can dispatch to them.
438#[derive(Clone, Default)]
439pub struct ActorHandlers {
440    handlers: BTreeMap<String, Arc<dyn ActorHandler>>,
441}
442
443impl ActorHandlers {
444    /// No handlers.
445    #[must_use]
446    pub fn new() -> Self {
447        Self::default()
448    }
449
450    /// Adds a handler for `kind`, replacing any already registered for it.
451    #[must_use]
452    pub fn with(mut self, kind: &str, handler: Arc<dyn ActorHandler>) -> Self {
453        self.handlers.insert(kind.to_owned(), handler);
454        self
455    }
456
457    /// The handler for `kind`, if one is registered.
458    #[must_use]
459    pub fn get(&self, kind: &str) -> Option<Arc<dyn ActorHandler>> {
460        self.handlers.get(kind).map(Arc::clone)
461    }
462
463    /// Every registered kind, sorted.
464    pub fn kinds(&self) -> impl Iterator<Item = &str> {
465        self.handlers.keys().map(String::as_str)
466    }
467}
468
469impl std::fmt::Debug for ActorHandlers {
470    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471        f.debug_struct("ActorHandlers")
472            .field("kinds", &self.kinds().collect::<Vec<_>>())
473            .finish()
474    }
475}
476
477/// Wraps an [`Actors`] so a module only reaches the kinds it declared in
478/// [`Module::actor_kinds`](crate::Module::actor_kinds), the actor equivalent
479/// of the table-ownership rule. When the module has a [`Clock`] wired, every
480/// call is also bounded by [`ACTOR_CALL_TIMEOUT`]; with no clock there is
481/// nothing to measure the wait with, so the call is made straight through and
482/// the host still serializes it. The harness applies this in `Ports::view_for`.
483pub struct ScopedActors {
484    inner: Arc<dyn Actors>,
485    module: String,
486    kinds: &'static [&'static str],
487    clock: Option<Arc<dyn Clock>>,
488}
489
490impl ScopedActors {
491    /// Scopes `inner` to `module`'s declared `kinds`, bounding each call with
492    /// `clock` when one is present.
493    #[must_use]
494    pub fn new(
495        inner: Arc<dyn Actors>,
496        module: &str,
497        kinds: &'static [&'static str],
498        clock: Option<Arc<dyn Clock>>,
499    ) -> Self {
500        Self {
501            inner,
502            module: module.to_owned(),
503            kinds,
504            clock,
505        }
506    }
507}
508
509#[async_trait]
510impl Actors for ScopedActors {
511    async fn call(&self, kind: &str, key: &str, message: &[u8]) -> Result<Vec<u8>, ActorError> {
512        if !self.kinds.contains(&kind) {
513            return Err(ActorError::Operation(format!(
514                "module `{}` does not declare actor kind `{kind}`",
515                self.module
516            )));
517        }
518        check_actor_call(kind, key, message)?;
519        let inner = Arc::clone(&self.inner);
520        let kind = kind.to_owned();
521        let key = key.to_owned();
522        let message = message.to_vec();
523        let reply = match &self.clock {
524            Some(clock) => timeout(
525                clock.as_ref(),
526                async move { inner.call(&kind, &key, &message).await },
527                ACTOR_CALL_TIMEOUT,
528            )
529            .await
530            .ok_or(ActorError::Timeout)??,
531            // No clock is wired, so there is nothing to measure a timeout
532            // against. The host still serializes the call; the caller just
533            // has no deadline of its own to impose.
534            None => inner.call(&kind, &key, &message).await?,
535        };
536        if reply.len() > MAX_ACTOR_REPLY_BYTES {
537            return Err(ActorError::TooLarge(format!(
538                "actor reply of {} bytes exceeds the {MAX_ACTOR_REPLY_BYTES}-byte bound",
539                reply.len()
540            )));
541        }
542        Ok(reply)
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    // Test fixtures are not request state (ADR 0007); the scoped allow
549    // follows the policy in the workspace clippy.toml, as the sibling port
550    // tests do.
551    #![allow(clippy::disallowed_types)]
552
553    use super::*;
554    use std::sync::Mutex;
555
556    /// Records every call so a test can prove a refused one never arrived.
557    #[derive(Default)]
558    struct RecordingActors {
559        seen: Mutex<Vec<(String, String)>>,
560        reply: Vec<u8>,
561    }
562
563    #[async_trait]
564    impl Actors for RecordingActors {
565        async fn call(
566            &self,
567            kind: &str,
568            key: &str,
569            _message: &[u8],
570        ) -> Result<Vec<u8>, ActorError> {
571            self.seen
572                .lock()
573                .unwrap()
574                .push((kind.to_owned(), key.to_owned()));
575            Ok(self.reply.clone())
576        }
577    }
578
579    #[pollster::test]
580    async fn a_scoped_actors_refuses_an_undeclared_kind_before_the_host() {
581        let host = Arc::new(RecordingActors::default());
582        let scoped = ScopedActors::new(
583            host.clone(),
584            "cms",
585            &["counter"],
586            Some(Arc::new(crate::ports::SystemClock)),
587        );
588        let err = scoped.call("other", "k", b"x").await.unwrap_err();
589        assert!(
590            matches!(err, ActorError::Operation(ref m) if m.contains("does not declare actor kind `other`")),
591            "got {err}"
592        );
593        assert!(host.seen.lock().unwrap().is_empty());
594    }
595
596    #[pollster::test]
597    async fn a_message_at_the_bound_passes_and_one_over_is_refused() {
598        let host = Arc::new(RecordingActors::default());
599        let scoped = ScopedActors::new(
600            host.clone(),
601            "cms",
602            &["counter"],
603            Some(Arc::new(crate::ports::SystemClock)),
604        );
605        let exact = vec![0_u8; MAX_ACTOR_MESSAGE_BYTES];
606        scoped.call("counter", "k", &exact).await.expect("at bound");
607        let over = vec![0_u8; MAX_ACTOR_MESSAGE_BYTES + 1];
608        let err = scoped.call("counter", "k", &over).await.unwrap_err();
609        assert!(matches!(err, ActorError::TooLarge(_)), "got {err}");
610        assert_eq!(
611            host.seen.lock().unwrap().len(),
612            1,
613            "the oversized message never reached the host"
614        );
615    }
616
617    #[pollster::test]
618    async fn an_oversized_reply_is_refused() {
619        let host = Arc::new(RecordingActors {
620            seen: Mutex::new(Vec::new()),
621            reply: vec![0_u8; MAX_ACTOR_REPLY_BYTES + 1],
622        });
623        let scoped = ScopedActors::new(
624            host,
625            "cms",
626            &["counter"],
627            Some(Arc::new(crate::ports::SystemClock)),
628        );
629        let err = scoped.call("counter", "k", b"x").await.unwrap_err();
630        assert!(matches!(err, ActorError::TooLarge(_)), "got {err}");
631    }
632
633    #[pollster::test]
634    async fn a_call_without_a_clock_reaches_the_host_with_no_timeout() {
635        let host = Arc::new(RecordingActors::default());
636        let scoped = ScopedActors::new(host.clone(), "cms", &["counter"], None);
637        // With no clock there is no timeout to impose, so the call goes
638        // straight through to the host rather than being refused.
639        scoped.call("counter", "k", b"x").await.expect("no clock");
640        assert_eq!(
641            host.seen.lock().unwrap().as_slice(),
642            &[("counter".to_owned(), "k".to_owned())]
643        );
644    }
645
646    /// An in-memory one-actor store: a map plus an optional alarm. `commit`
647    /// applies the whole set, the way a real adapter must.
648    #[derive(Default)]
649    struct MemStore {
650        values: Mutex<BTreeMap<String, Vec<u8>>>,
651        alarm: Mutex<Option<OffsetDateTime>>,
652    }
653
654    #[async_trait]
655    impl ActorStore for MemStore {
656        async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, ActorError> {
657            Ok(self.values.lock().unwrap().get(key).cloned())
658        }
659        async fn list_prefix(&self, prefix: &str) -> Result<Vec<(String, Vec<u8>)>, ActorError> {
660            Ok(self
661                .values
662                .lock()
663                .unwrap()
664                .iter()
665                .filter(|(k, _)| k.starts_with(prefix))
666                .map(|(k, v)| (k.clone(), v.clone()))
667                .collect())
668        }
669        async fn alarm(&self) -> Result<Option<OffsetDateTime>, ActorError> {
670            Ok(*self.alarm.lock().unwrap())
671        }
672        async fn commit(&self, writes: ActorWrites) -> Result<(), ActorError> {
673            let mut values = self.values.lock().unwrap();
674            if writes.clear_all {
675                values.clear();
676            }
677            for (key, value) in writes.entries {
678                match value {
679                    Some(value) => {
680                        values.insert(key, value);
681                    }
682                    None => {
683                        values.remove(&key);
684                    }
685                }
686            }
687            if let Some(alarm) = writes.alarm {
688                *self.alarm.lock().unwrap() = alarm;
689            }
690            Ok(())
691        }
692    }
693
694    /// A handler whose behaviour a test sets: it stages a put, optionally
695    /// errs or returns an oversized reply, and optionally re-arms the alarm.
696    struct ScriptedHandler {
697        put: Option<(String, Vec<u8>)>,
698        fail: bool,
699        reply_len: usize,
700        rearm: bool,
701    }
702
703    #[async_trait]
704    impl ActorHandler for ScriptedHandler {
705        async fn on_message(
706            &self,
707            ctx: &mut dyn ActorContext,
708            _message: &[u8],
709        ) -> Result<Vec<u8>, ActorError> {
710            if let Some((key, value)) = &self.put {
711                ctx.put(key, value.clone()).await?;
712            }
713            if self.fail {
714                return Err(ActorError::Handler("boom".to_owned()));
715            }
716            Ok(vec![0_u8; self.reply_len])
717        }
718
719        async fn on_alarm(&self, ctx: &mut dyn ActorContext) -> Result<(), ActorError> {
720            if let Some((key, value)) = &self.put {
721                ctx.put(key, value.clone()).await?;
722            }
723            if self.fail {
724                return Err(ActorError::Handler("boom".to_owned()));
725            }
726            if self.rearm {
727                ctx.set_alarm(OffsetDateTime::UNIX_EPOCH).await?;
728            }
729            Ok(())
730        }
731    }
732
733    fn clock() -> crate::ports::SystemClock {
734        crate::ports::SystemClock
735    }
736
737    #[pollster::test]
738    async fn a_handler_error_commits_nothing() {
739        let store = MemStore::default();
740        let handler = ScriptedHandler {
741            put: Some(("k".to_owned(), b"v".to_vec())),
742            fail: true,
743            reply_len: 0,
744            rearm: false,
745        };
746        let err = run_actor_message(&handler, &store, &clock(), "counter", "a", b"m")
747            .await
748            .unwrap_err();
749        assert!(matches!(err, ActorError::Handler(_)), "got {err}");
750        assert!(store.get("k").await.unwrap().is_none());
751    }
752
753    #[pollster::test]
754    async fn an_oversized_reply_commits_nothing() {
755        let store = MemStore::default();
756        let handler = ScriptedHandler {
757            put: Some(("k".to_owned(), b"v".to_vec())),
758            fail: false,
759            reply_len: MAX_ACTOR_REPLY_BYTES + 1,
760            rearm: false,
761        };
762        assert!(matches!(
763            run_actor_message(&handler, &store, &clock(), "counter", "a", b"m")
764                .await
765                .unwrap_err(),
766            ActorError::TooLarge(_)
767        ));
768        assert!(store.get("k").await.unwrap().is_none());
769    }
770
771    #[pollster::test]
772    async fn a_successful_message_commits_its_writes() {
773        let store = MemStore::default();
774        let handler = ScriptedHandler {
775            put: Some(("k".to_owned(), b"v".to_vec())),
776            fail: false,
777            reply_len: 3,
778            rearm: false,
779        };
780        let reply = run_actor_message(&handler, &store, &clock(), "counter", "a", b"m")
781            .await
782            .unwrap();
783        assert_eq!(reply, vec![0_u8; 3]);
784        assert_eq!(store.get("k").await.unwrap().as_deref(), Some(&b"v"[..]));
785    }
786
787    #[pollster::test]
788    async fn an_oversized_put_is_refused() {
789        let store = MemStore::default();
790        let handler = ScriptedHandler {
791            put: Some(("k".to_owned(), vec![0_u8; MAX_ACTOR_VALUE_BYTES + 1])),
792            fail: false,
793            reply_len: 0,
794            rearm: false,
795        };
796        assert!(matches!(
797            run_actor_message(&handler, &store, &clock(), "counter", "a", b"m")
798                .await
799                .unwrap_err(),
800            ActorError::TooLarge(_)
801        ));
802        assert!(store.values.lock().unwrap().is_empty());
803    }
804
805    /// Stages a put, then reads it back, deletes everything, and lists —
806    /// exercising the staged-view semantics through one handler.
807    struct StageReader;
808
809    #[async_trait]
810    impl ActorHandler for StageReader {
811        async fn on_message(
812            &self,
813            ctx: &mut dyn ActorContext,
814            _message: &[u8],
815        ) -> Result<Vec<u8>, ActorError> {
816            ctx.put("a/1", b"one".to_vec()).await?;
817            ctx.put("b/1", b"two".to_vec()).await?;
818            assert_eq!(ctx.get("a/1").await?.as_deref(), Some(&b"one"[..]));
819            assert_eq!(ctx.list_prefix("a/").await?.len(), 1);
820            ctx.delete("a/1").await?;
821            assert!(ctx.get("a/1").await?.is_none());
822            assert!(ctx.list_prefix("a/").await?.is_empty());
823            Ok(Vec::new())
824        }
825
826        async fn on_alarm(&self, _ctx: &mut dyn ActorContext) -> Result<(), ActorError> {
827            Ok(())
828        }
829    }
830
831    #[pollster::test]
832    async fn a_handler_sees_its_own_staged_writes() {
833        let store = MemStore::default();
834        run_actor_message(&StageReader, &store, &clock(), "counter", "a", b"m")
835            .await
836            .unwrap();
837        // `a/1` was deleted and `b/1` put; the commit reflects both.
838        assert_eq!(
839            store.get("b/1").await.unwrap().as_deref(),
840            Some(&b"two"[..])
841        );
842        assert!(store.get("a/1").await.unwrap().is_none());
843    }
844
845    /// `delete_all` after a staged put leaves the actor empty.
846    struct ClearAfterPut;
847
848    #[async_trait]
849    impl ActorHandler for ClearAfterPut {
850        async fn on_message(
851            &self,
852            ctx: &mut dyn ActorContext,
853            _message: &[u8],
854        ) -> Result<Vec<u8>, ActorError> {
855            ctx.put("k", b"v".to_vec()).await?;
856            ctx.delete_all().await?;
857            assert!(ctx.get("k").await?.is_none());
858            Ok(Vec::new())
859        }
860
861        async fn on_alarm(&self, _ctx: &mut dyn ActorContext) -> Result<(), ActorError> {
862            Ok(())
863        }
864    }
865
866    #[pollster::test]
867    async fn delete_all_drops_staged_writes() {
868        let store = MemStore::default();
869        let mut seeded = BTreeMap::new();
870        seeded.insert("old".to_owned(), b"x".to_vec());
871        *store.values.lock().unwrap() = seeded;
872        run_actor_message(&ClearAfterPut, &store, &clock(), "counter", "a", b"m")
873            .await
874            .unwrap();
875        assert!(store.values.lock().unwrap().is_empty());
876    }
877
878    #[pollster::test]
879    async fn an_alarm_is_cancelled_unless_re_armed() {
880        let store = MemStore::default();
881        *store.alarm.lock().unwrap() = Some(OffsetDateTime::UNIX_EPOCH);
882        let quiet = ScriptedHandler {
883            put: None,
884            fail: false,
885            reply_len: 0,
886            rearm: false,
887        };
888        run_actor_alarm(&quiet, &store, &clock(), "counter", "a")
889            .await
890            .unwrap();
891        assert!(store.alarm().await.unwrap().is_none());
892
893        *store.alarm.lock().unwrap() = Some(OffsetDateTime::UNIX_EPOCH);
894        let rearming = ScriptedHandler {
895            put: None,
896            fail: false,
897            reply_len: 0,
898            rearm: true,
899        };
900        run_actor_alarm(&rearming, &store, &clock(), "counter", "a")
901            .await
902            .unwrap();
903        assert_eq!(
904            store.alarm().await.unwrap(),
905            Some(OffsetDateTime::UNIX_EPOCH)
906        );
907    }
908
909    #[pollster::test]
910    async fn a_failing_alarm_commits_nothing() {
911        let store = MemStore::default();
912        *store.alarm.lock().unwrap() = Some(OffsetDateTime::UNIX_EPOCH);
913        let handler = ScriptedHandler {
914            put: Some(("k".to_owned(), b"v".to_vec())),
915            fail: true,
916            reply_len: 0,
917            rearm: false,
918        };
919        assert!(
920            run_actor_alarm(&handler, &store, &clock(), "counter", "a")
921                .await
922                .is_err()
923        );
924        // The alarm is still set: a failed run commits neither the write nor
925        // the cancel, so the host retries.
926        assert_eq!(
927            store.alarm().await.unwrap(),
928            Some(OffsetDateTime::UNIX_EPOCH)
929        );
930        assert!(store.get("k").await.unwrap().is_none());
931    }
932
933    #[test]
934    fn kind_and_key_validation() {
935        assert!(validate_actor_kind("counter").is_ok());
936        assert!(validate_actor_kind("a-b_9").is_ok());
937        for bad in [
938            "",
939            "Counter",
940            "a b",
941            "a:b",
942            &"a".repeat(MAX_ACTOR_KIND_BYTES + 1),
943        ] {
944            assert!(
945                matches!(validate_actor_kind(bad), Err(ActorError::Operation(_))),
946                "kind `{bad}` should be refused"
947            );
948        }
949        assert!(validate_actor_key("k").is_ok());
950        assert!(matches!(
951            validate_actor_key(""),
952            Err(ActorError::Operation(_))
953        ));
954        assert!(matches!(
955            validate_actor_key(&"k".repeat(MAX_ACTOR_KEY_BYTES + 1)),
956            Err(ActorError::TooLarge(_))
957        ));
958    }
959}