Skip to content

Commit 3a739f3

Browse files
cacieprinsAtofStrykerryanthemanuel
authored
perf: improve replay upload resiliency (#29174)
* tbd * workable templated filestream uploader * clean up async assertions * upload stream uses activity monitor to detect start timeouts and stalls * makes uploadStream retryable * filesize detection, enoent errors, and testable retry delays * extract fs logic to putProtocolArtifact, impl putProtocolArtifact * aggregate errors for retryable upload streams * fixes imports from moving api.ts, uses new upload mechanism in protocol.ts * use spec_helper in StreamActivityMonitor_spec due to global sinon clock changes * fix putProtocolArtifact specs when run as a part of the unit test suite * fix return type of ProtocolManager.uploadCaptureArtifact * convert from whatwg streams back to node streams * extract HttpError * ensure system test snapshots * changelog * more changelog * fix unit tests * fix api ref in integration test * fix refs to api in snapshotting and after-pack * small edits * Update packages/server/lib/cloud/api/HttpError.ts Co-authored-by: Bill Glesias <bglesias@gmail.com> * Update packages/server/lib/cloud/upload/uploadStream.ts Co-authored-by: Bill Glesias <bglesias@gmail.com> * camelcase -> snakeCase filenames * improve docs for StreamActivityMonitor * added documentation to: upload_stream, put_protocol_artifact_spec * move stream activity monitor params to consts - no magic numbers. docs. * Update packages/server/lib/cloud/api/http_error.ts Co-authored-by: Bill Glesias <bglesias@gmail.com> * Update packages/server/test/unit/cloud/api/put_protocol_artifact_spec.ts Co-authored-by: Bill Glesias <bglesias@gmail.com> * fix check-ts * fix imports in put_protocol_artifact_spec * Update packages/server/test/unit/cloud/upload/stream_activity_monitor_spec.ts Co-authored-by: Ryan Manuel <ryanm@cypress.io> * api.ts -> index.ts * fix comment style, remove confusingly inapplicable comment about whatwg streams --------- Co-authored-by: Bill Glesias <bglesias@gmail.com> Co-authored-by: Ryan Manuel <ryanm@cypress.io>
1 parent fb87950 commit 3a739f3

24 files changed

Lines changed: 1103 additions & 169 deletions

cli/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33

44
_Released 4/2/2024 (PENDING)_
55

6+
**Performance:**
7+
8+
- Improvements to Test Replay upload resiliency. Fixes [#28890](https://github.com/cypress-io/cypress/issues/28890). Addressed in [#29174](https://github.com/cypress-io/cypress/pull/29174)
9+
610
**Bugfixes:**
711

812
- Fixed an issue where Cypress was not executing beyond the first spec in `cypress run` for versions of Firefox 124 and up when a custom user agent was provided. Fixes [#29190](https://github.com/cypress-io/cypress/issues/29190).
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
const SENSITIVE_KEYS = Object.freeze(['x-amz-credential', 'x-amz-signature', 'Signature', 'AWSAccessKeyId'])
2+
const scrubUrl = (url: string, sensitiveKeys: readonly string[]): string => {
3+
const parsedUrl = new URL(url)
4+
5+
for (const [key, value] of parsedUrl.searchParams) {
6+
if (sensitiveKeys.includes(key)) {
7+
parsedUrl.searchParams.set(key, 'X'.repeat(value.length))
8+
}
9+
}
10+
11+
return parsedUrl.href
12+
}
13+
14+
export class HttpError extends Error {
15+
constructor (
16+
message: string,
17+
public readonly originalResponse: Response,
18+
) {
19+
super(message)
20+
}
21+
22+
public static async fromResponse (response: Response): Promise<HttpError> {
23+
const status = response.status
24+
const statusText = await (response.json().catch(() => {
25+
return response.statusText
26+
}))
27+
28+
return new HttpError(
29+
`${status} ${statusText} (${scrubUrl(response.url, SENSITIVE_KEYS)})`,
30+
response,
31+
)
32+
}
33+
}
Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,19 @@ const RequestErrors = require('@cypress/request-promise/errors')
99
const { agent } = require('@packages/network')
1010
const pkg = require('@packages/root')
1111

12-
const machineId = require('./machine_id')
13-
const errors = require('../errors')
14-
const { apiUrl, apiRoutes, makeRoutes } = require('./routes')
12+
const machineId = require('../machine_id')
13+
const errors = require('../../errors')
14+
const { apiUrl, apiRoutes, makeRoutes } = require('../routes')
1515

1616
import Bluebird from 'bluebird'
17-
import { getText } from '../util/status_code'
18-
import * as enc from './encryption'
19-
import getEnvInformationForProjectRoot from './environment'
17+
import { getText } from '../../util/status_code'
18+
import * as enc from '../encryption'
19+
import getEnvInformationForProjectRoot from '../environment'
2020

2121
import type { OptionsWithUrl } from 'request-promise'
22-
import { fs } from '../util/fs'
23-
import ProtocolManager from './protocol'
24-
import type { ProjectBase } from '../project-base'
22+
import { fs } from '../../util/fs'
23+
import ProtocolManager from '../protocol'
24+
import type { ProjectBase } from '../../project-base'
2525

2626
const THIRTY_SECONDS = humanInterval('30 seconds')
2727
const SIXTY_SECONDS = humanInterval('60 seconds')
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import fsAsync from 'fs/promises'
2+
import fs from 'fs'
3+
import Debug from 'debug'
4+
import { uploadStream, geometricRetry } from '../upload/upload_stream'
5+
import { StreamActivityMonitor } from '../upload/stream_activity_monitor'
6+
7+
const debug = Debug('cypress:server:cloud:api:protocol-artifact')
8+
9+
// the upload will get canceled if the source stream does not
10+
// begin flowing within 5 seconds, or if the stream pipeline
11+
// stalls (does not push data to the `fetch` sink) for more
12+
// than 5 seconds
13+
const MAX_START_DWELL_TIME = 5000
14+
const MAX_ACTIVITY_DWELL_TIME = 5000
15+
16+
export const putProtocolArtifact = async (artifactPath: string, maxFileSize: number, destinationUrl: string) => {
17+
debug(`Atttempting to upload Test Replay archive from ${artifactPath} to ${destinationUrl})`)
18+
const { size } = await fsAsync.stat(artifactPath)
19+
20+
if (size > maxFileSize) {
21+
throw new Error(`Spec recording too large: artifact is ${size} bytes, limit is ${maxFileSize} bytes`)
22+
}
23+
24+
const activityMonitor = new StreamActivityMonitor(MAX_START_DWELL_TIME, MAX_ACTIVITY_DWELL_TIME)
25+
const fileStream = fs.createReadStream(artifactPath)
26+
27+
await uploadStream(
28+
fileStream,
29+
destinationUrl,
30+
size, {
31+
retryDelay: geometricRetry,
32+
activityMonitor,
33+
},
34+
)
35+
}

packages/server/lib/cloud/protocol.ts

Lines changed: 29 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { agent } from '@packages/network'
1212
import pkg from '@packages/root'
1313

1414
import env from '../util/env'
15+
import { putProtocolArtifact } from './api/put_protocol_artifact'
16+
1517
import type { Readable } from 'stream'
1618
import type { ProtocolManagerShape, AppCaptureProtocolInterface, CDPClient, ProtocolError, CaptureArtifact, ProtocolErrorReport, ProtocolCaptureMethod, ProtocolManagerOptions, ResponseStreamOptions, ResponseEndedWithEmptyBodyOptions, ResponseStreamTimedOutOptions } from '@packages/types'
1719

@@ -23,9 +25,6 @@ const debugVerbose = Debug('cypress-verbose:server:protocol')
2325
const CAPTURE_ERRORS = !process.env.CYPRESS_LOCAL_PROTOCOL_PATH
2426
const DELETE_DB = !process.env.CYPRESS_LOCAL_PROTOCOL_PATH
2527

26-
// Timeout for upload
27-
const TWO_MINUTES = 120000
28-
const RETRY_DELAYS = [500, 1000]
2928
const DB_SIZE_LIMIT = 5000000000
3029

3130
const dbSizeLimit = () => {
@@ -51,13 +50,6 @@ const requireScript = (script: string) => {
5150
return mod.exports
5251
}
5352

54-
class CypressRetryableError extends Error {
55-
constructor (message: string) {
56-
super(message)
57-
this.name = 'CypressRetryableError'
58-
}
59-
}
60-
6153
export class ProtocolManager implements ProtocolManagerShape {
6254
private _runId?: string
6355
private _instanceId?: string
@@ -267,7 +259,7 @@ export class ProtocolManager implements ProtocolManagerShape {
267259
return this._errors.filter((e) => !e.fatal)
268260
}
269261

270-
async getArchiveInfo (): Promise<{ stream: Readable, fileSize: number } | void> {
262+
async getArchiveInfo (): Promise<{ filePath: string, fileSize: number } | void> {
271263
const archivePath = this._archivePath
272264

273265
debug('reading archive from', archivePath)
@@ -276,107 +268,52 @@ export class ProtocolManager implements ProtocolManagerShape {
276268
}
277269

278270
return {
279-
stream: fs.createReadStream(archivePath),
271+
filePath: archivePath,
280272
fileSize: (await fs.stat(archivePath)).size,
281273
}
282274
}
283275

284-
async uploadCaptureArtifact ({ uploadUrl, payload, fileSize }: CaptureArtifact, timeout) {
285-
const archivePath = this._archivePath
276+
async uploadCaptureArtifact ({ uploadUrl, fileSize, filePath }: CaptureArtifact): Promise<{
277+
success: boolean
278+
fileSize: number
279+
specAccess?: ReturnType<AppCaptureProtocolInterface['getDbMetadata']>
280+
} | void> {
281+
if (!this._protocol || !filePath || !this._db) {
282+
debug('not uploading due to one of the following being falsy: %O', {
283+
_protocol: !!this._protocol,
284+
archivePath: !!filePath,
285+
_db: !!this._db,
286+
})
286287

287-
if (!this._protocol || !archivePath || !this._db) {
288288
return
289289
}
290290

291-
debug(`uploading %s to %s with a file size of %s`, archivePath, uploadUrl, fileSize)
292-
293-
const retryRequest = async (retryCount: number, errors: Error[]) => {
294-
try {
295-
if (fileSize > dbSizeLimit()) {
296-
throw new Error(`Spec recording too large: db is ${fileSize} bytes, limit is ${dbSizeLimit()} bytes`)
297-
}
298-
299-
const controller = new AbortController()
300-
301-
setTimeout(() => {
302-
controller.abort()
303-
}, timeout ?? TWO_MINUTES)
304-
305-
const res = await fetch(uploadUrl, {
306-
agent,
307-
method: 'PUT',
308-
// @ts-expect-error - this is supported
309-
body: payload,
310-
headers: {
311-
'Accept': 'application/json',
312-
'Content-Type': 'application/x-tar',
313-
'Content-Length': `${fileSize}`,
314-
},
315-
signal: controller.signal,
316-
})
291+
debug(`uploading %s to %s with a file size of %s`, filePath, uploadUrl, fileSize)
317292

318-
if (res.ok) {
319-
return {
320-
fileSize,
321-
success: true,
322-
specAccess: this._protocol?.getDbMetadata(),
323-
}
324-
}
325-
326-
const errorMessage = await res.json().catch(() => {
327-
const url = new URL(uploadUrl)
328-
329-
for (const [key, value] of url.searchParams) {
330-
if (['x-amz-credential', 'x-amz-signature'].includes(key.toLowerCase())) {
331-
url.searchParams.set(key, 'X'.repeat(value.length))
332-
}
333-
}
334-
335-
return `${res.status} ${res.statusText} (${url.href})`
336-
})
337-
338-
debug(`error response: %O`, errorMessage)
339-
340-
if (res.status >= 500 && res.status < 600) {
341-
throw new CypressRetryableError(errorMessage)
342-
}
343-
344-
throw new Error(errorMessage)
345-
} catch (e) {
346-
// Only retry errors that are network related (e.g. connection reset or timeouts)
347-
if (['FetchError', 'AbortError', 'CypressRetryableError'].includes(e.name)) {
348-
if (retryCount < RETRY_DELAYS.length) {
349-
debug(`retrying upload %o`, { retryCount })
350-
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[retryCount]))
351-
352-
return await retryRequest(retryCount + 1, [...errors, e])
353-
}
354-
}
355-
356-
const totalErrors = [...errors, e]
293+
try {
294+
await putProtocolArtifact(filePath, dbSizeLimit(), uploadUrl)
357295

358-
throw new AggregateError(totalErrors, e.message)
296+
return {
297+
fileSize,
298+
success: true,
299+
specAccess: this._protocol?.getDbMetadata(),
359300
}
360-
}
361-
362-
try {
363-
return await retryRequest(0, [])
364301
} catch (e) {
365302
if (CAPTURE_ERRORS) {
366303
this._errors.push({
367304
error: e,
368305
captureMethod: 'uploadCaptureArtifact',
369306
fatal: true,
370307
})
371-
}
372308

373-
throw e
309+
throw e
310+
}
374311
} finally {
375-
await (
376-
DELETE_DB ? fs.unlink(archivePath).catch((e) => {
377-
debug(`Error unlinking db %o`, e)
378-
}) : Promise.resolve()
379-
)
312+
if (DELETE_DB) {
313+
await fs.unlink(filePath).catch((e) => {
314+
debug('Error unlinking db %o', e)
315+
})
316+
}
380317
}
381318
}
382319

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import Debug from 'debug'
2+
import { Transform, Readable } from 'stream'
3+
4+
const debug = Debug('cypress:server:cloud:stream-activity-monitor')
5+
const debugVerbose = Debug('cypress-verbose:server:cloud:stream-activity-monitor')
6+
7+
export class StreamStartTimedOutError extends Error {
8+
constructor (maxStartDwellTime: number) {
9+
super(`Source stream failed to begin sending data after ${maxStartDwellTime}ms`)
10+
}
11+
}
12+
13+
export class StreamStalledError extends Error {
14+
constructor (maxActivityDwellTime: number) {
15+
super(`Stream stalled: no activity detected in the previous ${maxActivityDwellTime}ms`)
16+
}
17+
}
18+
19+
/**
20+
* `StreamActivityMonitor` encapsulates state with regard to monitoring a stream
21+
* for flow failure states. Given a maxStartDwellTime and a maxActivityDwellTime, this class
22+
* can `monitor` a Node Readable stream and signal if the sink (e.g., a `fetch`) should be
23+
* aborted via an AbortController that can be retried via `getController`. It does this
24+
* by creating an identity Transform stream and piping the source stream through it. The
25+
* transform stream receives each chunk that the source emits, and orchestrates some timeouts
26+
* to determine if the stream has failed to start, or if the data flow has stalled.
27+
*
28+
* Example usage:
29+
*
30+
* const MAX_START_DWELL_TIME = 5000
31+
* const MAX_ACTIVITY_DWELL_TIME = 5000
32+
* const stallDetection = new StreamActivityMonitor(MAX_START_DWELL_TIME, MAX_ACTIVITY_DWELL_TIME)
33+
* try {
34+
* const source = fs.createReadStream('/some/source/file')
35+
* await fetch('/destination/url', {
36+
* method: 'PUT',
37+
* body: stallDetection.monitor(source)
38+
* signal: stallDetection.getController().signal
39+
* })
40+
* } catch (e) {
41+
* if (stallDetection.getController().signal.reason) {
42+
* // the `fetch` was aborted by the signal that `stallDetection` controlled
43+
* }
44+
* }
45+
*
46+
*/
47+
export class StreamActivityMonitor {
48+
private streamMonitor: Transform | undefined
49+
private startTimeout: NodeJS.Timeout | undefined
50+
private activityTimeout: NodeJS.Timeout | undefined
51+
private controller: AbortController
52+
53+
constructor (private maxStartDwellTime: number, private maxActivityDwellTime: number) {
54+
this.controller = new AbortController()
55+
}
56+
57+
public getController () {
58+
return this.controller
59+
}
60+
61+
public monitor (stream: Readable): Readable {
62+
debug('monitoring stream')
63+
if (this.streamMonitor || this.startTimeout || this.activityTimeout) {
64+
this.reset()
65+
}
66+
67+
this.streamMonitor = new Transform({
68+
transform: (chunk, _, callback) => {
69+
debugVerbose('Received chunk from File ReadableStream; Enqueing to network: ', chunk.length)
70+
71+
clearTimeout(this.startTimeout)
72+
this.markActivityInterval()
73+
callback(null, chunk)
74+
},
75+
})
76+
77+
this.startTimeout = setTimeout(() => {
78+
this.controller?.abort(new StreamStartTimedOutError(this.maxStartDwellTime))
79+
}, this.maxStartDwellTime)
80+
81+
return stream.pipe(this.streamMonitor)
82+
}
83+
84+
private reset () {
85+
debug('Resetting Stream Activity Monitor')
86+
clearTimeout(this.startTimeout)
87+
clearTimeout(this.activityTimeout)
88+
89+
this.streamMonitor = undefined
90+
this.startTimeout = undefined
91+
this.activityTimeout = undefined
92+
93+
this.controller = new AbortController()
94+
}
95+
96+
private markActivityInterval () {
97+
debug('marking activity interval')
98+
clearTimeout(this.activityTimeout)
99+
this.activityTimeout = setTimeout(() => {
100+
this.controller?.abort(new StreamStalledError(this.maxActivityDwellTime))
101+
}, this.maxActivityDwellTime)
102+
}
103+
}

0 commit comments

Comments
 (0)