Summary
On v9.7.0, the periodic cleanup:storage-locations task becomes CPU-bound on a production-sized SQLite database because cache_entries.locationId is not indexed. The task is scheduled at the same time as cleanup:parts; under load we observed SQLITE_BUSY, cache data-plane lock failures, health timeouts, and repeated Kubernetes restarts of the only replica.
This appears to be a different SQLite-specific cause from #175 / #177. The cleanup rewrite removed the older unbounded deletion path, but the current correlated NOT EXISTS query still performs a full cache_entries scan for each storage_locations row.
Environment
- github-actions-cache-server: 9.7.0
- Helm chart: 1.1.3
- database: SQLite on a single-writer ReadWriteOnce volume
- storage: S3
- deployment: one Kubernetes replica,
maxSurge: 0
- database scale: tens of thousands of
storage_locations rows
- current
dev checked at b7f06e0
No organization, cluster, bucket, repository, cache key, or workload identifiers are included here.
Observed behavior
At the 10-minute cleanup boundary:
cleanup:parts and cleanup:storage-locations start together.
cleanup:storage-locations spends roughly 30-35 seconds CPU-bound in the correlated query and later fails a transaction with SQLITE_BUSY.
- Cache write and lease-update paths also report database-locked failures.
- Both readiness and liveness time out. Even with a 30-second timeout and failure threshold 6, six consecutive probes failed over roughly eight minutes and kubelet restarted the only replica.
- The behavior repeated after a clean restart at the next cleanup boundary. The Kubernetes node itself was not CPU constrained.
Setting DISABLE_CLEANUP_JOBS=true stopped the cleanup lock/stall behavior and kept health and data-plane requests responsive. That is only a temporary workaround because retention and orphan cleanup are then deferred.
Query-plan evidence
The current task executes the equivalent of:
select sl.folderName, sl.id
from storage_locations sl
where not exists (
select ce.id
from cache_entries ce
where ce.locationId = sl.id
)
limit 10;
On the v9.7.0 schema, EXPLAIN QUERY PLAN reports:
SCAN sl
CORRELATED SCALAR SUBQUERY
SCAN ce
The migrations create indexes for cache key/version, scope, and repo ID, but not cache_entries.locationId. After adding a test-only index, the same plan changes to an indexed lookup:
SEARCH ce USING COVERING INDEX idx_cache_entries_locationId (locationId=?)
The current code is here:
Expected behavior
Periodic cleanup should remain bounded and should not make /health or cache data-plane routes unavailable. Concurrent cleanup tasks should not contend with each other or with cache writes for long enough to trigger pod restarts.
Suggested fix
At minimum, add a schema migration for an index on cache_entries(locationId) and a regression test that exercises the storage-location cleanup query at realistic row counts. It may also be worth serializing the two 10-minute cleanup tasks or bounding/yielding their SQLite work so cleanup cannot monopolize the single process.
Related:
Summary
On v9.7.0, the periodic
cleanup:storage-locationstask becomes CPU-bound on a production-sized SQLite database becausecache_entries.locationIdis not indexed. The task is scheduled at the same time ascleanup:parts; under load we observedSQLITE_BUSY, cache data-plane lock failures, health timeouts, and repeated Kubernetes restarts of the only replica.This appears to be a different SQLite-specific cause from #175 / #177. The cleanup rewrite removed the older unbounded deletion path, but the current correlated
NOT EXISTSquery still performs a fullcache_entriesscan for eachstorage_locationsrow.Environment
maxSurge: 0storage_locationsrowsdevchecked at b7f06e0No organization, cluster, bucket, repository, cache key, or workload identifiers are included here.
Observed behavior
At the 10-minute cleanup boundary:
cleanup:partsandcleanup:storage-locationsstart together.cleanup:storage-locationsspends roughly 30-35 seconds CPU-bound in the correlated query and later fails a transaction withSQLITE_BUSY.Setting
DISABLE_CLEANUP_JOBS=truestopped the cleanup lock/stall behavior and kept health and data-plane requests responsive. That is only a temporary workaround because retention and orphan cleanup are then deferred.Query-plan evidence
The current task executes the equivalent of:
On the v9.7.0 schema,
EXPLAIN QUERY PLANreports:The migrations create indexes for cache key/version, scope, and repo ID, but not
cache_entries.locationId. After adding a test-only index, the same plan changes to an indexed lookup:The current code is here:
github-actions-cache-server/nitro.config.ts
Line 28 in b7f06e0
Expected behavior
Periodic cleanup should remain bounded and should not make
/healthor cache data-plane routes unavailable. Concurrent cleanup tasks should not contend with each other or with cache writes for long enough to trigger pod restarts.Suggested fix
At minimum, add a schema migration for an index on
cache_entries(locationId)and a regression test that exercises the storage-location cleanup query at realistic row counts. It may also be worth serializing the two 10-minute cleanup tasks or bounding/yielding their SQLite work so cleanup cannot monopolize the single process.Related:
select fromsyntax error in storage-locations cleanup task crashes server #212: SQLiteselect fromsyntax fix for the same storage-location cleanup task