Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions docs/src/content/docs/durable/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ implement the `Store` interface out of tree.
- **Deterministic replay.** A recovered instance is byte-identical to one that
never crashed, because recovery replays the recorded driving events and
nondeterministic results rather than re-executing their sources.
- **Exactly-once effects.** A domain effect is applied exactly once over the
- **Exactly-once effects.** A domain effect from a step (not an initial entry, which is at-least-once, or a parent transition an actor drives, which is not dispatched) is applied exactly once over the
instance's lifetime (the live run plus any number of recoveries), even though
the replay loop is at-least-once. Each effect carries a deterministic
`EffectID` and is deduplicated through the `Store`'s dispatch set.
the replay loop is at-least-once. The exception is a crash or failed mark after the
handler returned, or a handoff between owners, where it can apply twice, so keep
handlers idempotent on `EffectInstance` plus the `EffectID`. Each effect carries a
deterministic `EffectID` and is deduplicated through the `Store`'s dispatch set.
- **Durability across restart.** Every `Fire` step is write-ahead appended to the
`Store` before it is acknowledged, so a crash after a successful `Fire` never
loses the step. Periodic checkpoints bound the tail that recovery replays.
Expand Down Expand Up @@ -74,7 +76,7 @@ the first time, and replayed verbatim on recovery:
| **Clock** (timers) | `WithRunnerClock` | `JournalClockRead`, replayed so timers fire at the same recorded instants, wall-clock-independent; armed deadlines survive checkpoint compaction |
| **Invoked services** | `WithServiceRegistry` + `Handle.RunService` | `JournalServiceResult`; a recorded result replays through the kernel's settle seam without re-running the service, and an unrecorded one (a context canceled during the service, a failed `Append`, a crash) runs again when the host calls `RunService` |
| **Child-machine actors** | `WithActorPalette` + `Handle.DeliverToActor` | `JournalActorMessage`; recovery re-fires a recorded parent transition; a settled actor doesn't re-run, and a running one restarts from its initial state on any `Recover` |
| **Domain effects** | `WithEffectHandler` | dispatch set, applied exactly once via deterministic `EffectID` dedup |
| **Domain effects** | `WithEffectHandler` | dispatch set, a step's effects applied exactly once via deterministic `EffectID` dedup, with the exceptions under Exactly-once effects |

