Skip to content

Commit 6ea1fc2

Browse files
ausimianclaude
andcommitted
fix: serialise the whole install, not just the marker's arming
arm_restart/4's three steps are one caller's sequence, and the argument that release_handler serialises install_release/1 anyway does not carry them: that serialisation is downstream of the entire protocol. Two callers read the running release, classify the transition and pass the marker check before either of them publishes anything, so refusing before clearing buys nothing across processes. The loser reaches clear_provisional/3 after the winner's install_release/1 has written new_start_erl.data, deletes the winner's live evidence, and refuses - the winner's reboot then comes back on the permanent release, install waits for a version that never becomes the running one, and the operator is told that nothing has been changed by the process that changed it. The protocol is not reordered to avoid that, because publishing before clearing would leave the marker pairable with a stale new_start_erl.data and boot a version nothing installed, which is worse. What is added is that there is only ever one caller in it: the running-release read, the classification, the arming, install_release/1 and the disarming all run inside :global.trans/3 over [node()]. The region has to reach that far for a second reason - restart_planned?/3 is a prediction about the running release, so a concurrent hot install moves the from-version and do_get_rh_script/4 then selects a different relup entry from the one that was classified. global_name_server is a kernel process and runs whether or not distribution does, and a lock restricted to [node()] talks to the local one only, so this works on a node with is_alive() == false - which is the ordinary case here, and the case it was measured on. trans/3 releases the lock in an after and global monitors the holder, so a caller that dies does not wedge every later install; retries are infinity, so there is no aborted to mean anything by. It needs no process of Castle's own, which a supervised lock server would have added to the managed system's supervision tree for a command that runs a handful of times in a deployment's life. [node()] rather than [node() | nodes()] because every caller arrives by rpc on the running node: a cluster-wide lock would wait on nodes that share nothing with the deployment and would still not cover a caller in another VM, which is the boundary and is documented as one. It waits rather than refusing, and the waiter then meets the marker check and is told a restart install is pending - the same message as before, said about a pair that is complete instead of said while taking half of it away. Only install takes the lock: unpack, commit and remove arm nothing, and the materialisation Castle.install/1 does first stays outside it. The tests drive two real callers with a barrier at which_releases/0, which is the first thing inside the region and the last before the arming - so a caller held there is one that has taken the region and armed nothing, which is the state two of them could previously occupy at once. The discriminator is that the second caller's lookup never happens; the end state is deliberately not one, because the interleaving that destroys evidence needs a caller suspended between the marker check and the clearing, and those are adjacent. A third test installs one relup concurrently from two different running versions, where the same target is hot from one and a restart from the other, so the classification is shown to belong to the caller that made it. Refs: ausimian/forecastle#10 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
1 parent b8f74e8 commit 6ea1fc2

4 files changed

Lines changed: 422 additions & 30 deletions

File tree

AGENTS.md

Lines changed: 117 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -602,20 +602,21 @@ Castle's job is configuration and release management on a running node.
602602
Back-to-back or concurrent installs broke it the other way, by overwriting and
603603
disarming each other's marker.
604604

605-
**So the pair is owned by an install *attempt*, and three things make it so.**
606-
`arm_restart/4` is the whole of it and the order is the protocol.
605+
**So the pair is owned by an install *attempt*, and four things make it so.**
606+
`arm_restart/4` is three of them and the order is the protocol; the fourth is
607+
that there is only ever one caller in the install at all.
607608

608609
1. **One pending restart install at a time.** A marker already at the path
609-
refuses the install rather than being adopted or replaced. `publish/2`
610-
decides it, by refusing rather than replacing; the `lstat` first is what
611-
keeps step 2 from destroying a pending attempt's evidence, since anything in
612-
flight holds the marker from before its own step 2 until its `disarm/2`.
610+
refuses the install rather than being adopted or replaced, which is what
611+
keeps step 2 from clearing the `new_start_erl.data` that attempt's
612+
preparation wrote. `publish/2` decides it a second time over, by refusing
613+
rather than replacing.
613614
2. **OTP's file is cleared before the marker is armed.** That is what closes
614615
the window above: after it, `new_start_erl.data` existing means *this*
615616
attempt's preparation wrote it. Removing it is safe —
616617
`write_new_start_erl/3` goes through `file:write_file/2`, which creates the
617618
file when it is absent. The order matters and must not be reversed: an
618-
attempt that refused *after* clearing would take a concurrent install's
619+
attempt that refused *after* clearing would take an already-requested
619620
reboot away silently.
620621
3. **The marker names the attempt that armed it**, on a second line, and
621622
`disarm/2` removes it only if it still does. The marker's name is shared and
@@ -630,6 +631,72 @@ Castle's job is configuration and release management on a running node.
630631
confusion, not forgery. **The hook never reads it**: the version is the
631632
first line, which is what `head -n 1` gives it, so the file carries this
632633
without the shell parsing anything it did not before.
634+
4. **One caller in the install at a time**, which is `Castle.Commands.serialised/2`
635+
and the whole of what makes the first three mean anything across processes.
636+
637+
**Steps 1 to 3 are one caller's sequence, and the first version of this
638+
believed that `release_handler` serialising `install_release/1` was enough to
639+
make them a protocol. It is not: that serialisation is *downstream* of all of
640+
them.** Two callers both read the running release, both classify it and both
641+
pass step 1, because none of that has published anything yet. Step 1 reversed
642+
with step 2 is then no protection at all — the loser reaches step 2 *after* the
643+
winner's `install_release/1` has written `new_start_erl.data`, deletes it, and
644+
the winner's reboot comes back on the permanent release while the loser reports
645+
that nothing has been changed. An operator sees a timeout and a false
646+
reassurance.
647+
648+
**Do not answer this by reordering the protocol.** Publishing before clearing
649+
leaves a window in which the marker pairs with a *stale* `new_start_erl.data`,
650+
and the hook then boots a version nothing installed — which is worse than
651+
losing a reboot, and is what step 2's position exists to prevent. The order is
652+
right for one caller; the fix is that there is one caller.
653+
654+
**The serialised region is the whole install, not just the arming**, because the
655+
classification is a prediction about the running release: an install that
656+
completes between `restart_planned?/3` and `install_release/1` moves the
657+
from-version, so `do_get_rh_script/4` evaluates a different relup entry and the
658+
armed state disagrees with the transition OTP selects. So the running-release
659+
read, the classification, the arming, `install_release/1` and the disarming are
660+
all inside it. The ERTS guard is the one part deliberately outside — it reads
661+
two directories and refuses without touching anything, and a refusal has no
662+
reason to wait.
663+
664+
**It is `:global.trans/3` over `[node()]`, and the mechanism was chosen rather
665+
than assumed.** `global_name_server` is a kernel process running whether or not
666+
distribution is, and `set_lock/2` restricted to `[node()]` talks to the local
667+
one only — so this works on a node with `is_alive() == false`, which is the
668+
ordinary case for a release that configures no distribution, and is the case it
669+
was measured on. `trans/3` releases the lock in an `after` and `global` monitors
670+
the holder besides, so a caller that dies releases it instead of wedging every
671+
later install; retries are `infinity`, so there is no `aborted` to have to mean
672+
something by. The alternative was a process of Castle's own, and it is a worse
673+
trade: these modules are deliberately stateless and run inline in whatever
674+
process asked, so a lock server would be a new entry in the *managed* system's
675+
supervision tree, with a lifetime and a restart strategy, to serialise a command
676+
that runs a handful of times in a deployment's life.
677+
678+
`[node()]` rather than the default `[node() | nodes()]` because every caller
679+
arrives on the running node — `bin/castle` is `rpc`, and the launcher's preboot
680+
only calls `make_releases/0` — so this node is the whole set of callers. A
681+
cluster-wide lock would make an install wait on nodes that share nothing with
682+
the deployment, and make a network partition its business, and it still would
683+
not cover a caller in some other VM. **That is the boundary and it should be
684+
said plainly:** a second VM writing into this releases directory is outside the
685+
lock, and what defends the marker there is the filesystem half alone —
686+
`publish/2` refusing rather than replacing — which is no worse than before and
687+
no better. It waits rather than refusing, and the waiter then meets step 1 and
688+
is told a restart install is pending: the same message as before, said about a
689+
pair that is complete instead of said while taking half of it away.
690+
691+
Only `install` takes it. `unpack`, `commit` and `remove` arm nothing and hold no
692+
two-file invariant of their own — `release_handler` serialising its own record
693+
writes is the whole of what they need — and putting `commit` behind an install
694+
that is waiting on a reboot would be a deadlock dressed as caution. The
695+
materialisation `Castle.install/1` does *first* is outside it too, and
696+
deliberately: it writes into the target's own version directory and touches no
697+
release record, its own primitives already refuse rather than replace, and
698+
holding this lock across a peer VM's boot would put every install behind
699+
another's configuration step. Nothing about the marker protocol depends on it.
633700

