Skuld.Effects.EffectLogger.Log (skuld v0.1.26)
View SourceA 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/rerunallow_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 completesResulting 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
- Effects create entries pushed to
effect_stack leave_scopehandlers mark entries as:discardedwhen continuations are abandoned- On finalize, stack is moved to queue for future replay
Replay/Resume
effect_queuecontains entries from previous run- Effects check if they match queue head
- If match and can short-circuit, return logged value
- If match but cannot short-circuit, re-execute handler
- 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
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
Loop hierarchy mapping: loop_id => parent_loop_id (nil for root loops).
Functions
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.
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)
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 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 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 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 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 an entry by ID in the effect stack.
@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.
Reconstruct Log from decoded JSON map.
Check if the log contains a root mark (:__root__).
The root mark is lazily inserted on the first intercepted effect.
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.
@spec new([Skuld.Effects.EffectLogger.EffectLogEntry.t()]) :: t()
Create a new empty log or a log with entries.
Variants
new()- Create empty lognew(entries)- Create log with entries for replay
@spec peek_queue(t()) :: Skuld.Effects.EffectLogger.EffectLogEntry.t() | nil
Get the head entry from the effect queue without removing it.
Pop an entry from the effect queue for replay.
Returns {entry, updated_log} or nil if queue is empty.
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 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 an entry onto the effect stack.
Check if the effect queue is empty.
@spec to_list(t()) :: [Skuld.Effects.EffectLogger.EffectLogEntry.t()]
Get all entries as a list (for inspection/debugging).
Returns entries in execution order.
@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.