{"id":51251962,"url":"https://github.com/avwohl/periodic","last_synced_at":"2026-06-29T07:32:19.227Z","repository":{"id":359619967,"uuid":"1218595147","full_name":"avwohl/periodic","owner":"avwohl","description":"Resumable replacement for run-parts / cron.daily: per-script completion tracking so re-runs skip what already succeeded this period.","archived":false,"fork":false,"pushed_at":"2026-05-22T16:31:36.000Z","size":20,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-22T20:53:23.798Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Shell","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/avwohl.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-23T03:04:13.000Z","updated_at":"2026-05-22T16:31:40.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/avwohl/periodic","commit_stats":null,"previous_names":["avwohl/periodic"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/avwohl/periodic","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/avwohl%2Fperiodic","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/avwohl%2Fperiodic/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/avwohl%2Fperiodic/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/avwohl%2Fperiodic/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/avwohl","download_url":"https://codeload.github.com/avwohl/periodic/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/avwohl%2Fperiodic/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34918101,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-29T02:00:05.398Z","response_time":58,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-06-29T07:32:18.504Z","updated_at":"2026-06-29T07:32:19.209Z","avatar_url":"https://github.com/avwohl.png","language":"Shell","funding_links":[],"categories":[],"sub_categories":[],"readme":"# periodic\n\nA small, resumable replacement for `run-parts` / `cron.daily`.\n\n`periodic` runs a set of shell scripts (\"parts\") once per day, week, or month.\nUnlike `run-parts`, each part's completion is tracked individually: if a later\npart fails, you fix it and re-run `periodic`, and only the unfinished parts run\nagain. Parts that already succeeded this period are skipped.\n\n## License\n\nGPL-3.0-or-later. See `LICENSE`.\n\n## Why\n\nThe common pattern is a nightly cron job that runs a pile of maintenance\nscripts in order: backup, prune, rsync, rebuild indexes, email a report. If\nscript #5 of 10 fails at 3am, you want to fix it and re-run — but you do **not**\nwant to re-run the four expensive scripts that already finished. `run-parts`\ngives you no way to express that. `periodic` does.\n\nThe mechanism is a per-script timestamp file: each script gets a marker\nrecording the period (e.g. `20260422`) in which it last succeeded. On each\ninvocation, `periodic` checks the marker and skips the script if it has already\nrun this period.\n\n## Components\n\n\tperiodic.sh            main driver — scan dirs, run parts, track completion\n\tpp_every               read/write per-script period markers\n\tpp_lock                run a command under a non-blocking file lock\n\tperiodic.conf.example  sample configuration\n\n## Installing\n\nDrop the four files into a directory of your choice (e.g. `/opt/periodic`) and\nmake sure `periodic.sh`, `pp_every`, and `pp_lock` are executable.\n\nCopy `periodic.conf.example` to `periodic.conf` (in the same directory, or\nanywhere — you can pass the path as an argument) and edit it.\n\nSchedule it from cron. Running every hour is a good default: if the machine is\noff or the previous run failed, the next hour picks up where you left off.\n\n\t0 * * * * /opt/periodic/periodic.sh\n\n## How it runs\n\n1. `periodic.sh` sources `periodic.conf`.\n2. It re-execs itself under `pp_lock` so only one copy runs at a time, with\n   all output redirected to a timestamped logfile in `PERIODIC_LOGDIR`.\n3. For each entry in `PERIODIC_DIRS` (in array order), it globs `*.sh`, keeps\n   the ones that are executable, and sorts alphabetically (LC_COLLATE=C).\n4. For each script, `pp_every read` checks the marker file. If the script\n   already ran this period, it is skipped.\n5. Otherwise the script is run. On success, `pp_every write` records the\n   current period key. On failure, `periodic` stops immediately, optionally\n   mails the log, and exits non-zero. The scripts that already succeeded keep\n   their markers; the failed script does not. The next invocation resumes from\n   the failed script.\n\nBecause failure is fatal to the whole run, order your parts so that later\nparts depend on earlier parts succeeding (backup before prune, fetch before\nprocess, etc.).\n\n## `periodic.conf`\n\nThe config file is sourced as bash, so anything bash accepts is legal\n(variables, command substitution, conditionals). The meaningful variables are:\n\n\tPERIODIC_DIRS        array of \"dir\" or \"dir:frequency\" entries (required)\n\tPERIODIC_LOGDIR      where per-run logs are written         (default /var/log/periodic)\n\tPERIODIC_TIMEDIR     where per-script markers are kept      (default /var/lib/periodic/times)\n\tPERIODIC_LOCKFILE    file lock path                          (default /tmp/periodic.lock)\n\tPERIODIC_NICE        nice level for the whole run            (default 20)\n\tPERIODIC_MAILTO      if non-empty, mail the log on success and failure (default empty)\n\n`PERIODIC_DIRS` is the one you care about. Each entry is a directory path,\noptionally followed by `:day`, `:week`, or `:month`. The suffix controls how\noften scripts in that directory are eligible to run; the directory name itself\nis just a label. Example:\n\n\tPERIODIC_DIRS=(\n\t    \"/local/periodic/daily.d:day\"\n\t    \"/local/periodic/weekly.d:week\"\n\t    \"/local/periodic/monthly.d:month\"\n\t)\n\nThe names `daily.d` / `weekly.d` / `monthly.d` are pure convention. The system\nmatches **whatever path you put on the left of the colon**, not a hard-coded\nset of names. `/srv/chores:day` works identically to `/local/periodic/daily.d:day`.\nIf you omit the suffix, `day` is assumed, so `\"/srv/chores\"` and\n`\"/srv/chores:day\"` are the same.\n\nThe period keys used for each frequency:\n\n\tday    date +%Y%m%d    e.g. 20260422\n\tweek   date +%Gw%V     e.g. 2026w17   (ISO year + ISO week)\n\tmonth  date +%Y%m      e.g. 202604\n\nA script \"already ran this period\" means its marker file contains the current\nperiod key. When the key rolls over (midnight for daily, Monday 00:00 ISO for\nweekly, first of the month for monthly), the marker no longer matches and the\nscript becomes eligible again.\n\n## Writing parts\n\nA part is any executable `*.sh` in one of the configured directories. Exit 0\nfor success, non-zero for failure. Standard output and standard error are\ncaptured into the run's logfile.\n\n`periodic.sh` exports a few variables that parts may use:\n\n\tPP_DATE           date +%Y%m%d at start of run (fixed for the whole run)\n\tPP_DATETIME       date +%Y%m%d_%H%M%S at start of run\n\tPERIODIC_DAY      date +%a (Mon, Tue, ...)\n\tPERIODIC_LOGDIR   same as configured\n\tPERIODIC_TIMEDIR  same as configured\n\nThese are set *before* the config is sourced, so the config can override them\nif you want to pin them (e.g. force all parts to share a specific date key).\n\nYou may also export your own variables from `periodic.conf` — they're\ninherited by every part. The example config exports `PP_HOSTNAME` and\n`PP_HOST_DATE_KEY` this way.\n\nNaming tip: parts run in `LC_COLLATE=C` alphabetical order, so prefix with\nnumbers if order matters:\n\n\t10-backup.sh\n\t20-prune.sh\n\t30-report.sh\n\n## Marker files\n\nEach script gets its own marker at:\n\n\t${PERIODIC_TIMEDIR}/per${freq}.${escaped_script_path}\n\nwhere `escaped_script_path` is the absolute path with `/` replaced by `_`.\nContents are a single line: `PERIODKEY,periodic_v1`.\n\nTo force a part to re-run this period, delete its marker file. To force the\nentire daily run to repeat, delete everything under `PERIODIC_TIMEDIR`\nmatching `perday.*`.\n\n## Locking\n\n`pp_lock LOCKFILE CMD...` takes a non-blocking `flock` on `LOCKFILE` and execs\nthe command. If the lock is already held it exits 75 (EX_TEMPFAIL). This is\nwhat keeps two overlapping cron ticks from stomping each other when a run\ntakes longer than the cron interval.\n\nYou can use `pp_lock` for your own scripts too — it's a standalone utility.\n\n## Mail\n\nIf `PERIODIC_MAILTO` is set:\n\n- On failure, the whole logfile is mailed with subject\n  `FAIL \u003chostname\u003e periodic: \u003cscript\u003e`.\n- On a successful run that actually did something (at least one part ran),\n  the whole logfile is mailed with subject `ok periodic \u003chostname\u003e`.\n- A run where every part was already complete (nothing to do) does not send\n  mail.\n\nRequires a working local `mail` command (e.g. `bsd-mailx`, `s-nail`).\n\n## Exit codes\n\n\t0     nothing ran, or everything ran and succeeded\n\t1     a part failed, or the config was unreadable\n\t75    another instance already holds the lock (from pp_lock)\n\n## Example: a typical daily job set\n\n\t/local/periodic/daily.d/10-backup.sh       # tar + rsync to backup host\n\t/local/periodic/daily.d/20-prune-old.sh    # delete backups older than N days\n\t/local/periodic/daily.d/30-fetch-feeds.sh  # pull external data\n\t/local/periodic/daily.d/40-reindex.sh      # rebuild search index\n\t/local/periodic/daily.d/90-report.sh       # email a summary\n\nIf `40-reindex.sh` fails at 03:00, you fix it at 09:00 and the 10:00 cron\ntick runs only `40-reindex.sh` and `90-report.sh`. The backup, prune, and\nfetch are not repeated.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Favwohl%2Fperiodic","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Favwohl%2Fperiodic","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Favwohl%2Fperiodic/lists"}