What changed
The CAS daemon’s per-operation analytics recording moves off the Swift-concurrency cooperative pool:
CASAnalyticsDatabase now buffers rows in memory (lock-guarded append) and persists them in batched transactions from a dedicated GCD utility queue. A 1s timer and a 128-row threshold bound staleness; a passive WAL checkpoint after each flush keeps the main db file current.
CASService.storeMetadata no longer spawns a detached Task per operation; the store is a cheap synchronous append.
wal_autocheckpoint=1 (a WAL checkpoint after essentially every insert) is removed; the build-report uploader now runs PRAGMA wal_checkpoint(TRUNCATE) right before it copies cas_analytics.db, so the copied file observes every flushed row.
- Reads flush synchronously first, preserving read-after-write for consumers and tests.
KeyValueService’s storeNode/storeKeyValueMetadata writes go through the same buffer and stop blocking pool threads too.
Why / root cause
On runner VMs, a fully warm xcode-cache build was slower than building with no cache at all (212-227s cached vs 193s no-cache), with the daemon’s per-op transfer_duration at 153.9ms for ~30KB payloads whose wire time is ~1ms.
The chain, each link measured on a staging runner:
- A packet capture showed the wire is fast (handshake 0.1ms, server processing 0.2ms, transfer p90 0.3ms).
- A standalone URLSession probe inside the same VM during the same build fetched the same-size artifact in 4-14ms, so neither the VM, the network, nor URLSession explains 153ms; the cost is internal to the daemon process.
- Every CAS op spawned a
Task that ran a SQLite insert through SQLite.swift’s Connection, whose internal serial queue blocks the calling cooperative-pool thread (queue.sync), and wal_autocheckpoint=1 turned every insert into real disk I/O on the same sparse disk xcodebuild hammers. With pool width = vCPUs (6), a few stalled writes left no threads to resume fetch continuations, so every fetch’s measured window inflated.
- A zero-code A/B (identical build with the daemon state dir on a RAM disk, removing only the disk variable) dropped per-op to 81.4ms and the cached build to 192.1s, below the no-cache baseline confirming the mechanism with the release daemon. The residual is the pool-blocking structure itself (
queue.sync on the pool, Task-per-op, per-insert checkpoint), which the RAM disk cannot remove and this change does.
An earlier fix (#11556) removed the per-insert fsync (synchronous=OFF) but left the write on the pool with a per-insert checkpoint; this finishes the job.
Impact
- xcode-cache on runner VMs flips from net-negative to net-positive; the RAM-disk A/B already crossed below no-cache, and this change removes the remaining ~81ms/op of pool-blocking structure (an unloaded client fetch in the same VM does ~4-14ms).
- All CLI paths recording CAS/keyvalue analytics stop competing with the daemon’s request handling.
- Analytics durability semantics are unchanged in spirit (disposable per-build data); a killed daemon can lose at most the final unflushed second of rows, where previously it lost whatever detached
Tasks had not run.
Validation
TuistCASAnalytics builds via SwiftPM; the full generated workspace (tuist scheme: TuistCASAnalytics, TuistCAS, TuistKit) builds clean via xcodebuild.
- New test
buffered_writes_survive_threshold_flushes_and_reads crosses the flush threshold several times and reads back through both async and read-triggered flushes; existing store-then-read tests pass unchanged through the flush-on-read path.
- swiftformat lint clean.
- Perf validation plan: once on a canary, re-run the staging xcode-cache bench (10k+ ops against a host-local kura) expecting per-op to drop from 153.9ms toward the unloaded ~5-15ms and the cached build from 212-227s toward the ~90-110s range.
🤖 Generated with Claude Code