Lesson 3
Apply is a commit attempt
Trace successful and conflicting snapshot applies without confusing consistency with recomposition scheduling.
Status: Apply is a visibility and conflict boundary, not a recomposition command. That separation is Durable . The
previous/current/appliedcomparison andSnapshotApplyResultclasses are Version-specific at the pinned revision.
Outcome
Given two mutable snapshots that write the same state object, you should be able to predict whether the second apply succeeds, fails, or asks a mutation policy to merge values.
Apply closes an isolation interval
A mutable snapshot accumulates modified StateObjects. apply() checks those writes before making them visible to global state or to the parent snapshot. On success, the changes become atomically visible at that boundary and the snapshot closes its active state. The result is SnapshotApplyResult.Success (Snapshot.kt).
A failed apply returns SnapshotApplyResult.Failure. Calling check() on that result disposes the snapshot and throws SnapshotApplyConflictException; the failed snapshot’s changes are not made visible (Snapshot.kt). This is why a test should assert both the result and the value that remains visible.
flowchart TD
First[First snapshot write] --> ApplyOne[First apply]
Second[Second snapshot write] --> Check[Conflict check]
ApplyOne --> Current[Current global record]
Current --> Check
Check -->|No conflict| Success[Success and publish]
Check -->|Conflict| Merge[Try policy merge]
Merge -->|Merge value| Success
Merge -->|No merge| Failure[Failure and no publish]What counts as a conflict
The pinned innerApplyLocked routine compares three records for each modified state object:
previous— the record the applying snapshot started from;current— the record now visible in the parent or global snapshot; andapplied— the applying snapshot’s record.
If both the applying snapshot and the parent/global side changed from previous, the runtime has a potential conflict. If one side did not change, the write can be applied without a merge. If both sides changed, the state object’s mergeRecords method is asked to resolve the situation (Snapshot.kt).
The default StateObject.mergeRecords returns null, so a state object without a merge policy cannot resolve a genuine collision. SnapshotMutableStateImpl delegates the decision to its SnapshotMutationPolicy; Lesson 4 explains that hook (SnapshotState.kt).
An equal result can avoid a conflict. Two snapshots that both change an integer from 0 to 1 can apply successfully under the default structural policy because the values are equivalent. That does not mean all same-looking writes are conflict-free: a referential or never-equal policy can make a different decision.
Apply is not a scheduling event
Applying a snapshot can notify snapshot apply observers. It does not itself run a composition, request a frame, or guarantee that a Recomposer immediately executes. A composition may consume the observation later, and the scheduler is a separate causal layer. Keep this chapter’s statement narrow:
Apply decides whether a snapshot’s records can become visible together.
Chapter 6 follows how observed writes become invalidation work. Chapter 7 will cover scheduling and frames. Neither is needed to understand why a conflicting snapshot is rejected.
Reproduce success and failure
This is a pinned upstream-test procedure, not an experiment run for this lesson. In an AndroidX checkout at cc1caf65677fc10a8ce8116eba46e716f4cef222, run:
./gradlew :compose:runtime:runtime:desktopTest \
--tests 'androidx.compose.runtime.snapshots.SnapshotTests.applyingCollidingSnapshotsWillFail' \
--tests 'androidx.compose.runtime.snapshots.SnapshotTests.snapshotsChangesCanMerge' \
--tests 'androidx.compose.runtime.snapshots.SnapshotTests.statesWithStructuralEqualityPolicyMerge' \
--tests 'androidx.compose.runtime.snapshots.SnapshotTests.throwInApplyWithMutableSnapshot'
The control is snapshotsChangesCanMerge: both snapshots write 1, and both applies should succeed with global value 1. The mutation is applyingCollidingSnapshotsWillFail: write 1 and 2 from separate snapshots, apply the first, then expect the second to report failure while global remains 1. Expected: success publishes atomically; failure leaves the already-applied value visible. Interpretation limit: the tests cover the pinned common runtime. They do not prove serializability; the implementation comments explicitly note that the current algorithm does not guarantee serializable snapshots. They also do not measure notification latency or recomposition.
Misconceptions
- “Apply always succeeds because snapshots are isolated.” Isolation delays the collision; apply still checks it.
- “Failure rolls back every earlier mutation.” The failed snapshot is not published; earlier successful applies remain visible.
- “Apply means recomposition happened.” Apply is a snapshot consistency boundary.
- “Same value always means no conflict.” Equivalence depends on the state’s policy.
Check yourself
Snapshot A and Snapshot B both start at 0. A writes 1; B writes 2; A applies first. Which three records does B’s apply conceptually compare, and why can its default merge fail?
Source notes
| Claim | Direct evidence | Status |
|---|---|---|
apply() publishes atomically to global or parent state | Snapshot.kt | Durable
Version-specific |
| Apply compares previous, current, and applied records | Snapshot.kt | Version-specific |
Failure is explicit and check() throws | Snapshot.kt | Version-specific |
| Conflicting and mergeable cases are tested | SnapshotTests.kt | Version-specific |
Freshness
Refresh this lesson after changes to innerApplyLocked, SnapshotApplyResult, StateObject.mergeRecords, or conflict tests. The current release notes record recent fixes to snapshot apply observers and merge-policy behavior; check the Compose Runtime release notes before mapping this consistency model onto a released artifact.
Finished this lesson?
Your progress stays only in this browser.