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

transactions multi-table MVP #688

Merged
merged 1 commit into from
Feb 13, 2024
Merged

Conversation

ckhsponge
Copy link
Contributor

This is a first attempt at dynamodb transactions that can span tables. Create, update, upsert and delete are supported. See README_transact.md for examples of the proposed API.

Tests have not yet been implemented.

@ckhsponge ckhsponge marked this pull request as draft October 12, 2023 06:30
Copy link
Member

@andrykonchin andrykonchin left a comment

Choose a reason for hiding this comment

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

I've left some comments. It isn't a call to action to make any change but is a way to get you more familiar with the project structure and its conventions.

lib/dynamoid/adapter_plugin/aws_sdk_v3/transact.rb Outdated Show resolved Hide resolved
lib/dynamoid/adapter_plugin/aws_sdk_v3/transact.rb Outdated Show resolved Hide resolved
lib/dynamoid/adapter_plugin/aws_sdk_v3/transact.rb Outdated Show resolved Hide resolved
@ckhsponge
Copy link
Contributor Author

@andrykonchin I made some updates to the transaction API and now there is no longer a mess of hashes being passed around. Your feedback would be appreciated. Some minor cleanup and tests are still needed.

Copy link
Member

@andrykonchin andrykonchin left a comment

Choose a reason for hiding this comment

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

Looks pretty good!

lib/dynamoid/adapter_plugin/aws_sdk_v3/transact.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction/executor.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction/executor.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction/executor.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction/create.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction/update.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction/update.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction/update.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction/update.rb Outdated Show resolved Hide resolved
table_name: model_class.table_name,
}
}
end
Copy link
Member

Choose a reason for hiding this comment

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

Class-level methods like create/update/upsert accepts keys and attributes as separate parameters:

 User.upsert('1', age: 26)

This way we can find the proper item in DynamoDB even when user updates sort key for instance (can he change a partition key? probably he can)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes! I changed update and delete to take an id which can be a hash_key or an [hash_key, range_key] like is used by find(). I don't think id is needed for create and upsert since those are both putting complete records. Should we include id anyways for those?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@andrykonchin I assumed that the partition key could be changed but that is not true. A record must be deleted and recreated if you wish to change the partition key. How would you feel about simplifying the APIs for update and upsert?

Instead of:
txn.update!(User, '1', {name: 'bob'} )
it would be:
txn.update!(User, {id: '1', name: 'bob'} )

When there's a range key:
txn.update!(User, ['1', 26], {name: 'bob'} )
would be:
txn.update!(User, {id: '1', age: 26, name: 'bob'} )

This makes the API similar to create which I like but perhaps it's too inconsistent with the other update and upserts.

Copy link
Member

@andrykonchin andrykonchin Dec 29, 2023

Choose a reason for hiding this comment

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

This makes the API similar to create which I like but perhaps it's too inconsistent with the other update and upserts.

It seems to me it will not work for updating a range key attribute. Its current value should be used to find an item and a new value to set.

Current approach for transaction methods is to be as close to the existing methods as possible. If there are reasons to change method signature - it makes sense to change both existing method and a transactional one (but it would be a breaking change so it would require a major release).

I don't think id is needed for create and upsert since those are both putting complete records. Should we include id anyways for those?

So create shouldn't accept id as a separate parameter, but upsert should.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No part of a primary key can be updated. The record must be deleted and re-created to change the key. If it's a composite key this includes the range key which cannot be changed.

I see your point about wanting to be consistent with the non-transaction methods however not allowing a separate key to be specified may be a better direction to go in. This will discourage people from trying to update primary keys among other things. I like the shorter method signature and that all the transaction method signatures are more similar as well.

Copy link
Member

Choose a reason for hiding this comment

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

No part of a primary key can be updated. The record must be deleted and re-created to change the key. If it's a composite key this includes the range key which cannot be changed.

Indeed, exception is raised:

One or more parameter values were invalid: Cannot update attribute age. This attribute is part
 of the key (Aws::DynamoDB::Errors::ValidationException)

Copy link

codecov bot commented Nov 29, 2023

Codecov Report

Attention: 6 lines in your changes are missing coverage. Please review.

Comparison is base (4520343) 90.32% compared to head (a5b7640) 90.89%.

Files Patch % Lines
lib/dynamoid/transaction_write/update_upsert.rb 90.62% 3 Missing ⚠️
lib/dynamoid/transaction_write/update.rb 88.23% 2 Missing ⚠️
lib/dynamoid/transaction_write/action.rb 98.46% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #688      +/-   ##
==========================================
+ Coverage   90.32%   90.89%   +0.56%     
==========================================
  Files          62       71       +9     
  Lines        3154     3414     +260     
