fix(mongo): pipeline document decode in CDC PullRecords - #4722
Conversation
Code reviewFour issues found in the new pipelined
1. peerdb/flow/connectors/mongo/cdc.go Lines 239 to 242 in 11938ef Same pattern in
Secondary consequences: in Fix: hoist 2. The peerdb/flow/connectors/mongo/cdc.go Lines 710 to 722 in 11938ef The old switch in
Those events have no Relatedly, the switch at L356 has no Fix: restore the op-type guard on the producer side before 3. peerdb/flow/connectors/mongo/cdc.go Lines 571 to 582 in 11938ef When a worker fails it writes to the buffered This is lossy, not just noisy: The batch-boundary case hits this routinely: when a bad document appears among the last records before Fix: case <-wgWaiter:
// Workers may have exited after buffering an error; do not lose it.
select {
case err := <-errChan:
return err
default:
}4. The peerdb/flow/connectors/mongo/cdc.go Lines 719 to 726 in 11938ef
The reachable path does not need an unlikely interleaving. A main goroutine parked on this send is the designed steady state under backpressure: Use Fix: select {
case decodeChan <- items:
case err := <-errChan:
workerCtxCancel()
return err
case <-ctx.Done():
workerCtxCancel()
return ctx.Err()
}The error-reporting selects in |
11938ef to
a7d74d5
Compare
|
|
||
| func (c *MongoConnector) PullRecords( | ||
| // Two additional loops are created by PullRecords, each running in their own goroutines. | ||
| // One is decodeLoop, which takes records from |
There was a problem hiding this comment.
One is decodeLoop, which takes records from ... has this code comment been cut?
There was a problem hiding this comment.
ah yes, my bad - forgot to finish up this comment.
There was a problem hiding this comment.
Pull request overview
This PR parallelizes MongoDB CDC record processing in PullRecords by offloading full-document conversion and record publishing to separate goroutines, aiming to improve CPU utilization and avoid blocking on downstream record streaming.
Changes:
- Introduces
decodeLoop(BSON-to-QValue conversion + record construction) andsendLoop(publishes toreq.RecordStream) worker goroutines. - Adds worker draining/restart logic to support change stream recreation while attempting to avoid record loss.
- Refactors
PullRecordshot path to enqueue lightweight items to the decode worker instead of doing full decode inline.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
bbf1df5 to
d2711b6
Compare
There was a problem hiding this comment.
Aside from the more superficial comments I left in the PR I wanted to confirm this deeper point:
The goal of the PR is to boost event processing performance through the use of parallelism in the so far serial process of:
- Decoding BSON
- Sending the decoded record to the record stream (
req.RecordStream.AddRecord(ctx, record)) were this action is, in turn, sends the record to another channel. When this final destination channel is full (back-pressure we already loggedwaiting on adding record to stream) messages.
So I am assuming that the sought performance boost is expected to come from the added ability to decode at most 10 BSON documents when (2) back-pressure kicks in.
Q1: Without such back-pressure, both the serial version (prior to the PR) and the one with these changes should take the same time, right? Events are decoded, pushed to the channel and sent as they come. So, in this case, there is no huge performance boost beyond being able to pull more records will decoding is happening, right?
In this case (no back-pressure from AddRecord), the bottleneck could be the decoding part.
Q2: However, with this implementation as I understand it, decoding is still serial (we are not decoding two or more different events at the same time at different go-routines). Am I understanding this correctly?
It's true that it adds a buffer workerBufferedChanSize that might allow for the continued processing of events in the main loop until the decoding queue grows up to 10 (current constant value).
Then we'd allow for at most other 10 events to be sent while decoding is working.
Q3: But back to point (2) above, there we already had a channel consumed by another go-routine. So, would this change actually improve things in this case?
Q4: And, finally, if the bottleneck actually happens at AddRecord point, then following a similar reasoning we wouldn't expect noticeable performance boost through parallelism as decoding needs to wait to be able to send to the destination channel, the pipeline becomes serialized.
Q5: Would it be easy to add https://pkg.go.dev/net/http/pprof and play with some operation in the local dev env?
| defer wg.Done() | ||
|
|
||
| // Start up sendLoop. Have a buffered channel in case decoding runs faster | ||
| // than the downstream addition of records to req.RecordStream. |
There was a problem hiding this comment.
I wonder if a a buffer of size 10 really makes a difference vs a size 1 if the addition back-pressures on the decoding.
Have you been able to play with different values to notice real differences?
There was a problem hiding this comment.
10 ended up being useful once I added the dynamically increasing number of decode workers - but I can experiment with just matching the number of workers if you want?
There was a problem hiding this comment.
but I can experiment with just matching the number of workers if you want?
I was mostly wondering what drove choosing that number, maybe others can drive to better results? Is 10 just good enough?
If you experimented and observed benefits from 10 that's fine by me. I was curious as to were it's coming from 🙂
|
@pfcoperez Just for some more context around this issue, there was a situation that came up while you were out where it took almost 48 hours to drain 24 hours of source side lag so this is what is driving this change. |
|
to answer this question first:
Back-pressure kicks in only after some time (~24 hours), so even if it doesn't kick in, this optimization will reduce latency if the processing is behind but yet triggered back-pressure. The most expensive/slow part is bson deser/ser into QRecord, so by parallelizing deserialization we can saturate cpu better and get some perf gain. I'm a bit behind on reviewing this, taking a look now |
|
After taking an initial look, here's my thoughts:
|
|
@jgao54 @pfcoperez thanks for the reviews! I expect that the current approach will provide some perf gains by parallelizing the event decode (in PullRecords) with the document decode (in decodeLoop). Based on how some of my in-progress stresshouse testing goes, I plan to update the code with an approach that allows for multiple parallel It's also possible that the bottlenecks are elsewhere and not with full document decode. But I'm looking forward to seeing what my experimentation yields. Also yes, I'm open to putting this behind a feature flag. |
a6c1f80 to
c3af623
Compare
|
I couldn't test this end-to-end using stresshouse but instead I wrote a microbenchmark that I can point at a mongoDB and a clickhouse running locally. I will polish the benchmark up in a separate PR, but in the meantime I noticed a few things:
I've updated the code to parallelize decode across multiple workers while still maintaining order of events, and with this I now see a ~40-50% reduction in the amount of time it takes to run PullRecords ( ~0.13 elapsedMinutes logged by PullRecords down to ~0.06-0.07 for my contrived benchmark of writing 2 million rows across multiple parallel writers to mongo and CDCing it over to a ClickHouse instance). Also translates to a ~40% reduction in end-to-end replication time because we were (and continue to be) bound by This should be ready for a review again. I'll scrutinize the |
❌ Test FailureAnalysis: Not flaky: the mongo connector test package fails to compile because PR #4722 added RemainingBatchLength to the ChangeStream interface without implementing it on mockChangeStream, failing identically in all three matrix jobs and on retry. |
c3af623 to
3f3f0bd
Compare
🔄 Flaky Test DetectedAnalysis: Two unrelated e2e tests in different matrix legs both hit the fixed 60-second ✅ Automatically retrying the workflow |
Tried this out, didn't help significantly (< 5% improvement) so I'll keep this change as-is. Only other TODO is to add more testing around the error / early termination cases as mentioned previously, but otherwise this is ready for a look. |
this would also depends on network roundtrip between peerdb node and source mongo node, and I'd expect putting them in the same region should shorten this if they are tested far away from each other. |
|
(i haven't yet looked at the code changes but just commenting based on what you wrote)
yeah this one needs to be very careful as order matters. before we checkpoint offset after every event in-memory, and then persist it to disk at the end of a batch. if we introduce concurrent checkpointing, we have to make sure the goroutines are coordinated in a way such that we never checkpoint until all the worker are past a certain point (kind of similar to cockroachdb's quirk we had to workaround recently, only this we have to manage it ourselves) if an error is hit, it's less of a concern because we don't persist checkpoint to disk when we encounter an error, so on retry we just resume from previously checkpointed token. edit: on second thought, maybe the worst case is we checkpoint an older offset than the latest event we send to the stream (when the events are processed out-of-order), so worst case is duplicates, not missing data... (but would still be good to avoid if possible). |
This ended up being relatively simple to do; I just moved checkpoint responsibility over to If duplicates in that specific error case are a non-issue, the current approach is slightly cleaner and marginally more performant. Also added tests for the error cases, this should be ready for a look, thanks! |
❌ Test FailureAnalysis: Real bug, not flake: the PR's new call to GetLastCheckpoint() at flow/connectors/mongo/cdc.go:588 runs before the CDC stream is closed, panicking with "last checkpoint not set, stream is still active" identically on all three matrix legs, and wedging the entire e2e package into a 20-minute timeout. |
fe08385 to
3184ada
Compare
Previously, we serially decoded the change event, then the full document that was changed, inside the PullRecords loop for a given batch. This would leave a lot of CPU underutilized as the bulk of work would happen on just one thread. This change moves the full document decode part to a different goroutine, and the record publish to another goroutine, which should allow for greater CPU utilization by the decoding function.
b0d5076 to
81291d2
Compare
🔄 Flaky Test DetectedAnalysis: Not a test failure at all — all three jobs failed in the ✅ Automatically retrying the workflow |
🔄 Flaky Test DetectedAnalysis: Not a test failure — both matrix jobs died in the "install lib-geos and pg_dump" setup step when ✅ Automatically retrying the workflow |
🔄 Flaky Test DetectedAnalysis: Not a test failure at all — all three matrix jobs died in the "install lib-geos and pg_dump" setup step because ✅ Automatically retrying the workflow |
🔄 Flaky Test DetectedAnalysis: Not a test failure at all — all three flow_test matrix jobs aborted during setup because ✅ Automatically retrying the workflow |
🔄 Flaky Test DetectedAnalysis: TestPullRecordsWorkerPoolErrorFromWorker asserts one specific worker's error token ("token-3") but a different worker's error ("token-1") won the goroutine race, and the same test passed in the pg18 matrix job on the identical commit — a race-dependent assertion in a test newly added by PR #4722. ✅ Automatically retrying the workflow |
81291d2 to
7ec39fc
Compare
|
@fable run a review on these changes focusing on potential data loss or event order changes regressions. |
|
Claude finished @pfcoperez's task in 11m 9s —— View job Review: data loss & event ordering in the pipelined PullRecords
TL;DR: I found no data-loss or reordering regression in the happy and error paths I traced. The core invariants hold by construction and are well tested. I did find one config-triggered wedge/panic (decode threads ≤ 0), one error-path behavior that delays failure and buffers unboundedly within a batch, and a couple of smaller hardening items. Why ordering and checkpoint safety holdWalking the design end to end:
Findings1.
2. After a worker/send error, Once any worker or 3. First-batch empty-checkpoint overwrite window (low, pre-existing, cheap to close) The stream's 4. Effective decode parallelism is silently capped at ~11 (perf note)
5. CI flagged this test flaking on run 34385388819 ( Test coverage suggestionThe one loss/ordering-relevant path without coverage is stream recreation with records already delivered: Nits
Nothing here blocks the PR on the data-loss/ordering question the review asked about — findings 1 and 2 are the ones I'd address before merge. |
|
@itsbilal From the Fable review I'd address this one, specially making sure the defaults don't lead to hanging syncs:
|
|
@pfcoperez addressed! thanks |
| // and chunks will maintain this AddItem-implied order on their way to Send(). Within a chunk, | ||
| // the slice of events is also ordered from earliest to latest. | ||
| type PullRecordsWorkerPool[E, D, RT any] struct { | ||
| WorkerFunc func(events []E) (D, error) |
pfcoperez
left a comment
There was a problem hiding this comment.
This reads like a charm now, thank you!
❌ Test FailureAnalysis: Not flaky: the new unit test TestPullRecordsWorkerPoolErrorFromWorker (flow/pkg/common/workers_test.go:76) fails deterministically in all three matrix jobs in 0.00s with an unexpected "decode failed" error, indicating a real bug in the PR's new worker-pool decode error handling rather than an environmental or timing issue. |
🔄 Flaky Test DetectedAnalysis: A single CDC event was delivered twice in TestCockroachDBSuite/Test_CDC_Exactly_Once_Across_Batches on only the crdb-v26 matrix leg (the same test passed on crdb-v24/v25 in the same run), which matches the timing-sensitive at-least-once replay behavior the CockroachDB changefeed connector's resolved-timestamp buffering guards against, rather than a deterministic bug. ✅ Automatically retrying the workflow |
| slog.Int("channelLen", req.RecordStream.ChannelLen()), | ||
| slog.Float64("elapsedMinutes", time.Since(pullStart).Minutes())) | ||
| }() | ||
| numParallelDecodeWorkers, err := internal.PeerDBMongoDBNumParallelDecodeThreads(ctx, req.Env) |
There was a problem hiding this comment.
one thing i missed earlier during review that may worth a follow-up: currently this hits the db on every PullRecords, which can be as frequent as every second. so applied to all the pipes in all services this can cause some db pressure.
given that this changes infrequently, and requires a pause/resume to update anyways, can be a connector level setting that gets read once upfront (similar to how we handle PeerDBMongoDBExcludedOperationTypes today).
Previously, we serially decoded the change event, then the full document that was changed, inside the PullRecords loop for a given batch. This would leave a lot of CPU underutilized as the bulk of work would happen on just one thread.
This change moves the full document decode part to a different goroutine, and the record publish to another goroutine, which should allow for greater CPU utilization by the decoding function.
Existing tests pass; currently looking into benchmarking this with a fast-changing table on Mongo.