Skip to content

feat(syncer): get actor type and node id - #194

Merged
bryangingechen merged 14 commits into
masterfrom
doc/051-timeline-actor-type
Aug 24, 2026
Merged

feat(syncer): get actor type and node id#194
bryangingechen merged 14 commits into
masterfrom
doc/051-timeline-actor-type

Conversation

@bryangingechen

Copy link
Copy Markdown
Contributor

This is useful for classifying bot actions as well as persisting data across renames.

bryangingechen and others added 10 commits August 13, 2026 06:21
Living implementation plan (design doc 051) for persisting the acting
account's GraphQL __typename and node id on PRTimelineEvent, so bot
classification stops depending on hardcoded downstream login lists.

Motivated by a qb-notebook data-quality investigation: the mathlib bots
were renamed on 2026-02-03, the downstream login list went stale, and
six months of the "open -> first review" metric were wrong.

Drafted downstream, then reviewed against the tree. Notable revisions
from the original draft:

- Backfill route is targeted `nodes(ids:)` resolution over stored
  timeline-item node ids, not a login->type map. The map's mechanism
  does not exist (GraphQL has no bot-by-login lookup) and it is blind
  to the retired logins that motivated the work. Probed live: ~1 point
  per batch, ~6k points total vs the <24h a full rewalk wave took.
- actor_node_id added to scope. actor_type alone leaves the machine-user
  half of the downstream list login-keyed, so it does not close the
  motivating rename bug.
- Archive-imported rows' missing actor_login is healed by the same
  backfill; the legacy query is no longer a live fetch path, so this is
  the only available fix.
- PRReviewInlineComment.author_login stays out of scope, with reasoning:
  probed 33 inline comments across 30 PRs, 0 author mismatches against
  the parent review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Persist the timeline actor's GraphQL __typename and node id so bot
classification is exact and downstream lists can key on a rename-stable
identity instead of a login (design doc 051, chunk 1).

