Skip to content

Commit

Permalink
Check compatibility between server and client versions (#90)
Browse files Browse the repository at this point in the history
* Check compatibility between server and client versions

* Address review

* Adjust arg name

* Replace HealthCheckRequest with {}
  • Loading branch information
tellet-q authored Jan 7, 2025
1 parent 60e9bf9 commit 2f8e89c
Show file tree
Hide file tree
Showing 6 changed files with 262 additions and 5 deletions.
58 changes: 58 additions & 0 deletions packages/js-client-grpc/src/client-version.ts
Original file line number Diff line number Diff line change
@@ -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;
}
},
};
31 changes: 30 additions & 1 deletion packages/js-client-grpc/src/qdrant-client.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,6 +10,7 @@ export type QdrantClientParams = {
url?: string;
host?: string;
timeout?: number;
checkCompatibility?: boolean;
};

export class QdrantClient {
Expand All @@ -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 ?? '';
Expand Down Expand Up @@ -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.`,
);
});
}
}

/**
Expand Down
42 changes: 41 additions & 1 deletion packages/js-client-grpc/tests/unit/client-version.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
58 changes: 58 additions & 0 deletions packages/js-client-rest/src/client-version.ts
Original file line number Diff line number Diff line change
@@ -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;
}
},
};
36 changes: 34 additions & 2 deletions packages/js-client-rest/src/qdrant-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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 ?? '';
Expand Down Expand Up @@ -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.`,
);
});
}
}

/**
Expand Down
42 changes: 41 additions & 1 deletion packages/js-client-rest/tests/unit/client-version.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});

0 comments on commit 2f8e89c

Please sign in to comment.