Repository navigation
Postgres: use set_config(..., true) instead of SET LOCAL to avoid "SET LOCAL can only be used in transaction blocks" warnings (follow-up to #90) #289
Description
Hi @madelson, thanks for the library!
This is a follow-up to #90. We still see this warning in the PostgreSQL server log on every acquire of a session-scoped lock:
WARNING: SET LOCAL can only be used in transaction blocks
I understand from #90 why SET LOCAL is used: inside an Npgsql batch it applies until the end of the batch and, unlike SET, does not leak into later commands on the connection. The upstream reports (npgsql/npgsql#3688, pgsql-bugs #16988) did not change the PostgreSQL behavior, so I'd like to suggest a fix on the library side that keeps exactly the same semantics.
Proposal
set_config(name, value, is_local => true) is the function form of SET LOCAL (docs), but PostgreSQL emits the "transaction blocks" warning only for the SET LOCAL statement, not for the function.
One detail matters: CreateAcquireCommand reads the result with ExecuteScalarAsync, which returns the first result set. A plain SELECT set_config(...) placed before the lock call would become that scalar. Wrapping the calls in a DO block avoids this, because DO returns no result set:
// PostgresAdvisoryLock.CreateAcquireCommand
-commandText.AppendLine("SET LOCAL statement_timeout = 0;");
-commandText.AppendLine($"SET LOCAL lock_timeout = {(timeout.IsZero || timeout.IsInfinite ? 0 : timeout.InMilliseconds)};");
+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$$;");lockTimeout is an integer computed by the library, so interpolating it into the DO body is safe. RestoreTimeoutSettingsIfNeededAsync only runs inside a transaction, where SET LOCAL does not warn, so it can stay as it is.
Verification
I ran the current command text and the proposed one side by side on a fresh connection with no transaction (PostgreSQL 18.4, Npgsql 10.0.2), read the result with ExecuteScalarAsync the same way the library does, and captured notices with NpgsqlConnection.Notice:
| Scenario | Current (SET LOCAL) |
Proposed (DO + set_config) |
|---|---|---|
Try-lock, ExecuteScalarAsync result |
True (Boolean) |
True (Boolean) |
statement_timeout / lock_timeout after the command |
0 / 0 (no leak) |
0 / 0 (no leak) |
Blocking acquire, lock held by another session, lock_timeout 500 ms, session statement_timeout = 200ms |
55P03 lock timeout after ~509 ms |
55P03 lock timeout after ~507 ms |
| Settings after the failed blocking acquire | 200ms / 0 (restored) |
200ms / 0 (restored) |
| Server notices | WARNING: SET LOCAL can only be used in transaction blocks |
none |
So the session statement_timeout is still overridden for the lock statement, lock_timeout still governs the wait, nothing leaks after success or error, and the warning disappears.
Repro program
using System.Diagnostics;
using Npgsql;
var cs = "Host=localhost;Port=5432;Username=postgres;Password=postgres;Database=postgres;Pooling=false";
async Task<NpgsqlConnection> Open(List<string> notices)
{
var c = new NpgsqlConnection(cs);
c.Notice += (_, e) => notices.Add($"{e.Notice.Severity}: {e.Notice.MessageText}");
await c.OpenAsync();
return c;
}
string Prefix(string style, int lockTimeoutMs) => style switch
{
"SET LOCAL" => $"SET LOCAL statement_timeout = 0;\nSET LOCAL lock_timeout = {lockTimeoutMs};\n",
"DO set_config" => $"DO $$BEGIN PERFORM set_config('statement_timeout', '0', true); PERFORM set_config('lock_timeout', '{lockTimeoutMs}', true); END$$;\n",
_ => throw new()
};
foreach (var style in new[] { "SET LOCAL", "DO set_config" })
{
// Try-lock, read like the library does
var n = new List<string>();
await using (var c = await Open(n))
{
await using var cmd = new NpgsqlCommand(Prefix(style, 0) + "SELECT pg_catalog.pg_try_advisory_lock(42) AS result", c);
var scalar = await cmd.ExecuteScalarAsync();
await using var show = new NpgsqlCommand("SELECT current_setting('statement_timeout') || ' / ' || current_setting('lock_timeout')", c);
Console.WriteLine($"[{style}] try-lock scalar={scalar}; after: {await show.ExecuteScalarAsync()}; notices: {string.Join(" | ", n)}");
}
// Blocking acquire against a held lock, with a session statement_timeout lower than lock_timeout
var n1 = new List<string>(); var n2 = new List<string>();
await using var holder = await Open(n2);
await using (var h = new NpgsqlCommand("SELECT pg_advisory_lock(7)", holder)) await h.ExecuteScalarAsync();
await using (var c = await Open(n1))
{
await using (var st = new NpgsqlCommand("SET statement_timeout = 200", c)) await st.ExecuteNonQueryAsync();
var sw = Stopwatch.StartNew();
string outcome;
try
{
await using var cmd = new NpgsqlCommand(Prefix(style, 500) + "SELECT pg_catalog.pg_advisory_lock(7) AS result", c);
outcome = "acquired " + await cmd.ExecuteScalarAsync();
}
catch (PostgresException e) { outcome = $"{e.SqlState} {e.MessageText}"; }
await using var show = new NpgsqlCommand("SELECT current_setting('statement_timeout') || ' / ' || current_setting('lock_timeout')", c);
Console.WriteLine($"[{style}] blocking: {outcome} after {sw.ElapsedMilliseconds} ms; after: {await show.ExecuteScalarAsync()}; notices: {string.Join(" | ", n1)}");
}
}Why it matters for us
We use PostgresDistributedSynchronizationProvider with the default settings (session-scoped, multiplexed). Every acquire logs this warning, so with frequent acquires the warnings bury the ones that matter. UseTransaction() removes the warning, but it gives up multiplexing and keeps a transaction open for as long as the lock is held, which we'd rather avoid just to quiet the log.
Environment
- DistributedLock.Postgres 1.3.0 (the same SQL is in 1.3.1)
- Npgsql 10.0.2
- PostgreSQL 18.4
- .NET 10
Activity
behruzsaifov commentedon Oct 13, 2026
If you're OK with this approach, I'm happy to open a PR.
madelson commentedon Oct 16, 2026
Thanks for investigating @behruzsaifov. Please do open a PR with the change. Please include as part of it a test case in PostgresBehaviorTest that shows that
set_config(..., true)is equivalent toSET LOCALin terms of how it behaves in the repro case here: #90 (comment)behruzsaifov commentedon Oct 16, 2026
Thanks, @madelson! I'll open a PR and include the test case.