Skip to content

Commit c8020cb

Browse files
committed
fix(state-writer): skip atomic rename when content unchanged
Wrappers under Restart=always with a sleep+exit-0 disabled branch (dump978-fa's no_hardware loop, airplanes-mlat's mlat_enabled_false loop) re-classify on every cycle and call airplanes_write_state with the same decision but a fresh decided_at timestamp. The atomic rename was firing every ~1200 cycles/day on a typical no-978-dongle feeder, waking any inotify consumer (e.g. systemd .path units watching the file). Compare the proposed content against the current target ignoring decided_at= lines and skip the rename when they match. Existing on-disk decided_at stays, so consumers see the time of the last material decision rather than the time of the last wrapper restart. AIRPLANES_WRITE_STATE_FORCE=1 keeps the previous always-rename behavior for callers that need it.
1 parent 01ffe62 commit c8020cb

2 files changed

Lines changed: 201 additions & 1 deletion

File tree

scripts/lib/state-writer.sh

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@
2020
# connected) is observable via systemd liveness, journal output, and
2121
# external probes.
2222
#
23+
# Dedupe: when an activation re-runs the wrapper and produces the same
24+
# decision (the common case for wrappers that sleep + exit 0 under
25+
# Restart=always — dump978-fa's no_hardware loop, airplanes-mlat's
26+
# disabled loop), the writer skips the atomic-rename if the proposed
27+
# content equals the current file content, ignoring decided_at= (which
28+
# is by convention a per-activation timestamp every caller refreshes).
29+
# Net effects: mtime stays at the time of the last *material* change,
30+
# inotify consumers (e.g. systemd .path units) don't fire on no-op
31+
# re-runs. AIRPLANES_WRITE_STATE_FORCE=1 disables this and always
32+
# atomically replaces the target.
33+
#
2334
# Readers MUST NOT `source` this file — values are unquoted; arbitrary
2435
# string content (e.g. user-supplied MLAT_USER) could contain shell
2536
# metacharacters. Parse line-by-line with KEY=VALUE split on first `=`.
@@ -34,7 +45,21 @@
3445
# mlat_user=
3546
#
3647
# Returns 0 on success, 1 on validation failure or write error
37-
# (target file is left unchanged on either).
48+
# (target file is left unchanged on either). Returns 0 with target
49+
# unchanged when the dedupe path skips the rename.
50+
51+
# Print $1's content with decided_at= lines filtered out. Used by the
52+
# dedupe path so a fresh timestamp on an otherwise-identical decision
53+
# doesn't force a rename.
54+
_airplanes_write_state_canonical() {
55+
local line
56+
while IFS= read -r line; do
57+
case "$line" in
58+
decided_at=*) continue ;;
59+
esac
60+
printf '%s\n' "$line"
61+
done < "$1"
62+
}
3863

