Exchange the concrete changed tables across processes#166
Conversation
Code reviewSelf-review of the initial commit (
powersync-swift/Sources/PowerSync/Implementation/SharedProcessCounters.swift Lines 103 to 112 in 7fe2382 Fixed: descriptor reset to
Fixed: moved to
Fixed: documented the participation assumption honestly instead of over-claiming. Also considered and dismissed: a PID-reuse slot leak (degrades gracefully to the pre-PR behavior) and a create/delete path canonicalization mismatch for the sidecar file (both paths resolve to the same inode, so no leak). 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
simolus3
left a comment
There was a problem hiding this comment.
Thanks for following up on this! To reduce the amount of code needed for this feature, I wonder if we could use a local SQLite table instead of a second shared file.
Potentially, we could have a CREATE TABLE ps_swift_updates(author INTEGER, timestamp INTEGER, tables TEXT) on the database. Each PowerSync database generates a random value for author and inserts into ps_swift_updates before posting a Darwin notification. When receiving a notification, a process collects updated tables from other authors and keeps track of the highest rowid it has seen in that table to avoid ever seeing a notification twice. To prevent that table from growing too much, we could delete entries older than a few seconds before writing.
But I need to understand the actual loop in more detail first, since from my understanding even a uploadCrud loop should settle eventually when it runs out of things to do.
|
You're right that uploadCrud settles on its own once ps_crud is empty and the $local target is resolved. The perpetuation doesn't come from crud piling up. It comes from the self-delivered catch-all marker re-triggering the upload machinery:
The powersyncControl write only touches system ps_ tables, which no user watch matches, so user watches are woken solely by the catch-all marker, and that marker only exists because the post is delivered back to the sender. Remove the self-delivery and the loop opens: the process's own bookkeeping write no longer comes back as a catch-all that re-arms its own uploadLoop, so uploadLoop is driven only by real ps_crud changes and genuine external markers, and it settles. crudThrottle bounds the crud-completion cadence to ~1s (matching your crud upload: notify completion log); the faster watch cadence lines up with the extra precise writes and the second (widget) process, but the closure above is the necessary enabler in every case. On the SQLite table side, it may be worth considering. It removes the second file and the whole mmap/flock/liveness primitive, and it lets us do something the current design cannot: carry the actual changed tables across processes. |
|
Is there a realistic test / repro in this PR that fails without these changes? I'm still surprised about this behavior and it sounds like there's something else worth fixing to break the loop.
It only does that if there's something to write, specifically a pending checkpoint that we couldn't apply before due to outstanding local data (we'd try again after So if you have a way to reproduce this, I'd love to step through that with a debugger to understand what's responsible for the continued writes. |
|
Repro / what's actually driving the loop Got a device trace with per-process logging. You were right that it's not the crud / The cycle, repeating every ~60 ms, entirely in the main app: 998 of 1000 Why it only happens with an App Group path: The We prototyped your suggestion and it fixes this well in the real app: https://github.com/Chubby-Studio/powersync-swift/tree/experiment/ps-swift-updates-log
One question for you The trace also shows a lot of internal-only writes during normal sync ( If you prefer this direction, we'll switch this PR over to it. |
I actually think a prefix is likely fine if you make it a denylist, skip Even though it adds a bit more complexity in lines of code from a quick look, I think the benefits of being able to track exact tables and not having to roll our own cross-process scheme kind of make me lean towards the approach based on the local table. |
Databases opened at an absolute path (App Group container) share update notifications through a Darwin signal. That signal carries no payload, so every receiver re-emitted a catch-all marker that matches every table: a process re-ran all of its watch queries on every write, including its own. That is also enough to sustain a loop. A write to table A self-delivers the catch-all, which re-runs a watch over an unrelated table B, whose handler writes A again. We hit this with the attachment queue: it watches a synced table and writes its local attachments table, so with an App Group path each write woke it again roughly every 60ms and pinned the CPU. Without the shared path the same write only dispatches its own table, so nothing re-fires and it settles. Processes now exchange the concrete tables through a local `ps_swift_updates` table (author, timestamp, tables): - Each pool picks a random `author`. Before committing a write it reads the changed tables from the update hooks and records them in the same transaction, then posts the signal after the commit, so a change and its record are atomic and no second, separately contended write is needed. - Receivers read rows newer than their watermark and written by other authors, and re-emit exactly those tables. Ignoring their own author is what stops a process from waking itself, and the precise tables stop unrelated watches from re-running. `EXTERNAL_CHANGES_MARKER` remains only as a fallback when precise information may be incomplete (read error, undecodable payload, the table's ids regressed, or a read gap longer than the retention window). - Rows older than the retention window are pruned, and only tables another process can act on are recorded: `ps_` is reserved for PowerSync, so anything else propagates, and of the internal tables only the data tables and `ps_crud` have a consumer. Local watchers still see every changed table. `execute()` now runs single writes in a transaction so they are recorded atomically too; statements that cannot run inside one (PRAGMA, VACUUM, transaction control) keep using the plain write lock.
0525679 to
de46953
Compare
|
Updated this PR to the local-table approach we discussed (force-pushed over the counter-file version, now a single commit). It exchanges the concrete changed tables through a local
Full write-up is in the updated description. Two things I would value your take on:
The diagnostic trace that pinned the loop (attachment queue + self-delivered catch-all) is summarized in the description under Problem. |
Fixes #165.
This replaces the earlier counter-file version of this PR with the local-table approach suggested in review, since it is less code and lets us propagate the exact changed tables.
Problem
Databases opened at an absolute path (App Group container) share update notifications through a Darwin signal so writes in one process wake
watchqueries in others. That signal carries no payload, so every receiver re-emitted a catch-allEXTERNAL_CHANGES_MARKERthat matches every table. Two consequences:Approach
Processes exchange the concrete changed tables through a local, non-synced
ps_swift_updates(id, author, timestamp, tables)table:author. Before committing a write it reads the changed tables from the update hooks and records them in the same transaction, then posts the Darwin signal after the commit, so a change and its record are atomic and no second, separately-contended write is needed. The record runs in aSAVEPOINTso a bookkeeping failure never rolls back the user's write.authoris what stops a process from waking itself, and the precise tables stop unrelated watches from re-running.EXTERNAL_CHANGES_MARKERremains only as a fallback when precise information may be incomplete (read error, undecodable payload, the table's ids regressed, or a read gap longer than the retention window, e.g. a suspended extension).ps_is reserved for PowerSync, so anything else propagates, and of the internal tables onlyps_data__*/ps_data_local__*(whatwatchmatches) andps_crud(what the upload loop matches) are kept. Local listeners still see every changed table.Notable behaviour changes / tradeoffs
execute(sql:parameters:)now runs its statement in a transaction so the record commits atomically with it. Statements that cannot run in a transaction (PRAGMA,VACUUM,ATTACH,DETACH, transaction control) keep using the plain write lock. This is shared with the GRDB backend (its interceptor isnil, so it just gets a transactionalexecute).ps_prefix, or their changes would be mistaken for internal bookkeeping and not propagate. That prefix is reserved for PowerSync; happy to add schema validation for it if you would prefer.Open questions
execute()without the write-path plumbing this needed.ps_data__*/ps_data_local__*/ps_crudallowlist the complete set with a cross-process consumer, or is there an internal table I should also propagate?