ydb-qdrant 8.1.1 → 9.1.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.
Files changed (87) hide show
  1. package/README.md +30 -19
  2. package/dist/SmokeTest.js +2 -2
  3. package/dist/compute/ComputePool.d.ts +5 -0
  4. package/dist/compute/ComputePool.js +64 -0
  5. package/dist/compute/ComputeWorker.d.ts +36 -0
  6. package/dist/compute/ComputeWorker.js +84 -0
  7. package/dist/config/env.d.ts +29 -7
  8. package/dist/config/env.js +70 -35
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +92 -2
  11. package/dist/logging/DeployLogFormatter.d.ts +2 -0
  12. package/dist/logging/DeployLogFormatter.js +131 -0
  13. package/dist/logging/logger.js +13 -1
  14. package/dist/logging/requestContext.d.ts +17 -0
  15. package/dist/logging/requestContext.js +43 -0
  16. package/dist/middleware/requestLogger.js +134 -6
  17. package/dist/middleware/upsertBodyPhase.d.ts +6 -0
  18. package/dist/middleware/upsertBodyPhase.js +184 -0
  19. package/dist/middleware/upsertRequestTimeout.d.ts +16 -0
  20. package/dist/middleware/upsertRequestTimeout.js +158 -0
  21. package/dist/package/api.d.ts +20 -12
  22. package/dist/package/api.js +57 -28
  23. package/dist/qdrant/QdrantRestTypes.d.ts +4 -0
  24. package/dist/qdrant/Requests.d.ts +97 -0
  25. package/dist/qdrant/Requests.js +72 -0
  26. package/dist/repositories/collectionsRepo.d.ts +18 -2
  27. package/dist/repositories/collectionsRepo.js +103 -7
  28. package/dist/repositories/collectionsRepo.one-table.d.ts +4 -3
  29. package/dist/repositories/collectionsRepo.one-table.js +99 -36
  30. package/dist/repositories/collectionsRepo.shared.d.ts +2 -2
  31. package/dist/repositories/collectionsRepo.shared.js +9 -4
  32. package/dist/repositories/pointsRepo.d.ts +6 -4
  33. package/dist/repositories/pointsRepo.js +8 -7
  34. package/dist/repositories/pointsRepo.one-table/Delete.d.ts +2 -2
  35. package/dist/repositories/pointsRepo.one-table/Delete.js +157 -60
  36. package/dist/repositories/pointsRepo.one-table/PathSegmentsFilter.d.ts +7 -5
  37. package/dist/repositories/pointsRepo.one-table/PathSegmentsFilter.js +44 -13
  38. package/dist/repositories/pointsRepo.one-table/Retrieve.d.ts +6 -0
  39. package/dist/repositories/pointsRepo.one-table/Retrieve.js +69 -0
  40. package/dist/repositories/pointsRepo.one-table/Search.d.ts +2 -3
  41. package/dist/repositories/pointsRepo.one-table/Search.js +102 -124
  42. package/dist/repositories/pointsRepo.one-table/Upsert.d.ts +2 -2
  43. package/dist/repositories/pointsRepo.one-table/Upsert.js +244 -48
  44. package/dist/repositories/pointsRepo.one-table.d.ts +1 -0
  45. package/dist/repositories/pointsRepo.one-table.js +1 -0
  46. package/dist/routes/collections.js +45 -36
  47. package/dist/routes/points.js +144 -59
  48. package/dist/server.js +42 -6
  49. package/dist/services/CollectionService.d.ts +7 -5
  50. package/dist/services/CollectionService.js +12 -9
  51. package/dist/services/CollectionService.one-table.js +1 -2
  52. package/dist/services/CollectionService.shared.d.ts +6 -5
  53. package/dist/services/CollectionService.shared.js +28 -12
  54. package/dist/services/PointsService.d.ts +8 -0
  55. package/dist/services/PointsService.js +132 -15
  56. package/dist/types.d.ts +4 -94
  57. package/dist/types.js +1 -54
  58. package/dist/utils/EnvParsers.d.ts +5 -0
  59. package/dist/utils/EnvParsers.js +30 -0
  60. package/dist/utils/PayloadSign.d.ts +4 -0
  61. package/dist/utils/PayloadSign.js +18 -0
  62. package/dist/utils/distance.d.ts +1 -12
  63. package/dist/utils/distance.js +0 -21
  64. package/dist/utils/pathPrefix.d.ts +3 -0
  65. package/dist/utils/pathPrefix.js +47 -0
  66. package/dist/utils/prefixExpansion.d.ts +1 -0
  67. package/dist/utils/prefixExpansion.js +11 -0
  68. package/dist/utils/qdrantResponse.d.ts +13 -0
  69. package/dist/utils/qdrantResponse.js +12 -0
  70. package/dist/utils/requestIdentity.d.ts +8 -0
  71. package/dist/utils/requestIdentity.js +52 -0
  72. package/dist/utils/retry.d.ts +2 -0
  73. package/dist/utils/retry.js +55 -11
  74. package/dist/utils/tenant.d.ts +12 -6
  75. package/dist/utils/tenant.js +41 -32
  76. package/dist/utils/vectorBinary.d.ts +0 -1
  77. package/dist/utils/vectorBinary.js +0 -98
  78. package/dist/utils/ydbErrors.d.ts +1 -0
  79. package/dist/utils/ydbErrors.js +14 -0
  80. package/dist/ydb/bootstrapMetaTable.js +14 -2
  81. package/dist/ydb/client.d.ts +10 -2
  82. package/dist/ydb/client.js +170 -24
  83. package/dist/ydb/helpers.d.ts +0 -1
  84. package/dist/ydb/helpers.js +1 -2
  85. package/dist/ydb/schema.d.ts +2 -0
  86. package/dist/ydb/schema.js +84 -7
  87. package/package.json +10 -5
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  # YDB Qdrant-compatible Service
18
18
 
