Skip to content

Conversation

@rkrage
Copy link
Contributor

@rkrage rkrage commented Nov 27, 2024

Closes #63

Leaving the below context, but just a note that most of that refactoring was actually done in: #123

Okay, so this is a massive one, but the diff is so large because I used this as an opportunity to reorganize the tests and moved all of the unsafe_* methods into unsafe_statements.rb. It became incredibly difficult to locate and modify tests to add locking expectations (and surprise some of those methods didn't even have tests), so I attempted to organize the tests into four categories:

  • raw methods that are simply delegated to the underlying Rails method via delegate_raw_method_to_migration_base_class
  • unsafe methods that are simply delegated to underlying Rails method via delegate_unsafe_method_to_migration_base_class_with_lock or delegate_unsafe_method_to_migration_base_class
  • unsafe methods that we define ourselves
  • safe methods that we define ourselves

This additionally removes support for Rails 7.0 (in a separate commit) which gets rid of a fifth type of method:

  • raw methods that we define ourselves

@rkrage rkrage force-pushed the locking-in-unsafe-methods branch from e1b5dc6 to ec7ea92 Compare March 6, 2025 19:36
@rkrage rkrage force-pushed the locking-in-unsafe-methods branch 2 times, most recently from 67f5753 to a044613 Compare March 20, 2025 14:21
@rkrage rkrage force-pushed the locking-in-unsafe-methods branch from 6561609 to 4709908 Compare April 9, 2025 15:09
# constraint name, we are converting name to string explicitly to solve that.
def raw_remove_check_constraint(table, name:, **options)
super(table, name: name.to_s, **options)
end
Copy link
Contributor Author

Choose a reason for hiding this comment

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

As I was looking to organize methods and their tests, I discovered this unicorn. Rails 7.0 is EOL, so my solution was to just remove this method and Rails 7.0 from our gemspec / build matrix

@rkrage rkrage force-pushed the locking-in-unsafe-methods branch from 4709908 to 9bbdc6d Compare April 9, 2025 15:33
@rkrage rkrage changed the title [WIP] Implement safe locking in unsafe_* methods when appropriate Implement safe locking in unsafe_* methods when appropriate Apr 9, 2025
@rkrage rkrage marked this pull request as ready for review April 9, 2025 15:42
@rkrage rkrage force-pushed the locking-in-unsafe-methods branch from 9bbdc6d to 6efefe0 Compare April 9, 2025 17:45
Copy link
Contributor

@jcoleman jcoleman left a comment

Choose a reason for hiding this comment

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

Do we want to have unsafe_statements_spec.rb now?

I left a few comments on the code, but the spec file diff is unreadable; I think the issue is that the removal of something deprecated/defaults is getting confused with the move of the unsafe methods testing. But that no tonly means that the things it thinks are changes aren’t necessarily changes, it means all of the moved stuff is just shown as all net new…Maybe the answer is a commit that moves the tests around in the file (like a pure refactor) and then a separate commit with actual changes?

# Direct dispatch to underlying Rails method as unsafe_<method_name> with dependent object check / no locking
#
# We don't do locking on execute because it's so generic.
# For drop_table and rename_table, it's implied that the table is unused.
Copy link
Contributor

Choose a reason for hiding this comment

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

This makes sense for drop_table, although I don't really see what blocks having safe locking on it -- it seems like it would work as expected out of the box just like the other methods, so why not have it?

Re: rename_table: that does not imply the table is unused, necessarily, given it's often used to compose swapping live tables, right?

Copy link
Contributor Author

@rkrage rkrage Apr 9, 2025

Choose a reason for hiding this comment

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

So both drop_table and rename_table are generally used together to swap live tables. I actually think if we added locking to these, a recent BT migration would not have worked...

The general operation is something like:

  1. acquire lock on old table (and implicitly open a transaction)
  2. create new table
  3. do some manipulation of the data to move stuff from old to new
  4. drop old
  5. rename new to old
  6. release lock (and commit transaction)

If we added locking to these methods, I think (5) would fail with a "nested lock detected" error.

However, if you're writing a migration that complex you might just use the raw_* methods to get around these limitations. So I guess I'm indifferent about adding locking to these methods. If something is actually still using a table when you go to drop / rename, the end result is still the same (it would just delay the catastrophe if it takes a bit of time to acquire the lock).

Copy link
Contributor

Choose a reason for hiding this comment

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

If we added locking to these methods, I think (5) would fail with a "nested lock detected" error.

It'd fail in development though. Since we support locking multiple tables with safely_acquire_lock_for_table I think it'd be easy to fix, right?

Basically my thought comes down to this: we want to be safe by default, and safely acquiring a lock on something that's safe to drop is safe, but failing to acquire that lock safely opens us to the possibility that a mistaken table drop also locks the system up.

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 think it'd be easy to fix, right?

Sure but then you would have to move the table creation outside the lock. Can't lock a table that doesn't exist!

But yeah, I see your point. I don't feel too strongly about all this so I'll go ahead and make this change.

A somewhat related question... would it add safety to have some kind of timeout / retry limit for lock acquisition? As it stands safely_acquire_lock_for_table just keeps trying indefinitely, correct?

Copy link
Contributor

Choose a reason for hiding this comment

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

A somewhat related question... would it add safety to have some kind of timeout / retry limit for lock acquisition? As it stands safely_acquire_lock_for_table just keeps trying indefinitely, correct?

Yeah, I think so. That sounds like a good candidate for an issue to discuss further.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jcoleman
Copy link
Contributor

I think the diff will be actually readable if rebased on #123

@rkrage rkrage force-pushed the locking-in-unsafe-methods branch from 9adbc2b to a490a8b Compare April 11, 2025 14:57
@rkrage rkrage force-pushed the locking-in-unsafe-methods branch 2 times, most recently from 7e3d6aa to 234de8c Compare April 11, 2025 15:41
@rkrage rkrage force-pushed the locking-in-unsafe-methods branch from 234de8c to fc6d5c5 Compare April 11, 2025 15:52
# Direct dispatch to underlying Rails method as unsafe_<method_name> with dependent object check / safe lock acquisition
delegate_unsafe_method_to_migration_base_class_with_lock :add_check_constraint
delegate_unsafe_method_to_migration_base_class_with_lock :add_column
delegate_unsafe_method_to_migration_base_class_with_lock :change_column
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems like we should treat this like we do change_table?

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 guess my thinking was that anything you're trying to do with change_table can be accomplished with a combination of other, more specific, unsafe methods. Do we have that same situation with change_column? Like if you wanted to change a column type would you be forced to use raw_change_column?

Copy link
Contributor

Choose a reason for hiding this comment

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

Like if you wanted to change a column type would you be forced to use raw_change_column?

Hmm, that's a good point. We probably should have a way to do those things discretely (and that would allow a safe version for types, also, since some are binary compatible).

But the non-existence of those now suggests we can punt that to an issue to track it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah I was thinking about that a bit too. I can open an issue and we can discuss further, but I'm assuming the list of binary compatible types needs to be whittled down to type changes that Rails can transparently handle. For example, json -> text appears to be binary compatible, but I imagine Rails might not like that

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, sounds good.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@rkrage rkrage force-pushed the locking-in-unsafe-methods branch from 016f0c6 to 4c6a23c Compare April 11, 2025 19:32
@rkrage rkrage merged commit fa78a33 into master Apr 15, 2025
48 checks passed
@rkrage rkrage deleted the locking-in-unsafe-methods branch April 15, 2025 19:23
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.

Enforce certain safety features even in unsafe_* by default

3 participants