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