Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/datastore/memdb/memdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ func (mdb *memdbDatastore) ReadWriteTx(
// Create a snapshot and add it to the revisions slice
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.

return newRevision, nil
}

Expand Down
69 changes: 69 additions & 0 deletions internal/datastore/memdb/memdb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,75 @@ func TestConcurrentWriteRelsSucceed(t *testing.T) {
require.NoError(g.Wait())
}

// 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.

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
Comment thread
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
}
Comment thread
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)

Expand Down
11 changes: 11 additions & 0 deletions internal/datastore/memdb/revisions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ package memdb

import (
"context"
"slices"
"sort"
"time"

"github.com/hashicorp/go-memdb"

"github.com/authzed/spicedb/internal/datastore/revisions"
"github.com/authzed/spicedb/pkg/datastore"
)
Expand Down Expand Up @@ -38,6 +42,13 @@ func (mdb *memdbDatastore) newRevisionID() revisions.TimestampRevision {
return created
}

func (mdb *memdbDatastore) insertRevisionSnapshot(newRevision revisions.TimestampRevision, schemaHash string, snap *memdb.MemDB) {
insertAt := sort.Search(len(mdb.revisions), func(i int) bool {
return mdb.revisions[i].revision.GreaterThan(newRevision)
})
mdb.revisions = slices.Insert(mdb.revisions, insertAt, snapshot{newRevision, schemaHash, snap})
Comment thread
miparnisari marked this conversation as resolved.
Outdated
}

func (mdb *memdbDatastore) HeadRevision(_ context.Context) (datastore.RevisionWithSchemaHash, error) {
mdb.RLock()
defer mdb.RUnlock()
Expand Down
Loading