Set CKRecord.modificationDate in MockCloudDatabase#411
Set CKRecord.modificationDate in MockCloudDatabase#411lukaskubanek wants to merge 4 commits intopointfreeco:mainfrom
CKRecord.modificationDate in MockCloudDatabase#411Conversation
|
Hey @lukaskubanek, thanks so much for exploring this! At first glance this seems like a good approach, and the fact that a test needs to be updated is fine and probably to be expected. We will look in more depth very soon. |
|
Here’s a follow-up after reviewing the comments in #386 starting here. If I understand this correctly, aligning the test and production environments means in-flight records are no longer eagerly stored as last-known server records from This is how the reminder record from the affected test evolves in
Previously, the modification dates were always With this change, however, we effectively observe what you, @mbrandonw, described here when completely removing the call that attempts to store the in-flight record in This suggests that the logic for tracking timestamps via eagerly stored in-flight records does not actually hold. It likely never did—except during initial record saves (which is still the case) and previously in tests (which is no longer the case). I was able to confirm that the This reopens the question of the role of the last-known server record, and whether it can serve as the ancestor in a three-way merge during conflict resolution. But let’s address this separately to keep this PR focused… |
Motivation
SQLiteData’s tests leverage
MockCloudDatabase, which currently doesn’t populate themodificationDateon saved records. Since this value is checked by the sync machinery, the tests effectively exercise different behavior than in production.This has been first noticed while documenting the current sync behavior while elaborating on custom conflict resolution support (search for modificationDate) and further confirmed as a known issue here.
This is another step toward bringing
MockCloudDatabasecloser to CloudKit’s actual behavior, and an intermediate step toward adding support for configurable conflict resolution.Implementation
Similar to record change tags,
MockCloudDatabasenow assigns incrementing dates to records saved inmodifyRecords(…), mirroring CloudKit’s server-side behavior. However, since the codebase checks the actualCKRecord.modificationDate, this PR, unlike the current handling of change tags, swizzles the Objective-C getter to return the value stored inCKRecord._modificationDatevia associated object storage. This is safe in production, as the global swizzle is only triggered when settingCKRecord._modificationDate, which is limited toMockCloudDatabase, used exclusively in tests. See the next section for alternative approaches that were considered.Similar to the change tag, the injected modification date must survive the SQLite roundtrips, which is why both
CKRecord.SystemFieldsRepresentationandCKRecord._AllFieldsRepresentationwere extended to persist_modificationDatealongside the existing_recordChangeTagusingNSKeyedArchiver/NSKeyedUnarchiver.My assumption is that when saving a record, CloudKit only sets the
modificationDateon that record and it doesn’t “touch” the parent to modify itsmodificationDate, as it apparently does with therecordChangeTag. I’m not 100% sure about this behavior, though.Alternative Approaches
One way to circumvent swizzling would be to subclass
CKRecord, overridemodificationDate, and return a mocked record fromMockCloudDatabase.modifyRecords(…). Not only is it tricky to change the class of the copied record due to its internal implementation, but CloudKit now logs the following message when attempting to subclassCKRecord:Another idea would be to use a facade property that returns the mock version and falls back to the real value if not set. Something like
CKRecord.effectiveModificationDate. However, this would pollute the API and require all call sites in the library to go through this property, which is quite error-prone.I’ve come to the conclusion that swizzling is the right tool for the job here, but I’m open to discussing further.
Also, I initially started with using
@Dependency(\.date.now)for providing dates set to themodificationDateinMockCloudDatabase. However, giving the mock database control over the values felt more natural and better aligned with how record change tags are handled, so I ended up using the same pattern.Impact on Test
When running the test suite with this change, there is one test failing,
MergeConflictTests.clientRecordUpdatedBeforeServerRecord():A single timestamp diff in the last assertion might seem minor, but there is an important conceptual decision to be made. This flow might not be immediately obvious, so let me break it down:
SyncMetadata.userModificationTimeCKRecord.🗓️CKRecord.title🗓️CKRecord.isCompleted🗓️.serverRecordChangederrorNote
The
CKRecordtimestamps in the table refer to the server state, i.e. theMockCloudDatabase, not the last-known server record stored in theSyncMetadata.In step 4, when hitting the
.serverRecordChangederror, the server record’sCKRecord.userModificationTimeis promoted to be the newSyncMetadata.userModificationTimefollowing this logic:max(SyncMetadata.userModificationTime, CKRecord.userModificationTime),max(30, 60),60.This means that in step 5, when processing the local changes again after resolving the conflict, the
isCompletedchange is now considered to happen att=60. This means that the original timestamp of the change is lost and I’m wondering if this behavior, which already occurs in production (!), is desired.I’d say yes, because with support for configurable conflict resolution via a three-way merge, an entirely new value may emerge rather than selecting either the local or server value. And in that case it should be marked as changed at the current
SyncMetadata.userModificationTime.Do you agree? Can we accept the change in the test?
Follow-up Ideas
I was thinking of two follow-up PRs:
MockCloudDatabasetoCKRecord._recordChangeTagto align it withCKRecord._modificationDate. Currently, the library doesn’t seem to checkCKRecord.recordChangeTagdirectly, but it is desired to use for detecting conflicts with support for configurable conflict resolution.