Skip to content

fix: Handle concurrent MemDB writes - #3239

Open
Naganathan05 wants to merge 14 commits into
authzed:mainfrom
Naganathan05:fix/handle-concurrent-memdb-writes
Open

fix: Handle concurrent MemDB writes#3239
Naganathan05 wants to merge 14 commits into
authzed:mainfrom
Naganathan05:fix/handle-concurrent-memdb-writes

Conversation

@Naganathan05

@Naganathan05 Naganathan05 commented Jul 19, 2026

Copy link
Copy Markdown

Description

Fixes #3212: concurrent writes to the memdb datastore could acknowledge a WriteRelationships call whose data was then invisible to an immediately following fully-consistent read.

Root cause: within a single ReadWriteTx, mdb.Lock() is acquired and released in three separate windows (assigning the revision number, claiming the write-transaction slot, and committing + recording the snapshot) rather than held for the whole transaction.
Between those windows, another transaction can fully commit, so two transactions can be recorded in a different order than their revision numbers were assigned in. Snapshots were recorded via a plain append, so mdb.revisions could end up out of order, and SnapshotReader's binary search assumes it's sorted, so it could resolve to a stale snapshot that predates the write being requested.

Fix: Holding one lock for a transaction's entire duration would also fix the ordering, but at the cost of serializing all transactions.
Instead, insertRevisionSnapshot inserts each snapshot at its correct sorted position based on revision ID, regardless of the order transactions actually commit in.

Testing

Added TestConcurrentWriteRevisionInversionLosesRead, which forces the scenario that triggers the bug and asserts a read at a transaction's own revision sees its write.

Test result without adding the fix for insertion:

image

References

@Naganathan05
Naganathan05 requested a review from a team as a code owner July 19, 2026 13:50
@github-actions github-actions Bot added area/datastore Affects the storage system area/tooling Affects the dev or user toolchain (e.g. tests, ci, build tools) labels Jul 19, 2026
@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown

CLA Assistant Lite bot All contributors have signed the CLA ✍️ ✅

@Naganathan05

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

authzedbot added a commit to authzed/cla that referenced this pull request Jul 19, 2026
@Naganathan05

Copy link
Copy Markdown
Author

Requires review @miparnisari

Comment thread internal/datastore/memdb/memdb_test.go Outdated
}

// TestConcurrentWriteRevisionInversionLosesRead reproduces concurrent writes to MemDB
func TestConcurrentWriteRevisionInversionLosesRead(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

does this test reproduce the error 100% of the time? if not, can we make it so? or at least make it so that it fails a large percentage of the runs?

e.g. run a for loop 1000 times where in each iteration we spin up two concurrent goroutines?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, its deterministic. The test fails 100% of the time if the code fix introduced is removed.
The test manually simluates goroutine B which has the latest revision number to complete its execution before goroutine A which has the earlier revision number.
This is done using channels and its the same reason why wait groups are not used as they will not ensure the order of execution that goroutine B completes the execution before A is unblocked and proceeds to commit..

Added docstrings mentioning the test scenario

@miparnisari miparnisari Jul 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Your test didn't have 100% pass rate when ran many times (there was another bug - the collision guard in newRevisionID).

I've updated it to account for this, plus i've added more assertions.

Comment thread internal/datastore/memdb/memdb_test.go Outdated
Comment thread internal/datastore/memdb/revisions.go Outdated
Comment thread internal/datastore/memdb/memdb.go Outdated
schemaHash := mdb.getCurrentSchemaHashNoLock()
snap := mdb.db.Snapshot()
mdb.revisions = append(mdb.revisions, snapshot{newRevision, schemaHash, snap})
mdb.insertRevisionSnapshot(newRevision, schemaHash, snap)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i'm confused... you said

Root cause: a transaction's revision number is stamped before it acquires the write lock

isn't the solution to acquire the lock? and which lock are you talking about? i see we're acquiring on line 249 already.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes the lock mdb.Lock() is already existing as you mentioned, but this is released and acquired thrice in ReadWriteTx by a single goroutine/transaction.
In between the release and acquire actions, another transaction B is able to run its own ReadWriteTx to completion, including its commit and snapshot recording before A resumes.

A single global lock can be done per transaction for ReadWriteTx, but this would affect the concurrency and serialize all the transaction in this operation.
Instead we can ensure that the snapshots are inserted in the sorted order as per their revision ID regardless of the transaction's commit order. This is the fix that is implemented in this PR.

There is a little gap in the PR description, will update it mentioning this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Your fix addresses the symptom but not the root cause. I've updated the code to fix the root cause.

Comment thread internal/datastore/memdb/memdb_test.go Outdated
@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 51.42857% with 51 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/datastore/memdb/memdb.go 51.52% 32 Missing and 16 partials ⚠️
internal/datastore/memdb/revisions.go 50.00% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@miparnisari
miparnisari force-pushed the fix/handle-concurrent-memdb-writes branch from ff4aa4a to d123966 Compare July 21, 2026 19:48
@miparnisari
miparnisari force-pushed the fix/handle-concurrent-memdb-writes branch from d123966 to 694389e Compare July 21, 2026 19:55
@Naganathan05

Naganathan05 commented Jul 23, 2026

Copy link
Copy Markdown
Author

@miparnisari
Went over the code changes, it makes sense that you moved the task of assigning revision number inside activeWriteTxn. This should address the issue.
Any additional test addition required ?

@miparnisari
miparnisari requested a review from tstirrat15 July 23, 2026 21:07
@github-actions github-actions Bot removed the area/tooling Affects the dev or user toolchain (e.g. tests, ci, build tools) label Jul 23, 2026
@Naganathan05

Copy link
Copy Markdown
Author

@miparnisari
Anything pending to be done ?

@miparnisari

miparnisari commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Since I made changes to your PR, i'm waiting for someone else in the maintainer team to review. @tstirrat15 can you review?

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

Labels

area/datastore Affects the storage system

Projects

None yet

Development

Successfully merging this pull request may close these issues.

memdb: concurrent WriteRelationships can be silently lost

2 participants