Fail closed when Forge caches cannot materialize

360f9c95fc4a · AtlantisPleb · · parent e98c9c5065c7

Fail closed when Forge caches cannot materialize

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified README.md
  • modified docs/2026-08-21-repository-storage-architecture-audit.md
  • added docs/operations/forge-cache-recovery.md
  • modified lib/openagents/application.ex
  • modified lib/openagents/cluster.ex
  • modified lib/openagents/forge/browse.ex
  • added lib/openagents/forge/cache_readiness.ex
  • modified lib/openagents/forge/git_http.ex
  • modified lib/openagents/forge/pushes.ex
  • modified lib/openagents/forge/repos.ex
  • modified lib/openagents/forge/sync.ex
  • added lib/openagents/forge/sync_error.ex
  • modified lib/openagents_web/controllers/health_controller.ex
  • modified test/openagents/forge/git_http_test.exs
  • modified test/openagents/forge/sync_test.exs
  • modified test/openagents_web/controllers/health_controller_test.exs

Diff

16 files changed, +548 -125

README.md modified +3 -1

@@ -57,7 +57,9 @@ itself establish that a feature is enabled for every production user.

57 57
  evidence before production promotion.
58 58
59 59
Read the [Forge hot loop runbook](docs/operations/forge-hot-loop.md) for the
60
current deployment contract and production evidence.
60
current deployment contract and production evidence. Read the
61
[Forge cache recovery runbook](docs/operations/forge-cache-recovery.md) when a
62
repository read differs between fleet nodes or returns `503`.
61 63
62 64
## Architecture
63 65
docs/2026-08-21-repository-storage-architecture-audit.md modified +1 -1

@@ -479,7 +479,7 @@ An audit that lists only faults misrepresents the design. Four decisions here ar

479 479
480 480
- **Refs live in exactly one place.** The WAL is authority, PostgreSQL receipts are derived from it and reconciled by sequence number (`lib/openagents/forge/pushes.ex:143`), and the bare repositories declare themselves cache (`lib/openagents/forge/repos.ex:3`). Almost every hard bug in a distributed Git host comes from having two ref authorities. This system has one.
481 481
- **The acknowledgement barrier is in the right place.** A push is not acknowledged until it is durable, and a WAL rejection rolls local refs back (`lib/openagents/forge/pushes.ex:57`, `:82`).
482
- **Reads degrade instead of failing.** An unreachable WAL logs and serves the local cache (`lib/openagents/forge/sync.ex:30`), and a damaged cache serves what it has rather than turning a node-local fault into a public outage (`lib/openagents/forge/browse.ex:49`).
482
- **Reads fail closed when the cache is not authoritative.** A repository-cache synchronization failure returns `503`, marks the node unready, and preserves the last complete local projection for recovery. It never turns missing cache objects into an authoritative `404` (`lib/openagents/forge/sync.ex`, `lib/openagents/forge/browse.ex`).
483 483
- **Git invocations are argv-only.** No request-derived value ever enters a shell string, including in the one place `sh` is used for stdin redirection (`lib/openagents/forge/repos.ex:7`, `lib/openagents/forge/git_http.ex:381`).
484 484
485 485
---
docs/operations/forge-cache-recovery.md added +90

@@ -0,0 +1,90 @@

1
# Forge cache recovery
2
3
Date: 2026-08-22
4
5
Status: Active operational procedure.
6
7
Use this runbook when the same forge blob, tree, or clone request differs
8
between fleet nodes or returns `503`. The WAL is the durable push authority.
9
Each node's bare Git repository is a disposable projection of that authority.
10
11
## Expected failure behavior
12
13
`OpenAgents.Forge.Sync` serializes synchronization and pushes per repository.
14
When it detects missing objects, it builds a complete sibling repository,
15
verifies every authoritative ref, and atomically activates that repository.
16
Readers never observe the sibling while it is incomplete.
17
18
If WAL replay or activation fails, the node:
19
20
- preserves the last complete local repository cache;
21
- returns `503` for the affected forge read instead of a false `404`;
22
- reports `forge_cache_ready: false` through the local cluster health report;
23
- returns `503` from `/health` so the load balancer can stop admitting traffic;
24
- logs `forge_sync_unavailable` with the repository, operation, and typed cause.
25
26
A later successful synchronization clears that repository's failure and
27
restores readiness without a process restart.
28
29
## Diagnose a divergent node
30
31
1. Request the same blob or Git ref advertisement directly from every fleet
32
   node. Record the node name, HTTP status, response size, and revision.
33
2. Compare the affected repository's applied WAL sequence on every node. A
34
   sequence of `0` or a sequence behind healthy peers identifies a stale local
35
   projection; it does not identify a missing durable push.
36
3. Search the node log for `forge_sync_cache_rebuild` and
37
   `forge_sync_unavailable`. Record the `operation`, `code`, and repository.
38
4. Inspect ownership and write permissions from the repository root through
39
   the failing object's two-character fan-out directory. Run the check as the
40
   same user that runs the release.
41
5. Verify that the WAL index and every referenced WAL object remain readable.
42
   Stop if the authority is unavailable. Do not infer repository absence from
43
   a cache failure.
44
45
The 2026-08-22 incident followed this pattern. One of three production nodes
46
had a root-owned Git object fan-out directory under a cache otherwise owned by
47
the release user. WAL replay failed with `EACCES` on that node. Requests through
48
the load balancer alternated between successful responses from two nodes and
49
false `404` responses from the damaged node.
50
51
## Recover one node
52
53
1. Keep the node out of load-balancer admission while
54
   `forge_cache_ready` is `false`.
55
2. Confirm that the WAL is readable and that another node can materialize the
56
   same repository and authoritative refs.
