Skip to content

Commit 5c62877

Browse files
committed
fix(webapp): harden the OTLP transform worker pool
Address review feedback on the opt-in worker pool: - Validate the pool config through env.server and clamp the worker count to at least 1, so a zero or non-numeric OTEL_TRANSFORM_WORKER_POOL_SIZE can no longer leave an empty pool that hangs every request. - Add a per-task timeout (reject and reap a stuck worker) and a bounded queue (shed when full), so a wedged worker or overload can't hang requests forever or grow memory without limit. - Respawn crashed workers with exponential backoff instead of a tight loop. - Guard the immediate pricing-reload listener replay so a throwing listener can't escape subscription setup.
1 parent a20709f commit 5c62877

5 files changed

Lines changed: 134 additions & 55 deletions

File tree

.server-changes/otlp-transform-worker-pool.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: improvement
44
---
55

6-
Optionally offload OTLP ingest processing (decode, transform, and LLM-cost enrichment) to a worker pool, freeing the main event loop under high telemetry volume. Off by default; opt in with the OTEL_TRANSFORM_WORKER_POOL_ENABLED environment variable.
6+
Optionally process high-volume telemetry ingestion in parallel for higher throughput under heavy load by setting `OTEL_TRANSFORM_WORKER_POOL_ENABLED=1`. Off by default.

apps/webapp/app/env.server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1937,6 +1937,13 @@ const EnvironmentSchema = z
19371937
EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(5_000),
19381938
EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING: z.coerce.number().int().default(2000),
19391939

1940+
// OTLP ingest transform worker pool (opt-in). When enabled, decode/convert/enrich run in a
1941+
// worker_threads pool instead of the request event loop; the single consolidated insert path
1942+
// is unchanged.
1943+
OTEL_TRANSFORM_WORKER_POOL_ENABLED: BoolEnv.default(false),
1944+
OTEL_TRANSFORM_WORKER_POOL_SIZE: z.coerce.number().int().optional(),
1945+
OTEL_TRANSFORM_WORKER_PATH: z.string().optional(),
1946+
19401947
// Organization data stores registry
19411948
ORGANIZATION_DATA_STORES_RELOAD_INTERVAL_MS: z.coerce
19421949
.number()

