Skip to content

Commit 5221057

Browse files
committed
fix(webapp): drain and terminate OTLP transform workers on shutdown
Register SIGTERM/SIGINT handlers for the OTLP transform worker pool so that on shutdown it stops accepting new work, lets in-flight transforms finish (bounded), and terminates its workers, instead of leaving them to be force-killed. This avoids respawn churn and error-log noise during a graceful shutdown; the main thread stays the only DB writer, so inserts are unaffected.
1 parent b0ecf59 commit 5221057

2 files changed

Lines changed: 41 additions & 2 deletions

File tree

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
type ObservableGauge,
99
} from "@internal/tracing";
1010
import { logger } from "~/services/logger.server";
11+
import { signalsEmitter } from "~/services/signals.server";
1112

1213
export type TransformKind = "traces" | "logs" | "metrics";
1314

@@ -37,6 +38,7 @@ const TASK_TIMEOUT_MS = 30_000;
3738
const MAX_QUEUE_DEPTH = 2_000;
3839
const RESPAWN_BASE_MS = 500;
3940
const RESPAWN_MAX_MS = 30_000;
41+
const SHUTDOWN_DRAIN_MS = 5_000;
4042

4143
// Hand-rolled worker_threads pool: one in-flight task per worker so CPU-bound transforms run
4244
// fully in parallel. The main thread stays the only DB reader and broadcasts pricing to workers.
@@ -48,6 +50,7 @@ export class OtlpWorkerPool {
4850
private readonly busyByWorker = new Map<Worker, number>();
4951
private nextId = 1;
5052
private consecutiveFailures = 0;
53+
private isShuttingDown = false;
5154
private latestPricingModels: unknown[];
5255

5356
// Pre-allocated per-kind {kind} attribute objects so the per-task record path never allocates.
@@ -199,10 +202,12 @@ export class OtlpWorkerPool {
199202
}
200203

201204
private scheduleRespawn() {
205+
if (this.isShuttingDown) return;
202206
if (this.workers.length >= this.size) return;
203207
const delay = Math.min(RESPAWN_BASE_MS * 2 ** this.consecutiveFailures, RESPAWN_MAX_MS);
204208
this.consecutiveFailures++;
205209
setTimeout(() => {
210+
if (this.isShuttingDown) return;
206211
if (this.workers.length < this.size) this.spawn();
207212
this.drain();
208213
}, delay);
@@ -247,6 +252,9 @@ export class OtlpWorkerPool {
247252
payload: Uint8Array,
248253
config: { spanAttributeValueLengthLimit: number; defaultEventStore: string }
249254
): Promise<any> {
255+
if (this.isShuttingDown) {
256+
return Promise.reject(new Error("otlp worker pool is shutting down"));
257+
}
250258
if (this.queue.length >= MAX_QUEUE_DEPTH) {
251259
this._tasksCounter?.add(1, { kind, outcome: "rejected" });
252260
return Promise.reject(new Error("otlp worker pool queue is full"));
@@ -289,13 +297,28 @@ export class OtlpWorkerPool {
289297
return this.queue.length;
290298
}
291299

292-
// Terminate all workers and reject anything in flight. Terminated workers fire "exit", but reap()
293-
// no-ops on an already-removed worker, so no respawn is scheduled.
300+
// Stop taking new work, let in-flight tasks finish (bounded), then terminate every worker.
301+
// Terminated workers fire "exit", but reap() no-ops on an already-removed worker, and the
302+
// isShuttingDown guard stops any pending respawn, so shutdown is quiet.
294303
async shutdown(): Promise<void> {
304+
if (this.isShuttingDown) return;
305+
this.isShuttingDown = true;
306+
307+
logger.info("OtlpWorkerPool shutting down", {
308+
workers: this.workers.length,
309+
inFlight: this.tasks.size,
310+
});
311+
312+
const deadline = Date.now() + SHUTDOWN_DRAIN_MS;
313+
while (this.tasks.size > 0 && Date.now() < deadline) {
314+
await new Promise((resolve) => setTimeout(resolve, 50));
315+
}
316+
295317
const workers = this.workers.splice(0);
296318
this.idle.length = 0;
297319
this.queue.length = 0;
298320
this.busyByWorker.clear();
321+
// Reject anything that didn't drain within the deadline.
299322
for (const [, task] of this.tasks) {
300323
clearTimeout(task.timer);
301324
task.reject(new Error("otlp worker pool shutting down"));
@@ -316,6 +339,10 @@ export function getOtlpWorkerPool(
316339
if (!pool) {
317340
const resolvedPath = workerPath ?? path.join(process.cwd(), "build", "otlpTransformWorker.cjs");
318341
pool = new OtlpWorkerPool(size, resolvedPath, pricingModels, meter);
342+
// Drain + terminate workers on shutdown so they aren't force-killed mid-task (which would
343+
// churn respawns). The main thread stays the only DB writer, so inserts are unaffected.
344+
signalsEmitter.on("SIGTERM", () => void pool!.shutdown());
345+
signalsEmitter.on("SIGINT", () => void pool!.shutdown());
319346
}
320347
return pool;
321348
}

apps/webapp/test/otlpWorkerPoolMetrics.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,16 @@ describe("OtlpWorkerPool self-observability", () => {
7272
{ timeout: 5000, interval: 50 }
7373
);
7474
});
75+
76+
it("rejects new work once shutdown has started", async () => {
77+
const pool = new OtlpWorkerPool(2, echoWorker, []);
78+
// A task before shutdown resolves normally.
79+
await expect(pool.runTransform("traces", payload(), config)).resolves.toBeDefined();
80+
81+
await pool.shutdown();
82+
83+
await expect(pool.runTransform("traces", payload(), config)).rejects.toThrow(/shutting down/);
84+
// Shutting down twice is a no-op.
85+
await expect(pool.shutdown()).resolves.toBeUndefined();
86+
});
7587
});

0 commit comments

Comments
 (0)