apache-iggy 0.10.0 → 0.10.1-edge.2
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/README.md +248 -28
- package/dist/client/client.config.d.ts +2 -0
- package/dist/client/client.config.js +13 -2
- package/dist/client/client.config.test.js +15 -2
- package/dist/client/client.connection-string.js +13 -1
- package/dist/client/client.connection-string.test.js +43 -1
- package/dist/client/client.connection.d.ts +33 -135
- package/dist/client/client.connection.js +190 -453
- package/dist/client/client.connection.test.js +244 -669
- package/dist/client/client.d.ts +4 -35
- package/dist/client/client.js +8 -101
- package/dist/client/client.operation.d.ts +120 -0
- package/dist/client/client.operation.js +428 -0
- package/dist/client/client.operation.test.d.ts +2 -0
- package/dist/client/client.operation.test.js +285 -0
- package/dist/client/client.socket.d.ts +92 -209
- package/dist/client/client.socket.js +1205 -981
- package/dist/client/client.socket.test.js +4865 -679
- package/dist/client/client.type.d.ts +65 -22
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client.e2e.js +3 -5
- package/dist/e2e/resource-options.e2e.d.ts +2 -0
- package/dist/{wire/stream/purge-stream.command.js → e2e/resource-options.e2e.js} +12 -20
- package/dist/e2e/resource-options.suite.d.ts +15 -0
- package/dist/e2e/resource-options.suite.js +272 -0
- package/dist/e2e/tcp.cluster.e2e.js +1 -0
- package/dist/e2e/tcp.consumer-stream.e2e.js +4 -0
- package/dist/e2e/tcp.parallel.e2e.js +5 -6
- package/dist/e2e/tcp.stream.e2e.js +0 -3
- package/dist/e2e/tcp.system.e2e.js +10 -1
- package/dist/e2e/tcp.token.e2e.js +8 -2
- package/dist/e2e/tcp.topic.e2e.js +0 -3
- package/dist/e2e/tcp.user.e2e.js +46 -3
- package/dist/e2e/tls.system.e2e.js +21 -12
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/stream/consumer-stream.js +97 -12
- package/dist/wire/cluster/cluster.type.d.ts +2 -2
- package/dist/wire/cluster/cluster.utils.d.ts +1 -0
- package/dist/wire/cluster/cluster.utils.js +4 -1
- package/dist/wire/command-set.d.ts +8 -8
- package/dist/wire/command-set.js +18 -9
- package/dist/wire/command.code.d.ts +3 -10
- package/dist/wire/command.code.js +9 -11
- package/dist/wire/command.utils.d.ts +3 -2
- package/dist/wire/command.utils.js +3 -2
- package/dist/wire/error.code.js +17 -1
- package/dist/wire/error.code.test.js +28 -1
- package/dist/wire/error.utils.d.ts +4 -2
- package/dist/wire/error.utils.js +6 -3
- package/dist/wire/identifier.utils.d.ts +2 -0
- package/dist/wire/identifier.utils.js +2 -0
- package/dist/wire/index.d.ts +1 -0
- package/dist/wire/index.js +1 -0
- package/dist/wire/message/header.type.d.ts +1 -1
- package/dist/wire/message/message-batch.test.js +11 -5
- package/dist/wire/message/message.utils.d.ts +0 -10
- package/dist/wire/message/message.utils.js +1 -22
- package/dist/wire/message/poll-messages.command.d.ts +12 -1
- package/dist/wire/message/poll-messages.command.js +64 -16
- package/dist/wire/message/poll-messages.command.test.js +143 -5
- package/dist/wire/message/poll.utils.d.ts +4 -0
- package/dist/wire/message/poll.utils.js +6 -1
- package/dist/wire/message/send-messages.command.d.ts +3 -3
- package/dist/wire/message/send-messages.command.js +103 -3
- package/dist/wire/message/send-messages.command.test.js +113 -2
- package/dist/wire/offset/delete-offset.command.d.ts +11 -4
- package/dist/wire/offset/delete-offset.command.js +1 -1
- package/dist/wire/offset/get-offset.command.d.ts +1 -1
- package/dist/wire/offset/offset-write.command.test.d.ts +2 -0
- package/dist/wire/offset/offset-write.command.test.js +43 -0
- package/dist/wire/offset/offset.utils.js +10 -6
- package/dist/wire/offset/store-offset.command.d.ts +10 -0
- package/dist/wire/offset/store-offset.command.js +1 -1
- package/dist/wire/options.utils.d.ts +46 -0
- package/dist/wire/options.utils.js +57 -0
- package/dist/wire/partition/partition-route.d.ts +12 -0
- package/dist/wire/partition/partition-route.js +40 -0
- package/dist/wire/partition/partition-route.test.d.ts +2 -0
- package/dist/wire/partition/partition-route.test.js +42 -0
- package/dist/wire/session/login-with-token.command.d.ts +2 -2
- package/dist/wire/session/login-with-token.command.js +7 -2
- package/dist/wire/session/login.command.d.ts +2 -2
- package/dist/wire/session/login.command.js +7 -2
- package/dist/wire/stream/create-stream.command.d.ts +5 -2
- package/dist/wire/stream/create-stream.command.js +20 -2
- package/dist/wire/stream/index.d.ts +1 -1
- package/dist/wire/stream/index.js +1 -1
- package/dist/wire/stream/stream-options.d.ts +77 -0
- package/dist/wire/stream/stream-options.js +101 -0
- package/dist/wire/stream/stream-options.test.d.ts +2 -0
- package/dist/wire/stream/stream-options.test.js +103 -0
- package/dist/wire/stream/stream.utils.d.ts +9 -0
- package/dist/wire/stream/stream.utils.js +17 -3
- package/dist/wire/stream/update-stream.command.d.ts +23 -5
- package/dist/wire/stream/update-stream.command.js +10 -17
- package/dist/wire/system/describe-options.command.d.ts +23 -6
- package/dist/wire/system/describe-options.command.js +21 -2
- package/dist/wire/system/get-stats.command.d.ts +17 -0
- package/dist/wire/system/get-stats.command.js +69 -16
- package/dist/wire/system/get-stats.command.test.d.ts +2 -0
- package/dist/wire/system/get-stats.command.test.js +97 -0
- package/dist/wire/system/index.d.ts +2 -0
- package/dist/wire/system/index.js +2 -0
- package/dist/wire/system/snapshot.command.d.ts +64 -0
- package/dist/wire/system/snapshot.command.js +76 -0
- package/dist/wire/system/snapshot.command.test.d.ts +2 -0
- package/dist/wire/system/snapshot.command.test.js +86 -0
- package/dist/wire/system/update-resource.command.d.ts +53 -0
- package/dist/wire/system/update-resource.command.js +130 -0
- package/dist/wire/system/update-resource.command.test.d.ts +2 -0
- package/dist/wire/system/update-resource.command.test.js +497 -0
- package/dist/wire/token/create-token.command.d.ts +1 -1
- package/dist/wire/token/create-token.command.js +1 -1
- package/dist/wire/token/create-token.command.test.js +6 -0
- package/dist/wire/topic/create-topic.command.d.ts +20 -12
- package/dist/wire/topic/create-topic.command.js +11 -4
- package/dist/wire/topic/index.d.ts +1 -2
- package/dist/wire/topic/index.js +1 -2
- package/dist/wire/topic/topic.utils.d.ts +14 -5
- package/dist/wire/topic/topic.utils.js +26 -11
- package/dist/wire/topic/topic.utils.test.js +13 -13
- package/dist/wire/topic/update-topic.command.d.ts +22 -15
- package/dist/wire/topic/update-topic.command.js +12 -34
- package/dist/wire/user/create-user.command.d.ts +12 -2
- package/dist/wire/user/create-user.command.js +7 -3
- package/dist/wire/user/index.d.ts +1 -0
- package/dist/wire/user/index.js +1 -0
- package/dist/wire/user/update-user.command.d.ts +25 -7
- package/dist/wire/user/update-user.command.js +18 -31
- package/dist/wire/user/user.utils.d.ts +2 -0
- package/dist/wire/user/user.utils.js +5 -3
- package/dist/wire/vsr/header.d.ts +23 -8
- package/dist/wire/vsr/header.js +32 -10
- package/dist/wire/vsr/header.test.js +5 -1
- package/dist/wire/vsr/index.d.ts +8 -2
- package/dist/wire/vsr/index.js +44 -11
- package/dist/wire/vsr/operation.d.ts +18 -6
- package/dist/wire/vsr/operation.js +46 -16
- package/dist/wire/vsr/operation.test.js +32 -8
- package/dist/wire/vsr/register.d.ts +10 -6
- package/dist/wire/vsr/register.js +24 -7
- package/dist/wire/vsr/register.test.js +28 -8
- package/dist/wire/vsr/reply.d.ts +4 -1
- package/dist/wire/vsr/reply.js +42 -7
- package/dist/wire/vsr/reply.test.js +11 -1
- package/dist/wire/vsr/session.d.ts +9 -10
- package/dist/wire/vsr/session.js +32 -23
- package/dist/wire/vsr/session.test.js +33 -7
- package/dist/wire/vsr/vsr.test.js +57 -6
- package/package.json +6 -7
- package/dist/wire/message/flush-unsaved-buffers.command.d.ts +0 -26
- package/dist/wire/message/flush-unsaved-buffers.command.js +0 -36
- package/dist/wire/stream/purge-stream.command.d.ts +0 -22
- package/dist/wire/topic/purge-topic.command.d.ts +0 -24
- package/dist/wire/topic/purge-topic.command.js +0 -39
|
@@ -21,7 +21,8 @@ import { readFileSync } from 'node:fs';
|
|
|
21
21
|
import { debug } from './client.debug.js';
|
|
22
22
|
import { DEFAULT_MAX_RESPONSE_FRAME_SIZE } from './client.config.js';
|
|
23
23
|
import { ProtocolFrameError, ResponseFrameDecoder } from './client.frame.js';
|
|
24
|
-
import { Command, peekCommand } from '../wire/vsr/header.js';
|
|
24
|
+
import { Command, EvictionReason, peekCommand, readEviction, readStatus, REQUEST_OFFSET, REPLY_OFFSET } from '../wire/vsr/header.js';
|
|
25
|
+
import { ChangeSignal, PermitPool, OPERATION_TIMEOUT_MS, VsrResponseTimeoutError, waitWithin } from './client.operation.js';
|
|
25
26
|
import { evictionError } from '../wire/vsr/reply.js';
|
|
26
27
|
/**
|
|
27
28
|
* Creates a TCP socket connection.
|
|
@@ -75,506 +76,242 @@ const getTransport = (config) => {
|
|
|
75
76
|
return createTcpSocket(options);
|
|
76
77
|
}
|
|
77
78
|
};
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
* first pass runs at once when more than one endpoint is known: the node just
|
|
91
|
-
* lost may be gone for good, and pausing before dialing a survivor only pushes
|
|
92
|
-
* the failover past the interval a caller is willing to wait.
|
|
93
|
-
*/
|
|
94
|
-
const DefaultReconnectOption = {
|
|
95
|
-
enabled: true,
|
|
96
|
-
interval: 5 * 1000,
|
|
97
|
-
maxRetries: 12
|
|
98
|
-
};
|
|
99
|
-
/**
|
|
100
|
-
* Waits before a reconnection attempt.
|
|
101
|
-
*
|
|
102
|
-
* The timer is unref'd: a queued command fails fast on 'disconnected', so
|
|
103
|
-
* the retry loop is background work that must not keep the process alive.
|
|
104
|
-
*
|
|
105
|
-
* @param interval - Delay in milliseconds before dialing again
|
|
106
|
-
*/
|
|
107
|
-
const waitForReconnect = (interval) => new Promise((resolve) => {
|
|
108
|
-
const timeout = setTimeout(resolve, interval);
|
|
109
|
-
timeout.unref();
|
|
110
|
-
});
|
|
111
|
-
/**
|
|
112
|
-
* Manages the low-level TCP/TLS connection to the Iggy server.
|
|
113
|
-
* Handles connection lifecycle, reconnection, and data buffering.
|
|
114
|
-
*/
|
|
79
|
+
export const DIAL_TIMEOUT_MS = 2_000;
|
|
80
|
+
export const LINK_WRITE_QUEUE = 32;
|
|
81
|
+
export const MAX_PENDING_PER_LINK = 256;
|
|
82
|
+
const STALE_CLIENT = 30;
|
|
83
|
+
/** A connected socket was lost; a later bootstrap can bind it again. */
|
|
84
|
+
export class ConnectionClosedError extends Error {
|
|
85
|
+
constructor(cause) {
|
|
86
|
+
super(cause?.message ?? 'replica connection closed', { cause });
|
|
87
|
+
this.name = 'ConnectionClosedError';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** One physical endpoint, with independent reader framing and a bounded writer. */
|
|
115
91
|
export class IggyConnection extends EventEmitter {
|
|
116
|
-
/** Client configuration */
|
|
117
92
|
config;
|
|
118
|
-
/** Underlying socket connection */
|
|
119
93
|
socket;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
ending;
|
|
126
|
-
/**
|
|
127
|
-
* Whether the socket is being replaced by a deliberate leader redirect
|
|
128
|
-
* rather than lost. The drop looks the same from the outside, but nothing a
|
|
129
|
-
* caller submitted is in doubt: work waiting to be sent belongs on the node
|
|
130
|
-
* the client moves to, not in an error.
|
|
131
|
-
*/
|
|
132
|
-
redirecting;
|
|
133
|
-
/** Reconnection configuration */
|
|
134
|
-
reconnectOption;
|
|
135
|
-
/**
|
|
136
|
-
* Number of passes made over the known endpoints. One pass dials the
|
|
137
|
-
* endpoint the client is on, the endpoint it was configured with, and every
|
|
138
|
-
* node the roster named.
|
|
139
|
-
*/
|
|
140
|
-
reconnectCount;
|
|
141
|
-
/** Shared promise for concurrent callers waiting on one connection attempt */
|
|
94
|
+
connected = false;
|
|
95
|
+
ending = false;
|
|
96
|
+
failure;
|
|
97
|
+
closed;
|
|
98
|
+
close;
|
|
142
99
|
connectPromise;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
* connection was still healthy.
|
|
152
|
-
*/
|
|
153
|
-
rosterEndpoints;
|
|
154
|
-
/** Incremental response frame decoder */
|
|
155
|
-
responseDecoder;
|
|
156
|
-
/**
|
|
157
|
-
* Creates a new IggyConnection.
|
|
158
|
-
*
|
|
159
|
-
* @param config - Client configuration
|
|
160
|
-
*/
|
|
100
|
+
decoder;
|
|
101
|
+
pending = new Map();
|
|
102
|
+
writes = [];
|
|
103
|
+
changed = new ChangeSignal();
|
|
104
|
+
admissions = new PermitPool(MAX_PENDING_PER_LINK);
|
|
105
|
+
writing = false;
|
|
106
|
+
frameTimer;
|
|
107
|
+
failed = false;
|
|
161
108
|
constructor(config) {
|
|
162
109
|
super();
|
|
163
110
|
this.config = config;
|
|
164
|
-
this.
|
|
165
|
-
this.
|
|
166
|
-
this.ending = false;
|
|
167
|
-
this.redirecting = false;
|
|
168
|
-
this.reconnectOption = { ...DefaultReconnectOption, ...config.reconnect };
|
|
169
|
-
this.seedOptions = { ...config.options };
|
|
170
|
-
this.rosterEndpoints = [];
|
|
171
|
-
this.reconnectCount = 0;
|
|
172
|
-
this.connectPromise = undefined;
|
|
173
|
-
this.reconnectPromise = undefined;
|
|
174
|
-
this.responseDecoder = new ResponseFrameDecoder(config.maxResponseFrameSize ?? DEFAULT_MAX_RESPONSE_FRAME_SIZE);
|
|
175
|
-
this.socket = this._installSocket(getTransport(config));
|
|
111
|
+
this.decoder = new ResponseFrameDecoder(config.maxResponseFrameSize ?? DEFAULT_MAX_RESPONSE_FRAME_SIZE);
|
|
112
|
+
this.closed = new Promise((resolve) => { this.close = resolve; });
|
|
176
113
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
* failed attempt is retried on the same socket.
|
|
181
|
-
*/
|
|
182
|
-
_installSocket(socket) {
|
|
183
|
-
socket.on('data', (data) => {
|
|
184
|
-
if (this.socket !== socket)
|
|
185
|
-
return;
|
|
186
|
-
if (!Buffer.isBuffer(data)) {
|
|
187
|
-
this.emit('error', new ProtocolFrameError('socket returned text instead of binary data'));
|
|
188
|
-
socket.destroy();
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
this._onData(data);
|
|
192
|
-
});
|
|
193
|
-
socket.on('error', (err) => {
|
|
194
|
-
if (this.socket !== socket)
|
|
195
|
-
return;
|
|
196
|
-
debug('socket/error event', err, err.code, this.ending);
|
|
197
|
-
if (this.ending && (err?.code === 'ECONNRESET' || err?.code === 'EPIPE'))
|
|
198
|
-
return;
|
|
199
|
-
this.emit('error', err);
|
|
200
|
-
});
|
|
201
|
-
// The readiness event, not 'connect': on TLS the socket is only usable
|
|
202
|
-
// once the handshake completes, and writing a request before that would
|
|
203
|
-
// announce a connection the peer has not agreed to yet.
|
|
204
|
-
socket.once(this._readyEvent(), () => {
|
|
205
|
-
if (this.socket !== socket)
|
|
206
|
-
return;
|
|
207
|
-
debug('socket/connect event');
|
|
208
|
-
this.connected = true;
|
|
209
|
-
this.connecting = false;
|
|
210
|
-
this.reconnectCount = 0;
|
|
211
|
-
this.emit('connect');
|
|
212
|
-
});
|
|
213
|
-
socket.once('close', (hadError) => {
|
|
214
|
-
if (this.socket !== socket)
|
|
215
|
-
return;
|
|
216
|
-
debug('socket/close event', hadError);
|
|
217
|
-
this.connected = false;
|
|
218
|
-
this.connecting = false;
|
|
219
|
-
this.connectPromise = undefined;
|
|
220
|
-
this._endResponseWait();
|
|
221
|
-
this.emit('disconnected', hadError);
|
|
222
|
-
if (!this.ending)
|
|
223
|
-
void this.reconnect().catch(() => undefined);
|
|
224
|
-
});
|
|
225
|
-
return socket;
|
|
226
|
-
}
|
|
227
|
-
/**
|
|
228
|
-
* Establishes the connection to the server.
|
|
229
|
-
*
|
|
230
|
-
* @returns Promise that resolves when connected
|
|
231
|
-
*/
|
|
232
|
-
connect(boundDial = false) {
|
|
233
|
-
if (this.ending)
|
|
234
|
-
return Promise.reject(new Error('connection is closed'));
|
|
114
|
+
connect(signal) {
|
|
115
|
+
if (this.ending || this.failed)
|
|
116
|
+
return Promise.reject(this.failure ?? new Error('connection is closed'));
|
|
235
117
|
if (this.connected)
|
|
236
118
|
return Promise.resolve(this);
|
|
237
|
-
if (this.reconnectPromise)
|
|
238
|
-
return this.reconnectPromise;
|
|
239
119
|
if (this.connectPromise)
|
|
240
120
|
return this.connectPromise;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
this.connecting = true;
|
|
244
|
-
const socket = this.socket;
|
|
245
|
-
// Bounded here too when the client knows somewhere else to go: this dial
|
|
246
|
-
// is not part of a pass, so an endpoint that never becomes usable would
|
|
247
|
-
// hold it with no timer of its own and the redial pass would never start.
|
|
248
|
-
const connectPromise = this._dialWithin(socket, boundDial || this._redialCandidates().length > 1);
|
|
249
|
-
this.connectPromise = connectPromise;
|
|
250
|
-
const clearConnectPromise = () => {
|
|
251
|
-
if (this.connectPromise === connectPromise)
|
|
252
|
-
this.connectPromise = undefined;
|
|
253
|
-
};
|
|
254
|
-
void connectPromise.then(clearConnectPromise, clearConnectPromise);
|
|
255
|
-
return connectPromise;
|
|
121
|
+
this.connectPromise = this.dial(signal);
|
|
122
|
+
return this.connectPromise;
|
|
256
123
|
}
|
|
257
|
-
|
|
258
|
-
* Waits for one dial, bounded while other endpoints are queued behind it.
|
|
259
|
-
*
|
|
260
|
-
* A socket has no connect deadline of its own and there is no 'timeout'
|
|
261
|
-
* listener on it, so a node whose syns are dropped holds the pass for the
|
|
262
|
-
* whole OS connect timeout -- and it leads every pass, because the current
|
|
263
|
-
* endpoint only moves on success. The bound covers the TLS handshake too,
|
|
264
|
-
* which is what `_readyEvent()` waits for and has no deadline of its own
|
|
265
|
-
* either. It matches the Rust, Go and C# SDKs'.
|
|
266
|
-
*/
|
|
267
|
-
async _dialWithin(socket, bounded) {
|
|
268
|
-
if (!bounded)
|
|
269
|
-
return this._waitForConnection(socket);
|
|
270
|
-
let expire;
|
|
271
|
-
const bound = new Promise((_resolve, reject) => {
|
|
272
|
-
expire = setTimeout(() => {
|
|
273
|
-
// Destroying it makes the pending dial settle and releases the handle;
|
|
274
|
-
// left alone it would keep the event loop alive.
|
|
275
|
-
socket.destroy();
|
|
276
|
-
reject(new Error(`dial exceeded ${FAILOVER_DIAL_TIMEOUT_MS}ms`));
|
|
277
|
-
}, FAILOVER_DIAL_TIMEOUT_MS);
|
|
278
|
-
expire.unref?.();
|
|
279
|
-
});
|
|
124
|
+
async dial(signal) {
|
|
280
125
|
try {
|
|
281
|
-
|
|
126
|
+
signal?.throwIfAborted();
|
|
127
|
+
const socket = getTransport(this.config);
|
|
128
|
+
this.socket = socket;
|
|
129
|
+
const ready = this.config.transport === 'TLS' ? 'secureConnect' : 'connect';
|
|
130
|
+
socket.on('data', (data) => this.onData(data));
|
|
131
|
+
socket.on('error', (error) => this.fail(this.connected ? new ConnectionClosedError(error) : error));
|
|
132
|
+
socket.once('close', () => this.fail(this.failure ?? new ConnectionClosedError()));
|
|
133
|
+
await waitWithin(new Promise((resolve, reject) => {
|
|
134
|
+
const cleanup = () => {
|
|
135
|
+
socket.removeListener(ready, established);
|
|
136
|
+
socket.removeListener('error', rejected);
|
|
137
|
+
socket.removeListener('close', closed);
|
|
138
|
+
};
|
|
139
|
+
const established = () => { cleanup(); resolve(); };
|
|
140
|
+
const rejected = (error) => { cleanup(); reject(error); };
|
|
141
|
+
const closed = () => rejected(new Error('connection closed before establishment'));
|
|
142
|
+
socket.once(ready, established);
|
|
143
|
+
socket.once('error', rejected);
|
|
144
|
+
socket.once('close', closed);
|
|
145
|
+
}), Date.now() + DIAL_TIMEOUT_MS, signal);
|
|
146
|
+
if (this.failed || this.ending)
|
|
147
|
+
throw this.failure ?? new Error('connection is closed');
|
|
148
|
+
this.connected = true;
|
|
149
|
+
this.emit('connect');
|
|
150
|
+
return this;
|
|
282
151
|
}
|
|
283
|
-
|
|
284
|
-
|
|
152
|
+
catch (error) {
|
|
153
|
+
this.fail(error);
|
|
154
|
+
throw error;
|
|
285
155
|
}
|
|
286
156
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
* On TLS that is 'secureConnect', not 'connect': the latter fires as soon as
|
|
291
|
-
* the TCP handshake completes, so waiting on it would treat a peer that
|
|
292
|
-
* never answers the ClientHello as connected and leave the handshake with no
|
|
293
|
-
* deadline at all.
|
|
294
|
-
*/
|
|
295
|
-
_readyEvent() {
|
|
296
|
-
return this.config.transport === 'TLS' ? 'secureConnect' : 'connect';
|
|
297
|
-
}
|
|
298
|
-
_waitForConnection(socket) {
|
|
299
|
-
const ready = this._readyEvent();
|
|
300
|
-
return new Promise((resolve, reject) => {
|
|
301
|
-
const cleanup = () => {
|
|
302
|
-
socket.removeListener(ready, resolveConnect);
|
|
303
|
-
socket.removeListener('error', rejectConnect);
|
|
304
|
-
socket.removeListener('close', rejectClosed);
|
|
305
|
-
};
|
|
306
|
-
const rejectConnect = (error) => {
|
|
307
|
-
cleanup();
|
|
308
|
-
reject(error);
|
|
309
|
-
};
|
|
310
|
-
const rejectClosed = () => {
|
|
311
|
-
rejectConnect(new Error('connection closed before it was established'));
|
|
312
|
-
};
|
|
313
|
-
const resolveConnect = () => {
|
|
314
|
-
cleanup();
|
|
315
|
-
resolve(this);
|
|
316
|
-
};
|
|
317
|
-
socket.once('error', rejectConnect);
|
|
318
|
-
socket.once('close', rejectClosed);
|
|
319
|
-
socket.once(ready, resolveConnect);
|
|
320
|
-
});
|
|
321
|
-
}
|
|
322
|
-
/**
|
|
323
|
-
* Attempts to reconnect to the server.
|
|
324
|
-
* Respects maxRetries limit and emits error when exceeded.
|
|
325
|
-
*
|
|
326
|
-
* @param err - Optional error that triggered the reconnection
|
|
327
|
-
*/
|
|
328
|
-
async reconnect(err) {
|
|
329
|
-
if (this.ending || this.connected)
|
|
330
|
-
return;
|
|
331
|
-
if (this.reconnectPromise)
|
|
332
|
-
return this.reconnectPromise;
|
|
333
|
-
const { enabled, interval, maxRetries } = this.reconnectOption;
|
|
334
|
-
debug('reconnect# event/reconnect?', {
|
|
335
|
-
reconnect: { enabled, interval, maxRetries },
|
|
336
|
-
count: this.reconnectCount,
|
|
337
|
-
lastError: err
|
|
338
|
-
});
|
|
339
|
-
const reconnectPromise = this._reconnectUntilConnected(enabled, interval, maxRetries, err);
|
|
340
|
-
this.reconnectPromise = reconnectPromise;
|
|
157
|
+
async exchange(frame, deadline, signal, owner) {
|
|
158
|
+
let release;
|
|
159
|
+
let pending;
|
|
341
160
|
try {
|
|
342
|
-
|
|
161
|
+
release = await this.admissions.acquire(deadline, signal);
|
|
162
|
+
signal.throwIfAborted();
|
|
163
|
+
if (Date.now() >= deadline)
|
|
164
|
+
throw new VsrResponseTimeoutError(0);
|
|
165
|
+
if (!this.connected || this.failed)
|
|
166
|
+
throw this.failure ?? new Error('replica connection is not ready');
|
|
167
|
+
const bytes = frame();
|
|
168
|
+
const id = bytes.readBigUInt64LE(REQUEST_OFFSET.request);
|
|
169
|
+
if (this.pending.has(id))
|
|
170
|
+
throw new Error('request id already has an active exchange');
|
|
171
|
+
let resolve;
|
|
172
|
+
const response = new Promise((complete) => { resolve = complete; });
|
|
173
|
+
pending = { id, frame: bytes, owner, resolve, done: false, started: false };
|
|
174
|
+
this.pending.set(id, pending);
|
|
175
|
+
while (this.writes.length >= LINK_WRITE_QUEUE && !pending.done) {
|
|
176
|
+
await waitWithin(this.changed.promise, deadline, signal);
|
|
177
|
+
}
|
|
178
|
+
signal.throwIfAborted();
|
|
179
|
+
if (!pending.done) {
|
|
180
|
+
owner?.retain();
|
|
181
|
+
this.writes.push(pending);
|
|
182
|
+
void this.writeFrames();
|
|
183
|
+
}
|
|
184
|
+
return await waitWithin(response, deadline, signal);
|
|
343
185
|
}
|
|
344
186
|
catch (error) {
|
|
345
|
-
if (
|
|
346
|
-
this.
|
|
347
|
-
return;
|
|
187
|
+
if (pending)
|
|
188
|
+
this.finish(pending, { error, started: pending.started });
|
|
189
|
+
return { error, started: pending?.started ?? false };
|
|
348
190
|
}
|
|
349
191
|
finally {
|
|
350
|
-
|
|
351
|
-
this.reconnectPromise = undefined;
|
|
352
|
-
this.connecting = false;
|
|
192
|
+
release?.();
|
|
353
193
|
}
|
|
354
194
|
}
|
|
355
|
-
async
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
// client that knows one endpoint and turned reconnection off redials
|
|
364
|
-
// nothing, which is what it asked for.
|
|
365
|
-
// Counted rather than tracked within this call: every dial the pass fails
|
|
366
|
-
// closes a socket, and a close starts a reconnect of its own. Bounded by
|
|
367
|
-
// `firstPass` alone, each of those closes would open another sweep and the
|
|
368
|
-
// pass would repeat for as long as the endpoints stay down. The count is
|
|
369
|
-
// reset when a connection is established, so a later loss sweeps again.
|
|
370
|
-
const sweepOnce = !enabled && this._redialCandidates().length > 1;
|
|
371
|
-
while ((enabled && this.reconnectCount < maxRetries) ||
|
|
372
|
-
(sweepOnce && this.reconnectCount < 1)) {
|
|
373
|
-
this.connecting = true;
|
|
374
|
-
this.reconnectCount += 1;
|
|
375
|
-
const candidates = this._redialCandidates();
|
|
376
|
-
// The backoff paces retries against a single endpoint. With other
|
|
377
|
-
// endpoints known there is somewhere else to go, and pausing first only
|
|
378
|
-
// pushes the failover past the interval a caller is willing to wait;
|
|
379
|
-
// later passes still back off.
|
|
380
|
-
if (enabled && (!firstPass || candidates.length === 1))
|
|
381
|
-
await waitForReconnect(interval);
|
|
382
|
-
firstPass = false;
|
|
383
|
-
if (this.ending)
|
|
384
|
-
throw new Error('connection is closed', { cause: lastError });
|
|
385
|
-
// A redirect may replace the socket at any point. Defer to the active
|
|
386
|
-
// connection instead of dialing the superseded endpoint.
|
|
387
|
-
if (this.connected || this.socket !== expectedSocket)
|
|
388
|
-
return this.connect();
|
|
389
|
-
// Every endpoint gets its turn inside one attempt, so a full pass over
|
|
390
|
-
// the cluster costs one retry rather than one per endpoint: a pass that
|
|
391
|
-
// stopped at the first refusal would never reach the survivors of a
|
|
392
|
-
// client configured for a single retry.
|
|
393
|
-
for (const options of candidates) {
|
|
394
|
-
// Re-checked every iteration, not once above the loop: a destroy or a
|
|
395
|
-
// redirect mid-pass has to stop the pass. Left running, the next
|
|
396
|
-
// endpoint that answers would leave an open socket nobody closes, a
|
|
397
|
-
// 'connect' event after the destroy, and the process alive.
|
|
398
|
-
if (this.ending)
|
|
399
|
-
throw new Error('connection is closed', { cause: lastError });
|
|
400
|
-
if (this.socket !== expectedSocket)
|
|
401
|
-
return this.connect();
|
|
402
|
-
const socket = this._installSocket(getTransport({ ...this.config, options }));
|
|
403
|
-
this.socket = socket;
|
|
404
|
-
expectedSocket = socket;
|
|
195
|
+
async writeFrames() {
|
|
196
|
+
if (this.writing)
|
|
197
|
+
return;
|
|
198
|
+
this.writing = true;
|
|
199
|
+
try {
|
|
200
|
+
while (this.writes.length > 0) {
|
|
201
|
+
const pending = this.writes.shift();
|
|
202
|
+
this.changed.pulse();
|
|
405
203
|
try {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
204
|
+
if (pending.done)
|
|
205
|
+
continue;
|
|
206
|
+
pending.started = true;
|
|
207
|
+
const written = new Promise((resolve, reject) => {
|
|
208
|
+
this.socket.write(pending.frame, (error) => error ? reject(new ConnectionClosedError(error)) : resolve());
|
|
209
|
+
});
|
|
210
|
+
try {
|
|
211
|
+
await waitWithin(written, Date.now() + OPERATION_TIMEOUT_MS);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
this.fail(error);
|
|
215
|
+
await written.catch(() => undefined);
|
|
410
216
|
}
|
|
411
|
-
if (this.socket !== socket)
|
|
412
|
-
return this.connect();
|
|
413
|
-
this.config.options = options;
|
|
414
|
-
return this;
|
|
415
217
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
? error
|
|
419
|
-
: new Error(String(error));
|
|
420
|
-
debug('reconnect attempt failed', lastError);
|
|
218
|
+
finally {
|
|
219
|
+
pending.owner?.release();
|
|
421
220
|
}
|
|
422
221
|
}
|
|
423
222
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
}
|
|
427
|
-
/**
|
|
428
|
-
* Records the cluster roster as redial candidates.
|
|
429
|
-
*
|
|
430
|
-
* Replaced wholesale rather than merged: the roster is the cluster's own
|
|
431
|
-
* answer about where its nodes are, so a node it dropped stops being
|
|
432
|
-
* dialed. The configured seed is kept separately and outlives it.
|
|
433
|
-
*/
|
|
434
|
-
rememberRoster(endpoints) {
|
|
435
|
-
if (endpoints.length === 0)
|
|
436
|
-
return;
|
|
437
|
-
this.rosterEndpoints = endpoints;
|
|
438
|
-
}
|
|
439
|
-
/**
|
|
440
|
-
* The roster endpoint after the one this connection is on, for a request
|
|
441
|
-
* the current node keeps refusing to admit. Metadata and partition
|
|
442
|
-
* consensus groups elect independently, so the metadata leader can hold a
|
|
443
|
-
* follower replica of the partition a request targets, and only walking
|
|
444
|
-
* the roster reaches that group's primary. `undefined` when the roster
|
|
445
|
-
* names nowhere else to go.
|
|
446
|
-
*/
|
|
447
|
-
nextRosterEndpoint(visited) {
|
|
448
|
-
const roster = this.rosterEndpoints;
|
|
449
|
-
if (roster.length === 0)
|
|
450
|
-
return undefined;
|
|
451
|
-
const index = roster.findIndex((endpoint) => this.isConnectedTo(endpoint.host, endpoint.port));
|
|
452
|
-
if (index >= 0)
|
|
453
|
-
visited.add(endpointKey(roster[index]));
|
|
454
|
-
const start = index < 0 ? 0 : (index + 1) % roster.length;
|
|
455
|
-
for (let offset = 0; offset < roster.length; offset += 1) {
|
|
456
|
-
const candidate = roster[(start + offset) % roster.length];
|
|
457
|
-
const key = endpointKey(candidate);
|
|
458
|
-
if (visited.has(key) || this.isConnectedTo(candidate.host, candidate.port))
|
|
459
|
-
continue;
|
|
460
|
-
visited.add(key);
|
|
461
|
-
return candidate;
|
|
223
|
+
catch (error) {
|
|
224
|
+
this.fail(error);
|
|
462
225
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Endpoints a redial rotates through, likeliest first: where the client
|
|
467
|
-
* currently is, the endpoint it was configured with, then the roster it
|
|
468
|
-
* learned while connected. After a leader redirect the current endpoint may
|
|
469
|
-
* die with the leader, and the rest of the list is the way back to the
|
|
470
|
-
* cluster.
|
|
471
|
-
*
|
|
472
|
-
* Duplicates are dropped by spelling: the loopback aliases and an
|
|
473
|
-
* IPv4-mapped IPv6 address collapse onto one endpoint. Names are not
|
|
474
|
-
* resolved, so a seed given as a DNS name and the roster's IP for the same
|
|
475
|
-
* node still count as two candidates -- one wasted dial per pass, not a
|
|
476
|
-
* correctness problem.
|
|
477
|
-
*/
|
|
478
|
-
_redialCandidates() {
|
|
479
|
-
const candidates = [this.config.options];
|
|
480
|
-
const known = [
|
|
481
|
-
this.seedOptions,
|
|
482
|
-
...this.rosterEndpoints.map(({ host, port }) => ({ ...this.config.options, host, port }))
|
|
483
|
-
];
|
|
484
|
-
for (const candidate of known) {
|
|
485
|
-
const duplicate = candidates.some((existing) => existing.port === candidate.port &&
|
|
486
|
-
normalizeHost(existing.host) === normalizeHost(candidate.host));
|
|
487
|
-
if (!duplicate)
|
|
488
|
-
candidates.push(candidate);
|
|
226
|
+
finally {
|
|
227
|
+
this.writing = false;
|
|
489
228
|
}
|
|
490
|
-
return candidates;
|
|
491
229
|
}
|
|
492
|
-
|
|
493
|
-
const redirectedOptions = { ...this.config.options, host, port };
|
|
494
|
-
const redirectedConfig = {
|
|
495
|
-
...this.config,
|
|
496
|
-
options: redirectedOptions
|
|
497
|
-
};
|
|
498
|
-
this.redirecting = true;
|
|
230
|
+
onData(data) {
|
|
499
231
|
try {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
this.
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
this.
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
232
|
+
if (!Buffer.isBuffer(data))
|
|
233
|
+
throw new ProtocolFrameError('socket returned text instead of binary data');
|
|
234
|
+
const frames = this.decoder.push(data);
|
|
235
|
+
if (!this.decoder.hasBufferedData) {
|
|
236
|
+
clearTimeout(this.frameTimer);
|
|
237
|
+
this.frameTimer = undefined;
|
|
238
|
+
}
|
|
239
|
+
else if (!this.frameTimer || frames.length > 0) {
|
|
240
|
+
clearTimeout(this.frameTimer);
|
|
241
|
+
this.frameTimer = setTimeout(() => this.fail(new ProtocolFrameError('incomplete consensus frame timed out')), OPERATION_TIMEOUT_MS);
|
|
242
|
+
}
|
|
243
|
+
for (const response of frames) {
|
|
244
|
+
if (peekCommand(response) === Command.Eviction) {
|
|
245
|
+
const error = evictionError(response);
|
|
246
|
+
this.emit('eviction', error);
|
|
247
|
+
// The server keeps the session of a stale client and drops only this
|
|
248
|
+
// link, so it fails as a lost link and the client rebinds the session.
|
|
249
|
+
this.fail(readEviction(response).reason === EvictionReason.StaleClient
|
|
250
|
+
? new Error('replica link evicted as a stale client', { cause: error }) : error);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (peekCommand(response) !== Command.Reply)
|
|
254
|
+
throw new ProtocolFrameError('unexpected consensus frame');
|
|
255
|
+
const id = response.readBigUInt64LE(REPLY_OFFSET.request);
|
|
256
|
+
const pending = this.pending.get(id);
|
|
257
|
+
if (pending)
|
|
258
|
+
this.finish(pending, { response, started: pending.started });
|
|
259
|
+
// A 30 refusal says that the server no longer attaches this link to the
|
|
260
|
+
// session. A replica that replaced its client table says so of a live
|
|
261
|
+
// session too, so the link fails as a lost link and the bind decides.
|
|
262
|
+
if (readStatus(response) === STALE_CLIENT) {
|
|
263
|
+
this.fail(new Error('replica link detached from its session'));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
513
267
|
}
|
|
514
|
-
|
|
515
|
-
this.
|
|
268
|
+
catch (error) {
|
|
269
|
+
this.fail(error);
|
|
516
270
|
}
|
|
517
271
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
272
|
+
finish(pending, result) {
|
|
273
|
+
if (pending.done)
|
|
274
|
+
return;
|
|
275
|
+
pending.done = true;
|
|
276
|
+
if (this.pending.get(pending.id) === pending)
|
|
277
|
+
this.pending.delete(pending.id);
|
|
278
|
+
pending.resolve(result);
|
|
279
|
+
this.changed.pulse();
|
|
280
|
+
}
|
|
281
|
+
fail(error) {
|
|
282
|
+
if (this.failed)
|
|
283
|
+
return;
|
|
284
|
+
this.failed = true;
|
|
285
|
+
this.failure = error;
|
|
286
|
+
this.connected = false;
|
|
287
|
+
clearTimeout(this.frameTimer);
|
|
288
|
+
this.decoder.clear();
|
|
289
|
+
for (const pending of this.pending.values())
|
|
290
|
+
this.finish(pending, { error, started: pending.started });
|
|
291
|
+
for (const pending of this.writes.splice(0))
|
|
292
|
+
pending.owner?.release();
|
|
293
|
+
this.socket?.destroy();
|
|
294
|
+
this.close();
|
|
295
|
+
this.changed.pulse();
|
|
296
|
+
if (this.listenerCount('error') > 0)
|
|
297
|
+
this.emit('error', error);
|
|
298
|
+
this.emit('disconnected');
|
|
299
|
+
debug('replica link closed', error);
|
|
521
300
|
}
|
|
522
301
|
isConnectedTo(host, port) {
|
|
523
302
|
const target = normalizeHost(host);
|
|
524
|
-
if (this.socket
|
|
525
|
-
normalizeHost(this.socket.remoteAddress) === target)
|
|
303
|
+
if (this.socket?.remotePort === port && normalizeHost(this.socket.remoteAddress) === target)
|
|
526
304
|
return true;
|
|
527
|
-
|
|
528
|
-
// address; falling back to the configured endpoint avoids a redirect to
|
|
529
|
-
// the peer the client is already connected to.
|
|
530
|
-
return this.config.options.port === port &&
|
|
531
|
-
normalizeHost(this.config.options.host) === target;
|
|
305
|
+
return this.config.options.port === port && normalizeHost(this.config.options.host) === target;
|
|
532
306
|
}
|
|
533
|
-
/**
|
|
534
|
-
* Destroys the connection and marks it as ending.
|
|
535
|
-
*/
|
|
536
307
|
_destroy() {
|
|
537
308
|
this.ending = true;
|
|
538
|
-
this.
|
|
539
|
-
this.socket.destroy();
|
|
540
|
-
}
|
|
541
|
-
/**
|
|
542
|
-
* Clears the response buffer and resets the waiting state.
|
|
543
|
-
*/
|
|
544
|
-
_endResponseWait() {
|
|
545
|
-
this.responseDecoder.clear();
|
|
546
|
-
}
|
|
547
|
-
/**
|
|
548
|
-
* Handles incoming data from the socket.
|
|
549
|
-
* Buffers incomplete responses and emits complete ones.
|
|
550
|
-
*
|
|
551
|
-
* @param data - Incoming data buffer
|
|
552
|
-
*/
|
|
553
|
-
_onData(data) {
|
|
554
|
-
debug('ONDATA', typeof data, Buffer.isBuffer(data), data?.length, this.responseDecoder.hasBufferedData);
|
|
555
|
-
try {
|
|
556
|
-
for (const response of this.responseDecoder.push(data)) {
|
|
557
|
-
if (peekCommand(response) === Command.Eviction)
|
|
558
|
-
this.emit('eviction', evictionError(response));
|
|
559
|
-
else
|
|
560
|
-
this.emit('response', response);
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
catch (error) {
|
|
564
|
-
this._endResponseWait();
|
|
565
|
-
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
566
|
-
this.socket.destroy();
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
writeFrame(frame) {
|
|
570
|
-
this.socket.write(frame);
|
|
309
|
+
this.fail(new Error('connection destroyed'));
|
|
571
310
|
}
|
|
572
311
|
}
|
|
573
312
|
const normalizeHost = (host) => {
|
|
574
313
|
const normalized = (host ?? '').toLowerCase().replace(/^::ffff:/, '');
|
|
575
|
-
return normalized === 'localhost' || normalized === '::1'
|
|
576
|
-
? '127.0.0.1'
|
|
577
|
-
: normalized;
|
|
314
|
+
return normalized === 'localhost' || normalized === '::1' ? '127.0.0.1' : normalized;
|
|
578
315
|
};
|
|
579
316
|
export const endpointKey = (endpoint) => `${normalizeHost(endpoint.host)}:${endpoint.port}`;
|
|
580
317
|
//# sourceMappingURL=client.connection.js.map
|