ydb-qdrant 4.7.1 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/env.d.ts +3 -0
- package/dist/config/env.js +9 -0
- package/dist/repositories/pointsRepo.one-table.js +74 -156
- package/dist/services/PointsService.js +14 -4
- package/dist/utils/distance.d.ts +3 -3
- package/dist/utils/distance.js +5 -6
- package/dist/ydb/client.d.ts +12 -0
- package/dist/ydb/client.js +106 -4
- package/package.json +1 -1
package/dist/config/env.d.ts
CHANGED
|
@@ -19,3 +19,6 @@ export declare const SEARCH_MODE: SearchMode;
|
|
|
19
19
|
export declare const OVERFETCH_MULTIPLIER: number;
|
|
20
20
|
export declare const CLIENT_SIDE_SERIALIZATION_ENABLED: boolean;
|
|
21
21
|
export declare const UPSERT_BATCH_SIZE: number;
|
|
22
|
+
export declare const SESSION_POOL_MIN_SIZE: number;
|
|
23
|
+
export declare const SESSION_POOL_MAX_SIZE: number;
|
|
24
|
+
export declare const SESSION_KEEPALIVE_PERIOD_MS: number;
|
package/dist/config/env.js
CHANGED
|
@@ -78,3 +78,12 @@ export const SEARCH_MODE = resolveSearchModeEnv(COLLECTION_STORAGE_MODE);
|
|
|
78
78
|
export const OVERFETCH_MULTIPLIER = parseIntegerEnv(process.env.YDB_QDRANT_OVERFETCH_MULTIPLIER, 10, { min: 1 });
|
|
79
79
|
export const CLIENT_SIDE_SERIALIZATION_ENABLED = parseBooleanEnv(process.env.YDB_QDRANT_CLIENT_SIDE_SERIALIZATION_ENABLED, false);
|
|
80
80
|
export const UPSERT_BATCH_SIZE = parseIntegerEnv(process.env.YDB_QDRANT_UPSERT_BATCH_SIZE, 100, { min: 1 });
|
|
81
|
+
// Session pool configuration
|
|
82
|
+
const RAW_SESSION_POOL_MIN_SIZE = parseIntegerEnv(process.env.YDB_SESSION_POOL_MIN_SIZE, 5, { min: 1, max: 500 });
|
|
83
|
+
const RAW_SESSION_POOL_MAX_SIZE = parseIntegerEnv(process.env.YDB_SESSION_POOL_MAX_SIZE, 100, { min: 1, max: 500 });
|
|
84
|
+
const NORMALIZED_SESSION_POOL_MIN_SIZE = RAW_SESSION_POOL_MIN_SIZE > RAW_SESSION_POOL_MAX_SIZE
|
|
85
|
+
? RAW_SESSION_POOL_MAX_SIZE
|
|
86
|
+
: RAW_SESSION_POOL_MIN_SIZE;
|
|
87
|
+
export const SESSION_POOL_MIN_SIZE = NORMALIZED_SESSION_POOL_MIN_SIZE;
|
|
88
|
+
export const SESSION_POOL_MAX_SIZE = RAW_SESSION_POOL_MAX_SIZE;
|
|
89
|
+
export const SESSION_KEEPALIVE_PERIOD_MS = parseIntegerEnv(process.env.YDB_SESSION_KEEPALIVE_PERIOD_MS, 5000, { min: 1000, max: 60000 });
|
|
@@ -231,195 +231,113 @@ async function searchPointsOneTableApproximate(tableName, queryVector, top, with
|
|
|
231
231
|
const rawCandidateLimit = safeTop * overfetchMultiplier;
|
|
232
232
|
const candidateLimit = Math.max(safeTop, rawCandidateLimit);
|
|
233
233
|
const results = await withSession(async (s) => {
|
|
234
|
+
let yql;
|
|
235
|
+
let params;
|
|
234
236
|
if (CLIENT_SIDE_SERIALIZATION_ENABLED) {
|
|
235
237
|
const binaries = buildVectorBinaryParams(queryVector);
|
|
236
|
-
|
|
237
|
-
const phase1Query = `
|
|
238
|
+
yql = `
|
|
238
239
|
DECLARE $qbin_bit AS String;
|
|
239
|
-
DECLARE $
|
|
240
|
+
DECLARE $qbinf AS String;
|
|
241
|
+
DECLARE $candidateLimit AS Uint32;
|
|
242
|
+
DECLARE $safeTop AS Uint32;
|
|
240
243
|
DECLARE $uid AS Utf8;
|
|
241
|
-
|
|
244
|
+
|
|
245
|
+
$candidates = (
|
|
246
|
+
SELECT point_id
|
|
247
|
+
FROM ${tableName}
|
|
248
|
+
WHERE uid = $uid AND embedding_quantized IS NOT NULL
|
|
249
|
+
ORDER BY ${bitFn}(embedding_quantized, $qbin_bit) ${bitOrder}
|
|
250
|
+
LIMIT $candidateLimit
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
SELECT point_id, ${withPayload ? "payload, " : ""}${fn}(embedding, $qbinf) AS score
|
|
242
254
|
FROM ${tableName}
|
|
243
|
-
WHERE uid = $uid
|
|
244
|
-
|
|
245
|
-
|
|
255
|
+
WHERE uid = $uid
|
|
256
|
+
AND point_id IN $candidates
|
|
257
|
+
ORDER BY score ${order}
|
|
258
|
+
LIMIT $safeTop;
|
|
246
259
|
`;
|
|
260
|
+
params = {
|
|
261
|
+
$qbin_bit: typeof TypedValues.bytes === "function"
|
|
262
|
+
? TypedValues.bytes(binaries.bit)
|
|
263
|
+
: binaries.bit,
|
|
264
|
+
$qbinf: typeof TypedValues.bytes === "function"
|
|
265
|
+
? TypedValues.bytes(binaries.float)
|
|
266
|
+
: binaries.float,
|
|
267
|
+
$candidateLimit: TypedValues.uint32(candidateLimit),
|
|
268
|
+
$safeTop: TypedValues.uint32(safeTop),
|
|
269
|
+
$uid: TypedValues.utf8(uid),
|
|
270
|
+
};
|
|
247
271
|
logger.debug({
|
|
248
272
|
tableName,
|
|
249
273
|
distance,
|
|
250
274
|
top,
|
|
251
275
|
safeTop,
|
|
252
276
|
candidateLimit,
|
|
253
|
-
mode: "
|
|
254
|
-
yql
|
|
277
|
+
mode: "one_table_approximate_client_side_serialization",
|
|
278
|
+
yql,
|
|
255
279
|
params: {
|
|
256
280
|
uid,
|
|
257
|
-
|
|
281
|
+
safeTop,
|
|
282
|
+
candidateLimit,
|
|
258
283
|
vectorLength: queryVector.length,
|
|
259
284
|
vectorPreview: queryVector.slice(0, 3),
|
|
260
285
|
},
|
|
261
|
-
}, "one_table search (approximate
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const rs1 = await s.executeQuery(phase1Query, phase1Params);
|
|
270
|
-
const rowset1 = rs1.resultSets?.[0];
|
|
271
|
-
const rows1 = (rowset1?.rows ?? []);
|
|
272
|
-
const candidateIds = rows1
|
|
273
|
-
.map((row) => row.items?.[0]?.textValue)
|
|
274
|
-
.filter((id) => typeof id === "string");
|
|
275
|
-
if (candidateIds.length === 0) {
|
|
276
|
-
return [];
|
|
277
|
-
}
|
|
278
|
-
// Phase 2: exact re-ranking on full-precision embedding for candidates only
|
|
279
|
-
const phase2Query = `
|
|
280
|
-
DECLARE $qbinf AS String;
|
|
281
|
-
DECLARE $k AS Uint32;
|
|
286
|
+
}, "one_table search (approximate): executing YQL with client-side serialization");
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
const qf = buildVectorParam(queryVector);
|
|
290
|
+
yql = `
|
|
291
|
+
DECLARE $qf AS List<Float>;
|
|
292
|
+
DECLARE $candidateLimit AS Uint32;
|
|
293
|
+
DECLARE $safeTop AS Uint32;
|
|
282
294
|
DECLARE $uid AS Utf8;
|
|
283
|
-
|
|
295
|
+
|
|
296
|
+
$qbin_bit = Knn::ToBinaryStringBit($qf);
|
|
297
|
+
$qbinf = Knn::ToBinaryStringFloat($qf);
|
|
298
|
+
|
|
299
|
+
$candidates = (
|
|
300
|
+
SELECT point_id
|
|
301
|
+
FROM ${tableName}
|
|
302
|
+
WHERE uid = $uid AND embedding_quantized IS NOT NULL
|
|
303
|
+
ORDER BY ${bitFn}(embedding_quantized, $qbin_bit) ${bitOrder}
|
|
304
|
+
LIMIT $candidateLimit
|
|
305
|
+
);
|
|
306
|
+
|
|
284
307
|
SELECT point_id, ${withPayload ? "payload, " : ""}${fn}(embedding, $qbinf) AS score
|
|
285
308
|
FROM ${tableName}
|
|
286
|
-
WHERE uid = $uid
|
|
309
|
+
WHERE uid = $uid
|
|
310
|
+
AND point_id IN $candidates
|
|
287
311
|
ORDER BY score ${order}
|
|
288
|
-
LIMIT $
|
|
312
|
+
LIMIT $safeTop;
|
|
289
313
|
`;
|
|
314
|
+
params = {
|
|
315
|
+
$qf: qf,
|
|
316
|
+
$candidateLimit: TypedValues.uint32(candidateLimit),
|
|
317
|
+
$safeTop: TypedValues.uint32(safeTop),
|
|
318
|
+
$uid: TypedValues.utf8(uid),
|
|
319
|
+
};
|
|
290
320
|
logger.debug({
|
|
291
321
|
tableName,
|
|
292
322
|
distance,
|
|
293
323
|
top,
|
|
294
324
|
safeTop,
|
|
295
|
-
|
|
296
|
-
mode: "
|
|
297
|
-
yql
|
|
325
|
+
candidateLimit,
|
|
326
|
+
mode: "one_table_approximate",
|
|
327
|
+
yql,
|
|
298
328
|
params: {
|
|
299
329
|
uid,
|
|
300
|
-
|
|
330
|
+
safeTop,
|
|
331
|
+
candidateLimit,
|
|
301
332
|
vectorLength: queryVector.length,
|
|
302
333
|
vectorPreview: queryVector.slice(0, 3),
|
|
303
|
-
ids: candidateIds,
|
|
304
334
|
},
|
|
305
|
-
}, "one_table search (approximate
|
|
306
|
-
const idsParam = TypedValues.list(Types.UTF8, candidateIds);
|
|
307
|
-
const phase2Params = {
|
|
308
|
-
$qbinf: typeof TypedValues.bytes === "function"
|
|
309
|
-
? TypedValues.bytes(binaries.float)
|
|
310
|
-
: binaries.float,
|
|
311
|
-
$k: TypedValues.uint32(safeTop),
|
|
312
|
-
$uid: TypedValues.utf8(uid),
|
|
313
|
-
$ids: idsParam,
|
|
314
|
-
};
|
|
315
|
-
const rs2 = await s.executeQuery(phase2Query, phase2Params);
|
|
316
|
-
const rowset2 = rs2.resultSets?.[0];
|
|
317
|
-
const rows2 = (rowset2?.rows ?? []);
|
|
318
|
-
return rows2.map((row) => {
|
|
319
|
-
const id = row.items?.[0]?.textValue;
|
|
320
|
-
if (typeof id !== "string") {
|
|
321
|
-
throw new Error("point_id is missing in YDB search result");
|
|
322
|
-
}
|
|
323
|
-
let payload;
|
|
324
|
-
let scoreIdx = 1;
|
|
325
|
-
if (withPayload) {
|
|
326
|
-
const payloadText = row.items?.[1]?.textValue;
|
|
327
|
-
if (payloadText) {
|
|
328
|
-
try {
|
|
329
|
-
payload = JSON.parse(payloadText);
|
|
330
|
-
}
|
|
331
|
-
catch {
|
|
332
|
-
payload = undefined;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
scoreIdx = 2;
|
|
336
|
-
}
|
|
337
|
-
const score = Number(row.items?.[scoreIdx]?.floatValue ?? row.items?.[scoreIdx]?.textValue);
|
|
338
|
-
return { id, score, ...(payload ? { payload } : {}) };
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
const qf = buildVectorParam(queryVector);
|
|
342
|
-
// Phase 1: approximate candidate selection using embedding_quantized
|
|
343
|
-
const phase1Query = `
|
|
344
|
-
DECLARE $qf AS List<Float>;
|
|
345
|
-
DECLARE $k AS Uint32;
|
|
346
|
-
DECLARE $uid AS Utf8;
|
|
347
|
-
$qbin_bit = Knn::ToBinaryStringBit($qf);
|
|
348
|
-
SELECT point_id
|
|
349
|
-
FROM ${tableName}
|
|
350
|
-
WHERE uid = $uid AND embedding_quantized IS NOT NULL
|
|
351
|
-
ORDER BY ${bitFn}(embedding_quantized, $qbin_bit) ${bitOrder}
|
|
352
|
-
LIMIT $k;
|
|
353
|
-
`;
|
|
354
|
-
logger.debug({
|
|
355
|
-
tableName,
|
|
356
|
-
distance,
|
|
357
|
-
top,
|
|
358
|
-
safeTop,
|
|
359
|
-
candidateLimit,
|
|
360
|
-
mode: "one_table_approximate_phase1",
|
|
361
|
-
yql: phase1Query,
|
|
362
|
-
params: {
|
|
363
|
-
uid,
|
|
364
|
-
top: candidateLimit,
|
|
365
|
-
vectorLength: queryVector.length,
|
|
366
|
-
vectorPreview: queryVector.slice(0, 3),
|
|
367
|
-
},
|
|
368
|
-
}, "one_table search (approximate, phase 1): executing YQL");
|
|
369
|
-
const phase1Params = {
|
|
370
|
-
$qf: qf,
|
|
371
|
-
$k: TypedValues.uint32(candidateLimit),
|
|
372
|
-
$uid: TypedValues.utf8(uid),
|
|
373
|
-
};
|
|
374
|
-
const rs1 = await s.executeQuery(phase1Query, phase1Params);
|
|
375
|
-
const rowset1 = rs1.resultSets?.[0];
|
|
376
|
-
const rows1 = (rowset1?.rows ?? []);
|
|
377
|
-
const candidateIds = rows1
|
|
378
|
-
.map((row) => row.items?.[0]?.textValue)
|
|
379
|
-
.filter((id) => typeof id === "string");
|
|
380
|
-
if (candidateIds.length === 0) {
|
|
381
|
-
return [];
|
|
335
|
+
}, "one_table search (approximate): executing YQL");
|
|
382
336
|
}
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
DECLARE $uid AS Utf8;
|
|
388
|
-
DECLARE $ids AS List<Utf8>;
|
|
389
|
-
$qbinf = Knn::ToBinaryStringFloat($qf);
|
|
390
|
-
SELECT point_id, ${withPayload ? "payload, " : ""}${fn}(embedding, $qbinf) AS score
|
|
391
|
-
FROM ${tableName}
|
|
392
|
-
WHERE uid = $uid AND point_id IN $ids
|
|
393
|
-
ORDER BY score ${order}
|
|
394
|
-
LIMIT $k;
|
|
395
|
-
`;
|
|
396
|
-
logger.debug({
|
|
397
|
-
tableName,
|
|
398
|
-
distance,
|
|
399
|
-
top,
|
|
400
|
-
safeTop,
|
|
401
|
-
candidateCount: candidateIds.length,
|
|
402
|
-
mode: "one_table_approximate_phase2",
|
|
403
|
-
yql: phase2Query,
|
|
404
|
-
params: {
|
|
405
|
-
uid,
|
|
406
|
-
top: safeTop,
|
|
407
|
-
vectorLength: queryVector.length,
|
|
408
|
-
vectorPreview: queryVector.slice(0, 3),
|
|
409
|
-
ids: candidateIds,
|
|
410
|
-
},
|
|
411
|
-
}, "one_table search (approximate, phase 2): executing YQL");
|
|
412
|
-
const idsParam = TypedValues.list(Types.UTF8, candidateIds);
|
|
413
|
-
const phase2Params = {
|
|
414
|
-
$qf: qf,
|
|
415
|
-
$k: TypedValues.uint32(safeTop),
|
|
416
|
-
$uid: TypedValues.utf8(uid),
|
|
417
|
-
$ids: idsParam,
|
|
418
|
-
};
|
|
419
|
-
const rs2 = await s.executeQuery(phase2Query, phase2Params);
|
|
420
|
-
const rowset2 = rs2.resultSets?.[0];
|
|
421
|
-
const rows2 = (rowset2?.rows ?? []);
|
|
422
|
-
return rows2.map((row) => {
|
|
337
|
+
const rs = await s.executeQuery(yql, params);
|
|
338
|
+
const rowset = rs.resultSets?.[0];
|
|
339
|
+
const rows = (rowset?.rows ?? []);
|
|
340
|
+
return rows.map((row) => {
|
|
423
341
|
const id = row.items?.[0]?.textValue;
|
|
424
342
|
if (typeof id !== "string") {
|
|
425
343
|
throw new Error("point_id is missing in YDB search result");
|
|
@@ -118,13 +118,23 @@ async function executeSearch(ctx, normalizedSearch, source) {
|
|
|
118
118
|
throw err;
|
|
119
119
|
}
|
|
120
120
|
const threshold = normalizedSearch.scoreThreshold;
|
|
121
|
+
// For Cosine, repository hits use distance scores; convert to a
|
|
122
|
+
// similarity-like score so API consumers and IDE thresholds see
|
|
123
|
+
// "higher is better". This keeps ranking identical (monotonic 1 - d).
|
|
124
|
+
const normalizedHits = meta.distance === "Cosine"
|
|
125
|
+
? hits.map((hit) => ({
|
|
126
|
+
...hit,
|
|
127
|
+
score: 1 - hit.score,
|
|
128
|
+
}))
|
|
129
|
+
: hits;
|
|
121
130
|
const filtered = threshold === undefined
|
|
122
|
-
?
|
|
123
|
-
:
|
|
124
|
-
|
|
125
|
-
|
|
131
|
+
? normalizedHits
|
|
132
|
+
: normalizedHits.filter((hit) => {
|
|
133
|
+
if (meta.distance === "Dot" || meta.distance === "Cosine") {
|
|
134
|
+
// Similarity metrics: threshold is minimum similarity.
|
|
126
135
|
return hit.score >= threshold;
|
|
127
136
|
}
|
|
137
|
+
// Euclid / Manhattan: pure distance metrics; threshold is max distance.
|
|
128
138
|
return hit.score <= threshold;
|
|
129
139
|
});
|
|
130
140
|
logger.info({
|
package/dist/utils/distance.d.ts
CHANGED
|
@@ -5,13 +5,13 @@ export declare function mapDistanceToKnnFn(distance: DistanceKind): {
|
|
|
5
5
|
};
|
|
6
6
|
export declare function mapDistanceToIndexParam(distance: DistanceKind): string;
|
|
7
7
|
/**
|
|
8
|
-
* Maps a user-specified distance metric to a YDB Knn
|
|
8
|
+
* Maps a user-specified distance metric to a YDB Knn function
|
|
9
9
|
* suitable for bit-quantized vectors (Phase 1 approximate candidate selection).
|
|
10
|
-
*
|
|
10
|
+
* Cosine uses similarity (DESC); other metrics use distance (ASC).
|
|
11
11
|
* For Dot, falls back to CosineDistance as a proxy since there is no
|
|
12
12
|
* direct distance equivalent for inner product.
|
|
13
13
|
*/
|
|
14
14
|
export declare function mapDistanceToBitKnnFn(distance: DistanceKind): {
|
|
15
15
|
fn: string;
|
|
16
|
-
order: "ASC";
|
|
16
|
+
order: "ASC" | "DESC";
|
|
17
17
|
};
|
package/dist/utils/distance.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export function mapDistanceToKnnFn(distance) {
|
|
2
2
|
switch (distance) {
|
|
3
3
|
case "Cosine":
|
|
4
|
-
return { fn: "Knn::
|
|
4
|
+
return { fn: "Knn::CosineDistance", order: "ASC" };
|
|
5
5
|
case "Dot":
|
|
6
6
|
return { fn: "Knn::InnerProductSimilarity", order: "DESC" };
|
|
7
7
|
case "Euclid":
|
|
@@ -9,7 +9,7 @@ export function mapDistanceToKnnFn(distance) {
|
|
|
9
9
|
case "Manhattan":
|
|
10
10
|
return { fn: "Knn::ManhattanDistance", order: "ASC" };
|
|
11
11
|
default:
|
|
12
|
-
return { fn: "Knn::
|
|
12
|
+
return { fn: "Knn::CosineDistance", order: "ASC" };
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
export function mapDistanceToIndexParam(distance) {
|
|
@@ -27,18 +27,17 @@ export function mapDistanceToIndexParam(distance) {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
30
|
-
* Maps a user-specified distance metric to a YDB Knn
|
|
30
|
+
* Maps a user-specified distance metric to a YDB Knn function
|
|
31
31
|
* suitable for bit-quantized vectors (Phase 1 approximate candidate selection).
|
|
32
|
-
*
|
|
32
|
+
* Cosine uses similarity (DESC); other metrics use distance (ASC).
|
|
33
33
|
* For Dot, falls back to CosineDistance as a proxy since there is no
|
|
34
34
|
* direct distance equivalent for inner product.
|
|
35
35
|
*/
|
|
36
36
|
export function mapDistanceToBitKnnFn(distance) {
|
|
37
37
|
switch (distance) {
|
|
38
38
|
case "Cosine":
|
|
39
|
-
return { fn: "Knn::
|
|
39
|
+
return { fn: "Knn::CosineSimilarity", order: "DESC" };
|
|
40
40
|
case "Dot":
|
|
41
|
-
// No direct distance equivalent; use Cosine as proxy
|
|
42
41
|
return { fn: "Knn::CosineDistance", order: "ASC" };
|
|
43
42
|
case "Euclid":
|
|
44
43
|
return { fn: "Knn::EuclideanDistance", order: "ASC" };
|
package/dist/ydb/client.d.ts
CHANGED
|
@@ -8,7 +8,19 @@ type DriverConfig = {
|
|
|
8
8
|
authService?: IAuthService;
|
|
9
9
|
};
|
|
10
10
|
export declare function __setDriverForTests(fake: unknown): void;
|
|
11
|
+
export declare function __setDriverFactoryForTests(factory: ((config: unknown) => unknown) | undefined): void;
|
|
12
|
+
export declare function __resetRefreshStateForTests(): void;
|
|
11
13
|
export declare function configureDriver(config: DriverConfig): void;
|
|
12
14
|
export declare function readyOrThrow(): Promise<void>;
|
|
13
15
|
export declare function withSession<T>(fn: (s: Session) => Promise<T>): Promise<T>;
|
|
14
16
|
export declare function isYdbAvailable(timeoutMs?: number): Promise<boolean>;
|
|
17
|
+
/**
|
|
18
|
+
* Destroys the current driver and its session pool.
|
|
19
|
+
* Next call to withSession or readyOrThrow will create a new driver.
|
|
20
|
+
*/
|
|
21
|
+
export declare function destroyDriver(): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Destroys the current driver and immediately creates a fresh one.
|
|
24
|
+
* Use this to recover from session pool exhaustion or zombie sessions.
|
|
25
|
+
*/
|
|
26
|
+
export declare function refreshDriver(): Promise<void>;
|
package/dist/ydb/client.js
CHANGED
|
@@ -1,16 +1,72 @@
|
|
|
1
1
|
import { createRequire } from "module";
|
|
2
|
-
import { YDB_DATABASE, YDB_ENDPOINT } from "../config/env.js";
|
|
2
|
+
import { YDB_DATABASE, YDB_ENDPOINT, SESSION_POOL_MIN_SIZE, SESSION_POOL_MAX_SIZE, SESSION_KEEPALIVE_PERIOD_MS, } from "../config/env.js";
|
|
3
|
+
import { logger } from "../logging/logger.js";
|
|
3
4
|
const require = createRequire(import.meta.url);
|
|
4
5
|
const { Driver, getCredentialsFromEnv, Types, TypedValues, TableDescription, Column, } = require("ydb-sdk");
|
|
5
6
|
export { Types, TypedValues, TableDescription, Column };
|
|
6
7
|
const DRIVER_READY_TIMEOUT_MS = 15000;
|
|
7
8
|
const TABLE_SESSION_TIMEOUT_MS = 20000;
|
|
8
9
|
const YDB_HEALTHCHECK_READY_TIMEOUT_MS = 5000;
|
|
10
|
+
const DRIVER_REFRESH_COOLDOWN_MS = 30000;
|
|
9
11
|
let overrideConfig;
|
|
10
12
|
let driver;
|
|
13
|
+
let lastDriverRefreshAt = 0;
|
|
14
|
+
let driverRefreshInFlight = null;
|
|
15
|
+
// Test-only: allows injecting a mock Driver factory
|
|
16
|
+
let driverFactoryOverride;
|
|
17
|
+
function shouldTriggerDriverRefresh(error) {
|
|
18
|
+
if (!(error instanceof Error)) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const msg = error.message ?? "";
|
|
22
|
+
if (/No session became available within timeout/i.test(msg)) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
if (/SESSION_POOL_EMPTY|session pool empty/i.test(msg)) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (/SessionExpired|SESSION_EXPIRED|session.*expired/i.test(msg)) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
async function maybeRefreshDriverOnSessionError(error) {
|
|
34
|
+
if (!shouldTriggerDriverRefresh(error)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
if (now - lastDriverRefreshAt < DRIVER_REFRESH_COOLDOWN_MS) {
|
|
39
|
+
logger.warn({ lastDriverRefreshAt, cooldownMs: DRIVER_REFRESH_COOLDOWN_MS }, "YDB driver refresh skipped due to cooldown");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (driverRefreshInFlight) {
|
|
43
|
+
logger.warn({ lastDriverRefreshAt, cooldownMs: DRIVER_REFRESH_COOLDOWN_MS }, "YDB driver refresh already in flight; skipping");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
lastDriverRefreshAt = now;
|
|
47
|
+
logger.warn({ err: error }, "YDB session-related error detected; refreshing driver");
|
|
48
|
+
try {
|
|
49
|
+
const refreshPromise = refreshDriver();
|
|
50
|
+
driverRefreshInFlight = refreshPromise;
|
|
51
|
+
await refreshPromise;
|
|
52
|
+
}
|
|
53
|
+
catch (refreshErr) {
|
|
54
|
+
logger.error({ err: refreshErr }, "YDB driver refresh failed; keeping current driver");
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
driverRefreshInFlight = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
11
60
|
export function __setDriverForTests(fake) {
|
|
12
61
|
driver = fake;
|
|
13
62
|
}
|
|
63
|
+
export function __setDriverFactoryForTests(factory) {
|
|
64
|
+
driverFactoryOverride = factory;
|
|
65
|
+
}
|
|
66
|
+
export function __resetRefreshStateForTests() {
|
|
67
|
+
lastDriverRefreshAt = 0;
|
|
68
|
+
driverRefreshInFlight = null;
|
|
69
|
+
}
|
|
14
70
|
export function configureDriver(config) {
|
|
15
71
|
if (driver) {
|
|
16
72
|
// Driver already created; keep existing connection settings.
|
|
@@ -28,10 +84,23 @@ function getOrCreateDriver() {
|
|
|
28
84
|
endpoint: overrideConfig?.endpoint ?? YDB_ENDPOINT,
|
|
29
85
|
database: overrideConfig?.database ?? YDB_DATABASE,
|
|
30
86
|
};
|
|
31
|
-
|
|
87
|
+
const driverConfig = {
|
|
32
88
|
...base,
|
|
33
89
|
authService: overrideConfig?.authService ?? getCredentialsFromEnv(),
|
|
34
|
-
|
|
90
|
+
poolSettings: {
|
|
91
|
+
minLimit: SESSION_POOL_MIN_SIZE,
|
|
92
|
+
maxLimit: SESSION_POOL_MAX_SIZE,
|
|
93
|
+
keepAlivePeriod: SESSION_KEEPALIVE_PERIOD_MS,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
driver = driverFactoryOverride
|
|
97
|
+
? driverFactoryOverride(driverConfig)
|
|
98
|
+
: new Driver(driverConfig);
|
|
99
|
+
logger.info({
|
|
100
|
+
poolMinSize: SESSION_POOL_MIN_SIZE,
|
|
101
|
+
poolMaxSize: SESSION_POOL_MAX_SIZE,
|
|
102
|
+
keepAlivePeriodMs: SESSION_KEEPALIVE_PERIOD_MS,
|
|
103
|
+
}, "YDB driver created with session pool settings");
|
|
35
104
|
return driver;
|
|
36
105
|
}
|
|
37
106
|
export async function readyOrThrow() {
|
|
@@ -43,7 +112,13 @@ export async function readyOrThrow() {
|
|
|
43
112
|
}
|
|
44
113
|
export async function withSession(fn) {
|
|
45
114
|
const d = getOrCreateDriver();
|
|
46
|
-
|
|
115
|
+
try {
|
|
116
|
+
return await d.tableClient.withSession(fn, TABLE_SESSION_TIMEOUT_MS);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
void maybeRefreshDriverOnSessionError(err);
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
47
122
|
}
|
|
48
123
|
export async function isYdbAvailable(timeoutMs = YDB_HEALTHCHECK_READY_TIMEOUT_MS) {
|
|
49
124
|
const d = getOrCreateDriver();
|
|
@@ -54,3 +129,30 @@ export async function isYdbAvailable(timeoutMs = YDB_HEALTHCHECK_READY_TIMEOUT_M
|
|
|
54
129
|
return false;
|
|
55
130
|
}
|
|
56
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Destroys the current driver and its session pool.
|
|
134
|
+
* Next call to withSession or readyOrThrow will create a new driver.
|
|
135
|
+
*/
|
|
136
|
+
export async function destroyDriver() {
|
|
137
|
+
if (!driver) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
logger.info("Destroying YDB driver and session pool");
|
|
141
|
+
try {
|
|
142
|
+
await driver.destroy();
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
logger.warn({ err }, "Error during driver destruction (ignored)");
|
|
146
|
+
}
|
|
147
|
+
driver = undefined;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Destroys the current driver and immediately creates a fresh one.
|
|
151
|
+
* Use this to recover from session pool exhaustion or zombie sessions.
|
|
152
|
+
*/
|
|
153
|
+
export async function refreshDriver() {
|
|
154
|
+
logger.info("Refreshing YDB driver");
|
|
155
|
+
await destroyDriver();
|
|
156
|
+
await readyOrThrow();
|
|
157
|
+
logger.info("YDB driver refreshed successfully");
|
|
158
|
+
}
|