-
Notifications
You must be signed in to change notification settings - Fork 407
fix: Handle concurrent MemDB writes #3239
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
956afb6
e709992
f1e543f
e2bb728
58d7829
b368945
e9670aa
2ad2859
18c9cab
694389e
920e7b7
90c653e
2a1515e
a684bfe
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -113,6 +113,75 @@ func TestConcurrentWriteRelsSucceed(t *testing.T) { | |
| require.NoError(g.Wait()) | ||
| } | ||
|
|
||
| // TestConcurrentWriteRevisionInversionLosesRead reproduces concurrent writes to MemDB | ||
| func TestConcurrentWriteRevisionInversionLosesRead(t *testing.T) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Added docstrings mentioning the test scenario
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I've updated it to account for this, plus i've added more assertions. |
||
| require := require.New(t) | ||
|
|
||
| ds, err := NewMemdbDatastore(0, 1*time.Hour, 1*time.Hour) | ||
| require.NoError(err) | ||
| mds := ds.(*memdbDatastore) | ||
|
|
||
| ctx := t.Context() | ||
|
|
||
| aEntered := make(chan struct{}) | ||
| releaseA := make(chan struct{}) | ||
| aDone := make(chan struct{}) | ||
|
|
||
| var revA datastore.Revision | ||
| var errA error | ||
| go func() { | ||
| defer close(aDone) | ||
| revA, errA = ds.ReadWriteTx(ctx, func(ctx context.Context, rwt datastore.ReadWriteTransaction) error { | ||
| close(aEntered) | ||
| <-releaseA | ||
| return rwt.WriteRelationships(ctx, []tuple.RelationshipUpdate{ | ||
| tuple.Touch(tuple.MustParse("document:doc-a#viewer@user:tom")), | ||
| }) | ||
| }, options.WithDisableRetries(true)) | ||
| }() | ||
|
|
||
| <-aEntered | ||
|
|
||
| revB, err := ds.ReadWriteTx(ctx, func(ctx context.Context, rwt datastore.ReadWriteTransaction) error { | ||
| return rwt.WriteRelationships(ctx, []tuple.RelationshipUpdate{ | ||
| tuple.Touch(tuple.MustParse("document:doc-b#viewer@user:tom")), | ||
| }) | ||
| }, options.WithDisableRetries(true)) | ||
| require.NoError(err) | ||
|
|
||
| close(releaseA) | ||
| <-aDone | ||
|
miparnisari marked this conversation as resolved.
Outdated
|
||
| require.NoError(errA) | ||
|
|
||
| require.True(revB.GreaterThan(revA), "expected B's revision (%v) to be greater than A's (%v)", revB, revA) | ||
|
|
||
| indexOf := func(r datastore.Revision) int { | ||
| mds.RLock() | ||
| defer mds.RUnlock() | ||
| for i, snap := range mds.revisions { | ||
| if snap.revision.Equal(r) { | ||
| return i | ||
| } | ||
| } | ||
| return -1 | ||
| } | ||
|
miparnisari marked this conversation as resolved.
Outdated
|
||
| t.Logf("storage order: A(rev=%v) at index %d, B(rev=%v) at index %d", revA, indexOf(revA), revB, indexOf(revB)) | ||
|
|
||
| reader := ds.SnapshotReader(revA) | ||
| it, err := reader.QueryRelationships(ctx, datastore.RelationshipsFilter{OptionalResourceType: "document"}) | ||
| require.NoError(err) | ||
| rels, err := datastore.IteratorToSlice(it) | ||
| require.NoError(err) | ||
|
|
||
| var sawA bool | ||
| for _, rel := range rels { | ||
| if rel.Resource.ObjectID == "doc-a" { | ||
| sawA = true | ||
| } | ||
| } | ||
| require.True(sawA, "relationship written by transaction A is invisible when reading at A's own returned revision %v", revA) | ||
| } | ||
|
|
||
| func TestAnythingAfterCloseDoesNotPanic(t *testing.T) { | ||
| require := require.New(t) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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
isn't the solution to acquire the lock? and which lock are you talking about? i see we're acquiring on line 249 already.
There was a problem hiding this comment.
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 inReadWriteTxby 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.
There was a problem hiding this comment.
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.