Introduce duplication constraint on StripeRecord table #13193
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
This is an attempted fix at an (apparent) race condition in our
StripeRecordstable.We have observed that two
StripeRecordentities exist with the same exactstripe_idandstripe_record_type. This normally should not happen, because the two aforementioned properties are exactly the two key elements inStripeRecord.create_or_update_from_api. This method internally usesfind_or_initialize_by, though, and since thecreated_attimestamps of the two duplicate rows are very close together, we suspect that the following happened:find_or_initialize_byand caused a race condition [footnote 1]StripeRecord, which (per our business logic) correspond to the same refund logically, but have two different primary key IDs technically.original_payment.refunding_registration_payments.where(receipt: stored_record).any?and happily concludes that the refund was not recorded yet because of the different primary key IDs [footnote 2]The suggested solution is to be more strict by introducing a uniqueness constraint on database level. This is the most certain, fool-proof approach to make sure that within the constraints of Stripe's business specific logic, duplicates never happen ("business-specific" means: PayPal may have completely different ways of identifying the uniqueness of a payment). The new
create_or_find_byapproach induces one more database write than before (once for creating, then immediately updating), but this is worth the reduced risk.The uniqueness constraint is handled on a low level directly by Rails. From the documentation of
create_or_find_by:Specifically, the internal Rails AR code catches
rescue ActiveRecord::RecordNotUnique, which is thrown by the database driver iff the uniqueness constraint is violated. Ironically enough, we do not want model-leveluniquenessvalidations in this case, because they would raise exceptions that we (as the application) would then need to catch/handle. (Also, it is unclear how to define this constraint: Should onestripe_idbe unique perstripe_record_type? Or should onestripe_record_typebe unique perstripe_id? These choices seem too arbitrary).Footnotes
1 -- The race condition happens because
find_or_initialize_bycreates new records in an unpersisted state if they are not found. Only the subsequentupdate!call actually writes them. This means that another thread from another request might have just barely enough time to trigger anotherfind_or_initialize_bywhile the original thread is waiting for theudpate!to actually write.2 -- Why not check for
stripe_idand/orstripe_record_typedirectly in the "has this refund already been issued" check? Because we (want to) support multiple, diverse payment systems. The relationrefund_receiptis intentionally polymorphic, and we don't know whether PayPal or WeChat Pay or IndiaPaymentGateway123 or YourCoolPaymentSolution identifies uniqueness of payments / payment identifiers in the same way that Stripe does. So we don't want to be Stripe-specific at this point in time. andStripeRecordshould be the one taking care of uniqueness in itself (see above)