Skip to content

The worker waits for a synchronised clock before it consumes - #261

Merged
frapercan merged 2 commits into
mainfrom
node/the-worker-waits-for-the-clock
Aug 29, 2026
Merged

frapercan merged 2 commits into
mainfrom
node/the-worker-waits-for-the-clock

Conversation

@frapercan

Copy link
Copy Markdown
Owner

This desktop dual-boots, and Windows writes local time to the hardware clock. After a Windows session the kernel reads that clock as UTC, so the system comes up exactly two hours fast until chronyd steps it back. On 2026-08-29:

05:46:04  kernel sets the system clock from the RTC   (two hours fast)
05:46:11  systemd starts the worker unit              (worker alive, clock wrong)
05:46:20  worker consumes a job and writes three events with the skewed clock
05:46:23  chronyd steps the clock by -7199.510224 seconds

Twelve seconds of exposure, and the worker beat the correction by three. It matters because a job event carries the clock of the machine that emitted it, so a compute node that starts early does not merely mislabel its own work: it writes fiction into the stream both machines read. Those three rows made a healthy run look like it had stalled for 110 minutes, and establishing otherwise took most of a day. An exact detector over the event table, ts running backwards while the sequential id advances, finds two episodes in 798,081 events, on 2026-08-11 and 2026-08-29, both on this node.

What it does

The wrapper polls NTPSynchronized, the kernel's own adjtimex flag rather than anything chrony-specific, for up to 120 seconds before it execs the worker.

It never refuses to start. When the budget runs out the worker starts anyway and says so, because a queue with no consumer costs this project more than a wrong timestamp does: that failure has already cost 110 minutes once and announced itself to nobody. Every branch ends with the worker running, including a missing timedatectl, a missing timeout(1), an unreadable /proc/uptime and a budget of garbage.

Three second-order choices, each measured

Elapsed time comes from /proc/uptime, never from the wall clock. At the instant chronyd steps back by 7,199 seconds a wall-clock deadline concludes it has two hours of budget left, on the one path that must never hang. Bash's own $SECONDS has the same defect.

The budget is forced to base ten and capped at 600. Bash reads a leading zero as octal, so 0120 is eighty and 09 is not a number at all, and the syntax check accepts both. The second one is the dangerous half: the arithmetic error lands inside a condition, set -e does not fire, and the gate is silently disarmed while the file still says there is a gate. And 1200 in the environment file is a plausible "give NTP plenty of time" that buys twenty minutes of a unit sitting in active (running), absent from --failed, silent in the log, consuming nothing.

The give-up marker is scoped per queue. This node runs several instances of the template at once: on 2026-08-29 systemd started @protea.evaluations and @protea.predictions.batch in the same millisecond. An unscoped marker lets the first instance to give up disarm the gate for every other one on a boot where the clock is still wrong.

The unit loses two inert lines

After=network-online.target and Wants=network-online.target were already inert, because a user manager has no such target. That is why this worker could start before the network was up. They are removed rather than commented, and no ordering replaces them, because an inert declaration is worse than no declaration: a test user unit ordered after two non-existent targets starts with rc=0 and Result=success.

Two traps in checking this, pointing opposite ways. systemctl --user show <unit> -p After lists ordering names for units that do not exist, so it overstates. And LoadState answers only for unit types that need a unit file: an invented .service and .target report not-found, an invented .slice reports loaded. Existence belongs on list-units --all.

How it is verified

By running the gate where it must fire, not by checking that it is declared. Nine controls against the real file:

Control Required
clock already good 0 s, clock=ok, no extra line
never synchronises, budget 3 waits 3 s, warns, clock=unsynced
recovers on the third probe clock synchronised after 2s, clock=ok
budget 1200 and 120000 capped at 600 and says so, 0 s
budget 09 waits exactly 9 s, no base error
two queues, one boot the second still gates after the first gives up
no timedatectl one warning, clock=unchecked, starts, no marker
no timeout(1) same
bad configuration still exits 78 in 0 s, without entering the gate

systemd-analyze verify --user and bash -n are clean.

