Skip to content

feat(gateway-pools): load balancing - #7703

Merged
bernie-g merged 20 commits into
mainfrom
bernie/pam-375-add-load-balancing-for-gateway-pools
Aug 27, 2026
Merged

feat(gateway-pools): load balancing#7703
bernie-g merged 20 commits into
mainfrom
bernie/pam-375-add-load-balancing-for-gateway-pools

Conversation

@bernie-g

@bernie-g bernie-g commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Context

Gateway pools picked a gateway at random, so one could end up carrying far more work than the others while its peers sat idle. Pools now send each new job to whichever gateway is least busy, and retry on another gateway when one can't be reached.

Needs the matching CLI change so gateways report how busy they are: Infisical/cli#370

Steps to verify the change

  1. Put two or more gateways in a pool and point an app connection or Kubernetes auth config at it.
  2. Drive traffic through and confirm it spreads instead of landing on one gateway.
  3. Kill a gateway mid-traffic: requests keep succeeding and the logs show a retry on another member.
  4. Load one gateway up (e.g. a few PAM CLI sessions) and confirm new work goes to the quieter one.
  5. Use a wrong password on the target and confirm it fails straight away, without being retried elsewhere.

Type

  • Fix
  • Feature
  • Improvement
  • Breaking
  • Docs
  • Chore

Checklist

  • Title follows the conventional commit format: type(scope): short description (scope is optional, e.g., fix: prevent crash on sync or fix(api): handle null response).
  • Tested locally
  • Updated docs (if needed)
  • Updated CLAUDE.md files (if needed)
  • Read the contributing guide

@linear

linear Bot commented Aug 18, 2026

Copy link
Copy Markdown

PAM-375

@infisical-review-police

Copy link
Copy Markdown

💬 Discussion in Slack: #pr-review-infisical-7703-feat-gateway-pools-send-work-to-the-least-busy-gateway

Posted by Review Police — reviews, comments, new commits, and CI failures will stream into this channel.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e954d8e86c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread backend/src/ee/services/gateway-pool/gateway-pool-service.ts Outdated
Comment thread backend/src/ee/routes/v2/gateway-router.ts
Comment thread backend/src/ee/services/gateway-v2/gateway-v2-service.ts Outdated
Comment thread backend/src/ee/services/gateway-v2/gateway-v2-service.ts Outdated
@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds least-loaded gateway-pool selection and retries transport failures on another healthy member.

  • Introduces shared Redis-backed gateway occupancy, reservation, and suspect-member tracking.
  • Adds an authenticated gateway load-reporting endpoint and propagates gateway IDs through proxy connection details.
  • Adds pool failover to app-connection validation and Kubernetes token review while preserving pinned-gateway behavior.
  • Updates gateway-backed integrations and tests for load selection, Redis degradation, capability filtering, and retry safety.

Confidence Score: 4/5

The PR appears safe to merge, with only non-blocking API-contract cleanup needed for the new load-reporting route and related errors.

The load-balancing and transport-only failover paths have explicit bounds and safety guards; the accepted concern is limited to missing schema descriptions, a receipt-only POST response, and ambiguous identifier formatting.

Files Needing Attention: backend/src/ee/routes/v2/gateway-router.ts and the new gateway identifier error sites

Important Files Changed

Filename Overview
backend/src/ee/services/gateway-pool/gateway-pool-service.ts Adds load-aware member selection, suspect exclusion, reservations, and bounded transport-only failover.
backend/src/lib/gateway-v2/gateway-load-tracker.ts Adds Redis-backed reported load, per-pod channel counts, reservations, stale-data handling, and suspect markers.
backend/src/lib/gateway-v2/gateway-v2.ts Tracks established channels and distinguishes pre-tunnel transport failures from target-side errors.
backend/src/ee/routes/v2/gateway-router.ts Adds the authenticated load-report endpoint, but its schema metadata and receipt response violate the backend API guide.
backend/src/services/app-connection/app-connection-service.ts Retries read-only connection validation through alternate pool members and reuses the successful member for non-retryable credential transitions.
backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts Adds pool failover around gateway-mediated Kubernetes token review while preserving direct gateway fallback behavior.

