| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License. |
| |
| import { EventEmitter } from 'node:events'; |
| import type { |
| ClientConfig, |
| ClientConfigOrString, |
| ClientCredentials, CommandResponse, |
| PasswordCredentials, RawClient, SendCommandOptions, |
| TokenCredentials |
| } from '../client/client.type.js'; |
| import { ResponseError, responseError } from '../wire/error.utils.js'; |
| import { debug } from './client.debug.js'; |
| import { |
| endpointKey, |
| type Endpoint, |
| IggyConnection |
| } from './client.connection.js'; |
| import { LOGIN, LOGIN_WITH_TOKEN, LOGOUT, PING } from '../wire/index.js'; |
| import { GET_CLUSTER_METADATA } from '../wire/cluster/get-cluster-metadata.command.js'; |
| import { COMMAND_CODE } from '../wire/command.code.js'; |
| import { |
| decodeVsrResponse, |
| prepareVsrCommand, |
| readRegisteredSession, |
| VsrEvictionError, |
| VsrSession, |
| } from '../wire/vsr/index.js'; |
| import { normalizeClientConfig } from './client.config.js'; |
| |
| const VSR_RESPONSE_TIMEOUT_MS = 30_000; |
| const VSR_RETRY_INTERVAL_MS = 50; |
| const LEADERLESS_WAIT_BUDGET_MS = 5_000; |
| const LEADERLESS_POLL_INTERVAL_MS = 250; |
| const MAX_LEADER_REDIRECTS = 3; |
| const TRANSIENT_NOT_COMMITTED = 57; |
| const TRANSIENT_NOT_ACCEPTED = 58; |
| /** |
| * How long a `TRANSIENT_NOT_ACCEPTED` request replays on the same connection |
| * before the roster is re-read. A node that stopped being primary refuses |
| * forever, so replaying alone never recovers. Matches the Rust SDK. |
| */ |
| const VSR_FAILOVER_CHECK_MS = 2_000; |
| |
| /** |
| * Whether a request's budget still holds enough for another attempt. One |
| * exchange needs at least a replay interval to be worth starting; below that |
| * the attempt can only end in a timeout, which would hide the refusal that |
| * actually came back. |
| */ |
| const worthAnotherAttempt = (deadline: number): boolean => |
| deadline - Date.now() > VSR_RETRY_INTERVAL_MS; |
| |
| /** |
| * A request the current node keeps refusing as not-admitted. Carries the |
| * refusal so the caller can surface it when the roster turns out to still name |
| * this node as the leader. Never escapes `sendCommand`. |
| */ |
| class LeaderMovedError extends Error { |
| constructor(readonly refusal: ResponseError) { |
| super('the node refused the request as not-admitted; re-reading the roster'); |
| } |
| } |
| |
| /** |
| * How a leader move resolved: onto the metadata leader, onto the next roster |
| * node (for a request the metadata leader itself keeps refusing), or not at |
| * all. |
| */ |
| type RosterWalkVerdict = { |
| endpoint: Endpoint, |
| moved: boolean |
| }; |
| type LeaderMoveVerdict = 'leader' | RosterWalkVerdict | false; |
| |
| /** |
| * Command codes that can be executed without authentication. |
| */ |
| const UNLOGGED_COMMAND_CODE = [ |
| PING.code, |
| LOGIN.code, |
| LOGIN_WITH_TOKEN.code |
| ]; |
| |
| /** |
| * Represents a queued command job waiting to be executed. |
| */ |
| type Job = { |
| /** Command code */ |
| command: number, |
| /** Command payload */ |
| payload: Buffer, |
| /** Whether to parse the response */ |
| handleResponse: boolean, |
| /** Whether the command is appended rather than prepended to the queue */ |
| last: boolean, |
| /** Whether a not-admitted refusal re-checks the leader and re-issues */ |
| followsLeaderMoves: boolean, |
| /** When the whole request gives up, however often it is re-issued */ |
| deadline: number, |
| /** Promise resolve function */ |
| resolve: (v: CommandResponse | PromiseLike<CommandResponse>) => void, |
| /** Promise reject function */ |
| reject: (e: unknown) => void |
| }; |
| |
| type ExchangeState = { |
| written: boolean |
| }; |
| |
| export class VsrResponseTimeoutError extends Error { |
| constructor(timeout: number) { |
| super(`timed out after ${timeout} ms waiting for VSR response`); |
| this.name = 'VsrResponseTimeoutError'; |
| Object.setPrototypeOf(this, VsrResponseTimeoutError.prototype); |
| } |
| } |
| |
| /** |
| * Manages command execution and response handling for the Iggy server. |
| * Implements command queuing, authentication, and heartbeat functionality. |
| */ |
| export class CommandResponseStream extends EventEmitter { |
| /** Client configuration */ |
| private options: ClientConfig; |
| /** Underlying connection to the server */ |
| private connection: IggyConnection; |
| /** Queue of pending command jobs */ |
| private _execQueue: Job[]; |
| /** Consensus session used by VSR framing */ |
| private vsrSession: VsrSession; |
| /** Shared authentication attempt for concurrent callers */ |
| private authenticationPromise?: Promise<boolean>; |
| /** Whether a login is already being moved to the leader */ |
| private settlingLeader: boolean; |
| /** |
| * The leader re-check a refused request started, shared with every other |
| * request refused by the same node so one demotion moves the client once. |
| */ |
| private leaderMoveInFlight?: Promise<LeaderMoveVerdict>; |
| /** |
| * Set when a roster walk just redirected this client, so the login that |
| * re-authenticates it stays on the dialed node instead of settling back on |
| * the metadata leader whose partition replica refused the request. |
| */ |
| private walkSettleSuppressed = false; |
| /** |
| * Refusals handed out to callers that have not decided what to do with them |
| * yet. The queue holds while any are outstanding: the caller of a refused |
| * command re-checks the leader, and a command written in the meantime goes |
| * out on the socket that check is about to replace. |
| */ |
| private leaderMovesUndecided: number; |
| /** How long a leaderless roster is polled before settling in place */ |
| private leaderlessWaitBudget: number; |
| /** Delay between roster reads while the cluster elects */ |
| private leaderlessPollInterval: number; |
| /** Calls that have acquired this stream but have not fully settled */ |
| private pendingSubmissions: number; |
| /** Whether the stream is currently processing a command */ |
| public busy: boolean; |
| /** Whether the client has been authenticated */ |
| isAuthenticated: boolean; |
| /** Authenticated user ID */ |
| userId?: number; |
| /** Heartbeat interval timer handle */ |
| heartbeatIntervalHandler?: NodeJS.Timeout; |
| /** Whether a heartbeat ping is still awaiting its response */ |
| private heartbeatInFlight: boolean; |
| |
| /** |
| * Creates a new CommandResponseStream. |
| * |
| * @param options - Client configuration |
| */ |
| constructor(options: ClientConfigOrString) { |
| super(); |
| const normalizedConfig = normalizeClientConfig(options); |
| this.options = normalizedConfig; |
| this.connection = new IggyConnection(normalizedConfig); |
| this.busy = false; |
| this.isAuthenticated = false; |
| this._execQueue = []; |
| this.vsrSession = new VsrSession(); |
| this.authenticationPromise = undefined; |
| this.settlingLeader = false; |
| this.leaderMovesUndecided = 0; |
| this.leaderlessWaitBudget = LEADERLESS_WAIT_BUDGET_MS; |
| this.leaderlessPollInterval = LEADERLESS_POLL_INTERVAL_MS; |
| this.pendingSubmissions = 0; |
| this.heartbeatInFlight = false; |
| this._init(); |
| }; |
| |
| /** |
| * Initializes the stream by setting up heartbeat and connection event handlers. |
| */ |
| _init() { |
| this.heartbeat(this.options.heartbeatInterval); |
| this.connection.on('error', (error: Error) => { |
| this._failQueue(error); |
| }); |
| this.connection.on('eviction', (error: VsrEvictionError) => { |
| this._resetSession(); |
| this.emit('eviction', error); |
| }); |
| this.connection.on('disconnected', () => { |
| this._resetSession(); |
| if (this.connection.redirecting) { |
| // The client is moving to the leader, which is its own doing: a queued |
| // command has not been written, so it belongs on the node being moved |
| // to rather than in an error. |
| this._reissueQueue(); |
| return; |
| } |
| this._failQueue( |
| new Error('connection closed before queued commands were sent') |
| ); |
| }); |
| } |
| |
| /** |
| * Re-submits queued commands through the full send path, so each one |
| * reconnects, re-authenticates and re-checks the leader as if it had just |
| * been called. |
| * |
| * Only for a drop the client caused. Nothing here was written, so there is no |
| * outcome in doubt: a command still in the queue when the socket is replaced |
| * would otherwise fail with a lost-connection error the caller can do nothing |
| * about. |
| */ |
| private _reissueQueue(): void { |
| const queued = this._execQueue; |
| this._execQueue = []; |
| for (const job of queued) { |
| debug('re-issuing a queued command after a leader move', job.command); |
| // The whole job, not just the payload: a fresh budget would let a command |
| // caught in a move take twice the response timeout, and a roster read |
| // re-issued as leader-following would answer a leader check with another |
| // leader check. |
| this.sendCommand(job.command, job.payload, { |
| handleResponse: job.handleResponse, |
| last: job.last, |
| followsLeaderMoves: job.followsLeaderMoves, |
| deadline: job.deadline |
| }).then(job.resolve, job.reject); |
| } |
| } |
| |
| /** |
| * Sends a command to the server. |
| * Automatically handles connection, authentication and leader settlement. |
| * |
| * @param command - Command code to send |
| * @param payload - Command payload buffer |
| * @param options - Response and queue options |
| * @returns Promise resolving to the command response |
| */ |
| async sendCommand( |
| command: number, |
| payload: Buffer, |
| options: SendCommandOptions = {} |
| ): Promise<CommandResponse> { |
| this.pendingSubmissions += 1; |
| try { |
| const { |
| handleResponse = true, |
| last = true, |
| followsLeaderMoves = true |
| } = options; |
| |
| if (!this.connection.connected) |
| await this.connection.connect() |
| |
| if (!this.isAuthenticated && !this.isUnloggedCommand(command)) |
| await this.authenticate(this.options.credentials); |
| |
| // The roster read is itself a queued command and the queue is |
| // single-flighted, so the leader re-check cannot happen inside |
| // `_processVsr`. The refusal comes back out here instead, where the |
| // queue is free, and the command is re-issued on the node that now |
| // leads. |
| // |
| // A not-admitted refusal means the request was never applied, so it is |
| // re-issued for the whole request budget rather than given up on after |
| // one window: the roster can still name this node -- an election in |
| // flight, a leader that has not moved yet -- and that is a wait, not a |
| // verdict. |
| // |
| // One budget for the whole request: the transient replays on a |
| // connection, the leader re-checks, and the re-issues after a move all |
| // spend it, so a request cannot outlive it by moving. A command re-issued |
| // after a move keeps the budget it was first submitted with, rather than |
| // opening a second one. |
| const deadline = options.deadline ?? Date.now() + VSR_RESPONSE_TIMEOUT_MS; |
| let response: CommandResponse; |
| let walkingRoster = false; |
| const visitedRosterEndpoints = new Set<string>(); |
| for (;;) { |
| try { |
| response = await this._queueCommand(command, payload, handleResponse, |
| last, followsLeaderMoves, deadline); |
| break; |
| } catch (error) { |
| if (!(error instanceof LeaderMovedError)) |
| throw error; |
| // The roster read that a re-check runs is itself a command that can |
| // be refused this way, and answering a leader check with another |
| // leader check would recurse. Its caller reads a failure as "stay |
| // where you are". |
| // |
| // A budget too small to carry another attempt ends it here, with the |
| // refusal the server actually gave: re-issued into what is left, the |
| // request would time out instead and the caller would see a timeout |
| // where the answer was "not admitted". |
| let moved: LeaderMoveVerdict = false; |
| try { |
| if (!followsLeaderMoves || !worthAnotherAttempt(deadline)) |
| throw responseError(command, error.refusal.errorCode); |
| // Once this request starts walking the roster it keeps walking: a |
| // leader recheck between hops would put it straight back on the |
| // metadata leader whose partition replica refused it, and the |
| // walk would bounce between two nodes without reaching the rest. |
| moved = await this._followLeaderMove( |
| walkingRoster, |
| visitedRosterEndpoints |
| ); |
| if (typeof moved === 'object') { |
| walkingRoster = true; |
| visitedRosterEndpoints.add(endpointKey(moved.endpoint)); |
| } |
| } finally { |
| // Released as soon as the move is decided, before the pace below |
| // and before any re-authentication: those go through the queue |
| // themselves, and a queue still held for this refusal would never |
| // reach them. |
| if (followsLeaderMoves) |
| this._releaseUndecidedMove(); |
| } |
| const connectionMoved = moved === 'leader' || |
| (typeof moved === 'object' && moved.moved); |
| if (!connectionMoved) { |
| // Nowhere else to go yet: the roster still names this node, or it |
| // could not be read. Paced, because the in-connection replay |
| // window belongs to the request's budget and has already been |
| // spent -- re-issuing straight away would spin. |
| await delay(Math.min( |
| VSR_FAILOVER_CHECK_MS, |
| Math.max(0, deadline - Date.now()) |
| )); |
| } |
| if (!worthAnotherAttempt(deadline)) |
| throw responseError(command, error.refusal.errorCode); |
| // A move drops the session with the socket it was bound to, so the |
| // re-issue would otherwise go out under no session: a replicated |
| // command fails client-side, a non-replicated one goes out with |
| // session 0. |
| if (!this.isAuthenticated && !this.isUnloggedCommand(command)) |
| await this.authenticate(this.options.credentials); |
| } |
| } |
| if (!isLoginCommand(command) || this.settlingLeader) |
| return response; |
| // A login that re-authenticates a roster walk stays on the dialed node: |
| // settling would put the client back on the metadata leader whose |
| // partition replica refused the walked request. One login only. |
| if (this.walkSettleSuppressed) { |
| this.walkSettleSuppressed = false; |
| return response; |
| } |
| this.settlingLeader = true; |
| try { |
| const settled = await this._settleOnLeader(command, payload); |
| return settled ?? response; |
| } finally { |
| this.settlingLeader = false; |
| } |
| } finally { |
| this.pendingSubmissions -= 1; |
| this._emitFinishQueue(); |
| } |
| } |
| |
| private _queueCommand( |
| command: number, |
| payload: Buffer, |
| handleResponse: boolean, |
| last: boolean, |
| followsLeaderMoves: boolean, |
| deadline: number |
| ): Promise<CommandResponse> { |
| return new Promise<CommandResponse>((resolve, reject) => { |
| const job: Job = { |
| command, |
| payload, |
| handleResponse, |
| last, |
| followsLeaderMoves, |
| deadline, |
| resolve, |
| reject |
| }; |
| if (last) |
| this._execQueue.push(job); |
| else |
| this._execQueue.unshift(job); |
| this._processQueue(); |
| }); |
| } |
| |
| /** |
| * Re-reads the roster and moves to the leader it names. |
| * |
| * Best effort: an unreadable roster, or one that still names this node, |
| * leaves the client where it is and the refused request is re-issued anyway. |
| * |
| * Single-flighted, and concurrent callers share the outcome instead of |
| * failing: several commands are refused by the same demoted node, and each |
| * starting its own redirect would move the client once per command. The |
| * first redirect's `'disconnected'` also fails the others' roster reads, so |
| * a caller that raced one would report a refusal it never had to. |
| * |
| * @returns Whether the client moved |
| */ |
| private _followLeaderMove( |
| walkPastLeader = false, |
| visitedRosterEndpoints = new Set<string>() |
| ): Promise<LeaderMoveVerdict> { |
| const inFlight = this.leaderMoveInFlight; |
| if (inFlight) |
| return inFlight; |
| |
| const move = (async (): Promise<LeaderMoveVerdict> => { |
| try { |
| if (!walkPastLeader) { |
| const leader = await this._readLeaderEndpoint(); |
| if (leader && !this.connection.isConnectedTo(leader.host, leader.port)) { |
| debug(`the leader moved to ${leader.host}:${leader.port}, following it`); |
| await this.connection.redirect(leader.host, leader.port); |
| return 'leader'; |
| } |
| } |
| // The roster names this node as the metadata leader (or said nothing |
| // usable), yet it keeps refusing to admit the request: its replica of |
| // the target partition group is not that group's primary, because |
| // metadata and partition consensus groups elect independently. Walk |
| // the roster instead of re-issuing into the same refusal. |
| const next = this.connection.nextRosterEndpoint(visitedRosterEndpoints); |
| if (!next) |
| return false; |
| debug( |
| 'the request keeps being refused here, walking the roster to ' + |
| `${next.host}:${next.port}` |
| ); |
| // The re-authentication after this redirect runs a login, and a login |
| // normally settles on the metadata leader, which would put the walk |
| // right back on the node that refused. One login only. |
| this.walkSettleSuppressed = true; |
| try { |
| await this.connection.redirect(next.host, next.port); |
| } catch (error) { |
| // The suppression belongs to the redirect above. If that redirect |
| // never lands, a later unrelated login must settle normally. |
| this.walkSettleSuppressed = false; |
| debug('the roster endpoint could not be reached', error); |
| return { endpoint: next, moved: false }; |
| } |
| return { endpoint: next, moved: true }; |
| } catch (error) { |
| debug('the leader could not be re-checked, staying on this node', error); |
| return false; |
| } |
| })(); |
| this.leaderMoveInFlight = move; |
| void move.finally(() => { |
| if (this.leaderMoveInFlight !== move) |
| return; |
| this.leaderMoveInFlight = undefined; |
| // The drain stopped while the move was being decided. A move that |
| // happened re-issues what was held back on the new socket; one that did |
| // not leaves it here, with nothing else due to pick it up. |
| if (!this.connection.redirecting) |
| void this._processQueue(); |
| }); |
| return move; |
| } |
| |
| /** Whether a leader move is being decided or carried out. */ |
| private _movePending(): boolean { |
| return this.leaderMovesUndecided > 0 || this.leaderMoveInFlight !== undefined; |
| } |
| |
| /** |
| * Releases the queue hold one refusal took, and drains what was held back |
| * once the last of them is decided. |
| */ |
| private _releaseUndecidedMove(): void { |
| if (this.leaderMovesUndecided > 0) |
| this.leaderMovesUndecided -= 1; |
| if (this._movePending() || this.connection.redirecting) |
| return; |
| void this._processQueue(); |
| } |
| |
| private _rememberRoster(response: CommandResponse): void { |
| try { |
| const metadata = GET_CLUSTER_METADATA.deserialize(response); |
| this.connection.rememberRoster( |
| metadata.nodes |
| .filter((node) => node.endpoints.tcp !== 0) |
| .map((node) => ({ host: node.ip, port: node.endpoints.tcp })) |
| ); |
| } catch (error) { |
| debug('an unreadable roster leaves the redial candidates as they are', |
| error); |
| } |
| } |
| |
| /** |
| * Processes queued commands sequentially. |
| * Emits 'finishQueue' when all commands are processed. |
| * |
| */ |
| async _processQueue(): Promise<void> { |
| if (this.busy) |
| return; |
| this.busy = true; |
| while (this._execQueue.length > 0 && this.connection.socket.writable) { |
| // While a leader move is being decided, only the roster read the move |
| // itself runs goes out -- it is what decides where the client lands, and |
| // it is the one command that does not follow moves. Draining the rest |
| // would write them to the socket `redirect()` is about to replace, and a |
| // command in flight when that happens dies with a lost-connection error |
| // instead of being re-issued on the node the move lands on. |
| const index = this._movePending() |
| ? this._execQueue.findIndex((job) => !job.followsLeaderMoves) |
| : 0; |
| if (index < 0) break; |
| const [next] = this._execQueue.splice(index, 1); |
| if (!next) break; |
| const { command, payload, handleResponse, deadline, resolve, reject } = next; |
| try { |
| resolve(await this._processNext(command, payload, handleResponse, deadline)); |
| } catch (err) { |
| if (err instanceof LeaderMovedError && next.followsLeaderMoves) |
| // Counted before the rejection is handed out, not after: the caller |
| // resumes as a microtask, so this loop would otherwise write the next |
| // command before the re-check it is about to start has begun. |
| this.leaderMovesUndecided += 1; |
| reject(err); |
| } |
| } |
| if (this._execQueue.length > 0) { |
| // The same distinction as on 'disconnected': the socket a leader move |
| // replaced stops being writable, and what is still queued belongs on the |
| // node being moved to. |
| if (this.connection.redirecting) |
| this._reissueQueue(); |
| else if (!this._movePending()) |
| this._failQueue(new Error('connection is not writable')); |
| // Otherwise the move is still being decided: these commands were never |
| // written, and they are drained again once it settles -- here if the |
| // client stays, on the new socket if it moves. |
| } |
| this.busy = false; |
| this._emitFinishQueue(); |
| } |
| |
| private _emitFinishQueue(): void { |
| if (this.pendingSubmissions === 0 && |
| !this.busy && |
| this._execQueue.length === 0) |
| this.emit('finishQueue'); |
| } |
| |
| /** |
| * Processes a single command by writing it to the connection and waiting for response. |
| * |
| * @param command - Command code |
| * @param payload - Command payload |
| * @param handleResp - Whether to parse the response |
| * @param deadline - When the whole request gives up, shared with the leader |
| * re-checks and the re-issues after a move |
| * @returns Promise resolving to the command response |
| */ |
| _processNext( |
| command: number, |
| payload: Buffer, |
| handleResp = true, |
| deadline = Date.now() + VSR_RESPONSE_TIMEOUT_MS |
| ): Promise<CommandResponse> { |
| if (isLoginCommand(command) && this.isAuthenticated) |
| return this._processVsrLogin(command, payload, handleResp, deadline); |
| return this._processVsr(command, payload, handleResp, deadline); |
| } |
| |
| private async _processVsrLogin( |
| command: number, |
| payload: Buffer, |
| handleResp: boolean, |
| deadline: number |
| ): Promise<CommandResponse> { |
| await this._processVsr(LOGOUT.code, LOGOUT.serialize(), true, deadline); |
| return this._processVsr(command, payload, handleResp, deadline); |
| } |
| |
| private async _processVsr( |
| command: number, |
| payload: Buffer, |
| handleResp: boolean, |
| deadline: number |
| ): Promise<CommandResponse> { |
| let requestWritten = false; |
| try { |
| const prepared = prepareVsrCommand(command, payload); |
| // A transient retry must preserve all request identity fields. |
| const frame = this.vsrSession.encode(prepared.command, prepared.payload); |
| // Derived from the request's own budget rather than read off the clock, |
| // so one request spends one budget however many times it is re-issued. |
| const notAcceptedDeadline = |
| deadline - VSR_RESPONSE_TIMEOUT_MS + VSR_FAILOVER_CHECK_MS; |
| let lastTransientError: ResponseError | undefined; |
| let parsed: CommandResponse; |
| while (true) { |
| const remaining = deadline - Date.now(); |
| if (remaining <= 0) { |
| if (lastTransientError) |
| throw lastTransientError; |
| throw new VsrResponseTimeoutError(VSR_RESPONSE_TIMEOUT_MS); |
| } |
| const exchangeState = { written: false }; |
| let response: Buffer; |
| try { |
| response = await this._exchange( |
| () => this.connection.writeFrame(frame), |
| remaining, |
| exchangeState |
| ); |
| } finally { |
| requestWritten ||= exchangeState.written; |
| } |
| if (!handleResp) |
| return response as unknown as CommandResponse; |
| try { |
| parsed = decodeVsrResponse(response, command); |
| break; |
| } catch (error) { |
| if (!(error instanceof ResponseError) || |
| !isTransientVsrError(error.errorCode)) |
| throw error; |
| lastTransientError = error; |
| // A not-admitted refusal is a statement about who leads, not about |
| // load: a node that stopped being primary refuses forever, so |
| // replaying on this connection never recovers. Hand it back for a |
| // roster re-read once the window is spent. Not-committed (57) stays |
| // here: the request is in flight on this very node, and its outcome |
| // is unknown anywhere else. |
| if (error.errorCode === TRANSIENT_NOT_ACCEPTED && |
| !isLoginCommand(command) && |
| Date.now() >= notAcceptedDeadline) |
| throw new LeaderMovedError(error); |
| const retryDelay = Math.min( |
| VSR_RETRY_INTERVAL_MS, |
| Math.max(0, deadline - Date.now()) |
| ); |
| if (retryDelay === 0) |
| throw error; |
| await delay(retryDelay); |
| } |
| } |
| |
| if (prepared.command === COMMAND_CODE.LoginRegister || |
| prepared.command === COMMAND_CODE.LoginRegisterWithAccessToken) { |
| this.vsrSession.bind(readRegisteredSession(parsed)); |
| this.isAuthenticated = true; |
| this.userId = parsed.data.readUInt32LE(0); |
| } |
| if (prepared.command === COMMAND_CODE.LogoutUser) { |
| this._resetSession(); |
| } |
| // Every roster read feeds the redial candidates, whoever asked for it |
| // and whatever it says: a node dies together with its address, the |
| // roster is unreachable exactly when it is needed, and reading it only |
| // during a login would leave the candidates stale between logins. |
| if (handleResp && command === GET_CLUSTER_METADATA.code) |
| this._rememberRoster(parsed); |
| return parsed; |
| } catch (error) { |
| // A not-admitted refusal is an answer, so the session is not in doubt |
| // and the request was never applied. |
| if (error instanceof LeaderMovedError) |
| throw error; |
| // Once bytes were handed to the socket, a local transport or decode |
| // failure leaves the request outcome ambiguous. Register a fresh session |
| // rather than replaying that request under a different client identity. |
| if (!(error instanceof ResponseError) && requestWritten) |
| this._resetSession(); |
| if (error instanceof VsrEvictionError) |
| throw error; |
| if (error instanceof ResponseError) |
| throw responseError(command, error.errorCode); |
| throw error; |
| } |
| } |
| |
| private _exchange( |
| write: () => void, |
| timeout?: number, |
| state?: ExchangeState |
| ): Promise<Buffer> { |
| return new Promise((resolve, reject) => { |
| let timeoutHandler: NodeJS.Timeout | undefined; |
| const cleanup = () => { |
| if (timeoutHandler) |
| clearTimeout(timeoutHandler); |
| this.connection.removeListener('error', errorCallback); |
| this.connection.removeListener('disconnected', disconnectedCallback); |
| this.connection.removeListener('eviction', evictionCallback); |
| this.connection.removeListener('response', responseCallback); |
| }; |
| const errorCallback = (error: unknown) => { |
| cleanup(); |
| reject(error); |
| }; |
| const disconnectedCallback = () => { |
| cleanup(); |
| reject(new Error('connection closed while waiting for response')); |
| }; |
| const evictionCallback = (error: VsrEvictionError) => { |
| cleanup(); |
| reject(error); |
| }; |
| const responseCallback = (response: Buffer) => { |
| cleanup(); |
| resolve(response); |
| }; |
| if (timeout !== undefined) { |
| timeoutHandler = setTimeout(() => { |
| cleanup(); |
| this.connection.abort(); |
| reject(new VsrResponseTimeoutError(timeout)); |
| }, timeout); |
| } |
| this.connection.once('error', errorCallback); |
| this.connection.once('disconnected', disconnectedCallback); |
| this.connection.once('eviction', evictionCallback); |
| this.connection.once('response', responseCallback); |
| try { |
| write(); |
| if (state) |
| state.written = true; |
| } catch (error) { |
| cleanup(); |
| reject(error); |
| } |
| }); |
| } |
| |
| // `GetClusterMetadata` is deliberately absent: the server auth-gates it, |
| // so the client authenticates before reading the topology. A login dialed |
| // at a backup still succeeds because the server forwards the register to |
| // the primary. |
| private isUnloggedCommand(command: number): boolean { |
| return UNLOGGED_COMMAND_CODE.includes(command); |
| } |
| |
| /** |
| * Moves a freshly authenticated session to the cluster leader. |
| * |
| * Only the leader accepts replicated commands, and the roster read is |
| * auth-gated, so the topology cannot be inspected before a login binds a |
| * session. The redirect drops that session along with the socket, so the |
| * login is replayed on the leader and its answer supersedes the one from the |
| * node the client dialed. Leadership can move between the roster read and |
| * the replay, so each freshly bound hop rechecks the roster under a bounded |
| * redirect budget. |
| * |
| * @returns The leader's login response, or undefined when the client stays |
| */ |
| private async _settleOnLeader( |
| loginCommand: number, |
| loginPayload: Buffer |
| ): Promise<CommandResponse | undefined> { |
| let settledResponse: CommandResponse | undefined; |
| for (let redirects = 0; redirects < MAX_LEADER_REDIRECTS; redirects += 1) { |
| const leader = await this._readLeaderEndpoint(); |
| if (!leader || this.connection.isConnectedTo(leader.host, leader.port)) |
| return settledResponse; |
| await this.connection.redirect(leader.host, leader.port); |
| settledResponse = await this.sendCommand( |
| loginCommand, |
| loginPayload, |
| { last: false } |
| ); |
| } |
| debug( |
| `leader settlement reached its ${MAX_LEADER_REDIRECTS}-hop budget, ` + |
| 'staying on the current node' |
| ); |
| return settledResponse; |
| } |
| |
| /** |
| * Reads the cluster roster and picks the endpoint to settle on. |
| * |
| * Best effort: an unreadable roster, `Unauthenticated` included (the session |
| * died between the login and this read), keeps the client on its current |
| * node instead of failing a login that already succeeded. |
| */ |
| private async _readLeaderEndpoint(): Promise<Endpoint | undefined> { |
| // A cluster can be transiently leaderless: a restarted node cedes the |
| // primaryship its stale view assigns it, and the roster reports no leader |
| // until the peers' election completes. That window is roughly one heartbeat |
| // timeout, so poll through it rather than settling on a replica that denies |
| // every replicated command for its whole retry budget. |
| const deadline = Date.now() + this.leaderlessWaitBudget; |
| while (true) { |
| // Reading without a session would re-enter authentication, which awaits |
| // the very login this settlement runs inside of. The session can also die |
| // between polls, so this holds for every pass, not just the first. |
| if (!this.isAuthenticated) |
| return undefined; |
| try { |
| // Queue the metadata fetch instead of writing directly: a bare write |
| // would race an in-flight exchange and both would wake on the same |
| // response event. |
| const response = await this.sendCommand( |
| GET_CLUSTER_METADATA.code, |
| GET_CLUSTER_METADATA.serialize(), |
| { last: false, followsLeaderMoves: false } |
| ); |
| // The redial candidates are fed by `_processVsr` for every roster |
| // read, leaderless ones included: a roster with no leader still names |
| // where the nodes are. |
| const metadata = GET_CLUSTER_METADATA.deserialize(response); |
| if (metadata.nodes.length <= 1) |
| return undefined; |
| const leader = metadata.nodes.find( |
| (node) => node.role === 'Leader' && node.status === 'Healthy' |
| ); |
| if (leader) |
| return { host: leader.ip, port: leader.endpoints.tcp }; |
| } catch (error) { |
| debug('cluster metadata is unreadable, staying on this node', error); |
| return undefined; |
| } |
| if (Date.now() >= deadline) { |
| debug( |
| 'cluster metadata named no healthy leader within ' + |
| `${this.leaderlessWaitBudget} ms, staying on this node` |
| ); |
| return undefined; |
| } |
| await delay(this.leaderlessPollInterval); |
| } |
| } |
| |
| /** |
| * Fails all queued commands with the given error. |
| * |
| * @param err - Error to reject all queued commands with |
| */ |
| _failQueue(err: Error) { |
| this._execQueue.forEach(({ reject }) => reject(err)); |
| this._execQueue = []; |
| } |
| |
| private _resetSession(): void { |
| if (!this.isAuthenticated && |
| this.userId === undefined && |
| !this.vsrSession.hasActivity) |
| return; |
| this.isAuthenticated = false; |
| this.userId = undefined; |
| this.vsrSession.reset(); |
| this.emit('sessionReset'); |
| } |
| |
| hold(): () => void { |
| this.pendingSubmissions += 1; |
| let released = false; |
| return () => { |
| if (released) |
| return; |
| released = true; |
| this.pendingSubmissions -= 1; |
| this._emitFinishQueue(); |
| }; |
| } |
| |
| /** |
| * Authenticates the client with the server. |
| * |
| * @param creds - Authentication credentials (token or password) |
| * @returns True if authentication succeeded |
| */ |
| async authenticate(creds: ClientCredentials): Promise<boolean> { |
| if (this.isAuthenticated) |
| return true; |
| if (this.authenticationPromise) |
| return this.authenticationPromise; |
| |
| this.authenticationPromise = this._authenticate(creds); |
| try { |
| return await this.authenticationPromise; |
| } finally { |
| this.authenticationPromise = undefined; |
| } |
| } |
| |
| private async _authenticate(creds: ClientCredentials): Promise<boolean> { |
| const r = ('token' in creds) ? |
| await this._authWithToken(creds) : |
| await this._authWithPassword(creds); |
| this.isAuthenticated = true; |
| this.userId = r.userId; |
| return this.isAuthenticated; |
| } |
| |
| /** |
| * Authenticates using username and password. |
| * |
| * @param creds - Password credentials |
| * @returns Login response with user ID |
| */ |
| async _authWithPassword(creds: PasswordCredentials) { |
| const pl = LOGIN.serialize(creds); |
| const logr = await this.sendCommand(LOGIN.code, pl, { last: false }); |
| return LOGIN.deserialize(logr); |
| } |
| |
| /** |
| * Authenticates using a token. |
| * |
| * @param creds - Token credentials |
| * @returns Login response with user ID |
| */ |
| async _authWithToken(creds: TokenCredentials) { |
| const pl = LOGIN_WITH_TOKEN.serialize(creds); |
| const logr = await this.sendCommand( |
| LOGIN_WITH_TOKEN.code, |
| pl, |
| { last: false } |
| ); |
| return LOGIN_WITH_TOKEN.deserialize(logr); |
| } |
| |
| /** |
| * Sends a ping command to the server. |
| * |
| * @returns Ping response |
| */ |
| async ping() { |
| const pl = PING.serialize(); |
| const pingR = await this.sendCommand(PING.code, pl); |
| return PING.deserialize(pingR); |
| } |
| |
| /** |
| * Starts sending periodic heartbeat pings to keep the connection alive. |
| * |
| * @param interval - Heartbeat interval in milliseconds |
| */ |
| heartbeat(interval?: number) { |
| if (!interval) |
| return |
| |
| this.heartbeatIntervalHandler = setInterval(async () => { |
| if (this.connection.connected && !this.heartbeatInFlight) { |
| this.heartbeatInFlight = true; |
| debug(`sending heartbeat ping (interval: ${interval} ms)`); |
| try { |
| await this.ping() |
| this.emit('heartbeat'); |
| } catch (error) { |
| debug('heartbeat ping failed', error); |
| } finally { |
| this.heartbeatInFlight = false; |
| } |
| } |
| }, interval); |
| // A pending heartbeat must not be the reason the process stays up: a script |
| // that never calls destroy() would otherwise hang on exit. |
| this.heartbeatIntervalHandler.unref(); |
| } |
| |
| /** |
| * Returns the underlying socket as a readable stream. |
| * |
| * @returns The connection socket |
| */ |
| getReadStream() { |
| return this.connection.socket; |
| } |
| |
| /** |
| * Destroys the stream and cleans up resources. |
| * Stops heartbeat and destroys the connection. |
| */ |
| destroy() { |
| if (this.heartbeatIntervalHandler) |
| clearInterval(this.heartbeatIntervalHandler); |
| return this.connection._destroy(); |
| } |
| }; |
| |
| |
| /** |
| * Creates a new RawClient instance. |
| * |
| * @param options - Client configuration |
| * @returns RawClient instance |
| */ |
| export function getRawClient(options: ClientConfigOrString): RawClient { |
| return new CommandResponseStream(options); |
| } |
| |
| const isLoginCommand = (command: number): boolean => |
| command === COMMAND_CODE.LoginUser || |
| command === COMMAND_CODE.LoginWithAccessToken; |
| |
| const delay = (milliseconds: number): Promise<void> => |
| new Promise((resolve) => setTimeout(resolve, milliseconds)); |
| |
| const isTransientVsrError = (errorCode: number): boolean => |
| errorCode === TRANSIENT_NOT_COMMITTED || |
| errorCode === TRANSIENT_NOT_ACCEPTED; |