perf(telemetry): worker_threads decode pool (on by default, adaptive) + streamed ClickHouse insert bodies - #3115
Open
simlarsen wants to merge 2 commits into
Open
perf(telemetry): worker_threads decode pool (on by default, adaptive) + streamed ClickHouse insert bodies#3115simlarsen wants to merge 2 commits into
simlarsen wants to merge 2 commits into
Conversation
… default) Moves gunzip + protobuf decode + toJSON off the ingest worker's event loop into a worker_threads pool, so large telemetry payloads decode in parallel instead of serializing on the single main thread. - New pure decode module (OtelDecode.ts) with an infra-free import closure (protobufjs + node builtins only) shared byte-for-byte by the inline path and the thread entry, so both paths run identical logic. - DecodeThreadPool: FIFO queue, one in-flight request per thread, copy-then-transfer ArrayBuffer handoff (ioredis reply Buffers are slab views), thread-death -> retryable rejection + respawn, rolling circuit breaker with inline fallback while unhealthy, graceful shutdown that never leaves a job's promise pending. - Threads spawn the .ts entry with explicit ts-node/register execArgv - the App runtime is ts-node in both dev and prod, and the explicit registration also lets jest spawn real threads in tests. - Routing in OtelPayloadDecoder: Redis body read stays on the main thread; payloads >= TELEMETRY_DECODE_MIN_PAYLOAD_BYTES (8192) go to the pool when enabled and healthy, everything else decodes inline. - TELEMETRY_DECODE_THREADS defaults to 0 (disabled): default behavior is exactly the historical inline path. - Helm: knob exposed on BOTH deployments (app pods consume the telemetry queue on the chart's default worker.enabled=false topology), with documented values.yaml defaults. 63 tests across 6 suites, including real-thread equivalence over the full product/format/encoding matrix, nonzero-byteOffset slab-view decode, breaker trip AND heal-after-cooldown, terminate-mid-decode respawn, no-hung-promise shutdown with queued work, and routing/config coverage; all verified with --detectOpenHandles. No new compile errors vs the master baseline. Reviewed by an adversarial multi-lens pass (thread lifecycle, decode equivalence, dev/prod/jest runtime integrity, test adequacy); all confirmed findings addressed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L1ioPKzqvWG9umQPpCryE2
Contributor
Author
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
…y default Two additions to the decode-pool branch: Streamed insert bodies (default on, CLICKHOUSE_STREAMED_INSERTS=false to disable): insertJsonRows now hands the ClickHouse client a fresh per-invocation object-mode Readable over the rows array instead of the array itself. The client encodes stream elements through the same encodeJSON it used for arrays — byte-identical wire bytes, proven in tests against the vendored encoder — but incrementally with socket/ gzip backpressure, eliminating the full-body string materialization (~100MB+ transient per 100k-row flush) and the single long stringify burst. Fresh-per-call streams keep the fan-in writer's same-token retries correct. Verified live against a real ClickHouse 26.7. Decode pool enabled by default with adaptive sizing: unset TELEMETRY_DECODE_THREADS now resolves to clamp(cpus - 1, 0, 4) where cpus is the cgroup-aware effective CPU count (v2 cpu.max, v1 cfs_quota_us, fallback os.availableParallelism) via the new Common/Server/Utils/CpuCount.ts. A 1-CPU pod stays at 0 threads (inline decode, no throttling regression); explicit values win, including 0 as a hard kill switch. Helm now renders the env only when explicitly set so charts defer to the adaptive default, and values.schema.json gained the telemetryDecodeThreads keys (fixing a latent additionalProperties rejection for anyone setting the knob). 39 new tests (streamed-insert equivalence/freshness/settings + live integration, CpuCount cgroup parsing, adaptive-default clamp table, routing contract updates); all decode/fan-in/write-path suites green; no new compile errors vs master baseline. Adversarially reviewed (stream-retry-dedup, adaptive sizing, Helm/schema, test contracts): zero confirmed findings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L1ioPKzqvWG9umQPpCryE2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two coupled efficiency changes for the telemetry ingest path:
worker_threadsdecode pool moves the worker's heaviest synchronous CPU — gunzip + protobuf decode +toJSON()— off the single event loop, so one pod uses multiple cores for decode instead of interleaving jobs on one thread. Enabled by default with adaptive sizing: unsetTELEMETRY_DECODE_THREADSresolves toclamp(effectiveCPUs − 1, 0, 4), where effective CPUs honor the pod's cgroup quota (v2cpu.max, v1cfs_quota_us, fallbackos.availableParallelism). A 1-CPU pod stays at 0 threads — inline decode, byte-for-byte the historical path, no throttling regression. Explicit values always win;0is a hard kill switch.CLICKHOUSE_STREAMED_INSERTS=falseto disable):insertJsonRowshands the client a fresh per-invocation object-modeReadableinstead of a rows array, so the client encodes incrementally with socket/gzip backpressure instead of materializing the entire body as one string (~100MB+ transient per 100k-row flush) in one long stringify burst. Wire bytes are byte-identical (sameencodeJSONpath in the client — proven in tests against the vendored encoder). Covers both the worker-direct path and the telemetry-writer tier.Design highlights
OtelDecode.ts) with an infra-free import closure (verified by require-cache dump) shared by the inline path and the thread entry — one implementation, two call sites.DecodeThreadPool.ts): FIFO, one in-flight per thread, copy-then-transfer ArrayBuffer handoff (ioredis reply Buffers are slab views; the copy respectsbyteOffset/length, pinned by a slab-view regression test), thread-death → retryable rejection + respawn, circuit breaker (5 failures/30s → 60s cooldown) degrading to inline decode, shutdown that settles every pending promise..tsentry with explicit ts-node registration — validated in all three runtimes (nodemon dev replica end-to-end, prod image inputs, jest real-thread suites).insertJsonRowsper attempt, so retries never see a consumed stream.telemetryDecodeThreadsexposed on bothappandworkerdeployments (app pods consume the telemetry queue on the defaultworker.enabled=falsetopology); env renders only when explicitly set so charts defer to the adaptive default. Also fixes a latentvalues.schema.jsongap (additionalProperties: falsewould have rejected anyone setting the knob).Safety nets for default-on
Threads spawn lazily on the first ≥8 KiB OTel payload (idle/non-telemetry pods pay nothing); 1-effective-CPU pods resolve to 0 threads; a broken pool trips the breaker and degrades to the pre-pool inline path; all pool rejections are BullMQ-retryable;
TELEMETRY_DECODE_THREADS=0andCLICKHOUSE_STREAMED_INSERTS=falseare no-deploy kill switches. When raising thread counts, raise the pod CPU request/limit to match; each thread is a V8+ts-node isolate — budget RSS accordingly.Testing
102 tests across 9 suites (8 new), real worker threads where it matters,
--detectOpenHandlesclean:byteOffsetslab-view decodeCpuCountcgroup parsing (v2/v1/fallback/garbage/never-throws) and the adaptive clamp table; routing and config contractsNo new compile errors vs the master baseline. Both change sets adversarially reviewed by multi-lens agent passes with per-finding refuters; final review: zero confirmed findings (earlier rounds' findings — Helm app-pod exposure, schema gap, three test gaps — are all fixed in this PR).
🤖 Generated with Claude Code
https://claude.ai/code/session_01L1ioPKzqvWG9umQPpCryE2