Skip to content

Commit 517b7ae

Browse files
authored
Merge pull request #26 from ausimian/issue/14-restart
feat: install restart transitions under an external supervisor
2 parents a9b007a + c0acc72 commit 517b7ae

10 files changed

Lines changed: 2663 additions & 139 deletions

File tree

AGENTS.md

Lines changed: 536 additions & 21 deletions
Large diffs are not rendered by default.

RELEASE.md

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,119 @@
112112
script that emits the marker before its applications are started defeats the
113113
check instead, since the marker is all there is to go on.
114114

115-
Nothing can build a relup that restarts the emulator until
116-
[forecastle#4](https://github.com/ausimian/forecastle/issues/4), so the
117-
restart transitions this addresses cannot be exercised end to end yet. The
118-
hot-upgrade path is covered by Forecastle's end-to-end suite; the statuses
119-
themselves are covered by unit tests here.
115+
Both the hot-upgrade path and the emulator-restart path are covered end to end
116+
by Forecastle's `:e2e` suite, which polls through a real reboot.
117+
- Upgrades that restart the emulator now work on a release supervised by
118+
systemd, Docker, Kubernetes or anything else that owns starting the service.
119+
`Castle.install/1` recognises such a transition from the relup before it asks
120+
`:release_handler` for anything, and leaves a marker beside the release records
121+
naming the version being installed. The launcher's `env.sh` fragment, which
122+
Forecastle 1.0.0 contributes, consumes that marker on the next start and boots
123+
the version it names.
124+
125+
Two files have to agree for that to happen, and the reason is worth stating:
126+
`:release_handler` writes `releases/new_start_erl.data` *before* the reboot and
127+
nothing ever removes it, so a preparation that failed part-way leaves a file
128+
naming a version that was never installed. Castle's own marker is what says a
129+
reboot was really asked for; it is written immediately before the install and
130+
removed on every path where the install failed, and the launcher requires both
131+
files and requires them to name one version. An install whose marker cannot be
132+
written - a release root nothing may write to - is refused rather than
133+
performed, because the alternative is a reboot that silently comes back on the
134+
version it was upgrading away from.
135+
136+
Agreeing on a version is not on its own enough, so the pair belongs to one
137+
install *attempt* rather than to a version. Any `new_start_erl.data` left by an
138+
earlier attempt is cleared before a new marker is armed - otherwise a retry of
139+
the same version would arm a marker beside a file it did not write, and a
140+
restart before the retry reached `:release_handler` would boot a version that
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.
149+
150+
What is serialised is `Castle.install/1` itself, and that includes
151+
materialising the target's configuration. It is worth saying which parts, since
152+
"the whole operation" was claimed here while the configuration step was still
153+
outside: materialising ends by renaming a resolved configuration onto the
154+
target's `sys.config`, so two callers doing it before either reached the lock
155+
meant the loser's config providers - evaluated in a VM of their own, with
156+
whatever environment that caller had - could replace the configuration the
157+
winner's provisional release was about to boot, after which the loser was
158+
refused for the winner's marker. The install that was refused decided what the
159+
install that succeeded booted. Configuration providers are not obliged to
160+
produce the same answer twice, which is the reason `sys.config.pristine` exists
161+
in the first place.
162+
163+
So the region now runs from the release-record lookup through the
164+
configuration step to `install_release/1` and the marker being settled, and a
165+
caller that is going to be told a restart install is pending is told *before*
166+
it configures anything. Only the ERTS guard is outside, because it reads two
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.
176+
177+
Which kind of transition an install is, is decided from the release the system
178+
is running, and another install completing in between would change that answer -
179+
which is the other reason the region reaches past the arming.
180+
181+
A restart install while another one is already pending
182+
is refused rather than allowed to take over its marker, saying so and changing
183+
nothing; the marker is consumed by the next start of the deployment, so a
184+
restart clears one left behind by an install that was interrupted. And the
185+
marker records which attempt armed it, so a failed install removes only its own
186+
- a start of the deployment consumes the marker whether or not it goes on to
187+
boot, so the file at that path when an install fails is not necessarily the one
188+
that install wrote. It is published by linking a file that is already complete
189+
into place, the way the pristine configuration above is, so no start can read a
190+
marker that is half written and a race is refused rather than silently won.
191+
192+
The marker is settled on **every** way out of the install, including the ones
193+
that do not return: an exit, a throw or a raise out of `install_release/1` is
194+
caught, the marker dealt with, and the failure then let out unchanged. Before,
195+
only a returned error cleared it - so an exception left the marker armed, and
196+
where `:release_handler` had already written its own file the pair was complete
197+
and the next start booted a version whose install had blown up.
198+
199+
And an install that cannot settle its marker now **says so, and says what it
200+
means**, rather than reporting the original failure alone. A marker Castle
201+
could not remove, or could not read well enough to tell whether it was still
202+
its own, is a live instruction to the next start of that system: the failure
203+
message names the file, says that `new_start_erl.data` may already be beside
204+
it, says that an ordinary restart will therefore boot the version the install
205+
did not finish, and asks for the marker to be removed first. Clearing it used
206+
to be best effort on the argument that a directory the marker cannot be removed
207+
from is one it could not have been linked into - which holds only if nothing
208+
changed in between, and `install_release/1` runs in between.
209+
210+
What the install *reports* is different for such a transition, because
211+
`install_release/1` replies the same `{ok, Vsn, Descr}` for a completed hot
212+
upgrade and for one that is about to reboot. Rather than say "Now running", it
213+
says that the version was installed, that the emulator is restarting, and that
214+
the version stays provisional until it is committed - which is what
215+
`releases/start_erl.data` still naming the previous version means. `bin/castle
216+
install` goes on asking the system what it is running across the reboot, and
217+
exits 0 once the installed version answers.
218+
219+
The rollback that provisional state buys is real and needs nothing:
220+
`make_permanent/1` is the only thing that writes `releases/start_erl.data`, so
221+
a provisional release that dies before `Castle.commit/1` is followed by an
222+
ordinary start of the version that was permanent before.
223+
224+
The two-stage `restart_new_emulator` transition remains unsupported. It reboots
225+
into a temporary hybrid release whose version directory holds a boot script and
226+
a configuration and none of the launcher's own files, so there is nothing for a
227+
launcher to boot; Forecastle refuses to generate one.
120228

121229
### Changed
122230

@@ -127,6 +235,11 @@
127235
release built by Mix that is the file OTP writes; a deployment that sets
128236
`RELDIR` or the `sasl` `releases_dir` parameter moves the release records
129237
elsewhere, and Castle does not yet follow them.
238+
- `Castle.install/1` accepts four further arguments, all defaulted, naming the
239+
releases directory and the modules it talks to. `Castle.install("1.2.3")` is
240+
unchanged and is still what `bin/castle` calls; the arguments exist so that
241+
concurrent installs can be exercised through the function an operator actually
242+
invokes, rather than one layer below it.
130243
- `unpack/1`, `install/1`, `commit/1`, `remove/1` and
131244
`make_releases/0` now fail when the operation fails, instead of printing the
132245
reason and returning normally. These are invoked over `bin/castle`, which

lib/castle.ex

Lines changed: 69 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ defmodule Castle do
55

66
alias Castle.Commands
77
alias Castle.Deployment
8+
alias Castle.Peer
89

910
# Every function in this module is a command entry point: `bin/castle` sends
1011
# each one to the running node over `bin/<release> rpc`, and the launcher's
@@ -42,18 +43,52 @@ defmodule Castle do
4243
report!(Commands.unpack(name))
4344
end
4445

45-
def install(vsn) when is_binary(vsn) do
46-
materialise(vsn)
47-
report!(Commands.install(vsn))
46+
# Materialising is *inside* `Commands.install/5`, and this composing it here is
47+
# the bug that put it there. Materialisation ends in a rename onto the target's
48+
# `sys.config` - a replace by design, and it has to be, because that is the file
49+
# `:release_handler` reads - so it is not the harmless idempotent work the note
50+
# below used to call it. Two callers here both materialised before either
51+
# entered the serialised region, and the loser's providers - evaluated in a
52+
# second VM, with whatever environment that call had - overwrote the
53+
# configuration the winner's provisional release was about to boot, after which
54+
# the loser was refused for the winner's marker. The refused install decided the
55+
# configuration of the one that succeeded.
56+
#
57+
# So there is nothing to compose: `Castle.install/1` is one call, and "an
58+
# install is serialised" is now true of *this* function rather than of a part of
59+
# it. See `Castle.Commands.install/5` and `serialised/2`.
60+
#
61+
# **And that claim is tested here rather than one layer down, which is what the
62+
# four defaulted arguments are for.** `Castle.Commands.install/5` already took
63+
# the handler, the peer and the deployment so that its own suite could drive two
64+
# concurrent callers through it; but a test that drives *it* cannot see anything
65+
# composed in *this* function, so the composition that was the whole defect would
66+
# have been reintroducible with every test still green. `rel_dir` joins them for
67+
# the same reason it is an argument there - a suite needs a releases directory of
68+
# its own to contend over, or the cases cannot run async - and the three module
69+
# arguments follow it because a caller held at `which_releases/0` is the only
70+
# seam the interleaving has.
71+
#
72+
# They are defaults rather than a separate entry point so that `bin/castle`
73+
# keeps calling `Castle.install/1` over `rpc` and nothing about the deployment
74+
# is chosen by a caller: see `rel_dir/0`.
75+
def install(
76+
vsn,
77+
rel_dir \\ rel_dir(),
78+
handler \\ :release_handler,
79+
peer \\ Peer,
80+
deployment \\ Deployment
81+
)
82+
when is_binary(vsn) do
83+
report!(Commands.install(vsn, rel_dir, handler, peer, deployment))
4884
end
4985

5086
def running(vsn) when is_binary(vsn) do
5187
report!(Commands.running(vsn))
5288
end
5389

5490
def commit(vsn) when is_binary(vsn) do
55-
materialise(vsn)
56-
report!(Commands.commit(vsn))
91+
report!(Commands.commit(vsn, rel_dir()))
5792
end
5893

5994
def remove(vsn) when is_binary(vsn) do
@@ -64,33 +99,40 @@ defmodule Castle do
6499
report!(Commands.releases())
65100
end
66101

67-
# Makes sure the target version's configuration exists before the version is
68-
# handed to `:release_handler`, and fails here if it cannot be made to. It
69-
# runs ahead of both operations that need it, and everything about the target
70-
# that can refuse to go on - a peer that will not start, a boot script that is
71-
# not there, a provider that raises - refuses from 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`.
72108
#
73-
# `Commands.install/3` then refuses a running node whose release record OTP
74-
# synthesised, which is a fact about this node rather than about the target,
75-
# and so cannot be answered here. Both refusals are before `install_release/1`
76-
# has been asked for anything, which is the line that matters: nothing after
77-
# that point may fail without saying that an install happened.
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.
78123
#
79-
# The order means a node that will be refused for its record materialises the
80-
# target's configuration before it hears so. That is what the record check
81-
# costs by living inside the operation instead of in front of it, and it is
82-
# only work: materialising writes into the target's version directory, never to
83-
# the running system and never to a release record, and it is idempotent, so
84-
# the refusal still leaves the system exactly as it was.
85-
defp materialise(vsn), do: report!(Commands.materialise(rel_vsn_dir(vsn)))
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.
86131

87-
# The release directory, and the version directory of the release being
88-
# 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
89133
# configuration lands in, and which file the release records go in, are
90134
# properties of the installation rather than arguments, and a caller's working
91-
# directory cannot make them name different ones. The version directory
92-
# resolves for any version the running release knows about, because
93-
# `:release_handler` unpacks every version into this same root.
135+
# directory cannot make them name different ones.
94136
#
95137
# `Castle.Deployment.root_dir/0` says what that root does and does not decide,
96138
# and is the one place that says it. The part that bears on these two: it is
@@ -103,7 +145,6 @@ defmodule Castle do
103145
# `RELEASE_ROOT` - which is the one deployment where this derivation names the
104146
# wrong tree, and where every operation that would act on it refuses.
105147
defp rel_dir, do: Path.join(Deployment.root_dir(), "releases")
106-
defp rel_vsn_dir(vsn), do: Path.join(rel_dir(), vsn)
107148

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

0 commit comments

Comments
 (0)