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

promoting changes to add support for read only db instance #429

Open
wants to merge 3 commits into
base: v0.25
Choose a base branch
from

Conversation

aankam-infoblox
Copy link

This is to support read only database instance for performing read operations on the database. Once these changes are approved and merged then all the modules can use this functionality to implement read only database support to perform database read operations in their project.

Copy link
Contributor

@davidhankins davidhankins left a comment

Choose a reason for hiding this comment

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

Yeah, I agree with the semantic changes here although they appear odd.

To try and explain my reasoning;

I think the Transaction struct is ~poorly named - it can produce one transaction from a parent database in the current ctx - and deliver that same transaction to all callers in that ctx's call graph. This is more of a "context database state" structure than specifically a transaction. To wit, we use BeginFromContext(ctx) regularly in read-only functions deeply nested in the call graph of API calls to avoid "plumbing" (passing the transaction reference as an argument down a long call chain).

So it's fine that a caller asking for a transaction is instead given a read-only-database reference because that's actually what they're after ("the database reference for this api request, read or write").

Probably on the master branch we might want to think about renaming or providing a new interface to the database-context that tries to clear up some of the confusing terminology.

Just some minor nits on my part - let's get some unit tests drafted and see about merging this.

@@ -39,13 +43,33 @@ func FromContext(ctx context.Context) (txn *Transaction, ok bool) {
return
}

