hack/designs/lockfile.md
This is the general design reference for Dagger lockfiles.
It describes:
| Term | Meaning |
|---|---|
| Lookup function | A function that turns symbolic inputs into a concrete resolved result. |
| Lookup inputs | The symbolic arguments to the lookup function. |
| Lookup result | The concrete resolved value: digest, commit SHA, immutable ID, and so on. |
| Lock entry | A recorded mapping from (namespace, operation, inputs) to (value, policy). |
| Lock policy | Entry-level refresh intent: pin or float. |
| Lock mode | Run-level read/write behavior: disabled, live, pinned, or frozen. |
| Lockfile snapshot | Parsed .dagger/lock state loaded into session-owned live state. |
| Lockfile delta | Tuple upserts buffered in session-owned live state before final export. |
Lockfiles are JSON lines. The first line is the version tuple:
[["version","1"]]
Each entry is a flat ordered tuple:
[namespace, operation, inputs, value, policy]
Examples:
["","container.from",["alpine:latest","linux/amd64"],"sha256:3d23f8","pin"]
["","git.branch",["https://github.com/dagger/dagger.git","main"],"495a8c8ce85670e58560a9561626297a436225c0","float"]
Rules:
namespace is "" for core lookups.operation is a stable lookup key such as container.from or git.branch.inputs is always an ordered positional array.value is the resolved immutable result.policy is pin or float.(namespace, operation, inputs-json)Lock policy is stored per entry.
| Policy | Meaning |
|---|---|
pin | Prefer the recorded value when the mode allows it. |
float | Prefer live resolution when the mode allows it. |
What users should memorize:
pin: stay on this recorded resultfloat: refresh this result when live resolution is allowedLock mode is chosen per run, typically with --lock.
| Mode | Meaning |
|---|---|
disabled | Ignore the lockfile completely. |
live | Resolve everything live and record the result. |
pinned | Reuse pinned entries, resolve everything else live, and record the result. |
frozen | Resolve only from the lockfile and fail on misses. |
What users should memorize:
disabled: feature offlive: refresh while runningpinned: prefer stable pins, refresh the restfrozen: use the lockfile only| Mode | Existing pin entry | Existing float entry | Missing entry |
|---|---|---|---|
disabled | resolve live, do not read or write lockfile | resolve live, do not read or write lockfile | resolve live, do not write |
live | resolve live and rewrite | resolve live and rewrite | resolve live and write |
pinned | use lockfile value | resolve live and rewrite | resolve live and write |
frozen | use lockfile value | use lockfile value | error |
Important consequence:
frozen, an existing float entry is still treated as a recorded snapshotfloat only matters in modes that allow live resolutionThis section is the proposed diff from the current lockfile branch.
It is intentionally narrow:
currentWorkspace.update() / dagger lock update() in the same
change| Area | Current branch | Proposed |
|---|---|---|
| Ambient reads | each lock-aware consumer rereads .dagger/lock from caller host | read .dagger/lock at most once per bound workspace in a session, via lazy init into daggerSession state |
| Ambient writes | reread + merge + export on each touched lookup | mutate session-owned workspace state in memory; export once on graceful shutdown |
| State owner | schema-local workspaceLookupLock helper | daggerSession |
| Concurrency | repeated sync caller-host I/O guarded only at export time | one workspace-keyed lock state map on daggerSession, guarded by an RW mutex |
| Synchronization boundary | schema helpers still own part of write coordination | locking and merge/export coordination live in engine/server only |
| Hot path boundary | schema consumers do caller-host lockfile I/O directly | schema consumers call lock methods exposed through core.Query.Server |
| DagQL role | not part of the live path today | still not part of the live path in this change |
| Explicit update | currentWorkspace.update(): Changeset! | unchanged in this change |
Concretely, the design change is:
daggerSession, keyed by workspace bindingdaggerSessioncore.Query.Server interfaceengine/server, not core/schemaThere are three real update paths:
dagger lock updateRefresh entries already present in .dagger/lock.
Properties:
currentWorkspace.update()--lock=liveRun the real workload in live lock mode.
Properties:
.dagger/lock at most once per bound workspace in a sessioncurrentWorkspace.update(): Changeset!Engine API for refreshing entries already present in .dagger/lock.
Properties:
Changeset instead of writing directly.dagger/lock does not existThis design update leaves explicit maintenance alone. It only changes the ambient live path.
Store live lockfile state on daggerSession in engine/server/session.go.
One session may host more than one bound workspace, so this state should be a map keyed by workspace binding, not a single session-global lockfile.
Recommended shape:
lockFiles map[workspaceLockKey]*workspaceLockStatelockFileMu sync.RWMutexWhere:
workspaceLockKey identifies the bound workspace for lockfile purposesworkspaceLockState holds:
*workspace.Lockloaded bitdirty bitProperties:
.dagger/lock from caller host at most once per bound workspaceExpose lockfile access through engine server methods:
core.Query.Servercore/ and core/schema/ callers use those methodsThis follows the existing server/session pattern already used elsewhere in the engine.
Ambient execution (--lock=live, plus the write-through cases of pinned) should:
It should not:
.dagger/lock from the caller host on each lookup.dagger/lock after each lookupWhen the main client shuts down:
engine/serverThe natural place for this is the /shutdown endpoint.
To preserve current behavior under cross-session contention, the final export can reuse the existing "merge against latest on-disk state" logic at shutdown time instead of on every lookup.
The important cleanup constraint is:
core/schema/lockfile.go should not own any global mutex map or export-time
synchronizationcore.Query.Serverengine/server should own the actual read/write/merge/export implementationcurrentWorkspace.update() / dagger lock update() in the same
changeTarget model: one lock system for all lookup functions.
Current core operation keys:
| Operation | Inputs | Result |
|---|---|---|
container.from | [imageRef, platform] | image digest |
modules.resolve | [source] | commit SHA |
git.head | [remoteURL] | commit SHA |
git.branch | [remoteURL, branchName] | commit SHA |
git.tag | [remoteURL, tagName] | commit SHA |
git.ref | [remoteURL, refName] | commit SHA |
Notes:
git.commit is already pinned by input and does not create lock entriesmodules.resolve defaults to pin for tags and explicit commits, float
otherwisegit.ref only creates lock entries for mutable refsutil/lockfile[namespace, operation, inputs, value, policy]container.from lookup lockingmodules.resolve lookup lockinghead, branch, tag, and mutable refcurrentWorkspace.update(): Changeset! temporary umbrella APIdagger lock update--lock=livedaggerSessioncore.Query.Server lock methods for hot-path consumers--lock=disabled|live|pinned|frozendisabledlive writes throughpinned writes through for float and missing entriesfrozen reuses both pin and float entries and fails on missescontainer.from defaults to pinmodules.resolve defaults to pin for tags and commits, float otherwisegit.branch defaults to floatgit.head defaults to floatgit.tag defaults to pingit.ref defaults to pin for tags and float for other mutable refsThese are current branch facts, not necessarily the final target for all future workspace behavior.
workspace-plumbing, that means .dagger/lock sits under the current detected workspace path, not necessarily repo root.dagger/lock from the caller host after the session
snapshot is loadeddagger lock update relies on ambient authentication for private registries and repositoriesNew lockfile consumers should attach to existing lookup resolution flows rather than introducing new engine hooks just for locking.
Why:
Implication:
modules.resolve, hook lock read/write behavior
into the current module resolution pathThis section is intentionally concrete. It is the level of detail that should have been reviewed before implementation so type shapes and file boundaries stay aligned.
core/query.gotype Server interface {
CurrentWorkspaceLock(context.Context) (*workspacepkg.Lock, bool, error)
SetCurrentWorkspaceLookup(context.Context, string, string, []any, workspacepkg.LookupResult) error
}
These are the only live-path hooks schema consumers should need.
core/schema/lockfile.gotype workspaceLookupLock struct {
ctx context.Context
query *core.Query
lock *workspace.Lock
}
func loadWorkspaceLookupLock(ctx context.Context, query *core.Query) (*workspaceLookupLock, error)
func (l *workspaceLookupLock) SetLookup(namespace, operation string, inputs []any, result workspace.LookupResult) error
The live-path responsibilities in this file should be narrow:
core.Query.Server for the current lock snapshotcore.Query.Server to stage an upsertThis file should not:
core/schema/container.gocontainer.from lock integrationlookupLock, err := loadWorkspaceLookupLock(ctx, query)
resolution, err := resolveLookupFromLock(lockMode, lookupLock.lock, lockContainerFromOperation, inputs, workspace.PolicyPin)
After live resolution:
if resolution.ShouldWrite {
err = lookupLock.SetLookup(lockCoreNamespace, lockContainerFromOperation, inputs, result)
}
core/schema/modulesource.gomodules.resolve lock integrationThe shape is the same as container.from:
loadWorkspaceLookupLockresolveLookupFromLock for policy/mode behaviorSetLookup after live resolutioncore/schema/git.goEach mutable Git lookup follows the same pattern:
git.headgit.branchgit.refPinned Git lookups such as immutable refs do not create lock entries.
core/workspace/lock.gofunc (l *Lock) Clone() (*Lock, error)
func (l *Lock) Merge(other *Lock) error
These helpers are the shared mutation substrate used by both:
currentWorkspace.update() refresh pathsengine/server/session.gotype daggerSession struct {
lockFiles map[workspaceLockKey]*workspaceLockState
lockFileMu sync.RWMutex
}
type workspaceLockKey struct {
ownerClientID string
lockPath string
}
type workspaceLockState struct {
ws *core.Workspace
lockPath string
lock *workspace.Lock
delta *workspace.Lock
loaded bool
dirty bool
}
func (srv *Server) CurrentWorkspaceLock(ctx context.Context) (*workspace.Lock, bool, error)
func (srv *Server) SetCurrentWorkspaceLookup(ctx context.Context, namespace string, operation string, inputs []any, result workspace.LookupResult) error
These methods:
deltafunc workspaceLockPath(ws *core.Workspace) (string, error)
func readWorkspaceLockState(ctx context.Context, bk interface{ ReadCallerHostFile(context.Context, string) ([]byte, error) }, ws *core.Workspace) (*workspace.Lock, bool, error)
func exportWorkspaceLockToHost(ctx context.Context, bk *buildkit.Client, ws *core.Workspace, lock *workspace.Lock) error
These helpers stay in engine/server for the live path. core/schema should not be
used as a utility package for engine-owned merge/export logic.
func (srv *Server) flushWorkspaceLocks(ctx context.Context, client *daggerClient) error
The export flow should be:
srv.locker.Lock(export.lockPath)
defer srv.locker.Unlock(export.lockPath)
latest, _, err := readWorkspaceLockState(workspaceCtx, bk, export.ws)
if err == nil {
err = latest.Merge(export.delta)
}
if err == nil {
err = exportWorkspaceLockToHost(workspaceCtx, bk, export.ws, latest)
}
Important constraints:
lockFileMu protects session-owned stateengine/serverhttp lookup lockingrefs, symrefs, or isPublic belong in the lock model.dagger/lock anchoringdisabled should remain the long-term defaultdagger lock update should gain richer output or selection flagsLockfiles are attached to workspace bindings.
Why:
modules.resolve is the clearest workspace-driven lookup consumerSo the intended long-term shape is:
dagger --lock=disabled call ...
dagger --lock=live call ...
dagger --lock=pinned call ...
dagger --lock=frozen call ...
dagger lock update