Skip to content

Commit a5f9559

Browse files
juan-fernandezBridgeAR
authored andcommitted
fix(test-optimization): bound final flush lifecycle (#9789)
1 parent d43009c commit a5f9559

29 files changed

Lines changed: 1859 additions & 126 deletions

packages/dd-trace/src/agent/info.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ module.exports = {
1717
* Fetches agent information from the /info endpoint
1818
* @param {URL} url - The agent URL
1919
* @param {Function} callback - Callback function with signature (err, agentInfo)
20+
* @param {{ deadline?: number, signal?: AbortSignal }} [options] - Request finalization options
21+
* @param {Function} [makeRequest] - Request implementation
2022
*/
21-
function fetchAgentInfo (url, callback) {
23+
function fetchAgentInfo (url, callback, options = {}, makeRequest = request) {
2224
const urlKey = url.href
2325

2426
if (cachedUrl !== null && cachedUrl !== urlKey) {
@@ -29,10 +31,9 @@ function fetchAgentInfo (url, callback) {
2931
return process.nextTick(callback, null, cachedData)
3032
}
3133

32-
request('', {
33-
path: '/info',
34-
url,
35-
}, (err, res) => {
34+
options.path = '/info'
35+
options.url = url
36+
makeRequest('', options, (err, res) => {
3637
if (err) {
3738
return callback(err)
3839
}

packages/dd-trace/src/ci-visibility/exporters/agent-proxy/index.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const AgentWriter = require('../../../exporters/agent/writer')
44
const AgentlessWriter = require('../agentless/writer')
55
const CoverageWriter = require('../agentless/coverage-writer')
66
const CiVisibilityExporter = require('../ci-visibility-exporter')
7+
const request = require('../request')
78
const { fetchAgentInfo } = require('../../../agent/info')
89
const { DEBUGGER_INPUT_V1 } = require('../../../debugger/constants')
910

@@ -43,9 +44,20 @@ class AgentProxyCiVisibilityExporter extends CiVisibilityExporter {
4344
testOptimization,
4445
} = config
4546

47+
const initializationController = new AbortController()
48+
const initializationOptions = { signal: initializationController.signal }
49+
this._initializationRequest = {
50+
controller: initializationController,
51+
options: initializationOptions,
52+
}
53+
4654
fetchAgentInfo(this._url, (err, agentInfo) => {
55+
this._initializationRequest = undefined
56+
const initializationAborted = initializationController.signal.aborted
57+
const agentInfoError = err || (initializationAborted ? initializationController.signal.reason : undefined)
58+
4759
this._isInitialized = true
48-
let latestEvpProxyVersion = getLatestEvpProxyVersion(err, agentInfo)
60+
let latestEvpProxyVersion = getLatestEvpProxyVersion(agentInfoError, agentInfo)
4961
const isEvpCompatible = latestEvpProxyVersion >= 2
5062
this._isGzipCompatible = latestEvpProxyVersion >= 4
5163

@@ -72,7 +84,7 @@ class AgentProxyCiVisibilityExporter extends CiVisibilityExporter {
7284
// path with evpProxyPrefix and sets X-Datadog-EVP-Subdomain: api (see uploadTestScreenshot).
7385
this._testScreenshotUploadUrl = this._url
7486
if (testOptimization.DD_TEST_FAILED_TEST_REPLAY_ENABLED) {
75-
const canFowardLogs = getCanForwardDebuggerLogs(err, agentInfo)
87+
const canFowardLogs = getCanForwardDebuggerLogs(agentInfoError, agentInfo)
7688
if (canFowardLogs) {
7789
const DynamicInstrumentationLogsWriter = require('../agentless/di-logs-writer')
7890
this._logsWriter = new DynamicInstrumentationLogsWriter({
@@ -89,14 +101,19 @@ class AgentProxyCiVisibilityExporter extends CiVisibilityExporter {
89101
lookup,
90102
protocolVersion,
91103
headers,
104+
isTestOptimization: true,
92105
})
93106
// coverages will never be used, so we discard them
94107
this._coverageBuffer = []
95108
}
96109
this._resolveCanUseCiVisProtocol(isEvpCompatible)
110+
if (initializationAborted) {
111+
this.resetUncodedTraces()
112+
return
113+
}
97114
this.exportUncodedTraces()
98115
this.exportUncodedCoverages()
99-
})
116+
}, initializationOptions, request)
100117
}
101118

102119
setUrl (url, coverageUrl) {

packages/dd-trace/src/ci-visibility/exporters/agentless/coverage-writer.js

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
'use strict'
22
const getConfig = require('../../../config')
3-
const request = require('../../../exporters/common/request')
43
const log = require('../../../log')
54
const { safeJSONStringify } = require('../../../exporters/common/util')
65

7-
const { CoverageCIVisibilityEncoder } = require('../../../encode/coverage-ci-visibility')
8-
const BaseWriter = require('../../../exporters/common/writer')
96
const {
107
incrementCountMetric,
118
distributionMetric,
@@ -15,16 +12,34 @@ const {
1512
TELEMETRY_ENDPOINT_PAYLOAD_REQUESTS_ERRORS,
1613
TELEMETRY_ENDPOINT_PAYLOAD_DROPPED,
1714
} = require('../../../ci-visibility/telemetry')
15+
const { CoverageCIVisibilityEncoder } = require('../../../encode/coverage-ci-visibility')
16+
const BaseWriter = require('../../../exporters/common/writer')
17+
const request = require('../request')
18+
const TestOptimizationRequestTracker = require('./request-tracker')
1819

1920
class Writer extends BaseWriter {
21+
#requestTracker
22+
2023
constructor ({ url, evpProxyPrefix = '' }) {
2124
super(...arguments)
25+
this.#requestTracker = new TestOptimizationRequestTracker(this)
2226
this._url = url
2327
this._encoder = new CoverageCIVisibilityEncoder(this)
2428
this._evpProxyPrefix = evpProxyPrefix
2529
}
2630

27-
_sendPayload (form, _, done) {
31+
/**
32+
* Flushes buffered coverage, waiting for tracked requests during finalization.
33+
*
34+
* @param {(error?: Error) => void} [done]
35+
* @param {{ deadline?: number }} [options]
36+
* @returns {void}
37+
*/
38+
flush (done, options) {
39+
this.#requestTracker.flush(done, options)
40+
}
41+
42+
_sendPayload (form, _, done, flushOptions) {
2843
const options = {
2944
path: '/api/v2/citestcov',
3045
method: 'POST',
@@ -34,6 +49,7 @@ class Writer extends BaseWriter {
3449
},
3550
timeout: 15_000,
3651
url: this._url,
52+
deadline: flushOptions?.deadline,
3753
}
3854

3955
if (this._evpProxyPrefix) {
@@ -50,7 +66,7 @@ class Writer extends BaseWriter {
5066
incrementCountMetric(TELEMETRY_ENDPOINT_PAYLOAD_REQUESTS, { endpoint: 'code_coverage' })
5167
distributionMetric(TELEMETRY_ENDPOINT_PAYLOAD_BYTES, { endpoint: 'code_coverage' }, form.size())
5268

53-
request(form, options, (err, res, statusCode) => {
69+
this.#requestTracker.send(request, form, options, (err, res, statusCode) => {
5470
distributionMetric(
5571
TELEMETRY_ENDPOINT_PAYLOAD_REQUESTS_MS,
5672
{ endpoint: 'code_coverage' },
@@ -66,7 +82,7 @@ class Writer extends BaseWriter {
6682
{ endpoint: 'code_coverage' }
6783
)
6884
log.error('Error sending CI coverage payload', err)
69-
done()
85+
done(err)
7086
return
7187
}
7288
log.debug('Response from the intake:', res)

packages/dd-trace/src/ci-visibility/exporters/agentless/di-logs-writer.js

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,42 @@
11
'use strict'
22
const getConfig = require('../../../config')
3-
const request = require('../../../exporters/common/request')
43
const log = require('../../../log')
54
const { safeJSONStringify } = require('../../../exporters/common/util')
65
const { JSONEncoder } = require('../../encode/json-encoder')
76
const { DEBUGGER_INPUT_V1 } = require('../../../debugger/constants')
8-
97
const BaseWriter = require('../../../exporters/common/writer')
108

9+
const request = require('../request')
10+
const TestOptimizationRequestTracker = require('./request-tracker')
11+
1112
// Writer used by the integration between Dynamic Instrumentation and Test Visibility
1213
// It is used to encode and send logs to both the logs intake directly and the
1314
// `/debugger/v1/input` endpoint in the agent, which is a proxy to the logs intake.
1415
class DynamicInstrumentationLogsWriter extends BaseWriter {
16+
#requestTracker
17+
1518
// TODO: what's a good value for timeout for the logs intake?
1619
constructor ({ url, timeout = 15_000, isAgentProxy = false }) {
1720
super(...arguments)
21+
this.#requestTracker = new TestOptimizationRequestTracker(this)
1822
this._url = url
1923
this._encoder = new JSONEncoder()
2024
this._isAgentProxy = isAgentProxy
2125
this.timeout = timeout
2226
}
2327

24-
_sendPayload (data, _, done) {
28+
/**
29+
* Flushes buffered logs, waiting for tracked requests during finalization.
30+
*
31+
* @param {(error?: Error) => void} [done]
32+
* @param {{ deadline?: number }} [options]
33+
* @returns {void}
34+
*/
35+
flush (done, options) {
36+
this.#requestTracker.flush(done, options)
37+
}
38+
39+
_sendPayload (data, _, done, flushOptions) {
2540
const options = {
2641
path: '/api/v2/logs',
2742
method: 'POST',
@@ -31,6 +46,7 @@ class DynamicInstrumentationLogsWriter extends BaseWriter {
3146
},
3247
timeout: this.timeout,
3348
url: this._url,
49+
deadline: flushOptions?.deadline,
3450
}
3551

3652
if (this._isAgentProxy) {
@@ -41,10 +57,10 @@ class DynamicInstrumentationLogsWriter extends BaseWriter {
4157
// eslint-disable-next-line eslint-rules/eslint-log-printf-style
4258
log.debug(() => `Request to the logs intake: ${safeJSONStringify(options)}`)
4359

44-
request(data, options, (err, res) => {
60+
this.#requestTracker.send(request, data, options, (err, res) => {
4561
if (err) {
4662
log.error('Error sending DI logs payload', err)
47-
done()
63+
done(err)
4864
return
4965
}
5066
log.debug('Response from the logs intake:', res)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
'use strict'
2+
3+
const BaseWriter = require('../../../exporters/common/writer')
4+
5+
const FINAL_FLUSH_TIMEOUT_CODE = 'ERR_DD_TEST_OPTIMIZATION_FLUSH_TIMEOUT'
6+
7+
class TestOptimizationRequestTracker {
8+
#writer
9+
#pendingRequests = new Set()
10+
#finalFlushes = new Set()
11+
#activeFinalFlush
12+
13+
/**
14+
* Creates request tracking for a Test Optimization writer.
15+
*
16+
* @param {BaseWriter} writer
17+
*/
18+
constructor (writer) {
19+
this.#writer = writer
20+
}
21+
22+
/**
23+
* Flushes queued payloads and, for a final flush, waits for requests that were
24+
* already in flight. The absolute deadline prevents Test Optimization from
25+
* keeping the test process alive indefinitely.
26+
*
27+
* @param {(error?: Error) => void} [done]
28+
* @param {{ deadline?: number }} [options]
29+
* @returns {void}
30+
*/
31+
flush (done, options) {
32+
if (options?.deadline === undefined) {
33+
BaseWriter.prototype.flush.call(this.#writer, done, options)
34+
return
35+
}
36+
37+
const finalFlush = {
38+
deadline: options.deadline,
39+
done: done || (() => {}),
40+
error: undefined,
41+
requests: new Set(),
42+
timeoutId: undefined,
43+
writerDone: false,
44+
}
45+
this.#finalFlushes.add(finalFlush)
46+
47+
for (const pendingRequest of this.#pendingRequests) {
48+
this.#attachRequest(finalFlush, pendingRequest)
49+
}
50+
51+
const remaining = Math.max(0, options.deadline - Date.now())
52+
finalFlush.timeoutId = setTimeout(() => {
53+
const error = new Error('Timed out flushing Test Optimization data')
54+
error.code = FINAL_FLUSH_TIMEOUT_CODE
55+
56+
finalFlush.error ||= error
57+
finalFlush.writerDone = true
58+
59+
for (const pendingRequest of finalFlush.requests) {
60+
pendingRequest.finalFlushes.delete(finalFlush)
61+
if (pendingRequest.finalFlushes.size === 0) {
62+
pendingRequest.controller.abort(error)
63+
this.#pendingRequests.delete(pendingRequest)
64+
} else {
65+
this.#updateRequestDeadline(pendingRequest)
66+
}
67+
}
68+
finalFlush.requests.clear()
69+
this.#finishFinalFlush(finalFlush)
70+
}, remaining)
71+
72+
const previousFinalFlush = this.#activeFinalFlush
73+
this.#activeFinalFlush = finalFlush
74+
try {
75+
BaseWriter.prototype.flush.call(this.#writer, (error) => {
76+
finalFlush.error ||= error
77+
finalFlush.writerDone = true
78+
this.#finishFinalFlush(finalFlush)
79+
}, options)
80+
} finally {
81+
this.#activeFinalFlush = previousFinalFlush
82+
}
83+
this.#finishFinalFlush(finalFlush)
84+
}
85+
86+
/**
87+
* Sends and tracks a request so a later final flush can wait for or abort it.
88+
*
89+
* @param {Function} request
90+
* @param {Buffer|string|object} data
91+
* @param {object} options
92+
* @param {(error: Error|null, result?: string|null, statusCode?: number,
93+
* headers?: import('node:http').IncomingHttpHeaders) => void} callback
94+
* @returns {void}
95+
*/
96+
send (request, data, options, callback) {
97+
const controller = new AbortController()
98+
const requestOptions = { ...options, signal: controller.signal }
99+
const pendingRequest = { controller, finalFlushes: new Set(), options: requestOptions }
100+
this.#pendingRequests.add(pendingRequest)
101+
if (this.#activeFinalFlush) this.#attachRequest(this.#activeFinalFlush, pendingRequest)
102+
103+
request(data, requestOptions, (error, result, statusCode, headers) => {
104+
if (error) {
105+
for (const finalFlush of pendingRequest.finalFlushes) finalFlush.error ||= error
106+
}
107+
108+
try {
109+
callback(error, result, statusCode, headers)
110+
} finally {
111+
this.#pendingRequests.delete(pendingRequest)
112+
for (const finalFlush of pendingRequest.finalFlushes) {
113+
finalFlush.requests.delete(pendingRequest)
114+
this.#finishFinalFlush(finalFlush)
115+
}
116+
pendingRequest.finalFlushes.clear()
117+
}
118+
})
119+
}
120+
121+
/**
122+
* Associates a request with the final flush boundary that must wait for it.
123+
*
124+
* @param {object} finalFlush
125+
* @param {object} pendingRequest
126+
* @returns {void}
127+
*/
128+
#attachRequest (finalFlush, pendingRequest) {
129+
finalFlush.requests.add(pendingRequest)
130+
pendingRequest.finalFlushes.add(finalFlush)
131+
this.#updateRequestDeadline(pendingRequest)
132+
}
133+
134+
/**
135+
* Gives a shared request the latest deadline of the flushes waiting for it.
136+
*
137+
* @param {object} pendingRequest
138+
* @returns {void}
139+
*/
140+
#updateRequestDeadline (pendingRequest) {
141+
let deadline = 0
142+
for (const finalFlush of pendingRequest.finalFlushes) {
143+
deadline = Math.max(deadline, finalFlush.deadline)
144+
}
145+
pendingRequest.options.deadline = deadline
146+
}
147+
148+
/**
149+
* Completes a final flush callback once its writer and associated requests
150+
* have settled.
151+
*
152+
* @param {object} finalFlush
153+
* @returns {void}
154+
*/
155+
#finishFinalFlush (finalFlush) {
156+
if (!this.#finalFlushes.has(finalFlush) || !finalFlush.writerDone || finalFlush.requests.size !== 0) return
157+
158+
this.#finalFlushes.delete(finalFlush)
159+
clearTimeout(finalFlush.timeoutId)
160+
finalFlush.done(finalFlush.error)
161+
}
162+
}
163+
164+
module.exports = TestOptimizationRequestTracker

0 commit comments

Comments
 (0)