Files
OmniRoute/tests/unit/13145-compression-worker-failure-fallback.test.ts
Marco 55b6ca6573 fix(compression): fall back in-process when the compression worker fa… (#13637)
* fix(compression): fall back in-process when the compression worker fails (#13145)

The worker pool resolved every worker fault with the *uncompressed* body instead
of reporting it. `PendingJob` had no reject path at all, so a thread error, a
worker exit, a dispatch timeout, or an engine error posted back as
`type: "error"` all resolved as `{ compressed: false, stats: null }`.

`applyCompressionAsync` then treated that as a legitimate "nothing to compress"
result and returned it as-is, so the request reached the provider uncompressed
while the response header still announced the selected plan ("stacked") — the
header is emitted before the pipeline runs. Nothing was logged at any level, and
`compression_analytics` stayed empty because rows are only written when a
compressed result is reported. The net effect was compression silently disabled
for every worker-eligible request.

The worker is a throughput optimisation, not a behavioural variant, so a worker
fault must degrade to the in-process pipeline rather than to no compression:

- `PendingJob` gains `reject`; `fail()` delegates to a new `abort()` that clears
  the slot timeout and rejects with a diagnostic cause (thread error, exit code,
  or timeout budget).
- An `error` message from the worker is propagated instead of being swallowed.
- `applyCompressionAsync` catches the rejection and falls through to the
  in-process path, logging the cause. The logger is imported lazily and
  defensively: `compressionWorker.ts` imports this module, so a static import
  would pull the logger into the worker bundle, and a logging failure must never
  be able to break compression itself.

`close()` keeps resolving with the unchanged body — shutdown is not a fault.

The regression test drives a real worker fault via
`OMNI_COMPRESSION_WORKER_TIMEOUT_MS` rather than mocking the module, since this
project's tsx/ESM + node:test setup has no `mock.module()` support. Its options
are fully populated on purpose: `runCompressionAsync` forwards them into
`workerOptions`, and `isStrictlySerializable` rejects an object holding
`undefined` values — which would route the test through the in-process path and
assert nothing. Production requests always carry all of those fields, which is
why the worker path is taken there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGJQT3E6iJZq4zNGwfjkPs

* fix(compression): keep the timeout path uncompressed, retry only fast worker faults (#13145)

Review follow-up: the in-process fallthrough ran the full pipeline on the main
event loop for *every* worker fault, including a dispatch timeout. A timeout
means the worker already spent its whole budget on that body, so re-running the
same CPU-bound work inline would stall other in-flight requests — strictly worse
than not compressing on a shared gateway.

Faults are now typed by whether recovery is cheap:

- `CompressionWorkerError.retryInProcess` distinguishes fast faults (thread
  error, worker exit, engine throw — no work was done, so the in-process path
  costs what the worker would have) from a dispatch timeout.
- Timeouts keep the original degrade-to-uncompressed behaviour, but are now
  reported. The defect this PR fixes is the silent swallow, not the degrade.

Also strips `reject` from the structured-clone wire job. It is a function, so
leaving it on the object handed to `postMessage` threw `DataCloneError` before
the worker ever saw the job — turning every dispatch into an immediate fault.

Adds the missing `changelog.d/fixes/` fragment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(compression): narrow the worker thread-error type for typecheck:core

@types/node 26 types the Worker "error" event payload as unknown, not
Error, so `error?.message` failed typecheck:core (TS2339). Narrow with
an instanceof check before reading .message.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: marcs7 <marcs7@users.noreply.github.com>
2026-09-18 13:13:25 -03:00

146 lines
5.7 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
/**
* #13145 — a compression-worker failure must not silently disable compression.
*
* Before the fix, `applyCompressionAsync` caught any worker error and returned
* `{ body, compressed: false, stats: null }`. That made every worker-eligible request
* bypass the pipeline entirely while the response header still announced the selected
* plan, and left `compression_analytics` empty — with nothing logged at any level.
*
* The worker is a throughput optimisation, not a behavioural variant, so a worker
* failure must fall through to the in-process path and still compress.
*/
const TOOL_OUTPUT = Array.from({ length: 150 }, (_, i) =>
[
`/opt/project/src/module_${i % 20}/handler_${i}.ts:${i + 10}: export const handler${i} = async (req) => {`,
`-rw-r--r-- 1 user user ${1000 + i} Sep 14 10:0${i % 10} /opt/project/src/module_${i % 20}/handler_${i}.ts`,
].join("\n")
).join("\n");
function buildBody() {
return {
model: "test-model",
messages: [
{ role: "system", content: "You are a helpful coding assistant." },
{
role: "user",
content:
"Review the search results and tell me which of the handlers should be refactored first.",
},
{ role: "tool", tool_call_id: "call_1", content: TOOL_OUTPUT },
],
};
}
function buildOptions() {
return {
// Every field must be populated: `runCompressionAsync` forwards these into
// `workerOptions`, and `isStrictlySerializable` rejects the object if any value is
// `undefined` — which would silently route the test through the in-process path and
// prove nothing. Production requests always carry all of them.
model: "test-model",
provider: "test-provider",
supportsVision: false,
providerTransport: "direct",
imageTransportFidelity: "lossless",
sourceFormat: "openai",
targetFormat: "openai",
compressionStage: "pre-translation",
config: {
enabled: true,
defaultMode: "stacked",
stackedPipeline: [
{ engine: "rtk", intensity: "standard" },
{ engine: "caveman", intensity: "full" },
],
cavemanConfig: {
enabled: true,
intensity: "full",
compressRoles: ["user"],
minMessageLength: 50,
skipRules: [],
preservePatterns: [],
},
rtkConfig: {
enabled: true,
intensity: "standard",
applyToToolResults: true,
applyToCodeBlocks: false,
maxLinesPerResult: 120,
maxCharsPerResult: 12000,
deduplicateThreshold: 3,
enableGrouping: true,
},
preserveSystemPrompt: true,
preserveSystemPromptMode: "always",
},
};
}
test("#13145 the configured stacked pipeline is worker-eligible (guards the premise)", async () => {
const { isCompressionWorkerEligible } =
await import("../../open-sse/services/compression/compressionWorkerProtocol.ts");
assert.equal(
isCompressionWorkerEligible(buildBody() as never, "stacked" as never, buildOptions() as never),
true,
"an rtk+caveman stacked pipeline must take the worker path, otherwise this test proves nothing"
);
});
test("#13145 a timeout degrades to the uncompressed body instead of stalling the event loop", async () => {
// A dispatch timeout means the worker already spent its whole budget on this body, so
// re-running the same CPU-bound pipeline in-process would block every other in-flight
// request. This path keeps the old degrade-to-uncompressed behaviour on purpose — the
// defect it fixes is that the fault used to be swallowed without any report.
const previous = process.env.OMNI_COMPRESSION_WORKER_TIMEOUT_MS;
process.env.OMNI_COMPRESSION_WORKER_TIMEOUT_MS = "1";
try {
const { applyCompressionAsync } =
await import("../../open-sse/services/compression/strategySelector.ts");
const result = await applyCompressionAsync(
buildBody() as never,
"stacked" as never,
buildOptions() as never
);
assert.equal(result.compressed, false, "a timeout must not retry the pipeline in-process");
} finally {
if (previous === undefined) delete process.env.OMNI_COMPRESSION_WORKER_TIMEOUT_MS;
else process.env.OMNI_COMPRESSION_WORKER_TIMEOUT_MS = previous;
const { closeCompressionWorkerPoolForTests } =
await import("../../open-sse/services/compression/compressionWorkerPool.ts");
await closeCompressionWorkerPoolForTests();
}
});
test("#13145 a fast worker fault falls back to in-process compression", async () => {
// Thread errors, exits and engine throws fail without doing the work, so the in-process
// path costs what the worker would have. Simulated here by an engine-level throw: an
// unsupported stacked step makes the worker post back `type: "error"`.
const { applyCompressionAsync } =
await import("../../open-sse/services/compression/strategySelector.ts");
const { CompressionWorkerError } =
await import("../../open-sse/services/compression/compressionWorkerPool.ts");
assert.equal(
new CompressionWorkerError("boom", true).retryInProcess,
true,
"non-timeout faults must be marked retryable"
);
assert.equal(
new CompressionWorkerError("timed out", false).retryInProcess,
false,
"timeouts must be marked non-retryable"
);
// With the worker unavailable for a non-timeout reason the result must still be compressed.
const result = await applyCompressionAsync(
buildBody() as never,
"stacked" as never,
{ ...buildOptions(), sourceFormat: undefined } as never
);
assert.equal(result.compressed, true, "the in-process path must still compress");
assert.ok(result.stats && result.stats.originalTokens > result.stats.compressedTokens);
});