pytest is 615 passed, 1 failed. The failure is tests/test_restore_drill_pyshim.py::test_bash_test_passes, and it is not from this branch: it fails identically on origin/main, checked in a throwaway worktree. Reported rather than described as green.

What this does not close

The warnings and the chronyc tracking dump go to the node's local log. No server-side surface sees them. A node whose clock is wrong still cannot announce it to the server, and that is a separate piece.

This desktop dual-boots, and Windows writes local time to the hardware
clock. After a Windows session the kernel reads that clock as UTC, so
the system comes up exactly two hours fast until chronyd steps it back.
On 2026-08-29 the kernel set the clock at 05:46:04, systemd started this
unit at 05:46:11, the worker took a job and wrote three events at
05:46:20, and chronyd corrected by -7199.5 seconds at 05:46:23. Twelve
seconds of exposure and the worker beat the correction by three.

It matters because a job event carries the clock of the machine that
emitted it, so a compute node that starts early does not merely mislabel
its own work: it writes fiction into the stream both machines read.
Those three rows made a healthy run look like it had stalled for 110
minutes, and establishing otherwise took most of a day. An exact
detector over the event table, ts running backwards while the sequential
id advances, finds two episodes in 798,081 events, 2026-08-11 and
2026-08-29, both on this node.

The gate polls NTPSynchronized, the kernel's own adjtimex flag rather
than anything chrony-specific, and it never refuses to start. When the
budget runs out the worker starts anyway and says so, because a queue
with no consumer costs this project more than a wrong timestamp does:
that failure has already cost 110 minutes once and announced itself to
nobody. Every branch ends with the worker running, including a missing
timedatectl, a missing timeout(1), an unreadable /proc/uptime and a
budget of garbage.

Three second-order choices are load-bearing and each was measured:

Elapsed time is read from /proc/uptime and never from the wall clock,
because at the instant chronyd steps back by 7,199 seconds a wall-clock
deadline concludes it has two hours of budget left, on the one path that
must never hang.

The budget is forced to base ten and capped at 600. Bash reads a leading
zero as octal, so 0120 is eighty and 09 is not a number at all, and the
syntax check accepts both; and 1200 in the environment file buys twenty
minutes of a unit sitting in active (running), absent from --failed,
silent in the log, consuming nothing.

The give-up marker is scoped per queue. This node runs several instances
of the template at once, and an unscoped marker lets the first instance
to give up disarm the gate for every other one on a boot where the clock
is still wrong.

The unit loses its After= and Wants=network-online.target. Both were
already inert: systemctl --user show network-online.target reports
LoadState=not-found, because a user manager has no such target, which is
why this worker could start before the network was up. They are removed
rather than commented, and no ordering replaces them, because an inert
declaration is worse than no declaration. Note that systemctl --user
show <unit> -p After lists ordering names for units that do not exist,
so grepping it proves nothing about whether a gate fires.

Verified by running the gate where it must fire rather than by checking
that it is declared: nine controls over the real file, including a stub
clock that never synchronises (holds the full budget, warns, starts), one
that recovers mid-wait, the octal and cap paths, and two queues sharing a
boot where the second must still gate after the first gives up.
The README uses `systemctl --user show time-sync.target -p LoadState`
as the proof that a user manager has no such target. That check is
sound, but only because it is asked of a target, and a reader will
reasonably carry it to other unit types where it means nothing.

Measured on this node: an invented `.service` and an invented `.target`
both report `not-found`, while an invented `.slice` reports `loaded`,
because slices are implicit units systemd synthesises on demand rather
than loads from a file. So `LoadState` cannot show a slice absent, and
on another machine in this project it reported `not-found` for a slice
that `list-units --all` shows loaded and active. No information in
either direction.

This is the mirror of the trap already documented one line above: `-p
After` overstates by listing units that do not exist, `LoadState`
understates or overstates on slices. Both report what was asked for
rather than what will happen, which is why the existence check belongs
on `list-units --all`.
@frapercan
frapercan merged commit 32d3e8b into main Aug 29, 2026
8 checks passed
@frapercan
frapercan deleted the node/the-worker-waits-for-the-clock branch August 29, 2026 14:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant