mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 14:22:14 +03:00
* 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>
146 lines
5.7 KiB
TypeScript
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);
|
|
});
|