EventStore.AdvisoryLocks hangs for good when its connection pool dies while new connections are refused

Aperta
#331 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
55/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
elixir, postgresql
Ambito
backend, databases

Direzione di ricerca

Start by tracing EventStore.AdvisoryLocks queries through EventStore.MonitoredServer and DBConnection.Holder.checkout_call/5, which the issue identifies as the point where checkout hangs. Reproduce with the supplied script and compare its 45-second run with the 20-second control. Done when subscriptions can retry and acquire their locks after PostgreSQL accepts connections again, without restarting the event store.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

When PostgreSQL refuses new connections for a while and the advisory lock connection is lost in that time, EventStore.AdvisoryLocks can end up waiting for a connection checkout that nothing will ever answer. In our runs every persistent subscription of that event store instance then crashed on its lock call, and no subscription could subscribe again until AdvisoryLocks or the whole instance restarted. The database recovering does not help: the lock connection pool is restarted and works, while AdvisoryLocks stays in DBConnection.Holder.checkout_call/5. Appends keep working.

I would expect the subscriptions to take their locks again once the database accepts new connections.

When it happens

It needs the lock session to end while new connections are refused and the existing connections of the main pool keep working. In the case we reproduced, the refusals also have to last past the lock pool's next restart, 30 s after it died. Running out of connection slots is one way to get there. On a shared test database that ran out of them (FATAL 53300 too_many_connections), our application kept logging try_advisory_lock timeouts at a steady rate for 38 and 66 minutes after the last refused connection was logged, until those nodes went away. In one of the two cases the refusals had lasted about one minute.

For a subscription that retries after lock_released with the default 60 s subscription_retry_interval, the refusals have to last more than about 60 s, because the first retry comes 60 s after the pool died, together with the pool's second restart, and that restart has to fail. In our runs refusals of 90 s and 120 s blocked AdvisoryLocks (1 of 1 and 10 of 10 runs), and refusals of 5, 45 and 60 s did not. With subscription_retry_interval: 1_000, as in the script below, 45 s is enough.

Terminating every backend while new connections were refused, which stands in for a database restart, did not trigger it in our runs (5, 45 or 120 s, default retry interval), because a subscription asks for its lock only after create_subscription has succeeded on the main pool, and by then new connections work again.

This is the counterpart of #330. There the lock connection reconnects, its pool survives and the subscriptions keep running without their locks. Here reconnects are refused, the pool dies, the subscriptions get lock_released and retry, and the retry can hang AdvisoryLocks.

Cause

