* 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 | } from "@executor-js/sdk/shared"; | 17 | } from "@executor-js/sdk/shared"; |
| 18 | import * as Atom from "effect/unstable/reactivity/Atom"; | 18 | import * as Atom from "effect/unstable/reactivity/Atom"; |
| 19 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 19 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 20 | import * as Reactivity from "effect/unstable/reactivity/Reactivity"; | ||
| 20 | import * as Effect from "effect/Effect"; | 21 | import * as Effect from "effect/Effect"; |
| 21 | 22 | ||
| 22 | import { ExecutorApiClient } from "./client"; | 23 | import { 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). */ |
| 199 | export const checkConnectionHealth = ExecutorApiClient.mutation("connections", "checkHealth"); | 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 | /** Validate an IN-FLIGHT credential without saving it (the key-first connect | 233 | /** 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. */ |
| 203 | export const validateConnection = ExecutorApiClient.mutation("connections", "validate"); | 235 | export const validateConnection = ExecutorApiClient.mutation("connections", "validate"); |
| @@ -7,11 +7,17 @@ · 6 unmodified lines | |||
| 7 | // drift apart. | 7 | // drift apart. |
| 8 | 8 | ||
| 9 | import { useCallback, useContext, useEffect, useRef, useState } from "react"; | 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 | import * as Exit from "effect/Exit"; | 12 | import * as Exit from "effect/Exit"; |
| 13 | import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; | ||
| 12 | import type { Connection, HealthCheckResult, HealthStatus, Owner } from "@executor-js/sdk/shared"; | 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 | import { connectionCheckKeys } from "../api/reactivity-keys"; | 21 | import { connectionCheckKeys } from "../api/reactivity-keys"; |
| 16 | 22 | ||
| 17 | /** Freshness window for automatic revalidation: a HEALTHY verdict younger | 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 | * Health for ONE connection, stale-while-revalidate. The persisted verdict | 133 | * Health for ONE connection, stale-while-revalidate. The persisted verdict |
| 102 | * renders instantly; a background probe on mount corrects it in place (once | 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 | // A live probe result, once a check has run; merged with the persisted | 144 | // 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 so | 192 | // Manual "Check now": invalidate the connections cache unconditionally so |
| 161 | // every surface picks up the freshly persisted verdict. Adopting the | 193 | // 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-connection | 228 | // 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) => { |