Worker Schedules
When each background worker actually runs its job, and for how long.
There is no crontab and no systemd timer in production. Every worker is a long-lived systemd unit
started with --daemon (scripts/setup-vps.sh), so “schedule” means the daemon loop’s cadence:
loop: new run id → /start ping → run one cycle of the job → success (or /fail) ping → sleep sleepBetweenCycles → repeat
The time between two job starts is cycle duration + sleepBetweenCycles, not a fixed clock time.
Nothing is aligned to wall-clock minutes. Every push to main restarts all units (deploy.yml), and
systemd restarts a crashed unit after 10 s (Restart=always, RestartSec=10); both reset the cadence.
The loop lives in runAsDaemon() (Src/lib/runner.php). Each entry script (Src/<worker>.php) passes
its own onceWork (cron/http modes), daemonCycleWork (daemon mode) and sleepBetweenCycles to runJob().
Summary
| Worker | systemd unit | One daemon cycle | Sleep after cycle | Job starts roughly every |
|---|---|---|---|---|
service |
webhooks-service |
Consume github for 5 min (10 min during peak hours) |
1 s | ~5 min (~10 min peak), effectively continuous |
database-service |
webhooks-database-service |
Consume github-database for 5 min |
1 s | ~5 min, effectively continuous |
hooks |
webhooks-hooks |
checkHooks(), then consume github-hooks for 10 min |
60 s | ~11 min |
cleanup |
webhooks-cleanup |
Trim github, sync meta, drain github-forbidden for 1 min, delete successful workflows |
300 s | ~6 min + DB/API time |
maintenance |
webhooks-maintenance |
Update workflow run conclusions, re-request cancelled workflows | 60 s | 1 min + however long the GitHub API calls take |
idle-pull-requests |
webhooks-idle-pull-requests |
One Idle Retry for the first due Idle Pull Request, round-robin across repositories (two quick queries) | 3600 s | ~1 h, plus once on every unit (re)start |
queue-relay |
webhooks-queue-relay |
No cycles: a continuous Unix-socket accept loop | n/a | Always running |
How a queue-consuming cycle ends
service, database-service, hooks and the forbidden-queue step of cleanup all go through
receiveQueue($timeout, ...) (Src/lib/queue.php). The consume window is time-bounded, not
message-bounded:
- The consume loop runs until
$timeoutseconds have passed since it started, even if the queue is empty for the whole window. An idle cycle still lasts the full window. - Every message callback (
processMessage,processDatabaseMessage,processMessageHook,processMessageForbidden) cancels the consumer once fewer than 5 s are left in the window. A message that arrives near the end therefore closes the cycle slightly early. - In daemon mode a SIGTERM/SIGINT/SIGHUP breaks the loop the next time
wait()returns (isDaemonRunning()), so stopping a unit doesn’t wait for the window to run out. - If connecting fails, the cycle retries once after 5 s and then gives up until the next cycle.
Per worker
service (GitHub queue consumer)
- Daemon:
runServiceJob(5), a 5-minute window on thegithubqueue, then 1 s sleep. - Peak hours: on Monday and Tuesday, 00:00–04:00 UTC (
gmdate(), inSrc/runners/service.runner.php) the window is doubled, to 10 minutes in daemon mode. The day and hour are checked when each cycle starts, so a cycle that begins at 03:59 UTC still runs a doubled window. - Cron / HTTP:
runServiceJob(15), a 15-minute window (30 minutes during peak hours). - Run log event:
{"event":"queue_consumed","queue":"github", ...}withbroker,minutes_per_broker,peak_hoursandconsumed(messages handed toprocessMessage()in that window).
database-service (GitHub database queue consumer)
- Daemon:
runDatabaseServiceJob(5), a 5-minute window ongithub-database, then 1 s sleep. There is no peak-hours doubling. - Cron / HTTP:
runDatabaseServiceJob(10), a 10-minute window. - Run log event:
{"event":"queue_consumed","queue":"github-database", ...}withbroker,minutes_per_brokerandconsumed.
hooks (GitHub hook redelivery)
- Every mode:
runHooksJob()callscheckHooks()(catch-up pagination against GitHub’s hook deliveries) and then consumesgithub-hooksfor a fixed 10 minutes. - Daemon: 60 s sleep, so a new
checkHooks()pass starts about every 11 minutes. - It was split out of
maintenanceso that slow workflow-run loops can’t hold back its cadence. It has its own HealthChecks.io check (HEALTHCHECKSIO_HOOKS). - Run log event:
{"event":"hooks", ...}withfailed_deliveries_queued(deliveriescheckHooks()pushed togithub-hooks),check_skipped(true when another run held thehooks.txtlock) andconsumedfor the 10-minute window.
cleanup
- Every mode:
runCleanupJob()runs these steps in order:cleanupTable("github")removes rows older than the retention window inconfig/config.json.downloadMeta()syncs GitHub meta.- The
github-forbiddenqueue is drained for a fixed 1 minute. deleteSuccessfulWorkflows()removes workflow runs that succeeded.
- Daemon: 300 s sleep, so a cycle starts about every 6 minutes plus the time steps 1, 2 and 4 take.
- Run log event:
{"event":"cleanup", ...}withgithub_rows_deleted,meta_hook_ranges,forbidden_consumedandworkflow_runs_deleted, one per step above.
maintenance
- Every mode:
runMaintenanceJob()runsWorkflowRunUpdater::updateWorkflowRunConclusions()and thenrerequestCancelledWorkflows(). - Daemon: 60 s sleep. There’s no consume window, so the cycle length depends on how many runs need
updating: the work is sequential, unbatched GitHub API calls (sometimes 100+), with no signal check
between them. For that reason the unit has
TimeoutStopSec=60instead of 30. - Run log event:
{"event":"maintenance", ...}withconclusions_checked(outdated runs looked up on GitHub) andrerequested(cancelled runs re-requested).
idle-pull-requests
- Every mode:
runIdlePullRequestsJob()callsretryIdlePullRequest()(Src/lib/bot.database.php). That function gives one Idle Retry (see ADR-0010) to the firstDUErow ofgithub_pull_requests_idle_view(Sql/0038,Sql/0039,Sql/0040,Sql/0042). The view lists open pull requests in thePROCESSEDstate with no update for at least a day, and gives each one anIdleStatus:DUE: idle for at leastIdleRetryCount + 1days, withIdleRetryCount < 10WAITING: still inside its backoff window;WaitingUntil(UpdatedAt+IdleRetryCount + 1days) is when it turnsDUE, and isNULLon the other twoGAVE_UP: has reached 10 Idle Retries
It left joins
github_repositories(the most recent row per owner/name) to reportRepositoryPrivate, which isNULLfor a pull request whose repository has nogithub_repositoriesrow. The join never decides membership:Sql/0039made it an inner join and the view shrank from 200+ rows to 35 in production, soSql/0040relaxed it.Order (
Sql/0042): round-robin across repositories. Ordered byUpdatedAtalone, the repository with the oldest pull requests got one Idle Retry after another while every other repository waited. The view now orders byRepositoryIdleRank(1 for the repository’s least retriedDUEpull request, oldest among equals, then its otherDUEones byIdleRetryCountand age, thenWAITING, thenGAVE_UP), thenRepositoryLastPullRequestUpdate(the most recentUpdatedAtamong all of the repository’s pull requests, open or closed), thenUpdatedAt: every repository’s first pull request, least recently active repository first; then every repository’s second, and so on. Once the bot processes an Idle Retry, that repository’s last update jumps to that moment and it goes to the back of the line, so consecutive Idle Retries go to different repositories whenever more than one has aDUEpull request. A repository with daily activity (dependabot, the bot’s own next-pull-request chain) sorts last on purpose: the normal workflow is already moving its pull requests along, and a pull request stuck in the middle of that chain is a manual check.Least retried first (
Sql/0043). A retried pull request’s backoff made it look older than the never-retried ones next to it (with 2 retries it isDUEagain after 3 idle days, with 0 after 1), so it kept rank 1 and got its third Idle Retry while the others waited for their first.IdleStatus(DUE,WAITING,GAVE_UP) and thenIdleRetryCountnow come before everything else in the order, andIdleRetryCountcomes before age inside the repository rank: everyDUEpull request with 0 retries is listed before any with 1, and so on, with the round-robin above applied inside each count. No pull request gets its second Idle Retry while another is still waiting for its first, whatever repository either is in. - The Idle Retry sets
ProcessingState = 'IDLE'andIdleRetryCount = IdleRetryCount + 1, and assignsUpdatedAtto itself. GStraccini-bot then claims the pull request like aRE_REQUESTEDone, near the front of itsORDER BY UpdatedAtqueue. TheUPDATEonly applies while the row is stillPROCESSED, so an Event that arrives in between wins. - Backoff: each Idle Retry waits one day longer than the last: 1, 2, 3 … 10 idle days, about 55 days in
total. Every Event-driven write resets
IdleRetryCountto 0, and so does the bot’s own re-request when another pull request in the repository closes. - Daemon: 3600 s sleep. The job runs immediately whenever the unit starts, so every deploy (which
restarts all units) adds one extra run on top of the hourly cadence. The async SIGTERM handler
interrupts the
sleep(), so stopping the unit doesn’t wait out the hour. GET /api/v1/pull-requests/idlereturns the whole view in that order, includingWAITINGandGAVE_UProws, each withrepositoryPrivate,repositoryLastPullRequestUpdate,repositoryIdleRankandwaitingUntil. Its firstDUErow is the next one this worker will retry.- It has its own HealthChecks.io check (
HEALTHCHECKSIO_IDLE_PULL_REQUESTS). - Run log event:
{"event":"idle_retry", ...}with the repository, pull request number and URL,sequence,updated_atand the newidle_retry_count, or{"event":"nothing_due"}when no pull request was due.
queue-relay
- It doesn’t use
runJob().Src/queue-relay.phpalways runsrunQueueRelayJob(), which accepts connections on the Unix socket continuously and checksisDaemonRunning()every 2 s. - It has no cron or HTTP mode, and
POST /api/v1/workers/queue-relay/runis deliberately not offered.
Running a job outside the daemon
- Cron mode:
php Src/<worker>.phpwith no--daemonrunsonceWorkonce and exits. Nothing in the repo schedules this. If you add your own crontab entry, make the interval longer than the cron-mode window (15 min forservice, 30 min if the run can start during peak hours; 10 min fordatabase-serviceandhooks) so runs don’t overlap. Also keep in mind that the systemd daemon is probably running too. - HTTP:
POST /api/v1/workers/{name}/run(Src/api/v1/workers.php) returns202and then runs the job once inside the PHP-FPM request, with the same window as cron mode. While it runs, it holds a PHP-FPM worker for that whole window.
HealthChecks.io implications
Every run — a cron execution, one daemon cycle, or an HTTP trigger — is one tracked run on the worker’s
check (runTracked() in Src/lib/runner.php): a fresh HealthChecks client with a new run id
(?rid=<GUID> on every ping), a /start ping before the job, and a success ping after it, or /fail
carrying the exception message if the job threw (the exception is then rethrown, so a daemon still exits
and is restarted by systemd). HealthChecks.io therefore shows each run as a start → end pair with its
own duration, and the global $hc always points at the current run, so $hc->error()/$hc->log()
calls inside a job and fatal_handler() land on the same run id. Every job also sends one /log event
per run with what it did (the Run log event lines above, always carrying run_id), so the check’s event
list reads start → log → end per run, and the same run_id (from currentRunId()) is on every LogStream
line of that run, so a run can be followed across HealthChecks.io and LogStream. The step functions report
what the events need: receiveQueue() returns the messages consumed, cleanupTable() /
deleteSuccessfulWorkflows() the rows deleted, downloadMeta() the hook ranges written, checkHooks() the
deliveries queued (or null when it skipped on the lock), and WorkflowRunUpdater’s two methods the runs
they touched. Because /start is pinged on every cycle, workers with a short sleepBetweenCycles (service,
database-service) ping twice per cycle. When you configure a check’s period and grace, use the
“Job starts roughly every” column above, with headroom for peak hours (service) and for slow GitHub
API passes (maintenance, cleanup). Every runJob() worker has its own check, so each one’s pings
come from a single cadence. queue-relay has no check: it’s a continuous accept loop with no cycles to
ping after.