-
Notifications
You must be signed in to change notification settings - Fork 3.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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>
- Loading branch information
1 parent
fb87950
commit 3a739f3
Showing
24 changed files
with
1,103 additions
and
169 deletions.
There are no files selected for viewing
This file contains 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
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
const SENSITIVE_KEYS = Object.freeze(['x-amz-credential', 'x-amz-signature', 'Signature', 'AWSAccessKeyId']) | ||
const scrubUrl = (url: string, sensitiveKeys: readonly string[]): string => { | ||
const parsedUrl = new URL(url) | ||
|
||
for (const [key, value] of parsedUrl.searchParams) { | ||
if (sensitiveKeys.includes(key)) { | ||
parsedUrl.searchParams.set(key, 'X'.repeat(value.length)) | ||
} | ||
} | ||
|
||
return parsedUrl.href | ||
} | ||
|
||
export class HttpError extends Error { | ||
constructor ( | ||
message: string, | ||
public readonly originalResponse: Response, | ||
) { | ||
super(message) | ||
} | ||
|
||
public static async fromResponse (response: Response): Promise<HttpError> { | ||
const status = response.status | ||
const statusText = await (response.json().catch(() => { | ||
return response.statusText | ||
})) | ||
|
||
return new HttpError( | ||
`${status} ${statusText} (${scrubUrl(response.url, SENSITIVE_KEYS)})`, | ||
response, | ||
) | ||
} | ||
} |
This file contains 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
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import fsAsync from 'fs/promises' | ||
import fs from 'fs' | ||
import Debug from 'debug' | ||
import { uploadStream, geometricRetry } from '../upload/upload_stream' | ||
import { StreamActivityMonitor } from '../upload/stream_activity_monitor' | ||
|
||
const debug = Debug('cypress:server:cloud:api:protocol-artifact') | ||
|
||
// the upload will get canceled if the source stream does not | ||
// begin flowing within 5 seconds, or if the stream pipeline | ||
// stalls (does not push data to the `fetch` sink) for more | ||
// than 5 seconds | ||
const MAX_START_DWELL_TIME = 5000 | ||
const MAX_ACTIVITY_DWELL_TIME = 5000 | ||
|
||
export const putProtocolArtifact = async (artifactPath: string, maxFileSize: number, destinationUrl: string) => { | ||
debug(`Atttempting to upload Test Replay archive from ${artifactPath} to ${destinationUrl})`) | ||
const { size } = await fsAsync.stat(artifactPath) | ||
|
||
if (size > maxFileSize) { | ||
throw new Error(`Spec recording too large: artifact is ${size} bytes, limit is ${maxFileSize} bytes`) | ||
} | ||
|
||
const activityMonitor = new StreamActivityMonitor(MAX_START_DWELL_TIME, MAX_ACTIVITY_DWELL_TIME) | ||
const fileStream = fs.createReadStream(artifactPath) | ||
|
||
await uploadStream( | ||
fileStream, | ||
destinationUrl, | ||
size, { | ||
retryDelay: geometricRetry, | ||
activityMonitor, | ||
}, | ||
) | ||
} |
This file contains 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
103 changes: 103 additions & 0 deletions
103
packages/server/lib/cloud/upload/stream_activity_monitor.ts
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,103 @@ | ||
import Debug from 'debug' | ||
import { Transform, Readable } from 'stream' | ||
|
||
const debug = Debug('cypress:server:cloud:stream-activity-monitor') | ||
const debugVerbose = Debug('cypress-verbose:server:cloud:stream-activity-monitor') | ||
|
||
export class StreamStartTimedOutError extends Error { | ||
constructor (maxStartDwellTime: number) { | ||
super(`Source stream failed to begin sending data after ${maxStartDwellTime}ms`) | ||
} | ||
} | ||
|
||
export class StreamStalledError extends Error { | ||
constructor (maxActivityDwellTime: number) { | ||
super(`Stream stalled: no activity detected in the previous ${maxActivityDwellTime}ms`) | ||
} | ||
} | ||
|
||
/** | ||
* `StreamActivityMonitor` encapsulates state with regard to monitoring a stream | ||
* for flow failure states. Given a maxStartDwellTime and a maxActivityDwellTime, this class | ||
* can `monitor` a Node Readable stream and signal if the sink (e.g., a `fetch`) should be | ||
* aborted via an AbortController that can be retried via `getController`. It does this | ||
* by creating an identity Transform stream and piping the source stream through it. The | ||
* transform stream receives each chunk that the source emits, and orchestrates some timeouts | ||
* to determine if the stream has failed to start, or if the data flow has stalled. | ||
* | ||
* Example usage: | ||
* | ||
* const MAX_START_DWELL_TIME = 5000 | ||
* const MAX_ACTIVITY_DWELL_TIME = 5000 | ||
* const stallDetection = new StreamActivityMonitor(MAX_START_DWELL_TIME, MAX_ACTIVITY_DWELL_TIME) | ||
* try { | ||
* const source = fs.createReadStream('/some/source/file') | ||
* await fetch('/destination/url', { | ||
* method: 'PUT', | ||
* body: stallDetection.monitor(source) | ||
* signal: stallDetection.getController().signal | ||
* }) | ||
* } catch (e) { | ||
* if (stallDetection.getController().signal.reason) { | ||
* // the `fetch` was aborted by the signal that `stallDetection` controlled | ||
* } | ||
* } | ||
* | ||
*/ | ||
export class StreamActivityMonitor { | ||
private streamMonitor: Transform | undefined | ||
private startTimeout: NodeJS.Timeout | undefined | ||
private activityTimeout: NodeJS.Timeout | undefined | ||
private controller: AbortController | ||
|
||
constructor (private maxStartDwellTime: number, private maxActivityDwellTime: number) { | ||
this.controller = new AbortController() | ||
} | ||
|
||
public getController () { | ||
return this.controller | ||
} | ||
|
||
public monitor (stream: Readable): Readable { | ||
debug('monitoring stream') | ||
if (this.streamMonitor || this.startTimeout || this.activityTimeout) { | ||
this.reset() | ||
} | ||
|
||
this.streamMonitor = new Transform({ | ||
transform: (chunk, _, callback) => { | ||
debugVerbose('Received chunk from File ReadableStream; Enqueing to network: ', chunk.length) | ||
|
||
clearTimeout(this.startTimeout) | ||
this.markActivityInterval() | ||
callback(null, chunk) | ||
}, | ||
}) | ||
|
||
this.startTimeout = setTimeout(() => { | ||
this.controller?.abort(new StreamStartTimedOutError(this.maxStartDwellTime)) | ||
}, this.maxStartDwellTime) | ||
|
||
return stream.pipe(this.streamMonitor) | ||
} | ||
|
||
private reset () { | ||
debug('Resetting Stream Activity Monitor') | ||
clearTimeout(this.startTimeout) | ||
clearTimeout(this.activityTimeout) | ||
|
||
this.streamMonitor = undefined | ||
this.startTimeout = undefined | ||
this.activityTimeout = undefined | ||
|
||
this.controller = new AbortController() | ||
} | ||
|
||
private markActivityInterval () { | ||
debug('marking activity interval') | ||
clearTimeout(this.activityTimeout) | ||
this.activityTimeout = setTimeout(() => { | ||
this.controller?.abort(new StreamStalledError(this.maxActivityDwellTime)) | ||
}, this.maxActivityDwellTime) | ||
} | ||
} |
Oops, something went wrong.
3a739f3
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circle has built the
linux x64
version of the Test Runner.Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version
Run this command to install the pre-release locally:
3a739f3
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circle has built the
linux arm64
version of the Test Runner.Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version
Run this command to install the pre-release locally:
3a739f3
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circle has built the
darwin arm64
version of the Test Runner.Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version
Run this command to install the pre-release locally:
3a739f3
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circle has built the
darwin x64
version of the Test Runner.Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version
Run this command to install the pre-release locally:
3a739f3
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circle has built the
win32 x64
version of the Test Runner.Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version
Run this command to install the pre-release locally: