chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,50 @@
/**
* Tiny in-process bounded TTL cache shared by the realtime feeds: entries expire after `ttlMs` (evicted on read),
* and at-capacity writes sweep expired entries then drop the oldest. A stored `undefined` is indistinguishable from a miss (use `null` for absence).
*/
export class BoundedTtlCache<V> {
readonly #entries = new Map<string, { value: V; expiresAt: number }>();
constructor(
private readonly ttlMs: number,
private readonly maxEntries: number
) {}
get(key: string): V | undefined {
const entry = this.#entries.get(key);
if (!entry) {
return undefined;
}
if (entry.expiresAt > Date.now()) {
return entry.value;
}
// Evict on read so expired entries don't linger until the next at-capacity
// sweep — important for read-heavy / low-churn caches (per-handle working sets).
this.#entries.delete(key);
return undefined;
}
set(key: string, value: V): void {
// Only run capacity eviction when inserting a NEW key — updating an existing key
// doesn't grow the map, so it must never drop an unrelated live entry.
if (!this.#entries.has(key) && this.#entries.size >= this.maxEntries) {
const now = Date.now();
for (const [key, entry] of this.#entries) {
if (entry.expiresAt <= now) {
this.#entries.delete(key);
}
}
if (this.#entries.size >= this.maxEntries) {
const oldest = this.#entries.keys().next().value;
if (oldest !== undefined) {
this.#entries.delete(oldest);
}
}
}
this.#entries.set(key, { value, expiresAt: Date.now() + this.ttlMs });
}
get size(): number {
return this.#entries.size;
}
}
@@ -0,0 +1,34 @@
import { env } from "~/env.server";
/**
* Canonical storage URI for a session's chat.agent snapshot. Stamped on
* `Session.chatSnapshotStoragePath` at row creation so PUT/GET presigns
* resolve to the same store even if `OBJECT_STORE_DEFAULT_PROTOCOL`
* changes later.
*/
export function chatSnapshotStoragePathForSession(friendlyId: string): string {
const path = `sessions/${friendlyId}/snapshot.json`;
const protocol = env.OBJECT_STORE_DEFAULT_PROTOCOL;
return protocol ? `${protocol}://${path}` : path;
}
/**
* Resolve the storage key/URI a session's chat snapshot is written to and read
* from. Single source of truth shared by every reader/writer so they all hit
* the same object store:
* - the SDK write + boot read (via the `snapshot-url` presign route), and
* - the dashboard `SessionPresenter` (Agent/Session view).
*
* Prefers `chatSnapshotStoragePath` stamped at row creation (already
* protocol-qualified, e.g. `s3://sessions/{id}/snapshot.json`), falling back to
* recomputing it for sessions created before the column existed. Using a bare,
* unqualified key here is the bug this guards against: the object store applies
* `OBJECT_STORE_DEFAULT_PROTOCOL` to unprefixed keys on PUT but not on GET, so a
* bare key can write to one store and read from another.
*/
export function chatSnapshotStorageKey(session: {
friendlyId: string;
chatSnapshotStoragePath: string | null;
}): string {
return session.chatSnapshotStoragePath ?? chatSnapshotStoragePathForSession(session.friendlyId);
}
@@ -0,0 +1,39 @@
import { type ClickHouse } from "@internal/clickhouse";
import { type PrismaClientOrTransaction } from "~/db.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { type RunListFilter, type RunListResolver } from "./runReader.server";
export type ClickHouseRunListResolverOptions = {
/** Resolves the per-organization ClickHouse client (multi-tenant routing). */
getClickhouse: (organizationId: string) => Promise<ClickHouse>;
prisma: PrismaClientOrTransaction;
};
/**
* Resolves the realtime tag/list filter into matching run ids via ClickHouse `listRunIds` (filter-only;
* rows hydrated from Postgres by id afterward). Tag matching is contains-ALL, byte-matching Electric's
* `runTags @> ARRAY[...]` shape.
*/
export class ClickHouseRunListResolver implements RunListResolver {
constructor(private readonly options: ClickHouseRunListResolverOptions) {}
async resolveMatchingRunIds(filter: RunListFilter): Promise<string[]> {
const clickhouse = await this.options.getClickhouse(filter.organizationId);
const repository = new RunsRepository({ clickhouse, prisma: this.options.prisma });
const { runIds } = await repository.listRunIds({
organizationId: filter.organizationId,
projectId: filter.projectId,
environmentId: filter.environmentId,
tags: filter.tags && filter.tags.length > 0 ? filter.tags : undefined,
// Contains-ALL, matching the Electric shape's `runTags @> ARRAY[...]` semantics.
tagsMatch: "all",
batchId: filter.batchId,
from: filter.createdAtAfter?.getTime(),
page: { size: filter.limit },
});
// listRunIds is keyset-paginated; runIds is already capped to page.size (= limit).
return runIds;
}
}
@@ -0,0 +1,48 @@
/**
* Duration string parsing for stream-basin retention / delete-on-empty
* configuration. Used by `streamBasinProvisioner` (to convert to S2's
* integer-seconds wire format) and by `env.server.ts` (to validate
* duration-shaped env vars at boot rather than at first use).
*
* Accepts the short forms (`7d`, `30d`, `365d`, `1h`, `90m`, `45s`,
* `2w`, `1y`) and the human forms (`7days`, `1week`, `1year`).
*/
const PATTERN =
/^(\d+)\s*(s|sec|secs|seconds?|m|min|mins|minutes?|h|hour|hours?|d|day|days?|w|week|weeks?|y|year|years?)$/;
export function isValidDuration(input: string): boolean {
return PATTERN.test(input.trim().toLowerCase());
}
/**
* Parse a duration string into seconds. Throws on garbage so a
* misconfigured env var fails loudly. Use {@link isValidDuration}
* for non-throwing validation (e.g. inside a Zod `.refine()`).
*/
export function parseDuration(input: string): number {
const trimmed = input.trim().toLowerCase();
const match = trimmed.match(PATTERN);
if (!match) {
throw new Error(`Invalid duration string: ${input}`);
}
const value = parseInt(match[1]!, 10);
const unit = match[2]!;
const multiplier = /^s/.test(unit)
? 1
: /^m(?:in|ins|inute|inutes)?$/.test(unit)
? 60
: /^h/.test(unit)
? 3600
: /^d/.test(unit)
? 86400
: /^w/.test(unit)
? 604800
: /^y/.test(unit)
? 31_536_000
: NaN;
if (!Number.isFinite(multiplier)) {
throw new Error(`Invalid duration unit: ${unit}`);
}
return value * multiplier;
}
@@ -0,0 +1,281 @@
/**
* Pure (no DB/Redis/env) Electric HTTP shape-stream wire serializer, byte-faithful to what the
* deployed `@electric-sql/client` (1.0.14 + 0.4.0) and the SDK's `SubscribeRunRawShape` expect.
* Each column value is wire-encoded as a string (or null) decoded via the `electric-schema` header;
* `up-to-date` is the only control message that makes the client emit, and re-sending a full row is idempotent.
*/
export type ElectricColumnType =
| "text"
| "timestamp"
| "int4"
| "int8"
| "float8"
| "bool"
| "jsonb";
type ElectricColumn = {
name: string;
type: ElectricColumnType;
/** Array dimensionality. 1 => `type[]` (Postgres `{a,b}` literal). */
dims?: number;
/** Array columns only: true when the column has no SQL default, so an empty value emits `null` (not `{}`). Prisma erases this distinction, so we re-derive it here. */
emptyArrayAsNull?: boolean;
};
/** Columns the realtime run feed exposes; keep in sync with `DEFAULT_ELECTRIC_COLUMNS`. `type`/`dims` drive the schema header and value encoding. */
export const RUN_ELECTRIC_COLUMNS: ReadonlyArray<ElectricColumn> = [
{ name: "id", type: "text" },
{ name: "taskIdentifier", type: "text" },
{ name: "createdAt", type: "timestamp" },
{ name: "updatedAt", type: "timestamp" },
{ name: "startedAt", type: "timestamp" },
{ name: "delayUntil", type: "timestamp" },
{ name: "queuedAt", type: "timestamp" },
{ name: "expiredAt", type: "timestamp" },
{ name: "completedAt", type: "timestamp" },
{ name: "friendlyId", type: "text" },
{ name: "number", type: "int4" },
{ name: "isTest", type: "bool" },
{ name: "status", type: "text" },
{ name: "usageDurationMs", type: "int4" },
{ name: "costInCents", type: "float8" },
{ name: "baseCostInCents", type: "float8" },
{ name: "ttl", type: "text" },
{ name: "payload", type: "text" },
{ name: "payloadType", type: "text" },
{ name: "metadata", type: "text" },
{ name: "metadataType", type: "text" },
{ name: "output", type: "text" },
{ name: "outputType", type: "text" },
{ name: "runTags", type: "text", dims: 1, emptyArrayAsNull: true },
{ name: "error", type: "jsonb" },
{ name: "realtimeStreams", type: "text", dims: 1 },
];
/** Columns that can never be skipped via `skipColumns` (mirrors realtimeClient). */
export const RESERVED_COLUMNS = ["id", "taskIdentifier", "friendlyId", "status", "createdAt"];
/** A single run hydrated for the realtime feed; structurally compatible with the `RunHydrator` Prisma `TaskRun` projection. */
export type RealtimeRunRow = {
id: string;
taskIdentifier: string;
createdAt: Date;
updatedAt: Date;
startedAt: Date | null;
delayUntil: Date | null;
queuedAt: Date | null;
expiredAt: Date | null;
completedAt: Date | null;
friendlyId: string;
number: number;
isTest: boolean;
status: string;
usageDurationMs: number;
costInCents: number;
baseCostInCents: number;
ttl: string | null;
payload: string;
payloadType: string;
metadata: string | null;
metadataType: string;
output: string | null;
outputType: string;
runTags: string[];
error: unknown;
realtimeStreams: string[];
};
type Operation = "insert" | "update" | "delete";
type ChangeMessage = {
key: string;
value: Record<string, string | null>;
headers: { operation: Operation };
};
type ControlMessage = {
headers: { control: "up-to-date" | "must-refetch" };
};
type ShapeMessage = ChangeMessage | ControlMessage;
const UP_TO_DATE: ControlMessage = { headers: { control: "up-to-date" } };
function effectiveSkipColumns(skipColumns: string[]): Set<string> {
return new Set(skipColumns.filter((c) => c !== "" && !RESERVED_COLUMNS.includes(c)));
}
function quoteArrayElement(value: string): string {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function pgArrayLiteral(values: unknown[]): string {
if (values.length === 0) {
return "{}";
}
return `{${values.map((v) => quoteArrayElement(String(v))).join(",")}}`;
}
function serializeValue(value: unknown, column: ElectricColumn): string | null {
if (value === null || value === undefined) {
return null;
}
if (column.dims && column.dims > 0) {
if (!Array.isArray(value)) {
return null;
}
// A no-default array column stores NULL when empty, so Electric emits `null`
// (not `{}`); match that here since Prisma handed us `[]` for the NULL value.
if (value.length === 0 && column.emptyArrayAsNull) {
return null;
}
return pgArrayLiteral(value);
}
switch (column.type) {
case "bool":
// Postgres text representation; the client's parseBool accepts "t"/"f".
return value ? "t" : "f";
case "timestamp":
// The SDK's RawShapeDate appends "Z" before parsing, so we emit the ISO
// string WITHOUT the trailing "Z".
return value instanceof Date ? value.toISOString().slice(0, -1) : String(value);
case "jsonb":
return JSON.stringify(value);
case "int4":
case "int8":
case "float8":
case "text":
default:
return String(value);
}
}
/** The merge key the client uses to reassemble a row across insert/update cycles. */
export function runShapeKey(runId: string): string {
return `"public"."TaskRun"/"${runId}"`;
}
/** Encode a single run row into the wire `value` object (column -> string|null). */
export function serializeRunRow(
row: RealtimeRunRow,
skipColumns: string[] = []
): Record<string, string | null> {
const skip = effectiveSkipColumns(skipColumns);
const value: Record<string, string | null> = {};
for (const column of RUN_ELECTRIC_COLUMNS) {
if (skip.has(column.name)) {
continue;
}
value[column.name] = serializeValue((row as Record<string, unknown>)[column.name], column);
}
return value;
}
/** The `electric-schema` response header value for the (optionally trimmed) column set. */
export function buildElectricSchemaHeader(skipColumns: string[] = []): string {
const skip = effectiveSkipColumns(skipColumns);
const schema: Record<string, { type: string; dims?: number }> = {};
for (const column of RUN_ELECTRIC_COLUMNS) {
if (skip.has(column.name)) {
continue;
}
schema[column.name] = column.dims
? { type: column.type, dims: column.dims }
: { type: column.type };
}
return JSON.stringify(schema);
}
/** Initial snapshot body: an `insert` for the row (if present) then `up-to-date`; an absent row emits a bare `up-to-date` (empty shape). */
export function buildSnapshotBody(row: RealtimeRunRow | null, skipColumns: string[] = []): string {
const messages: ShapeMessage[] = [];
if (row) {
messages.push({
key: runShapeKey(row.id),
value: serializeRunRow(row, skipColumns),
headers: { operation: "insert" },
});
}
messages.push(UP_TO_DATE);
return JSON.stringify(messages);
}
/** Live body when the row advanced: a full-row `update` followed by `up-to-date`. */
export function buildUpdateBody(row: RealtimeRunRow, skipColumns: string[] = []): string {
const messages: ShapeMessage[] = [
{
key: runShapeKey(row.id),
value: serializeRunRow(row, skipColumns),
headers: { operation: "update" },
},
UP_TO_DATE,
];
return JSON.stringify(messages);
}
/** Live body when nothing advanced: a bare `up-to-date` (no row emission). */
export function buildUpToDateBody(): string {
return JSON.stringify([UP_TO_DATE]);
}
export type RowChange = { row: RealtimeRunRow; operation: "insert" | "update" };
/** Multi-row body for the tag-list feed: one change message per row then `up-to-date` (empty `changes` emits a bare `up-to-date`). */
export function buildRowsBody(changes: RowChange[], skipColumns: string[] = []): string {
const messages: ShapeMessage[] = changes.map((change) => ({
key: runShapeKey(change.row.id),
value: serializeRunRow(change.row, skipColumns),
headers: { operation: change.operation },
}));
messages.push(UP_TO_DATE);
return JSON.stringify(messages);
}
/** A row change whose wire `value` was already serialized (once, shared across feeds by
* the EnvChangeRouter); the per-feed `operation` is applied here. */
export type SerializedRowChange = {
runId: string;
value: Record<string, string | null>;
operation: "insert" | "update";
};
/** Like `buildRowsBody`, but from values serialized once per (runId, columnSet) upstream,
* so a run matching many feeds is serialized once and reused across their bodies. */
export function buildRowsBodyFromSerialized(changes: SerializedRowChange[]): string {
const messages: ShapeMessage[] = changes.map((change) => ({
key: runShapeKey(change.runId),
value: change.value,
headers: { operation: change.operation },
}));
messages.push(UP_TO_DATE);
return JSON.stringify(messages);
}
export const INITIAL_OFFSET = "-1";
/** Opaque `<updatedAtMs>_<seq>` offset token (client `${number}_${number}` type); the first segment lets a live request detect whether the row advanced. */
export function encodeOffset(updatedAtMs: number, seq: number): string {
return `${Math.trunc(updatedAtMs)}_${Math.trunc(seq)}`;
}
/** Extract the `updatedAt` epoch-ms a client last saw from its echoed offset. */
export function parseOffsetUpdatedAtMs(offset: string | null | undefined): number {
if (!offset) {
return 0;
}
const [first] = offset.split("_");
const value = Number(first);
return Number.isFinite(value) && value > 0 ? value : 0;
}
/** Mirror of realtimeClient's DEQUEUED->EXECUTING rewrite for non-current API versions. */
export function rewriteBodyForLegacyApiVersion(body: string): string {
return body.replace(/"status":"DEQUEUED"/g, '"status":"EXECUTING"');
}
@@ -0,0 +1,695 @@
import { type ChangeRecord } from "./runChangeNotifier.server";
import { type RealtimeRunRow, serializeRunRow } from "./electricStreamProtocol.server";
import { logger } from "~/services/logger.server";
/**
* EnvChangeRouter — per-instance routing layer that fans one env's change stream out to the feeds it
* matches. Owns one subscription per env (over the RunChangeNotifier) plus an inverted index of held
* feeds, then per batch: routes via the index, batch-hydrates matched runs once per column set,
* serializes each row's wire value once, and resolves each matched feed's pending wait. Stateless across reconnects.
*/
export type WakeReason = "notify" | "timeout" | "abort";
/** A feed's membership predicate over the env stream. */
export type FeedFilter =
| { kind: "run"; runId: string }
| { kind: "tag"; tags: string[]; createdAtFloorMs?: number }
| { kind: "batch"; batchId: string };
/** A matched run handed to a feed: the hydrated row (for the feed's working-set diff) and
* its wire `value` serialized once for this feed's column set (shared across feeds). */
export type MatchedRow = { row: RealtimeRunRow; value: Record<string, string | null> };
export type WaitResult = { reason: WakeReason; rows: MatchedRow[] };
/** Minimal deps so the router is unit-testable without Redis/Postgres. */
export interface EnvChangeSource {
subscribeToEnv(environmentId: string, onBatch: (records: ChangeRecord[]) => void): () => void;
}
export interface RowHydrator {
hydrateByIds(
environmentId: string,
ids: string[],
skipColumns: string[]
): Promise<RealtimeRunRow[]>;
}
export type EnvChangeRouterOptions = {
source: EnvChangeSource;
hydrator: RowHydrator;
/** Observability: a hydrate-by-id batch ran (count = runs hydrated this tick). */
onHydrate?: (runCount: number) => void;
/** How far back (ms) a newly-armed feed replays buffered records. 0 disables replay. */
replayWindowMs?: number;
/** Cap on buffered recent records per env (latest record per run). */
replayMaxRunsPerEnv?: number;
/** How long (ms) to keep an env subscribed + buffering after its last feed closes. 0 disables. */
unsubscribeLingerMs?: number;
/** Observability: a replay scan found candidates and delivered rows (or none survived). */
onReplay?: (result: "delivered" | "empty") => void;
/** Observability: a buffered record was evicted. `cap` evictions mean the env churns more
* runs inside the window than the buffer holds (the replay guarantee is degrading). */
onReplayEviction?: (reason: "cap" | "window") => void;
/** Read-your-writes gate over the replica: delays wake-path hydrates until the replica
* should have applied the change (record.updatedAtMs + lag + margin), and re-hydrates
* rows the tripwire still finds stale. Omit to hydrate immediately (legacy behavior). */
replicaLag?: ReplicaLagGate;
};
export type ReplicaLagGate = {
/** Current replica-lag estimate (ms). */
getLagMs(): number;
/** Feedback: a hydrate provably read at least this far behind the primary. */
noteObservedLagMs(lagMs: number): void;
/** Safety margin added on top of the estimate (clock skew + scheduling). */
marginMs: number;
/** Hard cap on any single gate delay — a sick replica degrades freshness, never liveness. */
maxDelayMs: number;
/** Re-hydrate attempts for rows that still read stale after the delay. */
staleRetries: number;
/** Observability: stale rows recovered by a retry, or delivered stale after exhausting them. */
onStaleHydrate?: (outcome: "recovered" | "gave_up", runCount: number) => void;
};
const DEFAULT_REPLAY_WINDOW_MS = 2_000;
const DEFAULT_REPLAY_MAX_RUNS_PER_ENV = 512;
const DEFAULT_UNSUBSCRIBE_LINGER_MS = 5_000;
/** Handle a feed holds for the duration of one long-poll. */
export type FeedRegistration = {
/** Wait for the next batch matching this feed (or timeout/abort), with the matched runs
* hydrated + serialized for this feed's columns. One wait active at a time. */
waitForMatch(signal: AbortSignal | undefined, timeoutMs: number): Promise<WaitResult>;
/** Deregister from the index; unsubscribes the env when the last feed leaves. */
close(): void;
/** False when this instance's env subscription is younger than the replay window, so a
* change in the caller's inter-poll gap may have been missed (hop/cold start) — the
* caller should resolve once instead of holding blind. */
gapCovered: boolean;
};
type Feed = {
filter: FeedFilter;
skipColumns: string[];
columnSig: string;
/** The currently-waiting poll's resolver (null between polls). */
resolve: ((result: WaitResult) => void) | null;
/** Buffered records at or before this timestamp have been replayed (or predate this feed). */
replayCursorMs: number;
};
type EnvState = {
unsubscribe: () => void;
feeds: Set<Feed>;
byRunId: Map<string, Set<Feed>>;
byTag: Map<string, Set<Feed>>;
byBatchId: Map<string, Set<Feed>>;
/** All tag feeds, for routing partial records (no tags) as hydrate-to-classify candidates. */
tagFeeds: Set<Feed>;
/** Tag feeds with no tag filter — they match every record but are unreachable via byTag. */
unfilteredTagFeeds: Set<Feed>;
/** When this env's channel subscription started (for the gap-coverage check). */
subscribedAtMs: number;
/** Latest record per run, insertion-ordered, for replaying inter-poll gaps to newly-armed feeds. */
recent: Map<string, { record: ChangeRecord; receivedAtMs: number }>;
/** Pending teardown while the env lingers with zero feeds. */
lingerTimer?: ReturnType<typeof setTimeout>;
};
function sleepMs(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
timer.unref?.();
});
}
function addToIndex(index: Map<string, Set<Feed>>, key: string, feed: Feed) {
let set = index.get(key);
if (!set) {
set = new Set();
index.set(key, set);
}
set.add(feed);
}
function removeFromIndex(index: Map<string, Set<Feed>>, key: string, feed: Feed) {
const set = index.get(key);
if (set) {
set.delete(feed);
if (set.size === 0) {
index.delete(key);
}
}
}
export class EnvChangeRouter {
readonly #envs = new Map<string, EnvState>();
constructor(private readonly options: EnvChangeRouterOptions) {}
register(
environmentId: string,
filter: FeedFilter,
skipColumns: string[],
opts?: {
/** When the caller last received data for this connection. Bounds the replay to the
* true inter-poll gap; older than the window can't be proven covered. */
replaySinceMs?: number;
}
): FeedRegistration {
const env = this.#ensureEnv(environmentId);
const replayWindowMs = this.options.replayWindowMs ?? DEFAULT_REPLAY_WINDOW_MS;
const now = Date.now();
const windowFloorMs = now - replayWindowMs;
const sinceMs = opts?.replaySinceMs ?? windowFloorMs;
const feed: Feed = {
filter,
skipColumns,
columnSig: skipColumns.length > 0 ? [...skipColumns].sort().join(",") : "",
resolve: null,
// First arm replays the caller's inter-poll gap; later arms only what arrived since.
// The buffer only spans the window, so never rewind past it.
replayCursorMs: Math.max(sinceMs, windowFloorMs),
};
env.feeds.add(feed);
this.#indexFeed(env, feed);
const waitForMatch = (signal: AbortSignal | undefined, timeoutMs: number) =>
new Promise<WaitResult>((resolve) => {
if (signal?.aborted) {
resolve({ reason: "abort", rows: [] });
return;
}
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const settle = (result: WaitResult) => {
if (settled) return;
settled = true;
feed.resolve = null;
if (timer) clearTimeout(timer);
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
resolve(result);
};
feed.resolve = settle;
timer = setTimeout(() => settle({ reason: "timeout", rows: [] }), timeoutMs);
timer.unref?.();
if (signal) {
onAbort = () => settle({ reason: "abort", rows: [] });
signal.addEventListener("abort", onAbort, { once: true });
}
// Deliver any buffered records this feed hasn't seen (catches changes that
// landed while the caller was between polls).
if (replayWindowMs > 0 && env.recent.size > 0) {
this.#replayRecent(environmentId, env, feed).catch((error) => {
logger.error("[envChangeRouter] failed to replay buffered records", {
environmentId,
error,
});
});
}
});
const close = () => {
if (!env.feeds.has(feed)) {
return;
}
env.feeds.delete(feed);
this.#deindexFeed(env, feed);
// Resolve any in-flight wait so the poll doesn't hang.
feed.resolve?.({ reason: "abort", rows: [] });
feed.resolve = null;
if (env.feeds.size === 0) {
this.#scheduleEnvTeardown(environmentId, env);
}
};
return {
waitForMatch,
close,
// Covered when this instance was already subscribed (and buffering) at the gap's
// start, and the gap fits inside the buffer's window.
gapCovered:
replayWindowMs <= 0 || (env.subscribedAtMs <= sinceMs && sinceMs >= windowFloorMs),
};
}
/** Distinct environments currently routed (for metrics). */
get activeEnvCount(): number {
return this.#envs.size;
}
/** Currently-held feeds by kind (for metrics) — the system's capacity unit. */
get heldFeedCounts(): { run: number; tag: number; batch: number } {
const counts = { run: 0, tag: 0, batch: 0 };
for (const env of this.#envs.values()) {
for (const feed of env.feeds) {
counts[feed.filter.kind]++;
}
}
return counts;
}
#ensureEnv(environmentId: string): EnvState {
const existing = this.#envs.get(environmentId);
if (existing) {
// A pending teardown is cancelled by new interest; the buffer survives the gap.
if (existing.lingerTimer) {
clearTimeout(existing.lingerTimer);
existing.lingerTimer = undefined;
}
return existing;
}
const env: EnvState = {
unsubscribe: () => {},
feeds: new Set(),
byRunId: new Map(),
byTag: new Map(),
byBatchId: new Map(),
tagFeeds: new Set(),
unfilteredTagFeeds: new Set(),
subscribedAtMs: Date.now(),
recent: new Map(),
};
this.#envs.set(environmentId, env);
env.unsubscribe = this.options.source.subscribeToEnv(environmentId, (records) => {
this.#bufferRecent(env, records);
// Fire-and-forget; catch hydrate failures here (unhandled rejection exits the process) — waiters time out into the backstop.
this.#onBatch(environmentId, env, records).catch((error) => {
logger.error("[envChangeRouter] failed to route a change batch", {
environmentId,
error,
});
});
});
return env;
}
/** Keep the env subscribed + buffering for a linger after its last feed closes, so a
* client's next poll (or another instance hop landing back here) can replay the gap. */
#scheduleEnvTeardown(environmentId: string, env: EnvState) {
const lingerMs = this.options.unsubscribeLingerMs ?? DEFAULT_UNSUBSCRIBE_LINGER_MS;
if (lingerMs <= 0) {
this.#envs.delete(environmentId);
env.unsubscribe();
return;
}
if (env.lingerTimer) {
clearTimeout(env.lingerTimer);
}
env.lingerTimer = setTimeout(() => {
if (env.feeds.size === 0) {
this.#envs.delete(environmentId);
env.unsubscribe();
}
}, lingerMs);
env.lingerTimer.unref?.();
}
/** Upsert the latest record per run (insertion-ordered) and prune to the window + cap. */
#bufferRecent(env: EnvState, records: ChangeRecord[]) {
const windowMs = this.options.replayWindowMs ?? DEFAULT_REPLAY_WINDOW_MS;
if (windowMs <= 0) {
return;
}
const maxRuns = this.options.replayMaxRunsPerEnv ?? DEFAULT_REPLAY_MAX_RUNS_PER_ENV;
const now = Date.now();
for (const record of records) {
env.recent.delete(record.runId);
env.recent.set(record.runId, { record, receivedAtMs: now });
}
const cutoff = now - windowMs;
for (const [runId, entry] of env.recent) {
if (entry.receivedAtMs >= cutoff && env.recent.size <= maxRuns) {
break;
}
this.options.onReplayEviction?.(entry.receivedAtMs < cutoff ? "window" : "cap");
env.recent.delete(runId);
}
}
/** Whether a buffered record matches a feed's predicate (mirrors #onBatch's routing). */
#recordMatchesFeed(record: ChangeRecord, feed: Feed): boolean {
switch (feed.filter.kind) {
case "run":
return record.runId === feed.filter.runId;
case "batch":
return record.batchId != null && record.batchId === feed.filter.batchId;
case "tag": {
const tags = feed.filter.tags;
// Unfiltered feed matches everything; partial record (no tags) = hydrate-to-classify.
if (tags.length === 0 || record.tags === undefined) {
return true;
}
return record.tags.some((tag) => tags.includes(tag));
}
}
}
/** How long to wait before hydrating so the replica has applied every change in the
* batch: each record is safe at updatedAtMs + lag + margin (records without a watermark
* anchor at now, degrading to a plain lag-sized delay). Capped — see ReplicaLagGate. */
#gateDelayMs(records: ChangeRecord[]): number {
const gate = this.options.replicaLag;
if (!gate || records.length === 0) {
return 0;
}
const now = Date.now();
const lagMs = gate.getLagMs();
let safeAtMs = 0;
for (const record of records) {
const anchorMs = record.updatedAtMs ?? now;
safeAtMs = Math.max(safeAtMs, anchorMs + lagMs + gate.marginMs);
}
return Math.max(0, Math.min(safeAtMs - now, gate.maxDelayMs));
}
/** Deliver buffered records newer than the feed's cursor through the normal
* hydrate -> serialize -> settle pipeline. Already-seen rows diff to nothing downstream. */
async #replayRecent(environmentId: string, env: EnvState, feed: Feed) {
const cursor = feed.replayCursorMs;
feed.replayCursorMs = Date.now();
const runIds: string[] = [];
const candidateRecords: ChangeRecord[] = [];
for (const [runId, entry] of env.recent) {
if (entry.receivedAtMs > cursor && this.#recordMatchesFeed(entry.record, feed)) {
runIds.push(runId);
candidateRecords.push(entry.record);
}
}
if (runIds.length === 0 || !feed.resolve) {
return;
}
// Replayed records are usually past the lag window already (delay computes to 0); a
// just-buffered one gets the same read-your-writes gate as the live path. No tripwire
// here — a stale replay diffs to a re-emission on the next wake or backstop.
const replayDelayMs = this.#gateDelayMs(candidateRecords);
if (replayDelayMs > 0) {
await sleepMs(replayDelayMs);
if (!feed.resolve) {
return;
}
}
const hydrated = await this.options.hydrator.hydrateByIds(
environmentId,
runIds,
feed.skipColumns
);
this.options.onHydrate?.(hydrated.length);
const rows: MatchedRow[] = [];
for (const row of hydrated) {
if (feed.filter.kind === "tag" && !this.#tagRowMatches(row, feed.filter)) {
continue;
}
rows.push({ row, value: serializeRunRow(row, feed.skipColumns) });
}
if (rows.length > 0 && feed.resolve) {
this.options.onReplay?.("delivered");
feed.resolve({ reason: "notify", rows });
} else {
this.options.onReplay?.("empty");
}
}
#indexFeed(env: EnvState, feed: Feed) {
switch (feed.filter.kind) {
case "run":
addToIndex(env.byRunId, feed.filter.runId, feed);
break;
case "batch":
addToIndex(env.byBatchId, feed.filter.batchId, feed);
break;
case "tag":
env.tagFeeds.add(feed);
if (feed.filter.tags.length === 0) {
env.unfilteredTagFeeds.add(feed);
}
for (const tag of feed.filter.tags) {
addToIndex(env.byTag, tag, feed);
}
break;
}
}
#deindexFeed(env: EnvState, feed: Feed) {
switch (feed.filter.kind) {
case "run":
removeFromIndex(env.byRunId, feed.filter.runId, feed);
break;
case "batch":
removeFromIndex(env.byBatchId, feed.filter.batchId, feed);
break;
case "tag":
env.tagFeeds.delete(feed);
env.unfilteredTagFeeds.delete(feed);
for (const tag of feed.filter.tags) {
removeFromIndex(env.byTag, tag, feed);
}
break;
}
}
async #onBatch(environmentId: string, env: EnvState, records: ChangeRecord[], attempt = 0) {
// 0. Read-your-writes gate: wait out the replica's apply lag before hydrating, so the
// rows we read contain the changes the records announce. Retry attempts were
// scheduled with their own delay, so only the first pass gates here.
if (attempt === 0) {
const delayMs = this.#gateDelayMs(records);
if (delayMs > 0) {
await sleepMs(delayMs);
}
}
// 1. Route each record to the held feeds it matches; collect matched runIds per feed.
const matchedRunIdsByFeed = new Map<Feed, Set<string>>();
const addMatch = (feed: Feed, runId: string) => {
if (!feed.resolve) {
// Feed isn't currently waiting (between polls). Drop — its backstop catches gaps.
return;
}
let set = matchedRunIdsByFeed.get(feed);
if (!set) {
set = new Set();
matchedRunIdsByFeed.set(feed, set);
}
set.add(runId);
};
for (const record of records) {
// run feeds: exact runId match.
const runFeeds = env.byRunId.get(record.runId);
if (runFeeds) {
for (const feed of runFeeds) addMatch(feed, record.runId);
}
// batch feeds: exact batchId match (only when the record carries one).
if (record.batchId) {
const batchFeeds = env.byBatchId.get(record.batchId);
if (batchFeeds) {
for (const feed of batchFeeds) addMatch(feed, record.runId);
}
}
// tag feeds.
if (record.tags !== undefined) {
// Full record: prune via the tag index; only feeds whose filter intersects match.
const seen = new Set<Feed>();
for (const tag of record.tags) {
const tagFeeds = env.byTag.get(tag);
if (!tagFeeds) continue;
for (const feed of tagFeeds) {
if (seen.has(feed)) continue;
seen.add(feed);
addMatch(feed, record.runId);
}
}
// Unfiltered tag feeds match every record but live outside the index.
for (const feed of env.unfilteredTagFeeds) addMatch(feed, record.runId);
} else {
// Partial record (no membership data): route to every tag feed as a candidate to
// hydrate-and-classify (rare; the publish side emits full records in practice).
for (const feed of env.tagFeeds) addMatch(feed, record.runId);
}
}
if (matchedRunIdsByFeed.size === 0) {
return;
}
// 2. Batch-hydrate ONCE per column set, then 3. serialize ONCE per (runId, column set).
const runIdsByColumnSig = new Map<string, { skipColumns: string[]; runIds: Set<string> }>();
for (const [feed, runIds] of matchedRunIdsByFeed) {
let group = runIdsByColumnSig.get(feed.columnSig);
if (!group) {
group = { skipColumns: feed.skipColumns, runIds: new Set() };
runIdsByColumnSig.set(feed.columnSig, group);
}
for (const id of runIds) group.runIds.add(id);
}
const hydratedByColumnSig = new Map<string, Map<string, MatchedRow>>();
await Promise.all(
[...runIdsByColumnSig.entries()].map(async ([columnSig, group]) => {
const ids = [...group.runIds];
const rows = await this.options.hydrator.hydrateByIds(
environmentId,
ids,
group.skipColumns
);
this.options.onHydrate?.(rows.length);
const map = new Map<string, MatchedRow>();
for (const row of rows) {
map.set(row.id, { row, value: serializeRunRow(row, group.skipColumns) });
}
hydratedByColumnSig.set(columnSig, map);
})
);
// 3.5 Stale tripwire: a watermarked record whose hydrated row is older (or missing —
// the insert race) read a replica that hadn't applied the change. Withhold those
// rows and re-hydrate shortly. Exhausting the retry budget delivers what we have
// (liveness over freshness) — but a stale emission advances the feed's cursor, so
// it ALSO schedules echo passes past the gate: re-hydrates flowing through normal
// emission, where the working-set diff drops unchanged rows and emits the fresh
// version once the replica catches up. The backstop stays the terminal net.
// Each detection feeds the lag estimator.
const gate = this.options.replicaLag;
const isEchoPass = gate !== undefined && attempt > gate.staleRetries;
const staleRunIds = gate
? this.#detectStaleRuns(records, runIdsByColumnSig, hydratedByColumnSig)
: new Set<string>();
if (attempt > 0 && !isEchoPass) {
const recovered = new Set(records.map((r) => r.runId)).size - staleRunIds.size;
if (recovered > 0) {
gate?.onStaleHydrate?.("recovered", recovered);
}
}
if (staleRunIds.size > 0 && gate) {
const staleRecords = records.filter((record) => staleRunIds.has(record.runId));
// Re-buffer the withheld records so a feed that re-arms between now and the next
// pass replays them instead of waiting for its backstop.
this.#bufferRecent(env, staleRecords);
if (attempt >= gate.staleRetries) {
// Budget exhausted: deliver the stale rows below (liveness) — but a stale emission
// advances the feed's cursor, so keep echoing re-hydrates through normal emission
// (the working-set diff drops unchanged rows, emits the fresh version when the
// replica catches up). Echoes stop once the change ages past the horizon; deeper
// outages are the backstop's job.
if (attempt === gate.staleRetries) {
gate.onStaleHydrate?.("gave_up", staleRunIds.size);
}
staleRunIds.clear();
}
const echoHorizonMs = gate.maxDelayMs * 10;
const newestWatermarkMs = Math.max(...staleRecords.map((record) => record.updatedAtMs ?? 0));
const withinEchoHorizon = Date.now() - newestWatermarkMs < echoHorizonMs;
if (attempt < gate.staleRetries || withinEchoHorizon) {
const retryDelayMs = Math.max(
25,
Math.min(gate.getLagMs() + gate.marginMs, gate.maxDelayMs)
);
const timer = setTimeout(() => {
this.#onBatch(environmentId, env, staleRecords, attempt + 1).catch((error) => {
logger.error("[envChangeRouter] failed to re-hydrate stale rows", {
environmentId,
error,
});
});
}, retryDelayMs);
timer.unref?.();
}
}
// 4. Assemble each feed's matched rows (post-filtering tag feeds against the
// authoritative hydrated row) and resolve its pending wait.
for (const [feed, runIds] of matchedRunIdsByFeed) {
if (!feed.resolve) {
continue; // stopped waiting while we hydrated; its next poll/backstop covers it
}
const hydrated = hydratedByColumnSig.get(feed.columnSig);
if (!hydrated) continue;
const rows: MatchedRow[] = [];
for (const runId of runIds) {
if (staleRunIds.has(runId)) {
continue; // withheld; the scheduled re-hydrate delivers the fresh version
}
const matched = hydrated.get(runId);
if (!matched) continue; // run not found / left the table
if (feed.filter.kind === "tag" && !this.#tagRowMatches(matched.row, feed.filter)) {
continue; // re-confirm tags + createdAt floor against the authoritative row
}
rows.push(matched);
}
if (rows.length > 0) {
feed.resolve({ reason: "notify", rows });
}
// No surviving rows (e.g. a partial-record candidate that didn't actually match):
// leave the feed waiting; nothing relevant changed for it.
}
}
/** Runs whose hydrated row is provably behind its record's watermark (stale content),
* or absent entirely despite a watermark (the insert hasn't applied). Records without
* `updatedAtMs` can't be judged and always pass. */
#detectStaleRuns(
records: ChangeRecord[],
runIdsByColumnSig: Map<string, { skipColumns: string[]; runIds: Set<string> }>,
hydratedByColumnSig: Map<string, Map<string, MatchedRow>>
): Set<string> {
const gate = this.options.replicaLag;
const stale = new Set<string>();
if (!gate) {
return stale;
}
const expectedByRunId = new Map<string, number>();
for (const record of records) {
if (record.updatedAtMs !== undefined) {
const existing = expectedByRunId.get(record.runId);
if (existing === undefined || record.updatedAtMs > existing) {
expectedByRunId.set(record.runId, record.updatedAtMs);
}
}
}
if (expectedByRunId.size === 0) {
return stale;
}
const now = Date.now();
for (const [columnSig, group] of runIdsByColumnSig) {
const hydrated = hydratedByColumnSig.get(columnSig);
for (const runId of group.runIds) {
const expected = expectedByRunId.get(runId);
if (expected === undefined || stale.has(runId)) {
continue;
}
const matched = hydrated?.get(runId);
if (!matched || matched.row.updatedAt.getTime() < expected) {
stale.add(runId);
gate.noteObservedLagMs(now - expected);
}
}
}
return stale;
}
/** Authoritative re-check for tag feeds: the hydrated row carries ALL the filter's tags
* (Electric's `runTags @> ARRAY[...]` semantics) and its createdAt is within the window. */
#tagRowMatches(row: RealtimeRunRow, filter: Extract<FeedFilter, { kind: "tag" }>): boolean {
if (
filter.createdAtFloorMs !== undefined &&
row.createdAt.getTime() < filter.createdAtFloorMs
) {
return false;
}
const rowTags = row.runTags ?? [];
return filter.tags.every((tag) => rowTags.includes(tag));
}
}
@@ -0,0 +1,164 @@
import { validateJWT, type ValidationResult } from "@trigger.dev/core/v3/jwt";
import { $replica } from "~/db.server";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
import type { AuthenticatedEnvironment } from "../apiAuth.server";
export type ValidatePublicJwtKeySuccess = {
ok: true;
environment: AuthenticatedEnvironment;
claims: Record<string, unknown>;
};
export type ValidatePublicJwtKeyError = {
ok: false;
error: string;
};
export type ValidatePublicJwtKeyResult = ValidatePublicJwtKeySuccess | ValidatePublicJwtKeyError;
export async function validatePublicJwtKey(token: string): Promise<ValidatePublicJwtKeyResult> {
// Get the sub claim from the token
// Use the sub claim to find the environment
// Validate the token against the environment.apiKey
// Once that's done, return the environment and the claims
const sub = extractJWTSub(token);
if (!sub) {
return { ok: false, error: "Invalid Public Access Token, missing subject." };
}
const environment = await findEnvironmentById(sub);
if (!environment) {
return { ok: false, error: "Invalid Public Access Token, environment not found." };
}
let result = await validateJWT(
token,
environment.parentEnvironment?.apiKey ?? environment.apiKey
);
// PATs are signed with the env's apiKey at mint time. If the env's apiKey
// has since been rotated, signature verification fails against the current
// key — fall back to any RevokedApiKey rows still in their grace window.
// Only run this query on the failure path so the success path is unchanged.
if (!result.ok) {
result = await validateAgainstRevokedApiKeys(
token,
environment.parentEnvironment?.id ?? environment.id,
result
);
}
if (!result.ok) {
switch (result.code) {
case "ERR_JWT_EXPIRED": {
return {
ok: false,
error:
"Public Access Token has expired. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
};
}
case "ERR_JWT_CLAIM_INVALID": {
return {
ok: false,
error: `Public Access Token is invalid: ${result.error}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
};
}
default: {
return {
ok: false,
error:
"Public Access Token is invalid. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
};
}
}
}
return {
ok: true,
environment,
claims: result.payload,
};
}
async function validateAgainstRevokedApiKeys(
token: string,
signingEnvironmentId: string,
primaryResult: ValidationResult
): Promise<ValidationResult> {
const revokedApiKeys = await $replica.revokedApiKey.findMany({
where: {
runtimeEnvironmentId: signingEnvironmentId,
expiresAt: { gt: new Date() },
},
select: { apiKey: true },
});
for (const { apiKey } of revokedApiKeys) {
const fallbackResult = await validateJWT(token, apiKey);
if (fallbackResult.ok) {
return fallbackResult;
}
}
return primaryResult;
}
export function isPublicJWT(token: string): boolean {
// Split the token
const parts = token.split(".");
if (parts.length !== 3) return false;
try {
// Decode the payload (second part)
const payload = JSON.parse(decodeBase64Url(parts[1]));
if (payload === null || typeof payload !== "object") return false;
// Check for the pub: true claim
return "pub" in payload && payload.pub === true;
} catch (_error) {
// If there's any error in decoding or parsing, it's not a valid JWT
return false;
}
}
export function extractJwtSigningSecretKey(environment: AuthenticatedEnvironment) {
return environment.parentEnvironment?.apiKey ?? environment.apiKey;
}
function extractJWTSub(token: string): string | undefined {
// Split the token
const parts = token.split(".");
if (parts.length !== 3) return;
try {
// Decode the payload (second part)
const payload = JSON.parse(decodeBase64Url(parts[1]));
if (payload === null || typeof payload !== "object") return;
// Check for the pub: true claim
return "sub" in payload && typeof payload.sub === "string" ? payload.sub : undefined;
} catch (_error) {
// If there's any error in decoding or parsing, it's not a valid JWT
return;
}
}
function decodeBase64Url(str: string): string {
// Replace URL-safe characters and add padding
str = str.replace(/-/g, "+").replace(/_/g, "/");
switch (str.length % 4) {
case 2:
str += "==";
break;
case 3:
str += "=";
break;
}
// Decode using Node.js Buffer
return Buffer.from(str, "base64").toString("utf8");
}
@@ -0,0 +1,41 @@
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
import { extractJwtSigningSecretKey } from "./jwtAuth.server";
type Environment = Parameters<typeof extractJwtSigningSecretKey>[0];
export type MintRunTokenOptions = {
/** Include the input-stream write scope (needed for steering messages from the playground). */
includeInputStreamWrite?: boolean;
/** Token expiration. Defaults to "1h". */
expirationTime?: string;
};
/**
* Mint a run-scoped public access token (JWT) for browser subscription to a
* run's realtime streams.
*
* Used by:
* - The playground action to give a freshly triggered chat session a token.
* - The run details page to let the agent view subscribe to the chat stream
* of an existing run (read-only).
*/
export async function mintRunToken(
environment: Environment,
runFriendlyId: string,
options: MintRunTokenOptions = {}
): Promise<string> {
const scopes = [`read:runs:${runFriendlyId}`];
if (options.includeInputStreamWrite) {
scopes.push(`write:inputStreams:${runFriendlyId}`);
}
return internal_generateJWT({
secretKey: extractJwtSigningSecretKey(environment),
payload: {
sub: environment.id,
pub: true,
scopes,
},
expirationTime: options.expirationTime ?? "1h",
});
}
@@ -0,0 +1,40 @@
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
import { extractJwtSigningSecretKey } from "./jwtAuth.server";
type Environment = Parameters<typeof extractJwtSigningSecretKey>[0];
export type MintSessionTokenOptions = {
/** Token expiration. Defaults to "1h". */
expirationTime?: string;
};
/**
* Mint a session-scoped public access token (JWT) covering both `.in`
* append and `.out` subscribe for a session's realtime channels.
*
* Returned by `POST /api/v1/sessions` so the browser holds a single
* long-lived token that survives across runs (sessions outlive any
* single run). Includes both read and write scopes since the transport
* needs both: read for SSE subscribe on `.out`, write for `.in` appends
* (`stop`, follow-up messages, action chunks).
*/
export async function mintSessionToken(
environment: Environment,
sessionAddressingKey: string,
options: MintSessionTokenOptions = {}
): Promise<string> {
const scopes = [
`read:sessions:${sessionAddressingKey}`,
`write:sessions:${sessionAddressingKey}`,
];
return internal_generateJWT({
secretKey: extractJwtSigningSecretKey(environment),
payload: {
sub: environment.id,
pub: true,
scopes,
},
expirationTime: options.expirationTime ?? "1h",
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,272 @@
import { getMeter } from "@internal/tracing";
import { $replica } from "~/db.server";
import { runStore } from "~/v3/runStore.server";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { getCachedLimit } from "../platform.v3.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { ClickHouseRunListResolver } from "./clickHouseRunListResolver.server";
import { EnvChangeRouter, type EnvChangeSource } from "./envChangeRouter.server";
import { NativeRealtimeClient } from "./nativeRealtimeClient.server";
import { RealtimeConcurrencyLimiter } from "./realtimeConcurrencyLimiter.server";
import { getRunChangeNotifier } from "./runChangeNotifierInstance.server";
import { RedisReplayCursorStore } from "./replayCursorStore.server";
import { createPostgresReplicaLagSource, ReplicaLagEstimator } from "./replicaLagEstimator.server";
import { RunHydrator } from "./runReader.server";
// Process-singleton wiring for the native realtime client; only constructed when a
// request actually routes to it, so a disabled webapp never instantiates it.
function initializeNativeRealtimeClient(): NativeRealtimeClient {
const meter = getMeter("realtime-native");
const wakeups = meter.createCounter("realtime_native.wakeups", {
description:
"Live realtime wakeups by reason. A rising 'timeout' share suggests a write site is missing its publishChangeRecord delegate.",
});
const runSetResolves = meter.createCounter("realtime_native.runset_resolves", {
description:
"Multi-run (tag-list/batch) resolve+hydrate outcomes. 'hit'/'coalesced' vs 'miss' shows how effectively concurrent same-filter feeds share a single ClickHouse + Postgres query.",
});
const runSetQueryMs = meter.createHistogram("realtime_native.runset_query_ms", {
description: "Latency of the multi-run resolve (ClickHouse) and hydrate (Postgres) stages.",
unit: "ms",
});
const livePollPaths = meter.createCounter("realtime_native.live_polls", {
description:
"How live polls resolved. 'fast-hydrate' = router wake with rows hydrated by id (no ClickHouse); 'full-resolve' = backstop; 'cold-resolve' = fresh env subscription probed once.",
});
const routerHydrates = meter.createCounter("realtime_native.router_hydrated_runs", {
description:
"Runs hydrated by the EnvChangeRouter's batch-hydrate (one query per column set per wake, shared across all feeds matching the same run).",
});
const resolveAdmissionWaits = meter.createCounter("realtime_native.resolve_admission_waits", {
description:
"Fresh ClickHouse resolves that had to queue for an admission permit. A rising count means a distinct-filter reconnect stampede is being throttled (the gate is doing its job).",
});
const replays = meter.createCounter("realtime_native.replays", {
description:
"Buffered change records replayed to a newly-armed feed (inter-poll gap recovery). 'delivered' = rows reached the feed; 'empty' = candidates hydrated but none survived the filter/diff.",
});
const replayEvictions = meter.createCounter("realtime_native.replay_evictions", {
description:
"Replay-buffer evictions. 'window' expiry is normal; 'cap' means an env churns more runs inside the window than the buffer holds (replay guarantee degrading — retune the knobs).",
});
const deliveryLagMs = meter.createHistogram("realtime_native.delivery_lag_ms", {
description:
"Live emissions: now minus the newest emitted row's updatedAt (PG clock vs app clock, so approximate). The end-to-end delivery SLI — a p99 near the backstop hold means wakes are being missed.",
unit: "ms",
});
const emittedRows = meter.createHistogram("realtime_native.emitted_rows", {
description:
"Rows per live emission. Deltas should be small; a fat tail means working-set/offset-floor fallbacks are re-emitting full sets.",
unit: "rows",
});
const backstops = meter.createCounter("realtime_native.backstops", {
description:
"Backstop full resolves by outcome. 'empty' is normal idle behavior; sustained 'delivered' means the notify/replay path missed changes — alert on it.",
});
const concurrencyRejections = meter.createCounter("realtime_native.concurrency_rejections", {
description: "Polls rejected (429) by the per-env concurrency limiter.",
});
const replayCursorOps = meter.createCounter("realtime_native.replay_cursor_ops", {
description:
"Shared replay-cursor store operations by outcome. Errors degrade hops to cold resolves (watch live_polls{path='cold-resolve'} rise with them), never failed polls.",
});
const staleHydrates = meter.createCounter("realtime_native.stale_hydrates", {
description:
"Wake hydrates the read-your-writes tripwire caught reading behind the publish. 'recovered' = a retry delivered the fresh row; sustained 'gave_up' means replica lag is outrunning the retry budget.",
});
const limiter = new RealtimeConcurrencyLimiter({
keyPrefix: "tr:realtime:native:concurrency",
redis: {
port: env.RATE_LIMIT_REDIS_PORT,
host: env.RATE_LIMIT_REDIS_HOST,
username: env.RATE_LIMIT_REDIS_USERNAME,
password: env.RATE_LIMIT_REDIS_PASSWORD,
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
},
});
// Fleet-shared replay cursors (one timestamp per connection) on the same Redis as the
// change channel, so a load-balancer hop reads the connection's true inter-poll gap.
const replayCursorStore =
env.REALTIME_BACKEND_NATIVE_SHARED_REPLAY_CURSORS === "1"
? new RedisReplayCursorStore({
redis: {
host: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_HOST,
port: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_PORT,
username: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_USERNAME,
password: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_PASSWORD,
tlsDisabled: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_TLS_DISABLED === "true",
clusterMode: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_CLUSTER_MODE_ENABLED === "1",
},
ttlMs: env.REALTIME_BACKEND_NATIVE_WORKING_SET_TTL_MS,
onResult: (op, ok) => replayCursorOps.add(1, { op, result: ok ? "ok" : "error" }),
})
: undefined;
// One RunHydrator shared by the router and the client, so its single-flight + short-TTL cache covers both.
const runReader = new RunHydrator({
replica: $replica,
runStore,
cacheTtlMs: env.REALTIME_BACKEND_NATIVE_RUN_CACHE_TTL_MS,
maxCacheEntries: env.REALTIME_BACKEND_NATIVE_RUN_CACHE_MAX_ENTRIES,
});
// Read-your-writes gate: the estimator samples replica lag (reader-side only, paused
// when idle) and the router delays wake hydrates by it, anchored to each record's
// updatedAtMs — so a publish racing the replica's apply is waited out, not read stale.
const lagEstimator =
env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_GATE_ENABLED === "1"
? new ReplicaLagEstimator({
source: createPostgresReplicaLagSource($replica),
sampleIntervalMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_SAMPLE_INTERVAL_MS,
idleAfterMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_IDLE_AFTER_MS,
windowMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_WINDOW_MS,
defaultLagMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_DEFAULT_MS,
observedFloorTtlMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_OBSERVED_FLOOR_TTL_MS,
})
: undefined;
// The notifier wrapped so router activity keeps the lag sampler warm.
const notifier = getRunChangeNotifier();
const source: EnvChangeSource = lagEstimator
? {
subscribeToEnv(environmentId, onBatch) {
lagEstimator.touch();
return notifier.subscribeToEnv(environmentId, (records) => {
lagEstimator.touch();
onBatch(records);
});
},
}
: notifier;
const router = new EnvChangeRouter({
source,
hydrator: runReader,
onHydrate: (runCount) => routerHydrates.add(runCount),
replayWindowMs: env.REALTIME_BACKEND_NATIVE_REPLAY_WINDOW_MS,
replayMaxRunsPerEnv: env.REALTIME_BACKEND_NATIVE_REPLAY_MAX_RUNS,
unsubscribeLingerMs: env.REALTIME_BACKEND_NATIVE_UNSUBSCRIBE_LINGER_MS,
onReplay: (result) => replays.add(1, { result }),
onReplayEviction: (reason) => replayEvictions.add(1, { reason }),
replicaLag: lagEstimator
? {
getLagMs: () => lagEstimator.getLagMs(),
noteObservedLagMs: (lagMs) => lagEstimator.noteObservedLagMs(lagMs),
marginMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_MARGIN_MS,
maxDelayMs: env.REALTIME_BACKEND_NATIVE_REPLICA_LAG_MAX_DELAY_MS,
staleRetries: env.REALTIME_BACKEND_NATIVE_STALE_HYDRATE_RETRIES,
onStaleHydrate: (outcome, runCount) => staleHydrates.add(runCount, { outcome }),
}
: undefined,
});
const client = new NativeRealtimeClient({
runReader,
runListResolver: new ClickHouseRunListResolver({
getClickhouse: (organizationId) =>
clickhouseFactory.getClickhouseForOrganization(organizationId, "realtime"),
prisma: $replica,
}),
router,
limiter,
cachedLimitProvider: {
async getCachedLimit(organizationId, defaultValue) {
const result = await getCachedLimit(
organizationId,
"realtimeConcurrentConnections",
defaultValue
);
return result.val;
},
},
defaultConcurrencyLimit: env.REALTIME_BACKEND_NATIVE_DEFAULT_CONCURRENCY_LIMIT,
livePollTimeoutMs: env.REALTIME_BACKEND_NATIVE_LIVE_POLL_TIMEOUT_MS,
livePollJitterRatio: env.REALTIME_BACKEND_NATIVE_LIVE_POLL_JITTER_RATIO,
maximumCreatedAtFilterAgeMs: env.REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS,
maxListResults: env.REALTIME_BACKEND_NATIVE_MAX_LIST_RESULTS,
runSetResolveCacheTtlMs: env.REALTIME_BACKEND_NATIVE_RUNSET_CACHE_TTL_MS,
runSetResolveCacheMaxEntries: env.REALTIME_BACKEND_NATIVE_RUNSET_CACHE_MAX_ENTRIES,
listCacheMaxEntries: env.REALTIME_BACKEND_NATIVE_WORKING_SET_MAX_ENTRIES,
workingSetCacheTtlMs: env.REALTIME_BACKEND_NATIVE_WORKING_SET_TTL_MS,
runSetCreatedAtBucketMs: env.REALTIME_BACKEND_NATIVE_RUNSET_CREATED_AT_BUCKET_MS,
holdOnEmpty: env.REALTIME_BACKEND_NATIVE_HOLD_ON_EMPTY === "1",
resolveAdmissionLimit: env.REALTIME_BACKEND_NATIVE_RESOLVE_ADMISSION_LIMIT,
replayCursorStore,
onWakeup: (reason) => wakeups.add(1, { reason }),
onLivePollPath: (path) => livePollPaths.add(1, { path }),
onRunSetResolve: (result) => runSetResolves.add(1, { result }),
onRunSetQuery: (stage, ms) => runSetQueryMs.record(ms, { stage }),
onResolveAdmissionWait: () => resolveAdmissionWaits.add(1),
onEmit: (path, lagMs, rowCount) => {
deliveryLagMs.record(Math.max(lagMs, 0), { path });
emittedRows.record(rowCount);
},
onBackstopResult: (result) => backstops.add(1, { result }),
onConcurrencyRejected: () => concurrencyRejections.add(1),
});
meter
.createObservableGauge("realtime_native.working_set_size", {
description:
"Entries in the per-handle working-set cache (one per active multi-run feed session).",
})
.addCallback((result) => result.observe(client.workingSetCacheSize));
meter
.createObservableGauge("realtime_native.resolve_admission_in_use", {
description:
"Fresh ClickHouse resolves currently holding an admission permit (live concurrency against the gate's limit).",
})
.addCallback((result) => result.observe(client.resolveAdmissionInUse));
meter
.createObservableGauge("realtime_native.held_feeds", {
description: "Long-polls currently held, by feed kind — the system's capacity unit.",
})
.addCallback((result) => {
const counts = router.heldFeedCounts;
result.observe(counts.run, { kind: "run" });
result.observe(counts.tag, { kind: "tag" });
result.observe(counts.batch, { kind: "batch" });
});
meter
.createObservableGauge("realtime_native.active_envs", {
description:
"Environments currently routed on this instance (held feeds + lingering subscriptions).",
})
.addCallback((result) => result.observe(router.activeEnvCount));
if (lagEstimator) {
meter
.createObservableGauge("realtime_native.replica_lag_estimate_ms", {
description:
"The read-your-writes gate's current replica-lag estimate (max sample in the window). Wake hydrates are delayed by roughly this much past each change's commit.",
})
.addCallback((result) => result.observe(lagEstimator.getLagMs()));
}
return client;
}
export function getNativeRealtimeClient(): NativeRealtimeClient {
return singleton("nativeRealtimeClient", initializeNativeRealtimeClient);
}
@@ -0,0 +1,112 @@
import type { Callback, Result } from "ioredis";
import type { RedisClient, RedisWithClusterOptions } from "~/redis.server";
import { createRedisClient } from "~/redis.server";
import { logger } from "../logger.server";
export type RealtimeConcurrencyLimiterOptions = {
redis: RedisWithClusterOptions;
keyPrefix: string;
/** How long a tracked request lives before it's swept as stale (seconds). */
expiryTimeInSeconds?: number;
connectionName?: string;
};
/**
* Per-environment concurrent-connection limiter for realtime long-polls; a standalone copy of the limiter in
* `realtimeClient.server.ts` (identical Lua + key shape, different key prefix) so the native backend tracks independently.
*/
export class RealtimeConcurrencyLimiter {
private redis: RedisClient;
private expiryTimeInSeconds: number;
constructor(private options: RealtimeConcurrencyLimiterOptions) {
this.redis = createRedisClient(
options.connectionName ?? "trigger:realtime:native:concurrency",
options.redis
);
this.expiryTimeInSeconds = options.expiryTimeInSeconds ?? 60 * 5;
this.#registerCommands();
}
async incrementAndCheck(
environmentId: string,
requestId: string,
limit: number
): Promise<boolean> {
const key = this.#getKey(environmentId);
const now = Date.now();
const result = await this.redis.incrementAndCheckRealtimeNativeConcurrency(
key,
now.toString(),
requestId,
this.expiryTimeInSeconds.toString(),
(now - this.expiryTimeInSeconds * 1000).toString(),
limit.toString()
);
return result === 1;
}
async decrement(environmentId: string, requestId: string): Promise<void> {
const key = this.#getKey(environmentId);
await this.redis.zrem(key, requestId);
}
#getKey(environmentId: string): string {
return `${this.options.keyPrefix}:${environmentId}`;
}
#registerCommands() {
this.redis.defineCommand("incrementAndCheckRealtimeNativeConcurrency", {
numberOfKeys: 1,
lua: /* lua */ `
local concurrencyKey = KEYS[1]
local timestamp = tonumber(ARGV[1])
local requestId = ARGV[2]
local expiryTime = tonumber(ARGV[3])
local cutoffTime = tonumber(ARGV[4])
local limit = tonumber(ARGV[5])
-- Remove expired entries
redis.call('ZREMRANGEBYSCORE', concurrencyKey, '-inf', cutoffTime)
-- Add the new request to the sorted set
redis.call('ZADD', concurrencyKey, timestamp, requestId)
-- Set the expiry time on the key
redis.call('EXPIRE', concurrencyKey, expiryTime)
-- Get the total number of concurrent requests
local totalRequests = redis.call('ZCARD', concurrencyKey)
-- Check if the limit has been exceeded
if totalRequests > limit then
redis.call('ZREM', concurrencyKey, requestId)
return 0
end
return 1
`,
});
this.redis.on("error", (error) => {
logger.error("[realtimeConcurrencyLimiter] redis error", { error });
});
}
}
declare module "ioredis" {
interface RedisCommander<Context> {
incrementAndCheckRealtimeNativeConcurrency(
key: string,
timestamp: string,
requestId: string,
expiryTime: string,
cutoffTime: string,
limit: string,
callback?: Callback<number>
): Result<number, Context>;
}
}
@@ -0,0 +1,479 @@
import type { LogLevel } from "@trigger.dev/core/logger";
import { Logger } from "@trigger.dev/core/logger";
import type { RedisOptions } from "ioredis";
import Redis from "ioredis";
import { defaultReconnectOnError } from "@internal/redis";
import { env } from "~/env.server";
import type { StreamIngestor, StreamResponder, StreamResponseOptions } from "./types";
export type RealtimeStreamsOptions = {
redis: RedisOptions | undefined;
logger?: Logger;
logLevel?: LogLevel;
inactivityTimeoutMs?: number; // Close stream after this many ms of no new data (default: 15000)
};
// Legacy constant for backward compatibility (no longer written, but still recognized when reading)
const END_SENTINEL = "<<CLOSE_STREAM>>";
// Internal types for stream pipeline
type StreamChunk =
| { type: "ping" }
| { type: "data"; redisId: string; data: string }
| { type: "legacy-data"; redisId: string; data: string };
// Class implementing both interfaces
export class RedisRealtimeStreams implements StreamIngestor, StreamResponder {
private logger: Logger;
private inactivityTimeoutMs: number;
// Shared connection for short-lived non-blocking operations (XADD, XREVRANGE, EXPIRE).
// Lazily created on first use so we don't open a connection if only streamResponse is called.
private _sharedRedis: Redis | undefined;
constructor(private options: RealtimeStreamsOptions) {
this.logger = options.logger ?? new Logger("RedisRealtimeStreams", options.logLevel ?? "info");
this.inactivityTimeoutMs = options.inactivityTimeoutMs ?? 15000; // Default: 15 seconds
}
private get sharedRedis(): Redis {
if (!this._sharedRedis) {
this._sharedRedis = new Redis({
reconnectOnError: defaultReconnectOnError,
...this.options.redis,
connectionName: "realtime:shared",
});
}
return this._sharedRedis;
}
async initializeStream(
runId: string,
streamId: string
): Promise<{ responseHeaders?: Record<string, string> }> {
return {};
}
async streamResponse(
request: Request,
runId: string,
streamId: string,
signal: AbortSignal,
options?: StreamResponseOptions
): Promise<Response> {
const redis = new Redis({
reconnectOnError: defaultReconnectOnError,
...this.options.redis,
connectionName: "realtime:streamResponse",
});
const streamKey = `stream:${runId}:${streamId}`;
let isCleanedUp = false;
const stream = new ReadableStream<StreamChunk>({
start: async (controller) => {
// Start from lastEventId if provided, otherwise from beginning
let lastId = options?.lastEventId ?? "0";
let retryCount = 0;
const maxRetries = 3;
let lastDataTime = Date.now();
let lastEnqueueTime = Date.now();
const blockTimeMs = 5000;
const pingIntervalMs = 10000; // 10 seconds
if (options?.lastEventId) {
this.logger.debug("[RealtimeStreams][streamResponse] Resuming from lastEventId", {
streamKey,
lastEventId: options?.lastEventId,
});
}
try {
while (!signal.aborted) {
// Check if we need to send a ping
const timeSinceLastEnqueue = Date.now() - lastEnqueueTime;
if (timeSinceLastEnqueue >= pingIntervalMs) {
controller.enqueue({ type: "ping" });
lastEnqueueTime = Date.now();
}
// Compute inactivity threshold once to use consistently in both branches
const inactivityThresholdMs = options?.timeoutInSeconds
? options.timeoutInSeconds * 1000
: this.inactivityTimeoutMs;
try {
const messages = await redis.xread(
"COUNT",
100,
"BLOCK",
blockTimeMs,
"STREAMS",
streamKey,
lastId
);
retryCount = 0;
if (messages && messages.length > 0) {
const [_key, entries] = messages[0];
let foundData = false;
for (let i = 0; i < entries.length; i++) {
const [id, fields] = entries[i];
lastId = id;
if (fields && fields.length >= 2) {
// Extract the data field from the Redis entry
// Fields format: ["field1", "value1", "field2", "value2", ...]
let data: string | null = null;
for (let j = 0; j < fields.length; j += 2) {
if (fields[j] === "data") {
data = fields[j + 1];
break;
}
}
// Handle legacy entries that don't have field names (just data at index 1)
if (data === null && fields.length >= 2) {
data = fields[1];
}
if (data) {
// Skip legacy END_SENTINEL entries (backward compatibility)
if (data === END_SENTINEL) {
continue;
}
// Enqueue structured chunk with Redis stream ID
controller.enqueue({
type: "data",
redisId: id,
data,
});
foundData = true;
lastDataTime = Date.now();
lastEnqueueTime = Date.now();
if (signal.aborted) {
controller.close();
return;
}
}
}
}
// If we didn't find any data in this batch, might have only seen sentinels
if (!foundData) {
// Check for inactivity timeout
const inactiveMs = Date.now() - lastDataTime;
if (inactiveMs >= inactivityThresholdMs) {
this.logger.debug(
"[RealtimeStreams][streamResponse] Closing stream due to inactivity",
{
streamKey,
inactiveMs,
threshold: inactivityThresholdMs,
}
);
controller.close();
return;
}
}
} else {
// No messages received (timed out on BLOCK)
// Check for inactivity timeout
const inactiveMs = Date.now() - lastDataTime;
if (inactiveMs >= inactivityThresholdMs) {
this.logger.debug(
"[RealtimeStreams][streamResponse] Closing stream due to inactivity",
{
streamKey,
inactiveMs,
threshold: inactivityThresholdMs,
}
);
controller.close();
return;
}
}
} catch (error) {
if (signal.aborted) break;
this.logger.error(
"[RealtimeStreams][streamResponse] Error reading from Redis stream:",
{
error,
}
);
retryCount++;
if (retryCount >= maxRetries) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount));
}
}
} catch (error) {
this.logger.error("[RealtimeStreams][streamResponse] Fatal error in stream processing:", {
error,
});
controller.error(error);
} finally {
await cleanup();
}
},
cancel: async () => {
await cleanup();
},
})
.pipeThrough(
// Transform 1: Buffer partial lines across Redis entries
(() => {
let buffer = "";
let lastRedisId = "0";
return new TransformStream<StreamChunk, StreamChunk & { line: string }>({
transform(chunk, controller) {
if (chunk.type === "ping") {
controller.enqueue(chunk as any);
} else if (chunk.type === "data" || chunk.type === "legacy-data") {
// Buffer partial lines: accumulate until we see newlines
buffer += chunk.data;
// Split on newlines
const lines = buffer.split("\n");
// The last element might be incomplete, hold it back in buffer
buffer = lines.pop() || "";
// Emit complete lines with the Redis ID of the chunk that completed them
for (const line of lines) {
if (line.trim().length > 0) {
controller.enqueue({
...chunk,
line,
});
}
}
// Update last Redis ID for next iteration
lastRedisId = chunk.redisId;
}
},
flush(controller) {
// On stream end, emit any leftover buffered text
if (buffer.trim().length > 0) {
controller.enqueue({
type: "data",
redisId: lastRedisId,
data: "",
line: buffer.trim(),
});
}
},
});
})()
)
.pipeThrough(
// Transform 2: Format as SSE
new TransformStream<StreamChunk & { line?: string }, string>({
transform(chunk, controller) {
if (chunk.type === "ping") {
controller.enqueue(`: ping\n\n`);
} else if ((chunk.type === "data" || chunk.type === "legacy-data") && chunk.line) {
// Use Redis stream ID as SSE event ID
controller.enqueue(`id: ${chunk.redisId}\ndata: ${chunk.line}\n\n`);
}
},
})
)
.pipeThrough(new TextEncoderStream());
async function cleanup() {
if (isCleanedUp) return;
isCleanedUp = true;
// disconnect() tears down the TCP socket immediately, which causes any
// pending XREAD BLOCK to reject right away instead of waiting for the
// block timeout to elapse. quit() would queue behind the blocking command.
redis.disconnect();
}
signal.addEventListener("abort", cleanup, { once: true });
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
async ingestData(
stream: ReadableStream<Uint8Array>,
runId: string,
streamId: string,
clientId: string,
resumeFromChunk?: number
): Promise<Response> {
const redis = this.sharedRedis;
const streamKey = `stream:${runId}:${streamId}`;
const startChunk = resumeFromChunk ?? 0;
// Start counting from the resume point, not from 0
let currentChunkIndex = startChunk;
try {
const textStream = stream.pipeThrough(new TextDecoderStream());
const reader = textStream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done || !value) {
break;
}
// Write each chunk with its index and clientId
this.logger.debug("[RedisRealtimeStreams][ingestData] Writing chunk", {
streamKey,
runId,
clientId,
chunkIndex: currentChunkIndex,
resumeFromChunk: startChunk,
value,
});
await redis.xadd(
streamKey,
"MAXLEN",
"~",
String(env.REALTIME_STREAM_MAX_LENGTH),
"*",
"clientId",
clientId,
"chunkIndex",
currentChunkIndex.toString(),
"data",
value
);
currentChunkIndex++;
}
// Set TTL for cleanup when stream is done
await redis.expire(streamKey, env.REALTIME_STREAM_TTL);
return new Response(null, { status: 200 });
} catch (error) {
if (error instanceof Error) {
if ("code" in error && error.code === "ECONNRESET") {
this.logger.info("[RealtimeStreams][ingestData] Connection reset during ingestData:", {
error,
});
return new Response(null, { status: 500 });
}
}
this.logger.error("[RealtimeStreams][ingestData] Error in ingestData:", { error });
return new Response(null, { status: 500 });
}
}
async appendPart(part: string, partId: string, runId: string, streamId: string): Promise<void> {
const redis = this.sharedRedis;
const streamKey = `stream:${runId}:${streamId}`;
await redis.xadd(
streamKey,
"MAXLEN",
"~",
String(env.REALTIME_STREAM_MAX_LENGTH),
"*",
"clientId",
"",
"chunkIndex",
"0",
"data",
JSON.stringify(part) + "\n"
);
// Set TTL for cleanup when stream is done
await redis.expire(streamKey, env.REALTIME_STREAM_TTL);
}
async getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise<number> {
const redis = this.sharedRedis;
const streamKey = `stream:${runId}:${streamId}`;
try {
// Paginate through the stream from newest to oldest until we find this client's last chunk
const batchSize = 100;
let lastId = "+"; // Start from newest
while (true) {
const entries = await redis.xrevrange(streamKey, lastId, "-", "COUNT", batchSize);
if (!entries || entries.length === 0) {
// Reached the beginning of the stream, no chunks from this client
this.logger.debug(
"[RedisRealtimeStreams][getLastChunkIndex] No chunks found for client",
{
streamKey,
clientId,
}
);
return -1;
}
// Search through this batch for the client's last chunk
for (const [_id, fields] of entries) {
let entryClientId: string | null = null;
let chunkIndex: number | null = null;
let data: string | null = null;
for (let i = 0; i < fields.length; i += 2) {
if (fields[i] === "clientId") {
entryClientId = fields[i + 1];
}
if (fields[i] === "chunkIndex") {
chunkIndex = parseInt(fields[i + 1], 10);
}
if (fields[i] === "data") {
data = fields[i + 1];
}
}
// Skip legacy END_SENTINEL entries (backward compatibility)
if (data === END_SENTINEL) {
continue;
}
// Check if this entry is from our client and has a chunkIndex
if (entryClientId === clientId && chunkIndex !== null) {
this.logger.debug("[RedisRealtimeStreams][getLastChunkIndex] Found last chunk", {
streamKey,
clientId,
chunkIndex,
});
return chunkIndex;
}
}
// Move to next batch (older entries)
// Use the ID of the last entry in this batch as the new cursor
lastId = `(${entries[entries.length - 1][0]}`; // Exclusive range with (
}
} catch (error) {
this.logger.error("[RedisRealtimeStreams][getLastChunkIndex] Error getting last chunk:", {
error,
streamKey,
clientId,
});
// Return -1 to indicate we don't know what the server has
return -1;
}
}
async readRecords(): Promise<never> {
throw new Error("readRecords is not implemented for Redis realtime streams");
}
}
@@ -0,0 +1,145 @@
import { createRedisClient, type RedisClient, type RedisWithClusterOptions } from "~/redis.server";
import { logger } from "../logger.server";
import { BoundedTtlCache } from "./boundedTtlCache";
/**
* Per-connection replay cursors ("when did this connection last receive data"), keyed by the
* env-prefixed working-set key. Sharing them fleet-wide makes an instance hop look like a normal
* inter-poll gap instead of an unknown one, so hops stop triggering cold resolves and full-window
* replays. Values are single timestamps, so the shared store stays cheap.
*/
export interface ReplayCursorStore {
/** The connection's last-response timestamp; undefined on miss OR error (the caller
* degrades to a cold probe / full-window replay, never blocks the poll). */
get(key: string): Promise<number | undefined>;
/** Fire-and-forget stamp; must never throw. */
set(key: string, ms: number): void;
}
/** Per-instance fallback with the same shape (used when the shared store is disabled, and in tests). */
export class InMemoryReplayCursorStore implements ReplayCursorStore {
readonly #cache: BoundedTtlCache<number>;
constructor(ttlMs: number, maxEntries: number) {
this.#cache = new BoundedTtlCache<number>(ttlMs, maxEntries);
}
async get(key: string): Promise<number | undefined> {
return this.#cache.get(key);
}
set(key: string, ms: number): void {
this.#cache.set(key, ms);
}
}
export type RedisReplayCursorStoreOptions = {
redis: RedisWithClusterOptions;
/** Entry TTL (ms); matches the working-set TTL so both views of a connection age out together. */
ttlMs: number;
/** Read deadline (ms): a slow or down Redis degrades the poll to a cold probe instead of stalling it. */
getTimeoutMs?: number;
keyPrefix?: string;
connectionName?: string;
/** Observability hook: a store op settled (errors are the degradation signal, not failures). */
onResult?: (op: "get" | "set", ok: boolean) => void;
};
const DEFAULT_KEY_PREFIX = "realtime:replay-cursor:";
const DEFAULT_GET_TIMEOUT_MS = 250;
const TIMED_OUT = Symbol("replay-cursor-get-timeout");
export class RedisReplayCursorStore implements ReplayCursorStore {
#client: RedisClient | undefined;
constructor(private readonly options: RedisReplayCursorStoreOptions) {}
async get(key: string): Promise<number | undefined> {
try {
const raw = await this.#getWithDeadline(this.#key(key));
if (raw === TIMED_OUT) {
this.options.onResult?.("get", false);
logger.warn("[replayCursorStore] replay-cursor read timed out", { key });
return undefined;
}
this.options.onResult?.("get", true);
if (raw === null) {
return undefined;
}
const ms = Number(raw);
return Number.isFinite(ms) && ms > 0 ? ms : undefined;
} catch (error) {
this.options.onResult?.("get", false);
logger.error("[replayCursorStore] failed to read a replay cursor", { error, key });
return undefined;
}
}
/** GET raced against the read deadline (ioredis queues commands while disconnected, which
* would otherwise stall every poll start through an outage). */
#getWithDeadline(key: string): Promise<string | null | typeof TIMED_OUT> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => resolve(TIMED_OUT),
this.options.getTimeoutMs ?? DEFAULT_GET_TIMEOUT_MS
);
timer.unref?.();
this.#ensureClient()
.get(key)
.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
}
);
});
}
set(key: string, ms: number): void {
try {
this.#ensureClient()
.set(this.#key(key), String(ms), "PX", this.options.ttlMs)
.then(
() => this.options.onResult?.("set", true),
(error) => {
this.options.onResult?.("set", false);
logger.error("[replayCursorStore] failed to write a replay cursor", { error, key });
}
);
} catch (error) {
this.options.onResult?.("set", false);
logger.error("[replayCursorStore] failed to write a replay cursor", { error, key });
}
}
async quit(): Promise<void> {
const client = this.#client;
this.#client = undefined;
if (!client) return;
try {
// Bounded graceful QUIT; cursor writes are best-effort, so force-close beyond it.
await Promise.race([client.quit(), new Promise((resolve) => setTimeout(resolve, 500))]);
} catch {
// force-close below
}
client.disconnect();
}
#key(key: string): string {
return `${this.options.keyPrefix ?? DEFAULT_KEY_PREFIX}${key}`;
}
#ensureClient(): RedisClient {
if (!this.#client) {
this.#client = createRedisClient(
this.options.connectionName ?? "trigger:realtime:replay-cursors",
this.options.redis
);
}
return this.#client;
}
}
@@ -0,0 +1,257 @@
import { logger } from "~/services/logger.server";
/**
* ReplicaLagEstimator — tracks how far the read replica trails the primary so the
* EnvChangeRouter can delay wake-path hydrates just long enough to read their own writes.
* Two inputs: a ReplicaLagSource (active, reader-side only — never queries the primary)
* sampled on an interval while the router is busy, and passive observations fed back by
* the router's stale-hydrate tripwire. The estimate is the max over a short window —
* floored by recent observations — so spikes widen the delay immediately and decay back
* out as fresh samples land.
*/
type RawQueryable = {
$queryRawUnsafe<T = unknown>(query: string): Promise<T>;
};
/** A dialect-specific reader-side lag measure. `sampleLagMs()` returns the current lag in
* ms, or undefined when lag is genuinely unmeasurable right now (NOT an error — errors
* throw, and the composing source uses them to rule the dialect out). */
export interface ReplicaLagSource {
readonly name: string;
sampleLagMs(): Promise<number | undefined>;
}
/** Aurora: replicas share the storage layer and reject every standard WAL function;
* `aurora_replica_status()` is the only live lag source. Max across readers, since the
* `$replica` pool balances over all of them. No reader rows = `$replica` is the writer =
* no lag. Throws on non-Aurora (the function doesn't exist). */
export class AuroraReplicaLagSource implements ReplicaLagSource {
readonly name = "aurora";
constructor(private readonly db: RawQueryable) {}
async sampleLagMs(): Promise<number | undefined> {
const rows = await this.db.$queryRawUnsafe<{ lag: number | null }[]>(
`SELECT max(replica_lag_in_msec)::float8 AS lag FROM aurora_replica_status() WHERE session_id <> 'MASTER_SESSION_ID' AND replica_lag_in_msec IS NOT NULL`
);
const lag = rows[0]?.lag;
return typeof lag === "number" && Number.isFinite(lag) ? Math.max(0, lag) : 0;
}
}
/** Vanilla PG streaming replication. A primary (not in recovery — no replica configured,
* `$replica` is the writer) has no lag by definition; a caught-up replica (receive LSN ==
* replay LSN) reports 0. Mid-apply there is NO honest reader-side timestamp measure —
* `now() - pg_last_xact_replay_timestamp()` reads as the full inter-write gap on
* low-traffic systems, which (measured locally) pins the estimate at the delay cap — so
* mid-apply reports undefined and the tripwire's observed-staleness floor carries the
* estimate instead. */
export class VanillaPgReplicaLagSource implements ReplicaLagSource {
readonly name = "vanilla-pg";
constructor(private readonly db: RawQueryable) {}
async sampleLagMs(): Promise<number | undefined> {
const rows = await this.db.$queryRawUnsafe<{ caught_up: boolean | null }[]>(
`SELECT CASE
WHEN NOT pg_is_in_recovery() THEN true
WHEN pg_last_wal_receive_lsn() IS NOT DISTINCT FROM pg_last_wal_replay_lsn() THEN true
ELSE false
END AS caught_up`
);
return rows[0]?.caught_up ? 0 : undefined;
}
}
/** Composes dialect sources: the first whose sample succeeds is selected and used from
* then on; a database where none work degrades to never-measuring (the estimator then
* runs on its default + tripwire observations). Selection is by thrown-vs-returned —
* sources throw on unsupported dialects and return undefined for "can't measure now". */
export class FirstSupportedReplicaLagSource implements ReplicaLagSource {
/** undefined = not probed yet; null = no candidate works here. */
#selected: ReplicaLagSource | null | undefined;
constructor(private readonly candidates: ReplicaLagSource[]) {}
get name(): string {
return this.#selected ? this.#selected.name : "undetected";
}
async sampleLagMs(): Promise<number | undefined> {
if (this.#selected === null) {
return undefined;
}
if (this.#selected) {
// Transient errors don't unselect the dialect; the sample is just skipped.
try {
return await this.#selected.sampleLagMs();
} catch {
return undefined;
}
}
for (const candidate of this.candidates) {
try {
const lag = await candidate.sampleLagMs();
this.#selected = candidate;
logger.info("[replicaLagEstimator] selected lag source", { source: candidate.name });
return lag;
} catch {
// unsupported dialect; try the next
}
}
this.#selected = null;
logger.warn(
"[replicaLagEstimator] no usable lag source; relying on default + tripwire observations"
);
return undefined;
}
}
/** The standard composition for a Prisma replica client. */
export function createPostgresReplicaLagSource(replica: RawQueryable): ReplicaLagSource {
return new FirstSupportedReplicaLagSource([
new AuroraReplicaLagSource(replica),
new VanillaPgReplicaLagSource(replica),
]);
}
export type ReplicaLagEstimatorOptions = {
source: ReplicaLagSource;
/** Sample cadence while active. */
sampleIntervalMs?: number;
/** Stop sampling this long after the last touch(); the next touch resumes. */
idleAfterMs?: number;
/** The estimate is the max sample inside this window. */
windowMs?: number;
/** Estimate before any sample lands (and the floor when sampling is unavailable). */
defaultLagMs?: number;
/** Ceiling on accepted samples — shields the estimate from a wild observation. */
maxLagMs?: number;
/** How long a tripwire observation floors the estimate. Sources that can't measure
* mid-apply lag (vanilla PG) return nothing, so without this floor the estimate decays
* to the caught-up zeros within windowMs and every ~window one wake pays a stale retry
* to re-learn. */
observedFloorTtlMs?: number;
/** Observability: a sample (active or passive) was accepted. */
onSample?: (lagMs: number, source: "probe" | "observed") => void;
};
const DEFAULT_SAMPLE_INTERVAL_MS = 250;
const DEFAULT_IDLE_AFTER_MS = 30_000;
const DEFAULT_WINDOW_MS = 5_000;
const DEFAULT_DEFAULT_LAG_MS = 30;
const DEFAULT_MAX_LAG_MS = 60_000;
const DEFAULT_OBSERVED_FLOOR_TTL_MS = 60_000;
export class ReplicaLagEstimator {
readonly #sampleIntervalMs: number;
readonly #idleAfterMs: number;
readonly #windowMs: number;
readonly #defaultLagMs: number;
readonly #maxLagMs: number;
readonly #observedFloorTtlMs: number;
#samples: { atMs: number; lagMs: number }[] = [];
#lastKnownLagMs: number | undefined;
#observedFloorLagMs = 0;
#observedFloorAtMs = 0;
#lastTouchMs = 0;
#timer: ReturnType<typeof setInterval> | undefined;
#sampling = false;
constructor(private readonly options: ReplicaLagEstimatorOptions) {
this.#sampleIntervalMs = options.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS;
this.#idleAfterMs = options.idleAfterMs ?? DEFAULT_IDLE_AFTER_MS;
this.#windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
this.#defaultLagMs = options.defaultLagMs ?? DEFAULT_DEFAULT_LAG_MS;
this.#maxLagMs = options.maxLagMs ?? DEFAULT_MAX_LAG_MS;
this.#observedFloorTtlMs = options.observedFloorTtlMs ?? DEFAULT_OBSERVED_FLOOR_TTL_MS;
}
/** Mark router activity; starts (or keeps) the sampler running. */
touch() {
this.#lastTouchMs = Date.now();
if (!this.#timer) {
this.#timer = setInterval(() => this.#tick(), this.#sampleIntervalMs);
this.#timer.unref?.();
// Sample immediately so the first wake after idle doesn't run on a stale estimate.
this.#tick();
}
}
/** Current lag estimate (ms): the max recent sample (else last known, else the default),
* floored by the latest tripwire observation while it's fresh. Never throws. */
getLagMs(): number {
const now = Date.now();
const cutoff = now - this.#windowMs;
let max: number | undefined;
for (const sample of this.#samples) {
if (sample.atMs >= cutoff && (max === undefined || sample.lagMs > max)) {
max = sample.lagMs;
}
}
const base = max ?? this.#lastKnownLagMs ?? this.#defaultLagMs;
const floor =
now - this.#observedFloorAtMs < this.#observedFloorTtlMs ? this.#observedFloorLagMs : 0;
return Math.max(base, floor);
}
/** Feedback from the stale-hydrate tripwire: a read provably ran at least this far
* behind the primary. Widens the estimate immediately AND floors it for a while —
* sources that can't measure mid-apply lag would otherwise decay it straight back. */
noteObservedLagMs(lagMs: number) {
const clamped = Math.min(Math.max(0, lagMs), this.#maxLagMs);
const floorExpired = Date.now() - this.#observedFloorAtMs >= this.#observedFloorTtlMs;
if (clamped >= this.#observedFloorLagMs || floorExpired) {
this.#observedFloorLagMs = clamped;
this.#observedFloorAtMs = Date.now();
}
this.#accept(lagMs, "observed");
}
stop() {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = undefined;
}
}
#accept(lagMs: number, source: "probe" | "observed") {
if (!Number.isFinite(lagMs)) {
return;
}
const clamped = Math.min(Math.max(0, lagMs), this.#maxLagMs);
const now = Date.now();
this.#samples.push({ atMs: now, lagMs: clamped });
this.#lastKnownLagMs = clamped;
const cutoff = now - this.#windowMs;
while (this.#samples.length > 0 && this.#samples[0].atMs < cutoff) {
this.#samples.shift();
}
this.options.onSample?.(clamped, source);
}
#tick() {
if (Date.now() - this.#lastTouchMs > this.#idleAfterMs) {
this.stop();
return;
}
if (this.#sampling) {
return; // a slow sample shouldn't stack
}
this.#sampling = true;
this.options.source
.sampleLagMs()
.then((lagMs) => {
if (lagMs !== undefined) {
this.#accept(lagMs, "probe");
}
})
.catch(() => {
// sampling errors never propagate; the estimate just ages
})
.finally(() => {
this.#sampling = false;
});
}
}
@@ -0,0 +1,93 @@
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { FEATURE_FLAG } from "~/v3/featureFlags";
import { makeFlag } from "~/v3/featureFlags.server";
import { logger } from "../logger.server";
import { type RealtimeEnvironment } from "../realtimeClient.server";
import { realtimeClient } from "../realtimeClientGlobal.server";
import { BoundedTtlCache } from "./boundedTtlCache";
import { type RealtimeStreamClient } from "./nativeRealtimeClient.server";
import { getNativeRealtimeClient } from "./nativeRealtimeClientInstance.server";
import { getShadowRealtimeClient } from "./shadowRealtimeClientInstance.server";
type RealtimeBackend = "electric" | "native" | "shadow";
// Two gates: the env master switch, then the per-org `realtimeBackend` feature flag (cached so
// long-polls don't hit the DB per request), falling back to REALTIME_BACKEND_DEFAULT.
const nativeBackendEnabled = env.REALTIME_BACKEND_NATIVE_ENABLED === "1";
const flag = singleton("realtimeBackendFlag", () => makeFlag($replica));
const backendCache = singleton(
"realtimeBackendCache",
() =>
new BoundedTtlCache<RealtimeBackend>(
env.REALTIME_BACKEND_FLAG_CACHE_TTL_MS,
env.REALTIME_BACKEND_FLAG_CACHE_MAX_ENTRIES
)
);
export async function resolveRealtimeStreamClient(
environment: RealtimeEnvironment & { organization?: { featureFlags?: unknown } }
): Promise<RealtimeStreamClient> {
if (!nativeBackendEnabled) {
return realtimeClient;
}
// The authenticated environment already carries the org's feature flags; pass them
// through so a cache miss doesn't need an extra organization read.
const orgFeatureFlags = environment.organization
? (environment.organization.featureFlags ?? {})
: undefined;
switch (await getRealtimeBackend(environment.organizationId, orgFeatureFlags)) {
case "native":
return getNativeRealtimeClient();
case "shadow":
// The client is still served Electric; the native path is diffed in the background.
return getShadowRealtimeClient();
case "electric":
default:
return realtimeClient;
}
}
async function getRealtimeBackend(
organizationId: string,
orgFeatureFlags: unknown | undefined
): Promise<RealtimeBackend> {
const cached = backendCache.get(organizationId);
if (cached !== undefined) {
return cached;
}
let backend: RealtimeBackend = env.REALTIME_BACKEND_DEFAULT;
try {
const overrides =
orgFeatureFlags !== undefined
? orgFeatureFlags
: (
await $replica.organization.findFirst({
where: { id: organizationId },
select: { featureFlags: true },
})
)?.featureFlags;
backend = await flag({
key: FEATURE_FLAG.realtimeBackend,
defaultValue: env.REALTIME_BACKEND_DEFAULT,
overrides: (overrides as Record<string, unknown>) ?? {},
});
} catch (error) {
// Never let a flag lookup failure break the realtime feed.
logger.error("[resolveRealtimeStreamClient] failed to resolve realtimeBackend flag", {
organizationId,
error,
});
backend = env.REALTIME_BACKEND_DEFAULT;
}
backendCache.set(organizationId, backend);
return backend;
}
@@ -0,0 +1,332 @@
import type { RedisClient, RedisWithClusterOptions } from "~/redis.server";
import { createRedisClient } from "~/redis.server";
import { logger } from "../logger.server";
export const CHANGE_RECORD_VERSION = 1;
/**
* A self-describing run-change fact published once to the run's environment channel; row state is
* never on the wire. `tags` present (even `[]`) marks a "full" record a feed can classify locally;
* `tags` absent marks a "partial" record (envId+runId only) a tag feed must hydrate to classify.
*/
export type ChangeRecord = {
v: number;
runId: string;
envId: string;
tags?: string[];
batchId?: string | null;
createdAtMs?: number;
updatedAtMs?: number;
status?: string;
};
/** What a publish site provides; the notifier stamps the version. */
export type ChangeRecordInput = Omit<ChangeRecord, "v">;
export function encodeChangeRecord(record: ChangeRecord): string {
return JSON.stringify(record);
}
/** Decode a wire message into a ChangeRecord; a bare/malformed frame degrades to a partial record rather than throwing. */
export function decodeChangeRecord(message: string): ChangeRecord {
if (message.length === 0 || message[0] !== "{") {
return { v: 0, runId: message, envId: "" };
}
try {
const parsed = JSON.parse(message) as Partial<ChangeRecord>;
if (parsed && typeof parsed.runId === "string") {
return {
v: parsed.v ?? 0,
runId: parsed.runId,
envId: parsed.envId ?? "",
tags: parsed.tags,
batchId: parsed.batchId,
createdAtMs: parsed.createdAtMs,
updatedAtMs: parsed.updatedAtMs,
status: parsed.status,
};
}
} catch {
// fall through to the bare-runId fallback
}
return { v: 0, runId: message, envId: "" };
}
export type RunChangeNotifierOptions = {
redis: RedisWithClusterOptions;
/** Channel name prefix; the envId is appended inside a hash-tag for slot locality. */
channelPrefix?: string;
connectionName?: string;
/** Leading-edge throttle (ms) for the per-env channel, bounding the wake rate per env. Defaults to 100ms; 0 disables. */
envWakeCoalesceWindowMs?: number;
/** Use Redis sharded pub/sub (SSUBSCRIBE/SPUBLISH); cluster-only and requires `clusterOptions.shardedSubscribers`. Defaults to false (classic). */
shardedPubSub?: boolean;
/** Observability hook: a publish settled (ok) or failed (the leading degradation signal). */
onPublishResult?: (ok: boolean) => void;
/** Observability hook: a raw channel message arrived (pre-coalesce). */
onMessageReceived?: () => void;
/** Observability hook: a coalesced batch was delivered to listeners (records per batch). */
onBatchDelivered?: (recordCount: number) => void;
};
const DEFAULT_CHANNEL_PREFIX = "realtime:";
const DEFAULT_ENV_WAKE_COALESCE_WINDOW_MS = 100;
/**
* RunChangeNotifier — carries "run X changed" facts from write sites to the realtime feeds over ONE
* per-environment channel (`<prefix>env:{<envId>}`, hash-tagged so an env stays on one cluster slot).
* Uses one shared multiplexed subscriber per process (refcounted), created lazily, and a fire-and-forget
* `publish` that never throws — a dropped publish only costs latency because the consumer has a backstop.
*/
export class RunChangeNotifier {
#publisher: RedisClient | undefined;
#subscriber: RedisClient | undefined;
readonly #listeners = new Map<string, Set<(records: ChangeRecord[]) => void>>();
/** Per-channel accumulator of records since the last delivery, deduped by runId (latest per run wins), so a coalesced wake carries every run that moved. */
readonly #pending = new Map<string, Map<string, ChangeRecord>>();
readonly #channelPrefix: string;
readonly #connectionName: string;
readonly #coalesceWindowMs: number;
/** When true, use sharded pub/sub (SSUBSCRIBE/SPUBLISH/smessage) — see options. */
readonly #sharded: boolean;
/** Active coalescing windows per channel. */
readonly #coalesceTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Channels that received a message while their window was open (need a trailing wake). */
readonly #coalesceDirty = new Set<string>();
constructor(private readonly options: RunChangeNotifierOptions) {
this.#channelPrefix = options.channelPrefix ?? DEFAULT_CHANNEL_PREFIX;
this.#connectionName = options.connectionName ?? "trigger:realtime:run-change-notifier";
this.#coalesceWindowMs = options.envWakeCoalesceWindowMs ?? DEFAULT_ENV_WAKE_COALESCE_WINDOW_MS;
this.#sharded = options.shardedPubSub ?? false;
}
/** Fire-and-forget publish of a run-changed fact to the run's environment channel; never throws. */
publish(input: ChangeRecordInput): void {
const record: ChangeRecord = { v: CHANGE_RECORD_VERSION, ...input };
this.#publishToChannel(this.#channelForEnv(record.envId), encodeChangeRecord(record));
}
/** Fire-and-forget publish of many run-changed facts. Never throws. */
publishMany(inputs: ChangeRecordInput[]): void {
for (const input of inputs) {
this.publish(input);
}
}
#publishToChannel(channel: string, payload: string): void {
try {
const publisher = this.#ensurePublisher();
// Sharded pub/sub (SPUBLISH) routes to the channel's slot owner; classic PUBLISH
// broadcasts cluster-wide. The channel is hash-tagged by envId.
const result = this.#sharded
? publisher.spublish(channel, payload)
: publisher.publish(channel, payload);
if (typeof (result as Promise<number>)?.then === "function") {
(result as Promise<number>).then(
() => this.options.onPublishResult?.(true),
(error) => {
this.options.onPublishResult?.(false);
logger.error("[runChangeNotifier] Failed to publish run-changed notification", {
error,
channel,
});
}
);
} else {
this.options.onPublishResult?.(true);
}
} catch (error) {
this.options.onPublishResult?.(false);
logger.error("[runChangeNotifier] Failed to publish run-changed notification", {
error,
channel,
});
}
}
/** Subscribe to an env's run-change stream; refcounted over the shared subscriber (first listener SUBSCRIBEs, last UNSUBSCRIBEs). */
subscribeToEnv(environmentId: string, onBatch: (records: ChangeRecord[]) => void): () => void {
const channel = this.#channelForEnv(environmentId);
const subscriber = this.#ensureSubscriber();
let listeners = this.#listeners.get(channel);
if (!listeners) {
listeners = new Set();
this.#listeners.set(channel, listeners);
this.#subscribeChannel(subscriber, channel).catch((error) => {
logger.error("[runChangeNotifier] Failed to subscribe to run-change channel", {
error,
channel,
});
});
}
listeners.add(onBatch);
let unsubscribed = false;
return () => {
if (unsubscribed) {
return;
}
unsubscribed = true;
const current = this.#listeners.get(channel);
if (!current) {
return;
}
current.delete(onBatch);
if (current.size === 0) {
// Drop the channel from the map only after Redis confirms UNSUBSCRIBE and no new listener re-subscribed in the meantime.
this.#unsubscribeChannel(subscriber, channel)
.then(() => {
const latest = this.#listeners.get(channel);
if (!latest) {
return;
}
if (latest.size === 0) {
this.#listeners.delete(channel);
} else {
// A listener arrived during the in-flight UNSUBSCRIBE; re-subscribe so it keeps receiving (the backstop covers the gap).
this.#subscribeChannel(subscriber, channel).catch((error) => {
logger.error("[runChangeNotifier] Failed to re-subscribe to run-change channel", {
error,
channel,
});
});
}
})
.catch((error) => {
// UNSUBSCRIBE failed (likely still subscribed in Redis): keep the empty map entry so a future subscriber reuses it.
logger.error("[runChangeNotifier] Failed to unsubscribe from run-change channel", {
error,
channel,
});
});
}
};
}
/** Number of distinct env channels currently subscribed (for metrics). */
get activeSubscriptionCount(): number {
return this.#listeners.size;
}
async quit(): Promise<void> {
for (const timer of this.#coalesceTimers.values()) {
clearTimeout(timer);
}
this.#coalesceTimers.clear();
this.#coalesceDirty.clear();
this.#pending.clear();
await Promise.allSettled([this.#subscriber?.quit(), this.#publisher?.quit()]);
this.#subscriber = undefined;
this.#publisher = undefined;
this.#listeners.clear();
}
#ensurePublisher(): RedisClient {
if (!this.#publisher) {
// Publishes are fire-and-forget with a consumer-side backstop, so a dropped publish is
// latency-only. Cap retries (vs ioredis's default 20) so a pub/sub outage rejects publishes
// after ~1 reconnect cycle instead of buffering them in memory across the fleet.
this.#publisher = createRedisClient(`${this.#connectionName}:pub`, {
...this.options.redis,
maxRetriesPerRequest: 1,
});
}
return this.#publisher;
}
#ensureSubscriber(): RedisClient {
if (!this.#subscriber) {
const subscriber = createRedisClient(`${this.#connectionName}:sub`, this.options.redis);
const onMessage = (channel: string, message: string) => this.#onMessage(channel, message);
// Classic pub/sub delivers "message"; sharded pub/sub delivers "smessage". Register
// both so the delivery path is identical regardless of mode.
subscriber.on("message", onMessage);
subscriber.on("smessage", onMessage);
this.#subscriber = subscriber;
}
return this.#subscriber;
}
/** SUBSCRIBE (classic) vs SSUBSCRIBE (sharded, cluster-only). */
#subscribeChannel(subscriber: RedisClient, channel: string): Promise<unknown> {
return this.#sharded ? subscriber.ssubscribe(channel) : subscriber.subscribe(channel);
}
/** UNSUBSCRIBE (classic) vs SUNSUBSCRIBE (sharded, cluster-only). */
#unsubscribeChannel(subscriber: RedisClient, channel: string): Promise<unknown> {
return this.#sharded ? subscriber.sunsubscribe(channel) : subscriber.unsubscribe(channel);
}
#onMessage(channel: string, message: string) {
this.options.onMessageReceived?.();
// Accumulate the decoded record (deduped by runId) before delivering, so a coalesced
// wake carries every run that moved during the window.
this.#addPending(channel, decodeChangeRecord(message));
if (this.#coalesceWindowMs > 0) {
this.#deliverCoalesced(channel);
return;
}
this.#deliver(channel);
}
/** Accumulate a record into the channel's pending batch, deduped by runId (a later
* record for the same run replaces the earlier one, keeping the freshest keys). */
#addPending(channel: string, record: ChangeRecord) {
let batch = this.#pending.get(channel);
if (!batch) {
batch = new Map();
this.#pending.set(channel, batch);
}
batch.set(record.runId, record);
}
#deliver(channel: string) {
// Drain the accumulated batch (and clear it) so listeners woken now get every run that
// changed since the last delivery, and a later message starts a fresh batch.
const batchMap = this.#pending.get(channel);
const batch = batchMap ? [...batchMap.values()] : [];
this.#pending.delete(channel);
const listeners = this.#listeners.get(channel);
if (!listeners || batch.length === 0) {
return;
}
this.options.onBatchDelivered?.(batch.length);
for (const onBatch of [...listeners]) {
onBatch(batch);
}
}
/** Leading-edge throttle capping the wake rate to ~1/window: deliver the first wake immediately, then one trailing wake per window while activity continues. Lossless. */
#deliverCoalesced(channel: string) {
if (this.#coalesceTimers.has(channel)) {
this.#coalesceDirty.add(channel);
return;
}
this.#deliver(channel);
this.#openCoalesceWindow(channel);
}
#openCoalesceWindow(channel: string) {
const timer = setTimeout(() => {
this.#coalesceTimers.delete(channel);
if (this.#coalesceDirty.delete(channel)) {
this.#deliver(channel);
this.#openCoalesceWindow(channel);
}
}, this.#coalesceWindowMs);
// Don't let a pending coalescing window hold the process open at shutdown.
timer.unref?.();
this.#coalesceTimers.set(channel, timer);
}
// Hash-tagged (`...{<envId>}`) so all of an env's traffic maps to one cluster slot (one
// shard) under sharded pub/sub.
#channelForEnv(environmentId: string): string {
return `${this.#channelPrefix}env:{${environmentId}}`;
}
}
@@ -0,0 +1,87 @@
import { env } from "~/env.server";
import { engine } from "~/v3/runEngine.server";
import { logger } from "../logger.server";
import { publishChangeRecord } from "./runChangeNotifierInstance.server";
/**
* Builds and publishes a self-describing `ChangeRecord` for the lifecycle events whose engine-bus payload
* already carries env + tags + batchId. Terminal transitions, runAttemptFailed, and runMetadataUpdated publish
* from `runEngineHandlers.server.ts` instead. Coverage isn't exhaustive — a dropped transition only adds latency
* because the consumer has a periodic backstop full-resolve. The env master switch is `REALTIME_BACKEND_NATIVE_ENABLED`.
*/
export function registerRunChangeNotifierHandlers() {
// Return truthy in every path so singleton() caches this factory and never re-runs it (re-running would attach duplicate engine-bus listeners on dev reload).
if (env.REALTIME_BACKEND_NATIVE_ENABLED !== "1") {
return true;
}
// Run created: the first signal for a brand-new run (born QUEUED with no status transition), so it surfaces before ClickHouse ingests it.
engine.eventBus.on("runCreated", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
// Status transitions (checkpoint suspend/resume, pending version, dequeue).
engine.eventBus.on("runStatusChanged", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
// Dequeue/lock (sets startedAt) and attempt start (DEQUEUED -> EXECUTING) — the
// most-watched "my run started" transitions.
engine.eventBus.on("runLocked", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
engine.eventBus.on("runAttemptStarted", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
engine.eventBus.on("runRetryScheduled", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
// Delay lifecycle (delayUntil / queued-after-delay changes).
engine.eventBus.on("runDelayRescheduled", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
engine.eventBus.on("runEnqueuedAfterDelay", ({ run, environment }) => {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: run.runTags,
batchId: run.batchId,
});
});
logger.info("[runChangeNotifier] realtime change-record builder registered");
return true;
}
@@ -0,0 +1,99 @@
import { getMeter } from "@internal/tracing";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { logger } from "../logger.server";
import { RunChangeNotifier, type ChangeRecordInput } from "./runChangeNotifier.server";
/**
* Process-singleton wiring for the RunChangeNotifier plus the gated convenience functions write sites
* delegate to. The notifier is constructed lazily, so `REALTIME_BACKEND_NATIVE_ENABLED=0` (default) opens no Redis connections.
*/
const nativeBackendEnabled = env.REALTIME_BACKEND_NATIVE_ENABLED === "1";
function initializeRunChangeNotifier(): RunChangeNotifier {
const clusterMode = env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_CLUSTER_MODE_ENABLED === "1";
// Sharded pub/sub only works against a cluster; classic pub/sub there would
// broadcast every message to every node, so this is what actually shards load.
const shardedPubSub =
clusterMode && env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_SHARDED_ENABLED === "1";
const meter = getMeter("realtime-notifier");
const publishes = meter.createCounter("realtime_notifier.publishes", {
description:
"Change-record publishes by outcome. Failures are the leading indicator that feeds are degrading to their backstops (pub/sub Redis trouble).",
});
const received = meter.createCounter("realtime_notifier.messages_received", {
description: "Raw channel messages received by this instance's subscriber, pre-coalesce.",
});
const delivered = meter.createCounter("realtime_notifier.batches_delivered", {
description:
"Coalesced batches delivered to listeners. received/batches = the coalesce ratio (how hard a busy env is being collapsed).",
});
const notifier = new RunChangeNotifier({
redis: {
host: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_HOST,
port: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_PORT,
username: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_USERNAME,
password: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_PASSWORD,
tlsDisabled: env.REALTIME_BACKEND_NATIVE_PUBSUB_REDIS_TLS_DISABLED === "true",
clusterMode,
// One subscriber connection per shard so SSUBSCRIBE routes to the slot owner.
...(shardedPubSub ? { clusterOptions: { shardedSubscribers: true } } : {}),
},
envWakeCoalesceWindowMs: env.REALTIME_BACKEND_NATIVE_ENV_WAKE_COALESCE_WINDOW_MS,
shardedPubSub,
onPublishResult: (ok) => publishes.add(1, { result: ok ? "ok" : "error" }),
onMessageReceived: () => received.add(1),
onBatchDelivered: () => delivered.add(1),
});
meter
.createObservableGauge("realtime_notifier.active_subscriptions", {
description: "Distinct env channels currently subscribed for realtime change notifications.",
})
.addCallback((result) => result.observe(notifier.activeSubscriptionCount));
return notifier;
}
/** Lazily construct (and memoize) the notifier singleton. */
export function getRunChangeNotifier(): RunChangeNotifier {
return singleton("runChangeNotifier", initializeRunChangeNotifier);
}
/** Whether the notifier subsystem is enabled for this process. */
export function isRunChangeNotifierEnabled(): boolean {
return nativeBackendEnabled;
}
/** Fire-and-forget publish of a run-changed record. No-op (and no notifier construction)
* when disabled, so publish sites can call it unconditionally. */
export function publishChangeRecord(input: ChangeRecordInput): void {
if (!nativeBackendEnabled) {
return;
}
// Publish runs on the run-engine event bus / metadata flush loop; lazy init + encoding happen
// before the notifier's own try/catch, so guard the whole call — it must never throw at its caller.
try {
getRunChangeNotifier().publish(input);
} catch (error) {
logger.error("[runChangeNotifier] publishChangeRecord threw; dropping notification", { error });
}
}
export function publishManyChangeRecords(inputs: ChangeRecordInput[]): void {
if (!nativeBackendEnabled) {
return;
}
try {
getRunChangeNotifier().publishMany(inputs);
} catch (error) {
logger.error("[runChangeNotifier] publishManyChangeRecords threw; dropping notifications", {
error,
});
}
}
@@ -0,0 +1,174 @@
import {
type Prisma,
type PrismaClient,
type PrismaClientOrTransaction,
} from "@trigger.dev/database";
import type { RunStore } from "@internal/run-store";
import { BoundedTtlCache } from "./boundedTtlCache";
import { RESERVED_COLUMNS, type RealtimeRunRow } from "./electricStreamProtocol.server";
/**
* RunReader — the pluggable read half of the native-backend realtime feed: ClickHouse is filter-only
* (resolves ids), Postgres always hydrates row columns. Owns the `RunHydrator` (by-id) and the
* `RunListResolver` interface (the tag/list filter -> id-set seam, implemented over ClickHouse).
*/
/** The TaskRun columns the realtime feed projects (mirrors DEFAULT_ELECTRIC_COLUMNS). */
export const RUN_HYDRATOR_SELECT = {
id: true,
taskIdentifier: true,
createdAt: true,
updatedAt: true,
startedAt: true,
delayUntil: true,
queuedAt: true,
expiredAt: true,
completedAt: true,
friendlyId: true,
number: true,
isTest: true,
status: true,
usageDurationMs: true,
costInCents: true,
baseCostInCents: true,
ttl: true,
payload: true,
payloadType: true,
metadata: true,
metadataType: true,
output: true,
outputType: true,
runTags: true,
error: true,
realtimeStreams: true,
} satisfies Prisma.TaskRunSelect;
/** Columns hydrated regardless of `skipColumns`: `id` keys the row, `updatedAt` drives the offset and working-set diff. */
const ALWAYS_HYDRATED_COLUMNS = new Set<string>(["id", "updatedAt", ...RESERVED_COLUMNS]);
/** Project `RUN_HYDRATOR_SELECT` down to the columns the client didn't skip (plus
* the always-needed ones). An empty skip set returns the full select unchanged. */
export function buildHydratorSelect(skipColumns: string[] = []): Prisma.TaskRunSelect {
if (skipColumns.length === 0) {
return RUN_HYDRATOR_SELECT;
}
const skip = new Set(skipColumns);
const select: Record<string, boolean> = {};
for (const column of Object.keys(RUN_HYDRATOR_SELECT)) {
if (ALWAYS_HYDRATED_COLUMNS.has(column) || !skip.has(column)) {
select[column] = true;
}
}
return select as Prisma.TaskRunSelect;
}
export type RunListFilter = {
organizationId: string;
projectId: string;
environmentId: string;
/** Contains-ANY tag match (OR). Omit/empty for non-tag feeds. */
tags?: string[];
/** Restrict to a single batch (internal batch id) — the batch feed. */
batchId?: string;
/** Lower bound on createdAt (the tag-list feed pins this; batch omits it). */
createdAtAfter?: Date;
/** Hard cap on the result set so a broad filter can't unbound the snapshot. */
limit: number;
};
/** Resolves a tag/list filter into the matching run id-set, filter-only (rows hydrated from Postgres by id afterward). ClickHouse impl in `clickHouseRunListResolver.server.ts`. */
export interface RunListResolver {
resolveMatchingRunIds(filter: RunListFilter): Promise<string[]>;
}
export type RunHydratorOptions = {
/** A read-replica Prisma client (`$replica`). Always Postgres. */
replica: Pick<PrismaClient, "taskRun">;
/** RunStore the reads are routed through; `replica` is passed as the read client. */
runStore: RunStore;
/** Read-through cache TTL (ms) collapsing duplicate refetches for the same run. Set 0 to disable. Defaults to 250ms. */
cacheTtlMs?: number;
/** Hard cap on cache entries before expired entries are swept. */
maxCacheEntries?: number;
};
const DEFAULT_CACHE_TTL_MS = 250;
const DEFAULT_MAX_CACHE_ENTRIES = 5_000;
/** Hydrates runs by id through the runStore seam (split routing lives in the store, below this file), projected to the realtime columns; concurrent same-run refetches are single-flighted + short-TTL cached. */
export class RunHydrator {
readonly #inflight = new Map<string, Promise<RealtimeRunRow | null>>();
readonly #cache: BoundedTtlCache<RealtimeRunRow | null>;
readonly #cacheTtlMs: number;
constructor(private readonly options: RunHydratorOptions) {
this.#cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
this.#cache = new BoundedTtlCache(
this.#cacheTtlMs,
options.maxCacheEntries ?? DEFAULT_MAX_CACHE_ENTRIES
);
}
async getRunById(environmentId: string, runId: string): Promise<RealtimeRunRow | null> {
const key = `${environmentId}:${runId}`;
if (this.#cacheTtlMs > 0) {
// A cached null is a valid "run not found" hit; only undefined is a miss.
const cached = this.#cache.get(key);
if (cached !== undefined) {
return cached;
}
}
const existing = this.#inflight.get(key);
if (existing) {
return existing;
}
const promise = this.#fetch(environmentId, runId).finally(() => this.#inflight.delete(key));
this.#inflight.set(key, promise);
const row = await promise;
if (this.#cacheTtlMs > 0) {
this.#cache.set(key, row);
}
return row;
}
/** Hydrate many runs by id in one query (order not guaranteed); `skipColumns` projects the SELECT so dropped columns aren't shipped. */
async hydrateByIds(
environmentId: string,
ids: string[],
skipColumns: string[] = []
): Promise<RealtimeRunRow[]> {
if (ids.length === 0) {
return [];
}
const rows = await this.options.runStore.findRuns(
{
where: {
runtimeEnvironmentId: environmentId,
id: { in: ids },
},
select: buildHydratorSelect(skipColumns),
},
this.options.replica as PrismaClientOrTransaction
);
return rows as unknown as RealtimeRunRow[];
}
async #fetch(environmentId: string, runId: string): Promise<RealtimeRunRow | null> {
const run = await this.options.runStore.findRun(
{
id: runId,
runtimeEnvironmentId: environmentId,
},
{ select: RUN_HYDRATOR_SELECT },
this.options.replica as PrismaClientOrTransaction
);
return (run ?? null) as RealtimeRunRow | null;
}
}
@@ -0,0 +1,742 @@
// app/realtime/S2RealtimeStreams.ts
import type { UnkeyCache } from "@internal/cache";
import type { StreamIngestor, StreamRecord, StreamResponder, StreamResponseOptions } from "./types";
import type { LogLevel } from "@trigger.dev/core/logger";
import { Logger } from "@trigger.dev/core/logger";
import { headerValue } from "@trigger.dev/core/v3";
import { randomUUID } from "node:crypto";
import { ServiceValidationError } from "~/v3/services/common.server";
// S2's per-record metered-size limit. Verified empirically against
// cloud S2: append succeeds at metered=1048576 and 422s at 1048577
// with `"record must have metered size less than 1 MiB"` (the "less
// than" wording is slightly off — the boundary is inclusive).
//
// Metered size formula:
// metered = 8 (record overhead) + 2*H + Σ(header name + value) + body
// where `body` is the unescaped record body length in UTF-8 bytes and
// `H` is the number of S2 record headers.
//
// We attach no record headers (H=0), so the budget reduces to:
// 8 + body ≤ 1048576 → body ≤ 1048568
export const S2_MAX_METERED_BYTES = 1024 * 1024; // 1 MiB
export const S2_RECORD_BASE_OVERHEAD_BYTES = 8;
/**
* Thrown when a record's metered size would exceed S2's hard per-record
* limit. Caught by the route handler and surfaced as 413.
*/
export class S2RecordTooLargeError extends ServiceValidationError {
constructor(public readonly meteredBytes: number) {
super(
`Record metered size ${meteredBytes} bytes exceeds the S2 per-record limit of ${S2_MAX_METERED_BYTES} bytes. Reduce tool-output size or split into smaller parts.`,
413
);
this.name = "S2RecordTooLargeError";
}
}
export type S2RealtimeStreamsOptions = {
// S2
basin: string; // e.g., "my-basin"
accessToken: string; // "Bearer" token issued in S2 console
streamPrefix?: string; // defaults to ""
// Custom endpoint for s2-lite (self-hosted)
endpoint?: string; // e.g., "http://localhost:4566/v1"
// Skip access token issuance (s2-lite doesn't support /access-tokens)
skipAccessTokens?: boolean;
// Read behavior
s2WaitSeconds?: number;
flushIntervalMs?: number; // how often to flush buffered chunks (default 200ms)
maxRetries?: number; // max number of retries for failed flushes (default 10)
logger?: Logger;
logLevel?: LogLevel;
accessTokenExpirationInMs?: number;
cache?: UnkeyCache<{
accessToken: string;
}>;
};
// Ops the issued S2 access token is scoped to. `trim` is a distinct op
// from `append` even though trim records are appended like any other —
// without it, `AppendRecord.trim()` 403s with "Operation not permitted".
// `chat.agent`'s per-turn trim chain depends on it.
//
// The fingerprint folds the ops list into the cache key, so any future
// scope change auto-invalidates pre-deploy cached tokens.
const S2_TOKEN_OPS = ["append", "create-stream", "trim"] as const;
const S2_TOKEN_OPS_FINGERPRINT = [...S2_TOKEN_OPS].sort().join(",");
type S2IssueAccessTokenResponse = { access_token: string };
type S2AppendInput = { records: { body: string }[] };
type S2AppendAck = {
start: { seq_num: number; timestamp: number };
end: { seq_num: number; timestamp: number };
tail: { seq_num: number; timestamp: number };
};
export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
private readonly basin: string;
private readonly baseUrl: string;
private readonly accountUrl: string;
private readonly endpoint?: string;
private readonly token: string;
private readonly streamPrefix: string;
private readonly skipAccessTokens: boolean;
private readonly s2WaitSeconds: number;
private readonly flushIntervalMs: number;
private readonly maxRetries: number;
private readonly logger: Logger;
private readonly level: LogLevel;
private readonly accessTokenExpirationInMs: number;
private readonly cache?: UnkeyCache<{
accessToken: string;
}>;
constructor(opts: S2RealtimeStreamsOptions) {
this.basin = opts.basin;
this.baseUrl = opts.endpoint ?? `https://${this.basin}.b.aws.s2.dev/v1`;
this.accountUrl = opts.endpoint ?? `https://aws.s2.dev/v1`;
this.endpoint = opts.endpoint;
this.token = opts.accessToken;
this.streamPrefix = opts.streamPrefix ?? "";
this.skipAccessTokens = opts.skipAccessTokens ?? false;
this.s2WaitSeconds = opts.s2WaitSeconds ?? 60;
this.flushIntervalMs = opts.flushIntervalMs ?? 200;
this.maxRetries = opts.maxRetries ?? 10;
this.logger = opts.logger ?? new Logger("S2RealtimeStreams", opts.logLevel ?? "info");
this.level = opts.logLevel ?? "info";
this.cache = opts.cache;
this.accessTokenExpirationInMs = opts.accessTokenExpirationInMs ?? 60_000 * 60 * 24; // 1 day
}
private toStreamName(runId: string, streamId: string): string {
return `${this.streamPrefix}/runs/${runId}/${streamId}`;
}
/**
* Build an S2 stream name for a `Session`-primitive channel, addressed by
* the session's `friendlyId` and the I/O direction. Used by the session
* realtime routes to route traffic to `sessions/{friendlyId}/{out|in}`.
*/
public toSessionStreamName(friendlyId: string, io: "out" | "in"): string {
return `${this.streamPrefix}/sessions/${friendlyId}/${io}`;
}
async initializeStream(
runId: string,
streamId: string
): Promise<{ responseHeaders?: Record<string, string> }> {
return this.#initializeStreamByName(
this.toStreamName(runId, streamId),
`/runs/${runId}/${streamId}`
);
}
/**
* Initialize an S2 stream by `(sessionFriendlyId, io)` — mirrors
* {@link initializeStream} but addresses the new `sessions/*` key format.
*/
async initializeSessionStream(
friendlyId: string,
io: "out" | "in"
): Promise<{ responseHeaders?: Record<string, string> }> {
return this.#initializeStreamByName(
this.toSessionStreamName(friendlyId, io),
`/sessions/${friendlyId}/${io}`
);
}
async #initializeStreamByName(
prefixedName: string,
relativeName: string
): Promise<{ responseHeaders?: Record<string, string> }> {
const accessToken = this.skipAccessTokens
? this.token
: await this.getS2AccessToken(randomUUID());
return {
responseHeaders: {
"X-S2-Access-Token": accessToken,
"X-S2-Stream-Name": this.skipAccessTokens ? prefixedName : relativeName,
"X-S2-Basin": this.basin,
"X-S2-Flush-Interval-Ms": this.flushIntervalMs.toString(),
"X-S2-Max-Retries": this.maxRetries.toString(),
...(this.endpoint ? { "X-S2-Endpoint": this.endpoint } : {}),
},
};
}
ingestData(
stream: ReadableStream<Uint8Array>,
runId: string,
streamId: string,
clientId: string,
resumeFromChunk?: number
): Promise<Response> {
throw new Error("S2 streams are written to S2 via the client, not from the server");
}
async appendPart(part: string, partId: string, runId: string, streamId: string): Promise<void> {
await this.#appendPartByName(part, partId, this.toStreamName(runId, streamId));
}
/** Append one record to a `Session` channel; returns its seq (same space as `session-in-event-id`). */
async appendPartToSessionStream(
part: string,
partId: string,
friendlyId: string,
io: "out" | "in"
): Promise<number> {
return this.#appendPartByName(part, partId, this.toSessionStreamName(friendlyId, io));
}
async #appendPartByName(part: string, partId: string, s2Stream: string): Promise<number> {
this.logger.debug(`S2 appending to stream`, { part, stream: s2Stream });
const recordBody = JSON.stringify({ data: part, id: partId });
const meteredBytes = Buffer.byteLength(recordBody, "utf8") + S2_RECORD_BASE_OVERHEAD_BYTES;
if (meteredBytes > S2_MAX_METERED_BYTES) {
throw new S2RecordTooLargeError(meteredBytes);
}
const result = await this.s2Append(s2Stream, {
records: [{ body: recordBody }],
});
this.logger.debug(`S2 append result`, { result });
return result.start.seq_num;
}
getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise<number> {
throw new Error("S2 streams are written to S2 via the client, not from the server");
}
async readRecords(
runId: string,
streamId: string,
afterSeqNum?: number
): Promise<StreamRecord[]> {
return this.#readRecordsByName(this.toStreamName(runId, streamId), afterSeqNum);
}
/**
* Read records from a `Session`-primitive channel starting after the
* given sequence number. Used by the `.wait()` race-check path.
*/
async readSessionStreamRecords(
friendlyId: string,
io: "out" | "in",
afterSeqNum?: number
): Promise<StreamRecord[]> {
return this.#readRecordsByName(this.toSessionStreamName(friendlyId, io), afterSeqNum);
}
async #readRecordsByName(s2Stream: string, afterSeqNum?: number): Promise<StreamRecord[]> {
const startSeq = afterSeqNum != null ? afterSeqNum + 1 : 0;
const qs = new URLSearchParams();
qs.set("seq_num", String(startSeq));
qs.set("clamp", "true");
qs.set("wait", "0"); // Non-blocking: return immediately with existing records
const res = await fetch(
`${this.baseUrl}/streams/${encodeURIComponent(s2Stream)}/records?${qs}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "text/event-stream",
"S2-Format": "raw",
"S2-Basin": this.basin,
},
}
);
if (!res.ok) {
// Stream may not exist yet (no data sent)
if (res.status === 404) {
return [];
}
const text = await res.text().catch(() => "");
throw new Error(`S2 readRecords failed: ${res.status} ${res.statusText} ${text}`);
}
// Parse the SSE response body to extract records
const body = await res.text();
return this.parseSSEBatchRecords(body);
}
private parseSSEBatchRecords(sseText: string): StreamRecord[] {
const records: StreamRecord[] = [];
// SSE events are separated by double newlines
const events = sseText.split("\n\n").filter((e) => e.trim());
for (const event of events) {
const lines = event.split("\n");
let eventType: string | undefined;
let data: string | undefined;
for (const line of lines) {
if (line.startsWith("event:")) {
eventType = line.slice(6).trim();
} else if (line.startsWith("data:")) {
data = line.slice(5).trim();
}
}
if (eventType === "batch" && data) {
try {
const parsed = JSON.parse(data) as {
records: Array<{
body: string;
seq_num: number;
timestamp: number;
headers?: Array<[string, string]>;
}>;
};
for (const record of parsed.records) {
// S2 command records (trim/fence) have a single header with
// empty name. Skip — callers want only data + Trigger control
// records.
if (record.headers?.[0]?.[0] === "") {
continue;
}
// Data records carry a JSON envelope; Trigger control records
// have an empty body and route via headers. Tolerate non-JSON
// bodies so a control record (or a malformed data record)
// doesn't take the whole batch down with it.
let parsedBody: { data: string; id: string } | undefined;
try {
parsedBody = JSON.parse(record.body) as { data: string; id: string };
} catch {
parsedBody = undefined;
}
records.push({
data: parsedBody?.data ?? "",
id: parsedBody?.id ?? "",
seqNum: record.seq_num,
headers: record.headers,
});
}
} catch {
// Skip malformed events
}
}
}
return records;
}
// ---------- Serve SSE from S2 ----------
async streamResponse(
request: Request,
runId: string,
streamId: string,
signal: AbortSignal,
options?: StreamResponseOptions
): Promise<Response> {
return this.#streamResponseByName(this.toStreamName(runId, streamId), signal, options);
}
/**
* Serve SSE from a `Session`-primitive channel addressed by
* `(friendlyId, io)`.
*
* For `io=out`, peek the tail of the stream. If the most recent
* non-command record is a `turn-complete` control record (i.e. the
* agent has finished a turn and is either idle-waiting on `.in` or
* has exited), no more chunks will arrive without further user
* action. We switch the downstream S2 read to `wait=0` (drain
* whatever's left, close fast) and set `X-Session-Settled: true` so
* the client knows this SSE close is terminal instead of the normal
* 60s long-poll cycle.
*
* The actual tail is now usually an S2 `trim` command record (the
* agent appends one after every turn-complete to keep `.out`
* bounded). The peek reads two records and walks past the trim to
* find the turn-complete underneath.
*
* Mid-turn tail (streaming UIMessageChunk) falls through to the
* long-poll path; a crashed-mid-turn stream is indistinguishable
* here and behaves like today (client sees wait=60 close, retries).
*/
async streamResponseFromSessionStream(
request: Request,
friendlyId: string,
io: "out" | "in",
signal: AbortSignal,
options?: StreamResponseOptions
): Promise<Response> {
const s2Stream = this.toSessionStreamName(friendlyId, io);
let waitSeconds = options?.timeoutInSeconds ?? this.s2WaitSeconds;
let settled = false;
// Only peek + settle when the client opts in via `options.peekSettled`.
// Reconnect-on-reload paths (`TriggerChatTransport.reconnectToStream`)
// set it; active send-a-message paths don't — otherwise the peek
// races the newly-triggered turn's first chunk and the SSE closes
// before records land.
if (io === "out" && options?.peekSettled) {
settled = await this.#peekIsSettled(s2Stream);
if (settled) {
waitSeconds = 0;
}
}
const s2Response = await this.#streamResponseByName(s2Stream, signal, {
...options,
timeoutInSeconds: waitSeconds,
});
if (!settled) return s2Response;
const headers = new Headers(s2Response.headers);
headers.set("X-Session-Settled", "true");
return new Response(s2Response.body, {
status: s2Response.status,
statusText: s2Response.statusText,
headers,
});
}
/**
* Peek the tail of `.out` and return whether the stream is "settled" —
* i.e. the most recent non-command record is a `turn-complete` control
* record. The agent appends an S2 `trim` command record immediately
* after every turn-complete to keep the stream bounded, so we read two
* tail records and walk past any trim command to find the
* turn-complete underneath.
*/
async #peekIsSettled(s2Stream: string): Promise<boolean> {
const qs = new URLSearchParams();
// `tail_offset=2` rewinds two seq positions; `count=2` caps it to
// those two records. At steady state these are `[turn-complete, trim]`.
// `wait=0` returns immediately with no long-poll.
qs.set("tail_offset", "2");
qs.set("count", "2");
qs.set("wait", "0");
let res: Response;
try {
res = await fetch(`${this.baseUrl}/streams/${encodeURIComponent(s2Stream)}/records?${qs}`, {
method: "GET",
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "application/json",
"S2-Format": "raw",
"S2-Basin": this.basin,
},
});
} catch (err) {
this.logger.warn("S2 peek last record: fetch failed", { err, stream: s2Stream });
return false;
}
if (!res.ok) {
// 404: stream has never been written to. 416: range not
// satisfiable (empty stream). Both mean "nothing to peek."
if (res.status === 404 || res.status === 416) return false;
const text = await res.text().catch(() => "");
this.logger.warn("S2 peek last record failed", {
status: res.status,
statusText: res.statusText,
text,
stream: s2Stream,
});
return false;
}
let records: Array<{
body: string;
seq_num: number;
timestamp: number;
headers?: Array<[string, string]>;
}>;
try {
const json = (await res.json()) as {
records?: Array<{
body: string;
seq_num: number;
timestamp: number;
headers?: Array<[string, string]>;
}>;
};
records = json.records ?? [];
} catch (err) {
this.logger.warn("S2 peek last record: parse failed", { err, stream: s2Stream });
return false;
}
// Walk from most-recent backward, skipping S2 command records
// (`headers[0][0] === ""`). The first non-command record is the
// real tail — settled iff its `trigger-control` header is
// `turn-complete`.
for (let i = records.length - 1; i >= 0; i--) {
const record = records[i]!;
if (record.headers?.[0]?.[0] === "") {
continue;
}
const controlValue = headerValue(record.headers, "trigger-control");
return controlValue === "turn-complete";
}
return false;
}
async #streamResponseByName(
s2Stream: string,
signal: AbortSignal,
options?: StreamResponseOptions
): Promise<Response> {
const startSeq = this.parseLastEventId(options?.lastEventId);
this.logger.info(`S2 streaming records from stream`, { stream: s2Stream, startSeq });
// Request SSE stream from S2 and return it directly
const s2Response = await this.s2StreamRecords(s2Stream, {
seq_num: startSeq ?? 0,
clamp: true,
wait: options?.timeoutInSeconds ?? this.s2WaitSeconds, // S2 will keep the connection open and stream new records
signal, // Pass abort signal so S2 connection is cleaned up when client disconnects
});
// Return S2's SSE response directly to the client
return s2Response;
}
// ---------- Internals: S2 REST ----------
private async s2Append(stream: string, body: S2AppendInput): Promise<S2AppendAck> {
// POST /v1/streams/{stream}/records (JSON).
//
// Retries transient failures (network errors and 5xx) up to 3 times with
// exponential backoff. Undici's "fetch failed" errors observed locally
// are pre-connection (DNS/TCP) so the request never reaches S2, making
// retry safe — the alternative is a 500 surfacing to the SDK transport,
// which then retries the whole `/in/append` round-trip and pollutes
// logs. 4xx are not retried (genuine client errors).
const url = `${this.baseUrl}/streams/${encodeURIComponent(stream)}/records`;
const init: RequestInit = {
method: "POST",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
"S2-Format": "raw",
"S2-Basin": this.basin,
},
body: JSON.stringify(body),
};
const maxAttempts = 3;
const backoffsMs = [100, 250, 600];
let lastError: unknown;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// The `try` only wraps `fetch` — once we have a Response we handle status
// outside the catch, so a 4xx throw can't be swallowed and retried.
let res: Response | undefined;
try {
res = await fetch(url, init);
} catch (err) {
lastError = err;
}
if (res) {
if (res.ok) {
return (await res.json()) as S2AppendAck;
}
const text = await res.text().catch(() => "");
const httpError = new Error(`S2 append failed: ${res.status} ${res.statusText} ${text}`);
if (res.status >= 400 && res.status < 500) {
// 4xx — caller-side problem (auth, malformed body, closed stream).
// Retrying won't help.
throw httpError;
}
// 5xx — retryable.
lastError = httpError;
}
const isLastAttempt = attempt === maxAttempts - 1;
const diagnostics = describeFetchError(lastError);
if (isLastAttempt) {
this.logger.error("S2 append failed after retries", {
stream,
attempts: maxAttempts,
...diagnostics,
});
break;
}
this.logger.warn("S2 append transient failure, retrying", {
stream,
attempt: attempt + 1,
nextDelayMs: backoffsMs[attempt],
...diagnostics,
});
await new Promise((resolve) => setTimeout(resolve, backoffsMs[attempt]));
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
private async getS2AccessToken(id: string): Promise<string> {
if (!this.cache) {
return this.s2IssueAccessToken(id);
}
// Cache key includes basin so per-org basins never collide on
// cached tokens, and the ops fingerprint so a scope change in code
// (e.g. adding `trim` in #3644) auto-invalidates pre-deploy entries
// instead of returning stale tokens for up to 24h.
const cacheKey = `${this.basin}:${this.streamPrefix}:${S2_TOKEN_OPS_FINGERPRINT}`;
const result = await this.cache.accessToken.swr(cacheKey, async () => {
return this.s2IssueAccessToken(id);
});
if (!result.val) {
throw new Error("Failed to get S2 access token");
}
return result.val;
}
private async s2IssueAccessToken(id: string): Promise<string> {
// POST /v1/access-tokens
const res = await fetch(`${this.accountUrl}/access-tokens`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id,
scope: {
basins: {
exact: this.basin,
},
ops: [...S2_TOKEN_OPS],
streams: {
prefix: this.streamPrefix,
},
},
expires_at: new Date(Date.now() + this.accessTokenExpirationInMs).toISOString(),
auto_prefix_streams: true,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`S2 issue access token failed: ${res.status} ${res.statusText} ${text}`);
}
const data = (await res.json()) as S2IssueAccessTokenResponse;
return data.access_token;
}
private async s2StreamRecords(
stream: string,
opts: {
seq_num?: number;
clamp?: boolean;
wait?: number;
signal?: AbortSignal;
}
): Promise<Response> {
// GET /v1/streams/{stream}/records with Accept: text/event-stream for SSE streaming
const qs = new URLSearchParams();
if (opts.seq_num != null) qs.set("seq_num", String(opts.seq_num));
if (opts.clamp != null) qs.set("clamp", String(opts.clamp));
if (opts.wait != null) qs.set("wait", String(opts.wait));
const res = await fetch(`${this.baseUrl}/streams/${encodeURIComponent(stream)}/records?${qs}`, {
method: "GET",
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "text/event-stream",
"S2-Format": "raw",
"S2-Basin": this.basin,
},
signal: opts.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`S2 stream failed: ${res.status} ${res.statusText} ${text}`);
}
const headers = new Headers(res.headers);
headers.set("X-Stream-Version", "v2");
headers.set("Access-Control-Expose-Headers", "*");
return new Response(res.body, {
headers,
status: res.status,
statusText: res.statusText,
});
}
private parseLastEventId(lastEventId?: string): number | undefined {
if (!lastEventId) return undefined;
// tolerate formats like "1699999999999-5" (take leading digits)
const digits = lastEventId.split("-")[0];
const n = Number(digits);
return Number.isFinite(n) && n >= 0 ? n + 1 : undefined;
}
}
// Pulls the underlying network error out of undici's generic "fetch failed".
// undici sets `error.cause` to either a SystemError-shaped object with `code`
// (e.g. `ECONNRESET`, `UND_ERR_SOCKET`, `ETIMEDOUT`), `errno`, and `syscall`,
// or — for happy-eyeballs / multi-address connect attempts — an
// `AggregateError` whose `errors[]` each carry their own code. Surfacing
// those tells us whether failures are pre-connection (DNS / TCP), mid-stream
// socket resets, or genuine S2 server errors.
function describeFetchError(err: unknown): Record<string, unknown> {
if (!(err instanceof Error)) {
return { error: String(err) };
}
const out: Record<string, unknown> = {
error: err.message,
name: err.name,
};
const cause = (err as { cause?: unknown }).cause;
if (cause && typeof cause === "object") {
const c = cause as Record<string, unknown>;
if (typeof c.code === "string") out.causeCode = c.code;
if (typeof c.errno === "number" || typeof c.errno === "string") out.causeErrno = c.errno;
if (typeof c.syscall === "string") out.causeSyscall = c.syscall;
if (typeof c.message === "string") out.causeMessage = c.message;
if (Array.isArray(c.errors)) {
out.causeErrors = c.errors
.filter((e: unknown): e is Error => e instanceof Error)
.map((e) => ({
message: e.message,
code: (e as { code?: unknown }).code,
syscall: (e as { syscall?: unknown }).syscall,
address: (e as { address?: unknown }).address,
port: (e as { port?: unknown }).port,
}));
}
}
return out;
}
@@ -0,0 +1,525 @@
import type { Session, TaskRunStatus } from "@trigger.dev/database";
import { SessionTriggerConfig as SessionTriggerConfigZod } from "@trigger.dev/core/v3";
import type { z } from "zod";
import { prisma, $replica } from "~/db.server";
import { runStore } from "~/v3/runStore.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server";
import { TriggerTaskService } from "~/v3/services/triggerTask.server";
import { isFinalRunStatus } from "~/v3/taskStatus";
/**
* Schema for `Session.triggerConfig` (stored as JSONB). The wire-format
* source of truth lives in `@trigger.dev/core/v3` as `SessionTriggerConfig`;
* we re-export it here for the trigger machinery to validate on read.
*
* `basePayload` carries the customer's wire payload (for chat.agent:
* `{ chatId, ...clientData, idleTimeoutInSeconds? }`). Runtime fields
* specific to a particular trigger (e.g. `trigger: "trigger" | "preload"`,
* an `isContinuation` flag) come in via the `payloadOverrides` argument
* to `ensureRunForSession` and shallow-merge on top of `basePayload`.
*/
export const SessionTriggerConfigSchema = SessionTriggerConfigZod;
export type SessionTriggerConfig = z.infer<typeof SessionTriggerConfigSchema>;
export type EnsureRunReason = "initial" | "continuation" | "upgrade" | "manual";
/**
* Hard cap on how many times `ensureRunForSession` will recurse on the
* pathological "we lost the claim race AND the winner's run was already
* terminal" path. In practice progress through the run engine bounds
* this, but a misconfigured task that crashes before it can be dequeued
* could otherwise loop without limit. After this many attempts we
* surface `SessionRunManagerError` so the caller can 5xx instead of
* blowing the stack.
*/
const ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS = 3;
type EnsureRunForSessionParams = {
/**
* Session row to operate on. Caller is responsible for the env match —
* we don't re-check `runtimeEnvironmentId` against `environment.id`.
*
* `friendlyId` is used to pre-populate `payload.sessionId` on the new
* run so the agent's `chat.agent` boot path can attach to `session.in/.out`
* without a control-plane round-trip. `currentRunId` is also forwarded
* as `payload.previousRunId` (with `continuation: true`) when the prior
* run is dead, so the agent's boot gate triggers snapshot.read + replay
* instead of treating the run as a fresh chat.
*/
session: Pick<
Session,
"id" | "friendlyId" | "taskIdentifier" | "triggerConfig" | "currentRunId" | "currentRunVersion"
>;
environment: AuthenticatedEnvironment;
reason: EnsureRunReason;
/**
* Shallow-merged on top of `triggerConfig.basePayload`. Runtime fields
* only — caller-controlled data that varies per trigger (`trigger:
* "preload"` vs `"trigger"`, etc).
*/
payloadOverrides?: Record<string, unknown>;
/**
* @internal Recursion-guard counter for the lost-claim-race retry path.
* Public callers should leave this unset; the function recurses with
* an incremented value on the pathological "winner's run was already
* terminal" branch and throws once it exceeds
* {@link ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS}.
*/
_attempt?: number;
};
export type EnsureRunResult = {
runId: string;
/** True if this call triggered a fresh run; false if it reused an alive existing one. */
triggered: boolean;
};
/**
* Idempotently make sure the session has a live run.
*
* Algorithm:
* 1. If `currentRunId` is set, probe its status. Alive → return as-is.
* 2. Trigger a new run upfront (cheap to cancel if we lose the race).
* 3. Atomic claim via `updateMany` keyed on `currentRunVersion`.
* - Won: return new runId, record SessionRun audit row.
* - Lost: cancel our triggered run, re-read session, reuse winner's
* run if alive. If pathological (winner's run already terminal),
* recurse.
*
* No DB lock is held across the trigger call. Wasted-trigger window is
* the rare multi-tab race on a dead run; cancel cost is negligible and
* the run-engine handles it gracefully.
*/
export async function ensureRunForSession(
params: EnsureRunForSessionParams
): Promise<EnsureRunResult> {
const { session, environment, reason, payloadOverrides, _attempt = 1 } = params;
if (_attempt > ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS) {
throw new SessionRunManagerError(
`ensureRunForSession exceeded ${ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS} attempts for session ${session.id} — every triggered run reached a terminal state before claim could resolve`
);
}
// 1. Probe currentRunId.
let priorDeadRunFriendlyId: string | undefined;
if (session.currentRunId) {
let probe = await getRunStatusAndFriendlyId(session.currentRunId);
if (!probe) {
// Replica miss on a row we just observed via `currentRunId` — the
// run was likely triggered moments ago and hasn't replicated yet.
// Re-probe the writer BEFORE deciding liveness: treating a lagging
// replica as "row vanished" double-triggers the session (a fast
// first append after session create races the replica apply delay
// and spawns a second live run consuming the same `.in`).
probe = await runStore.findRun(
{ id: session.currentRunId },
{ select: { status: true, friendlyId: true } },
prisma
);
}
if (probe && !isFinalRunStatus(probe.status)) {
return { runId: session.currentRunId, triggered: false };
}
// Either the row vanished on the writer too (probe null) or its status
// is final. Either way the prior run isn't going to consume new
// appends — but the session may still hold conversation state on
// `session.out` and an S3 snapshot keyed on `session.friendlyId`.
// Forward the prior run's public-form id (friendlyId — same shape as
// `ctx.run.id`) to the agent as `previousRunId` so its boot gate flips
// `couldHavePriorState` and replays the persisted state instead of
// treating this as a fresh chat. See `chat.agent`'s boot orchestration
// in `packages/trigger-sdk/src/v3/ai.ts`.
priorDeadRunFriendlyId = probe?.friendlyId ?? session.currentRunId;
}
// 2. Validate config + trigger upfront. Continuation overrides
// (`continuation`, `previousRunId`) are derived from session state above
// and merged AFTER caller-supplied overrides — caller can't accidentally
// unset them on a session that has had a prior run, but can still
// override `trigger`/`metadata` etc. `sessionId` is always set so the
// agent doesn't need a control-plane round-trip to look up the session
// friendlyId from `payload.chatId`.
// Continuation overrides strip the basePayload's first-run-only fields
// so a continuation run doesn't inherit a stale boot payload. The Session
// row's `triggerConfig.basePayload` is captured at create-time and used
// verbatim for every Run we trigger; if the customer included `message`
// / `messages` / `trigger: "submit-message"` to make the FIRST run boot
// straight into a first turn (via `chat.createStartSessionAction`), those
// values stick around and get replayed on every continuation. With
// `continuation: true` and `message`/`messages` cleared, the SDK boot
// path enters its continuation-wait branch and waits for the next
// session.in record before running a turn.
const continuationOverrides: Record<string, unknown> = {
sessionId: session.friendlyId,
...(priorDeadRunFriendlyId !== undefined
? {
continuation: true,
previousRunId: priorDeadRunFriendlyId,
// Clear sticky boot-payload fields so the new run waits for the
// next session.in record instead of re-processing whatever was
// in the original `createStartSessionAction({ basePayload })`.
message: undefined,
messages: undefined,
trigger: undefined,
}
: {}),
};
const mergedPayloadOverrides: Record<string, unknown> = {
...(payloadOverrides ?? {}),
...continuationOverrides,
};
const config = SessionTriggerConfigSchema.parse(session.triggerConfig);
const triggered = await triggerSessionRun({
session,
config,
environment,
payloadOverrides: mergedPayloadOverrides,
});
// 3. Try to claim the slot atomically.
const claim = await prisma.session.updateMany({
where: {
id: session.id,
currentRunVersion: session.currentRunVersion,
},
data: {
currentRunId: triggered.id,
currentRunVersion: { increment: 1 },
},
});
if (claim.count === 1) {
// Won. Audit the SessionRun. Best-effort — failure here doesn't
// invalidate the live run, just leaves a missing audit row.
prisma.sessionRun
.create({
data: { sessionId: session.id, runId: triggered.id, reason },
})
.catch((error) => {
logger.warn("Failed to record SessionRun audit row", {
sessionId: session.id,
runId: triggered.id,
reason,
error,
});
});
return { runId: triggered.id, triggered: true };
}
// 4. Lost the race. Cancel our triggered run; reuse the winner's.
cancelLostRaceRun(triggered.id, environment).catch((error) => {
logger.warn("Failed to cancel lost-race session run", {
sessionId: session.id,
runId: triggered.id,
error,
});
});
// Read-after-write: the winner just wrote `currentRunId` /
// `currentRunVersion` on the writer. Reading from `$replica` could
// return pre-race state and cause us to recurse with the same stale
// version, losing the next claim, until we exhaust max attempts.
const fresh = await prisma.session.findFirst({
where: { id: session.id },
select: {
id: true,
friendlyId: true,
taskIdentifier: true,
triggerConfig: true,
currentRunId: true,
currentRunVersion: true,
},
});
if (!fresh) {
// Session vanished mid-flight. Surface as an error — caller decides
// whether to 404 or retry.
throw new SessionRunManagerError(`Session ${session.id} not found after lost claim race`);
}
if (fresh.currentRunId) {
// Same read-after-write reason as the `fresh` reload above: the winner
// just wrote `currentRunId` on the writer, so probe the writer too —
// the replica may not have the run row yet, and a missed probe forces
// another trigger+recurse until `ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS`.
const probe = await runStore.findRun(
{ id: fresh.currentRunId },
{ select: { status: true, friendlyId: true } },
prisma
);
if (probe && !isFinalRunStatus(probe.status)) {
return { runId: fresh.currentRunId, triggered: false };
}
}
// Pathological: winner's run already terminal. Recurse with the fresh
// version. Bounded by `ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS` so a task
// that always crashes before being dequeued surfaces as an error
// instead of a stack overflow.
return ensureRunForSession({
session: fresh,
environment,
reason,
payloadOverrides,
_attempt: _attempt + 1,
});
}
/**
* Trigger a single run for a session. Builds `TriggerTaskRequestBody`
* by shallow-merging `payloadOverrides` over `config.basePayload` and
* threading `config`'s machine/queue/tags through the trigger options.
*/
async function triggerSessionRun(params: {
session: Pick<Session, "id" | "taskIdentifier">;
config: SessionTriggerConfig;
environment: AuthenticatedEnvironment;
payloadOverrides?: Record<string, unknown>;
}): Promise<{ id: string; friendlyId: string }> {
const { session, config, environment, payloadOverrides } = params;
const payload = {
...config.basePayload,
...(config.idleTimeoutInSeconds !== undefined
? { idleTimeoutInSeconds: config.idleTimeoutInSeconds }
: {}),
...(payloadOverrides ?? {}),
};
const body = {
payload,
context: {},
options: {
...(config.machine ? { machine: config.machine as never } : {}),
...(config.queue ? { queue: { name: config.queue } } : {}),
...(config.tags ? { tags: config.tags } : {}),
...(config.maxAttempts !== undefined ? { maxAttempts: config.maxAttempts } : {}),
...(config.maxDuration !== undefined ? { maxDuration: config.maxDuration } : {}),
...(config.lockToVersion ? { lockToVersion: config.lockToVersion } : {}),
...(config.region ? { region: config.region } : {}),
},
};
const service = new TriggerTaskService();
const result = await service.call(session.taskIdentifier, environment, body, {
triggerSource: "session",
triggerAction: "trigger",
});
if (!result) {
throw new SessionRunManagerError(
`TriggerTaskService returned no result for taskIdentifier=${session.taskIdentifier}`
);
}
return { id: result.run.id, friendlyId: result.run.friendlyId };
}
type SwapSessionRunParams = {
/**
* Session row to swap. `friendlyId` is forwarded as `payload.sessionId`
* on the new run so the agent attaches to `session.in/.out` without a
* control-plane round-trip (same convention as
* {@link EnsureRunForSessionParams}).
*/
session: Pick<
Session,
"id" | "friendlyId" | "taskIdentifier" | "triggerConfig" | "currentRunId" | "currentRunVersion"
>;
/**
* The run requesting the swap. Optimistic claim requires
* `Session.currentRunId === callingRunId` so the swap can't clobber
* a run triggered out-of-band (e.g. a parallel `.in/append` probe
* that already replaced the dead run).
*
* Also forwarded as `payload.previousRunId` on the new run alongside
* `continuation: true` — every swap is a continuation by construction
* (`chat.requestUpgrade` / `chat.endRun` deliberately hand off prior
* conversation state to a new run), so the agent's boot gate flips
* `couldHavePriorState` and replays the snapshot + session.out tail.
*/
callingRunId: string;
environment: AuthenticatedEnvironment;
reason: EnsureRunReason;
payloadOverrides?: Record<string, unknown>;
};
export type SwapSessionRunResult = {
/** runId of the newly-triggered run that has taken over the session. */
runId: string;
/**
* False when the swap was preempted (currentRunId is no longer the
* calling run). The caller should treat this as "someone else
* already moved on" — exit cleanly without expecting to drive the
* next run.
*/
swapped: boolean;
};
/**
* Force-swap the session to a freshly-triggered run, regardless of
* whether the current run is alive. Called by `end-and-continue` when
* the running agent wants a clean handoff (typically version upgrade).
*
* Differs from `ensureRunForSession`: never reuses the current run.
* The optimistic claim is keyed on `currentRunId === callingRunId`, so
* a parallel append-time probe that already swapped to a different
* run wins the race and `swapped: false` is surfaced.
*/
export async function swapSessionRun(params: SwapSessionRunParams): Promise<SwapSessionRunResult> {
const { session, callingRunId, environment, reason, payloadOverrides } = params;
// `callingRunId` is the internal cuid (`Session.currentRunId` stores
// cuid; the route handler resolves the wire's friendlyId before passing
// it here). The agent's `previousRunId` is customer-visible and must
// match the public `run_*` form exposed via `ctx.run.id` — resolve
// before forwarding.
const callingRunFriendlyId = await resolveRunFriendlyId(callingRunId);
// Continuation overrides — unconditionally set on swap. Unlike
// `ensureRunForSession`, there's no dead-run-detection branch here:
// every swap is a deliberate handoff from `callingRunId` (which owned
// prior conversation state) to a fresh run. Merged AFTER caller-supplied
// overrides so a caller can't accidentally unset them.
//
// Sticky boot-payload fields (`message` / `messages` / `trigger`) are
// cleared here for the same reason as in `ensureRunForSession`: the
// Session's basePayload is captured at create-time and replays on every
// continuation if not stripped. See the comment in `ensureRunForSession`.
const mergedPayloadOverrides: Record<string, unknown> = {
...(payloadOverrides ?? {}),
sessionId: session.friendlyId,
continuation: true,
previousRunId: callingRunFriendlyId,
message: undefined,
messages: undefined,
trigger: undefined,
};
const config = SessionTriggerConfigSchema.parse(session.triggerConfig);
const triggered = await triggerSessionRun({
session,
config,
environment,
payloadOverrides: mergedPayloadOverrides,
});
const claim = await prisma.session.updateMany({
where: {
id: session.id,
currentRunId: callingRunId,
currentRunVersion: session.currentRunVersion,
},
data: {
currentRunId: triggered.id,
currentRunVersion: { increment: 1 },
},
});
if (claim.count === 1) {
prisma.sessionRun
.create({
data: { sessionId: session.id, runId: triggered.id, reason },
})
.catch((error) => {
logger.warn("Failed to record SessionRun audit row", {
sessionId: session.id,
runId: triggered.id,
reason,
error,
});
});
return { runId: triggered.id, swapped: true };
}
// Lost the race — someone else already swapped to a new run. Cancel
// ours, surface the existing winner.
cancelLostRaceRun(triggered.id, environment).catch((error) => {
logger.warn("Failed to cancel preempted swap run", {
sessionId: session.id,
runId: triggered.id,
error,
});
});
// Read-after-write: the winner's swap was just committed on the
// writer. A replica read could return the pre-swap `currentRunId`
// (often `callingRunId` itself), which would tell the caller it is
// still the canonical run when in fact a different run has taken
// over.
const fresh = await prisma.session.findFirst({
where: { id: session.id },
select: { currentRunId: true },
});
// Mirror `ensureRunForSession`'s "session vanished" branch: if we
// can't find the row (or it has no current run) on the writer right
// after losing the race, surface as an error rather than handing back
// `callingRunId` with `swapped: false` — that would tell the caller
// it's still the canonical run when in fact we don't know who is.
if (!fresh?.currentRunId) {
throw new SessionRunManagerError(
`Session ${session.id} has no currentRunId after preempted swap`
);
}
return {
runId: fresh.currentRunId,
swapped: false,
};
}
async function getRunStatusAndFriendlyId(
runId: string
): Promise<{ status: TaskRunStatus; friendlyId: string } | null> {
// Use the read replica — this is a hot-path probe and stale-by-ms is
// fine. The append handler re-checks if it ends up reusing the runId.
// `friendlyId` is fetched alongside `status` so the dead-run-detection
// branch in `ensureRunForSession` can forward the public-form id as
// `payload.previousRunId` without a second read. `Session.currentRunId`
// stores the internal cuid; the agent's wire / customer hooks expose
// the friendlyId via `ctx.run.id`, so consistency matters.
const row = await runStore.findRun(
{ id: runId },
{ select: { status: true, friendlyId: true } },
$replica
);
return row ?? null;
}
/**
* Resolve a TaskRun cuid to its friendlyId. Used by `swapSessionRun` to
* forward the calling run's public-form id as `payload.previousRunId` on
* the new run. Falls back to the cuid on lookup miss so the swap doesn't
* fail just because the read replica hasn't caught up — the agent only
* uses `previousRunId` for customer-visible bookkeeping (e.g.
* `runs.retrieve(previousRunId)`), so a stale-but-non-null value is
* acceptable degraded behavior.
*/
async function resolveRunFriendlyId(runId: string): Promise<string> {
const row = await runStore.findRun({ id: runId }, { select: { friendlyId: true } }, $replica);
return row?.friendlyId ?? runId;
}
async function cancelLostRaceRun(
runId: string,
environment: AuthenticatedEnvironment
): Promise<void> {
const service = new CancelTaskRunService();
// Read-after-write: the run was just triggered on the writer, so go
// through `prisma`. A `$replica` miss here would silently no-op the
// cancel and leak an orphan run that no session is going to claim.
const run = await runStore.findRun({ id: runId }, prisma);
if (!run) return;
await service.call(run, { reason: "Lost session-run claim race" });
}
export class SessionRunManagerError extends Error {
readonly name = "SessionRunManagerError";
}
@@ -0,0 +1,194 @@
import type { PrismaClient, Session } from "@trigger.dev/database";
import type { SessionItem } from "@trigger.dev/core/v3";
import type { RunStore } from "@internal/run-store";
import { $replica, prisma } from "~/db.server";
import { runStore as defaultRunStore } from "~/v3/runStore.server";
/**
* Prefix that {@link SessionId.generate} attaches to every Session friendlyId.
* Used to distinguish friendlyId lookups (`session_abc...`) from externalId
* lookups on the public `GET /api/v1/sessions/:session` route.
*/
const SESSION_FRIENDLY_ID_PREFIX = "session_";
/**
* Resolve a session from a URL path parameter that may contain either a
* friendlyId (`session_abc...`) or a user-supplied externalId.
*
* Disambiguated by prefix: values starting with `session_` are treated as
* friendlyIds, anything else is looked up against `externalId` scoped to
* the caller's environment.
*/
// CONTROL-PLANE: `Session` lives on the control-plane DB; these reads are NOT
// routed to run-ops read-through — only the `TaskRun` currentRunId resolves in
// this file are run-ops read-through routed.
export async function resolveSessionByIdOrExternalId(
prisma: Pick<PrismaClient, "session">,
runtimeEnvironmentId: string,
idOrExternalId: string
): Promise<Session | null> {
if (isSessionFriendlyIdForm(idOrExternalId)) {
return prisma.session.findFirst({
where: { friendlyId: idOrExternalId, runtimeEnvironmentId },
});
}
// `findFirst` rather than `findUnique` per the repo rule — `findUnique`'s
// implicit DataLoader has open correctness bugs in Prisma 6.x that bite
// hot-path lookups exactly like this one.
return prisma.session.findFirst({
where: { runtimeEnvironmentId, externalId: idOrExternalId },
});
}
/**
* Replica-first session resolution with a writer fallback on miss. For the
* hot realtime routes (append / SSE subscribe / end-and-continue): a fresh
* session's first append or subscribe can arrive inside the replica's apply
* window, and a bare replica miss there surfaces as a 404 (or a subscribe
* that never finds its session) for a session that exists on the writer.
*/
export async function resolveSessionWithWriterFallback(
runtimeEnvironmentId: string,
idOrExternalId: string
): Promise<Session | null> {
const row = await resolveSessionByIdOrExternalId($replica, runtimeEnvironmentId, idOrExternalId);
if (row) return row;
return resolveSessionByIdOrExternalId(prisma, runtimeEnvironmentId, idOrExternalId);
}
/** True for `session_*` friendlyId form, false for everything else. */
export function isSessionFriendlyIdForm(value: string): boolean {
return value.startsWith(SESSION_FRIENDLY_ID_PREFIX);
}
/**
* Canonicalise the addressing key used for everything stream-level: the
* S2 stream path and the run-engine waitpoint cache key. `chat.agent`
* and the rest of the operational surface always pass `externalId`, but
* a public-API caller may legitimately address by `friendlyId` — and a
* session created without an `externalId` only has a friendlyId at all.
*
* Rule:
* - If we have a Session row, the canonical key is `externalId` if
* set, else `friendlyId`. This way two callers addressing the same
* row via different forms always converge to the same S2 stream.
* - If we have no row (yet — chat.agent's transport may subscribe
* before the agent's bind-time upsert lands), the canonical key is
* whatever the URL had. Operationally that's always an externalId.
* Friendlyid-form callers without a matching row are rejected by
* the route handler before this is reached.
*/
export function canonicalSessionAddressingKey(row: Session | null, paramSession: string): string {
if (row) {
return row.externalId ?? row.friendlyId;
}
return paramSession;
}
/**
* Convert a Prisma `Session` row to the public {@link SessionItem} wire format.
* Strips internal columns (project/environment/organization ids) and narrows
* the `metadata` JSON to a record.
*
* Note: `currentRunId` is left as-is — Prisma stores the internal run id
* (cuid), but `SessionItem.currentRunId` is the *friendly* form. Routes
* that emit `SessionItem`s must translate: single-row endpoints via
* {@link serializeSessionWithFriendlyRunId}, list endpoints via the
* batched {@link serializeSessionsWithFriendlyRunIds}. Never put this
* raw form on the wire directly.
*/
export function serializeSession(session: Session): SessionItem {
return {
id: session.friendlyId,
externalId: session.externalId,
type: session.type,
taskIdentifier: session.taskIdentifier,
triggerConfig: session.triggerConfig as SessionItem["triggerConfig"],
currentRunId: session.currentRunId,
tags: session.tags,
metadata: (session.metadata ?? null) as SessionItem["metadata"],
closedAt: session.closedAt,
closedReason: session.closedReason,
expiresAt: session.expiresAt,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
}
/**
* Same as {@link serializeSession} but resolves `currentRunId` from the
* internal cuid to the public `run_*` friendlyId via a TaskRun lookup.
* Single-row endpoints (`POST/GET/PATCH/close /api/v1/sessions/:s`) use
* this so the wire-side `currentRunId` is consistent with the rest of
* the public API (which only accepts friendlyIds for run lookups).
*
* Skips the lookup when `currentRunId` is null.
*
* Resolves `currentRunId` -> `friendlyId` through `runStore.findRun` so a
* run-ops id (NEW-DB) session run resolves from its owning store rather than the
* control-plane replica. Mirrors `sessionRunManager.server.ts`.
* Tenant-scoped because `Session.currentRunId` is a no-FK pointer.
*/
export async function serializeSessionWithFriendlyRunId(
session: Session,
runStore: RunStore = defaultRunStore
): Promise<SessionItem> {
const base = serializeSession(session);
if (!session.currentRunId) return base;
const run = await runStore.findRun(
{
id: session.currentRunId,
projectId: session.projectId,
runtimeEnvironmentId: session.runtimeEnvironmentId,
},
{ select: { friendlyId: true } }
);
return {
...base,
currentRunId: run?.friendlyId ?? null,
};
}
/**
* Batched form of {@link serializeSessionWithFriendlyRunId} for list
* endpoints: one `IN` lookup per page instead of N+1. `currentRunId` on
* the wire is always the public `run_*` friendlyId — the raw
* {@link serializeSession} form leaks the internal cuid, which customers
* can't use with `runs.retrieve(...)`.
*/
export async function serializeSessionsWithFriendlyRunIds(
sessions: Session[],
scope: { projectId: string; runtimeEnvironmentId: string },
runStore: RunStore = defaultRunStore
): Promise<SessionItem[]> {
const runIds = [
...new Set(sessions.map((s) => s.currentRunId).filter((id): id is string => !!id)),
];
// `runStore.findRuns` fans out across both stores under split (NEW + LEGACY
// replica merge) and is a plain `$replica` find when split is off. Tenant-
// scoped: `Session.currentRunId` is a no-FK pointer, so a stale id must never
// resolve a run in another env.
const runs =
runIds.length > 0
? await runStore.findRuns({
where: {
id: { in: runIds },
projectId: scope.projectId,
runtimeEnvironmentId: scope.runtimeEnvironmentId,
},
select: { id: true, friendlyId: true },
})
: [];
const friendlyIdByRunId = new Map(runs.map((run) => [run.id, run.friendlyId]));
return sessions.map((session) => ({
...serializeSession(session),
currentRunId: session.currentRunId
? (friendlyIdByRunId.get(session.currentRunId) ?? null)
: null,
}));
}
@@ -0,0 +1,283 @@
import {
type ElectricColumnType,
RUN_ELECTRIC_COLUMNS,
serializeRunRow,
} from "./electricStreamProtocol.server";
import { type RunHydrator, type RunListFilter, type RunListResolver } from "./runReader.server";
/**
* Dual-run shadow-compare: the client is always served the Electric response while this re-derives what
* the native backend would emit and diffs the two, to prove parity on real traffic before cutover. Checks
* serialization (semantic per-column compare, gated on same updatedAt so a changed row is "skew", not a
* divergence) and membership (emitted id-set, only on tag/batch initial snapshots). Pure but for the injected deps.
*/
export type ShadowFeed = "run" | "runs" | "batch";
type WireValue = Record<string, string | null>;
type ShapeMessage = {
key?: string;
value?: WireValue;
headers: { operation?: string; control?: string };
};
const COLUMN_BY_NAME = new Map(RUN_ELECTRIC_COLUMNS.map((column) => [column.name, column]));
export type ColumnDiff = {
runId: string;
column: string;
electric: string | null;
native: string | null;
};
export type ShadowCompareOutcome = {
feed: ShadowFeed;
/** Runs whose every emitted column matched (same-version). */
serializationMatched: number;
/** Runs with at least one semantic column divergence (same-version). */
serializationDiverged: number;
/** Runs that changed between Electric's emit and our refetch (not a divergence). */
serializationSkew: number;
/** Per-column divergences (capped) for logging. */
diffs: ColumnDiff[];
/** Set membership (tag/batch initial snapshot only). undefined when not checked. */
membershipMatch?: boolean;
missingInNative?: string[];
extraInNative?: string[];
};
export type ShadowCompareInput = {
feed: ShadowFeed;
/** The served Electric response body (a JSON array of messages, or "" / "[]"). */
electricBody: string;
environment: { id: string };
skipColumns: string[];
/** True when this was an initial snapshot request (offset=-1); enables membership compare. */
isInitialSnapshot: boolean;
/** When set (tag/batch initial snapshot), compare the resolved id-set. */
membershipFilter?: RunListFilter;
};
const MAX_DIFFS = 20;
export class RealtimeShadowComparator {
constructor(
private readonly options: { runReader: RunHydrator; runListResolver: RunListResolver }
) {}
async compare(input: ShadowCompareInput): Promise<ShadowCompareOutcome> {
const messages = parseBody(input.electricBody);
const changes = messages.filter(
(m): m is ShapeMessage & { value: WireValue } =>
typeof m.headers?.operation === "string" && !!m.value && m.headers.operation !== "delete"
);
const outcome: ShadowCompareOutcome = {
feed: input.feed,
serializationMatched: 0,
serializationDiverged: 0,
serializationSkew: 0,
diffs: [],
};
// Bulk-hydrate every emitted run in one query rather than a per-message round
// trip, so shadow mode doesn't inflate the very replica load it's measuring.
const emittedIds = changes
.map((m) => m.value.id)
.filter((id): id is string => typeof id === "string");
const hydrated = await this.options.runReader.hydrateByIds(input.environment.id, emittedIds);
const rowsById = new Map(hydrated.map((row) => [row.id, row]));
for (const message of changes) {
const runId = message.value.id ?? undefined;
if (!runId) {
continue;
}
const row = rowsById.get(runId);
if (!row) {
// Run no longer readable (deleted / replica miss). Not a serialization divergence.
outcome.serializationSkew++;
continue;
}
const nativeValue = serializeRunRow(row, input.skipColumns);
// Only compare rows at the same version; otherwise the row advanced between
// Electric's emit and our refetch (timing skew, not a divergence).
if (!sameInstant(message.value.updatedAt, nativeValue.updatedAt)) {
outcome.serializationSkew++;
continue;
}
let rowDiverged = false;
for (const [column, electricRaw] of Object.entries(message.value)) {
const meta = COLUMN_BY_NAME.get(column);
if (!meta) {
continue;
}
const nativeRaw = nativeValue[column] ?? null;
if (!valuesEqual(electricRaw, nativeRaw, meta.type, meta.dims, column)) {
rowDiverged = true;
if (outcome.diffs.length < MAX_DIFFS) {
outcome.diffs.push({ runId, column, electric: electricRaw, native: nativeRaw });
}
}
}
if (rowDiverged) {
outcome.serializationDiverged++;
} else {
outcome.serializationMatched++;
}
}
if (input.isInitialSnapshot && input.membershipFilter) {
const electricIds = new Set(
changes.map((m) => m.value.id).filter((id): id is string => typeof id === "string")
);
const nativeIds = new Set(
await this.options.runListResolver.resolveMatchingRunIds(input.membershipFilter)
);
outcome.missingInNative = [...electricIds].filter((id) => !nativeIds.has(id));
outcome.extraInNative = [...nativeIds].filter((id) => !electricIds.has(id));
outcome.membershipMatch =
outcome.missingInNative.length === 0 && outcome.extraInNative.length === 0;
}
return outcome;
}
}
function parseBody(body: string): ShapeMessage[] {
const text = body.trim();
if (!text) {
return [];
}
try {
const parsed = JSON.parse(text);
return Array.isArray(parsed) ? (parsed as ShapeMessage[]) : [];
} catch {
return [];
}
}
/** Status carries a known legacy rewrite (DEQUEUED -> EXECUTING) applied equally to
* both paths for non-current API versions; treat them as equivalent. */
function normalizeStatus(value: string): string {
return value === "DEQUEUED" ? "EXECUTING" : value;
}
function sameInstant(a: string | null | undefined, b: string | null | undefined): boolean {
if (a == null || b == null) {
return a == null && b == null;
}
// Mirror the SDK's RawShapeDate (`new Date(val + "Z")`).
return new Date(`${a}Z`).getTime() === new Date(`${b}Z`).getTime();
}
function valuesEqual(
electricRaw: string | null,
nativeRaw: string | null,
type: ElectricColumnType,
dims: number | undefined,
column: string
): boolean {
if (electricRaw == null || nativeRaw == null) {
return electricRaw == null && nativeRaw == null;
}
if (dims && dims > 0) {
return arraysEqual(parsePgTextArray(electricRaw), parsePgTextArray(nativeRaw));
}
switch (type) {
case "timestamp":
return new Date(`${electricRaw}Z`).getTime() === new Date(`${nativeRaw}Z`).getTime();
case "bool":
return parseBool(electricRaw) === parseBool(nativeRaw);
case "int4":
case "int8":
case "float8":
return Number(electricRaw) === Number(nativeRaw);
case "jsonb":
return jsonEqual(electricRaw, nativeRaw);
case "text":
default:
if (column === "status") {
return normalizeStatus(electricRaw) === normalizeStatus(nativeRaw);
}
return electricRaw === nativeRaw;
}
}
function parseBool(value: string): boolean {
return value === "t" || value === "true";
}
function jsonEqual(a: string, b: string): boolean {
try {
return deepEqual(JSON.parse(a), JSON.parse(b));
} catch {
return a === b;
}
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== typeof b || a === null || b === null) return false;
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
}
if (typeof a === "object" && typeof b === "object") {
const ak = Object.keys(a as object).sort();
const bk = Object.keys(b as object).sort();
return (
ak.length === bk.length &&
ak.every((k, i) => k === bk[i]) &&
ak.every((k) => deepEqual((a as any)[k], (b as any)[k]))
);
}
return false;
}
function arraysEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
/** Parse a Postgres text-array literal (`{"a","b"}` / `{}`). Mirrors the client's pgArrayParser. */
function parsePgTextArray(literal: string): string[] {
if (literal === "{}" || literal === "") {
return [];
}
const inner = literal.startsWith("{") && literal.endsWith("}") ? literal.slice(1, -1) : literal;
const result: string[] = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === '"') {
i++;
let s = "";
while (i < inner.length && inner[i] !== '"') {
if (inner[i] === "\\") {
i++;
}
s += inner[i];
i++;
}
result.push(s);
i++;
if (inner[i] === ",") i++;
} else {
let s = "";
while (i < inner.length && inner[i] !== ",") {
s += inner[i];
i++;
}
result.push(s);
if (inner[i] === ",") i++;
}
}
return result;
}
@@ -0,0 +1,188 @@
import type { API_VERSIONS } from "~/api/versions";
import { logger } from "../logger.server";
import {
type RealtimeEnvironment,
type RealtimeRequestOptions,
type RealtimeRunsParams,
} from "../realtimeClient.server";
import { RESERVED_COLUMNS } from "./electricStreamProtocol.server";
import {
type RealtimeListEnvironment,
type RealtimeStreamClient,
} from "./nativeRealtimeClient.server";
import { type RunListFilter } from "./runReader.server";
import {
type RealtimeShadowComparator,
type ShadowCompareOutcome,
type ShadowFeed,
} from "./shadowCompare.server";
export type ShadowRealtimeClientOptions = {
/** The path actually served to the client (Electric). */
electric: RealtimeStreamClient;
comparator: RealtimeShadowComparator;
/** createdAt window (ms) used to resolve tag-list membership for the compare. */
maximumCreatedAtFilterAgeMs: number;
/** Cap for the membership resolve. */
maxListResults: number;
/** Metrics sink for compare outcomes. */
onOutcome?: (outcome: ShadowCompareOutcome) => void;
};
/** Transparent wrapper that serves the Electric response unchanged and, in the background (fire-and-forget), diffs what the native backend would emit. */
export class ShadowRealtimeClient implements RealtimeStreamClient {
constructor(private readonly options: ShadowRealtimeClientOptions) {}
async streamRun(
url: URL | string,
environment: RealtimeEnvironment,
runId: string,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
): Promise<Response> {
const response = await this.options.electric.streamRun(
url,
environment,
runId,
apiVersion,
requestOptions,
clientVersion,
signal
);
this.#shadow("run", response, url, environment, requestOptions);
return response;
}
async streamRuns(
url: URL | string,
environment: RealtimeListEnvironment,
params: RealtimeRunsParams,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
): Promise<Response> {
const response = await this.options.electric.streamRuns(
url,
environment,
params,
apiVersion,
requestOptions,
clientVersion,
signal
);
this.#shadow("runs", response, url, environment, requestOptions, { tags: params.tags ?? [] });
return response;
}
async streamBatch(
url: URL | string,
environment: RealtimeListEnvironment,
batchId: string,
apiVersion: API_VERSIONS,
requestOptions?: RealtimeRequestOptions,
clientVersion?: string,
signal?: AbortSignal
): Promise<Response> {
const response = await this.options.electric.streamBatch(
url,
environment,
batchId,
apiVersion,
requestOptions,
clientVersion,
signal
);
this.#shadow("batch", response, url, environment, requestOptions, { batchId });
return response;
}
/** Fire-and-forget; never blocks the served response, never throws into the request. */
#shadow(
feed: ShadowFeed,
electricResponse: Response,
url: URL | string,
environment: RealtimeEnvironment & { projectId?: string },
requestOptions?: RealtimeRequestOptions,
membership?: { tags?: string[]; batchId?: string }
): void {
// Clone synchronously before the client consumes the body.
let bodyClone: Response;
try {
if (electricResponse.status !== 200) {
return;
}
bodyClone = electricResponse.clone();
} catch {
return;
}
void this.#runShadow(feed, bodyClone, url, environment, requestOptions, membership).catch(
(error) => logger.debug("[shadowRealtime] compare failed", { feed, error })
);
}
async #runShadow(
feed: ShadowFeed,
bodyClone: Response,
url: URL | string,
environment: RealtimeEnvironment & { projectId?: string },
requestOptions: RealtimeRequestOptions | undefined,
membership: { tags?: string[]; batchId?: string } | undefined
): Promise<void> {
const $url = new URL(url.toString());
const offset = $url.searchParams.get("offset") ?? "-1";
const handle = $url.searchParams.get("handle") ?? $url.searchParams.get("shape_id");
const isInitialSnapshot = offset === "-1" || !handle;
const skipColumns = resolveSkipColumns($url, requestOptions);
const electricBody = await bodyClone.text();
let membershipFilter: RunListFilter | undefined;
if (isInitialSnapshot && membership && environment.projectId) {
membershipFilter = {
organizationId: environment.organizationId,
projectId: environment.projectId,
environmentId: environment.id,
tags: membership.tags,
batchId: membership.batchId,
createdAtAfter: membership.batchId
? undefined
: new Date(Date.now() - this.options.maximumCreatedAtFilterAgeMs),
limit: this.options.maxListResults,
};
}
const outcome = await this.options.comparator.compare({
feed,
electricBody,
environment: { id: environment.id },
skipColumns,
isInitialSnapshot,
membershipFilter,
});
this.options.onOutcome?.(outcome);
if (outcome.serializationDiverged > 0 || outcome.membershipMatch === false) {
logger.warn("[shadowRealtime] divergence detected", {
feed,
serializationDiverged: outcome.serializationDiverged,
serializationMatched: outcome.serializationMatched,
serializationSkew: outcome.serializationSkew,
membershipMatch: outcome.membershipMatch,
missingInNative: outcome.missingInNative?.slice(0, 20),
extraInNative: outcome.extraInNative?.slice(0, 20),
// Log only which run/column diverged, never the raw cell values — they can
// include run payload/output/metadata and must not leak into logs.
diffs: outcome.diffs.map(({ runId, column }) => ({ runId, column })),
});
}
}
}
function 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));
}
@@ -0,0 +1,69 @@
import { getMeter } from "@internal/tracing";
import { $replica } from "~/db.server";
import { runStore } from "~/v3/runStore.server";
import { env } from "~/env.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { singleton } from "~/utils/singleton";
import { realtimeClient } from "../realtimeClientGlobal.server";
import { ClickHouseRunListResolver } from "./clickHouseRunListResolver.server";
import { RunHydrator } from "./runReader.server";
import { RealtimeShadowComparator } from "./shadowCompare.server";
import { ShadowRealtimeClient } from "./shadowRealtimeClient.server";
/**
* Process-singleton wiring for the shadow-compare client. Only constructed
* when an org's `realtimeBackend` flag is set to "shadow".
*/
function initializeShadowRealtimeClient(): ShadowRealtimeClient {
const compares = getMeter("realtime-shadow").createCounter("realtime_shadow.compares", {
description:
"Dual-run shadow-compare outcomes (Electric vs native). kind=serialization|membership, result=match|diverge|skew.",
});
const comparator = new RealtimeShadowComparator({
runReader: new RunHydrator({ replica: $replica, runStore }),
runListResolver: new ClickHouseRunListResolver({
getClickhouse: (organizationId) =>
clickhouseFactory.getClickhouseForOrganization(organizationId, "realtime"),
prisma: $replica,
}),
});
return new ShadowRealtimeClient({
electric: realtimeClient,
comparator,
maximumCreatedAtFilterAgeMs: env.REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS,
maxListResults: env.REALTIME_BACKEND_NATIVE_MAX_LIST_RESULTS,
onOutcome: (outcome) => {
const { feed } = outcome;
if (outcome.serializationMatched) {
compares.add(outcome.serializationMatched, {
feed,
kind: "serialization",
result: "match",
});
}
if (outcome.serializationDiverged) {
compares.add(outcome.serializationDiverged, {
feed,
kind: "serialization",
result: "diverge",
});
}
if (outcome.serializationSkew) {
compares.add(outcome.serializationSkew, { feed, kind: "serialization", result: "skew" });
}
if (outcome.membershipMatch !== undefined) {
compares.add(1, {
feed,
kind: "membership",
result: outcome.membershipMatch ? "match" : "diverge",
});
}
},
});
}
export function getShadowRealtimeClient(): ShadowRealtimeClient {
return singleton("shadowRealtimeClient", initializeShadowRealtimeClient);
}
@@ -0,0 +1,246 @@
/**
* Per-org S2 basin provisioning. Gated by
* `REALTIME_STREAMS_PER_ORG_BASINS_ENABLED`: when off, all orgs share
* `REALTIME_STREAMS_S2_BASIN` and this module no-ops.
*
* Pure retention-string in / S2-call out. Plan vocabulary lives in the
* cloud billing app, which calls into the admin sync route to drive
* provisioning + reconfiguration.
*/
import type { PrismaClientOrTransaction } from "~/db.server";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
import { parseDuration } from "./duration.server";
export function isPerOrgBasinsEnabled(): boolean {
return env.REALTIME_STREAMS_PER_ORG_BASINS_ENABLED === "true";
}
export function defaultRetention(): string {
return env.REALTIME_STREAMS_BASIN_DEFAULT_RETENTION;
}
// Org id is a cuid — fixed-length and stable, so the basin name is
// collision-free without truncation. Slugs are user-editable and would
// drift.
export function basinNameForOrg(org: { id: string }): string {
const prefix = env.REALTIME_STREAMS_BASIN_NAME_PREFIX;
const envName = env.REALTIME_STREAMS_BASIN_NAME_ENV;
return `${prefix}-${envName}-org-${org.id}`;
}
type ProvisionInput = {
id: string;
retention?: string;
streamBasinName: string | null | undefined;
};
type ProvisionResult =
| { kind: "skipped"; reason: "feature-disabled" | "already-provisioned"; basin: string | null }
| { kind: "provisioned"; basin: string; retention: string };
// Idempotent. Treats S2 409 as success (race with another caller, or
// previous run that crashed after S2 ack but before the column write).
export async function provisionBasinForOrg(
org: ProvisionInput,
prismaClient: PrismaClientOrTransaction = prisma
): Promise<ProvisionResult> {
if (!isPerOrgBasinsEnabled()) {
return { kind: "skipped", reason: "feature-disabled", basin: null };
}
if (org.streamBasinName) {
return { kind: "skipped", reason: "already-provisioned", basin: org.streamBasinName };
}
const accessToken = env.REALTIME_STREAMS_S2_ACCESS_TOKEN;
if (!accessToken) {
throw new Error(
"REALTIME_STREAMS_S2_ACCESS_TOKEN must be set when REALTIME_STREAMS_PER_ORG_BASINS_ENABLED=true"
);
}
const basin = basinNameForOrg(org);
const retention = org.retention ?? defaultRetention();
await s2CreateBasin(basin, {
accessToken,
retentionPolicy: retention,
storageClass: env.REALTIME_STREAMS_BASIN_STORAGE_CLASS,
deleteOnEmptyMinAge: env.REALTIME_STREAMS_BASIN_DELETE_ON_EMPTY_MIN_AGE,
});
await prismaClient.organization.update({
where: { id: org.id },
data: { streamBasinName: basin },
});
// streamBasinName is embedded in every env of the org; drop all its cached env rows.
controlPlaneResolver.invalidateOrganization(org.id);
logger.info("[streamBasinProvisioner] provisioned basin for org", {
orgId: org.id,
basin,
retention,
});
return { kind: "provisioned", basin, retention };
}
export async function reconfigureBasinForOrg(orgId: string, retention: string): Promise<void> {
if (!isPerOrgBasinsEnabled()) return;
const accessToken = env.REALTIME_STREAMS_S2_ACCESS_TOKEN;
if (!accessToken) {
throw new Error(
"REALTIME_STREAMS_S2_ACCESS_TOKEN must be set when REALTIME_STREAMS_PER_ORG_BASINS_ENABLED=true"
);
}
const org = await prisma.organization.findFirst({
where: { id: orgId },
select: { id: true, streamBasinName: true },
});
if (!org?.streamBasinName) return;
await s2ReconfigureBasin(org.streamBasinName, { accessToken, retentionPolicy: retention });
logger.info("[streamBasinProvisioner] reconfigured basin retention", {
orgId,
basin: org.streamBasinName,
retention,
});
}
type EnsureResult =
| { kind: "skipped"; reason: "feature-disabled" | "org-not-found" }
| { kind: "provisioned"; basin: string; retention: string }
| { kind: "reconfigured"; basin: string; retention: string };
// Idempotent: provisions if the org has no basin, PATCHes retention if
// it does. The single entrypoint the cloud billing app drives — both
// for the live plan-change path and the bulk backfill.
export async function ensureBasinForOrg(orgId: string, retention: string): Promise<EnsureResult> {
if (!isPerOrgBasinsEnabled()) {
return { kind: "skipped", reason: "feature-disabled" };
}
const org = await prisma.organization.findFirst({
where: { id: orgId },
select: { id: true, streamBasinName: true },
});
if (!org) return { kind: "skipped", reason: "org-not-found" };
if (!org.streamBasinName) {
const result = await provisionBasinForOrg({ id: org.id, streamBasinName: null, retention });
if (result.kind === "provisioned") {
return { kind: "provisioned", basin: result.basin, retention: result.retention };
}
return { kind: "skipped", reason: "feature-disabled" };
}
await reconfigureBasinForOrg(org.id, retention);
return { kind: "reconfigured", basin: org.streamBasinName, retention };
}
// Inverse of ensureBasinForOrg: nulls the column so future runs/sessions
// land in the shared global basin. The S2 basin lingers; existing streams
// age out on their original retention.
export async function deprovisionBasinForOrg(
orgId: string
): Promise<{ kind: "deprovisioned" } | { kind: "skipped"; reason: "no-basin" }> {
const org = await prisma.organization.findFirst({
where: { id: orgId },
select: { id: true, streamBasinName: true },
});
if (!org?.streamBasinName) return { kind: "skipped", reason: "no-basin" };
await prisma.organization.update({
where: { id: org.id },
data: { streamBasinName: null },
});
// streamBasinName is embedded in every env of the org; drop all its cached env rows.
controlPlaneResolver.invalidateOrganization(org.id);
logger.info("[streamBasinProvisioner] deprovisioned basin for org", {
orgId,
previousBasin: org.streamBasinName,
});
return { kind: "deprovisioned" };
}
// S2 REST: POST /v1/basins to create, PATCH /v1/basins/{name} to
// reconfigure. Wire shape takes integer seconds; we accept human strings
// like "7d" / "1y" as env-var ergonomics and parse them here.
type CreateBasinOptions = {
accessToken: string;
retentionPolicy: string;
storageClass: "express" | "standard";
deleteOnEmptyMinAge: string;
};
async function s2CreateBasin(name: string, opts: CreateBasinOptions): Promise<void> {
const url = `https://aws.s2.dev/v1/basins`;
const body = {
basin: name,
config: {
create_stream_on_append: true,
create_stream_on_read: true,
default_stream_config: {
storage_class: opts.storageClass,
retention_policy: { age: parseDuration(opts.retentionPolicy) },
delete_on_empty: { min_age_secs: parseDuration(opts.deleteOnEmptyMinAge) },
},
},
};
const res = await fetch(url, {
signal: AbortSignal.timeout(10_000),
method: "POST",
headers: {
Authorization: `Bearer ${opts.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
// 409 = basin already exists; treat as success (idempotent).
if (res.ok || res.status === 409) return;
const text = await res.text().catch(() => "");
throw new Error(`S2 createBasin failed: ${res.status} ${res.statusText} ${text}`);
}
type ReconfigureBasinOptions = {
accessToken: string;
retentionPolicy: string;
};
async function s2ReconfigureBasin(name: string, opts: ReconfigureBasinOptions): Promise<void> {
const url = `https://aws.s2.dev/v1/basins/${encodeURIComponent(name)}`;
const body = {
default_stream_config: {
retention_policy: { age: parseDuration(opts.retentionPolicy) },
},
};
const res = await fetch(url, {
signal: AbortSignal.timeout(10_000),
method: "PATCH",
headers: {
Authorization: `Bearer ${opts.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (res.ok) return;
const text = await res.text().catch(() => "");
throw new Error(`S2 reconfigureBasin failed: ${res.status} ${res.statusText} ${text}`);
}
@@ -0,0 +1,63 @@
export type StreamRecord = {
data: string;
id: string;
seqNum: number;
/**
* S2 record headers, when the underlying backend is the v2 (S2) shape.
* Undefined or empty for run-scoped Redis streams. First-header empty-name
* is an S2 command record (trim/fence); the parser strips those before
* surfacing the record, so callers never see them.
*/
headers?: Array<[string, string]>;
};
// Interface for stream ingestion
export interface StreamIngestor {
initializeStream(
runId: string,
streamId: string
): Promise<{ responseHeaders?: Record<string, string> }>;
ingestData(
stream: ReadableStream<Uint8Array>,
runId: string,
streamId: string,
clientId: string,
resumeFromChunk?: number
): Promise<Response>;
appendPart(part: string, partId: string, runId: string, streamId: string): Promise<void>;
getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise<number>;
readRecords(runId: string, streamId: string, afterSeqNum?: number): Promise<StreamRecord[]>;
}
export type StreamResponseOptions = {
timeoutInSeconds?: number;
lastEventId?: string;
/**
* Session-stream-only. When `true`, the responder MAY peek the tail
* of `.out` and short-circuit to `wait=0` + `X-Session-Settled: true`
* if the last record is a terminal marker (a `trigger-control`
* `turn-complete` control record, ignoring any trailing S2 trim
* command record). Used by `TriggerChatTransport.reconnectToStream`
* on page reload.
*
* When absent/false, the responder keeps the unconditional long-poll
* behavior — required on the active send-a-message path where the
* peek would race the newly-triggered turn's first chunk.
*/
peekSettled?: boolean;
};
// Interface for stream response
export interface StreamResponder {
streamResponse(
request: Request,
runId: string,
streamId: string,
signal: AbortSignal,
options?: StreamResponseOptions
): Promise<Response>;
}
@@ -0,0 +1,33 @@
export class LineTransformStream extends TransformStream<string, string[]> {
private buffer = "";
constructor() {
super({
transform: (chunk, controller) => {
// Append the chunk to the buffer
this.buffer += chunk;
// Split on newlines
const lines = this.buffer.split("\n");
// The last element might be incomplete, hold it back in buffer
this.buffer = lines.pop() || "";
// Filter out empty or whitespace-only lines
const fullLines = lines.filter((line) => line.trim().length > 0);
// If we got any complete lines, emit them as an array
if (fullLines.length > 0) {
controller.enqueue(fullLines);
}
},
flush: (controller) => {
// On stream end, if there's leftover text, emit it as a single-element array
const trimmed = this.buffer.trim();
if (trimmed.length > 0) {
controller.enqueue([trimmed]);
}
},
});
}
}
@@ -0,0 +1,143 @@
import {
createCache,
createLRUMemoryStore,
DefaultStatefulContext,
Namespace,
RedisCacheStore,
} from "@internal/cache";
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import type { AuthenticatedEnvironment } from "../apiAuth.server";
import { RedisRealtimeStreams } from "./redisRealtimeStreams.server";
import { S2RealtimeStreams } from "./s2realtimeStreams.server";
import type { StreamIngestor, StreamResponder } from "./types";
function initializeRedisRealtimeStreams() {
return new RedisRealtimeStreams({
redis: {
port: env.REALTIME_STREAMS_REDIS_PORT,
host: env.REALTIME_STREAMS_REDIS_HOST,
username: env.REALTIME_STREAMS_REDIS_USERNAME,
password: env.REALTIME_STREAMS_REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REALTIME_STREAMS_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
keyPrefix: "tr:realtime:streams:",
},
inactivityTimeoutMs: env.REALTIME_STREAMS_INACTIVITY_TIMEOUT_MS,
});
}
export const v1RealtimeStreams = singleton("realtimeStreams", initializeRedisRealtimeStreams);
/**
* Resolve a stream's basin. Precedence: run → session → org → global env.
* Pre-migration rows have `streamBasinName: null` and fall through to
* the global basin (where their streams actually live), so only pass
* `organization` when no run/session row exists at all — otherwise a
* null column would short-circuit to the org's *current* basin.
*/
export type StreamBasinContext = {
run?: { streamBasinName: string | null } | null;
session?: { streamBasinName: string | null } | null;
organization?: { streamBasinName: string | null } | null;
};
export function resolveStreamBasin(ctx: StreamBasinContext): string | undefined {
return (
ctx.run?.streamBasinName ??
ctx.session?.streamBasinName ??
ctx.organization?.streamBasinName ??
env.REALTIME_STREAMS_S2_BASIN ??
undefined
);
}
export function getRealtimeStreamInstance(
environment: AuthenticatedEnvironment,
streamVersion: string,
basinContext?: StreamBasinContext
): StreamIngestor & StreamResponder {
if (streamVersion === "v1") {
return v1RealtimeStreams;
}
const resolvedBasin = resolveStreamBasin(basinContext ?? {});
if (
resolvedBasin &&
(env.REALTIME_STREAMS_S2_ACCESS_TOKEN || env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true")
) {
return new S2RealtimeStreams({
basin: resolvedBasin,
accessToken: env.REALTIME_STREAMS_S2_ACCESS_TOKEN ?? "",
endpoint: env.REALTIME_STREAMS_S2_ENDPOINT,
skipAccessTokens: env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true",
streamPrefix: streamPrefixFor(environment, resolvedBasin),
logLevel: env.REALTIME_STREAMS_S2_LOG_LEVEL,
flushIntervalMs: env.REALTIME_STREAMS_S2_FLUSH_INTERVAL_MS,
maxRetries: env.REALTIME_STREAMS_S2_MAX_RETRIES,
s2WaitSeconds: env.REALTIME_STREAMS_S2_WAIT_SECONDS,
accessTokenExpirationInMs: env.REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS,
cache: s2RealtimeStreamsCache,
});
}
throw new Error("Realtime streams v2 is required for this run but S2 configuration is missing");
}
// Shared basin needs `org/{orgId}` to namespace; per-org basin already
// isolates so the segment drops.
function streamPrefixFor(environment: AuthenticatedEnvironment, basin: string): string {
const isPerOrgBasin = basin !== env.REALTIME_STREAMS_S2_BASIN;
const segments = isPerOrgBasin
? ["env", environment.slug, environment.id]
: ["org", environment.organization.id, "env", environment.slug, environment.id];
return segments.join("/");
}
export function determineRealtimeStreamsVersion(streamVersion?: string): "v1" | "v2" {
if (!streamVersion) {
return env.REALTIME_STREAMS_DEFAULT_VERSION;
}
if (
streamVersion === "v2" &&
env.REALTIME_STREAMS_S2_BASIN &&
(env.REALTIME_STREAMS_S2_ACCESS_TOKEN || env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true")
) {
return "v2";
}
return "v1";
}
const s2RealtimeStreamsCache = singleton(
"s2RealtimeStreamsCache",
initializeS2RealtimeStreamsCache
);
function initializeS2RealtimeStreamsCache() {
const ctx = new DefaultStatefulContext();
const redisCacheStore = new RedisCacheStore({
name: "s2-realtime-streams-cache",
connection: {
port: env.REALTIME_STREAMS_REDIS_PORT,
host: env.REALTIME_STREAMS_REDIS_HOST,
username: env.REALTIME_STREAMS_REDIS_USERNAME,
password: env.REALTIME_STREAMS_REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REALTIME_STREAMS_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
keyPrefix: "s2-realtime-streams-cache:",
},
useModernCacheKeyBuilder: true,
});
const memoryStore = createLRUMemoryStore(5000);
return createCache({
accessToken: new Namespace<string>(ctx, {
stores: [memoryStore, redisCacheStore],
fresh: Math.floor(env.REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS / 2),
stale: Math.floor(env.REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS / 2 + 60_000),
}),
});
}