3964
airplanes_write_state() {
4065
local target="$1"; shift
@@ -81,6 +106,17 @@ airplanes_write_state() {
81106
return 1
82107
fi
83108

109+
# Dedupe path: same decision as already on disk → skip the rename.
110+
# The mode-preserving chmod is also skipped — the existing file's
111+
# mode reflects an earlier successful write that already set 0644.
112+
if [[ "${AIRPLANES_WRITE_STATE_FORCE:-}" != "1" ]] \
113+
&& [[ -f "$target" && -r "$target" ]] \
114+
&& [[ "$(_airplanes_write_state_canonical "$tmp")" \
115+
== "$(_airplanes_write_state_canonical "$target")" ]]; then
116+
rm -f "$tmp"
117+
return 0
118+
fi
119+
84120
chmod 0644 "$tmp" || { rm -f "$tmp"; return 1; }
85121
mv -f "$tmp" "$target" || { rm -f "$tmp"; return 1; }
86122
return 0

test/test_state_writer.bats

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,167 @@ bar"
143143
grep -qx 'service=second' "$TARGET"
144144
! grep -qx 'service=first' "$TARGET"
145145
}
146+
147+
# --- Dedupe path ---
148+
# The writer skips the atomic rename when the proposed content matches
149+
# the current target ignoring decided_at= lines. This kills inotify
150+
# wakeups on no-op wrapper re-runs (Restart=always + sleep + exit 0)
151+
# without changing the on-disk content readers see.
152+
153+
@test "dedupe: identical content with same decided_at → no rename, mtime preserved" {
154+
airplanes_write_state "$TARGET" \
155+
service=airplanes-mlat \
156+
state=enabled \
157+
reason=ok \
158+
decided_at=2026-05-18T10:00:00Z
159+
local mtime_before
160+
mtime_before="$(stat -c %Y "$TARGET")"
161+
sleep 1 # widen mtime resolution window
162+
airplanes_write_state "$TARGET" \
163+
service=airplanes-mlat \
164+
state=enabled \
165+
reason=ok \
166+
decided_at=2026-05-18T10:00:00Z
167+
local mtime_after
168+
mtime_after="$(stat -c %Y "$TARGET")"
169+
[ "$mtime_after" -eq "$mtime_before" ]
170+
}
171+
172+
@test "dedupe: identical content with fresh decided_at → no rename" {
173+
# Every wrapper restart cycle re-computes decided_at. Without
174+
# ignoring it the file would be atomically replaced ~1200 times/day
175+
# on a feeder where the daemon's classify produces the same result
176+
# cycle after cycle.
177+
airplanes_write_state "$TARGET" \
178+
service=airplanes-mlat \
179+
state=disabled \
180+
reason=mlat_enabled_false \
181+
decided_at=2026-05-18T10:00:00Z
182+
local mtime_before
183+
mtime_before="$(stat -c %Y "$TARGET")"
184+
sleep 1
185+
airplanes_write_state "$TARGET" \
186+
service=airplanes-mlat \
187+
state=disabled \
188+
reason=mlat_enabled_false \
189+
decided_at=2026-05-18T10:00:30Z
190+
local mtime_after
191+
mtime_after="$(stat -c %Y "$TARGET")"
192+
[ "$mtime_after" -eq "$mtime_before" ]
193+
# Old decided_at stays — readers see the time of the last material
194+
# decision, not the time of the last wrapper restart.
195+
grep -qx 'decided_at=2026-05-18T10:00:00Z' "$TARGET"
196+
}
197+
198+
@test "dedupe: changed non-timestamp field → full rename happens" {
199+
airplanes_write_state "$TARGET" \
200+
service=airplanes-mlat \
201+
state=enabled \
202+
reason=ok \
203+
decided_at=2026-05-18T10:00:00Z
204+
local mtime_before
205+
mtime_before="$(stat -c %Y "$TARGET")"
206+
sleep 1
207+
airplanes_write_state "$TARGET" \
208+
service=airplanes-mlat \
209+
state=disabled \
210+
reason=mlat_enabled_false \
211+
decided_at=2026-05-18T10:00:30Z
212+
local mtime_after
213+
mtime_after="$(stat -c %Y "$TARGET")"
214+
[ "$mtime_after" -gt "$mtime_before" ]
215+
grep -qx 'state=disabled' "$TARGET"
216+
grep -qx 'reason=mlat_enabled_false' "$TARGET"
217+
grep -qx 'decided_at=2026-05-18T10:00:30Z' "$TARGET"
218+
}
219+
220+
@test "dedupe: added field → full rename happens" {
221+
airplanes_write_state "$TARGET" \
222+
service=airplanes-mlat \
223+
state=disabled \
224+
decided_at=2026-05-18T10:00:00Z
225+
local mtime_before
226+
mtime_before="$(stat -c %Y "$TARGET")"
227+
sleep 1
228+
airplanes_write_state "$TARGET" \
229+
service=airplanes-mlat \
230+
state=disabled \
231+
decided_at=2026-05-18T10:00:30Z \
232+
new_field=v1
233+
local mtime_after
234+
mtime_after="$(stat -c %Y "$TARGET")"
235+
[ "$mtime_after" -gt "$mtime_before" ]
236+
grep -qx 'new_field=v1' "$TARGET"
237+
}
238+
239+
@test "dedupe: removed field → full rename happens" {
240+
airplanes_write_state "$TARGET" \
241+
service=airplanes-mlat \
242+
state=enabled \
243+
reason=ok \
244+
decided_at=2026-05-18T10:00:00Z \
245+
extra=v
246+
local mtime_before
247+
mtime_before="$(stat -c %Y "$TARGET")"
248+
sleep 1
249+
airplanes_write_state "$TARGET" \
250+
service=airplanes-mlat \
251+
state=enabled \
252+
reason=ok \
253+
decided_at=2026-05-18T10:00:30Z
254+
local mtime_after
255+
mtime_after="$(stat -c %Y "$TARGET")"
256+
[ "$mtime_after" -gt "$mtime_before" ]
257+
! grep -qx 'extra=v' "$TARGET"
258+
}
259+
260+
@test "dedupe: reordered fields → full rename happens (order is semantic)" {
261+
# KEY=VALUE order is part of the writer's contract (caller-provided
262+
# order). A reorder is a content change.
263+
airplanes_write_state "$TARGET" \
264+
service=foo \
265+
state=enabled \
266+
decided_at=2026-05-18T10:00:00Z
267+
local mtime_before
268+
mtime_before="$(stat -c %Y "$TARGET")"
269+
sleep 1
270+
airplanes_write_state "$TARGET" \
271+
state=enabled \
272+
service=foo \
273+
decided_at=2026-05-18T10:00:30Z
274+
local mtime_after
275+
mtime_after="$(stat -c %Y "$TARGET")"
276+
[ "$mtime_after" -gt "$mtime_before" ]
277+
}
278+
279+
@test "dedupe: AIRPLANES_WRITE_STATE_FORCE=1 always renames" {
280+
airplanes_write_state "$TARGET" \
281+
service=foo \
282+
state=enabled \
283+
decided_at=2026-05-18T10:00:00Z
284+
local mtime_before
285+
mtime_before="$(stat -c %Y "$TARGET")"
286+
sleep 1
287+
AIRPLANES_WRITE_STATE_FORCE=1 airplanes_write_state "$TARGET" \
288+
service=foo \
289+
state=enabled \
290+
decided_at=2026-05-18T10:00:30Z
291+
local mtime_after
292+
mtime_after="$(stat -c %Y "$TARGET")"
293+
[ "$mtime_after" -gt "$mtime_before" ]
294+
grep -qx 'decided_at=2026-05-18T10:00:30Z' "$TARGET"
295+
}
296+
297+
@test "dedupe: target missing → write proceeds (no dedupe path engaged)" {
298+
[ ! -e "$TARGET" ]
299+
airplanes_write_state "$TARGET" service=foo state=enabled
300+
[ -f "$TARGET" ]
301+
grep -qx 'service=foo' "$TARGET"
302+
}
303+
304+
@test "dedupe: validation failure short-circuits before dedupe check" {
305+
airplanes_write_state "$TARGET" service=before
306+
run airplanes_write_state "$TARGET" service=after 'bad-key=value'
307+
[ "$status" -eq 1 ]
308+
grep -qx 'service=before' "$TARGET"
309+
}

0 commit comments

Comments
 (0)