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
17} from "@executor-js/sdk/shared";17} from "@executor-js/sdk/shared";
18import * as Atom from "effect/unstable/reactivity/Atom";18import * as Atom from "effect/unstable/reactivity/Atom";
19import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";19import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
20import * as Reactivity from "effect/unstable/reactivity/Reactivity";
20import * as Effect from "effect/Effect";21import * as Effect from "effect/Effect";
21 22
22import { ExecutorApiClient } from "./client";23import { ExecutorApiClient } from "./client";
@@ -198,6 +199,37 @@ export const refreshConnection = ExecutorApiClient.mutation("connections", "refr · 175 unmodified lines
198 * cache on every load). */199 * cache on every load). */
199export const checkConnectionHealth = ExecutorApiClient.mutation("connections", "checkHealth");200export const checkConnectionHealth = ExecutorApiClient.mutation("connections", "checkHealth");
200 201
202export 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. */
223export 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
201/** Validate an IN-FLIGHT credential without saving it (the key-first connect233/** Validate an IN-FLIGHT credential without saving it (the key-first connect
202 * flow). Returns the probe result the UI derives a connection name from. */234 * flow). Returns the probe result the UI derives a connection name from. */
203export const validateConnection = ExecutorApiClient.mutation("connections", "validate");235export const validateConnection = ExecutorApiClient.mutation("connections", "validate");
packages/react/src/lib/use-connection-health.ts+39−7
@@ -7,11 +7,17 @@ · 6 unmodified lines
7// drift apart.7// drift apart.
8 8
9import { useCallback, useContext, useEffect, useRef, useState } from "react";9import { useCallback, useContext, useEffect, useRef, useState } from "react";
10import { RegistryContext, useAtomSet } from "@effect/atom-react";10import { RegistryContext } from "@effect/atom-react";
11import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";12import * as Exit from "effect/Exit";
13import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry";
12import type { Connection, HealthCheckResult, HealthStatus, Owner } from "@executor-js/sdk/shared";14import type { Connection, HealthCheckResult, HealthStatus, Owner } from "@executor-js/sdk/shared";
13 15
14import { checkConnectionHealth, connectionsOptimisticAtom } from "../api/atoms";16import {
17 checkConnectionHealthFor,
18 connectionsOptimisticAtom,
19 type CheckConnectionHealthArgs,
20} from "../api/atoms";
15import { connectionCheckKeys } from "../api/reactivity-keys";21import { connectionCheckKeys } from "../api/reactivity-keys";
16 22
17/** Freshness window for automatic revalidation: a HEALTHY verdict younger23/** Freshness window for automatic revalidation: a HEALTHY verdict younger
@@ -97,6 +103,32 @@ function useInvalidateConnections(): (owner: Owner) => void { · 79 unmodified lines
97 );103 );
98}104}
99 105
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 */
115function 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
100/**132/**
101 * Health for ONE connection, stale-while-revalidate. The persisted verdict133 * Health for ONE connection, stale-while-revalidate. The persisted verdict
102 * renders instantly; a background probe on mount corrects it in place (once134 * renders instantly; a background probe on mount corrects it in place (once
@@ -112,7 +144,7 @@ export function useConnectionHealth(connection: Connection): { · 9 unmodified lines
112 // A live probe result, once a check has run; merged with the persisted144 // A live probe result, once a check has run; merged with the persisted
113 // verdict by freshness (see freshestVerdict for why not live-always-wins).145 // verdict by freshness (see freshestVerdict for why not live-always-wins).
114 const [liveProbe, setLiveProbe] = useState<HealthCheckResult | null>(null);146 const [liveProbe, setLiveProbe] = useState<HealthCheckResult | null>(null);
115 const doCheck = useAtomSet(checkConnectionHealth, { mode: "promiseExit" });147 const doCheck = useProbeConnection();
116 const invalidateConnections = useInvalidateConnections();148 const invalidateConnections = useInvalidateConnections();
117 149
118 const probe = freshestVerdict(liveProbe, connection.lastHealth);150 const probe = freshestVerdict(liveProbe, connection.lastHealth);
@@ -137,7 +169,7 @@ export function useConnectionHealth(connection: Connection): { · 18 unmodified lines
137 seenEpoch.current = epoch;169 seenEpoch.current = epoch;
138 if (!firstSight && !cleared) return;170 if (!firstSight && !cleared) return;
139 if (healthyAndFresh(last)) return;171 if (healthyAndFresh(last)) return;
140 void doCheck({172 void doCheck(connection, {
141 params: connectionParams(connection),173 params: connectionParams(connection),
142 query: revalidateQuery(last),174 query: revalidateQuery(last),
143 }).then((exit) => {175 }).then((exit) => {
@@ -160,7 +192,7 @@ export function useConnectionHealth(connection: Connection): { · 16 unmodified lines
160 // Manual "Check now": invalidate the connections cache unconditionally so192 // Manual "Check now": invalidate the connections cache unconditionally so
161 // every surface picks up the freshly persisted verdict. Adopting the193 // every surface picks up the freshly persisted verdict. Adopting the
162 // result's epoch keeps the resulting refetch from re-probing.194 // result's epoch keeps the resulting refetch from re-probing.
163 const exit = await doCheck({195 const exit = await doCheck(connection, {
164 params: connectionParams(connection),196 params: connectionParams(connection),
165 query: {},197 query: {},
166 reactivityKeys: connectionCheckKeys,198 reactivityKeys: connectionCheckKeys,
@@ -190,7 +222,7 @@ export function useConnectionsHealth( · 23 unmodified lines
190 connections: readonly Connection[],222 connections: readonly Connection[],
191): (connection: Connection) => HealthCheckResult | null {223): (connection: Connection) => HealthCheckResult | null {
192 const [liveProbes, setLiveProbes] = useState<ReadonlyMap<string, HealthCheckResult>>(new Map());224 const [liveProbes, setLiveProbes] = useState<ReadonlyMap<string, HealthCheckResult>>(new Map());
193 const doCheck = useAtomSet(checkConnectionHealth, { mode: "promiseExit" });225 const doCheck = useProbeConnection();
194 const invalidateConnections = useInvalidateConnections();226 const invalidateConnections = useInvalidateConnections();
195 227
196 // Once per VERDICT per connection (same epoch guard as the single-connection228 // Once per VERDICT per connection (same epoch guard as the single-connection
@@ -206,7 +238,7 @@ export function useConnectionsHealth( · 9 unmodified lines
206 if (revalidated.current.has(key) && revalidated.current.get(key) === epoch) continue;238 if (revalidated.current.has(key) && revalidated.current.get(key) === epoch) continue;
207 revalidated.current.set(key, epoch);239 revalidated.current.set(key, epoch);
208 if (healthyAndFresh(last)) continue;240 if (healthyAndFresh(last)) continue;
209 void doCheck({241 void doCheck(connection, {
210 params: connectionParams(connection),242 params: connectionParams(connection),
211 query: revalidateQuery(last),243 query: revalidateQuery(last),
212 }).then((exit) => {244 }).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