Skuld.Effects.EffectLogger.Log (skuld v0.1.26)

View Source

A flat log of effect invocations.

The log captures effects in execution order (when they started), enabling:

  • Replay: Short-circuit completed entries with their logged values
  • Resume: Continue suspended computations from where they left off
  • Rerun: Re-execute failed computations, replaying successful entries

Flat Structure

Unlike a tree-structured log, this log stores entries in a flat list ordered by when each effect started. The hierarchical structure of the computation is NOT captured. Instead, we use leave_scope handlers to mark entries as :discarded when their continuations are abandoned (e.g., by a Throw effect).

Stack/Queue Model

  • effect_stack - Entries being built during execution (newest first)
  • effect_queue - Entries to replay (oldest first) during resume/rerun
  • allow_divergence? - Whether to accept effects that don't match the log

Example: Throw with Catch

Consider a computation where EffectC throws and is caught:

EffectA fires  completes normally
   [catch scope]
     EffectB fires  handler completes
       EffectC fires (Throw)  discards continuation
       leave_scope marks C as :discarded
     leave_scope marks B as :discarded
   catch intercepts
     EffectD fires  completes normally
 EffectA completes

Resulting flat log:

[
  {A, :executed, value_A},
  {B, :discarded, value_B},   # has value - handler called wrapped_k
  {C, :discarded, nil},       # no value - handler discarded k
  {D, :executed, value_D}
]

Lifecycle

First Run

  1. Effects create entries pushed to effect_stack
  2. leave_scope handlers mark entries as :discarded when continuations are abandoned
  3. On finalize, stack is moved to queue for future replay

Replay/Resume

  1. effect_queue contains entries from previous run
  2. Effects check if they match queue head
  3. If match and can short-circuit, return logged value
  4. If match but cannot short-circuit, re-execute handler
  5. If no match and divergence allowed, continue with fresh execution

Divergence

By default, effects must match the log exactly (strict mode). For rerun scenarios with patched code, enable divergence to allow the computation to take a different path.

Summary

Types

Loop hierarchy mapping: loop_id => parent_loop_id (nil for root loops).

t()

Functions

Enable divergence mode on a log.

Check if ancestor_id is an ancestor of descendant_id in the hierarchy.

Get all ancestors of a loop_id (from immediate parent up to root).

Build the loop hierarchy from log entries.

Enable eager pruning on mark_loop.

Extract the most recent env_state checkpoint for each loop_id from the log.

Finalize the log after execution completes.

Find an entry by ID in the effect stack.

Find the most recent mark entry in the log queue (for cold resume).

Reconstruct Log from decoded JSON map.

Check if the log contains a root mark (:__root__).

Mark an entry as :discarded by its ID.

Create a new empty log or a log with entries.

Get the head entry from the effect queue without removing it.

Pop an entry from the effect queue for replay.

Prune completed loop segments, respecting the loop hierarchy.

Prune completed loop segments in place, suitable for use during execution.

Push an entry onto the effect stack.

Check if the effect queue is empty.

Get all entries as a list (for inspection/debugging).

Update the most recent entry on the stack.

Types

hierarchy()

@type hierarchy() :: %{required(atom()) => atom() | nil}

Loop hierarchy mapping: loop_id => parent_loop_id (nil for root loops).

t()

@type t() :: %Skuld.Effects.EffectLogger.Log{
  allow_divergence?: boolean(),
  effect_queue: [Skuld.Effects.EffectLogger.EffectLogEntry.t()],
  effect_stack: [Skuld.Effects.EffectLogger.EffectLogEntry.t()],
  prune_on_mark?: boolean()
}

Functions

allow_divergence(log)

@spec allow_divergence(t()) :: t()

Enable divergence mode on a log.

When allow_divergence? is true, the logger will accept effects that don't match the logged entries. This is used for rerun scenarios where patched code may take a different path.

ancestor?(hierarchy, ancestor_id, descendant_id)

@spec ancestor?(hierarchy(), atom(), atom()) :: boolean()

Check if ancestor_id is an ancestor of descendant_id in the hierarchy.

Returns true if walking up from descendant_id reaches ancestor_id.

Example

hierarchy = %{M1 => nil, M2 => M1, M3 => M2}
Log.ancestor?(hierarchy, M1, M3)  # => true (M1 <- M2 <- M3)
Log.ancestor?(hierarchy, M3, M1)  # => false
Log.ancestor?(hierarchy, M1, M1)  # => false (not an ancestor of itself)

ancestors(hierarchy, loop_id)

@spec ancestors(hierarchy(), atom()) :: [atom()]

Get all ancestors of a loop_id (from immediate parent up to root).

Returns a list of ancestor loop_ids, closest first.

Example

hierarchy = %{M1 => nil, M2 => M1, M3 => M2}
Log.ancestors(hierarchy, M3)  # => [M2, M1]

