Skip to content

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

Open

Description

@behruzsaifov

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

  1. behruzsaifov commented on Oct 13, 2026

    @behruzsaifov
    Author

    If you're OK with this approach, I'm happy to open a PR.

  2. madelson commented on Oct 16, 2026

    @madelson
    Owner

    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 to SET LOCAL in terms of how it behaves in the repro case here: #90 (comment)

  3. behruzsaifov commented on Oct 16, 2026

    @behruzsaifov
    Author

    Thanks, @madelson! I'll open a PR and include the test case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions