ydb-qdrant 5.2.1 → 6.0.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.
@@ -20,3 +20,4 @@ export declare const SESSION_KEEPALIVE_PERIOD_MS: number;
20
20
  export declare const STARTUP_PROBE_SESSION_TIMEOUT_MS: number;
21
21
  export declare const UPSERT_OPERATION_TIMEOUT_MS: number;
22
22
  export declare const SEARCH_OPERATION_TIMEOUT_MS: number;
23
+ export declare const LAST_ACCESS_MIN_WRITE_INTERVAL_MS: number;
@@ -71,3 +71,4 @@ export const SESSION_KEEPALIVE_PERIOD_MS = parseIntegerEnv(process.env.YDB_SESSI
71
71
  export const STARTUP_PROBE_SESSION_TIMEOUT_MS = parseIntegerEnv(process.env.YDB_QDRANT_STARTUP_PROBE_SESSION_TIMEOUT_MS, 5000, { min: 1000 });
72
72
  export const UPSERT_OPERATION_TIMEOUT_MS = parseIntegerEnv(process.env.YDB_QDRANT_UPSERT_TIMEOUT_MS, 5000, { min: 1000 });
73
73
  export const SEARCH_OPERATION_TIMEOUT_MS = parseIntegerEnv(process.env.YDB_QDRANT_SEARCH_TIMEOUT_MS, 10000, { min: 1000 });
74
+ export const LAST_ACCESS_MIN_WRITE_INTERVAL_MS = parseIntegerEnv(process.env.YDB_QDRANT_LAST_ACCESS_MIN_WRITE_INTERVAL_MS, 60000, { min: 1000 });
@@ -1,10 +1,14 @@
1
- import type { DistanceKind, VectorType } from "../types";
1
+ import type { DistanceKind, VectorType, CollectionMeta } from "../types";
2
2
  export declare function createCollection(metaKey: string, dim: number, distance: DistanceKind, vectorType: VectorType): Promise<void>;
3
- export declare function getCollectionMeta(metaKey: string): Promise<{
4
- table: string;
5
- dimension: number;
6
- distance: DistanceKind;
7
- vectorType: VectorType;
8
- } | null>;
3
+ export declare function getCollectionMeta(metaKey: string): Promise<CollectionMeta | null>;
9
4
  export declare function verifyCollectionsQueryCompilationForStartup(): Promise<void>;
10
5
  export declare function deleteCollection(metaKey: string, uid?: string): Promise<void>;
6
+ /**
7
+ * Best-effort metadata update for a collection's last_accessed_at timestamp.
8
+ *
9
+ * - Uses an in-memory throttle (per metaKey) to avoid writing more often than
10
+ * LAST_ACCESS_MIN_WRITE_INTERVAL_MS.
11
+ * - Accepts an optional now parameter (default: current time) to aid testing.
12
+ * - Logs and ignores YDB errors so callers' primary operations are not affected.
13
+ */
14
+ export declare function touchCollectionLastAccess(metaKey: string, now?: Date): Promise<void>;
@@ -1,15 +1,39 @@
1
1
  import { TypedValues, withSession, createExecuteQuerySettings, withStartupProbeSession, createExecuteQuerySettingsWithTimeout, } from "../ydb/client.js";
2
- import { STARTUP_PROBE_SESSION_TIMEOUT_MS } from "../config/env.js";
2
+ import { STARTUP_PROBE_SESSION_TIMEOUT_MS, LAST_ACCESS_MIN_WRITE_INTERVAL_MS, } from "../config/env.js";
3
+ import { logger } from "../logging/logger.js";
3
4
  import { uidFor } from "../utils/tenant.js";
4
5
  import { createCollectionOneTable, deleteCollectionOneTable, } from "./collectionsRepo.one-table.js";
5
6
  import { withRetry, isTransientYdbError } from "../utils/retry.js";
7
+ const lastAccessWriteCache = new Map();
8
+ const LAST_ACCESS_CACHE_MAX_SIZE = 10000;
9
+ function evictOldestLastAccessEntry() {
10
+ if (lastAccessWriteCache.size < LAST_ACCESS_CACHE_MAX_SIZE) {
11
+ return;
12
+ }
13
+ const oldestKey = lastAccessWriteCache.keys().next().value;
14
+ if (oldestKey !== undefined) {
15
+ lastAccessWriteCache.delete(oldestKey);
16
+ }
17
+ }
18
+ function shouldWriteLastAccess(nowMs, key) {
19
+ const last = lastAccessWriteCache.get(key);
20
+ if (last === undefined) {
21
+ return true;
22
+ }
23
+ return nowMs - last >= LAST_ACCESS_MIN_WRITE_INTERVAL_MS;
24
+ }
6
25
  export async function createCollection(metaKey, dim, distance, vectorType) {
7
26
  await createCollectionOneTable(metaKey, dim, distance, vectorType);
8
27
  }
9
28
  export async function getCollectionMeta(metaKey) {
10
29
  const qry = `
11
30
  DECLARE $collection AS Utf8;
12
- SELECT table_name, vector_dimension, distance, vector_type
31
+ SELECT
32
+ table_name,
33
+ vector_dimension,
34
+ distance,
35
+ vector_type,
36
+ CAST(last_accessed_at AS Utf8) AS last_accessed_at
13
37
  FROM qdr__collections
14
38
  WHERE collection = $collection;
15
39
  `;
@@ -27,13 +51,31 @@ export async function getCollectionMeta(metaKey) {
27
51
  const dimension = Number(row.items?.[1]?.uint32Value ?? row.items?.[1]?.textValue);
28
52
  const distance = row.items?.[2]?.textValue ?? "Cosine";
29
53
  const vectorType = row.items?.[3]?.textValue ?? "float";
30
- return { table, dimension, distance, vectorType };
54
+ const lastAccessRaw = row.items?.[4]?.textValue;
55
+ const lastAccessedAt = typeof lastAccessRaw === "string" && lastAccessRaw.length > 0
56
+ ? new Date(lastAccessRaw)
57
+ : undefined;
58
+ const result = {
59
+ table,
60
+ dimension,
61
+ distance,
62
+ vectorType,
63
+ };
64
+ if (lastAccessedAt) {
65
+ result.lastAccessedAt = lastAccessedAt;
66
+ }
67
+ return result;
31
68
  }
32
69
  export async function verifyCollectionsQueryCompilationForStartup() {
33
70
  const probeKey = "__startup_probe__/__startup_probe__";
34
71
  const qry = `
35
72
  DECLARE $collection AS Utf8;
36
- SELECT table_name, vector_dimension, distance, vector_type
73
+ SELECT
74
+ table_name,
75
+ vector_dimension,
76
+ distance,
77
+ vector_type,
78
+ CAST(last_accessed_at AS Utf8) AS last_accessed_at
37
79
  FROM qdr__collections
38
80
  WHERE collection = $collection;
39
81
  `;
@@ -69,3 +111,38 @@ export async function deleteCollection(metaKey, uid) {
69
111
  }
70
112
  await deleteCollectionOneTable(metaKey, effectiveUid);
71
113
  }
114
+ /**
115
+ * Best-effort metadata update for a collection's last_accessed_at timestamp.
116
+ *
117
+ * - Uses an in-memory throttle (per metaKey) to avoid writing more often than
118
+ * LAST_ACCESS_MIN_WRITE_INTERVAL_MS.
119
+ * - Accepts an optional now parameter (default: current time) to aid testing.
120
+ * - Logs and ignores YDB errors so callers' primary operations are not affected.
121
+ */
122
+ export async function touchCollectionLastAccess(metaKey, now = new Date()) {
123
+ const nowMs = now.getTime();
124
+ if (!shouldWriteLastAccess(nowMs, metaKey)) {
125
+ return;
126
+ }
127
+ const qry = `
128
+ DECLARE $collection AS Utf8;
129
+ DECLARE $last_accessed AS Timestamp;
130
+ UPDATE qdr__collections
131
+ SET last_accessed_at = $last_accessed
132
+ WHERE collection = $collection;
133
+ `;
134
+ try {
135
+ await withSession(async (s) => {
136
+ const settings = createExecuteQuerySettings();
137
+ await s.executeQuery(qry, {
138
+ $collection: TypedValues.utf8(metaKey),
139
+ $last_accessed: TypedValues.timestamp(now),
140
+ }, undefined, settings);
141
+ });
142
+ evictOldestLastAccessEntry();
143
+ lastAccessWriteCache.set(metaKey, nowMs);
144
+ }
145
+ catch (err) {
146
+ logger.warn({ err, collection: metaKey }, "touchCollectionLastAccess: failed to update last_accessed_at (ignored)");
147
+ }
148
+ }
@@ -1,5 +1,6 @@
1
1
  import { TypedValues, withSession } from "../ydb/client.js";
2
2
  export async function upsertCollectionMeta(metaKey, dim, distance, vectorType, tableName) {
3
+ const now = new Date();
3
4
  const upsertMeta = `
4
5
  DECLARE $collection AS Utf8;
5
6
  DECLARE $table AS Utf8;
@@ -7,8 +8,9 @@ export async function upsertCollectionMeta(metaKey, dim, distance, vectorType, t
7
8
  DECLARE $distance AS Utf8;
8
9
  DECLARE $vtype AS Utf8;
9
10
  DECLARE $created AS Timestamp;
10
- UPSERT INTO qdr__collections (collection, table_name, vector_dimension, distance, vector_type, created_at)
11
- VALUES ($collection, $table, $dim, $distance, $vtype, $created);
11
+ DECLARE $last_accessed AS Timestamp;
12
+ UPSERT INTO qdr__collections (collection, table_name, vector_dimension, distance, vector_type, created_at, last_accessed_at)
13
+ VALUES ($collection, $table, $dim, $distance, $vtype, $created, $last_accessed);
12
14
  `;
13
15
  await withSession(async (s) => {
14
16
  await s.executeQuery(upsertMeta, {
@@ -17,7 +19,8 @@ export async function upsertCollectionMeta(metaKey, dim, distance, vectorType, t
17
19
  $dim: TypedValues.uint32(dim),
18
20
  $distance: TypedValues.utf8(distance),
19
21
  $vtype: TypedValues.utf8(vectorType),
20
- $created: TypedValues.timestamp(new Date()),
22
+ $created: TypedValues.timestamp(now),
23
+ $last_accessed: TypedValues.timestamp(now),
21
24
  });
22
25
  });
23
26
  }
@@ -1,6 +1,6 @@
1
1
  import { CreateCollectionReq } from "../types.js";
2
2
  import { ensureMetaTable } from "../ydb/schema.js";
3
- import { createCollection as repoCreateCollection, deleteCollection as repoDeleteCollection, getCollectionMeta, } from "../repositories/collectionsRepo.js";
3
+ import { createCollection as repoCreateCollection, deleteCollection as repoDeleteCollection, getCollectionMeta, touchCollectionLastAccess, } from "../repositories/collectionsRepo.js";
4
4
  import { QdrantServiceError } from "./errors.js";
5
5
  import { normalizeCollectionContextShared } from "./CollectionService.shared.js";
6
6
  export async function putCollectionIndex(ctx) {
@@ -13,6 +13,7 @@ export async function putCollectionIndex(ctx) {
13
13
  error: "collection not found",
14
14
  });
15
15
  }
16
+ await touchCollectionLastAccess(normalized.metaKey);
16
17
  return { acknowledged: true };
17
18
  }
18
19
  export async function createCollection(ctx, body) {
@@ -33,6 +34,7 @@ export async function createCollection(ctx, body) {
33
34
  if (existing.dimension === dim &&
34
35
  existing.distance === distance &&
35
36
  existing.vectorType === vectorType) {
37
+ await touchCollectionLastAccess(normalized.metaKey);
36
38
  return { name: normalized.collection, tenant: normalized.tenant };
37
39
  }
38
40
  const errorMessage = `Collection already exists with different config: dimension=${existing.dimension}, distance=${existing.distance}, type=${existing.vectorType}`;
@@ -54,6 +56,7 @@ export async function getCollection(ctx) {
54
56
  error: "collection not found",
55
57
  });
56
58
  }
59
+ await touchCollectionLastAccess(normalized.metaKey);
57
60
  return {
58
61
  name: normalized.collection,
59
62
  vectors: {
@@ -1,6 +1,7 @@
1
1
  export interface NormalizedCollectionContextLike {
2
2
  tenant: string;
3
3
  collection: string;
4
+ metaKey: string;
4
5
  }
5
6
  export declare function tableNameFor(tenantId: string, collection: string): string;
6
7
  export declare function uidFor(tenantId: string, collection: string): string;
@@ -1,4 +1,4 @@
1
- import { sanitizeCollectionName, sanitizeTenantId, metaKeyFor, tableNameFor as tableNameForInternal, uidFor as uidForInternal, hashApiKey, hashUserAgent, } from "../utils/tenant.js";
1
+ import { sanitizeCollectionName, sanitizeTenantId, metaKeyFor, tableNameFor as tableNameForInternal, uidFor as uidForInternal, hashApiKey, normalizeUserAgent, } from "../utils/tenant.js";
2
2
  export function tableNameFor(tenantId, collection) {
3
3
  return tableNameForInternal(tenantId, collection);
4
4
  }
@@ -8,8 +8,8 @@ export function uidFor(tenantId, collection) {
8
8
  export function normalizeCollectionContextShared(tenant, collection, apiKey, userAgent) {
9
9
  const normalizedTenant = sanitizeTenantId(tenant);
10
10
  const apiKeyHash = hashApiKey(apiKey);
11
- const userAgentHash = hashUserAgent(userAgent);
12
- const normalizedCollection = sanitizeCollectionName(collection, apiKeyHash, userAgentHash);
11
+ const userAgentNormalized = normalizeUserAgent(userAgent);
12
+ const normalizedCollection = sanitizeCollectionName(collection, apiKeyHash, userAgentNormalized);
13
13
  const metaKey = metaKeyFor(normalizedTenant, normalizedCollection);
14
14
  return {
15
15
  tenant: normalizedTenant,
@@ -1,6 +1,6 @@
1
1
  import { UpsertPointsReq, SearchReq, DeletePointsReq } from "../types.js";
2
2
  import { ensureMetaTable } from "../ydb/schema.js";
3
- import { getCollectionMeta } from "../repositories/collectionsRepo.js";
3
+ import { getCollectionMeta, touchCollectionLastAccess, } from "../repositories/collectionsRepo.js";
4
4
  import { deletePoints as repoDeletePoints, searchPoints as repoSearchPoints, upsertPoints as repoUpsertPoints, } from "../repositories/pointsRepo.js";
5
5
  import { logger } from "../logging/logger.js";
6
6
  import { QdrantServiceError, isVectorDimensionMismatchError, } from "./errors.js";
@@ -44,6 +44,7 @@ export async function upsertPoints(ctx, body) {
44
44
  }
45
45
  throw err;
46
46
  }
47
+ await touchCollectionLastAccess(normalized.metaKey);
47
48
  return { upserted };
48
49
  }
49
50
  async function executeSearch(ctx, normalizedSearch, source) {
@@ -108,6 +109,7 @@ async function executeSearch(ctx, normalizedSearch, source) {
108
109
  }
109
110
  throw err;
110
111
  }
112
+ await touchCollectionLastAccess(normalized.metaKey);
111
113
  const threshold = normalizedSearch.scoreThreshold;
112
114
  // For Cosine, repository hits use distance scores; convert to a
113
115
  // similarity-like score so API consumers and IDE thresholds see
@@ -162,5 +164,6 @@ export async function deletePoints(ctx, body) {
162
164
  }
163
165
  const { tableName, uid } = await resolvePointsTableAndUidOneTable(normalized);
164
166
  const deleted = await repoDeletePoints(tableName, parsed.data.points, uid);
167
+ await touchCollectionLastAccess(normalized.metaKey);
165
168
  return { deleted };
166
169
  }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,19 @@
1
1
  import { z } from "zod";
2
2
  export type DistanceKind = "Cosine" | "Euclid" | "Dot" | "Manhattan";
3
3
  export type VectorType = "float";
4
+ /**
5
+ * Collection metadata from qdr__collections table.
6
+ *
7
+ * @property lastAccessedAt - Timestamp of last access; undefined for collections
8
+ * created before this feature, null if explicitly unset.
9
+ */
10
+ export interface CollectionMeta {
11
+ table: string;
12
+ dimension: number;
13
+ distance: DistanceKind;
14
+ vectorType: VectorType;
15
+ lastAccessedAt?: Date | null;
16
+ }
4
17
  export declare const CreateCollectionReq: z.ZodObject<{
5
18
  vectors: z.ZodObject<{
6
19
  size: z.ZodNumber;
@@ -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
  }
@@ -14,13 +14,32 @@ export async function ensureMetaTable() {
14
14
  await withSession(async (s) => {
15
15
  // If table exists, describeTable will succeed
16
16
  try {
17
- await s.describeTable("qdr__collections");
17
+ const tableDescription = await s.describeTable("qdr__collections");
18
+ const columns = tableDescription.columns ?? [];
19
+ const hasLastAccessedAt = columns.some((col) => col.name === "last_accessed_at");
20
+ if (!hasLastAccessedAt) {
21
+ const alterDdl = `
22
+ ALTER TABLE qdr__collections
23
+ ADD COLUMN last_accessed_at Timestamp;
24
+ `;
25
+ // NOTE: ydb-sdk's public TableSession type does not surface executeSchemeQuery,
26
+ // but the underlying implementation provides it. This cast relies on the
27
+ // current ydb-sdk internals (tested with ydb-sdk v5.11.1) to run ALTER TABLE
28
+ // as a scheme query. If the SDK changes its internal API, this may need to be
29
+ // revisited or replaced with an officially supported migration mechanism.
30
+ const rawSession = s;
31
+ await rawSession.api.executeSchemeQuery({
32
+ sessionId: rawSession.sessionId,
33
+ yqlText: alterDdl,
34
+ });
35
+ logger.info("added last_accessed_at column to metadata table qdr__collections");
36
+ }
18
37
  return;
19
38
  }
20
39
  catch {
21
40
  // create via schema API
22
41
  const desc = new TableDescription()
23
- .withColumns(new Column("collection", Types.UTF8), new Column("table_name", Types.UTF8), new Column("vector_dimension", Types.UINT32), new Column("distance", Types.UTF8), new Column("vector_type", Types.UTF8), new Column("created_at", Types.TIMESTAMP))
42
+ .withColumns(new Column("collection", Types.UTF8), new Column("table_name", Types.UTF8), new Column("vector_dimension", Types.UINT32), new Column("distance", Types.UTF8), new Column("vector_type", Types.UTF8), new Column("created_at", Types.TIMESTAMP), new Column("last_accessed_at", Types.TIMESTAMP))
24
43
  .withPrimaryKey("collection");
25
44
  await s.createTable("qdr__collections", desc);
26
45
  logger.info("created metadata table qdr__collections");
@@ -28,7 +47,7 @@ export async function ensureMetaTable() {
28
47
  });
29
48
  }
30
49
  catch (err) {
31
- logger.debug({ err }, "ensureMetaTable: ignored");
50
+ logger.warn({ err }, "ensureMetaTable: failed to verify or migrate qdr__collections; subsequent operations may fail if schema is incomplete");
32
51
  }
33
52
  }
34
53
  export async function ensureGlobalPointsTable() {
@@ -67,6 +86,11 @@ export async function ensureGlobalPointsTable() {
67
86
  ALTER TABLE ${GLOBAL_POINTS_TABLE}
68
87
  ADD COLUMN embedding_quantized String;
69
88
  `;
89
+ // NOTE: Same rationale as in ensureMetaTable: executeSchemeQuery is not part of
90
+ // the public TableSession TypeScript surface, so we reach into the underlying
91
+ // ydb-sdk implementation (verified with ydb-sdk v5.11.1) to apply schema changes.
92
+ // If future SDK versions alter this shape, this cast and migration path must be
93
+ // updated accordingly.
70
94
  const rawSession = s;
71
95
  await rawSession.api.executeSchemeQuery({
72
96
  sessionId: rawSession.sessionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ydb-qdrant",
3
- "version": "5.2.1",
3
+ "version": "6.0.0",
4
4
  "main": "dist/package/api.js",
5
5
  "types": "dist/package/api.d.ts",
6
6
  "exports": {