Skip to content

cronjob: idempotent CRUD over a user crontab - #104

Merged
ralyodio merged 2 commits into
masterfrom
worktree-dev2-cron-idempotent
Sep 25, 2026
Merged

ralyodio merged 2 commits into
masterfrom
worktree-dev2-cron-idempotent

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

cronjob installs, lists and removes scheduled jobs without ever installing one twice.

A crontab has no identity: nothing in the file says which line is the nightly summary. So the obvious way to install a job is to append, and the obvious consequence is the job once per deploy.

Identity comes from a marker comment — a managed job is exactly two lines:

# cli-tools:cronjob nightly-summary
0 9 * * * /usr/bin/whatever
  • set replaces in place, so run 1 and run 1000 leave a byte-identical file and a clean diff.
  • remove on an absent id changes nothing and exits 0.
  • Duplicate ids from a hand edit collapse onto the first — leaving them means the job still fires twice after a "fix".
  • Everything outside a marker block is preserved exactly: hand-written entries, MAILTO, PATH, comments.

Three silent failures it handles

Trap Why it bites
% in a command cron reads the first unescaped one as end-of-command and feeds the rest in as stdin, so date +%Y-%m-%d runs as date + and quietly does the wrong thing
No trailing newline some crons ignore the last line — a job that looks installed and never runs
crontab - replaces the whole file read-modify-write over shared state; the write holds an flock so two deploys landing together cannot drop each other's job

Verification

21 unit tests, plus an end-to-end run against a real crontab carrying 17 unmanaged lines: three set runs produced byte-identical output with one copy of the job and % escaped to \%, two remove runs were idempotent, and the file came back identical to the pre-test backup. tsc --noEmit clean.

Usage

cronjob list                   [--host dev2]
cronjob set <id> --schedule '0 9 * * *' --command '...' [--host dev2] [-n]
cronjob remove <id>            [--host dev2]

First use is the coinpayportal nightly summary, which has never had a caller — no Railway service in the project has a cronSchedule (checked all 72) and dev2 has no host cron or systemd timer for it.

🤖 Generated with Claude Code

A crontab has no identity: nothing in the file says which line is the nightly
summary. So the obvious way to install a job is to append, and the obvious
consequence is that a deploy step which reruns gives you the job once per run,
firing N times for N deploys, with nothing afterwards to say which copy was
meant to be there.

Identity comes from a marker comment. A managed job is exactly two lines:

    # cli-tools:cronjob nightly-summary
    0 9 * * * /usr/bin/whatever

`set` replaces those two lines IN PLACE when the id already exists, so the
first run and the thousandth leave a byte-identical file and a clean diff.
Appending would still converge, but it reorders the file on every changed job
and makes the diff unreadable. `remove` on an absent id changes nothing and
exits 0, because the requested end state already holds. Duplicate ids left by
a hand edit collapse onto the first, since leaving them means the job still
fires twice after a "fix".

Everything outside a marker block is preserved exactly -- hand-written
entries, MAILTO, PATH, blank lines, other people's comments. This is a shared
file and we are a guest in it.

Three details that are silent failures rather than errors:

- `%` is not an ordinary character in a crontab command. cron reads the first
  unescaped one as end-of-command and feeds the rest to the job as stdin, so
  `date +%Y-%m-%d` runs as `date +` and quietly does the wrong thing. Escaped
  on write, and not double-escaped on rewrite.
- A crontab must end in a newline or some crons ignore the last line, which is
  a job that looks installed and never runs.
- `crontab -` replaces the whole file, so this is read-modify-write over
  shared state. The write holds an flock, or two deploys landing together let
  the later read win and drop the earlier job.

Verified end to end against a real crontab carrying 17 unmanaged lines: three
`set` runs produced byte-identical output with one copy of the job and `%`
escaped, two `remove` runs were idempotent, and the file came back identical
to the pre-test backup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

23 finding(s)

HIGH/CRITICAL: 4 | MEDIUM: 10 | LOW: 9

Severity Rule Location
HIGH sh-remote-script-execution root-ubuntu.sh:3227
HIGH sh-remote-script-execution root-ubuntu.sh:3228
HIGH sh-remote-script-execution root-ubuntu.sh:4840
HIGH sh-remote-script-execution root-ubuntu.sh:4844
MEDIUM sh-remote-script-execution root-ubuntu.sh:4993
MEDIUM redos-nested-quantifier src/domain-free.ts:56
MEDIUM redos-nested-quantifier src/emoji.ts:167
MEDIUM redos-nested-quantifier src/icon.ts:166
MEDIUM redos-nested-quantifier src/mail.ts:1042
MEDIUM sql-template-interpolation src/users-dump.ts:487
MEDIUM sql-string-concatenation src/users-dump.ts:507
MEDIUM sql-template-interpolation src/users-dump.ts:540
MEDIUM sql-string-concatenation src/users-dump.ts:574
MEDIUM redos-nested-quantifier src/wcag.ts:556
LOW secret-generic-credential src/credentials.ts:36
LOW secret-generic-credential src/user-export.ts:632
LOW secret-generic-credential src/user-export.ts:638
LOW secret-generic-api-key test/credentials.test.ts:208
LOW secret-generic-credential test/mail.test.ts:141
LOW secret-generic-credential test/shorten.test.ts:36
LOW secret-database-url test/users-dump.test.ts:108
LOW secret-database-url test/users-dump.test.ts:119
LOW secret-database-url test/users-dump.test.ts:120

Snippets are redacted; ThreatCrush never prints matched credential material.

`registry.test.ts` reads the real bin directory, so adding bin/cronjob.ts alone
made "gives every command a summary" fail. That test is doing its job: a tool
with no summary is invisible in `cli-tools list`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ralyodio
ralyodio merged commit 72073ad into master Sep 25, 2026
5 checks passed
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