ydb-qdrant 5.2.1 → 7.0.1

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.
Files changed (60) hide show
  1. package/README.md +2 -2
  2. package/dist/config/env.d.ts +9 -3
  3. package/dist/config/env.js +16 -5
  4. package/dist/package/api.d.ts +2 -2
  5. package/dist/package/api.js +2 -2
  6. package/dist/qdrant/QdrantTypes.d.ts +19 -0
  7. package/dist/qdrant/QdrantTypes.js +1 -0
  8. package/dist/repositories/collectionsRepo.d.ts +12 -7
  9. package/dist/repositories/collectionsRepo.js +157 -39
  10. package/dist/repositories/collectionsRepo.one-table.js +47 -129
  11. package/dist/repositories/pointsRepo.d.ts +5 -7
  12. package/dist/repositories/pointsRepo.js +6 -3
  13. package/dist/repositories/pointsRepo.one-table/Delete.d.ts +2 -0
  14. package/dist/repositories/pointsRepo.one-table/Delete.js +111 -0
  15. package/dist/repositories/pointsRepo.one-table/PathSegmentsFilter.d.ts +11 -0
  16. package/dist/repositories/pointsRepo.one-table/PathSegmentsFilter.js +32 -0
  17. package/dist/repositories/pointsRepo.one-table/Search/Approximate.d.ts +18 -0
  18. package/dist/repositories/pointsRepo.one-table/Search/Approximate.js +119 -0
  19. package/dist/repositories/pointsRepo.one-table/Search/Exact.d.ts +17 -0
  20. package/dist/repositories/pointsRepo.one-table/Search/Exact.js +101 -0
  21. package/dist/repositories/pointsRepo.one-table/Search/index.d.ts +8 -0
  22. package/dist/repositories/pointsRepo.one-table/Search/index.js +30 -0
  23. package/dist/repositories/pointsRepo.one-table/Upsert.d.ts +2 -0
  24. package/dist/repositories/pointsRepo.one-table/Upsert.js +100 -0
  25. package/dist/repositories/pointsRepo.one-table.d.ts +3 -13
  26. package/dist/repositories/pointsRepo.one-table.js +3 -403
  27. package/dist/routes/collections.js +61 -7
  28. package/dist/routes/points.js +71 -3
  29. package/dist/server.d.ts +1 -0
  30. package/dist/server.js +70 -2
  31. package/dist/services/CollectionService.d.ts +9 -0
  32. package/dist/services/CollectionService.js +13 -1
  33. package/dist/services/CollectionService.shared.d.ts +1 -0
  34. package/dist/services/CollectionService.shared.js +3 -3
  35. package/dist/services/PointsService.d.ts +8 -10
  36. package/dist/services/PointsService.js +82 -5
  37. package/dist/types.d.ts +85 -8
  38. package/dist/types.js +43 -17
  39. package/dist/utils/normalization.d.ts +1 -0
  40. package/dist/utils/normalization.js +15 -13
  41. package/dist/utils/retry.js +29 -19
  42. package/dist/utils/tenant.d.ts +2 -2
  43. package/dist/utils/tenant.js +21 -6
  44. package/dist/utils/typeGuards.d.ts +1 -0
  45. package/dist/utils/typeGuards.js +3 -0
  46. package/dist/utils/vectorBinary.js +88 -9
  47. package/dist/ydb/QueryDiagnostics.d.ts +6 -0
  48. package/dist/ydb/QueryDiagnostics.js +52 -0
  49. package/dist/ydb/SessionPool.d.ts +36 -0
  50. package/dist/ydb/SessionPool.js +248 -0
  51. package/dist/ydb/bulkUpsert.d.ts +6 -0
  52. package/dist/ydb/bulkUpsert.js +52 -0
  53. package/dist/ydb/client.d.ts +17 -16
  54. package/dist/ydb/client.js +427 -62
  55. package/dist/ydb/helpers.d.ts +0 -2
  56. package/dist/ydb/helpers.js +0 -7
  57. package/dist/ydb/schema.js +172 -54
  58. package/package.json +12 -7
  59. package/dist/repositories/collectionsRepo.shared.d.ts +0 -2
  60. package/dist/repositories/collectionsRepo.shared.js +0 -23
