[MongoDB Storage] Incremental parameter index compacting - #759
Open
rkistner wants to merge 22 commits into
Open
Conversation
🦋 Changeset detectedLatest commit: e4de973 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
This helps preserve progress when interrupting long-running jobs.
rkistner
marked this pull request as ready for review
August 20, 2026 08:24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#752 made compacting of bucket data incremental under MongoDB V3 storage. This does the same for parameter indexes, under both MongoDB v1 and v3 storage. See the initial proposal here: https://github.com/orgs/powersync-ja/discussions/537
For background, parameter index entries are essentially an
attribute (lookup) -> valueindex , but we store prior versions to allow finding the value at a point-in-time. So we store an auto-incrementing _id, and index by a variation of (lookup, key, _id). When we read the values, we only use the last value for every (lookup, key) combination.V3 tweaked the storage format slightly, but did not fundamentally change it. The index is different to allow more efficient reads, and we split the collections by parameter index.
Conceptually, this makes incremental parameter index compaction is much simpler than bucket data compaction:
Previously, we did a full scan through the collection on each compact, and tracked seen entries in memory to de-duplicate. This rewrites the process to match the above.
Implementation
We store a new field
parameter_compaction.compacted_beforeto track the position.The naive implementation here would be:
compacted_before.This has some issues:
deleteManyis not atomic by itself, and can execute the deletes in any order. If the deleteMany removes a tombstone before its prior entries, that can result in a read returning inconsistent data.deleteManyis not cheap for V1 storage: It partially uses the('key.g': 1, lookup: 1, _id: 1)index, but if there are hundreds of keys with the same lookup, this is inefficient.We cater for (1) by explicitly deleting prior entries before deleting the tombstone itself, in separate batches.
We cater for (2) by:
We use a LRU cache to keep track of the last _id we've seen for each (lookup, key), safely falling back to the deleteMany approach for evicted entries.
The implementation is shared for MongoDB v1 and v3, with mostly minor differences related to the v3 collection splits, and the exact fields used for lookups.
Compact fence
The compact could cause consistency issues in rare cases. It could specifically happen when:
That could cause the entry to be deleted before the API process' change detection has read it. It does not affect reads directly, but does affect change detection triggering the reads.
In the case of an entry update, the new checkpoint would typically see the update and recover. But in the case of a delete, it could miss it completely, and the client could continue syncing buckets that should have been removed, until reconnecting, or until another change is detected that triggers a refresh.
In practice it would be very rare to have an impact: The specific conditions above are rare to trigger, and most cases would automatically recover shortly. It is nonetheless a consistency issue, and this PR fixes it.
In theory we could fix it by only compacting up to a prior checkpoint, say one that is 5 minutes old. That would then only trigger the issue if an API process is more than 5 minutes behind. However, we currently have no way to find prior checkpoints with a specific age, and we have no theoretical upper limit on how far behind an API process can be.
The fix here instead persists an
checkpoint_changes_invalid_beforeinvalidation fence:invalidateParameterBuckets: trueinstead of tracking the incremental changes. This is already a supported path to triggers a refresh of all parameter queries, instead of a more targeted refresh based on changes.Persisting progress
The implementation described above already avoids repeating work from previous jobs on new runs, since the job persists the last compacted position. However, there can still be cases where the job can take a long time to run, such as on the first run after updating to this version, the first run after a large snapshot, or when the job has a long interval between runs. To cover for those cases, we'd like to continue where we left off if the job was interrupted.
For v1 storage, we scan through a single collection. That gives a simple way to persist progress: We can update
compacted_beforeafter each batch. To avoid updatingsync_rulestoo frequently and potentially causing write conflicts with the replication process, we throttle this to max once per minute.For v3 storage, this is slightly more complicated, since there are multiple collections, and we only persist a single
compacted_before. Theoretically we can persist separate progress indicators for each collection, but I'd like to avoid that as far as possible. So instead, we process the collections in lock-step. For each step:compacted_before(similarly throttled to once per minute).While each collection can be arbitrarily far ahead in terms of it's max _id in the sequence, that would be at most 1x batch in that collection. That means if the process is interrupted, we lose progress of at most one batch per defined parameter index (in addition to some further progress lost due to throttling the position advancement).
The exact same implementation works with V1 storage - it effectively collapses to the simpler process described above, since the same single collection is picked on each step.
I initially considered splitting this change out into a separate stacked PR, since it's conceptually an incremental change on top of the other work. In practice, this change resulted in a diff covering a very large portion of the implementation, so it doesn't give significant review gains. The specific changes can still be seen in the commit history.
AI Usage
Implemented and reviewed using both Codex gpt-5.6 and Claude Opus 5. Manually designed the approach, with input from Codex and Claude.