solver: ReleaseUnreferenced can hold the cache.db write lock for extended periods under sustained load
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 10.3k
- Forks
- 1.5k
- Avg merge
- 1d 21h
- Merged PRs (30d)
- 50
Description
What happens
cacheManager.ReleaseUnreferenced is currently invoked from two places:
- After every build, through
throttledReleaseUnreferencedatcontrol/control.go:715, with a hard-coded 5-minute throttle window. - After every
Prune, as a fallback in thedeferblock atcontrol/control.go:255-263whenever the prune produced any records.
The execution path is:
cacheManager.ReleaseUnreferenced(solver/cachemanager.go:45) walks the entire_linksbucket and callsStore.Releasefor every orphanresultID.Store.Release(solver/bboltcachestorage/storage.go:200) runsemptyBranchWithParents(storage.go:249) recursively inside a singledb.Updatewrite transaction.- The recursion visits the
_result,_links,_byresult, and_backlinksbuckets in turn, and the same write transaction keeps bbolt's exclusive write lock until the entire subgraph has been cleared.
Why it matters
Under sustained parallel build load we've observed the following, all reproducible in principle:
- The
_linksbucket grows roughly linearly with build volume, and both the recursion depth and branching factor inemptyBranchWithParentsgrow with it. - A single
ReleaseUnreferencedinvocation can hold the cache.db write transaction for seconds, and in the worst case tens of seconds, depending on the orphan population at that moment. - Anything else that goes through
db.Updateon the same store (AddResult,AddLink, or the nextRelease) queues behind bbolt's single writer lock during that window. - As a result, solver-hot-path operations (
Query,Load,AddResult,AddLink) show a visible tail-latency increase while a release pass is in flight.
The current throttling strategy is hard-coded at control/control.go:145:
c.throttledReleaseUnreferenced = throttle.After(5*time.Minute, func() {
c.releaseUnreferencedCache(context.TODO())
})
In a high-concurrency workload this 5-minute constant has a few practical limitations:
- Not configurable. Every deployment shares the same constant regardless of build density, so operators cannot tune it against their own load profile.
- No way to trigger manually. Even when an operator knows the daemon is in a lull and would like to run a release pass right now, there is no entry point to do so.
- Backlog grows faster than the release pass can drain it. Under sustained traffic, the number of new orphan entries produced within a 5-minute window can exceed what a single pass clears. The release cadence falls behind orphan production, and each subsequent pass has more work to do — so the pass gets slower over time rather than reaching steady state.
Related prior work
This isn't an isolated issue; the community has been chipping away at "cache.db index correctness + physical space reclamation" through several patches:
- #4353 (
cache: fix cache leak) — cleans up compression-variant leases when snapshot load fails at init, preventing blob leaks. - #5116 (
bboltcachestorage: only delete link after releasing result) — fixesemptyBranchWithParentsso it no longer deletessubLinkswhensubResultis still non-empty, keepingReleasesemantics correct. - #7135 (
cache/metadata: remove obsolete indexes when replacing values) — prevents stale_indexentries from being left behind when a record's indexed value is replaced. - #7138 (
opt-in metadata database compaction, in review) — introduces opt-in bbolt physical compaction forcache.db,history.db,metadata_v2.db, andcontainerdmeta.db.
Those four cover, respectively, "is Release correct?", "do we still produce new orphan index entries?", and "how do we reclaim the physical space bbolt has already given up on?". The remaining question — when ReleaseUnreferenced runs, at what cadence, and whether an operator can drive it explicitly — isn't addressed by any of them.
Proposed direction
We'd like to confirm the direction at the issue level first, and are happy to submit a PR if the maintainers agree. The minimal, non-invasive shape we have in mind is two complementary changes:
- A debug endpoint to trigger
ReleaseUnreferencedmanually, for examplePOST /debug/cache/release-unreferenced, aligned in style with the/debug/compactionendpoint added by #7138. This lets an operator drive a release pass at a known-quiet moment instead of waiting for the post-build throttle to expire. - Lift the 5-minute constant into
buildkitd.toml, e.g.[cache] releaseUnreferencedInterval, keeping the default at5mso existing deployments are unaffected.
Explicitly out of scope for this issue and any follow-up PR, to keep the change surface small:
- Splitting the transaction granularity inside
Release(turning the singledb.Updaterecursion into batched sub-transactions). That's an independent performance improvement and worth its own PR. - Changing the recursion in
emptyBranchWithParents. That's a correctness-adjacent area and, again, better on its own. - Anything that touches #7138's compaction scheduler. The release cadence and physical compaction are independent concerns; we'd rather not couple them.
Reproduction
We're preparing a minimal reproducer along these lines and will add it before opening the PR:
- Run buildkitd with the
ociworker. - Drive it with a persistent loop that exports cache on every build, e.g. ~20 goroutines each running
buildctl build ... --export-cache=type=local,dest=.... - Sample the
_linksbucket size every 30s (viabbolt statsor by instrumentingbbolt.Bucket.Stats()). - Once
_linksgrows into the tens-of-thousands range, instrument thedb.Updateduration of the next post-buildReleaseUnreferenced. - Expected observation: the
db.Updatehold time increases monotonically with_linkssize, and external calls that needdb.Update(subsequent build steps,buildctl du, etc.) see correlated latency increases during the release pass.
Environment
- BuildKit: master, base commit
99bd9de47d29269020476c3eea5898f6038fa0a1. - Runtime: multi-replica buildkitd behind a consistent-hash router.
- Worker: runc + overlayfs.
- Steady state:
cache.dbin the multi-GiB range, sustained parallel builds; exact numbers omitted, but the qualitative pattern (release pass lengthens over time, tail latency correlates with release duration) is stable across the fleet.
What we'd like from maintainers
- Confirm or reject the "manual trigger + configurable throttle" direction.
- If accepted: guidance on the preferred surface — HTTP debug endpoint (
POST /debug/cache/release-unreferenced) vs. abuildctl debugsubcommand vs. both. - If there's a different direction you'd prefer (for example, replacing the throttle with a dynamic threshold based on orphan count or
_linksgrowth rate), we're happy to rework along those lines.
Happy to submit a PR once the direction is confirmed.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with control/control.go:145, 255-263, and 715, then read solver/cachemanager.go:45 and bboltcachestorage/storage.go:200 and 249 to understand the release path and its transaction scope. Compare the proposed debug trigger with the /debug/compaction endpoint from #7138. Done means maintainers' preferred trigger surface and configurable interval are implemented with the existing 5-minute default, without changing release transaction granularity or compaction scheduling.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- backend, databases, performance
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100