Skip to content

GitHub source: read every notifications page instead of only the first - #491

Merged
pufit merged 5 commits into
ClickHouse:mainfrom
groeneai:groeneai/github-source-paginate-notifications
Oct 20, 2026
Merged

pufit merged 5 commits into
ClickHouse:mainfrom
groeneai:groeneai/github-source-paginate-notifications

Conversation

@groeneai

@groeneai groeneai commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Problem to be solved:

GitHubSource.fetch() ran gh api notifications?since=... without --paginate, so it read only the first page (50 threads; per_page is capped at 50), then moved the cursor to the newest updated_at on that page. The next since excluded the unread pages, so they never reached source_messages. On this install a 5.6 h sync gap lost about 40 threads, 7 of them direct asks from maintainers; the store shows 19 full-page runs since June, 14 after gaps under 1.3 h.

The cursor rule has to change too. GitHub orders /notifications newest-first by, and applies since to, the time a thread last notified the user, which it does not return. updated_at also moves on events that do not notify (own pushes, CI, labels, edits): in a live 48 h window, 276 of 497 threads had an updated_at more than 1 h above their place in the listing. So no updated_at watermark is safe inside a window.

Changes:

  • gh api ... --paginate --jq '.[]', parsed as JSON Lines split on \n (not splitlines(): gh prints U+0085 raw). A failed listing (rc != 0, timeout, bad line) holds the cursor.
  • De-duplicate by id, keeping the later copy: a thread notified mid-listing pushes the boundary item onto the next page.
  • The cursor is the first listed item's updated_at, not the max: the first item comes from the first page, so no notification landing while later pages are read is older than it. The next since is the cursor itself (inclusive), so a notification in the cursor's second is not lost.
  • insert_source_messages returns the ids it stored, and SourceRunner condenses and counts only those, so the thread re-listed in that second is not condensed again or logged as ingested on every run.

Cost: one run now enriches the whole backlog before it persists, ~15 s per notification (a 114-thread gap ~30 min, ~495 threads ~2 h). A scheduled run meanwhile is skipped by the runner lock; a source reload or a CLI nerve sync reads the window again, which repeats work but cannot lose a thread (each run persists before it writes its cursor).

Not changed: github_events and github_repos also read one page (both disabled by default); I can page them too if wanted. Overlaps open #422: both edit SourceRunner._run_locked, and its test reads insert_source_messages as a count. Residual: a thread marked read during the ~3 s listing shifts the next page up by one and can skip an item; nothing in nerve marks notifications read.

Tests: a fake that models the hidden listing key (_k <= updated_at): a 120-thread backlog with 5 updated_at above every key comes back in full from one call; a notification landing between pages or in the cursor's second comes back from the next call; a raw U+0085 parses; a second run over unchanged records condenses and counts nothing. Live (read-only, 48 h): main misses 448 of 498 threads, this branch 0. pytest tests/: 3934 passed (3929 on main).

🤖 Generated with Claude Code

@groeneai

groeneai commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author
Internal second-model review: adjudication log (click to expand)

Pre-publication review by an independent model (engine: codex; 4 rounds, 4 findings) plus my own cold review
(4 rounds, 10 findings). The last round found no new defect.

# Sev Finding Verdict Evidence / action
1 ❌ A notification landing in the cursor's own second is skipped by the next since = cursor + 1s (nerve/sources/github.py fetch) AGREE, fixed since is the cursor itself; same-second regression test
2 ⚠️ str.splitlines() also splits on U+0085 inside a JSON string, which gh prints raw, so one such notification stalls the source AGREE, fixed split on \n only; raw UTF-8 regression test
3 ⚠️ The thread re-listed in the cursor's second is condensed again and counted as ingested on every run (SourceRunner._run_locked) AGREE, fixed insert_source_messages returns the ids it stored; the runner condenses and counts only those; consecutive-run test
4 ⚠️ The runner lock is per instance, so a reload or CLI nerve sync can overlap a long run, contrary to the description AGREE (description), no code change an overlap repeats work but cannot lose a thread: each run persists before it writes its cursor (runner.py), the batch insert is atomic; description corrected
5 💡 Test fakes escape non-ASCII (ensure_ascii=True), unlike gh AGREE, fixed the paging fake emits raw UTF-8
6 💡 A thread marked read mid-listing shifts the next page up by one AGREE, not changed nothing in nerve marks notifications read; stated in the description
7 💡 On the 60 s listing timeout the gh child is not killed AGREE, not changed pre-existing, unchanged by this PR
8 💡 A very large backlog (more than ~150 pages, or a run longer than the restart cadence) repeats the window AGREE, not changed far above the measured rate; stated in the description
9 ⚠️ docs/sources.md still said GitHub advances its cursor by +1s, and listed condensation before persistence AGREE, fixed Cursor Design, overview, ingestion list and config table brought in line with the code
10 💡 Open #422 (and #465, which contains it) also edits SourceRunner._run_locked and docs/sources.md, and reads insert_source_messages as a count AGREE stated in the description; the docs/sources.md overlap is a text conflict for whichever PR merges second

