Skip to content

Commit 45527e3

Browse files
authored
feat(webapp): opt-in worker pool for OTLP ingest transform (#4232)
## Summary Under high OTLP ingest volume, the whole decode, transform, and enrich pipeline runs on the request event loop, so a single CPU core becomes the ceiling while the rest sit idle. This adds an opt-in worker pool that moves decode, transform, and LLM-cost enrichment onto worker threads, keeping the main thread free for I/O. It is off by default (`OTEL_TRANSFORM_WORKER_POOL_ENABLED`), so behavior is unchanged unless enabled. ## Design Workers do decode, filter, convert, and enrich (including LLM pricing match). The main thread stays the single database reader: it loads the pricing registry and broadcasts the compiled model rows to the workers (re-broadcasting on every reload), so workers never touch the database. The pure transform is extracted into a dependency-light module (no Prisma/Redis/ClickHouse imports) so it can run inside a worker. Importantly, the main thread keeps the existing single consolidated insert path, so ClickHouse insert batching and part count are unchanged. The parallelism buys CPU headroom, not more insert streams (which would add merge pressure). The worker is bundled as a standalone file at build time and ships in the existing image with no Dockerfile change. In local load testing the pool sustained roughly 2.6x the throughput of the single-thread path and kept the main thread responsive under load.
1 parent 9b3a7bd commit 45527e3

21 files changed

Lines changed: 2154 additions & 982 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
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
@@ -1939,6 +1939,13 @@ const EnvironmentSchema = z
19391939
EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(5_000),
19401940
EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING: z.coerce.number().int().default(2000),
19411941

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

apps/webapp/app/routes/otel.v1.logs.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { ExportLogsServiceRequest, ExportLogsServiceResponse } from "@trigger.dev/otlp-importer";
4-
import { otlpExporter } from "~/v3/otlpExporter.server";
4+
import { otlpExporter, otlpTransformWorkerPoolEnabled } from "~/v3/otlpExporter.server";
55

66
export async function action({ request }: ActionFunctionArgs) {
77
try {
@@ -17,6 +17,15 @@ export async function action({ request }: ActionFunctionArgs) {
1717
} else if (contentType.startsWith("application/x-protobuf")) {
1818
const buffer = await request.arrayBuffer();
1919

20+
if (otlpTransformWorkerPoolEnabled) {
21+
await exporter.exportLogsRaw(new Uint8Array(buffer));
22+
23+
return new Response(
24+
ExportLogsServiceResponse.encode(ExportLogsServiceResponse.create()).finish(),
25+
{ status: 200 }
26+
);
27+
}
28+
2029
const exportRequest = ExportLogsServiceRequest.decode(new Uint8Array(buffer));
2130

2231
const exportResponse = await exporter.exportLogs(exportRequest);

apps/webapp/app/routes/otel.v1.metrics.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
ExportMetricsServiceRequest,
55
ExportMetricsServiceResponse,
66
} from "@trigger.dev/otlp-importer";
7-
import { otlpExporter } from "~/v3/otlpExporter.server";
7+
import { otlpExporter, otlpTransformWorkerPoolEnabled } from "~/v3/otlpExporter.server";
88

99
export async function action({ request }: ActionFunctionArgs) {
1010
try {
@@ -21,6 +21,15 @@ export async function action({ request }: ActionFunctionArgs) {
2121
const exporter = await otlpExporter;
2222
const buffer = await request.arrayBuffer();
2323

24+
if (otlpTransformWorkerPoolEnabled) {
25+
await exporter.exportMetricsRaw(new Uint8Array(buffer));
26+
27+
return new Response(
28+
ExportMetricsServiceResponse.encode(ExportMetricsServiceResponse.create()).finish(),
29+
{ status: 200 }
30+
);
31+
}
32+
2433
const exportRequest = ExportMetricsServiceRequest.decode(new Uint8Array(buffer));
2534

2635
const exportResponse = await exporter.exportMetrics(exportRequest);

apps/webapp/app/routes/otel.v1.traces.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { ExportTraceServiceRequest, ExportTraceServiceResponse } from "@trigger.dev/otlp-importer";
4-
import { otlpExporter } from "~/v3/otlpExporter.server";
4+
import { otlpExporter, otlpTransformWorkerPoolEnabled } from "~/v3/otlpExporter.server";
55

66
export async function action({ request }: ActionFunctionArgs) {
77
try {
@@ -17,6 +17,15 @@ export async function action({ request }: ActionFunctionArgs) {
1717
} else if (contentType.startsWith("application/x-protobuf")) {
1818
const buffer = await request.arrayBuffer();
1919

20+
if (otlpTransformWorkerPoolEnabled) {
21+
await exporter.exportTracesRaw(new Uint8Array(buffer));
22+
23+
return new Response(
24+
ExportTraceServiceResponse.encode(ExportTraceServiceResponse.create()).finish(),
25+
{ status: 200 }
26+
);
27+
}
28+
2029
const exportRequest = ExportTraceServiceRequest.decode(new Uint8Array(buffer));
2130

2231
const exportResponse = await exporter.exportTraces(exportRequest);

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Logger } from "@trigger.dev/core/logger";
22
import { tryCatch } from "@trigger.dev/core/utils";
3+
import { getMeter, type Counter, type Histogram, type Meter } from "@internal/tracing";
34
import { nanoid } from "nanoid";
45
import pLimit from "p-limit";
56
import { signalsEmitter } from "~/services/signals.server";
@@ -16,6 +17,11 @@ export type DynamicFlushSchedulerConfig<T> = {
1617
loadSheddingThreshold?: number; // Number of items that triggers load shedding
1718
loadSheddingEnabled?: boolean;
1819
isDroppableEvent?: (item: T) => boolean; // Function to determine if an event can be dropped
20+
// Self-observability. `name` is the low-cardinality `scheduler` label that separates the
21+
// task_events / llm_metrics / otlp_metrics instances in the same process. `meter` defaults to
22+
// the global provider; inject one in tests. Instruments are no-op unless metrics are enabled.
23+
meter?: Meter;
24+
name?: string;
1925
};
2026

2127
export class DynamicFlushScheduler<T> {
@@ -54,7 +60,21 @@ export class DynamicFlushScheduler<T> {
5460

5561
private readonly logger: Logger = new Logger("EventRepo.DynamicFlushScheduler", "info");
5662

63+
// Pre-allocated attribute objects (closed label sets) so the hot flush path never allocates.
64+
private readonly _metricAttrs: { scheduler: string };
65+
private readonly _batchOkAttrs: { scheduler: string; outcome: string };
66+
private readonly _batchFailedAttrs: { scheduler: string; outcome: string };
67+
private _batchesCounter?: Counter;
68+
private _itemsCounter?: Counter;
69+
private _flushDurationHistogram?: Histogram;
70+
private _batchSizeHistogram?: Histogram;
71+
private _droppedEventsCounter?: Counter;
72+
5773
constructor(config: DynamicFlushSchedulerConfig<T>) {
74+
const schedulerName = config.name ?? "unknown";
75+
this._metricAttrs = { scheduler: schedulerName };
76+
this._batchOkAttrs = { scheduler: schedulerName, outcome: "ok" };
77+
this._batchFailedAttrs = { scheduler: schedulerName, outcome: "failed" };
5878
this.batchQueue = [];
5979
this.currentBatch = [];
6080
this.BATCH_SIZE = config.batchSize;
@@ -80,6 +100,54 @@ export class DynamicFlushScheduler<T> {
80100
this.startFlushTimer();
81101
this.startMetricsReporter();
82102
this.setupShutdownHandlers();
103+
this.#setupOtelMetrics(config.meter, schedulerName);
104+
}
105+
106+
#setupOtelMetrics(meterOverride: Meter | undefined, name: string): void {
107+
const meter = meterOverride ?? getMeter("ingest-flush");
108+
109+
this._batchesCounter = meter.createCounter("ingest.flush.batches", {
110+
description: "Batches flushed to the sink, by outcome",
111+
unit: "batches",
112+
});
113+
this._itemsCounter = meter.createCounter("ingest.flush.items", {
114+
description: "Items successfully flushed to the sink",
115+
unit: "items",
116+
});
117+
this._flushDurationHistogram = meter.createHistogram("ingest.flush.duration", {
118+
description: "Wall-clock duration of a single batch flush",
119+
unit: "ms",
120+
});
121+
this._batchSizeHistogram = meter.createHistogram("ingest.flush.batch_size", {
122+
description: "Number of items in a flushed batch",
123+
unit: "items",
124+
});
125+
this._droppedEventsCounter = meter.createCounter("ingest.flush.dropped_events", {
126+
description: "Events dropped by load shedding before they reached the sink",
127+
unit: "events",
128+
});
129+
130+
// Pull-based gauges: read at export time only, so they add zero hot-path cost.
131+
const queueDepthGauge = meter.createObservableGauge("ingest.flush.queue_depth", {
132+
description: "Items queued and awaiting flush",
133+
unit: "items",
134+
});
135+
const concurrencyGauge = meter.createObservableGauge("ingest.flush.concurrency", {
136+
description: "Current concurrent-flush limit",
137+
unit: "flushes",
138+
});
139+
const loadSheddingGauge = meter.createObservableGauge("ingest.flush.load_shedding", {
140+
description: "1 while actively shedding load, otherwise 0",
141+
});
142+
143+
meter.addBatchObservableCallback(
144+
(result) => {
145+
result.observe(queueDepthGauge, this.totalQueuedItems, this._metricAttrs);
146+
result.observe(concurrencyGauge, this.limiter.concurrency, this._metricAttrs);
147+
result.observe(loadSheddingGauge, this.isLoadShedding ? 1 : 0, this._metricAttrs);
148+
},
149+
[queueDepthGauge, concurrencyGauge, loadSheddingGauge]
150+
);
83151
}
84152

85153
addToBatch(items: T[]): void {
@@ -92,6 +160,7 @@ export class DynamicFlushScheduler<T> {
92160

93161
if (dropped.length > 0) {
94162
this.metrics.droppedEvents += dropped.length;
163+
this._droppedEventsCounter?.add(dropped.length, this._metricAttrs);
95164

96165
// Track dropped events by kind if possible
97166
dropped.forEach((item) => {
@@ -213,6 +282,11 @@ export class DynamicFlushScheduler<T> {
213282
self.metrics.flushedBatches++;
214283
self.metrics.totalItemsFlushed += itemCount;
215284

285+
self._flushDurationHistogram?.record(duration, self._metricAttrs);
286+
self._batchSizeHistogram?.record(itemCount, self._metricAttrs);
287+
self._itemsCounter?.add(itemCount, self._metricAttrs);
288+
self._batchesCounter?.add(1, self._batchOkAttrs);
289+
216290
self.logger.debug("Batch flushed successfully", {
217291
flushId,
218292
itemCount,
@@ -253,6 +327,7 @@ export class DynamicFlushScheduler<T> {
253327
this.logger.error("Error flushing batch", {
254328
error: flushError,
255329
});
330+
this._batchesCounter?.add(1, this._batchFailedAttrs);
256331
}
257332
})
258333
);

apps/webapp/app/v3/eventRepository/clickhouseEventRepository.server.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import type {
88
TaskEventV1Input,
99
TaskEventV2Input,
1010
} from "@internal/clickhouse";
11-
import type { Attributes, Tracer } from "@internal/tracing";
12-
import { startSpan, trace } from "@internal/tracing";
11+
import type { Attributes, Counter, Meter, Tracer } from "@internal/tracing";
12+
import { getMeter, startSpan, trace } from "@internal/tracing";
1313

1414
import { createJsonErrorObject } from "@trigger.dev/core/v3/errors";
1515
import { serializeTraceparent } from "@trigger.dev/core/v3/isomorphic";
@@ -104,6 +104,8 @@ export type ClickhouseEventRepositoryConfig = {
104104
otlpMetricsBatchSize?: number;
105105
otlpMetricsFlushInterval?: number;
106106
otlpMetricsMaxConcurrency?: number;
107+
/** Inject a meter for self-observability; defaults to the global provider. */
108+
meter?: Meter;
107109
};
108110

109111
/**
@@ -125,14 +127,22 @@ export class ClickhouseEventRepository implements IEventRepository {
125127
* track the drop count for observability.
126128
*/
127129
private _permanentlyDroppedBatches = 0;
130+
private readonly _droppedBatchesCounter: Counter;
128131

129132
constructor(config: ClickhouseEventRepositoryConfig) {
130133
this._clickhouse = config.clickhouse;
131134
this._config = config;
132135
this._tracer = config.tracer ?? trace.getTracer("clickhouseEventRepo", "0.0.1");
133136
this._version = config.version ?? "v1";
134137

138+
const meter = config.meter ?? getMeter("ingest-flush");
139+
this._droppedBatchesCounter = meter.createCounter("ingest.flush.batches_dropped", {
140+
description: "Batches permanently dropped after an unrecoverable ClickHouse JSON parse error",
141+
unit: "batches",
142+
});
143+
135144
this._flushScheduler = new DynamicFlushScheduler({
145+
name: `task_events_${this._version}`,
136146
batchSize: config.batchSize ?? 1000,
137147
flushInterval: config.flushInterval ?? 1000,
138148
callback: this.#flushBatch.bind(this),
@@ -149,6 +159,7 @@ export class ClickhouseEventRepository implements IEventRepository {
149159
});
150160

151161
this._llmMetricsFlushScheduler = new DynamicFlushScheduler({
162+
name: "llm_metrics",
152163
batchSize: config.llmMetricsBatchSize ?? 5000,
153164
flushInterval: config.llmMetricsFlushInterval ?? 2000,
154165
callback: this.#flushLlmMetricsBatch.bind(this),
@@ -160,6 +171,7 @@ export class ClickhouseEventRepository implements IEventRepository {
160171
});
161172

162173
this._otlpMetricsFlushScheduler = new DynamicFlushScheduler({
174+
name: "otlp_metrics",
163175
batchSize: config.otlpMetricsBatchSize ?? 10000,
164176
flushInterval: config.otlpMetricsFlushInterval ?? 1000,
165177
callback: this.#flushOtelMetricsBatch.bind(this),
@@ -359,6 +371,7 @@ export class ClickhouseEventRepository implements IEventRepository {
359371
// exactly the retry storm this wrapper is designed to avoid.
360372
if (fieldsSanitized === 0) {
361373
this._permanentlyDroppedBatches += 1;
374+
this._droppedBatchesCounter.add(1, { table: contextLabel });
362375
logger.error(
363376
"Dropped batch — ClickHouse JSON parse error but sanitizer found nothing to fix",
364377
{
@@ -390,6 +403,7 @@ export class ClickhouseEventRepository implements IEventRepository {
390403
if (!isClickHouseJsonParseError(retryError)) throw retryError;
391404

392405
this._permanentlyDroppedBatches += 1;
406+
this._droppedBatchesCounter.add(1, { table: contextLabel });
393407
const retryMessage =
394408
typeof retryError === "object" && retryError !== null && "message" in retryError
395409
? String((retryError as { message?: unknown }).message ?? "")

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export class EventRepository implements IEventRepository {
101101
private readonly _config: EventRepoConfig
102102
) {
103103
this._flushScheduler = new DynamicFlushScheduler({
104+
name: "postgres_events",
104105
batchSize: _config.batchSize,
105106
flushInterval: _config.batchInterval,
106107
callback: this.#flushBatch.bind(this),

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

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ModelPricingRegistry, seedLlmPricing } from "@internal/llm-model-catalog";
2+
import type { LlmModelWithPricing } from "@internal/llm-model-catalog";
23
import { prisma, $replica } from "~/db.server";
34
import { env } from "~/env.server";
45
import { logger } from "~/services/logger.server";
@@ -7,6 +8,43 @@ import { createRedisClient } from "~/redis.server";
78
import { singleton } from "~/utils/singleton";
89
import { setLlmPricingRegistry } from "./utils/enrichCreatableEvents.server";
910

11+
type PricingReloadListener = (models: LlmModelWithPricing[]) => void;
12+
const pricingReloadListeners = new Set<PricingReloadListener>();
13+
14+
// Notify subscribers (e.g. the OTLP worker pool) after each load/reload so they can rebuild
15+
// their own in-memory copy. No-op until something subscribes.
16+
function emitPricingReload() {
17+
if (!llmPricingRegistry || !llmPricingRegistry.isLoaded || pricingReloadListeners.size === 0) {
18+
return;
19+
}
20+
const models = llmPricingRegistry.toSerializable();
21+
for (const listener of pricingReloadListeners) {
22+
try {
23+
listener(models);
24+
} catch (err) {
25+
logger.warn("LLM pricing reload listener failed", {
26+
error: err instanceof Error ? err.message : String(err),
27+
});
28+
}
29+
}
30+
}
31+
32+
export function subscribeToPricingReload(listener: PricingReloadListener): () => void {
33+
pricingReloadListeners.add(listener);
34+
if (llmPricingRegistry?.isLoaded) {
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+
}
42+
}
43+
return () => {
44+
pricingReloadListeners.delete(listener);
45+
};
46+
}
47+
1048
async function initRegistry(registry: ModelPricingRegistry) {
1149
if (env.LLM_PRICING_SEED_ON_STARTUP) {
1250
await seedLlmPricing(prisma);
@@ -25,16 +63,21 @@ export const llmPricingRegistry = singleton("llmPricingRegistry", () => {
2563
// Wire up the registry so enrichCreatableEvents can use it
2664
setLlmPricingRegistry(registry);
2765

28-
initRegistry(registry).catch((err) => {
29-
console.error("Failed to initialize LLM pricing registry", err);
30-
});
66+
initRegistry(registry)
67+
.then(() => emitPricingReload())
68+
.catch((err) => {
69+
console.error("Failed to initialize LLM pricing registry", err);
70+
});
3171

3272
// Periodic reload (backstop for the pub/sub path below)
3373
const reloadInterval = env.LLM_PRICING_RELOAD_INTERVAL_MS;
3474
const interval = setInterval(() => {
35-
registry.reload().catch((err) => {
36-
console.error("Failed to reload LLM pricing registry", err);
37-
});
75+
registry
76+
.reload()
77+
.then(() => emitPricingReload())
78+
.catch((err) => {
79+
console.error("Failed to reload LLM pricing registry", err);
80+
});
3881
}, reloadInterval);
3982

4083
// Pub/sub reload is opt-in per process (default off). Without it, the
@@ -73,11 +116,14 @@ export const llmPricingRegistry = singleton("llmPricingRegistry", () => {
73116
if (pendingReloadTimer) return;
74117
pendingReloadTimer = setTimeout(() => {
75118
pendingReloadTimer = null;
76-
registry.reload().catch((err) => {
77-
logger.warn("Failed to reload LLM pricing registry from pub/sub", {
78-
error: err instanceof Error ? err.message : String(err),
119+
registry
120+
.reload()
121+
.then(() => emitPricingReload())
122+
.catch((err) => {
123+
logger.warn("Failed to reload LLM pricing registry from pub/sub", {
124+
error: err instanceof Error ? err.message : String(err),
125+
});
79126
});
80-
});
81127
}, debounceMs);
82128
}
83129

0 commit comments

Comments
 (0)