@cratis/chronicle 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ChronicleClient.ts +69 -60
- package/connection/ChronicleConnection.ts +32 -9
- package/connection/ChronicleConnectionString.spec.ts +227 -0
- package/connection/ChronicleConnectionString.ts +290 -40
- package/connection/ChronicleServerAddressResolver.spec.ts +41 -0
- package/connection/ChronicleServerAddressResolver.ts +33 -0
- package/connection/ChronicleSrvResolutionError.ts +13 -0
- package/connection/ChronicleSrvResolver.spec.ts +103 -0
- package/connection/ChronicleSrvResolver.ts +57 -0
- package/connection/ConnectionLifecycle.spec.ts +109 -0
- package/connection/ILoadBalancerStrategy.ts +16 -0
- package/connection/KernelKeepAlive.spec.ts +175 -0
- package/connection/KernelKeepAlive.ts +161 -0
- package/connection/LeastConnectionsLoadBalancerStrategy.spec.ts +159 -0
- package/connection/LeastConnectionsLoadBalancerStrategy.ts +117 -0
- package/connection/LoadBalancerMode.ts +25 -0
- package/connection/LoadBalancerStrategyFactory.ts +28 -0
- package/connection/RandomLoadBalancerStrategy.spec.ts +50 -0
- package/connection/RandomLoadBalancerStrategy.ts +19 -0
- package/connection/RoundRobinLoadBalancerStrategy.spec.ts +61 -0
- package/connection/RoundRobinLoadBalancerStrategy.ts +29 -0
- package/connection/TokenProvider.spec.ts +159 -0
- package/connection/TokenProvider.ts +80 -68
- package/connection/fetchOAuthAccessToken.ts +86 -0
- package/connection/formatServerAddress.ts +16 -0
- package/dist/ChronicleClient.d.ts +8 -1
- package/dist/ChronicleClient.d.ts.map +1 -1
- package/dist/ChronicleClient.js +63 -56
- package/dist/ChronicleClient.js.map +1 -1
- package/dist/connection/ChronicleConnection.d.ts +4 -1
- package/dist/connection/ChronicleConnection.d.ts.map +1 -1
- package/dist/connection/ChronicleConnection.js +29 -9
- package/dist/connection/ChronicleConnection.js.map +1 -1
- package/dist/connection/ChronicleConnectionString.d.ts +77 -3
- package/dist/connection/ChronicleConnectionString.d.ts.map +1 -1
- package/dist/connection/ChronicleConnectionString.js +251 -37
- package/dist/connection/ChronicleConnectionString.js.map +1 -1
- package/dist/connection/ChronicleServerAddressResolver.d.ts +23 -0
- package/dist/connection/ChronicleServerAddressResolver.d.ts.map +1 -0
- package/dist/connection/ChronicleServerAddressResolver.js +32 -0
- package/dist/connection/ChronicleServerAddressResolver.js.map +1 -0
- package/dist/connection/ChronicleSrvResolutionError.d.ts +8 -0
- package/dist/connection/ChronicleSrvResolutionError.d.ts.map +1 -0
- package/dist/connection/ChronicleSrvResolutionError.js +13 -0
- package/dist/connection/ChronicleSrvResolutionError.js.map +1 -0
- package/dist/connection/ChronicleSrvResolver.d.ts +28 -0
- package/dist/connection/ChronicleSrvResolver.d.ts.map +1 -0
- package/dist/connection/ChronicleSrvResolver.js +51 -0
- package/dist/connection/ChronicleSrvResolver.js.map +1 -0
- package/dist/connection/ILoadBalancerStrategy.d.ts +13 -0
- package/dist/connection/ILoadBalancerStrategy.d.ts.map +1 -0
- package/dist/connection/ILoadBalancerStrategy.js +4 -0
- package/dist/connection/ILoadBalancerStrategy.js.map +1 -0
- package/dist/connection/KernelKeepAlive.d.ts +73 -0
- package/dist/connection/KernelKeepAlive.d.ts.map +1 -0
- package/dist/connection/KernelKeepAlive.js +115 -0
- package/dist/connection/KernelKeepAlive.js.map +1 -0
- package/dist/connection/LeastConnectionsLoadBalancerStrategy.d.ts +30 -0
- package/dist/connection/LeastConnectionsLoadBalancerStrategy.d.ts.map +1 -0
- package/dist/connection/LeastConnectionsLoadBalancerStrategy.js +95 -0
- package/dist/connection/LeastConnectionsLoadBalancerStrategy.js.map +1 -0
- package/dist/connection/LoadBalancerMode.d.ts +21 -0
- package/dist/connection/LoadBalancerMode.d.ts.map +1 -0
- package/dist/connection/LoadBalancerMode.js +24 -0
- package/dist/connection/LoadBalancerMode.js.map +1 -0
- package/dist/connection/LoadBalancerStrategyFactory.d.ts +11 -0
- package/dist/connection/LoadBalancerStrategyFactory.d.ts.map +1 -0
- package/dist/connection/LoadBalancerStrategyFactory.js +26 -0
- package/dist/connection/LoadBalancerStrategyFactory.js.map +1 -0
- package/dist/connection/RandomLoadBalancerStrategy.d.ts +9 -0
- package/dist/connection/RandomLoadBalancerStrategy.d.ts.map +1 -0
- package/dist/connection/RandomLoadBalancerStrategy.js +15 -0
- package/dist/connection/RandomLoadBalancerStrategy.js.map +1 -0
- package/dist/connection/RoundRobinLoadBalancerStrategy.d.ts +12 -0
- package/dist/connection/RoundRobinLoadBalancerStrategy.d.ts.map +1 -0
- package/dist/connection/RoundRobinLoadBalancerStrategy.js +22 -0
- package/dist/connection/RoundRobinLoadBalancerStrategy.js.map +1 -0
- package/dist/connection/TokenProvider.d.ts +32 -7
- package/dist/connection/TokenProvider.d.ts.map +1 -1
- package/dist/connection/TokenProvider.js +69 -61
- package/dist/connection/TokenProvider.js.map +1 -1
- package/dist/connection/fetchOAuthAccessToken.d.ts +18 -0
- package/dist/connection/fetchOAuthAccessToken.d.ts.map +1 -0
- package/dist/connection/fetchOAuthAccessToken.js +62 -0
- package/dist/connection/fetchOAuthAccessToken.js.map +1 -0
- package/dist/connection/formatServerAddress.d.ts +10 -0
- package/dist/connection/formatServerAddress.d.ts.map +1 -0
- package/dist/connection/formatServerAddress.js +14 -0
- package/dist/connection/formatServerAddress.js.map +1 -0
- package/dist/reactors/Reactors.d.ts +13 -0
- package/dist/reactors/Reactors.d.ts.map +1 -1
- package/dist/reactors/Reactors.js +42 -4
- package/dist/reactors/Reactors.js.map +1 -1
- package/dist/reducers/Reducers.d.ts +14 -0
- package/dist/reducers/Reducers.d.ts.map +1 -1
- package/dist/reducers/Reducers.js +43 -8
- package/dist/reducers/Reducers.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -5
- package/reactors/Reactors.ts +53 -4
- package/reducers/Reducers.ts +55 -8
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { Agent } from 'undici';
|
|
5
|
+
import type { ChronicleServerAddress } from './ChronicleConnectionString';
|
|
6
|
+
import { formatServerAddress } from './formatServerAddress';
|
|
7
|
+
import type { ILoadBalancerStrategy } from './ILoadBalancerStrategy';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MAX_JITTER_MS = 250;
|
|
10
|
+
const PROBE_TIMEOUT_MS = 2000;
|
|
11
|
+
|
|
12
|
+
// Node's global fetch types its `dispatcher` option against `undici-types` (bundled with
|
|
13
|
+
// @types/node), which is structurally close to but not identical to the real `undici`
|
|
14
|
+
// package's own `Agent`/`Dispatcher` types - hence the assertion through `unknown` at every
|
|
15
|
+
// use below.
|
|
16
|
+
type FetchDispatcher = NonNullable<RequestInit['dispatcher']>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Selects the least-loaded candidate by probing each one's current connection count over
|
|
20
|
+
* HTTP and picking the minimum, breaking ties randomly. This is the default strategy.
|
|
21
|
+
*
|
|
22
|
+
* Before every probe attempt (not just the first), waits a random jitter to avoid a
|
|
23
|
+
* thundering herd of clients probing every candidate at the exact same instant. A single
|
|
24
|
+
* candidate is returned immediately without probing, since there is nothing to choose
|
|
25
|
+
* between.
|
|
26
|
+
*/
|
|
27
|
+
export class LeastConnectionsLoadBalancerStrategy implements ILoadBalancerStrategy {
|
|
28
|
+
private readonly _dispatcher: Agent;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Initializes a new instance of {@link LeastConnectionsLoadBalancerStrategy}.
|
|
32
|
+
* @param skipTlsValidation - Whether to skip TLS certificate validation on the
|
|
33
|
+
* `/connections/count` and `/connections/reserve` probe requests, matching the toggle
|
|
34
|
+
* used for the gRPC channel itself.
|
|
35
|
+
* @param maxJitterMs - The maximum jitter, in milliseconds, to wait before every probe
|
|
36
|
+
* attempt. Defaults to 250ms. 0 disables jitter entirely.
|
|
37
|
+
*/
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly _skipTlsValidation: boolean,
|
|
40
|
+
private readonly _maxJitterMs: number = DEFAULT_MAX_JITTER_MS
|
|
41
|
+
) {
|
|
42
|
+
this._dispatcher = new Agent({ connect: { rejectUnauthorized: !this._skipTlsValidation } });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async select(candidates: ChronicleServerAddress[]): Promise<ChronicleServerAddress> {
|
|
46
|
+
if (candidates.length === 0) {
|
|
47
|
+
throw new Error('Cannot select a server address from an empty candidate list');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (candidates.length === 1) {
|
|
51
|
+
return candidates[0];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await this.jitter();
|
|
55
|
+
|
|
56
|
+
const connectionCounts = await Promise.all(candidates.map(candidate => this.probeConnectionCount(candidate)));
|
|
57
|
+
const minimumCount = Math.min(...connectionCounts);
|
|
58
|
+
const minimumIndices = connectionCounts.reduce<number[]>((indices, count, index) => {
|
|
59
|
+
if (count === minimumCount) {
|
|
60
|
+
indices.push(index);
|
|
61
|
+
}
|
|
62
|
+
return indices;
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const selected = candidates[minimumIndices[Math.floor(Math.random() * minimumIndices.length)]];
|
|
66
|
+
|
|
67
|
+
await this.reserve(selected);
|
|
68
|
+
|
|
69
|
+
return selected;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async jitter(): Promise<void> {
|
|
73
|
+
if (this._maxJitterMs <= 0) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const delayMs = Math.floor(Math.random() * this._maxJitterMs);
|
|
78
|
+
if (delayMs <= 0) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async probeConnectionCount(candidate: ChronicleServerAddress): Promise<number> {
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(`https://${formatServerAddress(candidate)}/connections/count`, {
|
|
88
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
89
|
+
dispatcher: this._dispatcher as unknown as FetchDispatcher
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
return Number.MAX_SAFE_INTEGER;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const body = (await response.text()).trim();
|
|
97
|
+
const count = Number(body);
|
|
98
|
+
return body.length > 0 && Number.isFinite(count) ? count : Number.MAX_SAFE_INTEGER;
|
|
99
|
+
} catch {
|
|
100
|
+
// A candidate that cannot be reached or answers with garbage is never preferred
|
|
101
|
+
// over one that responds normally, but must not fail the whole selection round.
|
|
102
|
+
return Number.MAX_SAFE_INTEGER;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async reserve(candidate: ChronicleServerAddress): Promise<void> {
|
|
107
|
+
try {
|
|
108
|
+
await fetch(`https://${formatServerAddress(candidate)}/connections/reserve`, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
111
|
+
dispatcher: this._dispatcher as unknown as FetchDispatcher
|
|
112
|
+
});
|
|
113
|
+
} catch {
|
|
114
|
+
// Best-effort reservation; the selected server is still used even if this fails.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The strategy used to select one server address from a multi-host connection string
|
|
6
|
+
* (or a resolved set of `chronicle+srv://` addresses) for each connect/reconnect attempt.
|
|
7
|
+
*/
|
|
8
|
+
export enum LoadBalancerMode {
|
|
9
|
+
/**
|
|
10
|
+
* Probes every candidate's current connection count and picks the least-loaded one,
|
|
11
|
+
* breaking ties randomly. The default strategy.
|
|
12
|
+
*/
|
|
13
|
+
LeastConnections = 'least-connections',
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Cycles through candidates in order, starting from a random offset chosen once per
|
|
17
|
+
* strategy instance.
|
|
18
|
+
*/
|
|
19
|
+
RoundRobin = 'round-robin',
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Picks a uniformly random candidate on every call.
|
|
23
|
+
*/
|
|
24
|
+
Random = 'random'
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import type { ILoadBalancerStrategy } from './ILoadBalancerStrategy';
|
|
5
|
+
import { LeastConnectionsLoadBalancerStrategy } from './LeastConnectionsLoadBalancerStrategy';
|
|
6
|
+
import { LoadBalancerMode } from './LoadBalancerMode';
|
|
7
|
+
import { RandomLoadBalancerStrategy } from './RandomLoadBalancerStrategy';
|
|
8
|
+
import { RoundRobinLoadBalancerStrategy } from './RoundRobinLoadBalancerStrategy';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates the {@link ILoadBalancerStrategy} instance matching a {@link LoadBalancerMode}.
|
|
12
|
+
* @param mode - The load balancer mode to create a strategy for.
|
|
13
|
+
* @param skipTlsValidation - Whether to skip TLS certificate validation for strategies that
|
|
14
|
+
* probe candidates over HTTP (currently only {@link LoadBalancerMode.LeastConnections}).
|
|
15
|
+
* @returns The load balancer strategy instance.
|
|
16
|
+
*/
|
|
17
|
+
export function createLoadBalancerStrategy(mode: LoadBalancerMode, skipTlsValidation: boolean): ILoadBalancerStrategy {
|
|
18
|
+
switch (mode) {
|
|
19
|
+
case LoadBalancerMode.RoundRobin:
|
|
20
|
+
return new RoundRobinLoadBalancerStrategy();
|
|
21
|
+
case LoadBalancerMode.Random:
|
|
22
|
+
return new RandomLoadBalancerStrategy();
|
|
23
|
+
case LoadBalancerMode.LeastConnections:
|
|
24
|
+
return new LeastConnectionsLoadBalancerStrategy(skipTlsValidation);
|
|
25
|
+
default:
|
|
26
|
+
throw new Error(`Unknown load balancer mode: ${mode}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import type { ChronicleServerAddress } from './ChronicleConnectionString';
|
|
6
|
+
import { RandomLoadBalancerStrategy } from './RandomLoadBalancerStrategy';
|
|
7
|
+
|
|
8
|
+
describe('RandomLoadBalancerStrategy', () => {
|
|
9
|
+
describe('when selecting from an empty candidate list', () => {
|
|
10
|
+
const strategy = new RandomLoadBalancerStrategy();
|
|
11
|
+
|
|
12
|
+
it('should throw', async () => {
|
|
13
|
+
await expect(strategy.select([])).rejects.toThrow();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('when selecting from a single candidate', () => {
|
|
18
|
+
const strategy = new RandomLoadBalancerStrategy();
|
|
19
|
+
const candidate: ChronicleServerAddress = { host: 'host1', port: 35000 };
|
|
20
|
+
|
|
21
|
+
it('should return that candidate', async () => {
|
|
22
|
+
expect(await strategy.select([candidate])).toEqual(candidate);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('when selecting from many candidates repeatedly', () => {
|
|
27
|
+
const strategy = new RandomLoadBalancerStrategy();
|
|
28
|
+
const candidates: ChronicleServerAddress[] = [
|
|
29
|
+
{ host: 'host1', port: 35000 },
|
|
30
|
+
{ host: 'host2', port: 35000 },
|
|
31
|
+
{ host: 'host3', port: 35000 }
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
it('should always return one of the candidates', async () => {
|
|
35
|
+
for (let attempt = 0; attempt < 25; attempt++) {
|
|
36
|
+
const selected = await strategy.select(candidates);
|
|
37
|
+
expect(candidates).toContainEqual(selected);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should eventually select more than one distinct candidate', async () => {
|
|
42
|
+
const selections = new Set<string>();
|
|
43
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
44
|
+
const selected = await strategy.select(candidates);
|
|
45
|
+
selections.add(`${selected.host}:${selected.port}`);
|
|
46
|
+
}
|
|
47
|
+
expect(selections.size).toBeGreaterThan(1);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import type { ChronicleServerAddress } from './ChronicleConnectionString';
|
|
5
|
+
import type { ILoadBalancerStrategy } from './ILoadBalancerStrategy';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Selects a uniformly random candidate on every call.
|
|
9
|
+
*/
|
|
10
|
+
export class RandomLoadBalancerStrategy implements ILoadBalancerStrategy {
|
|
11
|
+
async select(candidates: ChronicleServerAddress[]): Promise<ChronicleServerAddress> {
|
|
12
|
+
if (candidates.length === 0) {
|
|
13
|
+
throw new Error('Cannot select a server address from an empty candidate list');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const index = Math.floor(Math.random() * candidates.length);
|
|
17
|
+
return candidates[index];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import type { ChronicleServerAddress } from './ChronicleConnectionString';
|
|
6
|
+
import { RoundRobinLoadBalancerStrategy } from './RoundRobinLoadBalancerStrategy';
|
|
7
|
+
|
|
8
|
+
describe('RoundRobinLoadBalancerStrategy', () => {
|
|
9
|
+
describe('when selecting from an empty candidate list', () => {
|
|
10
|
+
const strategy = new RoundRobinLoadBalancerStrategy();
|
|
11
|
+
|
|
12
|
+
it('should throw', async () => {
|
|
13
|
+
await expect(strategy.select([])).rejects.toThrow();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('when selecting repeatedly from the same strategy instance', () => {
|
|
18
|
+
const strategy = new RoundRobinLoadBalancerStrategy();
|
|
19
|
+
const candidates: ChronicleServerAddress[] = [
|
|
20
|
+
{ host: 'host1', port: 35000 },
|
|
21
|
+
{ host: 'host2', port: 35000 },
|
|
22
|
+
{ host: 'host3', port: 35000 }
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
it('should cycle through every candidate exactly once per full cycle', async () => {
|
|
26
|
+
const first = await strategy.select(candidates);
|
|
27
|
+
const second = await strategy.select(candidates);
|
|
28
|
+
const third = await strategy.select(candidates);
|
|
29
|
+
const fourth = await strategy.select(candidates);
|
|
30
|
+
|
|
31
|
+
const firstCycle = [first, second, third];
|
|
32
|
+
expect(firstCycle).toContainEqual(candidates[0]);
|
|
33
|
+
expect(firstCycle).toContainEqual(candidates[1]);
|
|
34
|
+
expect(firstCycle).toContainEqual(candidates[2]);
|
|
35
|
+
|
|
36
|
+
// The cycle wraps back to whichever candidate started it.
|
|
37
|
+
expect(fourth).toEqual(first);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('when two independent strategy instances select from the same candidates', () => {
|
|
42
|
+
const candidates: ChronicleServerAddress[] = [
|
|
43
|
+
{ host: 'host1', port: 35000 },
|
|
44
|
+
{ host: 'host2', port: 35000 },
|
|
45
|
+
{ host: 'host3', port: 35000 }
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
it('should not always start at the same candidate', async () => {
|
|
49
|
+
const startingPoints = new Set<string>();
|
|
50
|
+
for (let attempt = 0; attempt < 25; attempt++) {
|
|
51
|
+
const strategy = new RoundRobinLoadBalancerStrategy();
|
|
52
|
+
const selected = await strategy.select(candidates);
|
|
53
|
+
startingPoints.add(`${selected.host}:${selected.port}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// With a random starting offset across many fresh instances, at least one run
|
|
57
|
+
// should land somewhere other than the first candidate.
|
|
58
|
+
expect(startingPoints.size).toBeGreaterThan(1);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import type { ChronicleServerAddress } from './ChronicleConnectionString';
|
|
5
|
+
import type { ILoadBalancerStrategy } from './ILoadBalancerStrategy';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cycles through candidates in order, starting from a random offset chosen once per
|
|
9
|
+
* strategy instance so that multiple clients don't all start at the same candidate.
|
|
10
|
+
*/
|
|
11
|
+
export class RoundRobinLoadBalancerStrategy implements ILoadBalancerStrategy {
|
|
12
|
+
private _startOffset?: number;
|
|
13
|
+
private _callCount = 0;
|
|
14
|
+
|
|
15
|
+
async select(candidates: ChronicleServerAddress[]): Promise<ChronicleServerAddress> {
|
|
16
|
+
if (candidates.length === 0) {
|
|
17
|
+
throw new Error('Cannot select a server address from an empty candidate list');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (this._startOffset === undefined) {
|
|
21
|
+
this._startOffset = Math.floor(Math.random() * candidates.length);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const index = (this._startOffset + this._callCount) % candidates.length;
|
|
25
|
+
this._callCount++;
|
|
26
|
+
|
|
27
|
+
return candidates[index];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import type { OAuthTokenResponse } from './fetchOAuthAccessToken';
|
|
6
|
+
import { OAuthTokenProvider } from './TokenProvider';
|
|
7
|
+
|
|
8
|
+
// Long enough to stay outside the 60s refresh margin for the whole spec.
|
|
9
|
+
const longLifetime = 3600;
|
|
10
|
+
// Short enough to be inside the refresh margin immediately.
|
|
11
|
+
const shortLifetime = 30;
|
|
12
|
+
|
|
13
|
+
const token = (value: string, expiresIn?: number | string): OAuthTokenResponse =>
|
|
14
|
+
({ access_token: value, ...(expiresIn === undefined ? {} : { expires_in: expiresIn }) });
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates a provider whose token fetches are served from the given script of
|
|
18
|
+
* responses — the last one repeats for any further fetches.
|
|
19
|
+
*/
|
|
20
|
+
function createProvider(responses: Array<OAuthTokenResponse | Error>) {
|
|
21
|
+
let call = 0;
|
|
22
|
+
const fetchToken = vi.fn(() => {
|
|
23
|
+
const response = responses[Math.min(call++, responses.length - 1)];
|
|
24
|
+
return response instanceof Error ? Promise.reject(response) : Promise.resolve(response);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const provider = new OAuthTokenProvider('https://localhost:35000/connect/token', 'client', 'secret', true, fetchToken);
|
|
28
|
+
return { provider, fetchToken };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('OAuthTokenProvider', () => {
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.useRealTimers();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('when requesting the first token', () => {
|
|
37
|
+
it('should fetch it lazily and return it', async () => {
|
|
38
|
+
const { provider, fetchToken } = createProvider([token('token-1', longLifetime)]);
|
|
39
|
+
|
|
40
|
+
expect(await provider.getAccessToken()).toBe('token-1');
|
|
41
|
+
expect(fetchToken).toHaveBeenCalledTimes(1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('when the cached token is fresh', () => {
|
|
46
|
+
it('should serve it without fetching again', async () => {
|
|
47
|
+
const { provider, fetchToken } = createProvider([token('token-1', longLifetime), token('token-2', longLifetime)]);
|
|
48
|
+
|
|
49
|
+
expect(await provider.getAccessToken()).toBe('token-1');
|
|
50
|
+
expect(await provider.getAccessToken()).toBe('token-1');
|
|
51
|
+
expect(fetchToken).toHaveBeenCalledTimes(1);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('when the token response has no expires_in', () => {
|
|
56
|
+
it('should assume the default lifetime and cache the token', async () => {
|
|
57
|
+
const { provider, fetchToken } = createProvider([token('token-1')]);
|
|
58
|
+
|
|
59
|
+
expect(await provider.getAccessToken()).toBe('token-1');
|
|
60
|
+
expect(await provider.getAccessToken()).toBe('token-1');
|
|
61
|
+
expect(fetchToken).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('when the token response sends expires_in as a string', () => {
|
|
66
|
+
it('should parse it and cache the token', async () => {
|
|
67
|
+
const { provider, fetchToken } = createProvider([token('token-1', '3600')]);
|
|
68
|
+
|
|
69
|
+
expect(await provider.getAccessToken()).toBe('token-1');
|
|
70
|
+
expect(await provider.getAccessToken()).toBe('token-1');
|
|
71
|
+
expect(fetchToken).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('when the token enters the refresh margin', () => {
|
|
76
|
+
it('should refresh ahead of expiry', async () => {
|
|
77
|
+
const { provider, fetchToken } = createProvider([token('token-1', shortLifetime), token('token-2', shortLifetime)]);
|
|
78
|
+
|
|
79
|
+
expect(await provider.getAccessToken()).toBe('token-1');
|
|
80
|
+
|
|
81
|
+
// The short lifetime is already inside the margin, so the next request
|
|
82
|
+
// refreshes even though the first token has not expired yet.
|
|
83
|
+
expect(await provider.getAccessToken()).toBe('token-2');
|
|
84
|
+
expect(fetchToken).toHaveBeenCalledTimes(2);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('when a refresh fails while the cached token is still valid', () => {
|
|
89
|
+
it('should keep serving the cached token', async () => {
|
|
90
|
+
const { provider } = createProvider([token('token-1', shortLifetime), new Error('unavailable')]);
|
|
91
|
+
|
|
92
|
+
expect(await provider.getAccessToken()).toBe('token-1');
|
|
93
|
+
|
|
94
|
+
// Refresh is due (inside the margin) and fails — the token is still valid
|
|
95
|
+
// for another 30s, so it must keep flowing rather than dropping auth.
|
|
96
|
+
expect(await provider.getAccessToken()).toBe('token-1');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('when no token can be fetched', () => {
|
|
101
|
+
it('should return undefined instead of rejecting', async () => {
|
|
102
|
+
const { provider } = createProvider([new Error('unavailable')]);
|
|
103
|
+
|
|
104
|
+
// The RPC proceeds and fails with the server's auth rejection — that is
|
|
105
|
+
// the session machinery's problem, not the token provider's.
|
|
106
|
+
expect(await provider.getAccessToken()).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should throttle further fetch attempts', async () => {
|
|
110
|
+
const { provider, fetchToken } = createProvider([new Error('unavailable')]);
|
|
111
|
+
|
|
112
|
+
expect(await provider.getAccessToken()).toBeUndefined();
|
|
113
|
+
expect(await provider.getAccessToken()).toBeUndefined();
|
|
114
|
+
|
|
115
|
+
// The second request arrives well inside the retry delay — one attempt,
|
|
116
|
+
// not one per RPC (the session answers a keepalive every second).
|
|
117
|
+
expect(fetchToken).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should try again once the retry delay has passed', async () => {
|
|
121
|
+
vi.useFakeTimers();
|
|
122
|
+
const { provider, fetchToken } = createProvider([new Error('unavailable'), token('token-1', longLifetime)]);
|
|
123
|
+
|
|
124
|
+
expect(await provider.getAccessToken()).toBeUndefined();
|
|
125
|
+
vi.advanceTimersByTime(5000);
|
|
126
|
+
|
|
127
|
+
expect(await provider.getAccessToken()).toBe('token-1');
|
|
128
|
+
expect(fetchToken).toHaveBeenCalledTimes(2);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('when multiple requests race', () => {
|
|
133
|
+
it('should share a single fetch', async () => {
|
|
134
|
+
let resolveFetch!: (response: OAuthTokenResponse) => void;
|
|
135
|
+
const fetchToken = vi.fn(() => new Promise<OAuthTokenResponse>(resolve => {
|
|
136
|
+
resolveFetch = resolve;
|
|
137
|
+
}));
|
|
138
|
+
const provider = new OAuthTokenProvider('https://localhost:35000/connect/token', 'client', 'secret', true, fetchToken);
|
|
139
|
+
|
|
140
|
+
const first = provider.getAccessToken();
|
|
141
|
+
const second = provider.getAccessToken();
|
|
142
|
+
resolveFetch(token('token-1', longLifetime));
|
|
143
|
+
|
|
144
|
+
expect(await first).toBe('token-1');
|
|
145
|
+
expect(await second).toBe('token-1');
|
|
146
|
+
expect(fetchToken).toHaveBeenCalledTimes(1);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('when a refresh is forced', () => {
|
|
151
|
+
it('should discard the cached token and fetch a new one', async () => {
|
|
152
|
+
const { provider, fetchToken } = createProvider([token('token-1', longLifetime), token('token-2', longLifetime)]);
|
|
153
|
+
|
|
154
|
+
expect(await provider.getAccessToken()).toBe('token-1');
|
|
155
|
+
expect(await provider.refresh()).toBe('token-2');
|
|
156
|
+
expect(fetchToken).toHaveBeenCalledTimes(2);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|