@cratis/chronicle 1.0.2 → 2.1.0
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/ChronicleOptions.ts +5 -6
- package/README.md +1 -1
- package/connection/ChronicleConnection.ts +37 -17
- package/connection/ChronicleConnectionString.spec.ts +227 -0
- package/connection/ChronicleConnectionString.ts +289 -35
- 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.ts +8 -3
- 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/ChronicleOptions.d.ts +4 -2
- package/dist/ChronicleOptions.d.ts.map +1 -1
- package/dist/ChronicleOptions.js +5 -3
- package/dist/ChronicleOptions.js.map +1 -1
- package/dist/connection/ChronicleConnection.d.ts +4 -5
- package/dist/connection/ChronicleConnection.d.ts.map +1 -1
- package/dist/connection/ChronicleConnection.js +34 -12
- 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 +250 -32
- 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 +2 -1
- package/dist/connection/TokenProvider.d.ts.map +1 -1
- package/dist/connection/TokenProvider.js +9 -3
- package/dist/connection/TokenProvider.js.map +1 -1
- 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
|
+
}
|
|
@@ -53,7 +53,8 @@ export class OAuthTokenProvider implements ITokenProvider {
|
|
|
53
53
|
constructor(
|
|
54
54
|
private readonly _tokenEndpoint: string,
|
|
55
55
|
private readonly _clientId: string,
|
|
56
|
-
private readonly _clientSecret: string
|
|
56
|
+
private readonly _clientSecret: string,
|
|
57
|
+
private readonly _skipTlsValidation: boolean = true
|
|
57
58
|
) {}
|
|
58
59
|
|
|
59
60
|
async getAccessToken(): Promise<string | undefined> {
|
|
@@ -89,14 +90,18 @@ export class OAuthTokenProvider implements ITokenProvider {
|
|
|
89
90
|
|
|
90
91
|
return new Promise((resolve, reject) => {
|
|
91
92
|
const url = new URL(this._tokenEndpoint);
|
|
92
|
-
const
|
|
93
|
+
const isHttps = url.protocol === 'https:';
|
|
94
|
+
const httpModule = isHttps ? https : http;
|
|
93
95
|
|
|
94
96
|
const req = httpModule.request(url, {
|
|
95
97
|
method: 'POST',
|
|
96
98
|
headers: {
|
|
97
99
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
98
100
|
'Content-Length': Buffer.byteLength(body)
|
|
99
|
-
}
|
|
101
|
+
},
|
|
102
|
+
// Chain validation is skipped only when skipTlsValidation is explicitly set,
|
|
103
|
+
// matching the gRPC channel's credentials for the same connection string.
|
|
104
|
+
...(isHttps && this._skipTlsValidation ? { rejectUnauthorized: false } : {})
|
|
100
105
|
}, response => {
|
|
101
106
|
let data = '';
|
|
102
107
|
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Formats a {@link ChronicleServerAddress} as a `host:port` string, wrapping IPv6 host
|
|
8
|
+
* literals in bracket notation (e.g. `[::1]:35000`) as required by both the connection
|
|
9
|
+
* string grammar and gRPC target grammar.
|
|
10
|
+
* @param address - The address to format.
|
|
11
|
+
* @returns The formatted `host:port` string.
|
|
12
|
+
*/
|
|
13
|
+
export function formatServerAddress(address: ChronicleServerAddress): string {
|
|
14
|
+
const host = address.host.includes(':') ? `[${address.host}]` : address.host;
|
|
15
|
+
return `${host}:${address.port}`;
|
|
16
|
+
}
|
|
@@ -19,6 +19,7 @@ import { IEventStore } from './IEventStore';
|
|
|
19
19
|
export declare class ChronicleClient implements IChronicleClient {
|
|
20
20
|
readonly options: ChronicleOptions;
|
|
21
21
|
private static readonly _healthCheckIntervalMs;
|
|
22
|
+
private static readonly _maxBackoffMs;
|
|
22
23
|
private readonly _connection;
|
|
23
24
|
private readonly _stores;
|
|
24
25
|
private readonly _lifecycle;
|
|
@@ -29,6 +30,7 @@ export declare class ChronicleClient implements IChronicleClient {
|
|
|
29
30
|
private _discoveryOperation?;
|
|
30
31
|
private _isDisposed;
|
|
31
32
|
private _keepAliveAbortController?;
|
|
33
|
+
private _healthCheckInFlight;
|
|
32
34
|
/**
|
|
33
35
|
* Creates a new {@link ChronicleClient} using the provided options.
|
|
34
36
|
* @param options - The options to configure the client, including the connection string.
|
|
@@ -43,13 +45,18 @@ export declare class ChronicleClient implements IChronicleClient {
|
|
|
43
45
|
private connectWithRetry;
|
|
44
46
|
private ensureConnected;
|
|
45
47
|
private reconnect;
|
|
48
|
+
/**
|
|
49
|
+
* Waits out the exponential backoff for a failed attempt and reports why.
|
|
50
|
+
* The delay is jittered so a fleet of clients that lost the same kernel does
|
|
51
|
+
* not come back in lockstep and knock it over again.
|
|
52
|
+
*/
|
|
53
|
+
private backOff;
|
|
46
54
|
private withReconnect;
|
|
47
55
|
private shouldReconnect;
|
|
48
56
|
private toErrorMessage;
|
|
49
57
|
private startConnectionWatchdog;
|
|
50
58
|
private runHealthCheck;
|
|
51
59
|
private startKernelKeepAlive;
|
|
52
|
-
private runKeepAliveLoop;
|
|
53
60
|
private registerArtifactsForStore;
|
|
54
61
|
}
|
|
55
62
|
//# sourceMappingURL=ChronicleClient.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChronicleClient.d.ts","sourceRoot":"","sources":["../ChronicleClient.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ChronicleClient.d.ts","sourceRoot":"","sources":["../ChronicleClient.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAKtD,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AACpE,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAK5C;;;;;;;;;;;;GAYG;AACH,qBAAa,eAAgB,YAAW,gBAAgB;IAwBxC,QAAQ,CAAC,OAAO,EAAE,gBAAgB;IAvB9C,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,sBAAsB,CAAQ;IACtD,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAU;IAE/C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsB;IAClD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsC;IAC9D,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA6B;IAExD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAErB;IAEH,OAAO,CAAC,eAAe,CAAC,CAAiC;IACzD,OAAO,CAAC,iBAAiB,CAAC,CAAgB;IAC1C,OAAO,CAAC,mBAAmB,CAAC,CAAgB;IAC5C,OAAO,CAAC,mBAAmB,CAAC,CAAgB;IAC5C,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,yBAAyB,CAAC,CAAkB;IACpD,OAAO,CAAC,oBAAoB,CAAS;IAErC;;;OAGG;gBACkB,OAAO,EAAE,gBAAgB;IAgD9C,kBAAkB;IACZ,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,uBAAuB,GAAG,OAAO,CAAC,WAAW,CAAC;IA0DtH,kBAAkB;IACZ,cAAc,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IA0BjD,kBAAkB;IAClB,OAAO,IAAI,IAAI;YAsBD,gBAAgB;YA0ChB,eAAe;YAkBf,SAAS;IA0CvB;;;;OAIG;YACW,OAAO;YAcP,aAAa;IAa3B,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,uBAAuB;YAQjB,cAAc;YAkBd,oBAAoB;YAqCpB,yBAAyB;CAmB1C"}
|
package/dist/ChronicleClient.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// Copyright (c) Cratis. All rights reserved.
|
|
2
2
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
import * as os from 'os';
|
|
3
4
|
import { diag } from '@opentelemetry/api';
|
|
4
5
|
import { SpanStatusCode } from '@opentelemetry/api';
|
|
5
6
|
import { ChronicleConnection } from './connection';
|
|
6
7
|
import { ConnectionLifecycle } from './connection/ConnectionLifecycle';
|
|
8
|
+
import { KernelKeepAlive } from './connection/KernelKeepAlive';
|
|
7
9
|
import { EventStore } from './EventStore';
|
|
8
10
|
import { EventStoreName } from './EventStoreName';
|
|
9
11
|
import { EventStoreNamespaceName } from './EventStoreNamespaceName';
|
|
@@ -26,6 +28,7 @@ import { TypeDiscoverer } from './types';
|
|
|
26
28
|
export class ChronicleClient {
|
|
27
29
|
options;
|
|
28
30
|
static _healthCheckIntervalMs = 5000;
|
|
31
|
+
static _maxBackoffMs = 30_000;
|
|
29
32
|
_connection;
|
|
30
33
|
_stores = new Map();
|
|
31
34
|
_lifecycle = new ConnectionLifecycle();
|
|
@@ -38,6 +41,7 @@ export class ChronicleClient {
|
|
|
38
41
|
_discoveryOperation;
|
|
39
42
|
_isDisposed = false;
|
|
40
43
|
_keepAliveAbortController;
|
|
44
|
+
_healthCheckInFlight = false;
|
|
41
45
|
/**
|
|
42
46
|
* Creates a new {@link ChronicleClient} using the provided options.
|
|
43
47
|
* @param options - The options to configure the client, including the connection string.
|
|
@@ -188,15 +192,16 @@ export class ChronicleClient {
|
|
|
188
192
|
let attempt = 0;
|
|
189
193
|
while (!this._isDisposed) {
|
|
190
194
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
195
|
+
// Resolve (DNS SRV, when applicable) and select (load balancer strategy) a
|
|
196
|
+
// server address and rebuild the gRPC channel from scratch on every attempt,
|
|
197
|
+
// including the first — not just once at startup — so membership and load
|
|
198
|
+
// changes are always picked up. This also guarantees a fresh IDLE channel: a
|
|
199
|
+
// failed probe can leave a channel in TRANSIENT_FAILURE, which gRPC won't
|
|
200
|
+
// recover from without a new channel. The contracts connect() is bypassed
|
|
201
|
+
// here — it uses watchConnectivityState and rejects as soon as the state
|
|
202
|
+
// changes to CONNECTING (not READY), making it unreliable for initial
|
|
203
|
+
// connection establishment.
|
|
204
|
+
await this._connection.resetChannel();
|
|
200
205
|
this._logger.debug('Connecting to Chronicle kernel', { attempt: attempt + 1 });
|
|
201
206
|
// Probe with a real RPC call. gRPC connects lazily on the first call,
|
|
202
207
|
// so this effectively waits until the channel reaches READY or fails.
|
|
@@ -212,13 +217,8 @@ export class ChronicleClient {
|
|
|
212
217
|
}
|
|
213
218
|
catch (error) {
|
|
214
219
|
attempt++;
|
|
215
|
-
const delayMs =
|
|
216
|
-
this._logger.
|
|
217
|
-
attempt,
|
|
218
|
-
delayMs,
|
|
219
|
-
error: this.toErrorMessage(error)
|
|
220
|
-
});
|
|
221
|
-
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
220
|
+
const delayMs = await this.backOff(attempt, 'Connection attempt failed, retrying', error);
|
|
221
|
+
this._logger.verbose('Backed off before next connection attempt', { attempt, delayMs });
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
throw new Error('ChronicleClient was disposed during connection attempt.');
|
|
@@ -254,7 +254,7 @@ export class ChronicleClient {
|
|
|
254
254
|
let attempt = 0;
|
|
255
255
|
while (!this._isDisposed) {
|
|
256
256
|
try {
|
|
257
|
-
this._connection.resetChannel();
|
|
257
|
+
await this._connection.resetChannel();
|
|
258
258
|
await this._connection.server.getVersionInfo({}, { signal: AbortSignal.timeout(10_000) });
|
|
259
259
|
this._logger.info('Reconnected to Chronicle kernel', { attempt: attempt + 1 });
|
|
260
260
|
await this.startKernelKeepAlive();
|
|
@@ -267,13 +267,7 @@ export class ChronicleClient {
|
|
|
267
267
|
}
|
|
268
268
|
catch (reconnectError) {
|
|
269
269
|
attempt++;
|
|
270
|
-
|
|
271
|
-
this._logger.warn('Reconnect attempt failed, retrying', {
|
|
272
|
-
attempt,
|
|
273
|
-
delayMs,
|
|
274
|
-
error: this.toErrorMessage(reconnectError)
|
|
275
|
-
});
|
|
276
|
-
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
270
|
+
await this.backOff(attempt, 'Reconnect attempt failed, retrying', reconnectError);
|
|
277
271
|
}
|
|
278
272
|
}
|
|
279
273
|
})().finally(() => {
|
|
@@ -282,6 +276,22 @@ export class ChronicleClient {
|
|
|
282
276
|
}
|
|
283
277
|
await this._reconnectOperation;
|
|
284
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Waits out the exponential backoff for a failed attempt and reports why.
|
|
281
|
+
* The delay is jittered so a fleet of clients that lost the same kernel does
|
|
282
|
+
* not come back in lockstep and knock it over again.
|
|
283
|
+
*/
|
|
284
|
+
async backOff(attempt, message, error) {
|
|
285
|
+
const ceiling = Math.min(1000 * Math.pow(2, attempt - 1), ChronicleClient._maxBackoffMs);
|
|
286
|
+
const delayMs = Math.round(ceiling / 2 + Math.random() * (ceiling / 2));
|
|
287
|
+
this._logger.warn(message, {
|
|
288
|
+
attempt,
|
|
289
|
+
delayMs,
|
|
290
|
+
error: this.toErrorMessage(error)
|
|
291
|
+
});
|
|
292
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
293
|
+
return delayMs;
|
|
294
|
+
}
|
|
285
295
|
async withReconnect(operation, action) {
|
|
286
296
|
try {
|
|
287
297
|
return await action();
|
|
@@ -327,54 +337,51 @@ export class ChronicleClient {
|
|
|
327
337
|
this._watchdogHandle.unref?.();
|
|
328
338
|
}
|
|
329
339
|
async runHealthCheck() {
|
|
330
|
-
|
|
340
|
+
// A black-holed connection makes the probe itself hang, so without the
|
|
341
|
+
// in-flight guard every tick would stack another one that never returns.
|
|
342
|
+
if (this._isDisposed || !this._lifecycle.isConnected || this._healthCheckInFlight) {
|
|
331
343
|
return;
|
|
332
344
|
}
|
|
345
|
+
this._healthCheckInFlight = true;
|
|
333
346
|
try {
|
|
334
|
-
await this._connection.server.getVersionInfo({});
|
|
347
|
+
await this._connection.server.getVersionInfo({}, { signal: AbortSignal.timeout(10_000) });
|
|
335
348
|
this._logger.verbose('Connection health check passed');
|
|
336
349
|
}
|
|
337
350
|
catch (error) {
|
|
338
351
|
await this.reconnect('watchdog-health-check', error);
|
|
339
352
|
}
|
|
353
|
+
finally {
|
|
354
|
+
this._healthCheckInFlight = false;
|
|
355
|
+
}
|
|
340
356
|
}
|
|
341
357
|
async startKernelKeepAlive() {
|
|
342
358
|
this._keepAliveAbortController?.abort();
|
|
343
359
|
this._keepAliveAbortController = new AbortController();
|
|
344
360
|
const { signal } = this._keepAliveAbortController;
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
361
|
+
// Losing the keep-alive means the kernel has stopped counting us as
|
|
362
|
+
// connected, so observers are already being torn down server-side.
|
|
363
|
+
// Reconnecting is the only way back — without this the client sits on a
|
|
364
|
+
// dead session, appends keep working, and reactors never fire again.
|
|
365
|
+
const keepAlive = new KernelKeepAlive(this._connection.connections, (reason, error) => {
|
|
366
|
+
if (this._isDisposed || signal.aborted) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
void this.reconnect(reason, error);
|
|
370
|
+
});
|
|
371
|
+
await keepAlive.start({
|
|
372
|
+
ConnectionId: this._lifecycle.connectionId,
|
|
373
|
+
// TODO: Not derived from this package's own version anywhere yet; kept as
|
|
374
|
+
// the pre-existing hardcoded placeholder until such a mechanism exists.
|
|
375
|
+
ClientVersion: '1.0.0',
|
|
376
|
+
IsRunningWithDebugger: false,
|
|
377
|
+
ProcessId: process.pid,
|
|
378
|
+
ProcessPath: process.execPath,
|
|
379
|
+
MachineName: os.hostname(),
|
|
380
|
+
ClientType: 'TypeScript'
|
|
381
|
+
}, signal);
|
|
352
382
|
this._logger.info('Client registered with kernel keep-alive mechanism', {
|
|
353
383
|
connectionId: this._lifecycle.connectionId
|
|
354
384
|
});
|
|
355
|
-
void this.runKeepAliveLoop(iterator, signal);
|
|
356
|
-
}
|
|
357
|
-
async runKeepAliveLoop(iterator, signal) {
|
|
358
|
-
try {
|
|
359
|
-
while (!signal.aborted) {
|
|
360
|
-
const result = await iterator.next();
|
|
361
|
-
if (result.done) {
|
|
362
|
-
this._logger.info('Keep-alive stream ended');
|
|
363
|
-
break;
|
|
364
|
-
}
|
|
365
|
-
await this._connection.connections.connectionKeepAlive(result.value);
|
|
366
|
-
this._logger.verbose('Keep-alive ping responded', {
|
|
367
|
-
connectionId: this._lifecycle.connectionId
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
catch (err) {
|
|
372
|
-
if (!signal.aborted) {
|
|
373
|
-
this._logger.warn('Keep-alive loop ended with error', {
|
|
374
|
-
error: this.toErrorMessage(err)
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
385
|
}
|
|
379
386
|
async registerArtifactsForStore(store, reason) {
|
|
380
387
|
this._logger.debug('Registering artifacts for event store', {
|