Hive Hive
Sign in

fix(cli): batch CAS analytics writes off the cooperative pool

GitHub issue · Closed

Metadata
Source
tuist/tuist #11630
Updated
Jul 5, 2026
Domains
Cache
Details

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:

  1. A packet capture showed the wire is fast (handshake 0.1ms, server processing 0.2ms, transfer p90 0.3ms).
  2. 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.
  3. 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.
  4. 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

Flights

Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.

New Flights are paused Configure model inference, GitHub, and a sandbox provider to start another Flight. Existing results remain available below.
No Flights yet

Start a Flight and preserve its objective, outcome, and session here.

Comments
T
tuist[bot] Jul 2, 2026

🛠️ Tuist Run Report 🛠️

Tests 🧪
Scheme Status Cache hit rate Tests Skipped Ran Commit
TuistAcceptanceTests 7 % 48 0 48 e4ee4e060
TuistUnitTests 7 % 3033 6 3027 e4ee4e060
Flaky Tests ⚠️
  • TuistUnitTests: 3 flaky tests (View all)
Test case Module Suite
parseTestStatuses_returnsCorrectStatuses() TuistXCResultServiceTests XCResultServiceTests
parseTestWithCustomLabelXCResult() TuistXCResultServiceTests XCResultServiceTests
parseTestXCResult() TuistXCResultServiceTests XCResultServiceTests
Builds 🔨
Scheme Status Duration Commit
TuistAcceptanceTests 2m 37s e4ee4e060
TuistUnitTests 3m 20s e4ee4e060
F
fortmarek Jul 3, 2026

Local A/B validation of the improvement (real SQLite via the C API, loopback fetch server, 8-way fetch concurrency mirroring the daemon, host CPU saturated with 2x spinners, an injected per-write-burst stall modeling the xcodebuild-contended sparse disk; old = detached Task -> serial-queue-sync insert + per-insert checkpoint as shipped today, new = this PR’s buffered writer, verbatim structure):

injected disk stall old fetch mean (p50) new fetch mean (p50) rows landed (both)
0 ms 1.0 ms (0.9) 0.9 ms (0.8) 4000/4000
5 ms 49.7 ms (49.8) 3.2 ms (2.0) 4000/4000
20 ms 187.5 ms (188.7) 2.5 ms (1.9) 2000/2000
  • The old structure reproduces the runner daemon’s disease locally: its 5-20 ms band spans 50-187 ms per fetch, bracketing the 153.9 ms/op observed on staging, so the structural model is quantitatively consistent with production.
  • The new writer is flat at ~2-3 ms regardless of stall size: analytics writes are fully decoupled from the fetch path, and no rows are lost at any point (the wall-clock also collapses, e.g. 47.1 s -> 0.6 s for the 20 ms/2000-op case, because the serialized write backlog no longer throttles the whole pipeline).
  • With a healthy disk the two are equivalent, so the change introduces no overhead outside the contended case.

Remaining validation after merge: re-run the staging xcode-cache bench on a canary carrying this change, expecting per-op to drop from 153.9 ms toward the unloaded ~5-15 ms and the warm cached build from 212-227 s toward the ~90-110 s range (no-cache baseline: 193 s).