// GetReadOnlyDB returns the read only db instance stored in the ctx if there is no txn in use with read/write database
func GetReadOnlyDB(ctx context.Context) (*gorm.DB, error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's add unit tests for this new function, see transaction_test.go

txn, ok := FromContext(ctx)
if !ok {
return nil, ErrCtxTxnMissing
}
if txn.readOnly == true {
Copy link
Contributor

Choose a reason for hiding this comment

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

We'll need to update the BeginFromContext tests for this change. The existing tests do not check that a transaction from the read-write parent is returned in the current cases, and we'll have to add a test to confirm the read-only database reference is returned on a read-only context.

@@ -220,6 +258,12 @@ func UnaryServerInterceptorTxn(txn *Transaction) grpc.UnaryServerInterceptor {
}()

ctx = NewContext(ctx, txn)
if len(readOnlyDB) > 0 {
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's add a check for this to TestUnaryServerInterceptorTxn_success

@@ -57,15 +81,29 @@ func (t *Transaction) AddAfterCommitHook(hooks ...func(context.Context)) {
t.afterCommitHook = append(t.afterCommitHook, hooks...)
}

// BeginFromContext will extract transaction wrapper from context and start new transaction.
// GetReadOnlyDBInstance returns the read only database instance stored in the ctx
func GetReadOnlyDBInstance(ctx context.Context) (*gorm.DB, error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's not export this unless there's a reason to? getReadOnlyDBInstance

With GetReadOnlyDB and BeginFromContext tested, I don't think there's much value in specifically testing this function, so I don't think a unit test for it alone is required.

@akleinib
Copy link

It would be good to document here how this is supposed to be used. Do we need to add something like the following everywhere to fall back on R/W replica?

txn, err := GetReadOnlyDB(ctx)

if errors.Is(err, ErrNoReadOnlyDB) {
   txn, err = BeginFromContext(ctx)
}

I believe it would be much better to implement this using the standard "option" pattern, e.g.:

func BeginFromContext(ctx context.Context, OPTIONS...) (*gorm.DB, error) { }

OPTIONS can be:

  • If none, current behavior,
  • Support of https://pkg.go.dev/database/sql#TxOptions,
  • From read-only replica, if it doesn't exist fall back to read/write,
  • From read-only replica, if it doesn't exist return an error,
  • ...

This way we can support all the current and future use cases rather keep adding functions with new prototypes.

gorm/transaction.go Show resolved Hide resolved
return nil, ErrCtxTxnMissing
}
if txn.current != nil {
logger.Warnf("GetReadOnlyDB: Txn already initialized with read/write DB")

Choose a reason for hiding this comment

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

  1. Maybe the caller would like to know that it asked for a read-only transaction and got a read-write one instead.
  2. Extracting the logger from the context assumes this code is used with the gRPC gateway which might not be the case.

return dbRO, nil
}

// BeginFromContext will extract transaction wrapper from context and start new transaction if transaction is not set to read only otherwise it will return read only database instance

Choose a reason for hiding this comment

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

Not using an actual transaction for read-only operations introduces two problems:

  1. No support for isolation level,
  2. Performance (see Statements are executed more quickly in a transaction block, because transaction start/commit requires significant CPU and disk activity. in https://www.postgresql.org/docs/15/sql-begin.html).

Choose a reason for hiding this comment

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

This point is not addressed. The code still returns the "database" and not a transaction.

@@ -18,14 +19,24 @@ import (
// This prevents collisions with keys defined in other packages.
type ctxKey int

// readOnlyDBKey is an unexported type and used to define a key for storing read only db instance in the context.

Choose a reason for hiding this comment

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

Rather than storing the read-only database in the context, cannot we store it in the Transaction structure like the existing database? A nil value for this variable indicates no read-only database.


type DatabaseOption func(*databaseOptions)

// WithRODB returns clouser to set the readOnlyReplica flag

Choose a reason for hiding this comment

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

Typo clouser.

Comment on lines +133 to +136
} else if txn.readOnly == true {
logger.Warnf("BeginFromContext: requested: read-write DB, returns: read-only DB, reason: txn set to read only")
return getReadOnlyDBInstance(ctx)
}

Choose a reason for hiding this comment

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

Returning a R/W transaction instead of R-O transaction is ok since all the read-only operations will be executed by the R/W transaction. Doing the inverse doesn't work. This should return an error.

txn, ok := FromContext(ctx)
if !ok {
return nil, ErrCtxTxnMissing
}
opts := toDatabaseOptions(options...)

Choose a reason for hiding this comment

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

I would keep the check of txn.parent before adding all this new code.

if txn.current == nil {
return getReadOnlyDBInstance(ctx)
} else {
logger.Warnf("BeginFromContext: requested: read-only DB, returns: read-write DB, reason: read-write DB txn in use")

Choose a reason for hiding this comment

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

It might be better to provide a WithLogger() function people can use to provide their logger instance. This way all these messages are logged with the same logger as the rest of the application. If no logger is provided, we can use a global one.

Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if this should be at the UnaryServerInterceptor level rather than the Transaction level.

type DatabaseOption func(*databaseOptions)

// WithRODB returns clouser to set the readOnlyReplica flag
func WithRODB(readOnlyReplica bool) DatabaseOption {

Choose a reason for hiding this comment

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

We should add a "with function" for sql.TxOptions since we have existing use-cases.

return dbRO, nil
}

// BeginFromContext will extract transaction wrapper from context and start new transaction if transaction is not set to read only otherwise it will return read only database instance

Choose a reason for hiding this comment

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

This point is not addressed. The code still returns the "database" and not a transaction.

Comment on lines +478 to +480
if err != nil {
t.Error("Received an error beginning transaction")
}

Choose a reason for hiding this comment

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

err should be checked before anything else returned by a function.

if txn2 != txn1 {
t.Error("Got a different txn than was opened before")
}
if err != nil {

Choose a reason for hiding this comment

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

As above.

}

func UnaryServerInterceptorTxn(txn *Transaction) grpc.UnaryServerInterceptor {
func UnaryServerInterceptorTxn(txn *Transaction, readOnlyDB ...*gorm.DB) grpc.UnaryServerInterceptor {
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we should plan for the future here and use a similar WithReadOnlyDB(roDB) interface here, so we can e.g. WithLogger() as well.

if txn.current == nil {
return getReadOnlyDBInstance(ctx)
} else {
logger.Warnf("BeginFromContext: requested: read-only DB, returns: read-write DB, reason: read-write DB txn in use")
Copy link
Contributor

Choose a reason for hiding this comment

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

I wonder if this should be at the UnaryServerInterceptor level rather than the Transaction level.

return txn.current, nil
}
} else if txn.readOnly == true {
logger.Warnf("BeginFromContext: requested: read-write DB, returns: read-only DB, reason: txn set to read only")
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 is normal now, and the log line can be removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants