test/openagents/work/handoff_test.exs

main at 436d37facc96 · 6 KB

defmodule OpenAgents.Work.HandoffTest do
  @moduledoc """
  M1 chaos proof at the supervision layer: a cluster-wide singleton under the
  app's own Horde topology (`OpenAgents.HordeRegistry` + `OpenAgents.HordeSupervisor`,
  `members: :auto`, `keys: :unique`) **relocates to a survivor when its node
  dies**, and stays a singleton (exactly one registration) across the handoff.

  This is the mechanism `OpenAgents.Work.JobServer`/`DelegationServer` ride on: the
  app-specific rehydrate-and-finish glue (`claim_for_run` adopt +
  `finish_job` terminal idempotency) is proven against the real DB in
  `work_job_test.exs`; here we prove the distributed relocation itself, across
  real BEAM nodes. Tagged `:cluster`, excluded by default; run with
  `mix test --include cluster` (needs epmd).
  """
  use ExUnit.Case, async: false

  @moduletag :cluster

  @registry OpenAgents.HordeRegistry
  @supervisor OpenAgents.HordeSupervisor

  setup do
    case ensure_distributed() do
      :ok -> :ok
      :unavailable -> flunk("distribution unavailable — start epmd (`epmd -daemon`)")
    end
  end

  test "a Horde singleton relocates to a survivor when its node dies, staying unique" do
    cookie = :erlang.get_cookie()
    {peer1, node1} = start_peer(:openagents_handoff_peer1, cookie)
    {peer2, node2} = start_peer(:openagents_handoff_peer2, cookie)

    on_exit(fn ->
      for p <- [peer1, peer2], do: safe_stop(p)
    end)

    # All three nodes run the app's Horde components and converge on membership.
    assert eventually(fn ->
             members = horde_member_nodes()
             node1 in members and node2 in members and node() in members
           end),
           "Horde membership did not converge across the 3 nodes: #{inspect(horde_member_nodes())}"

    # Find a singleton key whose owner node is peer1, so killing peer1 forces a
    # real cross-node relocation. Horde places by hashing the child id over the
    # member ring, so we probe keys until one lands on peer1.
    {key, pid0} = singleton_on(node1)
    assert node(pid0) == node1

    # Kill peer1 (hard node loss).
    safe_stop(peer1)

    # The singleton reappears on a surviving node, with a NEW pid, and there is
    # exactly one registration for the key (no split/duplicate).
    assert eventually(
             fn ->
               case Horde.Registry.lookup(@registry, key) do
                 [{pid, _}] -> node(pid) in [node(), node2] and pid != pid0
                 _ -> false
               end
             end,
             300
           ),
           "singleton did not relocate to a survivor after node loss"

    assert [{pid1, _}] = Horde.Registry.lookup(@registry, key)
    assert node(pid1) in [node(), node2]
    assert node(pid1) != node1
    # The relocated Agent still answers — it was restarted, not orphaned.
    # (:sys.get_state ships no closure to the remote node, unlike Agent.get.)
    assert :sys.get_state(pid1) == :handoff_singleton
  end

  # ── helpers ──────────────────────────────────────────────────────────────

  # Start a singleton Agent (a stdlib process, so no custom module needs to be
  # loaded on peers) under Horde, probing keys until it lands on `target_node`.
  defp singleton_on(target_node) do
    Enum.find_value(0..60, fn i ->
      key = {:work_job, "handoff-#{i}"}
      spec = OpenAgents.Test.HordePeer.singleton_spec(key)

      case Horde.DynamicSupervisor.start_child(@supervisor, spec) do
        {:ok, _pid} ->
          # give Horde a beat to route/register, then see where it landed
          if pid = wait_lookup(key) do
            if node(pid) == target_node, do: {key, pid}, else: nil
          end

        {:error, {:already_started, _}} ->
          nil

        _ ->
          nil
      end
    end) || flunk("could not place a singleton on #{inspect(target_node)}")
  end

  defp wait_lookup(key) do
    Enum.find_value(1..40, fn _ ->
      case Horde.Registry.lookup(@registry, key) do
        [{pid, _}] -> pid
        _ -> Process.sleep(50) && nil
      end
    end)
  end

  defp horde_member_nodes do
    @registry
    |> Horde.Cluster.members()
    |> Enum.map(fn
      {_name, n} -> n
      n when is_atom(n) -> n
    end)
    |> Enum.uniq()
  end

  defp start_peer(name, cookie) do
    {:ok, peer, node} =
      :peer.start_link(%{
        name: unique_peer_name(name),
        host: ~c"127.0.0.1",
        shutdown: OpenAgents.Test.RemoteCover.shutdown(),
        args: [~c"-setcookie", Atom.to_charlist(cookie)]
      })

    # Share this node's code paths so horde + deps + support modules load.
    :ok = :erpc.call(node, :code, :add_paths, [:code.get_path()])
    {:ok, _apps} = :erpc.call(node, Application, :ensure_all_started, [:horde])

    # Start the app's Horde topology on the peer, owned by a persistent keeper
    # (see OpenAgents.Test.HordePeer) so it lives with the node and dies with it.
    {:ok, _keeper} = :erpc.call(node, OpenAgents.Test.HordePeer, :start!, [])

    {peer, node}
  end

  defp safe_stop(peer) do
    _ = :peer.stop(peer)
    :ok
  rescue
    _ -> :ok
  catch
    _, _ -> :ok
  end

  defp ensure_distributed do
    cond do
      Node.self() != :nonode@nohost ->
        :ok

      # A fixed node name wedges this whole stage on any machine where an
      # earlier run left that name registered with epmd: net_kernel then
      # refuses to start and every distribution test flunks "unavailable".
      # Unique per run, and OpenAgents-named now that this is not Sarah's BEAM.
      match?({:ok, _}, :net_kernel.start([unique_test_node(), :longnames])) ->
        :erlang.set_cookie(Node.self(), :openagents_cluster_test_cookie)
        :ok

      true ->
        :unavailable
    end
  end

  defp eventually(fun, attempts \\ 120) do
    cond do
      fun.() -> true
      attempts <= 0 -> false
      true -> Process.sleep(100) && eventually(fun, attempts - 1)
    end
  end

  defp unique_test_node do
    :erlang.list_to_atom(~c"openagents_test_#{:erlang.unique_integer([:positive])}@127.0.0.1")
  end

  # Peer node names register with epmd too. A fixed name that a killed peer
  # left behind makes the next run's :peer.start_link fail, so the gate is
  # green once and wedged thereafter. Suffix every peer uniquely.
  defp unique_peer_name(base) do
    :erlang.list_to_atom(~c"#{base}_#{:erlang.unique_integer([:positive])}")
  end
end