@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
JavaScript
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