-
Notifications
You must be signed in to change notification settings - Fork 118
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
base: v0.25
Are you sure you want to change the base?
promoting changes to add support for read only db instance #429
Conversation
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.
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.
gorm/transaction.go
Outdated
@@ -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) { |
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.
Let's add unit tests for this new function, see transaction_test.go
gorm/transaction.go
Outdated
txn, ok := FromContext(ctx) | ||
if !ok { | ||
return nil, ErrCtxTxnMissing | ||
} | ||
if txn.readOnly == true { |
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.
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 { |
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.
Let's add a check for this to TestUnaryServerInterceptorTxn_success
gorm/transaction.go
Outdated
@@ -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) { |
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.
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.
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) { }
This way we can support all the current and future use cases rather keep adding functions with new prototypes. |
gorm/transaction.go
Outdated
return nil, ErrCtxTxnMissing | ||
} | ||
if txn.current != nil { | ||
logger.Warnf("GetReadOnlyDB: Txn already initialized with read/write DB") |
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.
- Maybe the caller would like to know that it asked for a read-only transaction and got a read-write one instead.
- Extracting the logger from the context assumes this code is used with the gRPC gateway which might not be the case.
gorm/transaction.go
Outdated
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 |
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.
Not using an actual transaction for read-only operations introduces two problems:
- No support for isolation level,
- 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).
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.
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. |
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.
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 |
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.
Typo clouser
.
} 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) | ||
} |
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.
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...) |
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 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") |
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.
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.
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 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 { |
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.
We should add a "with function" for sql.TxOptions
since we have existing use-cases.
gorm/transaction.go
Outdated
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 |
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.
This point is not addressed. The code still returns the "database" and not a transaction.
if err != nil { | ||
t.Error("Received an error beginning transaction") | ||
} |
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.
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 { |
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.
As above.
} | ||
|
||
func UnaryServerInterceptorTxn(txn *Transaction) grpc.UnaryServerInterceptor { | ||
func UnaryServerInterceptorTxn(txn *Transaction, readOnlyDB ...*gorm.DB) grpc.UnaryServerInterceptor { |
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.
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") |
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 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") |
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 think this is normal now, and the log line can be removed.
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.