Skip to content

feat(gql): add configurable soft deadline to GraphQL fan-out#813

Open
arielmelendez wants to merge 2 commits into
developfrom
feat/gql-fanout-soft-deadline
Open

feat(gql): add configurable soft deadline to GraphQL fan-out#813
arielmelendez wants to merge 2 commits into
developfrom
feat/gql-fanout-soft-deadline

Conversation

@arielmelendez

Copy link
Copy Markdown
Contributor

Problem

GatewaysGqlQueryable fans list queries (transactions/blocks) out to every configured upstream and waits for all of them via Promise.allSettled. A single degraded upstream therefore imposes its full request timeout (default 9.5s) on the whole query — even when a healthy peer already returned in milliseconds. The result is still served (partial, with warnings), but only after the slow upstream's timeout elapses.

Change

Add an opt-in soft deadline for list-query fan-out, off by default:

  • GATEWAYS_GQL_SOFT_DEADLINE_ENABLED (default false — preserves the current Promise.allSettled behavior exactly)
  • GATEWAYS_GQL_SOFT_DEADLINE_MS (default 2000)

When enabled, once the first upstream returns a usable result the merge waits at most the deadline for the remaining upstreams, then returns a partial merge and surfaces each unfinished source as an UPSTREAM_SOFT_DEADLINE warning.

Design notes

  • The deadline is armed only after the first fulfillment, not at call time. This guarantees an all-slow moment never turns a would-succeed query into an all-failed error.
  • Abandoned requests are not aborted — they keep running so the per-upstream circuit breaker still records their true outcome (real success or timeout). This is the key difference from simply lowering GATEWAYS_GQL_REQUEST_TIMEOUT_MS, which would make the breaker treat "slower than the deadline" as a failure and open on sources that would have succeeded.
  • Single-record getGqlTransaction / getGqlBlock are unchanged — they already resolve on the first responding source.

Observability

  • New gateways_gql_soft_deadline_source_cut_total{source} counter.
  • UPSTREAM_SOFT_DEADLINE GraphQL warning per cut source; logged at debug (like circuit-open) so it doesn't drown real upstream errors.

Testing

  • 4 new unit tests: fast-return-with-warning, disabled-waits-and-no-warning (proves the toggle), arm-after-first-result safety, blocks-path coverage. Full file 40/40 pass.
  • yarn lint:check clean; prod build typecheck (tsconfig.prod.json) clean.

Rollout

Ships disabled; enable per deployment via env once validated.

🤖 Generated with Claude Code

List queries (transactions/blocks) fan out to every upstream and wait for
all of them via Promise.allSettled, so one degraded upstream imposes its
full request timeout (default 9.5s) on the whole query even when a healthy
peer already answered in milliseconds.

Add GATEWAYS_GQL_SOFT_DEADLINE_ENABLED (default false, preserving current
behavior) and GATEWAYS_GQL_SOFT_DEADLINE_MS (default 2000). When enabled,
the merge returns once the fastest upstreams have answered and the deadline
elapses, surfacing each unfinished source as an UPSTREAM_SOFT_DEADLINE
warning. The deadline is armed only after the first usable result, so an
all-slow moment never turns a would-succeed query into a failure. Abandoned
requests keep running so the per-upstream circuit breaker still observes
their true outcome.

Adds a gateways_gql_soft_deadline_source_cut_total counter, unit tests, and
env docs (envs.md + docker-compose passthrough).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 89a208b3-7f48-4d60-a929-f74c7505b9d1

📥 Commits

Reviewing files that changed from the base of the PR and between f69e079 and b6794d5.

📒 Files selected for processing (2)
  • src/database/gateways-gql-queryable.test.ts
  • src/database/gateways-gql-queryable.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/database/gateways-gql-queryable.test.ts
  • src/database/gateways-gql-queryable.ts

📝 Walkthrough

Walkthrough

Introduces a configurable soft deadline for GraphQL fan-out queries in GatewaysGqlQueryable, with new config, env/docs wiring, metric tracking, and tests. When enabled, slow upstreams can be cut off after the first result and reported as partial merges with warnings.

Changes

GraphQL Fan-out Soft Deadline

Layer / File(s) Summary
Configuration constants and env wiring
src/config.ts, docker-compose.yaml, docs/envs.md
Adds GATEWAYS_GQL_SOFT_DEADLINE_ENABLED and GATEWAYS_GQL_SOFT_DEADLINE_MS config constants, docker-compose env vars, and documentation rows.
Soft deadline metric
src/metrics.ts
Adds gatewaysGqlSoftDeadlineSourceCutTotal counter metric labeled by source.
settleWithSoftDeadline implementation and wiring
src/database/gateways-gql-queryable.ts
Adds SoftDeadlineExceededError, instance fields, constructor and testing wiring, settleWithSoftDeadline in place of Promise.allSettled, and updated failure/warning classification for ESOFTDEADLINE.
Soft deadline tests
src/database/gateways-gql-queryable.test.ts
Adds tests for enabled and disabled transaction fan-out behavior, soft-deadline arming, and block query warnings.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • ar-io/ar-io-node#679: Extends the same GatewaysGqlQueryable fan-out/merge implementation with soft-deadline settling logic and related tests.

Suggested reviewers: djwhitt

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: a configurable soft deadline for GraphQL fan-out.
Description check ✅ Passed The description is clearly related to the PR and explains the soft-deadline feature, defaults, and behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/gql-fanout-soft-deadline

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/database/gateways-gql-queryable.ts (1)

1272-1282: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider adding a short TSDoc for warningCodeForReason.

Newly added helper; a one-line TSDoc documenting the code → warning-code mapping keeps it consistent with the documented methods around it.

As per coding guidelines: "Add or improve TSDoc comments on code you touch".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/database/gateways-gql-queryable.ts` around lines 1272 - 1282, Add a short
TSDoc comment for warningCodeForReason describing that it maps an upstream error
reason/code to a warning code, with the EOPENBREAKER and ESOFTDEADLINE cases
documented and the default fallback noted. Keep it consistent with nearby
documented helpers in gateways-gql-queryable.ts and place it directly above
warningCodeForReason so future readers understand the mapping at a glance.

Source: Coding guidelines

src/database/gateways-gql-queryable.test.ts (2)

1006-1035: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Tight real-timer margin risks flaky test under CI load.

This test relies on both sources resolving in ~30ms and the deadline (armed after the first fulfillment) firing 15ms later (~45ms total). Under a loaded CI runner, a few extra milliseconds of scheduling jitter on the second DelayedTxQueryable could push it past the 45ms window, causing the test to intermittently produce a warning/partial result instead of asserting ['a', 'b'] with no warnings.

Consider widening the margin (e.g., delay=20ms / deadline=60ms) or using fake timers to make the arming semantics deterministic instead of relying on real wall-clock races.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/database/gateways-gql-queryable.test.ts` around lines 1006 - 1035, The
soft-deadline test in GatewaysGqlQueryable.forTesting is too timing-sensitive
because it depends on real 30ms delays and a 15ms post-fulfillment deadline,
which can flake under CI load. Update the test to avoid wall-clock races by
either widening the timing gap (for example, slower source delays with a much
larger softDeadlineMs) or switching the DelayedTxQueryable setup to fake timers
so the arming behavior in getGqlTransactions is deterministic. Keep the
assertions on the merged transaction IDs and warnings unchanged.

1072-1077: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Tighten block-query warning assertion for parity with the transaction test.

The transaction test (Line 977) asserts warnings.length === 1 and the source (Line 979), but this block test only checks the first warning's code via optional chaining, without asserting length or source. Consider mirroring the stricter assertions here for consistency and to catch regressions (e.g., duplicate warnings).

Proposed tightened assertions
-    assert.equal((result.warnings ?? [])[0]?.code, 'UPSTREAM_SOFT_DEADLINE');
+    const warnings = result.warnings ?? [];
+    assert.equal(warnings.length, 1);
+    assert.equal(warnings[0].code, 'UPSTREAM_SOFT_DEADLINE');
+    assert.equal(warnings[0].source, 'http://slow.example');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/database/gateways-gql-queryable.test.ts` around lines 1072 - 1077, The
block-query test currently only checks the first warning code, which is looser
than the transaction test; update the assertions in
gateways-gql-queryable.test.ts around the block-query result to mirror the
stricter pattern used in the transaction test by verifying warnings.length is
exactly 1 and asserting the warning source as well as the UPSTREAM_SOFT_DEADLINE
code. Use the existing result.warnings access in this test and the transaction
test’s warning checks as the reference point so the two cases stay consistent
and catch duplicate warnings.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/database/gateways-gql-queryable.test.ts`:
- Around line 925-940: The soft-deadline test helper DelayedTxQueryable is
leaving ref’ed setTimeout handles behind, which can keep node --test open after
the fast path finishes. Update the delayed wait inside getGqlTransactions to use
an unref’ed timer (or otherwise avoid keeping the event loop alive) while
preserving the existing delay behavior in the DelayedTxQueryable test class.

---

Nitpick comments:
In `@src/database/gateways-gql-queryable.test.ts`:
- Around line 1006-1035: The soft-deadline test in
GatewaysGqlQueryable.forTesting is too timing-sensitive because it depends on
real 30ms delays and a 15ms post-fulfillment deadline, which can flake under CI
load. Update the test to avoid wall-clock races by either widening the timing
gap (for example, slower source delays with a much larger softDeadlineMs) or
switching the DelayedTxQueryable setup to fake timers so the arming behavior in
getGqlTransactions is deterministic. Keep the assertions on the merged
transaction IDs and warnings unchanged.
- Around line 1072-1077: The block-query test currently only checks the first
warning code, which is looser than the transaction test; update the assertions
in gateways-gql-queryable.test.ts around the block-query result to mirror the
stricter pattern used in the transaction test by verifying warnings.length is
exactly 1 and asserting the warning source as well as the UPSTREAM_SOFT_DEADLINE
code. Use the existing result.warnings access in this test and the transaction
test’s warning checks as the reference point so the two cases stay consistent
and catch duplicate warnings.

In `@src/database/gateways-gql-queryable.ts`:
- Around line 1272-1282: Add a short TSDoc comment for warningCodeForReason
describing that it maps an upstream error reason/code to a warning code, with
the EOPENBREAKER and ESOFTDEADLINE cases documented and the default fallback
noted. Keep it consistent with nearby documented helpers in
gateways-gql-queryable.ts and place it directly above warningCodeForReason so
future readers understand the mapping at a glance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b9a6b289-0ab8-481d-bcd3-8c2f1eb60934

📥 Commits

Reviewing files that changed from the base of the PR and between feefd25 and f69e079.

📒 Files selected for processing (6)
  • docker-compose.yaml
  • docs/envs.md
  • src/config.ts
  • src/database/gateways-gql-queryable.test.ts
  • src/database/gateways-gql-queryable.ts
  • src/metrics.ts

Comment thread src/database/gateways-gql-queryable.test.ts
@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.85%. Comparing base (feefd25) to head (b6794d5).

Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #813      +/-   ##
===========================================
+ Coverage    78.82%   78.85%   +0.03%     
===========================================
  Files          132      132              
  Lines        50172    50321     +149     
  Branches      3787     3800      +13     
===========================================
+ Hits         39548    39683     +135     
- Misses       10575    10589      +14     
  Partials        49       49              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

- Add TSDoc for warningCodeForReason (code -> warning-code mapping).
- Bound the abandoned-laggard delays in the soft-deadline tests to 500ms
  (was 10s) so their still-ref'd timers can't hang `node --test` at exit;
  tighten test 1's assertion (<300ms) to prove the deadline path. (Note:
  unref'ing the shared helper's timers instead, as suggested, cancels the
  tests that legitimately await the laggard once the timer is the sole
  live handle, so the delay is bounded rather than unref'd.)
- Widen the "arm after first result" test to 200ms delay / 100ms deadline
  to avoid CI timer-jitter flakiness, keeping deadline < delay so it still
  distinguishes call-time vs first-fulfillment arming.
- Tighten the block-query test's warning assertions (length + source) for
  parity with the transaction test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@arielmelendez

Copy link
Copy Markdown
Contributor Author

@coderabbitai — addressed the nitpicks in b6794d5:

  • TSDoc for warningCodeForReason: added, documenting the reason-code → warning-code mapping (EOPENBREAKERUPSTREAM_CIRCUIT_OPEN, ESOFTDEADLINEUPSTREAM_SOFT_DEADLINE, else UPSTREAM_UNAVAILABLE).
  • Tight-timing flakiness (arms the deadline only after the first result): widened to 200ms source delay / 100ms deadline (~100ms slack between the second source and the timer). I intentionally kept deadline < delay — the suggested delay=20 / deadline=60 inverts that, so both sources would resolve before any deadline fires and the test would no longer distinguish call-time vs first-fulfillment arming, which is the whole property it verifies.
  • Block-test assertion parity: now asserts warnings.length === 1 and warnings[0].source, matching the transaction test.

The one actionable inline comment (unref timers) is answered in its thread.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@arielmelendez Thanks for the detailed rundown — all three points make sense to me:

  • The TSDoc on warningCodeForReason documenting the EOPENBREAKER/ESOFTDEADLINE/default mapping addresses the earlier ask.
  • Your reasoning on keeping deadline < delay (200ms/100ms) is sound — inverting it to delay=20/deadline=60 would indeed let both sources settle before the timer fires, defeating the purpose of that test (verifying arm-on-first-fulfillment vs. arm-on-call). Good catch, and the added ~100ms slack should meaningfully reduce flakiness risk.
  • Glad the block-test assertions now mirror the transaction test for parity.

I'll take a look at the unref timers thread separately. This all looks good to me.

⋆⦅•ω•⦆⋆


✏️ Learnings added
Learnt from: arielmelendez
URL: https://github.com/ar-io/ar-io-node/pull/813

Timestamp: 2026-07-02T16:07:16.563Z
Learning: In `src/database/gateways-gql-queryable.test.ts` (ar-io/ar-io-node), the "arms the deadline only after the first result" soft-deadline test intentionally uses source delay (200ms) greater than the soft deadline (100ms), i.e., `deadline < delay`. This ordering is required to distinguish "deadline armed at call-time" from "deadline armed on first fulfillment" — inverting it (e.g., delay=20ms/deadline=60ms) would let all sources settle before any deadline timer fires, making the test unable to verify that property.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

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