[MongoDB] Optimize change stream filters to avoid PSYNC_S1345 timeouts #417
+40
−6
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.
This optimizes MongoDB change stream filters to use
{'ns.coll': {$in: ['a', 'b', 'c']}, rather than{'ns': {'$in': [{db: 'db', coll: 'a'}, {db: 'db', coll: 'b'}, {db: 'db', coll: 'c'}]}, in cases where only a single database is being replicated.This appears to improve oplog scanning performance when a lot of individual collections (say 30+) are being replicated - observed 10x or more difference in scanning time in my testing. This should help to significantly reduce the occurrence of
[PSYNC_S1345] Timeout while reading MongoDB ChangeStreamerrors.This also sets
maxTimeMSon the change stream query, so that we get a query timeout rather than a socket timeout. Before:[PSYNC_S1345] Timeout while reading MongoDB ChangeStream cause: connection 2 to 1.2.3.4:27017 timed out. After:[PSYNC_S1345] Timeout while reading MongoDB ChangeStream cause: Executor error during aggregate command on namespace: powersync_test_data.$cmd.aggregate :: caused by :: operation exceeded time limitBackground
We're seeing more and more users run into this error:
Example Discord thread
Most commonly this happens when:
For example, when a staging and production db use the same cluster, and the larger production database receives some bulk updates, the staging powersync instance may get that error. Luckily, the issue is less common in production setups.
One case we have seen in production is when the same cluster is used both for the source database and the bucket storage database. In that case, doing initial replication causes bulk updates in the cluster, which can trigger this issue.
The main issue is that the instance cannot recover from the issue automatically.
Why does this happen?
Similar to logical decoding on Postgres, MongoDB change streams effectively do a sequential scan through the oplog. If there are many changes in the oplog unrelated to the Change Stream, MongoDB may need to scan through a lot before receiving the first change. In that time, it does not return any response, and eventually the query or connection times out. Since there is no intermediate progress (resumeToken) returned, the next attempt restarts at the same point, and receives the same error.
Now, the performance is affected a lot by the change stream pipeline. MongoDB internally converts the
$changeStreampipeline into an oplog aggregation pipeline. It uses many internal stages, for example first running some pre-filters, then doing thefullDocumentlookup, then additional filters.What we've seen during testing, is that the current approach of filtering collections using
{'ns': {'$in': [{db: 'db', coll: 'a'}, {db: 'db', coll: 'b'}, {db: 'db', coll: 'c'}]}is expanded into a massive pipeline for the oplog aggregation, which can be very slow.Fixing the issue
There is not much we can do to capture progress or recover the issue if we do consistently run into timeouts, so our main options are to (1) increase the timeout, or (2) improve the performance of the pipeline. To improve the performance, one option is to simply remove the collection filters, and stream changes for all collections in the database. In my testing that mostly resolves this issue, but has the potential to also stream a lot of changes to collections that we don't use.
Luckily, changing the collection filters to the form
{'ns.coll': {$in: ['a', 'b', 'c']}appears to give a massive gain in performance here.Multiple databases
This PR still uses the old
nsfilters when replicating multiple databases on the same cluster. We can investigate and address this in a future PR.Performance Testing
These tests were all run on MongoDB 7.0. 8.0 has slightly different internal pipelines, but similar performance.
For my testing, I used an M10 Atlas cluster, initialized with sample data (specifically sample_airbnb for this, although the only thing that really matters is the volume of data). Then:
On the
testdb, capture a resumeToken:Then, on
sample_airbnb, generate lots of updates:Then we can test performance of various change stream pipelines on the
testdatabase.Case 1
Current style filtering on 50 test collections (which don't actually exist) - takes around 17s in my tests.
Explain output: https://gist.github.com/rkistner/6140f2b80831375266ab4e94bbf6b955
Case 2
We can remove the collection filters, reducing the time to around 1.7s in my tests.
Explain output: https://gist.github.com/rkistner/f07709ae52c4eee70495987c732a991f
Case 3
Here we use the
ns.coll-style collection filters - also takes around 1.7-2.1s in my tests.Explain output: https://gist.github.com/rkistner/c14df923f1bd1b419db6f8e800b6aa05
Analysis
Some notes from the explain output and other testing:
showExpandedEvents: falsethere are a lot of filters on e.g.o.dropIndexes. This causes quite a large pipeline overall.Overall, the pipeline appears to be roughly:
Looking at
executionTimeMillisEstimate, practically all the time is spend in the first stage of scanning the oplog.Looking at the filters, we can see that case 1 has an $or stage with 351 conditions, and MongoDB has to evaluate each of these conditions on each document. While in case 3, the first condition effectively filters out just on the database name, which is a much smaller check.
We can run these filters directly against the oplog collection itself:
Working with actual data
The above analysis focused on performance when there is no actual data in the change stream after the filters, which is the case causing the timeout. But what about replication performance where there is a lot of data that we do need to replicate?
For this, we run the same tests against the
sample_airbnbdatabase with the same data. We use the same setup as above, which gives us around 438k change documents. We run the same 3 test cases, but with thesample_airbnbdatabase, andinclude the
listingsAndReviewscollection.Case 1B (ns filters): 129s.
Case 2B (no filters): 80s.
Case 3B (ns.coll filters): 83s.
Conclusion: While the impact is less drastic, the pipeline still has a significant effect here, and
ns.collfilters still appear to be a good option.Code:
MongoDB 8.0
The tests above were all run on MongoDB 7.0. After upgrading the cluster to MongoDB 8.0 and 8.2.2, all 3 tests appear to run slightly faster, but the relative performance between them is still about the same.