- PRActorType choices (User/Bot/Mannequin, GitHub's wire casing) and the
  two nullable columns; NULL means unknown, never "User".
- No index and no CHECK constraint: the columns are meaningful on every
  event type, actor_type is three-valued, and the only consumer is a
  flat parquet export.
- Select `id` alongside `login` in the actor/author unions of
  pr_bundle / timeline_page / timeline_page_back. Adding fields to an
  existing selection is free on the GraphQL point budget. The inline
  comment author union is left alone — typing it is a Non-Goal.
- PRTimelineEventAdmin picks up both fields per the AGENTS.md admin-sync
  rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Populate actor_type/actor_node_id on every ingest path (design doc 051,
chunk 2). _extract_event_fields is the single funnel for the live bundle,
both timeline page loops, and the archive importer, so one change covers
all of them.

- _actor_type_or_none / _actor_node_id_or_none primitives plus an
  _actor_identity pair-builder; the allowed typename set is derived from
  PRActorType.values so it cannot drift from the model.
- Wired into all 12 branches that set actor_login, including the two
  distinct idioms (_login_or_empty and raw .get("login")) and the
  IssueComment / PullRequestReview `author` selections.
- REVIEW_DISMISSED denormalizes dismissed_review_author_type/_node_id
  into `extra` so _synthesize_dismissed_review_parent can type the row
  it materializes — those are review rows, exactly what the motivating
  downstream metric reads.
- actor_type/actor_node_id added to the fill-empty allowlist in
  sync_timeline_events. Without this, every ordinary rewalk would leave
  the new columns null forever.

Unmodelled typenames store the node id but leave actor_type null: NULL
means unknown, never "User".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Backfill actor_type/actor_node_id on historical rows by re-resolving each
row's stored github_node_id through GitHub's nodes(ids:) root field
(design doc 051, chunk 3). Exact rather than heuristic: it resolves the
actual actor attached to each specific event, so login reuse cannot
mis-type anything. The same response carries `login`, so archive-imported
rows get their missing actor_login filled in the same pass — fill-only, so
ingest-time logins and the rename history survive.

- actor_types_by_node_ids.graphql, registered with
  scripts/validate_github_graphql.py (which uses an explicit path list, not
  a glob) and validated against ids fetched earlier in the same run.
- GitHubClient.get_timeline_actors_by_node_ids, with the 100-id cap
  enforced client-side.
- The command splits a batch in half on GraphQL errors, so one unresolvable
  id cannot poison the other 99.
- Per-repository clients so GitHub App operation tokens resolve, matching
  every other syncer entry point.
- Stops at the rate floor by default and is resumable; --wait-for-rate
  sleeps until resetAt for an unattended drain.

The two actor helpers are now public (actor_type_or_none /
actor_node_id_or_none) since the command imports them.

Measured live: a full 100-id call costs 1 GraphQL point, so the whole
~598k-row table is ~6k points. 154 real mathlib4 node ids resolved
cleanly. That probe also corrected the doc's premise — the 2026-02-03
mathlib bot changeover was an account replacement (machine users →
GitHub Apps), not a rename; the plan records the correction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Chunk 4's assertions all read production rows that do not exist until the
drain runs, so it cannot be pre-verified. Record the ordered unblock
sequence (deploy → drain → canaries → export → downstream switch) and the
reason the export must come after the drain: a first export written while
the columns are entirely NULL lands as all-NaN float64 in parquet.

Export coverage and sanitization are verified statically and noted as
settled: EXPORT_TABLE_QUERIES is SELECT *, validate_backup_policy.py
passes, and sanitize_backup.py never touches the table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Constructing a GitHubClient requires a token, so `--repo X` with nothing
left to backfill failed with "GitHub token not found" instead of reporting
a no-op. Filter the explicit-repo case on actual work, matching what the
cross-repo path already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A full drain is ~6 k calls, so meeting a transient failure is near
certain. Three failure shapes now get told apart, because they mean
different things and the reported counters are what the rollout canaries
read:

- Transport failures (5xx, resets, timeouts) are retried with backoff,
  then recorded as `call_failed` and skipped, so one bad call no longer
  aborts the run before it prints its counters. Deliberately not split:
  an outage would fan one batch out into hundreds of sleeping calls.
- A rate-limit rejection unwinds to the existing resumable stop. The
  gate reads a cached snapshot, so it can be stale; retrying or
  splitting would only spend a budget that is already gone.
- Only "could not resolve" still counts as `unresolved`, i.e. a fact
  about the row. Previously any GraphQL error ended up there, so a
  stale-snapshot rate rejection would have reported up to 100 rows as
  nodes GitHub no longer has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per docs/design-decisions/README.md: the architecture is settled and
chunks 1-3 are implemented, so the living plan collapses into ADR shape
(Context / Decision / Consequences / Operational Notes / Alternatives).

Preserved: the invariants (NULL is unknown and never User, "Bot" means
GitHub App only, key residual automation lists on actor_node_id, the
fill-empty allowlist requirement, fill-only login semantics), the
2026-02-03 account-replacement correction, the parquet dtype trap, and
the inline-comment non-goal with its reasoning. Dropped: chunk
sequencing and the progress log, both in git history.

Operational Notes now carry the rollout as a runbook -- deploy, dry run,
detached drain, what each counter means, the canary queries, then export,
then the downstream switch -- plus the fact that this needs no new
settings or env vars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The drain moved none of the existing convergence counters, so there was
nowhere to watch it except the command's own stdout. Two per-repo
counters on SyncerConvergenceSnapshot, following the precedent of
archive_resync_remaining for the doc-043 drain:

- timeline_events_missing_actor_type is the backfill command's exact
  target set, so the page and the command agree. It plateaus at the
  null-actor floor instead of reaching 0.
- timeline_events_untyped_with_login narrows that to rows carrying a
  login. Such a row demonstrably had an actor, so this is typeable work:
  it converges to ~0 and stays there, which makes it the standing canary
  for the fill-empty column allowlist in sync_timeline_events.

Both are per-repo counts every ANALYTICS_CONVERGENCE_PERIOD_SECONDS
(900s), sequential scans given there is no index on actor_type; a
partial index WHERE actor_type IS NULL is the escape hatch if the
collector ever gets slow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A --limit run is the cost probe before committing to the full drain, so
it should report what it actually spent instead of leaving the operator
to multiply api_calls by an assumed price. `points` sums `rateLimit.cost`
from the responses, and a closing `rate:` line gives the remaining/used/
resetAt the run leaves behind.

Also note in --dry-run's help that it resolves for real and only skips
the writes, so it costs the same as a live run of the same size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bryangingechen and others added 2 commits August 23, 2026 23:10
The production probe measured the installation budget at ~9700 points/hr
(remaining=9621 used=79), not the 5000 a PAT gets, and confirmed 1 point
per 100-id call at scale: 2000 rows resolved in 20 calls for 20 points.
So the full drain is ~6.1k points and fits in one rate window; the
binding constraint is wall clock, not budget.

That makes an ad-hoc 500-point floor the wrong default. Raise it to
2500, matching ARCHIVE_RESYNC_MIN_RATE_REMAINING from the doc-043 drain:
the live syncer stops itself only at SYNCER_RATE_REMAINING_MIN (200), so
this keeps it fed while the drain runs.

Also label the dry-run count `would_write` instead of `written` — these
reports get pasted into runbooks, where "written" on a dry run reads as
a claim that rows changed.

Doc records the measured numbers, including why null_actor (0.3% here vs
12% in the recent-timeline probe) should rise as the drain reaches newer
rows, and why logins=0 early is an artifact of id order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The live drain hit dense runs of deleted IssueComment ids. One
unresolvable id makes GitHub reject the whole call, and the halving path
then spends 13 calls on a 100-id batch to rediscover which id was
bad — a fact the error message already states verbatim
("...global id of 'IC_kwDOFcwZ1c7vKdns'").

Parse the named ids out, drop them, retry the remainder: 2 calls instead
of 13. At 0.25% deleted rows over ~608k that is ~1.5k points instead of
~18k, i.e. the difference between one rate window and four. Halving
stays as the fallback for messages with no id to parse, and dead ids
still land in `unresolved` because they are absent from the result.

Also collapse the per-id log line into one per batch: a dense run of
deleted comments was burying everything else in the dyno log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bryangingechen and others added 2 commits August 24, 2026 01:17
565,269 of 607,558 mathlib4 rows typed; the 42,289 remaining are exactly
the final pass's null_actor + unresolved, which is what shows the drain
finished rather than stalled. 39.7% of the second pass's typed rows were
Bot.

Two corrections to earlier estimates, both from measured data:

- Rejected calls are free, so the bisection penalty was ~6 charged
  points per affected batch (~2k total), not the 12-13 I projected by
  counting calls as points. Its real cost was wall clock, and dead ids
  were 0.26% of rows rather than the ~1% a dense log window implied.
- The archive actor_login healing recovered nothing, because every
  empty-login row the drain saw also had no actor on GitHub. Those
  events are permanently unattributable rather than merely
  unattributed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Production confirms the central claim at scale: the mathlib-* replacements
are Bots and the retired mathlib4-* accounts are Users. 207,795 automation
events are now caught by actor_type alone; 27,879 machine-user events
still need a list, keyed on the four frozen node ids.

New invariant, and the one thing the design did not anticipate: 678
events carry a known automation login but no type -- 83% of the 812
unresolvable node ids, 564 of them mathlib-dependent-issues comments the
bot deletes and reposts. Those are ISSUE_COMMENTED rows, a first-touch
type, so filtering on actor_type == 'Bot' alone would count them as human
touches and reintroduce a smaller version of the bug this change exists
to fix. The downstream predicate must be the union of type, node id, and
login.

Also: only 10 archive rows still have an empty actor_login, so the
healing folded into scope had already happened via ordinary rewalks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bryangingechen
bryangingechen merged commit d801da5 into master Aug 24, 2026
4 checks passed
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