Lazy Evaluation in DagQL Cache
This document describes the current lazy-evaluation model used by the dagql cache and the `core` object implementations built on top of it.
Overview
Lazy Evaluation in DagQL Cache
This document describes the current lazy-evaluation model used by the dagql cache and the core object implementations built on top of it.
At a high level, laziness means a resolver can return a real dagql result immediately while deferring some materialization work until that result is actually needed later. The returned result is still a normal cache-backed result with normal identity, dependency edges, session-resource requirements, and persistence behavior. What is deferred is the work needed to fully materialize its internal state.
In practice, this is used heavily for Directory, File, and Container objects. The typical pattern is:
- A schema resolver returns an object shell immediately.
- The object carries a
Lazyimplementation that knows how to finish materializing it later. - Future code accesses a lazy-backed field through a
LazyAccessor, which calls intodagql.Cache.Evaluate. - The cache guarantees that evaluation for a given attached result only runs once at a time, with correct call context and telemetry reconstruction.
Why laziness exists here
There are three main reasons this exists:
- It lets dagql publish and cache a result before doing all the expensive work needed to fully materialize it.
- It lets later callers share that deferred work instead of each redoing it independently.
- It lets object implementations keep a small, stable shell alive in the cache while computing heavyweight state only on demand.
This is different from DoNotCache. A lazy result is still expected to be attached to the cache. In fact, the cache rejects newly created lazy results from DoNotCache calls, because lazy evaluation depends on the result having an attached sharedResult.
A DoNotCache field can still return an already attached lazy result. What is rejected is creating a brand new lazy result that never becomes cache-backed.
DagQL-Side Contract
The dagql cache does not know anything about Directory, File, Container, or any other concrete type-specific lazy shape. Its contract is intentionally small:
dagql.HasLazyEvaluationdagql.LazyEvalFuncdagql.Cache.Evaluate
Any result can participate in lazy evaluation if its wrapped value implements:
type HasLazyEvaluation interface {
LazyEvalFunc() LazyEvalFunc
}
That means the mechanism is generic. In practice, the engine mostly uses it for object results, but the cache itself is not limited to objects.
Cache Entry Points
The key dagql entry points are:
dagql.Cache.EvaluateThe public API for forcing one or more attached results to finish lazy evaluation.dagql.Cache.evaluateOneThe per-result implementation that handles singleflight, recursion detection, cancelation, call-context restoration, and telemetry resumption.dagql.Cache.registerLazyEvaluationStores the current lazy callback on the attachedsharedResultwhen a result is first published or when a cache/persisted hit is re-wrapped.dagql.HasPendingLazyEvaluationReports whether an attached result still has deferred work. Telemetry uses this to avoid treating a pending lazy hit as a fully satisfied cache hit.
What Cache.Evaluate Guarantees
Cache.Evaluate(ctx, results...) does two different kinds of coordination:
First, if the caller passes multiple results, the cache evaluates those different results in parallel with an errgroup.
Second, for any single attached result, the cache uses per-sharedResult singleflight so that only one lazy callback is running for that result at a time. Other callers wait for the same work instead of duplicating it.
That gives two important properties:
- evaluating several unrelated lazy results can proceed concurrently
- evaluating the same lazy result from many callers collapses to one actual callback execution
The tests in dagql/cache_test.go cover both behaviors.
sharedResult Lazy State
Attached results carry cache-owned lazy state in dagql.sharedResult:
lazyEvallazyEvalCompletelazyEvalWaitChlazyEvalCancellazyEvalWaiterslazyEvalErr
This state is guarded by lazyMu.
Conceptually:
lazyEvalis the callback the cache should runlazyEvalCompletemeans the attached result is fully materializedlazyEvalWaitChmeans evaluation is currently in flightlazyEvalWaiterstracks how many callers are waiting on that in-flight evaluationlazyEvalCancellets the cache cancel the in-flight evaluation if the last waiter goes away
How Lazy Callbacks Get Registered
The cache registers lazy evaluation whenever it publishes or reconstructs an attached result that still has deferred work.
Important places where this happens:
- after a new completed call is initialized in
initCompletedResult - when an attached dependency result resolves to an existing cache hit
- when a cache hit is re-wrapped from an attached
sharedResult - when a persisted hit payload is decoded lazily and wrapped back into a typed result
This matters because the sharedResult is the stable cache-owned object, while typed wrappers may be re-created on hit paths. The cache re-derives the current LazyEvalFunc from the wrapped value and stores it on the sharedResult so later Evaluate calls have the right callback.
Evaluate Flow
For a single result, Cache.evaluateOne works roughly like this:
- Validate that the cache and result are non-nil.
- Require that the result is attached to a real
sharedResult. - Detect recursive lazy evaluation using a stack of
sharedResultIDs stored in context. - Re-read the current
LazyEvalFuncfrom the wrapped value. - If the result is already fully materialized, return.
- If another goroutine is already evaluating this result, wait on its channel.
- Otherwise start the lazy callback in a background goroutine and wait for it.
Two details are especially important.
Recursive Evaluation Detection
The cache threads a linked stack of sharedResultIDs through context while evaluating lazy results. If a lazy callback tries to re-enter evaluation of itself, or any ancestor already on that stack, the cache returns:
recursive lazy evaluation detected
That prevents accidental infinite recursion when a lazy implementation evaluates the wrong result.
Waiter and Cancelation Semantics
The actual callback runs under a context built from context.WithoutCancel(stackCtx), then wrapped in context.WithCancelCause.
That means one impatient caller does not immediately tear down the shared lazy callback for everyone else. Instead:
- each waiter can independently cancel its own wait
- if the last waiter goes away, the cache invokes the stored cancel func with that cause
This is a shared-work model, not a per-caller callback model.
Call Context Restoration
Before starting the lazy callback, the cache restores the result's authoritative ResultCall into the callback context with:
dagql.ContextWithCall(evalCtx, resultCall)
This is crucial. Lazy evaluation often runs much later than the original field resolver, but many core helpers still need the current dagql call.
One concrete example is DirectoryWithoutLazy.Evaluate, which calls:
dir.Without(ctx, lazy.Parent, dagql.CurrentCall(ctx), true, lazy.Paths...)
That only works because Cache.Evaluate restored the original call frame first.
Without this, lazy implementations that depend on dagql.CurrentCall(ctx) would behave differently from eager execution and could break equivalence-teaching, provenance, or other call-sensitive behavior.
Telemetry Resumption
The lazy model also restores telemetry lineage instead of treating lazy work as an unrelated background task.
When a result is first returned from GetOrInitCall or wait, the cache captures the session's current span context in captureSessionLazySpanContext.
Later, when some caller actually triggers Cache.Evaluate, the cache:
- creates a hidden resume span named either
resume lazy evaluationorresume <field> - parents that span under the current triggering span
- links it back to the original span context from the initial call
Then it wraps the callback context with resumedCallbackSpan, which deliberately reports the original span context to the lazy callback itself.
That gives the desired split:
- the trigger path records that lazy work resumed now
- logs and child spans emitted by the lazy callback still line up with the original call lineage
This is why the telemetry tests verify both:
- a hidden
resume ...span linked to the original span - child spans and logs from the lazy callback appearing under the original span context
Success and Failure Semantics
After the lazy callback returns successfully, the cache:
- syncs snapshot owner leases with
syncResultSnapshotLeases - marks
lazyEvalComplete = true - clears the stored
lazyEval
If the callback fails, the cache does not mark the result complete. Future Evaluate calls can try again.
So the rule is simple:
- success permanently materializes the attached result
- failure leaves it pending
The Second Layer: core.Lazy[T]
The cache-level mechanism is only half the story. The object implementations use a second layer in core/lazy_state.go:
type Lazy[T dagql.Typed] interface {
Evaluate(context.Context, T) error
AttachDependencies(context.Context, func(dagql.AnyResult) (dagql.AnyResult, error)) ([]dagql.AnyResult, error)
EncodePersisted(context.Context, dagql.PersistedObjectCache) (json.RawMessage, error)
}
This is the object-side contract.
Every concrete lazy type in core embeds a LazyState:
type LazyState struct {
LazyMu *sync.Mutex
LazyInitComplete bool
}
LazyState.Evaluate gives per-instance idempotence:
- if the instance already finished, it returns immediately
- otherwise it locks
LazyMu, runs the callback once, and marks the instance complete on success
This is distinct from the cache-level singleflight.
Why There Are Two Layers
There are two separate jobs here:
- dagql cache lazy state coordinates attached results across callers, restores call context, and restores telemetry
core.LazyStatemakes each concrete lazy object implementation itself behave like a one-time materializer
The cache layer is the authoritative cross-caller coordination layer. The core layer keeps each lazy implementation internally disciplined and idempotent.
LazyAccessor: The Actual Field Boundary
The most important practical API for authors is LazyAccessor.
Examples:
Directory.DirDirectory.SnapshotFile.FileFile.SnapshotContainer.FSContainer.MetaSnapshot
LazyAccessor exists to make it hard to accidentally read a lazy-populated field without first evaluating the owning result.
GetOrEval
GetOrEval(ctx, ownerResult) is the normal access path.
It:
- fetches the engine cache from context
- calls
cache.Evaluate(ctx, ownerResult) - returns the accessor's value after evaluation
If evaluation succeeds but the accessor still was not populated, it returns an error. In other words, GetOrEval treats "the lazy callback forgot to set the field" as a bug.
Peek
Peek() returns the currently stored value without triggering lazy evaluation.
This is intentional and important. Many paths need to inspect already-known state without forcing full materialization, including:
- release paths
- cache usage accounting
- persisted snapshot link reporting
- persisted object encoding
- schema constructors that can pre-seed a cheap path or shell immediately
Peek is for "use what is already present." GetOrEval is for "I need the real materialized value."
SetValue
SetValue is used by:
- schema constructors that pre-seed obvious cheap values
- lazy implementations after they finish materializing
The comment on GetOrEval is important: the caller must pass the dagql result wrapper for the same owning object as the accessor. That pairing is not validated automatically today.
Attach-Time Dependencies vs Evaluate-Time Dependencies
Every concrete core.Lazy type also implements AttachDependencies.
This is not the same thing as calling cache.Evaluate inside the lazy callback.
They serve different purposes:
AttachDependenciesruns when the object is attached to the cache. It rewrites embedded result references to attached/cache-backed results and returns the exact dependency edges that should be recorded for ownership, pruning, and persistence closure.cache.Evaluateinside the lazy callback runs later when the implementation actually needs those dependencies materialized.
This distinction is central to the design. A dependency can be known structurally and retained correctly long before its expensive value is actually needed.
Common Authoring Pattern in Schema Code
Most schema resolvers follow the same shape:
- Normalize or load the inputs they care about.
- Construct an object shell immediately.
- Pre-seed any cheap fields that are already known.
- Store a concrete
Lazyimplementation on the object. - Return a normal dagql object result immediately.
Two common examples:
Example: container.rootfs
The schema returns a Directory shell immediately with:
Lazy: &core.ContainerRootFSLazy{...}Dirpre-seeded to"/"
The expensive work of resolving the actual rootfs snapshot is deferred until somebody needs it.
Example: container.directory(path)
The schema resolves env expansion and working-directory normalization immediately, then returns a Directory shell with:
Lazy: &core.ContainerDirectoryLazy{Parent: parent, Path: path}Dirpre-seeded to the resolved path
Validation and snapshot reopening happen later during lazy evaluation.
Example: file.withName(name)
The schema constructs a File shell immediately, stores FileWithNameLazy, and if the parent's current path is already known it pre-seeds the derived path in the accessor right away.
Lazy Args Are the Actual Evaluation Recipe
This is a very important practical point: the fields stored on a concrete lazy struct are the arguments that define eventual evaluation.
They are not required to match the outer GraphQL arg struct one-for-one.
In many places, schema code has already normalized the inputs before storing them on the lazy struct. For example:
- IDs are often loaded into attached
dagql.ObjectResult[...]values before being stored - alternate arg shapes are collapsed into one normalized source
- paths may already be env-expanded or made absolute
- a resolver may pre-seed some cheap state separately from the lazy struct itself
So when reading a lazy type, treat its fields as the real execution recipe for deferred evaluation, not as a copy of some public API shape.
Directory and File Pattern
Directory and File follow a very consistent shape:
- they expose lazy-populated accessors for path and snap