Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions packages/common-utils/src/__tests__/clickhouseVersion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,85 @@ describe('supportsDirectReadMap', () => {
it('returns false when the version is undefined', () => {
expect(supportsDirectReadMap(undefined)).toBe(false);
});

describe('ClickHouse Cloud (isCloud=true)', () => {
describe('26.2 cloud backport branch (min 26.2.1.307)', () => {
it.each<readonly [ClickHouseVersionTuple, boolean]>([
[[26, 2, 1, 307], true],
[[26, 2, 1, 308], true],
[[26, 2, 2, 0], true],
[[26, 2, 99, 99], true],
[[26, 2, 1, 306], false],
[[26, 2, 1, 0], false],
[[26, 2, 0, 99], false],
[[26, 2, 0, 0], false],
])('%j → %s', (version, expected) => {
expect(supportsDirectReadMap(version, true)).toBe(expected);
});
});

describe('26.3 cloud branch (always supported)', () => {
it.each<readonly [ClickHouseVersionTuple, boolean]>([
[[26, 3, 0, 0], true],
[[26, 3, 0, 1], true],
[[26, 3, 1, 0], true],
[[26, 3, 12, 2], true],
[[26, 3, 99, 99], true],
])('%j → %s', (version, expected) => {
expect(supportsDirectReadMap(version, true)).toBe(expected);
});
});

describe('26.4 cloud backport branch (min 26.4.1.1740)', () => {
it.each<readonly [ClickHouseVersionTuple, boolean]>([
[[26, 4, 1, 1740], true],
[[26, 4, 1, 1741], true],
[[26, 4, 2, 0], true],
[[26, 4, 99, 99], true],
[[26, 4, 1, 1739], false],
[[26, 4, 1, 0], false],
[[26, 4, 0, 99], false],
[[26, 4, 0, 0], false],
])('%j → %s', (version, expected) => {
expect(supportsDirectReadMap(version, true)).toBe(expected);
});
});

describe('26.5+ cloud baseline (always supported)', () => {
it.each<readonly [ClickHouseVersionTuple, boolean]>([
[[26, 5, 0, 0], true],
[[26, 6, 0, 0], true],
[[27, 0, 0, 0], true],
])('%j → %s', (version, expected) => {
expect(supportsDirectReadMap(version, true)).toBe(expected);
});
});

describe('unsupported cloud branches', () => {
it.each<readonly [ClickHouseVersionTuple, boolean]>([
[[26, 1, 99, 99], false],
[[26, 0, 0, 0], false],
[[25, 12, 0, 0], false],
[[24, 0, 0, 0], false],
])('%j → %s', (version, expected) => {
expect(supportsDirectReadMap(version, true)).toBe(expected);
});
});

it('returns false when the version is undefined', () => {
expect(supportsDirectReadMap(undefined, true)).toBe(false);
});

it.each<readonly [ClickHouseVersionTuple]>([
[[26, 3, 0, 0]],
[[26, 2, 1, 307]],
[[26, 4, 1, 1740]],
])(
'cloud cutoffs differ from self-hosted on %j (supported on cloud, not self-hosted)',
version => {
expect(supportsDirectReadMap(version, false)).toBe(false);
expect(supportsDirectReadMap(version, true)).toBe(true);
},
);
});
});
77 changes: 77 additions & 0 deletions packages/common-utils/src/__tests__/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,83 @@ describe('Metadata', () => {
});
});

describe('isClickHouseCloud', () => {
beforeEach(() => {
mockCache.getOrFetch.mockImplementation((key, queryFn) => queryFn());
});

it('returns true when SharedMergeTree is registered in system.table_engines', async () => {
(mockClickhouseClient.query as jest.Mock).mockResolvedValue({
json: jest.fn().mockResolvedValue({
data: [{ is_cloud: true }],
}),
});

const result = await metadata.isClickHouseCloud({
connectionId: 'test_connection',
});

expect(result).toBe(true);
});

it('returns false when SharedMergeTree is absent from system.table_engines', async () => {
(mockClickhouseClient.query as jest.Mock).mockResolvedValue({
json: jest.fn().mockResolvedValue({
data: [],
}),
});

const result = await metadata.isClickHouseCloud({
connectionId: 'test_connection',
});

expect(result).toBe(false);
});

it('re-probes after a transient failure instead of caching false', async () => {
const realCache = new MetadataCache();
const realMetadata = new Metadata(mockClickhouseClient, realCache);

(mockClickhouseClient.query as jest.Mock)
.mockRejectedValueOnce(new Error('connection refused'))
.mockResolvedValueOnce({
json: jest.fn().mockResolvedValue({ data: [{ is_cloud: true }] }),
});

const first = await realMetadata.isClickHouseCloud({
connectionId: 'test_connection',
});
expect(first).toBe(false);

const second = await realMetadata.isClickHouseCloud({
connectionId: 'test_connection',
});
expect(second).toBe(true);

expect(mockClickhouseClient.query).toHaveBeenCalledTimes(2);
});

it('caches a successful negative result and does not re-query', async () => {
const realCache = new MetadataCache();
const realMetadata = new Metadata(mockClickhouseClient, realCache);

(mockClickhouseClient.query as jest.Mock).mockResolvedValue({
json: jest.fn().mockResolvedValue({ data: [] }),
});

const first = await realMetadata.isClickHouseCloud({
connectionId: 'test_connection',
});
const second = await realMetadata.isClickHouseCloud({
connectionId: 'test_connection',
});

expect(first).toBe(false);
expect(second).toBe(false);
expect(mockClickhouseClient.query).toHaveBeenCalledTimes(1);
});
});