AdvisoryLocks runs its lock queries through the registered name of the lock connection, which belongs to an EventStore.MonitoredServer and not to the Postgrex pool. DBConnection.Holder resolves that name to a pid, and checkout_call/5 monitors that process, sends the checkout request and waits in a receive without a timeout for a reply from a pool or a :DOWN for that monitor. While the lock pool is down, MonitoredServer queues the request, and when it starts the next pool 30 s later, it forwards its queue to that pool and keeps no copy. If new connections are still refused, the new pool dies right away (with backoff_type: :stop each refused connect stops the connection, and the pool's supervisor gives up after a few) and takes the request with it. Nothing replies, and no :DOWN for that monitor arrives, because the monitor watches MonitoredServer, which stays up. The pool started after that works, but AdvisoryLocks is still waiting for the lost request and handles no other message.

Effect on subscriptions

While AdvisoryLocks waits, each subscription's try_advisory_lock call times out after the subscription's 15 s query_timeout, and the subscription process exits with {:timeout, {GenServer, :call, [..., {:try_advisory_lock, ...}, 15000]}}. EventStore.Subscriptions.Subscription is restart: :temporary, so nothing restarts it, and the subscriber only learns of it if it monitors the subscription. A new call to subscribe_to_all_streams or subscribe_to_stream exits after 5 s.

The crash happens even without the permanent hang. In the control run below the refusals end before the pool's restart, and both subscriptions still crash, because AdvisoryLocks waits more than 15 s for that restart. A new subscription works afterwards there.

Workaround

Kill the instance's AdvisoryLocks process, registered as Module.concat([event_store_name, EventStore.AdvisoryLocks]) (Store.EventStore.AdvisoryLocks in the script below). EventStore.Supervisor is :one_for_all, so this restarts the whole event store instance. It does not bring back the subscriptions that crashed, so their subscribers have to subscribe again.

Reproduction

The script needs only PostgreSQL and takes about two minutes. All connections of one event store go through a small TCP proxy inside the script, which either forwards new connections or refuses them while the connections it already relays keep working. Two persistent subscriptions on $all, sub-a and sub-b, take their locks. The script then makes the proxy refuse new connections, terminates the backend that holds the advisory locks with pg_terminate_backend, lets the proxy forward again after 45 s and prints the state 60 s later. At the end it kills AdvisoryLocks and subscribes again. It sets subscription_retry_interval: 1_000 to keep the run short.

Run it with PGHOST=localhost PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres elixir repro.exs. It drops and recreates the database named by REPRO_DATABASE (default eventstore_blocked_lock_repro). elixir repro.exs 20 is the control run: the proxy refuses for 20 s only, which ends before the lock pool's restart.

repro.exs
# After the advisory lock connection pool of an event store dies while PostgreSQL refuses new
# connections, EventStore.AdvisoryLocks can end up waiting for a connection checkout that nothing
# will answer. Every subscription of the event store crashes on its 15 s lock call, and no
# subscription can subscribe until AdvisoryLocks or the event store restarts.
#
# Every connection of the event store goes through a small TCP proxy in this script. The proxy
# either forwards new connections or refuses them; connections it already relays keep working.
# Two persistent subscriptions take their advisory locks. The script then makes the proxy refuse
# new connections, terminates the backend that holds the advisory locks, forwards again 45 s later
# and prints the state 60 s after that. At the end it kills EventStore.AdvisoryLocks and subscribes
# again. The event store uses subscription_retry_interval: 1_000 to keep the run short; with the
# default of 60 s the refusal has to last longer than about 60 s.
#
#   elixir repro.exs      # refuse for 45 s, past the lock pool's restart 30 s after it died
#   elixir repro.exs 20   # refuse for 20 s, which ends before that restart
#
# Connection settings come from PGHOST, PGPORT, PGUSER and PGPASSWORD (defaults localhost, 5432,
# postgres, postgres). REPRO_DATABASE names the database (default eventstore_blocked_lock_repro);
# the script drops and recreates it on every run.

Mix.install([
  {:eventstore, "1.4.10"},
  {:postgrex, "0.22.4"},
  {:db_connection, "2.10.2"}
])

# Postgrex logs every refused connection attempt. The script prints what it observes itself.
Logger.configure(level: :none)
# Process.info/2 cuts stacks at 8 frames by default, short of EventStore.AdvisoryLocks.handle_call/3.
:erlang.system_flag(:backtrace_depth, 20)

defmodule Store do
  use EventStore, otp_app: :repro
end

defmodule Clock do
  @moduledoc "Prints lines with the time since the outage started. Lines printed before it have no time."

  def start, do: :persistent_term.put(__MODULE__, System.monotonic_time(:millisecond))

  def elapsed_ms, do: System.monotonic_time(:millisecond) - :persistent_term.get(__MODULE__)

  def sleep_until(at_ms), do: Process.sleep(max(at_ms - elapsed_ms(), 0))

  def log(text) do
    case :persistent_term.get(__MODULE__, nil) do
      nil -> IO.puts(text)
      _started_at -> IO.puts("[+#{format_seconds(elapsed_ms())}s] #{text}")
    end
  end

  def format_seconds(milliseconds), do: :erlang.float_to_binary(milliseconds / 1000, decimals: 1)
end

defmodule Proxy do
  @moduledoc """
  TCP proxy in front of PostgreSQL. In `:forward` mode it accepts connections and relays them. In
  `:refuse` mode it closes its listening socket, so new connections fail with `:econnrefused`.
  Connections it already relays keep working in both modes.
  """

  use GenServer

  @listen_options [:binary, ip: {127, 0, 0, 1}, active: false, reuseaddr: true, packet: :raw, backlog: 128]

  def start_link(upstream_host, upstream_port) do
    upstream = {String.to_charlist(upstream_host), upstream_port}
    GenServer.start_link(__MODULE__, upstream)
  end

  @doc "The local port the proxy listens on."
  def port(proxy), do: GenServer.call(proxy, :port)

  def set_mode(proxy, mode) when mode in [:forward, :refuse], do: GenServer.call(proxy, {:set_mode, mode})

  @impl GenServer
  def init(upstream), do: {:ok, open_listener(%{upstream: upstream, port: 0, mode: :forward, listener: nil})}

  @impl GenServer
  def handle_call(:port, _from, state), do: {:reply, state.port, state}

  def handle_call({:set_mode, :refuse}, _from, state), do: {:reply, :ok, %{close_listener(state) | mode: :refuse}}

  def handle_call({:set_mode, :forward}, _from, state), do: {:reply, :ok, %{open_listener(state) | mode: :forward}}

  @impl GenServer
  def handle_info({:accepted, client}, %{mode: :forward} = state) do
    {:ok, relay} = Task.start(fn -> relay_connection(client, state.upstream) end)
    :ok = :gen_tcp.controlling_process(client, relay)
    send(relay, :go)
    {:noreply, state}
  end

  def handle_info({:accepted, client}, state) do
    :gen_tcp.close(client)
    {:noreply, state}
  end

  defp open_listener(%{listener: nil} = state) do
    {:ok, listener} = :gen_tcp.listen(state.port, @listen_options)
    {:ok, port} = :inet.port(listener)
    proxy = self()
    spawn_link(fn -> accept_connections(listener, proxy) end)
    %{state | listener: listener, port: port}
  end

  defp open_listener(state), do: state

  defp close_listener(%{listener: nil} = state), do: state

  defp close_listener(state) do
    :gen_tcp.close(state.listener)
    %{state | listener: nil}
  end

  defp accept_connections(listener, proxy) do
    case :gen_tcp.accept(listener) do
      {:ok, client} ->
        :ok = :gen_tcp.controlling_process(client, proxy)
        send(proxy, {:accepted, client})
        accept_connections(listener, proxy)

      {:error, :closed} ->
        :ok
    end
  end

  defp relay_connection(client, {host, port}) do
    receive do
      :go -> :ok
    end

    case :gen_tcp.connect(host, port, [:binary, active: false, packet: :raw]) do
      {:ok, server} ->
        start_pipe(client, server)
        start_pipe(server, client)

      {:error, _reason} ->
        :gen_tcp.close(client)
    end
  end

  defp start_pipe(from, to) do
    pipe = spawn(fn -> await_ownership_then_pipe(from, to) end)
    :ok = :gen_tcp.controlling_process(from, pipe)
    send(pipe, :go)
  end

  defp await_ownership_then_pipe(from, to) do
    receive do
      :go -> pipe(from, to)
    end
  end

  defp pipe(from, to) do
    with {:ok, data} <- :gen_tcp.recv(from, 0),
         :ok <- :gen_tcp.send(to, data) do
      pipe(from, to)
    else
      _closed ->
        :gen_tcp.close(from)
        :gen_tcp.close(to)
    end
  end
end

defmodule Database do
  @moduledoc "Database setup and the pg_locks and pg_terminate_backend queries, on a direct connection that bypasses the proxy."

  def settings do
    [
      hostname: System.get_env("PGHOST", "localhost"),
      port: String.to_integer(System.get_env("PGPORT", "5432")),
      username: System.get_env("PGUSER", "postgres"),
      password: System.get_env("PGPASSWORD", "postgres")
    ]
  end

  def connect(database) do
    {:ok, conn} = Postgrex.start_link([database: database] ++ settings())
    conn
  end

  def recreate(admin, database) do
    Postgrex.query!(admin, ~s[DROP DATABASE IF EXISTS "#{database}" WITH (FORCE)], [])
    Postgrex.query!(admin, ~s[CREATE DATABASE "#{database}"], [])
    config = Store.config([database: database] ++ settings())
    :ok = EventStore.Tasks.Init.exec(config, quiet: true)
  end

  def server_version(admin) do
    %Postgrex.Result{rows: [[version]]} =
      Postgrex.query!(admin, "SELECT split_part(current_setting('server_version'), ' ', 1)", [])

    version
  end

  @doc "Advisory locks granted in the database, as `{backend_pid, key}` ordered by key. The key is the subscription id."
  def advisory_locks(admin, database) do
    sql = """
    SELECT l.pid, l.objid
    FROM pg_locks l JOIN pg_database d ON d.oid = l.database
    WHERE l.locktype = 'advisory' AND l.granted AND d.datname = $1
    ORDER BY l.objid
    """

    %Postgrex.Result{rows: rows} = Postgrex.query!(admin, sql, [database])
    Enum.map(rows, &List.to_tuple/1)
  end

  def terminate_backend(admin, database, backend_pid) do
    sql = "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE pid = $1 AND datname = $2"
    %Postgrex.Result{rows: [[terminated?]]} = Postgrex.query!(admin, sql, [backend_pid, database])
    terminated?
  end
end

defmodule Subscriber do
  @moduledoc "Subscriber process that acknowledges every event and tells the script when it is subscribed and which events it received."

  def start(subscription_name, script), do: spawn(fn -> receive_events(subscription_name, script, nil) end)

  defp receive_events(subscription_name, script, subscription) do
    receive do
      {:subscribed, subscription} ->
        send(script, {:subscribed, subscription_name})
        receive_events(subscription_name, script, subscription)

      {:events, events} ->
        :ok = Store.ack(subscription, events)
        event_numbers = Enum.map(events, & &1.event_number)
        send(script, {:received, subscription_name, event_numbers})
        receive_events(subscription_name, script, subscription)
    end
  end
end

defmodule Watcher do
  @moduledoc """
  Prints, when it happens, every exit of a lock connection pool and of a watched subscription
  process. It learns of pool exits through `EventStore.MonitoredServer.monitor/1`, the call that
  `EventStore.AdvisoryLocks` uses for the same purpose.
  """

  alias EventStore.MonitoredServer

  def start(lock_connection) do
    script = self()
    watcher = spawn(fn -> watch_lock_pool(lock_connection, script) end)

    receive do
      {:watching, ^watcher} -> watcher
    end
  end

  def watch_subscription(watcher, subscription, subscription_name), do: send(watcher, {:watch, subscription, subscription_name})

  defp watch_lock_pool(lock_connection, script) do
    {:ok, pool_ref} = MonitoredServer.monitor(lock_connection)
    send(script, {:watching, self()})
    report_exits(pool_ref, %{})
  end

  defp report_exits(pool_ref, subscription_names) do
    receive do
      {:watch, subscription, subscription_name} ->
        ref = Process.monitor(subscription)
        report_exits(pool_ref, Map.put(subscription_names, ref, subscription_name))

      {:DOWN, ^pool_ref, :process, pool, reason} ->
        Clock.log("Lock pool #{inspect(pool)} exited: #{inspect(reason)}")
        report_exits(pool_ref, subscription_names)

      {:DOWN, ref, :process, subscription, reason} ->
        {subscription_name, subscription_names} = Map.pop!(subscription_names, ref)
        Clock.log("Subscription #{subscription_name} #{inspect(subscription)} exited: #{inspect(reason)}")
        report_exits(pool_ref, subscription_names)
    end
  end
end

defmodule Repro do
  @versioned_apps [:eventstore, :postgrex, :db_connection]
  @subscription_names ["sub-a", "sub-b"]
  @status_interval_ms 10_000
  @observation_after_refusal_ms 60_000
  @receive_window_ms 5_000
  @subscribe_timeout_ms 10_000

  # Registered names inside the event store `Store`: the lock server, and the
  # EventStore.MonitoredServer that starts and restarts its connection pool.
  @advisory_locks Module.concat([Store, EventStore.AdvisoryLocks])
  @lock_connection Module.concat([Store, EventStore.AdvisoryLocks, Postgrex])

  def run(refusal_ms) do
    database = System.get_env("REPRO_DATABASE", "eventstore_blocked_lock_repro")
    admin = Database.connect("postgres")
    Database.recreate(admin, database)
    print_versions(admin)

    settings = Database.settings()
    upstream_host = Keyword.fetch!(settings, :hostname)
    upstream_port = Keyword.fetch!(settings, :port)
    {:ok, proxy} = Proxy.start_link(upstream_host, upstream_port)
    proxy_port = Proxy.port(proxy)
    {:ok, _store} = Store.start_link(store_options(settings, database, proxy_port))
    watcher = Watcher.start(@lock_connection)

    subscriptions = Enum.map(@subscription_names, &subscribe(&1, watcher))
    Clock.log("Subscribed #{Enum.join(@subscription_names, " and ")}. Advisory locks: #{describe_locks(admin, database)}")
    append_and_report(1)

    evidence_at_ms = refusal_ms + @observation_after_refusal_ms
    status_times_ms = Enum.to_list(@status_interval_ms..(evidence_at_ms - 1)//@status_interval_ms)
    {while_refusing_ms, after_refusal_ms} = Enum.split_with(status_times_ms, &(&1 < refusal_ms))

    start_outage(proxy, admin, database, refusal_ms)
    Enum.each(while_refusing_ms, &print_status_at(&1, subscriptions, admin, database))
    Clock.sleep_until(refusal_ms)
    :ok = Proxy.set_mode(proxy, :forward)
    Clock.log("Proxy forwards new connections again")
    Enum.each(after_refusal_ms, &print_status_at(&1, subscriptions, admin, database))

    Clock.sleep_until(evidence_at_ms)
    print_evidence(subscriptions, admin, database)
    kill_advisory_locks_and_resubscribe(watcher, admin, database)
  end

  defp store_options(settings, database, proxy_port) do
    proxied = [
      hostname: "127.0.0.1",
      port: proxy_port,
      database: database,
      serializer: EventStore.TermSerializer,
      subscription_retry_interval: 1_000
    ]

    Keyword.merge(settings, proxied)
  end

  defp subscribe(subscription_name, watcher) do
    subscriber = Subscriber.start(subscription_name, self())
    {:ok, subscription} = Store.subscribe_to_all_streams(subscription_name, subscriber)
    Watcher.watch_subscription(watcher, subscription, subscription_name)
    :subscribed = await_subscribed(subscription_name)
    {subscription_name, subscription}
  end

  # A subscriber gets `{:subscribed, subscription}` only once its subscription holds the advisory lock.
  defp await_subscribed(subscription_name) do
    receive do
      {:subscribed, ^subscription_name} -> :subscribed
    after
      @subscribe_timeout_ms -> :not_subscribed
    end
  end

  defp start_outage(proxy, admin, database, refusal_ms) do
    [{lock_backend, _key_a}, {lock_backend, _key_b}] = Database.advisory_locks(admin, database)
    :ok = Proxy.set_mode(proxy, :refuse)
    Clock.start()
    terminated? = Database.terminate_backend(admin, database, lock_backend)

    Clock.log(
      "Proxy refuses new connections for #{div(refusal_ms, 1000)} s. pg_terminate_backend(#{lock_backend}), the backend holding the locks: #{terminated?}"
    )
  end

  defp print_status_at(at_ms, subscriptions, admin, database) do
    Clock.sleep_until(at_ms)
    alive_count = Enum.count(subscriptions, &subscription_alive?/1)

    Clock.log(
      "#{describe_lock_pool()} | #{describe_lock_server()} | subscriptions alive: #{alive_count} | advisory locks: #{describe_locks(admin, database)}"
    )
  end

  defp print_evidence(subscriptions, admin, database) do
    Clock.log("60 s after the proxy forwards again:")
    print_lock_server_stack()
    Clock.log("  #{describe_lock_pool()}")
    Clock.log("  The lock connection answers another process: #{query_lock_connection()}")
    Clock.log("  Advisory locks in the database: #{describe_locks(admin, database)}")
    Clock.log("  Subscription processes alive: #{Enum.map_join(subscriptions, ", ", &describe_liveness/1)}")
    append_and_report(2)
    try_new_subscription("sub-c")
    Clock.log("  #{describe_lock_server()} | advisory locks: #{describe_locks(admin, database)}")
  end

  defp print_lock_server_stack do
    advisory_locks = Process.whereis(@advisory_locks)
    {:current_stacktrace, stack} = Process.info(advisory_locks, :current_stacktrace)
    Clock.log("  #{describe_lock_server()}, stack:")
    Enum.each(stack, &IO.puts("      #{format_frame(&1)}"))
  end

  defp format_frame({module, function, arity, _location}), do: Exception.format_mfa(module, function, arity)

  # Postgrex sends this query through the same registered name that EventStore.AdvisoryLocks uses.
  defp query_lock_connection do
    {:ok, %Postgrex.Result{rows: [[backend]]}} = Postgrex.query(@lock_connection, "SELECT pg_backend_pid()", [])
    "backend #{backend}"
  end

  defp try_new_subscription(subscription_name) do
    subscriber = Subscriber.start(subscription_name, self())
    started_at = System.monotonic_time(:millisecond)
    result = subscribe_catching_exit(subscription_name, subscriber)
    elapsed_ms = System.monotonic_time(:millisecond) - started_at

    Clock.log(
      ~s[  subscribe_to_all_streams("#{subscription_name}") returned after #{Clock.format_seconds(elapsed_ms)} s: #{inspect(result, limit: 10)}]
    )
  end

  defp subscribe_catching_exit(subscription_name, subscriber) do
    Store.subscribe_to_all_streams(subscription_name, subscriber)
  catch
    :exit, reason -> {:exit, reason}
  end

  defp kill_advisory_locks_and_resubscribe(watcher, admin, database) do
    advisory_locks = Process.whereis(@advisory_locks)
    Clock.log("Killing #{inspect(@advisory_locks)} #{inspect(advisory_locks)}")
    Process.exit(advisory_locks, :kill)
    restarted = await_restart(advisory_locks)
    Clock.log("#{inspect(@advisory_locks)} restarted as #{inspect(restarted)}")

    Enum.each(@subscription_names, &resubscribe(&1, watcher))
    Clock.log("Advisory locks: #{describe_locks(admin, database)}")
    append_and_report(3)
  end

  # EventStore.Supervisor restarts every child of the instance when AdvisoryLocks dies (one_for_all).
  defp await_restart(killed) do
    case Process.whereis(@advisory_locks) do
      restarted when is_pid(restarted) and restarted != killed -> restarted
      _killed_or_none -> retry_await_restart(killed)
    end
  end

  defp retry_await_restart(killed) do
    Process.sleep(100)
    await_restart(killed)
  end

  defp resubscribe(subscription_name, watcher) do
    {^subscription_name, subscription} = subscribe(subscription_name, watcher)
    Clock.log("Subscribed #{subscription_name} again: #{inspect(subscription)}")
  end

  defp append_and_report(number) do
    event = %EventStore.EventData{event_type: "appended", data: %{number: number}, metadata: %{}}
    :ok = Store.append_to_stream("stream-1", :any_version, [event])
    received = collect_received(%{}, deadline_in(@receive_window_ms))
    Clock.log("Appended event #{number}. Received within #{div(@receive_window_ms, 1000)} s: #{describe_received(received)}")
  end

  defp collect_received(received, deadline) do
    receive do
      {:received, subscription_name, event_numbers} ->
        received
        |> Map.update(subscription_name, event_numbers, &(&1 ++ event_numbers))
        |> collect_received(deadline)
    after
      max(deadline - System.monotonic_time(:millisecond), 0) -> received
    end
  end

  defp describe_received(received) when map_size(received) == 0, do: "nothing"

  defp describe_received(received) do
    received
    |> Enum.sort()
    |> Enum.map_join(", ", &describe_events_received/1)
  end

  defp describe_events_received({subscription_name, event_numbers}), do: "#{subscription_name} #{inspect(event_numbers)}"

  defp deadline_in(timeout_ms), do: System.monotonic_time(:millisecond) + timeout_ms

  # EventStore.MonitoredServer keeps the current pool pid (nil while there is none) and a queue of
  # the messages that arrived while there was no pool, to forward to the next one it starts.
  defp describe_lock_pool do
    %{pid: pool, queue: queue} = :sys.get_state(@lock_connection)
    "lock pool #{describe_pool(pool)}, MonitoredServer queue #{:queue.len(queue)}"
  end

  defp describe_pool(nil), do: "none"
  defp describe_pool(pool), do: "#{inspect(pool)} alive=#{Process.alive?(pool)}"

  defp describe_lock_server do
    advisory_locks = Process.whereis(@advisory_locks)

    [current_function: {module, function, arity}, message_queue_len: mailbox] =
      Process.info(advisory_locks, [:current_function, :message_queue_len])

    "AdvisoryLocks in #{Exception.format_mfa(module, function, arity)}, mailbox #{mailbox}"
  end

  defp subscription_alive?({_subscription_name, subscription}), do: Process.alive?(subscription)

  defp describe_liveness({subscription_name, subscription}), do: "#{subscription_name} #{Process.alive?(subscription)}"

  defp describe_locks(admin, database) do
    case Database.advisory_locks(admin, database) do
      [] -> "none"
      locks -> describe_lock_holders(locks)
    end
  end

  defp describe_lock_holders(locks) do
    locks
    |> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
    |> Enum.map_join(", ", &describe_lock_holder/1)
  end

  defp describe_lock_holder({backend, keys}), do: "backend #{backend} holds keys #{inspect(keys)}"

  defp print_versions(admin) do
    versions = Enum.map_join(@versioned_apps, ", ", &"#{&1} #{Application.spec(&1, :vsn)}")

    Clock.log(
      "#{versions}, PostgreSQL #{Database.server_version(admin)}, Elixir #{System.version()}, OTP #{System.otp_release()}"
    )
  end
end

refusal_ms =
  case System.argv() do
    [] -> 45_000
    [seconds] -> String.to_integer(seconds) * 1000
  end

Repro.run(refusal_ms)

The output below is from one of two runs, which differed only in pids and backend numbers, and by 0.1 s in timing. The lock pool dies at +0.9 s. The checkout of the first retry waits in MonitoredServer (queue 1) until the next pool takes it and dies at +30.9 s (queue 0 from then on). From the +70.0s line on, a new pool is alive and answers a query from another process, while AdvisoryLocks stays in checkout_call/5.

eventstore 1.4.10, postgrex 0.22.4, db_connection 2.10.2, PostgreSQL 18.6, Elixir 1.18.4, OTP 27
Subscribed sub-a and sub-b. Advisory locks: backend 56301 holds keys [1, 2]
Appended event 1. Received within 5 s: sub-a [1], sub-b [1]
[+0.0s] Proxy refuses new connections for 45 s. pg_terminate_backend(56301), the backend holding the locks: true
[+0.9s] Lock pool #PID<0.260.0> exited: :killed
[+10.0s] lock pool none, MonitoredServer queue 1 | AdvisoryLocks in DBConnection.Holder.checkout_call/5, mailbox 1 | subscriptions alive: 2 | advisory locks: none
[+16.9s] Subscription sub-a #PID<0.328.0> exited: {:timeout, {GenServer, :call, [Store.EventStore.AdvisoryLocks, {:try_advisory_lock, 1, #PID<0.328.0>, 15000}, 15000]}}
[+16.9s] Subscription sub-b #PID<0.330.0> exited: {:timeout, {GenServer, :call, [Store.EventStore.AdvisoryLocks, {:try_advisory_lock, 2, #PID<0.330.0>, 15000}, 15000]}}
[+20.0s] lock pool none, MonitoredServer queue 1 | AdvisoryLocks in DBConnection.Holder.checkout_call/5, mailbox 3 | subscriptions alive: 0 | advisory locks: none
[+30.0s] lock pool none, MonitoredServer queue 1 | AdvisoryLocks in DBConnection.Holder.checkout_call/5, mailbox 3 | subscriptions alive: 0 | advisory locks: none
[+30.9s] Lock pool #PID<0.335.0> exited: :killed
[+40.0s] lock pool none, MonitoredServer queue 0 | AdvisoryLocks in DBConnection.Holder.checkout_call/5, mailbox 4 | subscriptions alive: 0 | advisory locks: none
[+45.0s] Proxy forwards new connections again
[+50.0s] lock pool none, MonitoredServer queue 0 | AdvisoryLocks in DBConnection.Holder.checkout_call/5, mailbox 4 | subscriptions alive: 0 | advisory locks: none
[+60.0s] lock pool none, MonitoredServer queue 0 | AdvisoryLocks in DBConnection.Holder.checkout_call/5, mailbox 4 | subscriptions alive: 0 | advisory locks: none
[+70.0s] lock pool #PID<0.342.0> alive=true, MonitoredServer queue 0 | AdvisoryLocks in DBConnection.Holder.checkout_call/5, mailbox 4 | subscriptions alive: 0 | advisory locks: none
[+80.0s] lock pool #PID<0.342.0> alive=true, MonitoredServer queue 0 | AdvisoryLocks in DBConnection.Holder.checkout_call/5, mailbox 4 | subscriptions alive: 0 | advisory locks: none
[+90.0s] lock pool #PID<0.342.0> alive=true, MonitoredServer queue 0 | AdvisoryLocks in DBConnection.Holder.checkout_call/5, mailbox 4 | subscriptions alive: 0 | advisory locks: none
[+100.0s] lock pool #PID<0.342.0> alive=true, MonitoredServer queue 0 | AdvisoryLocks in DBConnection.Holder.checkout_call/5, mailbox 4 | subscriptions alive: 0 | advisory locks: none
[+105.0s] 60 s after the proxy forwards again:
[+105.0s]   AdvisoryLocks in DBConnection.Holder.checkout_call/5, mailbox 4, stack:
      DBConnection.Holder.checkout_call/5
      DBConnection.Holder.checkout/3
      DBConnection.checkout/3
      DBConnection.run_with_retries/5
      DBConnection.parsed_prepare_execute/5
      DBConnection.prepare_execute/4
      Postgrex.query_prepare_execute/4
      EventStore.Storage.Lock.try_acquire_exclusive_lock/3
      EventStore.AdvisoryLocks.try_acquire_exclusive_lock/4
      EventStore.AdvisoryLocks.handle_call/3
      :gen_server.try_handle_call/4
      :gen_server.handle_msg/6
      :proc_lib.init_p_do_apply/3
[+105.0s]   lock pool #PID<0.342.0> alive=true, MonitoredServer queue 0
[+105.0s]   The lock connection answers another process: backend 62393
[+105.0s]   Advisory locks in the database: none
[+105.0s]   Subscription processes alive: sub-a false, sub-b false
[+110.0s] Appended event 2. Received within 5 s: nothing
[+115.0s]   subscribe_to_all_streams("sub-c") returned after 5.0 s: {:exit, {:timeout, {GenServer, :call, [#PID<0.349.0>, {:connect, ...}, 5000]}}}
[+115.0s]   AdvisoryLocks in DBConnection.Holder.checkout_call/5, mailbox 5 | advisory locks: none
[+115.0s] Killing Store.EventStore.AdvisoryLocks #PID<0.263.0>
[+115.1s] Store.EventStore.AdvisoryLocks restarted as #PID<0.402.0>
[+115.1s] Subscribed sub-a again: #PID<0.464.0>
[+115.1s] Subscribed sub-b again: #PID<0.466.0>
[+115.1s] Advisory locks: backend 67089 holds keys [1, 2]
[+120.1s] Appended event 3. Received within 5 s: sub-a [2, 3], sub-b [2, 3]

The control run, shortened to its key lines. Both subscriptions crash at +17 s as before, but the next pool (alive from the +40.0s line) serves the queued checkout, AdvisoryLocks is back in :gen_server.loop/7, and sub-c takes its lock:

[+0.0s] Proxy refuses new connections for 20 s. pg_terminate_backend(56313), the backend holding the locks: true
[+1.0s] Lock pool #PID<0.260.0> exited: :killed
[+17.0s] Subscription sub-b #PID<0.330.0> exited: {:timeout, {GenServer, :call, [Store.EventStore.AdvisoryLocks, {:try_advisory_lock, 2, #PID<0.330.0>, 15000}, 15000]}}
[+17.0s] Subscription sub-a #PID<0.328.0> exited: {:timeout, {GenServer, :call, [Store.EventStore.AdvisoryLocks, {:try_advisory_lock, 1, #PID<0.328.0>, 15000}, 15000]}}
[+20.0s] Proxy forwards new connections again
[+40.0s] lock pool #PID<0.336.0> alive=true, MonitoredServer queue 0 | AdvisoryLocks in :gen_server.loop/7, mailbox 0 | subscriptions alive: 0 | advisory locks: none
[+85.0s]   subscribe_to_all_streams("sub-c") returned after 0.0 s: {:ok, #PID<0.350.0>}
[+85.0s]   AdvisoryLocks in :gen_server.loop/7, mailbox 0 | advisory locks: backend 59763 holds keys [3]
Versions

eventstore 1.4.10 (the same code as master at 49ba9f9), postgrex 0.22.4, db_connection 2.10.2, PostgreSQL 18.6, Elixir 1.18.4, OTP 27. The receive in DBConnection.Holder.checkout_call/5 is the same on db_connection master.

Lingua principale
Elixir
Stelle
1.2k
Fork
156
Metriche di merge delle PR
Nessuna PR unita negli ultimi 30g

Preparare l'ambiente

  • Include un Dockerfile o un file Docker Compose
  • Nessun modello di pull request
  • Nessuna guida per i contributori

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di commanded/eventstore

Tutte le issue di commanded/eventstore

Issue simili

Altre issue su Elixir

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.