Skip to content

Commit 16cc34c

Browse files
committed
Bound external healthcheck probe with a timeout
AwsClient.healthcheck HeadBucket probe uses the shared client with requestTimeout: 0, so an unreachable endpoint hangs clientCheck and blocks cloudserver startup. Bound it with an AbortController and a new externalBackendHealthCheckTimeout (5s) so a dead location fails fast. Issue: ARSN-612
1 parent 2502c3e commit 16cc34c

3 files changed

Lines changed: 46 additions & 4 deletions

File tree

lib/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ export const legacyLocations = ['sproxyd', 'legacy'];
119119
// for external backends, don't call unless at least 1 minute
120120
// (60,000 milliseconds) since last call
121121
export const externalBackendHealthCheckInterval = 60000;
122+
// bound each healthcheck probe so an unreachable endpoint can't hang startup
123+
export const externalBackendHealthCheckTimeout = 5000;
122124
// some of the available data backends (if called directly rather
123125
// than through the multiple backend gateway) need a key provided
124126
// as a string as first parameter of the get/delete methods.

lib/storage/data/external/AwsClient.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const getMetaHeaders = require('../../../s3middleware/userMetadata').getMetaHead
2828
const { prepareStream } = require('../../../s3middleware/prepareStream');
2929
const { createLogger, logHelper, removeQuotes, trimXMetaPrefix } = require('./utils');
3030
const jsutil = require('../../../jsutil');
31+
const { externalBackendHealthCheckTimeout } = require('../../../constants');
3132
const { callbackify } = require('util');
3233

3334
const missingVerIdInternalError = errorInstances.InternalError.customizeDescription(
@@ -341,20 +342,28 @@ class AwsClient {
341342

342343
healthcheck(location, callback) {
343344
const awsResp = {};
345+
// bound the probe so an unreachable endpoint can't hang startup
346+
const controller = new AbortController();
347+
const timer = setTimeout(() => controller.abort(), externalBackendHealthCheckTimeout);
348+
const done = (err, resp) => {
349+
clearTimeout(timer);
350+
return callback(err, resp);
351+
};
344352
this._client
345-
.send(new HeadBucketCommand({ Bucket: this._awsBucketName }))
353+
.send(new HeadBucketCommand({ Bucket: this._awsBucketName }), { abortSignal: controller.signal })
346354
.then(() => {
347355
if (!this._supportsVersioning) {
348356
awsResp[location] = {
349357
message: 'Congrats! You own the bucket',
350358
};
351-
return callback(null, awsResp);
359+
return done(null, awsResp);
352360
}
353361
return this._client
354362
.send(
355363
new GetBucketVersioningCommand({
356364
Bucket: this._awsBucketName,
357365
}),
366+
{ abortSignal: controller.signal },
358367
)
359368
.then(data => {
360369
if (!data.Status || data.Status === 'Suspended') {
@@ -369,12 +378,12 @@ class AwsClient {
369378
message: 'Congrats! You own the bucket',
370379
};
371380
}
372-
return callback(null, awsResp);
381+
return done(null, awsResp);
373382
});
374383
})
375384
.catch(err => {
376385
awsResp[location] = { error: err, external: true };
377-
return callback(null, awsResp);
386+
return done(null, awsResp);
378387
});
379388
}
380389
createMPU(

tests/unit/storage/data/external/ExternalClients.spec.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const DummyService = require('../DummyService');
1010
const { DummyRequestLogger } = require('../../../helpers');
1111
const BucketInfo = require('../../../../../lib/models/BucketInfo').default;
1212
const { HeadBucketCommand } = require('@aws-sdk/client-s3');
13+
const { externalBackendHealthCheckTimeout } = require('../../../../../lib/constants');
1314

1415
const backendClients = [
1516
{
@@ -182,6 +183,36 @@ describe('external backend clients', () => {
182183
assert.strictEqual(entry.external, true);
183184
});
184185

186+
itIfAws(`${backend.name} healthcheck should pass an abort signal to bound the probe`, async () => {
187+
const sendStub = sandbox.stub(testClient._client, 'send').resolves({});
188+
await promisify(testClient.healthcheck.bind(testClient))(backend.config.dataStoreName);
189+
190+
const opts = sendStub.firstCall.args[1];
191+
assert.ok(
192+
opts && opts.abortSignal instanceof AbortSignal,
193+
'expected healthcheck to pass an AbortSignal to send',
194+
);
195+
});
196+
197+
itIfAws(`${backend.name} healthcheck should abort a hanging probe and report an error`, async () => {
198+
const clock = sandbox.useFakeTimers();
199+
// unreachable endpoint: settles only when aborted
200+
sandbox.stub(testClient._client, 'send').callsFake(
201+
(_cmd, opts) =>
202+
new Promise((_resolve, reject) => {
203+
opts.abortSignal.addEventListener('abort', () => reject(new Error('aborted')));
204+
}),
205+
);
206+
const healthcheckPromise = promisify(testClient.healthcheck.bind(testClient))(backend.config.dataStoreName);
207+
208+
await clock.tickAsync(externalBackendHealthCheckTimeout);
209+
const resp = await healthcheckPromise;
210+
211+
const entry = resp[backend.config.dataStoreName];
212+
assert.ok(entry.error, 'expected the aborted probe to report an error');
213+
assert.strictEqual(entry.external, true);
214+
});
215+
185216
it(`${backend.name} get() should stream a range of data`, async () => {
186217
const readable = await getAsync(
187218
{

0 commit comments

Comments
 (0)