Skip to content

Commit c0acc72

Browse files
ausimianclaude
andcommitted
fix: serialise commit, and stop two refusals claiming nothing changed
Two findings from a Castle review of the restart-marker work. commit/1 composed materialise-then-commit at the boundary with no lock, so a duplicate install of the version being committed could configure it between the two steps: the commit succeeds, that install then fails as already installed, and its configuration is what the newly permanent release boots on the next restart. A failed caller deciding what a successful one boots, which is the failure this protocol exists to prevent, through the one operation still outside the serialised region. The reason it was left outside does not hold. AGENTS.md called putting commit behind the install lock a deadlock dressed as caution, because an install waiting on a reboot is when a commit is wanted - but an install never holds the lock across a reboot. install_release/1 replies before init:reboot(), the reboot runs in release_handler's process, and after a restart transition the VM that held the lock is gone. The only thing commit can wait for is a hot install still inside install_release/1, where waiting is correct. So Commands.commit/5 takes rel_dir and materialises inside serialised/2, and the refusal an ERTS-less deployment used to hear from commit changes from naming the configuration step to naming commit itself. That asymmetry was the visible symptom of the composition, and erts_guard_test now pins the new direction, so a reappearance means a composition came back at the boundary. Separately, arm/4's occupied and stale refusals both claimed nothing had been changed. Materialisation runs before arming, so the target's configuration has already been replaced by then and the claim was false. They now say what did and did not happen. unclaimed/3's refusal keeps the phrase, because it fires before materialising and there it is true. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6b299b6 commit c0acc72

6 files changed

Lines changed: 201 additions & 83 deletions

File tree

AGENTS.md

Lines changed: 52 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -545,10 +545,15 @@ Castle's job is configuration and release management on a running node.
545545
nothing about the deployment is chosen by a caller in a release, because nothing
546546
in a release passes the extra arguments.
547547

548-
`Castle.commit/1` does still compose materialise-then-commit, which is why an
549-
ERTS-less deployment hears "Cannot configure" from `commit` and "Cannot
550-
install" from `install`. That asymmetry is exact rather than untidy, and
551-
`erts_guard_test.exs` pins both.
548+
**`Castle.commit/1` composes nothing either, and the asymmetry this used to
549+
describe is gone.** It said an ERTS-less deployment hears "Cannot configure"
550+
from `commit` and "Cannot install" from `install`, and that the difference was
551+
exact rather than untidy. It was exact, and it was also the visible symptom of
552+
the same composition: `commit` materialised in front of the operation, so the
553+
configuration step's guard answered first. `Commands.commit/5` now materialises
554+
inside its own serialised region, so every command names itself.
555+
`erts_guard_test.exs` pins that in the new direction — a "Cannot configure"
556+
reappearing there would mean a composition had come back at the boundary.
552557

553558
Every refusal still falls before `install_release/1` is asked for anything,
554559
which is the line that matters.
@@ -711,10 +716,28 @@ Castle's job is configuration and release management on a running node.
711716
is told a restart install is pending: the same message as before, said about a
712717
pair that is complete instead of said while taking half of it away.
713718

714-
Only `install` takes it. `unpack`, `commit` and `remove` arm nothing and hold no
715-
two-file invariant of their own — `release_handler` serialising its own record
716-
writes is the whole of what they need — and putting `commit` behind an install
717-
that is waiting on a reboot would be a deadlock dressed as caution.
719+
**`install` and `commit` take it; `unpack` and `remove` do not.** Those two arm
720+
nothing and hold no two-file invariant of their own, and `release_handler`
721+
serialising its own record writes is the whole of what they need.
722+
723+
`commit` was left out at first, on the argument that putting it behind an
724+
install "waiting on a reboot" would be a deadlock dressed as caution. **That
725+
was wrong, and the error was about when the lock is held rather than about
726+
commit.** An install never holds it across a reboot: `install_release/1`
727+
replies *before* `init:reboot()`, the reboot runs in `release_handler`'s own
728+
process, so `Commands.install/5` returns and its `trans` releases while the
729+
system is still up — and after a restart transition the VM that held the lock
730+
is gone entirely. `bin/castle install` then polls `Castle.running/1` over
731+
separate rpcs that take no lock at all. The only thing a commit can wait for is
732+
a *hot* install still inside `install_release/1`, and waiting there is correct:
733+
committing part-way through an upgrade is the thing not to do.
734+
735+
What the omission left open was reachable and is the failure this protocol
736+
exists to prevent. A duplicate install of the version being committed
737+
materialises between `commit`'s two steps; the commit succeeds; that install
738+
then fails as already installed; and *its* configuration is what the newly
739+
permanent release boots on the next restart. A failed caller deciding what a
740+
successful one boots, through the one operation left outside the region.
718741

