Skip to content

Commit 5931988

Browse files
committed
fix: preserve durable replay fidelity under opt-in trace observability
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent d3b5fa2 commit 5931988

9 files changed

Lines changed: 145 additions & 5 deletions

File tree

durable/runner.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,12 @@ func (r *Runner[S, E, C]) Start(ctx context.Context, id InstanceID, input C, opt
246246
// (WithClock); the scheduler reads it when arming and ticking timers.
247247
buf := make([]state.JournalEntry, 0)
248248
recClock := newRecordingClock(r.cfg.clock, &buf)
249-
castOpts := append([]state.CastOption[S]{state.WithClock[S](recClock)}, opts...)
249+
// Force unbounded full-trace history: the runner journals each step's Trace
250+
// (notably the EventPayload) and derives both the step ordinal and the checkpoint
251+
// snapshots from the instance's retained Trace history, so a recover can replay it
252+
// byte-identically. WithUnboundedHistory elevates to full trace and retains every
253+
// step; it is prepended so a caller's opts cannot downgrade it.
254+
castOpts := append([]state.CastOption[S]{state.WithClock[S](recClock), state.WithUnboundedHistory[S]()}, opts...)
250255
inst := r.machine.Cast(input, castOpts...)
251256

252257
// Persist a baseline checkpoint at baselineStep (below the first fired step)
@@ -530,7 +535,7 @@ func (r *Runner[S, E, C]) recover(ctx context.Context, id InstanceID) (*Handle[S
530535
recClock := newRecordingClock(r.cfg.clock, &buf)
531536
repClock := newReplayClock(clockReadings(tail), recClock)
532537

533-
inst, err := r.machine.Restore(snap, state.WithRestoreClock[S](repClock))
538+
inst, err := r.machine.Restore(snap, state.WithRestoreClock[S](repClock), state.WithRestoreUnboundedHistory[S]())
534539
if err != nil {
535540
return nil, fmt.Errorf("durable: restoring checkpoint for %q: %w", id, err)
536541
}

durable/runner_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ func parallelMachine() *state.Machine[string, string, *runCtx] {
9595
// recovered instance must match byte-for-byte.
9696
func liveSnapshotBytes[C any](t *testing.T, m *state.Machine[string, string, C], initial string, entity C, events []string) []byte {
9797
t.Helper()
98-
inst := m.Cast(entity, state.WithInitialState(initial))
98+
// The Runner records in full-trace, unbounded-history mode, so the never-durable
99+
// reference must run in the same mode for a byte-identical snapshot comparison;
100+
// a plain (lite) cast would omit the recorded Trace history.
101+
inst := m.Cast(entity, state.WithInitialState(initial), state.WithUnboundedHistory[string]())
99102
ctx := context.Background()
100103
for _, ev := range events {
101104
if res := inst.Fire(ctx, ev); res.Err != nil {

durable/timetravel.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,10 @@ func restoreBaseline[S comparable, E comparable, C any](
210210
if err != nil {
211211
return nil, fmt.Errorf("unmarshaling baseline snapshot: %w", err)
212212
}
213-
inst, err := m.Restore(snap, state.WithRestoreClock[S](repClock))
213+
// Reconstruct in the same full-trace, unbounded-history mode the runner records
214+
// in, so a time-travel reconstruction's Trace history is byte-identical to the
215+
// live run it replays.
216+
inst, err := m.Restore(snap, state.WithRestoreClock[S](repClock), state.WithRestoreUnboundedHistory[S]())
214217
if err != nil {
215218
return nil, fmt.Errorf("restoring baseline snapshot: %w", err)
216219
}

state/actor.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,19 @@ func NewActor[S comparable, E comparable, C any](inst *Instance[S, E, C], output
294294
return a
295295
}
296296

297+
// inheritObservability puts the actor's backing child instance into the parent's
298+
// trace mode: full trace, and (for a durable parent) unbounded history retention so
299+
// the actor's own snapshot round-trips its Traces. The ActorSystem calls it (via an
300+
// optional-interface assertion) when its parent runs that way, so a journal/replay
301+
// host that drives the parent — the durable runner — records each actor's rich
302+
// per-step trace and EventPayload too. It is an internal observability gate, not
303+
// part of the ActorInstance contract, so a host's own ActorInstance implementation
304+
// is unaffected.
305+
func (a *actorAdapter[S, E, C]) inheritObservability(full, unbounded bool) {
306+
a.inst.traceFull = full
307+
a.inst.histUnbounded = unbounded
308+
}
309+
297310
// isActorEffect reports whether an effect is one the ActorSystem acts on: a
298311
// lifecycle effect (SpawnActor / StopActor) or an actor-communication send effect
299312
// (SendTo / SendParent / RespondToSender / ForwardEvent). Other effects (e.g. a

state/actor_snapshot.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,17 @@ func (a *actorAdapter[S, E, C]) RestoreJSON(b []byte) error {
9797
if err := json.Unmarshal(b, &snap); err != nil {
9898
return &SnapshotError{Op: "unmarshal", Reason: "actor child decode failed: " + err.Error()}
9999
}
100-
restored, err := a.inst.machine.Restore(snap, WithRestoreClock[S](a.inst.clock))
100+
restoreOpts := []RestoreOption[S]{WithRestoreClock[S](a.inst.clock)}
101+
// Preserve the actor's observability mode across an in-place restore so a
102+
// journal/replay host keeps recording the child's rich trace, and (under a
103+
// durable parent) keeps retaining its history so the child still round-trips.
104+
switch {
105+
case a.inst.histUnbounded:
106+
restoreOpts = append(restoreOpts, WithRestoreUnboundedHistory[S]())
107+
case a.inst.traceFull:
108+
restoreOpts = append(restoreOpts, WithRestoreFullTrace[S]())
109+
}
110+
restored, err := a.inst.machine.Restore(snap, restoreOpts...)
101111
if err != nil {
102112
return err
103113
}
@@ -245,6 +255,7 @@ func (s *ActorSystem[S, E, C]) restoreActor(ctx context.Context, snap actorSnaps
245255
if err != nil {
246256
return &SnapshotError{Op: "restore", State: snap.ID, Reason: "actor re-spawn failed: " + err.Error()}
247257
}
258+
s.propagateTrace(inst)
248259

249260
ra := &runningActor[E]{
250261
inst: inst,

state/actor_system.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,22 @@ func (s *ActorSystem[S, E, C]) absorb(ctx context.Context, effects []Effect, cur
287287
// spawn creates and registers a running actor from a SpawnActor effect. On an
288288
// unbound Src it fires the parent's onError (when usable) so completion still
289289
// routes.
290+
// propagateTrace puts a freshly spawned or restored child actor into full trace
291+
// mode when the parent instance runs in full mode, so the parent's observability
292+
// choice (notably a journal/replay host such as the durable runner) reaches the
293+
// whole actor subtree. It uses an optional-interface assertion so a host's own
294+
// ActorInstance implementation is left untouched.
295+
func (s *ActorSystem[S, E, C]) propagateTrace(inst ActorInstance) {
296+
if s.parent == nil || !s.parent.traceFull {
297+
return
298+
}
299+
if ft, ok := inst.(interface {
300+
inheritObservability(full, unbounded bool)
301+
}); ok {
302+
ft.inheritObservability(s.parent.traceFull, s.parent.histUnbounded)
303+
}
304+
}
305+
290306
func (s *ActorSystem[S, E, C]) spawn(ctx context.Context, e SpawnActor) {
291307
s.mu.Lock()
292308
behavior, ok := s.palette[e.Src.Name]
@@ -305,6 +321,7 @@ func (s *ActorSystem[S, E, C]) spawn(ctx context.Context, e SpawnActor) {
305321
s.routeError(ctx, e, err)
306322
return
307323
}
324+
s.propagateTrace(inst)
308325

309326
ra := &runningActor[E]{
310327
inst: inst,

state/observability_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,69 @@ func TestHistory_DefaultReturnsNil(t *testing.T) {
395395
}
396396
}
397397

398+
// ---------------------------------------------------------------------------
399+
// 5b. Restore trace mode: WithRestoreFullTrace / WithRestoreUnboundedHistory.
400+
// ---------------------------------------------------------------------------
401+
402+
// TestRestore_TraceModeOptIn asserts a restored instance is lite by default (a
403+
// subsequent Fire omits rich fields and does not retain new traces), that
404+
// WithRestoreFullTrace restores full per-step traces without continued retention,
405+
// and that WithRestoreUnboundedHistory restores full traces AND keeps retaining
406+
// them — the mode the durable runner relies on for byte-identical replay. (Restore
407+
// always seeds history from the snapshot's traces, so the distinguishing behavior
408+
// is whether later Fires are rich and whether they keep accumulating.)
409+
func TestRestore_TraceModeOptIn(t *testing.T) {
410+
m := buildToggleMachine()
411+
ctx := context.Background()
412+
413+
src := m.Cast(nil, state.WithInitialState[toggleState](toggleA), state.WithUnboundedHistory[toggleState]())
414+
src.Fire(ctx, toggleGo)
415+
snap := src.Snapshot()
416+
417+
// Default restore: lite Fire (no rich fields) and no further retention.
418+
lite, err := m.Restore(snap)
419+
if err != nil {
420+
t.Fatalf("Restore (default): %v", err)
421+
}
422+
base := len(lite.History())
423+
r := lite.Fire(ctx, toggleGo)
424+
if len(r.Trace.EnteredStates) != 0 || len(r.Trace.EffectsEmitted) != 0 {
425+
t.Errorf("default restore should fire lite, got rich fields: %+v", r.Trace)
426+
}
427+
if got := len(lite.History()); got != base {
428+
t.Errorf("default restore should not retain new traces: %d -> %d", base, got)
429+
}
430+
431+
// WithRestoreFullTrace: rich Fire, but still no continued retention.
432+
full, err := m.Restore(snap, state.WithRestoreFullTrace[toggleState]())
433+
if err != nil {
434+
t.Fatalf("Restore (full trace): %v", err)
435+
}
436+
fbase := len(full.History())
437+
rf := full.Fire(ctx, toggleGo)
438+
if len(rf.Trace.EnteredStates) == 0 || len(rf.Trace.EffectsEmitted) == 0 {
439+
t.Errorf("WithRestoreFullTrace should populate rich fields, got %+v", rf.Trace)
440+
}
441+
if got := len(full.History()); got != fbase {
442+
t.Errorf("WithRestoreFullTrace should not retain new traces: %d -> %d", fbase, got)
443+
}
444+
445+
// WithRestoreUnboundedHistory: rich Fire AND continued unbounded retention.
446+
dur, err := m.Restore(snap, state.WithRestoreUnboundedHistory[toggleState]())
447+
if err != nil {
448+
t.Fatalf("Restore (unbounded): %v", err)
449+
}
450+
dbase := len(dur.History())
451+
dur.Fire(ctx, toggleGo)
452+
dur.Fire(ctx, toggleGo)
453+
if got := len(dur.History()); got != dbase+2 {
454+
t.Errorf("WithRestoreUnboundedHistory should keep retaining: %d -> %d (want +2)", dbase, got)
455+
}
456+
if h := dur.History(); len(h[len(h)-1].EnteredStates) == 0 {
457+
t.Errorf("retained trace under unbounded restore should be full, got %+v", h[len(h)-1])
458+
}
459+
}
460+
398461
// ---------------------------------------------------------------------------
399462
// 6. Label cache: m.label correctness.
400463
// ---------------------------------------------------------------------------

state/options.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,8 @@ type RestoreOption[S comparable] func(*restoreConfig[S])
301301
type restoreConfig[S comparable] struct {
302302
clock Clock
303303
rejectMachineVersion bool
304+
traceFull bool
305+
histUnbounded bool
304306
}
305307

306308
// RejectMachineVersionMismatch makes Restore enforce the machine DEFINITION
@@ -321,6 +323,27 @@ func WithRestoreClock[S comparable](c Clock) RestoreOption[S] {
321323
return func(cfg *restoreConfig[S]) { cfg.clock = c }
322324
}
323325

326+
// WithRestoreFullTrace restores an instance in full trace mode, mirroring
327+
// WithFullTrace at Cast. A restored instance is lite by default like a freshly
328+
// cast one; a journal/replay consumer that reconstructs from the recorded Trace
329+
// (the durable runner) restores in full mode so the rich per-step fields and the
330+
// EventPayload are produced on every replayed Fire.
331+
func WithRestoreFullTrace[S comparable]() RestoreOption[S] {
332+
return func(cfg *restoreConfig[S]) { cfg.traceFull = true }
333+
}
334+
335+
// WithRestoreUnboundedHistory restores an instance in full trace mode with
336+
// unbounded history retention, mirroring WithUnboundedHistory at Cast. The durable
337+
// runner uses it on recover: it reconstructs the step ordinal and checkpoint
338+
// snapshots from the retained Trace history, so post-recovery Fires must keep
339+
// retaining every trace exactly as a freshly started durable instance does.
340+
func WithRestoreUnboundedHistory[S comparable]() RestoreOption[S] {
341+
return func(cfg *restoreConfig[S]) {
342+
cfg.traceFull = true
343+
cfg.histUnbounded = true
344+
}
345+
}
346+
324347
// SnapshotCodecOption configures MarshalSnapshot / UnmarshalSnapshot.
325348
type SnapshotCodecOption[C any] func(*snapshotCodecConfig[C])
326349

state/snapshot.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,8 @@ func (m *Machine[S, E, C]) Restore(snap Snapshot[S, E, C], opts ...RestoreOption
465465
historyShallow: copyMap(snap.HistoryShallow),
466466
historyDeep: copyLeafMap(snap.HistoryDeep),
467467
clock: clock,
468+
traceFull: rcfg.traceFull,
469+
histUnbounded: rcfg.histUnbounded,
468470
}
469471
return inst, nil
470472
}

0 commit comments

Comments
 (0)