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