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

Merged

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.

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

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.

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.

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.

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.

}

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.

Comment on lines 115 to 117
txn.txOpts = &sql.TxOptions{}
txn.txOpts.ReadOnly = opts.txOpts.ReadOnly
txn.txOpts.Isolation = opts.txOpts.Isolation
Copy link

Choose a reason for hiding this comment

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

Cannot we use the following instead?

txn.txOpts = &sql.TxOptions{*opts.txOpts}

Copy link
Contributor

Choose a reason for hiding this comment

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

(see above comment I think we might want to just store opts directly rather than have two fields)

}
mock.ExpectBegin()
mock.ExpectCommit()
dbROMock.ExpectBegin()
Copy link

Choose a reason for hiding this comment

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

  1. We don't expect a read-only transaction to be started.
  2. ExpectationsWereMet() isn't called on dbROMock unlike mock.

Comment on lines 107 to 109
mock.ExpectBegin()
mock.ExpectCommit()
dbROMock.ExpectBegin()
Copy link

Choose a reason for hiding this comment

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

Don't we expect no R/W transaction being used and a R-O transaction being committed?

current *gorm.DB
txOpts *sql.TxOptions
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider using e.g. currentOpts *databaseOptions rather than separately txOpts and currentDB etc.

I think you could more directly compare "current and prior options" that way; just keep the options value from when the transaction was started and compare options to options.

afterCommitHook []func(context.Context)
}

type databaseOptions struct {
readOnlyReplica bool
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider using dbType here (so you can have notset / readonly / readwrite as a trinary instead of boolean value).

// getReadOnlyDBInstance returns the read only db txn if RO DB available otherwise it returns read/write db txn
func getReadOnlyDBTxn(ctx context.Context, opts *databaseOptions, txn *Transaction) (*gorm.DB, error) {
var db *gorm.DB
if txn.parentRO == nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

Try to restructure the if / else / if / if / else / ... chain here as a switch {.

You might have to have nested levels of switch { or some if conditions inside cases, but I think it would read better e.g.

switch {
  case txn.parentRO == nil:
    return ...
  case opts.txOpts != nil:
    ... handle case where txn.txOpts is or isn't nil (nested if)
  case txn.txOpts != nil:
    ... at this point you know opts.txOpts is nil.
  default:
    ...
}

Comment on lines 115 to 117
txn.txOpts = &sql.TxOptions{}
txn.txOpts.ReadOnly = opts.txOpts.ReadOnly
txn.txOpts.Isolation = opts.txOpts.Isolation
Copy link
Contributor

Choose a reason for hiding this comment

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

(see above comment I think we might want to just store opts directly rather than have two fields)

Comment on lines 115 to 126
default:
// We should error in two cases 1. We should error if read-only DB requested with read-write txn
// 2. If no txn options provided in previous call but provided in subsequent call
if opts.txOpts != nil {
if opts.txOpts.ReadOnly == false || txn.currentOpts.database != dbNotSet {
return nil, ErrCtxTxnOptMismatch
}
txnOpts := *opts.txOpts
txn.currentOpts.txOpts = &txnOpts
}
}
if txn.current != nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

you can collapse these two and dedent a bit as well;

case opts.txOpts != nil:
    if opts.txOpts.ReadOnly == false ...
case txn.current != nil:
    return txn.current, nil

i quite like this layout, thanks for updating. ^_^

Copy link
Contributor

Choose a reason for hiding this comment

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

update: you can't move the txn.current != nil test into the switch, sorry about that.

case txn.parentRO == nil:
return getReadWriteDBTxn(ctx, opts, txn)
case opts.txOpts != nil && txn.currentOpts.txOpts != nil:
if opts.txOpts.ReadOnly != txn.currentOpts.txOpts.ReadOnly || opts.txOpts.Isolation != txn.currentOpts.txOpts.Isolation {
Copy link
Contributor

Choose a reason for hiding this comment

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

can we simplify to *opts.txOpts != *txn.currentOpts.txOpts?

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.

LGTM, but we'll wait and see if Arnaud is available to confirm his questions are addressed.

t.Error("Did not receive a transaction from context")
}
// Case: Transaction begin is idempotent
if txn1 != txn4 {
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 we previous checked that txn1 != nil so you could omit the txn4 == nil case if you wanted to, but it works either way and you might want the more precise error message for the nil case, so not blocking.

@davidhankins davidhankins merged commit d104685 into infobloxopen:v0.25 Jan 8, 2025
2 checks passed
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