-
Notifications
You must be signed in to change notification settings - Fork 894
chore: fix concurrent CommitQuota
transactions for unrelated users/orgs
#15261
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
Changes from 16 commits
dce750d
4fd9760
6215603
9ccd0b2
784a98f
5f021ad
426eeb6
b69dd0f
ec8f799
f613f31
746203d
c0d05db
245f6df
422e694
685b21e
33dbd92
a99882a
6ce8bfe
3a9270d
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 |
---|---|---|
@@ -0,0 +1,127 @@ | ||
package dbfake | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/google/uuid" | ||
"github.com/stretchr/testify/require" | ||
|
||
"github.com/coder/coder/v2/coderd/database" | ||
"github.com/coder/coder/v2/coderd/database/dbauthz" | ||
"github.com/coder/coder/v2/coderd/database/dbgen" | ||
"github.com/coder/coder/v2/coderd/database/dbtime" | ||
"github.com/coder/coder/v2/testutil" | ||
) | ||
|
||
type OrganizationBuilder struct { | ||
t *testing.T | ||
db database.Store | ||
seed database.Organization | ||
allUsersAllowance int32 | ||
members []uuid.UUID | ||
groups map[database.Group][]uuid.UUID | ||
} | ||
|
||
func Organization(t *testing.T, db database.Store) OrganizationBuilder { | ||
return OrganizationBuilder{ | ||
t: t, | ||
db: db, | ||
members: []uuid.UUID{}, | ||
groups: make(map[database.Group][]uuid.UUID), | ||
} | ||
} | ||
|
||
type OrganizationResponse struct { | ||
Org database.Organization | ||
AllUsersGroup database.Group | ||
Members []database.OrganizationMember | ||
Groups []database.Group | ||
} | ||
|
||
func (b OrganizationBuilder) EveryoneAllowance(allowance int) OrganizationBuilder { | ||
//nolint: revive // returns modified struct | ||
b.allUsersAllowance = int32(allowance) | ||
return b | ||
} | ||
|
||
func (b OrganizationBuilder) Seed(seed database.Organization) OrganizationBuilder { | ||
//nolint: revive // returns modified struct | ||
b.seed = seed | ||
return b | ||
} | ||
|
||
func (b OrganizationBuilder) Members(users ...database.User) OrganizationBuilder { | ||
for _, u := range users { | ||
//nolint: revive // returns modified struct | ||
b.members = append(b.members, u.ID) | ||
} | ||
return b | ||
} | ||
|
||
func (b OrganizationBuilder) Group(seed database.Group, members ...database.User) OrganizationBuilder { | ||
//nolint: revive // returns modified struct | ||
b.groups[seed] = []uuid.UUID{} | ||
for _, u := range members { | ||
//nolint: revive // returns modified struct | ||
b.groups[seed] = append(b.groups[seed], u.ID) | ||
} | ||
return b | ||
} | ||
|
||
func (b OrganizationBuilder) Do() OrganizationResponse { | ||
org := dbgen.Organization(b.t, b.db, b.seed) | ||
|
||
ctx := testutil.Context(b.t, testutil.WaitShort) | ||
//nolint:gocritic // builder code needs perms | ||
ctx = dbauthz.AsSystemRestricted(ctx) | ||
everyone, err := b.db.InsertAllUsersGroup(ctx, org.ID) | ||
require.NoError(b.t, err) | ||
|
||
if b.allUsersAllowance > 0 { | ||
everyone, err = b.db.UpdateGroupByID(ctx, database.UpdateGroupByIDParams{ | ||
Name: everyone.Name, | ||
DisplayName: everyone.DisplayName, | ||
AvatarURL: everyone.AvatarURL, | ||
QuotaAllowance: b.allUsersAllowance, | ||
ID: everyone.ID, | ||
}) | ||
require.NoError(b.t, err) | ||
} | ||
|
||
members := make([]database.OrganizationMember, 0) | ||
if len(b.members) > 0 { | ||
for _, u := range b.members { | ||
newMem := dbgen.OrganizationMember(b.t, b.db, database.OrganizationMember{ | ||
UserID: u, | ||
OrganizationID: org.ID, | ||
CreatedAt: dbtime.Now(), | ||
UpdatedAt: dbtime.Now(), | ||
Roles: nil, | ||
}) | ||
members = append(members, newMem) | ||
} | ||
} | ||
|
||
groups := make([]database.Group, 0) | ||
if len(b.groups) > 0 { | ||
for g, users := range b.groups { | ||
g.OrganizationID = org.ID | ||
group := dbgen.Group(b.t, b.db, g) | ||
groups = append(groups, group) | ||
|
||
for _, u := range users { | ||
dbgen.GroupMember(b.t, b.db, database.GroupMemberTable{ | ||
UserID: u, | ||
GroupID: group.ID, | ||
}) | ||
} | ||
} | ||
} | ||
|
||
return OrganizationResponse{ | ||
Org: org, | ||
AllUsersGroup: everyone, | ||
Members: members, | ||
Groups: groups, | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
package dbtestutil | ||
|
||
import ( | ||
"sync" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/assert" | ||
"golang.org/x/xerrors" | ||
|
||
"github.com/coder/coder/v2/coderd/database" | ||
) | ||
|
||
type DBTx struct { | ||
database.Store | ||
mu sync.Mutex | ||
done chan error | ||
finalErr chan error | ||
} | ||
|
||
// StartTx starts a transaction and returns a DBTx object. This allows running | ||
// 2 transactions concurrently in a test more easily. | ||
// Example: | ||
// | ||
// a := StartTx(t, db, opts) | ||
// b := StartTx(t, db, opts) | ||
// | ||
// a.GetUsers(...) | ||
// b.GetUsers(...) | ||
// | ||
// require.NoError(t, a.Done() | ||
func StartTx(t *testing.T, db database.Store, opts *database.TxOptions) *DBTx { | ||
done := make(chan error) | ||
finalErr := make(chan error) | ||
txC := make(chan database.Store) | ||
|
||
go func() { | ||
t.Helper() | ||
once := sync.Once{} | ||
count := 0 | ||
|
||
err := db.InTx(func(store database.Store) error { | ||
// InTx can be retried | ||
once.Do(func() { | ||
txC <- store | ||
}) | ||
count++ | ||
if count > 1 { | ||
// If you recursively call InTx, then don't use this. | ||
t.Logf("InTx called more than once: %d", count) | ||
assert.NoError(t, xerrors.New("InTx called more than once, this is not allowed with the StartTx helper")) | ||
} | ||
|
||
<-done | ||
// Just return nil. The caller should be checking their own errors. | ||
return nil | ||
}, opts) | ||
finalErr <- err | ||
}() | ||
|
||
txStore := <-txC | ||
close(txC) | ||
|
||
return &DBTx{Store: txStore, done: done, finalErr: finalErr} | ||
} | ||
|
||
// Done can only be called once. If you call it twice, it will panic. | ||
func (tx *DBTx) Done() error { | ||
tx.mu.Lock() | ||
defer tx.mu.Unlock() | ||
|
||
close(tx.done) | ||
return <-tx.finalErr | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,23 +18,34 @@ INNER JOIN groups ON | |
WITH latest_builds AS ( | ||
SELECT | ||
DISTINCT ON | ||
(workspace_id) id, | ||
workspace_id, | ||
daily_cost | ||
(wb.workspace_id) wb.id, | ||
wb.workspace_id, | ||
wb.daily_cost | ||
FROM | ||
workspace_builds wb | ||
-- This INNER JOIN prevents a seq scan of the workspace_builds table. | ||
-- Limit the rows to the absolute minimum required, which is all workspaces | ||
-- in a given organization for a given user. | ||
INNER JOIN | ||
Emyrk marked this conversation as resolved.
Show resolved
Hide resolved
|
||
workspaces on wb.workspace_id = workspaces.id | ||
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. Ok, so in the query plan you have now, it's doing this nested loop where it finds the workspace IDs, then for each workspace ID it's doing this bitmap query:
That is, it first scans the index to find the pages to load, then scans the pages with the Do you know whether this results in page locks for the transaction, or tuple locks (I'm assuming these are row-level locks)? Page locks have a greater likelihood of catching unrelated transactions. And, some suggestions:
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.
I was under the impression it was row-level locks, but I admit that was a result of my experiments with very few rows. The docs seem to imply it could take out larger locks.
Originally there was a suspicion we were getting this error from different transactions interring with I thought about moving I think this index + sorting by build number would be a large win: As I look at this query more, I don't see why we need to inner join If we add the index According to the quote I pulled though, is the goal just to reduce the number of rows touched to prevent the lock from having to go more "coarse" to the page lock? The text implies the behavior depends on the memory availability. 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. I agree that we don't need to join to And yeah, my understanding is that with page locks, updating quota for an unrelated user could cause a serialization error if the builds happen to share a page. So it's undesirable to use page locks if we could get away with finer grained locking. I realize that postgres can use page locks for memory reasons, and maybe there isn't anything we can do about that, but I'm also wondering whether it automatically uses page locks when it does a bitmap query, rather than tuple locks if we were able to use the index. |
||
WHERE | ||
workspaces.owner_id = @owner_id AND | ||
workspaces.organization_id = @organization_id | ||
ORDER BY | ||
workspace_id, | ||
created_at DESC | ||
wb.workspace_id, | ||
wb.created_at DESC | ||
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. It might be faster to use
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. I wondered about that too. I had a specific goal and did not want to start mutating the query too much to solicit feedback on different things. Can I throw this up in another PR? 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. yeah, that works |
||
) | ||
SELECT | ||
coalesce(SUM(daily_cost), 0)::BIGINT | ||
FROM | ||
workspaces | ||
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. Looks like it's still doing a Seq scan on
Doesn't that mean any update to the workspaces table will still cause a serialization error? However! I think the query planner takes into account cardinality of the columns relative to the rows in the table, so it might make a different plan in a "real" deployment. 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, we still do a seq scan on the workspaces.
The serializable lock only affects tables (rows) that it written to. The 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. How do you square this with the observation that a Seq Scan on Maybe it depends on the number of workspaces and the assumed cardinality. 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. Experimentally, I am running this:
The TX mode that is important here is
What I see my change doing is
And now does:
So we went from a full The With my query changes raw
Before my changes raw
Disclaimer: I recognize the docs suggest these lock patterns can change with table size and memory availability. This is a very small dataset. 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. Interesting that serializable transactions only interfere with other serializable transactions --- OK for now, but I feel like this puts us on shaky ground. Today we insert new builds with repeatable read isolation, but do not set the Or, if we ever introduce a new update query on Not necessarily for this PR, but I think we should still aim to remove the Seq Scan on 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.
I agree. I will make a follow up PR (or issue if it takes to long) to address all these extra concerns. In this PR I did disable retries, so a unit test is more likely to fail if we hit this (would be a valid flake). I do not see a way to implement forward thinking protections if someone adds a new 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.
Just to clarify, I specifically meant the error There is another error you can get that is not between 2 serializable transactions.
|
||
JOIN latest_builds ON | ||
latest_builds.workspace_id = workspaces.id | ||
WHERE NOT | ||
deleted AND | ||
WHERE | ||
NOT deleted AND | ||
-- We can likely remove these conditions since we check above. | ||
-- But it does not hurt to be defensive and make sure future query changes | ||
-- do not break anything. | ||
workspaces.owner_id = @owner_id AND | ||
workspaces.organization_id = @organization_id | ||
; | ||
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. This is the fix. Everything else is just tests |
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 don't think the build ID is needed.
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 didn't try and change the query too much beyond the seq scan.
It looks like it was never used at all:
coder/coderd/database/queries/quotas.sql
Lines 11 to 30 in d75c469