Reviews (1): Last reviewed commit: "fix(gateway-pools): address review, and ..." | Re-trigger Greptile

@veria-ai

veria-ai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

Comment thread backend/src/ee/routes/v2/gateway-router.ts Outdated
@mintlify

mintlify Bot commented Aug 18, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
infisical 🟢 Ready View Preview Aug 18, 2026, 6:38 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@usefactorapp

Copy link
Copy Markdown

FINDING 1

Redis-backed load tracking governs selection for 500+ connection fan-out paths

The repository migrates diverse connection providers to a centralized GatewayPoolService that uses a Redis-backed GatewayLoadTracker for load-aware selection. Logic like claimLeastLoaded and trackReservationRelease now manages concurrency across all connection types (SSH, LDAP, SQL), introducing a repository-wide dependency on the performance of load-tracking hash keys. Factor found no validation evidence for this critical selection logic within the changed paths.

Evidence:

  • Symbol: backend.src.ee.services.gateway-pool.gateway-pool-service.selectGatewayFromPool
  • Symbol: backend.src.lib.gateway-v2.gateway-load-tracker.claimLeastLoaded
  • Discovery IR reports 2247 boundary crossings with no validation coverage for the centralized selection path

Agent Prompt:
Trace the lifecycle of a connection request starting from AppConnectionService.createAppConnection in backend/src/services/app-connection/app-connection-service.ts down through GatewayPoolService.selectGatewayFromPool and into GatewayLoadTracker.claimLeastLoaded. Investigate how Redis keys like reservationKey and reportedKey are used in backend/src/lib/gateway-v2/gateway-load-tracker.ts to maintain state. Explain how runWithPoolFailover handles transport failures vs. application-level errors and cite relevant file evidence. Do not modify code.

FINDING 2

AsyncLocalStorage attempt context decoupled from 2200+ deep cross-service execution chains

The new retry logic in gateway-retry.ts depends on a GatewayAttemptContext managed via AsyncLocalStorage. This context allows services deep in the call stack to call markAttemptTransportFailure without requiring signature changes across thousands of symbols. This decoupling is essential for the isGatewayTransportFailure check, which distinguishes between connectivity issues and application errors in the failover mechanism.

Evidence:

  • Symbol: backend.src.lib.gateway-v2.gateway-attempt-context.runGatewayAttempt
  • Symbol: backend.src.lib.gateway-v2.gateway-retry.isAttemptRetryable
  • The state is propagated implicitly using AsyncLocalStorage storage in backend/src/lib/gateway-v2/gateway-attempt-context.ts

Agent Prompt:
Trace the implementation and usage of runGatewayAttempt in backend/src/lib/gateway-v2/gateway-attempt-context.ts. Identify where markAttemptTransportFailure is called by connection providers and how gateway-retry.ts consumes this state via isGatewayTransportFailure to decide on retries. Explain the mechanism by which the AsyncLocalStorage state is persisted across the service boundaries indicated by the discovery metrics. Do not modify code.

FINDING 3

Unified ALPN-based protocol multiplexing standardizes disparate tunneling execution paths

Disparate connection providers for SSH, SQL, and HTTP are being refactored to use a unified withGatewayV2Proxy execution wrapper. This wrapper implements protocol multiplexing through a protocolToAlpn mapping, effectively standardizing how tunnels are established across the repository. This change moves protocol-specific connectivity logic into a shared infrastructure layer, centralizing blast radius for tunneling failures.

Evidence:

  • Symbol: backend.src.lib.gateway-v2.gateway-v2.protocolToAlpn
  • Symbol: backend.src.lib.gateway-v2.gateway-v2.withGatewayV2Proxy
  • Refactoring of executeSshCommandViaGateway, requestWithHCVaultGateway, and executeWinRMGatewayOperation to this shared wrapper

Agent Prompt:
Inspect backend/src/lib/gateway-v2/gateway-v2.ts and focus on the withGatewayV2Proxy function. Determine how it maps internal protocol requests to ALPN identifiers via protocolToAlpn and how the relayServer handles lifecycle cleanup. Trace a specific consumer like executeSshCommandViaGateway in ssh-connection-fns.ts and explain how it passes credentials and configuration through this standardized tunnel. Do not modify code.