==========================================
+ Hits         2849     3103     +254     
- Misses        305      311       +6     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@ckhsponge
Copy link
Contributor Author

@andrykonchin Another round of feedback would be appreciated. I believe all issues were covered although adding block support for updates is something that could still be done. This might be a cleaner place to add add() expressions.

I'd like to get to a v1 candidate at some point with unit tests included.

Copy link
Member

@andrykonchin andrykonchin left a comment

Choose a reason for hiding this comment

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

Looks good!

@@ -8,6 +8,7 @@
require_relative 'aws_sdk_v3/item_updater'
require_relative 'aws_sdk_v3/table'
require_relative 'aws_sdk_v3/until_past_table_status'
require_relative 'aws_sdk_v3/transact'
Copy link
Member

Choose a reason for hiding this comment

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

minor: Not the perfect name for a class.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Any suggestions? This class wraps TransactWriteItems and someday maybe TransactGetItems as named by AWS.

Copy link
Member

Choose a reason for hiding this comment

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

TBH I would move transact_write_items into the main plugin class. It's small and simple enough (what was a reason to move some methods into separate classes)

lib/dynamoid/transaction.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction/action.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction/delete.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction/delete.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction/update.rb Outdated Show resolved Hide resolved
lib/dynamoid/transaction/upsert.rb Outdated Show resolved Hide resolved
@ckhsponge
Copy link
Contributor Author

@andrykonchin Thanks for the feedback. There are now proper delete and destroys. Upsert is working like an upsert as well.

@andrykonchin
Copy link
Member

Looks good so we can move on and and add specs.

@ckhsponge ckhsponge changed the title transactions multi-table MVP WIP transactions multi-table MVP Dec 21, 2023
@ckhsponge ckhsponge marked this pull request as ready for review December 21, 2023 01:38
@ckhsponge
Copy link
Contributor Author

@andrykonchin There's now a collection of specs for the transaction writes. Can you run them on github or give me permission to run them?

What else is needed to get v1 released?

Thanks!

@andrykonchin
Copy link
Member

andrykonchin commented Dec 24, 2023

The new specs look good, but I would prefer to have more granular test cases that check only one aspect of a method behaviour (ideally only one check/assert per case)

@ckhsponge
Copy link
Contributor Author

The new specs look good, but I would prefer to have more granular test cases that check only one aspect of a method behaviour (ideally only one check/assert per case)

Yeah, I usually put 4 or 5 syntax checks in each case. I'll split those out.

spec/dynamoid/transaction_write_spec.rb Outdated Show resolved Hide resolved
spec/dynamoid/transaction_write_spec.rb Outdated Show resolved Hide resolved
spec/dynamoid/transaction_write_spec.rb Outdated Show resolved Hide resolved
spec/dynamoid/transaction_write_spec.rb Outdated Show resolved Hide resolved
expect(obj1_found.name).to eql("one")
expect(obj2_found.name).to eql("two")
expect(obj3_found.name).to eql("three")
end
Copy link
Member

Choose a reason for hiding this comment

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

It makes sense to test transaction rollback - non of changes should be persisted.

Copy link
Member

Choose a reason for hiding this comment

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

Also makes sense to check a case when some model isn't valid - it shouldn't rollback transaction.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Validation tests now check there is no rollback with non-bang saves and that there is a rollback with bang! saves.

Copy link
Member

@andrykonchin andrykonchin Jan 21, 2024

Choose a reason for hiding this comment

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

Yeah, I see. So there are still cases to check that a transaction is rolled back when some of operations fails on DynamoDB side, e.g. deleting/updating not existing item, creation of item which primary key already exists.

So it makes sense to add a case for each transactional method (create/update/delete etc) to ensure that transaction is atomic.

spec/dynamoid/transaction_write_spec.rb Outdated Show resolved Hide resolved
spec/dynamoid/transaction_write_spec.rb Outdated Show resolved Hide resolved
spec/dynamoid/transaction_write_spec.rb Outdated Show resolved Hide resolved
spec/dynamoid/transaction_write_spec.rb Outdated Show resolved Hide resolved
spec/dynamoid/transaction_write_spec.rb Outdated Show resolved Hide resolved
@ckhsponge
Copy link
Contributor Author

@andrykonchin Thanks for the specs review. What are your thoughts on this one?
#688 (comment)

@andrykonchin
Copy link
Member

A failing spec is fixed on master.

@ckhsponge
Copy link
Contributor Author

@andrykonchin update() and upsert() no longer allow a key to be specified separate from the attributes. A key must always be included in the model or in the attributes. This simplifies the method signatures and makes them consistent with the other transaction actions.

I believe I covered all your feedback on the specs so another round of review would be good. Thanks!!!

expect(obj1_found.name).to eql("oneone")
expect(obj2_found.name).to eql("two")
end
end
Copy link
Member

