manna: what is eaten stays eaten — the ledger is written beside the provision

The MannaLedger counted the day's consumption in memory. A restart
forgot it, and the next cycle gathered the same day's provision whole.
While manna was an emptyDir that took the kohen's hand; with the
provision on a volume (41a3e51) a crash loop alone would have refilled
the jar, once per crash. Ish l'fi ochlo — Shemot 16:18.

- After every cycle that eats, the day's consumption is written to
  ledger.json beside the provision file (tmp + rename; on the manna
  volume in production).
- A process that starts while today's record is on disk takes the day
  up at once (handle_continue), so /kodesh/status never shows an
  untouched jar that is half eaten; otherwise the first load of the day
  resumes from it.
- Another day's record is ignored. A record that is not a ledger
  (bad JSON, negative or non-integer counts) is ignored with a warning.
  The record can only reduce what remains.
- At the day boundary the record goes with the surplus. A release
  within the same day (the late-provision re-gather) lets go of the
  allocation and keeps the record.
- If the file cannot be written the ledger says so and keeps counting
  in memory; the cycle is not stopped by its own bookkeeping.
- MannaLedger.reset/0 (tests) forgets the record; test_helper clears
  any record an interrupted run left behind.

test/manna_ledger_persistence_test.exs — 13 tests, restarting the
ledger under its real supervisor. All fail without the change.

Read before committing: Elixir 474/0 on seeds 0 7 42 99 191243. Built
image, real container restarts against a mounted manna directory:
3 of 5 units eaten → restart → status shows 3 before any request →
2 more served, the 6th refused → restart → still refused.

Found while writing the tests, not changed: killing the ledger more
than three times in five seconds takes the whole application down (OTP
restart intensity, one_for_one defaults). On the pod that is a
container restart — which now resumes the day instead of refilling it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Scott KarpandClaude Fable 5.1 committed 2026-10-04 18:34:08 +00:00
1 parent 41a3e51a43
commit 8e109e82d9
3 files changed
+441 -36

No files matched your search