@@ -1,4 +1,5 @@
1
1
  import { logger } from "../logging/logger.js";
2
+ import { retry as ydbRetry } from "@ydbjs/retry";
2
3
  const DEFAULT_MAX_RETRIES = 6;
3
4
  const DEFAULT_BASE_DELAY_MS = 250;
4
5
  export function isTransientYdbError(error) {
@@ -22,26 +23,35 @@ export async function withRetry(fn, options = {}) {
22
23
  const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
23
24
  const isTransient = options.isTransient ?? isTransientYdbError;
24
25
  const context = options.context ?? {};
25
- let attempt = 0;
26
- while (true) {
27
- try {
28
- return await fn();
29
- }
30
- catch (e) {
31
- if (!isTransient(e) || attempt >= maxRetries) {
32
- throw e;
33
- }
34
- const backoffMs = Math.floor(baseDelayMs * Math.pow(2, attempt) + Math.random() * 100);
26
+ // We keep the public API in terms of `maxRetries`, but @ydbjs/retry uses a budget
27
+ // in terms of total attempts. Convert retries→attempts.
28
+ const attemptsBudget = Math.max(0, maxRetries) + 1;
29
+ const delayByAttempt = new Map();
30
+ return await ydbRetry({
31
+ budget: attemptsBudget,
32
+ retry: (error) => isTransient(error),
33
+ strategy: (ctx) => {
34
+ // Preserve previous backoff shape: baseDelayMs * 2^attemptIndex + jitter(0..100)
35
+ // where attemptIndex started at 0 for the first retry.
36
+ const attemptIndex = Math.max(0, ctx.attempt - 1);
37
+ const delayMs = Math.floor(baseDelayMs * Math.pow(2, attemptIndex) + Math.random() * 100);
38
+ delayByAttempt.set(ctx.attempt, delayMs);
39
+ return delayMs;
40
+ },
41
+ onRetry: (ctx) => {
42
+ const attemptIndex = Math.max(0, ctx.attempt - 1);
35
43
  logger.warn({
36
44
  ...context,
37
- attempt,
38
- backoffMs,
39
- err: e instanceof Error
40
- ? e
41
- : new Error(typeof e === "string" ? e : JSON.stringify(e)),
45
+ attempt: attemptIndex,
46
+ backoffMs: delayByAttempt.get(ctx.attempt),
47
+ err: ctx.error instanceof Error
48
+ ? ctx.error
49
+ : new Error(typeof ctx.error === "string"
50
+ ? ctx.error
51
+ : JSON.stringify(ctx.error)),
42
52
  }, "operation aborted due to transient error; retrying");
43
- await new Promise((r) => setTimeout(r, backoffMs));
44
- attempt += 1;
45
- }
46
- }
53
+ },
54
+ }, async () => {
55
+ return await fn();
56
+ });
47
57
  }
@@ -1,6 +1,6 @@
1
1
  export declare function hashApiKey(apiKey: string | undefined): string | undefined;
2
- export declare function hashUserAgent(userAgent: string | undefined): string | undefined;
3
- export declare function sanitizeCollectionName(name: string, apiKeyHash?: string, userAgentHash?: string): string;
2
+ export declare function normalizeUserAgent(userAgent: string | undefined): string | undefined;
3
+ export declare function sanitizeCollectionName(name: string, apiKeyHash?: string, userAgentNormalized?: string): string;
4
4
  export declare function sanitizeTenantId(tenantId: string | undefined): string;
5
5
  export declare function tableNameFor(sanitizedTenant: string, sanitizedCollection: string): string;
6
6
  export declare function metaKeyFor(sanitizedTenant: string, sanitizedCollection: string): string;
@@ -5,26 +5,41 @@ export function hashApiKey(apiKey) {
5
5
  const hash = createHash("sha256").update(apiKey).digest("hex");
6
6
  return hash.slice(0, 8);
7
7
  }
8
- export function hashUserAgent(userAgent) {
8
+ export function normalizeUserAgent(userAgent) {
9
9
  if (!userAgent || userAgent.trim() === "")
10
10
  return undefined;
11
+ let lowered = userAgent
12
+ .trim()
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9_]/g, "_")
15
+ .replace(/_+/g, "_")
16
+ .replace(/^_+|_+$/g, "");
17
+ if (lowered.length === 0)
18
+ return undefined;
19
+ const MAX_LEN = 32;
20
+ if (lowered.length > MAX_LEN) {
21
+ lowered = lowered.slice(0, MAX_LEN).replace(/_+$/g, "");
22
+ }
23
+ if (lowered.length === 0)
24
+ return undefined;
11
25
  const hash = createHash("sha256").update(userAgent).digest("hex");
12
- return hash.slice(0, 8);
26
+ const shortHash = hash.slice(0, 8);
27
+ return `${lowered}_${shortHash}`;
13
28
  }
