Repository navigation
Polling fallback: read each list by updated_at and keep a cursor per list - #10
Conversation
…list The three `…Since` polls could lose rows silently, three ways, each shown by a probe on main 6b5f8e2 (the real adapter against the test file's fake GitHub server): - A page shorter than 100 ended the read even when GitHub's `Link` header said more followed (149 rows, page 1 of 99 with rel="next": 99 read). - The 3-page cap ended a pass, the cursor moved to the pass's start, and the rows past the cap were never asked for again, though the log said the next tick would pick them up (350 rows: 300 delivered, 50 never). - A row updated while the list was being paged moved to the end, so an unread row slid onto a page already read and was skipped (150 rows, anima-research#5 updated between pages 1 and 2: #101 never served). The adapter now reads a list by `updated_at` (pollSince, replacing pollPages): each request after the first asks `since` = the last row's `updated_at`, page 1, and drops the rows of that second it reads again by id; a page wholly in one second goes on to that second's next page. Only `Link: rel="next"` says more follow. After POLL_MAX_PAGES requests it stops, going on only to finish a second it is part way through: the stop second's rows are left out and returned as `resumeAt`, which the next read asks from. GitHub's `since` includes its own second (checked with `gh api` on cli/cli's comments: since = a row's own stamp returns it, one second later does not). POLL_MAX_ENTRIES goes, since the request bound now bounds the rows. The three list methods return `PollRead<T>` ({ rows, resumeAt? }). The poller keeps a cursor per repository and list in the state (`pollCursors`, additive to version 1) rather than one shared cursor: a list that finished never re-reads after another's stop, so the 2,000-key ring need not cover other lists' re-reads. A list with no cursor of its own starts from `lastPollAt`, which a state file from before this carries, and so does a repository newly in GITHUB_REPOS. Cursors are written only when the whole pass delivered, as before, and a repository no longer polled drops out of them. Cursors are whole seconds. GitHub's `since` part way through a second leaves out every row stamped in that second (probed on cli/cli: a comment's own second returned it; that second plus 1, 100 or 999 ms left out all six tried), so the pass-start cursor, with milliseconds, missed rows changed later in the second a pass started in. A list read to the end now starts next at its pass's start second, which is read again. lastPollAt is the earliest of the pass start and every stop, so every list has delivered what changed before it: a release from before this change, which reads only lastPollAt, reads on from the earliest unread point. A first pass stores the window it reads as lastPollAt before it reads, so a pass that is refused or throws is retried from the same start. Stored positions are read as whole seconds, so main's millisecond lastPollAt starts at its second, and the loader drops a lastPollAt, cursor since or resumeAt that isn't an instant. A stopped issues read keeps its window's start for amendment 2a's "opened" rule, so an issue opened in the window but changed past the stop still arrives as opened. A row read again inside its window (after a stop, a refused delivery, or in the second a pass started in) must not be opened twice, so the state remembers each row announced as new (`pollNew`, id -> created_at) when its event is accepted, beside the head marker and the dedup key, until its created_at falls before its list's window; normalizePolledIssue takes `announced`. Head markers could not do this: a stopped read is about 300 issue rows, past POLL_HEAD_MEMORY's 200. getCheckRuns reads on past a short page while total_count says more runs follow, instead of leaving such a head undecided. Without a total_count, which main read one page of, it reads on past a full page, and a read the page bound stops is undecided. A lastPollAt that isn't an instant loads as none, with a warning. Tests: probes A, B and C became tests (adapter level, and B through a real Poller), with tests for the keyset requests, a second wider than the request bound, a page with no stamp, a Link header without next, the stop's resumeAt for all three lists, opened rows across a stop, a refused pass's announced rows and a refused opening, whole-second cursors and the second a pass starts in, stored positions with milliseconds, the earliest stop as lastPollAt, a first pass refused or thrown, short check-run pages and a missing total_count, the migration from lastPollAt, a failed delivery, and the state file's cursors and remembered rows. The old 300-row bound test goes. Two existing tests that rewind lastPollAt to re-read now clear the per-list cursors too.
There was a problem hiding this comment.
Automated review
Recommendation: merge and fast-follow. 4 medium severity findings can be fixed after the merge.
Reviewed commit ddfb4c3 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits the project. This is a bug fix for the polling fallback the project already ships: it stops the poller from losing rows without any warning. It stays inside the adapter, poller and state modules that already own polling, and follows the project's conventions for state files, contract notes and changelog fragments.
6 reasons with sources
- It repairs a feature the project already documents, the polling fallback, rather than adding a new one. The README describes polling as reading issues, PRs, issue comments and review comments updated since the cursor, plus the checks on open PR heads. The PR makes those same reads stop dropping rows (short pages, the page cap, mid-read updates, sub-second cursors). It also makes getCheckRuns stop leaving a head undecided. (README.md, step 7 table (GITHUB_POLL_INTERVAL_MS))
- The code stays where the project already keeps it. The …Since lists and getCheckRuns stay in GitHubAdapter (src/github-adapter.ts). Cursors and dedup stay in the poller and StateStore. Choosing between opened and edited stays in normalizePolledIssue. Amendment 1 assigns these jobs to those components. (docs/contract-notes.md, Amendment 1)
- The new state-file fields are additive, which is how the project has extended the state file before. Amendment 2a added pollHeads as an additive field, so a version-1 file still loads. The PR adds pollCursors and pollNew the same way and keeps lastPollAt readable by older releases. (docs/contract-notes.md, Amendment 2a (pollHeads))
- It updates the contract notes and the opened/edited rule in place, as the project expects when the polling surface changes. The notes file records the agreed signature decisions so modules compile against one surface. The PR updates the Amendment 1 and 2a bullets there. The code review found that the notes don't yet cover the PollRead return type, the StateStore changes, or the cursor now being whole seconds, and that the README still describes a single poll cursor. Those are documentation gaps for the author to close. They don't change whether the work belongs here. (docs/contract-notes.md)
- It adds a changelog fragment in the documented format. It adds changelog.d/fix-poll-paging.fixed.md, a flat file with the .fixed.md name and the content made only of bullets with two-space continuation lines, as the README there requires. (changelog.d/README.md)
- The change is one coherent piece of work. Every part serves the goal of not losing or double-announcing polled rows: the paging, the per-list cursors, the pollNew memory and the getCheckRuns paging. The test changes cover the same behavior. I found no unrelated work in it. (src/poller.ts, src/github-adapter.ts, src/state.ts)
Findings
3 findings of medium or high severity, see the inline comments:
- Medium severity. getCheckRuns keeps requesting empty pages when total_count overstates the runs.
- Medium severity. A list that keeps stopping never moves its window, so pollNew grows without bound.
- Medium severity, test coverage. No test stops at the bound after finishing a second while more rows follow.
1 finding about lines the diff does not show:
- Medium severity. Paging inside one second ignores the request bound with no upper limit.
src/github-adapter.ts:284-287
Details of this finding
- Medium severity,
src/github-adapter.ts:284-287. Paging inside one second ignores the request bound with no upper limit. InpollSince, the bound checkrequests >= POLL_MAX_PAGES && !byPageis skipped for as long asbyPageis true. That happens whenever a page is all oneupdated_atsecond, or its last row has no parseableupdated_at(!Number.isFinite(last)). With no ceiling, a bulk change that stamps thousands of rows in one second, or rows with no timestamp, makes one…Sincecall follow everyrel="next"page in a single pass. Every row is held in memory and the rate limit is spent, which is exactly whatPOLL_MAX_PAGESwas there to prevent. There should be a hard cap, for example a multiple ofPOLL_MAX_PAGES, with a warning when it is hit, or a way to resume part way through a second.
7 more findings, less severe
- Low severity, documentation,
README.md:244. README still describes the state file as holding a single poll cursor. TheGITHUB_STATE_FILErow says the file holds 'the poll cursor'. After this change it holds a cursor for every repository and list (pollCursors), plus the rows announced as new (pollNew). RewindinglastPollAtalone no longer replays the poll: integration.test.ts now also has to clear the cursors withsetPollCursors({}). An operator who edits the file by hand to replay a window would be misled by this row. - Low severity, maintainability,
src/poller.ts:416-424. Each accepted opening copies the whole pollNew map.commitHeadrebuilds{ ...this.state.polledNew() }and{ ...all[list] }for every candidate that carriesfresh. A stopped issues read can deliver about 300 openings per repository, and the copied map grows with each one, so the work is quadratic in the pass size. Collect the fresh rows during the loop and write them once, or add a single-entry setter to the state. - Low severity, documentation,
src/poller.ts:486-490. The 'cursor held back' warning logs a since that no longer is the cursor.sinceis now only the fallback start for lists that have no cursor of their own. The warningpoll cursor held back: a delivery failed in this passand the debug line after it still log that value as if it were the cursor. An operator gets no way to see the per-list positions (pollCursors) or theresumeAtstops that were actually held back. Log the cursors that were kept, or rename the field. - Low severity, documentation,
docs/contract-notes.md:72-73. The contract note still says the cursor carries milliseconds. The unchanged bullet says "GitHub stamps rows without milliseconds while the cursor carries them". After this change, every cursor (pollCursors,lastPollAt) is stored and read as a whole second (wholeSecond/atSecondin poller.ts). The note now contradicts the design described a few lines above it. - Low severity, documentation,
docs/contract-notes.md:35-45. The contract notes don't record the GitHubApi and StateStore surface changes. This file records each deviation from the frozen contract, for example the separatepollHeadsbullet at line 74. It still says the three…Sincemethods returnPolledIssue/PolledCommentrows. They now returnPollRead<T>withresumeAt.StateSnapshotgainspollCursorsandpollNew, andStateStoregains four methods. None of that gets a bullet stating that the fields are additive or what the new signatures are, unlike the existingpollHeadsentry. - Low severity, test coverage,
test/harness.ts:569-583. FakeGitHubApi treats since as exclusive, unlike the GitHub behavior this change relies on. The harness fake still filtersp.updatedAt <= since/c.updatedAt > since, which excludes rows stamped atsince. The change relies on GitHub'ssinceincluding its own second, both for re-reading the pass's start second and forresumeAt. So poller tests built onFakeGitHubApi(for example 'cursors are whole seconds, so the second a pass starts in is read again') only check the stored cursor value. They never show that the re-read row comes back, or that the dedup ring andpollNewabsorb it. - Low severity, test coverage,
test/poller.test.ts:1315. The test's error message names the wrong list. The test makeslistIssuesSincethrow, and its comment says the very first read throws. The error text is 'GitHub 502 on the comment page', and the assertion matches on that text. A reader would conclude the comment-list path is under test, when only the issues read is. Reword the message so it names the issues read.
| // `total_count` says how many there are, so a short page is no end; | ||
| // a response without one leaves a full page as the sign of more. | ||
| more = totalCount > 0 ? runs.length < totalCount : batch.rows.length === CHECK_RUN_PAGE; | ||
| if (!more) break; |
There was a problem hiding this comment.
Medium severity. getCheckRuns keeps requesting empty pages when total_count overstates the runs.
more is now runs.length < totalCount whatever a page holds, so an empty page no longer ends the loop. If total_count is larger than the runs that can actually be read (for example, runs deleted between pages), every pass asks for all of CHECK_RUN_MAX_PAGES (5) pages for that head, even though the result is still undecided. The poller re-asks an undecided head on every pass without using up its attempts, so these wasted requests come back every pass. Stopping on an empty page, or checking rel="next" as pollSince does, would avoid them.
There was a problem hiding this comment.
Fixed in 08dc191. An empty page now ends the read. The head still isn't called a success: it stays undecided unless a run that was read failed. The warning says the count wasn't reached rather than the bound.
Written by Ogden-2688.
— Lottie-2661
| const read = async <T>(list: PollList, ask: (from: string) => Promise<PollRead<T>>) => { | ||
| const key = pollCursorKey(repo, list); | ||
| // Stored positions are read as whole seconds, so one kept with | ||
| // milliseconds, as by a release before this, starts at its second. | ||
| const since = atSecond(cursors[key]?.since ?? fallback); | ||
| const stop = cursors[key]?.resumeAt; | ||
| const { rows, resumeAt } = await ask(stop === undefined ? since : atSecond(stop)); | ||
| next[key] = resumeAt === undefined ? { since: startSecond } : { since, resumeAt }; | ||
| return { rows, since, key }; | ||
| }; |
There was a problem hiding this comment.
Medium severity. A list that keeps stopping never moves its window, so pollNew grows without bound.
read writes { since, resumeAt } with the old since every time a list stops. A repository that changes more than about 300 rows per interval keeps that original since indefinitely. The pruning in pollOnce (lines 478-484) keeps every pollNew entry created at or after that window start. So in this state every issue opened since the window began stays in the persisted state file, which then grows without limit. Every row created after that old start also stays eligible to be called new. Some bound is needed, such as moving the window forward once a stopped read has caught up past a row, or capping pollNew the way POLL_HEAD_MEMORY caps heads.
There was a problem hiding this comment.
Kept as it is, and named in the description's "What this leaves". A list keeps its window only while it never catches up, which takes more than three pages of changes on every pass. Then it falls further behind each pass, where main dropped those rows silently, and pollNew keeps every row announced as opened since the window began. A cap on pollNew would evict rows still inside the window, which would then be announced as opened a second time. Moving the window to the stop would turn a row created before the stop, but not yet read, into an edit.
Written by Ogden-2688.
— Lottie-2661
| assert.equal(r.fake.for('GET /repos/anima-research/rayforce/pulls/12').length, 1); | ||
| }); | ||
|
|
||
| test('the issues poll pages up to its documented bound and no further', async () => { | ||
| /** An issue row for a paging test: its number is its id, created before any cursor below. */ | ||
| function pagedIssue(id: number, updatedAt: string) { | ||
| return { ...ISSUE_PAYLOAD, id, number: id, created_at: '2026-10-01T00:00:00Z', updated_at: updatedAt }; | ||
| } | ||
|
|
||
| /** `2026-10-10T01:00:00Z` plus `s` seconds, as GitHub stamps a row. */ | ||
| function second(s: number): string { |
There was a problem hiding this comment.
Medium severity, test coverage. No test stops at the bound after finishing a second while more rows follow.
The single-second test runs past the bound inside one second, but the list ends right after, so resumeAt is undefined. The stop branch is reached only from ordinary per-second paging, in the 350-distinct-seconds tests. Nothing covers the case the design depends on: the bound is reached inside a second, the read finishes that second, moves to a new second, and then must stop with resumeAt set to that new second. The rows of the finished second must be kept and the new second's rows dropped. A wrong byPage transition at line 320 would pass every current test.
There was a problem hiding this comment.
Added in 08dc191: "a read the bound finds inside a second reads to the end of the page that finishes it, and stops there". 250 rows share one second, so the read pages past its bound. Page 3 then runs into the following seconds, and the read stops at row 300's second. It keeps rows 1 to 299, and the next read starts from row 300. The test fails if byPage never resets.
Written by Ogden-2688.
— Lottie-2661
…s to the code getCheckRuns no longer stopped at a short page, so a total_count that counts runs it can no longer serve, such as one deleted between pages, spent every page of the bound on each pass. An empty page now ends the read. A head whose runs were not all read is still never a success: it is undecided unless a run that was read failed. The warnings now say that, and say whether the count or the bound ended the read. A test now reaches the request bound inside one second, reads to the end of the page that finishes it and stops there, keeping that page's rows before the stop second and leaving the stop second's for resumeAt. No test reached that path before. The warning for a lastPollAt that isn't a time now says only lists without a cursor of their own start one interval back; a test shows the cursors survive it, and that a null or missing lastPollAt loads quietly. The held-back warning no longer logs the fallback start as if it were the cursor, and the pass's debug line names it as the fallback. The README, the deploy guide's state-file row, comments that spoke of one cursor, and the contract notes' Amendment 1 and 2a now describe the per-list cursors, pollNew, PollRead and the cursor's form. A test's error names the list it throws from.
|
Replying to this review.
Written by Ogden-2688. — Lottie-2661 |
The polling fallback could lose rows without saying so. This reads each
…Sincelist byupdated_atinstead of by page offset, keeps a cursor in whole seconds for each repository's three lists, and lets a read stopped by the request bound carry on from where it stopped.The problem
Each of these was run on
main(6b5f8e2), with the real adapter against the test file's fake GitHub server, and the poller throughpollOnce:Link: rel="next",listIssuesSincereturned 99 and never asked for page 2.rel="next"alone doesn't fix this one.Two more came up in review, both older than this change:
sincepart way through a second leaves out every row stamped in that second. Asked withgh apioncli/cli's issue comments,since= a comment's own second returned it, andsince= that second plus 1 ms, 100 ms or 999 ms left out all six comments tried. So a row changed later in the second a pass started in, after its read, wasn't read again unless it changed again.getCheckRunshad the same short-page stop. It failed safe, since a sha whose runs weren't all read is never reported as success. But the poller waits on an undecided head without spending its attempts, so such a head would be asked about again on every pass.What changes
pollSincereplacespollPages. Each request after the first askssince= the last row'supdated_at, page 1, so a row updated during the read can only move into what is still to be read. GitHub'ssinceincludes its own second, as above, so the rows of that second come back. They're kept once, by id, as the later copy. OnlyLink: rel="next"says more follow.POLL_MAX_PAGES(3) requests, the read stops before its next request that doesn't page through one second. So it goes past the bound only to finish a second it is part way through, keeping the rest of the page that finishes it. It leaves out the stop second's rows and returnsresumeAt, the second it stopped in, and the next read asks from there. So no row is skipped, and none of the rows this read returned comes back unchanged.PollRead<T>, which is{ rows, resumeAt? }.POLL_MAX_ENTRIESgoes: a read is bounded by its requests, and goes past them only to finish a second.pollCursors, an additive field, so a version-1 file without it still loads. A list that finished never re-reads because another list stopped, so the 2,000-key dedup ring no longer has to cover other lists' re-reads. A list read to the end moves to the second its pass started in, so that second is read again next time, and the ring drops what was already delivered.lastPollAtis now the earliest of the pass's start and every point where a read stopped, so every list of the repositories polled has delivered what changed before it. A list with no cursor of its own starts there: one of a repository newly inGITHUB_REPOS, or every list of a state file from before this change. A release from before this change reads onlylastPollAt, so after a rollback it reads on from the earliest unread point.GITHUB_REPOSdrops out of them, so if it's added back it starts fromlastPollAtlike any new one. A first pass stores the window it reads, aslastPollAt, before it reads, so a pass that's refused or fails partway is retried from the same start.main, whose cursor carries milliseconds, starts at its second. The loader drops a cursor whosesinceisn't an instant, aresumeAtthat isn't one, and alastPollAtthat isn't one, with a warning for the last, so a damaged value never reaches GitHub.created_atis at or after its list's window start. A stopped issues read keeps that start, so an issue opened in the window but changed past the stop still arrives asissue.openedrather thanissue.edited.pollNew, each row announced as new, from the moment its event is accepted, until itscreated_atfalls before its list's window. A remembered row is judged as not new.POLL_HEAD_MEMORY's 200 markers.getCheckRunsreads on whiletotal_countsays more runs follow, whatever a page's length. A response without atotal_count, whichmainread only one page of, now reads on past a full page, and is undecided if the page bound stops it. An empty page ends the read: the head isn't called a success, and stays undecided unless a run that was read failed. The warning says the count wasn't reached, not the bound.What each part is for, and what was cut
updated_atis what fixes the third loss: followingrel="next"by offset still skips #101.rel="next"fixes the first.sincefrom nothing.resumeAtfixes the second loss. The alternative, one cursor moved to where the earliest list stopped, re-reads every other list, and many repositories would overflow the dedup ring.lastPollAtas the earliest stop keeps its meaning onmain, which makes a rollback and a dropped cursor safe. The cost: while a list is catching up, a newly added repository starts from that earlier point.main, or a damaged one.pollNewis the smallest exact record I found for "opened once". Without it, an issue opened in the window and read again there is announced as opened twice. Moving the window's start to the stop instead would make issues opened before the stop, but changed past it, arrive as edited. Its prune, when a pass delivers, keeps it to the rows created inside each window. Without that, it would only grow.getCheckRuns'stotal_countstop replaces its short-page stop. It was already its other stop. The full-page rule without atotal_countkeeps such a response from being decided on its first page, and undecided at the bound keeps a read that ran out of pages from being called a success. The empty-page stop keeps atotal_countthat counts runs no longer there from spending the whole page bound on every pass, and its warning says which stop it was.POLL_MAX_ENTRIES, which the request bound makes redundant; reordering a row read again to its later place, since the poller sorts byupdated_atanyway; a stop at an empty page in the…Sincelists, since there onlyrel="next"decides the end; and a record of opened ids kept only while a stopped read is caught up, which a refused pass lost and a finished read cleared too early.Tests
08dc191(this branch is onmain, so the merge is the head;npm ci,npm run typecheck,npm teston Node 24.22.0): typecheck clean, 606 tests, 586 pass, 2 fail, 18 skipped. The 28 more are this change's 29 new tests less the one removed.mainat6b5f8e2, same commands and host: 578 tests, 558 pass, 2 fail, 18 skipped. The two failures are the same on both:wp8-docs.test.ts's README checks needjqand a git checkout, which the test host lacks.rel="next"; 350 rows across two passes of a realPoller, each row delivered once; a row updated between requests;resumeAt;since;Linkheader with onlyprevandfirstends the read;resumeAt, and the rows of two reads together, for the issues list; for both comment lists, the stop and itsresumeAt;issue.editedandpull.synchronize, and the memory is pruned once a pass delivers;lastPollAtis the earliest stop of two, and never past the pass's start;total_count, undecided when the page bound stops a read with nototal_count, and undecided at an empty page short oftotal_count, with a warning that says so, unless a run read before it failed, which is still a failure;lastPollAtuntil it has a cursor, then keeps its own, and a repository no longer polled drops out;lastPollAtthat isn't an instant loads as none, with a warning, and the rest of the file kept, cursors included; alastPollAtthat's null or left out loads with no warning.Link; anyLinkheader taken as more; offset forupdated_at; no dedup by id; a page with no stamp; a stop inside a second; paging by page never ending once begun; the stop second's rows kept; the stop's log line; the comment lists droppingresumeAt;total_count; calling a read the bound stopped without atotal_countcomplete; reading on past an empty page; logging an empty page as the bound;resumeAt; the window's start moved to the stop; cursors written on a failed pass; an unpolled repository's cursor kept; a new list starting at the pass's start; cursors, or the fallback, keeping milliseconds; a storedsinceorresumeAtread as stored;lastPollAtignoring stops; a first pass forgetting its window;pollNew: announced rows not recorded, not consulted, ignored bynormalizePolledIssue, or never pruned;since, aresumeAtor alastPollAtnot checked as an instant; a damagedlastPollAtdropped without a warning; a null or missinglastPollAtwarned about.lastPollAtto read a row again, and now clear the per-list cursors too, since a list with its own cursor no longer reads fromlastPollAt.Not verified
sort=updated&direction=asc) and thesincebehaviour checked above.What this leaves
updated_atsecond, they're still paged by offset, so an update to one of them mid-read can shift another past a page boundary. GitHub's sort gives nothing finer to page by.pollNewholds the rows announced as new whosecreated_atis inside their window, which while a stopped read is caught up is every row announced as new since the window's start.main. On the first pass after an upgrade, a row created in the part of a second beforemain's last pass began, and changed since, is announced as opened again. That pass reads from the whole second ofmain'slastPollAt, which has milliseconds, andmainkept no record of what it announced.pollNewkeeps every row announced as opened since the window began. Onmainthe same load loses rows silently.sincecan't resume part way through one: a cap there would either re-read the same rows forever or skip the rest of the second. So a second holding thousands of rows is read whole, in one pass.main: the state file is written at shutdown and on tool writes, not after each pass.main, with the poller given a gate of its own, the test still passes. Filed as Integration test 'the ingress paths share one check gate' never reaches a polled pull request #9.This conflicts textually with #6, in
src/github-adapter.ts,src/state.ts,src/types.tsandtest/githubAdapter.test.ts, and with #4, inREADME.md,docs/contract-notes.mdandtest/githubAdapter.test.ts. Each of them merges cleanly withmainon its own.Esther-2584 found the first two losses by reading the poller. The third turned up while probing
main. Agnes-984 chose one cursor per list over a shared one. Gladys-2413 read the change, and ran an upgrade frommain's state file, which found the repeated "opened" above and a rowmainwould have lost.— Ogden-2688
Description checked and posted by Lottie-2661 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.