build_loop_hierarchy(log)

@spec build_loop_hierarchy(t()) :: hierarchy()

Build the loop hierarchy from log entries.

Scans entries in execution order and determines parent-child relationships based on nesting order. The first unique loop-id seen is a root (parent = nil). Loop-ids seen inside another loop's segment become children.

Example

# Log with: M1 -> M2 -> M3 -> M3 -> M1 -> M2 -> M3
hierarchy = Log.build_loop_hierarchy(log)
# => %{M1 => nil, M2 => M1, M3 => M2}

enable_prune_on_mark(log)

@spec enable_prune_on_mark(t()) :: t()

Enable eager pruning on mark_loop.

When enabled, prune_completed_loops/1 is called after each mark_loop effect, keeping the log bounded during long-running computations.

extract_loop_checkpoints(log)

@spec extract_loop_checkpoints(t()) :: %{required(atom()) => term()}

Extract the most recent env_state checkpoint for each loop_id from the log.

Returns a map of loop_id => env_state (the captured state from the most recent mark).

Example

checkpoints = Log.extract_loop_checkpoints(log)
# => %{:__root__ => %{...initial state...}, MyLoop => %{...state at mark...}}

finalize(log)

@spec finalize(t()) :: t()

Finalize the log after execution completes.

Moves entries from effect_stack to effect_queue, preparing for future replay. Entries are reversed so they're in execution order (oldest first).

find_entry(log, entry_id)

@spec find_entry(t(), String.t()) ::
  Skuld.Effects.EffectLogger.EffectLogEntry.t() | nil

Find an entry by ID in the effect stack.

find_latest_checkpoint(log)

@spec find_latest_checkpoint(t()) ::
  Skuld.Effects.EffectLogger.EffectLogEntry.t() | nil

Find the most recent mark entry in the log queue (for cold resume).

Returns the MarkLoop entry with the most recent env_state, or nil.

from_json(map)

@spec from_json(map()) :: t()

Reconstruct Log from decoded JSON map.

has_root_mark?(log)

@spec has_root_mark?(t()) :: boolean()

Check if the log contains a root mark (:__root__).

The root mark is lazily inserted on the first intercepted effect.

mark_discarded(log, entry_id)

@spec mark_discarded(t(), String.t()) :: t()

Mark an entry as :discarded by its ID.

Used by leave_scope handlers when a continuation is abandoned. Searches the effect_stack for an entry with the given ID and marks it.

new(arg \\ [])

@spec new([Skuld.Effects.EffectLogger.EffectLogEntry.t()]) :: t()

Create a new empty log or a log with entries.

Variants

  • new() - Create empty log
  • new(entries) - Create log with entries for replay

peek_queue(log)

@spec peek_queue(t()) :: Skuld.Effects.EffectLogger.EffectLogEntry.t() | nil

Get the head entry from the effect queue without removing it.

pop_queue(log)

@spec pop_queue(t()) :: {Skuld.Effects.EffectLogger.EffectLogEntry.t(), t()} | nil

Pop an entry from the effect queue for replay.

Returns {entry, updated_log} or nil if queue is empty.

prune_completed_loops(log)

@spec prune_completed_loops(t()) :: t()

Prune completed loop segments, respecting the loop hierarchy.

For each loop-id, removes all but the last segment (entries since the most recent mark). Pruning for a given loop_id stops at marks of ancestor loops, preserving the outer loop structure.

The most recent checkpoint for each loop_id is preserved in loop_checkpoints.

Example

# Before: M1 -> E1 -> M2 -> E2 -> M3 -> E3 -> M3 -> E4 -> M1 -> E5 -> M2 -> E6 -> M3 -> E7
# After:  M1 -> E5 -> M2 -> E6 -> M3 -> E7
# (plus checkpoints from the pruned M1, M2, M3 marks)

prune_in_place(log)

@spec prune_in_place(t()) :: t()

Prune completed loop segments in place, suitable for use during execution.

Unlike prune_completed_loops/1, this keeps entries on the stack so that new entries pushed after pruning maintain correct execution order.

push_entry(log, entry)

@spec push_entry(t(), Skuld.Effects.EffectLogger.EffectLogEntry.t()) :: t()

Push an entry onto the effect stack.

queue_empty?(log)

@spec queue_empty?(t()) :: boolean()

Check if the effect queue is empty.

to_list(log)

@spec to_list(t()) :: [Skuld.Effects.EffectLogger.EffectLogEntry.t()]

Get all entries as a list (for inspection/debugging).

Returns entries in execution order.

update_head(log, update_fn)

@spec update_head(t(), (Skuld.Effects.EffectLogger.EffectLogEntry.t() ->
                    Skuld.Effects.EffectLogger.EffectLogEntry.t())) :: t()

Update the most recent entry on the stack.

Used by wrapped_k to mark an entry as :executed with its value.