Severity: ❌ blocker / ⚠️ major / 💡 nit. DISAGREE verdicts carry recorded evidence and are
terminal per finding. Findings on hunks unchanged by the fix round are auto-dropped.

Session id: cron:clickhouse-review-self-slot-58:20261006-141800

@groeneai

groeneai commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

cc @alex-clickhouse, could you review this? It makes the GitHub source read every /notifications page (a sync gap over 50 threads lost the older ones before storage) and changes insert_source_messages to return the stored ids, which overlaps your #422 and #465 in SourceRunner._run_locked and docs/sources.md.

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

LGTM but let's merge after #422

@pufit

pufit commented Oct 20, 2026

Copy link
Copy Markdown
Member

@groeneai please resolve conflicts

groeneai and others added 5 commits October 20, 2026 16:23
fetch() ran `gh api notifications?since=...` without --paginate, so it
read only the first page (50 threads, the per_page cap) and then set the
cursor to the newest updated_at on that page. The next `since` excluded
every thread on the unread pages, so they never reached source_messages.
Any sync gap with more than 50 participating notifications lost the rest.

GitHub orders /notifications newest-first by, and applies `since` to, the
time a thread last notified the user, which the API does not return.
updated_at also moves on events that do not notify (own pushes, CI,
labels, edits), so it can sit hours above that key. That rules out any
updated_at watermark inside a window: oldest-first batches with such a
cursor lost 226 of 497 threads on a live 48 h window. So:

- Read the whole window in one call: `--paginate --jq '.[]'`, parsed as
  JSON Lines. The output is identical on gh 2.23.0 and 2.93.0, so there
  is no version floor. Any failure still returns nothing and holds the
  cursor; partial output with rc != 0 is never parsed.
- De-duplicate by id, keeping the later copy. A thread notified while
  the listing runs moves the boundary item onto the next page, so it is
  listed twice, and the later read is the newer state.
- Take the cursor from the first listed item, not the max. The first
  item comes from the first response, so its updated_at is at most the
  first page's read time, below the key of any notification that lands
  while later pages are read. The max can come from a later page whose
  updated_at was bumped by a non-notifying event after such a
  notification, and pass it. The first item also carries the largest key
  in the window, so nothing already returned is listed again.

limit stays unused, as it has been since the source was added, and the
docs now say batch_size does not cap this source. A run after a long gap
enriches the whole backlog before persisting.

Tests model the hidden key with a per-thread `_k <= updated_at`. Both
fail on main; the backlog test also fails on the oldest-first batching
design. Live, read-only, 48 h window: main misses 445 of 495 threads,
this change 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…s on \n

The cursor is the updated_at of the first listed item, at second
precision. A notification that lands after the first page is served has
a listing key at or above that value, so it can fall in the cursor's own
second. `since = cursor + 1s` then excluded it for good. `since` is now
the cursor itself; the re-listed thread of that second (normally the
first item alone) is skipped by insert_source_messages when unchanged.

str.splitlines() also breaks on U+0085, U+2028, U+2029 and a few C0/C1
separators. gh prints non-ASCII characters raw, so a U+0085 in any string
of a notification split its line, json.loads failed, and every later run
failed on the same window with the cursor held: the source stopped. The
listing is now split on "\n" only.

