Skip to content

Commit 94483ed

Browse files
authored
Merge pull request #239 from madelson/release-2.6
Release 2.6
2 parents c06f3ef + 4d9acd9 commit 94483ed

File tree

9 files changed

+418
-28
lines changed

9 files changed

+418
-28
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ Contributions are welcome! If you are interested in contributing towards a new o
141141
Setup steps for working with the repository locally are documented [here](docs/Developing%20DistributedLock.md).
142142

143143
## Release notes
144+
- 2.6
145+
- Add support for acquiring transaction-scoped Postgres locks using externally-owned transactions. Thanks [@Tzachi009](https://github.com/Tzachi009) for implementing! ([#213](https://github.com/madelson/DistributedLock/issues/213), DistributedLock.Postgres 1.3)
144146
- 2.5.1
145147
- Increase efficiency of Azure blob locks when the blob does not exist. Thanks [@richardkooiman](https://github.com/richardkooiman) for implementing! ([#227](https://github.com/madelson/DistributedLock/pull/227), DistributedLock.Azure 1.0.2)
146148
- Improve error handling in race condition scenarios for Azure blobs. Thanks [@MartinDembergerR9](https://github.com/MartinDembergerR9) for implementing! ([#228](https://github.com/madelson/DistributedLock/pull/228), DistributedLock.Azure 1.0.2)

docs/DistributedLock.Postgres.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ await using (await @lock.AcquireAsync())
1818
- The `PostgresDistributedReaderWriterLock` class implements the `IDistributedReaderWriterLock` interface.
1919
- The `PostgresDistributedSynchronizationProvider` class implements the `IDistributedLockProvider` and `IDistributedReaderWriterLockProvider` interfaces.
2020

21+
As of version 1.3, an additional set of static APIs on `PostgresDistributedLock` allows you to leverage transaction-scoped locking with an existing `IDbTransaction` instance. Since Postgres offers no way to explicitly release transaction-scoped locks and the caller controls the transaction, these locks are acquire-only and do not need a using block. For example:
22+
```C#
23+
using (var transaction = connection.BeginTransaction())
24+
{
25+
...
26+
// acquires the lock; it will be held until the transaction ends
27+
await PostgresDistributedLock.AcquireWithTransactionAsync(key, transaction);
28+
...
29+
}
30+
```
31+
2132
## Implementation notes
2233

2334
Under the hood, [Postgres advisory locks can be based on either one 64-bit integer value or a pair of 32-bit integer values](https://www.postgresql.org/docs/12/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS). Because of this, rather than taking in a name the lock constructors take a `PostgresAdvisoryLockKey` object which can be constructed in several ways:

src/DistributedLock.Postgres/DistributedLock.Postgres.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
</PropertyGroup>
1212

1313
<PropertyGroup>
14-
<Version>1.2.1</Version>
14+
<Version>1.3.0</Version>
1515
<AssemblyVersion>1.0.0.0</AssemblyVersion>
1616
<Authors>Michael Adelson</Authors>
1717
<Description>Provides a distributed lock implementation based on Postgresql</Description>

src/DistributedLock.Postgres/PostgresAdvisoryLock.cs

Lines changed: 92 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
using Npgsql;
44
using System.Data;
55
using System.Text;
6+
using System.Text.RegularExpressions;
7+
using System.Threading;
68

79
namespace Medallion.Threading.Postgres;
810

@@ -44,13 +46,22 @@ private PostgresAdvisoryLock(bool isShared)
4446
return null;
4547
}
4648

49+
// Only in the case where we will try to acquire a transaction-scoped lock, we will define a save point, but we won't be able to roll it back
50+
// in case of a successful lock acquisition becuase the lock will be released. Therefore, in such cases, we capture the timeout settings values before
51+
// we set a save point, and then we try to restore the values after the attempt to acquire the lock.
52+
// NOTE: the save point functionality can't be removed in favor of capturing and restoring the values for all cases.
53+
// When an error occurs while attempting to acquire the lock, the transaction is aborted and we can't run any other query, unless either
54+
// the transaction or the save point are rolled back.
55+
var capturedTimeoutSettings = await CaptureTimeoutSettingsIfNeededAsync(connection, cancellationToken).ConfigureAwait(false);
56+
4757
// Our acquire command will use SET LOCAL to set up statement timeouts. This lasts until the end
4858
// of the current transaction instead of just the current batch if we're in a transaction. To make sure
49-
// we don't leak those settings, in the case of a transaction we first set up a save point which we can
50-
// later roll back (taking the settings changes with it but NOT the lock). Because we can't confidently
51-
// roll back a save point without knowing that it has been set up, we start the save point in its own
52-
// query before we try-catch
53-
var needsSavePoint = await HasTransactionAsync(connection).ConfigureAwait(false);
59+
// we don't leak those settings, in the case of a transaction, we first set up a save point which we can
60+
// later roll back (only in cases where we don't acquire a transaction scoped lock - taking the settings changes with it but NOT the lock).
61+
// Because we can't confidently roll back a save point without knowing that it has been set up, we start the save point in its own
62+
// query before we try-catch.
63+
var needsSavePoint = await ShouldDefineSavePoint(connection).ConfigureAwait(false);
64+
5465
if (needsSavePoint)
5566
{
5667
using var setSavePointCommand = connection.CreateCommand();
@@ -69,6 +80,8 @@ private PostgresAdvisoryLock(bool isShared)
6980
{
7081
await RollBackTransactionTimeoutVariablesIfNeededAsync(acquired: false).ConfigureAwait(false);
7182

83+
await RestoreTimeoutSettingsIfNeededAsync(capturedTimeoutSettings, connection).ConfigureAwait(false);
84+
7285
if (ex is PostgresException postgresException)
7386
{
7487
switch (postgresException.SqlState)
@@ -114,6 +127,8 @@ private PostgresAdvisoryLock(bool isShared)
114127

115128
await RollBackTransactionTimeoutVariablesIfNeededAsync(acquired: acquired == true).ConfigureAwait(false);
116129

130+
await RestoreTimeoutSettingsIfNeededAsync(capturedTimeoutSettings, connection).ConfigureAwait(false);
131+
117132
return acquired switch
118133
{
119134
false => null,
@@ -123,10 +138,11 @@ private PostgresAdvisoryLock(bool isShared)
123138

124139
async ValueTask RollBackTransactionTimeoutVariablesIfNeededAsync(bool acquired)
125140
{
126-
if (needsSavePoint
127-
// For transaction scoped locks, we can't roll back the save point on success because that will roll
128-
// back our hold on the lock. It's ok to "leak" the savepoint in that case because it's an internally-owned
129-
// transaction/connection and the savepoint will be cleaned up with the disposal of the transaction.
141+
if (needsSavePoint
142+
// For transaction scoped locks, we can't roll back the save point on success because that will roll back our hold on the lock.
143+
// It's ok to "leak" the savepoint because if it's an internally-owned transaction then the savepoint will be cleaned up with the disposal of the transaction.
144+
// If it's an externally-owned transaction then we must "leak" it or we will lose the lock. Also, we can't avoid using a save point in this case
145+
// because otherwise if an exception had occurred the extrenally-owned transaction will be aborted and become completely unusable.
130146
&& !(acquired && UseTransactionScopedLock(connection)))
131147
{
132148
// attempt to clear the timeout variables we set
@@ -182,26 +198,74 @@ private DatabaseCommand CreateAcquireCommand(DatabaseConnection connection, Post
182198
return command;
183199
}
184200

185-
private static async ValueTask<bool> HasTransactionAsync(DatabaseConnection connection)
201+
private static async ValueTask<CapturedTimeoutSettings?> CaptureTimeoutSettingsIfNeededAsync(DatabaseConnection connection, CancellationToken cancellationToken)
186202
{
203+
var shouldCaptureTimeoutSettings = connection.IsExernallyOwned && UseTransactionScopedLock(connection);
204+
205+
// Return null in case we won't try to acquire an externally-owned transaction-scoped lock.
206+
if (!shouldCaptureTimeoutSettings) { return null; }
207+
208+
var statementTimeout = await GetCurrentSetting("statement_timeout", connection, cancellationToken).ConfigureAwait(false);
209+
var lockTimeout = await GetCurrentSetting("lock_timeout", connection, cancellationToken).ConfigureAwait(false);
210+
211+
var capturedTimeoutSettings = new CapturedTimeoutSettings(statementTimeout!, lockTimeout!);
212+
213+
return capturedTimeoutSettings;
214+
215+
async ValueTask<string?> GetCurrentSetting(string settingName, DatabaseConnection connection, CancellationToken cancellationToken)
216+
{
217+
using var getCurrentSettingCommand = connection.CreateCommand();
218+
219+
getCurrentSettingCommand.SetCommandText($"SELECT current_setting('{settingName}', 'true') AS {settingName};");
220+
221+
return (string?)await getCurrentSettingCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
222+
}
223+
}
224+
225+
private static async ValueTask<bool> ShouldDefineSavePoint(DatabaseConnection connection)
226+
{
227+
// If the connection is internally-owned, we only define a save point if a transaction has been opened.
228+
if (!connection.IsExernallyOwned) { return connection.HasTransaction; }
229+
230+
// If the connection is externally-owned with an established transaction,
231+
// it means that the connection came through the transactional locking APIs (see PostgresDistributedLock.Transactions.cs).
187232
if (connection.HasTransaction) { return true; }
188-
if (!connection.IsExernallyOwned) { return false; }
189233

190-
// If the connection is externally owned, then it might be part of a transaction that we can't
191-
// see. In that case, the only real way to detect it is to begin a new one
234+
// The externally-owned connection might still be part of a transaction that we can't see.
235+
// This can only be the case if the externally-owned connection didn't came through the transactional locking APIs (see PostgresDistributedLock.Transactions.cs).
236+
// In that case, the only real way to detect the transaction is to begin a new one.
192237
try
193238
{
194239
await connection.BeginTransactionAsync().ConfigureAwait(false);
195240
}
196241
catch (InvalidOperationException)
197242
{
243+
// Externally-owned connection with a transaction => we need to define a save point.
198244
return true;
199245
}
200246

201247
await connection.DisposeTransactionAsync().ConfigureAwait(false);
248+
249+
// Externally-owned connection with no transaction => no save point
202250
return false;
203251
}
204252

253+
private static async ValueTask RestoreTimeoutSettingsIfNeededAsync(CapturedTimeoutSettings? settings, DatabaseConnection connection)
254+
{
255+
// Settings is expected to be null in case we didn't try to acquire an externally-owned transaction-scoped lock.
256+
if (settings is null) { return; }
257+
258+
using var restoreTimeoutSettingsCommand = connection.CreateCommand();
259+
260+
StringBuilder commandText = new();
261+
commandText.AppendLine($"SET LOCAL statement_timeout = {settings.Value.StatementTimeout};");
262+
commandText.AppendLine($"SET LOCAL lock_timeout = {settings.Value.LockTimeout};");
263+
264+
restoreTimeoutSettingsCommand.SetCommandText(commandText.ToString());
265+
266+
await restoreTimeoutSettingsCommand.ExecuteNonQueryAsync(CancellationToken.None).ConfigureAwait(false);
267+
}
268+
205269
public ValueTask ReleaseAsync(DatabaseConnection connection, string resourceName, object lockCookie) =>
206270
this.ReleaseAsync(connection, new PostgresAdvisoryLockKey(resourceName), isTry: false);
207271

@@ -235,10 +299,9 @@ private static string AddKeyParametersAndGetKeyArguments(DatabaseCommand command
235299
}
236300

237301
private static bool UseTransactionScopedLock(DatabaseConnection connection) =>
238-
// This implementation (similar to what we do for SQL Server) is based on the fact that we only create transactions on
239-
// internally-owned connections when doing transaction-scoped locking, and we only support transaction-scoped locking on
240-
// internally-owned connections (since there's no explicit release).
241-
!connection.IsExernallyOwned && connection.HasTransaction;
302+
// Transaction-scoped locking is supported on internally-owned connections and externally-owned connections which explicitly have a transaction
303+
// (meaning that the external connection came through the transactional locking APIs, see PostgresDistributedLock.Transactions.cs).
304+
connection.HasTransaction;
242305

243306
private static string AddPGLocksFilterParametersAndGetFilterExpression(DatabaseCommand command, PostgresAdvisoryLockKey key)
244307
{
@@ -268,4 +331,16 @@ private static string AddPGLocksFilterParametersAndGetFilterExpression(DatabaseC
268331

269332
return $"(l.classid = @{classIdParameter} AND l.objid = @{objIdParameter} AND l.objsubid = {objSubId})";
270333
}
334+
335+
private readonly struct CapturedTimeoutSettings(string statementTimeout, string lockTimeout)
336+
{
337+
public int StatementTimeout { get; } = ParsePostgresTimeout(statementTimeout);
338+
339+
public int LockTimeout { get; } = ParsePostgresTimeout(lockTimeout);
340+
341+
private static int ParsePostgresTimeout(string timeout) =>
342+
Regex.Match(timeout, @"^\d+(?=(?:ms)?$)") is { Success: true, Value: var value }
343+
? int.Parse(value)
344+
: throw new FormatException($"Unexpected timeout setting value '{timeout}'");
345+
}
271346
}

0 commit comments

Comments
 (0)