Scheduling OSM Diff Sync with systemd Timers Jump to heading
Run the minutely OSM sync loop reliably on a Linux host — restarted after reboots, logged to journald, isolated when it fails, and never running two overlapping copies at once — using a systemd service and timer pair instead of a cron line.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A cron job answers only one question — when — and answers it badly for a long-running data task: it captures no structured logs, has no concept of a run that outlives its interval, restarts nothing when the host reboots, and cheerfully launches a second copy while the first is still working. systemd splits the job into two objects that each do one thing. A service unit (.service) describes how to run the process: which user, which working directory, what to do when it exits non-zero, how long it may run. A timer unit (.timer) describes when to activate that service, and — crucially — it is itself a supervised unit, so systemctl can tell you when it last fired and when it will fire next. Because the timer activates the service rather than forking a shell, every run inherits journald logging, cgroup accounting, and the restart policy for free. The relationship up to the loop it schedules is covered in the parent minutely update pipeline guide; here the mechanism is the pairing itself.
The one hazard a naive timer still leaves open is overlap. If a sync run occasionally takes longer than the interval — a large catch-up after an outage, say — the timer will start a second run while the first is mid-apply, and two processes writing the same base extract and checkpoint is exactly the corruption the pipeline’s crash-safety was designed to prevent. The fix is a mutual-exclusion lock: wrap the loop in flock against a lockfile, and a second invocation exits immediately rather than racing the first. Type=oneshot with a timer, plus flock, gives a run-to-completion model with hard non-overlap.
Runnable solution Jump to heading
Three files. The invoked script wraps the loop in a lock and logs to stdout (which journald captures); the service describes how to run it; the timer says when. Install the units under /etc/systemd/system/.
The wrapper script, /opt/osm-sync/run-sync.sh:
#!/usr/bin/env bash
# Run exactly one guarded sync process. flock ensures no overlap: if a prior
# run still holds the lock, --nonblock makes this invocation exit at once.
set -euo pipefail
LOCKFILE=/run/osm-sync/sync.lock
mkdir -p "$(dirname "$LOCKFILE")"
exec flock --nonblock "$LOCKFILE" \
/opt/osm-sync/.venv/bin/python -m osm_sync.serve \
--store /var/lib/osm-sync/checkpoint.json \
--poll-seconds 60
The service unit, /etc/systemd/system/osm-diff-sync.service:
[Unit]
Description=OSM minutely diff sync (one guarded cycle batch)
Documentation=https://osm-data-processing.org/osm-replication-diff-sync/building-a-minutely-update-pipeline/
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=osm
Group=osm
WorkingDirectory=/opt/osm-sync
ExecStart=/opt/osm-sync/run-sync.sh
# Failure isolation: retry a crashed run a few times, then stop and stay failed.
Restart=on-failure
RestartSec=30s
StartLimitIntervalSec=300
StartLimitBurst=4
# Do not let a wedged run block the next tick forever.
RuntimeMaxSec=50s
# journald captures stdout/stderr with this identifier.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=osm-diff-sync
# Light sandboxing; the extract dir is the only writable path.
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/osm-sync /run/osm-sync
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
The timer unit, /etc/systemd/system/osm-diff-sync.timer:
[Unit]
Description=Fire the OSM diff-sync service every minute
Documentation=https://osm-data-processing.org/osm-replication-diff-sync/building-a-minutely-update-pipeline/
[Timer]
# First run 90s after boot, then 60s after each run *finishes* (not starts),
# so a slow run naturally spaces out the next rather than stacking up.
OnBootSec=90s
OnUnitActiveSec=60s
# Anchor to wall-clock minutes as an alternative cadence if you prefer:
# OnCalendar=*:*:00
AccuracySec=5s
Persistent=true
Unit=osm-diff-sync.service
[Install]
WantedBy=timers.target
Enable and start the timer (not the service — the timer owns the schedule):
sudo systemctl daemon-reload
sudo systemctl enable --now osm-diff-sync.timer
Step-by-step walkthrough Jump to heading
flock --nonblockin the wrapper is the non-overlap guarantee. The first run acquiressync.lock; while it holds the descriptor, any second invocation fails to acquire and — thanks to--nonblock— exits immediately instead of queuing.execreplaces the shell so the Python process inherits the lock for its whole lifetime.Type=oneshottells systemd the service runs to completion and exits, which is the right model for a timer-driven task: the unit isactive (exited)between ticks, not a daemon systemd tries to keep alive.Restart=on-failurewithStartLimitBurst=4is failure isolation. A transient crash is retried up to four times in five minutes; a persistently failing run then lands in thefailedstate and stops retrying, so a genuine outage surfaces insystemctl statusinstead of spinning silently forever.RuntimeMaxSec=50scaps a single activation just under the timer interval, so a wedged run is killed rather than blocking the host — theflockstill prevents the next tick from overlapping the one being killed.OnUnitActiveSec=60smeasures the interval from when the previous activation finished, so runtimes and interval add up rather than colliding.OnBootSec=90sdelays the first run until after the network is up. Swap inOnCalendar=*:*:00if you want runs anchored to wall-clock minute boundaries instead.Persistent=truemakes systemd run a missed activation immediately after boot if the host was down when a tick was due — the timer equivalent ofanacron, so a rebooted host resumes syncing without waiting a full interval.StandardOutput=journalandSyslogIdentifierroute every log line the loop prints into journald under a searchable identifier, replacing cron’s silent-or-emailed output with structured, queryable logs.ProtectSystem=strictplusReadWritePathsmakes the whole filesystem read-only to the service except the checkpoint directory and the lock directory, so a bug or a compromised dependency cannot write outside the data dir.
Verification Jump to heading
Confirm the schedule and a healthy run:
- The timer is armed.
systemctl list-timers osm-diff-sync.timershows aNEXTfiring time in the future and a recentLAST. IfNEXTis blank, the timer is not enabled. - The service succeeds.
systemctl status osm-diff-sync.servicereportsActive: inactive (dead)between runs andstatus=0/SUCCESSfor the last invocation. A oneshot that just finished showsactive (exited)briefly. - Logs are flowing.
journalctl -u osm-diff-sync.service -n 50 --no-pagershows the loop’sapplied seq …, lag …slines.journalctl -u osm-diff-sync.service -ffollows them live. - Non-overlap holds. Start two runs by hand —
sudo systemctl start osm-diff-sync.servicetwice quickly — and the journal shows the second exiting without doing work becauseflockrefused the lock. - Reboot recovery. After
sudo reboot,list-timersshows the timer re-armed and, withPersistent=true, a catch-up run fired shortly after boot.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Timer never fires | Enabled the .service, not the .timer |
systemctl enable --now osm-diff-sync.timer |
| Two runs write the base at once | Missing or blocking lock | Use flock --nonblock in the wrapper as shown |
status=203/EXEC |
ExecStart script not executable |
chmod +x /opt/osm-sync/run-sync.sh |
| Run killed at 50s every time | Catch-up backlog exceeds RuntimeMaxSec |
Raise RuntimeMaxSec, or batch catch-up separately |
Read-only file system writing checkpoint |
Path not in ReadWritePaths |
Add the checkpoint dir to ReadWritePaths= |
Unit stuck in failed, no retries |
Hit StartLimitBurst |
systemctl reset-failed osm-diff-sync.service |
| No logs in journald | Output redirected elsewhere | Set StandardOutput=journal; reload the daemon |
Specification reference Jump to heading
A timer unit activates its paired service on the schedule declared in
[Timer].OnUnitActiveSec=fires relative to when the unit was last activated,OnCalendar=fires on wall-clock expressions, andPersistent=trueruns a missed timer immediately after boot. Monotonic timers such asOnBootSec=andOnUnitActiveSec=are documented alongside calendar events in the official systemd.timer manual, and the service-side directivesType=oneshot,Restart=, andRuntimeMaxSec=in the systemd.service manual.
Frequently Asked Questions Jump to heading
Why use a systemd timer instead of a cron job for OSM diff sync?
A timer activates a service unit, so every run inherits journald logging, a restart policy, resource accounting, and a defined behaviour after reboot — none of which cron provides. You also get systemctl list-timers to see the last and next firing, and Persistent=true to catch up a missed run after downtime. cron only decides when to fork a shell and leaves logging, supervision, and overlap entirely to you.
How do I guarantee two sync runs never overlap?
Wrap the loop in flock --nonblock against a lockfile. The first run holds the lock for its whole lifetime because the shell execs into the Python process; a second invocation that starts while the first is still running fails to acquire the lock and exits immediately. Combined with OnUnitActiveSec=, which measures the interval from when the previous run finished, this makes overlap impossible even when a run outlasts its interval.
What does Type=oneshot mean for a timer-driven sync?
It tells systemd the service runs to completion and exits, rather than being a long-lived daemon to keep alive. Between ticks the unit sits as inactive or active-exited, and the timer re-activates it each interval. This is the correct model for a task that does a bounded amount of work — apply the pending diffs — and then stops, as opposed to a resident process you would model with Restart=always.
How do I read and follow the sync logs?
Because the service sets StandardOutput=journal with a SyslogIdentifier, use journalctl -u osm-diff-sync.service to read the history, add -f to follow live, and -n 50 to see the last fifty lines. Filter by time with --since "10 min ago". Every line the sync loop prints — applied sequence, lag, back-off warnings — is captured there with no extra plumbing.
Related Jump to heading
- Building a Minutely Update Pipeline — the sync loop this service supervises, including its crash-safe checkpoint ordering.
- Applying .osc Change Files with osmium — the merge operation each scheduled run performs.
- Replication Sequence Numbers and State — the sequence and state files the loop reads to decide what to fetch.
- Catching Up a Stale OSM Extract with pyosmium — batching a large backlog before returning to per-minute cadence.
- Error Handling in Large OSM Extracts — isolating a bad diff so a scheduled run fails cleanly instead of corrupting state.
Up one level: Building a Minutely Update Pipeline.