Skip to content

github-backup(ghb_common): inventory_owner paginates forever when a page says hasNextPage with a null/empty endCursor — the reconcile sweep hangs re-fetching page 1 instead of raising #6532

Open

Description

@dezren39

github-backup(ghb_common): inventory_owner paginates forever when a page says hasNextPage with a null/empty endCursor — the reconcile sweep hangs re-fetching page 1 instead of raising

infra/github-backup/bin/ghb_common.py inventory_owner() (the GraphQL inventory every github-backup consumer builds on: the weekly reconcile.py sweep, clone_all.py planning, manifest regeneration) advances its pagination cursor with no validation: when a page reports hasNextPage: true, the loop takes pageInfo.endCursor as the next after unconditionally (ghb_common.py:126-127). If that cursor is null (or empty), after stays falsy, the after variable is omitted from the next request (:75-77), and the loop re-fetches the first page forever — appending the same repos on every iteration, issuing a real gh api graphql call each time, never returning. Every other malformed-response shape in this function was hardened to raise instead of being misread (#3229: top-level errors, null repositoryOwner, null repositories connection, missing nodes/pageInfo all raise RuntimeError); the non-advancing cursor is the one malformed shape left unguarded.

Evidence (file:line, origin/main 2bcd46e0dc)

  • ghb_common.py:126-127: if conn["pageInfo"]["hasNextPage"]: after = conn["pageInfo"]["endCursor"] — no check that the cursor is a non-empty string, and no check that it differs from the cursor just used. A repeated (stuck) cursor loops the same way.
  • ghb_common.py:75-77: the next request includes after only if after: — a null/empty cursor therefore silently restarts at page 1 rather than failing.
  • ghb_common.py:96-124 (contrast): the function's own stated contract — a successful gh exit carrying a failed/malformed inventory must raise, never be read as data (github-backup(inventory): a null repositoryOwner is silently read as 'this owner has no repos' — reconcile drops the owner's mirrors from plan.json and MANIFEST.json and exits 0 #3229 comment, :96-102). The cursor path violates that contract by hanging instead of raising.
  • Callers hang with it: reconcile.py main() calls inventory(cfg["owners"]) → inventory_owner per owner before any plan/report is written, so one malformed page wedges the whole weekly correctness sweep (its timer surfaces failures via exit code — a hang surfaces nothing).

Verification (laptop probe, this run)

Probe ~/git/fleet/probe-ghb-cursor/ against the real ghb_common.py at origin/main 2bcd46e0dc, with ghb_common.run stubbed to return canned GraphQL pages:

  • Malformed page (hasNextPage: true, endCursor: null, one node): inventory_owner was still looping after 6 identical page fetches — the probe aborted it there; without the abort it never returns (each iteration appends a duplicate repo and makes another API call).
  • Control (two well-formed pages, cursors c1 → c2, second page hasNextPage: false): returns 2 repos in exactly 2 calls.

Why it matters

The reconcile sweep is the github-backup system's correctness floor — the guarantee that no repo is missed. A single malformed pagination response (API change, partial response, proxy-mangled page) turns it into a silent infinite loop: no report, no exit-code failure for the timer to surface, unbounded duplicate accumulation in memory, and a stream of identical GraphQL calls against the API quota until something external kills it. The function's other malformed shapes all fail loud within one call; this one should too.

Acceptance criteria

  • inventory_owner raises RuntimeError (the function's existing failure mode, naming the owner and the malformed cursor) when a page reports hasNextPage: true with a null, empty, or non-advancing (same-as-just-used) endCursor, instead of issuing another request.
  • Well-formed multi-page inventories are unchanged: pages are fetched in order, each repo appears once, and the final page's hasNextPage: false ends the loop (existing test_inventory_owner.py suite passes unchanged).
  • A regression test in infra/github-backup/tests/test_inventory_owner.py pins the malformed-cursor page: the stubbed transport is called a bounded number of times and the call raises.
  • inventory()'s per-owner error recording picks the failure up as an owner-level error (the github-backup(inventory): a null repositoryOwner is silently read as 'this owner has no repos' — reconcile drops the owner's mirrors from plan.json and MANIFEST.json and exits 0 #3229 path), so reconcile.py reports it and exits non-zero rather than hanging.

Filed by the new-issue-creator (lane B) after verification this run; not a duplicate of #3229/#3240 (state-file atomicity) or #3282/#3283 (github-backup contracts/audit) — this is the pagination loop's missing cursor guard specifically.

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    task/triageQueue packet kind: verify, reproduce, and route this item
    source/creatorFiled by the new-issue-creator job
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 6, 2026
  2. developing-today commented on Nov 6, 2026

    @developing-today
    Contributor
  3. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator (lane B, new-issue-creator-code-b)
    • Session / ID: cron new-issue-creator-code-b, run 2026-11-06 08:50 CST
    • Found via: filed this run
    • Task: Triage the inventory_owner pagination-cursor hang: confirm the non-advancing endCursor loop in infra/github-backup/bin/ghb_common.py and route the raise-instead-of-loop fix with a regression test.
    • Expectation: effort s — done looks like a triage verdict on this issue, then a fix that raises RuntimeError on a null/empty/repeated cursor with the existing test_inventory_owner.py suite green.
    • Links: probe ~/git/fleet/probe-ghb-cursor/ (laptop); body file hidden_files/issue-ghb-inventory-cursor-loop.md
  4. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 6, 2026
  5. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    agent-note:v1

    🤖 Agent note · picked up

  6. 12 remaining items

  7. removed
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 6, 2026
  8. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    agent-note:v1

    • task: 11193
    • lane: 7b47536b-c162-444c-be8a-3a9538cc4cb5
    • event: picked_up
    • detail: Claimed the small-fix task in this OpenChamber scheduled lane. Fresh GitHub check: issue is OPEN, no hard-stop labels, and no matching PR is open or closed; prior parked labels were synchronized to in-progress. Revalidating the current code and preparing an isolated worktree.

    🤖 Agent note · picked up

  9. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    agent-note:v1

    🤖 Agent note · PR opened

  10. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    agent-note:v1

    🤖 Agent note · parked

  11. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    and removed
    queue/claimedQueue worker lifecycle state
    on Nov 6, 2026
  12. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · handed off

  13. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · handed off

  14. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · design-architect pass 63 — adopted as a phase of design #6622

  15. added
    queue/queuedHas a queued task in the Supabase agent queue
    and removed
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    app/github-backupinfra/github-backup estate mirror systemkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)queue/queuedHas a queued task in the Supabase agent queuesource/creatorFiled by the new-issue-creator jobsource/design-architectFiled by the repo design architect jobstatus/parkedDeliberately paused with a recorded resume conditiontask/triageQueue packet kind: verify, reproduce, and route this itemwork/assignedQueue worker lifecycle state

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions