import { json } from "@remix-run/server-runtime"; import { safeParseNaturalLanguageDurationAgo } from "@trigger.dev/core/v3/isomorphic"; import { randomUUID } from "node:crypto"; import type { API_VERSIONS } from "~/api/versions"; import { CURRENT_API_VERSION } from "~/api/versions"; import { type CachedLimitProvider, type RealtimeEnvironment, type RealtimeRequestOptions, type RealtimeRunsParams, } from "../realtimeClient.server"; import { logger } from "../logger.server"; import { buildElectricSchemaHeader, buildRowsBody, buildRowsBodyFromSerialized, buildSnapshotBody, buildUpdateBody, buildUpToDateBody, encodeOffset, INITIAL_OFFSET, parseOffsetUpdatedAtMs, type RealtimeRunRow, rewriteBodyForLegacyApiVersion, RESERVED_COLUMNS, type RowChange, type SerializedRowChange, } from "./electricStreamProtocol.server"; import { BoundedTtlCache } from "./boundedTtlCache"; import { type EnvChangeRouter, type FeedFilter, type MatchedRow } from "./envChangeRouter.server"; import { type RunHydrator, type RunListResolver } from "./runReader.server"; import { type RealtimeConcurrencyLimiter } from "./realtimeConcurrencyLimiter.server"; import { InMemoryReplayCursorStore, type ReplayCursorStore } from "./replayCursorStore.server"; /** Widened with projectId so the tag-list feed can resolve ids via ClickHouse (needs org + project + env). */ export type RealtimeListEnvironment = RealtimeEnvironment & { projectId: string }; /** The realtime feeds the run routes depend on (single-run, tag-list, batch); both backends satisfy it. */ export interface RealtimeStreamClient { streamRun( url: URL | string, environment: RealtimeEnvironment, runId: string, apiVersion: API_VERSIONS, requestOptions?: RealtimeRequestOptions, clientVersion?: string, signal?: AbortSignal ): Promise; streamRuns( url: URL | string, environment: RealtimeListEnvironment, params: RealtimeRunsParams, apiVersion: API_VERSIONS, requestOptions?: RealtimeRequestOptions, clientVersion?: string, signal?: AbortSignal ): Promise; streamBatch( url: URL | string, environment: RealtimeListEnvironment, batchId: string, apiVersion: API_VERSIONS, requestOptions?: RealtimeRequestOptions, clientVersion?: string, signal?: AbortSignal ): Promise; } export type WakeupReason = "notify" | "timeout" | "abort"; /** How a live poll resolved: `fast-hydrate` (router woke us, hydrate-by-id), `full-resolve` * (backstop), or `cold-resolve` (fresh env subscription probed once instead of holding blind). */ export type LivePollPath = "fast-hydrate" | "full-resolve" | "cold-resolve"; export type NativeRealtimeClientOptions = { runReader: RunHydrator; /** Resolves the tag/list filter into the matching id-set (filter-only). */ runListResolver: RunListResolver; /** Per-instance routing layer over the single env change channel. */ router: EnvChangeRouter; limiter: RealtimeConcurrencyLimiter; cachedLimitProvider: CachedLimitProvider; /** Fallback per-env concurrent-connection limit when the org has none cached. */ defaultConcurrencyLimit?: number; /** Backstop wait before refetching on a live request (ms). Defaults to 20000. */ livePollTimeoutMs?: number; /** Jitter ratio applied to the live-poll timeout (0.15 = ±15%). */ livePollJitterRatio?: number; /** Ceiling for the tag-list createdAt lookback window (ms). */ maximumCreatedAtFilterAgeMs: number; /** Hard cap on tag-list snapshot size. Defaults to 1000. */ maxListResults?: number; /** TTL (ms) for the multi-run resolve+hydrate coalescing cache (initial + backstop). */ runSetResolveCacheTtlMs?: number; /** Max entries in the resolve+hydrate cache. Defaults to 5000. */ runSetResolveCacheMaxEntries?: number; /** Max entries in the per-handle working-set cache. Defaults to 10000. */ listCacheMaxEntries?: number; /** TTL (ms) for working-set cache entries. Defaults to 300000. */ workingSetCacheTtlMs?: number; /** Epoch-aligned bucket (ms) the tag-list createdAt floor is floored to, so same-tag feeds share a cache entry. Defaults to 60000; 0 disables. */ runSetCreatedAtBucketMs?: number; /** When true (default), a multi-run live poll holds until a real delta or the backstop rather than returning an empty up-to-date. */ holdOnEmpty?: boolean; /** Max concurrent fresh ClickHouse resolves (cache misses) per instance, bounding a distinct-filter stampede. Defaults to 16; 0 disables. */ resolveAdmissionLimit?: number; /** Per-connection replay-cursor store. Inject a fleet-shared (Redis) store so an instance * hop reads the connection's true inter-poll gap instead of cold-probing; defaults to a * per-instance in-memory cache. */ replayCursorStore?: ReplayCursorStore; /** Observability hook: why a live request woke (notify vs timeout vs abort). */ onWakeup?: (reason: WakeupReason) => void; /** Observability hook: how a live poll resolved (fast path vs full resolve). */ onLivePollPath?: (path: LivePollPath) => void; /** Observability hook: whether a multi-run resolve hit the cache, coalesced onto an in-flight resolve, or missed. */ onRunSetResolve?: (result: "hit" | "miss" | "coalesced") => void; /** Observability hook: latency (ms) of the ClickHouse resolve / Postgres hydrate. */ onRunSetQuery?: (stage: "resolve" | "hydrate", ms: number) => void; /** Observability hook: a fresh resolve waited `ms` for an admission permit (only when the gate engaged). */ onResolveAdmissionWait?: (ms: number) => void; /** Observability hook: a live emission left the server — lag is now minus the newest * emitted row's updatedAt (the end-to-end delivery SLI), rowCount the delta size. */ onEmit?: (path: LivePollPath, lagMs: number, rowCount: number) => void; /** Observability hook: a backstop resolve found missed changes (delivered) or nothing * (empty). Sustained `delivered` means the notify/replay path is leaking. */ onBackstopResult?: (result: "delivered" | "empty") => void; /** Observability hook: a poll was rejected by the per-env concurrency limiter (429). */ onConcurrencyRejected?: () => void; }; const DEFAULT_CONCURRENCY_LIMIT = 100_000; // Matches Electric's ~20s live long-poll hold (jittered ±15% per request). const DEFAULT_LIVE_POLL_TIMEOUT_MS = 20_000; const DEFAULT_LIVE_POLL_JITTER_RATIO = 0.15; const DEFAULT_MAX_LIST_RESULTS = 1_000; const LIST_CACHE_TTL_MS = 5 * 60_000; const LIST_CACHE_MAX_ENTRIES = 10_000; const DEFAULT_RUNSET_CACHE_TTL_MS = 1_000; const DEFAULT_RUNSET_CACHE_MAX_ENTRIES = 5_000; const DEFAULT_RUNSET_CREATED_AT_BUCKET_MS = 60_000; const DEFAULT_RESOLVE_ADMISSION_LIMIT = 16; /** Fair FIFO semaphore bounding concurrent fresh ClickHouse resolves. Sits behind the single-flight + TTL cache, so only genuine cache-miss resolves take a permit. */ class ResolveAdmissionGate { #available: number; #inUse = 0; readonly #waiters: Array<() => void> = []; constructor(limit: number) { this.#available = limit; } /** Permits currently held (for a metrics gauge); never exceeds the limit. */ get inUse(): number { return this.#inUse; } async acquire(): Promise { if (this.#available > 0) { this.#available--; this.#inUse++; return; } await new Promise((resolve) => this.#waiters.push(resolve)); this.#inUse++; } release(): void { this.#inUse--; const next = this.#waiters.shift(); if (next) { next(); // hand the freed permit straight to the next waiter (FIFO, no count churn) } else { this.#available++; } } } /** A multi-run feed's filter: tag-list sets `tags` (+ pinned `createdAtAfter`); the batch feed sets `batchId`. */ type RunSetFilter = { tags?: string[]; batchId?: string; createdAtAfter?: Date; }; /** Per-handle working set: runId -> last-emitted updatedAt (ms), so live polls emit only rows that advanced. */ type WorkingSet = Map; type ResponseHeaderInput = { offset: string; handle: string; cursor?: string; schema?: string; }; /** * Native-backend implementation of the realtime run feeds. All three feeds are predicates over ONE * per-environment change stream (the EnvChangeRouter), which decides membership, hydrates the matched * runs, and serializes their wire values once; this client owns the snapshot, the per-handle working-set * diff, the ClickHouse backstop, and the wire response (opaque `offset`/`handle`/`cursor` tokens). */ export class NativeRealtimeClient implements RealtimeStreamClient { #seq = 0; readonly #workingSetCache: BoundedTtlCache; /** Coalescing cache for the multi-run resolve+hydrate, keyed by (env, filter, columns), so identical filters share one resolve. */ readonly #runSetCache: BoundedTtlCache; readonly #runSetInflight = new Map>(); /** Bounds concurrent fresh CH resolves (undefined => unbounded). */ readonly #admissionGate?: ResolveAdmissionGate; /** Per-connection: when this connection's last response was sent, so the router's * replay covers exactly the inter-poll gap instead of rewinding a full window. * Fleet-shared when a store is injected (hops stop looking like unknown gaps). */ readonly #replayCursors: ReplayCursorStore; constructor(private readonly options: NativeRealtimeClientOptions) { this.#workingSetCache = new BoundedTtlCache( options.workingSetCacheTtlMs ?? LIST_CACHE_TTL_MS, options.listCacheMaxEntries ?? LIST_CACHE_MAX_ENTRIES ); this.#replayCursors = options.replayCursorStore ?? new InMemoryReplayCursorStore( options.workingSetCacheTtlMs ?? LIST_CACHE_TTL_MS, options.listCacheMaxEntries ?? LIST_CACHE_MAX_ENTRIES ); this.#runSetCache = new BoundedTtlCache( options.runSetResolveCacheTtlMs ?? DEFAULT_RUNSET_CACHE_TTL_MS, options.runSetResolveCacheMaxEntries ?? DEFAULT_RUNSET_CACHE_MAX_ENTRIES ); const admissionLimit = options.resolveAdmissionLimit ?? DEFAULT_RESOLVE_ADMISSION_LIMIT; if (admissionLimit > 0) { this.#admissionGate = new ResolveAdmissionGate(admissionLimit); } } /** Current size of the per-handle working-set cache (for a metrics gauge). */ get workingSetCacheSize(): number { return this.#workingSetCache.size; } /** Fresh CH resolves currently holding an admission permit (for a metrics gauge). */ get resolveAdmissionInUse(): number { return this.#admissionGate?.inUse ?? 0; } async streamRun( url: URL | string, environment: RealtimeEnvironment, runId: string, apiVersion: API_VERSIONS, requestOptions?: RealtimeRequestOptions, clientVersion?: string, signal?: AbortSignal ): Promise { const { offset, handle, isLive, skipColumns } = this.#parseStreamRequest(url, requestOptions); // Initial snapshot — no prior offset/handle. if (offset === INITIAL_OFFSET || !handle) { const row = await this.options.runReader.getRunById(environment.id, runId); return this.#snapshotResponse(runId, row, skipColumns, apiVersion, clientVersion); } if (isLive) { return this.#liveResponse({ environment, runId, offset, handle, skipColumns, apiVersion, clientVersion, signal, }); } // Non-live catch-up with a handle: re-emit the current snapshot (idempotent). const row = await this.options.runReader.getRunById(environment.id, runId); return this.#snapshotResponse(runId, row, skipColumns, apiVersion, clientVersion, handle); } async streamRuns( url: URL | string, environment: RealtimeListEnvironment, params: RealtimeRunsParams, apiVersion: API_VERSIONS, requestOptions?: RealtimeRequestOptions, clientVersion?: string, signal?: AbortSignal ): Promise { const { offset, handle, isLive, skipColumns } = this.#parseStreamRequest(url, requestOptions); const tags = params.tags ?? []; // Initial snapshot — pin the createdAt window in a fresh handle. if (offset === INITIAL_OFFSET || !handle) { const createdAtFilterMs = this.#computeCreatedAtFilter(params.createdAt).getTime(); return this.#runSetSnapshotResponse( environment, { tags, createdAtAfter: new Date(createdAtFilterMs) }, this.#mintListHandle(createdAtFilterMs), skipColumns, apiVersion, clientVersion ); } // Recover the pinned window from the handle so the lower bound never drifts. // Re-clamp the recovered value to the max-age floor so a stale or crafted handle // can't widen the lookback past the configured ceiling. const recoveredMs = this.#filterMsFromHandle(handle); const filter: RunSetFilter = { tags, createdAtAfter: new Date( recoveredMs !== undefined ? this.#clampCreatedAtFloor(recoveredMs) : this.#computeCreatedAtFilter(params.createdAt).getTime() ), }; if (isLive) { return this.#runSetLiveResponse( environment, filter, handle, offset, skipColumns, apiVersion, clientVersion, signal ); } // Non-live catch-up under the same handle. return this.#runSetSnapshotResponse( environment, filter, handle, skipColumns, apiVersion, clientVersion ); } async streamBatch( url: URL | string, environment: RealtimeListEnvironment, batchId: string, apiVersion: API_VERSIONS, requestOptions?: RealtimeRequestOptions, clientVersion?: string, signal?: AbortSignal ): Promise { const { offset, handle, isLive, skipColumns } = this.#parseStreamRequest(url, requestOptions); const filter: RunSetFilter = { batchId }; if (offset !== INITIAL_OFFSET && handle && isLive) { return this.#runSetLiveResponse( environment, filter, handle, offset, skipColumns, apiVersion, clientVersion, signal ); } // Initial snapshot + non-live catch-up. The handle must be per-connection, never // derived from the batchId: working sets are keyed by handle, and a shared handle // lets one subscriber's emit permanently suppress the same row for another. return this.#runSetSnapshotResponse( environment, filter, handle ?? this.#mintBatchHandle(batchId), skipColumns, apiVersion, clientVersion ); } #snapshotResponse( runId: string, row: Awaited>, skipColumns: string[], apiVersion: API_VERSIONS, clientVersion?: string, existingHandle?: string ): Response { const body = buildSnapshotBody(row, skipColumns); const offset = row ? encodeOffset(row.updatedAt.getTime(), this.#nextSeq()) : encodeOffset(0, 0); return this.#buildResponse(body, apiVersion, clientVersion, { offset, handle: existingHandle ?? this.#mintHandle(runId), schema: buildElectricSchemaHeader(skipColumns), }); } /** Live poll for a single-run feed: emit a full-row `update` only when the row advanced past the client's offset, else a bare `up-to-date`. */ async #liveResponse(params: { environment: RealtimeEnvironment; runId: string; offset: string; handle: string; skipColumns: string[]; apiVersion: API_VERSIONS; clientVersion?: string; signal?: AbortSignal; }): Promise { const { environment, runId, offset, handle, skipColumns, apiVersion, clientVersion, signal } = params; return this.#withConcurrencySlot(environment, async () => { const lastSeenMs = parseOffsetUpdatedAtMs(offset); const registration = this.options.router.register( environment.id, { kind: "run", runId }, skipColumns ); const deadline = Date.now() + this.#jitteredTimeout(); try { // Cold start (fresh env subscription, e.g. an instance hop): a change in the // caller's inter-poll gap may have been missed — check the row once, then hold. if (!registration.gapCovered) { this.options.onLivePollPath?.("cold-resolve"); const probed = await this.options.runReader.getRunById(environment.id, runId); if (probed && probed.updatedAt.getTime() > lastSeenMs) { const seq = this.#nextSeq(); this.options.onEmit?.("cold-resolve", Date.now() - probed.updatedAt.getTime(), 1); return this.#buildResponse( buildUpdateBody(probed, skipColumns), apiVersion, clientVersion, { offset: encodeOffset(probed.updatedAt.getTime(), seq), handle, cursor: String(seq), } ); } } while (true) { const remaining = deadline - Date.now(); const { reason, rows } = remaining > 0 ? await registration.waitForMatch(signal, remaining) : { reason: "timeout" as const, rows: [] as MatchedRow[] }; this.options.onWakeup?.(reason); if (reason === "abort") { return this.#buildResponse(buildUpToDateBody(), apiVersion, clientVersion, { offset, handle, cursor: String(this.#nextSeq()), }); } if (reason === "notify" && rows.length > 0) { // The router hydrated + serialized this run; emit it (only on advance). this.options.onLivePollPath?.("fast-hydrate"); const matched = rows[0]; const updatedAtMs = matched.row.updatedAt.getTime(); if (updatedAtMs > lastSeenMs) { const seq = this.#nextSeq(); this.options.onEmit?.("fast-hydrate", Date.now() - updatedAtMs, 1); return this.#buildResponse( buildRowsBodyFromSerialized([ { runId: matched.row.id, value: matched.value, operation: "update" }, ]), apiVersion, clientVersion, { offset: encodeOffset(updatedAtMs, seq), handle, cursor: String(seq) } ); } // Already seen (e.g. a replayed record): keep holding rather than returning an // empty up-to-date the client would immediately re-issue. continue; } // Backstop timeout: re-check the run directly (no ClickHouse for the single-run feed). this.options.onLivePollPath?.("full-resolve"); const row = await this.options.runReader.getRunById(environment.id, runId); const seq = this.#nextSeq(); if (row && row.updatedAt.getTime() > lastSeenMs) { this.options.onBackstopResult?.("delivered"); this.options.onEmit?.("full-resolve", Date.now() - row.updatedAt.getTime(), 1); return this.#buildResponse( buildUpdateBody(row, skipColumns), apiVersion, clientVersion, { offset: encodeOffset(row.updatedAt.getTime(), seq), handle, cursor: String(seq), } ); } this.options.onBackstopResult?.("empty"); return this.#buildResponse(buildUpToDateBody(), apiVersion, clientVersion, { offset, handle, cursor: String(seq), }); } } finally { registration.close(); } }); } /** Initial (and non-live catch-up) snapshot for a multi-run feed: resolve the * id-set, hydrate, emit every row as an `insert`, and seed the working set. */ async #runSetSnapshotResponse( environment: RealtimeListEnvironment, filter: RunSetFilter, handle: string, skipColumns: string[], apiVersion: API_VERSIONS, clientVersion?: string ): Promise { const rows = await this.#resolveAndHydrate(environment, filter, skipColumns); const changes: RowChange[] = rows.map((row) => ({ row, operation: "insert" as const })); // updatedAt comes from the authoritative Postgres hydrate, not ClickHouse. const seen: WorkingSet = new Map(); let maxUpdatedAt = 0; for (const row of rows) { const updatedAtMs = row.updatedAt.getTime(); seen.set(row.id, updatedAtMs); maxUpdatedAt = Math.max(maxUpdatedAt, updatedAtMs); } this.#workingSetCache.set(this.#workingSetKey(environment.id, handle), seen); this.#replayCursors.set(this.#workingSetKey(environment.id, handle), Date.now()); return this.#buildResponse(buildRowsBody(changes, skipColumns), apiVersion, clientVersion, { offset: encodeOffset(maxUpdatedAt, this.#nextSeq()), handle, schema: buildElectricSchemaHeader(skipColumns), }); } /** Live poll for a multi-run feed: fast path diffs router-notified rows against the working set; the timeout backstop does a full ClickHouse resolve. */ async #runSetLiveResponse( environment: RealtimeListEnvironment, filter: RunSetFilter, handle: string, offset: string, skipColumns: string[], apiVersion: API_VERSIONS, clientVersion: string | undefined, signal: AbortSignal | undefined ): Promise { return this.#withConcurrencySlot(environment, async () => { const offsetFloorMs = parseOffsetUpdatedAtMs(offset); // Total time to hold this long-poll, jittered to avoid synchronized refetch herds. const deadline = Date.now() + this.#jitteredTimeout(); const holdOnEmpty = this.options.holdOnEmpty ?? true; // Working set we diff against: seeded from the cache (or the offset floor on a // miss) and advanced on each refetch within this held request. const workingSetKey = this.#workingSetKey(environment.id, handle); let prevSeen = this.#workingSetCache.get(workingSetKey); const markPollEnd = () => this.#replayCursors.set(workingSetKey, Date.now()); const emitFromSerialized = ( changes: SerializedRowChange[], maxUpdatedAt: number ): Response => { const seq = this.#nextSeq(); markPollEnd(); return this.#buildResponse( buildRowsBodyFromSerialized(changes), apiVersion, clientVersion, { offset: encodeOffset(maxUpdatedAt, seq), handle, cursor: String(seq), } ); }; const emitFromRows = (changes: RowChange[], maxUpdatedAt: number): Response => { const seq = this.#nextSeq(); markPollEnd(); return this.#buildResponse(buildRowsBody(changes, skipColumns), apiVersion, clientVersion, { offset: encodeOffset(maxUpdatedAt, seq), handle, cursor: String(seq), }); }; const emitUpToDate = (maxUpdatedAt: number): Response => { const seq = this.#nextSeq(); markPollEnd(); return this.#buildResponse(buildUpToDateBody(), apiVersion, clientVersion, { offset: encodeOffset(maxUpdatedAt, seq), handle, cursor: String(seq), }); }; // When this connection last received data, so replay covers exactly its gap. A store // error degrades to undefined (cold probe), never a failed poll. const replaySinceMs = await this.#replayCursors.get(workingSetKey); const registration = this.options.router.register( environment.id, this.#feedFilter(filter), skipColumns, { replaySinceMs } ); // Cold start (fresh env subscription, e.g. an instance hop): resolve once up front // instead of holding blind — a change in the caller's inter-poll gap may have been missed. let coldProbe = !registration.gapCovered; try { while (true) { if (coldProbe) { coldProbe = false; this.options.onLivePollPath?.("cold-resolve"); const resolved = await this.#resolveAndHydrate(environment, filter, skipColumns); const { changes, maxUpdatedAt, touched } = this.#diffRows( resolved, prevSeen, offsetFloorMs ); this.#workingSetCache.set(workingSetKey, touched); prevSeen = touched; if (changes.length > 0) { this.options.onEmit?.("cold-resolve", Date.now() - maxUpdatedAt, changes.length); return emitFromRows(changes, maxUpdatedAt); } continue; // nothing was missed — hold as usual } const remaining = deadline - Date.now(); const { reason, rows } = remaining > 0 ? await registration.waitForMatch(signal, remaining) : { reason: "timeout" as const, rows: [] as MatchedRow[] }; this.options.onWakeup?.(reason); if (reason === "abort") { return emitUpToDate(offsetFloorMs); } // FAST PATH: the router already confirmed membership + the createdAt window and // hydrated/serialized the matched runs. Just diff against the working set. if (reason === "notify") { this.options.onLivePollPath?.("fast-hydrate"); const { changes, maxUpdatedAt, touched } = this.#diffMatched( rows, prevSeen, offsetFloorMs ); // Merge (not replace): the router only surfaced the changed subset, so keep the // rest of the working set intact. The backstop full-resolve rebuilds it. const merged = this.#mergeWorkingSet(prevSeen, touched); this.#workingSetCache.set(workingSetKey, merged); prevSeen = merged; if (changes.length > 0) { this.options.onEmit?.("fast-hydrate", Date.now() - maxUpdatedAt, changes.length); return emitFromSerialized(changes, maxUpdatedAt); } // Matched but no row advanced (already seen). Keep holding. if (holdOnEmpty) { continue; } return emitUpToDate(maxUpdatedAt); } // BACKSTOP: full ClickHouse resolve + hydrate. Replaces the working set so runs // that left the filter stop being tracked (the client keeps showing them). this.options.onLivePollPath?.("full-resolve"); const resolved = await this.#resolveAndHydrate(environment, filter, skipColumns); const { changes, maxUpdatedAt, touched } = this.#diffRows( resolved, prevSeen, offsetFloorMs ); this.#workingSetCache.set(workingSetKey, touched); prevSeen = touched; if (changes.length > 0) { this.options.onBackstopResult?.("delivered"); this.options.onEmit?.("full-resolve", Date.now() - maxUpdatedAt, changes.length); return emitFromRows(changes, maxUpdatedAt); } // Empty backstop diff: timeout returns up-to-date; (holdOnEmpty never reaches // here on a notify — those are handled in the fast path above). this.options.onBackstopResult?.("empty"); return emitUpToDate(maxUpdatedAt); } } finally { registration.close(); } }); } /** Translate a multi-run filter into the router's membership predicate. */ #feedFilter(filter: RunSetFilter): FeedFilter { if (filter.batchId !== undefined) { return { kind: "batch", batchId: filter.batchId }; } return { kind: "tag", tags: filter.tags ?? [], createdAtFloorMs: filter.createdAtAfter?.getTime(), }; } /** Diff router-matched rows (already serialized) against the prior working set, pairing * each row's shared `value` with this feed's operation. */ #diffMatched( matched: MatchedRow[], prevSeen: WorkingSet | undefined, offsetFloorMs: number ): { changes: SerializedRowChange[]; maxUpdatedAt: number; touched: WorkingSet } { const changes: SerializedRowChange[] = []; const touched: WorkingSet = new Map(); let maxUpdatedAt = offsetFloorMs; for (const { row, value } of matched) { const updatedAtMs = row.updatedAt.getTime(); touched.set(row.id, updatedAtMs); maxUpdatedAt = Math.max(maxUpdatedAt, updatedAtMs); if (prevSeen) { const prior = prevSeen.get(row.id); if (prior === undefined) { changes.push({ runId: row.id, value, operation: "insert" }); } else if (updatedAtMs > prior) { changes.push({ runId: row.id, value, operation: "update" }); } } else if (updatedAtMs > offsetFloorMs) { changes.push({ runId: row.id, value, operation: "update" }); } } return { changes, maxUpdatedAt, touched }; } /** Diff hydrated rows against the prior working set on Postgres `updatedAt`: not-in-set is `insert`, advanced is `update`. */ #diffRows( rows: RealtimeRunRow[], prevSeen: WorkingSet | undefined, offsetFloorMs: number ): { changes: RowChange[]; maxUpdatedAt: number; touched: WorkingSet } { const changes: RowChange[] = []; const touched: WorkingSet = new Map(); let maxUpdatedAt = offsetFloorMs; for (const row of rows) { const updatedAtMs = row.updatedAt.getTime(); touched.set(row.id, updatedAtMs); maxUpdatedAt = Math.max(maxUpdatedAt, updatedAtMs); if (prevSeen) { const prior = prevSeen.get(row.id); if (prior === undefined) { changes.push({ row, operation: "insert" }); } else if (updatedAtMs > prior) { changes.push({ row, operation: "update" }); } } else if (updatedAtMs > offsetFloorMs) { changes.push({ row, operation: "update" }); } } return { changes, maxUpdatedAt, touched }; } /** Merge fast-path touched rows into the prior working set. The fast path only saw the * changed subset, so we keep the rest (the backstop full-resolve does the exact rebuild). */ #mergeWorkingSet(prevSeen: WorkingSet | undefined, touched: WorkingSet): WorkingSet { const merged: WorkingSet = new Map(prevSeen ?? undefined); for (const [id, updatedAtMs] of touched) { merged.set(id, updatedAtMs); } return merged; } /** Resolve the filter's id-set (ClickHouse) and hydrate (Postgres), coalesced + short-TTL cached so identical filters share one resolve+hydrate. */ async #resolveAndHydrate( environment: RealtimeListEnvironment, filter: RunSetFilter, skipColumns: string[] ): Promise { const key = this.#runSetCacheKey(environment.id, filter, skipColumns); const cached = this.#runSetCache.get(key); if (cached) { this.options.onRunSetResolve?.("hit"); return cached; } const existing = this.#runSetInflight.get(key); if (existing) { this.options.onRunSetResolve?.("coalesced"); return existing; } this.options.onRunSetResolve?.("miss"); // Registered in #runSetInflight synchronously below, so same-filter callers that arrive // while this is still waiting for an admission permit coalesce onto it (one permit, not N). const promise = this.#admitAndResolveUncached(environment, filter, skipColumns) .then((rows) => { this.#runSetCache.set(key, rows); return rows; }) .finally(() => { this.#runSetInflight.delete(key); }); this.#runSetInflight.set(key, promise); return promise; } /** Acquire an admission permit (if the gate is enabled) before the fresh CH+PG resolve, so * a distinct-filter stampede is throttled to the configured concurrency. */ async #admitAndResolveUncached( environment: RealtimeListEnvironment, filter: RunSetFilter, skipColumns: string[] ): Promise { if (!this.#admissionGate) { return this.#resolveAndHydrateUncached(environment, filter, skipColumns); } const waitStart = Date.now(); await this.#admissionGate.acquire(); const waited = Date.now() - waitStart; if (waited > 0) { this.options.onResolveAdmissionWait?.(waited); } try { return await this.#resolveAndHydrateUncached(environment, filter, skipColumns); } finally { this.#admissionGate.release(); } } async #resolveAndHydrateUncached( environment: RealtimeListEnvironment, filter: RunSetFilter, skipColumns: string[] ): Promise { const resolveStart = Date.now(); const ids = await this.#resolveIds(environment, filter); this.options.onRunSetQuery?.("resolve", Date.now() - resolveStart); const hydrateStart = Date.now(); const rows = await this.options.runReader.hydrateByIds(environment.id, ids, skipColumns); this.options.onRunSetQuery?.("hydrate", Date.now() - hydrateStart); return rows; } /** Stable cache key for the resolve+hydrate cache. Same key => same id-set and the * same projected columns, so cached rows always match the requesting feed. */ #runSetCacheKey(environmentId: string, filter: RunSetFilter, skipColumns: string[]): string { // JSON-encode the arrays (not a join) so a tag containing the separator can't collide with a different filter. const tags = filter.tags && filter.tags.length > 0 ? JSON.stringify([...filter.tags].sort()) : ""; const cols = skipColumns.length > 0 ? JSON.stringify([...skipColumns].sort()) : ""; const maxListResults = this.options.maxListResults ?? DEFAULT_MAX_LIST_RESULTS; return `${environmentId}|${tags}|${filter.batchId ?? ""}|${ filter.createdAtAfter?.getTime() ?? "" }|${maxListResults}|${cols}`; } async #resolveIds(environment: RealtimeListEnvironment, filter: RunSetFilter): Promise { const maxListResults = this.options.maxListResults ?? DEFAULT_MAX_LIST_RESULTS; const ids = await this.options.runListResolver.resolveMatchingRunIds({ organizationId: environment.organizationId, projectId: environment.projectId, environmentId: environment.id, tags: filter.tags, batchId: filter.batchId, createdAtAfter: filter.createdAtAfter, limit: maxListResults, }); if (ids.length >= maxListResults) { logger.warn("[nativeRealtimeClient] run-set feed hit the result cap", { environmentId: environment.id, filter, cap: maxListResults, }); } return ids; } #computeCreatedAtFilter(createdAt: string | undefined): Date { // Clamp to the maximum lookback window, mirroring realtimeClient. const floor = new Date(Date.now() - this.options.maximumCreatedAtFilterAgeMs); const parsed = safeParseNaturalLanguageDurationAgo(createdAt ?? "24h"); const resolved = !parsed || parsed < floor ? floor : parsed; // Bucket the lower bound so same-tag feeds share a cache key; floored, so the window only ever widens by < bucket. return new Date(this.#bucketCreatedAtMs(resolved.getTime())); } #bucketCreatedAtMs(ms: number): number { const bucket = this.options.runSetCreatedAtBucketMs ?? DEFAULT_RUNSET_CREATED_AT_BUCKET_MS; return bucket > 0 ? Math.floor(ms / bucket) * bucket : ms; } /** Clamp a handle-recovered createdAt lower bound up to the max-age floor (so a * stale or crafted handle can't widen the window past the ceiling), then re-bucket. */ #clampCreatedAtFloor(ms: number): number { const floorMs = Date.now() - this.options.maximumCreatedAtFilterAgeMs; return this.#bucketCreatedAtMs(Math.max(ms, floorMs)); } #mintListHandle(createdAtFilterMs: number): string { // Pins the createdAt threshold in the opaque handle so live polls reuse the // same lower bound even on a working-set cache miss. return `runs_${Math.trunc(createdAtFilterMs)}_${this.#mintUniqueSuffix()}`; } #mintBatchHandle(batchId: string): string { return `batch_${batchId}_${this.#mintUniqueSuffix()}`; } #mintUniqueSuffix(): string { // The seq alone isn't unique across instances/restarts; behind a non-sticky ALB a // collision would land two connections on one working-set cache entry. return `${this.#nextSeq()}_${randomUUID().slice(0, 8)}`; } #workingSetKey(environmentId: string, handle: string): string { // The handle is client-echoed; env-prefix the key so a foreign handle can never // read or overwrite another tenant's working set. return `${environmentId}:${handle}`; } #filterMsFromHandle(handle: string): number | undefined { const parts = handle.split("_"); if (parts[0] !== "runs") { return undefined; } const ms = Number(parts[1]); return Number.isFinite(ms) && ms > 0 ? ms : undefined; } #parseStreamRequest( url: URL | string, requestOptions?: RealtimeRequestOptions ): { offset: string; handle: string | null; isLive: boolean; skipColumns: string[] } { const $url = new URL(url.toString()); return { offset: $url.searchParams.get("offset") ?? INITIAL_OFFSET, handle: $url.searchParams.get("handle") ?? $url.searchParams.get("shape_id"), isLive: $url.searchParams.get("live") === "true", skipColumns: this.#resolveSkipColumns($url, requestOptions), }; } /** Runs `work` inside a per-env concurrency slot (429 if over the org limit, 500 if the limit can't be read), always releasing it after. */ async #withConcurrencySlot( environment: RealtimeEnvironment, work: () => Promise ): Promise { const requestId = randomUUID(); const concurrencyLimit = await this.options.cachedLimitProvider.getCachedLimit( environment.organizationId, this.options.defaultConcurrencyLimit ?? DEFAULT_CONCURRENCY_LIMIT ); if (concurrencyLimit == null) { logger.error("[nativeRealtimeClient] Failed to get concurrency limit", { organizationId: environment.organizationId, }); return json({ error: "Failed to get concurrency limit" }, { status: 500 }); } const canProceed = await this.options.limiter.incrementAndCheck( environment.id, requestId, concurrencyLimit ); if (!canProceed) { this.options.onConcurrencyRejected?.(); return json({ error: "Too many concurrent requests" }, { status: 429 }); } try { return await work(); } finally { await this.options.limiter.decrement(environment.id, requestId); } } #jitteredTimeout(): number { const base = this.options.livePollTimeoutMs ?? DEFAULT_LIVE_POLL_TIMEOUT_MS; // Jittered to avoid synchronized refetch herds. const ratio = this.options.livePollJitterRatio ?? DEFAULT_LIVE_POLL_JITTER_RATIO; return Math.round(base * (1 - ratio + Math.random() * 2 * ratio)); } #buildResponse( body: string, apiVersion: API_VERSIONS, clientVersion: string | undefined, headers: ResponseHeaderInput ): Response { const finalBody = apiVersion === CURRENT_API_VERSION ? body : rewriteBodyForLegacyApiVersion(body); const responseHeaders = new Headers(); responseHeaders.set("content-type", "application/json"); responseHeaders.set("cache-control", "no-store"); // Expose the electric-* headers cross-origin or the deployed react-hooks fail with MissingHeadersError (bearer requests are non-credentialed, so wildcard is safe). responseHeaders.set("access-control-allow-origin", "*"); responseHeaders.set("access-control-expose-headers", "*"); // Modern clients send `x-trigger-electric-version` and read `electric-offset`/`electric-handle`; legacy clients omit it and read the shape-id/chunk-last-offset names. if (clientVersion) { responseHeaders.set("electric-offset", headers.offset); responseHeaders.set("electric-handle", headers.handle); } else { responseHeaders.set("electric-chunk-last-offset", headers.offset); responseHeaders.set("electric-shape-id", headers.handle); } if (headers.cursor !== undefined) { responseHeaders.set("electric-cursor", headers.cursor); } if (headers.schema !== undefined) { responseHeaders.set("electric-schema", headers.schema); } return new Response(finalBody, { status: 200, headers: responseHeaders }); } #mintHandle(runId: string): string { // Stable per-run handle: the single-run shape never changes columns, so the // client never needs a must-refetch from a handle change. return `run-${runId}`; } #nextSeq(): number { this.#seq = (this.#seq + 1) % Number.MAX_SAFE_INTEGER; return this.#seq; } #resolveSkipColumns(url: URL, requestOptions?: RealtimeRequestOptions): string[] { const raw = requestOptions?.skipColumns ?? url.searchParams.get("skipColumns")?.split(",") ?? []; return raw.map((c) => c.trim()).filter((c) => c !== "" && !RESERVED_COLUMNS.includes(c)); } }