Skip to content
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

Read replica support for Postgres and MySQL datastores #1878

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

josephschorr
Copy link
Member

No description provided.

This is accomplished by adding a datastore proxy that selects a read replica for each SnapshotReader, in a round robin fashion, and ensures that the revision being requested is available on the replica. This extra check does add some latency to the overall operation, but it should provide for the safest approach for using read replicas

Fixes authzed#1321
Fixes authzed#1320
@github-actions github-actions bot added area/CLI Affects the command line area/datastore Affects the storage system area/tooling Affects the dev or user toolchain (e.g. tests, ci, build tools) labels Apr 25, 2024
@josephschorr josephschorr marked this pull request as ready for review April 26, 2024 18:22
@josephschorr josephschorr requested review from vroldanbet and a team as code owners April 26, 2024 18:22
@benny-yamagata
Copy link

Just to clarify, this update will allow spice to use multiple reader nodes for postgres and mysql? So if I have 3 readers I would be able to pass in the entries for all of them to be used?

@josephschorr
Copy link
Member Author

@benny-yamagata Yes, the replica URI parameter is a list of URIs and the system will round robin between them

Copy link
Contributor

@vroldanbet vroldanbet left a comment

Choose a reason for hiding this comment

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

The implementation works with the assumption individual hosts will be listed, but folks most of the time would use replicas behind a load-balancer in order to be able to scale read traffic when needed. I don't think the implementation will work for that more common use case because the datastore snapshot reader does not use a single transaction.

@@ -97,15 +97,30 @@ type sqlFilter interface {
// URI: [scheme://][user[:[password]]@]host[:port][/schema][?attribute1=value1&attribute2=value2...
// See https://dev.mysql.com/doc/refman/8.0/en/connecting-using-uri-or-key-value-pairs.html
func NewMySQLDatastore(ctx context.Context, uri string, options ...Option) (datastore.Datastore, error) {
ds, err := newMySQLDatastore(ctx, uri, options...)
ds, err := newMySQLDatastore(ctx, uri, -1 /* primary */, options...)
Copy link
Contributor

Choose a reason for hiding this comment

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

magic number -> use constant

please replace it everywhere it's used

index uint32,
options ...Option,
) (datastore.ReadOnlyDatastore, error) {
ds, err := newMySQLDatastore(ctx, url, int(index), options...)
Copy link
Contributor

Choose a reason for hiding this comment

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

I searched online if there is any URI query parameter we can add to enforce read only, but didn't find any. Users should make sure the credentials provided for the replica have read-only permissions.

@@ -48,7 +48,7 @@ type datastoreTester struct {
func (dst *datastoreTester) createDatastore(revisionQuantization, gcInterval, gcWindow time.Duration, _ uint16) (datastore.Datastore, error) {
ctx := context.Background()
ds := dst.b.NewDatastore(dst.t, func(engine, uri string) datastore.Datastore {
ds, err := newMySQLDatastore(ctx, uri,
ds, err := newMySQLDatastore(ctx, uri, -1,
Copy link
Contributor

Choose a reason for hiding this comment

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

magic number, use constant

@@ -116,7 +117,24 @@ func NewPostgresDatastore(
url string,
options ...Option,
) (datastore.Datastore, error) {
ds, err := newPostgresDatastore(ctx, url, options...)
ds, err := newPostgresDatastore(ctx, url, -1 /* is primary */, options...)
Copy link
Contributor

Choose a reason for hiding this comment

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

magic number, use constant

func NewReadOnlyPostgresDatastore(
ctx context.Context,
url string,
index uint32,
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this is how typically folks deploy MySQL read replicas. There is usually a URI for the primary, and a URI for replicas behind a load balancer. Why did you choose to enumerate the replicas?

Copy link
Member Author

Choose a reason for hiding this comment

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

Because there may be cases where there are multiple replicas

Comment on lines +19 to +22
if len(replicas) == 0 {
log.Debug().Msg("No replicas provided, using primary as read source.")
return primary, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

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

no test exercising this

return
}
}
finalError = err
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
finalError = err
finalError = err

rr.chosenReader = rr.replica.SnapshotReader(rr.rev)
rr.chosePrimary = false
})
return finalError
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
return finalError
return finalError

return rr.chosenReader.LookupNamespacesWithNames(ctx, nsNames)
}

func (rr *replicatedReader) chooseSource(ctx context.Context) error {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think returning the chosen reader in chooseSource would better encapsulate this logic.

var finalError error
rr.choose.Do(func() {
// If the revision is not known to the replica, use the primary instead.
if err := rr.replica.CheckRevision(ctx, rr.rev); err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not entirely certain whether this works with replicas behind a load-balancer.

If the user inputs a load balancer as the single replica URI, it will only check the revision once. However, it cannot guarantee that it will hit the same replica every time, as the connection pool would have opened connections against multiple replicas.

Even if you called CheckRevision each time it still cannot be guaranteed since checking the revision and then performing the other datastore API call is not using the same transaction. This worked when we were hitting the primary and doing our own MVCC, but does not when introducing load-balanced replicas.

Copy link
Member Author

Choose a reason for hiding this comment

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

It should hold to a single connection and therefore be safe; the only other approach would be to add the revision check into every transaction, and that would likely make things too slow.

@josephschorr
Copy link
Member Author

I don't think the implementation will work for that more common use case because the datastore snapshot reader does not use a single transaction.

It uses a single connection, which means it should stay connected to the same replica

options ...Option,
) (datastore.Datastore, error) {
isPrimary := replicaIndex < 0
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this flag makes the rest of the function harder to follow. Maybe something like:

newBasePostgresDatastore()

newPostgresDatastore(index) {
   ds := newBasePostgresDatastore()
   if index < 0 {
     return makeIntoRWDS(ds)
   }
   return makeIntoRODS(ds)
}

or

newBasePostgresDatastore()

newReadWriteDatastore(base) {}

newReadReplicaDatastore(base) {}

@ecordell
Copy link
Contributor

ecordell commented May 2, 2024

It uses a single connection, which means it should stay connected to the same replica

I possibly just missed it, but it looked like you were using a pgxpool.Conn for the read replicas which cycles actual connections out from under itself.

@josephschorr
Copy link
Member Author

It uses a single connection, which means it should stay connected to the same replica

I possibly just missed it, but it looked like you were using a pgxpool.Conn for the read replicas which cycles actual connections out from under itself.

Yeah, I traced it and it does use the pool. We'll have to do something else

@josephschorr josephschorr marked this pull request as draft May 2, 2024 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area/CLI Affects the command line area/datastore Affects the storage system area/tooling Affects the dev or user toolchain (e.g. tests, ci, build tools)
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants