@fastify/compress 9.1.1 → 9.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.c8rc +1 -1
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lock-threads.yml +1 -1
- package/.github/workflows/release.yml +24 -0
- package/README.md +85 -1
- package/benchmark/sync-compression.mjs +282 -0
- package/index.js +135 -21
- package/lib/utils.js +42 -2
- package/package.json +3 -4
- package/test/global-compress.test.js +161 -2
- package/test/regression/issue-350.test.js +3 -1
- package/test/routes-compress.test.js +26 -0
- package/test/sync-compress.test.js +741 -0
- package/test/utils.test.js +35 -1
- package/types/index.d.ts +2 -0
- package/types/index.tst.ts +3 -1
package/.c8rc
CHANGED
package/.github/workflows/ci.yml
CHANGED
|
@@ -27,7 +27,7 @@ jobs:
|
|
|
27
27
|
permissions:
|
|
28
28
|
contents: write
|
|
29
29
|
pull-requests: write
|
|
30
|
-
uses: fastify/workflows/.github/workflows/plugins-ci.yml@
|
|
30
|
+
uses: fastify/workflows/.github/workflows/plugins-ci.yml@ef591e2186785d5ab36b9fe6a79c7ce2f1d94e57 # v7.0.0
|
|
31
31
|
with:
|
|
32
32
|
license-check: true
|
|
33
33
|
lint: true
|
|
@@ -16,4 +16,4 @@ jobs:
|
|
|
16
16
|
permissions:
|
|
17
17
|
issues: write
|
|
18
18
|
pull-requests: write
|
|
19
|
-
uses: fastify/workflows/.github/workflows/lock-threads.yml@
|
|
19
|
+
uses: fastify/workflows/.github/workflows/lock-threads.yml@ef591e2186785d5ab36b9fe6a79c7ce2f1d94e57 # v7.0.0
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: Release the package
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
inputs:
|
|
6
|
+
semver:
|
|
7
|
+
description: 'Release bump type'
|
|
8
|
+
required: true
|
|
9
|
+
type: choice
|
|
10
|
+
options:
|
|
11
|
+
- patch
|
|
12
|
+
- minor
|
|
13
|
+
- major
|
|
14
|
+
|
|
15
|
+
permissions: {}
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
release:
|
|
19
|
+
permissions:
|
|
20
|
+
id-token: write # required for npm provenance via OIDC
|
|
21
|
+
contents: write # required to push the release commit and the tag
|
|
22
|
+
uses: fastify/workflows/.github/workflows/reusable-release.yml@b1a11bce713bc857e52b2231fec3c89071ad3135
|
|
23
|
+
with:
|
|
24
|
+
semver: ${{ inputs.semver }}
|
package/README.md
CHANGED
|
@@ -167,6 +167,84 @@ await fastify.register(
|
|
|
167
167
|
{ threshold: 2048 }
|
|
168
168
|
)
|
|
169
169
|
```
|
|
170
|
+
### syncThreshold
|
|
171
|
+
The maximum byte size for compressing a response synchronously. Defaults to a value derived
|
|
172
|
+
from `os.availableParallelism()`, between `4096` and `65536`.
|
|
173
|
+
|
|
174
|
+
Payloads that are already fully in memory (a `string` or a `Buffer`) and are not larger than
|
|
175
|
+
`syncThreshold` are compressed in one call instead of being pushed through a compression
|
|
176
|
+
stream. This is faster for small responses — a per-response zlib stream costs more in setup
|
|
177
|
+
and scheduling than the compression itself at these sizes — and it uses much less memory
|
|
178
|
+
while the response is in flight, since no zlib context is allocated and the source payload
|
|
179
|
+
is released immediately.
|
|
180
|
+
|
|
181
|
+
Larger payloads, streams, [web `ReadableStream`s](#supported-payload-types) and fetch
|
|
182
|
+
`Response`s always use the streaming path, so the event loop is never held for long. Set
|
|
183
|
+
`syncThreshold` to `0` to compress every response through the streaming path.
|
|
184
|
+
|
|
185
|
+
```js
|
|
186
|
+
await fastify.register(
|
|
187
|
+
import('@fastify/compress'),
|
|
188
|
+
{ syncThreshold: 65536 }
|
|
189
|
+
)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
#### How the default is chosen
|
|
193
|
+
|
|
194
|
+
The streaming path hands compression to libuv's threadpool, so it only pays off when there
|
|
195
|
+
are spare cores to overlap that work with the event loop. The more cores are available for
|
|
196
|
+
that overlap, the smaller the payload at which streaming starts to win, so the default
|
|
197
|
+
shrinks as the host gets wider:
|
|
198
|
+
|
|
199
|
+
| cores reported | default |
|
|
200
|
+
| --- | --- |
|
|
201
|
+
| 1 | `65536` |
|
|
202
|
+
| 2 | `8192` |
|
|
203
|
+
| 3 or more | `4096` |
|
|
204
|
+
|
|
205
|
+
The scale stops at four because libuv's threadpool defaults to four threads: beyond that,
|
|
206
|
+
extra cores cannot compress any more responses in parallel. The value is computed once at
|
|
207
|
+
startup and is always overridden by an explicit `syncThreshold`.
|
|
208
|
+
|
|
209
|
+
The memory saving does not depend on payload size — it comes from not allocating a zlib
|
|
210
|
+
context per in-flight response — so it is retained in full even at the lowest default.
|
|
211
|
+
|
|
212
|
+
#### Raising it on CPU-constrained deployments
|
|
213
|
+
|
|
214
|
+
Since libuv 1.49 (Node.js 22.12), `os.availableParallelism()` accounts for a cgroup CPU
|
|
215
|
+
quota, so a container limited to whole cores reports those rather than the host's and gets
|
|
216
|
+
an appropriate default on its own. Two cases still read as a wide host and so get the
|
|
217
|
+
smallest default:
|
|
218
|
+
|
|
219
|
+
| deployment | reported | why |
|
|
220
|
+
| --- | --- | --- |
|
|
221
|
+
| Node.js before 22.12 | host cores | the quota is not consulted at all |
|
|
222
|
+
| a quota below one core, e.g. Kubernetes `cpu: 500m` | host cores | the quota floors to zero and falls back to the host count |
|
|
223
|
+
|
|
224
|
+
Both are precisely the deployments that benefit most from compressing synchronously: with
|
|
225
|
+
little or no spare CPU, there is nothing for the streaming path to overlap with. Quotas are
|
|
226
|
+
also floored rather than rounded, so `cpu: 2500m` reports two cores.
|
|
227
|
+
|
|
228
|
+
If either applies to you, set `syncThreshold` explicitly:
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
await fastify.register(
|
|
232
|
+
import('@fastify/compress'),
|
|
233
|
+
// a container pinned to ~1 CPU behaves like a single core host
|
|
234
|
+
{ syncThreshold: 65536 }
|
|
235
|
+
)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The default is biased towards the low end on purpose: choosing too low a value only forgoes
|
|
239
|
+
part of the possible throughput gain, while choosing too high a value costs considerably
|
|
240
|
+
more than it can ever return.
|
|
241
|
+
|
|
242
|
+
Because the compressed size is known upfront, responses compressed synchronously carry an
|
|
243
|
+
accurate `Content-Length` instead of being sent with chunked transfer encoding. Replies that
|
|
244
|
+
already carry a `Content-Length` the plugin has been asked to keep
|
|
245
|
+
(see [removeContentLengthHeader](#manage-content-length-header-removal-with-removecontentlengthheader))
|
|
246
|
+
use the streaming path so the provided value is preserved.
|
|
247
|
+
|
|
170
248
|
### customTypes
|
|
171
249
|
[mime-db](https://github.com/jshttp/mime-db) determines if a `content-type` should be compressed. Additional content types can be compressed via regex or a function.
|
|
172
250
|
|
|
@@ -268,6 +346,12 @@ By default, `@fastify/compress` removes the reply `Content-Length` header. Chang
|
|
|
268
346
|
)
|
|
269
347
|
```
|
|
270
348
|
|
|
349
|
+
This option exists because the streaming path cannot know the compressed size upfront, so
|
|
350
|
+
the reply `Content-Length` would otherwise describe the uncompressed payload. Responses
|
|
351
|
+
taking the [synchronous path](#syncthreshold) do know the compressed size and always send an
|
|
352
|
+
accurate `Content-Length`; a reply that already carries a `Content-Length` and sets
|
|
353
|
+
`removeContentLengthHeader` to `false` is streamed instead, so the provided value is kept.
|
|
354
|
+
|
|
271
355
|
## Usage - Decompress request payloads
|
|
272
356
|
This plugin adds a `preParsing` hook to decompress the request payload based on the `content-encoding` request header.
|
|
273
357
|
|
|
@@ -396,7 +480,7 @@ await fastify.register(
|
|
|
396
480
|
|
|
397
481
|
## Acknowledgments
|
|
398
482
|
|
|
399
|
-
Past
|
|
483
|
+
Past sponsors:
|
|
400
484
|
- LetzDoIt
|
|
401
485
|
|
|
402
486
|
## License
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Measures the synchronous compression path (`syncThreshold`) against the stream
|
|
2
|
+
// pipeline it replaces for small buffered payloads.
|
|
3
|
+
//
|
|
4
|
+
// node benchmark/sync-compression.mjs
|
|
5
|
+
// node benchmark/sync-compression.mjs --conns 100 --reps 5 --duration 8000
|
|
6
|
+
//
|
|
7
|
+
// Both configurations run against the same build: the baseline registers the plugin
|
|
8
|
+
// with `syncThreshold: 0`, which always streams, and the candidate uses the default.
|
|
9
|
+
//
|
|
10
|
+
// A few things this harness deliberately does, because getting any of them wrong
|
|
11
|
+
// produces numbers that look meaningful but are not:
|
|
12
|
+
//
|
|
13
|
+
// * The server runs in a forked process. Compressing synchronously occupies the
|
|
14
|
+
// event loop, so a client sharing that loop would measure itself as much as
|
|
15
|
+
// the server.
|
|
16
|
+
// * Requests go over real sockets with real concurrency. `inject()` has neither,
|
|
17
|
+
// and cannot observe that the stream path hands work to the libuv threadpool
|
|
18
|
+
// and so overlaps with other cores.
|
|
19
|
+
// * Every size is measured several times, alternating between the two
|
|
20
|
+
// configurations, and reported as a median with its spread.
|
|
21
|
+
// * A control payload larger than the threshold is always included. Both
|
|
22
|
+
// configurations stream it, so its result must come out near zero. When it
|
|
23
|
+
// does not, the machine was too noisy and the whole run is rejected.
|
|
24
|
+
|
|
25
|
+
import { fork } from 'node:child_process'
|
|
26
|
+
import { createRequire } from 'node:module'
|
|
27
|
+
import { availableParallelism, loadavg } from 'node:os'
|
|
28
|
+
import { gzipSync } from 'node:zlib'
|
|
29
|
+
import http from 'node:http'
|
|
30
|
+
import { performance } from 'node:perf_hooks'
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url)
|
|
33
|
+
|
|
34
|
+
// Payload sizes must straddle the threshold: the last one is the control.
|
|
35
|
+
const CONTROL_SIZE = 131072
|
|
36
|
+
const DEFAULT_SIZES = [1024, 2048, 4096, 8192, CONTROL_SIZE]
|
|
37
|
+
// Above this, the control row is too far off for the run to mean anything.
|
|
38
|
+
const CONTROL_TOLERANCE = 0.1
|
|
39
|
+
const TARGET_RATIO = 8
|
|
40
|
+
|
|
41
|
+
function parseArgs (argv) {
|
|
42
|
+
const args = {
|
|
43
|
+
conns: 50,
|
|
44
|
+
duration: 5000,
|
|
45
|
+
reps: 3,
|
|
46
|
+
warmup: 1000,
|
|
47
|
+
sizes: DEFAULT_SIZES
|
|
48
|
+
}
|
|
49
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
50
|
+
const key = argv[i].replace(/^--/, '')
|
|
51
|
+
const value = argv[i + 1]
|
|
52
|
+
if (!(key in args)) throw new Error(`Unknown option: ${argv[i]}`)
|
|
53
|
+
args[key] = key === 'sizes'
|
|
54
|
+
? value.split(',').map(Number)
|
|
55
|
+
: Number(value)
|
|
56
|
+
}
|
|
57
|
+
return args
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Deterministic payload with a realistic compression ratio. Content structure drives
|
|
61
|
+
// compression cost far more than length does, so a fixed ratio keeps sizes comparable.
|
|
62
|
+
function makePayload (byteCount, randomFraction) {
|
|
63
|
+
const data = Buffer.alloc(byteCount)
|
|
64
|
+
let state = 0x12345678
|
|
65
|
+
for (let i = 0; i < byteCount; i++) {
|
|
66
|
+
state = (Math.imul(state, 1664525) + 1013904223) >>> 0
|
|
67
|
+
if (state / 0x100000000 < randomFraction) data[i] = state >>> 24
|
|
68
|
+
}
|
|
69
|
+
return data
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function makePayloadAtRatio (byteCount, ratio) {
|
|
73
|
+
let low = 0
|
|
74
|
+
let high = 1
|
|
75
|
+
let best
|
|
76
|
+
for (let i = 0; i < 20; i++) {
|
|
77
|
+
const fraction = (low + high) / 2
|
|
78
|
+
const payload = makePayload(byteCount, fraction)
|
|
79
|
+
const actual = byteCount / gzipSync(payload).byteLength
|
|
80
|
+
if (!best || Math.abs(actual - ratio) < Math.abs(best.ratio - ratio)) {
|
|
81
|
+
best = { payload, ratio: actual }
|
|
82
|
+
}
|
|
83
|
+
if (actual > ratio) low = fraction
|
|
84
|
+
else high = fraction
|
|
85
|
+
}
|
|
86
|
+
return best
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ------------------------------------------------------------------ server role */
|
|
90
|
+
|
|
91
|
+
if (process.env.BENCHMARK_ROLE === 'server') {
|
|
92
|
+
const Fastify = require('fastify')
|
|
93
|
+
const compress = require('../index.js')
|
|
94
|
+
|
|
95
|
+
const size = Number(process.env.BENCHMARK_SIZE)
|
|
96
|
+
const syncThreshold = process.env.BENCHMARK_SYNC_THRESHOLD === 'default'
|
|
97
|
+
? undefined
|
|
98
|
+
: Number(process.env.BENCHMARK_SYNC_THRESHOLD)
|
|
99
|
+
|
|
100
|
+
const { payload } = makePayloadAtRatio(size, TARGET_RATIO)
|
|
101
|
+
const body = payload.toString('latin1')
|
|
102
|
+
|
|
103
|
+
const app = Fastify()
|
|
104
|
+
const options = { global: true }
|
|
105
|
+
if (syncThreshold !== undefined) options.syncThreshold = syncThreshold
|
|
106
|
+
await app.register(compress, options)
|
|
107
|
+
|
|
108
|
+
app.get('/payload', async (_request, reply) => {
|
|
109
|
+
reply.type('text/plain')
|
|
110
|
+
return body
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const address = await app.listen({ port: 0, host: '127.0.0.1' })
|
|
114
|
+
const port = Number(new URL(address).port)
|
|
115
|
+
|
|
116
|
+
process.on('message', (message) => {
|
|
117
|
+
if (message === 'rss') process.send({ rssMiB: process.memoryUsage().rss / 1048576 })
|
|
118
|
+
})
|
|
119
|
+
process.send({ ready: true, port })
|
|
120
|
+
} else {
|
|
121
|
+
await main()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ------------------------------------------------------------------ client role */
|
|
125
|
+
|
|
126
|
+
function startServer (size, syncThreshold) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const child = fork(import.meta.filename, [], {
|
|
129
|
+
env: {
|
|
130
|
+
...process.env,
|
|
131
|
+
BENCHMARK_ROLE: 'server',
|
|
132
|
+
BENCHMARK_SIZE: String(size),
|
|
133
|
+
BENCHMARK_SYNC_THRESHOLD: String(syncThreshold)
|
|
134
|
+
},
|
|
135
|
+
stdio: ['ignore', 'ignore', 'inherit', 'ipc']
|
|
136
|
+
})
|
|
137
|
+
const onMessage = (message) => {
|
|
138
|
+
if (message.ready) {
|
|
139
|
+
child.off('message', onMessage)
|
|
140
|
+
resolve({ child, port: message.port })
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
child.on('message', onMessage)
|
|
144
|
+
child.once('error', reject)
|
|
145
|
+
child.once('exit', (code) => reject(new Error(`benchmark server exited early (${code})`)))
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function rssOf (child) {
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
const onMessage = (message) => {
|
|
152
|
+
if (message.rssMiB !== undefined) {
|
|
153
|
+
child.off('message', onMessage)
|
|
154
|
+
resolve(message.rssMiB)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
child.on('message', onMessage)
|
|
158
|
+
child.send('rss')
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function get (port, agent) {
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const request = http.get(
|
|
165
|
+
{ host: '127.0.0.1', port, path: '/payload', agent, headers: { 'accept-encoding': 'gzip' } },
|
|
166
|
+
(response) => {
|
|
167
|
+
response.resume()
|
|
168
|
+
response.once('end', () => resolve(response.headers['content-encoding']))
|
|
169
|
+
response.once('error', reject)
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
request.once('error', reject)
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function measure (port, { conns, duration, warmup }) {
|
|
177
|
+
const agent = new http.Agent({ keepAlive: true, maxSockets: conns })
|
|
178
|
+
const countFrom = performance.now() + warmup
|
|
179
|
+
const stopAt = countFrom + duration
|
|
180
|
+
let completed = 0
|
|
181
|
+
let encoding
|
|
182
|
+
|
|
183
|
+
// Requests keep flowing across the warmup boundary, so the measured window sees a
|
|
184
|
+
// server already at steady state rather than one still filling its connection pool.
|
|
185
|
+
const workers = Array.from({ length: conns }, async () => {
|
|
186
|
+
while (performance.now() < stopAt) {
|
|
187
|
+
encoding = await get(port, agent)
|
|
188
|
+
if (performance.now() >= countFrom) completed++
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
await Promise.all(workers)
|
|
193
|
+
agent.destroy()
|
|
194
|
+
|
|
195
|
+
return { reqPerSec: completed / (duration / 1000), encoding }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function runCase (size, syncThreshold, args) {
|
|
199
|
+
const { child, port } = await startServer(size, syncThreshold)
|
|
200
|
+
try {
|
|
201
|
+
const result = await measure(port, args)
|
|
202
|
+
return { ...result, rssMiB: await rssOf(child) }
|
|
203
|
+
} finally {
|
|
204
|
+
child.kill('SIGKILL')
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Declared as a function so it is hoisted above the top-level `main()` call.
|
|
209
|
+
function median (values) {
|
|
210
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
211
|
+
return sorted[Math.floor(sorted.length / 2)]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatSize (bytes) {
|
|
215
|
+
return bytes >= 1024 ? `${bytes / 1024} KiB` : `${bytes} B`
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function main () {
|
|
219
|
+
const args = parseArgs(process.argv.slice(2))
|
|
220
|
+
const { computeSyncThreshold } = require('../lib/utils.js')
|
|
221
|
+
const cores = availableParallelism()
|
|
222
|
+
const computed = computeSyncThreshold()
|
|
223
|
+
|
|
224
|
+
console.log('@fastify/compress — synchronous compression path')
|
|
225
|
+
console.log(`node ${process.version} ${process.platform}/${process.arch} ${cores} cores`)
|
|
226
|
+
console.log(`computed default syncThreshold: ${computed} bytes`)
|
|
227
|
+
console.log(`${args.conns} connections, ${args.duration} ms x ${args.reps} reps, target ratio ${TARGET_RATIO}:1\n`)
|
|
228
|
+
|
|
229
|
+
const load = loadavg()[0]
|
|
230
|
+
if (load > cores / 4) {
|
|
231
|
+
console.log(`WARNING: 1-minute load average is ${load.toFixed(2)} on ${cores} cores.`)
|
|
232
|
+
console.log('Results from a busy machine are not meaningful. Wait for it to settle.\n')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const results = new Map(args.sizes.map((size) => [size, { baseline: [], candidate: [] }]))
|
|
236
|
+
|
|
237
|
+
for (let rep = 1; rep <= args.reps; rep++) {
|
|
238
|
+
for (const size of args.sizes) {
|
|
239
|
+
// Alternate within a rep so slow drift hits both configurations equally.
|
|
240
|
+
const baseline = await runCase(size, 0, args)
|
|
241
|
+
const candidate = await runCase(size, 'default', args)
|
|
242
|
+
if (baseline.encoding !== 'gzip' || candidate.encoding !== 'gzip') {
|
|
243
|
+
throw new Error(`payload of ${size} B was not compressed — check the plugin threshold`)
|
|
244
|
+
}
|
|
245
|
+
results.get(size).baseline.push(baseline)
|
|
246
|
+
results.get(size).candidate.push(candidate)
|
|
247
|
+
}
|
|
248
|
+
console.log(`rep ${rep}/${args.reps} done`)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
console.log('\npayload streamed r/s default r/s change streamed RSS default RSS')
|
|
252
|
+
let controlChange = 0
|
|
253
|
+
for (const size of args.sizes) {
|
|
254
|
+
const { baseline, candidate } = results.get(size)
|
|
255
|
+
const base = median(baseline.map((r) => r.reqPerSec))
|
|
256
|
+
const cand = median(candidate.map((r) => r.reqPerSec))
|
|
257
|
+
const change = cand / base - 1
|
|
258
|
+
const isControl = size > Math.max(computed, ...args.sizes.filter((s) => s <= computed))
|
|
259
|
+
if (isControl) controlChange = Math.max(controlChange, Math.abs(change))
|
|
260
|
+
console.log(
|
|
261
|
+
formatSize(size).padEnd(12) +
|
|
262
|
+
base.toFixed(0).padStart(12) +
|
|
263
|
+
cand.toFixed(0).padStart(14) +
|
|
264
|
+
`${(change * 100).toFixed(0)}%`.padStart(11) +
|
|
265
|
+
`${median(baseline.map((r) => r.rssMiB)).toFixed(0)} MiB`.padStart(15) +
|
|
266
|
+
`${median(candidate.map((r) => r.rssMiB)).toFixed(0)} MiB`.padStart(14) +
|
|
267
|
+
(isControl ? ' <- control, expect ~0%' : '')
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// The control streams in both configurations, so anything other than a near-zero
|
|
272
|
+
// result means the measurement environment, not the plugin, moved the numbers.
|
|
273
|
+
console.log('')
|
|
274
|
+
if (controlChange > CONTROL_TOLERANCE) {
|
|
275
|
+
console.log(`REJECTED: control moved by ${(controlChange * 100).toFixed(0)}%, above the ${CONTROL_TOLERANCE * 100}% tolerance.`)
|
|
276
|
+
console.log('Both configurations stream that payload, so the difference is machine noise.')
|
|
277
|
+
console.log('Re-run on an idle machine, or raise --reps, before drawing conclusions.')
|
|
278
|
+
process.exitCode = 1
|
|
279
|
+
} else {
|
|
280
|
+
console.log(`Control within ${(CONTROL_TOLERANCE * 100).toFixed(0)}% — run looks valid.`)
|
|
281
|
+
}
|
|
282
|
+
}
|