333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
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}}`;
|
|
}
|
|
}
|