Skip to content

Commit 4513f6c

Browse files
feat: flux-op publishes a result atomically
An operation writes into a staging directory and is moved into place only on success, so a failed command, a killed container and a power cut all leave the caller's data exactly as it was. Publishing swaps rather than deleting first. rename(2) refuses a non-empty directory as its target and cannot replace a file with a directory at all, so mv alone has to remove the existing entry before renaming - and a crash in that window loses the destination outright while its replacement sits under a staging name. Moving the old entry aside makes both steps atomic; the worst a crash leaves is the previous data under .flux-old-*, which a startup sweep restores. The swap is applied to every existing destination rather than branching on entry type. The branch is where the file-replaced-by-directory case was missed, and the smoke tests now cover all four combinations. Living in the image means one container does the work and the publish, so the two decisions cannot drift apart. Operands are positional parameters and are never interpolated into a command string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 61ff08e commit 4513f6c

4 files changed

Lines changed: 163 additions & 0 deletions

File tree

.github/workflows/build.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,54 @@ jobs:
7272
'
7373
echo "ok - cp -a -T does not nest"
7474
75+
# flux-op's contract: the destination changes only on success, and it
76+
# changes atomically whatever the two entry types are. rename(2) cannot
77+
# replace a file with a directory (or the reverse) at all, and refuses
78+
# a non-empty directory target - which is why publishing goes through a
79+
# swap rather than a delete-then-rename, and why every combination is
80+
# covered here rather than just the common one.
81+
op() {
82+
local label="$1" script="$2"
83+
if docker run --rm flux-volume-tools:test sh -c "$script" >/dev/null 2>&1; then
84+
echo "ok - $label"
85+
else
86+
echo "FAIL - $label"
87+
exit 1
88+
fi
89+
}
90+
91+
op "a failed command leaves the destination untouched" '
92+
mkdir -p /w && echo original > /w/dest
93+
flux-op /w/.flux-op-1 /w/dest -- false 2>/dev/null && exit 1
94+
grep -qx original /w/dest'
95+
96+
op "a directory replaces an existing file" '
97+
mkdir -p /w/src && echo new > /w/src/f && echo original > /w/dest
98+
flux-op /w/.flux-op-2 /w/dest -- cp -a -T /w/src /w/.flux-op-2
99+
grep -qx new /w/dest/f'
100+
101+
op "a directory replaces an existing directory, and does not merge into it" '
102+
mkdir -p /w/src /w/dest && echo new > /w/src/f && echo old > /w/dest/keep
103+
flux-op /w/.flux-op-3 /w/dest -- cp -a -T /w/src /w/.flux-op-3
104+
grep -qx new /w/dest/f
105+
test ! -e /w/dest/keep'
106+
107+
op "a file replaces an existing directory" '
108+
mkdir -p /w && echo new > /w/src && mkdir -p /w/dest && echo old > /w/dest/keep
109+
flux-op /w/.flux-op-4 /w/dest -- cp -a -T /w/src /w/.flux-op-4
110+
grep -qx new /w/dest'
111+
112+
op "a new destination leaves no staging or swap directory behind" '
113+
mkdir -p /w/src && echo new > /w/src/f
114+
flux-op /w/.flux-op-5 /w/dest -- cp -a -T /w/src /w/.flux-op-5
115+
grep -qx new /w/dest/f
116+
test -z "$(ls -A /w | grep flux- || true)"'
117+
118+
op "--mkdir creates the staging directory for commands that need one" '
119+
mkdir -p /w/src && echo x > /w/src/f && tar -cf /w/a.tar -C /w src
120+
flux-op --mkdir /w/.flux-op-6 /w/out -- tar -xf /w/a.tar -C /w/.flux-op-6
121+
grep -qx x /w/out/src/f'
122+
75123
publish:
76124
needs: smoke-test
77125
if: github.event_name != 'pull_request'

Dockerfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ FROM alpine:3.24
2222
# -T, so this one is about predictable behaviour rather than a missing flag.
2323
RUN apk add --no-cache coreutils tar unzip
2424

25+
# Runs a command into a staging directory and publishes the result with an atomic
26+
# rename, so an operation that fails, is cancelled, or is interrupted by a power
27+
# cut leaves the caller's data exactly as it was. Living in the image means one
28+
# container does the work AND the publish - see the script for why that matters.
29+
COPY flux-op /usr/local/bin/flux-op
30+
RUN chmod 0755 /usr/local/bin/flux-op
31+
2532
# No default command by design: the executor always supplies argv, and an accidental
2633
# `docker run` of this image should do nothing.
2734
ENTRYPOINT []

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,42 @@ routing filesystem work through Docker adds no new privilege — whereas running
5050
`sudo cp` on the host requires sudoers rules that the planned demotion of FluxOS to
5151
an unprivileged system user is meant to remove.
5252

