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.
Files changed (157) hide show
  1. package/README.md +248 -28
  2. package/dist/client/client.config.d.ts +2 -0
  3. package/dist/client/client.config.js +13 -2
  4. package/dist/client/client.config.test.js +15 -2
  5. package/dist/client/client.connection-string.js +13 -1
  6. package/dist/client/client.connection-string.test.js +43 -1
  7. package/dist/client/client.connection.d.ts +33 -135
  8. package/dist/client/client.connection.js +190 -453
  9. package/dist/client/client.connection.test.js +244 -669
  10. package/dist/client/client.d.ts +4 -35
  11. package/dist/client/client.js +8 -101
  12. package/dist/client/client.operation.d.ts +120 -0
  13. package/dist/client/client.operation.js +428 -0
  14. package/dist/client/client.operation.test.d.ts +2 -0
  15. package/dist/client/client.operation.test.js +285 -0
  16. package/dist/client/client.socket.d.ts +92 -209
  17. package/dist/client/client.socket.js +1205 -981
  18. package/dist/client/client.socket.test.js +4865 -679
  19. package/dist/client/client.type.d.ts +65 -22
  20. package/dist/client/index.d.ts +1 -0
  21. package/dist/client/index.js +1 -0
  22. package/dist/client.e2e.js +3 -5
  23. package/dist/e2e/resource-options.e2e.d.ts +2 -0
  24. package/dist/{wire/stream/purge-stream.command.js → e2e/resource-options.e2e.js} +12 -20
  25. package/dist/e2e/resource-options.suite.d.ts +15 -0
  26. package/dist/e2e/resource-options.suite.js +272 -0
  27. package/dist/e2e/tcp.cluster.e2e.js +1 -0
  28. package/dist/e2e/tcp.consumer-stream.e2e.js +4 -0
  29. package/dist/e2e/tcp.parallel.e2e.js +5 -6
  30. package/dist/e2e/tcp.stream.e2e.js +0 -3
  31. package/dist/e2e/tcp.system.e2e.js +10 -1
  32. package/dist/e2e/tcp.token.e2e.js +8 -2
  33. package/dist/e2e/tcp.topic.e2e.js +0 -3
  34. package/dist/e2e/tcp.user.e2e.js +46 -3
  35. package/dist/e2e/tls.system.e2e.js +21 -12
  36. package/dist/index.d.ts +2 -1
  37. package/dist/index.js +1 -1
  38. package/dist/stream/consumer-stream.js +97 -12
  39. package/dist/wire/cluster/cluster.type.d.ts +2 -2
  40. package/dist/wire/cluster/cluster.utils.d.ts +1 -0
  41. package/dist/wire/cluster/cluster.utils.js +4 -1
  42. package/dist/wire/command-set.d.ts +8 -8
  43. package/dist/wire/command-set.js +18 -9
  44. package/dist/wire/command.code.d.ts +3 -10
  45. package/dist/wire/command.code.js +9 -11
  46. package/dist/wire/command.utils.d.ts +3 -2
  47. package/dist/wire/command.utils.js +3 -2
  48. package/dist/wire/error.code.js +17 -1
  49. package/dist/wire/error.code.test.js +28 -1
  50. package/dist/wire/error.utils.d.ts +4 -2
  51. package/dist/wire/error.utils.js +6 -3
  52. package/dist/wire/identifier.utils.d.ts +2 -0
  53. package/dist/wire/identifier.utils.js +2 -0
  54. package/dist/wire/index.d.ts +1 -0
  55. package/dist/wire/index.js +1 -0
  56. package/dist/wire/message/header.type.d.ts +1 -1
  57. package/dist/wire/message/message-batch.test.js +11 -5
  58. package/dist/wire/message/message.utils.d.ts +0 -10
  59. package/dist/wire/message/message.utils.js +1 -22
  60. package/dist/wire/message/poll-messages.command.d.ts +12 -1
  61. package/dist/wire/message/poll-messages.command.js +64 -16
  62. package/dist/wire/message/poll-messages.command.test.js +143 -5
  63. package/dist/wire/message/poll.utils.d.ts +4 -0
  64. package/dist/wire/message/poll.utils.js +6 -1
  65. package/dist/wire/message/send-messages.command.d.ts +3 -3
  66. package/dist/wire/message/send-messages.command.js +103 -3
  67. package/dist/wire/message/send-messages.command.test.js +113 -2
  68. package/dist/wire/offset/delete-offset.command.d.ts +11 -4
  69. package/dist/wire/offset/delete-offset.command.js +1 -1
  70. package/dist/wire/offset/get-offset.command.d.ts +1 -1
  71. package/dist/wire/offset/offset-write.command.test.d.ts +2 -0
  72. package/dist/wire/offset/offset-write.command.test.js +43 -0
  73. package/dist/wire/offset/offset.utils.js +10 -6
  74. package/dist/wire/offset/store-offset.command.d.ts +10 -0
  75. package/dist/wire/offset/store-offset.command.js +1 -1
  76. package/dist/wire/options.utils.d.ts +46 -0
  77. package/dist/wire/options.utils.js +57 -0
  78. package/dist/wire/partition/partition-route.d.ts +12 -0
  79. package/dist/wire/partition/partition-route.js +40 -0
  80. package/dist/wire/partition/partition-route.test.d.ts +2 -0
  81. package/dist/wire/partition/partition-route.test.js +42 -0
  82. package/dist/wire/session/login-with-token.command.d.ts +2 -2
  83. package/dist/wire/session/login-with-token.command.js +7 -2
  84. package/dist/wire/session/login.command.d.ts +2 -2
  85. package/dist/wire/session/login.command.js +7 -2
  86. package/dist/wire/stream/create-stream.command.d.ts +5 -2
  87. package/dist/wire/stream/create-stream.command.js +20 -2
  88. package/dist/wire/stream/index.d.ts +1 -1
  89. package/dist/wire/stream/index.js +1 -1
  90. package/dist/wire/stream/stream-options.d.ts +77 -0
  91. package/dist/wire/stream/stream-options.js +101 -0
  92. package/dist/wire/stream/stream-options.test.d.ts +2 -0
  93. package/dist/wire/stream/stream-options.test.js +103 -0
  94. package/dist/wire/stream/stream.utils.d.ts +9 -0
  95. package/dist/wire/stream/stream.utils.js +17 -3
  96. package/dist/wire/stream/update-stream.command.d.ts +23 -5
  97. package/dist/wire/stream/update-stream.command.js +10 -17
  98. package/dist/wire/system/describe-options.command.d.ts +23 -6
  99. package/dist/wire/system/describe-options.command.js +21 -2
  100. package/dist/wire/system/get-stats.command.d.ts +17 -0
  101. package/dist/wire/system/get-stats.command.js +69 -16
  102. package/dist/wire/system/get-stats.command.test.d.ts +2 -0
  103. package/dist/wire/system/get-stats.command.test.js +97 -0
  104. package/dist/wire/system/index.d.ts +2 -0
  105. package/dist/wire/system/index.js +2 -0
  106. package/dist/wire/system/snapshot.command.d.ts +64 -0
  107. package/dist/wire/system/snapshot.command.js +76 -0
  108. package/dist/wire/system/snapshot.command.test.d.ts +2 -0
  109. package/dist/wire/system/snapshot.command.test.js +86 -0
  110. package/dist/wire/system/update-resource.command.d.ts +53 -0
  111. package/dist/wire/system/update-resource.command.js +130 -0
  112. package/dist/wire/system/update-resource.command.test.d.ts +2 -0
  113. package/dist/wire/system/update-resource.command.test.js +497 -0
  114. package/dist/wire/token/create-token.command.d.ts +1 -1
  115. package/dist/wire/token/create-token.command.js +1 -1
  116. package/dist/wire/token/create-token.command.test.js +6 -0
  117. package/dist/wire/topic/create-topic.command.d.ts +20 -12
  118. package/dist/wire/topic/create-topic.command.js +11 -4
  119. package/dist/wire/topic/index.d.ts +1 -2
  120. package/dist/wire/topic/index.js +1 -2
  121. package/dist/wire/topic/topic.utils.d.ts +14 -5
  122. package/dist/wire/topic/topic.utils.js +26 -11
  123. package/dist/wire/topic/topic.utils.test.js +13 -13
  124. package/dist/wire/topic/update-topic.command.d.ts +22 -15
  125. package/dist/wire/topic/update-topic.command.js +12 -34
  126. package/dist/wire/user/create-user.command.d.ts +12 -2
  127. package/dist/wire/user/create-user.command.js +7 -3
  128. package/dist/wire/user/index.d.ts +1 -0
  129. package/dist/wire/user/index.js +1 -0
  130. package/dist/wire/user/update-user.command.d.ts +25 -7
  131. package/dist/wire/user/update-user.command.js +18 -31
  132. package/dist/wire/user/user.utils.d.ts +2 -0
  133. package/dist/wire/user/user.utils.js +5 -3
  134. package/dist/wire/vsr/header.d.ts +23 -8
  135. package/dist/wire/vsr/header.js +32 -10
  136. package/dist/wire/vsr/header.test.js +5 -1
  137. package/dist/wire/vsr/index.d.ts +8 -2
  138. package/dist/wire/vsr/index.js +44 -11
  139. package/dist/wire/vsr/operation.d.ts +18 -6
  140. package/dist/wire/vsr/operation.js +46 -16
  141. package/dist/wire/vsr/operation.test.js +32 -8
  142. package/dist/wire/vsr/register.d.ts +10 -6
  143. package/dist/wire/vsr/register.js +24 -7
  144. package/dist/wire/vsr/register.test.js +28 -8
  145. package/dist/wire/vsr/reply.d.ts +4 -1
  146. package/dist/wire/vsr/reply.js +42 -7
  147. package/dist/wire/vsr/reply.test.js +11 -1
  148. package/dist/wire/vsr/session.d.ts +9 -10
  149. package/dist/wire/vsr/session.js +32 -23
  150. package/dist/wire/vsr/session.test.js +33 -7
  151. package/dist/wire/vsr/vsr.test.js +57 -6
  152. package/package.json +6 -7
  153. package/dist/wire/message/flush-unsaved-buffers.command.d.ts +0 -26
  154. package/dist/wire/message/flush-unsaved-buffers.command.js +0 -36
  155. package/dist/wire/stream/purge-stream.command.d.ts +0 -22
  156. package/dist/wire/topic/purge-topic.command.d.ts +0 -24
  157. 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