14
- export function sanitizeCollectionName(name, apiKeyHash, userAgentHash) {
29
+ export function sanitizeCollectionName(name, apiKeyHash, userAgentNormalized) {
15
30
  const cleaned = name.replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_");
16
31
  const lowered = cleaned.toLowerCase().replace(/^_+/, "");
17
32
  const base = lowered.length > 0 ? lowered : "collection";
18
33
  const hasApiKey = apiKeyHash !== undefined && apiKeyHash.trim().length > 0;
19
- const hasUserAgent = userAgentHash !== undefined && userAgentHash.trim().length > 0;
34
+ const hasUserAgent = userAgentNormalized !== undefined && userAgentNormalized.trim().length > 0;
20
35
  if (hasApiKey && hasUserAgent) {
21
- return `${base}_${apiKeyHash}_${userAgentHash}`;
36
+ return `${base}_${apiKeyHash}_${userAgentNormalized}`;
22
37
  }
23
38
  else if (hasApiKey) {
24
39
  return `${base}_${apiKeyHash}`;
25
40
  }
26
41
  else if (hasUserAgent) {
27
- return `${base}_${userAgentHash}`;
42
+ return `${base}_${userAgentNormalized}`;
28
43
  }
29
44
  return base;
30
45
  }
@@ -0,0 +1 @@
1
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
@@ -0,0 +1,3 @@
1
+ export function isRecord(value) {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }
@@ -14,22 +14,101 @@ export function vectorToFloatBinary(vector) {
14
14
  return buffer;
15
15
  }
16
16
  export function vectorToBitBinary(vector) {
17
- if (vector.length === 0) {
18
- return Buffer.from([10]);
19
- }
20
- const byteCount = Math.ceil(vector.length / 8);
21
- const buffer = Buffer.alloc(byteCount + 1);
17
+ // Mirrors YDB's TKnnBitVectorSerializer (Knn::ToBinaryStringBit) layout:
18
+ // - Packed bits as integers written in native endianness
19
+ // - Then 1 byte: count of unused bits in the last data byte
20
+ // - Then 1 byte: format marker (10 = BitVector)
21
+ //
22
+ // Source: https://raw.githubusercontent.com/ydb-platform/ydb/0b506f56e399e0b4e6a6a4267799da68a3164bf7/ydb/library/yql/udfs/common/knn/knn-serializer.h
23
+ const bitLen = vector.length;
24
+ const dataByteLen = Math.ceil(bitLen / 8);
25
+ const totalLen = dataByteLen + 2; // +1 unused-bit-count +1 format marker
26
+ const buffer = Buffer.alloc(totalLen);
27
+ const isLittleEndian = new Uint8Array(new Uint16Array([0x00ff]).buffer)[0] === 0xff;
28
+ let offset = 0;
29
+ const writeU64 = (v) => {
30
+ if (isLittleEndian) {
31
+ buffer.writeBigUInt64LE(v, offset);
32
+ }
33
+ else {
34
+ buffer.writeBigUInt64BE(v, offset);
35
+ }
36
+ offset += 8;
37
+ };
38
+ const writeU32 = (v) => {
39
+ if (isLittleEndian) {
40
+ buffer.writeUInt32LE(v, offset);
41
+ }
42
+ else {
43
+ buffer.writeUInt32BE(v, offset);
44
+ }
45
+ offset += 4;
46
+ };
47
+ const writeU16 = (v) => {
48
+ if (isLittleEndian) {
49
+ buffer.writeUInt16LE(v, offset);
50
+ }
51
+ else {
52
+ buffer.writeUInt16BE(v, offset);
53
+ }
54
+ offset += 2;
55
+ };
56
+ const writeU8 = (v) => {
57
+ buffer.writeUInt8(v, offset);
58
+ offset += 1;
59
+ };
60
+ let accumulator = 0n;
61
+ let filledBits = 0;
22
62
  for (let i = 0; i < vector.length; i += 1) {
23
63
  const value = vector[i];
24
64
  if (!Number.isFinite(value)) {
25
65
  throw new Error(`Non-finite value in vector at index ${i}: ${value}`);
26
66
  }
27
67
  if (value > 0) {
28
- const byteIndex = Math.floor(i / 8);
29
- const bitIndex = i % 8;
30
- buffer[byteIndex] |= 1 << bitIndex;
68
+ accumulator |= 1n;
69
+ }
70
+ filledBits += 1;
71
+ if (filledBits === 64) {
72
+ writeU64(accumulator);
73
+ accumulator = 0n;
74
+ filledBits = 0;
31
75
  }
76
+ accumulator <<= 1n;
32
77
  }
33
- buffer.writeUInt8(10, byteCount);
78
+ accumulator >>= 1n;
79
+ filledBits += 7;
80
+ const tailWriteIf = (bits) => {
81
+ if (filledBits < bits) {
82
+ return;
83
+ }
84
+ if (bits === 64) {
85
+ writeU64(accumulator & 0xffffffffffffffffn);
86
+ filledBits -= 64;
87
+ return;
88
+ }
89
+ if (bits === 32) {
90
+ writeU32(Number(accumulator & 0xffffffffn));
91
+ accumulator >>= 32n;
92
+ filledBits -= 32;
93
+ return;
94
+ }
95
+ if (bits === 16) {
96
+ writeU16(Number(accumulator & 0xffffn));
97
+ accumulator >>= 16n;
98
+ filledBits -= 16;
99
+ return;
100
+ }
101
+ writeU8(Number(accumulator & 0xffn));
102
+ accumulator >>= 8n;
103
+ filledBits -= 8;
104
+ };
105
+ tailWriteIf(64);
106
+ tailWriteIf(32);
107
+ tailWriteIf(16);
108
+ tailWriteIf(8);
109
+ // After tail writes, we must have < 8 "filledBits" left.
110
+ const unusedBitsInLastByte = 7 - filledBits;
111
+ writeU8(unusedBitsInLastByte);
112
+ writeU8(10);
34
113
  return buffer;
35
114
  }
@@ -0,0 +1,6 @@
1
+ import type { Query } from "@ydbjs/query";
2
+ type QueryDiagContext = Record<string, unknown> & {
3
+ operation: string;
4
+ };
5
+ export declare function attachQueryDiagnostics<T extends unknown[]>(q: Query<T>, context: QueryDiagContext): Query<T>;
6
+ export {};
@@ -0,0 +1,52 @@
1
+ import { StatsMode } from "@ydbjs/api/query";
2
+ import { QUERY_RETRY_LOG_ENABLED, QUERY_STATS_MODE, QueryStatsMode, } from "../config/env.js";
3
+ import { logger } from "../logging/logger.js";
4
+ import { isRecord } from "../utils/typeGuards.js";
5
+ function mapStatsMode(mode) {
6
+ if (mode === QueryStatsMode.Basic)
7
+ return StatsMode.BASIC;
8
+ if (mode === QueryStatsMode.Full)
9
+ return StatsMode.FULL;
10
+ if (mode === QueryStatsMode.Profile)
11
+ return StatsMode.PROFILE;
12
+ return null;
13
+ }
14
+ function pickStatsSummary(stats) {
15
+ if (!isRecord(stats)) {
16
+ return {};
17
+ }
18
+ const phase = stats.queryPhaseStats;
19
+ if (!isRecord(phase)) {
20
+ return {};
21
+ }
22
+ return {
23
+ cpuTimeUs: phase.cpuTimeUs,
24
+ durationUs: phase.durationUs,
25
+ };
26
+ }
27
+ export function attachQueryDiagnostics(q, context) {
28
+ const statsMode = mapStatsMode(QUERY_STATS_MODE);
29
+ let out = q;
30
+ if (statsMode) {
31
+ out = out.withStats(statsMode);
32
+ out.on("stats", (stats) => {
33
+ logger.info({
34
+ ...context,
35
+ queryStatsMode: QUERY_STATS_MODE,
36
+ ...pickStatsSummary(stats),
37
+ }, `${context.operation}: stats`);
38
+ });
39
+ }
40
+ if (QUERY_RETRY_LOG_ENABLED) {
41
+ out.on("retry", (ctx) => {
42
+ logger.warn({
43
+ ...context,
44
+ attempt: ctx.attempt,
45
+ err: ctx.error instanceof Error
46
+ ? ctx.error
47
+ : new Error(String(ctx.error)),
48
+ }, `${context.operation}: retry`);
49
+ });
50
+ }
51
+ return out;
52
+ }
@@ -0,0 +1,36 @@
1
+ import type { Driver } from "@ydbjs/core";
2
+ export type PooledQuerySession = {
3
+ nodeId: bigint;
4
+ sessionId: string;
5
+ };
6
+ export declare class SessionPool {
7
+ private readonly driver;
8
+ private readonly available;
9
+ private readonly inUse;
10
+ private readonly waiters;
11
+ private keepaliveTimer;
12
+ private isClosed;
13
+ constructor(driver: Driver);
14
+ /**
15
+ * Test-only escape hatch to inspect and drive internal state without unsafe casts.
16
+ * Not part of the public API surface.
17
+ */
18
+ __getInternalsForTests(): {
19
+ available: Array<{
20
+ sessionId: string;
21
+ lastCheckedAtMs: number;
22
+ }>;
23
+ keepaliveTick: () => Promise<void>;
24
+ inUse: Set<string>;
25
+ };
26
+ start(): void;
27
+ close(): Promise<void>;
28
+ warmup(signal: AbortSignal): Promise<void>;
29
+ acquire(signal: AbortSignal): Promise<PooledQuerySession>;
30
+ release(session: PooledQuerySession): void;
31
+ discard(session: PooledQuerySession): Promise<void>;
32
+ private totalSize;
33
+ private createAndAttachSession;
34
+ private deleteSessionBestEffort;
35
+ private keepaliveTick;
36
+ }
@@ -0,0 +1,248 @@
1
+ import { StatusIds_StatusCode } from "@ydbjs/api/operation";
2
+ import { QueryServiceDefinition } from "@ydbjs/api/query";
3
+ import { YDBError } from "@ydbjs/error";
4
+ import { logger } from "../logging/logger.js";
5
+ import { SESSION_KEEPALIVE_PERIOD_MS, SESSION_POOL_MAX_SIZE, SESSION_POOL_MIN_SIZE, STARTUP_PROBE_SESSION_TIMEOUT_MS, } from "../config/env.js";
6
+ function isSuccessStatus(status) {
7
+ return status === StatusIds_StatusCode.SUCCESS;
8
+ }
9
+ function asAttachSessionResponse(value) {
10
+ if (typeof value === "object" && value !== null) {
11
+ return value;
12
+ }
13
+ return {};
14
+ }
15
+ function toStatusCode(status) {
16
+ return typeof status === "number"
17
+ ? status
18
+ : StatusIds_StatusCode.STATUS_CODE_UNSPECIFIED;
19
+ }
20
+ const EMPTY_ISSUES = [];
21
+ function nowMs() {
22
+ return Date.now();
23
+ }
24
+ export class SessionPool {
25
+ driver;
26
+ available = [];
27
+ inUse = new Set();
28
+ waiters = [];
29
+ keepaliveTimer = null;
30
+ isClosed = false;
31
+ constructor(driver) {
32
+ this.driver = driver;
33
+ }
34
+ /**
35
+ * Test-only escape hatch to inspect and drive internal state without unsafe casts.
36
+ * Not part of the public API surface.
37
+ */
38
+ __getInternalsForTests() {
39
+ return {
40
+ available: this.available,
41
+ keepaliveTick: () => this.keepaliveTick(),
42
+ inUse: this.inUse,
43
+ };
44
+ }
45
+ start() {
46
+ if (this.keepaliveTimer)
47
+ return;
48
+ this.keepaliveTimer = setInterval(() => {
49
+ void this.keepaliveTick();
50
+ }, SESSION_KEEPALIVE_PERIOD_MS);
51
+ // Don't keep the Node process alive just because of keepalive.
52
+ this.keepaliveTimer.unref();
53
+ }
54
+ async close() {
55
+ this.isClosed = true;
56
+ if (this.keepaliveTimer) {
57
+ clearInterval(this.keepaliveTimer);
58
+ this.keepaliveTimer = null;
59
+ }
60
+ for (const w of this.waiters.splice(0)) {
61
+ w.reject(new Error("SessionPool is closed"));
62
+ }
63
+ const toDelete = this.available.splice(0);
64
+ for (const s of toDelete) {
65
+ await this.deleteSessionBestEffort(s);
66
+ }
67
+ this.inUse.clear();
68
+ }
69
+ async warmup(signal) {
70
+ const target = SESSION_POOL_MIN_SIZE;
71
+ if (target <= 0)
72
+ return;
73
+ while (!this.isClosed && this.totalSize() < target) {
74
+ const s = await this.createAndAttachSession(signal);
75
+ this.available.push({
76
+ ...s,
77
+ lastUsedAtMs: nowMs(),
78
+ lastCheckedAtMs: 0,
79
+ });
80
+ }
81
+ }
82
+ async acquire(signal) {
83
+ if (this.isClosed) {
84
+ throw new Error("SessionPool is closed");
85
+ }
86
+ // Prefer an existing idle session.
87
+ const existing = this.available.pop();
88
+ if (existing) {
89
+ this.inUse.add(existing.sessionId);
90
+ existing.lastUsedAtMs = nowMs();
91
+ return { nodeId: existing.nodeId, sessionId: existing.sessionId };
92
+ }
93
+ // Create a new one if we are under the max.
94
+ if (this.totalSize() < SESSION_POOL_MAX_SIZE) {
95
+ const created = await this.createAndAttachSession(signal);
96
+ const internal = {
97
+ ...created,
98
+ lastUsedAtMs: nowMs(),
99
+ lastCheckedAtMs: 0,
100
+ };
101
+ this.inUse.add(internal.sessionId);
102
+ return { nodeId: internal.nodeId, sessionId: internal.sessionId };
103
+ }
104
+ // Otherwise, wait for a release.
105
+ return await new Promise((resolve, reject) => {
106
+ const cleanup = () => {
107
+ signal.removeEventListener("abort", onAbort);
108
+ };
109
+ const waiter = {
110
+ fulfill: (s) => {
111
+ cleanup();
112
+ this.inUse.add(s.sessionId);
113
+ s.lastUsedAtMs = nowMs();
114
+ resolve({ nodeId: s.nodeId, sessionId: s.sessionId });
115
+ },
116
+ reject: (err) => {
117
+ cleanup();
118
+ reject(err);
119
+ },
120
+ cleanup,
121
+ };
122
+ const onAbort = () => {
123
+ const idx = this.waiters.indexOf(waiter);
124
+ if (idx >= 0) {
125
+ this.waiters.splice(idx, 1);
126
+ }
127
+ waiter.reject(new Error("SessionPool acquire aborted"));
128
+ };
129
+ if (signal.aborted)
130
+ return onAbort();
131
+ signal.addEventListener("abort", onAbort);
132
+ this.waiters.push(waiter);
133
+ });
134
+ }
135
+ release(session) {
136
+ if (this.isClosed) {
137
+ this.inUse.delete(session.sessionId);
138
+ void this.deleteSessionBestEffort(session);
139
+ return;
140
+ }
141
+ if (!this.inUse.has(session.sessionId)) {
142
+ return;
143
+ }
144
+ this.inUse.delete(session.sessionId);
145
+ const internal = {
146
+ ...session,
147
+ lastUsedAtMs: nowMs(),
148
+ lastCheckedAtMs: 0,
149
+ };
150
+ const waiter = this.waiters.shift();
151
+ if (waiter) {
152
+ waiter.fulfill(internal);
153
+ return;
154
+ }
155
+ this.available.push(internal);
156
+ // Soft cap: if we exceed max (shouldn't happen), evict extras.
157
+ while (this.totalSize() > SESSION_POOL_MAX_SIZE &&
158
+ this.available.length > 0) {
159
+ const victim = this.available.shift();
160
+ if (victim) {
161
+ void this.deleteSessionBestEffort(victim);
162
+ }
163
+ }
164
+ }
165
+ async discard(session) {
166
+ this.inUse.delete(session.sessionId);
167
+ await this.deleteSessionBestEffort(session);
168
+ }
169
+ totalSize() {
170
+ return this.available.length + this.inUse.size;
171
+ }
172
+ async createAndAttachSession(signal) {
173
+ await this.driver.ready(signal);
174
+ const client = this.driver.createClient(QueryServiceDefinition);
175
+ const sessionResponse = await client.createSession({}, { signal });
176
+ if (!isSuccessStatus(sessionResponse.status)) {
177
+ throw new YDBError(sessionResponse.status, sessionResponse.issues);
178
+ }
179
+ const nodeId = sessionResponse.nodeId;
180
+ const sessionId = sessionResponse.sessionId;
181
+ const nodeClient = this.driver.createClient(QueryServiceDefinition, nodeId);
182
+ const attachStream = nodeClient.attachSession({ sessionId }, { signal });
183
+ const attach = attachStream[Symbol.asyncIterator]();
184
+ const first = await attach.next();
185
+ const attachResp = asAttachSessionResponse(first.value);
186
+ const code = toStatusCode(attachResp.status);
187
+ if (!isSuccessStatus(code)) {
188
+ throw new YDBError(code, EMPTY_ISSUES);
189
+ }
190
+ return { nodeId, sessionId };
191
+ }
192
+ async deleteSessionBestEffort(session) {
193
+ try {
194
+ const client = this.driver.createClient(QueryServiceDefinition, session.nodeId);
195
+ await client.deleteSession({ sessionId: session.sessionId }, { signal: AbortSignal.timeout(STARTUP_PROBE_SESSION_TIMEOUT_MS) });
196
+ }
197
+ catch (err) {
198
+ logger.warn({ err }, "SessionPool: failed to delete session (ignored)");
199
+ }
200
+ }
201
+ async keepaliveTick() {
202
+ if (this.isClosed)
203
+ return;
204
+ if (this.available.length === 0)
205
+ return;
206
+ // Probe at most one idle session per tick to bound overhead.
207
+ const idx = this.available.reduce((best, cur, i, arr) => {
208
+ const bestAt = arr[best]?.lastCheckedAtMs ?? 0;
209
+ return cur.lastCheckedAtMs < bestAt ? i : best;
210
+ }, 0);
211
+ const s = this.available[idx];
212
+ if (!s)
213
+ return;
214
+ const sessionId = s.sessionId;
215
+ const nodeId = s.nodeId;
216
+ const now = nowMs();
217
+ if (now - s.lastCheckedAtMs < SESSION_KEEPALIVE_PERIOD_MS) {
218
+ return;
219
+ }
220
+ s.lastCheckedAtMs = now;
221
+ try {
222
+ const signal = AbortSignal.timeout(STARTUP_PROBE_SESSION_TIMEOUT_MS);
223
+ const client = this.driver.createClient(QueryServiceDefinition, s.nodeId);
224
+ const attachStream = client.attachSession({ sessionId: s.sessionId }, { signal });
225
+ const attach = attachStream[Symbol.asyncIterator]();
226
+ const first = await attach.next();
227
+ const attachResp = asAttachSessionResponse(first.value);
228
+ const code = toStatusCode(attachResp.status);
229
+ if (!isSuccessStatus(code)) {
230
+ throw new YDBError(code, EMPTY_ISSUES);
231
+ }
232
+ }
233
+ catch {
234
+ // Session likely dead; evict from pool and delete best-effort.
235
+ // Important: `this.available` may have been modified while awaiting the probe.
236
+ // Avoid using the pre-await index, and never delete a session that is currently leased.
237
+ if (this.inUse.has(sessionId)) {
238
+ return;
239
+ }
240
+ const availableIdx = this.available.findIndex((x) => x.sessionId === sessionId);
241
+ if (availableIdx === -1) {
242
+ return;
243
+ }
244
+ this.available.splice(availableIdx, 1);
245
+ void this.deleteSessionBestEffort({ nodeId, sessionId });
246
+ }
247
+ }
248
+ }
@@ -0,0 +1,6 @@
1
+ import type { List } from "@ydbjs/value/list";
2
+ export declare function bulkUpsertRowsOnce(args: {
3
+ tableName: string;
4
+ rowsValue: List;
5
+ timeoutMs: number;
6
+ }): Promise<void>;
@@ -0,0 +1,52 @@
1
+ import { create } from "@bufbuild/protobuf";
2
+ import { StatusIds_StatusCode } from "@ydbjs/api/operation";
3
+ import { OperationServiceDefinition } from "@ydbjs/api/operation";
4
+ import { TableServiceDefinition } from "@ydbjs/api/table";
5
+ import { TypedValueSchema } from "@ydbjs/api/value";
6
+ import { YDBError } from "@ydbjs/error";
7
+ import { setTimeout as sleep } from "node:timers/promises";
8
+ import { __getDriverForInternalUse } from "./client.js";
9
+ function tablePath(database, tableName) {
10
+ const db = database.endsWith("/") ? database.slice(0, -1) : database;
11
+ const t = tableName.startsWith("/") ? tableName : `/${tableName}`;
12
+ return `${db}${t}`;
13
+ }
14
+ async function waitOperationReady(args) {
15
+ for (;;) {
16
+ const resp = await args.operationClient.getOperation({ id: args.operationId }, { signal: args.signal });
17
+ const op = resp.operation;
18
+ if (!op) {
19
+ throw new Error("BulkUpsert: getOperation returned no operation");
20
+ }
21
+ if (op.ready) {
22
+ return { status: op.status, issues: op.issues };
23
+ }
24
+ // Small delay before next poll.
25
+ await sleep(25, undefined, { signal: args.signal });
26
+ }
27
+ }
28
+ export async function bulkUpsertRowsOnce(args) {
29
+ const d = __getDriverForInternalUse();
30
+ const fullTablePath = tablePath(d.database, args.tableName);
31
+ const signal = AbortSignal.timeout(args.timeoutMs);
32
+ await d.ready(signal);
33
+ const tableClient = d.createClient(TableServiceDefinition);
34
+ const operationClient = d.createClient(OperationServiceDefinition);
35
+ const typedRows = create(TypedValueSchema, {
36
+ type: args.rowsValue.type.encode(),
37
+ value: args.rowsValue.encode(),
38
+ });
39
+ const resp = await tableClient.bulkUpsert({ table: fullTablePath, rows: typedRows }, { signal });
40
+ const op = resp.operation;
41
+ if (!op) {
42
+ throw new Error("BulkUpsert: response has no operation");
43
+ }
44
+ const final = op.ready
45
+ ? { status: op.status, issues: op.issues }
46
+ : op.id
47
+ ? await waitOperationReady({ operationClient, operationId: op.id, signal })
48
+ : { status: op.status, issues: op.issues };
49
+ if (final.status !== StatusIds_StatusCode.SUCCESS) {
50
+ throw new YDBError(final.status, final.issues);
51
+ }
52
+ }