describe('getSkipIndices', () => {
beforeEach(() => {
mockCache.getOrFetch.mockImplementation((key, queryFn) => queryFn());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ describe('renderChartConfig', () => {
.mockResolvedValue({ primary_key: 'timestamp' }),
getSkipIndices: jest.fn().mockResolvedValue([]),
getSetting: jest.fn().mockResolvedValue(undefined),
isClickHouseCloud: jest.fn().mockResolvedValue(false),
} as unknown as jest.Mocked<Metadata>;
});

Expand Down
29 changes: 28 additions & 1 deletion packages/common-utils/src/core/clickhouseVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@ const DIRECT_READ_MAP_BACKPORT_MINS: ReadonlyArray<ClickHouseVersion> = [
[26, 4, 3, 37],
];

/**
* Per-branch minimum versions required for the direct_read map column
* optimization on ClickHouse Cloud. Cloud has its own release cadence and
* received the backport at different cutoffs than self-hosted:
*
* - 26.2 branch → first available at 26.2.1.307
* - 26.3 branch → automatically supported (any 26.3.x on Cloud)
* - 26.4 branch → first available at 26.4.1.1740
* - 26.5+ → always supported (inherits `DIRECT_READ_MAP_BASELINE`)
*
* The 26.3 entry sits at the floor of the branch so every 26.3.x.y version
* passes the per-branch comparison.
*/
const DIRECT_READ_MAP_CLOUD_BACKPORT_MINS: ReadonlyArray<ClickHouseVersion> = [
[26, 2, 1, 307],
[26, 3, 0, 0],
[26, 4, 1, 1740],
];

/**
* First release where direct_read map support shipped unconditionally. Any
* server with major.minor at or above this is considered supported, even if
Expand All @@ -95,18 +114,26 @@ const DIRECT_READ_MAP_BASELINE: ClickHouseVersion = [26, 5, 0, 0];
* Returns true when the connected ClickHouse server supports the direct_read
* map column optimization. Returns false when the version is undefined or
* predates every known backport.
*
* When `isCloud` is true, uses the ClickHouse Cloud-specific backport cutoffs
* (which differ from self-hosted because Cloud ships its own release stream).
*/
export function supportsDirectReadMap(
version: ClickHouseVersion | undefined,
isCloud = false,
): boolean {
if (!version) return false;

if (compareClickHouseVersion(version, DIRECT_READ_MAP_BASELINE) >= 0) {
return true;
}

const branchMins = isCloud
? DIRECT_READ_MAP_CLOUD_BACKPORT_MINS
: DIRECT_READ_MAP_BACKPORT_MINS;

const [vMajor, vMinor] = version;
const branchMin = DIRECT_READ_MAP_BACKPORT_MINS.find(
const branchMin = branchMins.find(
([major, minor]) => major === vMajor && minor === vMinor,
);
if (!branchMin) return false;
Expand Down
38 changes: 38 additions & 0 deletions packages/common-utils/src/core/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,44 @@ export class Metadata {
});
}

/**
* Returns true when the connected server is ClickHouse Cloud, detected by
* checking whether `SharedMergeTree` is registered in `system.table_engines`.
* The SharedMergeTree engine is compiled into Cloud builds only, so its
* presence in the engine registry is a reliable Cloud signal that does not
* depend on any user table existing.
*
* Result is cached per connection — Cloud-ness is a server property.
*/
async isClickHouseCloud({
connectionId,
}: {
connectionId: string;
}): Promise<boolean> {
const result = await this.cache.getOrFetch(
`${connectionId}.isClickHouseCloud`,
async () => {
try {
const query =
"SELECT count() > 0 AS is_cloud FROM system.table_engines WHERE name = 'SharedMergeTree'";
const json = await this.clickhouseClient
.query<'JSON'>({
connectionId,
query,
clickhouse_settings: this.getClickHouseSettings(),
shouldSkipApplySettings: true,
})
.then(res => res.json<{ is_cloud: boolean }>());
return json.data.length > 0 && json.data[0].is_cloud;
} catch (e) {
console.warn('Error detecting ClickHouse Cloud:', e);
return undefined;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
},
);
return result ?? false;
}

/**
* Returns the parsed ClickHouse server version (from `SELECT version()`).
* Returns undefined when the query fails or the value cannot be parsed; the
Expand Down
5 changes: 3 additions & 2 deletions packages/common-utils/src/queryParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,15 +1103,16 @@ export async function buildKvItemsLookup({
}): Promise<KvItemsLookup> {
const lookup: KvItemsLookup = new Map();
try {
const [serverVersion, columns, skipIndices] = await Promise.all([
const [serverVersion, columns, skipIndices, isCloud] = await Promise.all([
metadata.getServerVersion({ connectionId }),
metadata.getColumns({ databaseName, tableName, connectionId }),
metadata
.getSkipIndices({ databaseName, tableName, connectionId })
.catch(() => [] as SkipIndexMetadata[]),
metadata.isClickHouseCloud({ connectionId }).catch(() => false),
]);

const directReadSupported = supportsDirectReadMap(serverVersion);
const directReadSupported = supportsDirectReadMap(serverVersion, isCloud);

const kvItemsCandidates = columns.filter(
c =>
Expand Down
Loading