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

#213 Postgres: Add support for transaction-scoped advisory locks with external transactions #222

Conversation

Tzachi009
Copy link
Contributor

Add support for for transaction-scoped advisory locks with external transactions.

Related Issue: #213

Invariant.Require(!UseTransactionScopedLock(connection));
// For transaction scoped advisory locks, the lock can only be released by ending the transaction.
// If the transaction is internally-owned, then the lock will be released when the transaction is disposed as part of the internal connection management.
// If the transaction is externally-owned, then the lock will have to be released explicitly by the transaction initiator.
Copy link
Owner

Choose a reason for hiding this comment

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

I'm not comfortable with these semantics; it's just too different from how the other locks work to say that releasing the handle does not release the lock. This feels like the kind of thing that will be hard to discover, since correct-looking code will just be wrong and I don't like having to add except with pg externally-owned-transaction-scoped-locks! to all the generic code examples.

The static utility method feels like a better model for what we're trying to do here, which is to apply a one-way change to a transaction without any notion of a returned disposable scope.

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, I can see why a static method in the API may be a better option in this case. I currently have some worries regarding how exactly it will be implemented, but I'll try.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi @madelson, I've finally managed to look into the static utility methods. I still didn't add summay comments for the static methods, and I need to revert the change in the ReleaseAsync method in the PostgresAdvisoryLock class, but please take a look at the recent changes and tell me if I am on the right track.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi @madelson, do you think you will have time to look into my changes soon?

Copy link
Owner

Choose a reason for hiding this comment

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

@Tzachi009 apologies for the long delay. The new static methods look like they're on the right track. I left a few comments.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi @madelson, thanks for the feedback.

  • I replied and fixed to your comments (with the exception of one, where what you asked is not possible).
  • I reverted my changes in the ReleaseAsync() method in the PostgresAdvisoryLock class.
  • Added summaries for the static utility methods.
  • Added some unit tests - I hope it's enough, considering I didn't added a lot of code here.


var handle = DistributedLockHelpers.TryAcquire(PostgresAdvisoryLock.ExclusiveLock, connection, key.ToString(), timeout, cancellationToken);

return handle != null;
Copy link
Owner

@madelson madelson Dec 3, 2024

Choose a reason for hiding this comment

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

We can use the SyncViaAsync class to unify the sync/async implementations here. E.g. I think this method should just be:

return SyncViaAsync.Run(
    state => TryAcquireWithTransactionAsync(state.key, state.transaction, state.timeout, state.cancellationToken),
    (key, transaction, timeout, cancellationToken);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, done

state => TryAcquireAsync(strategy, connection, resourceName, timeout, cancellationToken),
(strategy, connection, resourceName, timeout, cancellationToken)
);
#endregion
Copy link
Owner

Choose a reason for hiding this comment

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

Let's remove these helper methods. They're just calling strategy.TryAcquire(Async) which can be called directly by PostgresDistributedLock

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed


var connection = new PostgresDatabaseConnection(transaction);

return DistributedLockHelpers.AcquireAsync(PostgresAdvisoryLock.ExclusiveLock, connection, key.ToString(), timeout, cancellationToken).ConvertToVoid();
Copy link
Owner

Choose a reason for hiding this comment

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

I'd expect the implementation of this to be something like:

return TryAcquireWithTransactionAsync(...).ThrowTimeoutIfFalse(); // currently private in DistributedLockHelpers, can be made public

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 a call to ThrowTimeoutIfNull(), it was previsouly part of the helper method that I just removed


