Skip to content

Commit 3d6eb2d

Browse files
authored
feat(util-waiter): add createWaiter() (#1759)
* feat(util-waiter): add create waiter Refactor the original waiter util: 1. expose a createWaiter() only to clients in order to reduce the duplicated code-gen 2. fix infinite loop in job poller 3. fix potential number overflow * fix: address feedbacks * feat(util-waiter): merge waiter options with client * fix(util-waiter): use timestamp to determine maxWaittime rather than delay sum * fix(util-waiter): remove maxWaitTime promise The racing maxWaitTime will set a timeout that would eventually hang the user's process. So remove it. Instead, we break the poller promise if the totol wait time get close to the maxWaitTime config.
1 parent 6f73e9b commit 3d6eb2d

File tree

13 files changed

+253
-111
lines changed

13 files changed

+253
-111
lines changed

packages/util-waiter/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"description": "Shared utilities for client waiters for the AWS SDK",
55
"dependencies": {
66
"@aws-sdk/abort-controller": "1.0.0-rc.8",
7+
"@aws-sdk/types": "1.0.0-rc.8",
78
"tslib": "^1.8.0"
89
},
910
"devDependencies": {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { AbortController } from "@aws-sdk/abort-controller";
2+
3+
import { ResolvedWaiterOptions, WaiterState } from "./waiter";
4+
5+
const mockValidate = jest.fn();
6+
jest.mock("./utils/validate", () => ({
7+
validateWaiterOptions: mockValidate,
8+
}));
9+
10+
jest.useFakeTimers();
11+
12+
import { createWaiter } from "./createWaiter";
13+
14+
describe("createWaiter", () => {
15+
beforeEach(() => {
16+
jest.clearAllTimers();
17+
jest.clearAllMocks();
18+
});
19+
20+
const minimalWaiterConfig = {
21+
minDelay: 2,
22+
maxDelay: 120,
23+
maxWaitTime: 9999,
24+
client: "client",
25+
} as ResolvedWaiterOptions<any>;
26+
const input = "input";
27+
28+
const abortedState = {
29+
state: WaiterState.ABORTED,
30+
};
31+
const failureState = {
32+
state: WaiterState.FAILURE,
33+
};
34+
const retryState = {
35+
state: WaiterState.RETRY,
36+
};
37+
const successState = {
38+
state: WaiterState.SUCCESS,
39+
};
40+
41+
it("should abort when abortController is signalled", async () => {
42+
const abortController = new AbortController();
43+
const mockAcceptorChecks = jest.fn().mockResolvedValue(retryState);
44+
const statusPromise = createWaiter(
45+
{
46+
...minimalWaiterConfig,
47+
maxWaitTime: 20,
48+
abortController,
49+
},
50+
input,
51+
mockAcceptorChecks
52+
);
53+
jest.advanceTimersByTime(10 * 1000);
54+
abortController.abort(); // Abort before maxWaitTime(20s);
55+
expect(await statusPromise).toEqual(abortedState);
56+
});
57+
58+
it("should success when acceptor checker returns seccess", async () => {
59+
const mockAcceptorChecks = jest.fn().mockResolvedValue(successState);
60+
const statusPromise = createWaiter(
61+
{
62+
...minimalWaiterConfig,
63+
maxWaitTime: 20,
64+
},
65+
input,
66+
mockAcceptorChecks
67+
);
68+
jest.advanceTimersByTime(minimalWaiterConfig.minDelay * 1000);
69+
expect(await statusPromise).toEqual(successState);
70+
});
71+
72+
it("should fail when acceptor checker returns failure", async () => {
73+
const mockAcceptorChecks = jest.fn().mockResolvedValue(failureState);
74+
const statusPromise = createWaiter(
75+
{
76+
...minimalWaiterConfig,
77+
maxWaitTime: 20,
78+
},
79+
input,
80+
mockAcceptorChecks
81+
);
82+
jest.advanceTimersByTime(minimalWaiterConfig.minDelay * 1000);
83+
expect(await statusPromise).toEqual(failureState);
84+
});
85+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { AbortSignal } from "@aws-sdk/types";
2+
3+
import { runPolling } from "./poller";
4+
import { sleep, validateWaiterOptions } from "./utils";
5+
import { SmithyClient, WaiterOptions, WaiterResult, waiterServiceDefaults, WaiterState } from "./waiter";
6+
7+
const waiterTimeout = async (seconds: number): Promise<WaiterResult> => {
8+
await sleep(seconds);
9+
return { state: WaiterState.TIMEOUT };
10+
};
11+
12+
const abortTimeout = async (abortSignal: AbortSignal): Promise<WaiterResult> => {
13+
return new Promise((resolve) => {
14+
abortSignal.onabort = () => resolve({ state: WaiterState.ABORTED });
15+
});
16+
};
17+
18+
/**
19+
* Create a waiter promise that only resolves when:
20+
* 1. Abort controller is signaled
21+
* 2. Max wait time is reached
22+
* 3. `acceptorChecks` succeeds, or fails
23+
* Otherwise, it invokes `acceptorChecks` with exponential-backoff delay.
24+
*
25+
* @internal
26+
*/
27+
export const createWaiter = async <Client extends SmithyClient, Input>(
28+
options: WaiterOptions<Client>,
29+
input: Input,
30+
acceptorChecks: (client: Client, input: Input) => Promise<WaiterResult>
31+
): Promise<WaiterResult> => {
32+
const params = {
33+
...waiterServiceDefaults,
34+
...options,
35+
};
36+
validateWaiterOptions(params);
37+
38+
const exitConditions = [runPolling<Client, Input>(params, input, acceptorChecks)];
39+
if (options.abortController) {
40+
exitConditions.push(abortTimeout(options.abortController.signal));
41+
}
42+
return Promise.race(exitConditions);
43+
};

packages/util-waiter/src/index.spec.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@ import * as exported from "./index";
22

33
describe("Waiter util module exports", () => {
44
it("should export the proper functions", () => {
5-
expect(exported.sleep).toBeDefined();
6-
expect(exported.waiterTimeout).toBeDefined();
7-
expect(exported.abortTimeout).toBeDefined();
8-
expect(exported.validateWaiterOptions).toBeDefined();
9-
expect(exported.runPolling).toBeDefined();
5+
expect(exported.createWaiter).toBeDefined();
106
});
117
});

packages/util-waiter/src/index.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,2 @@
1-
export * from "./utils/validate";
2-
export * from "./utils/sleep";
3-
export * from "./poller";
1+
export * from "./createWaiter";
42
export * from "./waiter";
Lines changed: 58 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { AbortController } from "@aws-sdk/abort-controller";
2+
13
import { runPolling } from "./poller";
24
import { sleep } from "./utils/sleep";
3-
import { WaiterState } from "./waiter";
5+
import { ResolvedWaiterOptions, WaiterState } from "./waiter";
46

57
jest.mock("./utils/sleep");
68

@@ -9,9 +11,12 @@ describe(runPolling.name, () => {
911
minDelay: 2,
1012
maxDelay: 30,
1113
maxWaitTime: 99999,
12-
};
13-
const client = "mockClient";
14+
client: "mockClient",
15+
} as ResolvedWaiterOptions<any>;
1416
const input = "mockInput";
17+
const abortedState = {
18+
state: WaiterState.ABORTED,
19+
};
1520
const failureState = {
1621
state: WaiterState.FAILURE,
1722
};
@@ -21,6 +26,9 @@ describe(runPolling.name, () => {
2126
const retryState = {
2227
state: WaiterState.RETRY,
2328
};
29+
const timeoutState = {
30+
state: WaiterState.TIMEOUT,
31+
};
2432

2533
let mockAcceptorChecks;
2634

@@ -36,30 +44,20 @@ describe(runPolling.name, () => {
3644

3745
it("should returns state in case of failure", async () => {
3846
mockAcceptorChecks = jest.fn().mockResolvedValueOnce(failureState);
39-
await expect(runPolling<string, string>(config, client, input, mockAcceptorChecks)).resolves.toStrictEqual(
40-
failureState
41-
);
47+
await expect(runPolling(config, input, mockAcceptorChecks)).resolves.toStrictEqual(failureState);
4248

4349
expect(mockAcceptorChecks).toHaveBeenCalled();
4450
expect(mockAcceptorChecks).toHaveBeenCalledTimes(1);
45-
expect(mockAcceptorChecks).toHaveBeenCalledWith(client, input);
51+
expect(mockAcceptorChecks).toHaveBeenCalledWith(config.client, input);
4652

4753
expect(sleep).toHaveBeenCalled();
4854
expect(sleep).toHaveBeenCalledTimes(1);
4955
expect(sleep).toHaveBeenCalledWith(config.minDelay);
5056
});
5157

5258
it("returns state in case of success", async () => {
53-
const config = {
54-
minDelay: 2,
55-
maxDelay: 30,
56-
maxWaitTime: 99999,
57-
};
58-
5959
mockAcceptorChecks = jest.fn().mockResolvedValueOnce(successState);
60-
await expect(runPolling<string, string>(config, client, input, mockAcceptorChecks)).resolves.toStrictEqual(
61-
successState
62-
);
60+
await expect(runPolling(config, input, mockAcceptorChecks)).resolves.toStrictEqual(successState);
6361
expect(sleep).toHaveBeenCalled();
6462
expect(sleep).toHaveBeenCalledTimes(1);
6563
expect(sleep).toHaveBeenCalledWith(config.minDelay);
@@ -76,18 +74,54 @@ describe(runPolling.name, () => {
7674
.mockResolvedValueOnce(retryState)
7775
.mockResolvedValueOnce(successState);
7876

79-
await expect(runPolling<string, string>(config, client, input, mockAcceptorChecks)).resolves.toStrictEqual(
80-
successState
81-
);
77+
await expect(runPolling(config, input, mockAcceptorChecks)).resolves.toStrictEqual(successState);
8278

8379
expect(sleep).toHaveBeenCalled();
8480
expect(sleep).toHaveBeenCalledTimes(7);
85-
expect(sleep).toHaveBeenNthCalledWith(1, 2); // min delay
86-
expect(sleep).toHaveBeenNthCalledWith(2, 3); // +random() * 2
87-
expect(sleep).toHaveBeenNthCalledWith(3, 5); // +random() * 4
88-
expect(sleep).toHaveBeenNthCalledWith(4, 9); // +random() * 8
89-
expect(sleep).toHaveBeenNthCalledWith(5, 17); // +random() * 16
81+
expect(sleep).toHaveBeenNthCalledWith(1, 2); // min delay. random(2, 2)
82+
expect(sleep).toHaveBeenNthCalledWith(2, 3); // random(2, 4)
83+
expect(sleep).toHaveBeenNthCalledWith(3, 5); // +random(2, 8)
84+
expect(sleep).toHaveBeenNthCalledWith(4, 9); // +random(2, 16)
85+
expect(sleep).toHaveBeenNthCalledWith(5, 30); // max delay
9086
expect(sleep).toHaveBeenNthCalledWith(6, 30); // max delay
9187
expect(sleep).toHaveBeenNthCalledWith(7, 30); // max delay
9288
});
89+
90+
it("resolves after the last attempt before reaching maxWaitTime ", async () => {
91+
let now = Date.now();
92+
const delay = 2;
93+
const nowMock = jest
94+
.spyOn(Date, "now")
95+
.mockReturnValueOnce(now) // 1st invoke for getting the time stamp to wait until
96+
.mockImplementation(() => {
97+
const rtn = now;
98+
now += delay * 1000;
99+
return rtn;
100+
});
101+
const localConfig = {
102+
...config,
103+
minDelay: delay,
104+
maxDelay: delay,
105+
maxWaitTime: 5,
106+
};
107+
108+
mockAcceptorChecks = jest.fn().mockResolvedValue(retryState);
109+
await expect(runPolling(localConfig, input, mockAcceptorChecks)).resolves.toStrictEqual(timeoutState);
110+
expect(sleep).toHaveBeenCalled();
111+
expect(sleep).toHaveBeenCalledTimes(2);
112+
nowMock.mockReset();
113+
});
114+
115+
it("resolves when abortController is signalled", async () => {
116+
const abortController = new AbortController();
117+
const localConfig = {
118+
...config,
119+
abortController,
120+
};
121+
122+
mockAcceptorChecks = jest.fn().mockResolvedValue(retryState);
123+
abortController.abort();
124+
await expect(runPolling(localConfig, input, mockAcceptorChecks)).resolves.toStrictEqual(abortedState);
125+
expect(sleep).not.toHaveBeenCalled();
126+
});
93127
});

packages/util-waiter/src/poller.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { sleep } from "./utils/sleep";
2-
import { WaiterOptions, WaiterResult, WaiterState } from "./waiter";
2+
import { ResolvedWaiterOptions, SmithyClient, WaiterResult, WaiterState } from "./waiter";
33

44
/**
5-
* Reference: https://github.com/awslabs/smithy/pull/656
6-
* The theoretical limit to the attempt is max delay cannot be > Number.MAX_VALUE, but it's unlikely because of
7-
* `maxWaitTime`
5+
* Reference: https://awslabs.github.io/smithy/1.0/spec/waiters.html#waiter-retries
86
*/
9-
const exponentialBackoffWithJitter = (floor: number, ciel: number, attempt: number) =>
10-
Math.floor(Math.min(ciel, randomInRange(floor, floor * 2 ** (attempt - 1))));
7+
const exponentialBackoffWithJitter = (minDelay: number, maxDelay: number, attemptCeiling: number, attempt: number) => {
8+
if (attempt > attemptCeiling) return maxDelay;
9+
const delay = minDelay * 2 ** (attempt - 1);
10+
return randomInRange(minDelay, delay);
11+
};
12+
1113
const randomInRange = (min: number, max: number) => min + Math.random() * (max - min);
1214

1315
/**
@@ -17,20 +19,32 @@ const randomInRange = (min: number, max: number) => min + Math.random() * (max -
1719
* @param input client input
1820
* @param stateChecker function that checks the acceptor states on each poll.
1921
*/
20-
export const runPolling = async <T, S>(
21-
{ minDelay, maxDelay }: WaiterOptions,
22-
client: T,
22+
export const runPolling = async <T extends SmithyClient, S>(
23+
{ minDelay, maxDelay, maxWaitTime, abortController, client }: ResolvedWaiterOptions<T>,
2324
input: S,
2425
acceptorChecks: (client: T, input: S) => Promise<WaiterResult>
2526
): Promise<WaiterResult> => {
2627
let currentAttempt = 1;
27-
28+
const waitUntil = Date.now() + maxWaitTime * 1000;
29+
// The max attempt number that the derived delay time tend to increase.
30+
// Pre-compute this number to avoid Number type overflow.
31+
const attemptCeiling = Math.log(maxDelay / minDelay) / Math.log(2) + 1;
2832
while (true) {
29-
await sleep(exponentialBackoffWithJitter(minDelay, maxDelay, currentAttempt));
33+
if (abortController?.signal?.aborted) {
34+
return { state: WaiterState.ABORTED };
35+
}
36+
const delay = exponentialBackoffWithJitter(minDelay, maxDelay, attemptCeiling, currentAttempt);
37+
// Resolve the promise explicitly at timeout or aborted. Otherwise this while loop will keep making API call until
38+
// `acceptorCheck` returns non-retry status, even with the Promise.race() outside.
39+
if (Date.now() + delay * 1000 > waitUntil) {
40+
return { state: WaiterState.TIMEOUT };
41+
}
42+
await sleep(delay);
3043
const { state } = await acceptorChecks(client, input);
31-
if (state === WaiterState.SUCCESS || state === WaiterState.FAILURE) {
44+
if (state !== WaiterState.RETRY) {
3245
return { state };
3346
}
47+
3448
currentAttempt += 1;
3549
}
3650
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./sleep";
2+
export * from "./validate";

0 commit comments

Comments
 (0)