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
46 changes: 44 additions & 2 deletions src/components/Collections/CollectionsList.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router';
import { Checkbox, MenuItem, TableCell, TableRow, Typography, Table } from '@mui/material';
import { Box, Checkbox, MenuItem, TableCell, TableRow, Tooltip, Typography, Table } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import {
StyledTableBody,
Expand Down Expand Up @@ -74,6 +74,44 @@ CollectionNameCell.propTypes = {
collection: PropTypes.object.isRequired,
};

// With custom sharding `shard_number` is only the *default* number of shards per shard key:
// every shard key can be created with its own `shards_number`. So the total number of shards
// comes from the collection cluster info, and is never derived from `shard_number`.
const ShardsCell = ({ collectionParams, shardCount, shardKeysCount }) => {
const shardNumber = collectionParams.shard_number;

if (collectionParams.sharding_method !== 'custom') {
return <Typography>{shardNumber}</Typography>;
}

if (shardCount == null) {
return (
<Tooltip arrow title={`Custom sharding: ${shardNumber} shard(s) per shard key by default, keys may differ`}>
<Typography>{`${shardNumber} / key`}</Typography>
</Tooltip>
);
}

const keysLabel = shardKeysCount === 1 ? '1 shard key' : `${shardKeysCount} shard keys`;

return (
<Tooltip arrow title={`Custom sharding: ${shardCount} shard(s) across ${keysLabel}`}>
<Box>
<Typography>{shardCount}</Typography>
<Typography component={'p'} variant="caption" color="text.secondary" sx={{ whiteSpace: 'nowrap' }}>
{keysLabel}
</Typography>
</Box>
</Tooltip>
);
};

ShardsCell.propTypes = {
collectionParams: PropTypes.object.isRequired,
shardCount: PropTypes.number,
shardKeysCount: PropTypes.number,
};

const CollectionTableRow = ({
collection,
getCollectionsCall,
Expand Down Expand Up @@ -145,7 +183,11 @@ const CollectionTableRow = ({
<Typography>{collection.segments_count}</Typography>
</TableCell>
<TableCell align="center">
<Typography>{collection.config.params.shard_number}</Typography>
<ShardsCell
collectionParams={collection.config.params}
shardCount={collection.shard_count}
shardKeysCount={collection.shard_keys_count}
/>
</TableCell>
<TableCell align="center">
<VectorsConfigChips collectionConfigParams={collection.config.params} collectionName={collection.name} />
Expand Down
124 changes: 124 additions & 0 deletions src/components/Collections/collectionList.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,65 @@ const COLLECTIONS = [
},
];

const CUSTOM_SHARDED_COLLECTIONS = [
{
name: 'Custom sharded',
status: 'green',
points_count: 100,
segments_count: 7,
config: {
params: {
shard_number: 2,
sharding_method: 'custom',
vectors: {
size: 128,
distance: 'cosine',
},
},
},
shard_count: 6,
shard_keys_count: 3,
aliases: [],
},
{
name: 'Custom sharded without cluster info',
status: 'green',
points_count: 200,
segments_count: 8,
config: {
params: {
shard_number: 4,
sharding_method: 'custom',
vectors: {
size: 128,
distance: 'cosine',
},
},
},
aliases: [],
},
{
// shard keys created with their own `shards_number`: 2 + 2 + 1 shards
name: 'Custom sharded with uneven shard keys',
status: 'green',
points_count: 300,
segments_count: 9,
config: {
params: {
shard_number: 2,
sharding_method: 'custom',
vectors: {
size: 128,
distance: 'cosine',
},
},
},
shard_count: 5,
shard_keys_count: 3,
aliases: [],
},
];

const DEFAULT_SELECTION_PROPS = {
selectedCollections: new Set(),
handleToggleSelect: vi.fn(),
Expand Down Expand Up @@ -222,4 +281,69 @@ describe('CollectionsList', () => {
expect(btn.closest('li')).toHaveAttribute('aria-disabled', 'true');
});
});
it('should render shard_number as-is for automatic sharding', () => {
render(
<MemoryRouter>
<CollectionsList
collections={COLLECTIONS}
getCollectionsCall={() => {}}
refreshCollection={vi.fn()}
isRefreshing={false}
{...DEFAULT_SELECTION_PROPS}
/>
</MemoryRouter>
);
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.queryByText(/shard keys/)).not.toBeInTheDocument();
});

it('should render the total number of shards for custom sharding', () => {
render(
<MemoryRouter>
<CollectionsList
collections={CUSTOM_SHARDED_COLLECTIONS}
getCollectionsCall={() => {}}
refreshCollection={vi.fn()}
isRefreshing={false}
{...DEFAULT_SELECTION_PROPS}
/>
</MemoryRouter>
);
// 6 shards in total (3 shard keys x 2 shards per key), not the `shard_number` of 2
expect(screen.getByText('6')).toBeInTheDocument();
expect(screen.getAllByText('3 shard keys')).toHaveLength(2);
expect(screen.queryByText('2')).not.toBeInTheDocument();
});

it('should mark shard_number as per-key when the total is unknown for custom sharding', () => {
render(
<MemoryRouter>
<CollectionsList
collections={CUSTOM_SHARDED_COLLECTIONS}
getCollectionsCall={() => {}}
refreshCollection={vi.fn()}
isRefreshing={false}
{...DEFAULT_SELECTION_PROPS}
/>
</MemoryRouter>
);
expect(screen.getByText('4 / key')).toBeInTheDocument();
});

it('should not derive the total from shard_number when shard keys have different shard counts', () => {
render(
<MemoryRouter>
<CollectionsList
collections={CUSTOM_SHARDED_COLLECTIONS}
getCollectionsCall={() => {}}
refreshCollection={vi.fn()}
isRefreshing={false}
{...DEFAULT_SELECTION_PROPS}
/>
</MemoryRouter>
);
// 3 shard keys with 2 + 2 + 1 shards, which is not `shard_keys_count` * `shard_number`
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByLabelText(/5 shard\(s\) across 3 shard keys/)).toBeInTheDocument();
});
});
44 changes: 39 additions & 5 deletions src/pages/Collections.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function Collections() {
const [searchQuery, setSearchQuery] = useState('');
const [errorMessage, setErrorMessage] = useState(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const { client: qdrantClient } = useClient();
const { client: qdrantClient, isRestricted } = useClient();
const [currentPage, setCurrentPage] = useState(1);
const PAGE_SIZE = 5;
const [selectedCollections, setSelectedCollections] = useState(new Set());
Expand All @@ -44,6 +44,35 @@ function Collections() {
[qdrantClient]
);

// `shard_number` is only the *default* number of shards per shard key for custom sharding
// (each shard key can be created with its own `shards_number`), so the actual total shard
// count has to be read from the collection cluster info.
const getShardInfo = useCallback(
async (collectionName, collectionData) => {
if (isRestricted || collectionData.config?.params?.sharding_method !== 'custom') {
return {};
}
try {
const res = await qdrantClient.api('cluster').collectionClusterInfo({ collection_name: collectionName });
const clusterInfo = res.data?.result ?? {};
const shards = [...(clusterInfo.local_shards ?? []), ...(clusterInfo.remote_shards ?? [])];
const shardKeys = new Set(shards.filter((shard) => shard.shard_key != null).map((shard) => shard.shard_key));
const shardCount = Number(clusterInfo.shard_count);
if (!Number.isFinite(shardCount)) {
return {};
}
return {
shard_count: shardCount,
shard_keys_count: shardKeys.size,
};
} catch {
// Cluster info is unavailable (e.g. insufficient permissions), fall back to `shard_number`
return {};
}
},
[qdrantClient, isRestricted]
);

const getCollectionsCall = useCallback(
async (page = 1) => {
try {
Expand All @@ -61,9 +90,11 @@ function Collections() {
.map((alias) => alias.alias_name);
try {
const collectionData = await qdrantClient.getCollection(collection.name);
const shardInfo = await getShardInfo(collection.name, collectionData);
return {
name: collection.name,
...collectionData,
...shardInfo,
aliases: [...collectionAliases],
};
} catch (error) {
Expand All @@ -84,7 +115,7 @@ function Collections() {
setRawCollections(null);
}
},
[qdrantClient, getErrorMessageWithApiKey]
[qdrantClient, getErrorMessageWithApiKey, getShardInfo]
);

const getFilteredCollectionsCall = useCallback(
Expand All @@ -97,9 +128,11 @@ function Collections() {
filtered.map(async (collection) => {
try {
const collectionData = await qdrantClient.getCollection(collection.name);
const shardInfo = await getShardInfo(collection.name, collectionData);
return {
name: collection.name,
...collectionData,
...shardInfo,
};
} catch (error) {
return {
Expand All @@ -118,7 +151,7 @@ function Collections() {
setRawCollections(null);
}
},
[collections, qdrantClient, getErrorMessageWithApiKey]
[collections, qdrantClient, getErrorMessageWithApiKey, getShardInfo]
);

useEffect(() => {
Expand Down Expand Up @@ -150,8 +183,9 @@ function Collections() {
setIsRefreshing(true);
try {
const collectionData = await qdrantClient.getCollection(collectionName);
const shardInfo = await getShardInfo(collectionName, collectionData);
setRawCollections((prev) =>
prev.map((c) => (c.name === collectionName ? { ...c, ...collectionData, error: null } : c))
prev.map((c) => (c.name === collectionName ? { ...c, ...collectionData, ...shardInfo, error: null } : c))
);
setErrorMessage(null);
} catch (error) {
Expand All @@ -161,7 +195,7 @@ function Collections() {
setIsRefreshing(false);
}
},
[qdrantClient, getErrorMessageWithApiKey]
[qdrantClient, getErrorMessageWithApiKey, getShardInfo]
);

const handlePageChange = (event, value) => {
Expand Down