@FetchAll synchronous main-thread reads cause UI lag during CloudKit SyncEngine activity #451
Replies: 1 comment
-
|
Hi @joakim-hersche, the fact that you are seeing delays with keyboards, alerts, focus, etc. is very strong evidence that this has nothing to do with And as far as loading data on the main thread versus off the main thread, that is fully supported. When you specify the query for a Since I don't really feel this is an issue with the library I am going to convert it to a discussion. Please feel free to continue the conversation over there, and if you can share a reproducing project that would make it easier for others to investigate. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Environment
sqlite-dataversion: 1.6.1 (via Swift Package Manager)default-isolation=MainActor, strict concurrency checking enabledswift-structured-queries0.31.1,swift-dependencies1.12.0,swift-sharing2.8.0,GRDB7.10.0Summary
On an app that uses
@FetchAll+SyncEnginefor CloudKit sync, the UI freezes for ~500ms–1s on every user interaction that triggers keyboard presentation, alert appearance, or focus transition (e.g. tapping into aTextField, opening a.alertwithTextField, opening aSwiftUI.Menu). Writes themselves (try await db.write { ... }) are fast — ~20–60ms end-to-end, confirmed via timing instrumentation.The lag disappears completely when
SyncEngineis not constructed at app init. Writes still work via the local SQLite db, and every write still triggers@FetchAllobservation fire, but there's no lag. The lag only appears whenSyncEngineis running in the background doing CloudKit fetches.The iOS runtime emits this diagnostic repeatedly while the lag is occurring:
The warning continues to appear even when
SyncEngineis disabled, which suggests@FetchAllitself is doing theunsafeForcedSynccall on its read path — but withoutSyncEngine, there's no frequent background db-change to trigger the sync read, so it's invisible in practice.Hypothesis
@FetchAllappears to perform a synchronous main-thread read of the database via GRDB's sharing/reader machinery whenever it observes a change. WhenSyncEngineis running, every CloudKitwillFetchChanges/didFetchChanges/willSendChanges/sentDatabaseChangescycle modifies the local db, which causes@FetchAllto re-fetch — on the main actor, synchronously. Multiple rapid fires compound into observable UI lag.With
SyncEnginedisabled, the only db changes are user writes (rare compared to sync activity), so the single@FetchAllre-fetch per interaction is imperceptible. WithSyncEngineenabled, background fetches happen continuously and each one pays the synchronous-main-read cost, stacking up into noticeable freezes on UI transitions.Reproduction
SyncEnginefor a db with a handful of tables and a few dozen rows:@FetchAll(SomeTable.order(by: \.sortOrder)) var rows: [SomeTable]to render a list or summary..alert("Rename", isPresented: $showing) { TextField("name", text: $draft); Button("Save") { ... } }.showing = true.Expected
Keyboard should appear within a frame or two of the tap (~100ms).
SyncEnginebackground work should be fully off-main.Actual
~500ms–1000ms stall between tap and keyboard-ready. The delay scales with how recently CloudKit sync was active. "Second attempt feels less laggy" because the background fetch cycle has settled.
What didn't fix it (confirmed)
@FetchAll-derived computed properties with@State+.onChange(of: allNodes). Reduced the number of derived-computation runs per render but didn't address the underlying synchronous read.Task.detachedinside.task(id: allNodes). Moved the derivation off-main successfully but introduced a separate bug (stale values passed throughNavigationStackcached destinations). Reverted.startImmediately: falseon theSyncEngineinit + explicitstart()from a later.task. Addresses the cold-start sync hit only; ongoing lag remains.What fixed it (unfortunately)
SyncEngineat all (skipping theprepareDependencies { $0.defaultSyncEngine = ... }registration). Falls back to the no-op stub inDefaultSyncEngine.previewValue. UI becomes fully smooth immediately. This is what we've shipped as the current stable state — single-device only, no cross-device sync.Asks
@FetchAll's read path be made fully asynchronous so that observation fires don't block the main actor? Or is there a configuration option to throttle/debounce observation fire frequency?SyncEngine's background CloudKit fetch work so that the downstream@FetchAllobservers are batched or deferred instead of firing synchronously on each individual record-zone change?unsafeForcedSyncwarning expected in the current implementation, or does it indicate a known issue that's being worked on?Happy to provide an Instruments trace (Time Profiler, Swift Concurrency, System Trace) if it would help narrow down the blocking call. Let me know which would be most useful and I'll get one recorded.
Thanks for the library — the DSL story is fantastic, we just ran into a wall on the sync side and want to understand the right way through it.
Beta Was this translation helpful? Give feedback.
All reactions