57
3. Correct the state-directory ownership so the release user can create Git
58
   object fan-out directories and files. Apply the correction only to the
59
   affected state path.
60
4. Move the affected bare repository cache aside. Do not delete or modify WAL
61
   indexes or WAL objects. Keep the moved cache until verification finishes.
62
5. Trigger repository synchronization or restart the application so boot
63
   convergence replays the WAL into a new local cache.
64
6. Verify the applied WAL sequence, every authoritative ref, the reported blob,
65
   Git ref advertisement, and `/health` on that node.
66
7. Restore load-balancer admission only after `forge_cache_ready` and the
67
   aggregate `ready` value are both `true`.
68
8. Remove the moved cache after the node passes verification.
69
70
## Prevent recurrence
71
72
- Create and mount the forge state directory with the release user's numeric
73
  UID and GID before the application starts.
74
- Never run cache repair, import, or Git maintenance commands as a different
75
  user against the live state directory.
76
- Alert when `forge_cache_ready` becomes `false` or when
77
  `forge_sync_unavailable` appears.
78
- Include a multi-node blob and clone comparison in staging resilience tests.
79
- Treat a forge `503` as retryable. Treat `404` only as an authoritative
80
  repository, ref, tree, or blob absence after synchronization succeeds.
81
82
## Focused verification
83
84
Run the cache, Git HTTP, and health contracts together:
85
86
```sh
87
mix test test/openagents/forge/sync_test.exs \
88
  test/openagents/forge/git_http_test.exs \
89
  test/openagents_web/controllers/health_controller_test.exs
90
```
lib/openagents/application.ex modified +1

@@ -46,6 +46,7 @@ defmodule OpenAgents.Application do

46 46
        OpenAgentsWeb.Telemetry,
47 47
        OpenAgents.Repo,
48 48
        OpenAgents.ReleaseState,
49
        OpenAgents.Forge.CacheReadiness,
49 50
        # Deployment identity and boot convergence must settle before cluster
50 51
        # discovery or the endpoint can make this node externally reachable.
51 52
        OpenAgents.Forge.DeploymentNode,
lib/openagents/cluster.ex modified +4 -1

@@ -67,6 +67,7 @@ defmodule OpenAgents.Cluster do

67 67
    boot_ready? = OpenAgents.Forge.BootConverge.ready?()
68 68
    deployment = OpenAgents.Forge.DeploymentNode.health()
69 69
    admission_ready? = OpenAgents.Cluster.Admission.ready?()
70
    forge_cache_ready? = OpenAgents.Forge.CacheReadiness.ready?()
70 71
71 72
    %{
72 73
      "schema" => "openagents.cluster_health.v1",

@@ -77,9 +78,11 @@ defmodule OpenAgents.Cluster do

77 78
      "boot_converged" => boot_ready?,
78 79
      "deployment_ready" => deployment["participant_ready"],
79 80
      "admission_ready" => admission_ready?,
81
      "forge_cache_ready" => forge_cache_ready?,
80 82
      "uptime_ms" => uptime_ms(),
81 83
      "live" => true,
82
      "ready" => boot_ready? and deployment["ready"] == true and admission_ready?
84
      "ready" =>
85
        boot_ready? and deployment["ready"] == true and admission_ready? and forge_cache_ready?
83 86
    }
84 87
  end
85 88
lib/openagents/forge/browse.ex modified +1 -21

@@ -11,8 +11,6 @@ defmodule OpenAgents.Forge.Browse do

11 11
  never stream an unbounded `git show` to an anonymous socket.
12 12
  """
13 13
14
  require Logger
15
16 14
  alias OpenAgents.Forge.{Repos, Sync}
17 15
  alias OpenAgents.Repositories.Repository
18 16

@@ -46,26 +44,8 @@ defmodule OpenAgents.Forge.Browse do

46 44
    end
47 45
  end
48 46
49
  # A node whose bare-repo cache is damaged (the 2026-08-19 incident, #134:
50
  # a torn loose object after a VM reset) makes WAL replay raise. That is a
51
  # node-local cache fault, not a reason to fail a public read: serve what
52
  # this node already has, and let the operator repair path fix the cache.
53 47
  defp freshen(repo) do
54
    Sync.ensure_fresh(storage_key(repo), default_branch(repo))
55
  rescue
56
    error ->
57
      Logger.warning(
58
        "forge_browse_sync_failed repo=#{storage_key(repo)} code=#{OpenAgents.OperationalLog.code(error)}"
59
      )
60
61
      :ok
62
  catch
63
    :exit, reason ->
64
      Logger.warning(
65
        "forge_browse_sync_exited repo=#{storage_key(repo)} code=#{OpenAgents.OperationalLog.code(reason)}"
66
      )
67
68
      :ok
48
    Sync.ensure_fresh!(storage_key(repo), default_branch(repo))
69 49
  end
70 50
71 51
  @doc "The default branch head, if the repo has one."
lib/openagents/forge/cache_readiness.ex added +54

@@ -0,0 +1,54 @@

1
defmodule OpenAgents.Forge.CacheReadiness do
2
  @moduledoc """
3
  Tracks repository caches that this node cannot materialize from the WAL.
4
5
  A successful synchronization clears the repository's failure. Readiness
6
  returns only when no observed cache failure remains.
