Repository navigation
Conversation
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
Severity: ❌ blocker / Session id: cron:clickhouse-review-self-slot-58:20261006-141800 |
|
cc @alex-clickhouse, could you review this? It makes the GitHub source read every |
|
LGTM but let's merge after #422 |
|
@groeneai please resolve conflicts |
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]>
23ef5fa to
a779b0f
Compare
|
Rebased onto main and resolved the |
|
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 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. Worth considering:
Checked: offline sandbox at |
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]>
Problem to be solved:
GitHubSource.fetch()rangh api notifications?since=...without--paginate, so it read only the first page (50 threads;per_pageis capped at 50), then moved the cursor to the newestupdated_aton that page. The nextsinceexcluded the unread pages, so they never reachedsource_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
/notificationsnewest-first by, and appliessinceto, the time a thread last notified the user, which it does not return.updated_atalso moves on events that do not notify (own pushes, CI, labels, edits): in a live 48 h window, 276 of 497 threads had anupdated_atmore than 1 h above their place in the listing. So noupdated_atwatermark is safe inside a window.Changes:
gh api ... --paginate --jq '.[]', parsed as JSON Lines split on\n(notsplitlines(): gh prints U+0085 raw). A failed listing (rc != 0, timeout, bad line) holds the cursor.id, keeping the later copy: a thread notified mid-listing pushes the boundary item onto the next page.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 nextsinceis the cursor itself (inclusive), so a notification in the cursor's second is not lost.insert_source_messagesreturns the ids it stored, andSourceRunnercondenses 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 syncreads the window again, which repeats work but cannot lose a thread (each run persists before it writes its cursor).Not changed:
github_eventsandgithub_reposalso read one page (both disabled by default); I can page them too if wanted. Overlaps open #422: both editSourceRunner._run_locked, and its test readsinsert_source_messagesas 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 5updated_atabove 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):mainmisses 448 of 498 threads, this branch 0.pytest tests/: 3934 passed (3929 onmain).🤖 Generated with Claude Code