UNPKG

@ydbjs/core

Version:

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

241 lines 11.3 kB
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 } 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 { ClientError, Metadata, Status, composeClientMiddleware, createClientFactory, waitForChannelReady, } from 'nice-grpc'; import pkg from '../package.json' with { type: 'json' }; import { LazyConnection } from './conn.js'; import { debug } from './middleware.js'; import { ConnectionPool } from './pool.js'; import { detectRuntime } from './runtime.js'; let dbg = loggers.driver; const 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, }; const defaultChannelOptions = { 'grpc.primary_user_agent': `ydb-js-sdk/${pkg.version}`, 'grpc.secondary_user_agent': detectRuntime(), 'grpc.keepalive_time_ms': 30_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.initial_reconnect_backoff_ms': 50, 'grpc.max_reconnect_backoff_ms': 5_000, }; if (!Promise.withResolvers) { Promise.withResolvers = function () { let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve: resolve, reject: reject }; }; } export class Driver { cs; options = {}; #pool; #ready = Promise.withResolvers(); #connection; #middleware; #credentialsProvider = new AnonymousCredentialsProvider(); #discoveryClient; #rediscoverTimer; constructor(connectionString, options = defaultOptions) { dbg.log('Driver(connectionString: %s, options: %o)', connectionString, options); if (!connectionString) { throw new Error('Invalid connection string. Must be a non-empty string'); } this.cs = new URL(connectionString.replace(/^grpc/, 'http')); this.options = Object.assign({}, defaultOptions, options); this.options.secureOptions ??= this.options.ssl; // Merge default channel options with user-provided options this.options.channelOptions = Object.assign({}, defaultChannelOptions, this.options.channelOptions); if (['grpc:', 'grpcs:', 'http:', 'https:'].includes(this.cs.protocol) === false) { throw new Error('Invalid connection string protocol. Must be one of grpc, grpcs, http, https'); } if (this.cs.pathname === '' && this.cs.searchParams.has('database') === false) { throw new Error('Invalid connection string. Database name is required'); } if (this.cs.searchParams.has('application') === false) { this.cs.searchParams.set('application', this.options['ydb.sdk.application'] || ''); } else { this.options['ydb.sdk.application'] ??= this.cs.searchParams.get('application') || ''; } let discoveryInterval = this.options['ydb.sdk.discovery_interval_ms'] ?? defaultOptions['ydb.sdk.discovery_interval_ms']; let discoveryTimeout = this.options['ydb.sdk.discovery_timeout_ms'] ?? defaultOptions['ydb.sdk.discovery_timeout_ms']; if (discoveryInterval < discoveryTimeout) { throw new Error('Discovery interval must be greater than discovery timeout.'); } let endpoint = create(EndpointInfoSchema, { address: this.cs.hostname, nodeId: -1, port: parseInt(this.cs.port || (this.isSecure ? '443' : '80'), 10), ssl: this.isSecure, }); let channelCredentials = this.options.secureOptions ? credentials.createFromSecureContext(tls.createSecureContext(this.options.secureOptions)) : this.isSecure ? credentials.createSsl() : credentials.createInsecure(); this.#connection = new LazyConnection(endpoint, channelCredentials, this.options.channelOptions); this.#middleware = debug; this.#middleware = composeClientMiddleware(this.#middleware, (call, options) => { let metadata = Metadata(options.metadata) .set('x-ydb-database', this.database) .set('x-ydb-application-name', this.options['ydb.sdk.application'] || ''); return call.next(call.request, Object.assign(options, { metadata })); }); if (this.options.credentialsProvider) { this.#credentialsProvider = this.options.credentialsProvider; this.#middleware = composeClientMiddleware(this.#middleware, this.#credentialsProvider.middleware); } this.#pool = new ConnectionPool(channelCredentials, this.options.channelOptions); this.#discoveryClient = createClientFactory() .use(this.#middleware) .create(DiscoveryServiceDefinition, this.#connection.channel); if (this.options['ydb.sdk.enable_discovery'] === false) { dbg.log('discovery disabled, using single endpoint'); waitForChannelReady(this.#connection.channel, new Date(Date.now() + (this.options['ydb.sdk.ready_timeout_ms'] || 10000))) .then(() => { dbg.log('single endpoint ready'); this.#ready.resolve(); }) .catch((error) => { dbg.log('single endpoint failed to become ready: %O', error); this.#ready.reject(error); }); } if (this.options['ydb.sdk.enable_discovery'] === true) { dbg.log('discovery enabled, using connection pool'); // Initial discovery dbg.log('starting initial discovery with timeout %d ms', this.options['ydb.sdk.discovery_timeout_ms']); this.#discovery(AbortSignal.timeout(this.options['ydb.sdk.discovery_timeout_ms'])) .then(() => { dbg.log('initial discovery completed successfully'); this.#ready.resolve(); }) .catch((error) => { dbg.log('initial discovery failed: %O', error); this.#ready.reject(error); }); // Periodic discovery dbg.log('setting up periodic discovery every %d ms', this.options['ydb.sdk.discovery_interval_ms'] || defaultOptions['ydb.sdk.discovery_interval_ms']); this.#rediscoverTimer = setInterval(() => { dbg.log('starting periodic discovery'); void this.#discovery(AbortSignal.timeout(this.options['ydb.sdk.discovery_timeout_ms'])); }, this.options['ydb.sdk.discovery_interval_ms'] || defaultOptions['ydb.sdk.discovery_interval_ms']); // Unref the timer so it doesn't keep the process running this.#rediscoverTimer.unref(); } } get token() { let signal = AbortSignal.timeout(this.options['ydb.sdk.token_timeout_ms']); return this.#credentialsProvider.getToken(false, signal); } get database() { if (this.cs.pathname && this.cs.pathname !== '/') { return this.cs.pathname; } if (this.cs.searchParams.has('database')) { return this.cs.searchParams.get('database') || ''; } return ''; } get isSecure() { return this.cs.protocol === 'https:' || this.cs.protocol === 'grpcs:'; } async #discovery(signal) { dbg.log('starting discovery for database: %s', this.database); let retryConfig = { ...defaultRetryConfig, signal, onRetry: (ctx) => { dbg.log('retrying discovery, attempt %d, error: %O', ctx.attempt, ctx.error); }, }; let result = await retry(retryConfig, async (signal) => { dbg.log('attempting to list endpoints for database: %s', this.database); let response = await this.#discoveryClient.listEndpoints({ database: this.database }, { signal }); if (!response.operation) { throw new ClientError(DiscoveryServiceDefinition.listEndpoints.path, Status.UNKNOWN, 'No operation in response'); } if (response.operation.status !== StatusIds_StatusCode.SUCCESS) { throw new YDBError(response.operation.status, response.operation.issues); } let result = anyUnpack(response.operation.result, ListEndpointsResultSchema); if (!result) { throw new ClientError(DiscoveryServiceDefinition.listEndpoints.path, Status.UNKNOWN, 'No result in operation'); } dbg.log('discovery successful, received %d endpoints: %O', result.endpoints.length, result.endpoints); return result; }); for (let endpoint of result.endpoints) { this.#pool.add(endpoint); } dbg.log('connection pool updated successfully'); } async ready(signal) { dbg.log('waiting for driver to become ready'); signal = signal ? AbortSignal.any([signal, AbortSignal.timeout(this.options['ydb.sdk.ready_timeout_ms'])]) : AbortSignal.timeout(this.options['ydb.sdk.ready_timeout_ms']); try { await abortable(signal, this.#ready.promise); dbg.log('driver is ready'); } catch (error) { dbg.log('driver failed to become ready: %O', error); throw error; } } close() { dbg.log('closing driver'); if (this.#rediscoverTimer) { dbg.log('clearing discovery timer'); clearInterval(this.#rediscoverTimer); } dbg.log('closing connection pool'); this.#pool.close(); dbg.log('closing primary connection'); this.#connection.close(); dbg.log('driver closed'); } createClient(service, preferNodeId) { dbg.log(`creating client for %s${preferNodeId ? ` with preferNodeId: ${preferNodeId}` : ''}`, service.fullName || service.name); return createClientFactory() .use(this.#middleware) .create(service, new Proxy(this.#connection.channel, { get: (target, propertyKey) => { let channel = this.options['ydb.sdk.enable_discovery'] ? this.#pool.acquire(preferNodeId).channel : target; return Reflect.get(channel, propertyKey, channel); }, }), { '*': this.options.channelOptions, }); } [Symbol.dispose]() { this.close(); } [Symbol.asyncDispose]() { return Promise.resolve(this.close()); } } //# sourceMappingURL=driver.js.map