Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions apollo-ios/Design/adr/0007-selection-aware-cache-reads.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading