UNPKG

@ydbjs/core

Version:

Core driver for YDB: manages connections, endpoint discovery, authentication, and service client creation. Foundation for all YDB client operations.

457 lines 20.3 kB
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) { if (value !== null && value !== void 0) { if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected."); var dispose, inner; if (async) { if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined."); dispose = value[Symbol.asyncDispose]; } if (dispose === void 0) { if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined."); dispose = value[Symbol.dispose]; if (async) inner = dispose; } if (typeof dispose !== "function") throw new TypeError("Object not disposable."); if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } }; env.stack.push({ value: value, dispose: dispose, async: async }); } else if (async) { env.stack.push({ async: true }); } return value; }; var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) { return function (env) { function fail(e) { env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e; env.hasError = true; } var r, s = 0; function next() { while (r = env.stack.pop()) { try { if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next); if (r.dispose) { var result = r.dispose.call(r.value); if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); }); } else s |= 1; } catch (e) { fail(e); } } if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve(); if (env.hasError) throw env.error; } return next(); }; })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }); import * as assert from 'node:assert/strict'; import { channel as dc, tracingChannel } from 'node:diagnostics_channel'; import * as tls from 'node:tls'; import { create } from '@bufbuild/protobuf'; import { anyUnpack } from '@bufbuild/protobuf/wkt'; import { credentials } from '@grpc/grpc-js'; import { abortable, linkSignals } from '@ydbjs/abortable'; import { DiscoveryServiceDefinition, EndpointInfoSchema, ListEndpointsResultSchema, } from '@ydbjs/api/discovery'; import { StatusIds_StatusCode } from '@ydbjs/api/operation'; import { AnonymousCredentialsProvider } from '@ydbjs/auth/anonymous'; import { loggers } from '@ydbjs/debug'; import { YDBError } from '@ydbjs/error'; import { defaultRetryConfig, retry } from '@ydbjs/retry'; import { Metadata, composeClientMiddleware, createClientFactory, } from 'nice-grpc'; import pkg from '../package.json' with { type: 'json' }; import { BalancedChannel } from './channel.js'; import { GrpcConnection } from './conn.js'; import { DriverCSDatabaseError, DriverCSProtocolError, DriverDiscoveryIntervalError, DriverDiscoveryOptionsError, DriverDiscoveryTimeoutError, DriverResponseError, } from './errors.js'; import { debug, getRegisteredClientMiddlewares } from './middleware.js'; import { ConnectionPool } from './pool.js'; import { detectRuntime } from './runtime.js'; let discoveryCh = tracingChannel('tracing:ydb:driver.discovery'); let dbg = loggers.driver; function databaseFromUrl(url) { if (url.pathname && url.pathname !== '/') { return url.pathname; } if (url.searchParams.has('database')) { return url.searchParams.get('database') || ''; } return ''; } export const kRegisterLibrary = Symbol('ydbjs.core.registerLibrary'); let defaultOptions = { 'ydb.sdk.ready_timeout_ms': 30_000, 'ydb.sdk.token_timeout_ms': 10_000, 'ydb.sdk.enable_discovery': true, 'ydb.sdk.discovery_timeout_ms': 10_000, 'ydb.sdk.discovery_interval_ms': 60_000, 'ydb.sdk.connection_idle_timeout_ms': 300_000, 'ydb.sdk.connection_idle_interval_ms': 60_000, 'ydb.sdk.connection_pessimization_timeout_ms': 60_000, }; let defaultChannelOptions = { 'grpc.primary_user_agent': `ydb-js-sdk/${pkg.version}`, 'grpc.secondary_user_agent': detectRuntime(), 'grpc.keepalive_time_ms': 10_000, 'grpc.keepalive_timeout_ms': 5_000, 'grpc.keepalive_permit_without_calls': 1, 'grpc.max_send_message_length': 64 * 1024 * 1024, 'grpc.max_receive_message_length': 64 * 1024 * 1024, 'grpc.max_reconnect_backoff_ms': 5_000, 'grpc.initial_reconnect_backoff_ms': 50, }; if (!Promise.withResolvers) { Promise.withResolvers = function () { let resolve; let reject; let promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve: resolve, reject: reject }; }; } export class Driver { cs; options = {}; #pool; #ready = Promise.withResolvers(); // Single connection used when discovery is disabled or for the discovery // client itself (always contacts the initial endpoint directly). #connection; #middleware; #discoveryClient; #rediscoverTimer; // Aborted by close(); links into discovery signals so in-flight rounds // stop ASAP and don't publish ready/failed after driver.closed. #shutdown = new AbortController(); #credentialsProvider = new AnonymousCredentialsProvider(); #libraries = new Set(); #buildInfo = `ydb-js-sdk/${pkg.version}`; // Construction time, used for `driver.ready.duration` and `driver.closed.uptime`. #initAt; #readyAt; #closed = false; // Referentially stable per driver instance — subscribers (telemetry // registries, metric callbacks) key per-driver state by this reference. // A new object per `get identity()` would shatter those keys and split // every observable metric into one-shot series. #identity; constructor(connectionString, userOptions = defaultOptions) { dbg.log('Driver(connectionString: %s, options: %o)', connectionString, userOptions); this.#initAt = performance.now(); // close() rejects #ready to unblock awaiters; silence unhandled // rejection when no one observes the promise. this.#ready.promise.catch(() => { }); this.cs = this.#parseConnectionString(connectionString); this.options = this.#mergeOptions(userOptions); this.#assertDiscoveryTimings(); this.#identity = this.#buildIdentity(); let channelCredentials = this.#createChannelCredentials(); // The initial connection is always to the endpoint from the connection // string. It is used for discovery and as the sole connection when // discovery is disabled. GrpcConnection creates the channel eagerly but // grpc-js starts it in IDLE — no TCP/TLS until the first RPC. this.#connection = new GrpcConnection(this.#initialEndpoint(), channelCredentials, this.options.channelOptions); if (this.options.credentialsProvider) { this.#credentialsProvider = this.options.credentialsProvider; } this.#middleware = this.#buildMiddleware(); this.#pool = new ConnectionPool({ hooks: this.options.hooks, identity: this.identity, channelOptions: this.options.channelOptions, channelCredentials: channelCredentials, idleTimeout: this.options['ydb.sdk.connection_idle_timeout_ms'], idleInterval: this.options['ydb.sdk.connection_idle_interval_ms'], pessimizationTimeout: this.options['ydb.sdk.connection_pessimization_timeout_ms'], }); if (this.options['ydb.sdk.enable_discovery'] === false) { dbg.log('discovery disabled, using single endpoint'); this.#markReady(); } else { this.#startDiscoveryLoop(); } } get token() { let signal = AbortSignal.timeout(this.options['ydb.sdk.token_timeout_ms']); return this.#credentialsProvider.getToken(false, signal); } get database() { return databaseFromUrl(this.cs); } get isSecure() { return this.cs.protocol === 'https:' || this.cs.protocol === 'grpcs:'; } get application() { if (this.options['ydb.sdk.application']) { return this.options['ydb.sdk.application']; } if (this.cs.searchParams.has('application')) { return this.cs.searchParams.get('application') || ''; } return ''; } /** * Stable identity stamped onto every `diagnostics_channel` payload so * subscribers can attribute events to a specific Driver instance without * any AsyncLocalStorage cooperation from the publisher. * * Returns the same frozen object for the lifetime of the driver — * subscribers may use it as a Map key for per-driver state. */ get identity() { return this.#identity; } async ready(signal) { const env_1 = { stack: [], error: void 0, hasError: false }; try { dbg.log('waiting for driver to become ready'); let timeout = this.options['ydb.sdk.ready_timeout_ms']; const linkedSignal = __addDisposableResource(env_1, linkSignals(signal, AbortSignal.timeout(timeout)), false); try { await abortable(linkedSignal.signal, this.#ready.promise); dbg.log('driver is ready'); } catch (error) { dbg.log('driver failed to become ready: %O', error); throw error; } } catch (e_1) { env_1.error = e_1; env_1.hasError = true; } finally { __disposeResources(env_1); } } close() { // Idempotent — mixing an explicit `driver.close()` with `using` / // `Symbol.dispose` must not double-tear-down resources or double-fire // lifecycle events. if (this.#closed) return; this.#closed = true; clearInterval(this.#rediscoverTimer); // Cancel any in-flight discovery so its callbacks bail out instead of // publishing `ydb:driver.ready` / `ydb:driver.failed` after this point. this.#shutdown.abort(new Error('driver closed')); // Unblock anyone waiting on ready() — markReady/markFailed are now // guarded by #closed and won't settle the latch. this.#ready.reject(new Error('driver closed')); this.#pool.close(); this.#connection.close(); let uptime = this.#readyAt ? performance.now() - this.#readyAt : 0; dc('ydb:driver.closed').publish({ driver: this.identity, uptime }); dbg.log('closing driver (uptime %d ms)', uptime); } /** * Create a nice-grpc client for the given service. * * When discovery is enabled, each RPC is routed through a BalancedChannel * that acquires a connection from the pool exactly once per RPC. * * When discovery is disabled, the single initial connection is used directly * (no pool, no balancing). * * @param service gRPC service definition * @param preferNodeId Optional nodeId hint — route RPCs to this node when possible. */ createClient(service, preferNodeId) { dbg.log(`creating client for %s with preferNodeId %d`, service.fullName, preferNodeId); let channel = this.#connection.channel; if (this.options['ydb.sdk.enable_discovery'] === true) { channel = new BalancedChannel(this.#pool, this.options.hooks, preferNodeId); } return createClientFactory().use(this.#middleware).create(service, channel, { '*': this.options.channelOptions, }); } [Symbol.dispose]() { this.close(); } [Symbol.asyncDispose]() { return Promise.resolve(this.close()); } #buildIdentity() { // Drop `port` when the connection string has none rather than inventing // a default — leaks fewer wrong values into telemetry. let port = this.cs.port ? parseInt(this.cs.port, 10) : undefined; return Object.freeze({ database: this.database, address: this.cs.hostname, ...(port !== undefined && { port }), }); } #parseConnectionString(cs) { if (!cs) { throw new Error('Invalid connection string. Must be a non-empty string'); } let url = new URL(cs.replace(/^grpc/, 'http')); assert.match(url.protocol, /^(grpc|http)(s?):$/, new DriverCSProtocolError()); assert.ok(databaseFromUrl(url), new DriverCSDatabaseError()); return url; } #mergeOptions(userOptions) { let merged = { ...defaultOptions, ...userOptions }; merged.channelOptions = { ...defaultChannelOptions, ...merged.channelOptions }; return merged; } #assertDiscoveryTimings() { let timeout = this.options['ydb.sdk.discovery_timeout_ms']; let interval = this.options['ydb.sdk.discovery_interval_ms']; assert.ok(timeout > 0, new DriverDiscoveryTimeoutError(timeout)); assert.ok(interval > 0, new DriverDiscoveryIntervalError(interval)); assert.ok(timeout < interval, new DriverDiscoveryOptionsError()); } #initialEndpoint() { return create(EndpointInfoSchema, { address: this.cs.hostname, nodeId: -1, port: parseInt(this.cs.port || (this.isSecure ? '443' : '80'), 10), ssl: this.isSecure, }); } #createChannelCredentials() { if ((this.options.secureOptions ??= this.options.ssl)) { let secureContext = tls.createSecureContext(this.options.secureOptions); return credentials.createFromSecureContext(secureContext); } return this.isSecure ? credentials.createSsl() : credentials.createInsecure(); } [kRegisterLibrary](name, version) { let entry = `${name}/${version}`; if (this.#libraries.has(entry)) return; this.#libraries.add(entry); this.#buildInfo = `${this.#buildInfo};${entry}`; } #buildMiddleware() { let stamp = (call, options) => { let metadata = Metadata(options.metadata) .set('x-ydb-sdk-build-info', this.#buildInfo) .set('x-ydb-database', this.database) .set('x-ydb-application-name', this.application); return call.next(call.request, Object.assign(options, { metadata })); }; // Order: debug (logging) → stamp (SDK / db / app headers) → any // externally-registered middleware (e.g. @ydbjs/telemetry's W3C // propagator) → auth (x-ydb-auth-ticket). Auth runs last so a token // refresh that fires from inside another middleware still wins the // final header set. // // The registry snapshot is taken at construction time — call // `addClientMiddleware()` BEFORE `new Driver(...)` for it to apply. let chain = composeClientMiddleware(debug, stamp); for (let mw of getRegisteredClientMiddlewares()) { chain = composeClientMiddleware(chain, mw); } return composeClientMiddleware(chain, this.#credentialsProvider.middleware); } #startDiscoveryLoop() { let timeout = this.options['ydb.sdk.discovery_timeout_ms']; let interval = this.options['ydb.sdk.discovery_interval_ms']; dbg.log('discovery enabled, initial timeout %d ms, interval %d ms', timeout, interval); this.#discovery(this.#discoverySignal(timeout)).then(() => this.#markReady(), (error) => this.#markFailed(error)); this.#rediscoverTimer = setInterval(() => { if (this.#closed) return; void this.#discovery(this.#discoverySignal(timeout)); }, interval); // Don't keep the process alive solely for rediscovery. this.#rediscoverTimer.unref(); } #discoverySignal(timeoutMs) { // AbortSignal.any: closes when either the per-round timeout fires or // the driver shuts down — whichever comes first. return AbortSignal.any([this.#shutdown.signal, AbortSignal.timeout(timeoutMs)]); } #markReady() { // Discovery resolved after close() — drop the event so subscribers don't // see `ready` after `closed`. ready() already rejected via shutdown signal. if (this.#closed) return; this.#ready.resolve(); let duration = performance.now() - this.#initAt; dc('ydb:driver.ready').publish({ driver: this.identity, duration }); dbg.log('driver ready in %d ms', duration); } #markFailed(error) { // Same reason as #markReady — don't publish failure after `closed`. if (this.#closed) return; this.#ready.reject(error); let duration = performance.now() - this.#initAt; dc('ydb:driver.failed').publish({ driver: this.identity, duration, error }); dbg.log('driver init failed after %d ms: %O', duration, error); } async #discovery(signal) { await discoveryCh.tracePromise(() => this.#runDiscoveryRound(signal), { driver: this.identity, }); } async #runDiscoveryRound(signal) { let started = performance.now(); let retryConfig = { ...defaultRetryConfig, signal, onRetry: (ctx) => { dbg.log('retrying discovery, attempt %d, error: %O', ctx.attempt, ctx.error); this.#safeHook('onDiscoveryError', () => this.options.hooks?.onDiscoveryError?.({ error: ctx.error, attempt: ctx.attempt, duration: performance.now() - started, })); }, }; let result = await retry(retryConfig, (s) => this.#fetchEndpoints(s)); let { added, removed } = this.#pool.sync(result.endpoints); let duration = performance.now() - started; this.#safeHook('onDiscovery', () => this.options.hooks?.onDiscovery?.({ added, removed, duration, endpoints: result.endpoints.map((ep) => Object.freeze({ nodeId: BigInt(ep.nodeId), address: `${ep.address}:${ep.port}`, location: ep.location, })), })); dc('ydb:driver.discovery.completed').publish({ driver: this.identity, addedCount: added.length, totalCount: result.endpoints.length, removedCount: removed.length, duration, }); dbg.log('discovered %d endpoints (+%d / -%d) in %d ms', result.endpoints.length, added.length, removed.length, duration); } async #fetchEndpoints(signal) { let client = (this.#discoveryClient ??= createClientFactory() .use(this.#middleware) .create(DiscoveryServiceDefinition, this.#connection.channel)); let response = await client.listEndpoints({ database: this.database }, { signal }); assert.ok(response.operation, new DriverResponseError('Missing operation data.')); if (response.operation.status !== StatusIds_StatusCode.SUCCESS) { throw new YDBError(response.operation.status, response.operation.issues); } let res = anyUnpack(response.operation.result, ListEndpointsResultSchema); assert.ok(res, new DriverResponseError('Missing result in operation data.')); return res; } #safeHook(name, fn) { try { fn(); } catch (error) { dbg.log('hook %s threw an error (swallowed): %O', name, error); } } } //# sourceMappingURL=driver.js.map