719742
**Materialising the target's configuration is inside the region, and the
720743
argument for keeping it outside was wrong.** That argument was: it writes only
@@ -746,17 +769,24 @@ Castle's job is configuration and release management on a running node.
746769
front of `unclaimed/3` fixes nothing, and there is a test whose only job is to
747770
fail against exactly that arrangement.
748771

749-
**`Castle.commit/1` still materialises outside any lock, and that is a boundary
750-
rather than a claim.** It is not the same case: commit makes permanent a
751-
version this node already installed and is running, so materialising produces
752-
what a boot at commit time would produce, and there is no marker, no reboot,
753-
and no window between a configuration and a boot of it for another caller to
754-
land in. What is left open is an operator running `commit` of a version at the
755-
same moment as an `install` of that same version, where the two renames onto one
756-
`sys.config` are unordered. Nothing is known to do it, putting `commit` behind
757-
the install lock would be the deadlock above, and a lock of its own on the
758-
version directory would close it — that is the trade, written down rather than
759-
taken.
772+
**`commit` materialises inside the same region, and the argument for leaving it
773+
outside was wrong twice over.** It went: commit is not the same case, because it
774+
makes permanent a version this node already installed and is running, so there
775+
is no marker, no reboot and no window between a configuration and a boot of it —
776+
and putting it behind the install lock would be the deadlock above anyway.
777+
778+
The first half is true and does not license the second. The window is not
779+
between a configuration and a *boot*; it is between commit's own two steps. A
780+
duplicate install of the version being committed materialises there, the commit
781+
succeeds, that install fails as already installed, and its configuration is what
782+
the newly permanent release boots on the next restart. And the deadlock does not
783+
exist — see above: an install never holds this lock across a reboot, so the only
784+
thing commit can wait for is a hot install mid-`install_release/1`, which is
785+
exactly when it should wait.
786+
787+
So `Commands.commit/5` takes `rel_dir` and materialises inside `serialised/2`,
788+
the way `install` does, and `Castle.commit/1` composes nothing. The two renames
789+
onto one `sys.config` are now ordered wherever they meet.
760790

