Skip to content

Commit 6b299b6

Browse files
committed
test: drive the concurrent install through the command boundary
The case that pins two callers not deciding each other's configuration drove `Commands.install/5`, and the defect it names was a composition in `Castle.install/1` - `materialise/3` and then the install, so both callers configured the target before either reached the lock. Putting that composition back therefore left the case green: it was asserting about a function the defect was never in. `Castle.install/1` now takes the releases directory, the handler, the peer and the deployment as defaulted arguments, the way `Commands.install/5` already did, and that one case runs two concurrent callers through the boundary `bin/castle` actually reaches over `rpc`. `installer/3` takes `through: :boundary` for it; the boundary prints what succeeded and raises what failed, so `invoke/2` puts both back into the shape `Commands.install/5` returns - `with_io/1` inside the task, because that is whose group leader has to be swapped. Verified by reintroducing the composition: the case fails on the second caller's peer having been called and on the target holding its configuration, which is exactly what it is for. Every other concurrency case stays on `Commands.install/5`, which is the right level for a claim about the serialised region itself. The arity-1 form is unchanged and nothing in a release passes the extra arguments, so no caller chooses anything about the deployment. Claude-Session: https://claude.ai/code/session_01C4oaMvbR1cbxrZBj8qwkqN
1 parent f63f8df commit 6b299b6

4 files changed

Lines changed: 99 additions & 4 deletions

File tree

AGENTS.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,18 @@ Castle's job is configuration and release management on a running node.
533533
version directory and is idempotent; it ends in a rename onto `sys.config`, so
534534
it is not. See the restart-marker section for the whole of it.
535535

536+
**And that claim is now tested at the boundary, which is what
537+
`Castle.install/2..5` is for.** `install` takes the releases directory, the
538+
handler, the peer and the deployment as defaulted arguments, exactly as
539+
`Commands.install/5` does, for one reason: a concurrency test that drives
540+
`Commands.install/5` cannot see anything composed in `Castle.install/1`, so the
541+
defect above was reintroducible with the whole suite green. One case in
542+
`commands_test.exs` runs two concurrent callers through `Castle.install/5`
543+
instead, and fails if a `materialise/3` reappears in front of the install. The
544+
arity-1 form is unchanged and is still what `bin/castle` calls over `rpc`;
545+
nothing about the deployment is chosen by a caller in a release, because nothing
546+
in a release passes the extra arguments.
547+
536548
`Castle.commit/1` does still compose materialise-then-commit, which is why an
537549
ERTS-less deployment hears "Cannot configure" from `commit` and "Cannot
538550
install" from `install`. That asymmetry is exact rather than untidy, and
@@ -1154,6 +1166,20 @@ what this test fails against, because it is the point of it — not just
11541166
materialising outside the lock, but materialising *inside* the lock and in front
11551167
of `unclaimed/3`, which is the fix that looks sufficient and is not.
11561168

1169+
**It is also the one case that runs through `Castle.install/5` rather than
1170+
`Commands.install/5`, and that is not a detail.** The defect was a composition in
1171+
`Castle.install/1`, so a case that only ever called `Commands.install/5` was
1172+
asserting about a function the defect was not in: putting `materialise/3` back in
1173+
front of the install left it green. `installer/3` takes `through: :boundary` for
1174+
this one, which runs the command boundary — so the two callers are two `rpc`s,
1175+
which is what they are in a deployment. The boundary prints what succeeded and
1176+
raises what failed, so `invoke/2` puts both back into the shape
1177+
`Commands.install/5` returns: `with_io/1` inside the task, because that is whose
1178+
group leader has to be swapped, and an implicit-`try` `attempt/1` to turn
1179+
`Castle.Error` back into an `{:error, message}`. Every other case here stays on
1180+
`Commands.install/5`, which is the right level for a claim about the serialised
1181+
region itself.
1182+
11571183
**The exception path has tests of its own, and the seam is
11581184
`Castle.ReleaseHandlerStub`'s function reply again.** A raise, an exit and a
11591185
throw out of `install_release/1`, each asserted to leave no marker behind, with

RELEASE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,11 @@
230230
release built by Mix that is the file OTP writes; a deployment that sets
231231
`RELDIR` or the `sasl` `releases_dir` parameter moves the release records
232232
elsewhere, and Castle does not yet follow them.
233+
- `Castle.install/1` accepts four further arguments, all defaulted, naming the
234+
releases directory and the modules it talks to. `Castle.install("1.2.3")` is
235+
unchanged and is still what `bin/castle` calls; the arguments exist so that
236+
concurrent installs can be exercised through the function an operator actually
237+
invokes, rather than one layer below it.
233238
- `unpack/1`, `install/1`, `commit/1`, `remove/1` and
234239
`make_releases/0` now fail when the operation fails, instead of printing the
235240
reason and returning normally. These are invoked over `bin/castle`, which

lib/castle.ex

