@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,57 @@
|
|
|
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 { Resolver } from 'dns/promises';
|
|
5
|
+
import type { ChronicleServerAddress } from './ChronicleConnectionString';
|
|
6
|
+
import { ChronicleSrvResolutionError } from './ChronicleSrvResolutionError';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_DNS_PORT = 53;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolves Chronicle server addresses from DNS SRV records for `chronicle+srv://`
|
|
12
|
+
* connection strings, querying `_chronicle._tcp.<host>` the same way the .NET reference
|
|
13
|
+
* client does. Every call performs a fresh DNS query - results are never cached here, so
|
|
14
|
+
* that reconnects always pick up membership changes.
|
|
15
|
+
*/
|
|
16
|
+
export class ChronicleSrvResolver {
|
|
17
|
+
/**
|
|
18
|
+
* Initializes a new instance of {@link ChronicleSrvResolver}.
|
|
19
|
+
* @param _createResolver - Factory creating the `dns.promises.Resolver` used for each
|
|
20
|
+
* resolution. Overridable for testing; defaults to a fresh `Resolver` per call.
|
|
21
|
+
*/
|
|
22
|
+
constructor(private readonly _createResolver: () => Resolver = () => new Resolver()) {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolves the SRV records for a `chronicle+srv://` host.
|
|
26
|
+
* @param host - The host portion of the `chronicle+srv://` connection string.
|
|
27
|
+
* @param nameServer - Optional `host[:port]` DNS name server to query instead of the
|
|
28
|
+
* system default. Defaults to port 53 when no port is given.
|
|
29
|
+
* @returns The resolved addresses, sorted ascending by priority and then descending by
|
|
30
|
+
* weight, per RFC 2782.
|
|
31
|
+
*/
|
|
32
|
+
async resolve(host: string, nameServer?: string): Promise<ChronicleServerAddress[]> {
|
|
33
|
+
const resolver = this._createResolver();
|
|
34
|
+
|
|
35
|
+
if (nameServer) {
|
|
36
|
+
resolver.setServers([this.withDefaultPort(nameServer)]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const name = `_chronicle._tcp.${host}`;
|
|
40
|
+
const records = await resolver.resolveSrv(name);
|
|
41
|
+
|
|
42
|
+
if (records.length === 0) {
|
|
43
|
+
throw new ChronicleSrvResolutionError(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return records
|
|
47
|
+
.slice()
|
|
48
|
+
.sort((first, second) => first.priority - second.priority || second.weight - first.weight)
|
|
49
|
+
// SRV targets are frequently returned as FQDNs with a trailing root '.'.
|
|
50
|
+
.map(record => ({ host: record.name.replace(/\.$/, ''), port: record.port }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private withDefaultPort(nameServer: string): string {
|
|
54
|
+
const colonIndex = nameServer.lastIndexOf(':');
|
|
55
|
+
return colonIndex === -1 ? `${nameServer}:${DEFAULT_DNS_PORT}` : nameServer;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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, vi } from 'vitest';
|
|
5
|
+
import { ConnectionLifecycle } from './ConnectionLifecycle';
|
|
6
|
+
|
|
7
|
+
const ignoreErrors = () => { };
|
|
8
|
+
|
|
9
|
+
describe('ConnectionLifecycle', () => {
|
|
10
|
+
describe('when connected', () => {
|
|
11
|
+
it('should report being connected', async () => {
|
|
12
|
+
const lifecycle = new ConnectionLifecycle();
|
|
13
|
+
|
|
14
|
+
await lifecycle.connected(ignoreErrors);
|
|
15
|
+
|
|
16
|
+
expect(lifecycle.isConnected).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should invoke every connected handler', async () => {
|
|
20
|
+
const lifecycle = new ConnectionLifecycle();
|
|
21
|
+
const first = vi.fn().mockResolvedValue(undefined);
|
|
22
|
+
const second = vi.fn().mockResolvedValue(undefined);
|
|
23
|
+
lifecycle.onConnected(first);
|
|
24
|
+
lifecycle.onConnected(second);
|
|
25
|
+
|
|
26
|
+
await lifecycle.connected(ignoreErrors);
|
|
27
|
+
|
|
28
|
+
expect(first).toHaveBeenCalledOnce();
|
|
29
|
+
expect(second).toHaveBeenCalledOnce();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should isolate a failing handler from the others', async () => {
|
|
33
|
+
const lifecycle = new ConnectionLifecycle();
|
|
34
|
+
const failing = vi.fn().mockRejectedValue(new Error('registration failed'));
|
|
35
|
+
const succeeding = vi.fn().mockResolvedValue(undefined);
|
|
36
|
+
const onError = vi.fn();
|
|
37
|
+
lifecycle.onConnected(failing);
|
|
38
|
+
lifecycle.onConnected(succeeding);
|
|
39
|
+
|
|
40
|
+
await lifecycle.connected(onError);
|
|
41
|
+
|
|
42
|
+
expect(succeeding).toHaveBeenCalledOnce();
|
|
43
|
+
expect(onError).toHaveBeenCalledOnce();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('when disconnected', () => {
|
|
48
|
+
it('should report not being connected', async () => {
|
|
49
|
+
const lifecycle = new ConnectionLifecycle();
|
|
50
|
+
await lifecycle.connected(ignoreErrors);
|
|
51
|
+
|
|
52
|
+
await lifecycle.disconnected(ignoreErrors);
|
|
53
|
+
|
|
54
|
+
expect(lifecycle.isConnected).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should rotate the connection id', async () => {
|
|
58
|
+
const lifecycle = new ConnectionLifecycle();
|
|
59
|
+
const before = lifecycle.connectionId;
|
|
60
|
+
|
|
61
|
+
await lifecycle.disconnected(ignoreErrors);
|
|
62
|
+
|
|
63
|
+
// The kernel keys observer subscriptions by connection id, so reusing an
|
|
64
|
+
// evicted id after a reconnect leaves the client registered as a ghost.
|
|
65
|
+
expect(lifecycle.connectionId).not.toBe(before);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should rotate the connection id only after the handlers have run', async () => {
|
|
69
|
+
const lifecycle = new ConnectionLifecycle();
|
|
70
|
+
const idDuringHandler = { value: '' };
|
|
71
|
+
lifecycle.onDisconnected(async () => {
|
|
72
|
+
idDuringHandler.value = lifecycle.connectionId;
|
|
73
|
+
});
|
|
74
|
+
const before = lifecycle.connectionId;
|
|
75
|
+
|
|
76
|
+
await lifecycle.disconnected(ignoreErrors);
|
|
77
|
+
|
|
78
|
+
// Handlers tear down subscriptions registered under the *old* id, so they
|
|
79
|
+
// must still see it while they run.
|
|
80
|
+
expect(idDuringHandler.value).toBe(before);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should invoke every disconnected handler', async () => {
|
|
84
|
+
const lifecycle = new ConnectionLifecycle();
|
|
85
|
+
const first = vi.fn().mockResolvedValue(undefined);
|
|
86
|
+
const second = vi.fn().mockResolvedValue(undefined);
|
|
87
|
+
lifecycle.onDisconnected(first);
|
|
88
|
+
lifecycle.onDisconnected(second);
|
|
89
|
+
|
|
90
|
+
await lifecycle.disconnected(ignoreErrors);
|
|
91
|
+
|
|
92
|
+
expect(first).toHaveBeenCalledOnce();
|
|
93
|
+
expect(second).toHaveBeenCalledOnce();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('when a handler is unsubscribed', () => {
|
|
98
|
+
it('should not invoke it again', async () => {
|
|
99
|
+
const lifecycle = new ConnectionLifecycle();
|
|
100
|
+
const handler = vi.fn().mockResolvedValue(undefined);
|
|
101
|
+
const unsubscribe = lifecycle.onConnected(handler);
|
|
102
|
+
|
|
103
|
+
unsubscribe();
|
|
104
|
+
await lifecycle.connected(ignoreErrors);
|
|
105
|
+
|
|
106
|
+
expect(handler).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -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
|
+
* Selects one server address from a list of candidates for a connect/reconnect attempt.
|
|
8
|
+
*/
|
|
9
|
+
export interface ILoadBalancerStrategy {
|
|
10
|
+
/**
|
|
11
|
+
* Selects one address from the given candidates.
|
|
12
|
+
* @param candidates - The addresses to select from. Never empty.
|
|
13
|
+
* @returns A promise resolving to the selected address.
|
|
14
|
+
*/
|
|
15
|
+
select(candidates: ChronicleServerAddress[]): Promise<ChronicleServerAddress>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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, vi } from 'vitest';
|
|
5
|
+
import type { ConnectRequest } from '@cratis/chronicle.contracts';
|
|
6
|
+
import { KernelKeepAlive, type IKeepAliveConnections } from './KernelKeepAlive';
|
|
7
|
+
|
|
8
|
+
const request = { ConnectionId: 'connection-1' } as unknown as ConnectRequest;
|
|
9
|
+
|
|
10
|
+
const timeouts = { idleTimeoutMs: 50, answerTimeoutMs: 50 };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A `Connect` stream the spec drives by hand — push keep-alives, end it, or simply
|
|
14
|
+
* leave it silent to model the kernel evicting a client without closing the stream.
|
|
15
|
+
*/
|
|
16
|
+
function createStream() {
|
|
17
|
+
const pending: Array<(result: IteratorResult<unknown>) => void> = [];
|
|
18
|
+
const queued: Array<IteratorResult<unknown>> = [];
|
|
19
|
+
|
|
20
|
+
const push = (result: IteratorResult<unknown>) => {
|
|
21
|
+
const waiter = pending.shift();
|
|
22
|
+
if (waiter) {
|
|
23
|
+
waiter(result);
|
|
24
|
+
} else {
|
|
25
|
+
queued.push(result);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
sendKeepAlive: () => push({ done: false, value: { ConnectionId: 'connection-1' } }),
|
|
31
|
+
end: () => push({ done: true, value: undefined }),
|
|
32
|
+
asIterable: (): AsyncIterable<unknown> => ({
|
|
33
|
+
[Symbol.asyncIterator]: () => ({
|
|
34
|
+
next: () => {
|
|
35
|
+
const ready = queued.shift();
|
|
36
|
+
if (ready) {
|
|
37
|
+
return Promise.resolve(ready);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return new Promise<IteratorResult<unknown>>(resolve => pending.push(resolve));
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createConnections(stream: AsyncIterable<unknown>, answer = vi.fn().mockResolvedValue({})) {
|
|
48
|
+
return {
|
|
49
|
+
connections: { connect: () => stream, connectionKeepAlive: answer } as unknown as IKeepAliveConnections,
|
|
50
|
+
answer
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const flush = () => new Promise(resolve => setTimeout(resolve, 0));
|
|
55
|
+
|
|
56
|
+
describe('KernelKeepAlive', () => {
|
|
57
|
+
describe('when the kernel sends keep-alives', () => {
|
|
58
|
+
it('should answer every one of them', async () => {
|
|
59
|
+
const stream = createStream();
|
|
60
|
+
const { connections, answer } = createConnections(stream.asIterable());
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
|
|
63
|
+
stream.sendKeepAlive();
|
|
64
|
+
await new KernelKeepAlive(connections, vi.fn(), timeouts).start(request, controller.signal);
|
|
65
|
+
|
|
66
|
+
stream.sendKeepAlive();
|
|
67
|
+
await flush();
|
|
68
|
+
stream.sendKeepAlive();
|
|
69
|
+
await flush();
|
|
70
|
+
|
|
71
|
+
controller.abort();
|
|
72
|
+
|
|
73
|
+
// Each answer is what bumps LastSeen on the kernel; missing them gets the
|
|
74
|
+
// client evicted while the stream stays open.
|
|
75
|
+
expect(answer).toHaveBeenCalledTimes(3);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should not report the connection as lost', async () => {
|
|
79
|
+
const stream = createStream();
|
|
80
|
+
const { connections } = createConnections(stream.asIterable());
|
|
81
|
+
const onConnectionLost = vi.fn();
|
|
82
|
+
const controller = new AbortController();
|
|
83
|
+
|
|
84
|
+
stream.sendKeepAlive();
|
|
85
|
+
await new KernelKeepAlive(connections, onConnectionLost, timeouts).start(request, controller.signal);
|
|
86
|
+
|
|
87
|
+
stream.sendKeepAlive();
|
|
88
|
+
await flush();
|
|
89
|
+
controller.abort();
|
|
90
|
+
|
|
91
|
+
expect(onConnectionLost).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('when the stream goes silent', () => {
|
|
96
|
+
it('should report the connection as lost', async () => {
|
|
97
|
+
const stream = createStream();
|
|
98
|
+
const { connections } = createConnections(stream.asIterable());
|
|
99
|
+
const onConnectionLost = vi.fn();
|
|
100
|
+
|
|
101
|
+
stream.sendKeepAlive();
|
|
102
|
+
await new KernelKeepAlive(connections, onConnectionLost, timeouts).start(request, new AbortController().signal);
|
|
103
|
+
|
|
104
|
+
// The kernel does not close the stream when its watchdog evicts a client,
|
|
105
|
+
// so silence — not an error — is what a half-disconnect looks like.
|
|
106
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
107
|
+
|
|
108
|
+
expect(onConnectionLost).toHaveBeenCalledOnce();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('when the stream ends', () => {
|
|
113
|
+
it('should report the connection as lost', async () => {
|
|
114
|
+
const stream = createStream();
|
|
115
|
+
const { connections } = createConnections(stream.asIterable());
|
|
116
|
+
const onConnectionLost = vi.fn();
|
|
117
|
+
|
|
118
|
+
stream.sendKeepAlive();
|
|
119
|
+
await new KernelKeepAlive(connections, onConnectionLost, timeouts).start(request, new AbortController().signal);
|
|
120
|
+
|
|
121
|
+
stream.end();
|
|
122
|
+
await flush();
|
|
123
|
+
|
|
124
|
+
expect(onConnectionLost).toHaveBeenCalledWith('keep-alive-stream-ended', expect.anything());
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('when answering a keep-alive fails', () => {
|
|
129
|
+
it('should report the connection as lost', async () => {
|
|
130
|
+
const stream = createStream();
|
|
131
|
+
const answer = vi.fn().mockResolvedValueOnce({}).mockRejectedValue(new Error('unavailable'));
|
|
132
|
+
const { connections } = createConnections(stream.asIterable(), answer);
|
|
133
|
+
const onConnectionLost = vi.fn();
|
|
134
|
+
|
|
135
|
+
stream.sendKeepAlive();
|
|
136
|
+
await new KernelKeepAlive(connections, onConnectionLost, timeouts).start(request, new AbortController().signal);
|
|
137
|
+
|
|
138
|
+
stream.sendKeepAlive();
|
|
139
|
+
await flush();
|
|
140
|
+
|
|
141
|
+
expect(onConnectionLost).toHaveBeenCalledWith('keep-alive-failed', expect.anything());
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('when the kernel never sends a first keep-alive', () => {
|
|
146
|
+
it('should fail to start rather than hang', async () => {
|
|
147
|
+
const stream = createStream();
|
|
148
|
+
const { connections } = createConnections(stream.asIterable());
|
|
149
|
+
|
|
150
|
+
// Every caller waiting on the connection queues behind this, so it must
|
|
151
|
+
// never be allowed to wait forever.
|
|
152
|
+
await expect(
|
|
153
|
+
new KernelKeepAlive(connections, vi.fn(), timeouts).start(request, new AbortController().signal)
|
|
154
|
+
).rejects.toThrow(/Timed out waiting for the first keep-alive/);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('when the keep-alive has been aborted', () => {
|
|
159
|
+
it('should not report the connection as lost', async () => {
|
|
160
|
+
const stream = createStream();
|
|
161
|
+
const { connections } = createConnections(stream.asIterable());
|
|
162
|
+
const onConnectionLost = vi.fn();
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
|
|
165
|
+
stream.sendKeepAlive();
|
|
166
|
+
await new KernelKeepAlive(connections, onConnectionLost, timeouts).start(request, controller.signal);
|
|
167
|
+
|
|
168
|
+
controller.abort();
|
|
169
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
170
|
+
|
|
171
|
+
// A deliberate shutdown is not a lost connection and must not trigger a reconnect.
|
|
172
|
+
expect(onConnectionLost).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
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 { diag } from '@opentelemetry/api';
|
|
5
|
+
import type { ConnectRequest } from '@cratis/chronicle.contracts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Overrides for how long {@link KernelKeepAlive} waits before giving up on the kernel.
|
|
9
|
+
*/
|
|
10
|
+
export interface KeepAliveTimeouts {
|
|
11
|
+
/** How long to wait for a keep-alive before treating the connection as dead. */
|
|
12
|
+
idleTimeoutMs?: number;
|
|
13
|
+
/** How long a single answer may take before the connection is considered dead. */
|
|
14
|
+
answerTimeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The subset of the connection service {@link KernelKeepAlive} needs, so it can be
|
|
19
|
+
* exercised without a live kernel.
|
|
20
|
+
*/
|
|
21
|
+
export interface IKeepAliveConnections {
|
|
22
|
+
connect(request: ConnectRequest, options?: { signal?: AbortSignal }): AsyncIterable<unknown>;
|
|
23
|
+
connectionKeepAlive(request: object, options?: { signal?: AbortSignal }): Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Drives the client half of Chronicle's keep-alive ping-pong.
|
|
28
|
+
*
|
|
29
|
+
* The kernel pushes a `ConnectionKeepAlive` down the `Connect` server stream once
|
|
30
|
+
* per second, and for each one the client must call the separate unary
|
|
31
|
+
* `ConnectionKeepAlive` RPC back — that is what bumps `LastSeen` on the kernel and
|
|
32
|
+
* keeps the client registered. A client that stops answering is evicted, its
|
|
33
|
+
* observers are unsubscribed, and reactors and reducers go silent while appends
|
|
34
|
+
* keep working.
|
|
35
|
+
*
|
|
36
|
+
* Eviction does not close the `Connect` stream, so a dead session usually presents
|
|
37
|
+
* as a stream that simply goes quiet rather than one that errors. Both a gap
|
|
38
|
+
* between keep-alives and an ended stream therefore mean the same thing: the
|
|
39
|
+
* connection is gone and the client must reconnect.
|
|
40
|
+
*/
|
|
41
|
+
export class KernelKeepAlive {
|
|
42
|
+
/**
|
|
43
|
+
* How long to wait for a keep-alive before treating the connection as dead.
|
|
44
|
+
* The kernel emits one per second and evicts clients whose `LastSeen` falls
|
|
45
|
+
* more than five seconds behind, which is the threshold the C# client uses too.
|
|
46
|
+
*/
|
|
47
|
+
static readonly defaultIdleTimeoutMs = 5000;
|
|
48
|
+
|
|
49
|
+
/** How long a single answer may take before the connection is considered dead. */
|
|
50
|
+
static readonly defaultAnswerTimeoutMs = 5000;
|
|
51
|
+
|
|
52
|
+
private readonly _logger = diag.createComponentLogger({
|
|
53
|
+
namespace: '@cratis/chronicle/KernelKeepAlive'
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
private readonly _idleTimeoutMs: number;
|
|
57
|
+
private readonly _answerTimeoutMs: number;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Creates a new {@link KernelKeepAlive}.
|
|
61
|
+
* @param connections - The connection service to ping-pong with.
|
|
62
|
+
* @param onConnectionLost - Called once when the connection is found to be dead.
|
|
63
|
+
* @param timeouts - Overrides for the default timeouts.
|
|
64
|
+
*/
|
|
65
|
+
constructor(
|
|
66
|
+
private readonly connections: IKeepAliveConnections,
|
|
67
|
+
private readonly onConnectionLost: (reason: string, error: unknown) => void,
|
|
68
|
+
timeouts: KeepAliveTimeouts = {}
|
|
69
|
+
) {
|
|
70
|
+
this._idleTimeoutMs = timeouts.idleTimeoutMs ?? KernelKeepAlive.defaultIdleTimeoutMs;
|
|
71
|
+
this._answerTimeoutMs = timeouts.answerTimeoutMs ?? KernelKeepAlive.defaultAnswerTimeoutMs;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Opens the `Connect` stream and answers the first keep-alive, establishing the
|
|
76
|
+
* client's registration with the kernel. Resolves once the kernel has been heard
|
|
77
|
+
* from; the ongoing ping-pong then continues in the background until the
|
|
78
|
+
* connection is lost or `signal` is aborted.
|
|
79
|
+
* @param request - The connect request identifying this client.
|
|
80
|
+
* @param signal - Aborted to stop the keep-alive entirely.
|
|
81
|
+
*/
|
|
82
|
+
async start(request: ConnectRequest, signal: AbortSignal): Promise<void> {
|
|
83
|
+
const stream = this.connections.connect(request, { signal });
|
|
84
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
85
|
+
|
|
86
|
+
// A kernel that accepts the stream but never sends anything must not hang
|
|
87
|
+
// the connect indefinitely — every caller awaiting the connection would
|
|
88
|
+
// block forever behind it.
|
|
89
|
+
const first = await this.withTimeout(
|
|
90
|
+
iterator.next(),
|
|
91
|
+
this._idleTimeoutMs,
|
|
92
|
+
'Timed out waiting for the first keep-alive from the kernel'
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (first.done) {
|
|
96
|
+
throw new Error('Connection service stream ended before sending first keep-alive');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await this.answer(first.value, signal);
|
|
100
|
+
void this.run(iterator, signal);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async run(iterator: AsyncIterator<unknown, unknown>, signal: AbortSignal): Promise<void> {
|
|
104
|
+
try {
|
|
105
|
+
while (!signal.aborted) {
|
|
106
|
+
const result = await this.withTimeout(
|
|
107
|
+
iterator.next(),
|
|
108
|
+
this._idleTimeoutMs,
|
|
109
|
+
'No keep-alive received from the kernel within the idle timeout'
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (result.done) {
|
|
113
|
+
this.connectionLost('keep-alive-stream-ended', new Error('Keep-alive stream ended'));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await this.answer(result.value, signal);
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (!signal.aborted) {
|
|
121
|
+
this.connectionLost('keep-alive-failed', error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async answer(keepAlive: unknown, signal: AbortSignal): Promise<void> {
|
|
127
|
+
// An answer that never returns would stop the loop draining the stream, which
|
|
128
|
+
// gets the client evicted just as surely as never answering at all.
|
|
129
|
+
await this.withTimeout(
|
|
130
|
+
this.connections.connectionKeepAlive(keepAlive as object, { signal }),
|
|
131
|
+
this._answerTimeoutMs,
|
|
132
|
+
'Timed out answering a kernel keep-alive'
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private connectionLost(reason: string, error: unknown): void {
|
|
137
|
+
this._logger.warn('Kernel keep-alive lost the connection', {
|
|
138
|
+
reason,
|
|
139
|
+
error: error instanceof Error ? error.message : String(error)
|
|
140
|
+
});
|
|
141
|
+
this.onConnectionLost(reason, error);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
|
145
|
+
let handle: ReturnType<typeof setTimeout> | undefined;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
return await Promise.race([
|
|
149
|
+
promise,
|
|
150
|
+
new Promise<never>((_, reject) => {
|
|
151
|
+
handle = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
152
|
+
handle.unref?.();
|
|
153
|
+
})
|
|
154
|
+
]);
|
|
155
|
+
} finally {
|
|
156
|
+
if (handle) {
|
|
157
|
+
clearTimeout(handle);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -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, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import type { ChronicleServerAddress } from './ChronicleConnectionString';
|
|
6
|
+
import { LeastConnectionsLoadBalancerStrategy } from './LeastConnectionsLoadBalancerStrategy';
|
|
7
|
+
|
|
8
|
+
function textResponse(body: string, ok = true): Response {
|
|
9
|
+
return { ok, text: () => Promise.resolve(body) } as Response;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('LeastConnectionsLoadBalancerStrategy', () => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.unstubAllGlobals();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('when selecting from an empty candidate list', () => {
|
|
18
|
+
const strategy = new LeastConnectionsLoadBalancerStrategy(false, 0);
|
|
19
|
+
|
|
20
|
+
it('should throw', async () => {
|
|
21
|
+
await expect(strategy.select([])).rejects.toThrow();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('when selecting from a single candidate', () => {
|
|
26
|
+
const fetchMock = vi.fn();
|
|
27
|
+
const strategy = new LeastConnectionsLoadBalancerStrategy(false, 0);
|
|
28
|
+
const candidate: ChronicleServerAddress = { host: 'host1', port: 35000 };
|
|
29
|
+
|
|
30
|
+
beforeEach(() => vi.stubGlobal('fetch', fetchMock));
|
|
31
|
+
|
|
32
|
+
it('should return it immediately without probing', async () => {
|
|
33
|
+
const selected = await strategy.select([candidate]);
|
|
34
|
+
expect(selected).toEqual(candidate);
|
|
35
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('when candidates report different connection counts', () => {
|
|
40
|
+
const fetchMock = vi.fn((url: string, options?: { method?: string }) => {
|
|
41
|
+
if (options?.method === 'POST') {
|
|
42
|
+
return Promise.resolve(textResponse(''));
|
|
43
|
+
}
|
|
44
|
+
if (url.includes('host1')) {
|
|
45
|
+
return Promise.resolve(textResponse('5'));
|
|
46
|
+
}
|
|
47
|
+
if (url.includes('host2')) {
|
|
48
|
+
return Promise.resolve(textResponse('1'));
|
|
49
|
+
}
|
|
50
|
+
return Promise.resolve(textResponse('9'));
|
|
51
|
+
});
|
|
52
|
+
const strategy = new LeastConnectionsLoadBalancerStrategy(false, 0);
|
|
53
|
+
const candidates: ChronicleServerAddress[] = [
|
|
54
|
+
{ host: 'host1', port: 35000 },
|
|
55
|
+
{ host: 'host2', port: 35000 },
|
|
56
|
+
{ host: 'host3', port: 35000 }
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
beforeEach(() => vi.stubGlobal('fetch', fetchMock));
|
|
60
|
+
|
|
61
|
+
it('should select the candidate reporting the lowest count', async () => {
|
|
62
|
+
const selected = await strategy.select(candidates);
|
|
63
|
+
expect(selected).toEqual({ host: 'host2', port: 35000 });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should probe every candidate on the /connections/count route', async () => {
|
|
67
|
+
await strategy.select(candidates);
|
|
68
|
+
for (const candidate of candidates) {
|
|
69
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
70
|
+
`https://${candidate.host}:${candidate.port}/connections/count`,
|
|
71
|
+
expect.anything()
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should best-effort reserve a slot on the selected candidate', async () => {
|
|
77
|
+
await strategy.select(candidates);
|
|
78
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
79
|
+
'https://host2:35000/connections/reserve',
|
|
80
|
+
expect.objectContaining({ method: 'POST' })
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('when a candidate fails to respond', () => {
|
|
86
|
+
const fetchMock = vi.fn((url: string, options?: { method?: string }) => {
|
|
87
|
+
if (options?.method === 'POST') {
|
|
88
|
+
return Promise.resolve(textResponse(''));
|
|
89
|
+
}
|
|
90
|
+
if (url.includes('host1')) {
|
|
91
|
+
return Promise.reject(new Error('connection refused'));
|
|
92
|
+
}
|
|
93
|
+
return Promise.resolve(textResponse('3'));
|
|
94
|
+
});
|
|
95
|
+
const strategy = new LeastConnectionsLoadBalancerStrategy(false, 0);
|
|
96
|
+
const candidates: ChronicleServerAddress[] = [{ host: 'host1', port: 35000 }, { host: 'host2', port: 35000 }];
|
|
97
|
+
|
|
98
|
+
beforeEach(() => vi.stubGlobal('fetch', fetchMock));
|
|
99
|
+
|
|
100
|
+
it('should not fail the whole selection round', async () => {
|
|
101
|
+
await expect(strategy.select(candidates)).resolves.toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should never select the unreachable candidate over a responder', async () => {
|
|
105
|
+
const selected = await strategy.select(candidates);
|
|
106
|
+
expect(selected).toEqual({ host: 'host2', port: 35000 });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('when a candidate responds with a non-2xx status or an unparsable body', () => {
|
|
111
|
+
const fetchMock = vi.fn((url: string, options?: { method?: string }) => {
|
|
112
|
+
if (options?.method === 'POST') {
|
|
113
|
+
return Promise.resolve(textResponse(''));
|
|
114
|
+
}
|
|
115
|
+
if (url.includes('host1')) {
|
|
116
|
+
return Promise.resolve(textResponse('not-a-number'));
|
|
117
|
+
}
|
|
118
|
+
if (url.includes('host2')) {
|
|
119
|
+
return Promise.resolve(textResponse('irrelevant', false));
|
|
120
|
+
}
|
|
121
|
+
return Promise.resolve(textResponse('7'));
|
|
122
|
+
});
|
|
123
|
+
const strategy = new LeastConnectionsLoadBalancerStrategy(false, 0);
|
|
124
|
+
const candidates: ChronicleServerAddress[] = [
|
|
125
|
+
{ host: 'host1', port: 35000 },
|
|
126
|
+
{ host: 'host2', port: 35000 },
|
|
127
|
+
{ host: 'host3', port: 35000 }
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
beforeEach(() => vi.stubGlobal('fetch', fetchMock));
|
|
131
|
+
|
|
132
|
+
it('should treat both as maximally loaded and select the real responder', async () => {
|
|
133
|
+
const selected = await strategy.select(candidates);
|
|
134
|
+
expect(selected).toEqual({ host: 'host3', port: 35000 });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('when every candidate reports the same count', () => {
|
|
139
|
+
const fetchMock = vi.fn((_url: string, options?: { method?: string }) =>
|
|
140
|
+
Promise.resolve(textResponse(options?.method === 'POST' ? '' : '2')));
|
|
141
|
+
const strategy = new LeastConnectionsLoadBalancerStrategy(false, 0);
|
|
142
|
+
const candidates: ChronicleServerAddress[] = [
|
|
143
|
+
{ host: 'host1', port: 35000 },
|
|
144
|
+
{ host: 'host2', port: 35000 },
|
|
145
|
+
{ host: 'host3', port: 35000 }
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
beforeEach(() => vi.stubGlobal('fetch', fetchMock));
|
|
149
|
+
|
|
150
|
+
it('should break the tie randomly rather than always picking the first', async () => {
|
|
151
|
+
const selections = new Set<string>();
|
|
152
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
153
|
+
const selected = await strategy.select(candidates);
|
|
154
|
+
selections.add(`${selected.host}:${selected.port}`);
|
|
155
|
+
}
|
|
156
|
+
expect(selections.size).toBeGreaterThan(1);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|