@bernie-g bernie-g changed the title feat(gateway-pools): send work to the least busy gateway, and retry elsewhere on failure feat(gateway-pools): load balancing Aug 19, 2026
Comment thread backend/src/keystore/keystore.ts
Comment thread backend/src/keystore/keystore.ts Outdated
Comment thread backend/src/keystore/keystore.ts
Comment thread backend/src/lib/gateway-v2/gateway-v2.ts
Comment thread backend/src/server/routes/index.ts Outdated
Comment thread backend/src/ee/routes/v2/gateway-router.ts
Comment thread backend/src/ee/services/gateway-pool/gateway-pool-selection-fns.ts Outdated
Comment thread backend/src/ee/services/gateway-v2/gateway-v2-service.ts
Comment thread backend/src/ee/services/gateway-v2/gateway-v2-service.ts Outdated
Comment thread backend/src/server/config/rateLimiter.ts Outdated
Adds gatewayId to TGatewayV2ConnectionDetails and threads it to the proxy
layer, which until now received the gateway's mTLS material but no way to
say which gateway it was talking to. Nothing consumes it yet.

Call sites spread the connection details rather than re-listing their
fields, so the type stays the single source of truth and a field added
later cannot be silently dropped at 26 places.

No behaviour change.
Replace random gateway-pool selection with least-connections, and route
around a member whose tunnel cannot be established.

Selection
- Track open gateway channels per pod in Redis, summed at selection time
  alongside short-lived reservations covering the window between choosing
  a member and its channel opening.
- Count channels inside setupRelayServer rather than around the caller's
  operation, since one operation can open many channels.
- Pick the least-loaded member, breaking between two comparable members
  at random so concurrent pods reading the same counters do not stampede
  the same gateway. A tie band keeps the score meaningful for two-member
  pools instead of degrading to a coin flip.
- Degrade to random whenever Redis is unavailable.

Failover
- runWithPoolFailover retries on another member, but only when no tunnel
  was ever established, so an operation that may have partially applied
  on the target is never replayed. Providers rewrap gateway errors in
  their own BadRequestError, so the signal travels through async-local
  storage scoped to a single attempt rather than on the exception.
- Mark a failed member suspect for 60s, closing the window where a dead
  gateway still looks healthy to the heartbeat check.
- Wire into Kubernetes auth login and app-connection validation. The
  platform-managed credential transition deliberately does not fail over
  and reuses the member validation succeeded on.

Also folds the HSM connector's private selection copy onto the shared
selector via a capability filter, and fixes two keystore mocks that had
the wrong arity for setItemWithExpiry and a no-op decrementByOrDelete.
Review fixes
- Only treat a failure as retryable when no tunnel was ever established.
  The relay error list is shared by every channel a proxy serves and is
  never cleared, so one transient setup failure would otherwise keep
  marking later target-side errors as safe to replay.
- Clear that classification once any tunnel comes up, so an attempt whose
  first tunnel failed and was swallowed cannot be replayed after a later
  one reached the target.
- Release a reservation when its channel opens rather than on a fixed
  timer, which double counted the member and expired early under a slow
  relay.
- Read reservations and suspect marks from the Redis primary; they are
  written there and read back within the same sub-second window.
- Tear down a channel whose caller gave up during the handshake, which
  otherwise stayed counted for the life of the pod.
- Keep the BadRequest error name so the serialised error field does not
  change for callers; retry decisions use instanceof.
- Reuse the member validation succeeded on for the platform-managed
  credential transition, which mutates the target and must not fail over.
- Restore the 404 for an unknown pool, surface the real PKCS#11 error once
  HSM members run out, and never let load bookkeeping fail a selection.

Load signal
- The gateway now reports its own active channel count, which covers work
  the platform never proxied. A PAM CLI session dials the relay directly,
  so it was previously invisible and a saturated member scored zero.
- A member too old to report is scored on platform-opened channels only,
  which is a lower number than a reporting peer's true occupancy, so a
  mixed pool gets no load awareness rather than a biased guess that would
  pull traffic onto un-upgraded gateways for a whole rollout.
- Tie band is exact equality: a reservation is worth one, so any wider
  band cancelled it out and re-shortlisted the member just claimed.