Lines changed: 25 additions & 2 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
@@ -56,8 +57,30 @@ defmodule Castle do
5657
# So there is nothing to compose: `Castle.install/1` is one call, and "an
5758
# install is serialised" is now true of *this* function rather than of a part of
5859
# it. See `Castle.Commands.install/5` and `serialised/2`.
59-
def install(vsn) when is_binary(vsn) do
60-
report!(Commands.install(vsn, rel_dir()))
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))
6184
end
6285

6386
def running(vsn) when is_binary(vsn) do

test/castle/commands_test.exs

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
defmodule Castle.CommandsTest do
22
use ExUnit.Case, async: true
33

4+
import ExUnit.CaptureIO
5+
46
alias Castle.Commands
57
alias Castle.DeploymentStub
68
alias Castle.InitStub
@@ -796,6 +798,13 @@ defmodule Castle.CommandsTest do
796798
# the winner's marker. The install that was refused decided what the install
797799
# that succeeded booted.
798800
#
801+
# **So this one goes through `Castle.install/5`, and that is the point of the
802+
# arguments it takes.** Every other case here drives `Commands.install/5`,
803+
# which is the right level for them - but the composition was one layer up,
804+
# in the function `bin/castle` actually calls, and a case that never calls it
805+
# would stay green while somebody put `materialise/3` back in front of the
806+
# install. The two callers here are two `rpc`s, which is what they would be.
807+
#
799808
# Two providers that yield *distinguishable* results is what makes it
800809
# visible. With both callers answering `{:ok, []}` the end state is identical
801810
# whichever of them ran, which is why every existing test passed against it.
@@ -805,6 +814,7 @@ defmodule Castle.CommandsTest do
805814
installer(dir, "1.2.2",
806815
as: :first,
807816
hold: true,
817+
through: :boundary,
808818
install: prepares_then_reboots(dir),
809819
configure: configures("[{first, resolved}].\n")
810820
)
@@ -814,6 +824,7 @@ defmodule Castle.CommandsTest do
814824
second =
815825
installer(dir, "1.2.2",
816826
as: :second,
827+
through: :boundary,
817828
configure: configures("[{second, resolved}].\n")
818829
)
819830

@@ -1239,12 +1250,19 @@ defmodule Castle.CommandsTest do
12391250
# results are told apart. That is the only way to see *whose* configuration a
12401251
# version ended up with, which is the thing composing materialisation in front
12411252
# of the lock got wrong.
1253+
#
1254+
# `through: :boundary` runs `Castle.install/5` instead of `Commands.install/5`.
1255+
# That distinction is load bearing rather than tidy: the defect was
1256+
# `Castle.install/1` composing `materialise/3` and the install, so a case that
1257+
# only ever calls `Commands.install/5` cannot see it come back. One case uses it,
1258+
# and says why.
12421259
defp installer(rel_dir, from, opts) do
12431260
test = self()
12441261
name = Keyword.fetch!(opts, :as)
12451262
lookup = lookup(test, name, from, Keyword.get(opts, :hold, false))
12461263
reply = Keyword.get(opts, :install, {:ok, ~c"1.2.2", ~c"upgrade"})
12471264
configure = Keyword.get(opts, :configure, {:ok, []})
1265+
through = Keyword.get(opts, :through, :commands)
12481266

12491267
Task.async(fn ->
12501268
Stub.stub(:which_releases, lookup)
@@ -1253,11 +1271,34 @@ defmodule Castle.CommandsTest do
12531271

12541272
send(test, {:started, name})
12551273

1256-
{Commands.install("1.2.3", rel_dir, Stub, PeerStub), Stub.calls(:install_release),
1257-
PeerStub.calls()}
1274+
{invoke(through, rel_dir), Stub.calls(:install_release), PeerStub.calls()}
12581275
end)
12591276
end
12601277

1278+
defp invoke(:commands, rel_dir), do: Commands.install("1.2.3", rel_dir, Stub, PeerStub)
1279+
1280+
# Through `Castle.install/5`, which is the function `bin/castle` reaches over
1281+
# `rpc` and the only place a composition in front of the serialised region could
1282+
# live. It is a command boundary rather than an operation, so it *prints* what
1283+
# succeeded and *raises* what failed; both are turned back into the shape
1284+
# `Commands.install/5` returns so that a case can be written either way round.
1285+
#
1286+
# `with_io/1` rather than `capture_io/1` because the result is wanted as well as
1287+
# the output, and it runs in the task's own process because that is whose group
1288+
# leader has to be swapped.
1289+
defp invoke(:boundary, rel_dir) do
1290+
case with_io(fn -> attempt(rel_dir) end) do
1291+
{{:error, _} = refusal, _output} -> refusal
1292+
{:ok, output} -> {:ok, String.split(output, "\n", trim: true)}
1293+
end
1294+
end
1295+
1296+
defp attempt(rel_dir) do
1297+
Castle.install("1.2.3", rel_dir, Stub, PeerStub)
1298+
rescue
1299+
error in Castle.Error -> {:error, Exception.message(error)}
1300+
end
1301+
12611302
# The `which_releases/0` a caller is given: it says that the lookup happened
12621303
# and, when the caller is the one being held, waits there until it is let go.
12631304
defp lookup(test, name, from, hold?) do

0 commit comments

Comments
 (0)