634701
**It is published the way `sys.config.pristine` is** — staged in an owner-only
635702
working directory and hard-linked into place — and for the same two reasons a
@@ -944,11 +1011,36 @@ arming and the disarming, so that `disarm/2` can be shown to leave a marker it
9441011
did not write. None of those has an end state that distinguishes it — a
9451012
successful install leaves the marker armed either way, and a failed one leaves it
9461013
gone either way — which is the same reason `Castle.Peer`'s primitives are public.
947-
The concurrency case needs no seam: a marker already at the path is a pending
948-
attempt, and what is asserted is that the install is refused, that
949-
`install_release/1` was never called, and that *neither* the marker nor OTP's file
950-
was touched — the second half being the ordering that keeps a refusal from
951-
destroying a concurrent install's evidence.
1014+
A marker already at the path needs no seam of its own: it is a pending attempt,
1015+
and what is asserted is that the install is refused, that `install_release/1` was
1016+
never called, and that *neither* the marker nor OTP's file was touched — the
1017+
second half being the ordering that keeps a refusal from destroying a pending
1018+
attempt's evidence.
1019+
1020+
**Two real callers do need one, and the seam is `which_releases/0` rather than
1021+
`install_release/1`.** The state that used to be reachable is two callers past the
1022+
running-release lookup and neither of them armed, so what has to be held open is
1023+
the *front* of the serialised region — and the lookup is both the first thing in
1024+
it and the last thing before the marker is armed. So `installer/3` runs
1025+
`install/4` in a task of its own with a `which_releases/0` that reports where it
1026+
got to and, optionally, waits to be released; a second caller is started while the
1027+
first is held there, and the discriminator is that its lookup never happens
1028+
(`refute_receive {:looked_up, :second}`). That is the only kind of assertion
1029+
available, and it is honest about why: the end state is the *same* either way —
1030+
the second caller is refused for a marker it finds and changes nothing — because
1031+
the interleaving that destroys evidence needs a caller suspended between step 1
1032+
and step 2, which has no seam and needs none once there cannot be two.
1033+
`Task.await` and `assert_receive` carry generous timeouts because `global`'s lock
1034+
retry backs off by up to a second or two, and every stub reply and call record
1035+
lives in the *task's* process dictionary, so a caller answers with its own
1036+
`Stub.calls(:install_release)` rather than the test reading them.
1037+
1038+
Three of them, and the third is not about the marker: one relup for 1.2.3 whose
1039+
transition from 1.2.2 is hot and from 1.2.1 restarts the emulator, installed
1040+
concurrently by two callers running different versions. It says the classification
1041+
belongs to the caller that made it — which is the second half of why the region
1042+
reaches past the arming — and that the hot caller neither adopts nor disarms the
1043+
marker the restarting one is waiting on.
9521044

9531045
What is *not* covered here is a booted release: the upgrade of a running
9541046
system, and the exit statuses `bin/castle` returns, belong to Forecastle's
@@ -1007,6 +1099,19 @@ wrote, so Elixir's pipeline is still armed in the file the launcher reads.
10071099
the state each window leaves: a unit test observes the filesystem from inside
10081100
`install_release/1` and finds the marker alone, and the restart `:e2e` suite
10091101
covers the far window by killing the provisional release before the commit.
1102+
- **Serialising the install is node-local, so a second VM writing into the same
1103+
releases directory is outside it.** `Castle.Commands.serialised/2` locks over
1104+
`[node()]`, which is exact for every caller Castle has — `bin/castle` is `rpc`,
1105+
and the launcher's preboot step calls only `make_releases/0` — but it is a
1106+
statement about callers rather than about the directory. Something else running
1107+
`Castle.install/1` in a VM of its own against the same deployment gets the
1108+
filesystem half of the protocol and nothing more: `publish/2` refuses rather
1109+
than replaces, so the marker cannot be silently taken over, and the window the
1110+
lock closes — two callers both past `unclaimed/2` before either publishes — is
1111+
open again between them. Widening the lock does not fix it; a lock the
1112+
filesystem holds would, at the price of a stale one after a hard kill blocking
1113+
every later install. Nothing is known to do this, and Castle does not detect
1114+
it.
10101115
- **The public API is undocumented.** `@moduledoc` is still the generated
10111116
placeholder and there are no `@doc` or `@spec` annotations
10121117
([#11](https://github.com/ausimian/castle/issues/11)).

RELEASE.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,19 @@
138138
earlier attempt is cleared before a new marker is armed - otherwise a retry of
139139
the same version would arm a marker beside a file it did not write, and a
140140
restart before the retry reached `:release_handler` would boot a version that
141-
nothing had installed. A restart install while another one is already pending
141+
nothing had installed.
142+
143+
Only one install runs on the node at a time, and that is what makes the
144+
clearing mean anything: two of them could otherwise both decide to arm before
145+
either had, and the second would clear the `new_start_erl.data` the first one's
146+
reboot depends on - leaving the first system to come back on the version it was
147+
upgrading away from, while the second reported that nothing had been changed.
148+
An install that has to wait waits, and then finds the first one's marker. What
149+
is serialised is the whole operation and not only the arming: which kind of
150+
transition an install is, is decided from the release the system is running,
151+
and another install completing in between would change that answer.
152+
153+
A restart install while another one is already pending
142154
is refused rather than allowed to take over its marker, saying so and changing
143155
nothing; the marker is consumed by the next start of the deployment, so a
144156
restart clears one left behind by an install that was interrupted. And the

0 commit comments

Comments
 (0)