Use `WithCheckpointEvery(n)` to tune how often a full snapshot is written: a
shorter interval bounds recovery replay, a longer one cuts checkpoint cost.
Expand Down Expand Up @@ -131,19 +133,21 @@ in by implementing `HistoryStore` (the in-tree `MemStore` does so under
goroutines: each holds the Handle's lock for the whole step. A host callback must not
call back into its Handle. `Instance()` is outside the lock.

A step that may not have been recorded, or a `Tick`, `RunService` or `DeliverToActor` whose
context ended partway, fails the Handle with `ErrHandleFailed`. `Recover`
a new Handle with the same options to continue, unless the cause is `ErrFenced`: then a
newer owner holds the instance, so stop.
A step that may not have been recorded, a failed effect dispatch, or a `Tick`, `RunService`
or `DeliverToActor` whose context ended partway, fails the Handle with `ErrHandleFailed`.
`Recover` a new Handle with the same options to continue, unless the cause is `ErrFenced`:
then a newer owner holds the instance, so stop. `Recover` retries an unapplied effect, and
returns `ErrEffectDispatch` while the handler still fails.

## A note on serialized payloads

Events, service done-data, actor done-data, and actor messages are recorded as their
JSON form. A parent reducer that type-asserts a non-JSON Go type from `AssignCtx.Event`
observes the JSON-decoded shape on a replayed `onDone`. A `WithEventData` payload is
JSON-decoded live as well as on replay, so a reducer gets the same value both ways: a
struct is a map and an integer a `float64`. An error stays an error with the same
message, with any invalid UTF-8 replaced. `Handle.Fire` refuses a payload it can't
observes the JSON-decoded shape of actor done-data on a replayed `onDone`. A
`WithEventData` payload and a service's result are JSON-decoded live as well as on replay,
so a reducer gets the same value both ways: a struct is a map and an integer a `float64`,
which loses precision above 2^53. An error stays an error with the same message, with any
invalid UTF-8 replaced, and `errors.Is` no longer matches it. `Handle.Fire` refuses a payload it can't
record, or one over `WithMaxEventDataBytes` (8 MiB by default), with
`ErrEventDataEncoding`, and nothing applies. A
typed-codec option to carry the concrete Go value across the journal boundary is
Expand Down
24 changes: 24 additions & 0 deletions durable/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.

### Added

- **`EffectInstance`** reads the instance from the ctx an `EffectHandler` receives, because
effect ids repeat across instances.
- **Fencing.** `WithFence` on `Store.Append` and `WithCheckpointFence` on
`Store.Checkpoint` carry a caller-issued token, and a Store refuses a token lower
than the highest it has seen for the instance with `ErrFenced`, writing nothing.
Expand All @@ -30,6 +32,20 @@ and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.
### Changed

- Requires Go 1.27.3.
- **A failed effect dispatch fails the Handle.** The call returns `ErrHandleFailed`
wrapping `ErrEffectDispatch`, before any checkpoint, so the effect can't be compacted
away. `Recover` retries it, and fails with `ErrEffectDispatch` while the handler still
does. A `Fire` after a failed dispatch used to be accepted. A failed `MarkDispatched`
fails the Handle too, and `Recover` applies that effect again. The handler's error now
matches through `errors.Is`.
- **A `KindedEffect` that can't marshal fails `Tick` and `RunService`,** as it already
failed `Fire`, even with no handler wired.
- **A service result reaches `Assign` decoded, live as well as on replay,** so a retried
effect can't differ from the first attempt: a struct arrives as a map, an integer as a
`float64`, which loses precision above 2^53, and an error as an error with the same
message, any invalid UTF-8 replaced, which `errors.Is` no longer matches. An `Error`
method that panics settles with a message saying so. The journal records this decoded
value.
- **A Handle's steps are serialized across goroutines.** `Fire`, `Tick`, `RunService` and
`DeliverToActor` hold a lock for the whole step, so a host's timer loop no longer loses
acknowledged steps. A host callback must not call back into its Handle, or it
Expand Down Expand Up @@ -75,6 +91,14 @@ and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.

- **A failed checkpoint no longer makes the next step conflict.** `Tick`, `RunService`
and `DeliverToActor` now advance the next step as soon as the append lands.
- **Effects reach the handler from `Start`, `Tick` and `RunService`.** They were only
dispatched from `Fire`. `Start` applies the initial entry's effects after encoding the
baseline and before writing it, and only if the entry succeeded. Those effects are
at-least-once: a handler failure, a failed baseline write or overlapping `Start` calls
re-apply them, so key any dedupe on `EffectInstance` plus the effect id. Effects from a
parent transition an actor drives, including during `Fire`, are still not dispatched.
The first `Recover` of an older journal applies effects from uncompacted `Tick` and
service steps.
- **A step whose context ended partway isn't journaled.** A canceled `RunService`, `Tick`
or `DeliverToActor` used to record a result the live instance never applied, which
recovery then committed, or for an actor, failed on. Each now returns `ErrHandleFailed`
Expand Down
28 changes: 16 additions & 12 deletions durable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ backends implement the `Store` interface out of tree.
- **Deterministic replay**: a recovered instance is byte-identical to one that
never crashed, because recovery replays the recorded driving events and
nondeterministic results rather than re-executing their sources.
- **Exactly-once effects**: a domain effect is applied exactly once over the
- **Exactly-once effects**: a domain effect from a step (not an initial entry, which is at-least-once, or a parent transition an actor drives, which is not dispatched) is applied exactly once over the
instance's lifetime (the live run plus any number of recoveries), even though
the replay loop is at-least-once. Each effect carries a deterministic
`EffectID` and is deduplicated through the `Store`'s dispatch set.
the replay loop is at-least-once. The exception is a crash or failed mark after the
handler returned, or a handoff between owners, where it can apply twice, so keep
handlers idempotent on `EffectInstance` plus the `EffectID`. Each effect carries a
deterministic `EffectID` and is deduplicated through the `Store`'s dispatch set.
- **Durability across restart**: every `Fire` step is write-ahead appended to
the `Store` before it is acknowledged, so a crash after a successful `Fire`
never loses the step. Periodic checkpoints bound the tail recovery replays.
Expand Down Expand Up @@ -84,7 +86,7 @@ the first time, and replayed verbatim on recovery:
| **Clock** (timers) | `WithRunnerClock` | `JournalClockRead`, replayed so timers fire at the same recorded instants, wall-clock-independent; armed deadlines survive checkpoint compaction |
| **Invoked services** | `WithServiceRegistry` + `Handle.RunService` | `JournalServiceResult`; a recorded result replays through the kernel's settle seam without re-running the service, and an unrecorded one (a context canceled during the service, a failed `Append`, a crash) runs again when the host calls `RunService` |
| **Child-machine actors** | `WithActorPalette` + `Handle.DeliverToActor` | `JournalActorMessage`; recovery re-fires a recorded parent transition; a settled actor doesn't re-run, and a running one restarts from its initial state on any `Recover` |
| **Domain effects** | `WithEffectHandler` | dispatch set, applied exactly once via deterministic `EffectID` dedup |
| **Domain effects** | `WithEffectHandler` | dispatch set, a step's effects applied exactly once via deterministic `EffectID` dedup, with the exceptions under Exactly-once effects |

Use `WithCheckpointEvery(n)` to tune how often a full snapshot is written (a
shorter interval bounds recovery replay; a longer one cuts checkpoint cost).
Expand Down Expand Up @@ -142,19 +144,21 @@ otherwise.
goroutines: each holds the Handle's lock for the whole step. A host callback must not
call back into its Handle. `Instance()` is outside the lock.

A step that may not have been recorded, or a `Tick`, `RunService` or `DeliverToActor` whose
context ended partway, fails the Handle with `ErrHandleFailed`. `Recover`
a new Handle with the same options to continue, unless the cause is `ErrFenced`: then a
newer owner holds the instance, so stop.
A step that may not have been recorded, a failed effect dispatch, or a `Tick`, `RunService`
or `DeliverToActor` whose context ended partway, fails the Handle with `ErrHandleFailed`.
`Recover` a new Handle with the same options to continue, unless the cause is `ErrFenced`:
then a newer owner holds the instance, so stop. `Recover` retries an unapplied effect, and
returns `ErrEffectDispatch` while the handler still fails.

## Caveat: serialized payloads

Events, service done-data, actor done-data, and actor messages are recorded as their
JSON form. A parent reducer that type-asserts a non-JSON Go type from `AssignCtx.Event`
observes the JSON-decoded shape on a replayed `onDone`. A `WithEventData` payload is
JSON-decoded live as well as on replay, so a reducer gets the same value both ways: a
struct is a map and an integer a `float64`. An error stays an error with the same
message, with any invalid UTF-8 replaced. `Handle.Fire` refuses a payload it can't
observes the JSON-decoded shape of actor done-data on a replayed `onDone`. A
`WithEventData` payload and a service's result are JSON-decoded live as well as on replay,
so a reducer gets the same value both ways: a struct is a map and an integer a `float64`,
which loses precision above 2^53. An error stays an error with the same message, with any
invalid UTF-8 replaced, and `errors.Is` no longer matches it. `Handle.Fire` refuses a payload it can't
record, or one over `WithMaxEventDataBytes` (8 MiB by default), with
`ErrEventDataEncoding`, and nothing applies. A
typed-codec option to carry the concrete Go value across the journal boundary is
Expand Down
6 changes: 3 additions & 3 deletions durable/actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,9 @@ func (h *Handle[S, E, C]) DeliverToActor(ctx context.Context, ref state.ActorRef
*h.actorBuf = append(*h.actorBuf, entries...)
}

step := h.nextStep
rec := Record{Step: step, Entries: h.drainActors()}
if err := h.persistStep(ctx, step, &rec); err != nil {
rec := Record{Step: h.nextStep, Entries: h.drainActors()}
// nil: a parent transition an actor drives isn't dispatched (see doc.go).
if err := h.commitStep(ctx, &rec, nil); err != nil {
return true, err
}
return true, nil
Expand Down
9 changes: 6 additions & 3 deletions durable/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@
// recovery replays the exact recorded driving events and nondeterministic results
// through the same pure transition function.
//
// Exactly-once effects: a domain effect emitted by a transition is applied
// Exactly-once effects: a domain effect emitted by a step (Runner.Start's initial entry
// is at-least-once, and a parent transition an actor drives isn't dispatched) is applied
// exactly once over the instance's lifetime — the live run plus any number of
// recoveries — even though the runtime's replay loop is at-least-once. Each
// recoveries — even though the runtime's replay loop is at-least-once. The exception is
// a crash or failed mark after the handler returned, or a handoff between owners, where
// an effect can apply twice, so keep handlers idempotent on EffectInstance plus the EffectID. Each
// effect is stamped with a deterministic EffectID (step, ordinal, kind) and
// deduplicated through the Store's dispatch set.
//
Expand Down Expand Up @@ -118,7 +121,7 @@
// publishing a message — as an Effect value the kernel routes out through
// FireResult.Effects without applying. The Runner applies it through the caller-
// supplied EffectHandler (WithEffectHandler), exactly once over the instance's
// lifetime.
// lifetime, with the exceptions under Exactly-once effects above.
//
// Each emitted effect is stamped with a deterministic EffectID derived from its
// step, its emission ordinal within that step, and its kind — every component a
Expand Down
Loading
Loading