Skip to content

Fix the event capture regression: poll all three pages of /events - #1

Merged
alexey-milovidov merged 1 commit into
masterfrom
fix-event-capture
Aug 16, 2026
Merged

Fix the event capture regression: poll all three pages of /events#1
alexey-milovidov merged 1 commit into
masterfrom
fix-event-capture

Conversation

@alexey-milovidov

Copy link
Copy Markdown
Member

The crawler polled only page 1 of the Events API. GitHub does not spread event types evenly over the pages: page 1 carries the push / create / delete stream, and everything else — pull requests, issues, comments, stars, reviews, releases, forks — is only reachable on pages 2 and 3. Reading page 1 alone captured pushes and silently dropped nearly everything else.

This is the degradation reported upstream in igrigorik/gharchive.org#310 and #320.

Evidence

Of 4654 unique events observed on the live API over 78 seconds, grouped by the page each first appeared on:

type total page 1 page 2 page 3
PushEvent 1996 1995 1 0
PullRequestEvent 944 2 747 195
IssueCommentEvent 434 1 346 87
IssuesEvent 382 1 299 82
WatchEvent 225 0 179 46
PullRequestReviewEvent 211 0 160 51
others 154 0 122 32

Before and after, against the live API

Previous code, 20 seconds — 300 events, 4 types, no stars, no pull requests, no comments:

283 PushEvent   13 CreateEvent   3 DeleteEvent   1 IssuesEvent

This branch, 2 minutes — 16248 events, 16 types, zero duplicates, 67% push / 33% non-push:

10894 PushEvent          436 PullRequestReviewEvent         47 ForkEvent
 2054 PullRequestEvent   385 CreateEvent                    33 PublicEvent
  868 IssueCommentEvent  349 PullRequestReviewCommentEvent  21 CommitCommentEvent
  583 IssuesEvent        328 WatchEvent                     15 MemberEvent
                         118 ReleaseEvent                    4 DiscussionEvent
                         110 DeleteEvent                      3 GollumEvent

Star capture goes from about 0.03 to 2.7 events/second, which matches what the API actually serves.

Changes

  • Poll pages 1..3, each on its own schedule, so a busy page 1 cannot delay the others. Page 4 is not fetched — the API refuses it with pagination is limited for this resource, so 300 events per poll is the ceiling.
  • De-duplicate against a bounded set of recently seen event ids. The previous code compared only against the immediately preceding response, so an event that briefly dropped out of the window was archived twice.
  • Restore the missed-records alarm. It tested new_events.size >= PAGE_LIMIT, and f1f4200 set PAGE_LIMIT to 500 while the API clamps per_page to 100 — so the condition could never be true and the crawler ran saturated and silent for 15 months. It is now per page, and rate-limited to one line a minute because page 1 is expected to saturate.
  • Accept several tokens separated by commas, used round-robin. The default polling rate costs 14400 requests/hour, more than the 5000/hour of one user token; a GitHub App installation token (15000/hour) also works.
  • Back off when the remaining quota runs behind the window, and honour Retry-After on 403/429 instead of hammering through a throttle.
  • Conditional GETs with per-page ETags — from #317 by @emalaj-seq.
  • Report to StatHat only when STATHATKEY is set, instead of raising on every cycle.
  • Fix the git:// remotes in the Gemfile, also from #317 — GitHub disabled that protocol in 2022, so bundle install could not resolve them.

Known limitation

Page 1 alone runs at over 190 events/second — 100 entries replaced in under 0.53 s — which is faster than the API will serve to a single client. Pushes therefore cannot be captured completely at any affordable polling rate; pages 2 and 3 can. The crawler now reports when this happens rather than hiding it.

🤖 Generated with Claude Code

The crawler polled only page 1 of the Events API. GitHub does not spread event
types evenly over the pages: page 1 carries the push / create / delete stream,
and everything else - pull requests, issues, comments, stars, reviews, releases,
forks - is only reachable on pages 2 and 3. Reading page 1 alone therefore
captured pushes and silently dropped nearly everything else, which is the
degradation reported in #310 and #320.