@andrykonchin andrykonchin Jan 21, 2024

Choose a reason for hiding this comment

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

I believe validation should be tested here as well

spec/dynamoid/transaction_write/upsert_spec.rb Outdated Show resolved Hide resolved
expect(obj1_found.name).to eql("one")
expect(obj2_found.name).to eql("two")
expect(obj3_found.name).to eql("three")
end
Copy link
Member

@andrykonchin andrykonchin Jan 21, 2024

Choose a reason for hiding this comment

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

Yeah, I see. So there are still cases to check that a transaction is rolled back when some of operations fails on DynamoDB side, e.g. deleting/updating not existing item, creation of item which primary key already exists.

So it makes sense to add a case for each transactional method (create/update/delete etc) to ensure that transaction is atomic.

Copy link
Member

@andrykonchin andrykonchin left a comment

Choose a reason for hiding this comment

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

Specs look much better. Thank you!

spec/dynamoid/transaction_write/create_spec.rb Outdated Show resolved Hide resolved
spec/dynamoid/transaction_write/upsert_spec.rb Outdated Show resolved Hide resolved
spec/dynamoid/transaction_write/upsert_spec.rb Outdated Show resolved Hide resolved
include_context 'transaction_write'

# a 'put' is a create that overwrites existing records if present
context 'puts' do
Copy link
Member

Choose a reason for hiding this comment

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

The create method doesn't overwrite existing document with the same primary key and raises Dynamoid::Errors::RecordNotUnique instead.

https://github.com/Dynamoid/dynamoid/blob/master/lib/dynamoid/persistence/save.rb#L50

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm allowing create() to behave like a "put" when skip_existence_check: true is used. This does an overwrite if a previous record exists. The relatively short put_spec tests this. Maybe renaming to allow_overwrite: true would be better? Or add a put()?

Currently in a transaction when trying to create a record when it already exists raises Aws::DynamoDB::Errors::TransactionCanceledException.

spec/dynamoid/transaction_write/context.rb Show resolved Hide resolved
spec/dynamoid/transaction_write/save_spec.rb Show resolved Hide resolved
expect(klass_with_validation.exists?(obj1.id)).to be_falsey
expect(obj2.id).to be_present
expect(klass_with_validation.exists?(obj2.id)).to be_falsey
end
Copy link
Member

Choose a reason for hiding this comment

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

I would check rolling back of the whole transaction in a separate test case with corresponding title. Here and in specs for other methods.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added explicit rollback specs.

obj1 = klass.create!(name: "one")
obj1.name = "oneone"
Dynamoid::TransactionWrite.execute do |txn|
txn.update! obj1
Copy link
Member

Choose a reason for hiding this comment

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

update with instance has different signature:

# user.update(unless_exists: [:age]) do |t|
# t.set(age: 18)
# end

It's OK to skip this method for now.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

OK, skipping support for blocks in update() for now. I would like to support increment eventually.

spec/dynamoid/transaction_write/update_spec.rb Outdated Show resolved Hide resolved
spec/dynamoid/transaction_write/upsert_spec.rb Outdated Show resolved Hide resolved
@ckhsponge
Copy link
Contributor Author

@andrykonchin More tests were added for rollbacks and callbacks. Instance usage tests for upsert were removed.

@ckhsponge
Copy link
Contributor Author

I removed the named parameter for options. This simplifies the brackets for expected usage e.g.:

save!( instance, skip_callbacks: true)
create!( klass, name: 'hi')
create!( klass, {name: 'hi'}, {skip_callbacks: true})

instead of:

save!( instance, options: {skip_callbacks: true})
create!( klass, {name: 'hi'})
create!( klass, {name: 'hi'}, options: {skip_callbacks: true})

@ckhsponge
Copy link
Contributor Author

@andrykonchin I believe all feedback has been addressed and further review would be appreciated.

@ckhsponge
Copy link
Contributor Author

@andrykonchin Anything blocking this? Thanks.

@andrykonchin
Copy link
Member

Could you please fix Rubocop issues (related to the changes only - there are some existing issues on master - I will address them myself) and squash all the working commits into one or more atomic ones?

@andrykonchin
Copy link
Member

I want to release a new version without transactions now and release transactions in the next version. The last release was a year ago and I was already asked for new version.

@ckhsponge
Copy link
Contributor Author

@andrykonchin I squashed the commits and I believe Rubocops will pass. Anything else I need to do? There are Codeclimate complexity issues.

@andrykonchin andrykonchin merged commit 6b96d1d into Dynamoid:master Feb 13, 2024
58 of 60 checks passed
@andrykonchin
Copy link
Member

Merged! 🎉 Great work!

@ckhsponge
Copy link
Contributor Author

That's great news! Thanks for the support.

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