-
-
Notifications
You must be signed in to change notification settings - Fork 54
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
Feature/nested transactions #27
Conversation
* dsl, state checks * define transaction sql commands in connection
@will after merging will/crystal-pg#66 this will work also in crystal-pg and I will PR the specs of https://github.com/bcardiff/crystal-pg/tree/feature/nested-transactions like in mysql and sqlite. Your input is welcome for sure. |
end | ||
db.pool.is_available?(cnn).should be_true | ||
end | ||
end |
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'd add another spec here: ensure that a connection is not returned to the pool if a nested transaction is commited/rolledback, but there is still an outer active 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.
done.
# a design flaw. Transaction might been commited already. | ||
# Maybe we should wrap e in another exception that clarifies | ||
# this scenario. | ||
tx.rollback |
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.
+1 to adding unless tx.closed?
If we're at this point, there has already been an exception thrown from the user's code, which will bubble and be catched by the client code. If the tx is already closed, it means that he manually commited or rolled it back, so it makes no sense to me to try and do that again, triggering an additional exception. The design flaw is exposed by the original exception, there's no need for another one.
@asterite maybe you want to break the tie? :-P
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.
done. unless tx.closed?
added
@@ -17,4 +17,7 @@ module DB | |||
def initialize(@connection) | |||
end | |||
end | |||
|
|||
class Rollback < Exception | |||
end |
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'm a bit confused about Error vs Exception here. Could you explain?
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.
DB::Error are used when the origin of the exception was an interaction with the database.
Rollback is initiated by the user.
protected def create_transaction : Transaction | ||
TopLevelTransaction.new(self) | ||
end | ||
|
||
protected def do_close | ||
@statements_cache.each_value &.close | ||
@statements_cache.clear | ||
@database.pool.delete self | ||
end |
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.
Is it safe to close a connection without rolling back its transactions? I assume it is, but just checking.
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.
Connection can always go down, so yes, they are not committed by deafault.
|
||
# :nodoc: | ||
getter database | ||
@statements_cache = StringKeyCache(Statement).new | ||
@transaction = false |
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.
Why not store the TopLevelTransaction
object itself here, so we can keep a reference to it from the Connection?
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 was simpler to just store the bool.
We can have a TopLevelTransaction
or WeakRef(TopLevelTransaction)
but if doing that we should deal with explicit management of what will happen when the connection or parent transaction is closed IMO.
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 deal with explicit management of what will happen when the connection or parent transaction is closed
Well, we could, though don't need to do that now. What kind of things were you considering that would need to be managed, and are not being managed now?
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.
If the public api is used I can't think of anything that could go wrong. Right now we don't have errors regarding trying to close a connection explicitly when there is a ongoing transaction.
On nested transaction it is more evident maybe. Commit/Rollback a parent with nested, the user will still have a reference to the inner transaction. Should that transaction be marked as closed in crystal side since it should be closed in the 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.
Well, tracking the object force either a refactor of how the BEGIN/CREATE SAVEPOINT is done OR a :nodoc: setter is needed. Because we need to flag the connection as in-transaction before the sql command is sent.
I think I would prefer to keep bools for now, the required change is not that nice. Either way it should not impact to drivers implementors since they don't need to inherit Transaction class, or if done, they should call base constructor.
|
||
private def close! | ||
raise DB::Error.new("Transaction already closed") if closed? | ||
close |
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 there is an abstract def close
missing here? Or is that covered by Disposable?
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 is covered in Disposable
which is defined also in DB ;-)
|
||
def begin_transaction : Transaction | ||
raise DB::Error.new("There is an existing nested transaction in this transaction") if @nested_transaction | ||
@nested_transaction = 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.
Same as with @transaction
at Connection
previous_savepoint.succ | ||
else | ||
# random prefix to avoid determinism | ||
"crystal_#{Random.rand(10_000)}_00001" |
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'd increase 10_000 a bit more. Just in case.
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.
1_000_000
?
the usage of the prefix is also to when dealing with a pool, so not all savepoints of all connections are named equally.
When the same connection is returned to the pool it should have no longer outstanding transactions. dsl and the design enforce that when using the public api. So the risk is about dirty state between checkouts in the same connection. For long living connections the numbers will be reused, but again, between different connections the names can be the same without problems to the db.
We can prefix with the connection.object_id
also...
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.
The object_id
approach sounds good to me
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.
done.
class SavePointTransaction < Transaction | ||
getter connection : Connection | ||
|
||
def initialize(@parent : 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.
-1 to argument wrap. But I'm just being picky.
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 could go expose top_level_transaction in Transaction to avoid the wrap. That will allow us to remove SavePointTransaction#create_save_point_transaction
but the SavePoint needs:
- parent to release/unlock
- top_level to increase new savepoint name
- connection to exec
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 was just talking about coding style here, that is, keep both args to init in the same line :-)
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.
done.
end | ||
|
||
private def close! | ||
raise DB::Error.new("Transaction already closed") if closed? |
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'm wondering if we maybe should let just the DB fail in this case, rather than try to play smarter and throw the exception ourselves. However, I can't think of any scenario where we think the tx is closed?
but it actually isn't.
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 is safe to track this state and offer better errors in this scenario.
Also it is not clear that the driver can query the status of the transaction, so I will keep this design.
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 wasn't thinking about querying the status, but rather simply issuing the close
statement to the DB, and catch the "already closed" exception. However, either option is ok for me.
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.
Well, I prefer caution here. If a TopTransaction ref somehow leak at hold a reference to an open connection it could commit/rollback a not intended transaction. It should not happen, unless bugs, but let's keep this.
LGTM! |
Add support for transactions using BEGIN/ROLLBACK/END statements by default.
Add support to mimic nested transactions using SAVEPOINTS.
Transactions statements are executed using non prepared statements (overridable if needed per driver).
Some checks ensure there are no overlapping transactions per connection or per parent transaction.