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

Feature/nested transactions #27

Merged
merged 8 commits into from
Dec 14, 2016
Merged

Feature/nested transactions #27

merged 8 commits into from
Dec 14, 2016

Conversation

bcardiff
Copy link
Member

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.

db.transaction do |tx|
  tx.connection.query ....
  # raise exception to rollback exception will bubble   
  # or tx.rollback or tx.commit
  # or raise DB::Rollback exception will not bubble

  tx.transaction do |inner|
    inner.connection.query ...
    # same as tx for rollback
  end

  other_inner = tx.begin_transaction
  # use other_inner transaction without block
  other_inner.commit
end

Brian J. Cardiff added 3 commits December 13, 2016 16:47
* dsl, state checks
* define transaction sql commands in connection
@bcardiff
Copy link
Member Author

@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
Copy link
Contributor

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.

Copy link
Member Author

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
Copy link
Contributor

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

Copy link
Member Author

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
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 a bit confused about Error vs Exception here. Could you explain?

Copy link
Member Author

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
Copy link
Contributor

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.

Copy link
Member Author

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
Copy link
Contributor

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?

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

Copy link
Contributor

@spalladino spalladino Dec 14, 2016

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?

Copy link
Member Author

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?

Copy link
Member Author

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
Copy link
Contributor

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?

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 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
Copy link
Contributor

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"
Copy link
Contributor

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.

Copy link
Member Author

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

Copy link
Contributor

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

Copy link
Member Author

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,
Copy link
Contributor

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.

Copy link
Member Author

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

Copy link
Contributor

@spalladino spalladino Dec 14, 2016

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 :-)

Copy link
Member Author

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

Copy link
Member Author

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.

Copy link
Contributor

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.

Copy link
Member Author

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.

@spalladino
Copy link
Contributor

LGTM!

@bcardiff bcardiff merged commit 8a1824a into master Dec 14, 2016
@bcardiff bcardiff mentioned this pull request Dec 14, 2016
@bcardiff bcardiff deleted the feature/nested-transactions branch November 8, 2017 02:15
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.

2 participants