53+
## `flux-op` — publishing a result atomically
54+
55+
```
56+
flux-op [--mkdir] <staging> <destination> -- <command> [args...]
57+
```
58+
59+
The command writes into `<staging>`, never into `<destination>`. Only on success
60+
is the result moved into place. A command that fails, a container that is killed,
61+
and a node that loses power all leave `<destination>` exactly as it was.
62+
63+
Publishing goes through a swap — move the old entry aside, move the new one in,
64+
delete the old — rather than a delete-then-rename. `rename(2)` refuses a
65+
non-empty directory as its target and cannot replace a file with a directory at
66+
all, so `mv` alone would have to delete first, and a crash in that window loses
67+
the destination outright. Both renames in the swap are atomic, so the worst a
68+
crash leaves is the previous data under `.flux-old-*`.
69+
70+
Leftovers are named for a startup sweep to recognise:
71+
72+
| Left behind | Means | Recovery |
73+
|---|---|---|
74+
| `.flux-op-*` | the operation never completed | delete; nobody is waiting for it |
75+
| `.flux-old-*`, destination missing | crash mid-swap | rename it back |
76+
| `.flux-old-*`, destination present | the swap completed | delete |
77+
78+
This is **atomic visibility, not durability.** You will never see a half-written
79+
result at the destination path. A power cut seconds after a copy can still leave
80+
files whose contents had not reached disk — that is true of `cp` on Linux
81+
generally and is not something this changes.
82+
83+
It lives in the image rather than in the caller so one container does the work
84+
*and* the publish: the "did it succeed" and "put it in place" decisions cannot
85+
drift apart, and no second container spawn is needed per operation. Operands
86+
arrive as positional parameters and are never interpolated into a command
87+
string.
88+
5389
## Contract
5490

5591
The image guarantees these binaries, with GNU / Info-ZIP semantics:

flux-op

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/bin/sh
2+
# Run one file operation and publish its result atomically.
3+
#
4+
# flux-op [--mkdir] <staging> <destination> -- <command> [args...]
5+
#
6+
# The command writes into <staging>, never into <destination>. Only if it
7+
# succeeds is the result moved into place. A command that fails, a container
8+
# that is killed, and a node that loses power all leave <destination> exactly as
9+
# it was, with the incomplete work parked under a name the startup sweep
10+
# recognises.
11+
#
12+
# This lives in the image rather than in the caller so that one container does
13+
# the work AND the publish: the "did it succeed" and "put it in place" decisions
14+
# cannot drift apart, and a second container spawn is not needed per operation.
15+
#
16+
# Operands arrive as positional parameters and are never interpolated into a
17+
# command string - "$@" runs the command with its argv intact, so there is no
18+
# shell parsing of anything the caller supplied.
19+
set -eu
20+
21+
make_staging=0
22+
if [ "${1:-}" = "--mkdir" ]; then
23+
make_staging=1
24+
shift
25+
fi
26+
27+
if [ "$#" -lt 4 ]; then
28+
echo "flux-op: usage: flux-op [--mkdir] <staging> <destination> -- <command> [args...]" >&2
29+
exit 2
30+
fi
31+
32+
staging=$1
33+
destination=$2
34+
shift 2
35+
36+
if [ "$1" != "--" ]; then
37+
echo "flux-op: expected -- before the command" >&2
38+
exit 2
39+
fi
40+
shift
41+
42+
# Only the commands that need an existing directory to write into (tar -C, for
43+
# one) ask for this. A file copy must NOT have it: cp -T refuses to overwrite a
44+
# directory with a non-directory.
45+
if [ "$make_staging" -eq 1 ]; then
46+
mkdir -p "$staging"
47+
fi
48+
49+
"$@"
50+
51+
# A destination that does not exist is one atomic rename and nothing to clean up.
52+
#
53+
# Replacing one that DOES exist cannot be a single rename in the general case.
54+
# rename(2) refuses a non-empty directory as its target, and refuses to replace a
55+
# file with a directory (or the reverse) at all - so `mv` would have to delete
56+
# the existing entry first, and a crash in that window loses the destination
57+
# outright while the replacement sits under a staging name nobody recognises.
58+
#
59+
# Moving the old entry aside first avoids the window whatever the two types are.
60+
# Both renames are atomic, so the worst a crash leaves is the old data under
61+
# .flux-old-*, which the startup sweep renames back when it finds the
62+
# destination missing. Uniform rather than branching on type: the branch is
63+
# where the file-replaced-by-directory case was originally missed.
64+
if [ ! -e "$destination" ] && [ ! -L "$destination" ]; then
65+
mv -T "$staging" "$destination"
66+
else
67+
staging_base=$(basename "$staging")
68+
old="$(dirname "$staging")/.flux-old-${staging_base#.flux-op-}"
69+
mv -T "$destination" "$old"
70+
mv -T "$staging" "$destination"
71+
rm -rf "$old"
72+
fi

0 commit comments

Comments
 (0)