Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/DistributedLock.Postgres/PostgresAdvisoryLock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ private PostgresAdvisoryLock(bool isShared)
// the transaction or the save point are rolled back.
var capturedTimeoutSettings = await CaptureTimeoutSettingsIfNeededAsync(connection, cancellationToken).ConfigureAwait(false);

// Our acquire command will use SET LOCAL to set up statement timeouts. This lasts until the end
// of the current transaction instead of just the current batch if we're in a transaction. To make sure
// Our acquire command will use set_config(..., is_local: true), which behaves like SET LOCAL, to set up statement timeouts.
// This lasts until the end of the current transaction instead of just the current batch if we're in a transaction. To make sure
// we don't leak those settings, in the case of a transaction, we first set up a save point which we can
// 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).
// 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
Expand Down Expand Up @@ -176,10 +176,13 @@ private DatabaseCommand CreateAcquireCommand(DatabaseConnection connection, Post
var commandText = new StringBuilder();

// We set the statement_timeout to 0 (inf) because we want everything to be driven by the lock_timeout.
commandText.AppendLine("SET LOCAL statement_timeout = 0;");
// We set the lock timeout to our timeout, with the exception that if our timeout is zero we set it to inf because
// we'll be using the pg_try_advisory_lock function which doesn't block in that case.
commandText.AppendLine($"SET LOCAL lock_timeout = {(timeout.IsZero || timeout.IsInfinite ? 0 : timeout.InMilliseconds)};");
// We use set_config(..., is_local: true) rather than SET LOCAL: it behaves the same (see PostgresBehaviorTest), but SET LOCAL
// logs "SET LOCAL can only be used in transaction blocks" when we're not in a transaction. See https://github.com/madelson/DistributedLock/issues/90
// The calls are wrapped in a DO block so that they don't return a result set, since we read the result of this command with ExecuteScalar.
var lockTimeout = timeout.IsZero || timeout.IsInfinite ? 0 : timeout.InMilliseconds;
commandText.AppendLine($"DO $$BEGIN PERFORM pg_catalog.set_config('statement_timeout', '0', true); PERFORM pg_catalog.set_config('lock_timeout', '{lockTimeout}', true); END$$;");

commandText.Append("SELECT ");
var isTry = timeout.IsZero;
Expand Down
37 changes: 37 additions & 0 deletions src/DistributedLock.Tests/Tests/Postgres/PostgresBehaviorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,43 @@ public async Task TestPostgresCommandAutomaticallyParticipatesInTransaction()
(await commandInTransaction.ExecuteScalarAsync()).ShouldEqual("2020ms");
}

/// <summary>
/// Demonstrates that outside of a transaction set_config(..., is_local: true) behaves like SET LOCAL: the setting applies
/// to the rest of the multi-statement command and does not leak into later commands (unlike SET). This is why we
/// can use set_config instead of SET LOCAL when acquiring locks. Unlike SET LOCAL, set_config does not log
/// "SET LOCAL can only be used in transaction blocks" (see https://github.com/madelson/DistributedLock/issues/90).
/// </summary>
[TestCase(false)]
[TestCase(true)]
public async Task TestSetConfigIsLocalBehavesLikeSetLocalOutsideOfTransaction(bool useSetConfig)
{
using var connection = new NpgsqlConnection(TestingPostgresDb.DefaultConnectionString);
await connection.OpenAsync();
var notices = new List<string>();
connection.Notice += (_, e) => notices.Add(e.Notice.MessageText);

// set_config is wrapped in a DO block so that it does not return a result set, like SET LOCAL
string SetLockTimeout(int milliseconds) => useSetConfig
? $"DO $$BEGIN PERFORM pg_catalog.set_config('lock_timeout', '{milliseconds}', true); END$$;"
: $"SET LOCAL lock_timeout = {milliseconds};";

(await ExecuteAsync($"{SetLockTimeout(12345)} CREATE TEMPORARY TABLE temp_table_{useSetConfig} (id INT); SHOW lock_timeout;")).ShouldEqual("12345ms");
(await ExecuteAsync("SHOW lock_timeout;")).ShouldEqual("0");
(await ExecuteAsync($"{SetLockTimeout(456)} SELECT 'x'")).ShouldEqual("x");
(await ExecuteAsync("SHOW lock_timeout")).ShouldEqual("0");

const string SetLocalWarning = "SET LOCAL can only be used in transaction blocks";
if (useSetConfig) { Assert.That(notices, Has.None.Contains(SetLocalWarning)); }
else { Assert.That(notices, Has.Some.Contains(SetLocalWarning)); }

async Task<string?> ExecuteAsync(string sql)
{
using var command = connection.CreateCommand();
command.CommandText = sql;
return (string?)await command.ExecuteScalarAsync();
}
}

[Test]
public Task TestTransactionCancellationRecovery() =>
this.TestTransactionCancellationOrTimeoutRecovery(useTimeout: false);
Expand Down