UNPKG

@dieugene/ydb-serverless

Version:
676 lines (617 loc) 28.6 kB
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 };