| // 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 { deserializePartitionRoute, type PartitionRoute } from '../wire/partition/partition-route.js'; |
| import { ConsumerKind } from '../wire/offset/offset.utils.js'; |
| import { COMMAND_CODE } from '../wire/command.code.js'; |
| import { serializeIdentifier } from '../wire/identifier.utils.js'; |
| import { PartitionKind } from '../wire/message/partitioning.utils.js'; |
| import { |
| Command, HEADER_SIZE, REPLY_OFFSET, type PartitionContext, peekCommand, readReplyOperation, readStatus |
| } from '../wire/vsr/header.js'; |
| import { Operation, isKnownOperation } from '../wire/vsr/operation.js'; |
| import { |
| decodeVsrResponse, |
| prepareVsrCommand, |
| readRegisteredSession, |
| readWireName, |
| 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; |
| const UNAUTHENTICATED = 40; |
| const STALE_CLIENT = 30; |
| const HISTORY_UNAVAILABLE = 87; |
| const LIFECYCLE_BUSY = 88; |
| const NOT_PRIMARY = 89; |
| const INVALID_COMMAND = 3; |
| const FEATURE_UNAVAILABLE = 5; |
| /** Each lifecycle retry commits another refusal, so its pause doubles up to this. */ |
| const LIFECYCLE_RETRY_MAX_INTERVAL_MS = 1_000; |
| const MAX_PARTITION_ROUTES = 4096; |
| const MAX_POLL_CONNECTIONS = 256; |
| const POLL_OPTIONS_SIZE = 9 + 4 + 1; |
| const IDENTIFIER_PREFIX_SIZE = 2; |
| const SEND_METADATA_LENGTH_SIZE = 4; |
| const PARTITION_ID_SIZE = 4; |
| const STORE_OFFSET_SUFFIX_SIZE = 8 + 1; |
| const DELETE_OFFSET_SUFFIX_SIZE = 1; |
| const RAW_SEND_NEEDS_PARTITION = |
| 'Raw sends need an explicit partition id. Use the typed send API for Balanced or MessageKey partitioning.'; |
| |
| /** |
| * The client's own commands after which every cached send context is dropped, |
| * as the Rust SDK drops its topic discovery. Names and numeric ids can key the |
| * same topic twice, so a single entry cannot be singled out. |
| */ |
| const TOPIC_DISCOVERY_RESETS = new Set<number>([ |
| COMMAND_CODE.DeleteStream, |
| COMMAND_CODE.DeleteTopic, |
| COMMAND_CODE.CreatePartitions, |
| COMMAND_CODE.DeletePartitions |
| ]); |
| |
| type PollConnection = { |
| client: CommandResponseStream, |
| metadataWatermark?: bigint, |
| busy?: Promise<void>, |
| }; |
| |
| type RoutedAttempt = { frame?: Buffer, clientId?: bigint }; |
| |
| /** |
| * 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 = { |
| /** Partition context captured for the request, kept through its retries */ |
| context?: PartitionContext, |
| attempt?: RoutedAttempt, |
| /** 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; |
| private rememberedCredentials?: ClientCredentials; |
| private clustered?: boolean; |
| private metadataWatermark = 0n; |
| private routingGeneration = 0; |
| private partitionRoutes = new Map<string, PartitionRoute>(); |
| private replicaEndpoints = new Map<number, Endpoint>(); |
| private pollConnections = new Map<string, PollConnection>(); |
| /** |
| * A connection to a partition primary. Each request on it gets one attempt: |
| * its caller routes again instead, as the Rust SDK does. |
| */ |
| private dataConnection = false; |
| |
| /** |
| * 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 once the client has reconnected and signed in |
| * again on the node it moved to. |
| * |
| * 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); |
| this._resubmit(job).then(job.resolve, job.reject); |
| } |
| } |
| |
| /** |
| * The whole job goes back in the queue, not just its payload, and its caller |
| * still owns every retry decision. A fresh budget would let a command caught |
| * in a move take twice the response timeout, a roster read re-issued as |
| * leader-following would answer a leader check with another leader check, |
| * and a fresh context would commit work from one partition incarnation in |
| * another or refresh a send twice. |
| */ |
| private async _resubmit(job: Job): Promise<CommandResponse> { |
| if (!this.connection.connected) |
| await this.connection.connect(); |
| if (!this.isAuthenticated && !this.isUnloggedCommand(job.command)) |
| await this.authenticate(this._signInCredentials()); |
| return this._queueCommand(job.command, job.payload, job.handleResponse, |
| job.last, job.followsLeaderMoves, job.deadline, job.context, job.attempt); |
| } |
| |
| /** |
| * 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; |
| payload = Buffer.from(payload); |
| const route = partitionRoute(command, payload); |
| const routeDeadline = route || command === COMMAND_CODE.GetPartitionRoute |
| ? options.deadline ?? Date.now() + VSR_RESPONSE_TIMEOUT_MS |
| : undefined; |
| |
| if (!this.connection.connected) |
| await (routeDeadline === undefined ? this.connection.connect() |
| : withinDeadline(this.connection.connect(), routeDeadline)); |
| |
| if (!this.isAuthenticated && !this.isUnloggedCommand(command)) |
| await (routeDeadline === undefined ? this.authenticate(this._signInCredentials()) |
| : withinDeadline(this.authenticate(this._signInCredentials()), routeDeadline)); |
| |
| if (route && routeDeadline !== undefined) { |
| const deadline = routeDeadline; |
| if (this.clustered === undefined) |
| await withinDeadline(this.sendCommand(GET_CLUSTER_METADATA.code, |
| GET_CLUSTER_METADATA.serialize(), { deadline, followsLeaderMoves: false }), deadline); |
| if (this.clustered === undefined) |
| throw new Error('cannot determine the partition routing topology'); |
| return await this._sendOnPrimary(command, route, payload, deadline, |
| handleResponse, options.context); |
| } |
| |
| // 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; |
| const context = options.context; |
| let lifecycleRetryInterval = VSR_RETRY_INTERVAL_MS; |
| let response: CommandResponse; |
| let walkingRoster = false; |
| const visitedRosterEndpoints = new Set<string>(); |
| for (;;) { |
| try { |
| response = await this._queueCommand(command, payload, handleResponse, |
| last, followsLeaderMoves, deadline, context); |
| break; |
| } catch (error) { |
| // A lifecycle refusal is committed, so the request did not apply and |
| // its retry is a new request. The pause runs here, outside the queue, |
| // so it holds back no other command of this client. |
| if (error instanceof ResponseError && error.errorCode === LIFECYCLE_BUSY && |
| Date.now() + lifecycleRetryInterval < deadline) { |
| await delay(lifecycleRetryInterval); |
| lifecycleRetryInterval = Math.min(lifecycleRetryInterval * 2, LIFECYCLE_RETRY_MAX_INTERVAL_MS); |
| continue; |
| } |
| 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 && error.refusal.errorCode !== NOT_PRIMARY, |
| visitedRosterEndpoints, |
| error.refusal.errorCode !== NOT_PRIMARY |
| ); |
| 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._signInCredentials()); |
| } |
| } |
| if (TOPIC_DISCOVERY_RESETS.has(command)) { |
| this.partitionRoutes.clear(); |
| this.emit('topicDiscoveryReset'); |
| } |
| 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 async _lookupRoute(partition: PartitionTarget, deadline: number): Promise<PartitionRoute> { |
| const cached = this.partitionRoutes.get(partition.key); |
| if (cached && cached.context.metadataOp >= this.metadataWatermark) |
| return cached; |
| const response = await this._routingControl(partition, deadline); |
| return deserializePartitionRoute(response.data); |
| } |
| |
| private _rememberRoute(query: Buffer, response: CommandResponse): void { |
| const route = deserializePartitionRoute(response.data); |
| if (this.partitionRoutes.size >= MAX_PARTITION_ROUTES) |
| this.partitionRoutes.clear(); |
| this.partitionRoutes.set(query.toString('hex'), route); |
| this.emit('partitionRoute', { topic: query.subarray(0, -PARTITION_ID_SIZE).toString('hex'), |
| count: route.partitionsCount }); |
| } |
| |
| private _queueCommand( |
| command: number, |
| payload: Buffer, |
| handleResponse: boolean, |
| last: boolean, |
| followsLeaderMoves: boolean, |
| deadline: number, |
| context?: PartitionContext, |
| attempt?: RoutedAttempt |
| ): Promise<CommandResponse> { |
| return new Promise<CommandResponse>((resolve, reject) => { |
| const job: Job = { |
| command, |
| context, |
| attempt, |
| 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>(), |
| allowWalk = true |
| ): 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. |
| if (!allowWalk) |
| return false; |
| 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); |
| if (metadata.nodes.length === 0) |
| return; |
| this.clustered = metadata.nodes.length > 1; |
| this.replicaEndpoints = new Map(metadata.nodes |
| .filter((node) => node.endpoints.tcp !== 0) |
| .map((node) => [node.replicaId, { host: node.ip, port: node.endpoints.tcp }])); |
| 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); |
| } |
| } |
| |
| private async _sendOnPrimary( |
| command: number, |
| partition: PartitionTarget, |
| payload: Buffer, |
| deadline: number, |
| handleResponse: boolean, |
| context?: PartitionContext |
| ): Promise<CommandResponse> { |
| let captured = context; |
| let refreshedHistory = false; |
| let refusedPrimary = false; |
| let lifecycleRetryInterval = VSR_RETRY_INTERVAL_MS; |
| const attempt: RoutedAttempt = {}; |
| while (Date.now() < deadline && !this.connection.ending) { |
| let missingReplica = false; |
| try { |
| const generation = this.routingGeneration; |
| const route = await this._lookupRoute(partition, deadline); |
| if (generation !== this.routingGeneration) |
| throw responseError(command, TRANSIENT_NOT_ACCEPTED); |
| captured ??= route.context; |
| if (!this.clustered) |
| return await this._queueCommand(command, payload, handleResponse, true, false, |
| deadline, captured, attempt); |
| let endpoint = this.replicaEndpoints.get(route.primaryReplica); |
| if (!endpoint) { |
| await this.sendCommand(GET_CLUSTER_METADATA.code, GET_CLUSTER_METADATA.serialize(), |
| { deadline, followsLeaderMoves: false }); |
| endpoint = this.replicaEndpoints.get(route.primaryReplica); |
| if (!endpoint) { |
| missingReplica = true; |
| throw responseError(command, TRANSIENT_NOT_ACCEPTED); |
| } |
| } |
| const key = endpointKey(endpoint); |
| let entry = this.pollConnections.get(key); |
| if (!entry) { |
| if (this.pollConnections.size >= MAX_POLL_CONNECTIONS) { |
| const idle = Array.from(this.pollConnections).find(([, value]) => !value.busy); |
| if (!idle) |
| throw responseError(command, TRANSIENT_NOT_ACCEPTED); |
| this.pollConnections.delete(idle[0]); |
| idle[1].client.destroy(); |
| } |
| entry = { |
| client: new CommandResponseStream({ |
| ...this.options, |
| options: { ...this.options.options, ...endpoint }, |
| heartbeatInterval: 0, |
| reconnect: { enabled: false, interval: 0, maxRetries: 0 } |
| }) |
| }; |
| entry.client.vsrSession = this.vsrSession; |
| entry.client.dataConnection = true; |
| this.pollConnections.set(key, entry); |
| } |
| while (entry.busy) |
| await withinDeadline(entry.busy, deadline); |
| if (generation !== this.routingGeneration || this.pollConnections.get(key) !== entry) |
| throw responseError(command, TRANSIENT_NOT_ACCEPTED); |
| let release!: () => void; |
| entry.busy = new Promise<void>((resolve) => { release = resolve; }); |
| let sent = false; |
| try { |
| if (!entry.client.isAuthenticated) { |
| entry.metadataWatermark = undefined; |
| await withinDeadline(entry.client.connection.connect(true), |
| Math.min(deadline, Date.now() + VSR_FAILOVER_CHECK_MS), |
| () => entry.client.destroy()); |
| } |
| const watermark = route.context.metadataOp > this.metadataWatermark |
| ? route.context.metadataOp : this.metadataWatermark; |
| if (entry.metadataWatermark === undefined || entry.metadataWatermark < watermark) { |
| const binding = this.vsrSession.resumePayload(watermark); |
| if (!binding) |
| throw responseError(command, UNAUTHENTICATED); |
| await entry.client._queueCommand(COMMAND_CODE.BindSession, binding, |
| true, true, false, deadline); |
| entry.metadataWatermark = watermark; |
| } |
| if (generation !== this.routingGeneration) |
| throw responseError(command, TRANSIENT_NOT_ACCEPTED); |
| sent = true; |
| return await entry.client._queueCommand(command, payload, handleResponse, |
| true, false, deadline, captured, attempt); |
| } catch (error) { |
| if (!(error instanceof ResponseError) || error instanceof VsrEvictionError || |
| error.errorCode === UNAUTHENTICATED || error.errorCode === STALE_CLIENT) { |
| if (this.pollConnections.get(key) === entry) |
| this.pollConnections.delete(key); |
| entry.client.destroy(); |
| throw responseError(command, sent ? TRANSIENT_NOT_COMMITTED : TRANSIENT_NOT_ACCEPTED); |
| } |
| throw error; |
| } finally { |
| entry.busy = undefined; |
| release(); |
| } |
| } catch (error) { |
| this.partitionRoutes.delete(partition.key); |
| if (missingReplica || (attempt.clientId !== undefined && attempt.clientId !== this.vsrSession.clientId && |
| error instanceof ResponseError && error.errorCode !== TRANSIENT_NOT_COMMITTED)) |
| throw responseError(command, TRANSIENT_NOT_ACCEPTED); |
| if (error instanceof ResponseError && error.commandCode === command && error.errorCode === LIFECYCLE_BUSY && |
| Date.now() + lifecycleRetryInterval < deadline) { |
| await delay(lifecycleRetryInterval); |
| lifecycleRetryInterval = Math.min(lifecycleRetryInterval * 2, LIFECYCLE_RETRY_MAX_INTERVAL_MS); |
| attempt.frame = undefined; |
| continue; |
| } |
| if (command === COMMAND_CODE.SendMessages && error instanceof ResponseError && |
| error.errorCode === HISTORY_UNAVAILABLE && !context && !refreshedHistory && |
| worthAnotherAttempt(deadline)) { |
| refreshedHistory = true; |
| const topic = partition.query.subarray(0, -PARTITION_ID_SIZE).toString('hex'); |
| for (const key of this.partitionRoutes.keys()) |
| if (key.slice(0, -PARTITION_ID_SIZE * 2) === topic) |
| this.partitionRoutes.delete(key); |
| captured = undefined; |
| attempt.frame = undefined; |
| continue; |
| } |
| if (error instanceof VsrResponseTimeoutError) |
| throw responseError(command, attempt.frame ? TRANSIENT_NOT_COMMITTED : TRANSIENT_NOT_ACCEPTED); |
| if (!(error instanceof ResponseError) || |
| (error.errorCode !== TRANSIENT_NOT_ACCEPTED && error.errorCode !== NOT_PRIMARY)) |
| throw error instanceof ResponseError && !(error instanceof VsrEvictionError) |
| ? responseError(command, error.errorCode) : error; |
| if (!worthAnotherAttempt(deadline)) |
| throw responseError(command, error.errorCode); |
| if (error.errorCode !== NOT_PRIMARY || refusedPrimary) |
| await delay(VSR_RETRY_INTERVAL_MS); |
| refusedPrimary ||= error.errorCode === NOT_PRIMARY; |
| } |
| } |
| throw responseError(command, TRANSIENT_NOT_ACCEPTED); |
| } |
| |
| private async _routingControl(partition: PartitionTarget, deadline: number): Promise<CommandResponse> { |
| if (!this.connection.connected || !this.isAuthenticated) |
| throw responseError(COMMAND_CODE.GetPartitionRoute, TRANSIENT_NOT_ACCEPTED); |
| try { |
| return await this._queueCommand(COMMAND_CODE.GetPartitionRoute, partition.query, |
| true, true, false, deadline); |
| } catch (error) { |
| if (!(error instanceof ResponseError) || |
| error.errorCode === UNAUTHENTICATED || error.errorCode === STALE_CLIENT) { |
| this.connection.abort(); |
| this.connection.connected = false; |
| this._resetSession(); |
| } |
| throw error; |
| } |
| } |
| |
| private _observeMetadataReply(frame: Buffer): void { |
| if (frame.length < HEADER_SIZE || peekCommand(frame) !== Command.Reply) |
| return; |
| const operation = readReplyOperation(frame); |
| if (!isKnownOperation(operation) || operation === Operation.NonReplicated || |
| operation === Operation.SendMessages || operation === Operation.StoreConsumerOffset || |
| operation === Operation.DeleteConsumerOffset) |
| return; |
| const commit = frame.readBigUInt64LE(REPLY_OFFSET.commit); |
| if (commit > this.metadataWatermark) |
| this.metadataWatermark = commit; |
| } |
| |
| private _rememberCredentials(command: number, payload: Buffer): void { |
| if (command === LOGIN.code) { |
| const username = readWireName(payload, 0); |
| this.rememberedCredentials = { |
| username: username.value, password: readWireName(payload, username.next).value |
| }; |
| } else if (command === LOGIN_WITH_TOKEN.code) { |
| this.rememberedCredentials = { token: readWireName(payload, 0).value }; |
| } else if ((command === COMMAND_CODE.ChangePassword || command === COMMAND_CODE.UpdateUser) && |
| this.userId !== undefined && this.rememberedCredentials && |
| 'username' in this.rememberedCredentials) { |
| const credentials = this.rememberedCredentials; |
| const identifier = [serializeIdentifier(this.userId), serializeIdentifier(credentials.username)] |
| .find((encoded) => payload.subarray(0, encoded.length).equals(encoded)); |
| if (!identifier) |
| return; |
| if (command === COMMAND_CODE.ChangePassword) { |
| const currentPassword = readWireName(payload, identifier.length); |
| const password = readWireName(payload, currentPassword.next).value; |
| this.rememberedCredentials = { ...credentials, password }; |
| if ('username' in this.options.credentials && this.options.credentials.username === credentials.username) |
| this.options.credentials = { ...this.options.credentials, password }; |
| } else if (payload[identifier.length] === 1) { |
| const username = readWireName(payload, identifier.length + 1).value; |
| this.rememberedCredentials = { ...credentials, username }; |
| if ('username' in this.options.credentials && this.options.credentials.username === credentials.username) |
| this.options.credentials = { ...this.options.credentials, username }; |
| } |
| } |
| } |
| |
| private _signInCredentials(): ClientCredentials { |
| return this.rememberedCredentials ?? this.options.credentials; |
| } |
| |
| private _clearPollRouting(): void { |
| this.routingGeneration += 1; |
| this.partitionRoutes.clear(); |
| this.replicaEndpoints.clear(); |
| for (const entry of this.pollConnections.values()) |
| entry.client.destroy(); |
| this.pollConnections.clear(); |
| } |
| |
| /** |
| * 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, next.context, next.attempt)); |
| } 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, |
| context?: PartitionContext, |
| attempt?: RoutedAttempt |
| ): Promise<CommandResponse> { |
| if (isLoginCommand(command)) |
| return this._processVsrLogin(command, payload, handleResp, deadline); |
| return this._processVsr(command, payload, handleResp, deadline, context, attempt); |
| } |
| |
| private async _processVsrLogin( |
| command: number, |
| payload: Buffer, |
| handleResp: boolean, |
| deadline: number |
| ): Promise<CommandResponse> { |
| if (this.isAuthenticated) { |
| await this._processVsr(LOGOUT.code, LOGOUT.serialize(), true, deadline); |
| } else { |
| const resume = this.vsrSession.resumePayload(this.metadataWatermark); |
| if (resume) { |
| const remembered = this.rememberedCredentials; |
| const sameCredentials = remembered && ('token' in remembered |
| ? command === LOGIN_WITH_TOKEN.code && payload.equals(LOGIN_WITH_TOKEN.serialize(remembered)) |
| : command === LOGIN.code && payload.equals(LOGIN.serialize(remembered))); |
| if (sameCredentials) { |
| try { |
| return await this._processVsr(COMMAND_CODE.BindSession, resume, handleResp, deadline); |
| } catch (error) { |
| if (!(error instanceof ResponseError) || |
| (error.errorCode !== UNAUTHENTICATED && error.errorCode !== STALE_CLIENT)) |
| throw error; |
| } |
| } |
| this._resetSession(true); |
| } |
| } |
| return this._processVsr(command, payload, handleResp, deadline); |
| } |
| |
| private async _processVsr( |
| command: number, |
| payload: Buffer, |
| handleResp: boolean, |
| deadline: number, |
| context?: PartitionContext, |
| attempt?: RoutedAttempt |
| ): Promise<CommandResponse> { |
| let requestWritten = false; |
| try { |
| if (attempt?.clientId !== undefined && attempt.clientId !== this.vsrSession.clientId) |
| throw responseError(command, TRANSIENT_NOT_ACCEPTED); |
| if (command === COMMAND_CODE.GetPartitionRoute) |
| context = { incarnation: 0n, ownerGeneration: 0n, metadataOp: this.metadataWatermark }; |
| const prepared = prepareVsrCommand(command, payload, this.vsrSession.bindSecret); |
| // A transient retry must preserve all request identity fields. |
| const frame = attempt?.frame ?? this.vsrSession.encode(prepared.command, prepared.payload, context); |
| if (attempt) { |
| attempt.frame = frame; |
| attempt.clientId = this.vsrSession.clientId; |
| } |
| // 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; |
| } |
| this._observeMetadataReply(response); |
| try { |
| if (!handleResp) { |
| // Routing still needs refusals when the caller decodes the frame. |
| if ((this.dataConnection || attempt) && peekCommand(response) === Command.Reply && |
| readStatus(response) !== 0) |
| throw responseError(command, readStatus(response)); |
| return response as unknown as CommandResponse; |
| } |
| parsed = decodeVsrResponse(response, command); |
| break; |
| } catch (error) { |
| if (error instanceof ResponseError && |
| lastTransientError?.errorCode === TRANSIENT_NOT_COMMITTED) |
| error = responseError(command, TRANSIENT_NOT_COMMITTED); |
| if (!(error instanceof ResponseError) || |
| !isTransientVsrError(error.errorCode)) |
| throw error; |
| if (command === COMMAND_CODE.GetPartitionRoute || command === COMMAND_CODE.BindSession || |
| ((this.dataConnection || attempt) && |
| (command === COMMAND_CODE.PollMessages || command === COMMAND_CODE.GetOffset)) || |
| ((this.dataConnection || attempt) && error.errorCode !== TRANSIENT_NOT_COMMITTED)) |
| throw error; |
| if (error.errorCode === NOT_PRIMARY) |
| throw new LeaderMovedError(error); |
| // A later refusal cannot resolve an earlier uncertain attempt. |
| lastTransientError = lastTransientError?.errorCode === TRANSIENT_NOT_COMMITTED |
| ? lastTransientError : error; |
| if (lastTransientError.errorCode === TRANSIENT_NOT_ACCEPTED && |
| !isLoginCommand(command) && |
| Date.now() >= notAcceptedDeadline) |
| throw new LeaderMovedError(lastTransientError); |
| const retryDelay = Math.min( |
| VSR_RETRY_INTERVAL_MS, |
| Math.max(0, deadline - Date.now()) |
| ); |
| if (retryDelay === 0) |
| throw lastTransientError; |
| await delay(retryDelay); |
| } |
| } |
| |
| if (prepared.command === COMMAND_CODE.LoginRegister || |
| prepared.command === COMMAND_CODE.LoginRegisterWithAccessToken || |
| prepared.command === COMMAND_CODE.BindSession) { |
| this.vsrSession.bind(readRegisteredSession(parsed)); |
| this.isAuthenticated = true; |
| this.userId = parsed.data.readUInt32LE(0); |
| } |
| this._rememberCredentials(command, payload); |
| if (prepared.command === COMMAND_CODE.LogoutUser) { |
| this.rememberedCredentials = undefined; |
| this._resetSession(true); |
| } |
| // 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); |
| if (handleResp && command === COMMAND_CODE.GetPartitionRoute) |
| this._rememberRoute(payload, 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. Drop the transport while |
| // retaining the logical session and its bind proof. |
| 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(forget = false): void { |
| this._clearPollRouting(); |
| this.clustered = undefined; |
| if (!this.isAuthenticated && |
| this.userId === undefined && |
| !this.vsrSession.hasActivity) |
| return; |
| this.isAuthenticated = false; |
| this.userId = undefined; |
| if (forget) |
| 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() { |
| this.rememberedCredentials = undefined; |
| this._clearPollRouting(); |
| 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 withinDeadline = async <T>( |
| pending: Promise<T>, deadline: number, expired?: () => void |
| ): Promise<T> => { |
| let timer: NodeJS.Timeout | undefined; |
| try { |
| return await Promise.race([ |
| pending, |
| new Promise<never>((_resolve, reject) => { |
| timer = setTimeout(() => { |
| expired?.(); |
| reject(new VsrResponseTimeoutError(VSR_RESPONSE_TIMEOUT_MS)); |
| }, Math.max(0, deadline - Date.now())); |
| }) |
| ]); |
| } finally { |
| clearTimeout(timer); |
| } |
| }; |
| |
| const isTransientVsrError = (errorCode: number): boolean => |
| errorCode === TRANSIENT_NOT_COMMITTED || |
| errorCode === TRANSIENT_NOT_ACCEPTED || errorCode === NOT_PRIMARY; |
| |
| type PartitionTarget = { |
| query: Buffer, |
| key: string |
| }; |
| |
| const partitionRoute = (command: number, payload: Buffer): PartitionTarget | undefined => { |
| if (command === COMMAND_CODE.SendMessages) { |
| const { query } = sendTarget(payload); |
| return { query, key: query.toString('hex') }; |
| } |
| if (command !== COMMAND_CODE.PollMessages && command !== COMMAND_CODE.GetOffset && |
| command !== COMMAND_CODE.StoreOffset && command !== COMMAND_CODE.DeleteConsumerOffset) |
| return undefined; |
| let position = 1; |
| const consumerEnd = identifierEnd(payload, position, command); |
| const streamEnd = identifierEnd(payload, consumerEnd, command); |
| position = identifierEnd(payload, streamEnd, command); |
| const suffix = command === COMMAND_CODE.PollMessages ? POLL_OPTIONS_SIZE |
| : command === COMMAND_CODE.StoreOffset ? STORE_OFFSET_SUFFIX_SIZE |
| : command === COMMAND_CODE.DeleteConsumerOffset ? DELETE_OFFSET_SUFFIX_SIZE : 0; |
| if (payload.length !== position + 1 + PARTITION_ID_SIZE + suffix || payload[position] > 1) |
| throw responseError(command, INVALID_COMMAND); |
| if (payload[position] === 0) { |
| if (command === COMMAND_CODE.StoreOffset || command === COMMAND_CODE.DeleteConsumerOffset || |
| (command === COMMAND_CODE.PollMessages && payload[0] === ConsumerKind.Group)) |
| throw responseError(command, INVALID_COMMAND); |
| payload[position] = 1; |
| payload.writeUInt32LE(0, position + 1); |
| } |
| const query = Buffer.concat([payload.subarray(consumerEnd, position), |
| payload.subarray(position + 1, position + 1 + PARTITION_ID_SIZE)]); |
| return { query, key: query.toString('hex') }; |
| }; |
| |
| const identifierEnd = (payload: Buffer, position: number, command: number): number => { |
| if (position + IDENTIFIER_PREFIX_SIZE > payload.length) |
| throw responseError(command, INVALID_COMMAND); |
| const end = position + IDENTIFIER_PREFIX_SIZE + payload[position + 1]; |
| if (end > payload.length) |
| throw responseError(command, INVALID_COMMAND); |
| return end; |
| }; |
| |
| type SendTarget = { |
| /** `GetPartitionRoute` body: stream, topic and partition id */ |
| query: Buffer |
| }; |
| |
| /** |
| * Reads the explicit partition a raw send writes to. A Balanced or MessageKey |
| * send names no partition, so there is no context to capture for it, and the |
| * server refuses a send without one. |
| */ |
| const sendTarget = (payload: Buffer): SendTarget => { |
| let position = SEND_METADATA_LENGTH_SIZE; |
| for (let identifier = 0; identifier < 2; identifier++) { |
| if (position + IDENTIFIER_PREFIX_SIZE > payload.length) |
| throw responseError(COMMAND_CODE.SendMessages, INVALID_COMMAND); |
| position += IDENTIFIER_PREFIX_SIZE + payload[position + 1]; |
| } |
| if (position + IDENTIFIER_PREFIX_SIZE > payload.length) |
| throw responseError(COMMAND_CODE.SendMessages, INVALID_COMMAND); |
| if (payload[position] !== PartitionKind.PartitionId) |
| throw responseError(COMMAND_CODE.SendMessages, FEATURE_UNAVAILABLE, RAW_SEND_NEEDS_PARTITION); |
| const partition = position + IDENTIFIER_PREFIX_SIZE; |
| if (payload[position + 1] !== PARTITION_ID_SIZE || partition + PARTITION_ID_SIZE > payload.length) |
| throw responseError(COMMAND_CODE.SendMessages, INVALID_COMMAND); |
| const identifiers = payload.subarray(SEND_METADATA_LENGTH_SIZE, position); |
| return { |
| query: Buffer.concat([identifiers, payload.subarray(partition, partition + PARTITION_ID_SIZE)]) |
| }; |
| }; |