Skip to content

Ensembles 3.0.6

Latest

Choose a tag to compare

@drewmccormack drewmccormack released this 30 Jul 02:05

A reliability release focused on sync failures that were hard to diagnose and, in several cases, self-sustaining — prompted by one customer's detailed reports (thanks, Keith). Devices that repeatedly failed to sync with an opaque "CDEErrorDomain -1" should both report real causes and, in the identified cases, stop failing.

  • Fix the launch-time cleanup tasks racing the first sync. Every event — including the merge event built during integration, a save being captured, or a multi-file import in progress — is stored as .incomplete until finalized. The cleanup sweep that removes leftover incomplete events after a killed process ran in an unserialized task at ensemble initialization, exactly when containers fire their launch sync. If the sweep's database access was delayed behind a busy event store (large caches make this likely, fresh installs most of all), it could land mid-merge and delete the merge event out from under the integration — the merge then failed with a stage error and, before this release's error-reporting fixes, a bare "CDEErrorDomain -1" with no visible cause. Three guards now apply: the leftovers are snapshotted at initialization, before the first sync can have created any in-flight event; their deletion runs through the ensemble's serialized operation queue; and at deletion time each event is refetched and skipped unless it is still unfinalized and not registered as an in-flight save capture (captures run off-queue and register a persisted token before their first database write). The sibling removeUnusedData task at initialization had the same unserialized pattern — its DELETE could interleave inside an open save-capture or merge transaction and remove global-identifier rows between their insertion and the object changes referencing them — and now runs through the operation queue and is skipped while any save capture is in flight. The races were timing-dependent, so there is no deterministic regression test. Reported by Keith (fresh-install iPhone failing every launch sync with -1).
  • Don't leak the caller when an operation is enqueued after dismantle(). An attach/sync/detach call arriving after teardown had finished the operation stream had its continuation dropped unresumed (a task and ensemble reference leak, with a runtime continuation-misuse warning); it now fails with EnsembleError.cancelled. An operation buffered just before teardown can still be dropped by the task cancellation — a narrower, pre-existing gap.
  • Retry a merge aborted by a mid-merge save, within the same sync. A save landing during a merge aborts it (saveOccurredDuringMerge, 207). An app that saves in response to merged changes — a merge-notification handler rebuilding derived state — could therefore never complete a merge: every merge delivered data, the app reacted with a save, and the save aborted the merge, leaving the device failing every sync while its store slowly filled from the batches each aborted merge had committed. The sync now retries the merge up to two times; the retry includes the reactive save's event, so a purely reactive app converges and the sync succeeds. Continuous saving still exhausts the retries and reports 207, whose message now points at reactive-save handlers as the thing to check when it recurs on every sync. The "during merge" flag is also reset at the start of each merge, so a save that landed before the merge began (its event is included in the integration) no longer aborts it. Reported by Keith.
  • Make re-integration of unchanged data a genuine no-op. For the retry above to converge, re-applying identical data must not dirty the merge context — but three shapes always did: ordered to-many relationships were rewritten even when membership and ordering were unchanged, Data attributes over 10 KB skipped the equality check and were always rewritten (NSData compares lengths first, so the check is far cheaper than the needless rewrite it replaces), and unordered to-many re-adds of already-present members dirtied the object through the KVC proxy. All three now compare before writing. Beyond the retry, this also removes pointless store churn from every full integration over largely unchanged data. Covered by a test that forces a full re-integration over an identical store — including an ordered relationship and a 50 KB blob — and asserts no save is provoked at all.
  • Name the file and both format failures when an event file cannot be imported. An event file is imported as JSON first, then as a legacy binary event store. When both failed (a corrupt file in the cloud), only the binary fallback's error surfaced — "The file couldn't be opened because it isn't in the correct format", which describes a file that was never in that format to begin with and sent an app developer chasing the wrong problem. The error now carries the filename and both failure messages (both errors also under NSMultipleUnderlyingErrorsKey), and is reported as data corruption (203). Two adjacent gaps in the same path are closed: a file containing valid JSON that is not an event dictionary previously imported as a silent success — the cloud file was then re-downloaded and dropped again on every future sync with zero diagnostics — and now fails as corrupt; and a JSON import failing for a non-format reason (a full disk, a database error) now propagates its own error instead of being retried as a binary store and mislabeled as cloud corruption. Reported by Keith.
  • Fix a leftover imported data file permanently blocking sync. Importing a downloaded data file moved it into the event store's newly-imported directory and failed if a file with that name already existed — which a previously aborted sync can legitimately leave behind. Since data filenames are the MD5 of their content, a name collision means identical content; the import now treats it as already done instead of failing every subsequent sync on the same move. Reported by Keith.
  • Fix a save landing mid-merge being reported as error -1 (unknown) instead of 207 (saveOccurredDuringMerge). When the integrator detected an app save during a merge, one abort path deliberately skipped the context save and then reported the missing save as a bare CDEErrorDomain -1 with no description, hiding the actual (routine, transient) cause. An app that saves into every merge window would fail every merge with an opaque -1 and quietly fall behind, while looking idle between attempts. The abort is now reported as saveOccurredDuringMerge (207), matching the late-abort path. A regression test saves from inside the merge window and asserts the code, and that the merge succeeds on retry. Reported by Keith.
  • EnsembleError now bridges to NSError with CDEErrorDomain. Directly thrown cases previously bridged with the unstable domain "Ensembles.EnsembleError", while NSError-wrapped throw sites used CDEErrorDomain — the same failure surfaced under two domains depending on the internal throw site. All cases now bridge with CDEErrorDomain, the case's raw value as the code, and the self-diagnosing description as localizedDescription.
  • Stop discarding underlying errors in integration failures. The insert stage swallowed the real error from a failed event-store flush (try?), and a stage failure with no underlying error was thrown as a bare -1 with no message; both now carry what actually went wrong.
  • Fix concurrent sync triggers surfacing a spurious disallowedStateChange (103). With auto-sync enabled, two triggers (for example a save and the timer) could both observe a detached ensemble and both queue an attach; the loser's attach failed with 103, which was forwarded to didEncounterError and silently dropped that sync. The containers now recognize the benign case — the ensemble is attached by the time the queued attach runs — and proceed with the merge. Covered by a concurrent-sync regression test. Reported by Keith.
  • CloudKit: fix stale listing-cache misses being treated as proof a file does not exist. The persistent listing cache (3.0.3) is updated from zone deltas only, so records this device had just written (its own registration record, directories, uploads) were invisible to fileExists until CloudKit served them back — and a cache miss was answered "does not exist" without consulting the server. A stale miss on the device's own registration record force-detaches a healthy device with storeUnregistered (205). Local mutations (uploads, directory creation, deletions) now update the cache immediately, a cache miss is confirmed with a metadata-only record fetch before answering no (healing the cache on a hit), and creating a directory that already exists is treated as success (after confirming the conflicting server record really is a directory — anything else still surfaces the conflict). Deleting files also no longer downloads each record's CKAsset payload just to check whether it is a directory.
  • New documentation: sync lifecycle and recovery. A new DocC article ("Sync Lifecycle and Recovery") and Manual sections cover the operational model: when the app may save (hold writes until the first successful sync after launch; after that, save freely), how to detect sync success and staleness (currentActivity == .none means only "idle now"), which errors are transient, and the per-device and whole-fleet recovery paths.
  • Readable error descriptions. EnsembleError now conforms to LocalizedError, so a logged error reads, for example, "No source of global identifiers is configured. Conform model types to Syncable, set a globalIdentifiers closure, or implement globalIdentifiersForManagedObjects in the delegate before attaching. (EnsembleError.missingGlobalIdentifierSource, code 220)" instead of the bare "The operation couldn't be completed. (Ensembles.EnsembleError error 220.)". The numeric code alone had led an app developer (and their AI assistant) to misidentify code 220 as a different case by counting enum declarations in the swiftinterface; the raw values are explicit and non-contiguous, so the position in the declaration does not indicate the code. Reported by Keith.