async ValueTask<bool> TryAcquireAsync()
{
var handle = await DistributedLockHelpers.TryAcquireAsync(PostgresAdvisoryLock.ExclusiveLock, connection, key.ToString(), timeout, cancellationToken);
Copy link
Owner

Choose a reason for hiding this comment

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

All awaits need ConfigureAwait(false)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, added


async ValueTask<bool> TryAcquireAsync()
{
var handle = await DistributedLockHelpers.TryAcquireAsync(PostgresAdvisoryLock.ExclusiveLock, connection, key.ToString(), timeout, cancellationToken);
Copy link
Owner

Choose a reason for hiding this comment

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

I think we want to call await handle.DisposeAsync().ConfigureAwait(false); here with a comment saying that for an externally-owned transaction the release is a noop but we want to dispose proactively to prevent the handle's managed finalizer (see ManagedFinalizerQueue) from running.

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 am afraid that's not possible because of how the code is written. I actually tried to avoid this exact scenario, since disposal of the handle would have reached the ReleaseAsync method in the PostgresAdvisoryLock class - where you didn't like the changes I did there. Instead, I am calling the PostgresAdvisoryLock class directly in order to acquire the lock and get an object cookie in return, which I ignore. Releasing the handle would have required to go through the DedicatedConnectionOrTransactionDbDistributedLock class.

if (key == null) { throw new ArgumentNullException(nameof(key)); }
if (transaction == null) { throw new ArgumentNullException(nameof(transaction)); }

var connection = new PostgresDatabaseConnection(transaction);
Copy link
Owner

Choose a reason for hiding this comment

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

I feel like we can dispose this object after the acquire operation (just move the creation inside the helper function and add an async using block).

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 agree, it shouldn't impact anything regarding the external connection, so added disposal

@Tzachi009 Tzachi009 marked this pull request as ready for review December 18, 2024 14:16
public static ValueTask AcquireWithTransactionAsync(PostgresAdvisoryLockKey key, IDbTransaction transaction, TimeSpan? timeout = null, CancellationToken cancellationToken = default) =>
AcquireWithTransactionAsyncInternal(key, transaction, timeout, cancellationToken);

internal static ValueTask<bool> TryAcquireWithTransactionAsyncInternal(PostgresAdvisoryLockKey key, IDbTransaction transaction, TimeSpan timeout = default, CancellationToken cancellationToken = default)
Copy link
Owner

Choose a reason for hiding this comment

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

This internal method shouldn't have default parameter values

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done


await using (connection.ConfigureAwait(false))
{
var handle = await PostgresAdvisoryLock.ExclusiveLock.TryAcquireAsync(connection, key.ToString(), timeout, cancellationToken).ConfigureAwait(false);
Copy link
Owner

Choose a reason for hiding this comment

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

If I'm reading the code correctly, this isn't actually a handle but merely a "Cookie" object which for Postgres is just a sentinel value.

Let's call this var lockAcquiredCookie to avoid confusion.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's correct, I changed the name

@madelson madelson added this to the 2.6 milestone Jan 4, 2025
@madelson madelson changed the base branch from master to release-2.6 January 4, 2025 20:00
@madelson madelson merged commit 324e17c into madelson:release-2.6 Jan 4, 2025
1 check failed
if (!connection.IsExernallyOwned) { return connection.HasTransaction; }

// If the connection is externally-owned with an established transaction, we don't want to pollute it with a save point
// which we won't be able to release in case the lock will be acquired.
Copy link
Owner

@madelson madelson Jan 4, 2025

Choose a reason for hiding this comment

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

Thinking about this more, I'm not sure I agree with the logic for two reasons.

  1. Why would a save point pollute the external transaction? We always roll back the save point regardless of lock acquisition and this happens right after we succeed/fail to acquire. My understanding is that a savepoint rollback will not prevent us from holding onto an acquired lock. See calls to RollBackTransactionTimeoutVariablesIfNeededAsync
  2. In the catch statement on 198/201, we return true which is the opposite behavior for what should be the same scenario (external connection with transaction).

The problem with not having a save point is that we end up polluting the transaction by setting the statement_timeout and lock_timeout values; the point of the savepoint is to clean those up before we return to the caller.

Copy link
Contributor Author

@Tzachi009 Tzachi009 Jan 7, 2025

Choose a reason for hiding this comment

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

I added more details to the comments in this method in the new PR, I hope it's clearer.

  1. I think it's inaccurate to say that we always roll back the save point regardless of lock acquisition. Before my changes, there was an if statement (which is still there, just changed the comment above it) in RollBackTransactionTimeoutVariablesIfNeededAsync that checks if the lock has been acquired and if the lock is transactional - if that's the case, then we can't rollback a savepoint, since it will release the lock (it's true, I checked), therefore there is no point in creating a save point. Although, you are right when it comes to polluting the transaction by setting the statement_timeout and lock_timeout values in this scenario - how do you think we should handle it, if at all? A warning in the public comments of the API will suffice? Or should we write code that try to restore the previous values?
  2. You can only reach the try/catch statement if someone sends an external connection via the existing API - this statement already existed before my changes. I didn't change it and I don't think that we should. I prevented external connections that come through the new transactional APIs from getting there with the previous if statement - if (connection.HasTransaction) { return false; }.

Copy link
Owner

Choose a reason for hiding this comment

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

RollBackTransactionTimeoutVariablesIfNeededAsync that checks if the lock has been acquired and if the lock is transactional - if that's the case, then we can't rollback a savepoint

You are correct. Unfortunately this creates another problem which is that we are now polluting the external connection's statement_timeout and lock_timeout state.

So for the case of an externally-owned transaction, I think we need some code that does this:

  1. Reads the initial settings with code like this:
SELECT current_setting('statement_timeout') AS statement_timeout, 
    current_setting('lock_timeout') AS lock_timeout;
  1. In RollBackTransactionTimeoutVariablesIfNeededAsync, we can pass in these values. In that function it would do something like this:
if (originalTimeoutValues is { } timeoutValues)
{
    // run query to SET LOCAL restore the original values
    return;
}

Thinking about it, I wonder if we should just drop the SAVEPOINT logic altogether in favor of this. If so, we could replace ShouldDefineSavePoint and RollBackTransactionTimeoutVariablesIfNeededAsync with something like this:

private async ValueTask<CapturedTimeoutSettings?> CaptureTimeoutSettingsIfNeededAsync(...)
{
     // returns null if the connection does not have an implicit or explicit transaction
}

private async ValueTask RestoreTimeoutSettingsAsync(CapturedTimeoutSettings? settings)
{
    if (settings is null) { return; }

    // issue a command with 2 SET LOCAL to restore the settings
}

I think it would be good to add a unit test case for this in the case of the new API. Something like:

  1. Start transaction
  2. SET LOCAL on lock_timeout and statement_timeout
  3. Acquire lock
  4. Verify that lock_timeout and statement_timeout are unchanged

Copy link
Contributor Author

Choose a reason for hiding this comment

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

All right, I will start looking into replacing save point logic

!connection.IsExernallyOwned && connection.HasTransaction;
// Transaction-scoped locking is supported on both externally-owned and internally-owned connections,
// as long as the connection has a transaction.
connection.HasTransaction;
Copy link
Owner

Choose a reason for hiding this comment

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

I think this is right but the comment is somewhat misleading. My understanding is that there is no path to get here with an external connection that explicitly has a transaction (vs. implicitly which is tested for above) except in the case where the caller deliberately went through one of the transactional locking APIs. Do you concur?

If so, let's be clear about that.

Copy link
Contributor Author

@Tzachi009 Tzachi009 Jan 7, 2025

Choose a reason for hiding this comment

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

I concur and changed the comment in the new PR, I hope it's clearer

@madelson
Copy link
Owner

madelson commented Jan 4, 2025

@Tzachi009 I merged this into the next release branch but I may have acted too quickly as I now think there is an issue with the implementation: https://github.com/madelson/DistributedLock/pull/222/files#r1903151395

Also, https://github.com/madelson/DistributedLock/pull/222/files#r1903151800

Mind re-submitting against release-2.6?

@Tzachi009
Copy link
Contributor Author

@madelson I re-submitted the PR against release-2.6 - #235

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