7
  """
8
9
  use GenServer
10
11
  def start_link(options \\ []) do
12
    GenServer.start_link(__MODULE__, %{}, Keyword.put_new(options, :name, __MODULE__))
13
  end
14
15
  def mark_unavailable(repo, code) do
16
    GenServer.call(__MODULE__, {:mark_unavailable, repo, code})
17
  end
18
19
  def mark_available(repo) do
20
    GenServer.call(__MODULE__, {:mark_available, repo})
21
  end
22
23
  def ready? do
24
    GenServer.call(__MODULE__, :ready?)
25
  end
26
27
  def report do
28
    GenServer.call(__MODULE__, :report)
29
  end
30
31
  @doc false
32
  def reset do
33
    GenServer.call(__MODULE__, :reset)
34
  end
35
36
  @impl true
37
  def init(failures), do: {:ok, failures}
38
39
  @impl true
40
  def handle_call({:mark_unavailable, repo, code}, _from, failures) do
41
    {:reply, :ok, Map.put(failures, repo, code)}
42
  end
43
44
  def handle_call({:mark_available, repo}, _from, failures),
45
    do: {:reply, :ok, Map.delete(failures, repo)}
46
47
  def handle_call(:ready?, _from, failures),
48
    do: {:reply, map_size(failures) == 0, failures}
49
50
  def handle_call(:report, _from, failures),
51
    do: {:reply, %{"ready" => map_size(failures) == 0, "failures" => failures}, failures}
52
53
  def handle_call(:reset, _from, _failures), do: {:reply, :ok, %{}}
54
end
lib/openagents/forge/git_http.ex modified +11 -4

@@ -57,8 +57,8 @@ defmodule OpenAgents.Forge.GitHTTP do

57 57
    operation = if service == "git-upload-pack", do: :read, else: :write
58 58
59 59
    with {:ok, repository} <- resolve_repository(conn, owner, name),
60
         :ok <- authorize(conn, repository, operation) do
61
      Sync.ensure_fresh(repository.storage_key, repository.default_branch)
60
         :ok <- authorize(conn, repository, operation),
61
         :ok <- Sync.ensure_fresh(repository.storage_key, repository.default_branch) do
62 62
      command = String.trim_leading(service, "git-")
63 63
      path = Repos.ensure_repo!(repository.storage_key, repository.default_branch)
64 64

@@ -83,8 +83,8 @@ defmodule OpenAgents.Forge.GitHTTP do

83 83
  defp upload_pack(conn, owner, name) do
84 84
    with {:ok, repository} <- resolve_repository(conn, owner, name),
85 85
         :ok <- authorize(conn, repository, :read),
86
         {:ok, body, conn} <- read_git_body(conn) do
87
      Sync.ensure_fresh(repository.storage_key, repository.default_branch)
86
         {:ok, body, conn} <- read_git_body(conn),
87
         :ok <- Sync.ensure_fresh(repository.storage_key, repository.default_branch) do
88 88
      path = Repos.ensure_repo!(repository.storage_key, repository.default_branch)
89 89
      {output, _status} = run_git_service("upload-pack", [path], body, git_protocol(conn))
90 90

@@ -127,6 +127,9 @@ defmodule OpenAgents.Forge.GitHTTP do

127 127
        {:error, :wal_persist_failed} ->
128 128
          send_resp(conn, 503, "push not persisted; refs rolled back — retry") |> halt()
129 129
130
        {:error, %OpenAgents.Forge.SyncError{}} ->
131
          send_resp(conn, 503, "repository cache unavailable; retry") |> halt()
132
130 133
        {:error, _reason} ->
131 134
          send_resp(conn, 500, "push failed") |> halt()
132 135
      end

@@ -305,6 +308,10 @@ defmodule OpenAgents.Forge.GitHTTP do

305 308
    conn |> send_resp(status, message) |> halt()
306 309
  end
307 310
311
  defp send_git_error(conn, {:error, %OpenAgents.Forge.SyncError{}}) do
312
    conn |> send_resp(503, "repository cache unavailable; retry") |> halt()
313
  end
314
308 315
  defp send_git_error(conn, {:error, status, message, headers}) do
309 316
    conn =
310 317
      Enum.reduce(headers, conn, fn {name, value}, acc -> put_resp_header(acc, name, value) end)
lib/openagents/forge/pushes.ex modified +53 -50

@@ -6,9 +6,9 @@ defmodule OpenAgents.Forge.Pushes do

6 6
  back and the client sees a failed push; the cache never gets ahead of the
7 7
  authority.
8 8
9
  Pushes serialize per repo cluster-wide via `:global.trans`; the WAL index
10
  CAS remains the true serialization point (a conflict from a writer outside
11
  the cluster is re-synced and retried once).
9
  Pushes and local cache synchronization share one per-repository lock on each
10
  node. The WAL index CAS remains the cluster-wide serialization point (a
11
  conflict from another writer is re-synced and retried once).
12 12
13 13
  After the WAL accepts: a `forge_pushes` receipt row is derived (idempotent
14 14
  by WAL sequence — audit A7: receipts are derived from the WAL, never a

@@ -30,60 +30,63 @@ defmodule OpenAgents.Forge.Pushes do

30 30
  only after WAL persist; `{:error, :wal_persist_failed}` after rollback.
31 31
  """
32 32
  def handle_receive_pack(repo, body, principal, git_protocol) do