19
- Qdrant-compatible Node.js/TypeScript **service and npm library** that stores and searches vectors in YDB using a global one-table layout (`qdrant_all_points`) with exact KNN search (single-phase over `embedding`) by default and an optional approximate mode (two‑phase bit-quantized over `embedding_quantized` + `embedding`). Topics: ydb, vector-search, qdrant-compatible, nodejs, typescript, express, yql, ann, semantic-search, rag.
19
+ Qdrant-compatible Node.js/TypeScript **service and npm library** that stores and searches vectors in YDB using a global one-table layout (`qdrant_all_points`) with exact KNN search over `embedding`. Topics: ydb, vector-search, qdrant-compatible, nodejs, typescript, express, yql, ann, semantic-search, rag.
20
20
 
21
21
  Modes:
22
22
  - **HTTP server**: Qdrant-compatible REST API (`/collections`, `/points/*`) on top of YDB.
@@ -54,7 +54,16 @@ npm install
54
54
  ```
55
55
 
56
56
  ## Configure credentials
57
- The server uses `getCredentialsFromEnv()` and supports these env vars (first match wins):
57
+ The server supports explicit programmatic `authService` in the npm API. Without it, env-based credentials are used:
58
+
59
+ - Static username/password credentials (durable local YDB)
60
+ ```bash
61
+ export YDB_STATIC_CREDENTIALS_USER=qdrantapp
62
+ export YDB_STATIC_CREDENTIALS_PASSWORD_FILE=/run/secrets/qdrantapp.password
63
+ ```
64
+ `YDB_STATIC_CREDENTIALS_PASSWORD` is supported only as a fallback when a password file is not set. `YDB_STATIC_CREDENTIALS_AUTH_ENDPOINT` can override the auth endpoint; otherwise the YDB endpoint is used. For private CA TLS, set `YDB_SSL_ROOT_CERTIFICATES_FILE`.
65
+
66
+ The remaining YDB SDK env credentials are resolved by `getCredentialsFromEnv()`:
58
67
 
59
68
  - Service account key file (recommended)
60
69
  ```bash
