Skip to content

Durable state

DurableStateBehavior<C, S> persists the latest state instead of an event history. It is the smaller model when you need actor-serialized decisions, crash recovery, and optimistic concurrency but do not need to replay why the state changed.

The behavior is a side-effect-free blueprint. spawn(Arc<dyn DurableStateStore>) performs one recovery read and returns a DurableStateHandle only after recovery completes.

Counter quickstart

This example uses the I64Serializer and counter_catalog() from the event-sourcing quickstart.

rust
use std::sync::Arc;

use datum_persistence::{
    DurableStateStore, IdentityAdapter, MemoryDurableStateStore, PersistenceId,
    state::{DurableStateBehavior, Effect},
};

enum CounterCommand {
    Add(i64),
    Delete,
}

let behavior = DurableStateBehavior::new(
    PersistenceId::new("durable-counter", "counter-1").unwrap(),
    0_i64,
    |state, command| match command {
        CounterCommand::Add(amount) => Effect::persist(state + amount),
        CounterCommand::Delete => Effect::delete(),
    },
)
.with_state_adapter(IdentityAdapter, counter_catalog());

let store = Arc::new(MemoryDurableStateStore::new());
let handle = behavior.spawn(store.clone()).await.unwrap();
handle.tell(CounterCommand::Add(7)).unwrap();

let current = handle.state().await.unwrap();
assert_eq!(current.state, 7);
assert_eq!(current.revision.as_u64(), 1);
handle.stop();

tell is fire-and-forget. state().await runs through the same ordered mailbox and returns the confirmed DurableState { state, revision }. actor_ref() is available for ActorFlow::ask; request builders wrap commands with DurableStateMessage::command.

CAS revisions

The storage contract is explicit:

  • Unknown ids read as absent state at revision zero.
  • upsert and delete succeed only when new_revision == stored_revision + 1.
  • A mismatch returns PersistenceError::RevisionConflict { persistence_id, expected, actual }, never a generic storage failure.
  • The behavior runtime calculates the next revision and does not expose unconfirmed state.

Two live behavior instances for the same persistence id can both recover the same revision, but only one can win the next write. The loser enters the persist-failure path and stops by default. Avoid concurrent writers for one id; when using the store SPI directly, read the latest revision before retrying a conflict. See the CAS conflict recipe.

Tombstones

Effect::delete() is revision-advancing, not “forget this id.” On success the store keeps a tombstone with no payload or tag, and the behavior installs its configured empty state. A restarted behavior recovers the empty state and the tombstone's revision, so the next upsert must continue at the following revision.

rust
handle.tell(CounterCommand::Delete).unwrap();
let deleted = handle.state().await.unwrap();
assert_eq!(deleted.state, 0);
assert_eq!(deleted.revision.as_u64(), 2);

let stored = store
    .get(&PersistenceId::new("durable-counter", "counter-1").unwrap())
    .await
    .unwrap();
assert!(stored.state.is_none());
assert_eq!(stored.revision.as_u64(), 2);

The final get calls the DurableStateStore trait, which the quickstart imports explicitly.

Effects, replies, and signals

Durable-state effects are synchronous: persist, delete, none, unhandled, stash, unstash_all, and stop. There is intentionally no durable-state async_effect; storage I/O is still asynchronous inside the materialized runtime.

Chained then_run sees confirmed state. then_run_with_revision also receives the committed revision. For request/reply protocols, build with DurableStateBehavior::with_enforced_replies and return then_reply, then_reply_with_revision, or an explicit no-reply effect from every branch.

DurableStateSignal reports RecoveryCompleted and RecoveryFailed. Write failures and revision conflicts use the behavior failure path rather than separate persist signals. They stop the behavior unless on_persist_failure(RestartSettings) is configured; delayed backoff performs a fresh single-read recovery and automatically stashes domain commands.

Query tag

with_tag(Tag) writes one tag with each upsert. A later upsert replaces it; delete clears it. The DurableStateChangesQuery capability exposes coalesced latest changes by tag where implemented.

rust
use datum_persistence::Tag;

let behavior = behavior.with_tag(Tag::new("account-balance"));

Derive change events

A ChangeEventHandler can produce an event for every update or delete. The update callback receives previous state, new state, and the command. The delete callback receives previous state and the command.

rust
use std::sync::Arc;

use datum_persistence::{IdentityAdapter, MemoryJournal, Tag};
use datum_persistence::state::ChangeEventHandler;

#[derive(Clone, Copy)]
enum SetCommand {
    Set(i64),
    Delete,
}

let change_events = ChangeEventHandler::new(
    |previous: &i64, next: &i64, _command: &SetCommand| next - previous,
    |previous: &i64, _command: &SetCommand| -*previous,
);
let change_journal = Arc::new(MemoryJournal::new());

let behavior = DurableStateBehavior::new(
    PersistenceId::new("balance", "account-7").unwrap(),
    0_i64,
    |_state, command| match command {
        SetCommand::Set(value) => Effect::persist(value),
        SetCommand::Delete => Effect::delete(),
    },
)
.with_state_adapter(IdentityAdapter, counter_catalog())
.with_tag(Tag::new("balance-change"))
.with_change_event_handler(change_events, IdentityAdapter, counter_catalog())
.with_change_event_journal(change_journal);

The command is cloned before the consuming command handler runs, so enabling change events requires C: Clone. Spawn fails if a handler is configured without a change-event journal. The durable CAS is committed before the journal append and the two stores are not transactionally coupled; design the read model to reconcile a missing second write.

See also