- * Bound on one dial while other endpoints are queued behind it. Neither the
80
- * connect nor the TLS handshake has a deadline of its own, so a node whose syns
81
- * are dropped -- or one that accepts TCP and never answers the ClientHello --
82
- * would hold the whole pass. Matches the Rust SDK.
83
- */
84
- const FAILOVER_DIAL_TIMEOUT_MS = 2_000;
85
- /**
86
- * Default reconnection settings.
87
- *
88
- * One retry is one full pass over every endpoint the client knows, so this is
89
- * twelve passes rather than twelve dials, waiting 5 seconds between them. The
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
- /** Whether the connection is established */
121
- connected;
122
- /** Whether a connection attempt is in progress */
123
- connecting;
124
- /** Whether the connection is being intentionally closed */
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
- /** Shared promise for callers waiting on automatic reconnection */
144
- reconnectPromise;
145
- /** Endpoint the client was configured with, kept across leader redirects */
146
- seedOptions;
147
- /**
148
- * Every node the roster named on the last read, kept as redial candidates.
149
- * A node dies together with its address, and the roster is unreachable
150
- * exactly when it is needed, so it has to have been remembered while the
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.connected = false;
165
- this.connecting = false;
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
- * Attaches the lifecycle listeners exactly once per socket instance.
179
- * Attaching them in `connect()` would stack duplicate handlers whenever a
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
- if (this.socket.destroyed)
242
- this.socket = this._installSocket(getTransport(this.config));
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
- return await Promise.race([this._waitForConnection(socket), bound]);
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
- finally {
284
- clearTimeout(expire);
152
+ catch (error) {
153
+ this.fail(error);
154
+ throw error;
285
155
  }
286
156
  }
287
- /**
288
- * The event that says a socket can carry a request.
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
- return await reconnectPromise;
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 (!this.ending)
346
- this.emit('error', error);
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
- if (this.reconnectPromise === reconnectPromise)
351
- this.reconnectPromise = undefined;
352
- this.connecting = false;
192
+ release?.();
353
193
  }
354
194
  }
355
- async _reconnectUntilConnected(enabled, interval, maxRetries, initialError) {
356
- let lastError = initialError;
357
- let expectedSocket = this.socket;
358
- let firstPass = true;
359
- // Reconnection settings bound the retries, not the endpoints. With them off
360
- // and several endpoints known - the address the client was configured with,
361
- // the nodes the roster named - those endpoints were made known in order to
362
- // be tried, so they get one pass and no backoff, as in the other SDKs. A
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
- await this._dialWithin(socket, candidates.length > 1);
407
- if (this.ending) {
408
- socket.destroy();
409
- throw new Error('connection is closed', { cause: lastError });
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
- catch (error) {
417
- lastError = error instanceof Error
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
- debug(`reconnect reached maxRetries of ${maxRetries}`, lastError);
425
- throw new Error(`reconnect maxRetries exceeded (count: ${this.reconnectCount})`, { cause: lastError });
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
- return undefined;
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
- async redirect(host, port) {
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
- // Destroying the old socket settles any dial still waiting on it. Its
501
- // lifecycle listeners stay attached but go inert once the socket is
502
- // replaced below, so surface the drop to in-flight exchanges ourselves.
503
- this.socket.destroy();
504
- this.connected = false;
505
- this.connecting = false;
506
- this.connectPromise = undefined;
507
- this.reconnectPromise = undefined;
508
- this._endResponseWait();
509
- this.socket = this._installSocket(getTransport(redirectedConfig));
510
- this.emit('disconnected', false);
511
- await this.connect();
512
- this.config.options = redirectedOptions;
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
- finally {
515
- this.redirecting = false;
268
+ catch (error) {
269
+ this.fail(error);
516
270
  }
517
271
  }
518
- abort() {
519
- this._endResponseWait();
520
- this.socket.destroy();
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.remotePort === port &&
525
- normalizeHost(this.socket.remoteAddress) === target)
303
+ if (this.socket?.remotePort === port && normalizeHost(this.socket.remoteAddress) === target)
526
304
  return true;
527
- // A roster may advertise a DNS name while the socket reports a resolved
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._endResponseWait();
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