Skip to content

fix(orb): stop the relay losing events without a trace or a retry row (#9471) - #9510

Merged
JSONbored merged 2 commits into
mainfrom
fix/orb-relay-silent-loss
Jul 28, 2026
Merged

fix(orb): stop the relay losing events without a trace or a retry row (#9471)#9510
JSONbored merged 2 commits into
mainfrom
fix/orb-relay-silent-loss

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

Closes the relay's silent event-loss paths. The receiver ACKs GitHub 202 and forwards in a deferred task, so anything that throws out of forwardOrbEvent is an event GitHub considers delivered and this deployment has lost.

Closes #9471

The three paths

1. Only the push branch was guarded. forwardOrbEvent's try/catch (which degrades to "failed" and thereby persists to orb_relay_failures for the retry cron) wrapped only the push path. The enrollment SELECT and the entire pull branch — four D1 statements — sat outside it. And relayForward's catch was completely empty: no log, no metric.

So a transient D1 error in the pull path — precisely what a near-cap database produces, and this D1 has hit its 10 GB ceiling twice — left the event recorded in orb_webhook_events but in neither orb_relay_pending nor orb_relay_failures. No retry cron could ever see it; the review simply never ran. Pull mode is the shape edge-nl-01 runs.

Both paths now degrade to "failed", routing into the same durable retry machinery the push path already relies on, and relayForward's catch logs the delivery id and increments a counter.

2. The cap eviction was the last silent drop. pruneRelayPending and retryFailedRelays each emit an alertable *_dropped error with samples, but the per-installation cap deleted the oldest events with no log and without even checking meta.changes. A pull-mode container down for hours on an active repo can exceed 500 pending events (issue_comment + pull_request + check_suite together), and those deliveries vanished with zero trace — inconsistent with this file's own zero-trace-loss doctrine. It now samples before deleting and names the installation.

3. One bad row could wedge the retry consumer. retryFailedRelays is documented as never throwing and job-dispatch relies on that, but forwardOrbEvent could throw — so one row rejected the Promise.all, skipped every later chunk in the tick, and (because finalize never ran) left that row's attempts/last_attempt_at unadvanced. It stayed first-in-batch and immediately eligible, wedging the tail until its 1-hour TTL. Rows are isolated individually now.

Two notes on honesty of the fix

The retry-row isolation is now defence in depth, and is marked as such. Once the enrollment read and pull branch are guarded, forwardOrbEvent is total, and finalizeRelayFailureRetryRow already has its own catch — so nothing in retryRow can currently throw. I kept the isolation anyway (the function's "never throws" contract is relied upon elsewhere, and a future unguarded path must degrade rather than wedge) and annotated it with that reasoning rather than writing a test that cannot fail.

I dropped a nullish fallback rather than testing an unreachable arm. installationId: args.installationId ?? null in the new error log had a null arm that nothing can reach — forwardOrbEvent short-circuits before any IO for a null installation, so nothing throws. JSON.stringify omits undefined and keeps explicit null, so the fallback bought nothing.

Validation

  • npx tsc --noEmit -p tsconfig.json — clean
  • 141 passed across orb-relay (integration) and selfhost-metrics
  • Patch coverage measured against this diff: 0 uncovered changed lines

Regressions, verified to fail against the unfixed code:

  • A failing pull enqueue resolves to "failed" instead of throwing out of forwardOrbEvent (removing the guard makes it fail).
  • A failing enrollment read resolves to "failed" rather than escaping unclassified.
  • relayForward logs and counts a swallowed forward error, with the delivery id present so the event is identifiable.
  • The cap eviction logs and counts, naming the installation that lost events.

Invariants:

  • A "failed" outcome always persists a durable retry row, so the event stays recoverable.
  • The cap eviction is silent when nothing is actually over the cap (no false alarms).
  • A finalize failure does not abort the tick — later rows are still attempted.

Two new metrics are registered in DEFAULT_METRIC_META (the drift guard enforces this).

@loopover-orb

loopover-orb Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Warning

⏸️ LoopOver review result - manual review recommended

Review updated: 2026-07-28 02:35:40 UTC

4 files · 1 AI reviewer · no blockers · CI green · clean

⏸️ Suggested Action - Manual Review

Review summary
This PR closes three real silent event-loss paths in the Orb relay: it wraps the previously-unguarded enrollment SELECT and pull-mode enqueue in forwardOrbEvent with try/catch degrading to "failed" (routing into the existing retry-failure machinery), adds logging/metrics to relayForward's previously-empty catch and the per-installation pending-cap eviction, and isolates retryFailedRelays' per-row Promise.all so one throwing row can't wedge the whole batch. Each fix is traced correctly to its actual unguarded call site (the enrollment SELECT and enqueueRelayPending's four D1 statements sat outside the old push-only try/catch, and relayForward's catch was genuinely empty), and the new tests exercise the real failure injection points (mocked `DB.prepare` throwing for specific SQL fragments) rather than fabricating unreachable states. The eviction-log addition in `enqueueRelayPending` does introduce an extra SELECT + eviction check on every call, which is a real but bounded/intentional tradeoff (parity with the sibling drop-log paths) rather than a defect.

Nits — 6 non-blocking
  • src/orb/relay.ts:enqueueRelayPending now runs an extra SELECT before every DELETE eviction (even when nothing is over the cap) purely to log a sample — consider gating it behind checking `changes` from the delete first, or accept the extra round-trip as the cost of observability parity with pruneRelayPending/retryFailedRelays.
  • The `200` character truncation in `errorMessage(error).slice(0, 200)` (relay.ts and webhook.ts) is a repeated magic number; consider a named constant since it now appears at three call sites in this PR alone.
  • The `/* v8 ignore start/stop */` block around the try success-path comment in retryRow (relay.ts) is a slightly unusual pattern — worth confirming coverage tooling treats the comment placement correctly rather than accidentally excluding the catch block too.
  • Consider extracting the repeated `console.error(JSON.stringify({level:"error", event: ..., ...}))` shape used four times across this diff into a small structured-log helper to reduce duplication.
  • For the cap-eviction sample query in relay.ts, an index on `(installation_id, created_at, delivery_id)` would help if this table grows large, since the same ORDER BY is now done twice (once for the sample SELECT, once for the DELETE).
  • PR author also opened the linked issue — Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.

Decision drivers

  • ✅ Code review — No blockers (1 reviewer)
  • ✅ Gate result — Passing (No configured blocker found.)
Context & advisory signals — never blocks the verdict
Signal Result Evidence
Linked issue ✅ Linked #9471
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (1 linked issue).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 14 registered-repo PR(s), 13 merged, 334 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 14 PR(s), 334 issue(s).
Improvement ✅ Minor risk: clean · value: minor
Linked issue satisfaction

Partially addressed
The PR fixes the pull-branch/enrollment-SELECT silent throw, adds logging/metrics to relayForward's catch, adds cap-eviction logging, and isolates retryFailedRelays per row — covering four of the six listed deliverables with matching tests. However it does not touch the dedup guard in webhook.ts to distinguish 'recorded' from 'relayed' events (so manual redelivery of a recorded-but-unforwarded eve

Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository is configured for direct PR review.
  • Public profile languages: Python, TypeScript, Ruby, Go, MDX, Shell, Solidity, JavaScript
  • Official Gittensor activity: 14 PR(s), 334 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Start here: Treat this as maintainer-lane context rather than normal contributor-lane activity.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.
🧪 Chat with LoopOver

Ask LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.

  • @loopover ask <question> answers contribution-quality Q&A with source citations and freshness.
  • @loopover chat <question> answers in natural prose from cached decision-pack facts via local inference (maintainer/collaborator; read-only).
  • A plain-language @loopover mention with a real question is routed to the closest matching read-only command automatically — no exact syntax required.

Full command reference: https://loopover.ai/docs/loopover-commands

🧪 Experimental — new and may change.

Decision record
  • action: hold · clause: success
  • config: 8e6d410f8adad1bf812169e2353b77b53ac15e1d0c3d5248a7061f40db3bf4e7 · pack: oss-anti-slop · ci: failed
  • record: ce95ba6539f0ec0bb4017060c31212a3c54ac169dbb094932d6e2986b077bab4 (schema v5, head 851c635)

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.

  • Re-run LoopOver review

@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@loopover-orb loopover-orb Bot added the gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. label Jul 28, 2026
@loopover-orb loopover-orb Bot added the manual-review Gittensor contributor context label Jul 28, 2026
…#9471)

The receiver ACKs GitHub 202 and forwards in a deferred task, so anything that throws out of
forwardOrbEvent is an event GitHub considers delivered and this deployment has silently lost.
Only the PUSH branch sat inside forwardOrbEvent's try/catch: the enrollment SELECT and the
entire PULL branch -- four D1 statements -- were outside it, and relayForward's catch was
completely empty. A transient D1 error there, exactly what a near-cap database produces and
this D1 has hit its 10GB ceiling twice, left the event recorded in orb_webhook_events but in
NEITHER orb_relay_pending NOR orb_relay_failures, so no retry cron could ever see it and the
review simply never ran. Both paths now degrade to "failed", which routes them into the same
durable retry machinery the push path already relies on, and relayForward's catch logs and
counts instead of swallowing in silence.

The per-installation cap eviction was the last silent drop path: its two siblings each emit an
alertable *_dropped error with samples, but this one deleted the oldest events with no log and
without checking meta.changes. A pull-mode container down for hours on an active repo can
exceed 500 pending events, and those deliveries vanished with zero trace.

retryFailedRelays is documented as never throwing and job-dispatch relies on that, but
forwardOrbEvent can throw -- so one bad row rejected the Promise.all, skipped every later chunk
in the tick, and because finalize never ran left that row's attempts unadvanced. It stayed
first-in-batch and immediately eligible, wedging the retry consumer's tail until its 1h TTL.
Rows are now isolated individually and a throwing row still advances its attempt counter.
@JSONbored
JSONbored force-pushed the fix/orb-relay-silent-loss branch from 851c635 to 0a781fe Compare July 28, 2026 02:19
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.65%. Comparing base (d1c770c) to head (0a781fe).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9510      +/-   ##
==========================================
- Coverage   89.55%   88.65%   -0.90%     
==========================================
  Files         843      843              
  Lines      110135   110148      +13     
  Branches    26207    26208       +1     
==========================================
- Hits        98635    97657     -978     
- Misses      10238    11520    +1282     
+ Partials     1262      971     -291     
Flag Coverage Δ
backend 93.63% <100.00%> (-1.64%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/orb/relay.ts 100.00% <100.00%> (ø)
src/orb/webhook.ts 100.00% <100.00%> (ø)
src/selfhost/metrics.ts 100.00% <ø> (ø)

... and 3 files with indirect coverage changes

@JSONbored
JSONbored merged commit 37e4a28 into main Jul 28, 2026
7 checks passed
@JSONbored
JSONbored deleted the fix/orb-relay-silent-loss branch July 28, 2026 05:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. manual-review Gittensor contributor context

Projects

None yet

Development

Successfully merging this pull request may close these issues.

orb(relay): pull-mode forward errors are silently swallowed and unrecoverable — dedup guard blocks even manual redelivery

1 participant