33
    :global.trans({{:forge_push, repo}, self()}, fn ->
33
    Sync.with_repo_lock(repo, fn ->
34 34
      do_handle(repo, body, principal, git_protocol, false)
35 35
    end)
36 36
  end
37 37
38 38
  defp do_handle(repo, body, principal, git_protocol, retried?) do
39 39
    started_at = System.monotonic_time(:millisecond)
40
    Sync.ensure_fresh(repo)
41
    path = Repos.ensure_repo!(repo)
42
    refs_before = Repos.refs(repo)
43 40
44
    {output, status} = GitHTTP.run_git_service("receive-pack", [path], body, git_protocol)
45
46
    refs_after = Repos.refs(repo)
47
48
    cond do
49
      status != 0 ->
50
        {:error, :receive_pack_failed}
51
52
      refs_after == refs_before ->
53
        # Nothing changed (up to date, or all commands rejected by git);
54
        # the client's report-status in `output` says why. Nothing to persist.
55
        {:ok, output}
56
57
      true ->
58
        case persist(repo, body, refs_after, principal) do
59
          {:ok, seq} ->
60
            Repos.record_applied_seq!(repo, seq)
61
            record_repository_activity(repo)
62
63
            capture_push_received(
64
              repo,
65
              record_receipt(repo, seq, refs_before, refs_after, principal, started_at),
66
              refs_before,
67
              refs_after,
68
              started_at
69
            )
70
71
            broadcast(repo, seq, refs_after)
72
            mirror_async(repo)
73
            {:ok, output}
74
75
          {:error, :cas_conflict} when not retried? ->
76
            Sync.ensure_fresh(repo)
77
            do_handle(repo, body, principal, git_protocol, true)
78
79
          {:error, reason} ->
80
            Logger.error(
81
              "forge_push_wal_failed repo=#{repo} code=#{OpenAgents.OperationalLog.code(reason)}"
82
            )
83
84
            Repos.set_refs!(repo, refs_before)
85
            {:error, :wal_persist_failed}
86
        end
41
    with :ok <- Sync.ensure_fresh(repo) do
42
      path = Repos.ensure_repo!(repo)
43
      refs_before = Repos.refs(repo)
44
45
      {output, status} = GitHTTP.run_git_service("receive-pack", [path], body, git_protocol)
46
47
      refs_after = Repos.refs(repo)
48
49
      cond do
50
        status != 0 ->
51
          {:error, :receive_pack_failed}
52
53
        refs_after == refs_before ->
54
          # Nothing changed (up to date, or all commands rejected by git);
55
          # the client's report-status in `output` says why. Nothing to persist.
56
          {:ok, output}
57
58
        true ->
59
          case persist(repo, body, refs_after, principal) do
60
            {:ok, seq} ->
61
              Repos.record_applied_seq!(repo, seq)
62
              record_repository_activity(repo)
63
64
              capture_push_received(
65
                repo,
66
                record_receipt(repo, seq, refs_before, refs_after, principal, started_at),
67
                refs_before,
68
                refs_after,
69
                started_at
70
              )
71
72
              broadcast(repo, seq, refs_after)
73
              mirror_async(repo)
74
              {:ok, output}
75
76
            {:error, :cas_conflict} when not retried? ->
77
              with :ok <- Sync.ensure_fresh(repo) do
78
                do_handle(repo, body, principal, git_protocol, true)
79
              end
80
81
            {:error, reason} ->
82
              Logger.error(
83
                "forge_push_wal_failed repo=#{repo} code=#{OpenAgents.OperationalLog.code(reason)}"
84
              )
85
86
              Repos.set_refs!(repo, refs_before)
87
              {:error, :wal_persist_failed}
88
          end
89
      end
87 90
    end
88 91
  end
89 92
lib/openagents/forge/repos.ex modified +32 -8

@@ -51,28 +51,38 @@ defmodule OpenAgents.Forge.Repos do

51 51
52 52
  @doc "Initialize the bare repository if absent. Returns the path."
53 53
  def ensure_repo!(repo, default_branch \\ "main") do
54
    path = bare_path(repo)
54
    repo |> bare_path() |> ensure_repo_at!(default_branch)
55
  end
55 56
57
  @doc false
58
  def ensure_repo_at!(path, default_branch \\ "main") do
56 59
    unless File.exists?(Path.join(path, "HEAD")) do
57 60
      File.mkdir_p!(path)
58 61
      {_, 0} = git(path, ["init", "--bare", "--initial-branch=#{default_branch}", path])
59 62
    end
60 63
61
    set_default_branch!(repo, default_branch)
64
    set_default_branch_at!(path, default_branch)
62 65
63 66
    path
64 67
  end
65 68
66 69
  def set_default_branch!(repo, default_branch) do
67
    path = bare_path(repo)
70
    repo |> bare_path() |> set_default_branch_at!(default_branch)
71
  end
72
73
  @doc false
74
  def set_default_branch_at!(path, default_branch) do
68 75
    {_, 0} = git(path, ["symbolic-ref", "HEAD", "refs/heads/#{default_branch}"])
69 76
    :ok
70 77
  end
71 78
72 79
  @doc "Current refs of the bare repo as a `%{name => sha}` map."
73 80
  def refs(repo) do
74
    path = bare_path(repo)
81
    repo |> bare_path() |> refs_at()
82
  end
75 83
84
  @doc false
85
  def refs_at(path) do
76 86
    case git(path, ["for-each-ref", "--format=%(objectname) %(refname)"]) do
77 87
      {output, 0} ->
78 88
        output

@@ -93,8 +103,12 @@ defmodule OpenAgents.Forge.Repos do

93 103
  in the target.
94 104
  """
95 105
  def set_refs!(repo, target_refs) when is_map(target_refs) do
96
    path = bare_path(repo)
97
    current = refs(repo)
106
    repo |> bare_path() |> set_refs_at!(target_refs)
107
  end
108
109
  @doc false
110
  def set_refs_at!(path, target_refs) when is_map(target_refs) do
111
    current = refs_at(path)
98 112
99 113
    Enum.each(current, fn {name, _sha} ->
100 114
      unless Map.has_key?(target_refs, name) do

@@ -111,7 +125,12 @@ defmodule OpenAgents.Forge.Repos do

111 125
112 126
  @doc "The WAL sequence this bare repo has applied (cache freshness marker)."
113 127
  def applied_seq(repo) do
114
    case File.read(Path.join(bare_path(repo), "openagents-wal-seq")) do
128
    repo |> bare_path() |> applied_seq_at()
129
  end
130
131
  @doc false
132
  def applied_seq_at(path) do
133
    case File.read(Path.join(path, "openagents-wal-seq")) do
115 134
      {:ok, contents} ->
116 135
        case Integer.parse(String.trim(contents)) do
117 136
          {seq, _} -> seq

@@ -124,7 +143,12 @@ defmodule OpenAgents.Forge.Repos do

124 143
  end
125 144
126 145
  def record_applied_seq!(repo, seq) when is_integer(seq) do
127
    File.write!(Path.join(bare_path(repo), "openagents-wal-seq"), Integer.to_string(seq))
146
    repo |> bare_path() |> record_applied_seq_at!(seq)
147
  end
148
149
  @doc false
150
  def record_applied_seq_at!(path, seq) when is_integer(seq) do
151
    File.write!(Path.join(path, "openagents-wal-seq"), Integer.to_string(seq))
128 152
  end
129 153
130 154
  @doc "Run git with `--git-dir` pinned to the bare repo. Returns {output, status}."
lib/openagents/forge/sync.ex modified +125 -34

@@ -10,29 +10,32 @@ defmodule OpenAgents.Forge.Sync do

10 10
  require Logger
11 11
12 12
  alias OpenAgents.Cluster
13
  alias OpenAgents.Forge.{GitHTTP, Repos, WAL}
13
  alias OpenAgents.Forge.{CacheReadiness, GitHTTP, Repos, SyncError, WAL}
14 14
15 15
  @default_cluster_warm_timeout_ms 10 * 60 * 1_000
16 16
17 17
  @doc """
18
  Bring the local bare repo up to the WAL. Returns `:ok` (fresh, replayed,
19
  or nothing pushed yet) — read paths degrade to serving the local cache if
20
  the WAL is unreachable, logging honestly, rather than failing reads.
18
  Bring the local bare repo up to the WAL.
19
20
  Returns `:ok` when the projection is current and a typed error when the WAL
21
  or cache cannot produce an authoritative projection. Callers must not turn a
22
  synchronization error into a repository or object `404`.
21 23
  """
22 24
  def ensure_fresh(repo, default_branch \\ "main") do
23
    case WAL.read_index(repo) do
24
      {:error, :not_found} ->
25
        :ok
26
27
      {:ok, _generation, index} ->
28
        replay_missing(repo, index, default_branch)
29
30
      {:error, reason} ->
31
        Logger.warning(
32
          "forge_sync_wal_unreachable repo=#{repo} code=#{OpenAgents.OperationalLog.code(reason)}"
33
        )
25
    synchronize(repo, fn ->
26
      case WAL.read_index(repo) do
27
        {:error, :not_found} -> :ok
28
        {:ok, _generation, index} -> do_replay_missing(repo, index, default_branch)
29
        {:error, reason} -> raise_sync(repo, :read_wal, reason)
30
      end
31
    end)
32
  end
34 33
35
        :ok
34
  @doc "Bring the local bare repo up to the WAL or raise a `503`-typed error."
35
  def ensure_fresh!(repo, default_branch \\ "main") do
36
    case ensure_fresh(repo, default_branch) do
37
      :ok -> :ok
38
      {:error, %SyncError{} = error} -> raise error
36 39
    end
37 40
  end
38 41

@@ -69,39 +72,97 @@ defmodule OpenAgents.Forge.Sync do

69 72
70 73
  @doc "Replay WAL entries the local repo has not applied. Used by reads and boot."
71 74
  def replay_missing(repo, index, default_branch \\ "main") do
72
    Repos.ensure_repo!(repo, default_branch)
73
    applied = Repos.applied_seq(repo)
75
    synchronize(repo, fn -> do_replay_missing(repo, index, default_branch) end)
76
  end
77
78
  @doc false
79
  def with_repo_lock(repo, function) when is_function(function, 0) do
80
    lock_id = {{__MODULE__, repo}, self()}
81
82
    case :global.trans(lock_id, function, [node()]) do
83
      {:aborted, reason} -> raise_sync(repo, :acquire_lock, reason)
84
      result -> result
85
    end
86
  end
87
88
  defp do_replay_missing(repo, index, default_branch) do
89
    path = Repos.ensure_repo!(repo, default_branch)
90
    applied = Repos.applied_seq_at(path)
74 91
75 92
    index
76 93
    |> WAL.entries()
77 94
    |> Enum.filter(fn entry -> entry["seq"] > applied end)
78
    |> Enum.each(fn entry -> apply_entry!(repo, entry) end)
95
    |> Enum.each(fn entry -> apply_entry!(repo, path, entry) end)
79 96
80 97
    rebuild_if_objects_missing!(repo, index, default_branch)
81
    converge_refs(repo, index)
82
    Repos.set_default_branch!(repo, default_branch)
98
    path = Repos.bare_path(repo)
99
    converge_refs(path, index)
100
    Repos.set_default_branch_at!(path, default_branch)
83 101
    :ok
84 102
  end
85 103
86 104
  defp rebuild_if_objects_missing!(repo, index, default_branch) do
87 105
    unless refs_materialized?(repo, index) do
88 106
      Logger.warning("forge_sync_cache_rebuild repo=#{repo} code=missing_ref_object")
89
      :ok = Repos.delete_repo(repo)
90
      Repos.ensure_repo!(repo, default_branch)
107
      rebuild_at_sibling!(repo, index, default_branch)
108
    end
109
  end
110
111
  defp rebuild_at_sibling!(repo, index, default_branch) do
112
    live_path = Repos.bare_path(repo)
113
    suffix = System.unique_integer([:positive, :monotonic])
114
    rebuild_path = live_path <> ".rebuild-#{suffix}"
115
    previous_path = live_path <> ".previous-#{suffix}"
116
117
    try do
118
      Repos.ensure_repo_at!(rebuild_path, default_branch)
91 119
92 120
      index
93 121
      |> WAL.entries()
94
      |> Enum.each(fn entry -> apply_entry!(repo, entry) end)
122
      |> Enum.each(fn entry -> apply_entry!(repo, rebuild_path, entry) end)
123
124
      converge_refs(rebuild_path, index)
125
      Repos.set_default_branch_at!(rebuild_path, default_branch)
95 126
96
      unless refs_materialized?(repo, index) do
97
        raise "forge cache rebuild did not materialize every authoritative ref"
127
      unless refs_materialized_at?(rebuild_path, index) do
128
        raise_sync(repo, :verify_rebuild, :missing_ref_object)
98 129
      end
130
131
      swap_rebuild!(repo, live_path, rebuild_path, previous_path)
132
    after
133
      File.rm_rf(rebuild_path)
134
135
      # Keep the previous cache if activation and restoration both fail. It is
136
      # the last complete local projection an operator can recover.
137
      if File.exists?(live_path), do: File.rm_rf(previous_path)
138
    end
139
  end
140
141
  defp swap_rebuild!(repo, live_path, rebuild_path, previous_path) do
142
    live_exists? = File.exists?(live_path)
143
144
    if live_exists? do
145
      case File.rename(live_path, previous_path) do
146
        :ok -> :ok
147
        {:error, reason} -> raise_sync(repo, :stage_previous_cache, reason)
148
      end
149
    end
150
151
    case File.rename(rebuild_path, live_path) do
152
      :ok ->
153
        :ok
154
155
      {:error, reason} ->
156
        if live_exists?, do: File.rename(previous_path, live_path)
157
        raise_sync(repo, :activate_rebuild, reason)
99 158
    end
100 159
  end
101 160
102 161
  defp refs_materialized?(repo, index) do
103
    path = Repos.bare_path(repo)
162
    refs_materialized_at?(Repos.bare_path(repo), index)
163
  end
104 164
165
  defp refs_materialized_at?(path, index) do
105 166
    index
106 167
    |> WAL.refs()
107 168
    |> Map.values()

@@ -111,9 +172,7 @@ defmodule OpenAgents.Forge.Sync do

111 172
    end)
112 173
  end
113 174
114
  defp apply_entry!(repo, %{"seq" => seq, "object" => object} = entry) do
115
    path = Repos.bare_path(repo)
116
175
  defp apply_entry!(repo, path, %{"seq" => seq, "object" => object} = entry) do
117 176
    case entry["format"] || "receive_pack" do
118 177
      "receive_pack" ->
119 178
        {:ok, payload} = WAL.get_entry(repo, object)

@@ -126,17 +185,17 @@ defmodule OpenAgents.Forge.Sync do

126 185
        :ok
127 186
    end
128 187
129
    Repos.record_applied_seq!(repo, seq)
188
    Repos.record_applied_seq_at!(path, seq)
130 189
  end
131 190
132 191
  # Replay is exact in the common case; converge_refs makes the final state
133 192
  # authoritative even if an individual replayed request was non-idempotent
134 193
  # (e.g. a non-fast-forward the original push forced).
135
  defp converge_refs(repo, index) do
194
  defp converge_refs(path, index) do
136 195
    target = WAL.refs(index)
137 196
138
    if Repos.refs(repo) != target do
139
      Repos.set_refs!(repo, target)
197
    if Repos.refs_at(path) != target do
198
      Repos.set_refs_at!(path, target)
140 199
    end
141 200
  end
142 201

@@ -206,4 +265,36 @@ defmodule OpenAgents.Forge.Sync do

206 265
  catch
207 266
    :exit, reason -> {:error, reason}
208 267
  end
268
269
  defp synchronize(repo, function) do
270
    result = with_repo_lock(repo, function)
271
    CacheReadiness.mark_available(repo)
272
    result
273
  rescue
274
    error ->
275
      sync_error = normalize_error(repo, error)
276
      CacheReadiness.mark_unavailable(repo, sync_error.operation)
277
278
      Logger.error(
279
        "forge_sync_unavailable repo=#{repo} operation=#{sync_error.operation} " <>
280
          "code=#{OpenAgents.OperationalLog.code(sync_error.reason)} detail=#{inspect(sync_error.reason)}"
281
      )
282
283
      {:error, sync_error}
284
  catch
285
    kind, reason ->
286
      sync_error = %SyncError{repo: repo, operation: :materialize_cache, reason: {kind, reason}}
287
      CacheReadiness.mark_unavailable(repo, sync_error.operation)
288
      {:error, sync_error}
289
  end
290
291
  defp normalize_error(_repo, %SyncError{} = error), do: error
292
293
  defp normalize_error(repo, error) do
294
    %SyncError{repo: repo, operation: :materialize_cache, reason: error}
295
  end
296
297
  defp raise_sync(repo, operation, reason) do
298
    raise SyncError, repo: repo, operation: operation, reason: reason
299
  end
209 300
end
lib/openagents/forge/sync_error.ex added +10

@@ -0,0 +1,10 @@

1
defmodule OpenAgents.Forge.SyncError do
2
  @moduledoc "A typed repository-cache synchronization failure."
3
4
  defexception [:repo, :operation, :reason, plug_status: 503]
5
6
  @impl true
7
  def message(%__MODULE__{repo: repo, operation: operation}) do
8
    "Forge repository #{repo} is temporarily unavailable during #{operation}"
9
  end
10
end
lib/openagents_web/controllers/health_controller.ex modified +2 -1

@@ -14,7 +14,8 @@ defmodule OpenAgentsWeb.HealthController do

14 14
          reason: "runtime_not_ready",
15 15
          boot_converged: report["boot_converged"],
16 16
          deployment_ready: report["deployment_ready"],
17
          admission_ready: report["admission_ready"]
17
          admission_ready: report["admission_ready"],
18
          forge_cache_ready: report["forge_cache_ready"]
18 19
        })
19 20
20 21
      {{:error, _reason}, _report} ->
test/openagents/forge/git_http_test.exs modified +36 -1

@@ -11,7 +11,7 @@ defmodule OpenAgents.Forge.GitHTTPTest do

11 11
  import OpenAgents.AccountsFixtures
12 12
13 13
  alias OpenAgents.{AuditEvent, Forge, Machines, Repo, Repositories}
14
  alias OpenAgents.Forge.{Repos, WAL}
14
  alias OpenAgents.Forge.{CacheReadiness, Repos, WAL}
15 15
16 16
  defmodule TestPipeline do
17 17
    @moduledoc false

@@ -30,6 +30,7 @@ defmodule OpenAgents.Forge.GitHTTPTest do

30 30
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
31 31
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
32 32
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
33
    CacheReadiness.reset()
33 34
34 35
    user = repository_user_fixture("git-http-owner")
35 36

@@ -54,6 +55,7 @@ defmodule OpenAgents.Forge.GitHTTPTest do

54 55
    on_exit(fn ->
55 56
      Application.put_env(:openagents, :forge_data_dir, previous_data)
56 57
      Application.put_env(:openagents, :forge_wal_dir, previous_wal)
58
      CacheReadiness.reset()
57 59
      File.rm_rf(base)
58 60
    end)
59 61

@@ -194,6 +196,39 @@ defmodule OpenAgents.Forge.GitHTTPTest do

194 196
    assert Repos.refs(repository.storage_key) == refs_before
195 197
  end
196 198
199
  test "an unavailable cache returns 503 instead of a false repository 404", %{
200
    repository: repository,
201
    token: token
202
  } do
203
    entry = %{
204
      "seq" => 0,
205
      "object" => "entries/missing",
206
      "format" => "git_bundle",
207
      "refs" => %{"refs/heads/main" => String.duplicate("a", 40)},
208
      "principal" => "test",
209
      "pushed_at" => DateTime.to_iso8601(DateTime.utc_now())
210
    }
211
212
    {:ok, _generation} =
213
      WAL.cas_index(
214
        repository.storage_key,
215
        :none,
216
        WAL.append_entry(WAL.new_index(), entry)
217
      )
218
219
    authorization = "Basic " <> Base.encode64("x:#{token}")
220
221
    response =
222
      :get
223
      |> Plug.Test.conn("/git-http-owner/demo.git/info/refs?service=git-upload-pack")
224
      |> Plug.Conn.put_req_header("authorization", authorization)
225
      |> TestPipeline.call([])
226
227
    assert response.status == 503
228
    assert response.resp_body == "repository cache unavailable; retry"
229
    refute CacheReadiness.ready?()
230
  end
231
197 232
  test "unauthenticated and wrong-token pushes are refused", %{
198 233
    base: base,
199 234
    port: port,
test/openagents/forge/sync_test.exs modified +100 -1

@@ -1,7 +1,7 @@

1 1
defmodule OpenAgents.Forge.SyncTest do
2 2
  use ExUnit.Case, async: false
3 3
4
  alias OpenAgents.Forge.{Browse, Repos, Sync, WAL}
4
  alias OpenAgents.Forge.{Browse, CacheReadiness, Repos, Sync, SyncError, WAL}
5 5
  alias OpenAgents.Repositories.Repository
6 6
7 7
  setup do

@@ -15,11 +15,13 @@ defmodule OpenAgents.Forge.SyncTest do

15 15
    Application.put_env(:openagents, :forge_data_dir, Path.join(root, "data"))
16 16
    Application.put_env(:openagents, :forge_wal_dir, Path.join(root, "wal"))
17 17
    Application.put_env(:openagents, :forge_wal_adapter, OpenAgents.Forge.WAL.Local)
18
    CacheReadiness.reset()
18 19
19 20
    on_exit(fn ->
20 21
      restore_env(:forge_data_dir, previous_data)
21 22
      restore_env(:forge_wal_dir, previous_wal)
22 23
      restore_env(:forge_wal_adapter, previous_adapter)
24
      CacheReadiness.reset()
23 25
      File.rm_rf!(root)
24 26
    end)
25 27

@@ -104,6 +106,72 @@ defmodule OpenAgents.Forge.SyncTest do

104 106
    assert Repos.refs("empty-authority") == %{}
105 107
  end
106 108
109
  test "a failed sibling rebuild preserves the last complete cache and fails with 503", %{
110
    root: root
111
  } do
112
    {valid_index, sha} = put_bundle_entry!(root, "unavailable-cache", "trunk")
113
    assert :ok = Sync.ensure_fresh("unavailable-cache", "trunk")
114
115
    bare_path = Repos.bare_path("unavailable-cache")
116
    Repos.record_applied_seq_at!(bare_path, 1)
117
118
    missing_entry = %{
119
      "seq" => 1,
120
      "object" => "entries/00000001-000000000000",
121
      "format" => "git_bundle",
122
      "refs" => %{"refs/heads/trunk" => String.duplicate("a", 40)},
123
      "principal" => "test",
124
      "pushed_at" => DateTime.to_iso8601(DateTime.utc_now())
125
    }
126
127
    {:ok, generation, _index} = WAL.read_index("unavailable-cache")
128
129
    {:ok, next_generation} =
130
      WAL.cas_index("unavailable-cache", generation, WAL.append_entry(valid_index, missing_entry))
131
132
    assert {:error, %SyncError{operation: :materialize_cache, plug_status: 503} = error} =
133
             Sync.ensure_fresh("unavailable-cache", "trunk")
134
135
    assert Plug.Exception.status(error) == 503
136
    refute CacheReadiness.ready?()
137
    assert CacheReadiness.report()["failures"] == %{"unavailable-cache" => :materialize_cache}
138
139
    # The rebuild failed before activation, so readers never receive a partial
140
    # repository and the last complete cache remains available for recovery.
141
    assert String.trim(git_bare!(bare_path, ["show", "trunk:README.md"])) == "durable import"
142
    assert Repos.refs("unavailable-cache") == %{"refs/heads/trunk" => sha}
143
    assert Path.wildcard(bare_path <> ".rebuild-*") == []
144
    assert Path.wildcard(bare_path <> ".previous-*") == []
145
146
    repository = %Repository{storage_key: "unavailable-cache", default_branch: "trunk"}
147
    assert_raise SyncError, fn -> Browse.blob(repository, "trunk", "README.md") end
148
149
    # Once the authority becomes materializable again, the next successful
150
    # synchronization restores this node's readiness without a restart.
151
    assert {:ok, _generation} =
152
             WAL.cas_index("unavailable-cache", next_generation, valid_index)
153
154
    assert :ok = Sync.ensure_fresh("unavailable-cache", "trunk")
155
    assert CacheReadiness.ready?()
156
  end
157
158
  test "concurrent readers serialize one repository cache replay", %{root: root} do
159
    {_index, sha} = put_bundle_entry!(root, "serialized-cache", "trunk")
160
161
    results =
162
      1..12
163
      |> Task.async_stream(
164
        fn _reader -> Sync.ensure_fresh("serialized-cache", "trunk") end,
165
        max_concurrency: 12,
166
        timeout: 10_000
167
      )
168
      |> Enum.to_list()
169
170
    assert Enum.all?(results, &(&1 == {:ok, :ok}))
171
    assert Repos.refs("serialized-cache") == %{"refs/heads/trunk" => sha}
172
    assert CacheReadiness.ready?()
173
  end
174
107 175
  test "cluster warming materializes the local cache and every connected peer" do
108 176
    test_process = self()
109 177
    peer = :"peer@127.0.0.1"

@@ -147,6 +215,37 @@ defmodule OpenAgents.Forge.SyncTest do

147 215
    output
148 216
  end
149 217
218
  defp put_bundle_entry!(root, repository, branch) do
219
    source = Path.join(root, "#{repository}-source")
220
    File.mkdir_p!(source)
221
    git!(source, ["init", "--initial-branch=#{branch}"])
222
    git!(source, ["config", "user.email", "test@example.com"])
223
    git!(source, ["config", "user.name", "Forge test"])
224
    File.write!(Path.join(source, "README.md"), "durable import\n")
225
    git!(source, ["add", "README.md"])
226
    git!(source, ["commit", "-m", "Imported commit"])
227
228
    sha = source |> git!(["rev-parse", "HEAD"]) |> String.trim()
229
    bundle = Path.join(root, "#{repository}.bundle")
230
    git!(source, ["bundle", "create", bundle, "--all"])
231
    refs = %{"refs/heads/#{branch}" => sha}
232
233
    {:ok, object} = WAL.put_entry_file(repository, 0, bundle)
234
235
    entry = %{
236
      "seq" => 0,
237
      "object" => object,
238
      "format" => "git_bundle",
239
      "refs" => refs,
240
      "principal" => "test",
241
      "pushed_at" => DateTime.to_iso8601(DateTime.utc_now())
242
    }
243
244
    index = WAL.append_entry(WAL.new_index(), entry)
245
    {:ok, _generation} = WAL.cas_index(repository, :none, index)
246
    {index, sha}
247
  end
248
150 249
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
151 250
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
152 251
end
test/openagents_web/controllers/health_controller_test.exs modified +25 -2

@@ -1,6 +1,12 @@

1 1
defmodule OpenAgentsWeb.HealthControllerTest do
2 2
  use OpenAgentsWeb.ConnCase
3 3
4
  setup do
5
    OpenAgents.Forge.CacheReadiness.reset()
6
    on_exit(&OpenAgents.Forge.CacheReadiness.reset/0)
7
    :ok
8
  end
9
4 10
  test "reports healthy when PostgreSQL is reachable", %{conn: conn} do
5 11
    conn = get(conn, ~p"/health")
6 12

@@ -40,7 +46,8 @@ defmodule OpenAgentsWeb.HealthControllerTest do

40 46
             "reason" => "runtime_not_ready",
41 47
             "boot_converged" => false,
42 48
             "deployment_ready" => true,
43
             "admission_ready" => true
49
             "admission_ready" => true,
50
             "forge_cache_ready" => true
44 51
           }
45 52
  end
46 53

@@ -55,7 +62,23 @@ defmodule OpenAgentsWeb.HealthControllerTest do

55 62
             "reason" => "runtime_not_ready",
56 63
             "boot_converged" => true,
57 64
             "deployment_ready" => true,
58
             "admission_ready" => false
65
             "admission_ready" => false,
66
             "forge_cache_ready" => true
67
           }
68
  end
69
70
  test "refuses readiness after a repository cache cannot materialize", %{conn: conn} do
71
    :ok = OpenAgents.Forge.CacheReadiness.mark_unavailable("broken-cache", :materialize_cache)
72
73
    conn = get(conn, ~p"/healthz")
74
75
    assert json_response(conn, 503) == %{
76
             "status" => "unavailable",
77
             "reason" => "runtime_not_ready",
78
             "boot_converged" => true,
79
             "deployment_ready" => true,
80
             "admission_ready" => true,
81
             "forge_cache_ready" => false
59 82
           }
60 83
  end
61 84
end

This page updates live while a promote is in flight · changelog