Both retry decisions were inline boolean logic inside catch blocks, and
neither could be reached from a test: they only trigger on a mixed
sequence within one operation, where an early channel fails at setup and a
later one reaches the target.

Extracting them states the shared rule once (retry only when nothing can
have reached the target) and makes both testable. Deleting either guard
now fails tests, which was verified by mutation rather than assumed.
Choosing and claiming a pool member were a read then a separate write, so
every concurrent selection saw the same minimum before any of them claimed
it and they all routed to it. Both now happen in one Redis call; ties fall
to the caller's order, which is shuffled, so the random tie-break stands.

- Identity load reports run the same permission check heartbeat does, so a
  revoked identity cannot keep steering pool routing.
- Load reports get their own limiter keyed by the reporting gateway. The
  shared IP-keyed write limiter would be exhausted by ~100 gateways behind
  one NAT address, and their entries would then go stale.
- The endpoint returns the recorded state, documents its fields, and quotes
  identifiers in its errors.
- The fallback keeps the suspect filter. It was scoped inside the try, so
  any failure in the load path re-admitted the member that just failed.
- Channels opened since a gateway's last report count on top of it, instead
  of being invisible until the next one lands.
- Drop chooseLeastLoadedGateway: the atomic claim supersedes it, and its
  tests were pinning behaviour production no longer runs.

The failover suite's mocked tracker had no claimLeastLoaded, so it threw
inside the try and only ever measured the random fallback. One test was
passing for the wrong reason.
The page stated that selection was random and that there was no
round-robin or weighted selection, which is now the opposite of what
happens. Also documents that only failures occurring before the platform
reaches the resource are retried, and that a pool containing a gateway too
old to report its load falls back to random selection.
Hash tag the per-gateway keys so the claim script does not hit CROSSSLOT on
Redis cluster, set the reservation TTL only on the first claim so a dead pod's
reservations actually expire, guard the Lua script against a short occupancy
list, and release published load on shutdown.
setupRelayServer can now open the tunnel during setup and hand it to the first
client instead of dialing lazily, so an unreachable gateway fails where another
member can still be tried. The tunnel is reused rather than probed and dropped,
so the gateway sees one channel that becomes the session.
The rebase merged getPlatformConnectionDetailsByPoolId out of the service while
leaving its caller in place. Restored, now routing through the load-aware
selection instead of a random pick.
@bernie-g
bernie-g force-pushed the bernie/pam-375-add-load-balancing-for-gateway-pools branch from 70850ba to 4562505 Compare August 20, 2026 20:10
This call site enumerated the connection details by hand, so it stopped
satisfying the type once gatewayId was added to it. Spreading matches the other
withGatewayV2Proxy callers and survives the next field.
The harness carries an ephemeral port in relayHost, which the implementation
forwards as the TLS servername, and Node 22.23 rejects a servername containing a
colon instead of matching it against a SAN. Verification now runs against the
connected host, which the certificate already covers.
A gateway too old to report can only be scored on the connections the platform
itself opened, which misses whatever else it is carrying. Rather than trust that
number on its own, any pool with a non-reporting member now picks at random.

isPoolComparable becomes everyMemberReportsLoad: the old name read as a claim
about the gateways rather than about where the numbers came from.
Scoring an unreported gateway from the platform's own view was the only reason
the per-pod Redis hash existed, so the publish path, its debounce, refresh timer
and shutdown cleanup all go with it, along with hashGetAll/hashDelete.

Also drops TGatewayScore.score, which nothing read: the claim script fetches the
reservation keys itself, so folding them in here meant reading them twice per
selection. Its tests asserted on that field, so they now observe reservations
through the claim script, which is the path that actually uses them.
Most explained mechanism the code already shows, or restated a point made a few
lines above. Kept the non-obvious reasons in a clause each.
Named after the decision it gates rather than the criterion it checks, so the
branch reads as least-busy versus random at the call site.
Comment thread backend/src/server/routes/index.ts Outdated
Comment thread backend/src/server/routes/index.ts
shutdown no longer writes to Redis, so it does not drop published counts.
@bernie-g
bernie-g merged commit 156cb9b into main Aug 27, 2026
17 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.

3 participants