@@ -79,8 +88,8 @@ The server uses `getCredentialsFromEnv()` and supports these env vars (first mat
79
88
 
80
89
  Also set endpoint and database:
81
90
  ```bash
82
- export YDB_ENDPOINT=grpcs://ydb.serverless.yandexcloud.net:2135
83
- export YDB_DATABASE=/ru-central1/<cloud>/<db>
91
+ export YDB_QDRANT_ENDPOINT=grpcs://ydb.serverless.yandexcloud.net:2135
92
+ export YDB_QDRANT_DATABASE=/ru-central1/<cloud>/<db>
84
93
  ```
85
94
 
86
95
  Optional env:
@@ -88,23 +97,25 @@ Optional env:
88
97
  # Server
89
98
  export PORT=8080
90
99
  export LOG_LEVEL=info
91
- # One-table search tuning (default is 'exact' when unset)
92
- export YDB_QDRANT_SEARCH_MODE=approximate # approximate or exact (default: exact)
93
- export YDB_QDRANT_OVERFETCH_MULTIPLIER=10 # candidate multiplier in approximate mode
100
+ # One-table tuning
101
+ export YDB_QDRANT_UPSERT_BATCH_SIZE=100
102
+ export YDB_QDRANT_SEARCH_TIMEOUT_MS=10000
94
103
  ```
95
104
 
96
105
  ## Use as a Node.js library (npm package)
97
106
 
98
107
  The package entrypoint exports a programmatic API that mirrors the Qdrant HTTP semantics.
99
108
 
109
+ - `createYdbQdrantClient()` requires exactly one namespace/signing identifier:
110
+ pass either `apiKey` or `userUid`, but not both.
111
+
100
112
  - Import and initialize a client (reuses the same YDB env vars as the server):
101
113
  ```ts
102
114
  import { createYdbQdrantClient } from "ydb-qdrant";
103
115
 
104
116
  async function main() {
105
- // defaultTenant is optional; defaults to "default"
106
117
  const client = await createYdbQdrantClient({
107
- defaultTenant: "myapp",
118
+ apiKey: "my-stable-namespace-key",
108
119
  endpoint: "grpcs://lb.etn01g9tcilcon2mrt3h.ydb.mdb.yandexcloud.net:2135",
109
120
  database: "/ru-central1/b1ge4v9r1l3h1q4njclp/etn01g9tcilcon2mrt3h",
110
121
  });
@@ -133,20 +144,20 @@ The package entrypoint exports a programmatic API that mirrors the Qdrant HTTP s
133
144
  }
134
145
  ```
135
146
 
136
- - Multi-tenant usage with `forTenant`:
147
+ - Explicit namespace usage with `userUid`:
137
148
  ```ts
138
149
  const client = await createYdbQdrantClient({
150
+ userUid: "team_a",
139
151
  endpoint: "grpcs://lb.etn01g9tcilcon2mrt3h.ydb.mdb.yandexcloud.net:2135",
140
152
  database: "/ru-central1/b1ge4v9r1l3h1q4njclp/etn01g9tcilcon2mrt3h",
141
153
  });
142
- const tenantClient = client.forTenant("tenant-a");
143
154
 
144
- await tenantClient.upsertPoints("sessions", {
155
+ await client.upsertPoints("sessions", {
145
156
  points: [{ id: "s1", vector: [/* ... */] }],
146
157
  });
147
158
  ```
148
159
 
149
- The request/response shapes follow the same schemas as the HTTP API (`CreateCollectionReq`, `UpsertPointsReq`, `SearchReq`, `DeletePointsReq`), so code written against the REST API can usually be translated directly to the library calls.
160
+ The request/response shapes follow the same schemas as the HTTP API (`CreateCollectionReq`, `UpsertPointsReq`, `SearchReq`, `DeletePointsReq`, `RetrievePointsReq`), so code written against the REST API can usually be translated directly to the library calls. `createYdbQdrantClient` requires exactly one of `apiKey` or `userUid`.
150
161
 
151
162
  ### Example: in-process points search with a shared client
152
163
 
@@ -160,7 +171,7 @@ let clientPromise: ReturnType<typeof createYdbQdrantClient> | null = null;
160
171
  async function getClient() {
161
172
  if (!clientPromise) {
162
173
  clientPromise = createYdbQdrantClient({
163
- defaultTenant: 'myapp',
174
+ apiKey: 'myapp-shared-key',
164
175
  endpoint: 'grpcs://lb.etn01g9tcilcon2mrt3h.ydb.mdb.yandexcloud.net:2135',
165
176
  database: '/ru-central1/b1ge4v9r1l3h1q4njclp/etn01g9tcilcon2mrt3h',
166
177
  });
@@ -193,9 +204,9 @@ For full tables of popular embedding models and their dimensions, see [docs/vect
193
204
 
194
205
  **Option 1: Public Demo (No setup required)**
195
206
  - Set Qdrant URL to `http://ydb-qdrant.tech:8080`
196
- - No API key needed
207
+ - `api-key` recommended for stable isolation
197
208
  - Free to use for testing and development
198
- - Shared instance - use `X-Tenant-Id` header for isolation
209
+ - If `api-key` is omitted, anonymous isolation requires request client metadata such as IP or User-Agent
199
210
 
200
211
  **Option 2: Self-hosted (Local)**
201
212
  - Set Qdrant URL to `http://localhost:8080`
@@ -274,8 +285,8 @@ Basic example:
274
285
  ```bash
275
286
  docker run -d --name ydb-qdrant \
276
287
  -p 8080:8080 \
277
- -e YDB_ENDPOINT=grpcs://ydb.serverless.yandexcloud.net:2135 \
278
- -e YDB_DATABASE=/ru-central1/<cloud>/<db> \
288
+ -e YDB_QDRANT_ENDPOINT=grpcs://ydb.serverless.yandexcloud.net:2135 \
289
+ -e YDB_QDRANT_DATABASE=/ru-central1/<cloud>/<db> \
279
290
  -e YDB_SERVICE_ACCOUNT_KEY_FILE_CREDENTIALS=/sa-key.json \
280
291
  -v /abs/path/sa-key.json:/sa-key.json:ro \
281
292
  ghcr.io/astandrik/ydb-qdrant:latest
@@ -330,7 +341,7 @@ curl -X POST http://localhost:8080/collections/mycol/points/delete \
330
341
 
331
342
  ## Architecture and Storage
332
343
 
333
- For details on the YDB one-table storage layout, vector serialization (full-precision and bit‑quantized), approximate vs exact search modes, request normalization, and Qdrant compatibility semantics, see [docs/architecture-and-storage.md](docs/architecture-and-storage.md).
344
+ For details on the YDB one-table storage layout, vector serialization, request normalization, and Qdrant compatibility semantics, see [docs/architecture-and-storage.md](docs/architecture-and-storage.md).
334
345
 
335
346
  ## Evaluation, CI, and Release
336
347
 
package/dist/SmokeTest.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import "dotenv/config";
2
2
  import { createYdbQdrantClient } from "./package/api.js";
3
3
  async function main() {
4
- const tenant = process.env.SMOKE_TENANT ?? "smoke";
5
4
  const collection = process.env.SMOKE_COLLECTION ?? "demo";
6
- const client = await createYdbQdrantClient({ defaultTenant: tenant });
5
+ const apiKey = process.env.SMOKE_API_KEY ?? "smoke-demo-api-key";
6
+ const client = await createYdbQdrantClient({ apiKey });
7
7
  await client.createCollection(collection, {
8
8
  vectors: {
9
9
  size: 4,
@@ -0,0 +1,5 @@
1
+ import type { PrepareUpsertBatchTask, PreparedUpsertRow, VerifySearchRowsResult, VerifySearchRowsTask } from "./ComputeWorker.js";
2
+ export declare function isComputePoolEnabled(): boolean;
3
+ export declare function isComputePoolQueueAtLimitError(err: unknown): boolean;
4
+ export declare function runPrepareUpsertBatch(task: PrepareUpsertBatchTask): Promise<PreparedUpsertRow[]>;
5
+ export declare function runVerifySearchRows(task: VerifySearchRowsTask): Promise<VerifySearchRowsResult>;
@@ -0,0 +1,64 @@
1
+ import { Piscina } from "piscina";
2
+ import { WORKERS_ENABLED, WORKERS_IDLE_TIMEOUT_MS, WORKERS_MAX_QUEUE, WORKERS_MAX_THREADS, WORKERS_MIN_THREADS, } from "../config/env.js";
3
+ import { logger } from "../logging/logger.js";
4
+ let pool;
5
+ function buildWorkerFilename() {
6
+ const isTsSource = import.meta.url.endsWith(".ts");
7
+ const workerUrl = new URL(isTsSource ? "./ComputeWorker.ts" : "./ComputeWorker.js", import.meta.url);
8
+ return workerUrl.href;
9
+ }
10
+ function buildExecArgv() {
11
+ const isTsSource = import.meta.url.endsWith(".ts");
12
+ if (!isTsSource) {
13
+ return undefined;
14
+ }
15
+ // Enable TypeScript execution in worker threads while running under tsx (dev).
16
+ // See: https://tsx.is/dev-api/
17
+ return ["--import", "tsx"];
18
+ }
19
+ function createPool() {
20
+ const filename = buildWorkerFilename();
21
+ const execArgv = buildExecArgv();
22
+ const p = new Piscina({
23
+ filename,
24
+ ...(execArgv ? { execArgv } : {}),
25
+ minThreads: WORKERS_MIN_THREADS,
26
+ maxThreads: WORKERS_MAX_THREADS,
27
+ ...(WORKERS_IDLE_TIMEOUT_MS >= 0
28
+ ? { idleTimeout: WORKERS_IDLE_TIMEOUT_MS }
29
+ : {}),
30
+ ...(WORKERS_MAX_QUEUE !== undefined ? { maxQueue: WORKERS_MAX_QUEUE } : {}),
31
+ });
32
+ p.on("error", (err) => {
33
+ logger.error({ err }, "Compute worker pool emitted an error");
34
+ });
35
+ return p;
36
+ }
37
+ function getPool() {
38
+ if (!pool) {
39
+ pool = createPool();
40
+ }
41
+ return pool;
42
+ }
43
+ export function isComputePoolEnabled() {
44
+ return WORKERS_ENABLED === true;
45
+ }
46
+ export function isComputePoolQueueAtLimitError(err) {
47
+ if (!(err instanceof Error)) {
48
+ return false;
49
+ }
50
+ // Piscina uses these messages for backpressure / overload conditions.
51
+ // See piscina/src/errors.ts in the dependency for the canonical strings.
52
+ return (err.message === "Task queue is at limit" ||
53
+ err.message === "No task queue available and all Workers are busy");
54
+ }
55
+ export async function runPrepareUpsertBatch(task) {
56
+ return (await getPool().run(task, {
57
+ name: "prepareUpsertBatch",
58
+ }));
59
+ }
60
+ export async function runVerifySearchRows(task) {
61
+ return (await getPool().run(task, {
62
+ name: "verifySearchRows",
63
+ }));
64
+ }
@@ -0,0 +1,36 @@
1
+ import type { UpsertPoint } from "../qdrant/Requests.js";
2
+ import type { YdbQdrantScoredPoint } from "../qdrant/QdrantRestTypes.js";
3
+ export type PrepareUpsertBatchTask = {
4
+ collection: string;
5
+ apiKey: string;
6
+ batch: Array<Pick<UpsertPoint, "id" | "vector" | "payload">>;
7
+ };
8
+ export type PreparedUpsertRow = {
9
+ collection: string;
10
+ point_id: string;
11
+ embedding: Buffer;
12
+ payload: string;
13
+ payload_sign: string;
14
+ path_prefix: string | null;
15
+ };
16
+ export declare function prepareUpsertBatch(task: PrepareUpsertBatchTask): PreparedUpsertRow[];
17
+ export type VerifySearchRowsTask = {
18
+ collection: string;
19
+ apiKey: string;
20
+ withPayload: boolean | undefined;
21
+ rows: Array<{
22
+ pointId: string;
23
+ payloadText: string | undefined;
24
+ payloadSign: string | undefined;
25
+ scoreText: string | undefined;
26
+ scoreFloat: number | undefined;
27
+ }>;
28
+ };
29
+ export type VerifySearchRowsResult = {
30
+ points: YdbQdrantScoredPoint[];
31
+ dropped: Array<{
32
+ pointId: string;
33
+ reason: "missing_payload_or_signature" | "signature_mismatch";
34
+ }>;
35
+ };
36
+ export declare function verifySearchRows(task: VerifySearchRowsTask): VerifySearchRowsResult;
@@ -0,0 +1,84 @@
1
+ import { buildVectorBinaryParams } from "../ydb/helpers.js";
2
+ import { Piscina, move, transferableSymbol, valueSymbol } from "piscina";
3
+ import { computePayloadSign } from "../utils/PayloadSign.js";
4
+ import { extractPathPrefix } from "../utils/pathPrefix.js";
5
+ export function prepareUpsertBatch(task) {
6
+ const apiKey = task.apiKey.trim();
7
+ if (apiKey.length === 0) {
8
+ throw new Error("prepareUpsertBatch: apiKey is empty");
9
+ }
10
+ const rows = task.batch.map((p) => {
11
+ const binaries = buildVectorBinaryParams(p.vector);
12
+ const payloadObj = p.payload ?? {};
13
+ return {
14
+ collection: task.collection,
15
+ point_id: String(p.id),
16
+ embedding: binaries.float,
17
+ payload: JSON.stringify(payloadObj),
18
+ payload_sign: computePayloadSign({ apiKey, payload: payloadObj }),
19
+ path_prefix: extractPathPrefix(payloadObj),
20
+ };
21
+ });
22
+ if (!Piscina.isWorkerThread) {
23
+ return rows;
24
+ }
25
+ // Transfer underlying ArrayBuffers to the main thread to avoid cloning large binary payloads.
26
+ // See Node worker_threads transferList semantics and Piscina's Transferable interface:
27
+ // https://nodejs.org/api/worker_threads.html
28
+ // https://www.npmjs.com/package/piscina
29
+ const transferable = {
30
+ get [transferableSymbol]() {
31
+ const out = [];
32
+ for (const r of rows) {
33
+ out.push(r.embedding.buffer);
34
+ }
35
+ return out;
36
+ },
37
+ get [valueSymbol]() {
38
+ return rows;
39
+ },
40
+ };
41
+ return move(transferable);
42
+ }
43
+ export function verifySearchRows(task) {
44
+ const apiKey = task.apiKey.trim();
45
+ if (apiKey.length === 0) {
46
+ throw new Error("verifySearchRows: apiKey is empty");
47
+ }
48
+ const shouldReturnPayload = task.withPayload === true;
49
+ const points = [];
50
+ const dropped = [];
51
+ for (const row of task.rows) {
52
+ let payload;
53
+ if (row.payloadText) {
54
+ try {
55
+ payload = JSON.parse(row.payloadText);
56
+ }
57
+ catch {
58
+ payload = undefined;
59
+ }
60
+ }
61
+ if (!payload || typeof row.payloadSign !== "string" || row.payloadSign === "") {
62
+ dropped.push({
63
+ pointId: row.pointId,
64
+ reason: "missing_payload_or_signature",
65
+ });
66
+ continue;
67
+ }
68
+ const expected = computePayloadSign({ apiKey, payload });
69
+ if (expected !== row.payloadSign) {
70
+ dropped.push({
71
+ pointId: row.pointId,
72
+ reason: "signature_mismatch",
73
+ });
74
+ continue;
75
+ }
76
+ const score = Number(row.scoreFloat ?? row.scoreText);
77
+ points.push({
78
+ id: row.pointId,
79
+ score,
80
+ ...(shouldReturnPayload ? { payload } : {}),
81
+ });
82
+ }
83
+ return { points, dropped };
84
+ }
@@ -1,20 +1,42 @@
1
1
  import "dotenv/config";
2
+ export declare const YDB_QDRANT_ENDPOINT: string;
3
+ export declare const YDB_QDRANT_DATABASE: string;
4
+ export declare const YDB_STATIC_CREDENTIALS_USER: string;
5
+ export declare const YDB_STATIC_CREDENTIALS_PASSWORD_FILE: string;
6
+ export declare const YDB_STATIC_CREDENTIALS_PASSWORD: string;
7
+ export declare const YDB_STATIC_CREDENTIALS_AUTH_ENDPOINT: string;
8
+ export declare const YDB_SSL_ROOT_CERTIFICATES_FILE: string;
9
+ export declare const LEGACY_YDB_ENDPOINT: string;
10
+ export declare const LEGACY_YDB_DATABASE: string;
2
11
  export declare const YDB_ENDPOINT: string;
3
12
  export declare const YDB_DATABASE: string;
13
+ export declare function resolveYdbConnectionConfig(options?: {
14
+ endpoint?: string;
15
+ database?: string;
16
+ connectionString?: string;
17
+ }): {
18
+ connectionString: string;
19
+ } | {
20
+ endpoint: string;
21
+ database: string;
22
+ };
4
23
  export declare const PORT: number;
5
24
  export declare const LOG_LEVEL: string;
6
- export declare enum SearchMode {
7
- Exact = "exact",
8
- Approximate = "approximate"
9
- }
10
- export declare function resolveSearchMode(raw: string | undefined): SearchMode;
11
- export declare const SEARCH_MODE: SearchMode;
12
- export declare const OVERFETCH_MULTIPLIER: number;
13
25
  export declare const UPSERT_BATCH_SIZE: number;
26
+ export declare const DELETE_FILTER_SELECT_BATCH_SIZE: number;
14
27
  export declare const SESSION_POOL_MIN_SIZE: number;
15
28
  export declare const SESSION_POOL_MAX_SIZE: number;
16
29
  export declare const SESSION_KEEPALIVE_PERIOD_MS: number;
30
+ export declare const YDB_SESSION_RETRY_MAX_RETRIES: number;
17
31
  export declare const STARTUP_PROBE_SESSION_TIMEOUT_MS: number;
18
32
  export declare const UPSERT_OPERATION_TIMEOUT_MS: number;
33
+ export declare const UPSERT_BODY_TIMEOUT_MS: number;
34
+ export declare const UPSERT_HTTP_TIMEOUT_MS: number;
19
35
  export declare const SEARCH_OPERATION_TIMEOUT_MS: number;
36
+ export declare const TABLE_SESSION_TIMEOUT_MS: number;
20
37
  export declare const LAST_ACCESS_MIN_WRITE_INTERVAL_MS: number;
38
+ export declare const WORKERS_ENABLED: boolean;
39
+ export declare const WORKERS_MAX_THREADS: number;
40
+ export declare const WORKERS_MIN_THREADS: number;
41
+ export declare const WORKERS_IDLE_TIMEOUT_MS: number;
42
+ export declare const WORKERS_MAX_QUEUE: number | "auto" | undefined;
@@ -1,47 +1,59 @@
1
1
  import "dotenv/config";
2
- export const YDB_ENDPOINT = process.env.YDB_ENDPOINT ?? "";
3
- export const YDB_DATABASE = process.env.YDB_DATABASE ?? "";
4
- export const PORT = process.env.PORT ? Number(process.env.PORT) : 8080;
5
- export const LOG_LEVEL = process.env.LOG_LEVEL ?? "info";
6
- function parseIntegerEnv(value, defaultValue, opts) {
7
- if (value === undefined) {
8
- return defaultValue;
9
- }
10
- const parsed = Number.parseInt(value.trim(), 10);
11
- if (!Number.isFinite(parsed)) {
12
- return defaultValue;
2
+ import { parseBooleanEnv, parseIntegerEnv } from "../utils/EnvParsers.js";
3
+ export const YDB_QDRANT_ENDPOINT = process.env.YDB_QDRANT_ENDPOINT?.trim() ?? "";
4
+ export const YDB_QDRANT_DATABASE = process.env.YDB_QDRANT_DATABASE?.trim() ?? "";
5
+ export const YDB_STATIC_CREDENTIALS_USER = process.env.YDB_STATIC_CREDENTIALS_USER?.trim() ?? "";
6
+ export const YDB_STATIC_CREDENTIALS_PASSWORD_FILE = process.env.YDB_STATIC_CREDENTIALS_PASSWORD_FILE?.trim() ?? "";
7
+ export const YDB_STATIC_CREDENTIALS_PASSWORD = process.env.YDB_STATIC_CREDENTIALS_PASSWORD ?? "";
8
+ export const YDB_STATIC_CREDENTIALS_AUTH_ENDPOINT = process.env.YDB_STATIC_CREDENTIALS_AUTH_ENDPOINT?.trim() ?? "";
9
+ export const YDB_SSL_ROOT_CERTIFICATES_FILE = process.env.YDB_SSL_ROOT_CERTIFICATES_FILE?.trim() ?? "";
10
+ export const LEGACY_YDB_ENDPOINT = process.env.YDB_ENDPOINT?.trim() ?? "";
11
+ export const LEGACY_YDB_DATABASE = process.env.YDB_DATABASE?.trim() ?? "";
12
+ export const YDB_ENDPOINT = YDB_QDRANT_ENDPOINT;
13
+ export const YDB_DATABASE = YDB_QDRANT_DATABASE;
14
+ const LEGACY_YDB_ENDPOINT_ERROR = [
15
+ "Legacy env var YDB_ENDPOINT is not supported by ydb-qdrant.",
16
+ "Reason: ydb-sdk uses process.env.YDB_ENDPOINT as a dev-only override that forces all discovered endpoints to that host, breaking discovery and potentially causing session hangs/timeouts.",
17
+ "Fix: set YDB_QDRANT_ENDPOINT instead.",
18
+ ].join(" ");
19
+ const MISSING_YDB_CONNECTION_SETTINGS_ERROR = [
20
+ "Missing YDB connection settings.",
21
+ "Set YDB_QDRANT_ENDPOINT (grpc(s)://host:port) and YDB_QDRANT_DATABASE (/path/to/db).",
22
+ "Legacy YDB_ENDPOINT/YDB_DATABASE are not supported as application config.",
23
+ ].join(" ");
24
+ export function resolveYdbConnectionConfig(options) {
25
+ if (LEGACY_YDB_ENDPOINT) {
26
+ throw new Error(LEGACY_YDB_ENDPOINT_ERROR);
13
27
  }
14
- let result = parsed;
15
- if (opts?.min !== undefined && result < opts.min) {
16
- result = opts.min;
28
+ const connectionString = options?.connectionString?.trim();
29
+ if (connectionString) {
30
+ return { connectionString };
17
31
  }
18
- if (opts?.max !== undefined && result > opts.max) {
19
- result = opts.max;
32
+ const endpoint = options?.endpoint?.trim() || YDB_QDRANT_ENDPOINT;
33
+ const database = options?.database?.trim() || YDB_QDRANT_DATABASE;
34
+ if (!endpoint || !database) {
35
+ throw new Error(MISSING_YDB_CONNECTION_SETTINGS_ERROR);
20
36
  }
21
- return result;
37
+ return { endpoint, database };
22
38
  }
23
- export var SearchMode;
24
- (function (SearchMode) {
25
- SearchMode["Exact"] = "exact";
26
- SearchMode["Approximate"] = "approximate";
27
- })(SearchMode || (SearchMode = {}));
28
- export function resolveSearchMode(raw) {
29
- const normalized = raw?.trim().toLowerCase();
30
- if (normalized === SearchMode.Exact) {
31
- return SearchMode.Exact;
39
+ function parseWorkersMaxQueue(value) {
40
+ const raw = value?.trim().toLowerCase();
41
+ if (!raw) {
42
+ return "auto";
32
43
  }
33
- if (normalized === SearchMode.Approximate) {
34
- return SearchMode.Approximate;
44
+ if (raw === "auto") {
45
+ return "auto";
35
46
  }
36
- // Default: exact search (single-phase over full-precision embedding) for the one-table layout.
37
- return SearchMode.Exact;
47
+ const n = parseIntegerEnv(raw, 0, { min: 1 });
48
+ return n > 0 ? n : undefined;
38
49
  }
39
- function resolveSearchModeEnv() {
40
- return resolveSearchMode(process.env.YDB_QDRANT_SEARCH_MODE);
41
- }
42
- export const SEARCH_MODE = resolveSearchModeEnv();
43
- export const OVERFETCH_MULTIPLIER = parseIntegerEnv(process.env.YDB_QDRANT_OVERFETCH_MULTIPLIER, 10, { min: 1 });
50
+ export const PORT = parseIntegerEnv(process.env.PORT, 8080, {
51
+ min: 1,
52
+ max: 65535,
53
+ });
54
+ export const LOG_LEVEL = process.env.LOG_LEVEL ?? "info";
44
55
  export const UPSERT_BATCH_SIZE = parseIntegerEnv(process.env.YDB_QDRANT_UPSERT_BATCH_SIZE, 100, { min: 1 });
56
+ export const DELETE_FILTER_SELECT_BATCH_SIZE = parseIntegerEnv(process.env.YDB_QDRANT_DELETE_FILTER_SELECT_BATCH_SIZE, 10000, { min: 1 });
45
57
  // Session pool configuration
46
58
  const RAW_SESSION_POOL_MIN_SIZE = parseIntegerEnv(process.env.YDB_SESSION_POOL_MIN_SIZE, 5, { min: 1, max: 500 });
47
59
  const RAW_SESSION_POOL_MAX_SIZE = parseIntegerEnv(process.env.YDB_SESSION_POOL_MAX_SIZE, 100, { min: 1, max: 500 });
@@ -51,7 +63,30 @@ const NORMALIZED_SESSION_POOL_MIN_SIZE = RAW_SESSION_POOL_MIN_SIZE > RAW_SESSION
51
63
  export const SESSION_POOL_MIN_SIZE = NORMALIZED_SESSION_POOL_MIN_SIZE;
52
64
  export const SESSION_POOL_MAX_SIZE = RAW_SESSION_POOL_MAX_SIZE;
53
65
  export const SESSION_KEEPALIVE_PERIOD_MS = parseIntegerEnv(process.env.YDB_SESSION_KEEPALIVE_PERIOD_MS, 5000, { min: 1000, max: 60000 });
66
+ export const YDB_SESSION_RETRY_MAX_RETRIES = parseIntegerEnv(process.env.YDB_SESSION_RETRY_MAX_RETRIES, 3, { min: 0, max: 50 });
54
67
  export const STARTUP_PROBE_SESSION_TIMEOUT_MS = parseIntegerEnv(process.env.YDB_QDRANT_STARTUP_PROBE_SESSION_TIMEOUT_MS, 5000, { min: 1000 });
55
68
  export const UPSERT_OPERATION_TIMEOUT_MS = parseIntegerEnv(process.env.YDB_QDRANT_UPSERT_TIMEOUT_MS, 5000, { min: 1000 });
69
+ const RAW_UPSERT_BODY_TIMEOUT_MS = process.env.YDB_QDRANT_UPSERT_BODY_TIMEOUT_MS?.trim();
70
+ export const UPSERT_BODY_TIMEOUT_MS = RAW_UPSERT_BODY_TIMEOUT_MS === "0"
71
+ ? 0
72
+ : parseIntegerEnv(RAW_UPSERT_BODY_TIMEOUT_MS, 60000, {
73
+ min: 1000,
74
+ });
75
+ const RAW_UPSERT_HTTP_TIMEOUT_MS = process.env.YDB_QDRANT_UPSERT_HTTP_TIMEOUT_MS?.trim();
76
+ export const UPSERT_HTTP_TIMEOUT_MS = RAW_UPSERT_HTTP_TIMEOUT_MS === "0"
77
+ ? 0
78
+ : parseIntegerEnv(RAW_UPSERT_HTTP_TIMEOUT_MS, 10000, {
79
+ min: 1000,
80
+ });
56
81
  export const SEARCH_OPERATION_TIMEOUT_MS = parseIntegerEnv(process.env.YDB_QDRANT_SEARCH_TIMEOUT_MS, 10000, { min: 1000 });
82
+ export const TABLE_SESSION_TIMEOUT_MS = parseIntegerEnv(process.env.YDB_QDRANT_SESSION_TIMEOUT_MS, 30000, { min: 5000, max: 120000 });
57
83
  export const LAST_ACCESS_MIN_WRITE_INTERVAL_MS = parseIntegerEnv(process.env.YDB_QDRANT_LAST_ACCESS_MIN_WRITE_INTERVAL_MS, 60000, { min: 1000 });
84
+ export const WORKERS_ENABLED = parseBooleanEnv(process.env.YDB_QDRANT_WORKERS_ENABLED, false);
85
+ const RAW_WORKERS_MIN_THREADS = parseIntegerEnv(process.env.YDB_QDRANT_WORKERS_MIN_THREADS, 0, { min: 0, max: 512 });
86
+ const RAW_WORKERS_MAX_THREADS = parseIntegerEnv(process.env.YDB_QDRANT_WORKERS_MAX_THREADS, 1, { min: 1, max: 512 });
87
+ export const WORKERS_MAX_THREADS = RAW_WORKERS_MAX_THREADS;
88
+ export const WORKERS_MIN_THREADS = RAW_WORKERS_MIN_THREADS > RAW_WORKERS_MAX_THREADS
89
+ ? RAW_WORKERS_MAX_THREADS
90
+ : RAW_WORKERS_MIN_THREADS;
91
+ export const WORKERS_IDLE_TIMEOUT_MS = parseIntegerEnv(process.env.YDB_QDRANT_WORKERS_IDLE_TIMEOUT_MS, 10000, { min: 0, max: 600000 });
92
+ export const WORKERS_MAX_QUEUE = parseWorkersMaxQueue(process.env.YDB_QDRANT_WORKERS_MAX_QUEUE);
package/dist/index.d.ts CHANGED
@@ -1 +1,3 @@
1
1
  import "dotenv/config";
2
+ export declare function installStderrAsErrorLogger(): void;
3
+ export declare function __restoreStderrWriteForTests(): void;
package/dist/index.js CHANGED
@@ -1,15 +1,95 @@
1
1
  import "dotenv/config";
2
2
  import { buildServer } from "./server.js";
3
- import { PORT } from "./config/env.js";
3
+ import { PORT, UPSERT_BODY_TIMEOUT_MS, UPSERT_HTTP_TIMEOUT_MS, } from "./config/env.js";
4
4
  import { logger } from "./logging/logger.js";
5
5
  import { readyOrThrow, isCompilationTimeoutError } from "./ydb/client.js";
6
- import { ensureMetaTable, ensureGlobalPointsTable } from "./ydb/schema.js";
6
+ import { GLOBAL_POINTS_TABLE, ensureMetaTable, ensureGlobalPointsTable, POINTS_BY_FILE_LOOKUP_TABLE, ensurePointsByFileTable, } from "./ydb/schema.js";
7
7
  import { verifyCollectionsQueryCompilationForStartup } from "./repositories/collectionsRepo.js";
8
+ import { scheduleExit } from "./utils/exit.js";
9
+ function describeUnknownError(err) {
10
+ if (err instanceof Error) {
11
+ return err.message;
12
+ }
13
+ if (typeof err === "string") {
14
+ return err;
15
+ }
16
+ try {
17
+ return JSON.stringify(err);
18
+ }
19
+ catch {
20
+ return String(err);
21
+ }
22
+ }
23
+ function isFatalStartupSchemaError(err) {
24
+ if (!(err instanceof Error)) {
25
+ return false;
26
+ }
27
+ return (err.message.includes(`Global points table ${GLOBAL_POINTS_TABLE}`) ||
28
+ err.message.includes(`Points-by-file lookup table ${POINTS_BY_FILE_LOOKUP_TABLE}`));
29
+ }
30
+ let originalStderrWrite;
31
+ let stderrAsErrorLoggerInstalled = false;
32
+ function writeToOriginalStderr(chunk, encoding, cb) {
33
+ if (!originalStderrWrite) {
34
+ return true;
35
+ }
36
+ if (typeof encoding === "function") {
37
+ return originalStderrWrite(chunk, encoding);
38
+ }
39
+ if (encoding !== undefined) {
40
+ return originalStderrWrite(chunk, encoding, cb);
41
+ }
42
+ return originalStderrWrite(chunk);
43
+ }
44
+ export function installStderrAsErrorLogger() {
45
+ if (stderrAsErrorLoggerInstalled) {
46
+ return;
47
+ }
48
+ // Some infra log collectors treat raw stderr lines as DEBUG by default.
49
+ // Mirror stderr writes into structured error logs while preserving the
50
+ // original stream so native/runtime diagnostics still reach stderr.
51
+ originalStderrWrite = process.stderr.write.bind(process.stderr);
52
+ process.stderr.write = ((chunk, encoding, cb) => {
53
+ const enc = typeof encoding === "string" ? encoding : undefined;
54
+ const text = typeof chunk === "string"
55
+ ? chunk
56
+ : Buffer.from(chunk).toString(enc ?? "utf8");
57
+ for (const line of text.split(/\r?\n/)) {
58
+ if (line.length > 0) {
59
+ logger.error({ stream: "stderr" }, line);
60
+ }
61
+ }
62
+ return writeToOriginalStderr(chunk, encoding, cb);
63
+ });
64
+ stderrAsErrorLoggerInstalled = true;
65
+ }
66
+ export function __restoreStderrWriteForTests() {
67
+ if (!stderrAsErrorLoggerInstalled || !originalStderrWrite) {
68
+ return;
69
+ }
70
+ process.stderr.write = originalStderrWrite;
71
+ stderrAsErrorLoggerInstalled = false;
72
+ }
73
+ installStderrAsErrorLogger();
74
+ process.on("uncaughtException", (err) => {
75
+ logger.fatal({ err }, "Uncaught exception");
76
+ scheduleExit(1);
77
+ });
78
+ process.on("unhandledRejection", (reason) => {
79
+ if (reason instanceof Error) {
80
+ logger.fatal({ err: reason }, "Unhandled rejection");
81
+ }
82
+ else {
83
+ logger.fatal({ err: new Error(describeUnknownError(reason)), reason }, "Unhandled rejection");
84
+ }
85
+ scheduleExit(1);
86
+ });
8
87
  async function start() {
9
88
  try {
10
89
  await readyOrThrow();
11
90
  await ensureMetaTable();
12
91
  await ensureGlobalPointsTable();
92
+ await ensurePointsByFileTable();
13
93
  await verifyCollectionsQueryCompilationForStartup();
14
94
  logger.info("YDB compilation startup probe for qdr__collections completed successfully");
15
95
  }
@@ -17,9 +97,19 @@ async function start() {
17
97
  if (isCompilationTimeoutError(err)) {
18
98
  logger.error({ err }, "Fatal YDB compilation timeout during startup probe; exiting so supervisor can restart the process");
19
99
  process.exit(1);
100
+ return;
101
+ }
102
+ if (isFatalStartupSchemaError(err)) {
103
+ logger.error({ err }, "Fatal YDB schema/startup check failure; exiting until required migrations are applied");
104
+ process.exit(1);
105
+ return;
20
106
  }
21
107
  logger.error({ err }, "YDB not ready; startup continues, requests may fail until configured.");
22
108
  }
109
+ logger.info({
110
+ upsertBodyTimeoutMs: UPSERT_BODY_TIMEOUT_MS,
111
+ upsertProcessingTimeoutMs: UPSERT_HTTP_TIMEOUT_MS,
112
+ }, "Resolved upsert timeout budgets");
23
113
  const app = buildServer();
24
114
  app.listen(PORT, () => {
25
115
  logger.info({ port: PORT }, "ydb-qdrant proxy listening");
@@ -0,0 +1,2 @@
1
+ import { Transform } from "node:stream";
2
+ export declare function createDeployLogFormatter(): Transform;