History

Probe connection health through a per-connection atom (#2071)

* e2e: reproduce health-probe churn on the integrations list Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Probe connection health through a per-connection atom Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * e2e: bound the churn scenario to its own connections Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> ---------

Rhys Sullivan committed 1 month agomain

Commit 09c19e41 parent 1aa6fd93 files · +368 −7Browse files

Showing the changes to packages (2 of 3 files). Show all

Unified · Split

2 files
  1. packages/react/src/api/atoms.tsx +32 −0
  2. packages/react/src/lib/use-connection-health.ts +39 −7
packages/react/src/api/atoms.tsx+32−0
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
1717 } from "@executor-js/sdk/shared";
1818 import * as Atom from "effect/unstable/reactivity/Atom";
1919 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
20+import * as Reactivity from "effect/unstable/reactivity/Reactivity";
2021 import * as Effect from "effect/Effect";
2122
2223 import { ExecutorApiClient } from "./client";
@@ -198,6 +199,37 @@ export const refreshConnection = ExecutorApiClient.mutation("connections", "refr · 175 unmodified lines
198199 * cache on every load). */
199200 export const checkConnectionHealth = ExecutorApiClient.mutation("connections", "checkHealth");
200201
202+export interface CheckConnectionHealthArgs {
203+ readonly params: {
204+ readonly owner: Owner;
205+ readonly integration: IntegrationSlug;
206+ readonly name: ConnectionName;
207+ };
208+ readonly query: { readonly ifStaleMs?: number };
209+ readonly reactivityKeys?: ReadonlyArray<unknown>;
210+}
211+
212+/** The AUTOMATIC health probe, one atom PER CONNECTION.
213+ *
214+ * `checkConnectionHealth` above is one shared mutation atom. Awaiting it
215+ * (`useAtomSet(..., { mode: "promiseExit" })`) resolves with the atom's next
216+ * settled result, whichever call produced it, and a new call interrupts the
217+ * one in flight. A surface that probes every row of a list in one pass
218+ * therefore cancels all but the last probe and hands every row the LAST
219+ * row's verdict. Each row then reads a foreign verdict as a change to its own
220+ * connection, refreshes the connections cache, and re-probes: the probe storm
221+ * the automatic path was built to avoid. Keying the atom by connection address
222+ * gives every probe its own fiber and its own result. */
223+export const checkConnectionHealthFor = Atom.family((address: ConnectionAddress) =>
224+ ExecutorApiClient.runtime.fn<CheckConnectionHealthArgs>()((args) => {
225+ const probe = Effect.gen(function* () {
226+ const client = yield* ExecutorApiClient;
227+ return yield* client.connections.checkHealth({ params: args.params, query: args.query });
228+ }).pipe(Effect.withSpan("connection.health.probe", { attributes: { address } }));
229+ return args.reactivityKeys ? Reactivity.mutation(probe, args.reactivityKeys) : probe;
230+ }),
231+);
232+
201233 /** Validate an IN-FLIGHT credential without saving it (the key-first connect
202234 * flow). Returns the probe result the UI derives a connection name from. */
203235 export const validateConnection = ExecutorApiClient.mutation("connections", "validate");
packages/react/src/lib/use-connection-health.ts+39−7
@@ -7,11 +7,17 @@ · 6 unmodified lines
77 // drift apart.
88
99 import { useCallback, useContext, useEffect, useRef, useState } from "react";
10−import { RegistryContext, useAtomSet } from "@effect/atom-react";
10+import { RegistryContext } from "@effect/atom-react";
11+import * as Effect from "effect/Effect";
1112 import * as Exit from "effect/Exit";
13+import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry";
1214 import type { Connection, HealthCheckResult, HealthStatus, Owner } from "@executor-js/sdk/shared";
1315
14−import { checkConnectionHealth, connectionsOptimisticAtom } from "../api/atoms";
16+import {
17+ checkConnectionHealthFor,
18+ connectionsOptimisticAtom,
19+ type CheckConnectionHealthArgs,
20+} from "../api/atoms";
1521 import { connectionCheckKeys } from "../api/reactivity-keys";
1622
1723 /** Freshness window for automatic revalidation: a HEALTHY verdict younger
@@ -97,6 +103,32 @@ function useInvalidateConnections(): (owner: Owner) => void { · 79 unmodified lines
97103 );
98104 }
99105
106+/**
107+ * Run one probe against its OWN per-connection atom and await that atom's
108+ * result. The shared `checkConnectionHealth` mutation cannot be awaited from a
109+ * loop: every `set` interrupts the previous call and every waiter resolves
110+ * with whichever call settled last, so a list of N rows would hand N-1 rows a
111+ * verdict for a connection that is not theirs (see `checkConnectionHealthFor`).
112+ * This is the same set-then-await that `useAtomSet` performs in promise mode,
113+ * addressed at the connection's atom, and usable from a loop.
114+ */
115+function useProbeConnection(): (
116+ connection: Connection,
117+ args: CheckConnectionHealthArgs,
118+) => Promise<Exit.Exit<HealthCheckResult, unknown>> {
119+ const registry = useContext(RegistryContext);
120+ return useCallback(
121+ (connection: Connection, args: CheckConnectionHealthArgs) => {
122+ const atom = checkConnectionHealthFor(connection.address);
123+ registry.set(atom, args);
124+ return Effect.runPromiseExit(
125+ AtomRegistry.getResult(registry, atom, { suspendOnWaiting: true }),
126+ );
127+ },
128+ [registry],
129+ );
130+}
131+
100132 /**
101133 * Health for ONE connection, stale-while-revalidate. The persisted verdict
102134 * renders instantly; a background probe on mount corrects it in place (once
@@ -112,7 +144,7 @@ export function useConnectionHealth(connection: Connection): { · 9 unmodified lines
112144 // A live probe result, once a check has run; merged with the persisted
113145 // verdict by freshness (see freshestVerdict for why not live-always-wins).
114146 const [liveProbe, setLiveProbe] = useState<HealthCheckResult | null>(null);
115− const doCheck = useAtomSet(checkConnectionHealth, { mode: "promiseExit" });
147+ const doCheck = useProbeConnection();
116148 const invalidateConnections = useInvalidateConnections();
117149
118150 const probe = freshestVerdict(liveProbe, connection.lastHealth);
@@ -137,7 +169,7 @@ export function useConnectionHealth(connection: Connection): { · 18 unmodified lines
137169 seenEpoch.current = epoch;
138170 if (!firstSight && !cleared) return;
139171 if (healthyAndFresh(last)) return;
140− void doCheck({
172+ void doCheck(connection, {
141173 params: connectionParams(connection),
142174 query: revalidateQuery(last),
143175 }).then((exit) => {
@@ -160,7 +192,7 @@ export function useConnectionHealth(connection: Connection): { · 16 unmodified lines
160192 // Manual "Check now": invalidate the connections cache unconditionally so
161193 // every surface picks up the freshly persisted verdict. Adopting the
162194 // result's epoch keeps the resulting refetch from re-probing.
163− const exit = await doCheck({
195+ const exit = await doCheck(connection, {
164196 params: connectionParams(connection),
165197 query: {},
166198 reactivityKeys: connectionCheckKeys,
@@ -190,7 +222,7 @@ export function useConnectionsHealth( · 23 unmodified lines
190222 connections: readonly Connection[],
191223 ): (connection: Connection) => HealthCheckResult | null {
192224 const [liveProbes, setLiveProbes] = useState<ReadonlyMap<string, HealthCheckResult>>(new Map());
193− const doCheck = useAtomSet(checkConnectionHealth, { mode: "promiseExit" });
225+ const doCheck = useProbeConnection();
194226 const invalidateConnections = useInvalidateConnections();
195227
196228 // Once per VERDICT per connection (same epoch guard as the single-connection
@@ -206,7 +238,7 @@ export function useConnectionsHealth( · 9 unmodified lines
206238 if (revalidated.current.has(key) && revalidated.current.get(key) === epoch) continue;
207239 revalidated.current.set(key, epoch);
208240 if (healthyAndFresh(last)) continue;
209− void doCheck({
241+ void doCheck(connection, {
210242 params: connectionParams(connection),
211243 query: revalidateQuery(last),
212244 }).then((exit) => {

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t