Files
2026-07-13 13:32:57 +08:00

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}}`;
}
}