From 2f8e89c46bfc361209140c0818c66c6d8a2860ad Mon Sep 17 00:00:00 2001 From: tellet-q <166374656+tellet-q@users.noreply.github.com> Date: Tue, 7 Jan 2025 14:27:25 +0100 Subject: [PATCH] Check compatibility between server and client versions (#90) * Check compatibility between server and client versions * Address review * Adjust arg name * Replace HealthCheckRequest with {} --- packages/js-client-grpc/src/client-version.ts | 58 +++++++++++++++++++ packages/js-client-grpc/src/qdrant-client.ts | 31 +++++++++- .../tests/unit/client-version.test.ts | 42 +++++++++++++- packages/js-client-rest/src/client-version.ts | 58 +++++++++++++++++++ packages/js-client-rest/src/qdrant-client.ts | 36 +++++++++++- .../tests/unit/client-version.test.ts | 42 +++++++++++++- 6 files changed, 262 insertions(+), 5 deletions(-) diff --git a/packages/js-client-grpc/src/client-version.ts b/packages/js-client-grpc/src/client-version.ts index cf44b2c..e0e4613 100644 --- a/packages/js-client-grpc/src/client-version.ts +++ b/packages/js-client-grpc/src/client-version.ts @@ -1 +1,59 @@ export const PACKAGE_VERSION = '1.12.0'; + +interface Version { + major: number; + minor: number; +} + +export const ClientVersion = { + /** + * Parses a version string into a structured Version object. + * @param version - The version string to parse (e.g., "1.2.3"). + * @returns A Version object. + * @throws If the version format is invalid. + */ + parseVersion(version: string): Version { + if (!version) { + throw new Error('Version is null'); + } + + let major = undefined; + let minor = undefined; + [major, minor] = version.split('.', 2); + major = parseInt(major, 10); + minor = parseInt(minor, 10); + if (isNaN(major) || isNaN(minor)) { + throw new Error(`Unable to parse version, expected format: x.y[.z], found: ${version}`); + } + return { + major, + minor, + }; + }, + + /** + * Checks if the client version is compatible with the server version. + * @param clientVersion - The client version string. + * @param serverVersion - The server version string. + * @returns True if compatible, otherwise false. + */ + isCompatible(clientVersion: string, serverVersion: string): boolean { + if (!clientVersion || !serverVersion) { + console.debug( + `Unable to compare versions with null values. Client: ${clientVersion}, Server: ${serverVersion}`, + ); + return false; + } + + if (clientVersion === serverVersion) return true; + + try { + const client = ClientVersion.parseVersion(clientVersion); + const server = ClientVersion.parseVersion(serverVersion); + return client.major === server.major && Math.abs(client.minor - server.minor) <= 1; + } catch (error) { + console.debug(`Unable to compare versions: ${error as string}`); + return false; + } + }, +}; diff --git a/packages/js-client-grpc/src/qdrant-client.ts b/packages/js-client-grpc/src/qdrant-client.ts index 0f0d0bc..1801244 100644 --- a/packages/js-client-grpc/src/qdrant-client.ts +++ b/packages/js-client-grpc/src/qdrant-client.ts @@ -1,5 +1,6 @@ import {GrpcClients, createApis} from './api-client.js'; import {QdrantClientConfigError} from './errors.js'; +import {ClientVersion, PACKAGE_VERSION} from './client-version.js'; export type QdrantClientParams = { port?: number | null; @@ -9,6 +10,7 @@ export type QdrantClientParams = { url?: string; host?: string; timeout?: number; + checkCompatibility?: boolean; }; export class QdrantClient { @@ -20,7 +22,16 @@ export class QdrantClient { private _grcpClients: GrpcClients; private _restUri: string; - constructor({url, host, apiKey, https, prefix, port = 6334, timeout = 300_000}: QdrantClientParams = {}) { + constructor({ + url, + host, + apiKey, + https, + prefix, + port = 6334, + timeout = 300_000, + checkCompatibility = true, + }: QdrantClientParams = {}) { this._https = https ?? typeof apiKey === 'string'; this._scheme = this._https ? 'https' : 'http'; this._prefix = prefix ?? ''; @@ -71,6 +82,24 @@ export class QdrantClient { this._restUri = `${this._scheme}://${address}${this._prefix}`; this._grcpClients = createApis(this._restUri, {apiKey, timeout}); + + if (checkCompatibility) { + this._grcpClients.service + .healthCheck({}) + .then((response) => { + const serverVersion = response.version; + if (!ClientVersion.isCompatible(PACKAGE_VERSION, serverVersion)) { + console.warn( + `Client version ${PACKAGE_VERSION} is incompatible with server version ${serverVersion}. Major versions should match and minor version difference must not exceed 1. Set checkCompatibility=false to skip version check.`, + ); + } + }) + .catch(() => { + console.warn( + `Failed to obtain server version. Unable to check client-server compatibility. Set checkCompatibility=false to skip version check.`, + ); + }); + } } /** diff --git a/packages/js-client-grpc/tests/unit/client-version.test.ts b/packages/js-client-grpc/tests/unit/client-version.test.ts index 615ff02..52518c8 100644 --- a/packages/js-client-grpc/tests/unit/client-version.test.ts +++ b/packages/js-client-grpc/tests/unit/client-version.test.ts @@ -1,7 +1,47 @@ import {test, expect} from 'vitest'; import {version} from '../../package.json'; -import {PACKAGE_VERSION} from '../../src/client-version.js'; +import {PACKAGE_VERSION, ClientVersion} from '../../src/client-version.js'; test('Client version is consistent', () => { expect(version).toBe(PACKAGE_VERSION); }); + +test.each([ + {input: '1.2', expected: {major: 1, minor: 2}}, + {input: '1.2.3', expected: {major: 1, minor: 2}}, +])('parseVersion($input) should return $expected', ({input, expected}) => { + const result = ClientVersion.parseVersion(input); + expect(result).toEqual(expected); +}); + +test.each([ + {input: '', error: 'Version is null'}, + {input: '1', error: 'Unable to parse version, expected format: x.y[.z], found: 1'}, + {input: '1.', error: 'Unable to parse version, expected format: x.y[.z], found: 1.'}, + {input: '.1', error: 'Unable to parse version, expected format: x.y[.z], found: .1'}, + {input: '.1.', error: 'Unable to parse version, expected format: x.y[.z], found: .1.'}, + {input: '1.a.1', error: 'Unable to parse version, expected format: x.y[.z], found: 1.a.1'}, + {input: 'a.1.1', error: 'Unable to parse version, expected format: x.y[.z], found: a.1.1'}, +])('parseVersion($input) should throw error $error', ({input, error}) => { + expect(() => ClientVersion.parseVersion(input)).toThrow(error); +}); + +test.each([ + {client: '1.9.3.dev0', server: '2.8.1.dev12-something', expected: false}, + {client: '1.9', server: '2.8', expected: false}, + {client: '1', server: '2', expected: false}, + {client: '1.9.0', server: '2.9.0', expected: false}, + {client: '1.1.0', server: '1.2.9', expected: true}, + {client: '1.2.7', server: '1.1.8.dev0', expected: true}, + {client: '1.2.1', server: '1.2.29', expected: true}, + {client: '1.2.0', server: '1.2.0', expected: true}, + {client: '1.2.0', server: '1.4.0', expected: false}, + {client: '1.4.0', server: '1.2.0', expected: false}, + {client: '1.9.0', server: '3.7.0', expected: false}, + {client: '3.0.0', server: '1.0.0', expected: false}, + {client: '', server: '1.0.0', expected: false}, + {client: '1.0.0', server: '', expected: false}, +])('isCompatible($client, $server) should return $expected', ({client, server, expected}) => { + const result = ClientVersion.isCompatible(client, server); + expect(result).toEqual(expected); +}); diff --git a/packages/js-client-rest/src/client-version.ts b/packages/js-client-rest/src/client-version.ts index cf44b2c..e0e4613 100644 --- a/packages/js-client-rest/src/client-version.ts +++ b/packages/js-client-rest/src/client-version.ts @@ -1 +1,59 @@ export const PACKAGE_VERSION = '1.12.0'; + +interface Version { + major: number; + minor: number; +} + +export const ClientVersion = { + /** + * Parses a version string into a structured Version object. + * @param version - The version string to parse (e.g., "1.2.3"). + * @returns A Version object. + * @throws If the version format is invalid. + */ + parseVersion(version: string): Version { + if (!version) { + throw new Error('Version is null'); + } + + let major = undefined; + let minor = undefined; + [major, minor] = version.split('.', 2); + major = parseInt(major, 10); + minor = parseInt(minor, 10); + if (isNaN(major) || isNaN(minor)) { + throw new Error(`Unable to parse version, expected format: x.y[.z], found: ${version}`); + } + return { + major, + minor, + }; + }, + + /** + * Checks if the client version is compatible with the server version. + * @param clientVersion - The client version string. + * @param serverVersion - The server version string. + * @returns True if compatible, otherwise false. + */ + isCompatible(clientVersion: string, serverVersion: string): boolean { + if (!clientVersion || !serverVersion) { + console.debug( + `Unable to compare versions with null values. Client: ${clientVersion}, Server: ${serverVersion}`, + ); + return false; + } + + if (clientVersion === serverVersion) return true; + + try { + const client = ClientVersion.parseVersion(clientVersion); + const server = ClientVersion.parseVersion(serverVersion); + return client.major === server.major && Math.abs(client.minor - server.minor) <= 1; + } catch (error) { + console.debug(`Unable to compare versions: ${error as string}`); + return false; + } + }, +}; diff --git a/packages/js-client-rest/src/qdrant-client.ts b/packages/js-client-rest/src/qdrant-client.ts index 793ebd5..a94c5ec 100644 --- a/packages/js-client-rest/src/qdrant-client.ts +++ b/packages/js-client-rest/src/qdrant-client.ts @@ -2,7 +2,7 @@ import {maybe} from '@sevinf/maybe'; import {OpenApiClient, createApis} from './api-client.js'; import {QdrantClientConfigError} from './errors.js'; import {RestArgs, SchemaFor} from './types.js'; -import {PACKAGE_VERSION} from './client-version.js'; +import {PACKAGE_VERSION, ClientVersion} from './client-version.js'; export type QdrantClientParams = { port?: number | null; @@ -25,6 +25,10 @@ export type QdrantClientParams = { * to open simultaneously while building a request pool in memory. */ maxConnections?: number; + /** + * Check compatibility with the server version. Default: `true` + */ + checkCompatibility?: boolean; }; export class QdrantClient { @@ -36,7 +40,17 @@ export class QdrantClient { private _restUri: string; private _openApiClient: OpenApiClient; - constructor({url, host, apiKey, https, prefix, port = 6333, timeout = 300_000, ...args}: QdrantClientParams = {}) { + constructor({ + url, + host, + apiKey, + https, + prefix, + port = 6333, + timeout = 300_000, + checkCompatibility = true, + ...args + }: QdrantClientParams = {}) { this._https = https ?? typeof apiKey === 'string'; this._scheme = this._https ? 'https' : 'http'; this._prefix = prefix ?? ''; @@ -99,6 +113,24 @@ export class QdrantClient { const restArgs: RestArgs = {headers, timeout, connections}; this._openApiClient = createApis(this._restUri, restArgs); + + if (checkCompatibility) { + this._openApiClient.service + .root({}) + .then((response) => { + const serverVersion = response.data.version; + if (!ClientVersion.isCompatible(PACKAGE_VERSION, serverVersion)) { + console.warn( + `Client version ${PACKAGE_VERSION} is incompatible with server version ${serverVersion}. Major versions should match and minor version difference must not exceed 1. Set checkCompatibility=false to skip version check.`, + ); + } + }) + .catch(() => { + console.warn( + `Failed to obtain server version. Unable to check client-server compatibility. Set checkCompatibility=false to skip version check.`, + ); + }); + } } /** diff --git a/packages/js-client-rest/tests/unit/client-version.test.ts b/packages/js-client-rest/tests/unit/client-version.test.ts index 615ff02..52518c8 100644 --- a/packages/js-client-rest/tests/unit/client-version.test.ts +++ b/packages/js-client-rest/tests/unit/client-version.test.ts @@ -1,7 +1,47 @@ import {test, expect} from 'vitest'; import {version} from '../../package.json'; -import {PACKAGE_VERSION} from '../../src/client-version.js'; +import {PACKAGE_VERSION, ClientVersion} from '../../src/client-version.js'; test('Client version is consistent', () => { expect(version).toBe(PACKAGE_VERSION); }); + +test.each([ + {input: '1.2', expected: {major: 1, minor: 2}}, + {input: '1.2.3', expected: {major: 1, minor: 2}}, +])('parseVersion($input) should return $expected', ({input, expected}) => { + const result = ClientVersion.parseVersion(input); + expect(result).toEqual(expected); +}); + +test.each([ + {input: '', error: 'Version is null'}, + {input: '1', error: 'Unable to parse version, expected format: x.y[.z], found: 1'}, + {input: '1.', error: 'Unable to parse version, expected format: x.y[.z], found: 1.'}, + {input: '.1', error: 'Unable to parse version, expected format: x.y[.z], found: .1'}, + {input: '.1.', error: 'Unable to parse version, expected format: x.y[.z], found: .1.'}, + {input: '1.a.1', error: 'Unable to parse version, expected format: x.y[.z], found: 1.a.1'}, + {input: 'a.1.1', error: 'Unable to parse version, expected format: x.y[.z], found: a.1.1'}, +])('parseVersion($input) should throw error $error', ({input, error}) => { + expect(() => ClientVersion.parseVersion(input)).toThrow(error); +}); + +test.each([ + {client: '1.9.3.dev0', server: '2.8.1.dev12-something', expected: false}, + {client: '1.9', server: '2.8', expected: false}, + {client: '1', server: '2', expected: false}, + {client: '1.9.0', server: '2.9.0', expected: false}, + {client: '1.1.0', server: '1.2.9', expected: true}, + {client: '1.2.7', server: '1.1.8.dev0', expected: true}, + {client: '1.2.1', server: '1.2.29', expected: true}, + {client: '1.2.0', server: '1.2.0', expected: true}, + {client: '1.2.0', server: '1.4.0', expected: false}, + {client: '1.4.0', server: '1.2.0', expected: false}, + {client: '1.9.0', server: '3.7.0', expected: false}, + {client: '3.0.0', server: '1.0.0', expected: false}, + {client: '', server: '1.0.0', expected: false}, + {client: '1.0.0', server: '', expected: false}, +])('isCompatible($client, $server) should return $expected', ({client, server, expected}) => { + const result = ClientVersion.isCompatible(client, server); + expect(result).toEqual(expected); +});