LastSyncDate on github_installations and github_repositories, written only by the bot’s scheduled syncs
github_installations and github_repositories are fed by Events: an installation created Event
writes the installation and the repositories it came with, a repository created Event writes one
repository. Everything else that changes what the GitHub App can see is either not handled
(installation_repositories added/removed, privatized/publicized) or only partially handled
(renamed rewrites Name but not FullName), and a missed delivery leaves a gap for good. The tables
drift from GitHub, and the Idle Pull Request view (Sql/0040) already has to tolerate repositories with no
row at all. GStraccini-bot’s manual checkup job backfills missing rows, but only when someone runs it.
GStraccini-bot (gstraccini-bot-service) now reconciles both tables on a schedule, from the loops of its
existing installations and repositories workers (its ADR-0001), and Sql/0041 adds the one column each
pass needs to pace itself: LastSyncDate.
- The installations worker’s monthly pass, from the 1st: lists the app’s installations, registers the
missing ones (with their repositories), updates the account metadata of the known ones, marks the ones
GitHub no longer lists with
Status = '0'(the same write as thedeletedEvent), and re-lists the repositories of every installation whoseLastSyncDateisNULLor older than 3 months. A repository listing updates the metadata (name, full name, owner, visibility) of known repositories, adds missing ones, and appends adeleted/removedrow for the ones the installation can no longer access. - The repositories worker’s hourly pass: takes the one active repository with the oldest
LastSyncDate(NULLfirst), refreshes its metadata and reconciles its open pull requests, open issues and branches against GitHub, backfilling missing rows and flagging the ones GitHub no longer reports.
Considered options
- Handle the missing Events (
installation_repositories,privatized/publicized, a fullrenamed) in this service. Still worth doing, but it doesn’t close the gap: any delivery can be missed, and the historical drift stays. The scheduled sync is the safety net under the Events, not a replacement for them. - Make the checkup job periodic as it is. Rejected: it walks every installation, repository, pull
request, issue, branch and commit in one pass, deliberately slowly, which is fine to run by hand and
far too much to run every hour. Splitting the work into a monthly installation pass and an hourly
one-repository pass needs per-row bookkeeping, which is what
LastSyncDateis. - A unique key on
InstallationId/Idso the sync canINSERT ... ON DUPLICATE KEY UPDATE. Rejected for now: both tables hold a row per Event that announced the entity, so production has duplicates per id, and deduplicating historical rows is a migration with its own risks. The sync workers treat the most recent row (highestSequence) perInstallationId/ perIdas the current one instead;Sql/0041adds the(id, Sequence)indexes that pick needs.Sql/0039/Sql/0040already made the same most-recent-row choice for the Idle Pull Request view. - Keep the monthly pass’s last run in memory in the bot. Rejected: the bot’s workers start a fresh
process every minute in cron/HTTP mode and restart on every deploy as daemons, so the pass would run on
every start. The hourly pass needs no extra state (the newest
LastSyncDateis its own trace); the monthly one keeps its last run as a Setting (gstraccini-bot.sync-installations.last-run, ISO 8601) in thesettingstable, under agstraccini-bot.prefix so it can’t collide with this app’s Settings.
Consequences
LastSyncDateistimestamp NULL DEFAULT NULLon both tables, afterUpdatedAt.NULLmeans “never synced”, and sorts first, so every existing row is due on the workers’ first run after this migration. This service never writes the column; the Event writers leave it alone.- On
github_installationsthe date means “the installation’s repositories were last listed”, not “the installation row was last checked”: the monthly pass updates account metadata on every run but only moves the date after a successful repository listing. A failed listing (suspended installation, token error) leaves the date where it was, so the installation is due again next month. - On
github_repositoriesthe date is set when the hourly pass claims the row, before any GitHub call. A repository whose sync fails therefore goes to the back of the queue instead of blocking the pass every hour, at the cost of the column meaning “last sync attempt” for failed rows (the failure is logged). With N active repositories, every repository gets a turn roughly every N hours. The newest value in the table is also what paces the pass (TIMESTAMPDIFF(SECOND, MAX(LastSyncDate), NOW()), computed by the database so the two clocks’ time zones never enter it). - The monthly pass’s “missing from GitHub” decisions (installation
Status = '0', repositorydeleted/removedrows) are only taken after a complete listing: the bot’s fetcher aborts the whole pass on any failed page rather than treating a partial list as the truth. - The hourly pass never closes a pull request or issue in the database itself: when GitHub no longer
reports one as open, it sets the row back to
RE_REQUESTED, and the bot’s own pull request / issue handler fetches it and runs the normal closing flow (labels, notifications, next pull request). Branches GitHub no longer reports get the sameUPDATEthedeleteEvent writes (Event = 'delete',ProcessingState = 'UPDATED'), so the branches handler runs its deletion flow. - Rollout order: webhooks first, then gstraccini-bot-service. The bot’s scheduled passes read and write
LastSyncDate; deployed before0041they fail on every check on the unknown column (logged; nothing else in the workers touches it, so their regular processing is unaffected). The reverse window is harmless: the column sitsNULLuntil the workers ship. - The
*_processing_views and theirGET /api/v1/*-processingendpoints are unchanged;LastSyncDateisn’t exposed by this service’s API yet. settingsholds one row written by another service for the first time.SettingsManagerreads it like any other row; nothing here depends on it.