@dieugene/ydb-serverless
Version:
676 lines (617 loc) • 28.6 kB
JavaScript
const {
Driver,
MetadataAuthService,
TypedData, TypedValues, Types, Ydb, BulkUpsertSettings, OperationParams, ExecuteQuerySettings
} = require('ydb-sdk');
const authService = new MetadataAuthService();
function env_includes(str = '') {
let scope = process.env.YDB_LOG;
return (typeof scope === 'string') && scope.includes(str)
}
/**
* Логирует сообщения, если включено логирование YDB
* @param args
*/
function log(...args) {
if (env_includes('YDB')) console.log(...args)
}
/**
* Проверяет, отключено ли мониторинг запросов
* @returns {boolean} true, если мониторинг запросов отключен
*/
function is_query_monitoring_off() {
return env_includes('QUERY_MONITORING_OFF')
}
/**
* Отправляет критическое сообщение через внешний логгер
* @param {string} message - сообщение для отправки
* @param {...any} args - дополнительные аргументы
*/
function send(message, ...args) {
try {
let logger = require("@dieugene/logger")('YDB');
logger.critical(message, ...args);
} catch (e) {
console.log('YDB :: LOGGER :: SEND ERROR ::', e.message)
}
}
const driver_map = new Map();
/**
* Получает или создает драйвер для подключения к YDB
* @param {string} database - путь к базе данных
* @returns {Promise<Driver>} экземпляр драйвера YDB
*/
async function get_driver(database) {
if (!driver_map.has(database)) {
let driver = new Driver({
endpoint: 'grpcs://ydb.serverless.yandexcloud.net:2135',
database,
authService
}), timeout = 10000;
log('Driver initializing...');
if (!await driver.ready(timeout)) {
log(`Driver has not become ready in ${timeout}ms!`);
process.exit(1);
}
driver_map.set(database, driver);
}
return driver_map.get(database);
}
function timeout(time = 150) {
return new Promise(resolve => setTimeout(resolve, time));
}
/**
* Инициализирует модуль для работы с YDB
* @param {string} database - путь к базе данных (по умолчанию из переменной окружения YDB_ADDRESS)
* @returns {Object} объект с методами для работы с YDB
*/
function init(database = process.env.YDB_ADDRESS) {
let driver, isReady = false;
/**
* Инициализирует драйвер для подключения к базе данных
* @returns {Promise<Driver>} экземпляр драйвера
*/
async function init_driver() {
if (!driver) driver = await get_driver(database);
return driver;
}
/**
* Уничтожает соединение с базой данных
* @returns {Promise<void>}
*/
async function destroy() {
if (!isReady) return;
// await driver.destroy();
isReady = false;
}
/**
* Определяет тип YDB на основе типа JavaScript значения
* @param {any} value - значение для определения типа
* @returns {Object} тип YDB
*/
function get_ydb_type(value) {
//TypedValues.list(Types.UTF8, ['a','b'])
let result = Types.VOID;
switch (typeof value) {
case 'string' : result = Types.UTF8; break;
case 'number' : result = Types.UINT64; break;
case 'boolean': result = Types.BOOL; break;
case 'object' :
switch (value.ydb_data_type) {
case 'datetime': result = Types.DATETIME; break;
default: result = Types.JSON; break;
}
break;
}
return result;
}
/**
* Создает типизированное значение для YDB
* @param {any} value - значение для типизации
* @param {boolean} unfold_arrays - флаг развертывания массивов
* @returns {TypedValue} типизированное значение
*/
function get_typed_value(value, unfold_arrays = false) {
let result = value;
if (Array.isArray(value) && value.length > 0 && unfold_arrays) {
let first_value = value[0];
if (typeof first_value === 'object'){
switch (first_value.ydb_data_type) {
case 'datetime':
if (typeof first_value.value === 'string') value = value.map(v => new Date(v.value));
else value = value.map(v => v.value);
break;
default:
value = value.map(v => JSON.stringify(v));
break;
}
}
return TypedValues.list(get_ydb_type(first_value), value);
}
switch (typeof value) {
case 'string' : result = TypedValues.utf8(value); break;
case 'number' : result = TypedValues.uint64(value); break;
case 'boolean': result = TypedValues.bool(value); break;
case 'object' :
log('NORMALIZE OBJECT', JSON.stringify(value));
switch (value.ydb_data_type) {
case 'datetime':
if (typeof value.value === 'string') value.value = new Date(value.value);
result = TypedValues.datetime(value.value);
break;
default:
result = TypedValues.json(JSON.stringify(value));
break;
}
log('NORMALIZE OBJECT :: RESULT', JSON.stringify(result));
break;
}
return result;
}
/**
* Нормализует параметры для передачи в YDB запросы
* @param {Object} params - объект с параметрами
* @returns {Object} нормализованные параметры с типизированными значениями
*/
function normalizeParams(params) {
if (!params) return;
log('YDB :: NORMALIZING PARAMS :: ', JSON.stringify(params));
/*
unfold_arrays передается в объекте params. На его основе определяется,
следует ли сериализовывать массивы "как есть", или сериализовывать элементы массива.
Используется в ситуациях, когда происходит передача данных для пакетной вставки записей
*/
let result = {}, unfold_arrays = !!params.unfold_arrays;
for (let p in params) {
if (p !== 'unfold_arrays')
result[p] = get_typed_value(params[p], unfold_arrays);
}
log('YDB :: NORMALIZED PARAMS :: ', JSON.stringify(result));
return result
}
/**
* Выполняет SELECT запрос к YDB и возвращает результат
* @param {string} query - YQL запрос для выполнения
* @param {Object} params - параметры запроса
* @param {boolean} tryAgain - флаг повторной попытки при ошибке
* @param {boolean} make_log - флаг логирования запроса
* @returns {Promise<Array>} массив результатов запроса
*/
async function execute(query, params, tryAgain = false, make_log = true) {
/**
* Выполняет сессию без параметров
* @param {Session} session - сессия YDB
*/
async function runSessionNoParams(session) {
log('YDB :: RUNNING SESSION');
let settings = new ExecuteQuerySettings();
settings.onResponseMetadata = (metadata) => {
if (make_log) admin.log_query(query, database, metadata.get('x-ydb-consumed-units'));
};
const { resultSets } = await session.executeQuery(query, undefined, undefined, settings);
log('YDB :: PROCESSING RESULT');
result = TypedData.createNativeObjects(resultSets[0]);
log(`QUERY result :: ${JSON.stringify(result)}`);
}
/**
* Выполняет сессию с параметрами
* @param {Session} session - сессия YDB
*/
async function runSessionWithParams(session) {
log('YDB :: RUNNING SESSION EXECUTE WITH PARAMS');
let preparedQuery = await session.prepareQuery(query);
let settings = new ExecuteQuerySettings();
settings.onResponseMetadata = (metadata) => {
if (make_log) admin.log_query(query, database, metadata.get('x-ydb-consumed-units'));
};
const { resultSets } = await session.executeQuery(preparedQuery, params, undefined, settings);
result = TypedData.createNativeObjects(resultSets[0]);
log(`QUERY result :: ${JSON.stringify(result)}`);
}
/**
* Определяет тип сессии для выполнения
* @param {Session} session - сессия YDB
*/
async function runSession(session) {
if (params) return await runSessionWithParams(session);
else return await runSessionNoParams(session);
}
await init_driver();
if (!tryAgain) params = normalizeParams(params);
//params = tryAgain ? params : normalizeParams(params);
let result = [];
log((tryAgain ? 'AGAIN :: ' : '') + 'YDB :: EXECUTE :: DRIVER TABLE CLIENT WITH SESSION');
try {
await driver.tableClient.withSession(async (session) => {
await runSession(session);
});
return result;
} catch (e) {
if (tryAgain) {
if (e.message.includes('Query text size exceeds limit'))
send('EXECUTE :: Query text size exceeds limit', { query, params , env: process.env.ENV });
log_error(e, "YDB :: EXECUTE ::");
console.log("YDB :: EXECUTE :: ERROR :: QUERY :: ", query);
console.log("YDB :: EXECUTE :: ERROR :: PARAMS :: ", JSON.stringify(params ?? {}));
}
else {
let retry_timeout = process.env.YDB_RETRY_TIMEOUT;
if (retry_timeout) {
console.log('ydb EXECUTE error :: waiting for', retry_timeout, 'ms');
await timeout(Number(retry_timeout));
}
return await execute(query, params, true, make_log);
}
return result
}
}
/**
* Выполняет DML запрос к YDB (INSERT, UPDATE, DELETE, UPSERT)
* @param {string} query - YQL запрос для выполнения
* @param {Object} params - параметры запроса
* @param {boolean} tryAgain - флаг повторной попытки при ошибке
* @param {boolean} make_log - флаг логирования запроса
* @returns {Promise<void>}
*/
async function apply(query, params, tryAgain = false, make_log = true) {
/**
* Выполняет сессию без параметров
* @param {Session} session - сессия YDB
*/
async function runSessionNoParams(session) {
log('YDB :: RUNNING SESSION');
let settings = new ExecuteQuerySettings();
settings.onResponseMetadata = (metadata) => {
if (make_log) admin.log_query(query, database, metadata.get('x-ydb-consumed-units'));
};
await session.executeQuery(query, undefined, undefined, settings);
}
/**
* Выполняет сессию с параметрами
* @param {Session} session - сессия YDB
*/
async function runSessionWithParams(session) {
log('YDB :: RUNNING SESSION APPLY WITH PARAMS');
let preparedQuery = await session.prepareQuery(query);
let settings = new ExecuteQuerySettings();
settings.onResponseMetadata = (metadata) => {
if (make_log) admin.log_query(query, database, metadata.get('x-ydb-consumed-units'));
};
await session.executeQuery(preparedQuery, params, undefined, settings);
}
/**
* Определяет тип сессии для выполнения
* @param {Session} session - сессия YDB
*/
async function runSession(session) {
if (params) return await runSessionWithParams(session);
else return await runSessionNoParams(session);
}
await init_driver();
if (!tryAgain) params = normalizeParams(params);
//params = tryAgain ? params : normalizeParams(params);
log((tryAgain ? 'AGAIN :: ' : '') + 'YDB :: APPLY :: DRIVER TABLE CLIENT WITH SESSION');
try {
await driver.tableClient.withSession(async (session) => {
await runSession(session);
});
} catch (e) {
if (tryAgain) {
if (e.message.includes('Query text size exceeds limit'))
send('APPLY :: Query text size exceeds limit', { query, params , env: process.env.ENV });
log_error(e, "YDB :: APPLY ::");
console.log("YDB :: APPLY :: ERROR :: QUERY :: ", query);
console.log("YDB :: APPLY :: ERROR :: PARAMS :: ", JSON.stringify(params ?? {}));
}
if (!tryAgain) {
let retry_timeout = process.env.YDB_RETRY_TIMEOUT;
if (retry_timeout) {
console.log('ydb APPLY error :: waiting for', retry_timeout, 'ms');
await timeout(Number(retry_timeout));
}
await apply(query, params, true, make_log);
}
}
}
let bulk = (function () {
//https://github.com/ydb-platform/ydb-nodejs-sdk/blob/2d06563994ac63e2a46e0871857faacafdeaacda/src/types.ts#L24-L49
/*
export const primitiveTypeToValue: Record<number, string> = {
[Type.PrimitiveTypeId.BOOL]: 'boolValue',
[Type.PrimitiveTypeId.INT8]: 'int32Value',
[Type.PrimitiveTypeId.UINT8]: 'uint32Value',
[Type.PrimitiveTypeId.INT16]: 'int32Value',
[Type.PrimitiveTypeId.UINT16]: 'uint32Value',
[Type.PrimitiveTypeId.INT32]: 'int32Value',
[Type.PrimitiveTypeId.UINT32]: 'uint32Value',
[Type.PrimitiveTypeId.INT64]: 'int64Value',
[Type.PrimitiveTypeId.UINT64]: 'uint64Value',
[Type.PrimitiveTypeId.FLOAT]: 'floatValue',
[Type.PrimitiveTypeId.DOUBLE]: 'doubleValue',
[Type.PrimitiveTypeId.STRING]: 'bytesValue',
[Type.PrimitiveTypeId.UTF8]: 'textValue',
[Type.PrimitiveTypeId.YSON]: 'bytesValue',
[Type.PrimitiveTypeId.JSON]: 'textValue',
[Type.PrimitiveTypeId.JSON_DOCUMENT]: 'textValue',
[Type.PrimitiveTypeId.DYNUMBER]: 'textValue',
[Type.PrimitiveTypeId.DATE]: 'uint32Value',
[Type.PrimitiveTypeId.DATETIME]: 'uint32Value',
[Type.PrimitiveTypeId.TIMESTAMP]: 'uint64Value',
[Type.PrimitiveTypeId.INTERVAL]: 'int64Value',
[Type.PrimitiveTypeId.TZ_DATE]: 'textValue',
[Type.PrimitiveTypeId.TZ_DATETIME]: 'textValue',
[Type.PrimitiveTypeId.TZ_TIMESTAMP]: 'textValue',
};
*/
/**
* Определяет тип YDB для значения при bulk операциях
* @param {any} val - значение для определения типа
* @returns {Object} тип YDB
*/
function get_type(val) {
switch (typeof val) {
case 'string': return Types.UTF8; break;
case 'number': return Types.UINT64; break;
default: return Types.VOID;
}
}
/**
* Создает значение для YDB в нужном формате
* @param {any} val - значение для конвертации
* @returns {Object} объект со значением в формате YDB
*/
function get_value(val) {
switch (typeof val) {
case 'string': return { textValue: val }; break;
case 'number': return { uint64Value: val }; break;
default: return {};
}
}
/**
* Преобразует массив JavaScript объектов в типизированные данные для bulk операций
* @param {Array} values - массив объектов для вставки
* @returns {TypedValue|false} типизированные данные или false при ошибке
*/
function transform_values(values = []) {
let value = values[0],
typed_data = {
type: {
// Define list (rows)
listType: {
item: {
// Define struct type (a row)
structType: {
members: [],
},
},
},
},
// Values
value: {
items: [],
},
},
types = typed_data.type.listType.item.structType.members;
if (typeof value !== 'object') return false;
for (let p in value) {
types.push({
name: p,
type: get_type(value[p])
});
}
values.forEach(val => {
let item = {
items: [],
};
for (let p in val) {
item.items.push(get_value(val[p]));
}
typed_data.value.items.push(Ydb.Value.create(item))
});
return Ydb.TypedValue.create(typed_data);
}
/**
* Выполняет массовую вставку/обновление записей в таблицу
* @param {string} table_name - имя таблицы
* @param {Array} values - массив объектов для вставки
* @returns {Promise<void>}
*/
async function upsert(table_name, values = []) {
await init_driver();
let data = transform_values(values);
try {
if (!!data) await driver.tableClient.withSession(async (session) => {
await session.bulkUpsert(table_name, data);
/*let query_result = await session.bulkUpsert(table_name, data, (new BulkUpsertSettings()).withOperationParams(
(new OperationParams()).withReportCostInfo()
));
console.log('BULK UPSERT QUERY RESULT ::', query_result.toJSON());*/
});
} catch (e) {
log_error(e, "YDB :: BULK UPSERT ::");
}
}
return { upsert }
})();
let struct = (function () {
/**
* Создает YQL выражение для извлечения и типизации поля из JSON
* @param {string} name - имя поля
* @param {any} value - значение поля для определения типа
* @param {Object} description - описание поля с дополнительными параметрами
* @returns {string} YQL выражение для поля
*/
function get_typed_struct_element(name, value, description = {}) {
let result = `JSON_VALUE($v, '$.${name}')`,
is_non_null = false, special_type, desc = description[name];
if (!!desc) {
is_non_null = desc.not_null || false;
special_type = desc.type || undefined
}
switch (typeof value) {
case 'string' : result = `JSON_VALUE($v, '$.${name}' RETURNING ${special_type || 'Utf8'})`; break;
case 'number' : result = `JSON_VALUE($v, '$.${name}' RETURNING ${special_type || 'Uint64'})`; break;
case 'boolean': result = `JSON_VALUE($v, '$.${name}' RETURNING Bool)`; break;
case 'object' :
switch (value.ydb_data_type) {
case 'datetime': result = `JSON_VALUE($v, '$.${name}.value' RETURNING Datetime)`; break;
default: result = `CAST(JSON_QUERY($v, '$.${name}') as Json)`; break;
}
break;
}
return (is_non_null ? `UNWRAP(${result})` : result) + ` as ${name}`;
}
/**
* Генерирует YQL запрос для UPSERT операции на основе структуры объекта
* @param {string} table_name - имя таблицы
* @param {Object} obj - объект-образец для определения структуры
* @param {Object} description - описание полей таблицы
* @returns {string} готовый YQL запрос
*/
function get_query_from(table_name, obj = {}, description = {}) {
let table_elements = [];
for (let p in obj) {
table_elements.push(get_typed_struct_element(p, obj[p], description));
}
return `DECLARE $data_list AS List<Json>;
$data_table = ListMap($data_list, ($v)->{RETURN AsStruct(${table_elements.join(', ')})});
UPSERT INTO ${table_name}
SELECT * FROM AS_TABLE($data_table)`
}
/**
* Выполняет UPSERT операцию для массива структурированных объектов через JSON
* @param {string} table_name - имя таблицы
* @param {Array} obj_value_list - массив объектов для вставки
* @param {Object} description - описание полей таблицы
* @returns {Promise<void>}
*/
async function upsert_struct(table_name, obj_value_list = [], description = {}) {
if (!Array.isArray(obj_value_list) || obj_value_list.length === 0) return;
let first_element = obj_value_list[0], query = get_query_from(table_name, first_element, description);
log('UPSERT_STRUCT :: QUERY ::', query.replaceAll('\n', ' '));
await apply(query, {
unfold_arrays: true,
'$data_list': obj_value_list
})
}
return { upsert: upsert_struct}
})();
let id_lists = (function () {
/**
* @param table_name
* @param id_list
* @param id_field_name
* @returns {Promise<Array>}
*/
async function get_by_id_list(table_name, id_list = [], id_field_name = 'id') {
if (!Array.isArray(id_list) ||
id_list.some(id => typeof id !== 'string'))
return [];
let result = [],
query = `
DECLARE $id_list as List<Utf8>;
$id_struc = ListMap($id_list, ($v)->{RETURN AsStruct($v as N)});
SELECT * FROM ${table_name} as a
INNER JOIN AS_TABLE($id_struc) as b
ON a.${id_field_name} = b.N
`,
params = {
unfold_arrays: true,
'$id_list': id_list
};
try {
result = await execute(query, params)
} catch (e) {
log_error(e, 'YDB :: GET BY LIST');
}
// result.forEach(msg => {if (!!msg.metadata) msg.metadata = JSON.parse(msg.metadata)});
return result;
}
return { get_by_id_list }
})();
const Query = (function () {
/* для обратной совместимости */
return {
apply,
execute,
upsert: async (table_name, values = []) => await bulk.upsert(table_name, values),
upsert_struct: async (table_name, values = [], description = {}) => await struct.upsert(table_name, values, description),
get_by_id_list: async (table_name, id_list = [], id_field_name) =>
await id_lists.get_by_id_list(table_name, id_list, id_field_name),
destroy,
}
})();
return {Query,
apply,
execute,
upsert: async (table_name, values = []) => await bulk.upsert(table_name, values),
upsert_struct: async (table_name, values = [], description = {}) => await struct.upsert(table_name, values, description),
get_by_id_list: async (table_name, id_list = [], id_field_name) =>
await id_lists.get_by_id_list(table_name, id_list, id_field_name),
destroy,
helpers}
}
let helpers = {
uint64_id_definition: table_name => ({
declaration: `
DECLARE $count AS Uint64;
DECLARE $id AS Uint64;
`,
assignment:`
$count = SELECT COUNT (*) FROM ${table_name} ;
$id = SELECT IF($count > 0, MAX (id) + 1, 1) as _id FROM ${table_name};
`
}),
date_time: 'CurrentUtcDatetime()'
};
let admin = (function () {
let ydb_logging_on = !!process.env.ADMIN_YDB_ADDRESS && !is_query_monitoring_off(),
admin_ydb = init(process.env.ADMIN_YDB_ADDRESS);
/**
* Логирует выполненный запрос в административную базу данных
* @param {string} query - выполненный запрос
* @param {string} database - имя базы данных
* @param {number} ru_consumed - количество потребленных RU (Request Units)
*/
function log_query(query = '', database = '', ru_consumed = 0) {
if (!Number.isFinite(ru_consumed)) return;
if (!ydb_logging_on) return;
if (database === process.env.ADMIN_YDB_ADDRESS || !isNumber(ru_consumed)) return;
let q = `
DECLARE $db as Utf8;
DECLARE $query as Utf8;
DECLARE $created_on as Uint64;
DECLARE $ru_consumed as Uint64;
UPSERT INTO ydb_log(db, query, created_on, ru_consumed)
VALUES ($db, $query, $created_on, $ru_consumed);`,
p = {
'$db': process.env.FUNCTION_NAME || database,
'$query': query,
'$created_on': Date.now(),
'$ru_consumed': Number(ru_consumed),
};
admin_ydb.apply(q,p,false, false).then(
() => log('YDB :: QUERY LOG :: consumed', ru_consumed, 'RU')
);
}
return { log_query }
})();
/**
* Логирует ошибку с указанием префикса
* @param {Error} e - объект ошибки
* @param {string} prefix - префикс для лога
*/
function log_error(e, prefix = '') {
console.log(prefix, ':: ERROR ::', e?.message?.replaceAll('\n', ' '), ':: STACK ::', e?.stack?.replaceAll('\n', ' '))
}
/**
* Проверяет, является ли значение числом
* @param {any} n - значение для проверки
* @returns {boolean} true, если значение является числом
*/
function isNumber(n){
return (!!n || n === 0) &&
(typeof n !== 'boolean') &&
!Number.isNaN(Number(n))
}
module.exports = { init };