The test fake prints UTF-8 raw (ensure_ascii=False), like gh. New tests
cover a notification landing in the cursor's own second and a raw U+0085
in a subject title; the backlog and between-pages tests now expect the
boundary thread to be listed again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
insert_source_messages skips a record identical to the stored one, but
it returned only a count and SourceRunner ignored it: the runner then
condensed every fetched record and reported len(records) as ingested.
The GitHub source lists the cursor's own second again on every run, so
an idle run logged "1 records ingested" and, with condense: true, sent
the same unchanged thread to the fast model on every run.

insert_source_messages now returns the ids it stored, and the runner
keeps only those records for condensation and for records_ingested.
A failed insert still propagates and holds the cursor, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…usive

The ingestion list and the overview placed LLM condensation before
persistence, but the runner persists first and condenses only the
records storage stored. List condensation as its own step after
Persist, and drop "before storing" from the condense config field.

The Cursor Design note still said every inclusive source advances the
cursor by one second. That is Gmail only: the GitHub source queries the
cursor itself, so a notification that lands in the cursor's own second
is not skipped, and storage skips the unchanged thread listed again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
ChannelSource (ClickHouse#422) maps a message observed twice to the same record id,
so one batch can carry two copies of an id. Storage stores one row for
them (it skips an identical copy and replaces the row for a changed one),
but the runner kept every input record whose id storage returned, so it
condensed both copies and counted 2 ingested.

Keep one record per stored id, the last copy, which is the one storage
holds. A parametrized runner test covers an identical and a changed repeat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@groeneai
groeneai force-pushed the groeneai/github-source-paginate-notifications branch from 23ef5fa to a779b0f Compare October 20, 2026 17:05
@groeneai

Copy link
Copy Markdown
Contributor Author

Rebased onto main and resolved the docs/sources.md conflict with #422. Two follow-ups to #422 came with it: test_channel_observation.py now asserts on the stored-id list that insert_source_messages returns, and a779b0f makes the runner count and condense a record id that ChannelSource repeats within one batch once (it counted both copies). Full suite: 6419 passed.

@nerveai

nerveai commented Oct 20, 2026

Copy link
Copy Markdown

Nerve PR review · ✅ Ready to merge (once CI is approved)

This fixes real data loss. On main, a sync gap with more than 50 participating notifications drops everything past page 1. Reading the whole window with --paginate and taking the cursor from the first listed item is the right fix for GitHub's hidden listing key. I checked the ordering argument and found no gap. Returning stored ids from storage also stops repeat condense calls for every source.

Completeness: a paging fake models the hidden key, and there are runner tests for stored and repeated ids. 6 of the 7 new tests fail on main. docs/sources.md is updated. No config or migration changes.

Worth considering:

  • Overlap with Add a drop hook to the source guardrail #512: the cursor's own second is listed again every run (nerve/sources/github.py:124). Storage skips a kept copy, but a guardrail-dropped one is dropped again every run (nerve/sources/runner.py:347). With Add a drop hook to the source guardrail #512's drop hook, the hook fires on that thread every idle run. Whichever PR merges second should de-duplicate repeat drops.
  • After a long gap, nothing persists until the whole backlog is enriched. Fresh asks wait behind it, and a restart mid-run discards that work.

Checked: offline sandbox at a779b0f: 6405 passed, 38 skipped. test_slack_integration.py was excluded because it hangs on main e306482 too. CI not yet run (fork approval pending) · automated review by nerveai — mention @nerveai to ask a follow-up.

@pufit
pufit merged commit 7cfcb15 into ClickHouse:main Oct 20, 2026
4 checks passed
polyglotAI-bot added a commit to polyglotAI-bot/nerve that referenced this pull request Oct 20, 2026
Since ClickHouse#491, the GitHub source queries since=<cursor> inclusively. Storage
skips a kept record that did not change, but a dropped record is never
stored. So the thread in the cursor's own second was dropped again on every
run, and the hook got it again every run until a newer notification came.

The runner now remembers the (id, timestamp) pairs it passed to the hook
(the last 4096, in process) and does not pass them again. New activity on a
thread changes updated_at, so it reaches the hook. A failed attempt is
remembered too: no retry, as documented.

Two large-payload tests repeated one batch 2000 times. They now use
distinct ids, so the payload still fills the pipe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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.

4 participants