Multi-process database support: absolute paths, open retry, cross-process change signal#147
Conversation
…cess change signal Three core changes enabling a PowerSync database file to be shared across processes (an app and its widgets or App Intents extensions), extracted from the SwiftData DataStore work for separate review. 1. Absolute dbFilename paths. PowerSyncDatabase(dbFilename:) treats a path starting with '/' as absolute and uses it as-is, so the database can live in an App Group container. close(deleteDatabase: true) deletes the files at that location. Plain names are unchanged. 2. Concurrent-open retry. 'pragma journal_mode = WAL' needs a moment of exclusive access and reports SQLITE_BUSY/SQLITE_BUSY_RECOVERY without consulting the busy handler, so two processes opening the same file concurrently failed (~80% in a two-process harness). Opening and configuring a connection now retries with backoff. Reproduced in-process with a system-SQLite lock holder. 3. Cross-process change signal. Update hooks are per-pool, so watch was blind to writes from other processes. Each pool posts a Darwin notification (the mechanism Core Data uses for remote changes) after every committed write and re-emits tableUpdates with EXTERNAL_CHANGES_MARKER on receipt, which watch and the upload client treat as 'unknown tables changed'. Two pools over one file reproduce the cross-process behavior deterministically. Builds with -strict-concurrency=complete; 35 tests across the new suites and the in-memory sync integration tests pass.
simolus3
left a comment
There was a problem hiding this comment.
I did some research on what Darwin APIs there are for IPC, and it looks like the good stuff is all inaccessible to apps. So I think synchronizing by retrying on sqlite_busy errors is actually fine. It might be worth exerimenting with this suggestion to manage exclusive file access through NSFileCoordinator, but that sounds like it could add a fair bit of overhead.
The recovery loop when we open databases looks good to me (modulo blocking a thread, but even that it likely fine). For write statements, relying on sqlite_busy initially sounds acceptable to me too.
- Open retry is now asynchronous: the retry/backoff loop awaits with Task.sleep between attempts instead of blocking a thread with usleep, which also makes a database open cancellable (new test). The whole pool is still built in one runBlocking unit since RawSqliteConnection is ~Copyable and cannot cross the async boundary. - The cross-process change signal is only enabled for absolute-path databases (App Group containers); the default directory lives in the app's sandbox and is not reachable by extensions, so signalling it was wasted overhead. - EXTERNAL_CHANGES_MARKER is now internal; watch already accounts for it and there is no public onChange API, so consumers don't need it. - Extracted a shared connection-open helper between the default-directory and absolute-path cases. - Use isDirectory: true when building the default database directory URL to avoid a blocking stat, per Apple's file-access guidance. 109 XCTests plus 55 Swift Testing tests across 11 suites pass with -strict-concurrency=complete.
|
Thanks @simolus3, all addressed in the latest push.
On the article: I read through it. Most of it targets document-style access (iCloud Drive, file providers), and NSFileCoordinator specifically doesn't fit SQLite in WAL mode well: SQLite already coordinates concurrency with POSIX file locks, WAL spans three files (.sqlite, -wal, -shm) plus shared-memory mmap that a single-URL coordinator doesn't understand, and Core Data deliberately doesn't use file coordination for its SQLite store. So I think retrying on SQLITE_BUSY (as you concluded) is the right mechanism here. I did adopt the parts that apply to us: the isDirectory: hint to avoid a blocking stat, and all our database I/O already runs off the main thread on background GCD queues, which the async retry reinforces. |
simolus3
left a comment
There was a problem hiding this comment.
This looks good to me apart from some minor comments now 👍 Thanks for your contribution!
- Darwin notification name prefix com.powersync.changes (was co.). - Simplify the changelog entry to one feature bullet, dropping the internal-only details. - Document that only the main app should call connect(); extensions can read and write the shared database but must not open a second sync connection.
|
Thanks! All three applied in the latest push:
|
|
Thank you for your contribution! Before we release this, we'll need to update our documentation website to point out potential issues (like |
|
Thank you for letting me contribute! 🤗 |
What
Three core changes that let a PowerSync database file be safely shared across processes (an app and its widgets or App Intents extensions). Extracted from #146 (the SwiftData
DataStore) for separate review, as requested there.This PR has no SwiftData dependency: it only touches the
PowerSynctarget. #146 will rebase on top of it.Changes
Absolute
dbFilenamepaths.PowerSyncDatabase(dbFilename:)treats a path starting with/as absolute and uses it as-is, so the database can live in an App Group container.close(deleteDatabase: true)deletes the files at that location. Plain filenames keep the existing behavior (default databases directory).Concurrent-open retry.
pragma journal_mode = WALneeds a moment of exclusive access and reportsSQLITE_BUSY/SQLITE_BUSY_RECOVERYwithout consulting the busy handler, sobusy_timeoutcannot cover the open path. Two processes opening the same file concurrently (an app launching while its extension opens the same file) failed ~80% of the time in a two-process harness, even for read-only consumers (the pool always opens a writer first). Opening and configuring a connection now retries with backoff. Reproduced in-process with a system-SQLite connection holding a reserved lock, which is the same file-level contention two processes produce.Cross-process change signal. Update hooks are per-pool, so
watchwas structurally blind to writes made by other processes. Each pool now posts a Darwin notification after every committed write (the mechanism Core Data uses for its remote-change notifications; name derived from the canonical file path) and, on receipt, re-emitstableUpdateswithEXTERNAL_CHANGES_MARKER, whichwatchand the upload client treat as "unknown tables changed". In-memory databases skip the signal. TwoPowerSyncDatabaseinstances over the same file in one process use independent pools, reproducing the cross-process behavior deterministically (this is how the test exercises it).On locking
You mentioned an asynchronous locking scheme in your other SDKs to avoid using the write connection concurrently. The open retry here is a pragmatic first step (it backs off on
SQLITE_BUSYduring the WAL transition, where the busy handler does not apply), and it currently sleeps on a GCD thread rather than awaiting. I'm happy to align this with your existing scheme instead of keeping a parallel mechanism: where does it live? Same question for guarding concurrent writes across processes (today they serialize throughbusy_timeout).Testing
Builds with
-strict-concurrency=complete. 35 tests pass across the new suites (ConcurrentOpenTests,CrossProcessSignalTests,AbsolutePathTests) and the in-memory sync integration tests. Also validated with a real two-process harness (separate OS processes over one file): concurrent cold opens 5/5 clean, and the observing process sees writes made by the other process.