@cratis/chronicle 2.0.0 → 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.
Files changed (96) hide show
  1. package/ChronicleClient.ts +69 -60
  2. package/connection/ChronicleConnection.ts +32 -9
  3. package/connection/ChronicleConnectionString.spec.ts +227 -0
  4. package/connection/ChronicleConnectionString.ts +290 -40
  5. package/connection/ChronicleServerAddressResolver.spec.ts +41 -0
  6. package/connection/ChronicleServerAddressResolver.ts +33 -0
  7. package/connection/ChronicleSrvResolutionError.ts +13 -0
  8. package/connection/ChronicleSrvResolver.spec.ts +103 -0
  9. package/connection/ChronicleSrvResolver.ts +57 -0
  10. package/connection/ConnectionLifecycle.spec.ts +109 -0
  11. package/connection/ILoadBalancerStrategy.ts +16 -0
  12. package/connection/KernelKeepAlive.spec.ts +175 -0
  13. package/connection/KernelKeepAlive.ts +161 -0
  14. package/connection/LeastConnectionsLoadBalancerStrategy.spec.ts +159 -0
  15. package/connection/LeastConnectionsLoadBalancerStrategy.ts +117 -0
  16. package/connection/LoadBalancerMode.ts +25 -0
  17. package/connection/LoadBalancerStrategyFactory.ts +28 -0
  18. package/connection/RandomLoadBalancerStrategy.spec.ts +50 -0
  19. package/connection/RandomLoadBalancerStrategy.ts +19 -0
  20. package/connection/RoundRobinLoadBalancerStrategy.spec.ts +61 -0
  21. package/connection/RoundRobinLoadBalancerStrategy.ts +29 -0
  22. package/connection/TokenProvider.ts +5 -4
  23. package/connection/formatServerAddress.ts +16 -0
  24. package/dist/ChronicleClient.d.ts +8 -1
  25. package/dist/ChronicleClient.d.ts.map +1 -1
  26. package/dist/ChronicleClient.js +63 -56
  27. package/dist/ChronicleClient.js.map +1 -1
  28. package/dist/connection/ChronicleConnection.d.ts +4 -1
  29. package/dist/connection/ChronicleConnection.d.ts.map +1 -1
  30. package/dist/connection/ChronicleConnection.js +29 -9
  31. package/dist/connection/ChronicleConnection.js.map +1 -1
  32. package/dist/connection/ChronicleConnectionString.d.ts +77 -3
  33. package/dist/connection/ChronicleConnectionString.d.ts.map +1 -1
  34. package/dist/connection/ChronicleConnectionString.js +251 -37
  35. package/dist/connection/ChronicleConnectionString.js.map +1 -1
  36. package/dist/connection/ChronicleServerAddressResolver.d.ts +23 -0
  37. package/dist/connection/ChronicleServerAddressResolver.d.ts.map +1 -0
  38. package/dist/connection/ChronicleServerAddressResolver.js +32 -0
  39. package/dist/connection/ChronicleServerAddressResolver.js.map +1 -0
  40. package/dist/connection/ChronicleSrvResolutionError.d.ts +8 -0
  41. package/dist/connection/ChronicleSrvResolutionError.d.ts.map +1 -0
  42. package/dist/connection/ChronicleSrvResolutionError.js +13 -0
  43. package/dist/connection/ChronicleSrvResolutionError.js.map +1 -0
  44. package/dist/connection/ChronicleSrvResolver.d.ts +28 -0
  45. package/dist/connection/ChronicleSrvResolver.d.ts.map +1 -0
  46. package/dist/connection/ChronicleSrvResolver.js +51 -0
  47. package/dist/connection/ChronicleSrvResolver.js.map +1 -0
  48. package/dist/connection/ILoadBalancerStrategy.d.ts +13 -0
  49. package/dist/connection/ILoadBalancerStrategy.d.ts.map +1 -0
  50. package/dist/connection/ILoadBalancerStrategy.js +4 -0
  51. package/dist/connection/ILoadBalancerStrategy.js.map +1 -0
  52. package/dist/connection/KernelKeepAlive.d.ts +73 -0
  53. package/dist/connection/KernelKeepAlive.d.ts.map +1 -0
  54. package/dist/connection/KernelKeepAlive.js +115 -0
  55. package/dist/connection/KernelKeepAlive.js.map +1 -0
  56. package/dist/connection/LeastConnectionsLoadBalancerStrategy.d.ts +30 -0
  57. package/dist/connection/LeastConnectionsLoadBalancerStrategy.d.ts.map +1 -0
  58. package/dist/connection/LeastConnectionsLoadBalancerStrategy.js +95 -0
  59. package/dist/connection/LeastConnectionsLoadBalancerStrategy.js.map +1 -0
  60. package/dist/connection/LoadBalancerMode.d.ts +21 -0
  61. package/dist/connection/LoadBalancerMode.d.ts.map +1 -0
  62. package/dist/connection/LoadBalancerMode.js +24 -0
  63. package/dist/connection/LoadBalancerMode.js.map +1 -0
  64. package/dist/connection/LoadBalancerStrategyFactory.d.ts +11 -0
  65. package/dist/connection/LoadBalancerStrategyFactory.d.ts.map +1 -0
  66. package/dist/connection/LoadBalancerStrategyFactory.js +26 -0
  67. package/dist/connection/LoadBalancerStrategyFactory.js.map +1 -0
  68. package/dist/connection/RandomLoadBalancerStrategy.d.ts +9 -0
  69. package/dist/connection/RandomLoadBalancerStrategy.d.ts.map +1 -0
  70. package/dist/connection/RandomLoadBalancerStrategy.js +15 -0
  71. package/dist/connection/RandomLoadBalancerStrategy.js.map +1 -0
  72. package/dist/connection/RoundRobinLoadBalancerStrategy.d.ts +12 -0
  73. package/dist/connection/RoundRobinLoadBalancerStrategy.d.ts.map +1 -0
  74. package/dist/connection/RoundRobinLoadBalancerStrategy.js +22 -0
  75. package/dist/connection/RoundRobinLoadBalancerStrategy.js.map +1 -0
  76. package/dist/connection/TokenProvider.d.ts +2 -1
  77. package/dist/connection/TokenProvider.d.ts.map +1 -1
  78. package/dist/connection/TokenProvider.js +6 -4
  79. package/dist/connection/TokenProvider.js.map +1 -1
  80. package/dist/connection/formatServerAddress.d.ts +10 -0
  81. package/dist/connection/formatServerAddress.d.ts.map +1 -0
  82. package/dist/connection/formatServerAddress.js +14 -0
  83. package/dist/connection/formatServerAddress.js.map +1 -0
  84. package/dist/reactors/Reactors.d.ts +13 -0
  85. package/dist/reactors/Reactors.d.ts.map +1 -1
  86. package/dist/reactors/Reactors.js +42 -4
  87. package/dist/reactors/Reactors.js.map +1 -1
  88. package/dist/reducers/Reducers.d.ts +14 -0
  89. package/dist/reducers/Reducers.d.ts.map +1 -1
  90. package/dist/reducers/Reducers.js +43 -8
  91. package/dist/reducers/Reducers.js.map +1 -1
  92. package/dist/tsconfig.tsbuildinfo +1 -1
  93. package/package.json +8 -5
  94. package/reactors/Reactors.ts +53 -4
  95. package/reducers/Reducers.ts +55 -8
  96. 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> {
@@ -98,9 +99,9 @@ export class OAuthTokenProvider implements ITokenProvider {
98
99
  'Content-Type': 'application/x-www-form-urlencoded',
99
100
  'Content-Length': Buffer.byteLength(body)
100
101
  },
101
- // Chronicle generates a self-signed certificate in development, so chain
102
- // validation is skipped, matching the gRPC channel's credentials.
103
- ...(isHttps ? { rejectUnauthorized: false } : {})
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 } : {})
104
105
  }, response => {
105
106
  let data = '';
106
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":"AAKA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAItD,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;IAsBxC,QAAQ,CAAC,OAAO,EAAE,gBAAgB;IArB9C,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,sBAAsB,CAAQ;IAEtD,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;IAEpD;;;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;YA8ChB,eAAe;YAkBf,SAAS;YAgDT,aAAa;IAa3B,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,uBAAuB;YAQjB,cAAc;YAad,oBAAoB;YAuBpB,gBAAgB;YAuBhB,yBAAyB;CAmB1C"}
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"}
@@ -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
- if (attempt > 0) {
192
- // Recreate the gRPC channel so we start from IDLE. A failed
193
- // probe can leave the channel in TRANSIENT_FAILURE, which gRPC
194
- // won't recover without a fresh channel. The contracts connect()
195
- // is also bypassed here it uses watchConnectivityState and
196
- // rejects as soon as the state changes to CONNECTING (not READY),
197
- // making it unreliable for initial connection establishment.
198
- this._connection.resetChannel();
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 = Math.min(1000 * Math.pow(2, attempt - 1), 30_000);
216
- this._logger.warn('Connection attempt failed, retrying', {
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
- const delayMs = Math.min(1000 * Math.pow(2, attempt - 1), 30_000);
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
- if (this._isDisposed || !this._lifecycle.isConnected) {
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
- const keepAliveStream = this._connection.connections.connect({ ConnectionId: this._lifecycle.connectionId, ClientVersion: '1.0.0', IsRunningWithDebugger: false }, { signal });
346
- const iterator = keepAliveStream[Symbol.asyncIterator]();
347
- const firstResult = await iterator.next();
348
- if (firstResult.done) {
349
- throw new Error('Connection service stream ended before sending first keep-alive');
350
- }
351
- await this._connection.connections.connectionKeepAlive(firstResult.value);
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', {