From 45897823003aeed1b5b50b0df25c8b3a9c96dba6 Mon Sep 17 00:00:00 2001 From: Anthony Miller Date: Thu, 7 May 2026 11:55:38 -0700 Subject: [PATCH 01/24] docs(cache): add Phase 1 plan and summary for cache rewrite Adds the engineering plan and manager-facing summary for Phase 1 of the cache rewrite (3.0). Phase 1 scope is foundations (SQLite restructure + field-aware Record) and TTL via @cacheControl; Phase 2 features (@onDelete, eviction, ChainedNormalizedCache) are deferred. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Design/cache-rewrite-phase1-plan.md | 491 ++++++++++++++++++ .../Design/cache-rewrite-phase1-summary.md | 71 +++ 2 files changed, 562 insertions(+) create mode 100644 apollo-ios/Design/cache-rewrite-phase1-plan.md create mode 100644 apollo-ios/Design/cache-rewrite-phase1-summary.md diff --git a/apollo-ios/Design/cache-rewrite-phase1-plan.md b/apollo-ios/Design/cache-rewrite-phase1-plan.md new file mode 100644 index 000000000..97bd42dcc --- /dev/null +++ b/apollo-ios/Design/cache-rewrite-phase1-plan.md @@ -0,0 +1,491 @@ +# Cache Rewrite — Phase 1 Engineering Design + +**Audience:** Engineering — implementer, reviewer, future maintainers. +**Companion document:** [cache-rewrite-phase1-summary.md](./cache-rewrite-phase1-summary.md) — manager-facing summary. +**Source RFC:** [rfc-caching-rewrite.md](./rfc-caching-rewrite.md). +**Reference benchmark:** "SQLite Performance Benchmarks" (Confluence, ClientDev space, page 1585152147). +**Sample resolution rules:** [Samples/cache-control-samples.md](./Samples/cache-control-samples.md). + +## 1. Background and motivation + +The RFC proposes a major-version cache rewrite for Apollo iOS. Phase 1 — the subject of this document — is foundations and TTL only. Specifically: + +- Restructure the SQLite schema from a JSON-blob layout to a row-per-field layout. +- Add a `@cacheControl(maxAge:)` directive end-to-end through the GraphQL compiler, IR, codegen, and runtime. +- Implement per-field TTL evaluation on cache reads. +- Provide an opt-in auto-refresh path for `GraphQLQueryWatcher`. + +The RFC explicitly defers cascading deletion (`@onDelete`), size-limited LRU eviction, `ChainedNormalizedCache`, and watcher/search features to a later phase. This document scopes only Phase 1. + +## 2. Current-state audit + +Before the work begins, the codebase already contains more of the required infrastructure than the RFC implies. The following table captures what is in place and what must be built. + +| Item | State | File / location | +|---|---|---| +| SQLite.swift dependency removed | **Done** (apollo-ios#635, April 2025) | [ApolloSQLiteDatabase.swift](../Sources/ApolloSQLite/ApolloSQLiteDatabase.swift) — direct `SQLite3` C API | +| Apollo iOS 2.0 baseline | Just merged ([apollographql/apollo-ios-dev#780](https://github.com/apollographql/apollo-ios-dev/pull/780)) | Field policies, request chain, Swift 6 already in `main` | +| Field-grained in-memory cache model | Already exists | [Record.swift](../Sources/Apollo/Caching/Record.swift) — `(key, [field: value])`. Only the SQLite serialization is JSON-blob; the in-memory APIs are field-level | +| Schema directive plumbing | Mature pattern | [apolloCodegenSchemaExtension.ts](../../apollo-ios-codegen/Sources/GraphQLCompiler/JavaScript/src/utilities/apolloCodegenSchemaExtension.ts) defines `@typePolicy`, `@fieldPolicy`. New directives follow the same path | +| Field-level metadata in generated code | Already exists | [SelectionSetTemplate.swift:352](../../apollo-ios-codegen/Sources/ApolloCodegenLib/Templates/SelectionSetTemplate.swift) emits `fieldPolicy: .init(...)`. Pattern carries directly to `cacheControl:` | +| Directive interface inheritance with conflict detection | Already implemented | [typePolicyDirective.ts](../../apollo-ios-codegen/Sources/GraphQLCompiler/JavaScript/src/utilities/typePolicyDirective.ts:68-89) walks `getInterfaces()` and merges; we model `@cacheControl` inheritance on the same algorithm | +| Single `CacheInterceptor` protocol with default implementation | Well-bounded | [CacheInterceptor.swift](../Sources/Apollo/RequestChain/Interceptors/CacheInterceptor.swift) | +| `@cacheControl` directive support | **Does not exist** | Verified by grep across `apollo-ios/Sources/` and `apollo-ios-codegen/Sources/` | +| `@onDelete` directive support | **Does not exist** | Phase 2 | +| Per-field write timestamp | **Does not exist** | `Record` has no `writtenAt` | +| Cache-eviction logic | **Does not exist** | Phase 2 | +| Cache test coverage | Substantial | ~8,400 lines under `Tests/ApolloTests/Cache/` (FieldPolicyTests 1,563; ReadWriteFromStoreTests 2,563; WatchQueryTests 2,079) | + +### Implications for the plan + +1. **The "remove SQLite.swift" step from the RFC is already shipped.** Phase 1 starts directly on the row-per-field schema work. +2. **The directive plumbing is well-trodden.** Adding `@cacheControl` is a known-shape task, not invention. +3. **The in-memory `Record` shape doesn't need to change for storage reasons** — only the SQLite serialization layer is JSON-blob. We are changing `Record` anyway because of TTL bookkeeping (decision 2 below), but the change is independent of the SQLite work. +4. **The test surface is substantial.** A meaningful share of Phase 1 calendar time is test rewrites, not new code. + +## 3. Locked architectural decisions + +Each decision is final unless explicitly reopened in Phase 0. + +### 3.1 Major version bump (3.0) + +The schema change to generated code, the new SQLite schema, and the change to `Record.fields` are all breaking. Phase 1 ships as Apollo iOS 3.0. Feature-flagging on 2.x was rejected — the surface area of the change is too broad to dual-maintain. + +### 3.2 `Record` becomes field-aware + +```swift +public struct CachedField: Sendable, Hashable { + public let value: Value // any Hashable & Sendable + public let writtenAt: Int64 // epoch seconds + // Future: lastAccessedAt for LRU; parent refs for @onDelete; etc. +} + +public struct Record: Sendable, Hashable { + public let key: CacheKey + public typealias Fields = [CacheKey: CachedField] + public private(set) var fields: Fields + + // Backward-compatible read of the value alone. + public subscript(key: CacheKey) -> Value? { fields[key]?.value } + + // New API to access the metadata. + public func cachedField(for key: CacheKey) -> CachedField? { fields[key] } +} +``` + +**Rationale.** Considered three options: +- Option A: keep `Record` as `(key, [field: value])` and store TTL state in a parallel side-channel dictionary on the cache. +- Option B: change `Record.fields` to `[field: CachedField]`, so each field carries its own metadata. +- Option C: lazy-loaded fields with cache-handle proxies. + +Selected B because: +1. The major-version bump is the right time to absorb the breaking change. +2. Option A's parallel side-channel ages badly: every place that writes a `Record` must also remember to update the timestamp dict, which is a silent-drop bug surface. +3. Option B maps 1:1 to the row-per-field SQLite layout — round-trip is type-direct. +4. `CachedField` is the natural home for future Phase 2 metadata (LRU access timestamps, `@onDelete` parent references, faceted-search typed values). + +The `record[key]` subscript stays backward-compatible (returns `Value?`), so the executor in [CacheDataExecutionSource.swift](../Sources/Apollo/Execution/ExecutionSources/CacheDataExecutionSource.swift) needs no change. The breaking surface is `Record.fields`'s declared type and any code that constructs a `Record` directly — about ~10 call sites in the runtime and ~4 in tests. + +Custom `NormalizedCache` implementors (the public protocol contract) take a one-paragraph migration note. + +### 3.3 Drop-and-rebuild migration + +On first launch under 3.0, the SQLite cache file is detected as old-schema (or the schema check fails) and the cache is dropped and recreated in the new schema. No in-place data migration. + +**Rationale.** The cache is not a source of truth — its contents are reproducible from the network. Building a row-by-row migration tool would carry significant test surface for a one-time event. A clean rebuild is robust, predictable, and trivially correct. + +**User impact.** Day-one of upgrade requires a network fetch for previously-cached data. Acceptable; explicitly called out in the migration guide. + +### 3.4 Selection-set-scoped per-field TTL + +When a query is executed against the cache, TTL is checked **only** for fields the query selects. If any selected field has expired (`writtenAt + maxAge < now`), the entire query is treated as a cache miss and refetched. + +Fields that exist on the same `Record` but are not in the current query's selection set are **not** evaluated. Their staleness is irrelevant to this read. + +**Rationale.** Different queries select different subsets of an object's fields. A high-precision query may need only the always-fresh fields; a casual query may need only fields that change rarely. Treating the whole record as expired-or-not is too coarse and would force unnecessary refetches. + +**Implementation.** TTL evaluation lives in the read path inside `CacheDataExecutionSource.resolveField`. When `cacheControl.maxAge > 0` and `writtenAt + maxAge < now`, the resolver throws `JSONDecodingError.missingValue`. The existing missing-value propagation in [GraphQLExecutor.swift](../Sources/Apollo/Execution/GraphQLExecutor.swift) and [ApolloStore.load](../Sources/Apollo/Caching/ApolloStore.swift:142) turns this into a cache miss naturally — no new error type, no new control flow. + +### 3.5 Tri-state `maxAge` semantics + +| Schema | Resolved generated code | Runtime behavior | +|---|---|---| +| (no directive applied) | `cacheControl:` parameter omitted | No TTL check; cache indefinitely | +| `@cacheControl(maxAge: 0)` | `cacheControl: .init(maxAge: 0)` | Always treated as cache miss on consumer-initiated reads (per-field force-refetch) | +| `@cacheControl(maxAge: N)` where `N > 0` | `cacheControl: .init(maxAge: N)` | Check `writtenAt + N < now` | +| `@cacheControl` (no `maxAge` arg) | **Codegen error** | — | + +`Selection.Field.cacheControl` is `CacheControlDirective? = nil`. Generated files for fields with no directive omit the parameter entirely (smaller files; common case is cheaper). + +**Rationale.** +- The Apollo cache spec ([specs.apollo.dev/cache/v0.2](https://specs.apollo.dev/cache/v0.2/)) is silent on the meaning of `maxAge: 0` or the no-directive default. Apollo iOS gets to define this. +- Treating "no directive" as "uncacheable" would silently disable the cache for every customer who upgrades without annotating their schema. Unacceptable migration. +- Making `maxAge: 0` mean "always refetch on demand" gives schema authors per-field volatility control that nothing else provides. They mark `Stock.currentPrice` as volatile once and every query touching it refetches; queries that don't include it cache normally. Cheaper than per-query `cachePolicy: .networkOnly`. +- Requiring an explicit `maxAge:` argument when the directive is written eliminates the footgun of someone writing bare `@cacheControl` and getting unintended behavior. + +**Migration-guide note.** Users who want "always refetch this entire query" should use `cachePolicy: .networkOnly` on the operation. `@cacheControl(maxAge: 0)` is for per-field volatility, not per-query cache-busting. + +## 4. TTL semantics specification + +### 4.1 Resolution algorithm (codegen-time) + +For each scalar field reachable from a query, the codegen frontend resolves `maxAge` using the following precedence (most-specific wins): + +1. `@cacheControl` on the field in the operation +2. `@cacheControl` on the field in the schema +3. `@cacheControl` on the parent type in the operation (if applicable; not standard GraphQL but supported via inline directives in some setups — Phase 0 to confirm) +4. `@cacheControl` on the parent type in the schema +5. Inherited from interfaces the parent type implements (with conflict detection between interfaces — same algorithm as `@typePolicy` already uses) +6. None — the field has no `maxAge` resolved. + +For composite (object) types, the rule differs: +- Composite types do **not** automatically inherit from their parent. A composite field's `maxAge` is determined from its own schema/operation directives. +- The directive `@cacheControl(inheritMaxAge: true)` opts a composite type or field into parent inheritance explicitly. + +For scalars, inheritance is automatic from the parent object's `maxAge` unless overridden. + +All scenarios in [Samples/cache-control-samples.md](./Samples/cache-control-samples.md) must produce the documented resolved values when run through the codegen frontend. + +### 4.2 Read-path enforcement (runtime) + +```swift +// Pseudocode inside CacheDataExecutionSource.resolveField +guard let cachedField = record.cachedField(for: cacheKeyForField) else { + throw JSONDecodingError.missingValue +} + +if let maxAge = field.cacheControl?.maxAge { + if maxAge == 0 { + // Always treated as cache miss on initiating reads. + if ttlEnforcement == .strict { + throw JSONDecodingError.missingValue + } + } else if cachedField.writtenAt + Int64(maxAge) < now { + if ttlEnforcement == .strict { + throw JSONDecodingError.missingValue + } + } +} + +return cachedField.value +``` + +The `ttlEnforcement` parameter is propagated from the call site. See section 5 for the read-mode split. + +### 4.3 Write-path timestamp injection + +`SelectionSetDataResultNormalizer` and `RawJSONResultNormalizer` (both in [GraphQLResultNormalizer.swift](../Sources/Apollo/Execution/ResultAccumulators/GraphQLResultNormalizer.swift)) inject `Date.now` (as epoch seconds) into each `CachedField` they produce when normalizing a network response into a `RecordSet`. + +A protocol-injected clock allows tests to control time: + +```swift +protocol TimeProvider: Sendable { + var nowEpochSeconds: Int64 { get } +} +struct SystemTimeProvider: TimeProvider { + var nowEpochSeconds: Int64 { Int64(Date().timeIntervalSince1970) } +} +``` + +The provider is held by `ApolloStore` and threaded into the normalizer construction. + +## 5. Read-mode split + +Two read modes exist on `ApolloStore.load`: + +| Mode | TTL behavior | Used by | +|---|---|---| +| `.strict` (default) | TTL enforced; expired fields throw `missingValue`; query becomes a cache miss | `client.fetch(query:)`, `watcher.fetch(...)` (any explicit fetch by the consumer), watcher auto-refresh timer firing | +| `.permissive` | TTL ignored; deliver whatever the cache currently contains | Watcher re-read on `didChangeKeys` | + +API: + +```swift +public enum TTLEnforcement: Sendable { case strict, permissive } + +public func load( + _ operation: Operation, + ttlEnforcement: TTLEnforcement = .strict +) async throws -> GraphQLResponse? +``` + +**Rationale.** Distinguishes *initiating* reads (consumer asked for data; TTL is appropriate) from *propagating* reads (watcher is keeping its delivered result in sync with cache changes; TTL is irrelevant to the propagation). Without this split, a watcher whose query happens to share dependent keys with an unrelated mutation would refetch from the network every time that mutation fired, which is surprise behavior the consumer didn't ask for. + +## 6. Watcher × TTL behavior + +### 6.1 Default behavior (no opt-in) + +- Watcher continues to subscribe to `ApolloStore.didChangeKeys` events and re-read on overlap (existing behavior). +- The re-read uses `ttlEnforcement: .permissive`. +- Time-based expiry has no effect on the watcher's delivered output. The watcher's last-delivered result remains visible until either: + - A cache write to a dependent key triggers a re-read (which delivers the post-write value, regardless of TTL), or + - The consumer explicitly calls `watcher.fetch(cachePolicy: .cacheFirst)` — that goes through the strict read path and refetches on TTL miss. + +### 6.2 Opt-in auto-refresh + +```swift +let watcher = await GraphQLQueryWatcher( + client: client, + query: query, + automaticallyRefreshOnExpiry: false, // new flag, default false + resultHandler: { ... } +) +``` + +When `true`: + +1. After every successful result delivery, the watcher computes the **earliest finite expiry** across all fields in `dependentKeys`. Fields with `maxAge: 0` are excluded from this calculation (they have no schedulable expiry). +2. The watcher schedules a one-shot `Task` that sleeps until the earliest expiry. +3. When the timer fires, the watcher calls `fetch(cachePolicy: .cacheFirst)`. The strict read path will hit the network if anything has expired; otherwise it redelivers the cached value. Either path produces a result, which triggers a reschedule. +4. Cache writes that arrive via `didChangeKeys` (the propagating-read path) cancel the existing timer and reschedule based on the post-merge timestamps. +5. Watcher cancellation cancels the timer. + +### 6.3 `maxAge: 0` interaction + +Resolved by combining the two preceding rules: +- Default watcher: permissive read on `didChangeKeys` ignores the always-stale field; no thrash. +- Opt-in watcher: `maxAge: 0` fields are excluded from timer scheduling; no thrash from the timer. The propagating-read path is permissive even in opt-in mode (the opt-in flag controls timers, not read mode), so unrelated writes don't trigger refetches either. + +`maxAge: 0` fields refresh only when the consumer explicitly initiates a fetch (`watcher.fetch(...)` or a fresh `client.fetch(query:)`). This is the documented contract. + +### 6.4 Required additions to `GraphQLResponse` + +The earliest-expiry calculation requires per-field `writtenAt` data and per-field `maxAge` metadata, both available at normalization time. Rather than have the watcher walk the response after the fact, the response carries the precomputed value: + +```swift +public struct GraphQLResponse { + // existing fields unchanged + public let earliestExpiry: Date? // nil if no field has finite TTL +} +``` + +`GraphQLDependencyTracker` is extended to compute this in the same pass that produces `dependentKeys`. + +## 7. SQLite schema + +### 7.1 New schema (DDL) + +```sql +CREATE TABLE IF NOT EXISTS records ( + cache_key TEXT NOT NULL, + field_name TEXT NOT NULL, + int_value INTEGER, + string_value TEXT, + float_value REAL, + bool_value INTEGER, + list_value TEXT, -- JSON-encoded list + child_key_value TEXT, -- cache reference + custom_scalar_value TEXT, -- JSON-encoded + written_at INTEGER NOT NULL, + PRIMARY KEY (cache_key, field_name) +) WITHOUT ROWID; +``` + +A schema-version marker table records the schema generation: + +```sql +CREATE TABLE IF NOT EXISTS schema_metadata ( + key TEXT PRIMARY KEY, + value TEXT +); +-- on init: INSERT OR REPLACE INTO schema_metadata VALUES ('version', '3'); +``` + +### 7.2 Operations + +- `selectRecords(forKeys:)` — single `SELECT … WHERE cache_key IN (?, ?, …) ORDER BY cache_key, field_name`. Reassembles into `Record` instances by grouping by `cache_key` in Swift. Composite-PK clustering ensures rows for one record arrive contiguous in the result set. +- `addOrUpdate(records:)` — shreds each `Record.fields` into N row UPSERTs in one transaction. Each row carries its `written_at`. +- `deleteRecord(for:)` — `DELETE FROM records WHERE cache_key = ?`. +- `deleteRecords(matching:)` — unchanged semantics (`WHERE cache_key LIKE ? COLLATE NOCASE`). +- `clearDatabase` — unchanged. + +### 7.3 Migration on first 3.0 launch + +On `init`, after `createRecordsTableIfNeeded`: + +1. Read `schema_metadata` for the version. +2. If version is missing or `< 3`, drop and recreate the records table; insert the new version. +3. If version is `3`, no migration needed. + +The drop-and-rebuild is silent — no user-visible event other than the network fetches that follow on cache-miss reads. + +### 7.4 Performance gates + +The implementation must hit the following on iPhone 16 Pro hardware (drawn from Zach's benchmark): + +| Operation | Target | +|---|---| +| Select by exact cache key | < 0.2 ms | +| Select by object type + selection set, with `ORDER BY` | < 50 ms | +| Update by composite key | < 1 ms | +| Sort by field (CTE join) | < 250 ms | +| Insert one record (single field) | < 1 ms | + +These are 25% looser than the benchmark's measured numbers, allowing for production-environment variability while still detecting regression. + +## 8. Phased implementation + +### Phase 0 — Design lock and de-risking spikes (2 weeks) + +Inputs: +- This document, signed off. +- Codegen-frontend reviewer assigned. + +Activities: +- Resolve any open questions surfaced during this document's review (see section 12). +- **Spike 1: SQLite schema + benchmark micro-run.** Prototype the new schema (per section 7) on a throwaway branch and run a small benchmark on real iPhone 16 Pro hardware against a few thousand synthetic records. Goal: confirm the section 7.4 performance gates are reproducible on the dev device before Phase 1A starts. ~3 days of effort. Pays off immediately in Phase 1A. +- **Spike 2: `@cacheControl` JS directive transform.** Prototype the JS-side directive transform end-to-end in a throwaway branch — single test schema, no codegen, just confirm the precedence algorithm and interface inheritance work as designed. ~3 days of effort. Goal is to surface any compiler-API surprises *before* Phase 1B begins (note: spike findings will sit in an ADR for ~7 weeks before being consumed; minor staleness risk, mitigated by keeping the spike branch alive for reference). +- Architecture decision records (ADRs) written for each of the 5 locked decisions, plus any newly-resolved Phase 0 items, plus findings from each spike. + +Exit criteria: +- ADRs merged. +- Both spikes compile and validate their respective hypotheses on throwaway branches. +- Phase 1 work order approved. + +### Phase 1A — SQLite schema rewrite + field-aware `Record` (4 engineer-weeks; 6–7 calendar weeks) + +The storage refactor lands as a self-contained, no-feature milestone shippable as 3.0-alpha. Behavior for end users is unchanged from 2.x; the only public-API break is the declared type of `Record.fields`. The `written_at` column is added now and locked into the schema even though TTL evaluation does not yet consult it — adding the column later would force a second drop-and-rebuild migration on upgraders. + +Storage: +- Rewrite [ApolloSQLiteDatabase.swift](../Sources/ApolloSQLite/ApolloSQLiteDatabase.swift) per section 7. +- Rewrite [SQLiteSerialization.swift](../Sources/ApolloSQLite/SQLiteSerialization.swift) to encode/decode per typed column instead of the JSON-blob `record` field. +- Rewrite [SQLiteNormalizedCache.swift](../Sources/ApolloSQLite/SQLiteNormalizedCache.swift) to shred `Record` into rows on write and reassemble on read. +- Schema-version detection and drop-and-rebuild migration on init. +- Performance-gate test harness running on iPhone 16 Pro and asserting the section 7.4 numbers; runs in CI from the start of this phase. + +Field-aware `Record`: +- New `CachedField` type with `value` and `writtenAt` (section 3.2). `writtenAt` is populated by the result normalizer on every cache write; nothing yet reads it, but it is durable. +- `Record.fields` declared type changes to `[CacheKey: CachedField]`. +- The `record[key]` subscript stays as the public read primitive, returning `Value?` by unwrapping `.value`. The executor in [CacheDataExecutionSource.swift](../Sources/Apollo/Execution/ExecutionSources/CacheDataExecutionSource.swift) needs no change. +- Update the ~10 runtime call sites that construct `Record`s and the ~4 test sites that read `record.fields` directly. + +Tests: +- Update [SQLiteCacheTests.swift](../../Tests/ApolloTests/Cache/SQLite/SQLiteCacheTests.swift) and [CachePersistenceTests.swift](../../Tests/ApolloTests/Cache/SQLite/CachePersistenceTests.swift) for the new schema. +- Update the ~4 sites in `Tests/ApolloTests/` that read `record.fields` directly. +- Existing `LoadQueryFromStoreTests.swift`, `ReadWriteFromStoreTests.swift`, `WatchQueryTests.swift`, and `FieldPolicyTests.swift` should require no changes (subscript-only access is preserved); confirm in CI. + +Exit criteria: +- All cache tests in `Tests/ApolloTests/Cache/` pass on the new schema and `Record` shape. +- Performance gates met on iPhone 16 Pro within 25% of benchmark targets. +- Old-schema database files (from any 1.x or 2.x release) successfully migrate to the new schema on first launch. +- Generated code from existing test schemas continues to compile and run unchanged (no `@cacheControl` directive support yet; no behavioral change for end users). +- **3.0-alpha tag is releasable from this milestone.** Internal beta cycle on this alpha (concurrent with Phase 1B start) provides early production-like signal on the storage refactor before TTL behavior is layered on. + +### Phase 1B — `@cacheControl` codegen end-to-end (4 engineer-weeks; 5–6 calendar weeks) + +JS frontend ([apollo-ios-codegen/Sources/GraphQLCompiler/JavaScript/](../../apollo-ios-codegen/Sources/GraphQLCompiler/JavaScript/)): +- Add `directive_cacheControl` and `directive_cacheControlField` definitions to [apolloCodegenSchemaExtension.ts](../../apollo-ios-codegen/Sources/GraphQLCompiler/JavaScript/src/utilities/apolloCodegenSchemaExtension.ts). +- New `cacheControlDirective.ts` (parallel to `typePolicyDirective.ts`) implementing the precedence algorithm, scalar inheritance, composite `inheritMaxAge`, interface walk, and conflict detection. Build on findings from the Phase 0 Spike 2 ADR. +- Validation rules with clear error messages: `@cacheControl` without `maxAge` arg is rejected here. +- JS unit tests covering all 9 scenarios in `cache-control-samples.md`. + +Swift bridge: +- `CompilationResult.Field` and `CompilationResult.Type` gain `cacheControlMaxAge: Int?` (nil for "no directive resolved" or for explicit `maxAge: 0`'s "no scheduling" — the runtime distinguishes; Phase 0 spike to confirm this representation). +- `IR.Field` exposes the resolved `cacheControlMaxAge`. +- [SelectionSetTemplate.swift](../../apollo-ios-codegen/Sources/ApolloCodegenLib/Templates/SelectionSetTemplate.swift) emits `cacheControl: .init(maxAge: N)` on `Selection.Field` for fields with non-nil resolved `maxAge`. Omits the parameter entirely otherwise. + +Apollo runtime: +- New `Selection.CacheControlDirective` struct in [Selection.swift](../Sources/ApolloAPI/Selection.swift). +- `Selection.Field` gains `cacheControl: CacheControlDirective?` with a `nil` default and the appropriate convenience initializer overloads. +- The runtime stores the metadata on `Selection.Field` but does not yet enforce TTL — that lands in Phase 1C. + +Test code regeneration: +- All 6 [TestCodeGenConfigurations](../../Tests/TestCodeGenConfigurations) regenerated and validated. +- New snapshot tests for each precedence scenario. + +Exit criteria: +- All examples in [Samples/cache-control-samples.md](./Samples/cache-control-samples.md) produce the documented resolved values. +- All existing CodegenTests pass against regenerated test APIs. +- New snapshot tests for `@cacheControl` precedence pass. +- Generated code with `cacheControl` metadata is consumed by the existing 1A runtime without behavior change (metadata present, ignored). End-user behavior at the end of this phase remains identical to 3.0-alpha. + +### Phase 1C — TTL evaluation and read-mode split (3 engineer-weeks; 4 calendar weeks) + +- `TimeProvider` protocol and the system implementation; threaded into `ApolloStore`. +- TTL check inside `CacheDataExecutionSource.resolveField`, gated by the new `ttlEnforcement` parameter (section 4.2). +- `TTLEnforcement` enum and the `ApolloStore.load(_:ttlEnforcement:)` overload. +- `GraphQLResultNormalizer` injects `writtenAt` into each `CachedField` during write. +- `GraphQLDependencyTracker` extension to compute `earliestExpiry`. +- New `GraphQLResponse.earliestExpiry: Date?`. +- New `TTLTests.swift` covering all 9 sample scenarios as integration tests, plus boundary cases: `maxAge=0` reads, scalar inheritance, operation overrides, interface propagation, and the strict-vs-permissive distinction. + +Exit criteria: +- New TTL tests pass. +- The `written_at` column populated since Phase 1A is now consulted on reads; round-trip is end-to-end exercised. +- Existing `LoadQueryFromStoreTests.swift`, `ReadWriteFromStoreTests.swift`, `WatchQueryTests.swift`, and `FieldPolicyTests.swift` all pass — most should require no changes; some watcher tests will require updates because the existing "happy accident" revalidation path is no longer possible (the propagating read is now permissive; section 5). + +### Phase 1D — Opt-in watcher refresh, hardening, and beta (4 engineer-weeks; 4–5 calendar weeks) + +- `GraphQLQueryWatcher` `automaticallyRefreshOnExpiry` flag and the timer-scheduling logic per section 6.2. +- `InMemoryNormalizedCache` parity for TTL — same `CachedField` shape, no schema migration needed. +- Migration guide published in [Documentation.docc](../Sources/Apollo/Documentation.docc) covering the 3.0 upgrade path and the watcher × TTL semantics paragraph from section 6. +- Sample updates in [TestCodeGenConfigurations](../../Tests/TestCodeGenConfigurations) demonstrating `@cacheControl` usage. +- 1-week internal beta with at least one end-to-end real-application test. +- Public 3.0-beta tag. + +Exit criteria: +- Zero P0/P1 issues open after the 1-week internal beta. +- Migration guide validated by at least one external user. +- Public 3.0-beta tagged and announced. + +## 9. Timeline summary + +| Phase | Focus | Engineer-weeks | Calendar weeks | +|---|---|---|---| +| 0 | Design lock + two de-risking spikes | 2 | 2 | +| 1A | SQLite schema rewrite + field-aware `Record` (3.0-alpha) | 4 | 6–7 | +| 1B | `@cacheControl` codegen end-to-end | 4 | 5–6 | +| 1C | TTL evaluation + read-mode split | 3 | 4 | +| 1D | Opt-in watcher refresh + hardening + 3.0-beta | 4 | 4–5 | +| **Total** | | **17** | **21–24 (≈ 5–6 months)** | +| **With 25% contingency** | | | **27–30 (≈ 6–7 months)** | + +Plan against 6–7 months calendar. Two release tags ship from the plan: a 3.0-alpha at end of Phase 1A (storage refactor only, no behavioral change) and 3.0-beta at end of Phase 1D (full TTL feature). + +## 10. Risk register + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Test-rewrite scope under-estimated | High | Medium | Phase 1A (storage / `Record` shape) and Phase 1C (TTL behavior) deliberately budget time for test churn. Track test deltas weekly during those phases; escalate if rewrites exceed 30% of file lines. | +| Codegen-frontend surprise (precedence algorithm edge case, interface conflict logic) | Medium | High | Phase 0 Spike 2 prototypes the directive transform on a throwaway branch; surface surprises before Phase 1B starts. Spike branch kept alive across the ~7 weeks of Phase 1A for reference. | +| SQLite performance regression vs. benchmark | Low | High | Phase 0 Spike 1 validates gates on real iPhone 16 Pro hardware before Phase 1A. Performance-gate test harness runs in CI from start of Phase 1A; regression caught immediately. | +| Storage refactor breaks customer apps on upgrade | Medium | High | Storage refactor lands as 3.0-alpha at end of Phase 1A — independent of TTL behavior. Real-world signal on the riskiest piece arrives before the directive work is committed; rollback or fix scope is bounded to storage layer alone. | +| Watcher × TTL semantics confuse users | Medium | Medium | Clear migration-guide paragraph; flag the strict-vs-permissive distinction; document the opt-in flag prominently. | +| Generated-code/runtime mismatch on 2.x → 3.0 upgrade | Medium | High | Major version bump means clean break; `apollo-ios-cli` rejects 2.x configs against 3.0 runtime via build-time check. | +| Solo-engineer concentration risk | Medium | High | Consistent reviewer across all phases; ADRs document non-obvious decisions; design doc (this) lives next to RFC for handoff. | +| Drop-and-rebuild produces unexpected day-one network load on real customers | Low | Medium | Test on a representative customer profile during Phase 1D internal beta. | + +## 11. Out of scope (Phase 2+) + +The following are explicitly deferred, in roughly the order they are likely to be tackled: + +1. `@onDelete` / `@onDeleteField` directive and cascading record deletion. +2. `NormalizedCacheConfiguration` with size limits. +3. LRU eviction (uses `CachedField.lastAccessedAt`, which Phase 2 adds). +4. `evictionFieldsIgnoreList` and `NormalizedCacheConfigurationDelegate`. +5. `ChainedNormalizedCache` (in-memory + SQLite write-through). +6. Watcher auto-refresh on application foreground / scene activation (an alternative to the timer-based opt-in; complementary, not a replacement). +7. Object-level and field-level watchers (RFC explicit deferral). +8. Faceted searching support (RFC explicit deferral). + +Phase 2 is unestimated. A separate planning exercise will scope it after Phase 1 ships. + +## 12. Open questions to resolve in Phase 0 + +1. **Operation-level type directives.** Section 4.1 lists "operation-level parent type" as a precedence layer. Standard GraphQL doesn't allow `@cacheControl` on an inline fragment's type spread directly, but some framings of the precedence rule imply it. Phase 0 to confirm whether the operation-level type layer is meaningfully different from the operation-level field layer or whether to drop it from the algorithm. +2. **Encoding of `cacheControlMaxAge` on `CompilationResult.Field`.** Spike will confirm whether `Int?` is sufficient or whether a richer enum is needed to distinguish "no directive resolved" from "explicit `maxAge: 0`" all the way through the bridge. (Codegen output is the same in both cases, but downstream tooling may want to know.) +3. **Default `Date` representation.** This document specifies epoch seconds (`Int64`) for `writtenAt` to match SQLite-friendly storage. Confirm no consumer expects sub-second precision; if so, switch to milliseconds. +4. **Watcher's behavior when `automaticallyRefreshOnExpiry` is set but no field in the query has a `maxAge`.** Likely: the timer is never scheduled, and the flag has no observable effect. Document this; Phase 0 to confirm we don't want to assert/warn. +5. **Public-API freeze on `Record`.** Section 3.2 changes `Record.fields`. Confirm with the codegen team and any known custom-cache implementors that the migration path (subscript-only access continues to work; `.fields` direct reads must update) is acceptable. If a deprecation period is needed, add a temporary `var fieldsLegacy: [CacheKey: Value]` accessor for one minor cycle on 3.x. +6. **Exact `TTLEnforcement` API placement.** Whether to expose it on `ApolloStore.load` directly, on a new `ReadTransaction` configuration, or via a closure parameter. This is purely an API ergonomics decision. + +These are not blockers for starting Phase 0; they are the explicit agenda for the design lock. + +## 13. References + +- [rfc-caching-rewrite.md](./rfc-caching-rewrite.md) — original RFC. +- [Samples/cache-control-samples.md](./Samples/cache-control-samples.md) — `@cacheControl` resolution scenarios. +- [Samples/on-delete-samples.md](./Samples/on-delete-samples.md) — Phase 2 directive scenarios (not in Phase 1 scope). +- "SQLite Performance Benchmarks" — Confluence ClientDev page 1585152147. +- Apollo cache spec — [specs.apollo.dev/cache/v0.2](https://specs.apollo.dev/cache/v0.2/). +- [cache-rewrite-phase1-summary.md](./cache-rewrite-phase1-summary.md) — manager-facing summary. diff --git a/apollo-ios/Design/cache-rewrite-phase1-summary.md b/apollo-ios/Design/cache-rewrite-phase1-summary.md new file mode 100644 index 000000000..5b8cbb3b6 --- /dev/null +++ b/apollo-ios/Design/cache-rewrite-phase1-summary.md @@ -0,0 +1,71 @@ +# Cache Rewrite — Phase 1 Summary + +**Audience:** Engineering management. +**Companion document:** [cache-rewrite-phase1-plan.md](./cache-rewrite-phase1-plan.md) — engineering design and implementation detail. +**Source RFC:** [rfc-caching-rewrite.md](./rfc-caching-rewrite.md). + +## TL;DR + +Apollo iOS 3.0 will replace the SQLite normalized-cache layer with a row-per-field schema and introduce per-field Time-to-Live via a new `@cacheControl` directive. This is the first phase of the cache rewrite outlined in the RFC. Phase 1 ships only the foundation (SQLite restructure + TTL); other RFC features (cascading delete, eviction, cache chaining) are deferred to Phase 2. The work is led by a single engineer. + +**Bottom-line ask:** authorize a single-engineer effort of approximately **6 to 7 months calendar time** to deliver Apollo iOS 3.0-beta with foundations and TTL. + +## What ships in Phase 1 + +- New SQLite schema: `WITHOUT ROWID` table with composite primary key `(cache_key, field_name)` and per-type value columns. Recommended structure validated by Zach's earlier benchmark. +- A new `@cacheControl(maxAge:)` directive in the GraphQL schema and operation language. Per-field TTL with documented precedence rules (schema type → schema field → operation type → operation field). +- Per-field expiry checking on cache reads. Selection-set scoped: if any field a query needs has expired, the query refetches from the network; unrelated fields on the same record are unaffected. +- Optional auto-refresh on watchers, opt-in via a new initialization flag. +- Drop-and-rebuild migration on first launch under 3.0 (the cache is not a source of truth; rebuilding from network is acceptable). + +## What is not in Phase 1 (deferred to Phase 2) + +- `@onDelete` directive and cascading record deletion. +- `NormalizedCacheConfiguration` with size limits and LRU eviction. +- `ChainedNormalizedCache` (in-memory + SQLite chained writes). +- Object/field watchers and faceted searching (RFC explicitly calls these out as further-future). + +Phase 2 is unestimated and outside the scope of this plan. + +## Timeline + +| Phase | Focus | Engineer-weeks | Calendar weeks | +|---|---|---|---| +| **0** | Design lock + two de-risking spikes (SQLite, codegen) | 2 | 2 | +| **1A** | SQLite schema rewrite + field-aware `Record` (**3.0-alpha** ships from this milestone) | 4 | 6–7 | +| **1B** | `@cacheControl` codegen end-to-end | 4 | 5–6 | +| **1C** | TTL evaluation + read-mode split | 3 | 4 | +| **1D** | Opt-in watcher refresh + hardening + **3.0-beta** | 4 | 4–5 | +| **Total effort** | | ~17 | | +| **Calendar (with reviews and interruptions)** | | | **21–24 (≈ 5–6 months)** | +| **Calendar with 25% contingency** | | | **27–30 (≈ 6–7 months)** | + +The 25% contingency is the number to plan against. This subsystem has deep coupling between the runtime, the codegen frontend, and roughly 8,400 lines of cache-related test code; estimates without contingency consistently underrun in projects of this shape. + +**Two release tags ship from the plan.** A 3.0-alpha lands at the end of Phase 1A — storage refactor only, no behavior change for end users. This validates the riskiest piece of the project (new SQLite schema, drop-and-rebuild migration, public `Record` type change) in production-like conditions before TTL behavior is layered on top. A 3.0-beta lands at the end of Phase 1D with the full `@cacheControl` feature. + +## Top risks + +1. **Test surface.** ~8,400 lines of cache tests must be re-evaluated. Some semantics genuinely change under the new model (a previously-cached query may now miss because it includes a `maxAge=0` field). Budgeted into Phase 1B and 1C; expect surprises. +2. **Codegen-runtime coupling.** Generated code from 3.0 will not run against 2.x runtime, and vice versa. Clear upgrade guidance and tooling needed. +3. **Single-engineer concentration risk.** This is a deep subsystem with no second engineer cross-checking. Recommend assigning a consistent reviewer across all phases to mitigate bus factor. +4. **Watcher × TTL semantics.** Watchers do not auto-refresh on time-based expiry by default; users opt in via a new flag. The default behavior is a deliberate choice (avoid surprise network calls from unrelated cache writes) and is documented in the migration guide. Some users may expect the opposite default. +5. **Migration UX.** The drop-and-rebuild on first 3.0 launch means a network round trip on day one of upgrade. Acceptable, but worth a dedicated test on a real customer profile before locking it in. + +## Decisions already locked + +These were settled in design discussion before this document was written. The companion engineering doc explains each in detail. + +1. **Major version bump (3.0)** — breaking changes acceptable. +2. **Field-aware Record type** — `Record.fields` becomes `[CacheKey: CachedField]` with `value + writtenAt`. The `record[key]` subscript stays backward-compatible. +3. **Drop-and-rebuild migration** — no in-place migration of cached data. +4. **Selection-set-scoped per-field TTL** — any expired field in the current selection causes a whole-query refetch; fields outside the selection are not checked. +5. **Tri-state `maxAge` semantics** — no directive (cache forever) / `maxAge: 0` (force-refetch on consumer-initiated reads) / `maxAge: N` (expire after N seconds). `@cacheControl` with no `maxAge` argument is a codegen error. + +## What I need + +- **Approval to proceed** with Phase 0 (2-week design lock + codegen-frontend spike). Phase 0 produces the design doc that authorizes Phases 1A–1D. +- **Confirmation of the major-version bump** as the release vehicle. Phase 1 is a breaking change to generated code, the SQLite schema, and the public `Record` type. Shipping it on 2.x is not feasible. +- **Reviewer assignment** for the duration of the project. The work runs through the codegen frontend, runtime, and SQLite layer; a single consistent reviewer reduces context-switching cost and bus factor. + +The engineering doc has the full implementation plan, decision rationale, risk register, and Phase 0 entry criteria. From 703563d41ef7111b6b9bc9034ae3f455dfd2d103 Mon Sep 17 00:00:00 2001 From: Anthony Miller Date: Thu, 7 May 2026 11:58:21 -0700 Subject: [PATCH 02/24] docs(cache): add Phase 1 AI execution plan Adds the workflow document that an AI agent (Claude Code) follows when executing the Phase 1 cache rewrite. Defines branch and PR conventions, per-PR loop, quality gates, escalation triggers, the 31 stacked PRs that make up Phase 1, and session-bootstrap steps for resuming work across sessions. Companion to the engineering design plan (apollo-ios/Design/cache-rewrite-phase1-plan.md); the engineering plan specifies what to build, this document specifies how to ship it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Design/cache-rewrite-phase1-execution.md | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 apollo-ios/Design/cache-rewrite-phase1-execution.md diff --git a/apollo-ios/Design/cache-rewrite-phase1-execution.md b/apollo-ios/Design/cache-rewrite-phase1-execution.md new file mode 100644 index 000000000..1430dcb85 --- /dev/null +++ b/apollo-ios/Design/cache-rewrite-phase1-execution.md @@ -0,0 +1,308 @@ +# Cache Rewrite — Phase 1 AI Execution Plan + +**Audience:** Claude Code agent executing the Phase 1 work; the human reviewer. +**Companion documents:** +- [cache-rewrite-phase1-summary.md](./cache-rewrite-phase1-summary.md) — manager-facing summary. +- [cache-rewrite-phase1-plan.md](./cache-rewrite-phase1-plan.md) — engineering design (the **authoritative** spec for what is being built). + +This document describes **how** the Phase 1 work is executed by an AI agent, broken into small reviewable PRs stacked on each other. The engineering plan describes what to build; this plan describes the workflow for shipping it. + +## 1. Preamble + +This is the operating manual for an AI agent (Claude Code) executing the Phase 1 cache rewrite. Every Claude Code session that touches this project must consult this document before doing implementation work. It exists so that: + +- Any session can resume work where the last one left off without re-deriving context from the conversation. +- The human reviewer has predictable, small, reviewable artifacts to evaluate. +- Scope drift, accidental scope expansion, and silent design deviation are minimized. + +**The engineering plan is authoritative for design decisions.** This document is authoritative for *workflow*. If a conflict arises (e.g., a PR's scope can't fit the workflow rules in this document), escalate to the reviewer; do not silently widen scope. + +## 2. Operating principles + +1. **One PR at a time per active task.** Maximum **2 stacked unmerged PRs** in flight at once. If the reviewer falls behind, queue work — do not pile on more open PRs. +2. **Target diff size: ~300–500 LoC of meaningful change.** Generated code, snapshot data, and trivial test boilerplate don't count toward this. PRs over 600 meaningful LoC must be split unless explicitly approved by the reviewer. +3. **Each PR is self-contained.** It builds, all tests pass, and it is mergeable in isolation against its base branch (which may be `main` or a prior PR's branch in the stack). +4. **No scope creep.** A PR does exactly what its title and acceptance criteria say. Anything else — even an obvious cleanup or fix — is escalated as a follow-up task; do not silently include it. +5. **All functional changes must be fully unit-tested.** Every behavior added, modified, or moved by a PR has corresponding test coverage in the same PR. Pure refactors with no behavior change still require test runs to confirm no regression. Documentation-only and ADR-only PRs are exempt. "Fully unit-tested" means: every new branch in production code has at least one test exercising it; every documented edge case from the design plan has a test; every new public API has at least one test exercising its happy path and at least one error/boundary case. +6. **The engineering plan is authoritative.** This execution doc is workflow only. +7. **Memory and the design docs are the source of truth across sessions, not the conversation transcript.** A new session starts by reading the docs and the open-PR list, not by summarizing prior chat. + +## 3. Branch and PR conventions + +### Branch naming + +- **Stack root:** `cache-rewrite/phase-1-plan` (this branch; contains the three Phase 1 design docs). +- **Implementation PRs:** `cache-rewrite/phase-1--`, where `` is the phase (`a`, `b`, `c`, `d`) and `` is the two-digit PR sequence within that phase. + - Examples: `cache-rewrite/phase-1a-01-cached-field-type`, `cache-rewrite/phase-1b-03-bridge-max-age`, `cache-rewrite/phase-1c-07-ttl-tests`. +- **ADR PRs (Phase 0):** `cache-rewrite/phase-0-adr-`. Example: `cache-rewrite/phase-0-adr-record-abstraction`. +- **Spike branches (Phase 0):** `cache-rewrite/phase-0-spike-`. These are **not merged**; they are kept alive for reference and findings are captured in an ADR. + +### PR base + +- Stacked PRs base on the previous PR's branch, **not** `main`. +- The PR description's *Stacks on* field names the base branch explicitly. +- After the prior PR merges, the next PR is rebased to land on `main` (or the new tip of the merged stack). + +### Commit message convention + +Follow the repo's existing conventional-commits style observed in `git log` (e.g., `chore(deps): …`, `docs: …`, `feat: …`, `fix: …`, `feature: …`). Commit messages end with the `Co-Authored-By` line per `CLAUDE.md`. Use HEREDOC for multi-line messages. + +### PR description template + +Every implementation PR uses this template verbatim: + +```markdown +## Goal + + +## Design doc reference +- [cache-rewrite-phase1-plan.md](apollo-ios/Design/cache-rewrite-phase1-plan.md) §
: + +## Position in execution plan +PR- of the cache rewrite Phase 1 stack. See [cache-rewrite-phase1-execution.md](apollo-ios/Design/cache-rewrite-phase1-execution.md) §8. + +## Stacks on +- `` (or `main` for the first PR after the planning merge) + +## Followups in this stack +- PR-: +- PR-<NNN+2>: <title> + +## Files changed +<Bulleted list, grouped by directory.> + +## Tests added +<Bulleted list of new test cases / files. State explicitly which behaviors are covered.> + +## Acceptance criteria +- [ ] <Concrete, checkable item> +- [ ] <Another> +... + +## Verification +- `tuist generate` succeeds: <yes/no> +- Build green: <yes/no — which scheme(s)> +- Tests pass: <yes/no — which test plan(s)> +- `XcodeListNavigatorIssues severity:"error"` returns zero: <yes/no> +- New warnings introduced: <none / list> + +## Notes for reviewer +<Anything non-obvious. Empty if nothing.> +``` + +## 4. Per-PR workflow + +For each PR, the agent runs the following loop: + +### 4.1 Bootstrap + +1. Read `MEMORY.md` and the cache-rewrite project memory file. +2. Read this execution plan and locate the next un-started PR in §8. +3. Read the engineering plan section that PR implements. +4. Read the prior merged PR's description (and its predecessors as needed) to understand current cumulative state. +5. Run `git status`, `git branch -vv`, and `gh pr list --author @me --state open` to confirm stack state. + +### 4.2 Plan + +1. Use `TodoWrite` to break the PR into sub-tasks (typically 3–6 items). +2. If the planned change diverges from §8's scope or LoC estimate, **stop and escalate** before writing code. + +### 4.3 Implement + +1. Create the PR's branch off the correct base (per §3). +2. Implement sub-tasks one at a time, marking each complete in `TodoWrite` as it lands. +3. Add tests **alongside the production code change**, not in a separate commit. +4. Use Xcode MCP tools (`BuildProject`, `RunSomeTests`, `XcodeListNavigatorIssues`) for build/test verification — not raw `xcodebuild`. +5. For codegen-affecting changes (Phase 1B+), regenerate test API code and commit the regeneration in the same PR. + +### 4.4 Verify + +Quality gates per §5 must all be green before opening the PR. If any gate fails: +- Diagnose; if root cause is in the PR's scope, fix. +- If root cause is outside scope (pre-existing flake, unrelated bug), **stop and escalate**. + +### 4.5 Open PR + +1. Push the branch. +2. Open PR via `gh pr create` using the §3 template. +3. Update `TodoWrite` to mark the PR opened; mark the PR's task `completed` only after merge. +4. Move on to the next PR if and only if the in-flight stack count is below the §2 maximum. + +### 4.6 Address review feedback + +1. Read each comment carefully; ask clarifying questions if intent is ambiguous. +2. Make changes, push as new commits (do not force-push during active review unless the reviewer requests a rebase). +3. Respond inline to each comment indicating what changed. +4. Re-run quality gates. +5. Mark conversations resolved only after the reviewer has acknowledged or after the requested change is unambiguously complete. + +### 4.7 On merge + +1. Pull the merged change into local `main` (or the merged base). +2. Rebase the next stacked PR onto the new base; resolve any conflicts. +3. Re-verify the next PR's quality gates after rebase. +4. Continue with the next PR. + +## 5. Quality gates + +Every PR must pass all of the following before opening or before requesting re-review: + +| Gate | Tool | Notes | +|---|---|---| +| Workspace generates | `tuist generate` | If it fails, escalate — environment problem, not code | +| Build succeeds | Xcode MCP `BuildProject` for affected schemes | Apollo, ApolloSQLite, ApolloCodegenLib at minimum | +| Tests pass | Xcode MCP `RunSomeTests` against the relevant test plan | Per the table in `CLAUDE.md` mapping schemes → test plans | +| No errors in navigator | Xcode MCP `XcodeListNavigatorIssues` `severity:"error"` returns zero | Used because `RunSomeTests` has a known schema bug; per `CLAUDE.md` | +| New behaviors fully tested | Manual review of the diff + `RunSomeTests` confirming new tests run | See §2 principle 5 | +| No new warnings | Compare diff to baseline | If unavoidable, document in PR description | +| Codegen regenerated | `./scripts/run-codegen.sh` for codegen-affecting PRs | Phase 1B+ | +| Commit message conforms | Manual check against `git log --oneline -10 origin/main` | Conventional-commit prefix | + +## 6. Escalation triggers + +The agent **stops and asks the reviewer** under any of the following conditions. Do not work around them silently. + +1. A PR's planned diff exceeds 600 meaningful LoC (after subtracting generated/snapshot data). +2. A PR requires changes outside its declared scope — touching files the §8 entry doesn't list — to compile or pass tests. +3. A test fails for a reason that suggests a gap in the engineering design plan (e.g., the design implies behavior X but the existing test asserts behavior Y, and they're incompatible). +4. The build fails with an error not recognizable after one diagnostic pass; consult `axiom-ios-build` skill if available, then escalate if still unclear. +5. The agent is about to make a public-API change not explicitly described in the engineering design plan. +6. The agent notices an unrelated bug, dead code, or cleanup opportunity. **Propose as a follow-up task; do not fix in the current PR.** +7. Three or more existing tests in the same area must be modified to pass under the new behavior. Test-rewrite scope is real risk; reviewer should confirm the modifications are correct, not bandaids. +8. A spike (Phase 0) discovers that an assumption in the engineering plan is wrong. +9. Stack rebase conflicts cannot be resolved mechanically — the conflict represents a semantic clash between in-flight PRs. +10. Anything else ambiguous. Asking is cheap; silent drift is expensive. + +## 7. Review cadence and re-stack policy + +### Cadence expectations + +- The agent does not block on review beyond the §2 stacked-PR limit. If 2 PRs are open and unmerged, the agent waits. +- The agent does not work ahead of the reviewer's pace by more than 2 PRs. +- If review feedback is requested on an earlier PR while a later PR is open, the earlier PR is addressed first. + +### Re-stack policy + +- When PR-N merges, PR-(N+1) is rebased onto the new base. If conflicts are mechanical, resolve and force-push (this is acceptable for stacked PRs that have not yet received reviewer comments). If the rebased PR has reviewer comments, **do not force-push** without confirming with the reviewer. +- When `main` moves forward (unrelated changes from other contributors), the bottom of the stack is rebased and propagated up at the start of every session. +- Stack health check at session start: any open PR more than 7 days old, or with merge conflicts, is flagged to the reviewer for triage. + +## 8. The PR list + +**Convention.** Each entry below has a unique `PR-NNN` identifier. The identifier persists even if the order changes, so review threads can refer to a stable ID. + +**Status legend.** ⬜ not started · 🟦 in progress · 🟨 PR open · 🟩 merged. + +### Phase 0 — Design lock and ADRs (4 PRs + 2 spike branches) + +| ID | Title | Status | Base | Est. LoC | Tests required | +|---|---|---|---|---|---| +| PR-001 | docs(cache): ADR — major version bump rationale | ⬜ | `main` | ~150 | None (docs) | +| PR-002 | docs(cache): ADR — Record abstraction (field-aware via `CachedField`) | ⬜ | PR-001 | ~250 | None (docs) | +| PR-003 | docs(cache): ADR — TTL semantics (tri-state, selection-set scoped, read-mode split) | ⬜ | PR-002 | ~300 | None (docs) | +| PR-004 | docs(cache): ADR — Watcher × TTL (opt-in auto-refresh, permissive propagating reads) | ⬜ | PR-003 | ~250 | None (docs) | + +Phase 0 also produces two spike branches that are **not merged**: +- `cache-rewrite/phase-0-spike-sqlite-bench` — micro-benchmark of the new schema on iPhone 16 Pro hardware, validates the §7.4 performance gates from the engineering plan. +- `cache-rewrite/phase-0-spike-cachecontrol-jsdirective` — JS-side prototype of the `@cacheControl` directive transform; confirms the precedence algorithm and interface inheritance work. + +Findings from each spike are captured in their respective Phase 0 ADRs (PR-003 references the SQLite spike; the cachecontrol-jsdirective spike findings become a `cache-rewrite/phase-0-adr-cachecontrol-spike` PR if material surprises surface — otherwise findings live as a comment thread on the existing ADR). + +### Phase 1A — SQLite schema rewrite + field-aware `Record` (8 PRs) + +Goal: ship 3.0-alpha at end of this phase. No behavior change for end users. + +| ID | Title | Status | Base | Est. LoC | Tests required | +|---|---|---|---|---|---| +| PR-005 | feat(cache): introduce `CachedField` type (no consumers yet) | ⬜ | PR-004 | ~80 | Unit: `CachedField` Hashable/Sendable/Equatable; round-trip with sample values | +| PR-006 | refactor(cache): change `Record.fields` type to `[CacheKey: CachedField]` | ⬜ | PR-005 | ~400 | Update existing Record/RecordSet tests; verify `record[key]` subscript still returns `Value?` for all existing call sites | +| PR-007 | feat(sqlite): add `schema_metadata` table and version detection | ⬜ | PR-006 | ~150 | Unit: schema-version read/write, missing-row defaults to 0, version stamping on init | +| PR-008 | feat(sqlite): new schema DDL — records table with composite PK + typed columns | ⬜ | PR-007 | ~200 | Unit: table creation idempotent, `WITHOUT ROWID` preserved, schema_metadata version=3 stamped | +| PR-009 | feat(sqlite): implement insert/select/update/delete on new table (feature-flagged) | ⬜ | PR-008 | ~600 | Unit: each operation against new schema; round-trip Record↔rows; transactional behavior on failure; performance smoke test | +| PR-010 | feat(sqlite): switch `SQLiteNormalizedCache` to new schema; drop-and-rebuild migration | ⬜ | PR-009 | ~400 | Unit: migration on detected old schema; integration: existing cache tests pass on new schema; CachePersistenceTests updated | +| PR-011 | test(cache): SQLite performance-gate harness on iPhone 16 Pro | ⬜ | PR-010 | ~200 | Performance test asserting all §7.4 gates within 25% margin | +| PR-012 | chore: tag 3.0-alpha; release notes; changelog | ⬜ | PR-011 | ~100 | Smoke test: clean install + first launch reads/writes a record | + +### Phase 1B — `@cacheControl` codegen end-to-end (7 PRs) + +Goal: codegen emits `cacheControl` metadata on `Selection.Field`. Runtime stores but does not yet enforce TTL — landed in Phase 1C. + +| ID | Title | Status | Base | Est. LoC | Tests required | +|---|---|---|---|---|---| +| PR-013 | feat(codegen): add `@cacheControl` and `@cacheControlField` directive definitions to JS frontend | ⬜ | PR-012 | ~120 | JS unit: directive registration, schema extension AST roundtrip | +| PR-014 | feat(codegen): `cacheControlDirective.ts` precedence resolution algorithm + JS unit tests | ⬜ | PR-013 | ~700 | JS unit: all 9 cache-control-samples scenarios; interface inheritance with conflict; `inheritMaxAge` opt-in; bare `@cacheControl` rejected | +| PR-015 | feat(codegen): bridge resolved `cacheControlMaxAge` through `CompilationResult` | ⬜ | PR-014 | ~180 | Unit: bridge round-trip; nil for absent directive; explicit 0 distinguished from nil at the bridge if §12 question 2 says so | +| PR-016 | feat(api): add `Selection.CacheControlDirective` runtime type | ⬜ | PR-015 | ~100 | Unit: type Hashable/Sendable; convenience initializers | +| PR-017 | feat(ir): `IR.Field` exposes `cacheControlMaxAge` | ⬜ | PR-016 | ~120 | Unit: IR field reflects compilation-result value across precedence cases | +| PR-018 | feat(codegen): `SelectionSetTemplate` emits `cacheControl:` parameter when non-nil | ⬜ | PR-017 | ~250 | Snapshot tests: generated code with/without directive; minimal output for nil case | +| PR-019 | test(codegen): regenerate `TestCodeGenConfigurations`; snapshot tests for all 9 sample scenarios | ⬜ | PR-018 | ~600 | Snapshot tests; existing CodegenTests pass against regenerated APIs | + +### Phase 1C — TTL evaluation and read-mode split (7 PRs) + +Goal: `cacheControl` metadata is now consulted at read time; the `written_at` column populated since Phase 1A becomes load-bearing; watcher behavior splits into strict/permissive read modes. + +| ID | Title | Status | Base | Est. LoC | Tests required | +|---|---|---|---|---|---| +| PR-020 | feat(cache): `TimeProvider` protocol + `SystemTimeProvider`; threaded into `ApolloStore` | ⬜ | PR-019 | ~150 | Unit: protocol conformance; mockable `TimeProvider` for tests | +| PR-021 | feat(cache): `TTLEnforcement` enum + `ApolloStore.load(_:ttlEnforcement:)` overload | ⬜ | PR-020 | ~150 | Unit: enum cases; load with both modes returns correct results when no TTL applies | +| PR-022 | feat(cache): TTL check in `CacheDataExecutionSource.resolveField` gated by enforcement | ⬜ | PR-021 | ~250 | Unit: strict path throws `missingValue` on expired field; permissive path returns value; `maxAge=0` always missing on strict; nil never missing | +| PR-023 | feat(cache): `GraphQLResultNormalizer` injects `writtenAt` on cache writes | ⬜ | PR-022 | ~180 | Unit: normalized records carry `writtenAt`; `TimeProvider` injection works | +| PR-024 | feat(cache): `GraphQLDependencyTracker` computes `earliestExpiry`; `GraphQLResponse.earliestExpiry` exposed | ⬜ | PR-023 | ~250 | Unit: nil when no field has finite TTL; correct minimum across mixed-TTL queries; excludes `maxAge=0` from the calc | +| PR-025 | feat(cache): watcher uses `.permissive` on `didChangeKeys` re-read | ⬜ | PR-024 | ~150 | Unit: watcher delivers cached value through TTL boundary on unrelated write; no automatic network refetch from time-based expiry | +| PR-026 | test(cache): `TTLTests.swift` covering all 9 sample scenarios + boundary cases | ⬜ | PR-025 | ~700 | Integration tests for every `cache-control-samples.md` scenario; boundary cases for `maxAge=0`, scalar inheritance, operation overrides, interface propagation, strict vs permissive | + +### Phase 1D — Opt-in watcher refresh, hardening, beta (5 PRs) + +Goal: ship 3.0-beta. Full feature visible to consumers. + +| ID | Title | Status | Base | Est. LoC | Tests required | +|---|---|---|---|---|---| +| PR-027 | feat(cache): `GraphQLQueryWatcher.automaticallyRefreshOnExpiry` flag + timer scheduling | ⬜ | PR-026 | ~400 | Unit: timer fires at earliest finite expiry; reschedules on result; cancels on watcher cancel; excludes `maxAge=0` from scheduling; uses `.cacheFirst` on fire | +| PR-028 | feat(cache): `InMemoryNormalizedCache` parity for TTL | ⬜ | PR-027 | ~120 | Unit: in-memory writes carry `writtenAt`; in-memory reads honor `TTLEnforcement` | +| PR-029 | docs(cache): migration guide in `Documentation.docc` for 3.0 | ⬜ | PR-028 | ~500 | None (docs); manual proofread by reviewer | +| PR-030 | feat(samples): demonstrate `@cacheControl` usage in `TestCodeGenConfigurations` | ⬜ | PR-029 | ~250 | Codegen regression: existing test code generation continues to succeed with new samples | +| PR-031 | chore: tag 3.0-beta; final changelog; release announcement draft | ⬜ | PR-030 | ~150 | Internal beta cycle (1 week); zero P0/P1 issues open | + +### Total + +- **31 PRs** across 4 phases. +- **~7,250 meaningful LoC** of change at midpoint estimates. +- The estimates are guidance, not contracts. Splitting a PR is preferred to overrunning the §2 cap. + +## 9. Session bootstrap + +Every Claude Code session that resumes this work follows these steps before doing anything else: + +1. Read `~/.claude/projects/-Users-amdev-repos-apollo-ios-dev/memory/MEMORY.md`. +2. Read `~/.claude/projects/-Users-amdev-repos-apollo-ios-dev/memory/project_cache_rewrite.md`. +3. Read this execution plan; locate the next un-started PR in §8. +4. Read the engineering plan section that PR implements. +5. `git status` and `git branch -vv` to see local state. +6. `gh pr list --author @me --state open` to see in-flight PRs. +7. If there are open PRs, check their review status (`gh pr view <number>`) and address review feedback before starting new work. +8. If the stack is up-to-date, pull `main` and rebase the bottom of the stack if needed. + +If any step reveals state that doesn't match the expected workflow (rogue branches, force-pushed history, half-merged PRs), **stop and escalate**. + +## 10. Done conditions + +### Per-phase done + +- **Phase 0 done:** PR-001 through PR-004 merged; both spike branches' findings captured in ADRs. +- **Phase 1A done:** PR-005 through PR-012 merged; 3.0-alpha tag pushed; performance gates green in CI. +- **Phase 1B done:** PR-013 through PR-019 merged; codegen produces `cacheControl:` on `Selection.Field`; all `TestCodeGenConfigurations` regenerated and pass. +- **Phase 1C done:** PR-020 through PR-026 merged; TTL is enforced on strict reads; watcher uses permissive reads; `TTLTests.swift` covers every documented scenario. +- **Phase 1D done:** PR-027 through PR-031 merged; opt-in watcher refresh works; `InMemoryNormalizedCache` has TTL parity; migration guide live; 3.0-beta tag pushed; 1-week internal beta complete with zero P0/P1 issues. + +### Phase 1 done + +All 31 PRs merged. 3.0-beta tagged and announced. Migration guide validated by at least one external user. Engineering-plan §10 risks reviewed and either retired or escalated to Phase 2 planning. + +## 11. Living document + +This is a living document. As the work proceeds, the §8 PR list may evolve: +- A PR may split into two if scope is larger than estimated → renumber from the split point or use sub-IDs (`PR-009a`, `PR-009b`). +- A PR may be dropped if discovery shows it's unnecessary → mark `~~PR-NNN~~ — DROPPED, see <reason>`. +- Order may shift in response to learnings → update the table; do not delete entries. + +All such changes are made in their own `docs(cache): update execution plan` PRs, not silently. From f9d486f209123c97e31804ae6c82660a65153bc3 Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Thu, 7 May 2026 12:04:02 -0700 Subject: [PATCH 03/24] docs(cache): adopt long-lived plan branch as stack base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update execution plan §3, §7, §8, and §10 so that `cache-rewrite/phase-1-plan` is the long-lived base for the entire 31-PR stack. PR-001 bases on the plan branch (not main); subsequent PRs stack as before. The plan branch itself is only merged into main after every PR in the stack has merged into it. Plan revisions can land directly on the plan branch and propagate down through every open stacked PR via rebase. The 3.0-alpha and 3.0-beta release tags are cut from the plan branch; 3.0 final is cut from main after the plan branch merges. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../Design/cache-rewrite-phase1-execution.md | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/apollo-ios/Design/cache-rewrite-phase1-execution.md b/apollo-ios/Design/cache-rewrite-phase1-execution.md index 1430dcb85..fa84bda31 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-execution.md +++ b/apollo-ios/Design/cache-rewrite-phase1-execution.md @@ -31,7 +31,7 @@ This is the operating manual for an AI agent (Claude Code) executing the Phase 1 ### Branch naming -- **Stack root:** `cache-rewrite/phase-1-plan` (this branch; contains the three Phase 1 design docs). +- **Long-lived plan branch:** `cache-rewrite/phase-1-plan` — holds the three Phase 1 design docs (summary, plan, execution). **Not merged into `main` until every PR in the stack has been merged into it.** Treated as a long-running feature branch; serves as the base for the entire Phase 1 stack. - **Implementation PRs:** `cache-rewrite/phase-1<letter>-<NN>-<short-slug>`, where `<letter>` is the phase (`a`, `b`, `c`, `d`) and `<NN>` is the two-digit PR sequence within that phase. - Examples: `cache-rewrite/phase-1a-01-cached-field-type`, `cache-rewrite/phase-1b-03-bridge-max-age`, `cache-rewrite/phase-1c-07-ttl-tests`. - **ADR PRs (Phase 0):** `cache-rewrite/phase-0-adr-<slug>`. Example: `cache-rewrite/phase-0-adr-record-abstraction`. @@ -39,9 +39,12 @@ This is the operating manual for an AI agent (Claude Code) executing the Phase 1 ### PR base -- Stacked PRs base on the previous PR's branch, **not** `main`. +- **PR-001 (the first PR in the stack) bases on `cache-rewrite/phase-1-plan`**, not `main`. Every PR in the stack ultimately resolves to a merge into `cache-rewrite/phase-1-plan` (directly, or transitively through prior stacked PRs). +- Subsequent PRs base on the previous PR's branch (stacked). - The PR description's *Stacks on* field names the base branch explicitly. -- After the prior PR merges, the next PR is rebased to land on `main` (or the new tip of the merged stack). +- When the prior PR in the stack merges into `cache-rewrite/phase-1-plan`, the next PR is rebased onto the new tip of `cache-rewrite/phase-1-plan` (which now contains the merged predecessor) and its `--base` is updated. +- **Plan revisions propagate.** If `cache-rewrite/phase-1-plan` is updated directly (a commit landing on it that revises any of the three design docs), every open stacked PR is rebased onto the new tip in stack order at the start of the next session. +- **`cache-rewrite/phase-1-plan` itself is only merged into `main` once all 31 implementation PRs are merged into it** — see §10 done conditions. ### Commit message convention @@ -183,8 +186,11 @@ The agent **stops and asks the reviewer** under any of the following conditions. ### Re-stack policy -- When PR-N merges, PR-(N+1) is rebased onto the new base. If conflicts are mechanical, resolve and force-push (this is acceptable for stacked PRs that have not yet received reviewer comments). If the rebased PR has reviewer comments, **do not force-push** without confirming with the reviewer. -- When `main` moves forward (unrelated changes from other contributors), the bottom of the stack is rebased and propagated up at the start of every session. +The base for the entire stack is the long-lived `cache-rewrite/phase-1-plan` branch. The stack rebases whenever its tip advances: + +- **When PR-N merges into `cache-rewrite/phase-1-plan`,** PR-(N+1) is rebased onto the new tip and its `--base` is updated to `cache-rewrite/phase-1-plan`. If conflicts are mechanical, resolve and force-push (this is acceptable for stacked PRs that have not yet received reviewer comments). If the rebased PR has reviewer comments, **do not force-push** without confirming with the reviewer. +- **When the plan branch itself is updated** (a commit landing directly on `cache-rewrite/phase-1-plan` to revise one of the three design docs), every open stacked PR is rebased onto the new tip in stack order at the start of the next session. +- **When `main` moves forward,** the plan branch is rebased onto `main` (since the plan branch is the long-lived feature base, it tracks `main`), and that rebase propagates up through every open stacked PR. - Stack health check at session start: any open PR more than 7 days old, or with merge conflicts, is flagged to the reviewer for triage. ## 8. The PR list @@ -197,7 +203,7 @@ The agent **stops and asks the reviewer** under any of the following conditions. | ID | Title | Status | Base | Est. LoC | Tests required | |---|---|---|---|---|---| -| PR-001 | docs(cache): ADR — major version bump rationale | ⬜ | `main` | ~150 | None (docs) | +| PR-001 | docs(cache): ADR — major version bump rationale | ⬜ | `cache-rewrite/phase-1-plan` | ~150 | None (docs) | | PR-002 | docs(cache): ADR — Record abstraction (field-aware via `CachedField`) | ⬜ | PR-001 | ~250 | None (docs) | | PR-003 | docs(cache): ADR — TTL semantics (tri-state, selection-set scoped, read-mode split) | ⬜ | PR-002 | ~300 | None (docs) | | PR-004 | docs(cache): ADR — Watcher × TTL (opt-in auto-refresh, permissive propagating reads) | ⬜ | PR-003 | ~250 | None (docs) | @@ -286,17 +292,19 @@ If any step reveals state that doesn't match the expected workflow (rogue branch ## 10. Done conditions +All "merged" references below mean **merged into `cache-rewrite/phase-1-plan`**, not into `main`. The plan branch itself is merged into `main` only after Phase 1 is fully complete (see "Phase 1 done" below). + ### Per-phase done -- **Phase 0 done:** PR-001 through PR-004 merged; both spike branches' findings captured in ADRs. -- **Phase 1A done:** PR-005 through PR-012 merged; 3.0-alpha tag pushed; performance gates green in CI. +- **Phase 0 done:** PR-001 through PR-004 merged into `cache-rewrite/phase-1-plan`; both spike branches' findings captured in ADRs. +- **Phase 1A done:** PR-005 through PR-012 merged; 3.0-alpha tag pushed from `cache-rewrite/phase-1-plan`; performance gates green in CI. - **Phase 1B done:** PR-013 through PR-019 merged; codegen produces `cacheControl:` on `Selection.Field`; all `TestCodeGenConfigurations` regenerated and pass. - **Phase 1C done:** PR-020 through PR-026 merged; TTL is enforced on strict reads; watcher uses permissive reads; `TTLTests.swift` covers every documented scenario. -- **Phase 1D done:** PR-027 through PR-031 merged; opt-in watcher refresh works; `InMemoryNormalizedCache` has TTL parity; migration guide live; 3.0-beta tag pushed; 1-week internal beta complete with zero P0/P1 issues. +- **Phase 1D done:** PR-027 through PR-031 merged; opt-in watcher refresh works; `InMemoryNormalizedCache` has TTL parity; migration guide live; 3.0-beta tag pushed from `cache-rewrite/phase-1-plan`; 1-week internal beta complete with zero P0/P1 issues. ### Phase 1 done -All 31 PRs merged. 3.0-beta tagged and announced. Migration guide validated by at least one external user. Engineering-plan §10 risks reviewed and either retired or escalated to Phase 2 planning. +All 31 PRs merged into `cache-rewrite/phase-1-plan`. Migration guide validated by at least one external user. Engineering-plan §10 risks reviewed and either retired or escalated to Phase 2 planning. **Then, and only then,** `cache-rewrite/phase-1-plan` is merged into `main`. The 3.0-beta tag is republished from `main` if needed; the 3.0 final tag is cut from `main` when the public beta cycle completes. ## 11. Living document From 5c0a186b62c99fd5aaa3f94de5185e99773b33b6 Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Thu, 7 May 2026 13:14:41 -0700 Subject: [PATCH 04/24] docs(cache): add Phase 1 performance measurement plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth Phase 1 design doc — cache-rewrite-phase1-perf.md — specifying the performance measurement methodology and the published comparison dataset that will accompany the 3.0-alpha tag. Covers four measurement tiers (end-to-end ApolloClient.fetch, NormalizedCache protocol level, raw SQLite, memory/CPU profiling) across synthetic, real-schema, and stress workloads. Methodology mirrors Zach's existing benchmark (50 iterations, mean/std-dev/P50/P95/P99, multi-device) so SQLite numbers can compare directly against the published Confluence baseline. Adds three new PRs to the execution plan: - PR-004a (Phase 0): capture 2.x performance baseline dataset - PR-011a (Phase 1A): comprehensive measurement harness for Tier 1+2 - PR-011b (Phase 1A): alpha-vs-2.x comparison reporter Updates Phase 1A exit criteria so the 3.0-alpha tag is gated not only on SQLite gates green, but also on no `regressed` verdict in the published dataset (or all such regressions explicitly accepted). Timeline impact: - Phase 0: 2 wk -> 3 wk (engineer + calendar) - Phase 1A: 4 wk -> 5 wk effort; 6-7 -> 7-8 calendar - Total Phase 1: 17 -> 19 engineer-weeks; 21-24 -> 23-26 calendar; with 25% contingency 27-30 -> 29-33 (~6.5-7.5 months) Companion plan, summary, and execution docs updated with cross-refs. PR count goes 31 -> 34. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../Design/cache-rewrite-phase1-execution.md | 24 +- .../Design/cache-rewrite-phase1-perf.md | 248 ++++++++++++++++++ .../Design/cache-rewrite-phase1-plan.md | 12 +- .../Design/cache-rewrite-phase1-summary.md | 17 +- 4 files changed, 278 insertions(+), 23 deletions(-) create mode 100644 apollo-ios/Design/cache-rewrite-phase1-perf.md diff --git a/apollo-ios/Design/cache-rewrite-phase1-execution.md b/apollo-ios/Design/cache-rewrite-phase1-execution.md index fa84bda31..a8209b8be 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-execution.md +++ b/apollo-ios/Design/cache-rewrite-phase1-execution.md @@ -4,6 +4,7 @@ **Companion documents:** - [cache-rewrite-phase1-summary.md](./cache-rewrite-phase1-summary.md) — manager-facing summary. - [cache-rewrite-phase1-plan.md](./cache-rewrite-phase1-plan.md) — engineering design (the **authoritative** spec for what is being built). +- [cache-rewrite-phase1-perf.md](./cache-rewrite-phase1-perf.md) — performance measurement plan. This document describes **how** the Phase 1 work is executed by an AI agent, broken into small reviewable PRs stacked on each other. The engineering plan describes what to build; this plan describes the workflow for shipping it. @@ -31,7 +32,7 @@ This is the operating manual for an AI agent (Claude Code) executing the Phase 1 ### Branch naming -- **Long-lived plan branch:** `cache-rewrite/phase-1-plan` — holds the three Phase 1 design docs (summary, plan, execution). **Not merged into `main` until every PR in the stack has been merged into it.** Treated as a long-running feature branch; serves as the base for the entire Phase 1 stack. +- **Long-lived plan branch:** `cache-rewrite/phase-1-plan` — holds the four Phase 1 design docs (summary, plan, execution, perf). **Not merged into `main` until every PR in the stack has been merged into it.** Treated as a long-running feature branch; serves as the base for the entire Phase 1 stack. - **Implementation PRs:** `cache-rewrite/phase-1<letter>-<NN>-<short-slug>`, where `<letter>` is the phase (`a`, `b`, `c`, `d`) and `<NN>` is the two-digit PR sequence within that phase. - Examples: `cache-rewrite/phase-1a-01-cached-field-type`, `cache-rewrite/phase-1b-03-bridge-max-age`, `cache-rewrite/phase-1c-07-ttl-tests`. - **ADR PRs (Phase 0):** `cache-rewrite/phase-0-adr-<slug>`. Example: `cache-rewrite/phase-0-adr-record-abstraction`. @@ -199,7 +200,7 @@ The base for the entire stack is the long-lived `cache-rewrite/phase-1-plan` bra **Status legend.** ⬜ not started · 🟦 in progress · 🟨 PR open · 🟩 merged. -### Phase 0 — Design lock and ADRs (4 PRs + 2 spike branches) +### Phase 0 — Design lock, ADRs, and performance baseline (5 PRs + 2 spike branches) | ID | Title | Status | Base | Est. LoC | Tests required | |---|---|---|---|---|---| @@ -207,6 +208,7 @@ The base for the entire stack is the long-lived `cache-rewrite/phase-1-plan` bra | PR-002 | docs(cache): ADR — Record abstraction (field-aware via `CachedField`) | ⬜ | PR-001 | ~250 | None (docs) | | PR-003 | docs(cache): ADR — TTL semantics (tri-state, selection-set scoped, read-mode split) | ⬜ | PR-002 | ~300 | None (docs) | | PR-004 | docs(cache): ADR — Watcher × TTL (opt-in auto-refresh, permissive propagating reads) | ⬜ | PR-003 | ~250 | None (docs) | +| PR-004a | chore(cache): capture 2.x performance baseline dataset | ⬜ | PR-004 | ~600 | Unit: harness scenarios run cleanly against 2.x; baseline JSON produced and committed | Phase 0 also produces two spike branches that are **not merged**: - `cache-rewrite/phase-0-spike-sqlite-bench` — micro-benchmark of the new schema on iPhone 16 Pro hardware, validates the §7.4 performance gates from the engineering plan. @@ -214,20 +216,22 @@ Phase 0 also produces two spike branches that are **not merged**: Findings from each spike are captured in their respective Phase 0 ADRs (PR-003 references the SQLite spike; the cachecontrol-jsdirective spike findings become a `cache-rewrite/phase-0-adr-cachecontrol-spike` PR if material surprises surface — otherwise findings live as a comment thread on the existing ADR). -### Phase 1A — SQLite schema rewrite + field-aware `Record` (8 PRs) +### Phase 1A — SQLite schema rewrite + field-aware `Record` (10 PRs) -Goal: ship 3.0-alpha at end of this phase. No behavior change for end users. +Goal: ship 3.0-alpha at end of this phase. No behavior change for end users. Published performance dataset accompanies the alpha tag. | ID | Title | Status | Base | Est. LoC | Tests required | |---|---|---|---|---|---| -| PR-005 | feat(cache): introduce `CachedField` type (no consumers yet) | ⬜ | PR-004 | ~80 | Unit: `CachedField` Hashable/Sendable/Equatable; round-trip with sample values | +| PR-005 | feat(cache): introduce `CachedField` type (no consumers yet) | ⬜ | PR-004a | ~80 | Unit: `CachedField` Hashable/Sendable/Equatable; round-trip with sample values | | PR-006 | refactor(cache): change `Record.fields` type to `[CacheKey: CachedField]` | ⬜ | PR-005 | ~400 | Update existing Record/RecordSet tests; verify `record[key]` subscript still returns `Value?` for all existing call sites | | PR-007 | feat(sqlite): add `schema_metadata` table and version detection | ⬜ | PR-006 | ~150 | Unit: schema-version read/write, missing-row defaults to 0, version stamping on init | | PR-008 | feat(sqlite): new schema DDL — records table with composite PK + typed columns | ⬜ | PR-007 | ~200 | Unit: table creation idempotent, `WITHOUT ROWID` preserved, schema_metadata version=3 stamped | | PR-009 | feat(sqlite): implement insert/select/update/delete on new table (feature-flagged) | ⬜ | PR-008 | ~600 | Unit: each operation against new schema; round-trip Record↔rows; transactional behavior on failure; performance smoke test | | PR-010 | feat(sqlite): switch `SQLiteNormalizedCache` to new schema; drop-and-rebuild migration | ⬜ | PR-009 | ~400 | Unit: migration on detected old schema; integration: existing cache tests pass on new schema; CachePersistenceTests updated | | PR-011 | test(cache): SQLite performance-gate harness on iPhone 16 Pro | ⬜ | PR-010 | ~200 | Performance test asserting all §7.4 gates within 25% margin | -| PR-012 | chore: tag 3.0-alpha; release notes; changelog | ⬜ | PR-011 | ~100 | Smoke test: clean install + first launch reads/writes a record | +| PR-011a | feat(cache): comprehensive performance measurement harness (Tier 1 + Tier 2) | ⬜ | PR-011 | ~700 | Unit: each Tier 1 and Tier 2 scenario runs cleanly; JSON exporter produces well-formed output; harness is re-runnable across versions | +| PR-011b | chore(cache): alpha-vs-2.x comparison reporter + published dataset | ⬜ | PR-011a | ~400 | Unit: reporter generates `cache-rewrite-phase1-perf-dataset.json` and `cache-rewrite-phase1-perf-report.md` from harness JSON inputs; verdict thresholds applied per perf plan §5.1 | +| PR-012 | chore: tag 3.0-alpha; release notes; changelog | ⬜ | PR-011b | ~100 | Smoke test: clean install + first launch reads/writes a record. **Tag is gated on:** SQLite performance gates green AND no `regressed` verdict in the published dataset (or all such regressions explicitly accepted by the reviewer with documented rationale). | ### Phase 1B — `@cacheControl` codegen end-to-end (7 PRs) @@ -271,8 +275,8 @@ Goal: ship 3.0-beta. Full feature visible to consumers. ### Total -- **31 PRs** across 4 phases. -- **~7,250 meaningful LoC** of change at midpoint estimates. +- **34 PRs** across 4 phases (Phase 0: 5; Phase 1A: 10; Phase 1B: 7; Phase 1C: 7; Phase 1D: 5). +- **~8,950 meaningful LoC** of change at midpoint estimates. - The estimates are guidance, not contracts. Splitting a PR is preferred to overrunning the §2 cap. ## 9. Session bootstrap @@ -296,8 +300,8 @@ All "merged" references below mean **merged into `cache-rewrite/phase-1-plan`**, ### Per-phase done -- **Phase 0 done:** PR-001 through PR-004 merged into `cache-rewrite/phase-1-plan`; both spike branches' findings captured in ADRs. -- **Phase 1A done:** PR-005 through PR-012 merged; 3.0-alpha tag pushed from `cache-rewrite/phase-1-plan`; performance gates green in CI. +- **Phase 0 done:** PR-001 through PR-004a merged into `cache-rewrite/phase-1-plan`; both spike branches' findings captured in ADRs; 2.x baseline performance dataset checked in. +- **Phase 1A done:** PR-005 through PR-012 merged; 3.0-alpha tag pushed from `cache-rewrite/phase-1-plan`; SQLite performance gates green in CI; published comparison dataset (`cache-rewrite-phase1-perf-dataset.json`) shows no `regressed` verdict for any Tier 1 or Tier 2 scenario, or all such regressions explicitly accepted with documented rationale. - **Phase 1B done:** PR-013 through PR-019 merged; codegen produces `cacheControl:` on `Selection.Field`; all `TestCodeGenConfigurations` regenerated and pass. - **Phase 1C done:** PR-020 through PR-026 merged; TTL is enforced on strict reads; watcher uses permissive reads; `TTLTests.swift` covers every documented scenario. - **Phase 1D done:** PR-027 through PR-031 merged; opt-in watcher refresh works; `InMemoryNormalizedCache` has TTL parity; migration guide live; 3.0-beta tag pushed from `cache-rewrite/phase-1-plan`; 1-week internal beta complete with zero P0/P1 issues. diff --git a/apollo-ios/Design/cache-rewrite-phase1-perf.md b/apollo-ios/Design/cache-rewrite-phase1-perf.md new file mode 100644 index 000000000..ab3e15bbb --- /dev/null +++ b/apollo-ios/Design/cache-rewrite-phase1-perf.md @@ -0,0 +1,248 @@ +# Cache Rewrite — Phase 1 Performance Measurement Plan + +**Audience:** The cache rewrite implementer; reviewers; future maintainers; customers evaluating the 3.0-alpha. +**Companion documents:** +- [cache-rewrite-phase1-summary.md](./cache-rewrite-phase1-summary.md) — manager-facing summary. +- [cache-rewrite-phase1-plan.md](./cache-rewrite-phase1-plan.md) — engineering design plan. +- [cache-rewrite-phase1-execution.md](./cache-rewrite-phase1-execution.md) — AI execution workflow with the PR list. + +This document specifies the performance measurement plan for the Phase 1 cache rewrite. The deliverable is a comprehensive comparison dataset between Apollo iOS 2.x (current `main`) and 3.0-alpha (end of Phase 1A) covering cache I/O, in-memory serialization, and GraphQL execution. The dataset is published alongside the 3.0-alpha release tag. + +## 1. Goals + +1. **Detect regression early.** Catch any operation that becomes meaningfully slower in 3.0 before customers do. The new SQLite schema is expected to be faster on most operations and equivalent on the rest (per Zach's benchmark); other layers must not regress measurably. +2. **Quantify expected improvements.** Where 3.0 is faster, publish the magnitude. Customers evaluating whether to upgrade need numbers, not adjectives. +3. **Establish a multi-version baseline.** The dataset format and tooling continue past 3.0 — Phase 2 features re-run the same harness and the comparison framework continues to apply. +4. **Produce a customer-shareable artifact.** The 3.0-alpha release notes link to a published dataset that any consumer can read to understand the perf change at upgrade time. + +The performance gates in the engineering plan §7.4 are pass/fail thresholds for SQLite operations. This plan is broader: it produces a *dataset*, not just a gate. The gates remain as CI-blocking assertions; the dataset is informational. + +## 2. Measurement tiers + +Three tiers organized by the surface being measured. + +### Tier 1 — End-to-end `ApolloClient.fetch` + +What customers actually feel. Drives a full operation through `ApolloClient.fetch(query:cachePolicy:)` with each cache policy and measures wall-clock latency from call to result delivery. + +| Scenario | Cache policy | Measures | +|---|---|---| +| Cold cache, network only | `.networkOnly` | Baseline: pure network + parse + normalize + write | +| Warm cache, cache-first hit | `.cacheFirst` | Pure cache read + executor + response model construction | +| Warm cache, cache-first miss falling back to network | `.cacheFirst` (after manual eviction of one field) | Cache read attempt + miss + network fallback | +| Cache and network | `.cacheAndNetwork` | Two deliveries; measures both | +| TTL-induced miss (3.0 only) | `.cacheFirst` with `@cacheControl(maxAge: 1)` query, sleep 2s | TTL strict-read path → network fallback | + +Public `ApolloClient` API is stable across 2.x and 3.0 for the first four scenarios. The fifth is 3.0-only by design. + +### Tier 2 — `NormalizedCache` protocol level + +The cache API surface that custom-cache implementors and direct cache consumers see. Exercises `loadRecords(forKeys:)`, `merge(records:)`, `removeRecord(for:)`, `removeRecords(matching:)`, and `clear()`. + +| Scenario | Workload | Measures | +|---|---|---| +| Single-key load | One record of 10 fields | `loadRecords` latency, deserialization cost | +| Batch load | 100 records loaded by key set | Batched load throughput | +| Single-record merge | Add 10 new fields to one record | `merge` latency, serialization cost | +| Many-record merge | 1,000 records into a fresh cache | Bulk-write throughput | +| Pattern delete | `removeRecords(matching: "User_")` against 10k records | Pattern-match and delete cost | + +Run against both `InMemoryNormalizedCache` and `SQLiteNormalizedCache`. + +### Tier 3 — SQLite raw operations + +The lowest tier: direct measurements of the new schema's performance characteristics. Uses Zach's existing benchmark methodology (500k-row dataset, 50 iterations per scenario, multiple devices) and the scenarios documented in the [SQLite Performance Benchmarks Confluence page](https://apollographql.atlassian.net/wiki/spaces/ClientDev/pages/1585152147). + +The 2.x baseline for this tier **is the existing Confluence benchmark.** We do not re-capture; we publish the new schema's numbers against the same scenarios and produce a delta. The existing methodology is the reference. + +| Scenario | Workload | Measures | +|---|---|---| +| Insert (single field) | Single row INSERT in transaction | Insert latency | +| Select by exact cache key | `SELECT * WHERE cache_key = ?` | Exact-key hit latency (target ≤ 0.2 ms) | +| Select with `LIKE` patterns | Mid/prefix/suffix `LIKE` queries | Pattern-match latency | +| Select by selection set | `IN (?, ?, …)` against multiple cache keys | Batched selection-set load (target ≤ 50 ms with `ORDER BY`) | +| Update by composite key | `UPDATE … WHERE cache_key = ? AND field_name = ?` | Field-level update latency | +| Sort by field (CTE join) | CTE join sorting by `mass` | Worst-case query latency (target ≤ 250 ms) | + +These mirror Zach's benchmark scenarios so the delta is directly meaningful. + +### Tier 4 — Memory and CPU profiling + +A separate, smaller tier focused on resource consumption rather than latency. Captured via Instruments / `xctrace`, not via XCTest performance tests. + +| Scenario | Tool | Measures | +|---|---|---| +| Steady-state cache after 10k operations | Instruments Allocations | Peak RSS; sustained heap; `CachedField` allocation count vs `Record` | +| Sustained read load | Time Profiler | CPU time per `loadRecords`, per executor pass | +| GraphQL executor with deeply-nested query (5 levels, 50 fields) | Time Profiler | Hot-path identification; comparison of executor cost between versions | + +Tier 4 produces qualitative findings rather than a numeric dataset row. Captured once near the end of Phase 1A; included in the published report as a brief commentary section. + +## 3. Workloads + +Three workload classes. Every tier runs scenarios from at least one class; some run all three. + +### 3.1 Synthetic micro-workloads + +Controlled record sizes, controlled query shapes. The scenarios from Tier 3 (matching Zach's benchmark) are all synthetic. + +- **Tiny:** 100 records × 5 fields each. +- **Small:** 1,000 records × 10 fields each. +- **Medium:** 10,000 records × 10 fields each. +- **Large:** 500,000 records × ~10 fields each (matches Zach). + +### 3.2 Real-schema workloads + +Drive the existing test schemas (`AnimalKingdomAPI`, `StarWarsAPI`, `GitHubAPI` — present in `Sources/`) with realistic queries. Records are sized as the schemas naturally produce them; query shapes match the test operations. + +This catches issues that synthetic workloads miss — heterogeneous record sizes, real selection-set patterns, type-system effects (interfaces, unions, fragments). + +### 3.3 Stress workloads + +Find the cliff edges: + +- **Wide records:** records with 100+ fields. Tests serialization cost scaling. +- **Deep query nesting:** queries 6+ levels deep, with fragments at each level. Tests executor scaling. +- **Hot-key contention:** repeated reads of the same record across concurrent tasks. Tests `AsyncReadWriteLock` overhead. + +Stress workloads are not part of the per-PR gate; they run once at end of Phase 1A and any regression discovered is filed against the alpha tag for triage before 3.0-beta. + +## 4. Methodology + +### 4.1 Iteration count and statistics + +- **50 iterations per scenario** (matching Zach's methodology). For each scenario, report mean, standard deviation, P50, P95, P99 latency. +- **Cold and warm cache states** measured separately. A cold scenario is preceded by `cache.clear()` and a fresh database file; a warm scenario is preceded by populating the cache to the workload's required state. +- **Test isolation.** Each scenario runs in a fresh test fixture; no cross-scenario state. + +### 4.2 Device matrix + +| Device | Purpose | +|---|---| +| **iPhone 16 Pro (physical)** | Primary gate device; matches Zach's benchmark. All performance gates assert against this device. | +| **iPhone 16 Pro Simulator** | CI-runnable; tracks device numbers approximately. Used in PR-merge gating where physical devices aren't available. | +| **iPhone SE (3rd gen) Simulator** | Older-device baseline. Catches regressions that only appear on slower hardware. | +| **macOS CLI (Mac mini M2 or equivalent)** | Fastest reference numbers; useful for development iteration. Not a gate. | + +The published dataset includes numbers from at least the first three. + +### 4.3 Tooling + +- **XCTest performance tests (`measure { … }`)** for Tier 1, 2, and 3 latency captures. Built-in, integrates with the test runners. Statistical reporting limited to mean and standard deviation; we extract richer percentiles by inspecting the iteration array directly via the `XCTPerformanceMetric` API. +- **`xctrace record`** for Tier 4 profiling. Captures `.trace` files; exported via `xctrace export`. +- **Custom JSON exporter** for cross-version comparison. Each test produces a JSON line with `{scenario, tier, device, version, mean_ms, std_ms, p50_ms, p95_ms, p99_ms, iteration_count, timestamp}`. The reporter aggregates lines into the published dataset. + +### 4.4 What we explicitly do not measure in Phase 1 + +- **Cold-launch cache initialization.** The drop-and-rebuild migration adds startup latency on the first 3.0 launch (one extra network round trip). This is documented behavior, not a regression to detect; not measured in this dataset. +- **Database file size on disk.** Zach's benchmark measured this; the 7% size delta between single-col and multi-col layouts is settled. Re-measurement is not informative. +- **Network costs.** Tier 1 uses a stubbed local network transport. Real network variability would dominate the signal; we measure the cache layer's contribution only. +- **Multi-process or multi-app cache sharing.** Phase 2+. + +## 5. Comparison and reporting + +### 5.1 The published dataset + +A single JSON file (`cache-rewrite-phase1-perf-dataset.json`) checked into `apollo-ios/Design/perf/` and linked from the 3.0-alpha release notes. Schema: + +```json +{ + "version": "3.0-alpha", + "captured_at": "<ISO8601 timestamp>", + "git_sha": "<sha of the alpha commit>", + "baseline_version": "2.x", + "baseline_git_sha": "<sha of the 2.x commit>", + "device": "<device descriptor>", + "results": [ + { + "tier": 1, + "scenario": "warm-cache-first-hit-ten-fields", + "version": "3.0-alpha", + "mean_ms": <number>, + "std_ms": <number>, + "p50_ms": <number>, + "p95_ms": <number>, + "p99_ms": <number>, + "iterations": 50, + "delta_vs_baseline_pct": <number>, + "verdict": "improved" | "parity" | "regressed" + }, + ... + ] +} +``` + +Verdict thresholds: +- **Improved:** mean is at least 5% faster than 2.x baseline. +- **Parity:** within ±5% of 2.x baseline. +- **Regressed:** at least 5% slower than 2.x baseline. + +A regressed verdict on any Tier 1 or Tier 2 scenario is a blocker for the 3.0-alpha tag; it must be either fixed or explicitly accepted by the reviewer with documented rationale before tagging. + +### 5.2 Published report + +A short markdown document (`cache-rewrite-phase1-perf-report.md`) accompanies the JSON dataset. Format: + +1. Executive summary table (one row per tier, with overall improved/parity/regressed counts). +2. Headline numbers (top 5 improvements, top 5 regressions or parity-edge cases). +3. Tier 4 (memory/CPU) commentary section. +4. Methodology section pointing back to this document. + +Both the JSON and the markdown are checked into the repo so reviewers can see exactly what's published. + +## 6. Schedule and execution-plan integration + +### 6.1 Phase 0 deliverables + +A new PR in Phase 0 captures the 2.x baseline: + +- **PR-004a** (new): `chore(cache): capture 2.x performance baseline dataset`. Builds the harness against the 2.x codebase, runs against `main` on the gate device, produces `apollo-ios/Design/perf/baseline-2.x.json`. Stacks on PR-004 (the last ADR). + +The harness code lives in a new directory `Tests/PerformanceBenchmarks/` outside the subtree directories so it is visible to dev-repo CI but not pushed upstream. + +Phase 0 calendar grows from 2 weeks to 3 weeks to absorb this work. Engineer-weeks: 2 → 3. + +### 6.2 Phase 1A deliverables + +Two new PRs after PR-011 (the existing SQLite perf gate): + +- **PR-011a** (new): `feat(cache): comprehensive performance measurement harness`. Implements Tier 1 and Tier 2 scenarios against the 3.0 codebase. Stacks on PR-011. +- **PR-011b** (new): `chore(cache): alpha-vs-2.x comparison reporter`. Generates `cache-rewrite-phase1-perf-dataset.json` and `cache-rewrite-phase1-perf-report.md` from the harness output and the 2.x baseline JSON. Stacks on PR-011a. + +Phase 1A engineer-weeks: 4 → 5; calendar: 6–7 weeks → 7–8 weeks. + +### 6.3 Phase 1A exit criterion update + +The existing alpha-shippability exit criterion in execution plan §8 is augmented: + +> *"…3.0-alpha tag is releasable from this milestone. The published performance dataset (`cache-rewrite-phase1-perf-dataset.json`) accompanies the tag. No Tier 1 or Tier 2 scenario shows a `regressed` verdict, or all such regressions are explicitly accepted with documented rationale."* + +### 6.4 Total Phase 1 timeline impact + +| Phase | Engineer-weeks (was → now) | Calendar (was → now) | +|---|---|---| +| 0 | 2 → 3 | 2 → 3 | +| 1A | 4 → 5 | 6–7 → 7–8 | +| 1B | 4 (unchanged) | 5–6 (unchanged) | +| 1C | 3 (unchanged) | 4 (unchanged) | +| 1D | 4 (unchanged) | 4–5 (unchanged) | +| **Total** | **17 → 19** | **21–24 → 23–26 (≈ 5.5–6 months)** | +| **With 25% contingency** | | **27–30 → 29–33 (≈ 6.5–7.5 months)** | + +Plan against 6.5–7.5 months calendar. + +## 7. Subsequent phases + +Phase 1B, 1C, and 1D do not add new measurement work. The harness from PR-011a is run again at the end of each phase; results are appended to the dataset under a new version label (`3.0-beta-pr-019`, `3.0-beta-pr-026`, `3.0-beta`). The reporter is regenerated at each milestone. + +If a phase introduces a new performance-relevant subsystem not covered by the existing scenarios — for example, the watcher's auto-refresh timer in Phase 1D adds new timing characteristics that aren't covered by Tier 1 scenarios — a small `feat(perf): add scenario for X` PR adds the scenario before the phase exits. These are anticipated to be small (~100 LoC each) and are not pre-allocated in the §8 PR list; they are added inline as discovered. + +## 8. Open questions + +These are flagged for Phase 0 design lock alongside the ones in [cache-rewrite-phase1-plan.md §12](./cache-rewrite-phase1-plan.md): + +1. **Where does the published dataset live?** Options: GitHub release notes attachment, dedicated `apollo-ios/Design/perf/` directory in the repo, Confluence page in ClientDev, or a combination. This document assumes the repo-resident option. +2. **Iteration count tradeoff.** 50 iterations matches Zach but doubles CI time when the harness lives in CI. Some scenarios may warrant fewer iterations (10–20) for the per-PR gate, with the full 50 reserved for the alpha-tag dataset. Confirm during Phase 0. +3. **Regression threshold.** ±5% may be too tight for some scenarios (especially Tier 1 where network stubbing adds variance) or too loose for others (Tier 3 single-row operations where 5% is meaningful). Per-tier thresholds may be more honest than a single 5% rule. +4. **Tier 4 inclusion criteria.** Memory and CPU profiling captures are qualitative; how prominent should they be in the published report? Brief commentary section (this document's current assumption) versus full second dataset. +5. **Real-device CI access.** PR-merge-gating tests need device access. Option: run on a self-hosted Mac runner with a tethered iPhone 16 Pro; option: run synthetic-only on Simulator in CI and take real-device numbers manually pre-tag. The latter is what the engineering plan §7.4 currently assumes. diff --git a/apollo-ios/Design/cache-rewrite-phase1-plan.md b/apollo-ios/Design/cache-rewrite-phase1-plan.md index 97bd42dcc..f2a638275 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-plan.md +++ b/apollo-ios/Design/cache-rewrite-phase1-plan.md @@ -321,7 +321,7 @@ The implementation must hit the following on iPhone 16 Pro hardware (drawn from | Sort by field (CTE join) | < 250 ms | | Insert one record (single field) | < 1 ms | -These are 25% looser than the benchmark's measured numbers, allowing for production-environment variability while still detecting regression. +These are 25% looser than the benchmark's measured numbers, allowing for production-environment variability while still detecting regression. They are pass/fail gates for SQLite operations only; the broader performance measurement and reporting work — covering in-memory cache I/O, `CachedField` serialization, GraphQL execution, and the alpha-vs-2.x comparison dataset — is specified in [cache-rewrite-phase1-perf.md](./cache-rewrite-phase1-perf.md). The 3.0-alpha tag (Phase 1A exit) is gated on both: SQLite gates green AND no regression verdict in the broader dataset. ## 8. Phased implementation @@ -432,15 +432,15 @@ Exit criteria: | Phase | Focus | Engineer-weeks | Calendar weeks | |---|---|---|---| -| 0 | Design lock + two de-risking spikes | 2 | 2 | -| 1A | SQLite schema rewrite + field-aware `Record` (3.0-alpha) | 4 | 6–7 | +| 0 | Design lock + de-risking spikes + 2.x perf baseline capture | 3 | 3 | +| 1A | SQLite schema rewrite + field-aware `Record` + alpha perf dataset (3.0-alpha) | 5 | 7–8 | | 1B | `@cacheControl` codegen end-to-end | 4 | 5–6 | | 1C | TTL evaluation + read-mode split | 3 | 4 | | 1D | Opt-in watcher refresh + hardening + 3.0-beta | 4 | 4–5 | -| **Total** | | **17** | **21–24 (≈ 5–6 months)** | -| **With 25% contingency** | | | **27–30 (≈ 6–7 months)** | +| **Total** | | **19** | **23–26 (≈ 5.5–6 months)** | +| **With 25% contingency** | | | **29–33 (≈ 6.5–7.5 months)** | -Plan against 6–7 months calendar. Two release tags ship from the plan: a 3.0-alpha at end of Phase 1A (storage refactor only, no behavioral change) and 3.0-beta at end of Phase 1D (full TTL feature). +Plan against 6.5–7.5 months calendar. Two release tags ship from the plan: a 3.0-alpha at end of Phase 1A (storage refactor only, no behavioral change, accompanied by the performance comparison dataset per [cache-rewrite-phase1-perf.md](./cache-rewrite-phase1-perf.md)) and 3.0-beta at end of Phase 1D (full TTL feature). ## 10. Risk register diff --git a/apollo-ios/Design/cache-rewrite-phase1-summary.md b/apollo-ios/Design/cache-rewrite-phase1-summary.md index 5b8cbb3b6..a99d5c299 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-summary.md +++ b/apollo-ios/Design/cache-rewrite-phase1-summary.md @@ -1,7 +1,10 @@ # Cache Rewrite — Phase 1 Summary **Audience:** Engineering management. -**Companion document:** [cache-rewrite-phase1-plan.md](./cache-rewrite-phase1-plan.md) — engineering design and implementation detail. +**Companion documents:** +- [cache-rewrite-phase1-plan.md](./cache-rewrite-phase1-plan.md) — engineering design and implementation detail. +- [cache-rewrite-phase1-perf.md](./cache-rewrite-phase1-perf.md) — performance measurement plan and published dataset specification. + **Source RFC:** [rfc-caching-rewrite.md](./rfc-caching-rewrite.md). ## TL;DR @@ -31,18 +34,18 @@ Phase 2 is unestimated and outside the scope of this plan. | Phase | Focus | Engineer-weeks | Calendar weeks | |---|---|---|---| -| **0** | Design lock + two de-risking spikes (SQLite, codegen) | 2 | 2 | -| **1A** | SQLite schema rewrite + field-aware `Record` (**3.0-alpha** ships from this milestone) | 4 | 6–7 | +| **0** | Design lock + two de-risking spikes + 2.x perf baseline capture | 3 | 3 | +| **1A** | SQLite schema rewrite + field-aware `Record` + alpha perf dataset (**3.0-alpha** ships from this milestone) | 5 | 7–8 | | **1B** | `@cacheControl` codegen end-to-end | 4 | 5–6 | | **1C** | TTL evaluation + read-mode split | 3 | 4 | | **1D** | Opt-in watcher refresh + hardening + **3.0-beta** | 4 | 4–5 | -| **Total effort** | | ~17 | | -| **Calendar (with reviews and interruptions)** | | | **21–24 (≈ 5–6 months)** | -| **Calendar with 25% contingency** | | | **27–30 (≈ 6–7 months)** | +| **Total effort** | | ~19 | | +| **Calendar (with reviews and interruptions)** | | | **23–26 (≈ 5.5–6 months)** | +| **Calendar with 25% contingency** | | | **29–33 (≈ 6.5–7.5 months)** | The 25% contingency is the number to plan against. This subsystem has deep coupling between the runtime, the codegen frontend, and roughly 8,400 lines of cache-related test code; estimates without contingency consistently underrun in projects of this shape. -**Two release tags ship from the plan.** A 3.0-alpha lands at the end of Phase 1A — storage refactor only, no behavior change for end users. This validates the riskiest piece of the project (new SQLite schema, drop-and-rebuild migration, public `Record` type change) in production-like conditions before TTL behavior is layered on top. A 3.0-beta lands at the end of Phase 1D with the full `@cacheControl` feature. +**Two release tags ship from the plan.** A 3.0-alpha lands at the end of Phase 1A — storage refactor only, no behavior change for end users — accompanied by a published performance comparison dataset against 2.x covering cache I/O, in-memory serialization, and GraphQL execution (see [cache-rewrite-phase1-perf.md](./cache-rewrite-phase1-perf.md)). This validates the riskiest piece of the project (new SQLite schema, drop-and-rebuild migration, public `Record` type change) in production-like conditions before TTL behavior is layered on top, and gives customers concrete numbers to evaluate at upgrade time. A 3.0-beta lands at the end of Phase 1D with the full `@cacheControl` feature. ## Top risks From b4f01e169858d963ba78aeda515e162f6f1954ba Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Thu, 7 May 2026 13:25:05 -0700 Subject: [PATCH 05/24] =?UTF-8?q?docs(cache):=20split=20merge=20cadence=20?= =?UTF-8?q?=E2=80=94=20ADR=20PRs=20incremental,=20code=20PRs=20batched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update execution plan §2 and §7 to remove the blanket "max 2 stacked unmerged PRs" cap and replace it with a PR-type-aware policy: - ADR PRs (Phase 0 docs) merge into cache-rewrite/phase-1-plan as they are approved. One or two sit in active review at any time. - Code PRs (Phase 1A through 1D) stay open as a long-running stack with no preset cap; the reviewer batches them at end-of-phase or as convenient. The agent pauses only on explicit reviewer instruction, §6 escalation triggers, or genuine state dependencies between in-flight PRs. The agent does not unilaterally merge any PR — merges remain reviewer- initiated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apollo-ios/Design/cache-rewrite-phase1-execution.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apollo-ios/Design/cache-rewrite-phase1-execution.md b/apollo-ios/Design/cache-rewrite-phase1-execution.md index a8209b8be..ac3a98522 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-execution.md +++ b/apollo-ios/Design/cache-rewrite-phase1-execution.md @@ -20,7 +20,11 @@ This is the operating manual for an AI agent (Claude Code) executing the Phase 1 ## 2. Operating principles -1. **One PR at a time per active task.** Maximum **2 stacked unmerged PRs** in flight at once. If the reviewer falls behind, queue work — do not pile on more open PRs. +1. **Merge cadence depends on PR type.** + - **ADR PRs (Phase 0 documentation work)** merge into `cache-rewrite/phase-1-plan` as they are approved. Reviewer typically approves and merges in the same session; only one or two sit in active review at any time. + - **Code PRs (Phase 1A through 1D implementation work)** stay open as a long-running stack. The reviewer batches them — typically merging at end-of-phase boundaries or as convenient. There is no preset cap on the number of open code PRs; the agent does not pause work on PR-N because PR-(N-1) is unmerged. The reviewer signals when to slow down or pause if the stack grows beyond comfortable review capacity. + + The agent does pause when the reviewer explicitly says so, when one of the §6 escalation triggers fires, or when work on the next PR would require state from a still-changing earlier PR (in practice rare, since PRs are designed to be independent within their stacking constraints). 2. **Target diff size: ~300–500 LoC of meaningful change.** Generated code, snapshot data, and trivial test boilerplate don't count toward this. PRs over 600 meaningful LoC must be split unless explicitly approved by the reviewer. 3. **Each PR is self-contained.** It builds, all tests pass, and it is mergeable in isolation against its base branch (which may be `main` or a prior PR's branch in the stack). 4. **No scope creep.** A PR does exactly what its title and acceptance criteria say. Anything else — even an obvious cleanup or fix — is escalated as a follow-up task; do not silently include it. @@ -181,8 +185,8 @@ The agent **stops and asks the reviewer** under any of the following conditions. ### Cadence expectations -- The agent does not block on review beyond the §2 stacked-PR limit. If 2 PRs are open and unmerged, the agent waits. -- The agent does not work ahead of the reviewer's pace by more than 2 PRs. +- The agent does not block on review except when the reviewer explicitly says to pause, when a §6 escalation trigger fires, or when work on the next PR genuinely depends on the state of a still-in-review earlier PR (rare). +- ADR PRs merge into the plan branch promptly per §2. Code PRs accumulate as a stack until the reviewer chooses to merge them in batch. The agent does not unilaterally merge any PR. - If review feedback is requested on an earlier PR while a later PR is open, the earlier PR is addressed first. ### Re-stack policy From 50e697e3670951f4c1f3026bd0e400822790afb8 Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Thu, 7 May 2026 13:25:57 -0700 Subject: [PATCH 06/24] =?UTF-8?q?docs(cache):=20ADR=200001=20=E2=80=94=20m?= =?UTF-8?q?ajor=20version=20bump=20rationale=20(#968)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apollo iOS 3.0 ships the Phase 1 cache rewrite as a coordinated breaking release: SQLite schema change, public Record API change, generated code shape change, and TTL evaluation behavior. Captures four rejected alternatives (2.x feature flag, staged minor releases, parallel cache module, hold-for-Phase-2) and the positive/negative consequences. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../Design/adr/0001-major-version-bump.md | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 apollo-ios/Design/adr/0001-major-version-bump.md diff --git a/apollo-ios/Design/adr/0001-major-version-bump.md b/apollo-ios/Design/adr/0001-major-version-bump.md new file mode 100644 index 000000000..b90ec57ce --- /dev/null +++ b/apollo-ios/Design/adr/0001-major-version-bump.md @@ -0,0 +1,79 @@ +# ADR 0001 — Apollo iOS 3.0: Major version bump for the cache rewrite + +- **Status:** Accepted +- **Date:** 2026-05-07 +- **Phase 1 PR:** PR-001 (cache rewrite execution plan §8) +- **Engineering plan reference:** [cache-rewrite-phase1-plan.md §3.1](../cache-rewrite-phase1-plan.md) + +## Context + +Phase 1 of the cache rewrite introduces multiple breaking changes that must ship coordinated in a single release. They are not independent of each other and cannot be delivered piecemeal under semantic versioning rules: + +1. **SQLite schema change.** The on-disk records table moves from a single `(_id, key, record TEXT)` JSON-blob layout to a row-per-field layout: `WITHOUT ROWID` table with composite primary key `(cache_key, field_name)` and per-type value columns. Existing 2.x cache databases on disk are not readable by the new schema; on first launch the cache is dropped and rebuilt from the network. This is a behavior change visible to end users (one extra network round trip on upgrade day) even if it is not a compile-time API change. +2. **Public `Record` API change.** `Record.fields` changes type from `[CacheKey: any Hashable & Sendable]` to `[CacheKey: CachedField]`, where `CachedField` carries the field value plus its `writtenAt` epoch timestamp. The `record[key]` subscript stays backward-compatible (returns the unwrapped value), so the executor and most consumers do not change. However, any code that iterates `record.fields` directly — including custom `NormalizedCache` implementations in user codebases — must update. +3. **Generated code shape change.** The codegen frontend (`apollo-ios-codegen`) gains `@cacheControl(maxAge:)` directive support. Generated `Selection.Field` declarations may carry a new `cacheControl:` parameter. Generated code from the 3.0 codegen will not compile against the 2.x runtime (the `Selection.Field` initializer it expects to call does not exist in 2.x). This is a hard-coupled break between codegen and runtime. +4. **TTL evaluation behavior.** Cache reads under 3.0 may return cache miss for fields whose `@cacheControl` TTL has elapsed; under 2.x, no such miss path exists. While only schemas that adopt the new directive observe this behavior change, the change in semantics is real and warrants a major version. The watcher × TTL interaction (a new opt-in `automaticallyRefreshOnExpiry` flag, and the strict-vs-permissive read mode split for watcher re-reads on `didChangeKeys`) is part of this same TTL feature surface and is covered separately by ADR 0004; it does not constitute an independent breaking change because the permissive read path bypasses only TTL-induced misses, while genuine missing-value misses still throw and still trigger `cacheReadFailed` → network refetch — preserving the existing 2.x revalidation behavior for any consumer who has not adopted `@cacheControl` directives. + +Each of these changes is locked into the design (see decisions 3.1–3.5 in the engineering plan). The question for this ADR is the **release vehicle**: a major version bump on Apollo iOS, a feature-flagged dual-stack on 2.x, or a long deprecation cycle across multiple 2.x minors. + +## Decision + +**Apollo iOS 3.0 ships the Phase 1 cache rewrite as a coordinated breaking release.** A single major version bump delivers all four breaking changes in lockstep. There is no feature flag, no parallel codepath, and no dual-stack. Two pre-release tags are cut from the long-lived plan branch en route — 3.0-alpha at the end of Phase 1A (storage refactor only, no observable behavior change for end users) and 3.0-beta at the end of Phase 1D (full feature). The 3.0 final tag is cut from `main` after the long-lived plan branch merges and the public beta cycle completes. + +Apollo iOS 2.x enters maintenance mode upon 3.0's general availability. Critical fixes will continue on 2.x for a defined period; new features land only on 3.0. + +## Alternatives considered + +### A. Feature flag on 2.x + +Add the new SQLite schema, `@cacheControl` directive, and TTL evaluation to 2.x behind a runtime feature flag. Customers opt in by enabling the flag; the legacy code path remains the default. + +- *Rejected because:* The breaking surface is too broad to dual-maintain. Five distinct subsystems (SQLite, the public `Record` type, codegen frontend, runtime executor, watcher) would each need a dual-mode implementation. The codegen change is not feature-flaggable in any meaningful sense — generated code either emits the new metadata or it does not, and a single 2.x codegen cannot do both. Test surface roughly doubles. Maintenance burden compounds with every subsequent change to 2.x. The cumulative engineering cost exceeds the cost of the major bump itself, and the user-visible benefit (avoiding a major version label) is small. + +### B. Staged minor releases with deprecation cycles + +Spread the breaking changes across a sequence of 2.x minors, each deprecating one piece of API and offering a migration path before the next minor removes it. The ultimate 3.0 release would then be a no-op cleanup release that simply drops the deprecations. + +- *Rejected because:* The cache shape changes are not independently deliverable. The new SQLite schema, the `CachedField` type, and per-field `writtenAt` are all required for TTL evaluation; you cannot ship the SQLite work without the `Record` change without producing an incoherent middle state. Likewise, `@cacheControl` codegen and runtime TTL enforcement are coupled — generating metadata that the runtime ignores is a confusing intermediate state to ship. Staging would force artificial separation that produces unreviewable, unshippable middle releases. The deprecation-cycle approach works for narrow API renames; it does not work for coordinated subsystem replacement. + +### C. Parallel cache module on 2.x (e.g., `ApolloCache2`) + +Introduce a new module (`ApolloCache2` or similar) carrying the new cache types alongside the existing `ApolloSQLite` and `ApolloCaching`. Customers migrate by importing the new module and switching their `ApolloClient` configuration. + +- *Rejected because:* This is a feature flag with extra ceremony. Same dual-stack maintenance burden as Option A, additional surface area for the public module split, and a permanent forked codebase even after migration is complete. It also does not address the codegen-to-runtime coupling — the codegen would still need to choose which module's types to emit references against, which is functionally a feature flag. + +### D. Defer parts of Phase 1 (storage now, TTL later) + +Ship the SQLite/`Record` storage refactor as Apollo iOS 3.0, then ship the `@cacheControl` directive and TTL evaluation as a 3.1 or 4.0 release. + +- *Rejected because:* This *is* the plan internally — Phase 1A produces a 3.0-alpha that is the storage refactor in isolation, and Phase 1D produces 3.0-beta with the full feature. Splitting them into separate major (or minor) releases would force two migration cycles on customers within months of each other, when the design plan already validates that the same customers can absorb the full Phase 1 scope as a single 3.0. The internal phasing achieves the de-risking benefit without the externally-visible cost of two breaking releases. + +### E. Hold all of it until Phase 2 is also designed + +Combine Phase 1 and Phase 2 (cascading deletes, eviction, `ChainedNormalizedCache`) into a single 3.0 release. + +- *Rejected because:* Phase 2 is unestimated and depends on Phase 1 landing first. Holding 3.0 hostage to Phase 2 design extends the Phase 1 timeline unboundedly. The opt-in TTL feature delivers value to customers who want it; Phase 2 features deliver additional value but are not prerequisites for the Phase 1 features to be useful. + +## Consequences + +### Positive + +- **Atomic migration story.** Customers upgrade once, regenerate code once, write one round of migration changes, and land on a coherent 3.0 surface. +- **Engineering simplicity.** No feature flags to maintain, no dual codepaths to test, no codegen mode-switching. The 3.0 codebase is the only codebase under active development. +- **Clear semver signal.** Apollo iOS 3.0 communicates "breaking changes; consult migration guide" through a channel customers already understand. +- **Internal phasing intact.** The 3.0-alpha milestone (after Phase 1A) still de-risks the SQLite/`Record` work in production-like conditions before TTL behavior is committed; the alpha is shippable and observable without committing customers to the directive feature. + +### Negative + +- **2.x maintenance burden during the transition.** Until 3.0 reaches general availability, critical fixes may need to be backported. Mitigation: define a 2.x maintenance-window policy in the migration guide (e.g., 6 months of critical fixes after 3.0 GA). +- **Day-one network load on upgrade.** The drop-and-rebuild cache migration causes one extra round of network fetches for cached queries on first 3.0 launch. Acceptable per ADR 0003 (TTL semantics) and engineering plan §3.3, but explicitly called out in the migration guide. +- **Coordinated PR planning required.** The 31-PR Phase 1 stack must land coherently on the long-lived plan branch before any of it reaches `main`. Mitigation: the execution plan §3 documents the long-lived plan-branch workflow; the `cache-rewrite/phase-1-plan` branch holds all PRs until the stack is complete. +- **Existing 2.x users with custom `NormalizedCache` implementations must update on upgrade.** The `Record.fields` type change is the most likely public-API break encountered. Mitigation: a deprecation-period accessor (`var fieldsLegacy: [CacheKey: Value]` on 3.x for one minor cycle) is offered as a fallback if a non-trivial population of custom-cache users surfaces during the beta cycle. See engineering plan §12 open question 5. + +## References + +- [Engineering plan §3.1](../cache-rewrite-phase1-plan.md) — *Major version bump (3.0)* +- [Engineering plan §3.2](../cache-rewrite-phase1-plan.md) — `Record` becomes field-aware (covered separately in ADR 0002) +- [RFC: cache rewrite](../rfc-caching-rewrite.md) — original RFC on the `design/rfc-caching` branch +- [Execution plan §8](../cache-rewrite-phase1-execution.md) — PR-001 entry +- [Apollo iOS 2.0 release](https://github.com/apollographql/apollo-ios-dev/pull/780) — most recent prior major version, for reference on the migration-guide template and maintenance-window precedent From 6df7621a6ef2fdbf3bdb058fd2350dfe5d62aee8 Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Thu, 7 May 2026 13:27:32 -0700 Subject: [PATCH 07/24] =?UTF-8?q?docs(cache):=20ADR=200002=20=E2=80=94=20R?= =?UTF-8?q?ecord=20abstraction=20(field-aware=20via=20CachedField)=20(#969?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record.fields changes type from [CacheKey: any Hashable & Sendable] to [CacheKey: CachedField], where CachedField carries the field's value alongside its writtenAt epoch timestamp. The record[key] subscript stays backward-compatible (returns Value? by unwrapping .value), so the executor and most consumers don't change. The breaking surface is restricted to code that iterates record.fields directly. Captures three rejected alternatives (parallel side-channel, lazy proxy) and the positive/negative/neutral consequences — including the Hashable/Equatable semantics change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../Design/adr/0002-record-abstraction.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 apollo-ios/Design/adr/0002-record-abstraction.md diff --git a/apollo-ios/Design/adr/0002-record-abstraction.md b/apollo-ios/Design/adr/0002-record-abstraction.md new file mode 100644 index 000000000..5780c7618 --- /dev/null +++ b/apollo-ios/Design/adr/0002-record-abstraction.md @@ -0,0 +1,111 @@ +# ADR 0002 — Record abstraction: field-aware via `CachedField` + +- **Status:** Accepted +- **Date:** 2026-05-07 +- **Phase 1 PR:** PR-002 (cache rewrite execution plan §8) +- **Engineering plan reference:** [cache-rewrite-phase1-plan.md §3.2](../cache-rewrite-phase1-plan.md) + +## Context + +The Phase 1 cache rewrite introduces per-field metadata that did not exist in 2.x: + +- **`writtenAt` (epoch seconds).** Required for TTL evaluation. The cache miss path under `@cacheControl(maxAge:)` consults `writtenAt + maxAge < now`; this requires that every cached field carry its individual write timestamp. +- **Other metadata in the foreseeable Phase 2 future.** Phase 2 adds LRU eviction (needs `lastAccessedAt` per field), `@onDelete` cascading (needs to know which fields are reference-typed and what they point at), and faceted searching (wants typed value columns at the storage layer reflected in the in-memory shape). + +The current 2.x `Record` is a thin wrapper around a key plus an untyped field dictionary: + +```swift +public struct Record: Sendable, Hashable { + public let key: CacheKey + public typealias Value = any Hashable & Sendable + public typealias Fields = [CacheKey: Value] + public private(set) var fields: Fields + + public subscript(key: CacheKey) -> Value? { + get { fields[key] } + set { fields[key] = newValue } + } +} +``` + +There is no place on a `Record` for per-field metadata. The TTL feature, the SQLite layer (row-per-field with per-row `written_at`), and the executor that reads cached values all need to agree on where this metadata lives. This ADR captures that decision. + +The decision is independent of, and prior to, the SQLite schema decision (engineering plan §7) — the in-memory model must support per-field metadata regardless of how it is persisted on disk. However, the choice does have implications for round-trip cost between the in-memory model and the SQLite layer, and those implications are part of the comparison below. + +## Decision + +`Record.fields` changes type from `[CacheKey: Value]` to `[CacheKey: CachedField]`. A new `CachedField` value type carries the field's value alongside its `writtenAt` epoch timestamp. The existing `record[key]` subscript is preserved with backward-compatible semantics — it returns `Value?` by unwrapping `.value` from the underlying `CachedField`. A new `cachedField(for:)` accessor is added for callers that need the metadata. + +```swift +public struct CachedField: Sendable, Hashable { + public let value: Value // any Hashable & Sendable + public let writtenAt: Int64 // epoch seconds + // Future Phase 2: lastAccessedAt for LRU; parent refs for @onDelete; etc. +} + +public struct Record: Sendable, Hashable { + public let key: CacheKey + public typealias Value = any Hashable & Sendable + public typealias Fields = [CacheKey: CachedField] + public private(set) var fields: Fields + + // Backward-compatible read of the value alone. + public subscript(key: CacheKey) -> Value? { fields[key]?.value } + + // New API for metadata-aware access. + public func cachedField(for key: CacheKey) -> CachedField? { fields[key] } +} +``` + +## Alternatives considered + +### A. Keep `Record` value-only; store TTL state in a parallel side-channel + +Leave `Record.fields` as `[CacheKey: Value]`. Maintain a separate `[CacheKey: [CacheKey: Int64]]` (or similar) on the cache itself, mapping `(recordKey, fieldName) → writtenAt`. The executor consults this side dictionary at TTL check time. + +- *Rejected because:* Two parallel data structures must be kept in sync at every cache write. Every code path that constructs or merges a `Record` must also remember to update the timestamp dict. Forgetting to update the dict in even one path is a silent bug — TTL evaluation just returns wrong answers for fields written through that path. Additionally, the side-channel approach does not extend cleanly to Phase 2 metadata: each new piece of per-field state (`lastAccessedAt` for LRU, parent refs for `@onDelete`) becomes another parallel structure with the same drift risk. Over the lifetime of the cache subsystem this approach accumulates bug surface much faster than Option B. + +### B. `Record.fields` carries `CachedField` (chosen) + +The selected option, described above. Each field's metadata travels with its value as a single struct. + +- *Selected because:* + 1. **Single source of truth per field.** The value and its metadata are inseparable. Every code path that touches a field gets — and writes — both at once. There is no "did I remember to update the parallel dict?" failure mode. + 2. **One-to-one with the SQLite row layout.** The new schema (engineering plan §7) is row-per-field with per-row `written_at`. Decoding a row produces a `CachedField` directly; encoding a `CachedField` produces a row. No reshape is required at the storage boundary. + 3. **Backward-compatible at the most-used surface.** The `record[key]` subscript returns `Value?` exactly as it does in 2.x. The executor in [CacheDataExecutionSource.swift](../../Sources/Apollo/Execution/ExecutionSources/CacheDataExecutionSource.swift) — which uses only the subscript — needs no changes. The breaking surface is restricted to direct iteration of `record.fields`. + 4. **Phase 2 feature growth has a home.** `lastAccessedAt`, `parentReferences`, typed-value optimizations, etc., become fields on `CachedField`. Each is added once; the rest of the system inherits the new metadata transparently. + +### C. Lazy field loading via cache-handle proxy + +`Record` becomes an opaque handle holding a back-reference to the underlying cache. Field reads go through the handle, which resolves to the cache row on demand. Each field's metadata is consulted at the storage layer at read time. + +- *Rejected because:* This is a substantially more invasive change to the executor's contract. The current execution model assumes a `Record` is a fully-materialized snapshot; introducing lazy-loading semantics breaks that invariant in ways that would force changes throughout `CacheDataExecutionSource`, `GraphQLExecutor`, the result accumulators, and likely the transaction model in `ApolloStore.ReadTransaction`. The performance argument is also weak: the new SQLite schema's exact-key lookup is 0.08 ms for the entire record (per Zach's benchmark), so eager materialization is not a measurable cost. Lazy loading might be reconsidered in Phase 2+ as a memory-pressure optimization for very large records, but is not warranted for Phase 1. + +## Consequences + +### Positive + +- **Executor unchanged.** The `record[key]` subscript continues to return `Value?`, so [CacheDataExecutionSource.swift](../../Sources/Apollo/Execution/ExecutionSources/CacheDataExecutionSource.swift) and the rest of the cache read path require no modification beyond the new TTL check itself (added in Phase 1C). +- **Storage layer round-trips are type-direct.** SQLite rows decode to `CachedField` values; encoding the reverse direction. No intermediate reshape, no adapter layer. +- **Phase 2 metadata has a clean home.** Each new piece of per-field state is added to `CachedField` exactly once. LRU eviction, cascading deletes, and faceted-search typed columns each pay only their own implementation cost — not an additional integration cost in twenty call sites. +- **Failure mode for adding metadata is loud, not silent.** A new `CachedField` field is either set everywhere (compiler-enforced when made non-optional) or visibly nil at sites that need to update. The side-channel alternative's silent-drop failure mode is eliminated. + +### Negative + +- **`Record.fields` declared type changes.** Code that iterates `record.fields` directly — to build a list of all field names, to inspect every value, etc. — must update from `[CacheKey: Value]` to `[CacheKey: CachedField]`. This is a public-API break for custom `NormalizedCache` implementations and for any user code that introspects records. +- **Bounded internal call-site update.** Approximately 10 sites in the runtime construct or read `Record.fields` directly (the result normalizer, the SQLite serialization layer, the in-memory cache, a few tests). Approximately 4 sites in the test suite read `record.fields` directly. All update mechanically; the change is not subtle. +- **Migration note required for custom-cache implementors.** Authors of custom `NormalizedCache` implementations (the public protocol contract) must update any direct-`fields` access. The migration guide will include a one-paragraph note. If a non-trivial population of custom-cache users surfaces during the 3.0-beta cycle, a one-cycle deprecation accessor (`var fieldsLegacy: [CacheKey: Value] { fields.mapValues(\.value) }`) is offered as a fallback per engineering plan §12 open question 5. +- **`CachedField` adds one indirection level.** A read of `record.fields["foo"]` produces a `CachedField?` rather than `Value?`. Code that wants the value still has to write `.value` on the result. This is a minor ergonomic cost and is mitigated by the subscript shortcut for the common case. + +### Neutral + +- **Equality and hashing semantics.** `Record`'s `Hashable` and `Equatable` conformances now hash and compare `CachedField` rather than `Value`. Two records with the same key and same field values but different `writtenAt` timestamps will compare as unequal. This is the correct semantics for cache invalidation purposes (a re-write at a later time produces a "different" record from the perspective of write-detection logic), but it is a change from 2.x and will be documented in the migration guide. + +## References + +- [Engineering plan §3.2](../cache-rewrite-phase1-plan.md) — *Record becomes field-aware* +- [Engineering plan §7](../cache-rewrite-phase1-plan.md) — SQLite schema (the row-per-field layout that this in-memory model mirrors) +- [Engineering plan §12 open question 5](../cache-rewrite-phase1-plan.md) — *Public-API freeze on Record* (the contingent `fieldsLegacy` accessor) +- [ADR 0001 — Major version bump](./0001-major-version-bump.md) — context for why a public-API break in `Record.fields` is acceptable in 3.0 +- [Record.swift](../../Sources/Apollo/Caching/Record.swift) — current 2.x implementation +- [CacheDataExecutionSource.swift](../../Sources/Apollo/Execution/ExecutionSources/CacheDataExecutionSource.swift) — primary executor consumer of the subscript (unchanged by this ADR) From 6c8ac5faee161b9743ffbcce9015f8c50238f495 Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Thu, 7 May 2026 13:28:09 -0700 Subject: [PATCH 08/24] =?UTF-8?q?docs(cache):=20ADR=200003=20=E2=80=94=20T?= =?UTF-8?q?TL=20semantics=20(tri-state,=20selection-set=20scope,=20read-mo?= =?UTF-8?q?de=20split)=20(#970)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three interlocking decisions defining the runtime behavior of @cacheControl: tri-state maxAge (nil / 0 / N>0; bare @cacheControl is a codegen error); selection-set-scoped TTL (any expired field in the current query → whole-query miss; fields outside the selection ignored); strict-vs-permissive read-mode split on ApolloStore.load (strict enforces TTL on consumer-initiated reads, permissive bypasses TTL only — not genuine missing-value errors — for watcher re-reads on didChangeKeys). Captures six rejected alternatives across the three sub-decisions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apollo-ios/Design/adr/0003-ttl-semantics.md | 183 ++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 apollo-ios/Design/adr/0003-ttl-semantics.md diff --git a/apollo-ios/Design/adr/0003-ttl-semantics.md b/apollo-ios/Design/adr/0003-ttl-semantics.md new file mode 100644 index 000000000..8c9046fa1 --- /dev/null +++ b/apollo-ios/Design/adr/0003-ttl-semantics.md @@ -0,0 +1,183 @@ +# ADR 0003 — TTL semantics: tri-state `maxAge`, selection-set scope, read-mode split + +- **Status:** Accepted +- **Date:** 2026-05-07 +- **Phase 1 PR:** PR-003 (cache rewrite execution plan §8) +- **Engineering plan reference:** [cache-rewrite-phase1-plan.md §3.4, §3.5, §4, §5](../cache-rewrite-phase1-plan.md) + +## Context + +Phase 1 introduces per-field Time-to-Live via the new `@cacheControl(maxAge:)` directive (engineering plan §3.5; sample resolution rules in [Samples/cache-control-samples.md](../Samples/cache-control-samples.md)). The directive itself defines what schema authors and operation authors can write; the runtime semantics — what those values *do* when the cache is read — are a separate set of decisions, captured in this ADR. + +Three sub-decisions are bundled together because they are interlocking: each one would be incoherent in isolation. They are presented in §1.1, §1.2, and §1.3 below. + +The Apollo cache spec ([specs.apollo.dev/cache/v0.2](https://specs.apollo.dev/cache/v0.2/)) is silent on the meaning of `maxAge: 0` and on what happens when no `@cacheControl` directive is present. Apollo iOS therefore defines its own runtime semantics for these cases. The Apollo Server documentation referenced by the spec was inaccessible in the relevant section at the time of writing, so the spec's silence is taken at face value. + +### 1.1 What does `maxAge` mean? + +Three plausible meanings for `maxAge` need disambiguation: + +- **No directive applied to the field at all.** Could mean "do not cache" or "cache forever". +- **`@cacheControl(maxAge: 0)`.** Could mean "always force a refetch" or "no TTL applied" (same as no directive). +- **`@cacheControl(maxAge: N)` for `N > 0`.** Means "valid for N seconds after `writtenAt`". This is unambiguous in the spec. + +A fourth case — `@cacheControl` with no `maxAge` argument — is technically legal syntactically but semantically ambiguous and must be resolved. + +### 1.2 What is the *scope* of an expiry check? + +When a query is executed against the cache, fields it selects may have different TTL states. Four possible interpretations: + +- **Whole-record:** if any field anywhere on the record has expired, the record is considered expired and any query reading it misses. +- **Selection-set:** if any field that the *current query selects* has expired, the query misses; fields not in the selection are ignored. +- **Per-field:** the executor returns a partial result, mixing fresh values with stale values per field. +- **Per-field with revalidation hint:** like per-field, but expired fields are flagged so the caller can choose to refetch. + +### 1.3 When should TTL be enforced? + +The cache is read from multiple call sites with different intents: + +- **Explicit fetches** (`client.fetch(query:)`, `watcher.fetch(...)`, etc.). The consumer asked for data; they have an opinion about freshness. +- **Watcher re-reads on `didChangeKeys`.** A watcher reacts to a cache write that touches one of its dependent keys. The consumer didn't ask for anything; the watcher is propagating cumulative cache state to its delivered result. +- **Cache mutation transactions** (`store.withinReadWriteTransaction`). User code is reading data to mutate it. + +Whether TTL is enforced should depend on the intent of the read. + +## Decision + +### 2.1 Tri-state `maxAge` semantics + +| Schema input | Codegen output | Runtime behavior | +|---|---|---| +| (no `@cacheControl` directive applied to the field's resolution chain) | `cacheControl:` parameter omitted on `Selection.Field` | No TTL check; cache indefinitely | +| `@cacheControl(maxAge: 0)` | `cacheControl: .init(maxAge: 0)` | Always treated as cache miss on **consumer-initiated** reads (per-field force-refetch) | +| `@cacheControl(maxAge: N)` where `N > 0` | `cacheControl: .init(maxAge: N)` | Check `writtenAt + N < now` at read time | +| `@cacheControl` with no `maxAge` argument | **Codegen error**: *"@cacheControl requires an explicit maxAge argument"* | — | + +The `cacheControl` parameter on `Selection.Field` is `CacheControlDirective? = nil`. Generated code for fields with no resolved directive omits the parameter entirely (smaller files, common case is cheaper at the source level). + +The `inheritMaxAge: true` argument on the directive is a codegen-time concern (it adjusts the precedence resolution algorithm). After resolution, the field's `maxAge` is one of the three runtime cases above. + +### 2.2 Selection-set-scoped TTL + +When a query is executed against the cache: + +- TTL is checked **only** for fields the query's selection set requires. +- If any selected field has `cacheControl?.maxAge` and is currently expired (`writtenAt + maxAge < now`, or `maxAge == 0` under strict enforcement — see §2.3), the query is treated as a cache miss as a whole and the existing miss path triggers a network refetch. +- Fields that exist on the same `Record` but are not in the current query's selection are not evaluated. Their staleness is irrelevant to this read. + +Implementation: the TTL check lives in `CacheDataExecutionSource.resolveField`. When the check fails, the resolver throws `JSONDecodingError.missingValue`. The existing missing-value propagation in `GraphQLExecutor` and `ApolloStore.load` turns this into a cache miss naturally — no new error type, no new control flow. + +### 2.3 Read-mode split (strict vs permissive) + +Two read modes exist on `ApolloStore.load`: + +| Mode | TTL behavior | Used by | +|---|---|---| +| `.strict` (default) | TTL enforced; expired fields throw `missingValue`; query becomes a cache miss | `client.fetch(query:)`, `watcher.fetch(...)` (any explicit fetch by the consumer), watcher auto-refresh timer firing (Phase 1D) | +| `.permissive` | TTL ignored; deliver whatever the cache currently contains. **Genuine missing-value errors still throw** (see §2.4) | Watcher re-read on `didChangeKeys` | + +API: + +```swift +public enum TTLEnforcement: Sendable { case strict, permissive } + +public func load<Operation: GraphQLOperation>( + _ operation: Operation, + ttlEnforcement: TTLEnforcement = .strict +) async throws -> GraphQLResponse<Operation>? +``` + +### 2.4 What permissive mode does and does not bypass + +The permissive mode bypasses **only** TTL-induced misses. The pseudocode in `CacheDataExecutionSource.resolveField` makes this explicit: + +```swift +// Genuine missing field — throws unconditionally, regardless of mode: +guard let cachedField = record.cachedField(for: cacheKeyForField) else { + throw JSONDecodingError.missingValue +} + +// TTL checks — only these are gated by enforcement mode: +if let maxAge = field.cacheControl?.maxAge { + if maxAge == 0 { + if ttlEnforcement == .strict { throw JSONDecodingError.missingValue } + } else if cachedField.writtenAt + Int64(maxAge) < now { + if ttlEnforcement == .strict { throw JSONDecodingError.missingValue } + } +} + +return cachedField.value +``` + +Genuine missing-value errors still propagate under permissive, which means the existing watcher revalidation-on-actual-cache-miss behavior from 2.x is fully preserved (see [ADR 0001](./0001-major-version-bump.md) §Context item 4 for context). + +## Alternatives considered + +### A. `maxAge: 0` collapses to "no TTL" (same as no directive) + +Treat `@cacheControl(maxAge: 0)` and the absence of a directive identically — both mean "cache indefinitely, no TTL check." + +- *Rejected because:* This conflates two distinct authorial intents. A schema author writing `@cacheControl(maxAge: 0)` is making an explicit choice — they want this field to be volatile. Collapsing it with "no directive applied" loses that signal. Furthermore, the directive's purpose is to *configure* cache behavior; if `maxAge: 0` had no effect, writing it would be useless. The chosen tri-state semantics give `maxAge: 0` a real and useful meaning (per-field force-refetch on consumer-initiated reads), distinguishable from both "no TTL" and "expire after some non-zero time." + +### B. `maxAge: 0` collapses to "always uncacheable" (HTTP `Cache-Control: max-age=0` analogy) + +Treat `@cacheControl(maxAge: 0)` as "this field is never cacheable" — every read goes to the network, the cache is bypassed entirely. + +- *Rejected because:* The HTTP `Cache-Control: max-age=0` analogy is a wire-protocol cache-busting signal between client and server, with no relationship to client-side normalized cache semantics. Borrowing the reading would be a footgun. More importantly, "always uncacheable" is already expressible at a different layer — the `cachePolicy: .networkOnly` per-query setting. Per-field cache-bypass adds no new capability over per-query cache-bypass for that intent. The chosen meaning ("force refetch on consumer-initiated reads, but the cache *is* updated when results return") is the more useful semantic and is genuinely new — it lets schema authors mark volatile fields once and have all consuming queries respect that volatility automatically. + +### C. Whole-record TTL scope + +Check TTL across all fields of a record on every read; if any field anywhere on the record has expired, every query touching the record misses. + +- *Rejected because:* Different queries select different subsets of an object's fields. A high-precision query that needs only the always-fresh fields would be forced into network refetches because some unrelated stale field happens to live on the same record. A casual query that needs only fields that change rarely would be similarly burdened. Whole-record scope is too coarse and produces unnecessary network traffic. The selection-set scope respects the principle that the cache should serve queries based on what each query actually needs. + +### D. Per-field TTL with mixed-freshness results + +Allow the executor to return a partial result mixing fresh and stale field values, with the staleness exposed to the caller as metadata. + +- *Rejected because:* GraphQL's response model doesn't admit "partially valid" data — a response is either valid against the schema or it isn't. Surfacing per-field staleness to consumers would require an entirely new result shape (every field accessor would need to indicate freshness), changing the public API of every generated `SelectionSet`. The complexity is enormous for a feature whose primary value (timely refresh of stale data) is better served by simply triggering a refetch when any selected field is stale. + +### E. Single read mode (always strict) + +Have only one read mode: TTL is always enforced. Every read, regardless of caller, applies the TTL check. The watcher's `didChangeKeys` re-read uses the same path. + +- *Rejected because:* This produces surprise behavior. A watcher whose query happens to share dependent keys with an unrelated mutation would refetch from the network every time that mutation fires — even though nothing about the watcher's data has changed and the consumer didn't ask for fresh data. The mutation triggers a `didChangeKeys` event, the watcher does a re-read to pick up the change, the re-read encounters an unrelated field with an expired TTL, the read fails, and a network fetch is triggered. The consumer sees a network roundtrip they didn't request. This is the exact kind of unpredictable, expensive behavior that good cache design avoids. + +### F. Single read mode (always permissive) + +Have only one read mode: TTL is never enforced at the cache layer. The TTL check moves to a higher layer (e.g., a wrapper around `client.fetch` that consults `earliestExpiry` after the result returns and re-fetches if expired). + +- *Rejected because:* This duplicates work and produces wrong behavior in subtle cases. The cache executor walks every field in the selection set during a read; checking TTL during that pass is essentially free. Pushing the check to a higher layer means the executor returns a complete result and the wrapper then walks the response a second time to check expiries, then potentially throws the entire result away to refetch. Worse, the higher-layer check cannot easily distinguish "this field was selected and expired" from "this field was unselected and irrelevant" without re-walking the operation's selection-set tree. The cleaner abstraction places the TTL check at the same layer that knows the selection set: the executor. + +## Consequences + +### Positive + +- **Tri-state semantics give schema authors a real volatility signal.** `@cacheControl(maxAge: 0)` lets a single schema declaration mark a field as always-volatile and have every consuming query inherit the freshness requirement automatically. This is cheaper for authors than per-query `.networkOnly` and more granular than per-record approaches. +- **Selection-set scope respects what queries actually need.** The same record can serve a high-precision query (which would trigger refetch on staleness of its selected fields) and a casual query (which is happy with the stale fields it doesn't read) without conflict. +- **Read-mode split keeps watcher behavior predictable.** Cache writes from unrelated mutations do not cause watchers to surprise-fetch from the network. Watchers serve cumulative cache state; consumer-initiated fetches respect TTL; the two concerns are cleanly separated. +- **Genuine missing-value behavior is preserved.** Permissive mode bypasses TTL-induced misses but not actual missing-data misses. Watchers' existing 2.x revalidation-on-missing behavior carries forward unchanged for consumers that have not adopted the new directive (cf. [ADR 0001](./0001-major-version-bump.md)). +- **Codegen for fields without TTL stays minimal.** Generated `Selection.Field` declarations omit the `cacheControl:` parameter entirely when there's no resolved directive. The common-case generated code is no larger than 2.x's. + +### Negative + +- **Two read modes adds API surface.** `ApolloStore.load(_:ttlEnforcement:)` is one new parameter. Custom `CacheInterceptor` implementors who delegate to `store.load` may need to think about which mode to pass. Mitigation: the default is `.strict`, which is what most callers want; permissive is internal to the watcher path. +- **`maxAge: 0` semantics differ from the HTTP `Cache-Control: max-age=0` reading.** Some users coming from HTTP backgrounds may expect "uncacheable." Mitigation: the migration guide includes a paragraph clarifying the difference and pointing at `.networkOnly` for per-query cache-bypass. +- **Bare `@cacheControl` is a codegen error.** A schema author who writes `@cacheControl` thinking they've opted into "some default" will get a compile error rather than silent behavior. This is a deliberate footgun-removal but may surprise authors writing the directive for the first time. +- **Selection-set scope means a single stale field forces a whole-query refetch.** A query selecting 50 fields where one has `@cacheControl(maxAge: 60)` will refetch all 50 from the network when the one expires. Mitigation: GraphQL fetches whole selection sets by design; partial refetch isn't a feature anywhere in the system. + +### Neutral + +- **`maxAge: 0` does not refresh on watcher `didChangeKeys`.** Per §2.3 and the watcher × TTL design (covered separately in ADR 0004), the watcher's propagating-read path uses permissive mode regardless of the watcher's opt-in flag. A watcher's query that includes a `maxAge: 0` field will not refetch on every unrelated cache write that touches its dependent keys. The `maxAge: 0` field refreshes only on consumer-initiated fetches. This is the right behavior — `maxAge: 0` means "force refresh when *consumer* asks for fresh data" — but it is worth pinning down because it is not the most obvious reading. + +## References + +- [Engineering plan §3.4](../cache-rewrite-phase1-plan.md) — *Selection-set-scoped per-field TTL* +- [Engineering plan §3.5](../cache-rewrite-phase1-plan.md) — *Tri-state maxAge semantics* +- [Engineering plan §4](../cache-rewrite-phase1-plan.md) — *TTL semantics specification* (resolution algorithm + read-path enforcement) +- [Engineering plan §5](../cache-rewrite-phase1-plan.md) — *Read-mode split* +- [Samples/cache-control-samples.md](../Samples/cache-control-samples.md) — concrete scenarios for `maxAge` resolution +- [Apollo cache spec v0.2](https://specs.apollo.dev/cache/v0.2/) — silent on `maxAge: 0` and the no-directive default +- [ADR 0001 — Major version bump](./0001-major-version-bump.md) — context for why these new TTL semantics ship as 3.0 +- [ADR 0002 — Record abstraction](./0002-record-abstraction.md) — `CachedField.writtenAt` is the timestamp consulted by §2.4's pseudocode +- ADR 0004 — Watcher × TTL (forthcoming, PR-004): the `automaticallyRefreshOnExpiry` opt-in design that builds on this ADR's read-mode split From c0d3cb6bfa81bfa0f5275693a645b5c692ab7442 Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Thu, 7 May 2026 14:36:01 -0700 Subject: [PATCH 09/24] docs(cache): update plan for stale-tolerance design (ADR 0005 forthcoming) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates ADR 0003, engineering plan, and execution plan ahead of ADR 0005 (PR-004b) which will document the consumer-facing stale-tolerance API: - RequestConfiguration.ttlEnforcement: TTLEnforcement = .strict - Source.cache(containsStaleFields: Bool) — staleness signal as associated value on the cache source case (structurally inapplicable to network responses) - JSONDecodingError.MissingValueReason — diagnostic distinction between absent and expired - .revalidateCache case on CachePolicy.Query.CacheAndNetwork — built-in SWR pattern; always reads permissively as part of its semantics ADR 0003 §2.3 read-mode table and §2.4 pseudocode updated to reflect that permissive mode marks staleness on the response (rather than silently returning) and that the throwing path uses MissingValueReason. Engineering plan §4.2 pseudocode updated to match. §6.4 documents the Source enum shape change and the GraphQLDependencyTracker extension to compute both earliestExpiry and the staleness flag. Execution plan §8 Phase 1C: adds PR-022a for the stale-tolerance API surface (~450 LoC); expands PR-022 and PR-024 to cover staleness tracking. Phase 1C goes from 7 PRs to 8 PRs; total stack 34 → 36. The intersection .revalidateCache + ttlEnforcement = .strict is documented as redundant with .cacheFirst + ttlEnforcement = .strict (accepted small cost of a composable two-axis design). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- apollo-ios/Design/adr/0003-ttl-semantics.md | 31 ++++++++++----- .../Design/cache-rewrite-phase1-execution.md | 19 +++++----- .../Design/cache-rewrite-phase1-plan.md | 38 ++++++++++++------- 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/apollo-ios/Design/adr/0003-ttl-semantics.md b/apollo-ios/Design/adr/0003-ttl-semantics.md index 8c9046fa1..0661fdab8 100644 --- a/apollo-ios/Design/adr/0003-ttl-semantics.md +++ b/apollo-ios/Design/adr/0003-ttl-semantics.md @@ -73,8 +73,8 @@ Two read modes exist on `ApolloStore.load`: | Mode | TTL behavior | Used by | |---|---|---| -| `.strict` (default) | TTL enforced; expired fields throw `missingValue`; query becomes a cache miss | `client.fetch(query:)`, `watcher.fetch(...)` (any explicit fetch by the consumer), watcher auto-refresh timer firing (Phase 1D) | -| `.permissive` | TTL ignored; deliver whatever the cache currently contains. **Genuine missing-value errors still throw** (see §2.4) | Watcher re-read on `didChangeKeys` | +| `.strict` (default) | TTL enforced; expired fields throw `missingValue` with reason `.expired` (see §2.4); query becomes a cache miss | `client.fetch(query:)`, `watcher.fetch(...)` (any explicit fetch by the consumer with `RequestConfiguration.ttlEnforcement = .strict`), watcher auto-refresh timer firing (Phase 1D) | +| `.permissive` | Expired fields are returned as values; the response's `source` is set to `.cache(containsStaleFields: true)` to mark the staleness for callers that branch on it. **Genuine missing-value errors still throw** (see §2.4) | Watcher re-read on `didChangeKeys`; consumer fetches with `RequestConfiguration.ttlEnforcement = .permissive`; the `.revalidateCache` cache policy as part of its SWR semantics | API: @@ -87,28 +87,40 @@ public func load<Operation: GraphQLOperation>( ) async throws -> GraphQLResponse<Operation>? ``` +Configuration of the read mode at the consumer-facing layer is via [ADR 0005](./0005-stale-tolerance.md): `RequestConfiguration.ttlEnforcement: TTLEnforcement = .strict` for explicit fetches, plus the `.revalidateCache` cache policy which always reads permissively as part of its SWR contract. The watcher's `didChangeKeys` re-read uses `.permissive` directly (per [ADR 0004](./0004-watcher-ttl.md)) and ignores the consumer's `RequestConfiguration` for that internal read. + ### 2.4 What permissive mode does and does not bypass -The permissive mode bypasses **only** TTL-induced misses. The pseudocode in `CacheDataExecutionSource.resolveField` makes this explicit: +The permissive mode bypasses **only** TTL-induced misses. Genuine missing fields still throw, and the resolver tracks staleness on the execution context so that the assembled response's `source` can be set to `.cache(containsStaleFields: true)` when permissive mode returns any expired values. The pseudocode in `CacheDataExecutionSource.resolveField` makes this explicit: ```swift // Genuine missing field — throws unconditionally, regardless of mode: guard let cachedField = record.cachedField(for: cacheKeyForField) else { - throw JSONDecodingError.missingValue + throw JSONDecodingError.missingValue(reason: .absent) } // TTL checks — only these are gated by enforcement mode: if let maxAge = field.cacheControl?.maxAge { - if maxAge == 0 { - if ttlEnforcement == .strict { throw JSONDecodingError.missingValue } - } else if cachedField.writtenAt + Int64(maxAge) < now { - if ttlEnforcement == .strict { throw JSONDecodingError.missingValue } + let isExpired = (maxAge == 0) || (cachedField.writtenAt + Int64(maxAge) < now) + if isExpired { + if ttlEnforcement == .strict { + throw JSONDecodingError.missingValue( + reason: .expired(writtenAt: Date(timeIntervalSince1970: TimeInterval(cachedField.writtenAt)), + maxAge: maxAge) + ) + } + // Permissive: continue with the value, but mark that the response + // was sourced from cache containing stale fields. The flag is read + // by the result accumulator and set on response.source. + executionContext.markCacheContainsStaleFields() } } return cachedField.value ``` +Two related pieces of public API are introduced alongside this: a richer `JSONDecodingError.missingValue(reason:)` distinguishing absent from expired (for diagnostics), and `Source.cache(containsStaleFields:)` as the staleness signal on the response. Both are designed and rationalized in [ADR 0005](./0005-stale-tolerance.md). + Genuine missing-value errors still propagate under permissive, which means the existing watcher revalidation-on-actual-cache-miss behavior from 2.x is fully preserved (see [ADR 0001](./0001-major-version-bump.md) §Context item 4 for context). ## Alternatives considered @@ -180,4 +192,5 @@ Have only one read mode: TTL is never enforced at the cache layer. The TTL check - [Apollo cache spec v0.2](https://specs.apollo.dev/cache/v0.2/) — silent on `maxAge: 0` and the no-directive default - [ADR 0001 — Major version bump](./0001-major-version-bump.md) — context for why these new TTL semantics ship as 3.0 - [ADR 0002 — Record abstraction](./0002-record-abstraction.md) — `CachedField.writtenAt` is the timestamp consulted by §2.4's pseudocode -- ADR 0004 — Watcher × TTL (forthcoming, PR-004): the `automaticallyRefreshOnExpiry` opt-in design that builds on this ADR's read-mode split +- [ADR 0004 — Watcher × TTL](./0004-watcher-ttl.md) — the `automaticallyRefreshOnExpiry` opt-in design that builds on this ADR's read-mode split +- ADR 0005 — Stale tolerance via `RequestConfiguration` and `.revalidateCache` (forthcoming, PR-004b): the consumer-facing API for choosing read mode, the `Source.cache(containsStaleFields:)` signal, and the `MissingValueReason` diagnostic distinction diff --git a/apollo-ios/Design/cache-rewrite-phase1-execution.md b/apollo-ios/Design/cache-rewrite-phase1-execution.md index ac3a98522..0f424dbb3 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-execution.md +++ b/apollo-ios/Design/cache-rewrite-phase1-execution.md @@ -251,19 +251,20 @@ Goal: codegen emits `cacheControl` metadata on `Selection.Field`. Runtime stores | PR-018 | feat(codegen): `SelectionSetTemplate` emits `cacheControl:` parameter when non-nil | ⬜ | PR-017 | ~250 | Snapshot tests: generated code with/without directive; minimal output for nil case | | PR-019 | test(codegen): regenerate `TestCodeGenConfigurations`; snapshot tests for all 9 sample scenarios | ⬜ | PR-018 | ~600 | Snapshot tests; existing CodegenTests pass against regenerated APIs | -### Phase 1C — TTL evaluation and read-mode split (7 PRs) +### Phase 1C — TTL evaluation, read-mode split, and stale-tolerance API (8 PRs) -Goal: `cacheControl` metadata is now consulted at read time; the `written_at` column populated since Phase 1A becomes load-bearing; watcher behavior splits into strict/permissive read modes. +Goal: `cacheControl` metadata is now consulted at read time; the `written_at` column populated since Phase 1A becomes load-bearing; watcher behavior splits into strict/permissive read modes; consumer-facing stale-tolerance API lands. | ID | Title | Status | Base | Est. LoC | Tests required | |---|---|---|---|---|---| | PR-020 | feat(cache): `TimeProvider` protocol + `SystemTimeProvider`; threaded into `ApolloStore` | ⬜ | PR-019 | ~150 | Unit: protocol conformance; mockable `TimeProvider` for tests | | PR-021 | feat(cache): `TTLEnforcement` enum + `ApolloStore.load(_:ttlEnforcement:)` overload | ⬜ | PR-020 | ~150 | Unit: enum cases; load with both modes returns correct results when no TTL applies | -| PR-022 | feat(cache): TTL check in `CacheDataExecutionSource.resolveField` gated by enforcement | ⬜ | PR-021 | ~250 | Unit: strict path throws `missingValue` on expired field; permissive path returns value; `maxAge=0` always missing on strict; nil never missing | -| PR-023 | feat(cache): `GraphQLResultNormalizer` injects `writtenAt` on cache writes | ⬜ | PR-022 | ~180 | Unit: normalized records carry `writtenAt`; `TimeProvider` injection works | -| PR-024 | feat(cache): `GraphQLDependencyTracker` computes `earliestExpiry`; `GraphQLResponse.earliestExpiry` exposed | ⬜ | PR-023 | ~250 | Unit: nil when no field has finite TTL; correct minimum across mixed-TTL queries; excludes `maxAge=0` from the calc | -| PR-025 | feat(cache): watcher uses `.permissive` on `didChangeKeys` re-read | ⬜ | PR-024 | ~150 | Unit: watcher delivers cached value through TTL boundary on unrelated write; no automatic network refetch from time-based expiry | -| PR-026 | test(cache): `TTLTests.swift` covering all 9 sample scenarios + boundary cases | ⬜ | PR-025 | ~700 | Integration tests for every `cache-control-samples.md` scenario; boundary cases for `maxAge=0`, scalar inheritance, operation overrides, interface propagation, strict vs permissive | +| PR-022 | feat(cache): TTL check in `CacheDataExecutionSource.resolveField` gated by enforcement | ⬜ | PR-021 | ~280 | Unit: strict path throws `missingValue(reason:)` with appropriate reason on expired field and on absent field; permissive path returns value AND sets `Source.cache(containsStaleFields: true)`; `maxAge=0` always missing on strict; nil never missing | +| PR-022a | feat(api): stale-tolerance API surface — `RequestConfiguration.ttlEnforcement` + `Source.cache(containsStaleFields:)` + `JSONDecodingError.MissingValueReason` + `.revalidateCache` cache policy | ⬜ | PR-022 | ~450 | Unit: round-trip RequestConfiguration through fetch→load→cache; pattern-matching on Source.cache associated value; MissingValueReason carries field metadata; `.revalidateCache` yields stream of (stale, fresh) on stale cache hit and single response on fresh hit; `.revalidateCache + ttlEnforcement = .strict` documented as redundant with `.cacheFirst + ttlEnforcement = .strict` | +| PR-023 | feat(cache): `GraphQLResultNormalizer` injects `writtenAt` on cache writes | ⬜ | PR-022a | ~180 | Unit: normalized records carry `writtenAt`; `TimeProvider` injection works | +| PR-024 | feat(cache): `GraphQLDependencyTracker` computes `earliestExpiry` and `containsStaleFields`; surfaced on `GraphQLResponse.earliestExpiry` and `GraphQLResponse.source.cache(containsStaleFields:)` | ⬜ | PR-023 | ~280 | Unit: `earliestExpiry` nil when no field has finite TTL; correct minimum across mixed-TTL queries; excludes `maxAge=0` from the calc; `containsStaleFields` true if any selected field returned was expired in permissive mode; false on fresh cache hit | +| PR-025 | feat(cache): watcher uses `.permissive` on `didChangeKeys` re-read | ⬜ | PR-024 | ~150 | Unit: watcher delivers cached value through TTL boundary on unrelated write; no automatic network refetch from time-based expiry; staleness flag passed through to consumer | +| PR-026 | test(cache): `TTLTests.swift` covering all 9 sample scenarios + boundary cases | ⬜ | PR-025 | ~750 | Integration tests for every `cache-control-samples.md` scenario; boundary cases for `maxAge=0`, scalar inheritance, operation overrides, interface propagation, strict vs permissive, RequestConfiguration override, `.revalidateCache` stream behavior | ### Phase 1D — Opt-in watcher refresh, hardening, beta (5 PRs) @@ -279,8 +280,8 @@ Goal: ship 3.0-beta. Full feature visible to consumers. ### Total -- **34 PRs** across 4 phases (Phase 0: 5; Phase 1A: 10; Phase 1B: 7; Phase 1C: 7; Phase 1D: 5). -- **~8,950 meaningful LoC** of change at midpoint estimates. +- **36 PRs** across 4 phases (Phase 0: 6; Phase 1A: 10; Phase 1B: 7; Phase 1C: 8; Phase 1D: 5). +- **~9,500 meaningful LoC** of change at midpoint estimates. - The estimates are guidance, not contracts. Splitting a PR is preferred to overrunning the §2 cap. ## 9. Session bootstrap diff --git a/apollo-ios/Design/cache-rewrite-phase1-plan.md b/apollo-ios/Design/cache-rewrite-phase1-plan.md index f2a638275..db181fe60 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-plan.md +++ b/apollo-ios/Design/cache-rewrite-phase1-plan.md @@ -151,26 +151,28 @@ All scenarios in [Samples/cache-control-samples.md](./Samples/cache-control-samp ```swift // Pseudocode inside CacheDataExecutionSource.resolveField guard let cachedField = record.cachedField(for: cacheKeyForField) else { - throw JSONDecodingError.missingValue + throw JSONDecodingError.missingValue(reason: .absent) } if let maxAge = field.cacheControl?.maxAge { - if maxAge == 0 { - // Always treated as cache miss on initiating reads. + let isExpired = (maxAge == 0) || (cachedField.writtenAt + Int64(maxAge) < now) + if isExpired { if ttlEnforcement == .strict { - throw JSONDecodingError.missingValue - } - } else if cachedField.writtenAt + Int64(maxAge) < now { - if ttlEnforcement == .strict { - throw JSONDecodingError.missingValue + throw JSONDecodingError.missingValue( + reason: .expired(writtenAt: Date(timeIntervalSince1970: TimeInterval(cachedField.writtenAt)), + maxAge: maxAge) + ) } + // Permissive: continue with the value, but mark the read so the assembled + // response's source becomes .cache(containsStaleFields: true). + executionContext.markCacheContainsStaleFields() } } return cachedField.value ``` -The `ttlEnforcement` parameter is propagated from the call site. See section 5 for the read-mode split. +The `ttlEnforcement` parameter is propagated from the call site. See section 5 for the read-mode split. The `MissingValueReason` enum and the `Source.cache(containsStaleFields:)` design are specified in ADR 0005. ### 4.3 Write-path timestamp injection @@ -250,16 +252,26 @@ Resolved by combining the two preceding rules: ### 6.4 Required additions to `GraphQLResponse` -The earliest-expiry calculation requires per-field `writtenAt` data and per-field `maxAge` metadata, both available at normalization time. Rather than have the watcher walk the response after the fact, the response carries the precomputed value: +Two pieces of metadata, both computed during the cache read pass and surfaced on `GraphQLResponse`: ```swift public struct GraphQLResponse<Operation: GraphQLOperation> { - // existing fields unchanged - public let earliestExpiry: Date? // nil if no field has finite TTL + // existing fields preserved + public let source: Source // shape changes; see below + public let earliestExpiry: Date? // nil if no field has finite TTL +} + +// Source.cache gains an associated value to carry the staleness signal. +public enum Source: Sendable { + case cache(containsStaleFields: Bool) + case network } ``` -`GraphQLDependencyTracker` is extended to compute this in the same pass that produces `dependentKeys`. +- `earliestExpiry: Date?` is the minimum of `writtenAt + maxAge` across fields with finite TTL. Used by the watcher's auto-refresh timer (§6.2). Excludes `maxAge: 0` fields (they have no schedulable expiry). +- `Source.cache(containsStaleFields:)` is set during cache reads in permissive mode: `true` if any selected field had a finite TTL or `maxAge: 0` and was returned despite being expired; `false` for fully-fresh cache hits. It is structurally inapplicable to network-sourced responses, hence the associated value lives on the `.cache` case rather than at the top level of `GraphQLResponse`. ADR 0005 is the design reference. + +`GraphQLDependencyTracker` is extended to compute both in the same pass that produces `dependentKeys`. ## 7. SQLite schema From 01cb5364a84cb9352de6079408debe6d2516f101 Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Mon, 11 May 2026 12:15:35 -0700 Subject: [PATCH 10/24] =?UTF-8?q?docs(cache):=20ADR=200004=20=E2=80=94=20W?= =?UTF-8?q?atcher=20=C3=97=20TTL=20(opt-in=20auto-refresh,=20permissive=20?= =?UTF-8?q?propagating=20reads)=20(#971)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging ADR 0004 (Watcher × TTL: opt-in auto-refresh, permissive propagating reads) into the plan branch per the ADR-merge-as-approved policy. Approved post-meeting. --- apollo-ios/Design/adr/0004-watcher-ttl.md | 137 ++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 apollo-ios/Design/adr/0004-watcher-ttl.md diff --git a/apollo-ios/Design/adr/0004-watcher-ttl.md b/apollo-ios/Design/adr/0004-watcher-ttl.md new file mode 100644 index 000000000..e49a8f0b7 --- /dev/null +++ b/apollo-ios/Design/adr/0004-watcher-ttl.md @@ -0,0 +1,137 @@ +# ADR 0004 — Watcher × TTL: opt-in auto-refresh with permissive propagating reads + +- **Status:** Accepted +- **Date:** 2026-05-07 +- **Phase 1 PR:** PR-004 (cache rewrite execution plan §8) +- **Engineering plan reference:** [cache-rewrite-phase1-plan.md §6](../cache-rewrite-phase1-plan.md) + +## Context + +Phase 1 introduces per-field TTL on cached data. Watchers (`GraphQLQueryWatcher`) interact with the cache in a way that's distinct from explicit fetches: they subscribe to `ApolloStore.didChangeKeys` events and re-read the query on overlap. This raises questions that weren't present in the 2.x model — questions that are not answered by the read-mode split alone: + +1. **Should watchers automatically detect that their delivered data has expired?** The watcher's last delivered result was fresh when delivered. If `writtenAt + maxAge` elapses while the watcher sits idle and nothing else writes to the cache, the data the consumer sees is now stale, but no `didChangeKeys` event will fire. +2. **What should happen when a `didChangeKeys` event fires for an unrelated reason and the re-read encounters expired data?** Without an explicit policy, the existing missing-value propagation would trigger a network refetch — but that fires not because the consumer asked for fresh data, but because some unrelated mutation happened to overlap with the watcher's dependent keys. The user-experience consequence is unpredictable network traffic. +3. **How should `maxAge: 0` (the always-stale case from ADR 0003 §2.1) interact with watcher behavior?** A literal reading would mean every watcher whose query touches a `maxAge: 0` field thrashes — every relevant cache write triggers a refetch, every refetch produces stale data again, every read keeps missing. + +These are interlocking questions about watcher semantics that need a coherent answer. The read-mode split from [ADR 0003 §2.3](./0003-ttl-semantics.md) is the substrate this ADR builds on; this ADR specifies the watcher-specific policy on top. + +## Decision + +### 2.1 Default behavior: lazy + +By default, watchers do **not** automatically refresh on TTL expiry. Specifically: + +- The watcher continues to subscribe to `ApolloStore.didChangeKeys` events and re-read the query on overlap (existing 2.x behavior, preserved). +- The re-read uses `ttlEnforcement: .permissive` (per [ADR 0003 §2.3](./0003-ttl-semantics.md)). Time-based expiry has no effect on the watcher's delivered output during a propagating read. +- The watcher's last delivered result remains visible until either: + - A cache write to a dependent key triggers a re-read (which delivers the post-write value, regardless of TTL), or + - The consumer explicitly calls `watcher.fetch(cachePolicy: .cacheFirst)`. That goes through the strict read path; if any field has expired, the strict read fails and the existing network fallback triggers a refetch. + +This is the conservative default. Consumers who want time-based revalidation opt in (§2.2). + +### 2.2 Opt-in auto-refresh + +A new `GraphQLQueryWatcher` initializer parameter: + +```swift +public init( + client: ApolloClient, + query: Query, + refetchOnFailedUpdates: Bool = true, + automaticallyRefreshOnExpiry: Bool = false, // new in 3.0 + resultHandler: @escaping ResultHandler +) async +``` + +When `automaticallyRefreshOnExpiry == true`: + +1. After every successful result delivery, the watcher computes the **earliest finite expiry** across all fields in `dependentKeys`. This is the minimum of `cachedField.writtenAt + maxAge` across fields whose `cacheControl.maxAge` is non-nil and `> 0`. Fields with `maxAge: 0` are excluded (they have no schedulable expiry — see §2.4). +2. The watcher schedules a one-shot `Task` that sleeps until the earliest expiry. +3. When the timer fires, the watcher calls `fetch(cachePolicy: .cacheFirst)`. The strict read path will hit the network if anything has expired; otherwise it redelivers the cached value. Either path produces a result, which triggers a reschedule of the timer for the new earliest expiry. +4. Cache writes that arrive via `didChangeKeys` cancel the existing timer and reschedule based on the post-merge timestamps (writes refresh `writtenAt`, so the earliest expiry typically moves forward in time). +5. Watcher cancellation cancels the timer. + +The earliest-expiry calculation is supported by a new `GraphQLResponse.earliestExpiry: Date?` field (engineering plan §6.4) computed by the dependency tracker during normalization. The watcher reads it from the result rather than walking the cache itself. + +### 2.3 Read-mode policy is independent of the opt-in flag + +The opt-in flag controls **timer scheduling**, not read mode. Watcher re-reads on `didChangeKeys` use `.permissive` regardless of whether `automaticallyRefreshOnExpiry` is true or false. + +The mental model: opt-in means "I want a timer that fires when finite-TTL fields expire, so my UI can show fresh data after a known interval." It does **not** mean "I want every cache write to be treated as a freshness opportunity." Those are different concerns; the flag addresses only the first. + +### 2.4 `maxAge: 0` interaction + +The combination of decisions above produces clean `maxAge: 0` semantics in both watcher modes: + +- **Default watcher (auto-refresh off).** Permissive read on `didChangeKeys` ignores the always-stale field; the cached value is returned. No thrash. The `maxAge: 0` field's "always stale" property has no effect on watchers in this mode; it activates only when the consumer issues an explicit fetch. +- **Opt-in watcher (auto-refresh on).** `maxAge: 0` fields are excluded from the earliest-expiry calculation in §2.2 step 1. They have no schedulable expiry; "now" is not a valid sleep deadline. So the timer is not scheduled around them. The propagating-read path is permissive (per §2.3), so unrelated cache writes don't trigger refetches either. `maxAge: 0` fields refresh only on consumer-initiated fetches. + +`maxAge: 0` thus has a precise semantics in the watcher world: *force network on consumer-initiated reads of this field, and do nothing in propagating or timer-driven paths.* + +## Alternatives considered + +### A. Default watcher uses strict reads on `didChangeKeys` + +Make the strict read mode the default for both consumer-initiated reads and watcher propagating reads. The opt-in flag is unnecessary because TTL is always enforced. + +- *Rejected because:* This produces the surprise behavior described in §Context item 2 — unrelated cache writes trigger network refetches because of TTL on fields that aren't visibly stale to the consumer. The "happy accident" revalidation would actually be unwanted behavior. Also, this rejected the option that ADR 0003 §2.3 already settled (single-strict mode). + +### B. No opt-in flag; auto-refresh is always on + +Remove the conservative default; every watcher schedules a timer for finite-TTL fields by default. + +- *Rejected because:* This makes timer-driven network calls implicit in any query that uses `@cacheControl`, which has battery, network-cost, and user-control implications that should be explicit. Some apps use TTL purely as a freshness hint for explicit fetches and don't want timers driving refetches in the background. Defaulting auto-refresh on couples the directive with timer behavior in a way that the directive itself doesn't imply. + +### C. Auto-refresh as a global client setting, not a per-watcher flag + +Configure auto-refresh on `ApolloClient` (e.g., `ApolloClient.Configuration.autoRefreshWatchersOnExpiry: Bool`); apply uniformly to all watchers. + +- *Rejected because:* Different watchers in the same app have different freshness requirements. A list view that shows cached items doesn't need timer-driven refresh; a detail view that shows volatile data does. Per-watcher control is the right granularity. A global setting is layered on top if desired (e.g., a default initializer parameter the client provides), but the per-watcher flag is the primitive. + +### D. Auto-refresh fires the timer with `.networkOnly`, not `.cacheFirst` + +When the timer fires, force a network fetch directly rather than re-reading the cache first. + +- *Rejected because:* If a write happened between the timer being scheduled and the timer firing — e.g., another query happened to refresh the relevant fields — the cache may already be fresh. `.cacheFirst` correctly handles that case (delivers cached data, no network call, reschedules). `.networkOnly` would force a wasteful network roundtrip even when unnecessary. The strict read path of `.cacheFirst` is exactly designed for this — go to network only if the cache can't satisfy the request. + +### E. Earliest-expiry includes `maxAge: 0` fields by treating them as instant expiry + +Schedule the timer to fire "immediately" for any `maxAge: 0` field in the watcher's dependencies, producing tight-loop refetch behavior. + +- *Rejected because:* This is the thrash failure mode. `maxAge: 0` should mean "force refresh on consumer-initiated reads," not "thrash forever." The cleaner semantics — `maxAge: 0` is excluded from the timer; refresh happens only on explicit consumer fetches — preserves the directive's usefulness without the thrash. If a consumer wants polling, they should implement it explicitly at the app level (a `Timer` driving a fetch loop), not have it be implicit in the watcher. + +### F. Coalesced store-level timer instead of per-watcher timer + +Maintain a min-heap of expiry times across all watchers in `ApolloStore`. One global timer fires at the earliest expiry across the whole cache; the store walks subscribers and notifies whoever's affected. + +- *Rejected because:* The min-heap data structure must be kept in sync with every cache write and every watcher subscription/cancellation, which is meaningful complexity for a primitive (timers) that has cheap natural alternatives. Per-watcher timers cost one sleeping `Task` per opt-in watcher; for the watcher counts typical in iOS apps (typically dozens, not thousands), this is negligible. The complexity of a coalesced timer is not justified by the per-timer cost. Reconsider in Phase 2+ if profiling shows per-watcher-task overhead is meaningful in real apps. + +## Consequences + +### Positive + +- **Default behavior is conservative and predictable.** Time-based expiry doesn't cause surprise network calls in apps that haven't opted in. The 2.x revalidation behavior (refetch on actual cache miss during a propagating read) is preserved. +- **Opt-in feature is genuinely useful.** Apps that want time-based UI refresh get it via one parameter at watcher construction. The implementation is bounded — one sleeping `Task` per opt-in watcher, with deterministic cancellation. +- **`maxAge: 0` thrash is structurally impossible.** The combination of permissive propagating reads and finite-only timer scheduling means that volatile fields refresh on consumer-initiated reads only, regardless of whether the watcher is opt-in. There is no path through the watcher that produces a refetch loop. +- **Earliest-expiry calculation lives in `GraphQLResponse`.** Watchers don't walk the cache to find expiries; they read a precomputed value. This isolates the watcher from cache internals and lets the calculation be optimized in the dependency tracker once. +- **Reversible default.** If real-world usage shows that the conservative default is too cautious, a future minor release can change the default of `automaticallyRefreshOnExpiry` to `true` without breaking the API surface. + +### Negative + +- **Stale data persists silently in the default mode.** A watcher whose query has finite TTL and whose `automaticallyRefreshOnExpiry` is false will continue to deliver the last known result indefinitely if no other writes touch its dependent keys. The UI shows stale data; nothing notifies the consumer. Mitigation: this is documented in the migration guide; consumers who want freshness either opt in or call `fetch(.cacheFirst)` at meaningful moments (app foreground, pull-to-refresh, etc.). +- **Per-watcher `Task` overhead.** Opt-in watchers each hold a sleeping `Task`. For thousands of opt-in watchers in a single app, this could accumulate measurable scheduler overhead. Mitigation: watcher counts in real apps are typically far below thousands; if profiling later shows this is meaningful, Option F (coalesced timer) is the upgrade path. +- **Earliest-expiry is recomputed on every result delivery.** Each result delivery cancels and reschedules the timer. The recomputation walks the dependent fields' metadata. For very large dependent-key sets this is non-trivial work. Mitigation: the dependent-key set for typical queries is bounded (~10s of fields); the overhead is below the per-fetch network cost it's preventing. + +### Neutral + +- **The opt-in flag does not affect the read-mode split.** A consumer who reads ADR 0003 expecting the read-mode split and the auto-refresh feature to be a single concept will need to read both ADRs to understand they're independent. Mitigation: this ADR's §2.3 makes the independence explicit; the migration guide spells it out for end users. +- **`maxAge: 0` fields refresh only on consumer-initiated reads, even in opt-in mode.** Some readers may expect the opt-in flag to enable polling-style behavior for `maxAge: 0` fields. It does not; that's a separate concern (a `Timer.publish` driving fetches at the app level). Documented and intentional. + +## References + +- [Engineering plan §6 — Watcher × TTL behavior](../cache-rewrite-phase1-plan.md) +- [Engineering plan §6.2 — Opt-in auto-refresh](../cache-rewrite-phase1-plan.md) +- [Engineering plan §6.4 — Required additions to GraphQLResponse](../cache-rewrite-phase1-plan.md) (`earliestExpiry`) +- [GraphQLQueryWatcher.swift](../../Sources/Apollo/GraphQLQueryWatcher.swift) — current 2.x implementation that this ADR extends +- [ADR 0003 — TTL semantics](./0003-ttl-semantics.md) — the read-mode split this ADR builds on, and the `maxAge: 0` semantics that the watcher rules complete +- [ADR 0001 — Major version bump](./0001-major-version-bump.md) §Context item 4 — confirms the watcher × TTL design is a new feature on top of the TTL surface, not a 2.x breaking change From 663ac773841ae3b6520cf2af69cfd093a9ba187f Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Mon, 11 May 2026 12:15:50 -0700 Subject: [PATCH 11/24] =?UTF-8?q?docs(cache):=20ADR=200005=20=E2=80=94=20s?= =?UTF-8?q?tale-tolerance=20API=20surface=20(#972)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging ADR 0005 (Stale-tolerance API surface — RequestConfiguration.ttlEnforcement, Source.cache(containsStaleFields:), MissingValueReason, .revalidateCache) into the plan branch. Revised post-review to clarify .revalidateCache honors ttlEnforcement on the internal read (resolving the orthogonality inconsistency with Alternative F). Approved post-meeting. --- apollo-ios/Design/adr/0005-stale-tolerance.md | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 apollo-ios/Design/adr/0005-stale-tolerance.md diff --git a/apollo-ios/Design/adr/0005-stale-tolerance.md b/apollo-ios/Design/adr/0005-stale-tolerance.md new file mode 100644 index 000000000..1813c9bb0 --- /dev/null +++ b/apollo-ios/Design/adr/0005-stale-tolerance.md @@ -0,0 +1,189 @@ +# ADR 0005 — Stale-tolerance API: `RequestConfiguration.ttlEnforcement`, `Source.cache(containsStaleFields:)`, `MissingValueReason`, and `.revalidateCache` + +- **Status:** Accepted +- **Date:** 2026-05-07 +- **Phase 1 PR:** PR-004b (cache rewrite execution plan §8) +- **Engineering plan reference:** [cache-rewrite-phase1-plan.md §4.2, §6.4](../cache-rewrite-phase1-plan.md) + +## Context + +[ADR 0003](./0003-ttl-semantics.md) specifies the TTL runtime semantics: a tri-state `maxAge` (nil/0/N>0), selection-set-scoped enforcement, and a strict-vs-permissive read-mode split on `ApolloStore.load`. This is a complete description of the *cache layer*'s behavior. What is not yet specified is the *consumer-facing API surface* — how application code chooses between strict and permissive reads, how it observes that a cache read returned stale data, and how it expresses common patterns built on that machinery. + +Three related concerns drive this ADR: + +1. **How does a consumer choose strict vs permissive enforcement?** ADR 0003's `ttlEnforcement` parameter is on `ApolloStore.load`, which is below the public consumer fetch API. A surface higher up needs to expose the choice. +2. **How does a consumer observe that a cache response contained stale data?** Permissive reads return values silently in ADR 0003's first formulation; consumers need a signal in order to act on staleness (e.g., trigger a follow-up network fetch, log telemetry, indicate "refreshing" in the UI). +3. **How is the common stale-while-revalidate (SWR) pattern packaged?** Consumers who want "deliver stale immediately, refresh in the background" should not have to write the branching themselves; the library should provide an ergonomic primitive. + +A fourth related question — diagnostic distinction between "field was absent" and "field expired" cache misses in strict mode — is small enough to fold into this ADR as well. + +## Decision + +Four coordinated additions to the public API: + +### 2.1 `RequestConfiguration.ttlEnforcement: TTLEnforcement` + +The existing [`RequestConfiguration`](../../Sources/Apollo/RequestConfiguration.swift) value type — already passed to `client.fetch(query:)` and friends — gains a `ttlEnforcement` field that propagates to the cache read mode. + +```swift +public struct RequestConfiguration: Sendable { + // ... existing fields preserved + public var ttlEnforcement: TTLEnforcement = .strict +} +``` + +Default is `.strict`, matching the schema author's evident intent when they write `@cacheControl(maxAge: N)`. Consumers who want stale tolerance opt in by setting `.permissive` on the configuration they pass to `fetch`. The value flows through the request chain into `ApolloStore.load(_:ttlEnforcement:)`. + +The watcher's `didChangeKeys` re-read uses `.permissive` directly (per [ADR 0004](./0004-watcher-ttl.md) §2.1) and ignores the consumer's `RequestConfiguration` for that internal read. The watcher's *opt-in auto-refresh* timer fires `fetch(cachePolicy: .cacheFirst)`, which uses the consumer's `RequestConfiguration` and therefore respects the `ttlEnforcement` setting on it. + +### 2.2 `Source.cache(containsStaleFields:)` associated value + +`GraphQLResponse.Source` changes from a flat enum to one with a `Bool` associated value on the `.cache` case: + +```swift +public enum Source: Sendable { + case cache(containsStaleFields: Bool) + case network +} +``` + +The flag is set during the cache read pass: `true` if any field returned by the resolver in permissive mode was past its TTL (either `maxAge: 0` or `writtenAt + maxAge < now`); `false` for fresh cache hits. It is structurally inapplicable to network-sourced responses — there is no notion of "stale network response" because network responses establish freshness, not consume it. + +Consumers branch on this in pattern matching: + +```swift +switch response.source { +case .cache(containsStaleFields: true): /* stale; consumer may revalidate */ +case .cache(containsStaleFields: false): /* fresh cache hit */ +case .network: /* by definition fresh */ +} +``` + +Under `ttlEnforcement = .permissive`, the signal is what `.revalidateCache` (§2.4) consults internally to decide whether to fire the network fetch. + +### 2.3 `JSONDecodingError.MissingValueReason` + +The existing `JSONDecodingError.missingValue` case is extended to carry an optional reason for diagnostic distinction between absence and expiry. Both still trigger the same control-flow path (cache miss → network refetch in strict mode), but the reason lets logging, telemetry, and debug surfaces tell them apart. + +```swift +public enum JSONDecodingError: Error { + case missingValue(reason: MissingValueReason?) // CHANGED — was case missingValue + // ... existing cases preserved + + public enum MissingValueReason: Sendable { + case absent // field not present in cache record + case expired(writtenAt: Date, maxAge: Int) // failed TTL check in strict mode + } +} +``` + +The associated value is optional, which preserves catch-site backward compatibility for code that only cares about "missing-or-not": `case .missingValue` continues to match every variant. Consumers wanting the distinction pattern-match on the reason. The change is breaking only in the sense that any code that constructs `.missingValue` directly must update — internal callers do, third-party callers should not have been constructing this case. + +### 2.4 `.revalidateCache` cache policy + +A new case on the existing `CachePolicy.Query.CacheAndNetwork` enum: + +```swift +public enum CachePolicy.Query.CacheAndNetwork: Sendable, Hashable { + case cacheAndNetwork // existing — always fetches network in addition to cache + case revalidateCache // NEW — fetches network only if cache contains stale fields +} +``` + +`.revalidateCache` packages the SWR pattern. Its behavior depends on the consumer's `RequestConfiguration.ttlEnforcement` — the two configuration axes compose orthogonally rather than `.revalidateCache` overriding the enforcement mode. + +**Under `ttlEnforcement = .permissive`** (the SWR-meaningful combination): + +| Cache state | Stream yields | +|---|---| +| Fresh cache hit (all selected fields present, none stale) | One response: `source = .cache(containsStaleFields: false)`. No network call. | +| Stale cache hit (all selected fields present, any stale) | Two responses: stale `source = .cache(containsStaleFields: true)` first, then fresh `source = .network` after refresh. | +| Genuine miss (any selected field absent) | One response: from network. | + +**Under `ttlEnforcement = .strict`** (the redundant intersection — see §3.2): + +| Cache state | Stream yields | +|---|---| +| Fresh cache hit | One response: from cache. No network call. | +| Stale "hit" (any field expired) | Strict mode turns this into a `missingValue(.expired)` miss per [ADR 0003](./0003-ttl-semantics.md). One response: from network. | +| Genuine miss | One response: from network. | + +Internally, `.revalidateCache` reads the cache using the consumer's `ttlEnforcement` setting. Under permissive, it consults the `Source.cache(containsStaleFields:)` flag to decide whether to fire the network fetch — the consumer doesn't need to write the branch themselves. Under strict, stale fields fail the read like any other miss and the policy falls back to network exactly as `.cacheFirst + .strict` would; there is nothing to revalidate when stale data is unobservable. The redundancy is mechanical (same code path), not merely behavioral; see §3.2. + +Consumers who want SWR semantics must explicitly opt into stale tolerance via `ttlEnforcement: .permissive`. This is intentional: the schema author's `@cacheControl(maxAge:)` directive is honored by default, and stale tolerance is a separate axis from cache-policy choice. A consumer dynamically wiring `ttlEnforcement` (for example, a wrapper that honors a user preference for strict freshness across all queries) gets consistent behavior across cache policies — `.cacheFirst` and `.revalidateCache` both respect the setting. + +## Alternatives considered + +### A. Separate `CachePolicy.Query.StaleWhileRevalidate` group + +Introduce a third enum group alongside `CacheAndNetwork` to hold an `.staleWhileRevalidate` case, mirroring the structure of `CacheOnly` and `CacheAndNetwork`. + +- *Rejected because:* the response shape (multi-response stream that may contain a cached value followed by a network value) is the same as `.cacheAndNetwork`'s. Putting `.revalidateCache` in `CacheAndNetwork` keeps single-response and multi-response policies cleanly separated. Adding a new top-level group for one case would split a concept that belongs together. + +### B. Top-level `cacheContainsStaleFields: Bool` on `GraphQLResponse` + +Keep `Source` as a flat enum (`.cache | .network`) and put the staleness signal at the top level of `GraphQLResponse`. + +- *Rejected because:* the signal is meaningful only for cache-sourced responses; on network-sourced responses, "containsStaleFields" is nonsensical. Encoding the signal at the top level admits the impossible state `Source = .network, cacheContainsStaleFields = true`. Encoding it as an associated value on `.cache` makes the impossible state unrepresentable in the type system. Pattern-matching consumers also benefit: `case .cache(containsStaleFields: true)` reads naturally; the alternative requires two-step matching (`if response.source == .cache && response.cacheContainsStaleFields`). + +### C. Three TTL enforcement modes (`.strict`, `.staleAware`, `.permissive`) + +Introduce a third mode where stale fields are returned and marked, distinct from a "permissive without marking" mode that returns silently. + +- *Rejected because:* the marking is essentially free (one Bool on the response), and there is no use case in Phase 1 for "return stale silently with no observable signal." Permissive mode always marks; consumers who don't care about the flag ignore it; consumers who do care branch on it. Two modes is simpler and the lost distinction has no practical cost. + +### D. `permitStaleCacheReads: Bool` on `RequestConfiguration` instead of the enum + +Use a Bool flag rather than the `TTLEnforcement` enum on `RequestConfiguration`. + +- *Rejected because:* the enum already exists at the `ApolloStore.load` layer ([ADR 0003](./0003-ttl-semantics.md) §2.3). Using the same type at the consumer-facing layer eliminates a translation step and keeps the design extensible — a future third mode would land naturally on the enum without breaking the API. A Bool would force a type change to add a third mode later. + +### E. Separate `staleDataError` thrown from the cache resolver + +Introduce a distinct error type (e.g., `StaleDataError`) thrown by `CacheDataExecutionSource.resolveField` on TTL expiry, distinct from `JSONDecodingError.missingValue`. + +- *Rejected because:* errors short-circuit the executor. If the resolver threw a stale-data error on field A, the executor would stop walking the selection set; fields B, C, D would never resolve. There would be no completed result to deliver in the stale-while-revalidate pattern. The chosen design — keep `missingValue` as the cache-miss signal in strict mode, and use response metadata (`Source.cache(containsStaleFields:)`) to surface staleness in permissive mode — does not have this problem. The `MissingValueReason` enum (§2.3) preserves the diagnostic distinction without changing control flow. + +### F. `.revalidateCache` ignores `ttlEnforcement` (no redundant intersection) + +Make `.revalidateCache` always read permissively, regardless of the consumer's `RequestConfiguration.ttlEnforcement` setting. The combination `.revalidateCache + .strict` would be silently treated as `.revalidateCache + .permissive`. + +- *Rejected because:* this breaks the orthogonality of the two configuration axes. Consumer code that passes a `RequestConfiguration` with `ttlEnforcement` set dynamically — for example, a wrapper that honors a user preference for strict freshness across all queries — would behave inconsistently: `.cacheFirst` would respect the setting, but `.revalidateCache` would not. The redundancy of `.revalidateCache + .strict ≡ .cacheFirst + .strict` is a smaller cost than the inconsistency this option would introduce. Consumers who pick `.revalidateCache` and want strict TTL behavior get the documented equivalent (cacheFirst-with-network-fallback-on-miss) — no behavior is silently lost. + +### G. No built-in SWR — consumers DIY using `Source.cache(containsStaleFields:)` + +Drop `.revalidateCache` and have consumers implement the SWR pattern themselves: read permissively, branch on the staleness signal, fire follow-up network fetches when stale. + +- *Rejected because:* the SWR pattern is common enough to deserve a built-in primitive. Forcing every consumer who wants it to write the same branching boilerplate is a quality-of-life cost. The DIY pattern is still available for variations (e.g., revalidating only specific fields, or rate-limiting the follow-up fetches), but the common case gets one-line ergonomics. + +## Consequences + +### Positive + +- **Composable axes.** `RequestConfiguration.ttlEnforcement` and `cachePolicy` compose freely: any of the existing cache policies plus either enforcement mode produces a sensible behavior. Offline-first apps use `.cacheOnly + ttlEnforcement = .permissive`; freshness-strict apps use `.cacheFirst + ttlEnforcement = .strict` (default); SWR apps use `.revalidateCache`. No combinatorial explosion of named cache policies. +- **Type-safe staleness signal.** `Source.cache(containsStaleFields:)` makes "network response with staleness flag" unrepresentable in the type system. Pattern matching is natural. +- **Diagnostic distinction without control-flow cost.** `MissingValueReason` lets logs and telemetry distinguish absent from expired cache misses without changing how callers handle the error. Existing `case .missingValue` catch sites continue to compile and behave as before. +- **`.revalidateCache` ergonomics.** The SWR pattern is one cache-policy choice plus `ttlEnforcement: .permissive`; consumers don't write the cache-read-then-conditionally-fetch-network branching themselves. +- **Default behavior preserves existing intent.** `RequestConfiguration.ttlEnforcement = .strict` by default means a schema author writing `@cacheControl(maxAge: N)` sees their directive honored automatically; consumers who want stale tolerance opt in. + +### Negative + +- **`.revalidateCache + ttlEnforcement = .strict` is mechanically redundant with `.cacheFirst + ttlEnforcement = .strict`.** Both configurations execute the same read path: strict TTL turns stale fields into `missingValue(.expired)` cache misses, which trigger network fallback. A consumer writing `.revalidateCache, ttlEnforcement: .strict` gets exactly the behavior of `.cacheFirst, ttlEnforcement: .strict` — there is nothing to revalidate when stale data is unobservable. Mitigation: the redundancy is documented in the API surface comments and in the migration guide. A category-error combination is an acceptable cost of preserving the orthogonal axes; consumers who write configurable code (where `ttlEnforcement` and `cachePolicy` come from different sources) get predictable behavior in every combination. +- **Existing pattern matches on `Source` need updating.** Code that pattern-matches `case .cache:` without the associated value will fail to compile in 3.0 (`.cache` no longer exists as a case without an associated value). Mitigation: this is a 3.0 breaking change called out in the migration guide; the typical fix is `case .cache(_):` or `case .cache(containsStaleFields: _):`. Trivial mechanical update. +- **`JSONDecodingError.missingValue` constructor signature changes.** Code that constructs `JSONDecodingError.missingValue` directly must now write `.missingValue(reason: nil)`. Internal call sites are the only known constructors; third-party code that catches the case is unaffected. Mitigation: 3.0 breaking change called out in the migration guide. +- **Three new public API surfaces in 3.0.** `RequestConfiguration.ttlEnforcement`, `Source.cache(containsStaleFields:)`, `MissingValueReason`, and `.revalidateCache` are all new public API. Each adds a small amount of conceptual surface area for new users. Mitigation: documentation comments on each; migration guide section explaining when to use which; the default behavior matches the most common intent so the new API surface is opt-in for the common case. + +### Neutral + +- **The redundancy in §3.2 is intentional and bounded.** Other combinations — `.cacheOnly + .permissive`, `.networkFirst + .permissive`, `.cacheFirst + .permissive`, `.cacheAndNetwork + .permissive`, etc. — are all distinct, useful, and orthogonal. Only the one corner is redundant, and it's the corner where the user has explicitly asked for two contradictory things at once (revalidate stale data, but treat stale as miss). +- **`.revalidateCache` does not separately expose the network response and the cache response in the result type.** Both responses are delivered through the same `AsyncSequence<GraphQLResponse<Query>>` API; consumers distinguish them via `response.source`. This is identical to `.cacheAndNetwork`'s existing shape. + +## References + +- [ADR 0003 — TTL semantics](./0003-ttl-semantics.md) §2.3, §2.4 — the read-mode split this ADR exposes to consumers +- [ADR 0004 — Watcher × TTL](./0004-watcher-ttl.md) §2.1 — confirms the watcher's `didChangeKeys` re-read uses `.permissive` internally regardless of consumer `RequestConfiguration` +- [Engineering plan §4.2](../cache-rewrite-phase1-plan.md) — read-path enforcement pseudocode (uses the `MissingValueReason` introduced here and the `markCacheContainsStaleFields()` mechanism for staleness tracking) +- [Engineering plan §6.4](../cache-rewrite-phase1-plan.md) — `GraphQLResponse` additions (the `Source` enum shape change is documented there) +- [Execution plan §8 PR-022a](../cache-rewrite-phase1-execution.md) — implementation PR for this ADR +- [CachePolicy.swift](../../Sources/Apollo/Caching/CachePolicy.swift) — current 2.x cache policy structure that gains `.revalidateCache` +- [RequestConfiguration.swift](../../Sources/Apollo/RequestConfiguration.swift) — current 2.x request configuration value type that gains `ttlEnforcement` From da9a2ffa7af36d386050a33271c61f2c5059ab66 Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Wed, 20 May 2026 12:43:31 -0700 Subject: [PATCH 12/24] docs(cache): codify inline-documentation conventions (#988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approved by Anthony — codifies the inline-documentation conventions established during PR-005 review. --- .../Design/cache-rewrite-phase1-execution.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apollo-ios/Design/cache-rewrite-phase1-execution.md b/apollo-ios/Design/cache-rewrite-phase1-execution.md index 0f424dbb3..9f7d60b46 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-execution.md +++ b/apollo-ios/Design/cache-rewrite-phase1-execution.md @@ -51,6 +51,26 @@ This is the operating manual for an AI agent (Claude Code) executing the Phase 1 - **Plan revisions propagate.** If `cache-rewrite/phase-1-plan` is updated directly (a commit landing on it that revises any of the three design docs), every open stacked PR is rebased onto the new tip in stack order at the start of the next session. - **`cache-rewrite/phase-1-plan` itself is only merged into `main` once all 31 implementation PRs are merged into it** — see §10 done conditions. +### Inline documentation conventions + +Inline comments and doc comments in source files describe what the code *is* and what it *does* at the current revision. They are read by future maintainers and by anyone reading the code in the upstream repos that the subtree directories are pushed to (`apollo-ios`, `apollo-ios-codegen`, `apollo-ios-pagination`). Several things that belong in PR descriptions or in the design docs do **not** belong in inline comments. + +**Do not reference in code comments:** + +- **ADRs.** ADR files live in `apollo-ios/Design/adr/` and are dev-repo-only design documentation. Upstream readers don't have access to them, and even dev-repo readers shouldn't need to chase a link to understand the code at hand. If a design decision is necessary to understand the code, paraphrase the *reason* inline; don't link to the ADR. +- **PR identifiers.** `PR-NNN` numbers are dev-repo workflow concepts. They mean nothing in the upstream repo and become stale even in the dev repo once the work has merged. +- **Phase identifiers.** "Phase 1," "Phase 2," etc. are execution-plan workflow terms. They don't belong in source code. +- **Forward-looking roadmap commentary.** Phrases like "will land in a later PR" or "Phase 2 will add X" describe planning intent, not the current code. They belong in PR descriptions or in the design docs. + +**Do include inline:** + +- What the type or function *is* and what it does. +- Why a non-obvious behavior choice was made (e.g., "drops sub-second precision because the timestamp is second-resolution"). +- Non-obvious invariants the code depends on. +- API contract notes for public symbols. + +The rule applies most strictly to code in subtree directories (`apollo-ios/Sources/`, `apollo-ios-codegen/Sources/`, `apollo-ios-pagination/Sources/`) which get pushed upstream. Files outside subtree directories (`Tests/`, `Sources/` test APIs, `scripts/`) are dev-repo-only and can reference ADRs / PRs / phases where genuinely useful — but the same principle of preferring inline explanation over external reference still produces clearer code. + ### Commit message convention Follow the repo's existing conventional-commits style observed in `git log` (e.g., `chore(deps): …`, `docs: …`, `feat: …`, `fix: …`, `feature: …`). Commit messages end with the `Co-Authored-By` line per `CLAUDE.md`. Use HEREDOC for multi-line messages. From 26185b9761d024453db6a62333ea86ed93e55fb1 Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Tue, 26 May 2026 12:41:20 -0700 Subject: [PATCH 13/24] =?UTF-8?q?docs(cache):=20add=20progress-tracker=20u?= =?UTF-8?q?pdate=20to=20=C2=A74.7=20on-merge=20checklist=20(#997)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approved as part of the manager-tracker workflow setup. The single failing unit test on the docs-only branch is unrelated — pre-existing flaky test (`test__cancellingTask__propogatesTaskCancellationToInterceptors`). --- apollo-ios/Design/cache-rewrite-phase1-execution.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apollo-ios/Design/cache-rewrite-phase1-execution.md b/apollo-ios/Design/cache-rewrite-phase1-execution.md index 9f7d60b46..aef90d45c 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-execution.md +++ b/apollo-ios/Design/cache-rewrite-phase1-execution.md @@ -169,7 +169,8 @@ Quality gates per §5 must all be green before opening the PR. If any gate fails 1. Pull the merged change into local `main` (or the merged base). 2. Rebase the next stacked PR onto the new base; resolve any conflicts. 3. Re-verify the next PR's quality gates after rebase. -4. Continue with the next PR. +4. Update the progress tracker in the plan-branch PR's description (the long-lived PR with base `main` and head `cache-rewrite/phase-1-plan`): flip the merged PR's row from 🟡 to ✅, fill in the merge date, and adjust per-phase / overall percentages. Done via `gh api repos/.../pulls/{plan-PR-number} -X PATCH -F body=@…` per the dev-repo's GitHub-CLI workaround in `CLAUDE.md`. +5. Continue with the next PR. ## 5. Quality gates From 7e4b411a54e6c840f2b0f7efb43ff8a037ac26df Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Tue, 26 May 2026 12:41:34 -0700 Subject: [PATCH 14/24] docs(cache): note Phase 1A writtenAt default and Phase 1C migration plan (#991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approved — companion to the PR-006 stack discussion. Documents the Phase 1A interim writtenAt default and the Phase 1C migration trigger. --- apollo-ios/Design/cache-rewrite-phase1-plan.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apollo-ios/Design/cache-rewrite-phase1-plan.md b/apollo-ios/Design/cache-rewrite-phase1-plan.md index db181fe60..bfac82c64 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-plan.md +++ b/apollo-ios/Design/cache-rewrite-phase1-plan.md @@ -191,6 +191,10 @@ struct SystemTimeProvider: TimeProvider { The provider is held by `ApolloStore` and threaded into the normalizer construction. +**Phase 1A interim:** `Record`'s legacy convenience initializer (`Record(key:_:writtenAt:)`) introduced alongside the field-aware `Record` change uses `writtenAt: Int64 = 0` as a default so existing call sites (the result normalizer's `Record(key: cachePath, object)` site, plus test fixtures using the dictionary-literal form) continue to compile unchanged. This is purely a placeholder during Phase 1A and 1B — no code reads `writtenAt` for any decision until TTL evaluation lands in Phase 1C. The reason the default is `0` rather than `Date.now` is to preserve deterministic equality for the parser-record tests (per [§3.2](#32-record-becomes-field-aware), two `Record`s with the same key and same field values but different `writtenAt` timestamps compare unequal; a `Date.now` default would make every test assertion comparing parser output against a literal-constructed fixture race against the wall clock). + +**Phase 1C migration:** when the `TimeProvider` plumbing above lands, the convenience initializer's `writtenAt` default is **dropped entirely** — the parameter becomes required. Production write sites pass the `TimeProvider`'s current epoch seconds; tests pass a pinned value from a stubbed clock. This forces every record-write site to take an explicit stance on the write timestamp rather than silently accepting an epoch-`0` record that would be immediately stale once TTL evaluation goes live. + ## 5. Read-mode split Two read modes exist on `ApolloStore.load`: From fb5efc6d38365daacaa2da2718bc523803c477c5 Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Tue, 26 May 2026 12:41:56 -0700 Subject: [PATCH 15/24] feat(perf): macOS 2.x cache baseline benchmark harness + dataset (PR-004a) (#980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-004a — macOS 2.x cache baseline benchmark harness + dataset. All CI green. Approved. --- .../CacheBenchmarks/BenchmarkHarness.swift | 84 ++++ .../CacheBenchmarks/BenchmarkResult.swift | 46 +++ .../CacheBenchmarks/BenchmarkWorkloads.swift | 36 ++ .../Tier1FetchBenchmarks.swift | 384 ++++++++++++++++++ .../Tier2CacheBenchmarks.swift | 192 +++++++++ .../CacheBenchmarks/baseline-2.x.json | 178 ++++++++ .../Apollo-CacheBenchmarksTestPlan.xctestplan | 29 ++ .../Apollo-PerformanceTestPlan.xctestplan | 6 + .../Enums/ApolloTestPlan.swift | 7 +- .../Target+ApolloPerformanceTests.swift | 4 +- .../Design/cache-rewrite-phase1-perf.md | 17 +- scripts/capture-perf-baseline.sh | 76 ++++ 12 files changed, 1042 insertions(+), 17 deletions(-) create mode 100644 Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkHarness.swift create mode 100644 Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkResult.swift create mode 100644 Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkWorkloads.swift create mode 100644 Tests/ApolloPerformanceTests/CacheBenchmarks/Tier1FetchBenchmarks.swift create mode 100644 Tests/ApolloPerformanceTests/CacheBenchmarks/Tier2CacheBenchmarks.swift create mode 100644 Tests/ApolloPerformanceTests/CacheBenchmarks/baseline-2.x.json create mode 100644 Tests/TestPlans/Apollo-CacheBenchmarksTestPlan.xctestplan create mode 100755 scripts/capture-perf-baseline.sh diff --git a/Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkHarness.swift b/Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkHarness.swift new file mode 100644 index 000000000..017364446 --- /dev/null +++ b/Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkHarness.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Async measurement harness used by the cache-rewrite Phase 1 perf dataset. +/// +/// Runs `body` for `warmupIterations + measuredIterations`, discards warmup, +/// and computes mean/std/P50/P95/P99 from the measured samples in milliseconds. +/// Emits a single `BENCHMARK_RESULT_JSONL: { ... }` line to stdout per call so +/// the dataset assembler script can grep results out of `xcodebuild` output. +/// +/// One iteration runs `body` exactly once; the harness does not retry on throw — +/// a thrown error fails the test outright. Pass `setup` for per-iteration state +/// that should not be timed (cache warmup, fresh fixtures, etc.). +public struct BenchmarkHarness: Sendable { + public let scenario: String + public let tier: Int + public let warmupIterations: Int + public let measuredIterations: Int + + public init( + scenario: String, + tier: Int, + warmupIterations: Int = 5, + measuredIterations: Int = 50 + ) { + self.scenario = scenario + self.tier = tier + self.warmupIterations = warmupIterations + self.measuredIterations = measuredIterations + } + + /// Run `body` for the configured iteration count and emit a `BenchmarkResult`. + /// + /// `setup` runs before each iteration *outside* the measurement window. Use it + /// to reset fixtures (clear cache, seed records) without polluting the sample. + /// Closures are non-`Sendable` because they routinely capture references to + /// non-`Sendable` caches/stores under test; the harness itself runs the loop + /// sequentially so there's no actual concurrency to worry about. + public func measure( + setup: ((Int) async throws -> Void)? = nil, + body: (Int) async throws -> Void + ) async throws -> BenchmarkResult { + var iterationDurationsNs: [UInt64] = [] + iterationDurationsNs.reserveCapacity(measuredIterations) + + for i in 0..<(warmupIterations + measuredIterations) { + try await setup?(i) + + let start = DispatchTime.now().uptimeNanoseconds + try await body(i) + let end = DispatchTime.now().uptimeNanoseconds + + if i >= warmupIterations { + iterationDurationsNs.append(end &- start) + } + } + + let result = BenchmarkResult( + scenario: scenario, + tier: tier, + iterationDurationsNs: iterationDurationsNs, + iterations: measuredIterations + ) + BenchmarkOutput.emit(result) + return result + } +} + +/// Emits `BenchmarkResult` records as JSONL lines that the run script picks up. +public enum BenchmarkOutput { + static let encoder: JSONEncoder = { + let e = JSONEncoder() + e.outputFormatting = [.sortedKeys] + return e + }() + + public static func emit(_ result: BenchmarkResult) { + guard let data = try? encoder.encode(result), + let line = String(data: data, encoding: .utf8) else { + return + } + // Single-line marker so the post-processor can grep + jq. + print("BENCHMARK_RESULT_JSONL: \(line)") + } +} diff --git a/Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkResult.swift b/Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkResult.swift new file mode 100644 index 000000000..66d11d1bb --- /dev/null +++ b/Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkResult.swift @@ -0,0 +1,46 @@ +import Foundation + +/// One measured scenario row in the published perf dataset. +/// +/// Schema corresponds to `cache-rewrite-phase1-perf.md` §5.1. Latencies are +/// reported in milliseconds for human readability; the harness records in +/// nanoseconds internally to avoid integer truncation on sub-ms samples. +public struct BenchmarkResult: Codable, Sendable { + public let scenario: String + public let tier: Int + public let iterations: Int + public let mean_ms: Double + public let std_ms: Double + public let p50_ms: Double + public let p95_ms: Double + public let p99_ms: Double + + public init(scenario: String, tier: Int, iterationDurationsNs: [UInt64], iterations: Int) { + self.scenario = scenario + self.tier = tier + self.iterations = iterations + + let iterationDurationsMs = iterationDurationsNs.map { Double($0) / 1_000_000.0 } + let sorted = iterationDurationsMs.sorted() + + let mean = iterationDurationsMs.reduce(0, +) / Double(max(iterationDurationsMs.count, 1)) + let variance = iterationDurationsMs.reduce(0.0) { $0 + ($1 - mean) * ($1 - mean) } + / Double(max(iterationDurationsMs.count - 1, 1)) + + self.mean_ms = mean + self.std_ms = variance.squareRoot() + self.p50_ms = Self.percentile(sorted, 0.50) + self.p95_ms = Self.percentile(sorted, 0.95) + self.p99_ms = Self.percentile(sorted, 0.99) + } + + private static func percentile(_ sorted: [Double], _ p: Double) -> Double { + guard !sorted.isEmpty else { return 0 } + let rank = p * Double(sorted.count - 1) + let lo = Int(rank.rounded(.down)) + let hi = Int(rank.rounded(.up)) + if lo == hi { return sorted[lo] } + let frac = rank - Double(lo) + return sorted[lo] + (sorted[hi] - sorted[lo]) * frac + } +} diff --git a/Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkWorkloads.swift b/Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkWorkloads.swift new file mode 100644 index 000000000..74168ac06 --- /dev/null +++ b/Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkWorkloads.swift @@ -0,0 +1,36 @@ +@_spi(Execution) import Apollo +import ApolloAPI +import Foundation + +/// Synthetic workload helpers shared across Tier 2 (`NormalizedCache`) benchmarks. +/// +/// Mirrors the size buckets in `cache-rewrite-phase1-perf.md` §3.1. Records use +/// stable cache keys (`record_<index>`) and a deterministic field-name pattern +/// so independent runs produce comparable shapes. +public enum BenchmarkWorkloads { + public static let fieldsPerRecord = 10 + + /// Build `count` records of `fieldsPerRecord` scalar fields each. Fields + /// alternate between String and Int values so the cache exercises both + /// scalar paths during serialization. + public static func syntheticRecords(count: Int) -> [Record] { + (0..<count).map { i in + var fields: Record.Fields = [:] + for f in 0..<fieldsPerRecord { + let key: CacheKey = "field_\(f)" + if f % 2 == 0 { + fields[key] = "value_\(i)_\(f)" + } else { + fields[key] = i * 100 + f + } + } + return Record(key: "record_\(i)", fields) + } + } + + /// First `count` cache keys produced by `syntheticRecords(count:)`. Useful + /// for batched `loadRecords(forKeys:)` scenarios. + public static func syntheticKeys(count: Int) -> Set<CacheKey> { + Set((0..<count).map { CacheKey("record_\($0)") }) + } +} diff --git a/Tests/ApolloPerformanceTests/CacheBenchmarks/Tier1FetchBenchmarks.swift b/Tests/ApolloPerformanceTests/CacheBenchmarks/Tier1FetchBenchmarks.swift new file mode 100644 index 000000000..125cacafa --- /dev/null +++ b/Tests/ApolloPerformanceTests/CacheBenchmarks/Tier1FetchBenchmarks.swift @@ -0,0 +1,384 @@ +@_spi(Execution) import Apollo +@_spi(Execution) import ApolloAPI +@_spi(Execution) import ApolloInternalTestHelpers +import Foundation +import XCTest + +/// Tier 1 — `ApolloClient.fetch` end-to-end benchmarks. See +/// `apollo-ios/Design/cache-rewrite-phase1-perf.md` §2.1 for scenario definitions. +/// +/// Drives a single small query (`HeroNameQuery`) through each cache policy and +/// measures wall-clock latency from call to result delivery. The mock server's +/// random delay is zeroed so the measurement reflects cache + executor cost, +/// not artificial network jitter. +final class Tier1FetchBenchmarks: XCTestCase { + + static let measuredIterations = 50 + + /// Shared selection set for all Tier 1 scenarios — keeps the comparison + /// clean by holding query shape constant across cache policies. + final class HeroNameSelectionSet: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { + [.field("hero", Hero.self)] + } + + final class Hero: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { + [ + .field("__typename", String.self), + .field("name", String.self), + ] + } + } + } + + /// Build a fresh store + client + mock server for one benchmark scenario. + private func makeTestRig() async -> (store: ApolloStore, server: MockGraphQLServer, client: ApolloClient) { + let store = ApolloStore(cache: InMemoryNormalizedCache()) + let server = MockGraphQLServer() + await server.setDelay(milliseconds: 0) + let transport = MockNetworkTransport(mockServer: server, store: store) + let client = ApolloClient(networkTransport: transport, store: store) + return (store, server, client) + } + + /// Standard fixture response — identical shape across scenarios so the + /// network/parse cost is comparable. + private static let fixtureResponse: JSONObject = [ + "data": [ + "hero": [ + "__typename": "Droid", + "name": "R2-D2", + ] + ] + ] + + /// Pre-populated cache records that satisfy `HeroNameSelectionSet`. + private static let fixtureRecords: RecordSet = [ + "QUERY_ROOT": ["hero": CacheReference("hero")], + "hero": [ + "__typename": "Droid", + "name": "R2-D2", + ], + ] + + // MARK: - Scenario 1: cold cache, network only + + func test__tier1__cold_cache_network_only() async throws { + let (_, server, client) = await makeTestRig() + + let harness = BenchmarkHarness( + scenario: "tier1.cold_cache_network_only", + tier: 1, + measuredIterations: Self.measuredIterations + ) + let expectation = await server.expect(MockQuery<HeroNameSelectionSet>.self) { @Sendable _ in + Self.fixtureResponse + } + expectation.expectedFulfillmentCount = harness.warmupIterations + harness.measuredIterations + expectation.assertForOverFulfill = false + + _ = try await harness.measure { _ in + let query = MockQuery<HeroNameSelectionSet>() + _ = try await client.fetch(query: query, cachePolicy: .networkOnly) + } + } + + // MARK: - Scenario 2: warm cache, cache-first hit + + func test__tier1__warm_cache_first_hit() async throws { + let (store, _, client) = await makeTestRig() + try await store.publish(records: Self.fixtureRecords) + + let harness = BenchmarkHarness( + scenario: "tier1.warm_cache_first_hit", + tier: 1, + measuredIterations: Self.measuredIterations + ) + _ = try await harness.measure { _ in + let query = MockQuery<HeroNameSelectionSet>() + _ = try await client.fetch(query: query, cachePolicy: .cacheFirst) + } + } + + // MARK: - Scenario 3: cache-first miss falling back to network + + func test__tier1__cache_first_miss_falls_back_to_network() async throws { + let (store, server, client) = await makeTestRig() + + // Pre-populate the cache *without* the requested field so every iteration + // hits the cache, misses, falls back to network, and writes the new value. + let partialRecords: RecordSet = [ + "QUERY_ROOT": ["hero": CacheReference("hero")], + // `hero` record exists but lacks `name` — the executor will treat + // `name` as a missing-field cache miss. + "hero": ["__typename": "Droid"], + ] + try await store.publish(records: partialRecords) + + let harness = BenchmarkHarness( + scenario: "tier1.cache_first_miss_network_fallback", + tier: 1, + measuredIterations: Self.measuredIterations + ) + let expectation = await server.expect(MockQuery<HeroNameSelectionSet>.self) { @Sendable _ in + Self.fixtureResponse + } + expectation.expectedFulfillmentCount = harness.warmupIterations + harness.measuredIterations + expectation.assertForOverFulfill = false + _ = try await harness.measure( + setup: { _ in + // Restore the partial-record state each iteration: the previous + // iteration's network fallback wrote the full record back, so we + // need to clear and re-seed to keep the scenario consistent. + try await store.clearCache() + try await store.publish(records: partialRecords) + }, + body: { _ in + let query = MockQuery<HeroNameSelectionSet>() + _ = try await client.fetch(query: query, cachePolicy: .cacheFirst) + } + ) + } + + // MARK: - Scenario 4: cacheAndNetwork — stream both responses + + func test__tier1__cache_and_network() async throws { + let (store, server, client) = await makeTestRig() + try await store.publish(records: Self.fixtureRecords) + + let harness = BenchmarkHarness( + scenario: "tier1.cache_and_network", + tier: 1, + measuredIterations: Self.measuredIterations + ) + let expectation = await server.expect(MockQuery<HeroNameSelectionSet>.self) { @Sendable _ in + Self.fixtureResponse + } + expectation.expectedFulfillmentCount = harness.warmupIterations + harness.measuredIterations + expectation.assertForOverFulfill = false + _ = try await harness.measure { _ in + let query = MockQuery<HeroNameSelectionSet>() + var deliveries = 0 + for try await _ in try client.fetch(query: query, cachePolicy: .cacheAndNetwork) { + deliveries += 1 + } + // Both responses (cache + network) must arrive for this scenario to + // be measured correctly. Assert as a guard, not a perf assertion. + XCTAssertEqual(deliveries, 2, "cacheAndNetwork must yield exactly two responses") + } + } + + // MARK: - Scenario 5: warm cache, single ref hop (nested object) + // + // Exercises CacheReference resolution: QUERY_ROOT → hero → bestFriend. + // Two cache records resolved across two batched loads (one for hero, + // one for the single bestFriend reference). Compares against the flat + // `warm_cache_first_hit` scenario to surface the cost of one ref hop. + + final class NestedHeroSelectionSet: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { + [.field("hero", Hero.self)] + } + + final class Hero: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { + [ + .field("__typename", String.self), + .field("name", String.self), + .field("bestFriend", BestFriend.self), + ] + } + + final class BestFriend: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { + [ + .field("__typename", String.self), + .field("name", String.self), + ] + } + } + } + } + + private static let nestedFixtureRecords: RecordSet = [ + "QUERY_ROOT": ["hero": CacheReference("hero")], + "hero": [ + "__typename": "Droid", + "name": "R2-D2", + "bestFriend": CacheReference("bestFriend"), + ], + "bestFriend": [ + "__typename": "Human", + "name": "Luke Skywalker", + ], + ] + + func test__tier1__warm_cache_first_hit_nested_object() async throws { + let (store, _, client) = await makeTestRig() + try await store.publish(records: Self.nestedFixtureRecords) + + let harness = BenchmarkHarness( + scenario: "tier1.warm_cache_first_hit_nested_object", + tier: 1, + measuredIterations: Self.measuredIterations + ) + _ = try await harness.measure { _ in + let query = MockQuery<NestedHeroSelectionSet>() + _ = try await client.fetch(query: query, cachePolicy: .cacheFirst) + } + } + + // MARK: - Scenario 6: warm cache, 1:N array of refs + // + // Exercises the batched-load path: hero.friends is an array of 20 + // CacheReferences, all resolved in a single batched loadRecords call. + // Realistic shape for paginated list queries. + + static let friendsCount = 20 + + final class HeroWithFriendsSelectionSet: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { + [.field("hero", Hero.self)] + } + + final class Hero: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { + [ + .field("__typename", String.self), + .field("name", String.self), + .field("friends", [Friend]?.self), + ] + } + + final class Friend: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { + [ + .field("__typename", String.self), + .field("name", String.self), + ] + } + } + } + } + + private static func arrayOfRefsFixture() -> RecordSet { + var records: RecordSet = [ + "QUERY_ROOT": ["hero": CacheReference("hero")], + "hero": [ + "__typename": "Droid", + "name": "R2-D2", + "friends": (0..<friendsCount).map { CacheReference("friend_\($0)") }, + ], + ] + for i in 0..<friendsCount { + records.insert(Record(key: "friend_\(i)", [ + "__typename": "Human", + "name": "Friend \(i)", + ])) + } + return records + } + + func test__tier1__warm_cache_first_hit_array_of_refs() async throws { + let (store, _, client) = await makeTestRig() + try await store.publish(records: Self.arrayOfRefsFixture()) + + let harness = BenchmarkHarness( + scenario: "tier1.warm_cache_first_hit_array_of_refs_20", + tier: 1, + measuredIterations: Self.measuredIterations + ) + _ = try await harness.measure { _ in + let query = MockQuery<HeroWithFriendsSelectionSet>() + _ = try await client.fetch(query: query, cachePolicy: .cacheFirst) + } + } + + // MARK: - Scenario 7: warm cache, N:M shared records (dedup payoff) + // + // 20 friends each reference one of 3 shared homeworlds. The executor's + // ref-resolution dedup should batch-load 3 unique homeworld records + // rather than 20 — this is THE normalization payoff Apollo's cache + // exists for, and it's worth measuring against a 3.0 baseline. + + static let homeworldKeys = ["tatooine", "alderaan", "naboo"] + + final class HeroWithFriendsAndHomeworldSelectionSet: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { + [.field("hero", Hero.self)] + } + + final class Hero: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { + [ + .field("__typename", String.self), + .field("name", String.self), + .field("friends", [Friend]?.self), + ] + } + + final class Friend: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { + [ + .field("__typename", String.self), + .field("name", String.self), + .field("homeworld", Homeworld.self), + ] + } + + final class Homeworld: MockSelectionSet, @unchecked Sendable { + override class var __selections: [Selection] { + [ + .field("__typename", String.self), + .field("name", String.self), + ] + } + } + } + } + } + + private static func sharedRecordsFixture() -> RecordSet { + var records: RecordSet = [ + "QUERY_ROOT": ["hero": CacheReference("hero")], + "hero": [ + "__typename": "Droid", + "name": "R2-D2", + "friends": (0..<friendsCount).map { CacheReference("friend_\($0)") }, + ], + ] + for i in 0..<friendsCount { + let homeworldKey = "homeworld_\(homeworldKeys[i % homeworldKeys.count])" + records.insert(Record(key: "friend_\(i)", [ + "__typename": "Human", + "name": "Friend \(i)", + "homeworld": CacheReference(homeworldKey), + ])) + } + let planetNames = ["Tatooine", "Alderaan", "Naboo"] + for (i, key) in homeworldKeys.enumerated() { + records.insert(Record(key: "homeworld_\(key)", [ + "__typename": "Planet", + "name": planetNames[i], + ])) + } + return records + } + + func test__tier1__warm_cache_first_hit_shared_records() async throws { + let (store, _, client) = await makeTestRig() + try await store.publish(records: Self.sharedRecordsFixture()) + + let harness = BenchmarkHarness( + scenario: "tier1.warm_cache_first_hit_shared_records_20_friends_3_homeworlds", + tier: 1, + measuredIterations: Self.measuredIterations + ) + _ = try await harness.measure { _ in + let query = MockQuery<HeroWithFriendsAndHomeworldSelectionSet>() + _ = try await client.fetch(query: query, cachePolicy: .cacheFirst) + } + } +} + diff --git a/Tests/ApolloPerformanceTests/CacheBenchmarks/Tier2CacheBenchmarks.swift b/Tests/ApolloPerformanceTests/CacheBenchmarks/Tier2CacheBenchmarks.swift new file mode 100644 index 000000000..b23f068ee --- /dev/null +++ b/Tests/ApolloPerformanceTests/CacheBenchmarks/Tier2CacheBenchmarks.swift @@ -0,0 +1,192 @@ +@_spi(Execution) import Apollo +import ApolloAPI +import ApolloInternalTestHelpers +import ApolloSQLite +import Foundation +import XCTest + +/// Tier 2 — `NormalizedCache` protocol benchmarks. See +/// `apollo-ios/Design/cache-rewrite-phase1-perf.md` §2.2 for scenario definitions. +/// +/// Subclasses bind `makeCache` to a specific backend so XCTest discovery yields +/// one test class per backend (`InMemory`, `SQLite`). The five scenarios are +/// identical across backends; the only difference is the cache factory. +class Tier2CacheBenchmarksBase: XCTestCase { + + /// Backend label included in the emitted scenario name (e.g. "InMemory", "SQLite"). + class var backendLabel: String { "Base" } + + /// Build a fresh cache plus optional teardown handler for resource cleanup. + class func makeCache() async -> TestDependency<any NormalizedCache> { + fatalError("override in subclass") + } + + /// Holder for per-iteration cache state. `@unchecked Sendable` because the + /// harness runs the setup → body loop sequentially — there is no actual + /// concurrent access despite the compiler's pessimism about closures that + /// span async/main-actor boundaries. + final class CacheHolder: @unchecked Sendable { + var cache: (any NormalizedCache)? + var teardown: TearDownHandler? + + func reset(with cache: any NormalizedCache, teardown: TearDownHandler?) throws { + try self.teardown?() + self.cache = cache + self.teardown = teardown + } + + func tearDownIfNeeded() throws { + try teardown?() + cache = nil + teardown = nil + } + } + + // MARK: - Scenario 1: Single-key load (one record, 10 fields) + + func runSingleKeyLoad() async throws { + let (cache, tearDown) = await Self.makeCache() + addTeardownBlock { try? tearDown?() } + + _ = try await cache.merge(records: RecordSet(records: BenchmarkWorkloads.syntheticRecords(count: 1))) + let keys: Set<CacheKey> = ["record_0"] + + let harness = BenchmarkHarness( + scenario: "tier2.\(Self.backendLabel.lowercased()).single_key_load_10_fields", + tier: 2 + ) + _ = try await harness.measure { _ in + _ = try await cache.loadRecords(forKeys: keys) + } + } + + // MARK: - Scenario 2: Batch load (100 records) + + func runBatchLoad() async throws { + let (cache, tearDown) = await Self.makeCache() + addTeardownBlock { try? tearDown?() } + + _ = try await cache.merge(records: RecordSet(records: BenchmarkWorkloads.syntheticRecords(count: 100))) + let keys = BenchmarkWorkloads.syntheticKeys(count: 100) + + let harness = BenchmarkHarness( + scenario: "tier2.\(Self.backendLabel.lowercased()).batch_load_100_records", + tier: 2 + ) + _ = try await harness.measure { _ in + _ = try await cache.loadRecords(forKeys: keys) + } + } + + // MARK: - Scenario 3: Single-record merge (10 new fields into one record) + // + // The perf plan asks for "merge 10 new fields into one record". We model that + // by merging a fresh single-record set into the same cache each iteration — + // the merge logic exercises the same code path as a real per-record write. + + func runSingleRecordMerge() async throws { + let (cache, tearDown) = await Self.makeCache() + addTeardownBlock { try? tearDown?() } + + let harness = BenchmarkHarness( + scenario: "tier2.\(Self.backendLabel.lowercased()).single_record_merge_10_fields", + tier: 2 + ) + _ = try await harness.measure { iteration in + // Use a distinct key per iteration so each merge is a write, not a no-op + // upsert; otherwise the cache could optimize the second-onwards writes. + var fields: Record.Fields = [:] + for f in 0..<BenchmarkWorkloads.fieldsPerRecord { + fields["field_\(f)"] = "value_\(iteration)_\(f)" + } + let record = Record(key: "merge_iter_\(iteration)", fields) + _ = try await cache.merge(records: RecordSet(records: [record])) + } + } + + // MARK: - Scenario 4: Many-record merge (1,000 records into fresh cache) + + func runManyRecordMerge() async throws { + let harness = BenchmarkHarness( + scenario: "tier2.\(Self.backendLabel.lowercased()).many_record_merge_1000_records", + tier: 2 + ) + let holder = CacheHolder() + addTeardownBlock { try? holder.tearDownIfNeeded() } + + let records = RecordSet(records: BenchmarkWorkloads.syntheticRecords(count: 1_000)) + _ = try await harness.measure( + setup: { _ in + let (c, t) = await Self.makeCache() + try holder.reset(with: c, teardown: t) + }, + body: { _ in + _ = try await holder.cache!.merge(records: records) + } + ) + } + + // MARK: - Scenario 5: Pattern delete against 10k records + + func runPatternDelete() async throws { + let harness = BenchmarkHarness( + scenario: "tier2.\(Self.backendLabel.lowercased()).pattern_delete_10k_records", + tier: 2 + ) + let holder = CacheHolder() + addTeardownBlock { try? holder.tearDownIfNeeded() } + + // Seed records under two prefixes; the matching delete only targets + // `User_*`, leaving `Other_*` untouched. This matches the perf plan + // scenario which exercises pattern selectivity, not full clear. + let userPrefixCount = 10_000 + let otherPrefixCount = 1_000 + let seedRecords: [Record] = (0..<userPrefixCount).map { i in + var fields: Record.Fields = [:] + for f in 0..<BenchmarkWorkloads.fieldsPerRecord { + fields["field_\(f)"] = "value_\(i)_\(f)" + } + return Record(key: "User_\(i)", fields) + } + (0..<otherPrefixCount).map { i in + Record(key: "Other_\(i)", ["field_0": "value_\(i)"]) + } + let seedSet = RecordSet(records: seedRecords) + + _ = try await harness.measure( + setup: { _ in + let (c, t) = await Self.makeCache() + try holder.reset(with: c, teardown: t) + _ = try await c.merge(records: seedSet) + }, + body: { _ in + try await holder.cache!.removeRecords(matching: "User_") + } + ) + } +} + +final class Tier2InMemoryCacheBenchmarks: Tier2CacheBenchmarksBase { + override class var backendLabel: String { "InMemory" } + override class func makeCache() async -> TestDependency<any NormalizedCache> { + await InMemoryTestCacheProvider.makeNormalizedCache() + } + + func test__tier2_inMemory__singleKeyLoad() async throws { try await runSingleKeyLoad() } + func test__tier2_inMemory__batchLoad() async throws { try await runBatchLoad() } + func test__tier2_inMemory__singleRecordMerge() async throws { try await runSingleRecordMerge() } + func test__tier2_inMemory__manyRecordMerge() async throws { try await runManyRecordMerge() } + func test__tier2_inMemory__patternDelete() async throws { try await runPatternDelete() } +} + +final class Tier2SQLiteCacheBenchmarks: Tier2CacheBenchmarksBase { + override class var backendLabel: String { "SQLite" } + override class func makeCache() async -> TestDependency<any NormalizedCache> { + await SQLiteTestCacheProvider.makeNormalizedCache() + } + + func test__tier2_sqlite__singleKeyLoad() async throws { try await runSingleKeyLoad() } + func test__tier2_sqlite__batchLoad() async throws { try await runBatchLoad() } + func test__tier2_sqlite__singleRecordMerge() async throws { try await runSingleRecordMerge() } + func test__tier2_sqlite__manyRecordMerge() async throws { try await runManyRecordMerge() } + func test__tier2_sqlite__patternDelete() async throws { try await runPatternDelete() } +} diff --git a/Tests/ApolloPerformanceTests/CacheBenchmarks/baseline-2.x.json b/Tests/ApolloPerformanceTests/CacheBenchmarks/baseline-2.x.json new file mode 100644 index 000000000..bfa1dd271 --- /dev/null +++ b/Tests/ApolloPerformanceTests/CacheBenchmarks/baseline-2.x.json @@ -0,0 +1,178 @@ +{ + "version": "2.x", + "captured_at": "2026-05-19T18:05:58Z", + "git_sha": "c3ed76fd91ab404c321a1cd6a574789074ac8752", + "device": "macOS 26.4.1 (MacBook Pro M1 Pro, arm64)", + "results": [ + { + "iterations": 50, + "mean_ms": 0.2943150599999999, + "p50_ms": 0.28412499999999996, + "p95_ms": 0.38920189999999993, + "p99_ms": 0.43746807999999987, + "scenario": "tier1.cache_and_network", + "std_ms": 0.04824122328144473, + "tier": 1 + }, + { + "iterations": 50, + "mean_ms": 0.2762991799999999, + "p50_ms": 0.27083349999999995, + "p95_ms": 0.34439404999999995, + "p99_ms": 0.36066974, + "scenario": "tier1.cache_first_miss_network_fallback", + "std_ms": 0.03842766114731227, + "tier": 1 + }, + { + "iterations": 50, + "mean_ms": 0.20843082000000007, + "p50_ms": 0.1951255, + "p95_ms": 0.30764739999999985, + "p99_ms": 0.36278215999999996, + "scenario": "tier1.cold_cache_network_only", + "std_ms": 0.047423537916932934, + "tier": 1 + }, + { + "iterations": 50, + "mean_ms": 0.10161255999999998, + "p50_ms": 0.09181249999999999, + "p95_ms": 0.13639549999999995, + "p99_ms": 0.23093716999999978, + "scenario": "tier1.warm_cache_first_hit", + "std_ms": 0.033197520971596534, + "tier": 1 + }, + { + "iterations": 50, + "mean_ms": 0.7211, + "p50_ms": 0.6888955, + "p95_ms": 0.8740106, + "p99_ms": 0.95869825, + "scenario": "tier1.warm_cache_first_hit_array_of_refs_20", + "std_ms": 0.07595628571965939, + "tier": 1 + }, + { + "iterations": 50, + "mean_ms": 0.1202075, + "p50_ms": 0.11456250000000001, + "p95_ms": 0.15084794999999995, + "p99_ms": 0.19324149999999984, + "scenario": "tier1.warm_cache_first_hit_nested_object", + "std_ms": 0.019257472863058345, + "tier": 1 + }, + { + "iterations": 50, + "mean_ms": 1.36967412, + "p50_ms": 1.335792, + "p95_ms": 1.5869583, + "p99_ms": 1.6687849199999998, + "scenario": "tier1.warm_cache_first_hit_shared_records_20_friends_3_homeworlds", + "std_ms": 0.12066024983138839, + "tier": 1 + }, + { + "iterations": 50, + "mean_ms": 0.02812414, + "p50_ms": 0.027958, + "p95_ms": 0.02875, + "p99_ms": 0.030532499999999994, + "scenario": "tier2.inmemory.batch_load_100_records", + "std_ms": 0.0006466977027758104, + "tier": 2 + }, + { + "iterations": 50, + "mean_ms": 6.7017583400000005, + "p50_ms": 6.659708, + "p95_ms": 7.31552325, + "p99_ms": 7.887077999999999, + "scenario": "tier2.inmemory.many_record_merge_1000_records", + "std_ms": 0.3997772763671992, + "tier": 2 + }, + { + "iterations": 50, + "mean_ms": 5.60135742, + "p50_ms": 5.4993545, + "p95_ms": 6.31913925, + "p99_ms": 6.6827517499999995, + "scenario": "tier2.inmemory.pattern_delete_10k_records", + "std_ms": 0.41843806930107574, + "tier": 2 + }, + { + "iterations": 50, + "mean_ms": 0.0005727200000000004, + "p50_ms": 0.000583, + "p95_ms": 0.000584, + "p99_ms": 0.0007093299999999998, + "scenario": "tier2.inmemory.single_key_load_10_fields", + "std_ms": 0.00003650833843809116, + "tier": 2 + }, + { + "iterations": 50, + "mean_ms": 0.015515760000000003, + "p50_ms": 0.0125625, + "p95_ms": 0.02569754999999999, + "p99_ms": 0.06842191999999986, + "scenario": "tier2.inmemory.single_record_merge_10_fields", + "std_ms": 0.013159113819427698, + "tier": 2 + }, + { + "iterations": 50, + "mean_ms": 2.950800880000001, + "p50_ms": 2.9601045, + "p95_ms": 3.16007675, + "p99_ms": 3.2261975, + "scenario": "tier2.sqlite.batch_load_100_records", + "std_ms": 0.12519300774708156, + "tier": 2 + }, + { + "iterations": 50, + "mean_ms": 56.663738460000005, + "p50_ms": 55.079500499999995, + "p95_ms": 57.7287663, + "p99_ms": 93.72824632999999, + "scenario": "tier2.sqlite.many_record_merge_1000_records", + "std_ms": 7.764093012148371, + "tier": 2 + }, + { + "iterations": 50, + "mean_ms": 12.173480780000004, + "p50_ms": 11.9349585, + "p95_ms": 13.4756981, + "p99_ms": 16.264947239999998, + "scenario": "tier2.sqlite.pattern_delete_10k_records", + "std_ms": 0.970686536545756, + "tier": 2 + }, + { + "iterations": 50, + "mean_ms": 0.045938400000000004, + "p50_ms": 0.0457705, + "p95_ms": 0.0478372, + "p99_ms": 0.04816817, + "scenario": "tier2.sqlite.single_key_load_10_fields", + "std_ms": 0.0008060563103842094, + "tier": 2 + }, + { + "iterations": 50, + "mean_ms": 2.06498762, + "p50_ms": 0.636667, + "p95_ms": 4.20499794999999, + "p99_ms": 31.56355490999995, + "scenario": "tier2.sqlite.single_record_merge_10_fields", + "std_ms": 6.5726935179914365, + "tier": 2 + } + ] +} diff --git a/Tests/TestPlans/Apollo-CacheBenchmarksTestPlan.xctestplan b/Tests/TestPlans/Apollo-CacheBenchmarksTestPlan.xctestplan new file mode 100644 index 000000000..655ccf94d --- /dev/null +++ b/Tests/TestPlans/Apollo-CacheBenchmarksTestPlan.xctestplan @@ -0,0 +1,29 @@ +{ + "configurations" : [ + { + "id" : "F8A7E920-D54E-4D6E-8C7D-CA8B5C9D6E91", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "selectedTests" : [ + "Tier1FetchBenchmarks", + "Tier2InMemoryCacheBenchmarks", + "Tier2SQLiteCacheBenchmarks" + ], + "target" : { + "containerPath" : "container:ApolloDev.xcodeproj", + "identifier" : "577B6E7422ED2B73EDB53D3F", + "name" : "ApolloPerformanceTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/TestPlans/Apollo-PerformanceTestPlan.xctestplan b/Tests/TestPlans/Apollo-PerformanceTestPlan.xctestplan index 10a9c782c..ded32fca5 100644 --- a/Tests/TestPlans/Apollo-PerformanceTestPlan.xctestplan +++ b/Tests/TestPlans/Apollo-PerformanceTestPlan.xctestplan @@ -13,6 +13,12 @@ }, "testTargets" : [ { + "skippedTests" : [ + "Tier1FetchBenchmarks", + "Tier2CacheBenchmarksBase", + "Tier2InMemoryCacheBenchmarks", + "Tier2SQLiteCacheBenchmarks" + ], "target" : { "containerPath" : "container:ApolloDev.xcodeproj", "identifier" : "577B6E7422ED2B73EDB53D3F", diff --git a/Tuist/ProjectDescriptionHelpers/Enums/ApolloTestPlan.swift b/Tuist/ProjectDescriptionHelpers/Enums/ApolloTestPlan.swift index 5e9771cf3..f0ee622ea 100644 --- a/Tuist/ProjectDescriptionHelpers/Enums/ApolloTestPlan.swift +++ b/Tuist/ProjectDescriptionHelpers/Enums/ApolloTestPlan.swift @@ -2,16 +2,19 @@ import Foundation import ProjectDescription enum ApolloTestPlan { + case cacheBenchmarkTest case ciTest case codegenTest case codegenCITest - case codegenCLITest + case codegenCLITest case paginationTest case performanceTest case unitTest - + var path: Path { switch self { + case .cacheBenchmarkTest: + return Path("Tests/TestPlans/Apollo-CacheBenchmarksTestPlan.xctestplan") case .ciTest: return Path("Tests/TestPlans/Apollo-CITestPlan.xctestplan") case .codegenTest: diff --git a/Tuist/ProjectDescriptionHelpers/Targets/Target+ApolloPerformanceTests.swift b/Tuist/ProjectDescriptionHelpers/Targets/Target+ApolloPerformanceTests.swift index cdbb0d6af..9f79677f5 100644 --- a/Tuist/ProjectDescriptionHelpers/Targets/Target+ApolloPerformanceTests.swift +++ b/Tuist/ProjectDescriptionHelpers/Targets/Target+ApolloPerformanceTests.swift @@ -23,6 +23,7 @@ extension Target { .target(name: ApolloTarget.animalKingdomAPI.name), .target(name: ApolloTarget.gitHubAPI.name), .package(product: "Apollo"), + .package(product: "ApolloSQLite"), .package(product: "Nimble"), ], settings: .forTarget(target) @@ -43,7 +44,8 @@ extension Scheme { ]), testAction: .testPlans( [ - ApolloTestPlan.performanceTest.path + ApolloTestPlan.performanceTest.path, + ApolloTestPlan.cacheBenchmarkTest.path, ], configuration: .debug ) diff --git a/apollo-ios/Design/cache-rewrite-phase1-perf.md b/apollo-ios/Design/cache-rewrite-phase1-perf.md index ab3e15bbb..1cedbcdee 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-perf.md +++ b/apollo-ios/Design/cache-rewrite-phase1-perf.md @@ -115,24 +115,13 @@ Stress workloads are not part of the per-PR gate; they run once at end of Phase - **Cold and warm cache states** measured separately. A cold scenario is preceded by `cache.clear()` and a fresh database file; a warm scenario is preceded by populating the cache to the workload's required state. - **Test isolation.** Each scenario runs in a fresh test fixture; no cross-scenario state. -### 4.2 Device matrix - -| Device | Purpose | -|---|---| -| **iPhone 16 Pro (physical)** | Primary gate device; matches Zach's benchmark. All performance gates assert against this device. | -| **iPhone 16 Pro Simulator** | CI-runnable; tracks device numbers approximately. Used in PR-merge gating where physical devices aren't available. | -| **iPhone SE (3rd gen) Simulator** | Older-device baseline. Catches regressions that only appear on slower hardware. | -| **macOS CLI (Mac mini M2 or equivalent)** | Fastest reference numbers; useful for development iteration. Not a gate. | - -The published dataset includes numbers from at least the first three. - -### 4.3 Tooling +### 4.2 Tooling - **XCTest performance tests (`measure { … }`)** for Tier 1, 2, and 3 latency captures. Built-in, integrates with the test runners. Statistical reporting limited to mean and standard deviation; we extract richer percentiles by inspecting the iteration array directly via the `XCTPerformanceMetric` API. - **`xctrace record`** for Tier 4 profiling. Captures `.trace` files; exported via `xctrace export`. - **Custom JSON exporter** for cross-version comparison. Each test produces a JSON line with `{scenario, tier, device, version, mean_ms, std_ms, p50_ms, p95_ms, p99_ms, iteration_count, timestamp}`. The reporter aggregates lines into the published dataset. -### 4.4 What we explicitly do not measure in Phase 1 +### 4.3 What we explicitly do not measure in Phase 1 - **Cold-launch cache initialization.** The drop-and-rebuild migration adds startup latency on the first 3.0 launch (one extra network round trip). This is documented behavior, not a regression to detect; not measured in this dataset. - **Database file size on disk.** Zach's benchmark measured this; the 7% size delta between single-col and multi-col layouts is settled. Re-measurement is not informative. @@ -196,7 +185,7 @@ Both the JSON and the markdown are checked into the repo so reviewers can see ex A new PR in Phase 0 captures the 2.x baseline: -- **PR-004a** (new): `chore(cache): capture 2.x performance baseline dataset`. Builds the harness against the 2.x codebase, runs against `main` on the gate device, produces `apollo-ios/Design/perf/baseline-2.x.json`. Stacks on PR-004 (the last ADR). +- **PR-004a** (new): `chore(cache): capture 2.x performance baseline dataset`. Builds the harness against the 2.x codebase, runs against `main` on the gate destination, produces `Tests/ApolloPerformanceTests/CacheBenchmarks/baseline-2.x.json`. Stacks on PR-004 (the last ADR). The harness code lives in a new directory `Tests/PerformanceBenchmarks/` outside the subtree directories so it is visible to dev-repo CI but not pushed upstream. diff --git a/scripts/capture-perf-baseline.sh b/scripts/capture-perf-baseline.sh new file mode 100755 index 000000000..50ef8fd36 --- /dev/null +++ b/scripts/capture-perf-baseline.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# Captures the 2.x cache-rewrite performance baseline by running the +# ApolloPerformanceTests/Apollo-CacheBenchmarksTestPlan against a destination +# and assembling the BENCHMARK_RESULT_JSONL lines into the dataset format +# defined in apollo-ios/Design/cache-rewrite-phase1-perf.md §5.1. +# +# Usage: +# scripts/capture-perf-baseline.sh # macOS run (dev iteration) +# scripts/capture-perf-baseline.sh -d "platform=iOS,name=My iPhone" \ +# -l "iPhone 16 Pro (physical)" \ +# -o Tests/ApolloPerformanceTests/CacheBenchmarks/baseline-2.x.json +# +# The script does not commit; it writes the JSON to -o and exits. +set -euo pipefail + +DESTINATION="platform=macOS" +DEVICE_LABEL="macOS" +OUTPUT_PATH="" +VERSION_LABEL="2.x" + +while getopts "d:l:o:v:" opt; do + case "$opt" in + d) DESTINATION="$OPTARG" ;; + l) DEVICE_LABEL="$OPTARG" ;; + o) OUTPUT_PATH="$OPTARG" ;; + v) VERSION_LABEL="$OPTARG" ;; + *) echo "usage: $0 [-d destination] [-l device-label] [-o output-path] [-v version]" >&2; exit 2 ;; + esac +done + +if [[ -z "$OUTPUT_PATH" ]]; then + echo "error: -o output-path is required" >&2 + exit 2 +fi + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +GIT_SHA="$(git rev-parse HEAD)" +CAPTURED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +RAW_LOG="$(mktemp -t apollo-perf-baseline-XXXXXX.log)" +trap 'rm -f "$RAW_LOG"' EXIT + +echo "==> Running cache benchmarks against $DEVICE_LABEL ($DESTINATION)..." +echo " Output: $OUTPUT_PATH" +echo " Raw log: $RAW_LOG" + +xcodebuild test \ + -workspace ApolloDev.xcworkspace \ + -scheme ApolloPerformanceTests \ + -testPlan Apollo-CacheBenchmarksTestPlan \ + -destination "$DESTINATION" \ + | tee "$RAW_LOG" + +# Extract every benchmark JSONL line emitted by the harness. +RESULTS_JSON="$(grep -E '^BENCHMARK_RESULT_JSONL:' "$RAW_LOG" \ + | sed 's/^BENCHMARK_RESULT_JSONL: //' \ + | jq -s '.')" + +if [[ -z "$RESULTS_JSON" || "$RESULTS_JSON" == "[]" ]]; then + echo "error: no benchmark results captured from test output" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$OUTPUT_PATH")" +jq --null-input \ + --arg version "$VERSION_LABEL" \ + --arg captured_at "$CAPTURED_AT" \ + --arg git_sha "$GIT_SHA" \ + --arg device "$DEVICE_LABEL" \ + --argjson results "$RESULTS_JSON" \ + '{version: $version, captured_at: $captured_at, git_sha: $git_sha, device: $device, results: $results}' \ + > "$OUTPUT_PATH" + +echo "==> Wrote $(jq '.results | length' "$OUTPUT_PATH") result rows to $OUTPUT_PATH" From abdba7fc8579a14028ab4646626ec13e8fa1d2dc Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Tue, 26 May 2026 12:45:48 -0700 Subject: [PATCH 16/24] feat(cache): introduce CachedField type (no consumers yet) (PR-005) (#998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-005 — introduce CachedField type. Approved as #981 prior to PR-004a's merge invalidating that PR's base; this is the rebased equivalent. --- .../ApolloTests/Cache/CachedFieldTests.swift | 104 ++++++++++++++++++ .../Sources/Apollo/Caching/CachedField.swift | 49 +++++++++ 2 files changed, 153 insertions(+) create mode 100644 Tests/ApolloTests/Cache/CachedFieldTests.swift create mode 100644 apollo-ios/Sources/Apollo/Caching/CachedField.swift diff --git a/Tests/ApolloTests/Cache/CachedFieldTests.swift b/Tests/ApolloTests/Cache/CachedFieldTests.swift new file mode 100644 index 000000000..7f7dc145c --- /dev/null +++ b/Tests/ApolloTests/Cache/CachedFieldTests.swift @@ -0,0 +1,104 @@ +@testable @_spi(Execution) import Apollo +import Foundation +import Nimble +import XCTest + +final class CachedFieldTests: XCTestCase { + + // MARK: - Equatable + + func test__equality__givenSameValueAndTimestamp__returnsTrue() { + let a = CachedField(value: "hello", writtenAt: 1_700_000_000) + let b = CachedField(value: "hello", writtenAt: 1_700_000_000) + expect(a) == b + } + + func test__equality__givenDifferentTimestamp__returnsFalse() { + let a = CachedField(value: "hello", writtenAt: 1_700_000_000) + let b = CachedField(value: "hello", writtenAt: 1_700_000_001) + expect(a) != b + } + + func test__equality__givenDifferentValue__returnsFalse() { + let a = CachedField(value: "hello", writtenAt: 1_700_000_000) + let b = CachedField(value: "world", writtenAt: 1_700_000_000) + expect(a) != b + } + + func test__equality__acrossValueTypes__returnsFalse() { + let intField = CachedField(value: 42, writtenAt: 100) + let stringField = CachedField(value: "42", writtenAt: 100) + expect(intField) != stringField + } + + // MARK: - Hashable + + func test__hashable__equalValuesProduceEqualHashes() { + let a = CachedField(value: 42, writtenAt: 100) + let b = CachedField(value: 42, writtenAt: 100) + expect(a.hashValue) == b.hashValue + } + + func test__hashable__roundTripThroughSet__deduplicatesEqualValues() { + let a = CachedField(value: "x", writtenAt: 1) + let b = CachedField(value: "y", writtenAt: 1) + let c = CachedField(value: "x", writtenAt: 1) // equal to a + let set: Set<CachedField> = [a, b, c] + expect(set).to(haveCount(2)) + expect(set.contains(a)).to(beTrue()) + expect(set.contains(b)).to(beTrue()) + } + + func test__hashable__usableAsDictionaryKey() { + let a = CachedField(value: "x", writtenAt: 1) + let b = CachedField(value: "x", writtenAt: 2) // different writtenAt + var dict: [CachedField: String] = [:] + dict[a] = "first" + dict[b] = "second" + expect(dict).to(haveCount(2)) + expect(dict[a]) == "first" + expect(dict[b]) == "second" + } + + // MARK: - Value-type round trips + + func test__init__supportsStringValues() { + let field = CachedField(value: "hello", writtenAt: 0) + expect(field.value as? String) == "hello" + } + + func test__init__supportsIntValues() { + let field = CachedField(value: 42, writtenAt: 0) + expect(field.value as? Int) == 42 + } + + func test__init__supportsBoolValues() { + let field = CachedField(value: true, writtenAt: 0) + expect(field.value as? Bool) == true + } + + func test__init__supportsDoubleValues() { + let field = CachedField(value: 3.14, writtenAt: 0) + expect(field.value as? Double) == 3.14 + } + + // MARK: - Date convenience init + + func test__initWithDate__truncatesToEpochSeconds() { + let date = Date(timeIntervalSince1970: 1_700_000_000.789) + let field = CachedField(value: "x", writtenAt: date) + expect(field.writtenAt) == 1_700_000_000 + } + + func test__initWithDate__zeroDateIsEpochOrigin() { + let field = CachedField(value: "x", writtenAt: Date(timeIntervalSince1970: 0)) + expect(field.writtenAt) == 0 + } + + // MARK: - CustomStringConvertible + + func test__description__includesValueAndTimestamp() { + let field = CachedField(value: "hello", writtenAt: 42) + expect(field.description) == "(hello @ 42)" + } +} diff --git a/apollo-ios/Sources/Apollo/Caching/CachedField.swift b/apollo-ios/Sources/Apollo/Caching/CachedField.swift new file mode 100644 index 000000000..81e68c490 --- /dev/null +++ b/apollo-ios/Sources/Apollo/Caching/CachedField.swift @@ -0,0 +1,49 @@ +@_spi(Internal) import ApolloAPI +import Foundation + +/// A single cached field's value alongside its written metadata. +public struct CachedField: Sendable, Hashable { + + /// The value stored at this field. Any value that is both `Hashable` + /// (for record-set deduplication) and `Sendable` (for cross-actor cache + /// traversal). + public typealias Value = any Hashable & Sendable + + /// The field's value. + public let value: Value + + /// Epoch seconds at which this field was last written to the cache. + /// + /// This is used for TTL evaluation under `@cacheControl(maxAge:)`. + /// TTL evaluation reads `writtenAt + maxAge < now` to decide if the + /// field is stale. + public let writtenAt: Int64 + + public init(value: Value, writtenAt: Int64) { + self.value = value + self.writtenAt = writtenAt + } + + /// Convenience: accept a `Date` and truncate to epoch seconds. + /// `Date.timeIntervalSince1970` is a fractional `Double`; the sub-second + /// portion is dropped because `writtenAt` is second-precision. + public init(value: Value, writtenAt: Date) { + self.init(value: value, writtenAt: Int64(writtenAt.timeIntervalSince1970)) + } + + public static func == (lhs: CachedField, rhs: CachedField) -> Bool { + lhs.writtenAt == rhs.writtenAt && + AnySendableHashable.equatableCheck(lhs.value, rhs.value) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(writtenAt) + hasher.combine(AnyHashable(value)) + } +} + +extension CachedField: CustomStringConvertible { + public var description: String { + "(\(value) @ \(writtenAt))" + } +} From fa681feaa1f3fd56215aa263271c4d79972bf14f Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Tue, 26 May 2026 12:49:53 -0700 Subject: [PATCH 17/24] refactor(cache): change Record.fields type to [CacheKey: CachedField] (PR-006) (#987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-006 — change Record.fields type to [CacheKey: CachedField]. Approved by reviewer; rebased onto post-#998 plan branch tip after #981's chain reorganization. Full Apollo-UnitTestPlan verified locally (970 tests passing). --- .../CacheBenchmarks/BenchmarkWorkloads.swift | 8 ++-- .../Tier2CacheBenchmarks.swift | 12 +++--- .../Cache/ReadWriteFromStoreTests.swift | 2 +- .../Sources/Apollo/Caching/Record.swift | 42 ++++++++++++------- .../Sources/Apollo/Caching/RecordSet.swift | 20 ++++++--- .../CacheDataExecutionSource.swift | 10 +++-- .../Execution/FieldSelectionCollector.swift | 32 +++++++++++--- .../Apollo/Execution/GraphQLExecutor.swift | 6 ++- .../ApolloSQLite/SQLiteNormalizedCache.swift | 2 +- .../ApolloSQLite/SQLiteSerialization.swift | 11 +++-- 10 files changed, 99 insertions(+), 46 deletions(-) diff --git a/Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkWorkloads.swift b/Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkWorkloads.swift index 74168ac06..22484843b 100644 --- a/Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkWorkloads.swift +++ b/Tests/ApolloPerformanceTests/CacheBenchmarks/BenchmarkWorkloads.swift @@ -15,16 +15,16 @@ public enum BenchmarkWorkloads { /// scalar paths during serialization. public static func syntheticRecords(count: Int) -> [Record] { (0..<count).map { i in - var fields: Record.Fields = [:] + var values: [CacheKey: Record.Value] = [:] for f in 0..<fieldsPerRecord { let key: CacheKey = "field_\(f)" if f % 2 == 0 { - fields[key] = "value_\(i)_\(f)" + values[key] = "value_\(i)_\(f)" } else { - fields[key] = i * 100 + f + values[key] = i * 100 + f } } - return Record(key: "record_\(i)", fields) + return Record(key: "record_\(i)", values) } } diff --git a/Tests/ApolloPerformanceTests/CacheBenchmarks/Tier2CacheBenchmarks.swift b/Tests/ApolloPerformanceTests/CacheBenchmarks/Tier2CacheBenchmarks.swift index b23f068ee..7892387ec 100644 --- a/Tests/ApolloPerformanceTests/CacheBenchmarks/Tier2CacheBenchmarks.swift +++ b/Tests/ApolloPerformanceTests/CacheBenchmarks/Tier2CacheBenchmarks.swift @@ -95,11 +95,11 @@ class Tier2CacheBenchmarksBase: XCTestCase { _ = try await harness.measure { iteration in // Use a distinct key per iteration so each merge is a write, not a no-op // upsert; otherwise the cache could optimize the second-onwards writes. - var fields: Record.Fields = [:] + var values: [CacheKey: Record.Value] = [:] for f in 0..<BenchmarkWorkloads.fieldsPerRecord { - fields["field_\(f)"] = "value_\(iteration)_\(f)" + values["field_\(f)"] = "value_\(iteration)_\(f)" } - let record = Record(key: "merge_iter_\(iteration)", fields) + let record = Record(key: "merge_iter_\(iteration)", values) _ = try await cache.merge(records: RecordSet(records: [record])) } } @@ -142,11 +142,11 @@ class Tier2CacheBenchmarksBase: XCTestCase { let userPrefixCount = 10_000 let otherPrefixCount = 1_000 let seedRecords: [Record] = (0..<userPrefixCount).map { i in - var fields: Record.Fields = [:] + var values: [CacheKey: Record.Value] = [:] for f in 0..<BenchmarkWorkloads.fieldsPerRecord { - fields["field_\(f)"] = "value_\(i)_\(f)" + values["field_\(f)"] = "value_\(i)_\(f)" } - return Record(key: "User_\(i)", fields) + return Record(key: "User_\(i)", values) } + (0..<otherPrefixCount).map { i in Record(key: "Other_\(i)", ["field_0": "value_\(i)"]) } diff --git a/Tests/ApolloTests/Cache/ReadWriteFromStoreTests.swift b/Tests/ApolloTests/Cache/ReadWriteFromStoreTests.swift index 21c1ae894..b86fb1d58 100644 --- a/Tests/ApolloTests/Cache/ReadWriteFromStoreTests.swift +++ b/Tests/ApolloTests/Cache/ReadWriteFromStoreTests.swift @@ -1749,7 +1749,7 @@ class ReadWriteFromStoreTests: XCTestCase, CacheDependentTesting, StoreLoading { let heroKey = "QUERY_ROOT.hero" let heroRecord = try await self.store.loadRecord(forKey: heroKey) - expect(heroRecord.fields["name"] as? String).to(equal("Han Solo")) + expect(heroRecord["name"] as? String).to(equal("Han Solo")) } @MainActor func test_writeDataForOperation_givenSelectionSetManuallyInitializedWithNamedFragmentInInclusionConditionIsFulfilled_writesFieldsForNamedFragment() async throws { diff --git a/apollo-ios/Sources/Apollo/Caching/Record.swift b/apollo-ios/Sources/Apollo/Caching/Record.swift index b635297d4..b02637ef6 100644 --- a/apollo-ios/Sources/Apollo/Caching/Record.swift +++ b/apollo-ios/Sources/Apollo/Caching/Record.swift @@ -7,32 +7,42 @@ public typealias CacheKey = String public struct Record: Sendable, Hashable { public let key: CacheKey + /// A field value carried by a record. Any value that is both `Hashable` + /// (for record-set deduplication) and `Sendable` (for cross-actor cache + /// traversal). public typealias Value = any Hashable & Sendable - public typealias Fields = [CacheKey: Value] - public private(set) var fields: Fields - public init(key: CacheKey, _ fields: Fields = [:]) { + /// The map of field name to cached field. Each `CachedField` pairs the + /// stored value with the timestamp at which it was last written. + public typealias Fields = [CacheKey: CachedField] + + public internal(set) var fields: Fields + + /// Construct a record from a fully-formed field dictionary. Use this + /// initializer when the caller has explicit `writtenAt` timestamps for + /// each field — e.g. when deserializing from storage. + public init(key: CacheKey, fields: Fields = [:]) { self.key = key self.fields = fields } - public subscript(key: CacheKey) -> Value? { - get { - return fields[key] - } - set { - fields[key] = newValue - } + /// Convenience initializer for callers that have raw field values and + /// no per-field timestamps. Each value is wrapped in a `CachedField` + /// stamped with the supplied `writtenAt` (default `0`). + public init(key: CacheKey, _ values: [CacheKey: Value], writtenAt: Int64 = 0) { + self.key = key + self.fields = values.mapValues { CachedField(value: $0, writtenAt: writtenAt) } } - public static func == (lhs: Record, rhs: Record) -> Bool { - lhs.key == rhs.key && - AnySendableHashable.equatableCheck(lhs.fields, rhs.fields) + /// Value-only read access to a field. + public subscript(key: CacheKey) -> Value? { + return fields[key]?.value } - public func hash(into hasher: inout Hasher) { - hasher.combine(key) - hasher.combine(fields) + /// Metadata-aware accessor for callers that need the field's + /// `writtenAt` timestamp alongside its value. + public func cachedField(for key: CacheKey) -> CachedField? { + fields[key] } } diff --git a/apollo-ios/Sources/Apollo/Caching/RecordSet.swift b/apollo-ios/Sources/Apollo/Caching/RecordSet.swift index 218bc590e..83ec61132 100644 --- a/apollo-ios/Sources/Apollo/Caching/RecordSet.swift +++ b/apollo-ios/Sources/Apollo/Caching/RecordSet.swift @@ -9,7 +9,7 @@ public struct RecordSet: Sendable, Hashable { public mutating func insert(_ record: Record) { storage[record.key] = record } - + public mutating func removeRecord(for key: CacheKey) { storage.removeValue(forKey: key) } @@ -58,12 +58,16 @@ public struct RecordSet: Sendable, Hashable { if let oldRecord = storage[record.key] { var changedKeys: Set<CacheKey> = Set() var updatedRecord = oldRecord - - for (key, value) in record.fields { - if let oldValue = oldRecord.fields[key], AnyHashable(oldValue) == AnyHashable(value) { + + // Always take the new `CachedField` so the stored timestamp advances + // to the latest write. Only notify watchers (via `changedKeys`) when + // the observable value actually differs from what was stored. + for (key, newField) in record.fields { + updatedRecord.fields[key] = newField + if let oldField = oldRecord.fields[key], + AnyHashable(oldField.value) == AnyHashable(newField.value) { continue } - updatedRecord[key] = value changedKeys.insert([record.key, key].joined(separator: ".")) } @@ -77,7 +81,11 @@ public struct RecordSet: Sendable, Hashable { } extension RecordSet: ExpressibleByDictionaryLiteral { - public init(dictionaryLiteral elements: (CacheKey, Record.Fields)...) { + /// Convenience for building a `RecordSet` from a literal. Values are + /// raw field maps (`[CacheKey: any Hashable & Sendable]`) — each is + /// wrapped into `CachedField`s with `writtenAt = 0` via `Record`'s + /// convenience initializer. + public init(dictionaryLiteral elements: (CacheKey, [CacheKey: Record.Value])...) { self.init(records: elements.map { Record(key: $0.0, $0.1) }) } } diff --git a/apollo-ios/Sources/Apollo/Execution/ExecutionSources/CacheDataExecutionSource.swift b/apollo-ios/Sources/Apollo/Execution/ExecutionSources/CacheDataExecutionSource.swift index b3803300f..5d1f29c92 100644 --- a/apollo-ios/Sources/Apollo/Execution/ExecutionSources/CacheDataExecutionSource.swift +++ b/apollo-ios/Sources/Apollo/Execution/ExecutionSources/CacheDataExecutionSource.swift @@ -166,8 +166,12 @@ struct CacheDataExecutionSource: GraphQLExecutionSource { return object.key } - /// A wrapper around the `DefaultFieldSelectionCollector` that maps the `Record` object to it's - /// `fields` representing the object's data. + /// A wrapper around the `DefaultFieldSelectionCollector` that supplies a + /// lazy `runtimeObjectType` resolver derived from the `Record`'s + /// `__typename` field. Resolution is deferred until an inline fragment + /// actually requires the runtime type, avoiding any transformation of + /// the record's field dictionary on selection sets that don't use type + /// cases. struct CacheDataFieldSelectionCollector: FieldSelectionCollector { static func collectFields( from selections: [Selection], @@ -178,7 +182,7 @@ struct CacheDataExecutionSource: GraphQLExecutionSource { return try DefaultFieldSelectionCollector.collectFields( from: selections, into: &groupedFields, - for: object.fields, + resolveRuntimeType: { info.runtimeObjectType(forTypename: object["__typename"] as? String) }, info: info ) } diff --git a/apollo-ios/Sources/Apollo/Execution/FieldSelectionCollector.swift b/apollo-ios/Sources/Apollo/Execution/FieldSelectionCollector.swift index 2059fab3a..e2b21af3d 100644 --- a/apollo-ios/Sources/Apollo/Execution/FieldSelectionCollector.swift +++ b/apollo-ios/Sources/Apollo/Execution/FieldSelectionCollector.swift @@ -82,6 +82,25 @@ public struct DefaultFieldSelectionCollector: FieldSelectionCollector { into groupedFields: inout FieldSelectionGrouping, for object: JSONObject, info: ObjectExecutionInfo + ) throws { + try collectFields( + from: selections, + into: &groupedFields, + resolveRuntimeType: { info.runtimeObjectType(for: object) }, + info: info + ) + } + + /// Variant for callers whose object data shape doesn't match `JSONObject` + /// directly — e.g. the cache path, where each value is wrapped in a + /// `CachedField`. The closure is invoked lazily only when an inline + /// fragment is encountered, so callers that hold non-`JSONObject` data + /// don't have to transform the entire field dictionary up front. + public static func collectFields( + from selections: [Selection], + into groupedFields: inout FieldSelectionGrouping, + resolveRuntimeType: () -> Object?, + info: ObjectExecutionInfo ) throws { for selection in selections { switch selection { @@ -92,7 +111,7 @@ public struct DefaultFieldSelectionCollector: FieldSelectionCollector { if conditions.evaluate(with: info.variables) { try collectFields(from: conditionalSelections, into: &groupedFields, - for: object, + resolveRuntimeType: resolveRuntimeType, info: info) } @@ -118,23 +137,26 @@ public struct DefaultFieldSelectionCollector: FieldSelectionCollector { } else { groupedFields.addFulfilledFragment(typeCase) - try collectFields(from: typeCase.__selections, into: &groupedFields, for: object, info: info) + try collectFields(from: typeCase.__selections, + into: &groupedFields, + resolveRuntimeType: resolveRuntimeType, + info: info) } case let .fragment(fragment): groupedFields.addFulfilledFragment(fragment) try collectFields(from: fragment.__selections, into: &groupedFields, - for: object, + resolveRuntimeType: resolveRuntimeType, info: info) case let .inlineFragment(typeCase): - if let runtimeType = info.runtimeObjectType(for: object), + if let runtimeType = resolveRuntimeType(), typeCase.__parentType.canBeConverted(from: runtimeType) { groupedFields.addFulfilledFragment(typeCase) try collectFields(from: typeCase.__selections, into: &groupedFields, - for: object, + resolveRuntimeType: resolveRuntimeType, info: info) } } diff --git a/apollo-ios/Sources/Apollo/Execution/GraphQLExecutor.swift b/apollo-ios/Sources/Apollo/Execution/GraphQLExecutor.swift index ac0a11e13..9ac86c0de 100644 --- a/apollo-ios/Sources/Apollo/Execution/GraphQLExecutor.swift +++ b/apollo-ios/Sources/Apollo/Execution/GraphQLExecutor.swift @@ -44,7 +44,11 @@ public class ObjectExecutionInfo { func runtimeObjectType( for json: JSONObject ) -> Object? { - guard let __typename = json["__typename"] as? String else { + return runtimeObjectType(forTypename: json["__typename"] as? String) + } + + func runtimeObjectType(forTypename __typename: String?) -> Object? { + guard let __typename else { guard let objectType = rootType.__parentType as? Object else { return nil } diff --git a/apollo-ios/Sources/ApolloSQLite/SQLiteNormalizedCache.swift b/apollo-ios/Sources/ApolloSQLite/SQLiteNormalizedCache.swift index 9c61ce663..a06a487d7 100644 --- a/apollo-ios/Sources/ApolloSQLite/SQLiteNormalizedCache.swift +++ b/apollo-ios/Sources/ApolloSQLite/SQLiteNormalizedCache.swift @@ -87,7 +87,7 @@ public final class SQLiteNormalizedCache { } let fields = try SQLiteSerialization.deserialize(data: recordData) - return Record(key: row.cacheKey, fields) + return Record(key: row.cacheKey, fields: fields) } } diff --git a/apollo-ios/Sources/ApolloSQLite/SQLiteSerialization.swift b/apollo-ios/Sources/ApolloSQLite/SQLiteSerialization.swift index df623417d..dde53d062 100644 --- a/apollo-ios/Sources/ApolloSQLite/SQLiteSerialization.swift +++ b/apollo-ios/Sources/ApolloSQLite/SQLiteSerialization.swift @@ -6,7 +6,9 @@ private let serializedReferenceKey = "$reference" enum SQLiteSerialization { static func serialize(fields: Record.Fields) throws -> Data { - let jsonObject = try fields.compactMapValues(serialize(fieldValue:)) + // Serializes only the field value; the row schema does not carry a + // per-field `writtenAt` column, so `CachedField.writtenAt` is omitted. + let jsonObject = try fields.compactMapValues { try serialize(fieldValue: $0.value) } return try JSONSerialization.data(withJSONObject: jsonObject, options: []) } @@ -22,10 +24,13 @@ enum SQLiteSerialization { } static func deserialize(data: Data) throws -> Record.Fields { - let jsonObject = try JSONSerializationFormat.deserialize(data: data) as JSONObject + let jsonObject = try JSONSerializationFormat.deserialize(data: data) as JSONObject var fields = Record.Fields() for (key, value) in jsonObject { - fields[key] = try deserialize(fieldJSONValue: value) + // The row schema does not carry a per-field `writtenAt`; each + // value is wrapped in a `CachedField` stamped with `0`. + let parsed = try deserialize(fieldJSONValue: value) + fields[key] = CachedField(value: parsed, writtenAt: 0) } return fields } From 64f6bc0143910faa6acc718dce413746822886fb Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Tue, 26 May 2026 13:22:43 -0700 Subject: [PATCH 18/24] feat(sqlite): add schema_metadata table and version detection (PR-007) (#999) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-007 — schema_metadata table + version read/write. Foundation for PR-008/010 migration logic. --- .../SQLiteDotSwiftDatabaseBehaviorTests.swift | 62 +++++++++++++++++ .../ApolloSQLite/ApolloSQLiteDatabase.swift | 69 +++++++++++++++++++ .../Sources/ApolloSQLite/SQLiteDatabase.swift | 43 ++++++++++-- .../ApolloSQLite/SQLiteNormalizedCache.swift | 2 + 4 files changed, 171 insertions(+), 5 deletions(-) diff --git a/Tests/ApolloTests/SQLiteDotSwiftDatabaseBehaviorTests.swift b/Tests/ApolloTests/SQLiteDotSwiftDatabaseBehaviorTests.swift index 5de342a57..11efee6df 100644 --- a/Tests/ApolloTests/SQLiteDotSwiftDatabaseBehaviorTests.swift +++ b/Tests/ApolloTests/SQLiteDotSwiftDatabaseBehaviorTests.swift @@ -21,6 +21,68 @@ class ApolloSQLiteDatabaseBehaviorTests: XCTestCase { XCTAssertThrowsError(try db.selectRawRows(forKeys: ["key"])) } + + // MARK: - schema_metadata + + func test__readSchemaVersion__givenFreshlyCreatedTable_returnsZero() throws { + let db = try ApolloSQLiteDatabase(fileURL: SQLiteTestCacheProvider.temporarySQLiteFileURL()) + try db.createSchemaMetadataTableIfNeeded() + + XCTAssertEqual(try db.readSchemaVersion(), 0) + } + + func test__readSchemaVersion__afterWrite_roundTripsValue() throws { + let db = try ApolloSQLiteDatabase(fileURL: SQLiteTestCacheProvider.temporarySQLiteFileURL()) + try db.createSchemaMetadataTableIfNeeded() + + try db.writeSchemaVersion(3) + + XCTAssertEqual(try db.readSchemaVersion(), 3) + } + + func test__writeSchemaVersion__overwritesPriorValue() throws { + let db = try ApolloSQLiteDatabase(fileURL: SQLiteTestCacheProvider.temporarySQLiteFileURL()) + try db.createSchemaMetadataTableIfNeeded() + + try db.writeSchemaVersion(1) + try db.writeSchemaVersion(3) + + XCTAssertEqual(try db.readSchemaVersion(), 3) + } + + func test__createSchemaMetadataTableIfNeeded__isIdempotent() throws { + let db = try ApolloSQLiteDatabase(fileURL: SQLiteTestCacheProvider.temporarySQLiteFileURL()) + + try db.createSchemaMetadataTableIfNeeded() + try db.createSchemaMetadataTableIfNeeded() + try db.writeSchemaVersion(3) + try db.createSchemaMetadataTableIfNeeded() + + XCTAssertEqual(try db.readSchemaVersion(), 3) + } + + func test__readSchemaVersion__persistsAcrossDatabaseHandles() throws { + let url = SQLiteTestCacheProvider.temporarySQLiteFileURL() + + let writer = try ApolloSQLiteDatabase(fileURL: url) + try writer.createSchemaMetadataTableIfNeeded() + try writer.writeSchemaVersion(3) + + let reader = try ApolloSQLiteDatabase(fileURL: url) + XCTAssertEqual(try reader.readSchemaVersion(), 3) + } + + func test__SQLiteNormalizedCache_init__createsSchemaMetadataTable() throws { + let url = SQLiteTestCacheProvider.temporarySQLiteFileURL() + + _ = try SQLiteNormalizedCache(fileURL: url) + + // Opening a fresh database against the same URL and querying the schema + // table should succeed (returning the default of 0) because + // `SQLiteNormalizedCache.init` is responsible for creating the table. + let probe = try ApolloSQLiteDatabase(fileURL: url) + XCTAssertEqual(try probe.readSchemaVersion(), 0) + } private func dropSQLiteTable(dbURL: URL, tableName: String) throws { var db: OpaquePointer? diff --git a/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift b/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift index bcc3cee7a..cf142f9c6 100644 --- a/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift +++ b/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift @@ -85,6 +85,75 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { } } + public func createSchemaMetadataTableIfNeeded() throws { + try performSync { + let sql = """ + CREATE TABLE IF NOT EXISTS "\(Self.schemaMetadataTableName)" ( + "\(Self.schemaMetadataKeyColumnName)" TEXT PRIMARY KEY, + "\(Self.schemaMetadataValueColumnName)" TEXT + ); + """ + try exec(sql, errorMessage: "Failed to create '\(Self.schemaMetadataTableName)' database table") + } + } + + public func readSchemaVersion() throws -> Int { + try performSync { + let sql = """ + SELECT \(Self.schemaMetadataValueColumnName) + FROM \(Self.schemaMetadataTableName) + WHERE \(Self.schemaMetadataKeyColumnName) = ? + """ + + let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare schema-version read") + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, Self.schemaVersionMetadataKey, -1, SQLITE_TRANSIENT) + + let stepResult = sqlite3_step(stmt) + switch stepResult { + case SQLITE_DONE: + // No row stored for the schema-version key — treat as version 0. + return 0 + case SQLITE_ROW: + guard let textPtr = sqlite3_column_text(stmt, 0) else { + return 0 + } + let raw = String(cString: textPtr) + return Int(raw) ?? 0 + default: + throw SQLiteError.step( + message: "Schema-version read failed: \(sqliteErrorMessage())", + resultCode: stepResult + ) + } + } + } + + public func writeSchemaVersion(_ version: Int) throws { + try performSync { + let sql = """ + INSERT INTO \(Self.schemaMetadataTableName) (\(Self.schemaMetadataKeyColumnName), \(Self.schemaMetadataValueColumnName)) + VALUES (?, ?) + ON CONFLICT(\(Self.schemaMetadataKeyColumnName)) DO UPDATE SET \(Self.schemaMetadataValueColumnName) = excluded.\(Self.schemaMetadataValueColumnName) + """ + + let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare schema-version write") + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, Self.schemaVersionMetadataKey, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 2, String(version), -1, SQLITE_TRANSIENT) + + let stepResult = sqlite3_step(stmt) + if stepResult != SQLITE_DONE { + throw SQLiteError.step( + message: "Schema-version write failed: \(sqliteErrorMessage())", + resultCode: stepResult + ) + } + } + } + public func selectRawRows(forKeys keys: Set<CacheKey>) throws -> [DatabaseRow] { guard !keys.isEmpty else { return [] } diff --git a/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift b/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift index 8b2b77664..c25f241e8 100644 --- a/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift +++ b/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift @@ -34,9 +34,25 @@ public enum SQLiteError: Error, CustomStringConvertible { public protocol SQLiteDatabase { init(fileURL: URL) throws - + func createRecordsTableIfNeeded() throws - + + /// Creates the schema-metadata table if it doesn't exist. The table is a + /// key/value store keyed on `String`; the only reserved key currently + /// recognized is `version`, holding the integer schema version of the + /// records table layout (read via `readSchemaVersion()`). + func createSchemaMetadataTableIfNeeded() throws + + /// Returns the integer schema version stamped in the metadata table, or + /// `0` when no row exists. Callers use the value to decide whether the + /// stored data needs to be migrated to a newer schema layout. + func readSchemaVersion() throws -> Int + + /// Writes the integer schema version into the metadata table, replacing + /// any prior value. The metadata table must already exist; the caller is + /// expected to call `createSchemaMetadataTableIfNeeded()` first. + func writeSchemaVersion(_ version: Int) throws + func selectRawRows(forKeys keys: Set<CacheKey>) throws -> [DatabaseRow] func addOrUpdate(records: [(cacheKey: CacheKey, recordString: String)]) throws @@ -44,7 +60,7 @@ public protocol SQLiteDatabase { func deleteRecord(for cacheKey: CacheKey) throws func deleteRecords(matching pattern: CacheKey) throws - + func clearDatabase(shouldVacuumOnClear: Bool) throws @available(*, deprecated, renamed: "addOrUpdate(records:)") @@ -61,11 +77,11 @@ extension SQLiteDatabase { } public extension SQLiteDatabase { - + static var tableName: String { "records" } - + static var idColumnName: String { "_id" } @@ -77,4 +93,21 @@ public extension SQLiteDatabase { static var recordColumName: String { "record" } + + static var schemaMetadataTableName: String { + "schema_metadata" + } + + static var schemaMetadataKeyColumnName: String { + "key" + } + + static var schemaMetadataValueColumnName: String { + "value" + } + + /// The metadata key under which the records-table schema version is stored. + static var schemaVersionMetadataKey: String { + "version" + } } diff --git a/apollo-ios/Sources/ApolloSQLite/SQLiteNormalizedCache.swift b/apollo-ios/Sources/ApolloSQLite/SQLiteNormalizedCache.swift index a06a487d7..40abf7f18 100644 --- a/apollo-ios/Sources/ApolloSQLite/SQLiteNormalizedCache.swift +++ b/apollo-ios/Sources/ApolloSQLite/SQLiteNormalizedCache.swift @@ -24,6 +24,7 @@ public final class SQLiteNormalizedCache { self.database = try databaseType.init(fileURL: fileURL) self.shouldVacuumOnClear = shouldVacuumOnClear try self.database.createRecordsTableIfNeeded() + try self.database.createSchemaMetadataTableIfNeeded() } public init(database: any SQLiteDatabase, @@ -31,6 +32,7 @@ public final class SQLiteNormalizedCache { self.database = database self.shouldVacuumOnClear = shouldVacuumOnClear try self.database.createRecordsTableIfNeeded() + try self.database.createSchemaMetadataTableIfNeeded() } private func recordCacheKey(forFieldCacheKey fieldCacheKey: CacheKey) -> CacheKey { From ba390ab0c533d48a1f845baf845d0ef6c30848f3 Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Wed, 27 May 2026 11:08:52 -0700 Subject: [PATCH 19/24] feat(sqlite): new schema DDL + SchemaVersion + SQLiteSchema namespace (PR-008) (#1000) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../SQLiteDotSwiftDatabaseBehaviorTests.swift | 178 ++++++++++++++++-- .../ApolloSQLite/ApolloSQLiteDatabase.swift | 89 +++++---- .../Sources/ApolloSQLite/SQLiteDatabase.swift | 67 +++---- .../Sources/ApolloSQLite/SQLiteSchema.swift | 50 +++++ .../Sources/ApolloSQLite/SchemaVersion.swift | 48 +++++ 5 files changed, 335 insertions(+), 97 deletions(-) create mode 100644 apollo-ios/Sources/ApolloSQLite/SQLiteSchema.swift create mode 100644 apollo-ios/Sources/ApolloSQLite/SchemaVersion.swift diff --git a/Tests/ApolloTests/SQLiteDotSwiftDatabaseBehaviorTests.swift b/Tests/ApolloTests/SQLiteDotSwiftDatabaseBehaviorTests.swift index 11efee6df..f5932cc87 100644 --- a/Tests/ApolloTests/SQLiteDotSwiftDatabaseBehaviorTests.swift +++ b/Tests/ApolloTests/SQLiteDotSwiftDatabaseBehaviorTests.swift @@ -17,37 +17,46 @@ class ApolloSQLiteDatabaseBehaviorTests: XCTestCase { XCTAssertEqual(rows.count, 1) // Use SQLite directly to manipulate the database (cannot be done with ApolloSQLiteDatabase) - try dropSQLiteTable(dbURL: sqliteFileURL, tableName: ApolloSQLiteDatabase.tableName) + try dropSQLiteTable(dbURL: sqliteFileURL, tableName: SQLiteSchema.recordsTableName) XCTAssertThrowsError(try db.selectRawRows(forKeys: ["key"])) } // MARK: - schema_metadata - func test__readSchemaVersion__givenFreshlyCreatedTable_returnsZero() throws { + func test__readSchemaVersion__givenFreshlyCreatedTable_returnsNil() throws { let db = try ApolloSQLiteDatabase(fileURL: SQLiteTestCacheProvider.temporarySQLiteFileURL()) try db.createSchemaMetadataTableIfNeeded() - XCTAssertEqual(try db.readSchemaVersion(), 0) + XCTAssertNil(try db.readSchemaVersion()) } func test__readSchemaVersion__afterWrite_roundTripsValue() throws { let db = try ApolloSQLiteDatabase(fileURL: SQLiteTestCacheProvider.temporarySQLiteFileURL()) try db.createSchemaMetadataTableIfNeeded() - try db.writeSchemaVersion(3) + try db.writeSchemaVersion(SchemaVersion(major: 3)) - XCTAssertEqual(try db.readSchemaVersion(), 3) + XCTAssertEqual(try db.readSchemaVersion(), SchemaVersion(major: 3)) + } + + func test__readSchemaVersion__afterWriteWithMinor_roundTripsValue() throws { + let db = try ApolloSQLiteDatabase(fileURL: SQLiteTestCacheProvider.temporarySQLiteFileURL()) + try db.createSchemaMetadataTableIfNeeded() + + try db.writeSchemaVersion(SchemaVersion(major: 3, minor: 1)) + + XCTAssertEqual(try db.readSchemaVersion(), SchemaVersion(major: 3, minor: 1)) } func test__writeSchemaVersion__overwritesPriorValue() throws { let db = try ApolloSQLiteDatabase(fileURL: SQLiteTestCacheProvider.temporarySQLiteFileURL()) try db.createSchemaMetadataTableIfNeeded() - try db.writeSchemaVersion(1) - try db.writeSchemaVersion(3) + try db.writeSchemaVersion(SchemaVersion(major: 1)) + try db.writeSchemaVersion(SchemaVersion(major: 3)) - XCTAssertEqual(try db.readSchemaVersion(), 3) + XCTAssertEqual(try db.readSchemaVersion(), SchemaVersion(major: 3)) } func test__createSchemaMetadataTableIfNeeded__isIdempotent() throws { @@ -55,10 +64,10 @@ class ApolloSQLiteDatabaseBehaviorTests: XCTestCase { try db.createSchemaMetadataTableIfNeeded() try db.createSchemaMetadataTableIfNeeded() - try db.writeSchemaVersion(3) + try db.writeSchemaVersion(SchemaVersion(major: 3)) try db.createSchemaMetadataTableIfNeeded() - XCTAssertEqual(try db.readSchemaVersion(), 3) + XCTAssertEqual(try db.readSchemaVersion(), SchemaVersion(major: 3)) } func test__readSchemaVersion__persistsAcrossDatabaseHandles() throws { @@ -66,10 +75,10 @@ class ApolloSQLiteDatabaseBehaviorTests: XCTestCase { let writer = try ApolloSQLiteDatabase(fileURL: url) try writer.createSchemaMetadataTableIfNeeded() - try writer.writeSchemaVersion(3) + try writer.writeSchemaVersion(SchemaVersion(major: 3)) let reader = try ApolloSQLiteDatabase(fileURL: url) - XCTAssertEqual(try reader.readSchemaVersion(), 3) + XCTAssertEqual(try reader.readSchemaVersion(), SchemaVersion(major: 3)) } func test__SQLiteNormalizedCache_init__createsSchemaMetadataTable() throws { @@ -78,12 +87,147 @@ class ApolloSQLiteDatabaseBehaviorTests: XCTestCase { _ = try SQLiteNormalizedCache(fileURL: url) // Opening a fresh database against the same URL and querying the schema - // table should succeed (returning the default of 0) because - // `SQLiteNormalizedCache.init` is responsible for creating the table. + // table should succeed (returning nil because no version is stamped yet) + // because `SQLiteNormalizedCache.init` is responsible for creating the + // table. let probe = try ApolloSQLiteDatabase(fileURL: url) - XCTAssertEqual(try probe.readSchemaVersion(), 0) + XCTAssertNil(try probe.readSchemaVersion()) + } + + // MARK: - createNewRecordsTableIfNeeded + + func test__createNewRecordsTableIfNeeded__createsRecordsTable() throws { + let url = SQLiteTestCacheProvider.temporarySQLiteFileURL() + let db = try ApolloSQLiteDatabase(fileURL: url) + try db.createSchemaMetadataTableIfNeeded() + + try db.createNewRecordsTableIfNeeded() + + // Verify the table exists by reading its CREATE statement from + // sqlite_master. Returning a non-empty SQL string confirms creation. + let createSQL = try readTableSQL(dbURL: url, tableName: SQLiteSchema.recordsTableName) + XCTAssertFalse(createSQL.isEmpty) + } + + func test__createNewRecordsTableIfNeeded__stampsCurrentSchemaVersion() throws { + let db = try ApolloSQLiteDatabase(fileURL: SQLiteTestCacheProvider.temporarySQLiteFileURL()) + try db.createSchemaMetadataTableIfNeeded() + + try db.createNewRecordsTableIfNeeded() + + XCTAssertEqual(try db.readSchemaVersion(), SQLiteSchema.currentVersion) + XCTAssertEqual(SQLiteSchema.currentVersion, SchemaVersion(major: 3, minor: 0)) + } + + func test__createNewRecordsTableIfNeeded__isIdempotent() throws { + let url = SQLiteTestCacheProvider.temporarySQLiteFileURL() + let db = try ApolloSQLiteDatabase(fileURL: url) + try db.createSchemaMetadataTableIfNeeded() + + try db.createNewRecordsTableIfNeeded() + try db.createNewRecordsTableIfNeeded() + try db.createNewRecordsTableIfNeeded() + + XCTAssertEqual(try db.readSchemaVersion(), SQLiteSchema.currentVersion) + // No throw from any of the three calls; same CREATE statement persists. + let createSQL = try readTableSQL(dbURL: url, tableName: SQLiteSchema.recordsTableName) + XCTAssertTrue(createSQL.contains(SQLiteSchema.Records.cacheKey)) + } + + func test__createNewRecordsTableIfNeeded__preservesWithoutRowID() throws { + let url = SQLiteTestCacheProvider.temporarySQLiteFileURL() + let db = try ApolloSQLiteDatabase(fileURL: url) + try db.createSchemaMetadataTableIfNeeded() + + try db.createNewRecordsTableIfNeeded() + + let createSQL = try readTableSQL(dbURL: url, tableName: SQLiteSchema.recordsTableName) + XCTAssertTrue( + createSQL.range(of: "WITHOUT ROWID", options: .caseInsensitive) != nil, + "Expected CREATE statement to retain WITHOUT ROWID, got: \(createSQL)" + ) + } + + func test__createNewRecordsTableIfNeeded__hasCompositePrimaryKey() throws { + let url = SQLiteTestCacheProvider.temporarySQLiteFileURL() + let db = try ApolloSQLiteDatabase(fileURL: url) + try db.createSchemaMetadataTableIfNeeded() + + try db.createNewRecordsTableIfNeeded() + + let createSQL = try readTableSQL(dbURL: url, tableName: SQLiteSchema.recordsTableName) + // The PRIMARY KEY clause must mention both composite columns; verifying + // both names appear together in the SQL is sufficient to catch a regression + // that dropped or reordered the composite key. + let normalized = createSQL.replacingOccurrences(of: "\"", with: "") + XCTAssertTrue( + normalized.contains("PRIMARY KEY"), + "Expected PRIMARY KEY clause in: \(createSQL)" + ) + XCTAssertTrue( + normalized.contains(SQLiteSchema.Records.cacheKey) && + normalized.contains(SQLiteSchema.Records.fieldName), + "Expected composite (cache_key, field_name) in: \(createSQL)" + ) } - + + // MARK: - SchemaVersion parsing + + func test__SchemaVersion_parse__givenDottedFormat_yieldsBothComponents() { + let parsed = SchemaVersion("3.1") + XCTAssertEqual(parsed, SchemaVersion(major: 3, minor: 1)) + } + + func test__SchemaVersion_parse__givenBareMajor_yieldsZeroMinor() { + let parsed = SchemaVersion("3") + XCTAssertEqual(parsed, SchemaVersion(major: 3, minor: 0)) + } + + func test__SchemaVersion_parse__givenMalformedInput_returnsNil() { + XCTAssertNil(SchemaVersion("")) + XCTAssertNil(SchemaVersion("abc")) + XCTAssertNil(SchemaVersion("3.x")) + XCTAssertNil(SchemaVersion("3.")) + XCTAssertNil(SchemaVersion(".5")) + } + + func test__SchemaVersion_description__roundTripsThroughParser() { + let original = SchemaVersion(major: 4, minor: 2) + XCTAssertEqual(SchemaVersion(original.description), original) + } + + func test__SchemaVersion_comparable__ordersByMajorThenMinor() { + XCTAssertLessThan(SchemaVersion(major: 1), SchemaVersion(major: 2)) + XCTAssertLessThan(SchemaVersion(major: 3, minor: 0), SchemaVersion(major: 3, minor: 1)) + XCTAssertLessThan(SchemaVersion(major: 3, minor: 9), SchemaVersion(major: 4, minor: 0)) + } + + private func readTableSQL(dbURL: URL, tableName: String) throws -> String { + var db: OpaquePointer? + let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX | SQLITE_OPEN_URI + let openResult = sqlite3_open_v2(dbURL.path, &db, flags, nil) + guard openResult == SQLITE_OK else { + throw SQLiteError.open(path: dbURL.path, resultCode: openResult) + } + defer { sqlite3_close(db) } + + var stmt: OpaquePointer? + let prepareResult = sqlite3_prepare_v2(db, "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", -1, &stmt, nil) + guard prepareResult == SQLITE_OK else { + throw SQLiteError.prepare(message: "Failed to prepare sqlite_master lookup", resultCode: prepareResult) + } + defer { sqlite3_finalize(stmt) } + + let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, tableName, -1, sqliteTransient) + + let step = sqlite3_step(stmt) + guard step == SQLITE_ROW, let cStr = sqlite3_column_text(stmt, 0) else { + return "" + } + return String(cString: cStr) + } + private func dropSQLiteTable(dbURL: URL, tableName: String) throws { var db: OpaquePointer? let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX | SQLITE_OPEN_URI @@ -91,7 +235,7 @@ class ApolloSQLiteDatabaseBehaviorTests: XCTestCase { if result != SQLITE_OK { throw SQLiteError.open(path: dbURL.path, resultCode: result) } - + let sql = "DROP TABLE IF EXISTS \(tableName)" let execResult = sqlite3_exec(db, sql, nil, nil, nil) if execResult != SQLITE_OK { diff --git a/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift b/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift index cf142f9c6..168322a97 100644 --- a/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift +++ b/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift @@ -74,53 +74,74 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { public func createRecordsTableIfNeeded() throws { try performSync { let sql = """ - CREATE TABLE IF NOT EXISTS "records" ( - "_id" INTEGER, - "key" TEXT UNIQUE, - "record" TEXT, - PRIMARY KEY("_id" AUTOINCREMENT) + CREATE TABLE IF NOT EXISTS "\(SQLiteSchema.recordsTableName)" ( + "\(SQLiteSchema.LegacyRecords.id)" INTEGER, + "\(SQLiteSchema.LegacyRecords.key)" TEXT UNIQUE, + "\(SQLiteSchema.LegacyRecords.record)" TEXT, + PRIMARY KEY("\(SQLiteSchema.LegacyRecords.id)" AUTOINCREMENT) ); """ - try exec(sql, errorMessage: "Failed to create 'records' database table") + try exec(sql, errorMessage: "Failed to create '\(SQLiteSchema.recordsTableName)' database table") } } + public func createNewRecordsTableIfNeeded() throws { + try performSync { + let sql = """ + CREATE TABLE IF NOT EXISTS "\(SQLiteSchema.recordsTableName)" ( + "\(SQLiteSchema.Records.cacheKey)" TEXT NOT NULL, + "\(SQLiteSchema.Records.fieldName)" TEXT NOT NULL, + "\(SQLiteSchema.Records.intValue)" INTEGER, + "\(SQLiteSchema.Records.stringValue)" TEXT, + "\(SQLiteSchema.Records.floatValue)" REAL, + "\(SQLiteSchema.Records.boolValue)" INTEGER, + "\(SQLiteSchema.Records.listValue)" TEXT, + "\(SQLiteSchema.Records.childKeyValue)" TEXT, + "\(SQLiteSchema.Records.customScalarValue)" TEXT, + "\(SQLiteSchema.Records.writtenAt)" INTEGER NOT NULL, + PRIMARY KEY ("\(SQLiteSchema.Records.cacheKey)", "\(SQLiteSchema.Records.fieldName)") + ) WITHOUT ROWID; + """ + try exec(sql, errorMessage: "Failed to create row-per-field '\(SQLiteSchema.recordsTableName)' database table") + } + try writeSchemaVersion(SQLiteSchema.currentVersion) + } + public func createSchemaMetadataTableIfNeeded() throws { try performSync { let sql = """ - CREATE TABLE IF NOT EXISTS "\(Self.schemaMetadataTableName)" ( - "\(Self.schemaMetadataKeyColumnName)" TEXT PRIMARY KEY, - "\(Self.schemaMetadataValueColumnName)" TEXT + CREATE TABLE IF NOT EXISTS "\(SQLiteSchema.Metadata.tableName)" ( + "\(SQLiteSchema.Metadata.keyColumn)" TEXT PRIMARY KEY, + "\(SQLiteSchema.Metadata.valueColumn)" TEXT ); """ - try exec(sql, errorMessage: "Failed to create '\(Self.schemaMetadataTableName)' database table") + try exec(sql, errorMessage: "Failed to create '\(SQLiteSchema.Metadata.tableName)' database table") } } - public func readSchemaVersion() throws -> Int { + public func readSchemaVersion() throws -> SchemaVersion? { try performSync { let sql = """ - SELECT \(Self.schemaMetadataValueColumnName) - FROM \(Self.schemaMetadataTableName) - WHERE \(Self.schemaMetadataKeyColumnName) = ? + SELECT \(SQLiteSchema.Metadata.valueColumn) + FROM \(SQLiteSchema.Metadata.tableName) + WHERE \(SQLiteSchema.Metadata.keyColumn) = ? """ let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare schema-version read") defer { sqlite3_finalize(stmt) } - sqlite3_bind_text(stmt, 1, Self.schemaVersionMetadataKey, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 1, SQLiteSchema.Metadata.versionKey, -1, SQLITE_TRANSIENT) let stepResult = sqlite3_step(stmt) switch stepResult { case SQLITE_DONE: - // No row stored for the schema-version key — treat as version 0. - return 0 + // No row stored for the schema-version key. + return nil case SQLITE_ROW: guard let textPtr = sqlite3_column_text(stmt, 0) else { - return 0 + return nil } - let raw = String(cString: textPtr) - return Int(raw) ?? 0 + return SchemaVersion(String(cString: textPtr)) default: throw SQLiteError.step( message: "Schema-version read failed: \(sqliteErrorMessage())", @@ -130,19 +151,19 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { } } - public func writeSchemaVersion(_ version: Int) throws { + public func writeSchemaVersion(_ version: SchemaVersion) throws { try performSync { let sql = """ - INSERT INTO \(Self.schemaMetadataTableName) (\(Self.schemaMetadataKeyColumnName), \(Self.schemaMetadataValueColumnName)) + INSERT INTO \(SQLiteSchema.Metadata.tableName) (\(SQLiteSchema.Metadata.keyColumn), \(SQLiteSchema.Metadata.valueColumn)) VALUES (?, ?) - ON CONFLICT(\(Self.schemaMetadataKeyColumnName)) DO UPDATE SET \(Self.schemaMetadataValueColumnName) = excluded.\(Self.schemaMetadataValueColumnName) + ON CONFLICT(\(SQLiteSchema.Metadata.keyColumn)) DO UPDATE SET \(SQLiteSchema.Metadata.valueColumn) = excluded.\(SQLiteSchema.Metadata.valueColumn) """ let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare schema-version write") defer { sqlite3_finalize(stmt) } - sqlite3_bind_text(stmt, 1, Self.schemaVersionMetadataKey, -1, SQLITE_TRANSIENT) - sqlite3_bind_text(stmt, 2, String(version), -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 1, SQLiteSchema.Metadata.versionKey, -1, SQLITE_TRANSIENT) + sqlite3_bind_text(stmt, 2, version.description, -1, SQLITE_TRANSIENT) let stepResult = sqlite3_step(stmt) if stepResult != SQLITE_DONE { @@ -165,9 +186,9 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { let rows = try performSync { let placeholders = batch.map { _ in "?" }.joined(separator: ", ") let sql = """ - SELECT \(Self.keyColumnName), \(Self.recordColumName) - FROM \(Self.tableName) - WHERE \(Self.keyColumnName) IN (\(placeholders)) + SELECT \(SQLiteSchema.LegacyRecords.key), \(SQLiteSchema.LegacyRecords.record) + FROM \(SQLiteSchema.recordsTableName) + WHERE \(SQLiteSchema.LegacyRecords.key) IN (\(placeholders)) """ let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare select statement") @@ -205,9 +226,9 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { try performSync { let sql = """ - INSERT INTO \(Self.tableName) (\(Self.keyColumnName), \(Self.recordColumName)) + INSERT INTO \(SQLiteSchema.recordsTableName) (\(SQLiteSchema.LegacyRecords.key), \(SQLiteSchema.LegacyRecords.record)) VALUES (?, ?) - ON CONFLICT(\(Self.keyColumnName)) DO UPDATE SET \(Self.recordColumName) = excluded.\(Self.recordColumName) + ON CONFLICT(\(SQLiteSchema.LegacyRecords.key)) DO UPDATE SET \(SQLiteSchema.LegacyRecords.record) = excluded.\(SQLiteSchema.LegacyRecords.record) """ try exec("BEGIN TRANSACTION", errorMessage: "Failed to begin insert/update transaction") @@ -218,7 +239,7 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { for (key, record) in records { sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT) sqlite3_bind_text(stmt, 2, record, -1, SQLITE_TRANSIENT) - + let result = sqlite3_step(stmt) if result != SQLITE_DONE { rollbackTransaction() @@ -240,7 +261,7 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { public func deleteRecord(for cacheKey: CacheKey) throws { try performSync { - let sql = "DELETE FROM \(Self.tableName) WHERE \(Self.keyColumnName) = ?" + let sql = "DELETE FROM \(SQLiteSchema.recordsTableName) WHERE \(SQLiteSchema.LegacyRecords.key) = ?" let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare delete statement") defer { sqlite3_finalize(stmt) } @@ -257,7 +278,7 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { let wildcardPattern = "%\(pattern)%" try performSync { - let sql = "DELETE FROM \(Self.tableName) WHERE \(Self.keyColumnName) LIKE ? COLLATE NOCASE" + let sql = "DELETE FROM \(SQLiteSchema.recordsTableName) WHERE \(SQLiteSchema.LegacyRecords.key) LIKE ? COLLATE NOCASE" let stmt = try prepareStatement(sql, errorMessage: "Failed to prepare delete pattern statement") defer { sqlite3_finalize(stmt) } @@ -271,7 +292,7 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { public func clearDatabase(shouldVacuumOnClear: Bool) throws { try performSync { - try exec("DELETE FROM \(Self.tableName)", errorMessage: "Failed to clear database") + try exec("DELETE FROM \(SQLiteSchema.recordsTableName)", errorMessage: "Failed to clear database") if shouldVacuumOnClear { try exec("VACUUM;", errorMessage: "Failed to vacuum database") } diff --git a/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift b/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift index c25f241e8..d8d9d258f 100644 --- a/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift +++ b/apollo-ios/Sources/ApolloSQLite/SQLiteDatabase.swift @@ -16,7 +16,7 @@ public enum SQLiteError: Error, CustomStringConvertible { case open(path: String, resultCode: Int32) case prepare(message: String, resultCode: Int32) case step(message: String, resultCode: Int32) - + public var description: String { switch self { case .execution(let message, _): @@ -37,21 +37,32 @@ public protocol SQLiteDatabase { func createRecordsTableIfNeeded() throws + /// Creates the row-per-field records table if it doesn't exist, and + /// stamps `SQLiteSchema.Metadata.versionKey` with `SQLiteSchema.currentVersion`. + /// The table uses a composite `(cache_key, field_name)` primary key and is + /// declared `WITHOUT ROWID` so rows for one record cluster on disk in + /// primary-key order, which keeps batched reads sequential. + /// + /// The caller must ensure the schema-metadata table already exists + /// (call `createSchemaMetadataTableIfNeeded()` first). + func createNewRecordsTableIfNeeded() throws + /// Creates the schema-metadata table if it doesn't exist. The table is a /// key/value store keyed on `String`; the only reserved key currently - /// recognized is `version`, holding the integer schema version of the - /// records table layout (read via `readSchemaVersion()`). + /// recognized is `SQLiteSchema.Metadata.versionKey`, holding the + /// `SchemaVersion` of the records-table layout (read via `readSchemaVersion()`). func createSchemaMetadataTableIfNeeded() throws - /// Returns the integer schema version stamped in the metadata table, or - /// `0` when no row exists. Callers use the value to decide whether the - /// stored data needs to be migrated to a newer schema layout. - func readSchemaVersion() throws -> Int + /// Returns the `SchemaVersion` stamped in the metadata table, or `nil` if + /// no version row exists or the stored value cannot be parsed. Callers + /// use the value to decide whether the stored data needs to be migrated + /// to a newer schema layout. + func readSchemaVersion() throws -> SchemaVersion? - /// Writes the integer schema version into the metadata table, replacing - /// any prior value. The metadata table must already exist; the caller is + /// Writes the `SchemaVersion` into the metadata table, replacing any + /// prior value. The metadata table must already exist; the caller is /// expected to call `createSchemaMetadataTableIfNeeded()` first. - func writeSchemaVersion(_ version: Int) throws + func writeSchemaVersion(_ version: SchemaVersion) throws func selectRawRows(forKeys keys: Set<CacheKey>) throws -> [DatabaseRow] @@ -75,39 +86,3 @@ extension SQLiteDatabase { } } - -public extension SQLiteDatabase { - - static var tableName: String { - "records" - } - - static var idColumnName: String { - "_id" - } - - static var keyColumnName: String { - "key" - } - - static var recordColumName: String { - "record" - } - - static var schemaMetadataTableName: String { - "schema_metadata" - } - - static var schemaMetadataKeyColumnName: String { - "key" - } - - static var schemaMetadataValueColumnName: String { - "value" - } - - /// The metadata key under which the records-table schema version is stored. - static var schemaVersionMetadataKey: String { - "version" - } -} diff --git a/apollo-ios/Sources/ApolloSQLite/SQLiteSchema.swift b/apollo-ios/Sources/ApolloSQLite/SQLiteSchema.swift new file mode 100644 index 000000000..da1eb581f --- /dev/null +++ b/apollo-ios/Sources/ApolloSQLite/SQLiteSchema.swift @@ -0,0 +1,50 @@ +/// Compile-time constants for the SQLite cache database schema. +/// +/// Grouped to make table boundaries explicit and to avoid free-floating +/// static properties on `SQLiteDatabase`. The two records-table layouts +/// share a physical table name (`recordsTableName`); only the column set +/// differs. +public enum SQLiteSchema { + + /// The records-table schema version that this build of Apollo iOS reads + /// and writes. Stamped into `Metadata` on a fresh database; consulted by + /// later openers to decide whether the on-disk layout needs to be + /// migrated to the current shape. + public static var currentVersion: SchemaVersion { + SchemaVersion(major: 3, minor: 0) + } + + /// Shared physical name of the records table across both layouts. + public static let recordsTableName = "records" + + /// Columns for the row-per-field records-table layout. + public enum Records { + public static let cacheKey = "cache_key" + public static let fieldName = "field_name" + public static let intValue = "int_value" + public static let stringValue = "string_value" + public static let floatValue = "float_value" + public static let boolValue = "bool_value" + public static let listValue = "list_value" + public static let childKeyValue = "child_key_value" + public static let customScalarValue = "custom_scalar_value" + public static let writtenAt = "written_at" + } + + /// Columns for the legacy single-row JSON-blob records-table layout used + /// by pre-3.0 Apollo iOS caches. Still referenced while migration support + /// from older caches lives in the SQLite layer. + public enum LegacyRecords { + public static let id = "_id" + public static let key = "key" + public static let record = "record" + } + + /// Names and columns for the `schema_metadata` key/value table. + public enum Metadata { + public static let tableName = "schema_metadata" + public static let keyColumn = "key" + public static let valueColumn = "value" + public static let versionKey = "version" + } +} diff --git a/apollo-ios/Sources/ApolloSQLite/SchemaVersion.swift b/apollo-ios/Sources/ApolloSQLite/SchemaVersion.swift new file mode 100644 index 000000000..88737435b --- /dev/null +++ b/apollo-ios/Sources/ApolloSQLite/SchemaVersion.swift @@ -0,0 +1,48 @@ +/// A dotted-decimal version tag for the SQLite records-table layout. +/// +/// `major` typically aligns with the Apollo iOS major release that introduces +/// a schema change; `minor` allows for incremental schema iterations within +/// the same major release. Ordering is lexicographic over `(major, minor)`. +/// +/// The on-disk representation is the `description` string (`"M.m"`), +/// stored as TEXT in the `schema_metadata` table. +public struct SchemaVersion: Sendable, Hashable, Comparable, CustomStringConvertible { + + public let major: Int + public let minor: Int + + public init(major: Int, minor: Int = 0) { + self.major = major + self.minor = minor + } + + /// Parses a string formatted as `"M.m"` or `"M"`. Returns `nil` for malformed + /// input. A bare integer (`"3"`) is treated as `"3.0"` so older stamps that + /// omitted the minor component still load. + public init?(_ rawValue: String) { + let majorPart: Substring + let minorPart: Substring + if let dot = rawValue.firstIndex(of: ".") { + majorPart = rawValue[..<dot] + minorPart = rawValue[rawValue.index(after: dot)...] + } else { + majorPart = Substring(rawValue) + minorPart = "0" + } + + guard let major = Int(majorPart), let minor = Int(minorPart) else { + return nil + } + self.major = major + self.minor = minor + } + + public var description: String { + "\(major).\(minor)" + } + + public static func < (lhs: SchemaVersion, rhs: SchemaVersion) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + return lhs.minor < rhs.minor + } +} From 2aaeba863a92c79ecfcc59224a0f8041b294e0b1 Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Fri, 29 May 2026 09:31:20 -0700 Subject: [PATCH 20/24] =?UTF-8?q?docs(cache):=20ADR=200006=20=E2=80=94=20l?= =?UTF-8?q?ist=20storage=20strategy=20(#1002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../Design/adr/0006-list-storage-strategy.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 apollo-ios/Design/adr/0006-list-storage-strategy.md diff --git a/apollo-ios/Design/adr/0006-list-storage-strategy.md b/apollo-ios/Design/adr/0006-list-storage-strategy.md new file mode 100644 index 000000000..ba1cf6bee --- /dev/null +++ b/apollo-ios/Design/adr/0006-list-storage-strategy.md @@ -0,0 +1,120 @@ +# ADR 0006 — List storage: in-place row-per-element with `position` + +- **Status:** Accepted +- **Date:** 2026-05-28 +- **Phase 1 PR:** Out-of-stack docs follow-up to PR-009. Schema implementation lands as part of PR-009's amended scope (see "Implementation impact"); engineering plan §7.1/§7.2 and execution plan §8 are revised in follow-up PRs. +- **Engineering plan reference:** [cache-rewrite-phase1-plan.md §7.1, §7.2](../cache-rewrite-phase1-plan.md) — to be rewritten per this ADR + +## Context + +[ADR 0002](./0002-record-abstraction.md) settled the in-memory `Record` shape; engineering plan §7.1 settled the on-disk shape as one row per field, with per-type value columns (`int_value`, `string_value`, `float_value`, `bool_value`, `child_key_value`, `custom_scalar_value`) plus `list_value TEXT` for list-typed fields. Scalars land in their typed column; lists land as a JSON-encoded blob. + +The PR-009 review flagged the asymmetry: scalar fields get a typed, indexable column, but list elements collapse to a single opaque string. The §7.1 choice traces to Zach's [SQLite Performance Benchmarks](https://apollographql.atlassian.net/wiki/spaces/ClientDev/pages/1585152147) (Confluence 1585152147), but the benchmark measures only exact-key selects, type+selection-set selects, composite-PK updates, single-row inserts, and CTE-join sorts — no list-heavy paths. The JSON shape was inherited without measurement. + +Separately, the cache must support **per-element queries against list-typed fields**: filtering and indexing list elements at the SQL level, watcher observation of single-element changes, and walking list elements during the Phase 2 `@onDelete` cascade. JSON storage forecloses all of these — every per-element operation requires loading and parsing the entire blob in Swift, with no opportunity for SQLite's query planner to participate. This is a capability constraint, not a performance preference. + +The capability requirement eliminates JSON from the option space. The remaining choice — between an in-place row-per-element layout and a sibling `list_items` table — decides on design merit. The in-place layout dominates on every dimension that matters; the sibling table only adds indirection and surface area. See "Alternatives considered." + +## Decision + +**Each list element becomes its own row in the `records` table, addressed by an extended primary key.** A `position` column joins `cache_key` and `field_name` in the PK; scalars use the sentinel `position = -1`, and list elements use `position = 0..N-1`. + +```sql +CREATE TABLE IF NOT EXISTS records ( + cache_key TEXT NOT NULL, + field_name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT -1, -- -1 = scalar; 0..N-1 = list element + int_value INTEGER, + string_value TEXT, + float_value REAL, + bool_value INTEGER, + child_key_value TEXT, + custom_scalar_value TEXT, + written_at INTEGER NOT NULL, + PRIMARY KEY (cache_key, field_name, position) +) WITHOUT ROWID; +``` + +The `list_value TEXT` column from the §7.1 shape is removed. + +**Depth-1 lists** (`[Int]`, `[String]`, `[Friend]` — ~99% of GraphQL list-typed fields in practice) are handled entirely in-place. List-element rows live at the same `cache_key` as the parent record's scalar fields; `WITHOUT ROWID` clustering keeps them physically adjacent on disk. One `SELECT … WHERE cache_key = ?` returns scalar fields and list elements together in the same result set. + +**Nested lists** (`[[Int]]`, `[[CacheReference]]` — the rare case) recurse via the existing `CacheKey` indirection. An outer list's row holds a synthetic `child_key_value` (e.g., `User:1.tags[3]`) pointing to a sub-record that itself uses the depth-1 layout. This reuses the reference machinery the cache already has rather than introducing a new mechanism. Depth is bounded by the GraphQL schema, known at codegen time, so the executor never needs recursive CTEs or "find all descendants" queries. This is the industry-standard **adjacency-list-with-position** pattern applied to a domain where depth is bounded. + +### Implementation impact + +The current §7.1 DDL (with `list_value TEXT`) was landed in PR-008 (#1000, merged into the plan branch). The new DDL replaces it. The migration cost is zero on existing 3.0-alpha installs because **3.0-alpha has not tagged** — the §7.1 schema has only ever existed on the long-lived plan branch and in development databases. The drop-and-rebuild path on first 3.0 launch (per [ADR 0001](./0001-major-version-bump.md)) carries this ADR's schema, not the rejected one. + +PR-009 (#1001, open) implemented row-per-field CRUD against the old `list_value` shape. Its scope is amended: +- The PK extension and `position` sentinel land as part of PR-009 (or a follow-up PR-009b, depending on review preference). +- The JSON list-encoding branches in `SQLiteFieldEncoding.swift` are replaced with position-aware row writers. +- The decoder's row-grouping logic gets a `position = -1` branch for scalar rows and a `position >= 0` branch that accumulates ordered list elements. +- The depth ≥ 2 recursion path adds a small `CacheKey` factory for synthetic sub-record keys. + +The PR-009 review-findings hardening (`NSNull` round-trip, `$reference`-wrapper disambiguation, `sortedKeys`, `JSONSerialization.isValidJSONObject` probe) is retained where it applies to the `custom_scalar_value` path. The list-encoding branches are discarded. + +Engineering plan §7.1 (DDL) and §7.2 (operations) are rewritten in a separate follow-up doc PR to match this ADR. Execution plan §8 is amended in the same follow-up to reflect the PR-009 scope change and any new PR slots that result. + +The performance-harness PRs (PR-011, PR-011a) still receive the list-heavy scenarios discussed during this ADR's drafting — `read-record-with-list-of-N`, `write-list-of-N`, `mutate-element-at-position-K-in-list-of-N`, the nested `[[T]]` reads, and `filter-list-elements-by-typed-column` — but as permanent regression coverage of the chosen design, not as decision-gating evidence. + +## Alternatives considered + +### A. JSON-encoded `list_value` (the §7.1 default) + +Each list-typed field stores its elements as a JSON-encoded `TEXT` blob in `list_value`. Nested lists handle naturally via JSON's recursive structure. + +- *Rejected because:* **list elements stored as a JSON blob cannot be queried at the SQL level.** Filtering by element value, indexing on element shape, single-element watcher observation, and the Phase 2 `@onDelete` cascade walk all require loading and parsing the entire blob in Swift — work proportional to list length on every operation, with no opportunity for SQLite's query planner to participate. The asymmetry with scalar fields (typed, indexable columns) is the surface symptom; the underlying problem is that JSON storage forecloses an entire class of capabilities the cache is required to support. No benchmark outcome rescues this option because the constraint is capability rather than cost. + +### B. Sibling `list_items` table + +Lists move to a second table: + +```sql +CREATE TABLE IF NOT EXISTS list_items ( + cache_key TEXT NOT NULL, field_name TEXT NOT NULL, position INTEGER NOT NULL, + int_value INTEGER, string_value TEXT, float_value REAL, bool_value INTEGER, + child_key_value TEXT, custom_scalar_value TEXT, + PRIMARY KEY (cache_key, field_name, position), + FOREIGN KEY (cache_key, field_name) REFERENCES records(cache_key, field_name) ON DELETE CASCADE +) WITHOUT ROWID; +``` + +Reads issue a second `SELECT` or `LEFT JOIN` for any list-typed field. Nested lists require an explicit depth strategy — depth column, recursive `list_of_lists` table, or per-element indirection. + +- *Rejected because:* the chosen layout dominates on every dimension. **Read locality:** the chosen layout's list-element rows are physically clustered with their parent record's scalar rows via `WITHOUT ROWID`; the sibling-table layout requires a second `SELECT` or join regardless of list size. **Schema surface:** the chosen layout is one table; the sibling table doubles the migration surface for every future schema change. **Nested lists:** the chosen layout reuses the `CacheKey` indirection the cache already has; the sibling table requires a new depth strategy with no existing mechanism to lean on. **Phase 2 `@onDelete` cascade:** the chosen layout's cascade walker reads from one source; the sibling-table walker reads from two. There is no scenario where the second table produces a capability or performance advantage the chosen layout lacks. Even hypothetical edge cases (e.g., wildly disproportionate list-vs-scalar sizes biasing index pressure) are bounded by `WITHOUT ROWID` clustering in the chosen layout. + +## Consequences + +### Positive + +- **Per-element queries are SQL-native.** Filtering, indexing, observing changes, and cascade-walking list elements all happen at the storage layer. No JSON parsing on hot paths. +- **Read locality wins for the common case.** `WITHOUT ROWID` clusters list-element rows next to their parent's scalar rows. A single record read returns the entire record — scalars and lists — in one query, with rows arriving contiguous in the result set. +- **One table, one migration surface.** Every future schema change (Phase 2 LRU `lastAccessedAt`, `@onDelete` cascade fields, additional typed columns) touches `records` only. The sibling-table alternative would have doubled the migration burden. +- **Nested lists reuse existing reference indirection.** No new mechanism for depth ≥ 2; `child_key_value` to a synthetic sub-record is just another use of the indirection the cache already has for object references. +- **PR-009's row-per-field harness is preserved.** The CRUD infrastructure landed in PR-009 (#1001) — query construction, transaction handling, result-row grouping — extends naturally to position-keyed rows. Only the encoder/decoder branches for list values change. + +### Negative + +- **PR-009's JSON list-encoding paths are interim code that gets replaced.** The hardening work in the PR-009 review-findings commit is mostly retained for the `custom_scalar_value` path, but the list-encoding branches in `SQLiteFieldEncoding.swift` are discarded. Mitigation: the discarded surface is localized; existing tests of those paths become test fixtures for the new row-per-element encoder. +- **Decoder branches on the sentinel.** Every `SELECT` against `records` returns scalar rows interleaved with list-element rows; the decoder must branch on `position = -1` for every row. Mitigation: a single integer comparison per row, negligible against the I/O cost of the underlying query. +- **Synthetic-key naming convention must be collision-safe.** The depth ≥ 2 indirection produces keys like `User:1.tags[3]`. The naming convention must be guaranteed not to collide with legitimate cache keys produced by the schema's key-field resolution. Mitigation: the `.field[N]` suffix form has no legitimate use in GraphQL field names, and the brackets are already part of cache-key syntax; a reserved-character audit lands with PR-009. +- **Engineering plan §7.1, §7.2 and execution plan §8 need follow-up doc updates** to match this ADR. Mitigation: those updates are mechanical and land as a single follow-up doc PR; this ADR is the source of truth in the meantime. + +### Neutral + +- **The PR-011 / PR-011a list-heavy scenarios still get added** — but as permanent regression coverage, not as decision-gating evidence. Tier 3 `read-record-with-list-of-N`, `write-list-of-N`, `mutate-element-at-position-K`, the nested-list scenarios, and `filter-list-elements-by-typed-column` (capability-coverage) all land in PR-011a's standing output. +- **The 2.x baseline dataset (PR-004a, #980) does not measure list paths separately** because the 2.x storage layer does not separate them. The alpha-vs-2.x comparison reports list-path numbers as new data without a 2.x baseline; this is documented in the comparison reporter's output (per perf plan §5). +- **3.0-alpha tag gating is unaffected.** The list-storage shape is no longer a separate "decision lock before PR-012" item; PR-012's existing gating (SQLite performance gates + no `regressed` verdict in the published dataset) covers regression detection against the chosen layout going forward. + +## References + +- [Engineering plan §7.1, §7.2](../cache-rewrite-phase1-plan.md) — DDL and operations (to be rewritten per this ADR) +- [Engineering plan §7.4](../cache-rewrite-phase1-plan.md) — published performance gates (unchanged) +- [Perf plan §3.2, §3.3, §5.2](../cache-rewrite-phase1-perf.md) — Tier 2 / Tier 3 scenario design and the inline `feat(perf): add scenario` pattern (used for the list-heavy scenarios as permanent coverage) +- [Execution plan §8](../cache-rewrite-phase1-execution.md) — PR list (PR-009 scope amended; PR list updated in follow-up) +- [ADR 0001 — Major version bump](./0001-major-version-bump.md) — drop-and-rebuild migration policy that absorbs the schema change at zero cost while 3.0-alpha is untagged +- [ADR 0002 — Record abstraction](./0002-record-abstraction.md) — in-memory `CachedField` shape; this ADR is the on-disk counterpart for list-typed fields +- [SQLite Performance Benchmarks (Confluence 1585152147)](https://apollographql.atlassian.net/wiki/spaces/ClientDev/pages/1585152147) — origin of the row-per-field shape; the source that does not cover list paths +- [PR-009 (#1001)](https://github.com/apollographql/apollo-ios-dev/pull/1001) — row-per-field CRUD implementation; scope amended per this ADR +- [`SQLiteFieldEncoding.swift`](../../Sources/ApolloSQLite/SQLiteFieldEncoding.swift) — encoder/decoder file affected by the schema change +- Industry pattern reference: adjacency-list-with-position, with depth bounded by the GraphQL schema rather than handled via closure table / nested set. See [Djellouli, *Storing Hierarchical Data in Relational Databases with SQL*](https://adamdjellouli.com/articles/databases_notes/03_sql/09_hierarchical_data) for the general pattern. From 4dfb34e4de7b097e9fa1d02c43cb51a240008d3f Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Fri, 29 May 2026 09:33:36 -0700 Subject: [PATCH 21/24] =?UTF-8?q?docs(cache):=20rewrite=20=C2=A77.1,=20?= =?UTF-8?q?=C2=A77.2,=20=C2=A77.3=20and=20amend=20=C2=A78=20PR-009=20per?= =?UTF-8?q?=20ADR=200006=20(#1004)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../Design/cache-rewrite-phase1-execution.md | 5 ++-- .../Design/cache-rewrite-phase1-plan.md | 24 +++++++++++-------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/apollo-ios/Design/cache-rewrite-phase1-execution.md b/apollo-ios/Design/cache-rewrite-phase1-execution.md index aef90d45c..db8390e6c 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-execution.md +++ b/apollo-ios/Design/cache-rewrite-phase1-execution.md @@ -241,7 +241,7 @@ Phase 0 also produces two spike branches that are **not merged**: Findings from each spike are captured in their respective Phase 0 ADRs (PR-003 references the SQLite spike; the cachecontrol-jsdirective spike findings become a `cache-rewrite/phase-0-adr-cachecontrol-spike` PR if material surprises surface — otherwise findings live as a comment thread on the existing ADR). -### Phase 1A — SQLite schema rewrite + field-aware `Record` (10 PRs) +### Phase 1A — SQLite schema rewrite + field-aware `Record` (11 PRs) Goal: ship 3.0-alpha at end of this phase. No behavior change for end users. Published performance dataset accompanies the alpha tag. @@ -251,7 +251,8 @@ Goal: ship 3.0-alpha at end of this phase. No behavior change for end users. Pub | PR-006 | refactor(cache): change `Record.fields` type to `[CacheKey: CachedField]` | ⬜ | PR-005 | ~400 | Update existing Record/RecordSet tests; verify `record[key]` subscript still returns `Value?` for all existing call sites | | PR-007 | feat(sqlite): add `schema_metadata` table and version detection | ⬜ | PR-006 | ~150 | Unit: schema-version read/write, missing-row defaults to 0, version stamping on init | | PR-008 | feat(sqlite): new schema DDL — records table with composite PK + typed columns | ⬜ | PR-007 | ~200 | Unit: table creation idempotent, `WITHOUT ROWID` preserved, schema_metadata version=3 stamped | -| PR-009 | feat(sqlite): implement insert/select/update/delete on new table (feature-flagged) | ⬜ | PR-008 | ~600 | Unit: each operation against new schema; round-trip Record↔rows; transactional behavior on failure; performance smoke test | +| PR-008b | feat(sqlite): replace records DDL with position-keyed v4 schema per [ADR 0006](./adr/0006-list-storage-strategy.md) | ⬜ | PR-008 | ~150 | Unit: new table creation idempotent with extended PK `(cache_key, field_name, position)` and `position INTEGER NOT NULL DEFAULT -1`; `WITHOUT ROWID` preserved; schema_metadata version=4 stamped; migration trigger updated to drop on `< 4` and absorbs the v3 → v4 rebuild path; PR-007/PR-008 tests updated for the new column shape and PK | +| PR-009 | feat(sqlite): row-per-element CRUD against position-keyed schema | ⬜ | PR-008b | ~600 | Unit: each operation against the position-keyed schema per [ADR 0006](./adr/0006-list-storage-strategy.md); round-trip Record↔rows for scalar and list-typed fields (position-aware encoder + decoder); nested-list synthetic sub-record recursion at depth ≥ 2; atomic list-element rewrite (no partial-list states); performance smoke test. Note: this PR supersedes the original "JSON list_value" scope; the row-per-field CRUD harness from the prior implementation is retained, the JSON list-encoding branches in `SQLiteFieldEncoding.swift` are replaced. | | PR-010 | feat(sqlite): switch `SQLiteNormalizedCache` to new schema; drop-and-rebuild migration | ⬜ | PR-009 | ~400 | Unit: migration on detected old schema; integration: existing cache tests pass on new schema; CachePersistenceTests updated | | PR-011 | test(cache): SQLite performance-gate harness on iPhone 16 Pro | ⬜ | PR-010 | ~200 | Performance test asserting all §7.4 gates within 25% margin | | PR-011a | feat(cache): comprehensive performance measurement harness (Tier 1 + Tier 2) | ⬜ | PR-011 | ~700 | Unit: each Tier 1 and Tier 2 scenario runs cleanly; JSON exporter produces well-formed output; harness is re-runnable across versions | diff --git a/apollo-ios/Design/cache-rewrite-phase1-plan.md b/apollo-ios/Design/cache-rewrite-phase1-plan.md index bfac82c64..29c0fc339 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-plan.md +++ b/apollo-ios/Design/cache-rewrite-phase1-plan.md @@ -281,19 +281,21 @@ public enum Source: Sendable { ### 7.1 New schema (DDL) +The schema is row-per-element: each scalar field is one row and each list element is one row, all in the same `records` table. The `position` column distinguishes them — `-1` for scalars, `0..N-1` for list elements — and is part of the primary key. List-element rows live at the same `cache_key` as their parent record's scalar fields and cluster physically next to them on disk via `WITHOUT ROWID`. Per [ADR 0006](./adr/0006-list-storage-strategy.md), nested lists (`[[T]]`) recurse via `child_key_value` indirection to synthetic sub-records that themselves use the same layout. + ```sql CREATE TABLE IF NOT EXISTS records ( cache_key TEXT NOT NULL, field_name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT -1, -- -1 = scalar; 0..N-1 = list element int_value INTEGER, string_value TEXT, float_value REAL, bool_value INTEGER, - list_value TEXT, -- JSON-encoded list - child_key_value TEXT, -- cache reference + child_key_value TEXT, -- cache reference (or synthetic sub-record key for nested lists; see ADR 0006) custom_scalar_value TEXT, -- JSON-encoded written_at INTEGER NOT NULL, - PRIMARY KEY (cache_key, field_name) + PRIMARY KEY (cache_key, field_name, position) ) WITHOUT ROWID; ``` @@ -304,14 +306,16 @@ CREATE TABLE IF NOT EXISTS schema_metadata ( key TEXT PRIMARY KEY, value TEXT ); --- on init: INSERT OR REPLACE INTO schema_metadata VALUES ('version', '3'); +-- on init: INSERT OR REPLACE INTO schema_metadata VALUES ('version', '4'); ``` +The version bump from 3 to 4 corresponds to ADR 0006's adoption of the row-per-element layout. v3 (the JSON `list_value` shape predecessor) was never tagged externally; the bump exists to give any local dev databases that ran the v3 shape on the plan branch a clean rebuild path on next launch. + ### 7.2 Operations -- `selectRecords(forKeys:)` — single `SELECT … WHERE cache_key IN (?, ?, …) ORDER BY cache_key, field_name`. Reassembles into `Record` instances by grouping by `cache_key` in Swift. Composite-PK clustering ensures rows for one record arrive contiguous in the result set. -- `addOrUpdate(records:)` — shreds each `Record.fields` into N row UPSERTs in one transaction. Each row carries its `written_at`. -- `deleteRecord(for:)` — `DELETE FROM records WHERE cache_key = ?`. +- `selectRecords(forKeys:)` — single `SELECT … WHERE cache_key IN (?, ?, …) ORDER BY cache_key, field_name, position`. Reassembles into `Record` instances by grouping by `cache_key` in Swift; the decoder branches on `position` to dispatch scalar rows (`position = -1`) and list-element rows (`position >= 0`, accumulated in order). Composite-PK clustering ensures rows for one record arrive contiguous in the result set, with scalar and list-element rows for a given field arriving as a contiguous run. +- `addOrUpdate(records:)` — shreds each `Record.fields` into row UPSERTs in one transaction. Scalar fields produce one row at `position = -1`; list-typed fields produce N rows at `position = 0..N-1`. Each row carries its `written_at`. List-element rows for a field are rewritten atomically — an update to a list-typed field deletes the existing element rows and inserts the new ones in the same transaction, so partial-list states are not observable. +- `deleteRecord(for:)` — `DELETE FROM records WHERE cache_key = ?`. Scalar rows and depth-1 list-element rows delete in the same statement (they share the cache_key). Nested-list sub-records at depth ≥ 2 live at synthetic keys (`<parent>.<field>[N]` per [ADR 0006](./adr/0006-list-storage-strategy.md)) and require a small cascading reachability walk that follows `child_key_value` columns. - `deleteRecords(matching:)` — unchanged semantics (`WHERE cache_key LIKE ? COLLATE NOCASE`). - `clearDatabase` — unchanged. @@ -320,10 +324,10 @@ CREATE TABLE IF NOT EXISTS schema_metadata ( On `init`, after `createRecordsTableIfNeeded`: 1. Read `schema_metadata` for the version. -2. If version is missing or `< 3`, drop and recreate the records table; insert the new version. -3. If version is `3`, no migration needed. +2. If version is missing or `< 4`, drop and recreate the records table; insert the new version. +3. If version is `4`, no migration needed. -The drop-and-rebuild is silent — no user-visible event other than the network fetches that follow on cache-miss reads. +The drop-and-rebuild is silent — no user-visible event other than the network fetches that follow on cache-miss reads. The migration trigger absorbs both genuine upgrades from 2.x (no `schema_metadata` row at all) and local-dev databases that ran the v3 shape on the plan branch before [ADR 0006](./adr/0006-list-storage-strategy.md) (version row reads `3`). v3 never tagged externally, so no end-user installation is on v3. ### 7.4 Performance gates From 9b2dbb3d1da383e0004642e98f39eed7b55cd66f Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Fri, 29 May 2026 10:49:37 -0700 Subject: [PATCH 22/24] =?UTF-8?q?docs(cache):=20ADR=200007=20=E2=80=94=20s?= =?UTF-8?q?election-set-aware=20cache=20reads=20(PR-009a)=20(#1003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../adr/0007-selection-aware-cache-reads.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 apollo-ios/Design/adr/0007-selection-aware-cache-reads.md diff --git a/apollo-ios/Design/adr/0007-selection-aware-cache-reads.md b/apollo-ios/Design/adr/0007-selection-aware-cache-reads.md new file mode 100644 index 000000000..88d87265b --- /dev/null +++ b/apollo-ios/Design/adr/0007-selection-aware-cache-reads.md @@ -0,0 +1,135 @@ +# ADR 0007 — Selection-set-aware cache reads with per-field column projection + +- **Status:** Accepted +- **Date:** 2026-05-28 +- **Phase 1 PR:** PR-009a (cache rewrite execution plan §8, sub-phase 1A.5) +- **Engineering plan reference:** [cache-rewrite-phase1-plan.md §7](../cache-rewrite-phase1-plan.md) +- **Related ADRs:** [0001](./0001-major-version-bump.md) (major version bump), [0002](./0002-record-abstraction.md) (Record abstraction) + +## Context + +PR-008 introduced the typed-column SQLite schema, and [ADR 0006](./0006-list-storage-strategy.md) amended it (via PR-008b, ~150 LoC) into its current row-per-element form. Each scalar field becomes one row at `position = -1`; each list element becomes one row at `position = 0..N-1`. The primary key is `(cache_key, field_name, position)`, and each row's value lives in exactly one of six type-specific columns: `int_value`, `string_value`, `float_value`, `bool_value`, `child_key_value`, `custom_scalar_value`. The other five value columns on every row are `NULL`. The schema's central design property is that **each field's (or list element's) storage column is determined by its GraphQL type, which is known at codegen time**. + +PR-009 (as amended by ADR 0006) lays down the corresponding row-per-element writes, deletes, and per-type value encoder/decoder. The read path was initially drafted alongside as `SQLiteDatabase.selectRecords(forKeys:)` — a single `SELECT * FROM records WHERE cache_key IN (…)` that projects all six value columns plus `position` and reassembles whole `Record` instances in Swift. That implementation works, and is preserved as a test-only `internal` helper in PR-009. But it is the wrong shape for the public read contract, for two reasons. + +**1. It does not exploit the schema's central optimization.** Five of the six value columns are `NULL` on every row, and every list-typed field's read pulls N rows where N is the list length. Pulling all six value columns across the SQLite-to-Swift boundary on every row, allocating Swift optionals for each, and then discarding five of them per row is pure waste. The row-per-element schema exists to enable typed reads — *if the caller knows a field is an Int, the SELECT should pull only `int_value`; if the caller knows it is a `[String]`, the SELECT should pull only `string_value` filtered to `position >= 0` and ordered by `position`*. The benchmark dataset that motivated the row-per-field shape (Zach's "SQLite Performance Benchmarks" Confluence page, ID 1585152147) does not gate this optimization — its targets cover scalar select/update/sort timings, not column-projection scenarios — so we have no empirical evidence that whole-record reads hit a perf wall. But shipping a public protocol that bakes whole-record reads into the contract forecloses the optimization for the rest of 3.x without another major version. The cost of holding the contract open is one sub-phase of work today. + +**2. The information needed to project per-field columns lives upstream of the cache.** When `CacheDataExecutionSource.resolveField(with:on:)` resolves a field, it has the field's `Selection.Field` in hand — including its declared output type. The executor *knows* `User.age` is `Int!` and `User.tags` is `[String!]!`. That information currently never reaches the cache: the executor asks `ApolloStore.load(_:)` for whole records, traverses `Record.fields` by response key, and discards everything it does not need. To project at the SQL layer, the executor must declare its field reads *before* it executes them, and that declaration must thread through `ApolloStore`, the `NormalizedCache` protocol, and `SQLiteNormalizedCache` down to the `SQLiteDatabase` SELECT. + +This is a public-protocol-shape change. `NormalizedCache.loadRecords(forKeys:)` is the documented extension point for custom cache implementations; changing it breaks downstream conformers. The cost is justified by the 3.0 major version bump (see [ADR 0001](./0001-major-version-bump.md)) and by the cost of *not* making the change — which would be permanent under semver. + +## Decision + +**Cache reads under 3.0 carry per-field projection info, including each requested field's storage-shape type, end-to-end from executor to SQL.** The whole-record `loadRecords(forKeys:)` API is replaced. `SQLiteNormalizedCache`, `InMemoryNormalizedCache`, and any custom `NormalizedCache` implementor must adopt the new contract. + +The new contract is captured by a value type, `FieldProjection`, that the executor populates from its `Selection.Field` traversal and the cache consumes to drive its read. The exact API surface of `FieldProjection` and the renamed `NormalizedCache.loadFields(_:)` method is fixed in PR-009b/c (the next two PRs in sub-phase 1A.5); this ADR commits only to the principles below. + +### Principles the implementation must honor + +1. **Per-field type info is required.** The cache layer must know, for each requested `(cacheKey, fieldName)` pair, which of the six storage columns the value lives in *and* whether the field is scalar or list-typed (per [ADR 0006](./0006-list-storage-strategy.md)'s `position` discriminator). The executor — not the cache — is the source of truth for this mapping, because the GraphQL type lives in the generated `Selection.Field`. A scalar `Int` field projects `int_value` filtered to `position = -1`; a `[String]` field projects `string_value` filtered to `position >= 0` and ordered by `position`. + +2. **The `NormalizedCache` protocol changes its read shape.** `loadRecords(forKeys:) -> [CacheKey: Record]` is replaced by a load API that takes `[FieldProjection]` (or equivalent) and returns the projected field values. Custom implementors get a one-paragraph migration note in the 3.0 migration guide (PR-029 in the original plan numbering; renumbered under the restructure). + +3. **SQL-level projection is the implementation target for `ApolloSQLiteDatabase`.** The new SELECT projects only the storage column(s) the request specifies, with the appropriate `position` predicate per field. Scalar `int_value`-typed fields produce single-column, single-row reads at `position = -1`; `[String]` fields produce single-column, N-row reads at `position >= 0`, ordered by `position`; mixed selections compose those projections in one SELECT via UNION ALL or a `(cache_key, field_name, position)`-tuple `IN` filter. The grouping back into a per-record shape happens in Swift after rows arrive. + +4. **`InMemoryNormalizedCache` filters fields client-side.** No SQL involved, no projection at the storage layer — the in-memory cache holds full records and just returns the requested subset. The shared `FieldProjection` type drives the same API surface so the two cache backends are interchangeable. + +5. **`CacheDataExecutionSource` declares field reads upfront, not lazily.** The current lazy-resolution pattern (`object[responseKey]`) is incompatible with one-shot SQL reads — the cache cannot project columns it does not know are wanted. The executor switches to a two-phase pattern: traverse the selection set to collect field projections, then resolve. This is the highest-risk PR in the sub-phase; if the executor's existing lazy contract cannot accommodate the change in one PR, the work is split (see PR-009d in the restructured plan). + +6. **Per-field dependency tracking falls out naturally.** `GraphQLDependencyTracker` already records field-level cache keys for watcher dirty-set computation; with field projections in hand, the watcher's `didChangeKeys` re-read becomes the same projection-driven load, only with a different set of fields. No new tracking primitive is required. + +7. **Custom scalars must expose their storage-shape type.** A custom scalar's `_jsonValue` ultimately maps to one of the six SQL columns (typically `custom_scalar_value` for dicts, or one of the primitive columns when the scalar's JSON shape is a primitive). The mechanism by which `CustomScalarType`-conforming types declare their column to the cache — whether via an existing `CustomScalarType` API, a new codegen-emitted property, or runtime introspection — is decided in PR-009b. This ADR commits only to the principle that the declaration is required and must be available statically (no runtime probe). + +### Non-goals + +- **The list-storage layout itself.** Whether list elements are stored as JSON, in a sibling table, or in-place as position-keyed rows is the subject of [ADR 0006](./0006-list-storage-strategy.md), which settled on the row-per-element in-place layout. This ADR *consumes* that layout — the projection API expresses list-field reads in `position`-aware terms because that is what ADR 0006 produced — but does not redecide it. Nested lists (`[[T]]`) are handled by ADR 0006's synthetic-sub-record indirection; the projection mechanism follows the indirection transparently and does not need a separate non-goal carve-out. +- **Per-field TTL evaluation during projection.** TTL is checked at read time inside `CacheDataExecutionSource.resolveField` per [ADR 0003](./0003-ttl-semantics.md). The projection mechanism delivers field values to the executor; TTL evaluation happens after delivery. The two concerns compose but do not interact. +- **Cross-cache compatibility with 2.x.** The new `NormalizedCache` protocol is a breaking change. There is no compatibility shim, no dual-stack. The migration guide is the contract. + +## Alternatives considered + +### A. Status quo — whole-record reads on a typed-column schema + +Keep the current `selectRecords(forKeys:)` shape; the typed columns exist purely to avoid the JSON-parse overhead of the legacy single-column blob layout. + +- *Rejected because:* This trades a 3.x perf optimization for a 5-day refactor today. The typed columns already pay back the JSON-parse cost (PR-008's benchmark gates pass), but the whole-record contract leaves the per-field-projection win on the table permanently. Reversing this later requires a 4.0 major version. Under [ADR 0001](./0001-major-version-bump.md), we have one major-version window to get the public read contract right; spending it on whole-record reads is a one-way ratchet against future performance work. + +### B. Field-name projection only — no GraphQL-type info at the cache layer + +The cache API takes `(cacheKey, fieldName)` pairs but no type. SQL SELECTs still project all six value columns (smaller result sets — fewer rows when the executor only wants a subset — but the same wide column projection per row). + +- *Rejected because:* This is half a fix. Smaller result sets help, but each row still carries five `NULL` columns across the boundary. The bigger win is per-column projection, and that requires type info. Doing the API change without the type info means changing the public `NormalizedCache` contract twice — once for field names, again for types — across the same major version. One coordinated change is cheaper. + +### C. Lazy field resolution via a cache callback + +Keep the executor's lazy `object[responseKey]` pattern; have `loadRecords` return a record-like object whose subscript reaches back into the cache on each field read. The cache projects per-field columns lazily. + +- *Rejected because:* This turns every field read into a separate SQL round trip. For a selection set with N fields on M records, that is N×M prepared-statement steps versus the one batched SELECT the upfront-projection pattern enables. SQLite's parse-and-prepare overhead per statement is small but non-zero, and at typical selection-set sizes (10–30 fields on 1–100 records per read) it dominates the savings from per-column projection. The benchmark we would need to validate this approach does not exist; the upfront-projection alternative is well-understood and aligns with how every other typed-storage cache (Realm, GRDB) shapes its read API. + +### D. Eager whole-record reads with column projection masked at the SQL layer + +The executor still asks for whole records; `SQLiteNormalizedCache` keeps internal selection-set-derived projections it learns from the executor's prior reads, and uses those to project columns on subsequent reads. + +- *Rejected because:* This works for steady-state selection sets that repeat (the same query running repeatedly populates the projection cache), but does nothing for first-read latency — the very metric most affected by cache reads on cold-start UI. The complexity of maintaining the projection cache (invalidation on schema changes, on selection-set evolution, on cache-key changes) is comparable to just plumbing the projection upstream. The upfront approach is also simpler to reason about and to test. + +### E. Single-column generic value with a type tag + +Collapse the six typed columns back to one `value BLOB` column plus a `value_type INTEGER` tag, and do projection by filtering rows on `value_type` instead of by selecting columns. SQL becomes simpler (one column always); reads become "give me rows where value_type is INT". + +- *Rejected because:* This re-introduces the JSON-blob layout's problem under a different name. The BLOB column needs a Swift-side decoder that branches on the type tag — exactly the dispatch table `SQLiteFieldEncoding` already implements at the column level. The typed-column schema is faster because SQLite's storage is column-typed; collapsing back to a generic BLOB undoes that. The schema commitments from PR-008 + PR-008b already locked us out of this alternative; reopening it here would require dropping the schema and re-running the benchmark gates. + +## Migration + +### For users of `Apollo` (the SDK) + +No change. The executor and the public client APIs (`ApolloClient.fetch`, `Watch`, `Subscribe`) have the same shape under 3.0 as under 2.x. Selection-set traversal is internal to the framework; the projection plumbing is invisible to consumers. + +### For custom `NormalizedCache` implementors + +The protocol changes. The legacy `loadRecords(forKeys:)` requirement is removed; in its place is a new requirement (exact name fixed in PR-009c) that takes `FieldProjection` values. The migration is mechanical: change the signature, project the requested fields out of the existing storage, return the new result type. + +Pre-3.0 cache implementations cannot be carried forward without modification. This is the standard cost of the major version bump per [ADR 0001](./0001-major-version-bump.md) and is documented in the 3.0 migration guide. + +### For codegen output + +Generated `Selection.Field` declarations already carry output-type information sufficient to drive projection. Codegen may need to emit one additional piece of metadata per field — the column-shape mapping for custom scalars (per principle 7) — but this is additive and does not break compatibility with pre-PR-009b codegen output. + +## Implementation sequence + +The sub-phase 1A.5 PRs that implement this decision, in order. PR-008b and PR-009 are already in §8 (PR-008b inserted, PR-009 rewritten, per [ADR 0006](./0006-list-storage-strategy.md) and the §8 amendment in PR #1004); PR-009a–h are the additions this ADR introduces. + +| Slot | Title | Notes | +|---|---|---| +| PR-008b | feat(sqlite): position-keyed v4 schema | per ADR 0006; lands the schema this ADR's projection mechanism reads against | +| PR-009 | feat(sqlite): row-per-element CRUD against position-keyed schema | amended per ADR 0006; the internal-test-only `selectRecords` is kept here until PR-009g supersedes it | +| PR-009a | docs(cache): ADR 0007 — selection-set-aware cache reads | **this PR** | +| PR-009b | refactor(cache): introduce `FieldProjection` types | new value types, no consumers yet; includes the scalar-vs-list discriminator required by Principle 1 | +| PR-009c | refactor(cache): `NormalizedCache` adopts field projection | breaking protocol change; `InMemoryNormalizedCache` implements; `SQLiteNormalizedCache` falls back to the PR-009 read path during transition | +| PR-009d | refactor(executor): `CacheDataExecutionSource` declares field reads upfront | highest-risk PR in the sub-phase; may split if the executor's lazy pattern can't be migrated in one shot | +| PR-009e | refactor(cache): `ApolloStore.load(_:)` propagates field projection | wires PR-009d through to `loadFields(_:)` | +| PR-009f | refactor(cache): `GraphQLDependencyTracker` consumes field projections | watcher dirty-set computation switches to projection-driven re-reads | +| PR-009g | feat(sqlite): field-aware `selectFields` with column projection | SQL-level projection in `ApolloSQLiteDatabase`, including `position` predicates per Principle 3; the internal-test-only `selectRecords` from PR-009 is removed in this PR or the next | +| PR-009h | refactor(sqlite): `SQLiteNormalizedCache` switches to field-aware path + drop-and-rebuild migration | the original PR-010 | + +Phase 1A's calendar estimate grows from the post-ADR-0006 count of 11 PRs to ~17 PRs, adding roughly 4–6 weeks to the Phase 1A end date. Phases 1B, 1C, and 1D are unchanged in scope and renumber but do not restructure. + +## Risk and rollback + +The highest risk is PR-009d (the executor reshape). If the executor's lazy field-resolution pattern proves intractable to convert in one PR, the sub-phase splits PR-009d into: + +- **PR-009d-i**: introduce the upfront-projection API alongside the existing lazy pattern; both paths coexist. +- **PR-009d-ii**: switch internal callers (executor and dependency tracker) to the upfront-projection path; remove the lazy pattern. + +This split adds time but does not change the destination. The split is invisible to downstream consumers because the lazy pattern's surface is internal to `ApolloExecution`. + +Rollback after merge: the design is not reversible without another major version. Once `NormalizedCache.loadFields(_:)` ships in 3.0, returning to `loadRecords(forKeys:)` would be a 4.0 break. This is the standard one-way-ratchet cost of public-protocol decisions and is accepted under [ADR 0001](./0001-major-version-bump.md)'s framing. + +## References + +- [Cache rewrite Phase 1 plan](../cache-rewrite-phase1-plan.md), §7 (SQLite schema — rewritten per ADR 0006) +- [Cache rewrite execution plan](../cache-rewrite-phase1-execution.md), §8 (PR list — PR-008b inserted and PR-009 amended per ADR 0006) +- [ADR 0001 — Major version bump](./0001-major-version-bump.md) +- [ADR 0002 — Record abstraction](./0002-record-abstraction.md) +- [ADR 0003 — TTL semantics](./0003-ttl-semantics.md) +- [ADR 0006 — List storage strategy](./0006-list-storage-strategy.md) — the row-per-element schema this ADR's projection mechanism reads against +- PR #1001 (PR-009 — row-per-element CRUD; `selectRecords` retained as internal-test-only) From e06a2b8595b506502a0a381123ef05afb36906ee Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Fri, 29 May 2026 11:49:34 -0700 Subject: [PATCH 23/24] feat(sqlite): replace records DDL with position-keyed v4 schema (PR-008b) (#1005) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../SQLiteDotSwiftDatabaseBehaviorTests.swift | 30 +++++++++++++++---- .../adr/0007-selection-aware-cache-reads.md | 2 +- .../Design/cache-rewrite-phase1-execution.md | 2 +- .../Design/cache-rewrite-phase1-plan.md | 10 +++---- .../ApolloSQLite/ApolloSQLiteDatabase.swift | 10 +++++-- .../Sources/ApolloSQLite/SQLiteSchema.swift | 15 ++++++++-- 6 files changed, 52 insertions(+), 17 deletions(-) diff --git a/Tests/ApolloTests/SQLiteDotSwiftDatabaseBehaviorTests.swift b/Tests/ApolloTests/SQLiteDotSwiftDatabaseBehaviorTests.swift index f5932cc87..923286391 100644 --- a/Tests/ApolloTests/SQLiteDotSwiftDatabaseBehaviorTests.swift +++ b/Tests/ApolloTests/SQLiteDotSwiftDatabaseBehaviorTests.swift @@ -156,9 +156,9 @@ class ApolloSQLiteDatabaseBehaviorTests: XCTestCase { try db.createNewRecordsTableIfNeeded() let createSQL = try readTableSQL(dbURL: url, tableName: SQLiteSchema.recordsTableName) - // The PRIMARY KEY clause must mention both composite columns; verifying - // both names appear together in the SQL is sufficient to catch a regression - // that dropped or reordered the composite key. + // The PRIMARY KEY clause must mention all three composite columns; + // verifying their names appear together in the SQL is sufficient to + // catch a regression that dropped or reordered the composite key. let normalized = createSQL.replacingOccurrences(of: "\"", with: "") XCTAssertTrue( normalized.contains("PRIMARY KEY"), @@ -166,8 +166,28 @@ class ApolloSQLiteDatabaseBehaviorTests: XCTestCase { ) XCTAssertTrue( normalized.contains(SQLiteSchema.Records.cacheKey) && - normalized.contains(SQLiteSchema.Records.fieldName), - "Expected composite (cache_key, field_name) in: \(createSQL)" + normalized.contains(SQLiteSchema.Records.fieldName) && + normalized.contains(SQLiteSchema.Records.position), + "Expected composite (cache_key, field_name, position) in: \(createSQL)" + ) + } + + func test__createNewRecordsTableIfNeeded__positionColumnHasDefaultValue() throws { + let url = SQLiteTestCacheProvider.temporarySQLiteFileURL() + let db = try ApolloSQLiteDatabase(fileURL: url) + try db.createSchemaMetadataTableIfNeeded() + + try db.createNewRecordsTableIfNeeded() + + // The column must declare `INTEGER NOT NULL DEFAULT -1`. The default + // ensures non-list writes that omit `position` land in the right row; + // dropping or changing the default would silently break that path. + let createSQL = try readTableSQL(dbURL: url, tableName: SQLiteSchema.recordsTableName) + let normalized = createSQL.replacingOccurrences(of: "\"", with: "") + XCTAssertTrue( + normalized.range(of: "\(SQLiteSchema.Records.position)[^,]*INTEGER[^,]*NOT NULL[^,]*DEFAULT \(SQLiteSchema.Records.defaultPositionValue)", + options: [.regularExpression, .caseInsensitive]) != nil, + "Expected '\(SQLiteSchema.Records.position) INTEGER NOT NULL DEFAULT \(SQLiteSchema.Records.defaultPositionValue)' in: \(createSQL)" ) } diff --git a/apollo-ios/Design/adr/0007-selection-aware-cache-reads.md b/apollo-ios/Design/adr/0007-selection-aware-cache-reads.md index 88d87265b..9ad9b4947 100644 --- a/apollo-ios/Design/adr/0007-selection-aware-cache-reads.md +++ b/apollo-ios/Design/adr/0007-selection-aware-cache-reads.md @@ -100,7 +100,7 @@ The sub-phase 1A.5 PRs that implement this decision, in order. PR-008b and PR-00 | Slot | Title | Notes | |---|---|---| -| PR-008b | feat(sqlite): position-keyed v4 schema | per ADR 0006; lands the schema this ADR's projection mechanism reads against | +| PR-008b | feat(sqlite): position-keyed schema | per ADR 0006; lands the schema this ADR's projection mechanism reads against. Schema version stays at `3` (Apollo iOS 3.x has not shipped externally, so the layout change is a within-v3 evolution rather than a wire-version bump). | | PR-009 | feat(sqlite): row-per-element CRUD against position-keyed schema | amended per ADR 0006; the internal-test-only `selectRecords` is kept here until PR-009g supersedes it | | PR-009a | docs(cache): ADR 0007 — selection-set-aware cache reads | **this PR** | | PR-009b | refactor(cache): introduce `FieldProjection` types | new value types, no consumers yet; includes the scalar-vs-list discriminator required by Principle 1 | diff --git a/apollo-ios/Design/cache-rewrite-phase1-execution.md b/apollo-ios/Design/cache-rewrite-phase1-execution.md index db8390e6c..04f67dc17 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-execution.md +++ b/apollo-ios/Design/cache-rewrite-phase1-execution.md @@ -251,7 +251,7 @@ Goal: ship 3.0-alpha at end of this phase. No behavior change for end users. Pub | PR-006 | refactor(cache): change `Record.fields` type to `[CacheKey: CachedField]` | ⬜ | PR-005 | ~400 | Update existing Record/RecordSet tests; verify `record[key]` subscript still returns `Value?` for all existing call sites | | PR-007 | feat(sqlite): add `schema_metadata` table and version detection | ⬜ | PR-006 | ~150 | Unit: schema-version read/write, missing-row defaults to 0, version stamping on init | | PR-008 | feat(sqlite): new schema DDL — records table with composite PK + typed columns | ⬜ | PR-007 | ~200 | Unit: table creation idempotent, `WITHOUT ROWID` preserved, schema_metadata version=3 stamped | -| PR-008b | feat(sqlite): replace records DDL with position-keyed v4 schema per [ADR 0006](./adr/0006-list-storage-strategy.md) | ⬜ | PR-008 | ~150 | Unit: new table creation idempotent with extended PK `(cache_key, field_name, position)` and `position INTEGER NOT NULL DEFAULT -1`; `WITHOUT ROWID` preserved; schema_metadata version=4 stamped; migration trigger updated to drop on `< 4` and absorbs the v3 → v4 rebuild path; PR-007/PR-008 tests updated for the new column shape and PK | +| PR-008b | feat(sqlite): replace records DDL with position-keyed schema per [ADR 0006](./adr/0006-list-storage-strategy.md) | ⬜ | PR-008 | ~150 | Unit: new table creation idempotent with extended PK `(cache_key, field_name, position)` and `position INTEGER NOT NULL DEFAULT -1`; `WITHOUT ROWID` preserved; `schema_metadata` version stays at `3` (aligned with the Apollo iOS 3.x major release; the layout change is a within-v3 evolution during Phase 1A); `list_value` removed from `SQLiteSchema.Records`; PR-007/PR-008 tests updated for the new column shape and PK | | PR-009 | feat(sqlite): row-per-element CRUD against position-keyed schema | ⬜ | PR-008b | ~600 | Unit: each operation against the position-keyed schema per [ADR 0006](./adr/0006-list-storage-strategy.md); round-trip Record↔rows for scalar and list-typed fields (position-aware encoder + decoder); nested-list synthetic sub-record recursion at depth ≥ 2; atomic list-element rewrite (no partial-list states); performance smoke test. Note: this PR supersedes the original "JSON list_value" scope; the row-per-field CRUD harness from the prior implementation is retained, the JSON list-encoding branches in `SQLiteFieldEncoding.swift` are replaced. | | PR-010 | feat(sqlite): switch `SQLiteNormalizedCache` to new schema; drop-and-rebuild migration | ⬜ | PR-009 | ~400 | Unit: migration on detected old schema; integration: existing cache tests pass on new schema; CachePersistenceTests updated | | PR-011 | test(cache): SQLite performance-gate harness on iPhone 16 Pro | ⬜ | PR-010 | ~200 | Performance test asserting all §7.4 gates within 25% margin | diff --git a/apollo-ios/Design/cache-rewrite-phase1-plan.md b/apollo-ios/Design/cache-rewrite-phase1-plan.md index 29c0fc339..165e3f1e5 100644 --- a/apollo-ios/Design/cache-rewrite-phase1-plan.md +++ b/apollo-ios/Design/cache-rewrite-phase1-plan.md @@ -306,10 +306,10 @@ CREATE TABLE IF NOT EXISTS schema_metadata ( key TEXT PRIMARY KEY, value TEXT ); --- on init: INSERT OR REPLACE INTO schema_metadata VALUES ('version', '4'); +-- on init: INSERT OR REPLACE INTO schema_metadata VALUES ('version', '3'); ``` -The version bump from 3 to 4 corresponds to ADR 0006's adoption of the row-per-element layout. v3 (the JSON `list_value` shape predecessor) was never tagged externally; the bump exists to give any local dev databases that ran the v3 shape on the plan branch a clean rebuild path on next launch. +The schema version is `3` and stays aligned with the Apollo iOS major library version (3.x). The records-table layout *within v3* evolved during Phase 1A development — first to the row-per-field shape in PR-008, then to the row-per-element shape in PR-008b per ADR 0006 — but Apollo iOS 3.x has not shipped externally, so no end-user installation has ever been on any in-progress v3 layout. Any local development databases that ran an earlier in-progress v3 layout rebuild silently via the drop-and-rebuild path in §7.3 on next open. ### 7.2 Operations @@ -324,10 +324,10 @@ The version bump from 3 to 4 corresponds to ADR 0006's adoption of the row-per-e On `init`, after `createRecordsTableIfNeeded`: 1. Read `schema_metadata` for the version. -2. If version is missing or `< 4`, drop and recreate the records table; insert the new version. -3. If version is `4`, no migration needed. +2. If version is missing or `< 3`, drop and recreate the records table; insert the new version (`3`). +3. If version is `3`, no migration needed. -The drop-and-rebuild is silent — no user-visible event other than the network fetches that follow on cache-miss reads. The migration trigger absorbs both genuine upgrades from 2.x (no `schema_metadata` row at all) and local-dev databases that ran the v3 shape on the plan branch before [ADR 0006](./adr/0006-list-storage-strategy.md) (version row reads `3`). v3 never tagged externally, so no end-user installation is on v3. +The drop-and-rebuild is silent — no user-visible event other than the network fetches that follow on cache-miss reads. The migration trigger handles genuine upgrades from 2.x (where there is no `schema_metadata` row at all). Apollo iOS 3.x has not shipped externally, so no end-user installation has ever been on any in-progress v3 layout; local development databases that ran an earlier in-progress v3 layout during Phase 1A (e.g., the row-per-field shape from PR-008) need to be removed manually — they are not auto-detected and re-migrated because the wire-version is unchanged. ### 7.4 Performance gates diff --git a/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift b/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift index 168322a97..47b7a09c3 100644 --- a/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift +++ b/apollo-ios/Sources/ApolloSQLite/ApolloSQLiteDatabase.swift @@ -91,18 +91,22 @@ public final class ApolloSQLiteDatabase: SQLiteDatabase { CREATE TABLE IF NOT EXISTS "\(SQLiteSchema.recordsTableName)" ( "\(SQLiteSchema.Records.cacheKey)" TEXT NOT NULL, "\(SQLiteSchema.Records.fieldName)" TEXT NOT NULL, + "\(SQLiteSchema.Records.position)" INTEGER NOT NULL DEFAULT \(SQLiteSchema.Records.defaultPositionValue), "\(SQLiteSchema.Records.intValue)" INTEGER, "\(SQLiteSchema.Records.stringValue)" TEXT, "\(SQLiteSchema.Records.floatValue)" REAL, "\(SQLiteSchema.Records.boolValue)" INTEGER, - "\(SQLiteSchema.Records.listValue)" TEXT, "\(SQLiteSchema.Records.childKeyValue)" TEXT, "\(SQLiteSchema.Records.customScalarValue)" TEXT, "\(SQLiteSchema.Records.writtenAt)" INTEGER NOT NULL, - PRIMARY KEY ("\(SQLiteSchema.Records.cacheKey)", "\(SQLiteSchema.Records.fieldName)") + PRIMARY KEY ( + "\(SQLiteSchema.Records.cacheKey)", + "\(SQLiteSchema.Records.fieldName)", + "\(SQLiteSchema.Records.position)" + ) ) WITHOUT ROWID; """ - try exec(sql, errorMessage: "Failed to create row-per-field '\(SQLiteSchema.recordsTableName)' database table") + try exec(sql, errorMessage: "Failed to create row-per-element '\(SQLiteSchema.recordsTableName)' database table") } try writeSchemaVersion(SQLiteSchema.currentVersion) } diff --git a/apollo-ios/Sources/ApolloSQLite/SQLiteSchema.swift b/apollo-ios/Sources/ApolloSQLite/SQLiteSchema.swift index da1eb581f..764670520 100644 --- a/apollo-ios/Sources/ApolloSQLite/SQLiteSchema.swift +++ b/apollo-ios/Sources/ApolloSQLite/SQLiteSchema.swift @@ -17,18 +17,29 @@ public enum SQLiteSchema { /// Shared physical name of the records table across both layouts. public static let recordsTableName = "records" - /// Columns for the row-per-field records-table layout. + /// Columns for the row-per-element records-table layout. public enum Records { public static let cacheKey = "cache_key" public static let fieldName = "field_name" + /// `-1` for non-list rows (a scalar field, a cache reference, or any + /// other field that occupies a single row), `0..N-1` for the + /// elements of a list-typed field. Part of the primary key, so + /// `(cache_key, field_name)` is no longer unique on its own — see + /// `defaultPositionValue`. + public static let position = "position" public static let intValue = "int_value" public static let stringValue = "string_value" public static let floatValue = "float_value" public static let boolValue = "bool_value" - public static let listValue = "list_value" public static let childKeyValue = "child_key_value" public static let customScalarValue = "custom_scalar_value" public static let writtenAt = "written_at" + + /// The `position` value for any row that does not represent a list + /// element — scalars and cache references both occupy a single row + /// at this position. Matches the column's DDL `DEFAULT -1` so + /// writes that omit `position` still land in the right row. + public static let defaultPositionValue: Int64 = -1 } /// Columns for the legacy single-row JSON-blob records-table layout used From ad05d4309f86bc59f049514167b8500dd312dc8a Mon Sep 17 00:00:00 2001 From: Anthony Miller <anthonymdev@gmail.com> Date: Tue, 2 Jun 2026 13:56:31 -0700 Subject: [PATCH 24/24] docs(cache): amend ADR 0007 implementation sequence (d-iii, d-iv, 009g design, 009g-bis) (#1014) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../adr/0007-selection-aware-cache-reads.md | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/apollo-ios/Design/adr/0007-selection-aware-cache-reads.md b/apollo-ios/Design/adr/0007-selection-aware-cache-reads.md index 9ad9b4947..2401ba31b 100644 --- a/apollo-ios/Design/adr/0007-selection-aware-cache-reads.md +++ b/apollo-ios/Design/adr/0007-selection-aware-cache-reads.md @@ -78,6 +78,18 @@ Collapse the six typed columns back to one `value BLOB` column plus a `value_typ - *Rejected because:* This re-introduces the JSON-blob layout's problem under a different name. The BLOB column needs a Swift-side decoder that branches on the type tag — exactly the dispatch table `SQLiteFieldEncoding` already implements at the column level. The typed-column schema is faster because SQLite's storage is column-typed; collapsing back to a generic BLOB undoes that. The schema commitments from PR-008 + PR-008b already locked us out of this alternative; reopening it here would require dropping the schema and re-running the benchmark gates. +### F. Two-pass cache read to resolve runtime type before field projection + +For records containing inline fragments, issue *two* cache round-trips: the first loads only `__typename`; the second issues a precise projection narrowed by the now-known runtime type. The collector wouldn't need an `includeAllInlineFragments` mode — it'd know which type cases apply before building the projection set. + +- *Rejected because:* Doubles the per-level round-trip count under PR-009g for any record with inline fragments. The single-query CTE/correlated-subquery design landed in PR-009g achieves the same precision in one SQL statement — the SQL itself reads `__typename` and filters inline-fragment columns by it. The collector still walks all inline fragments at projection time (cheap struct allocations), but the IO cost is bounded by the SQL filter. We get the precision of two-pass without the round-trip cost. + +### G. Cross-phase `FieldExecutionInfo` sharing as a Phase 1A foundation + +Restructure `FieldProjectionCollector` from the outset to emit both projections and a `FieldSelectionGrouping`, so the executor's `groupFields` consumes the precomputed grouping rather than walking again — eliminating the projection-time / resolve-time recompute of `cacheFieldKey` entirely. + +- *Deferred, not rejected:* This is captured as the optional PR-009g-bis in the implementation sequence, gated on profiling after PR-009g lands. The benefit is real but bounded by how expensive policy resolution is on realistic workloads: scalar fields short-circuit cheaply, and the resolver-side recompute is already addressed by PR-009d-iii's `FieldExecutionInfo` memo. The full cross-phase sharing is a meaningful API change (collector return shape, executor's groupFields accepting precomputed input) and is best evaluated against measured policy-resolution cost rather than committed up front. The collector-then-resolver dataflow is design-compatible with PR-009g-bis — landing the foundation now doesn't preclude the optimization later. + ## Migration ### For users of `Apollo` (the SDK) @@ -105,23 +117,31 @@ The sub-phase 1A.5 PRs that implement this decision, in order. PR-008b and PR-00 | PR-009a | docs(cache): ADR 0007 — selection-set-aware cache reads | **this PR** | | PR-009b | refactor(cache): introduce `FieldProjection` types | new value types, no consumers yet; includes the scalar-vs-list discriminator required by Principle 1 | | PR-009c | refactor(cache): `NormalizedCache` adopts field projection | breaking protocol change; `InMemoryNormalizedCache` implements; `SQLiteNormalizedCache` falls back to the PR-009 read path during transition | -| PR-009d | refactor(executor): `CacheDataExecutionSource` declares field reads upfront | highest-risk PR in the sub-phase; may split if the executor's lazy pattern can't be migrated in one shot | -| PR-009e | refactor(cache): `ApolloStore.load(_:)` propagates field projection | wires PR-009d through to `loadFields(_:)` | -| PR-009f | refactor(cache): `GraphQLDependencyTracker` consumes field projections | watcher dirty-set computation switches to projection-driven re-reads | -| PR-009g | feat(sqlite): field-aware `selectFields` with column projection | SQL-level projection in `ApolloSQLiteDatabase`, including `position` predicates per Principle 3; the internal-test-only `selectRecords` from PR-009 is removed in this PR or the next | +| PR-009d-i | refactor(executor): introduce `FieldProjectionCollector` | per-level selection-set traversal that emits `Set<FieldProjection>` for one record. Additive — no executor caller wired yet. Walks `[Selection]` with the same case-dispatch shape as `DefaultFieldSelectionCollector`, parameterized by inline-fragment and deferred-fragment policies. Split from the original PR-009d slot per the Risk and rollback fallback. | +| PR-009d-ii | refactor(executor): `CacheDataExecutionSource` adopts upfront projection | `ProjectionLoader` replaces `DataLoader<CacheKey, Record>`; `ReadTransaction.loadObject(forKey:selections:variables:schema:responsePath:)` drives projection-aware reads; per-field `CacheReference` resolution issues child-level projections through the same loader. `PossiblyDeferred` and the shared `GraphQLExecutor` are unchanged — only the cache execution source switches paths. `NormalizedCache.loadFields(_:)` contract refined: a cache key appears in the result iff the record exists in storage, with empty `fields` when no requested field is present (preserves the executor's per-field `missingValue` path wrapping). Introduces a `Selection.Field.cacheFieldKey(variables:schema:responsePath:)` shared helper so the collector and the resolver compute the same policy-aware field name(s) by construction. | +| PR-009d-iii | refactor(executor): `FieldExecutionInfo` memoizes `CacheFieldKey` | small follow-up. Adds a `_cacheFieldKey: CacheFieldKey?` cache on `FieldExecutionInfo` mirroring the existing `_cacheKeyForField` pattern. `CacheDataExecutionSource.resolveCacheKey` calls `info.cacheFieldKey()` instead of `info.field.cacheFieldKey(...)`. Resolver-side cache field key resolution becomes O(1) per info; the policy evaluator is invoked once per `(field, info)`, not once per `resolveField`. | +| PR-009d-iv | refactor(cache): extract shared `SelectionWalker` | deduplicates the Selection-case dispatch logic shared between `DefaultFieldSelectionCollector` (resolve path) and `FieldProjectionCollector` (projection path). Parameterized by per-field action, `InlineFragmentPolicy` (`byRuntimeType` / `includeAll`), and `DeferredFragmentPolicy` (`respectDeferCondition` / `eager`). Both collectors call the shared walker; no behavior change, no public API change. Lands before PR-009f so the dependency tracker's invalidation walk can use the unified helper. | +| PR-009e | refactor(cache): `ApolloStore.load(_:)` propagates field projection | finalizes the loose ends from PR-009d-ii — retires `DataLoader<CacheKey, Record>` from `ApolloStore.swift` (no longer referenced), verifies every public `load` / `read` entry point routes through the projection-aware path, updates test scaffolding that depended on the old DataLoader-keyed behavior. | +| PR-009f | refactor(cache): `GraphQLDependencyTracker` consumes field projections | watcher dirty-set computation switches to projection-driven re-reads. Dirty `(cacheKey, fieldName)` entries become `FieldProjection`s via the direct `(columnShape, cardinality)` initializer. Drops the dependency tracker's whole-record `loadRecords` path. Benefits from PR-009d-iv's shared walker for any selection-set traversal the tracker performs. | +| PR-009g | feat(sqlite): field-aware `selectFields` with column projection | SQL-level projection in `ApolloSQLiteDatabase`, including `position` predicates per Principle 3. **Design choice: a single SQL statement uses a correlated subquery / CTE on `__typename` to filter inline-fragment fields by the record's runtime type — no two-pass round-trip.** Eliminates the IO over-fetch the projection-time `includeAllInlineFragments: true` strategy creates (the walker still emits projections for every type case, but the SQL filters before the wire crosses). The internal-test-only `selectRecords` from PR-009 is removed in this PR or the next. | +| PR-009g-bis | refactor(cache): cross-phase `FieldExecutionInfo` sharing | **OPTIONAL — gated on profiling after PR-009g lands.** Restructure `FieldProjectionCollector.collect(...)` to return `(Set<FieldProjection>, FieldSelectionGrouping)`. `loadObject(...)` retains the grouping alongside the loaded `Record`; the executor's `groupFields` accepts a precomputed grouping for cache-path execution sources and falls back to building from scratch for the network / selection-set-model paths. Combined with PR-009d-iii's info memo, `cacheFieldKey` is computed once per `(field, parent_info)` ever — eliminates the projection-time recompute that's currently amortized only on the resolver side. Significant collector API change; commit only if measurement on realistic policy-heavy workloads justifies the complexity. | | PR-009h | refactor(sqlite): `SQLiteNormalizedCache` switches to field-aware path + drop-and-rebuild migration | the original PR-010 | -Phase 1A's calendar estimate grows from the post-ADR-0006 count of 11 PRs to ~17 PRs, adding roughly 4–6 weeks to the Phase 1A end date. Phases 1B, 1C, and 1D are unchanged in scope and renumber but do not restructure. +Phase 1A's calendar estimate grows from the post-ADR-0006 count of 11 PRs to ~19 PRs (~20 if PR-009g-bis lands), adding roughly 5–7 weeks to the Phase 1A end date. Phases 1B, 1C, and 1D are unchanged in scope and renumber but do not restructure. ## Risk and rollback -The highest risk is PR-009d (the executor reshape). If the executor's lazy field-resolution pattern proves intractable to convert in one PR, the sub-phase splits PR-009d into: +The highest risk is the PR-009d executor reshape. The sub-phase splits PR-009d into: - **PR-009d-i**: introduce the upfront-projection API alongside the existing lazy pattern; both paths coexist. - **PR-009d-ii**: switch internal callers (executor and dependency tracker) to the upfront-projection path; remove the lazy pattern. This split adds time but does not change the destination. The split is invisible to downstream consumers because the lazy pattern's surface is internal to `ApolloExecution`. +The follow-on cleanups PR-009d-iii (info memo) and PR-009d-iv (extract `SelectionWalker`) are low-risk additive refactors on top of PR-009d-ii. They surface as small, focused PRs rather than expanding PR-009d-ii's diff because (a) the memo is a refinement of the `CacheFieldKey` machinery introduced in PR-009d-ii and reads more clearly as a separate change, and (b) the walker extraction touches both the existing `DefaultFieldSelectionCollector` and the new `FieldProjectionCollector`, which is a refactoring concern distinct from the projection-path adoption. + +PR-009g-bis is a profile-gated commitment. The benefit (one-time `cacheFieldKey` computation per `(field, parent_info)`) is bounded by how expensive policy resolution is on realistic workloads — for scalar-heavy queries the resolution short-circuits cheaply and the cross-phase memo barely registers; for object-policy-heavy queries the savings may be measurable. The decision falls naturally after PR-009g because that PR bounds the IO over-fetch cost of the projection-time `includeAllInlineFragments: true` strategy: with the SQL filtering by `__typename` in one statement, the walker-level over-fetch is the only remaining cost, and PR-009g-bis is what addresses it. Without the PR-009g IO benefit in place, PR-009g-bis's payoff is harder to measure cleanly. + Rollback after merge: the design is not reversible without another major version. Once `NormalizedCache.loadFields(_:)` ships in 3.0, returning to `loadRecords(forKeys:)` would be a 4.0 break. This is the standard one-way-ratchet cost of public-protocol decisions and is accepted under [ADR 0001](./0001-major-version-bump.md)'s framing. ## References