Skip to content

fix: clone map race condition in UpdateIfNotExists#4328

Open
Amogh-Bharadwaj wants to merge 1 commit into
mainfrom
fix-clone-map-race
Open

fix: clone map race condition in UpdateIfNotExists#4328
Amogh-Bharadwaj wants to merge 1 commit into
mainfrom
fix-clone-map-race

Conversation

@Amogh-Bharadwaj

Copy link
Copy Markdown
Contributor

The problem

The pull and sync goroutines share record objects — addRecordWithKey stores a record in CDCStore and sends the same pointer to the CDCStream channel. Since RecordItems/PgItems are value types wrapping a map, copies share the underlying ColToVal map. When a later update/delete arrives for the same primary key, the pull goroutine retrieves the stored record and iterates its ColToVal via UpdateIfNotExists, while the sync goroutine concurrently iterates the same map for JSON serialization or numeric truncation.

The fix

Clone the input's ColToVal map at the start of UpdateIfNotExists in both RecordItems and PgItems, so the pull goroutine iterates a private copy. Additionally, the delete backfill path now uses UpdateIfNotExists (which clones internally) instead of directly assigning r.Items = latestRecord.GetItems(), which would have aliased the shared map.

@Amogh-Bharadwaj
Amogh-Bharadwaj requested review from a team as code owners May 20, 2026 11:56
@Amogh-Bharadwaj Amogh-Bharadwaj changed the title fix: clone map in UpdateIfNotExists fix: clone map race condition in UpdateIfNotExists May 20, 2026
@claude

claude Bot commented May 20, 2026

Copy link
Copy Markdown

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

@codecov

codecov Bot commented May 20, 2026

Copy link
Copy Markdown

❌ 2 Tests Failed:

Tests completed Failed Passed Skipped
2234 2 2232 189
View the top 1 failed test(s) by shortest run time
github.com/PeerDB-io/peerdb/flow/e2e::TestPeerFlowE2ETestSuitePG/Test_Soft_Delete_IUD_Same_Batch
Stack Traces | 197s run time
=== RUN   TestPeerFlowE2ETestSuitePG/Test_Soft_Delete_IUD_Same_Batch
=== PAUSE TestPeerFlowE2ETestSuitePG/Test_Soft_Delete_IUD_Same_Batch
=== CONT  TestPeerFlowE2ETestSuitePG/Test_Soft_Delete_IUD_Same_Batch
    postgres_test.go:600: WaitFor normalize tx 2026-05-20 12:05:48.458467933 +0000 UTC m=+281.541566560
    postgres_test.go:600: UNEXPECTED TIMEOUT normalize tx 2026-05-20 12:08:48.760719915 +0000 UTC m=+461.843818531
    postgres.go:92: begin tearing down postgres schema pg_nl4oulsb
