Repository navigation
Description
What happens
When an engine with a large dagql cache stops, the "close dagql cache" phase persists the in-memory state and only then writes clean_shutdown=1 in the close path of dagql/cache.go. If that phase does not finish, the marker stays 0. On the next start the engine logs dagql persistence store marked unclean; wiping and cold-starting and local cache state invalid; resetting worker and dagql persistence state reason=dagql_unclean_shutdown, and the cache starts empty, with the worker's local cache state reset too.
The persist does not finish in two cases:
- The default stop budget,
defaultGracefulStopTimeout = 5 * time.Minuteincmd/engine/stop_budget.go, runs out partway through the persist. One engine loggedfailed to persist dagql cache during close err="insert snapshot_content_hash (HHO5L4B2C4XMXAKSFJAI6RFCFY): context deadline exceeded"afterclose dagql cachehad run 4m54s. Its graceful stop ended at 5m32s with that error, and the next start wiped the cache. - The stop never reaches the persist. A second engine sat in
wait for garbage collectionfor 30 minutes, untildocker stop -t 1800killed it with exit 137, and the next start wiped the cache.
Measured on dagger main 8afd98b plus a small local patch, self-hosted engines on 64- and 128-vCPU machines with local NVMe, each restarted idle with no client connected. Engines are labelled by letter:
| Engine | Stop | Outcome |
|---|---|---|
| A | 104 s | clean |
| c | 3m08s | clean |
| e, f, s, t, u | 49-75 s | clean |
| p | 253 s | clean |
| d (an earlier stop) | 4m39s, close dagql cache 3m58s |
clean |
| g | 5m32s, persist hit the deadline at 4m54s | wiped |
| q | ~5m37s | wiped |
| d | killed at 1800 s in wait for garbage collection |
wiped |
An empty engine's first run of this workspace takes about 12 minutes, against 1-6 minutes for the same change classes on a warm one.
Why this matters for long-running engines
In-memory dagql state grows over hours, and checks on an engine three or more hours old ran slower: one dependency-bump run took 16m27s against 5m46s on a fresher pool. A periodic restart is the natural fix for that, but the engines whose caches are largest, and so most worth keeping, are the ones whose stop outlives the default budget.
Asks
- Persist incrementally, or checkpoint while running, so a cut-off close loses only what changed since the last checkpoint and does not leave the store marked unclean.
- If a wipe is still needed, scope it to what the unclean close could have left inconsistent, not the whole store.
- Bound
wait for garbage collectionwithin the stop budget, so the stop reaches the persist. - Document
--graceful-stop-timeout/shutdown.gracefulStopTimeoutnext to the stop-timeout guidance for self-hosted engines, and that it must stay below the container runtime's stop timeout.
Related: #14926 (Address.container resolving per call) and #14903 (git-remote workspace cache keys), from the same self-hosted CI setup.
Activity
goingforstudying-ctrl commentedon Nov 3, 2026
Put up #15052 for the budget-cut save case from this report. The final persist and the clean-shutdown marker now run detached from the stop budget, so a stop that outlives the budget keeps the cache. Left the GC-wait bounding and incremental checkpointing out of scope for now.