+204 -36
View File
@@ -20,6 +20,27 @@ defmodule VavPipeline.MannaLedger do
Architecture §4, Step 26: Manna anti-hoarding enforcement Architecture §4, Step 26: Manna anti-hoarding enforcement
and cycle-boundary surplus release. and cycle-boundary surplus release.
## What is eaten stays eaten
The day's consumption is written to `ledger.json` beside the provision
file, after every cycle that eats. A ledger that lives only in memory
forgets what was eaten whenever the process restarts: the same day's
provision is gathered again, whole. While manna was an emptyDir that
needed the kohen's hand; once the provision was put on a volume
(2026-10-04) a crash loop alone would have refilled the jar, as often
as it crashed. *Ish l'fi ochlo* — each according to what he eats
(Shemot 16:18): the omer is measured once.
- On the first load of a day — and at once when the process starts —
if `ledger.json` carries TODAY's date, consumption resumes from it. Another day's file is not this day's
record and is ignored.
- At the day boundary the file is cleared with the surplus. A release
within the same day clears the allocation but not the record.
- The file is a convenience of the vessel, never a source of manna:
it can only reduce what remains. If it cannot be read or written the
ledger keeps counting in memory and says so; it does not stop the
cycle.
""" """
use GenServer use GenServer
@@ -129,13 +150,23 @@ defmodule VavPipeline.MannaLedger do
end end
@doc """ @doc """
Reset the ledger to initial state. For testing only. Reset the ledger to initial state, and forget the persisted day.
For testing only.
""" """
@spec reset() :: :ok @spec reset() :: :ok
def reset do def reset do
GenServer.call(__MODULE__, :reset) GenServer.call(__MODULE__, :reset)
end end
@doc """
Where the day's consumption is kept: `ledger.json`, beside the
provision file (on the manna volume in production).
"""
@spec ledger_file() :: String.t()
def ledger_file do
Path.join(Path.dirname(Manna.provision_file()), "ledger.json")
end
# ============================================================ # ============================================================
# GenServer callbacks # GenServer callbacks
# ============================================================ # ============================================================
@@ -152,11 +183,30 @@ defmodule VavPipeline.MannaLedger do
} }
Logger.info("MannaLedger: ledger ready — d'var yom b'yomo") Logger.info("MannaLedger: ledger ready — d'var yom b'yomo")
{:ok, state} {:ok, state, {:continue, :resume_day}}
end
# A process that starts in the middle of a day whose record is on disk
# takes the day up at once, rather than at its first cycle — so that
# /kodesh/status never shows an untouched jar that is in fact half
# eaten. With no record for today there is nothing to resume, and the
# first cycle gathers as it always did.
@impl true
def handle_continue(:resume_day, state) do
today = Date.utc_today() |> Date.to_iso8601()
if persisted_date() == today do
{_reply, new_state} = load_for(today, state)
{:noreply, new_state}
else
{:noreply, state}
end
end end
@impl true @impl true
def handle_call(:reset, _from, _state) do def handle_call(:reset, _from, _state) do
forget_persisted()
{:reply, :ok, {:reply, :ok,
%{ %{
cycle_date: nil, cycle_date: nil,
@@ -175,40 +225,8 @@ defmodule VavPipeline.MannaLedger do
if state.cycle_date == today do if state.cycle_date == today do
{:reply, {:ok, :already_loaded}, state} {:reply, {:ok, :already_loaded}, state}
else else
case Manna.check() do {reply, new_state} = load_for(today, state)
{:ok, :no_provision} -> {:reply, reply, new_state}
new_state = %{
state
| cycle_date: today,
allocated: @zero_provision,
consumed: @zero_provision,
cycle_count: state.cycle_count + 1
}
Logger.warning("MannaLedger: no provision for #{today} — system will wait")
{:reply, {:ok, :no_provision}, new_state}
{:ok, manna} ->
new_state = %{
state
| cycle_date: today,
allocated: manna.provision,
consumed: @zero_provision,
cycle_count: state.cycle_count + 1
}
Logger.info(
"MannaLedger: provision loaded for #{today} (#{manna.type}) — " <>
"compute: #{manna.provision.compute_units}, " <>
"api_calls: #{manna.provision.api_calls_external}, " <>
"panels: #{manna.provision.sanhedrin_panels_max}"
)
{:reply, {:ok, :loaded}, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end end
end end
@@ -243,6 +261,7 @@ defmodule VavPipeline.MannaLedger do
new_consumed = Map.put(state.consumed, resource, consumed + amount) new_consumed = Map.put(state.consumed, resource, consumed + amount)
new_remaining = remaining - amount new_remaining = remaining - amount
new_state = %{state | consumed: new_consumed} new_state = %{state | consumed: new_consumed}
persist(new_state)
# Signal when provision runs low (< 10% remaining) # Signal when provision runs low (< 10% remaining)
if new_remaining < allocated * 0.1 and new_remaining > 0 do if new_remaining < allocated * 0.1 and new_remaining > 0 do
@@ -303,6 +322,155 @@ defmodule VavPipeline.MannaLedger do
hoarding_violations: state.hoarding_violations hoarding_violations: state.hoarding_violations
} }
# The day's record goes with the day's surplus — when the day is
# over. A release called while it is still the ledger's day (the
# late-provision re-gather in Manna.gather_for_cycle/0) lets go of
# the allocation, not of the memory of what was eaten: the reload
# that follows resumes today's count.
if state.cycle_date != (Date.utc_today() |> Date.to_iso8601()) do
forget_persisted()
end
{:reply, {:ok, %{released: released}}, new_state} {:reply, {:ok, %{released: released}}, new_state}
end end
# ============================================================
# Loading
# ============================================================
# Gather `today`'s provision into the ledger, resuming today's
# consumption if this process is not the first to gather it.
defp load_for(today, state) do
case Manna.check() do
{:ok, :no_provision} ->
new_state = %{
state
| cycle_date: today,
allocated: @zero_provision,
consumed: @zero_provision,
cycle_count: state.cycle_count + 1
}
Logger.warning("MannaLedger: no provision for #{today} — system will wait")
{{:ok, :no_provision}, new_state}
{:ok, manna} ->
# What was already eaten today, if this process is not the
# first to gather it.
consumed = restore_consumed(today)
new_state = %{
state
| cycle_date: today,
allocated: manna.provision,
consumed: consumed,
cycle_count: state.cycle_count + 1
}
Logger.info(
"MannaLedger: provision loaded for #{today} (#{manna.type}) — " <>
"compute: #{manna.provision.compute_units}, " <>
"api_calls: #{manna.provision.api_calls_external}, " <>
"panels: #{manna.provision.sanhedrin_panels_max}" <>
if(consumed == @zero_provision,
do: "",
else: " — resuming the day: already consumed #{inspect(consumed)}"
)
)
{{:ok, :loaded}, new_state}
{:error, reason} ->
{{:error, reason}, state}
end
end
# ============================================================
# Persistence — ledger.json beside the provision
# ============================================================
# Written whole and renamed into place, so a reader (or a crash) never
# meets half a file.
defp persist(state) do
path = ledger_file()
tmp = path <> ".tmp"
body =
Jason.encode!(%{
"cycle_date" => state.cycle_date,
"consumed" => Map.new(state.consumed, fn {k, v} -> {Atom.to_string(k), v} end),
"written_at" => DateTime.utc_now() |> DateTime.to_iso8601()
})
with :ok <- File.write(tmp, body),
:ok <- File.rename(tmp, path) do
:ok
else
{:error, reason} ->
Logger.warning(
"MannaLedger: could not persist consumption to #{path} (#{inspect(reason)}) — " <>
"counting in memory only; a restart today would forget what was eaten"
)
:ok
end
end
# Today's consumption from an earlier life of this process, or zeros.
defp restore_consumed(today) do
path = ledger_file()
with {:ok, contents} <- File.read(path),
{:ok, %{"cycle_date" => ^today, "consumed" => consumed}} when is_map(consumed) <-
Jason.decode(contents),
{:ok, restored} <- parse_consumed(consumed) do
restored
else
{:error, :enoent} ->
@zero_provision
{:ok, %{"cycle_date" => other}} ->
Logger.info("MannaLedger: #{path} is for #{inspect(other)}, not #{today} — not this day's record")
@zero_provision
other ->
Logger.warning(
"MannaLedger: #{path} is unreadable (#{inspect(other)}) — " <>
"starting today's count from zero"
)
@zero_provision
end
end
# Only the five known resources, only non-negative integers. Anything
# else is not a ledger.
defp parse_consumed(consumed) do
Enum.reduce_while(@zero_provision, {:ok, %{}}, fn {key, _}, {:ok, acc} ->
case Map.get(consumed, Atom.to_string(key), 0) do
n when is_integer(n) and n >= 0 -> {:cont, {:ok, Map.put(acc, key, n)}}
bad -> {:halt, {:error, {:bad_consumed, key, bad}}}
end
end)
end
# The date the record on disk is for, or nil.
defp persisted_date do
with {:ok, contents} <- File.read(ledger_file()),
{:ok, %{"cycle_date" => date}} when is_binary(date) <- Jason.decode(contents) do
date
else
_ -> nil
end
end
defp forget_persisted do
case File.rm(ledger_file()) do
:ok -> :ok
{:error, :enoent} -> :ok
{:error, reason} ->
Logger.warning("MannaLedger: could not clear #{ledger_file()} (#{inspect(reason)})")
:ok
end
end
end end
@@ -0,0 +1,233 @@
defmodule VavPipeline.MannaLedgerPersistenceTest do
@moduledoc """
What is eaten stays eaten.
Until 2026-10-04 the ledger lived only in memory. A restart forgot the
day's consumption and the next cycle gathered the whole provision again.
That was hidden while manna was an emptyDir (a restart emptied the jar
too, and the kohen had to refill it by hand); once the provision was put
on a volume, a crash loop alone would have refilled it.
These tests restart the ledger process under its real supervisor — a new
process, an empty state, the same disk — and ask what it remembers.
"""
use ExUnit.Case, async: false
alias VavPipeline.{HaltMonitor, Manna, MannaLedger}
setup do
if HaltMonitor.halted?(), do: HaltMonitor.resolve("manna persistence test setup")
MannaLedger.reset()
write_provision(10)
on_exit(fn ->
MannaLedger.reset()
File.rm(Manna.provision_file())
end)
:ok
end
describe "the record" do
test "is written beside the provision, with today's date, when a cycle eats" do
refute File.exists?(MannaLedger.ledger_file())
assert {:ok, :loaded} = MannaLedger.load_provision()
assert {:ok, 7} = MannaLedger.consume(:compute_units, 3)
assert Path.dirname(MannaLedger.ledger_file()) == Path.dirname(Manna.provision_file())
record = MannaLedger.ledger_file() |> File.read!() |> Jason.decode!()
assert record["cycle_date"] == today()
assert record["consumed"]["compute_units"] == 3
assert record["consumed"]["api_calls_external"] == 0
end
test "is not written by a refused consumption" do
assert {:ok, :loaded} = MannaLedger.load_provision()
assert {:error, :provision_exhausted} = MannaLedger.consume(:compute_units, 11)
refute File.exists?(MannaLedger.ledger_file())
end
end
describe "a restart in the middle of the day" do
test "resumes the count — the provision is not gathered whole again" do
assert {:ok, :loaded} = MannaLedger.load_provision()
assert {:ok, 6} = MannaLedger.consume(:compute_units, 4)
restart_ledger()
# The new process takes the day up as it starts — before any cycle
# asks — so the status never shows an untouched jar.
ledger = MannaLedger.ledger()
assert ledger.cycle_date == today()
assert ledger.allocated.compute_units == 10
assert ledger.consumed.compute_units == 4
assert {:ok, :already_loaded} = MannaLedger.load_provision()
assert MannaLedger.remaining(:compute_units) == 6
# And it keeps counting from there.
assert {:ok, 5} = MannaLedger.consume(:compute_units, 1)
end
test "an exhausted day stays exhausted — a crash loop does not refill the jar" do
assert {:ok, :loaded} = MannaLedger.load_provision()
assert {:ok, 0} = MannaLedger.consume(:compute_units, 10)
for _ <- 1..3 do
restart_ledger()
assert {:waiting, :provision_exhausted} = Manna.gather_for_cycle()
end
end
test "the whole cycle path: gather, restart, gather" do
assert :ok = Manna.gather_for_cycle()
assert :ok = Manna.gather_for_cycle()
restart_ledger()
assert :ok = Manna.gather_for_cycle()
assert MannaLedger.ledger().consumed.compute_units == 3
end
end
describe "a restart with nothing to resume" do
test "no record: the new process holds no day until the first cycle" do
restart_ledger()
assert MannaLedger.ledger().cycle_date == nil
assert {:ok, :loaded} = MannaLedger.load_provision()
assert MannaLedger.remaining(:compute_units) == 10
end
test "a record but no provision: nothing is resumed into an empty day" do
assert {:ok, :loaded} = MannaLedger.load_provision()
assert {:ok, 9} = MannaLedger.consume(:compute_units, 1)
File.rm!(Manna.provision_file())
restart_ledger()
assert MannaLedger.remaining(:compute_units) == 0
# The vessel waits. (Which of the two waiting reasons it gives is
# the ledger's older behaviour for a day pinned with no provision;
# the router answers MANNA_DEPLETED for both.)
assert {:waiting, reason} = Manna.gather_for_cycle()
assert reason in [:no_provision, :provision_exhausted]
end
end
describe "what the record may not do" do
test "another day's record is not this day's — it is ignored" do
write_record(%{"cycle_date" => "2026-01-01", "consumed" => %{"compute_units" => 9}})
assert {:ok, :loaded} = MannaLedger.load_provision()
assert MannaLedger.remaining(:compute_units) == 10
end
test "a record that is not a ledger is ignored, and the cycle goes on" do
for garbage <- [
"not json",
Jason.encode!(%{"cycle_date" => today(), "consumed" => "all of it"}),
Jason.encode!(%{"cycle_date" => today(), "consumed" => %{"compute_units" => -5}}),
Jason.encode!(%{"cycle_date" => today(), "consumed" => %{"compute_units" => "3"}})
] do
MannaLedger.reset()
File.write!(MannaLedger.ledger_file(), garbage)
assert {:ok, :loaded} = MannaLedger.load_provision()
assert MannaLedger.remaining(:compute_units) == 10
assert {:ok, 9} = MannaLedger.consume(:compute_units, 1)
end
end
test "it can only take away: a record larger than the provision leaves nothing" do
write_record(%{"cycle_date" => today(), "consumed" => %{"compute_units" => 500}})
assert {:ok, :loaded} = MannaLedger.load_provision()
assert MannaLedger.remaining(:compute_units) == 0
assert {:error, :provision_exhausted} = MannaLedger.consume(:compute_units, 1)
end
end
describe "release" do
test "at a day boundary the record goes with the surplus" do
# The ledger holds no day (cycle_date nil ≠ today), as it does when
# the day it loaded has passed and been released.
write_record(%{"cycle_date" => "2026-01-01", "consumed" => %{"compute_units" => 2}})
assert {:ok, _} = MannaLedger.enforce_cycle_boundary()
refute File.exists?(MannaLedger.ledger_file())
end
test "a release within the same day lets go of the allocation, not the memory" do
assert {:ok, :loaded} = MannaLedger.load_provision()
assert {:ok, 8} = MannaLedger.consume(:compute_units, 2)
# Manna.gather_for_cycle/0 does this when a provision arrives late.
assert {:ok, _} = MannaLedger.enforce_cycle_boundary()
assert File.exists?(MannaLedger.ledger_file())
assert {:ok, :loaded} = MannaLedger.load_provision()
assert MannaLedger.remaining(:compute_units) == 8
end
test "reset forgets the record (tests must not leak a day into each other)" do
assert {:ok, :loaded} = MannaLedger.load_provision()
assert {:ok, 9} = MannaLedger.consume(:compute_units, 1)
assert File.exists?(MannaLedger.ledger_file())
MannaLedger.reset()
refute File.exists?(MannaLedger.ledger_file())
end
end
# ------------------------------------------------------------
# Helpers
# ------------------------------------------------------------
defp today, do: Date.utc_today() |> Date.to_iso8601()
# Bring the ledger down and up again through its own supervisor — a
# new process with a new, empty state, and the file on disk untouched.
# That is a restart, not a reset.
#
# (Done with terminate_child/restart_child rather than Process.exit:
# this file restarts the ledger six times in well under five seconds,
# and a supervisor that sees more than three CRASHES in five seconds
# gives up and takes the application down with it — as the first
# version of this test found out.)
defp restart_ledger do
old = Process.whereis(MannaLedger)
:ok = Supervisor.terminate_child(VavPipeline.Supervisor, MannaLedger)
{:ok, new} = Supervisor.restart_child(VavPipeline.Supervisor, MannaLedger)
assert new != old
assert Process.whereis(MannaLedger) == new
end
defp write_record(record) do
File.mkdir_p!(Path.dirname(MannaLedger.ledger_file()))
File.write!(MannaLedger.ledger_file(), Jason.encode!(record))
end
defp write_provision(compute_units) do
expires = DateTime.utc_now() |> DateTime.add(6 * 3600) |> DateTime.to_iso8601()
provision = %{
"date" => today(),
"provision" => %{
"compute_units" => compute_units,
"memory_mb" => 512,
"api_calls_external" => 5,
"storage_delta_mb" => 2,
"sanhedrin_panels_max" => 2
},
"type" => "regular",
"expires_at" => expires,
"written_by" => "manna_ledger_persistence_test",
"written_at" => DateTime.utc_now() |> DateTime.to_iso8601()
}
path = Manna.provision_file()
File.mkdir_p!(Path.dirname(path))
File.write!(path, Jason.encode!(provision))
end
end
+4
View File
@@ -2,6 +2,10 @@
# Run with: SHORASHIM_L3=1 mix test --include l3 # Run with: SHORASHIM_L3=1 mix test --include l3
ExUnit.start(exclude: [:l3]) ExUnit.start(exclude: [:l3])
# The manna ledger resumes a day it finds on disk (ledger.json). A run
# that was interrupted could leave one behind; no test should inherit it.
if Process.whereis(VavPipeline.MannaLedger), do: VavPipeline.MannaLedger.reset()
if System.get_env("SHORASHIM_L3") do if System.get_env("SHORASHIM_L3") do
{:ok, _} = VavPipeline.Repo.start_link() {:ok, _} = VavPipeline.Repo.start_link()
Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, :manual)