apps/webapp/app/v3/llmPricingRegistry.server.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ function emitPricingReload() {
3232
export function subscribeToPricingReload(listener: PricingReloadListener): () => void {
3333
pricingReloadListeners.add(listener);
3434
if (llmPricingRegistry?.isLoaded) {
35-
listener(llmPricingRegistry.toSerializable());
35+
try {
36+
listener(llmPricingRegistry.toSerializable());
37+
} catch (err) {
38+
logger.warn("LLM pricing reload listener failed", {
39+
error: err instanceof Error ? err.message : String(err),
40+
});
41+
}
3642
}
3743
return () => {
3844
pricingReloadListeners.delete(listener);

apps/webapp/app/v3/otlpExporter.server.ts

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import { eventRepository } from "./eventRepository/eventRepository.server";
2020
import type { CreateEventInput, IEventRepository } from "./eventRepository/eventRepository.types";
2121
import { startSpan } from "./tracing.server";
2222
import { enrichCreatableEvents } from "./utils/enrichCreatableEvents.server";
23-
import { waitForLlmPricingReady } from "./llmPricingRegistry.server";
2423
import { env } from "~/env.server";
2524
import { singleton } from "~/utils/singleton";
2625
import {
@@ -32,16 +31,22 @@ import {
3231
} from "./otlpTransform.server";
3332
import os from "node:os";
3433
import { getOtlpWorkerPool } from "./otlpWorkerPool.server";
35-
import { llmPricingRegistry, subscribeToPricingReload } from "./llmPricingRegistry.server";
34+
import {
35+
llmPricingRegistry,
36+
subscribeToPricingReload,
37+
waitForLlmPricingReady,
38+
} from "./llmPricingRegistry.server";
3639

3740
// When enabled, decode+convert+enrich run in a worker pool; the main thread keeps the single
3841
// consolidated insert path (batching/part-count unchanged). Off = today's single-thread path.
39-
export const otlpTransformWorkerPoolEnabled =
40-
process.env.OTEL_TRANSFORM_WORKER_POOL_ENABLED === "1";
42+
export const otlpTransformWorkerPoolEnabled = env.OTEL_TRANSFORM_WORKER_POOL_ENABLED;
4143

42-
const OTEL_TRANSFORM_WORKER_POOL_SIZE = process.env.OTEL_TRANSFORM_WORKER_POOL_SIZE
43-
? parseInt(process.env.OTEL_TRANSFORM_WORKER_POOL_SIZE, 10)
44-
: Math.max(1, (os.cpus()?.length ?? 2) - 2);
44+
// Always at least 1 worker: a 0/negative override must not silently disable the pool while the
45+
// flag is on (that would hang every raw-export request on an empty pool).
46+
const OTEL_TRANSFORM_WORKER_POOL_SIZE = Math.max(
47+
1,
48+
env.OTEL_TRANSFORM_WORKER_POOL_SIZE ?? (os.cpus()?.length ?? 2) - 2
49+
);
4550

4651
type OTLPExporterConfig = {
4752
clickhouseFactory: ClickhouseFactory;
@@ -143,12 +148,20 @@ class OTLPExporter {
143148
}
144149

145150
async #exportRawEvents(kind: "traces" | "logs", payload: Uint8Array): Promise<void> {
146-
await startSpan(this._tracer, kind === "traces" ? "exportTracesRaw" : "exportLogsRaw", async (span) => {
147-
const pool = await this.#pool();
148-
const { eventsWithStores } = await pool.runTransform(kind, payload, this.#transformConfig());
149-
const eventCount = await this.#exportEvents(eventsWithStores, true);
150-
span.setAttribute("event_count", eventCount);
151-
});
151+
await startSpan(
152+
this._tracer,
153+
kind === "traces" ? "exportTracesRaw" : "exportLogsRaw",
154+
async (span) => {
155+
const pool = await this.#pool();
156+
const { eventsWithStores } = await pool.runTransform(
157+
kind,
158+
payload,
159+
this.#transformConfig()
160+
);
161+
const eventCount = await this.#exportEvents(eventsWithStores, true);
162+
span.setAttribute("event_count", eventCount);
163+
}
164+
);
152165
}
153166

154167
#transformConfig() {
@@ -162,7 +175,11 @@ class OTLPExporter {
162175
await waitForLlmPricingReady();
163176
const models =
164177
llmPricingRegistry && llmPricingRegistry.isLoaded ? llmPricingRegistry.toSerializable() : [];
165-
const pool = getOtlpWorkerPool(OTEL_TRANSFORM_WORKER_POOL_SIZE, models);
178+
const pool = getOtlpWorkerPool(
179+
OTEL_TRANSFORM_WORKER_POOL_SIZE,
180+
models,
181+
env.OTEL_TRANSFORM_WORKER_PATH
182+
);
166183
if (!this.#pricingSubscribed) {
167184
this.#pricingSubscribed = true;
168185
// Re-broadcast pricing to workers on every registry reload so their cost math stays fresh.

apps/webapp/app/v3/otlpWorkerPool.server.ts

Lines changed: 88 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,38 @@ import { logger } from "~/services/logger.server";
44

55
export type TransformKind = "traces" | "logs" | "metrics";
66

7-
type Pending = { resolve: (r: any) => void; reject: (e: Error) => void };
8-
9-
type QueueItem = {
10-
message: {
11-
id: number;
12-
kind: TransformKind;
13-
payload: Uint8Array;
14-
spanAttributeValueLengthLimit: number;
15-
defaultEventStore: string;
16-
};
7+
type TaskMessage = {
8+
id: number;
9+
kind: TransformKind;
10+
payload: Uint8Array;
11+
spanAttributeValueLengthLimit: number;
12+
defaultEventStore: string;
13+
};
14+
15+
type Task = {
16+
message: TaskMessage;
1717
transfer: ArrayBuffer[];
18-
pending: Pending;
18+
resolve: (r: any) => void;
19+
reject: (e: Error) => void;
20+
timer: NodeJS.Timeout;
21+
worker?: Worker;
1922
};
2023

24+
const TASK_TIMEOUT_MS = 30_000;
25+
const MAX_QUEUE_DEPTH = 2_000;
26+
const RESPAWN_BASE_MS = 500;
27+
const RESPAWN_MAX_MS = 30_000;
28+
2129
// Hand-rolled worker_threads pool: one in-flight task per worker so CPU-bound transforms run
2230
// fully in parallel. The main thread stays the only DB reader and broadcasts pricing to workers.
2331
export class OtlpWorkerPool {
2432
private readonly workers: Worker[] = [];
2533
private readonly idle: Worker[] = [];
26-
private readonly queue: QueueItem[] = [];
27-
private readonly pending = new Map<number, Pending>();
34+
private readonly queue: number[] = [];
35+
private readonly tasks = new Map<number, Task>();
2836
private readonly busyByWorker = new Map<Worker, number>();
2937
private nextId = 1;
38+
private consecutiveFailures = 0;
3039
private latestPricingModels: unknown[];
3140

3241
constructor(
@@ -35,9 +44,7 @@ export class OtlpWorkerPool {
3544
pricingModels: unknown[]
3645
) {
3746
this.latestPricingModels = pricingModels;
38-
for (let i = 0; i < size; i++) {
39-
this.spawn();
40-
}
47+
for (let i = 0; i < size; i++) this.spawn();
4148
logger.info("OtlpWorkerPool started", { size, workerPath });
4249
}
4350

@@ -47,12 +54,14 @@ export class OtlpWorkerPool {
4754
});
4855

4956
worker.on("message", (msg: { id: number; ok: boolean; result?: any; error?: string }) => {
50-
const pending = this.pending.get(msg.id);
51-
this.pending.delete(msg.id);
57+
this.consecutiveFailures = 0;
5258
this.busyByWorker.delete(worker);
53-
if (pending) {
54-
if (msg.ok) pending.resolve(msg.result);
55-
else pending.reject(new Error(msg.error ?? "otlp worker error"));
59+
const task = this.tasks.get(msg.id);
60+
if (task) {
61+
clearTimeout(task.timer);
62+
this.tasks.delete(msg.id);
63+
if (msg.ok) task.resolve(msg.result);
64+
else task.reject(new Error(msg.error ?? "otlp worker error"));
5665
}
5766
this.release(worker);
5867
});
@@ -72,22 +81,35 @@ export class OtlpWorkerPool {
7281
this.idle.push(worker);
7382
}
7483

75-
// On crash: fail its in-flight task, drop it, and spawn a replacement.
84+
// On crash/timeout: fail the worker's in-flight task (if still pending), drop the worker, and
85+
// respawn with exponential backoff so a persistently failing worker can't tight-loop.
7686
private reap(worker: Worker, error: Error) {
7787
const inFlightId = this.busyByWorker.get(worker);
88+
this.busyByWorker.delete(worker);
7889
if (inFlightId !== undefined) {
79-
const pending = this.pending.get(inFlightId);
80-
this.pending.delete(inFlightId);
81-
this.busyByWorker.delete(worker);
82-
pending?.reject(error);
90+
const task = this.tasks.get(inFlightId);
91+
if (task) {
92+
clearTimeout(task.timer);
93+
this.tasks.delete(inFlightId);
94+
task.reject(error);
95+
}
8396
}
8497
const wi = this.workers.indexOf(worker);
8598
if (wi !== -1) this.workers.splice(wi, 1);
8699
const ii = this.idle.indexOf(worker);
87100
if (ii !== -1) this.idle.splice(ii, 1);
88101
void worker.terminate().catch(() => {});
89-
if (this.workers.length < this.size) this.spawn();
90-
this.drain();
102+
this.scheduleRespawn();
103+
}
104+
105+
private scheduleRespawn() {
106+
if (this.workers.length >= this.size) return;
107+
const delay = Math.min(RESPAWN_BASE_MS * 2 ** this.consecutiveFailures, RESPAWN_MAX_MS);
108+
this.consecutiveFailures++;
109+
setTimeout(() => {
110+
if (this.workers.length < this.size) this.spawn();
111+
this.drain();
112+
}, delay);
91113
}
92114

93115
private release(worker: Worker) {
@@ -98,21 +120,43 @@ export class OtlpWorkerPool {
98120
private drain() {
99121
while (this.queue.length > 0 && this.idle.length > 0) {
100122
const worker = this.idle.pop()!;
101-
const item = this.queue.shift()!;
102-
this.pending.set(item.message.id, item.pending);
103-
this.busyByWorker.set(worker, item.message.id);
104-
worker.postMessage(item.message, item.transfer);
123+
const id = this.queue.shift()!;
124+
const task = this.tasks.get(id);
125+
if (!task) continue;
126+
task.worker = worker;
127+
this.busyByWorker.set(worker, id);
128+
worker.postMessage(task.message, task.transfer);
105129
}
106130
}
107131

132+
private onTimeout(id: number) {
133+
const task = this.tasks.get(id);
134+
if (!task) return;
135+
this.tasks.delete(id);
136+
const err = new Error(`otlp worker task timed out after ${TASK_TIMEOUT_MS}ms`);
137+
if (task.worker) {
138+
// Dispatched to a stuck worker: reap it. The task is already removed, so reap won't
139+
// double-reject.
140+
this.reap(task.worker, err);
141+
} else {
142+
const qi = this.queue.indexOf(id);
143+
if (qi !== -1) this.queue.splice(qi, 1);
144+
}
145+
task.reject(err);
146+
}
147+
108148
runTransform(
109149
kind: TransformKind,
110150
payload: Uint8Array,
111151
config: { spanAttributeValueLengthLimit: number; defaultEventStore: string }
112152
): Promise<any> {
153+
if (this.queue.length >= MAX_QUEUE_DEPTH) {
154+
return Promise.reject(new Error("otlp worker pool queue is full"));
155+
}
113156
const id = this.nextId++;
114157
return new Promise((resolve, reject) => {
115-
this.queue.push({
158+
const timer = setTimeout(() => this.onTimeout(id), TASK_TIMEOUT_MS);
159+
this.tasks.set(id, {
116160
message: {
117161
id,
118162
kind,
@@ -122,8 +166,11 @@ export class OtlpWorkerPool {
122166
},
123167
// Zero-copy the payload into the worker; the request owns a fresh ArrayBuffer.
124168
transfer: [payload.buffer as ArrayBuffer],
125-
pending: { resolve, reject },
169+
resolve,
170+
reject,
171+
timer,
126172
});
173+
this.queue.push(id);
127174
this.drain();
128175
});
129176
}
@@ -146,12 +193,14 @@ export class OtlpWorkerPool {
146193

147194
let pool: OtlpWorkerPool | undefined;
148195

149-
export function getOtlpWorkerPool(size: number, pricingModels: unknown[]): OtlpWorkerPool {
196+
export function getOtlpWorkerPool(
197+
size: number,
198+
pricingModels: unknown[],
199+
workerPath?: string
200+
): OtlpWorkerPool {
150201
if (!pool) {
151-
const workerPath =
152-
process.env.OTEL_TRANSFORM_WORKER_PATH ??
153-
path.join(process.cwd(), "build", "otlpTransformWorker.cjs");
154-
pool = new OtlpWorkerPool(size, workerPath, pricingModels);
202+
const resolvedPath = workerPath ?? path.join(process.cwd(), "build", "otlpTransformWorker.cjs");
203+
pool = new OtlpWorkerPool(size, resolvedPath, pricingModels);
155204
}
156205
return pool;
157206
}

0 commit comments

Comments
 (0)