761791
**It is published the way `sys.config.pristine` is** — staged in an owner-only
762792
working directory and hard-linked into place — and for the same two reasons a
@@ -1281,19 +1311,9 @@ wrote, so Elixir's pipeline is still armed in the file the launcher reads.
12811311
open again between them. Widening the lock does not fix it; a lock the
12821312
filesystem holds would, at the price of a stale one after a hard kill blocking
12831313
every later install. Nothing is known to do this, and Castle does not detect
1284-
it.
1285-
- **`commit` materialises outside the install lock, so the two renames onto one
1286-
`sys.config` are unordered where they meet.** `Castle.install/1` now
1287-
materialises inside its serialised region, which is what stops two installs
1288-
deciding each other's configuration. `Castle.commit/1` still composes
1289-
materialise-then-commit, and the case left open is an operator running a
1290-
`commit` of some version at the same moment as an `install` of that *same*
1291-
version — a narrow one, since commit is for a version this node has already
1292-
installed and is running. Putting `commit` behind the install lock would be a
1293-
deadlock dressed as caution, since an install waiting on a reboot is exactly
1294-
when a commit is wanted; a second lock on the version directory, taken by
1295-
materialisation itself, would close it. That is the trade and it has not been
1296-
taken. Nothing is known to do this, and Castle does not detect it.
1314+
it. It is the one limitation here that a lock cannot narrow, which is why the
1315+
filesystem half of the protocol — `publish/2` refusing rather than replacing —
1316+
has to stand on its own.
12971317
- **The public API is undocumented.** `@moduledoc` is still the generated
12981318
placeholder and there are no `@doc` or `@spec` annotations
12991319
([#11](https://github.com/ausimian/castle/issues/11)).

RELEASE.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,10 +164,15 @@
164164
configuration step to `install_release/1` and the marker being settled, and a
165165
caller that is going to be told a restart install is pending is told *before*
166166
it configures anything. Only the ERTS guard is outside, because it reads two
167-
directories and refuses without touching anything. `Castle.commit/1` still
168-
configures outside any lock, which is a boundary rather than an oversight - it
169-
makes permanent a version this node installed and is running, with no marker,
170-
no reboot and no window between a configuration and a boot of it.
167+
directories and refuses without touching anything.
168+
169+
`commit` is serialised the same way, and for a reason that is not obvious: it
170+
configures the version too, so a duplicate install of the version being
171+
committed could configure it between commit's two steps - the commit would
172+
succeed, that install would then fail as already installed, and its
173+
configuration would be what the newly permanent release booted on the next
174+
restart. A failed caller deciding what a successful one boots. `unpack` and
175+
`remove` are not serialised: they configure nothing and arm nothing.
171176

172177
Which kind of transition an install is, is decided from the release the system
173178
is running, and another install completing in between would change that answer -

lib/castle.ex

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,7 @@ defmodule Castle do
8888
end
8989

9090
def commit(vsn) when is_binary(vsn) do
91-
materialise(vsn)
92-
report!(Commands.commit(vsn))
91+
report!(Commands.commit(vsn, rel_dir()))
9392
end
9493

9594
def remove(vsn) when is_binary(vsn) do
@@ -100,33 +99,40 @@ defmodule Castle do
10099
report!(Commands.releases())
101100
end
102101

103-
# Makes sure the target version's configuration exists before the version is
104-
# handed to `:release_handler`, and fails here if it cannot be made to.
105-
# Everything about the target that can refuse to go on - a peer that will not
106-
# start, a boot script that is not there, a provider that raises - refuses from
107-
# inside this call.
102+
# **Nothing here composes materialisation any more, and neither entry point may
103+
# start again.** It is a *replace*: the last thing it does is rename the
104+
# resolved configuration onto `sys.config`. In front of an operation it is two
105+
# steps another caller can get between, which is why `Commands.install/5` took
106+
# it back inside its own lock — and `commit/1` has now followed, into
107+
# `Commands.commit/5`.
108108
#
109-
# **`commit/1` is the only caller, and `install/1` must not become one again.**
110-
# This is a *replace*: the last thing it does is rename the resolved
111-
# configuration onto `sys.config`. Composed in front of an operation it turns
112-
# into two steps that another caller can get between, which is exactly what
113-
# `Commands.install/5` had to take back inside its own lock. `commit/1` is
114-
# different in kind rather than merely luckier - it makes permanent a version
115-
# this node already installed and is running, so materialising produces what a
116-
# boot at commit time would produce, and there is no marker, no reboot and no
117-
# window between a configuration and a boot of it for a second caller to land
118-
# in. Putting `commit` behind the install lock would instead be a deadlock
119-
# dressed as caution, since an install waiting on a reboot is exactly when a
120-
# commit is wanted.
121-
defp materialise(vsn), do: report!(Commands.materialise(rel_vsn_dir(vsn)))
109+
# `commit` was left out on the argument that it is different in kind: it makes
110+
# permanent a version this node already installed and is running, so there is no
111+
# marker, no reboot and no window between a configuration and a boot of it. The
112+
# part that was wrong is what the argument then concluded — that putting commit
113+
# behind the install lock would be "a deadlock dressed as caution, since an
114+
# install waiting on a reboot is exactly when a commit is wanted". **An install
115+
# never holds the lock while waiting on a reboot.** `install_release/1` replies
116+
# before `init:reboot()` and the reboot runs in `release_handler`'s process, so
117+
# `Commands.install/5` returns and its `trans` releases before the node goes
118+
# down; `bin/castle install` then polls `Castle.running/1` over separate rpcs
119+
# that take no lock, and after a restart transition the VM that held it is gone
120+
# entirely. The only thing a commit can now wait for is a hot install still
121+
# inside `install_release/1` — and waiting there is right, because committing
122+
# part-way through an upgrade is what should not happen.
123+
#
124+
# What the composition actually left open was the reachable case: a duplicate
125+
# install of the version being committed, materialising between the two calls.
126+
# The commit succeeds, that install then fails as already installed, and its
127+
# configuration is what the newly permanent release boots on the next restart —
128+
# a failed caller deciding what a successful one boots, which is the failure
129+
# this protocol exists to prevent, reachable through the one operation left
130+
# outside it.
122131

123-
# The release directory, and the version directory of the release being
124-
# operated on beneath it. Derived, never chosen by the caller: which file the
132+
# The release directory. Derived, never chosen by the caller: which file the
125133
# configuration lands in, and which file the release records go in, are
126134
# properties of the installation rather than arguments, and a caller's working
127-
# directory cannot make them name different ones. The version directory
128-
# resolves for any version the running release knows about, because
129-
# `:release_handler` unpacks every version into this same root.
135+
# directory cannot make them name different ones.
130136
#
131137
# `Castle.Deployment.root_dir/0` says what that root does and does not decide,
132138
# and is the one place that says it. The part that bears on these two: it is
@@ -139,7 +145,6 @@ defmodule Castle do
139145
# `RELEASE_ROOT` - which is the one deployment where this derivation names the
140146
# wrong tree, and where every operation that would act on it refuses.
141147
defp rel_dir, do: Path.join(Deployment.root_dir(), "releases")
142-
defp rel_vsn_dir(vsn), do: Path.join(rel_dir(), vsn)
143148

144149
defp report!({:ok, lines}), do: Enum.each(lines, &IO.puts/1)
145150
defp report!({:error, message}), do: raise(Castle.Error, message)

lib/castle/commands.ex

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1251,7 +1251,9 @@ defmodule Castle.Commands do
12511251
"reboot, and there is already #{describe_type(type)} at that path. Castle " <>
12521252
"will not write through it or replace it, and there is nowhere else it can " <>
12531253
"arm the install - that path is what the launcher reads on the next start. " <>
1254-
"Nothing has been changed. Move whatever is there out of the way."
1254+
"The upgrade did not happen and nothing was made permanent, but the target's " <>
1255+
"configuration has already been expanded: that is the step before this one. " <>
1256+
"Move whatever is there out of the way."
12551257
end
12561258

12571259
defp describe_type(:directory), do: "a directory"
@@ -1264,7 +1266,8 @@ defmodule Castle.Commands do
12641266
"be cleared first (#{:file.format_error(reason)}). It has to be, because one " <>
12651267
"left by an earlier attempt would pair with the marker this install is about " <>
12661268
"to arm and tell the launcher to boot a version that was never installed. " <>
1267-
"Nothing has been changed."
1269+
"The upgrade did not happen and nothing was made permanent, but #{vsn}'s " <>
1270+
"configuration has already been expanded: that is the step before this one."
12681271
end
12691272

12701273
defp unarmed(marker, reason, refusal) do
@@ -1395,9 +1398,41 @@ defmodule Castle.Commands do
13951398
nowhere else, which is the whole of the rollback property: until this runs, a
13961399
restart returns to the version that was permanent before.
13971400
"""
1398-
@spec commit(String.t(), module(), module()) :: result()
1399-
def commit(vsn, handler \\ :release_handler, deployment \\ Castle.Deployment) do
1401+
@spec commit(String.t(), Path.t(), module(), module(), module()) :: result()
1402+
def commit(
1403+
vsn,
1404+
rel_dir,
1405+
handler \\ :release_handler,
1406+
peer \\ Peer,
1407+
deployment \\ Castle.Deployment
1408+
) do
14001409
with :ok <- ensure_own_erts("Cannot commit #{vsn}", deployment) do
1410+
serialised(rel_dir, fn ->
1411+
commit_materialised(vsn, rel_dir, handler, peer, deployment)
1412+
end)
1413+
end
1414+
end
1415+
1416+
# Materialising and committing under the *same* lock an install takes, and for
1417+
# the reason install takes it: both rename a `sys.config` into the version
1418+
# directory, and whichever renames last decides what the version boots.
1419+
#
1420+
# This composed at the boundary until it was found to be racy. A duplicate
1421+
# install of the version being committed could materialise between the two
1422+
# calls here; the commit would then succeed, that install would fail as already
1423+
# installed, and its configuration would be left as the configuration the newly
1424+
# permanent release boots on the next restart. A failed caller deciding what a
1425+
# successful one boots is the failure this whole protocol exists to prevent, and
1426+
# it was reachable through the one operation that had been left outside.
1427+
#
1428+
# **It cannot deadlock against an install, and the earlier belief that it could
1429+
# was wrong.** `install_release/1` replies before `init:reboot()` and the reboot
1430+
# runs in `release_handler`'s process, so `Commands.install/5` returns and its
1431+
# `trans` releases well before the node goes down - the lock is never held
1432+
# across a restart. `bin/castle install` then polls `Castle.running/1` through
1433+
# separate rpcs, none of which takes this lock at all.
1434+
defp commit_materialised(vsn, rel_dir, handler, peer, deployment) do
1435+
with {:ok, _} <- materialise(Path.join(rel_dir, vsn), peer, deployment) do
14011436
case handler.make_permanent(to_charlist(vsn)) do
14021437
:ok -> {:ok, ["Committed #{vsn}. System restarts will now boot into this version."]}
14031438
{:error, reason} -> {:error, "Commit of #{vsn} failed. #{inspect(reason)}"}

0 commit comments

Comments
 (0)