Measured on the live API: of 4654 unique events observed over 78 seconds, 1995
of 1996 PushEvents came from page 1, while all 944 PullRequestEvents, all 434
IssueCommentEvents and all 225 WatchEvents came from pages 2 and 3.

Reproduced against the live API with the previous code, 20 seconds of capture:

  283 PushEvent, 13 CreateEvent, 3 DeleteEvent, 1 IssuesEvent

and with this change, 2 minutes of capture, 16248 events and no duplicates:

  10894 PushEvent        436 PullRequestReviewEvent    47 ForkEvent
   2054 PullRequestEvent 385 CreateEvent               33 PublicEvent
    868 IssueCommentEvent 349 PullRequestReviewComment 21 CommitCommentEvent
    583 IssuesEvent      328 WatchEvent                15 MemberEvent
                         118 ReleaseEvent               4 DiscussionEvent
                         110 DeleteEvent                3 GollumEvent

Changes:

- poll pages 1..3, each on its own schedule, so a busy page 1 cannot delay the
  others. Page 4 is not fetched: the API refuses it with "pagination is limited
  for this resource";
- de-duplicate against a bounded set of recently seen event ids. The previous
  code compared only against the immediately preceding response, so an event
  that briefly dropped out of the window was archived twice;
- restore the missed-records alarm. It tested `new_events.size >= PAGE_LIMIT`,
  and f1f4200 set PAGE_LIMIT to 500 while the API clamps per_page to 100, so the
  condition could never be true and the crawler ran saturated and silent. It is
  now per page, and rate-limited to one line a minute because page 1 is expected
  to saturate;
- accept several tokens separated by commas, used round-robin. The polling rate
  above costs 14400 requests/hour, more than one user token allows;
- back off when the remaining quota runs behind the window, and honour
  Retry-After on 403 and 429 instead of hammering through a throttle;
- use conditional GETs with per-page ETags, from #317 by @emalaj-seq;
- only report to StatHat when STATHATKEY is set, instead of raising on every
  cycle;
- fix the git:// remotes in the Gemfile, also from #317. GitHub disabled the
  git:// protocol in 2022, so bundle install could not resolve them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alexey-milovidov
alexey-milovidov merged commit 52c48e0 into master Aug 16, 2026
@alexey-milovidov
alexey-milovidov deleted the fix-event-capture branch August 16, 2026 23:18
alexey-milovidov added a commit that referenced this pull request Aug 17, 2026
Follow-up to #1. Two claims made there were wrong, and measuring properly
exposed a scheduling bug.

A single response from /events is an unordered sample of the recent window
rather than the strict newest 100, so two adjacent responses can barely overlap
while their union still covers everything. The per-poll "all 100 entries are
new" check added in #1 therefore fired constantly on page 1 without any events
actually being lost, and the claim that pushes "cannot be captured completely at
any affordable polling rate" was drawn from that same misreading.

What the aggregate actually shows, measured on page 1 over 20 second windows:

  interval  requests/s  events/s captured  overlap  largest timestamp gap
     0.5 s        1.80              100.0      46%                  1.0 s
     2.0 s        0.50               50.0       0%                  2.0 s
     4.0 s        0.25               25.0       0%                  4.0 s

The stream runs at about 100 events/second and unique events scale linearly with
the length of the window, so it is a complete stream and two polls a second
capture all of it. Polling harder buys nothing: four concurrent workers at 7.7
requests/second found one extra event over a single worker at 1.9, with the
already-seen fraction rising from 44% to 87%. More clients cannot raise the
ceiling, because at this rate there is none.

So the signal to watch is the overlap between polls, aggregated per page per
minute: healthy is comfortably above zero, and a page at 0% for a whole minute
means consecutive polls never met and the difference was dropped.

Reporting that immediately turned up a bug in #1: the next poll was scheduled
from the moment the response came back, so each interval was silently stretched
by a round trip and page 1 ran at 0.85 requests/second instead of 2. It now
schedules from the moment the request goes out.

  before: 103 polls/minute, overlap p1:0%  p2:21% p3:94%, warning on page 1
  after:  193 polls/minute, overlap p1:42% p2:57% p3:97%, no warning

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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