2026/05/20 12:08:48 INFO Executing and processing query x-peerdb-additional-metadata={Operation:FLOW_OPERATION_UNKNOWN} partitionId=testpart query="SELECT id,c1,c2,t FROM e2e_test_pgchcl_dj2zvmg1.\"test_replident_full_toast\" ORDER BY id"
2026/05/20 12:08:48 INFO Executing and processing query stream x-peerdb-additional-metadata={Operation:FLOW_OPERATION_UNKNOWN} partitionId=testpart query="SELECT id,c1,c2,t FROM e2e_test_pgchcl_dj2zvmg1.\"test_replident_full_toast\" ORDER BY id"
2026/05/20 12:08:48 INFO [pg_query_executor] declared cursor x-peerdb-additional-metadata={Operation:FLOW_OPERATION_UNKNOWN} partitionId=testpart cursorQuery="DECLARE peerdb_cursor_10256297702212184742 CURSOR FOR SELECT id,c1,c2,t FROM e2e_test_pgchcl_dj2zvmg1.\"test_replident_full_toast\" ORDER BY id" args=[]
2026/05/20 12:08:48 INFO [pg_query_executor] fetching rows start x-peerdb-additional-metadata={Operation:FLOW_OPERATION_UNKNOWN} partitionId=testpart query="SELECT id,c1,c2,t FROM e2e_test_pgchcl_dj2zvmg1.\"test_replident_full_toast\" ORDER BY id" channelLen=0
2026/05/20 12:08:48 INFO [pg_query_executor] fetching from cursor x-peerdb-additional-metadata={Operation:FLOW_OPERATION_UNKNOWN} partitionId=testpart cursor=peerdb_cursor_10256297702212184742
2026/05/20 12:08:48 INFO processed row stream x-peerdb-additional-metadata={Operation:FLOW_OPERATION_UNKNOWN} partitionId=testpart cursor=peerdb_cursor_10256297702212184742 records=1 bytes=76045 channelLen=0
2026/05/20 12:08:48 INFO [pg_query_executor] fetched rows x-peerdb-additional-metadata={Operation:FLOW_OPERATION_UNKNOWN} partitionId=testpart query="SELECT id,c1,c2,t FROM e2e_test_pgchcl_dj2zvmg1.\"test_replident_full_toast\" ORDER BY id" rows=1 bytes=76045 channelLen=0
2026/05/20 12:08:48 INFO [pg_query_executor] fetching from cursor x-peerdb-additional-metadata={Operation:FLOW_OPERATION_UNKNOWN} partitionId=testpart cursor=peerdb_cursor_10256297702212184742
2026/05/20 12:08:48 INFO processed row stream x-peerdb-additional-metadata={Operation:FLOW_OPERATION_UNKNOWN} partitionId=testpart cursor=peerdb_cursor_10256297702212184742 records=0 bytes=0 channelLen=0
2026/05/20 12:08:48 INFO [pg_query_executor] fetched rows x-peerdb-additional-metadata={Operation:FLOW_OPERATION_UNKNOWN} partitionId=testpart query="SELECT id,c1,c2,t FROM e2e_test_pgchcl_dj2zvmg1.\"test_replident_full_toast\" ORDER BY id" rows=0 bytes=0 channelLen=0
2026/05/20 12:08:48 INFO [pg_query_executor] committing transaction x-peerdb-additional-metadata={Operation:FLOW_OPERATION_UNKNOWN} partitionId=testpart
2026/05/20 12:08:48 INFO [pg_query_executor] committed transaction for query x-peerdb-additional-metadata={Operation:FLOW_OPERATION_UNKNOWN} partitionId=testpart query="SELECT id,c1,c2,t FROM e2e_test_pgchcl_dj2zvmg1.\"test_replident_full_toast\" ORDER BY id" rows=1 bytes=76045 channelLen=0
--- FAIL: TestPeerFlowE2ETestSuitePG/Test_Soft_Delete_IUD_Same_Batch (197.44s)
View the full list of 1 ❄️ flaky test(s)
github.com/PeerDB-io/peerdb/flow/e2e::TestPeerFlowE2ETestSuitePG

Flake rate in main: 14.00% (Passed 129 times, Failed 21 times)

Stack Traces | 0.01s run time
=== RUN   TestPeerFlowE2ETestSuitePG
=== PAUSE TestPeerFlowE2ETestSuitePG
=== CONT  TestPeerFlowE2ETestSuitePG
--- FAIL: TestPeerFlowE2ETestSuitePG (0.01s)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@github-actions

Copy link
Copy Markdown
Contributor

❌ Test Failure

Analysis: Test_Soft_Delete_IUD_Same_Batch fails deterministically across all 3 matrix jobs with near-identical ~197s timeouts on "normalize tx", suggesting the fix-clone-map-race PR introduced a hang in the normalize path rather than a flaky failure.
Confidence: 0.85

⚠️ This appears to be a real bug - manual intervention needed

View workflow run

Comment thread flow/model/pg_items.go
for col, val := range input.ColToVal {
// clone input map to avoid concurrent map access, since input may reference
// a record that was already sent to the CDCStream channel for the sync goroutine
inputColToVal := maps.Clone(input.ColToVal)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why clone? No mutation

inputColToVal := maps.Clone(input.ColToVal)
updatedCols := make([]string, 0, len(inputColToVal))
for col, val := range inputColToVal {
if _, ok := r.ColToVal[col]; !ok {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're still mutating original. There's no lock. What's correct? You want read side to have updated version? Then need to block. Mutex only good if you don't care. You should care

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iow I think update should be moved to before record sent over channel; after sending it should be considered moved, like in rust where after moving object is no longer accessible

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless update is because of future record, in which case seems like bug having future record mutated past record

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants