Skip to content

Commit 230b5b0

Browse files
matt-aitkenTrigger.dev RepoOps
authored andcommitted
perf(webapp): subscribe socket.io namespaces lazily on first connection
Each server process opened Redis pub/sub subscriptions for every socket.io namespace at startup, including namespaces it would never serve a connection on. Every broadcast was therefore delivered to every process, and all but the few holding a relevant connection discarded it. Subscriptions are now opened per namespace the first time that process serves a connection on it, which keeps broadcast fan-out to the processes that can act on it. Behaviour is unchanged for a single process deployment, where the first worker or dev worker connection opens the same subscriptions as before. Mono-RevId: c14a7b60cd4303f463a24c6e044b4d48cc25e981
1 parent 4764978 commit 230b5b0

6 files changed

Lines changed: 1468 additions & 8 deletions

File tree

‎apps/webapp/app/v3/handleSocketIo.server.ts‎

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { EventBusEventArgs } from "@internal/run-engine";
2-
import { createAdapter } from "@socket.io/redis-adapter";
32
import { RunId } from "@trigger.dev/core/v3/isomorphic";
43
import type {
54
WorkerClientToServerEvents,
@@ -13,21 +12,31 @@ import { env } from "~/env.server";
1312
import { authenticateApiRequestWithFailure } from "~/services/apiAuth.server";
1413
import { logger } from "~/services/logger.server";
1514
import { singleton } from "~/utils/singleton";
15+
import {
16+
createLazySocketIoAdapter,
17+
withTimeout,
18+
type LazySocketIoAdapter,
19+
} from "./lazySocketIoAdapter.server";
1620
import { recordRunDebugLog } from "./eventRepository/index.server";
1721
import { engine } from "./runEngine.server";
1822
import { WorkerGroupTokenService } from "./services/worker/workerGroupTokenService.server";
1923

2024
export const socketIo = singleton("socketIo", initalizeIoServer);
2125

2226
function initalizeIoServer() {
23-
const io = initializeSocketIOServerInstance();
27+
const { io, lazyAdapter } = initializeSocketIOServerInstance();
28+
29+
if (lazyAdapter) {
30+
io.use(activateNamespaceMiddleware(lazyAdapter, "/"));
31+
}
2432

2533
io.on("connection", (socket) => {
2634
logger.log(`[socket.io][${socket.id}] connection at url: ${socket.request.url}`);
2735
});
2836

2937
const workerNamespace = createWorkerNamespace({
3038
io,
39+
lazyAdapter,
3140
namespace: "/worker",
3241
authenticate: async (request) => {
3342
const tokenService = new WorkerGroupTokenService();
@@ -40,6 +49,7 @@ function initalizeIoServer() {
4049
});
4150
const devWorkerNamespace = createWorkerNamespace({
4251
io,
52+
lazyAdapter,
4353
namespace: "/dev-worker",
4454
authenticate: async (request) => {
4555
const authentication = await authenticateApiRequestWithFailure(request);
@@ -73,17 +83,19 @@ function initializeSocketIOServerInstance() {
7383
});
7484
const subClient = pubClient.duplicate();
7585

86+
const lazyAdapter = createLazySocketIoAdapter(pubClient, subClient, {
87+
key: "tr:socket.io:",
88+
publishOnSpecificResponseChannel: true,
89+
});
90+
7691
const io = new Server({
77-
adapter: createAdapter(pubClient, subClient, {
78-
key: "tr:socket.io:",
79-
publishOnSpecificResponseChannel: true,
80-
}),
92+
adapter: lazyAdapter.adapter,
8193
});
8294

83-
return io;
95+
return { io, lazyAdapter };
8496
}
8597

86-
return new Server();
98+
return { io: new Server(), lazyAdapter: undefined };
8799
}
88100

89101
function headersFromHandshake(handshake: Socket["handshake"]) {
@@ -97,12 +109,48 @@ function headersFromHandshake(handshake: Socket["handshake"]) {
97109
return headers;
98110
}
99111

112+
/**
113+
* Bounds how long a handshake waits for the Redis subscriptions to land. The
114+
* Redis client's own offline queue and retry budget would otherwise hold the
115+
* handshake open for an unpredictable stretch during a Redis blip.
116+
*/
117+
const NAMESPACE_ACTIVATION_TIMEOUT_MS = 5_000;
118+
119+
/**
120+
* Opens the namespace's Redis subscriptions before the handshake completes, so
121+
* a socket cannot join a room while this process is still unsubscribed. A
122+
* failure or timeout rejects the connection rather than accepting one that
123+
* would silently miss notifications; the client reconnects and the run's
124+
* snapshot poll covers the gap. The activation itself keeps going, so a slow
125+
* one still benefits the next connection.
126+
*/
127+
function activateNamespaceMiddleware(lazyAdapter: LazySocketIoAdapter, namespace: string) {
128+
return async (_socket: Socket, next: (err?: Error) => void) => {
129+
try {
130+
await withTimeout(
131+
lazyAdapter.activate(namespace),
132+
NAMESPACE_ACTIVATION_TIMEOUT_MS,
133+
`socket.io namespace ${namespace} did not subscribe within ${NAMESPACE_ACTIVATION_TIMEOUT_MS}ms`
134+
);
135+
next();
136+
} catch (error) {
137+
logger.error("Failed to open socket.io Redis subscriptions", {
138+
namespace,
139+
error: error instanceof Error ? error.message : String(error),
140+
});
141+
next(error instanceof Error ? error : new Error("socket.io subscribe failed"));
142+
}
143+
};
144+
}
145+
100146
function createWorkerNamespace({
101147
io,
148+
lazyAdapter,
102149
namespace,
103150
authenticate,
104151
}: {
105152
io: Server;
153+
lazyAdapter: LazySocketIoAdapter | undefined;
106154
namespace: string;
107155
authenticate: (request: Request) => Promise<boolean>;
108156
}) {
@@ -142,6 +190,10 @@ function createWorkerNamespace({
142190
}
143191
});
144192

193+
if (lazyAdapter) {
194+
worker.use(activateNamespaceMiddleware(lazyAdapter, namespace));
195+
}
196+
145197
worker.on("connection", async (socket) => {
146198
logger.debug("worker connected", { namespace, socketId: socket.id });
147199

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { createAdapter } from "@socket.io/redis-adapter";
2+
import type { Redis } from "ioredis";
3+
import type { Namespace } from "socket.io";
4+
import type { Adapter } from "socket.io-adapter";
5+
6+
type AdapterFactory = (nsp: Namespace) => Adapter;
7+
type CreateAdapterOptions = Parameters<typeof createAdapter>[2];
8+
9+
export type LazySocketIoAdapter = {
10+
/** Pass as the socket.io `Server`'s `adapter` option. */
11+
adapter: AdapterFactory;
12+
/**
13+
* Opens this namespace's Redis subscriptions. Await it before a socket can
14+
* join a room, so a broadcast can never arrive at an unsubscribed process.
15+
* Idempotent, and safe to call concurrently. A failed attempt is retried on
16+
* the next call rather than being cached.
17+
*/
18+
activate: (namespaceName: string) => Promise<void>;
19+
/** True once {@link activate} has been called for the namespace's current adapter. */
20+
isActivated: (namespaceName: string) => boolean;
21+
activatedNamespaces: () => string[];
22+
};
23+
24+
/**
25+
* Activation state for one adapter instance. Completion handlers close over
26+
* this object rather than a namespace-keyed map, so an activation started by
27+
* an adapter that has since been replaced can only ever mutate its own,
28+
* orphaned state and never the replacement's.
29+
*/
30+
type NamespaceState = {
31+
queue: Array<() => unknown>;
32+
activation: Promise<void> | undefined;
33+
ready: boolean;
34+
};
35+
36+
/**
37+
* `subClient` calls the adapter makes at construction time that we hold back.
38+
* Everything else, including the `pmessageBuffer` / `messageBuffer` listener
39+
* registration, still runs eagerly so no message can be dropped in the gap
40+
* between subscribing and the handler being attached.
41+
*/
42+
const DEFERRED_METHODS = new Set(["subscribe", "psubscribe"]);
43+
44+
const isLocalOnly = (opts: unknown) =>
45+
Boolean((opts as { flags?: { local?: boolean } } | undefined)?.flags?.local);
46+
47+
/**
48+
* Adapter operations that publish a request and then wait on replies, keyed
49+
* to a predicate over the call's arguments saying whether this particular call
50+
* does. Each one sizes its wait from `serverCount()`, which is `NUMSUB` on the
51+
* request channel, and assumes the caller is one of those subscribers. An
52+
* unsubscribed caller would undercount peers (`fetchSockets` on a single
53+
* active peer sees `numSub === 1` and silently returns only local results) and
54+
* could not receive replies anyway, having no response-channel subscription.
55+
* So a call that needs replies activates first, trading a subscription for a
56+
* correct answer, while a call that cannot need one (`flags.local`) runs as
57+
* stock and leaves an idle process idle.
58+
*
59+
* Not listed: `serverSideEmit` without an ack publishes with no `requestId`,
60+
* so peers never reply, and with an ack it delegates to the wrapped
61+
* `serverSideEmitWithAck`. `serverCount` gets its own wrapper below, since it
62+
* must keep counting the caller without being made to subscribe.
63+
*/
64+
export const RESPONSE_DEPENDENT_METHODS: Readonly<
65+
Record<string, (args: readonly unknown[]) => boolean>
66+
> = {
67+
fetchSockets: ([opts]) => !isLocalOnly(opts),
68+
allRooms: () => true,
69+
serverSideEmitWithAck: () => true,
70+
broadcastWithAck: ([, opts]) => !isLocalOnly(opts),
71+
};
72+
73+
/**
74+
* Rejects if `promise` has not settled within `ms`. The underlying work keeps
75+
* running, so a slow activation still completes and benefits the next caller;
76+
* only this caller stops waiting.
77+
*/
78+
export function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
79+
return new Promise<T>((resolve, reject) => {
80+
const timer = setTimeout(() => reject(new Error(message)), ms);
81+
promise.then(
82+
(value) => {
83+
clearTimeout(timer);
84+
resolve(value);
85+
},
86+
(error) => {
87+
clearTimeout(timer);
88+
reject(error);
89+
}
90+
);
91+
});
92+
}
93+
94+
/**
95+
* Wraps `@socket.io/redis-adapter` so a process only subscribes to a
96+
* namespace's Redis channels once it actually serves a connection on that
97+
* namespace, or issues an adapter operation that needs replies.
98+
*
99+
* Fan-out is O(subscribing processes), so a process holding no sockets for a
100+
* namespace costs every publisher a delivery it will only discard. Deferring
101+
* the subscription keeps each namespace's fan-out to the processes that can
102+
* act on it, with no deployment-time configuration to keep in sync.
103+
*/
104+
export function createLazySocketIoAdapter(
105+
pubClient: Redis,
106+
subClient: Redis,
107+
opts: CreateAdapterOptions
108+
): LazySocketIoAdapter {
109+
/** The current adapter's state per namespace name; replaced wholesale on reconstruction. */
110+
const states = new Map<string, NamespaceState>();
111+
112+
async function flush(state: NamespaceState) {
113+
if (state.queue.length === 0) {
114+
return;
115+
}
116+
117+
/** Left in place until every call lands, so a failure retries the whole set. */
118+
for (const call of state.queue) {
119+
await call();
120+
}
121+
122+
state.queue.length = 0;
123+
}
124+
125+
function activateState(state: NamespaceState): Promise<void> {
126+
if (state.activation) {
127+
return state.activation;
128+
}
129+
130+
const activation = flush(state).then(
131+
() => {
132+
state.ready = true;
133+
},
134+
(error) => {
135+
state.activation = undefined;
136+
throw error;
137+
}
138+
);
139+
140+
state.activation = activation;
141+
142+
return activation;
143+
}
144+
145+
function activate(namespaceName: string): Promise<void> {
146+
const state = states.get(namespaceName);
147+
148+
return state ? activateState(state) : Promise.resolve();
149+
}
150+
151+
/**
152+
* socket.io instantiates the adapter with `new`, so this has to be a
153+
* constructable function. An arrow function throws "not a constructor".
154+
*/
155+
const adapter = function (nsp: Namespace) {
156+
/**
157+
* A fresh state object per construction. A namespace rebuilt under the
158+
* same name gets its own queue and readiness, and any activation the
159+
* previous adapter still has in flight settles into that previous object,
160+
* which nothing consults any more.
161+
*/
162+
const state: NamespaceState = { queue: [], activation: undefined, ready: false };
163+
states.set(nsp.name, state);
164+
165+
const deferredSubClient = new Proxy(subClient, {
166+
get(target, prop) {
167+
if (typeof prop === "string" && DEFERRED_METHODS.has(prop)) {
168+
return (...args: unknown[]) => {
169+
state.queue.push(() => (target as unknown as Record<string, Function>)[prop](...args));
170+
return Promise.resolve(0);
171+
};
172+
}
173+
174+
const value = Reflect.get(target, prop, target);
175+
return typeof value === "function" ? value.bind(target) : value;
176+
},
177+
});
178+
179+
const instance = createAdapter(pubClient, deferredSubClient as Redis, opts)(nsp);
180+
const methods = instance as unknown as Record<string, unknown>;
181+
182+
/**
183+
* The stock adapter's `serverCount()` always includes the caller, because
184+
* the caller is always subscribed to the request channel that `NUMSUB`
185+
* counts. A cold lazy adapter is not, so it adds itself back rather than
186+
* subscribing just to be counted. Mid-activation, wait for the
187+
* subscription to land so `NUMSUB` is exact instead of guessing which side
188+
* of it we are on. Once ready, `NUMSUB` already counts this process.
189+
*
190+
* The cold decision is re-checked after the count resolves. The
191+
* subscription travels on `subClient` and `NUMSUB` on `pubClient`, and
192+
* Redis orders nothing across connections, so an activation that starts
193+
* during the read can reach Redis before `NUMSUB` executes and the result
194+
* would already include this process. Recounting after that activation
195+
* settles is exact; the extra one is added only when the state stayed cold
196+
* for the whole read. A rejected activation leaves this process
197+
* unsubscribed, so it counts as cold.
198+
*/
199+
const stockServerCount = methods.serverCount;
200+
201+
if (typeof stockServerCount === "function") {
202+
const count = () =>
203+
Promise.resolve((stockServerCount as () => unknown).apply(instance, [])).then(Number);
204+
205+
const countOnceSubscribed = (activation: Promise<void>) =>
206+
activation.then(count, () => count().then((peers) => peers + 1));
207+
208+
methods.serverCount = () => {
209+
if (state.ready) {
210+
return count();
211+
}
212+
213+
if (state.activation) {
214+
return countOnceSubscribed(state.activation);
215+
}
216+
217+
return count().then((peers) =>
218+
state.activation ? countOnceSubscribed(state.activation) : peers + 1
219+
);
220+
};
221+
}
222+
223+
for (const [method, needsReplies] of Object.entries(RESPONSE_DEPENDENT_METHODS)) {
224+
const original = methods[method];
225+
226+
if (typeof original !== "function") {
227+
continue;
228+
}
229+
230+
const call = original as (...a: unknown[]) => unknown;
231+
232+
/**
233+
* A call that cannot need replies, or one on an already subscribed
234+
* namespace, delegates synchronously so the sync-returning
235+
* `broadcastWithAck` keeps its ordering relative to a plain `broadcast`.
236+
* Only the first reply-needing call pays the async activation. Readiness
237+
* is this instance's own, never a namesake's.
238+
*/
239+
methods[method] = (...args: unknown[]) => {
240+
if (!needsReplies(args) || state.ready) {
241+
return call.apply(instance, args);
242+
}
243+
244+
return activateState(state).then(() => call.apply(instance, args));
245+
};
246+
}
247+
248+
return instance;
249+
} as unknown as AdapterFactory;
250+
251+
return {
252+
adapter,
253+
activate,
254+
isActivated: (namespaceName) => Boolean(states.get(namespaceName)?.activation),
255+
activatedNamespaces: () =>
256+
Array.from(states.entries())
257+
.filter(([, state]) => Boolean(state.activation))
258+
.map(([name]) => name),
259+
};
260+
}

0 commit comments

Comments
 (0)