mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
feat: vectorchord (#18042)
* wip auto-detect available extensions auto-recovery, fix reindexing check use original image for ml * set probes * update image for sql checker update images for gha * cascade * fix new instance * accurate dummy vector * simplify dummy * preexisiting pg docs * handle different db name * maybe fix sql generation * revert refreshfaces sql change * redundant switch * outdated message * update docker compose files * Update docs/docs/administration/postgres-standalone.md Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> * tighten range * avoid always printing "vector reindexing complete" * remove nesting * use new images * add vchord to unit tests * debug e2e image * mention 1.107.2 in startup error * support new vchord versions --------- Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
This commit is contained in:
parent
fe71894308
commit
0d773af6c3
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -643,7 +643,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
|
image: ghcr.io/immich-app/postgres:14
|
||||||
env:
|
env:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
|
@ -122,7 +122,7 @@ services:
|
|||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@ -134,24 +134,6 @@ services:
|
|||||||
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
|
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
healthcheck:
|
|
||||||
test: >-
|
|
||||||
pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
|
|
||||||
Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
|
|
||||||
--command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
|
|
||||||
echo "checksum failure count is $$Chksum";
|
|
||||||
[ "$$Chksum" = '0' ] || exit 1
|
|
||||||
interval: 5m
|
|
||||||
start_interval: 30s
|
|
||||||
start_period: 5m
|
|
||||||
command: >-
|
|
||||||
postgres
|
|
||||||
-c shared_preload_libraries=vectors.so
|
|
||||||
-c 'search_path="$$user", public, vectors'
|
|
||||||
-c logging_collector=on
|
|
||||||
-c max_wal_size=2GB
|
|
||||||
-c shared_buffers=512MB
|
|
||||||
-c wal_compression=on
|
|
||||||
|
|
||||||
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
||||||
# immich-prometheus:
|
# immich-prometheus:
|
||||||
|
@ -63,7 +63,7 @@ services:
|
|||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@ -75,24 +75,6 @@ services:
|
|||||||
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
|
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
healthcheck:
|
|
||||||
test: >-
|
|
||||||
pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
|
|
||||||
Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
|
|
||||||
--command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
|
|
||||||
echo "checksum failure count is $$Chksum";
|
|
||||||
[ "$$Chksum" = '0' ] || exit 1
|
|
||||||
interval: 5m
|
|
||||||
start_interval: 30s
|
|
||||||
start_period: 5m
|
|
||||||
command: >-
|
|
||||||
postgres
|
|
||||||
-c shared_preload_libraries=vectors.so
|
|
||||||
-c 'search_path="$$user", public, vectors'
|
|
||||||
-c logging_collector=on
|
|
||||||
-c max_wal_size=2GB
|
|
||||||
-c shared_buffers=512MB
|
|
||||||
-c wal_compression=on
|
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
|
||||||
|
@ -56,7 +56,7 @@ services:
|
|||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
|
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
POSTGRES_USER: ${DB_USERNAME}
|
POSTGRES_USER: ${DB_USERNAME}
|
||||||
@ -65,24 +65,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
|
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
|
||||||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||||||
healthcheck:
|
# change ssd below to hdd if you are using a hard disk drive or other slow storage
|
||||||
test: >-
|
command: postgres -c config_file=/etc/postgresql/postgresql.ssd.conf
|
||||||
pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
|
|
||||||
Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
|
|
||||||
--command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
|
|
||||||
echo "checksum failure count is $$Chksum";
|
|
||||||
[ "$$Chksum" = '0' ] || exit 1
|
|
||||||
interval: 5m
|
|
||||||
start_interval: 30s
|
|
||||||
start_period: 5m
|
|
||||||
command: >-
|
|
||||||
postgres
|
|
||||||
-c shared_preload_libraries=vectors.so
|
|
||||||
-c 'search_path="$$user", public, vectors'
|
|
||||||
-c logging_collector=on
|
|
||||||
-c max_wal_size=2GB
|
|
||||||
-c shared_buffers=512MB
|
|
||||||
-c wal_compression=on
|
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
@ -10,12 +10,12 @@ Running with a pre-existing Postgres server can unlock powerful administrative f
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
You must install pgvecto.rs into your instance of Postgres using their [instructions][vectors-install]. After installation, add `shared_preload_libraries = 'vectors.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vectors.so'`.
|
You must install VectorChord into your instance of Postgres using their [instructions][vchord-install]. After installation, add `shared_preload_libraries = 'vchord.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vchord.so'`.
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
Immich is known to work with Postgres versions 14, 15, and 16. Earlier versions are unsupported. Postgres 17 is nominally compatible, but pgvecto.rs does not have prebuilt images or packages for it as of writing.
|
Immich is known to work with Postgres versions 14, 15, 16 and 17. Earlier versions are unsupported.
|
||||||
|
|
||||||
Make sure the installed version of pgvecto.rs is compatible with your version of Immich. The current accepted range for pgvecto.rs is `>= 0.2.0, < 0.4.0`.
|
Make sure the installed version of VectorChord is compatible with your version of Immich. The current accepted range for VectorChord is `>= 0.3.0, < 1.0.0`.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Specifying the connection URL
|
## Specifying the connection URL
|
||||||
@ -53,16 +53,75 @@ CREATE DATABASE <immichdatabasename>;
|
|||||||
\c <immichdatabasename>
|
\c <immichdatabasename>
|
||||||
BEGIN;
|
BEGIN;
|
||||||
ALTER DATABASE <immichdatabasename> OWNER TO <immichdbusername>;
|
ALTER DATABASE <immichdatabasename> OWNER TO <immichdbusername>;
|
||||||
CREATE EXTENSION vectors;
|
CREATE EXTENSION vchord CASCADE;
|
||||||
CREATE EXTENSION earthdistance CASCADE;
|
CREATE EXTENSION earthdistance CASCADE;
|
||||||
ALTER DATABASE <immichdatabasename> SET search_path TO "$user", public, vectors;
|
|
||||||
ALTER SCHEMA vectors OWNER TO <immichdbusername>;
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
```
|
```
|
||||||
|
|
||||||
### Updating pgvecto.rs
|
### Updating VectorChord
|
||||||
|
|
||||||
When installing a new version of pgvecto.rs, you will need to manually update the extension by connecting to the Immich database and running `ALTER EXTENSION vectors UPDATE;`.
|
When installing a new version of VectorChord, you will need to manually update the extension by connecting to the Immich database and running `ALTER EXTENSION vchord UPDATE;`.
|
||||||
|
|
||||||
|
## Migrating to VectorChord
|
||||||
|
|
||||||
|
VectorChord is the successor extension to pgvecto.rs, allowing for higher performance, lower memory usage and higher quality results for smart search and facial recognition.
|
||||||
|
|
||||||
|
### Migrating from pgvecto.rs
|
||||||
|
|
||||||
|
Support for pgvecto.rs will be dropped in a later release, hence we recommend all users currently using pgvecto.rs to migrate to VectorChord at their convenience. There are two primary approaches to do so.
|
||||||
|
|
||||||
|
The easiest option is to have both extensions installed during the migration:
|
||||||
|
|
||||||
|
1. Ensure you still have pgvecto.rs installed
|
||||||
|
2. [Install VectorChord][vchord-install]
|
||||||
|
3. Add `shared_preload_libraries= 'vchord.so, vectors.so'` to your `postgresql.conf`, making sure to include _both_ `vchord.so` and `vectors.so`. You may include other libraries here as well if needed
|
||||||
|
4. If Immich does not have superuser permissions, run the SQL command `CREATE EXTENSION vchord CASCADE;` using psql or your choice of database client
|
||||||
|
5. Start Immich and wait for the logs `Reindexed face_index` and `Reindexed clip_index` to be output
|
||||||
|
6. Remove the `vectors.so` entry from the `shared_preload_libraries` setting
|
||||||
|
7. Uninstall pgvecto.rs (e.g. `apt-get purge vectors-pg14` on Debian-based environments, replacing `pg14` as appropriate)
|
||||||
|
|
||||||
|
If it is not possible to have both VectorChord and pgvector.s installed at the same time, you can perform the migration with more manual steps:
|
||||||
|
|
||||||
|
1. While pgvecto.rs is still installed, run the following SQL command using psql or your choice of database client. Take note of the number outputted by this command as you will need it later
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT atttypmod as dimsize
|
||||||
|
FROM pg_attribute f
|
||||||
|
JOIN pg_class c ON c.oid = f.attrelid
|
||||||
|
WHERE c.relkind = 'r'::char
|
||||||
|
AND f.attnum > 0
|
||||||
|
AND c.relname = 'smart_search'::text
|
||||||
|
AND f.attname = 'embedding'::text;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Remove references to pgvecto.rs using the below SQL commands
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP INDEX IF EXISTS clip_index;
|
||||||
|
DROP INDEX IF EXISTS face_index;
|
||||||
|
ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE real[];
|
||||||
|
ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE real[];
|
||||||
|
```
|
||||||
|
|
||||||
|
3. [Install VectorChord][vchord-install]
|
||||||
|
4. Change the columns back to the appropriate vector types, replacing `<number>` with the number from step 1
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vchord CASCADE;
|
||||||
|
ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(<number>);
|
||||||
|
ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512);
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Start Immich and let it create new indices using VectorChord
|
||||||
|
|
||||||
|
### Migrating from pgvector
|
||||||
|
|
||||||
|
1. Ensure you have at least 0.7.0 of pgvector installed. If it is below that, please upgrade it and run the SQL command `ALTER EXTENSION vector UPDATE;` using psql or your choice of database client
|
||||||
|
2. Follow the Prerequisites to install VectorChord
|
||||||
|
3. If Immich does not have superuser permissions, run the SQL command `CREATE EXTENSION vchord CASCADE;`
|
||||||
|
4. Start Immich and let it create new indices using VectorChord
|
||||||
|
|
||||||
|
Note that VectorChord itself uses pgvector types, so you should not uninstall pgvector after following these steps.
|
||||||
|
|
||||||
### Common errors
|
### Common errors
|
||||||
|
|
||||||
@ -70,4 +129,4 @@ When installing a new version of pgvecto.rs, you will need to manually update th
|
|||||||
|
|
||||||
If you get the error `driverError: error: permission denied for view pg_vector_index_stat`, you can fix this by connecting to the Immich database and running `GRANT SELECT ON TABLE pg_vector_index_stat TO <immichdbusername>;`.
|
If you get the error `driverError: error: permission denied for view pg_vector_index_stat`, you can fix this by connecting to the Immich database and running `GRANT SELECT ON TABLE pg_vector_index_stat TO <immichdbusername>;`.
|
||||||
|
|
||||||
[vectors-install]: https://docs.vectorchord.ai/getting-started/installation.html
|
[vchord-install]: https://docs.vectorchord.ai/vectorchord/getting-started/installation.html
|
||||||
|
@ -5,7 +5,7 @@ import TabItem from '@theme/TabItem';
|
|||||||
|
|
||||||
Immich uses Postgres as its search database for both metadata and contextual CLIP search.
|
Immich uses Postgres as its search database for both metadata and contextual CLIP search.
|
||||||
|
|
||||||
Contextual CLIP search is powered by the [pgvecto.rs](https://github.com/tensorchord/pgvecto.rs) extension, utilizing machine learning models like [CLIP](https://openai.com/research/clip) to provide relevant search results. This allows for freeform searches without requiring specific keywords in the image or video metadata.
|
Contextual CLIP search is powered by the [VectorChord](https://github.com/tensorchord/VectorChord) extension, utilizing machine learning models like [CLIP](https://openai.com/research/clip) to provide relevant search results. This allows for freeform searches without requiring specific keywords in the image or video metadata.
|
||||||
|
|
||||||
## Advanced Search Filters
|
## Advanced Search Filters
|
||||||
|
|
||||||
|
@ -73,7 +73,7 @@ Information on the current workers can be found [here](/docs/administration/jobs
|
|||||||
## Database
|
## Database
|
||||||
|
|
||||||
| Variable | Description | Default | Containers |
|
| Variable | Description | Default | Containers |
|
||||||
| :---------------------------------- | :----------------------------------------------------------------------- | :----------: | :----------------------------- |
|
| :---------------------------------- | :--------------------------------------------------------------------------- | :--------: | :----------------------------- |
|
||||||
| `DB_URL` | Database URL | | server |
|
| `DB_URL` | Database URL | | server |
|
||||||
| `DB_HOSTNAME` | Database host | `database` | server |
|
| `DB_HOSTNAME` | Database host | `database` | server |
|
||||||
| `DB_PORT` | Database port | `5432` | server |
|
| `DB_PORT` | Database port | `5432` | server |
|
||||||
@ -81,12 +81,12 @@ Information on the current workers can be found [here](/docs/administration/jobs
|
|||||||
| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> |
|
| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> |
|
||||||
| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> |
|
| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> |
|
||||||
| `DB_SSL_MODE` | Database SSL mode | | server |
|
| `DB_SSL_MODE` | Database SSL mode | | server |
|
||||||
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`pgvector`, `pgvecto.rs`]) | `pgvecto.rs` | server |
|
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
|
||||||
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
|
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
|
||||||
|
|
||||||
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
|
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
|
||||||
|
|
||||||
\*2: This setting cannot be changed after the server has successfully started up.
|
\*2: If not provided, the appropriate extension to use is auto-detected at startup by introspecting the database. When multiple extensions are installed, the order of preference is VectorChord, pgvecto.rs, pgvector.
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
|
|
||||||
|
@ -37,8 +37,8 @@ services:
|
|||||||
image: redis:6.2-alpine@sha256:3211c33a618c457e5d241922c975dbc4f446d0bdb2dc75694f5573ef8e2d01fa
|
image: redis:6.2-alpine@sha256:3211c33a618c457e5d241922c975dbc4f446d0bdb2dc75694f5573ef8e2d01fa
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
|
image: ghcr.io/immich-app/postgres:14
|
||||||
command: -c fsync=off -c shared_preload_libraries=vectors.so
|
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { Duration } from 'luxon';
|
import { Duration } from 'luxon';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { SemVer } from 'semver';
|
import { SemVer } from 'semver';
|
||||||
import { DatabaseExtension, ExifOrientation } from 'src/enum';
|
import { DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
||||||
|
|
||||||
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
|
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
|
||||||
|
export const VECTORCHORD_VERSION_RANGE = '>=0.3 <1';
|
||||||
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
|
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
|
||||||
export const VECTOR_VERSION_RANGE = '>=0.5 <1';
|
export const VECTOR_VERSION_RANGE = '>=0.5 <1';
|
||||||
|
|
||||||
@ -20,8 +21,22 @@ export const EXTENSION_NAMES: Record<DatabaseExtension, string> = {
|
|||||||
earthdistance: 'earthdistance',
|
earthdistance: 'earthdistance',
|
||||||
vector: 'pgvector',
|
vector: 'pgvector',
|
||||||
vectors: 'pgvecto.rs',
|
vectors: 'pgvecto.rs',
|
||||||
|
vchord: 'VectorChord',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const VECTOR_EXTENSIONS = [
|
||||||
|
DatabaseExtension.VECTORCHORD,
|
||||||
|
DatabaseExtension.VECTORS,
|
||||||
|
DatabaseExtension.VECTOR,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const VECTOR_INDEX_TABLES = {
|
||||||
|
[VectorIndex.CLIP]: 'smart_search',
|
||||||
|
[VectorIndex.FACE]: 'face_search',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const VECTORCHORD_LIST_SLACK_FACTOR = 1.2;
|
||||||
|
|
||||||
export const SALT_ROUNDS = 10;
|
export const SALT_ROUNDS = 10;
|
||||||
|
|
||||||
export const IWorker = 'IWorker';
|
export const IWorker = 'IWorker';
|
||||||
|
@ -116,7 +116,7 @@ export const DummyValue = {
|
|||||||
DATE: new Date(),
|
DATE: new Date(),
|
||||||
TIME_BUCKET: '2024-01-01T00:00:00.000Z',
|
TIME_BUCKET: '2024-01-01T00:00:00.000Z',
|
||||||
BOOLEAN: true,
|
BOOLEAN: true,
|
||||||
VECTOR: '[1, 2, 3]',
|
VECTOR: JSON.stringify(Array.from({ length: 512 }, () => 0)),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GENERATE_SQL_KEY = 'generate-sql-key';
|
export const GENERATE_SQL_KEY = 'generate-sql-key';
|
||||||
|
@ -154,9 +154,9 @@ export class EnvDto {
|
|||||||
@Optional()
|
@Optional()
|
||||||
DB_USERNAME?: string;
|
DB_USERNAME?: string;
|
||||||
|
|
||||||
@IsEnum(['pgvector', 'pgvecto.rs'])
|
@IsEnum(['pgvector', 'pgvecto.rs', 'vectorchord'])
|
||||||
@Optional()
|
@Optional()
|
||||||
DB_VECTOR_EXTENSION?: 'pgvector' | 'pgvecto.rs';
|
DB_VECTOR_EXTENSION?: 'pgvector' | 'pgvecto.rs' | 'vectorchord';
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@Optional()
|
@Optional()
|
||||||
|
@ -414,6 +414,7 @@ export enum DatabaseExtension {
|
|||||||
EARTH_DISTANCE = 'earthdistance',
|
EARTH_DISTANCE = 'earthdistance',
|
||||||
VECTOR = 'vector',
|
VECTOR = 'vector',
|
||||||
VECTORS = 'vectors',
|
VECTORS = 'vectors',
|
||||||
|
VECTORCHORD = 'vchord',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum BootstrapEventPriority {
|
export enum BootstrapEventPriority {
|
||||||
|
@ -1,15 +1,13 @@
|
|||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { getVectorExtension } from 'src/repositories/database.repository';
|
||||||
import { getCLIPModelInfo } from 'src/utils/misc';
|
import { getCLIPModelInfo } from 'src/utils/misc';
|
||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
|
|
||||||
|
|
||||||
export class UsePgVectors1700713871511 implements MigrationInterface {
|
export class UsePgVectors1700713871511 implements MigrationInterface {
|
||||||
name = 'UsePgVectors1700713871511';
|
name = 'UsePgVectors1700713871511';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
|
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
|
||||||
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS ${vectorExtension}`);
|
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS ${await getVectorExtension(queryRunner)}`);
|
||||||
const faceDimQuery = await queryRunner.query(`
|
const faceDimQuery = await queryRunner.query(`
|
||||||
SELECT CARDINALITY(embedding::real[]) as dimsize
|
SELECT CARDINALITY(embedding::real[]) as dimsize
|
||||||
FROM asset_faces
|
FROM asset_faces
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { getVectorExtension } from 'src/repositories/database.repository';
|
||||||
import { vectorIndexQuery } from 'src/utils/database';
|
import { vectorIndexQuery } from 'src/utils/database';
|
||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
|
|
||||||
|
|
||||||
export class AddCLIPEmbeddingIndex1700713994428 implements MigrationInterface {
|
export class AddCLIPEmbeddingIndex1700713994428 implements MigrationInterface {
|
||||||
name = 'AddCLIPEmbeddingIndex1700713994428';
|
name = 'AddCLIPEmbeddingIndex1700713994428';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const vectorExtension = await getVectorExtension(queryRunner);
|
||||||
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
|
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
|
||||||
|
|
||||||
await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' }));
|
await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' }));
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { getVectorExtension } from 'src/repositories/database.repository';
|
||||||
import { vectorIndexQuery } from 'src/utils/database';
|
import { vectorIndexQuery } from 'src/utils/database';
|
||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
|
|
||||||
|
|
||||||
export class AddFaceEmbeddingIndex1700714033632 implements MigrationInterface {
|
export class AddFaceEmbeddingIndex1700714033632 implements MigrationInterface {
|
||||||
name = 'AddFaceEmbeddingIndex1700714033632';
|
name = 'AddFaceEmbeddingIndex1700714033632';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const vectorExtension = await getVectorExtension(queryRunner);
|
||||||
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
|
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
|
||||||
|
|
||||||
await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'asset_faces', indexName: 'face_index' }));
|
await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'asset_faces', indexName: 'face_index' }));
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import { DatabaseExtension } from 'src/enum';
|
import { DatabaseExtension } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { getVectorExtension } from 'src/repositories/database.repository';
|
||||||
import { vectorIndexQuery } from 'src/utils/database';
|
import { vectorIndexQuery } from 'src/utils/database';
|
||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
|
|
||||||
|
|
||||||
export class AddFaceSearchRelation1718486162779 implements MigrationInterface {
|
export class AddFaceSearchRelation1718486162779 implements MigrationInterface {
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const vectorExtension = await getVectorExtension(queryRunner);
|
||||||
if (vectorExtension === DatabaseExtension.VECTORS) {
|
if (vectorExtension === DatabaseExtension.VECTORS) {
|
||||||
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
|
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
|
||||||
}
|
}
|
||||||
@ -48,11 +47,11 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface {
|
|||||||
await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512)`);
|
await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512)`);
|
||||||
|
|
||||||
await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' }));
|
await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' }));
|
||||||
|
|
||||||
await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'face_search', indexName: 'face_index' }));
|
await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'face_search', indexName: 'face_index' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const vectorExtension = await getVectorExtension(queryRunner);
|
||||||
if (vectorExtension === DatabaseExtension.VECTORS) {
|
if (vectorExtension === DatabaseExtension.VECTORS) {
|
||||||
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
|
await queryRunner.query(`SET search_path TO "$user", public, vectors`);
|
||||||
}
|
}
|
||||||
|
@ -11,11 +11,3 @@ WHERE
|
|||||||
|
|
||||||
-- DatabaseRepository.getPostgresVersion
|
-- DatabaseRepository.getPostgresVersion
|
||||||
SHOW server_version
|
SHOW server_version
|
||||||
|
|
||||||
-- DatabaseRepository.shouldReindex
|
|
||||||
SELECT
|
|
||||||
idx_status
|
|
||||||
FROM
|
|
||||||
pg_vector_index_stat
|
|
||||||
WHERE
|
|
||||||
indexname = $1
|
|
||||||
|
@ -204,6 +204,21 @@ where
|
|||||||
"person"."ownerId" = $3
|
"person"."ownerId" = $3
|
||||||
and "asset_faces"."deletedAt" is null
|
and "asset_faces"."deletedAt" is null
|
||||||
|
|
||||||
|
-- PersonRepository.refreshFaces
|
||||||
|
with
|
||||||
|
"added_embeddings" as (
|
||||||
|
insert into
|
||||||
|
"face_search" ("faceId", "embedding")
|
||||||
|
values
|
||||||
|
($1, $2)
|
||||||
|
)
|
||||||
|
select
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
1
|
||||||
|
) as "dummy"
|
||||||
|
|
||||||
-- PersonRepository.getFacesByIds
|
-- PersonRepository.getFacesByIds
|
||||||
select
|
select
|
||||||
"asset_faces".*,
|
"asset_faces".*,
|
||||||
|
@ -64,6 +64,9 @@ limit
|
|||||||
$15
|
$15
|
||||||
|
|
||||||
-- SearchRepository.searchSmart
|
-- SearchRepository.searchSmart
|
||||||
|
begin
|
||||||
|
set
|
||||||
|
local vchordrq.probes = 1
|
||||||
select
|
select
|
||||||
"assets".*
|
"assets".*
|
||||||
from
|
from
|
||||||
@ -83,8 +86,12 @@ limit
|
|||||||
$7
|
$7
|
||||||
offset
|
offset
|
||||||
$8
|
$8
|
||||||
|
commit
|
||||||
|
|
||||||
-- SearchRepository.searchDuplicates
|
-- SearchRepository.searchDuplicates
|
||||||
|
begin
|
||||||
|
set
|
||||||
|
local vchordrq.probes = 1
|
||||||
with
|
with
|
||||||
"cte" as (
|
"cte" as (
|
||||||
select
|
select
|
||||||
@ -102,18 +109,22 @@ with
|
|||||||
and "assets"."id" != $5::uuid
|
and "assets"."id" != $5::uuid
|
||||||
and "assets"."stackId" is null
|
and "assets"."stackId" is null
|
||||||
order by
|
order by
|
||||||
smart_search.embedding <=> $6
|
"distance"
|
||||||
limit
|
limit
|
||||||
$7
|
$6
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
*
|
*
|
||||||
from
|
from
|
||||||
"cte"
|
"cte"
|
||||||
where
|
where
|
||||||
"cte"."distance" <= $8
|
"cte"."distance" <= $7
|
||||||
|
commit
|
||||||
|
|
||||||
-- SearchRepository.searchFaces
|
-- SearchRepository.searchFaces
|
||||||
|
begin
|
||||||
|
set
|
||||||
|
local vchordrq.probes = 1
|
||||||
with
|
with
|
||||||
"cte" as (
|
"cte" as (
|
||||||
select
|
select
|
||||||
@ -129,16 +140,17 @@ with
|
|||||||
"assets"."ownerId" = any ($2::uuid[])
|
"assets"."ownerId" = any ($2::uuid[])
|
||||||
and "assets"."deletedAt" is null
|
and "assets"."deletedAt" is null
|
||||||
order by
|
order by
|
||||||
face_search.embedding <=> $3
|
"distance"
|
||||||
limit
|
limit
|
||||||
$4
|
$3
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
*
|
*
|
||||||
from
|
from
|
||||||
"cte"
|
"cte"
|
||||||
where
|
where
|
||||||
"cte"."distance" <= $5
|
"cte"."distance" <= $4
|
||||||
|
commit
|
||||||
|
|
||||||
-- SearchRepository.searchPlaces
|
-- SearchRepository.searchPlaces
|
||||||
select
|
select
|
||||||
|
@ -89,7 +89,7 @@ describe('getEnv', () => {
|
|||||||
password: 'postgres',
|
password: 'postgres',
|
||||||
},
|
},
|
||||||
skipMigrations: false,
|
skipMigrations: false,
|
||||||
vectorExtension: 'vectors',
|
vectorExtension: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -58,7 +58,7 @@ export interface EnvData {
|
|||||||
database: {
|
database: {
|
||||||
config: DatabaseConnectionParams;
|
config: DatabaseConnectionParams;
|
||||||
skipMigrations: boolean;
|
skipMigrations: boolean;
|
||||||
vectorExtension: VectorExtension;
|
vectorExtension?: VectorExtension;
|
||||||
};
|
};
|
||||||
|
|
||||||
licensePublicKey: {
|
licensePublicKey: {
|
||||||
@ -196,6 +196,22 @@ const getEnv = (): EnvData => {
|
|||||||
ssl: dto.DB_SSL_MODE || undefined,
|
ssl: dto.DB_SSL_MODE || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let vectorExtension: VectorExtension | undefined;
|
||||||
|
switch (dto.DB_VECTOR_EXTENSION) {
|
||||||
|
case 'pgvector': {
|
||||||
|
vectorExtension = DatabaseExtension.VECTOR;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'pgvecto.rs': {
|
||||||
|
vectorExtension = DatabaseExtension.VECTORS;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'vectorchord': {
|
||||||
|
vectorExtension = DatabaseExtension.VECTORCHORD;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
host: dto.IMMICH_HOST,
|
host: dto.IMMICH_HOST,
|
||||||
port: dto.IMMICH_PORT || 2283,
|
port: dto.IMMICH_PORT || 2283,
|
||||||
@ -251,7 +267,7 @@ const getEnv = (): EnvData => {
|
|||||||
database: {
|
database: {
|
||||||
config: databaseConnection,
|
config: databaseConnection,
|
||||||
skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false,
|
skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false,
|
||||||
vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS,
|
vectorExtension,
|
||||||
},
|
},
|
||||||
|
|
||||||
licensePublicKey: isProd ? productionKeys : stagingKeys,
|
licensePublicKey: isProd ? productionKeys : stagingKeys,
|
||||||
|
@ -5,7 +5,16 @@ import { InjectKysely } from 'nestjs-kysely';
|
|||||||
import { readdir } from 'node:fs/promises';
|
import { readdir } from 'node:fs/promises';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
|
import {
|
||||||
|
EXTENSION_NAMES,
|
||||||
|
POSTGRES_VERSION_RANGE,
|
||||||
|
VECTOR_EXTENSIONS,
|
||||||
|
VECTOR_INDEX_TABLES,
|
||||||
|
VECTOR_VERSION_RANGE,
|
||||||
|
VECTORCHORD_LIST_SLACK_FACTOR,
|
||||||
|
VECTORCHORD_VERSION_RANGE,
|
||||||
|
VECTORS_VERSION_RANGE,
|
||||||
|
} from 'src/constants';
|
||||||
import { DB } from 'src/db';
|
import { DB } from 'src/db';
|
||||||
import { GenerateSql } from 'src/decorators';
|
import { GenerateSql } from 'src/decorators';
|
||||||
import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum';
|
import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum';
|
||||||
@ -14,11 +23,42 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
|
|||||||
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types';
|
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types';
|
||||||
import { vectorIndexQuery } from 'src/utils/database';
|
import { vectorIndexQuery } from 'src/utils/database';
|
||||||
import { isValidInteger } from 'src/validation';
|
import { isValidInteger } from 'src/validation';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export let cachedVectorExtension: VectorExtension | undefined;
|
||||||
|
export async function getVectorExtension(runner: Kysely<DB> | QueryRunner): Promise<VectorExtension> {
|
||||||
|
if (cachedVectorExtension) {
|
||||||
|
return cachedVectorExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedVectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
|
||||||
|
if (cachedVectorExtension) {
|
||||||
|
return cachedVectorExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
let availableExtensions: { name: VectorExtension }[];
|
||||||
|
const query = `SELECT name FROM pg_available_extensions WHERE name IN (${VECTOR_EXTENSIONS.map((ext) => `'${ext}'`).join(', ')})`;
|
||||||
|
if (runner instanceof Kysely) {
|
||||||
|
const { rows } = await sql.raw<{ name: VectorExtension }>(query).execute(runner);
|
||||||
|
availableExtensions = rows;
|
||||||
|
} else {
|
||||||
|
availableExtensions = (await runner.query(query)) as { name: VectorExtension }[];
|
||||||
|
}
|
||||||
|
const extensionNames = new Set(availableExtensions.map((row) => row.name));
|
||||||
|
cachedVectorExtension = VECTOR_EXTENSIONS.find((ext) => extensionNames.has(ext));
|
||||||
|
if (!cachedVectorExtension) {
|
||||||
|
throw new Error(`No vector extension found. Available extensions: ${VECTOR_EXTENSIONS.join(', ')}`);
|
||||||
|
}
|
||||||
|
return cachedVectorExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const probes: Record<VectorIndex, number> = {
|
||||||
|
[VectorIndex.CLIP]: 1,
|
||||||
|
[VectorIndex.FACE]: 1,
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DatabaseRepository {
|
export class DatabaseRepository {
|
||||||
private vectorExtension: VectorExtension;
|
|
||||||
private readonly asyncLock = new AsyncLock();
|
private readonly asyncLock = new AsyncLock();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -26,7 +66,6 @@ export class DatabaseRepository {
|
|||||||
private logger: LoggingRepository,
|
private logger: LoggingRepository,
|
||||||
private configRepository: ConfigRepository,
|
private configRepository: ConfigRepository,
|
||||||
) {
|
) {
|
||||||
this.vectorExtension = configRepository.getEnv().database.vectorExtension;
|
|
||||||
this.logger.setContext(DatabaseRepository.name);
|
this.logger.setContext(DatabaseRepository.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,6 +73,10 @@ export class DatabaseRepository {
|
|||||||
await this.db.destroy();
|
await this.db.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getVectorExtension(): Promise<VectorExtension> {
|
||||||
|
return getVectorExtension(this.db);
|
||||||
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DatabaseExtension.VECTORS] })
|
@GenerateSql({ params: [DatabaseExtension.VECTORS] })
|
||||||
async getExtensionVersion(extension: DatabaseExtension): Promise<ExtensionVersion> {
|
async getExtensionVersion(extension: DatabaseExtension): Promise<ExtensionVersion> {
|
||||||
const { rows } = await sql<ExtensionVersion>`
|
const { rows } = await sql<ExtensionVersion>`
|
||||||
@ -45,7 +88,20 @@ export class DatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getExtensionVersionRange(extension: VectorExtension): string {
|
getExtensionVersionRange(extension: VectorExtension): string {
|
||||||
return extension === DatabaseExtension.VECTORS ? VECTORS_VERSION_RANGE : VECTOR_VERSION_RANGE;
|
switch (extension) {
|
||||||
|
case DatabaseExtension.VECTORCHORD: {
|
||||||
|
return VECTORCHORD_VERSION_RANGE;
|
||||||
|
}
|
||||||
|
case DatabaseExtension.VECTORS: {
|
||||||
|
return VECTORS_VERSION_RANGE;
|
||||||
|
}
|
||||||
|
case DatabaseExtension.VECTOR: {
|
||||||
|
return VECTOR_VERSION_RANGE;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error(`Unsupported vector extension: '${extension}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql()
|
@GenerateSql()
|
||||||
@ -59,7 +115,14 @@ export class DatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createExtension(extension: DatabaseExtension): Promise<void> {
|
async createExtension(extension: DatabaseExtension): Promise<void> {
|
||||||
await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(extension)}`.execute(this.db);
|
await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(extension)} CASCADE`.execute(this.db);
|
||||||
|
if (extension === DatabaseExtension.VECTORCHORD) {
|
||||||
|
const dbName = sql.table(await this.getDatabaseName());
|
||||||
|
await sql`ALTER DATABASE ${dbName} SET vchordrq.prewarm_dim = '512,640,768,1024,1152,1536'`.execute(this.db);
|
||||||
|
await sql`SET vchordrq.prewarm_dim = '512,640,768,1024,1152,1536'`.execute(this.db);
|
||||||
|
await sql`ALTER DATABASE ${dbName} SET vchordrq.probes = 1`.execute(this.db);
|
||||||
|
await sql`SET vchordrq.probes = 1`.execute(this.db);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise<VectorUpdateResult> {
|
async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise<VectorUpdateResult> {
|
||||||
@ -78,120 +141,201 @@ export class DatabaseRepository {
|
|||||||
await this.db.transaction().execute(async (tx) => {
|
await this.db.transaction().execute(async (tx) => {
|
||||||
await this.setSearchPath(tx);
|
await this.setSearchPath(tx);
|
||||||
|
|
||||||
if (isVectors && installedVersion === '0.1.1') {
|
|
||||||
await this.setExtVersion(tx, DatabaseExtension.VECTORS, '0.1.11');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSchemaUpgrade = semver.satisfies(installedVersion, '0.1.1 || 0.1.11');
|
|
||||||
if (isSchemaUpgrade && isVectors) {
|
|
||||||
await this.updateVectorsSchema(tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(tx);
|
await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(tx);
|
||||||
|
|
||||||
const diff = semver.diff(installedVersion, targetVersion);
|
const diff = semver.diff(installedVersion, targetVersion);
|
||||||
if (isVectors && diff && ['minor', 'major'].includes(diff)) {
|
if (isVectors && (diff === 'major' || diff === 'minor')) {
|
||||||
await sql`SELECT pgvectors_upgrade()`.execute(tx);
|
await sql`SELECT pgvectors_upgrade()`.execute(tx);
|
||||||
restartRequired = true;
|
restartRequired = true;
|
||||||
} else {
|
} else if (diff) {
|
||||||
await this.reindex(VectorIndex.CLIP);
|
await Promise.all([this.reindexVectors(VectorIndex.CLIP), this.reindexVectors(VectorIndex.FACE)]);
|
||||||
await this.reindex(VectorIndex.FACE);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { restartRequired };
|
return { restartRequired };
|
||||||
}
|
}
|
||||||
|
|
||||||
async reindex(index: VectorIndex): Promise<void> {
|
async prewarm(index: VectorIndex): Promise<void> {
|
||||||
try {
|
const vectorExtension = await getVectorExtension(this.db);
|
||||||
await sql`REINDEX INDEX ${sql.raw(index)}`.execute(this.db);
|
if (vectorExtension !== DatabaseExtension.VECTORCHORD) {
|
||||||
} catch (error) {
|
return;
|
||||||
if (this.vectorExtension !== DatabaseExtension.VECTORS) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
this.logger.warn(`Could not reindex index ${index}. Attempting to auto-fix.`);
|
|
||||||
|
|
||||||
const table = await this.getIndexTable(index);
|
|
||||||
const dimSize = await this.getDimSize(table);
|
|
||||||
await this.db.transaction().execute(async (tx) => {
|
|
||||||
await this.setSearchPath(tx);
|
|
||||||
await sql`DROP INDEX IF EXISTS ${sql.raw(index)}`.execute(tx);
|
|
||||||
await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE real[]`.execute(tx);
|
|
||||||
await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE vector(${sql.raw(String(dimSize))})`.execute(
|
|
||||||
tx,
|
|
||||||
);
|
|
||||||
await sql.raw(vectorIndexQuery({ vectorExtension: this.vectorExtension, table, indexName: index })).execute(tx);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
this.logger.debug(`Prewarming ${index}`);
|
||||||
|
await sql`SELECT vchordrq_prewarm(${index})`.execute(this.db);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [VectorIndex.CLIP] })
|
async reindexVectorsIfNeeded(names: VectorIndex[]): Promise<void> {
|
||||||
async shouldReindex(name: VectorIndex): Promise<boolean> {
|
|
||||||
if (this.vectorExtension !== DatabaseExtension.VECTORS) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await sql<{
|
const { rows } = await sql<{
|
||||||
idx_status: string;
|
indexdef: string;
|
||||||
}>`SELECT idx_status FROM pg_vector_index_stat WHERE indexname = ${name}`.execute(this.db);
|
indexname: string;
|
||||||
return rows[0]?.idx_status === 'UPGRADE';
|
}>`SELECT indexdef, indexname FROM pg_indexes WHERE indexname = ANY(ARRAY[${sql.join(names)}])`.execute(this.db);
|
||||||
} catch (error) {
|
|
||||||
const message: string = (error as any).message;
|
const vectorExtension = await getVectorExtension(this.db);
|
||||||
if (message.includes('index is not existing')) {
|
|
||||||
return true;
|
const promises = [];
|
||||||
} else if (message.includes('relation "pg_vector_index_stat" does not exist')) {
|
for (const indexName of names) {
|
||||||
return false;
|
const row = rows.find((index) => index.indexname === indexName);
|
||||||
|
const table = VECTOR_INDEX_TABLES[indexName];
|
||||||
|
if (!row) {
|
||||||
|
promises.push(this.reindexVectors(indexName));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
|
switch (vectorExtension) {
|
||||||
|
case DatabaseExtension.VECTOR: {
|
||||||
|
if (!row.indexdef.toLowerCase().includes('using hnsw')) {
|
||||||
|
promises.push(this.reindexVectors(indexName));
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DatabaseExtension.VECTORS: {
|
||||||
|
if (!row.indexdef.toLowerCase().includes('using vectors')) {
|
||||||
|
promises.push(this.reindexVectors(indexName));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DatabaseExtension.VECTORCHORD: {
|
||||||
|
const matches = row.indexdef.match(/(?<=lists = \[)\d+/g);
|
||||||
|
const lists = matches && matches.length > 0 ? Number(matches[0]) : 1;
|
||||||
|
promises.push(
|
||||||
|
this.db
|
||||||
|
.selectFrom(this.db.dynamic.table(table).as('t'))
|
||||||
|
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
.then(({ count }) => {
|
||||||
|
const targetLists = this.targetListCount(count);
|
||||||
|
this.logger.log(`targetLists=${targetLists}, current=${lists} for ${indexName} of ${count} rows`);
|
||||||
|
if (
|
||||||
|
!row.indexdef.toLowerCase().includes('using vchordrq') ||
|
||||||
|
// slack factor is to avoid frequent reindexing if the count is borderline
|
||||||
|
(lists !== targetLists && lists !== this.targetListCount(count * VECTORCHORD_LIST_SLACK_FACTOR))
|
||||||
|
) {
|
||||||
|
probes[indexName] = this.targetProbeCount(targetLists);
|
||||||
|
return this.reindexVectors(indexName, { lists: targetLists });
|
||||||
|
} else {
|
||||||
|
probes[indexName] = this.targetProbeCount(lists);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promises.length > 0) {
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reindexVectors(indexName: VectorIndex, { lists }: { lists?: number } = {}): Promise<void> {
|
||||||
|
this.logger.log(`Reindexing ${indexName}`);
|
||||||
|
const table = VECTOR_INDEX_TABLES[indexName];
|
||||||
|
const vectorExtension = await getVectorExtension(this.db);
|
||||||
|
const { rows } = await sql<{
|
||||||
|
columnName: string;
|
||||||
|
}>`SELECT column_name as "columnName" FROM information_schema.columns WHERE table_name = ${table}`.execute(this.db);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Table ${table} does not exist, skipping reindexing. This is only normal if this is a new Immich instance.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dimSize = await this.getDimensionSize(table);
|
||||||
|
await this.db.transaction().execute(async (tx) => {
|
||||||
|
await sql`DROP INDEX IF EXISTS ${sql.raw(indexName)}`.execute(tx);
|
||||||
|
if (!rows.some((row) => row.columnName === 'embedding')) {
|
||||||
|
this.logger.warn(`Column 'embedding' does not exist in table '${table}', truncating and adding column.`);
|
||||||
|
await sql`TRUNCATE TABLE ${sql.raw(table)}`.execute(tx);
|
||||||
|
await sql`ALTER TABLE ${sql.raw(table)} ADD COLUMN embedding real[] NOT NULL`.execute(tx);
|
||||||
|
}
|
||||||
|
await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE real[]`.execute(tx);
|
||||||
|
const schema = vectorExtension === DatabaseExtension.VECTORS ? 'vectors.' : '';
|
||||||
|
await sql`
|
||||||
|
ALTER TABLE ${sql.raw(table)}
|
||||||
|
ALTER COLUMN embedding
|
||||||
|
SET DATA TYPE ${sql.raw(schema)}vector(${sql.raw(String(dimSize))})`.execute(tx);
|
||||||
|
await sql.raw(vectorIndexQuery({ vectorExtension, table, indexName, lists })).execute(tx);
|
||||||
|
});
|
||||||
|
this.logger.log(`Reindexed ${indexName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setSearchPath(tx: Transaction<DB>): Promise<void> {
|
private async setSearchPath(tx: Transaction<DB>): Promise<void> {
|
||||||
await sql`SET search_path TO "$user", public, vectors`.execute(tx);
|
await sql`SET search_path TO "$user", public, vectors`.execute(tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setExtVersion(tx: Transaction<DB>, extName: DatabaseExtension, version: string): Promise<void> {
|
private async getDatabaseName(): Promise<string> {
|
||||||
await sql`UPDATE pg_catalog.pg_extension SET extversion = ${version} WHERE extname = ${extName}`.execute(tx);
|
const { rows } = await sql<{ db: string }>`SELECT current_database() as db`.execute(this.db);
|
||||||
|
return rows[0].db;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getIndexTable(index: VectorIndex): Promise<string> {
|
async getDimensionSize(table: string, column = 'embedding'): Promise<number> {
|
||||||
const { rows } = await sql<{
|
|
||||||
relname: string | null;
|
|
||||||
}>`SELECT relname FROM pg_stat_all_indexes WHERE indexrelname = ${index}`.execute(this.db);
|
|
||||||
const table = rows[0]?.relname;
|
|
||||||
if (!table) {
|
|
||||||
throw new Error(`Could not find table for index ${index}`);
|
|
||||||
}
|
|
||||||
return table;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async updateVectorsSchema(tx: Transaction<DB>): Promise<void> {
|
|
||||||
const extension = DatabaseExtension.VECTORS;
|
|
||||||
await sql`CREATE SCHEMA IF NOT EXISTS ${extension}`.execute(tx);
|
|
||||||
await sql`UPDATE pg_catalog.pg_extension SET extrelocatable = true WHERE extname = ${extension}`.execute(tx);
|
|
||||||
await sql`ALTER EXTENSION vectors SET SCHEMA vectors`.execute(tx);
|
|
||||||
await sql`UPDATE pg_catalog.pg_extension SET extrelocatable = false WHERE extname = ${extension}`.execute(tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getDimSize(table: string, column = 'embedding'): Promise<number> {
|
|
||||||
const { rows } = await sql<{ dimsize: number }>`
|
const { rows } = await sql<{ dimsize: number }>`
|
||||||
SELECT atttypmod as dimsize
|
SELECT atttypmod as dimsize
|
||||||
FROM pg_attribute f
|
FROM pg_attribute f
|
||||||
JOIN pg_class c ON c.oid = f.attrelid
|
JOIN pg_class c ON c.oid = f.attrelid
|
||||||
WHERE c.relkind = 'r'::char
|
WHERE c.relkind = 'r'::char
|
||||||
AND f.attnum > 0
|
AND f.attnum > 0
|
||||||
AND c.relname = ${table}
|
AND c.relname = ${table}::text
|
||||||
AND f.attname = '${column}'
|
AND f.attname = ${column}::text
|
||||||
`.execute(this.db);
|
`.execute(this.db);
|
||||||
|
|
||||||
const dimSize = rows[0]?.dimsize;
|
const dimSize = rows[0]?.dimsize;
|
||||||
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
|
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
|
||||||
throw new Error(`Could not retrieve dimension size`);
|
this.logger.warn(`Could not retrieve dimension size of column '${column}' in table '${table}', assuming 512`);
|
||||||
|
return 512;
|
||||||
}
|
}
|
||||||
return dimSize;
|
return dimSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setDimensionSize(dimSize: number): Promise<void> {
|
||||||
|
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
|
||||||
|
throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is done in two transactions to handle concurrent writes
|
||||||
|
await this.db.transaction().execute(async (trx) => {
|
||||||
|
await sql`delete from ${sql.table('smart_search')}`.execute(trx);
|
||||||
|
await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute();
|
||||||
|
await sql`alter table ${sql.table('smart_search')} add constraint dim_size_constraint check (array_length(embedding::real[], 1) = ${sql.lit(dimSize)})`.execute(
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const vectorExtension = await this.getVectorExtension();
|
||||||
|
await this.db.transaction().execute(async (trx) => {
|
||||||
|
await sql`drop index if exists clip_index`.execute(trx);
|
||||||
|
await trx.schema
|
||||||
|
.alterTable('smart_search')
|
||||||
|
.alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`)))
|
||||||
|
.execute();
|
||||||
|
await sql
|
||||||
|
.raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: VectorIndex.CLIP }))
|
||||||
|
.execute(trx);
|
||||||
|
await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute();
|
||||||
|
});
|
||||||
|
probes[VectorIndex.CLIP] = 1;
|
||||||
|
|
||||||
|
await sql`vacuum analyze ${sql.table('smart_search')}`.execute(this.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAllSearchEmbeddings(): Promise<void> {
|
||||||
|
await sql`truncate ${sql.table('smart_search')}`.execute(this.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
private targetListCount(count: number) {
|
||||||
|
if (count < 128_000) {
|
||||||
|
return 1;
|
||||||
|
} else if (count < 2_048_000) {
|
||||||
|
return 1 << (32 - Math.clz32(count / 1000));
|
||||||
|
} else {
|
||||||
|
return 1 << (33 - Math.clz32(Math.sqrt(count)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private targetProbeCount(lists: number) {
|
||||||
|
return Math.ceil(lists / 8);
|
||||||
|
}
|
||||||
|
|
||||||
async runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise<void> {
|
async runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise<void> {
|
||||||
const { database } = this.configRepository.getEnv();
|
const { database } = this.configRepository.getEnv();
|
||||||
|
|
||||||
|
@ -398,6 +398,7 @@ export class PersonRepository {
|
|||||||
return results.map(({ id }) => id);
|
return results.map(({ id }) => id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] })
|
||||||
async refreshFaces(
|
async refreshFaces(
|
||||||
facesToAdd: (Insertable<AssetFaces> & { assetId: string })[],
|
facesToAdd: (Insertable<AssetFaces> & { assetId: string })[],
|
||||||
faceIdsToRemove: string[],
|
faceIdsToRemove: string[],
|
||||||
|
@ -5,9 +5,9 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { DB, Exif } from 'src/db';
|
import { DB, Exif } from 'src/db';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { probes } from 'src/repositories/database.repository';
|
||||||
import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database';
|
import { anyUuid, asUuid, searchAssetBuilder } from 'src/utils/database';
|
||||||
import { paginationHelper } from 'src/utils/pagination';
|
import { paginationHelper } from 'src/utils/pagination';
|
||||||
import { isValidInteger } from 'src/validation';
|
import { isValidInteger } from 'src/validation';
|
||||||
|
|
||||||
@ -168,10 +168,7 @@ export interface GetCameraMakesOptions {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchRepository {
|
export class SearchRepository {
|
||||||
constructor(
|
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||||
@InjectKysely() private db: Kysely<DB>,
|
|
||||||
private configRepository: ConfigRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@GenerateSql({
|
@GenerateSql({
|
||||||
params: [
|
params: [
|
||||||
@ -236,19 +233,21 @@ export class SearchRepository {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
async searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions) {
|
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions) {
|
||||||
if (!isValidInteger(pagination.size, { min: 1, max: 1000 })) {
|
if (!isValidInteger(pagination.size, { min: 1, max: 1000 })) {
|
||||||
throw new Error(`Invalid value for 'size': ${pagination.size}`);
|
throw new Error(`Invalid value for 'size': ${pagination.size}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = await searchAssetBuilder(this.db, options)
|
return this.db.transaction().execute(async (trx) => {
|
||||||
|
await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.CLIP])}`.execute(trx);
|
||||||
|
const items = await searchAssetBuilder(trx, options)
|
||||||
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
|
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
|
||||||
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
|
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
|
||||||
.limit(pagination.size + 1)
|
.limit(pagination.size + 1)
|
||||||
.offset((pagination.page - 1) * pagination.size)
|
.offset((pagination.page - 1) * pagination.size)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return paginationHelper(items, pagination.size);
|
return paginationHelper(items, pagination.size);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({
|
@GenerateSql({
|
||||||
@ -263,7 +262,9 @@ export class SearchRepository {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
searchDuplicates({ assetId, embedding, maxDistance, type, userIds }: AssetDuplicateSearch) {
|
searchDuplicates({ assetId, embedding, maxDistance, type, userIds }: AssetDuplicateSearch) {
|
||||||
return this.db
|
return this.db.transaction().execute(async (trx) => {
|
||||||
|
await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.CLIP])}`.execute(trx);
|
||||||
|
return await trx
|
||||||
.with('cte', (qb) =>
|
.with('cte', (qb) =>
|
||||||
qb
|
qb
|
||||||
.selectFrom('assets')
|
.selectFrom('assets')
|
||||||
@ -279,13 +280,14 @@ export class SearchRepository {
|
|||||||
.where('assets.type', '=', type)
|
.where('assets.type', '=', type)
|
||||||
.where('assets.id', '!=', asUuid(assetId))
|
.where('assets.id', '!=', asUuid(assetId))
|
||||||
.where('assets.stackId', 'is', null)
|
.where('assets.stackId', 'is', null)
|
||||||
.orderBy(sql`smart_search.embedding <=> ${embedding}`)
|
.orderBy('distance')
|
||||||
.limit(64),
|
.limit(64),
|
||||||
)
|
)
|
||||||
.selectFrom('cte')
|
.selectFrom('cte')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where('cte.distance', '<=', maxDistance as number)
|
.where('cte.distance', '<=', maxDistance as number)
|
||||||
.execute();
|
.execute();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({
|
@GenerateSql({
|
||||||
@ -303,7 +305,9 @@ export class SearchRepository {
|
|||||||
throw new Error(`Invalid value for 'numResults': ${numResults}`);
|
throw new Error(`Invalid value for 'numResults': ${numResults}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.db
|
return this.db.transaction().execute(async (trx) => {
|
||||||
|
await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.FACE])}`.execute(trx);
|
||||||
|
return await trx
|
||||||
.with('cte', (qb) =>
|
.with('cte', (qb) =>
|
||||||
qb
|
qb
|
||||||
.selectFrom('asset_faces')
|
.selectFrom('asset_faces')
|
||||||
@ -319,15 +323,18 @@ export class SearchRepository {
|
|||||||
.where('assets.deletedAt', 'is', null)
|
.where('assets.deletedAt', 'is', null)
|
||||||
.$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null))
|
.$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null))
|
||||||
.$if(!!minBirthDate, (qb) =>
|
.$if(!!minBirthDate, (qb) =>
|
||||||
qb.where((eb) => eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)])),
|
qb.where((eb) =>
|
||||||
|
eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.orderBy(sql`face_search.embedding <=> ${embedding}`)
|
.orderBy('distance')
|
||||||
.limit(numResults),
|
.limit(numResults),
|
||||||
)
|
)
|
||||||
.selectFrom('cte')
|
.selectFrom('cte')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where('cte.distance', '<=', maxDistance)
|
.where('cte.distance', '<=', maxDistance)
|
||||||
.execute();
|
.execute();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.STRING] })
|
@GenerateSql({ params: [DummyValue.STRING] })
|
||||||
@ -416,56 +423,6 @@ export class SearchRepository {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDimensionSize(): Promise<number> {
|
|
||||||
const { rows } = await sql<{ dimsize: number }>`
|
|
||||||
select atttypmod as dimsize
|
|
||||||
from pg_attribute f
|
|
||||||
join pg_class c ON c.oid = f.attrelid
|
|
||||||
where c.relkind = 'r'::char
|
|
||||||
and f.attnum > 0
|
|
||||||
and c.relname = 'smart_search'
|
|
||||||
and f.attname = 'embedding'
|
|
||||||
`.execute(this.db);
|
|
||||||
|
|
||||||
const dimSize = rows[0]['dimsize'];
|
|
||||||
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
|
|
||||||
throw new Error(`Could not retrieve CLIP dimension size`);
|
|
||||||
}
|
|
||||||
return dimSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setDimensionSize(dimSize: number): Promise<void> {
|
|
||||||
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
|
|
||||||
throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is done in two transactions to handle concurrent writes
|
|
||||||
await this.db.transaction().execute(async (trx) => {
|
|
||||||
await sql`delete from ${sql.table('smart_search')}`.execute(trx);
|
|
||||||
await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute();
|
|
||||||
await sql`alter table ${sql.table('smart_search')} add constraint dim_size_constraint check (array_length(embedding::real[], 1) = ${sql.lit(dimSize)})`.execute(
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const vectorExtension = this.configRepository.getEnv().database.vectorExtension;
|
|
||||||
await this.db.transaction().execute(async (trx) => {
|
|
||||||
await sql`drop index if exists clip_index`.execute(trx);
|
|
||||||
await trx.schema
|
|
||||||
.alterTable('smart_search')
|
|
||||||
.alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`)))
|
|
||||||
.execute();
|
|
||||||
await sql.raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' })).execute(trx);
|
|
||||||
await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute();
|
|
||||||
});
|
|
||||||
|
|
||||||
await sql`vacuum analyze ${sql.table('smart_search')}`.execute(this.db);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteAllSearchEmbeddings(): Promise<void> {
|
|
||||||
await sql`truncate ${sql.table('smart_search')}`.execute(this.db);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCountries(userIds: string[]): Promise<string[]> {
|
async getCountries(userIds: string[]): Promise<string[]> {
|
||||||
const res = await this.getExifField('country', userIds).execute();
|
const res = await this.getExifField('country', userIds).execute();
|
||||||
return res.map((row) => row.country!);
|
return res.map((row) => row.country!);
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { Kysely, sql } from 'kysely';
|
import { Kysely, sql } from 'kysely';
|
||||||
import { DatabaseExtension } from 'src/enum';
|
import { DatabaseExtension } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { getVectorExtension } from 'src/repositories/database.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { vectorIndexQuery } from 'src/utils/database';
|
import { vectorIndexQuery } from 'src/utils/database';
|
||||||
|
|
||||||
const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
|
|
||||||
const lastMigrationSql = sql<{ name: string }>`SELECT "name" FROM "migrations" ORDER BY "timestamp" DESC LIMIT 1;`;
|
const lastMigrationSql = sql<{ name: string }>`SELECT "name" FROM "migrations" ORDER BY "timestamp" DESC LIMIT 1;`;
|
||||||
const tableExists = sql<{ result: string | null }>`select to_regclass('migrations') as "result"`;
|
const tableExists = sql<{ result: string | null }>`select to_regclass('migrations') as "result"`;
|
||||||
const logger = LoggingRepository.create();
|
const logger = LoggingRepository.create();
|
||||||
@ -25,12 +24,14 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const vectorExtension = await getVectorExtension(db);
|
||||||
|
|
||||||
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`.execute(db);
|
await sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`.execute(db);
|
||||||
await sql`CREATE EXTENSION IF NOT EXISTS "unaccent";`.execute(db);
|
await sql`CREATE EXTENSION IF NOT EXISTS "unaccent";`.execute(db);
|
||||||
await sql`CREATE EXTENSION IF NOT EXISTS "cube";`.execute(db);
|
await sql`CREATE EXTENSION IF NOT EXISTS "cube";`.execute(db);
|
||||||
await sql`CREATE EXTENSION IF NOT EXISTS "earthdistance";`.execute(db);
|
await sql`CREATE EXTENSION IF NOT EXISTS "earthdistance";`.execute(db);
|
||||||
await sql`CREATE EXTENSION IF NOT EXISTS "pg_trgm";`.execute(db);
|
await sql`CREATE EXTENSION IF NOT EXISTS "pg_trgm";`.execute(db);
|
||||||
await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(vectorExtension)}`.execute(db);
|
await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(vectorExtension)} CASCADE`.execute(db);
|
||||||
await sql`CREATE OR REPLACE FUNCTION immich_uuid_v7(p_timestamp timestamp with time zone default clock_timestamp())
|
await sql`CREATE OR REPLACE FUNCTION immich_uuid_v7(p_timestamp timestamp with time zone default clock_timestamp())
|
||||||
RETURNS uuid
|
RETURNS uuid
|
||||||
VOLATILE LANGUAGE SQL
|
VOLATILE LANGUAGE SQL
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { EXTENSION_NAMES } from 'src/constants';
|
import { EXTENSION_NAMES } from 'src/constants';
|
||||||
import { DatabaseExtension } from 'src/enum';
|
import { DatabaseExtension, VectorIndex } from 'src/enum';
|
||||||
import { DatabaseService } from 'src/services/database.service';
|
import { DatabaseService } from 'src/services/database.service';
|
||||||
import { VectorExtension } from 'src/types';
|
import { VectorExtension } from 'src/types';
|
||||||
import { mockEnvData } from 'test/repositories/config.repository.mock';
|
import { mockEnvData } from 'test/repositories/config.repository.mock';
|
||||||
@ -47,8 +47,10 @@ describe(DatabaseService.name, () => {
|
|||||||
describe.each(<Array<{ extension: VectorExtension; extensionName: string }>>[
|
describe.each(<Array<{ extension: VectorExtension; extensionName: string }>>[
|
||||||
{ extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] },
|
{ extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] },
|
||||||
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
|
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
|
||||||
|
{ extension: DatabaseExtension.VECTORCHORD, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORCHORD] },
|
||||||
])('should work with $extensionName', ({ extension, extensionName }) => {
|
])('should work with $extensionName', ({ extension, extensionName }) => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mocks.database.getVectorExtension.mockResolvedValue(extension);
|
||||||
mocks.config.getEnv.mockReturnValue(
|
mocks.config.getEnv.mockReturnValue(
|
||||||
mockEnvData({
|
mockEnvData({
|
||||||
database: {
|
database: {
|
||||||
@ -240,41 +242,32 @@ describe(DatabaseService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it(`should reindex ${extension} indices if needed`, async () => {
|
it(`should reindex ${extension} indices if needed`, async () => {
|
||||||
mocks.database.shouldReindex.mockResolvedValue(true);
|
|
||||||
|
|
||||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||||
|
|
||||||
expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2);
|
expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledExactlyOnceWith([
|
||||||
expect(mocks.database.reindex).toHaveBeenCalledTimes(2);
|
VectorIndex.CLIP,
|
||||||
|
VectorIndex.FACE,
|
||||||
|
]);
|
||||||
|
expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
|
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should throw an error if reindexing fails`, async () => {
|
it(`should throw an error if reindexing fails`, async () => {
|
||||||
mocks.database.shouldReindex.mockResolvedValue(true);
|
mocks.database.reindexVectorsIfNeeded.mockRejectedValue(new Error('Error reindexing'));
|
||||||
mocks.database.reindex.mockRejectedValue(new Error('Error reindexing'));
|
|
||||||
|
|
||||||
await expect(sut.onBootstrap()).rejects.toBeDefined();
|
await expect(sut.onBootstrap()).rejects.toBeDefined();
|
||||||
|
|
||||||
expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(1);
|
expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledExactlyOnceWith([
|
||||||
expect(mocks.database.reindex).toHaveBeenCalledTimes(1);
|
VectorIndex.CLIP,
|
||||||
|
VectorIndex.FACE,
|
||||||
|
]);
|
||||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||||
expect(mocks.logger.warn).toHaveBeenCalledWith(
|
expect(mocks.logger.warn).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('Could not run vector reindexing checks.'),
|
expect.stringContaining('Could not run vector reindexing checks.'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should not reindex ${extension} indices if not needed`, async () => {
|
|
||||||
mocks.database.shouldReindex.mockResolvedValue(false);
|
|
||||||
|
|
||||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
|
||||||
|
|
||||||
expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2);
|
|
||||||
expect(mocks.database.reindex).toHaveBeenCalledTimes(0);
|
|
||||||
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
|
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
|
||||||
@ -300,23 +293,7 @@ describe(DatabaseService.name, () => {
|
|||||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should throw error if pgvector extension could not be created`, async () => {
|
it(`should throw error if extension could not be created`, async () => {
|
||||||
mocks.config.getEnv.mockReturnValue(
|
|
||||||
mockEnvData({
|
|
||||||
database: {
|
|
||||||
config: {
|
|
||||||
connectionType: 'parts',
|
|
||||||
host: 'database',
|
|
||||||
port: 5432,
|
|
||||||
username: 'postgres',
|
|
||||||
password: 'postgres',
|
|
||||||
database: 'immich',
|
|
||||||
},
|
|
||||||
skipMigrations: true,
|
|
||||||
vectorExtension: DatabaseExtension.VECTOR,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||||
installedVersion: null,
|
installedVersion: null,
|
||||||
availableVersion: minVersionInRange,
|
availableVersion: minVersionInRange,
|
||||||
@ -328,26 +305,7 @@ describe(DatabaseService.name, () => {
|
|||||||
|
|
||||||
expect(mocks.logger.fatal).toHaveBeenCalledTimes(1);
|
expect(mocks.logger.fatal).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.logger.fatal.mock.calls[0][0]).toContain(
|
expect(mocks.logger.fatal.mock.calls[0][0]).toContain(
|
||||||
`Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`,
|
`Alternatively, if your Postgres instance has any of vector, vectors, vchord, you may use one of them instead by setting the environment variable 'DB_VECTOR_EXTENSION=<extension name>'`,
|
||||||
);
|
|
||||||
expect(mocks.database.createExtension).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`should throw error if pgvecto.rs extension could not be created`, async () => {
|
|
||||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
|
||||||
installedVersion: null,
|
|
||||||
availableVersion: minVersionInRange,
|
|
||||||
});
|
|
||||||
mocks.database.updateVectorExtension.mockResolvedValue({ restartRequired: false });
|
|
||||||
mocks.database.createExtension.mockRejectedValue(new Error('Failed to create extension'));
|
|
||||||
|
|
||||||
await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension');
|
|
||||||
|
|
||||||
expect(mocks.logger.fatal).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.logger.fatal.mock.calls[0][0]).toContain(
|
|
||||||
`Alternatively, if your Postgres instance has pgvector, you may use this instead`,
|
|
||||||
);
|
);
|
||||||
expect(mocks.database.createExtension).toHaveBeenCalledTimes(1);
|
expect(mocks.database.createExtension).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
|
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
|
||||||
|
@ -6,7 +6,7 @@ import { BootstrapEventPriority, DatabaseExtension, DatabaseLock, VectorIndex }
|
|||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { VectorExtension } from 'src/types';
|
import { VectorExtension } from 'src/types';
|
||||||
|
|
||||||
type CreateFailedArgs = { name: string; extension: string; otherName: string };
|
type CreateFailedArgs = { name: string; extension: string; otherExtensions: string[] };
|
||||||
type UpdateFailedArgs = { name: string; extension: string; availableVersion: string };
|
type UpdateFailedArgs = { name: string; extension: string; availableVersion: string };
|
||||||
type RestartRequiredArgs = { name: string; availableVersion: string };
|
type RestartRequiredArgs = { name: string; availableVersion: string };
|
||||||
type NightlyVersionArgs = { name: string; extension: string; version: string };
|
type NightlyVersionArgs = { name: string; extension: string; version: string };
|
||||||
@ -25,18 +25,15 @@ const messages = {
|
|||||||
outOfRange: ({ name, version, range }: OutOfRangeArgs) =>
|
outOfRange: ({ name, version, range }: OutOfRangeArgs) =>
|
||||||
`The ${name} extension version is ${version}, but Immich only supports ${range}.
|
`The ${name} extension version is ${version}, but Immich only supports ${range}.
|
||||||
Please change ${name} to a compatible version in the Postgres instance.`,
|
Please change ${name} to a compatible version in the Postgres instance.`,
|
||||||
createFailed: ({ name, extension, otherName }: CreateFailedArgs) =>
|
createFailed: ({ name, extension, otherExtensions }: CreateFailedArgs) =>
|
||||||
`Failed to activate ${name} extension.
|
`Failed to activate ${name} extension.
|
||||||
Please ensure the Postgres instance has ${name} installed.
|
Please ensure the Postgres instance has ${name} installed.
|
||||||
|
|
||||||
If the Postgres instance already has ${name} installed, Immich may not have the necessary permissions to activate it.
|
If the Postgres instance already has ${name} installed, Immich may not have the necessary permissions to activate it.
|
||||||
In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${extension}' manually as a superuser.
|
In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${extension} CASCADE' manually as a superuser.
|
||||||
See https://immich.app/docs/guides/database-queries for how to query the database.
|
See https://immich.app/docs/guides/database-queries for how to query the database.
|
||||||
|
|
||||||
Alternatively, if your Postgres instance has ${otherName}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${otherName}'.
|
Alternatively, if your Postgres instance has any of ${otherExtensions.join(', ')}, you may use one of them instead by setting the environment variable 'DB_VECTOR_EXTENSION=<extension name>'.`,
|
||||||
Note that switching between the two extensions after a successful startup is not supported.
|
|
||||||
The exception is if your version of Immich prior to upgrading was 1.90.2 or earlier.
|
|
||||||
In this case, you may set either extension now, but you will not be able to switch to the other extension following a successful startup.`,
|
|
||||||
updateFailed: ({ name, extension, availableVersion }: UpdateFailedArgs) =>
|
updateFailed: ({ name, extension, availableVersion }: UpdateFailedArgs) =>
|
||||||
`The ${name} extension can be updated to ${availableVersion}.
|
`The ${name} extension can be updated to ${availableVersion}.
|
||||||
Immich attempted to update the extension, but failed to do so.
|
Immich attempted to update the extension, but failed to do so.
|
||||||
@ -67,8 +64,7 @@ export class DatabaseService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.databaseRepository.withLock(DatabaseLock.Migrations, async () => {
|
await this.databaseRepository.withLock(DatabaseLock.Migrations, async () => {
|
||||||
const envData = this.configRepository.getEnv();
|
const extension = await this.databaseRepository.getVectorExtension();
|
||||||
const extension = envData.database.vectorExtension;
|
|
||||||
const name = EXTENSION_NAMES[extension];
|
const name = EXTENSION_NAMES[extension];
|
||||||
const extensionRange = this.databaseRepository.getExtensionVersionRange(extension);
|
const extensionRange = this.databaseRepository.getExtensionVersionRange(extension);
|
||||||
|
|
||||||
@ -97,12 +93,23 @@ export class DatabaseService extends BaseService {
|
|||||||
throw new Error(messages.invalidDowngrade({ name, extension, availableVersion, installedVersion }));
|
throw new Error(messages.invalidDowngrade({ name, extension, availableVersion, installedVersion }));
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.checkReindexing();
|
try {
|
||||||
|
await this.databaseRepository.reindexVectorsIfNeeded([VectorIndex.CLIP, VectorIndex.FACE]);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
'Could not run vector reindexing checks. If the extension was updated, please restart the Postgres instance. If you are upgrading directly from a version below 1.107.2, please upgrade to 1.107.2 first.',
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const { database } = this.configRepository.getEnv();
|
const { database } = this.configRepository.getEnv();
|
||||||
if (!database.skipMigrations) {
|
if (!database.skipMigrations) {
|
||||||
await this.databaseRepository.runMigrations();
|
await this.databaseRepository.runMigrations();
|
||||||
}
|
}
|
||||||
|
await Promise.all([
|
||||||
|
this.databaseRepository.prewarm(VectorIndex.CLIP),
|
||||||
|
this.databaseRepository.prewarm(VectorIndex.FACE),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,10 +117,13 @@ export class DatabaseService extends BaseService {
|
|||||||
try {
|
try {
|
||||||
await this.databaseRepository.createExtension(extension);
|
await this.databaseRepository.createExtension(extension);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const otherExtension =
|
const otherExtensions = [
|
||||||
extension === DatabaseExtension.VECTORS ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS;
|
DatabaseExtension.VECTOR,
|
||||||
|
DatabaseExtension.VECTORS,
|
||||||
|
DatabaseExtension.VECTORCHORD,
|
||||||
|
].filter((ext) => ext !== extension);
|
||||||
const name = EXTENSION_NAMES[extension];
|
const name = EXTENSION_NAMES[extension];
|
||||||
this.logger.fatal(messages.createFailed({ name, extension, otherName: EXTENSION_NAMES[otherExtension] }));
|
this.logger.fatal(messages.createFailed({ name, extension, otherExtensions }));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -130,21 +140,4 @@ export class DatabaseService extends BaseService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkReindexing() {
|
|
||||||
try {
|
|
||||||
if (await this.databaseRepository.shouldReindex(VectorIndex.CLIP)) {
|
|
||||||
await this.databaseRepository.reindex(VectorIndex.CLIP);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await this.databaseRepository.shouldReindex(VectorIndex.FACE)) {
|
|
||||||
await this.databaseRepository.reindex(VectorIndex.FACE);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
'Could not run vector reindexing checks. If the extension was updated, please restart the Postgres instance.',
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -33,6 +33,7 @@ import {
|
|||||||
QueueName,
|
QueueName,
|
||||||
SourceType,
|
SourceType,
|
||||||
SystemMetadataKey,
|
SystemMetadataKey,
|
||||||
|
VectorIndex,
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
||||||
import { UpdateFacesData } from 'src/repositories/person.repository';
|
import { UpdateFacesData } from 'src/repositories/person.repository';
|
||||||
@ -418,6 +419,8 @@ export class PersonService extends BaseService {
|
|||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.databaseRepository.prewarm(VectorIndex.FACE);
|
||||||
|
|
||||||
const lastRun = new Date().toISOString();
|
const lastRun = new Date().toISOString();
|
||||||
const facePagination = this.personRepository.getAllFaces(
|
const facePagination = this.personRepository.getAllFaces(
|
||||||
force ? undefined : { personId: null, sourceType: SourceType.MACHINE_LEARNING },
|
force ? undefined : { personId: null, sourceType: SourceType.MACHINE_LEARNING },
|
||||||
|
@ -54,28 +54,28 @@ describe(SmartInfoService.name, () => {
|
|||||||
it('should return if machine learning is disabled', async () => {
|
it('should return if machine learning is disabled', async () => {
|
||||||
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig });
|
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig });
|
||||||
|
|
||||||
expect(mocks.search.getDimensionSize).not.toHaveBeenCalled();
|
expect(mocks.database.getDimensionSize).not.toHaveBeenCalled();
|
||||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
|
||||||
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return if model and DB dimension size are equal', async () => {
|
it('should return if model and DB dimension size are equal', async () => {
|
||||||
mocks.search.getDimensionSize.mockResolvedValue(512);
|
mocks.database.getDimensionSize.mockResolvedValue(512);
|
||||||
|
|
||||||
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig });
|
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig });
|
||||||
|
|
||||||
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
|
expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
|
||||||
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update DB dimension size if model and DB have different values', async () => {
|
it('should update DB dimension size if model and DB have different values', async () => {
|
||||||
mocks.search.getDimensionSize.mockResolvedValue(768);
|
mocks.database.getDimensionSize.mockResolvedValue(768);
|
||||||
|
|
||||||
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig });
|
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig });
|
||||||
|
|
||||||
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
|
expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512);
|
expect(mocks.database.setDimensionSize).toHaveBeenCalledWith(512);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -89,13 +89,13 @@ describe(SmartInfoService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
|
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
|
||||||
expect(mocks.search.getDimensionSize).not.toHaveBeenCalled();
|
expect(mocks.database.getDimensionSize).not.toHaveBeenCalled();
|
||||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
|
||||||
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return if model and DB dimension size are equal', async () => {
|
it('should return if model and DB dimension size are equal', async () => {
|
||||||
mocks.search.getDimensionSize.mockResolvedValue(512);
|
mocks.database.getDimensionSize.mockResolvedValue(512);
|
||||||
|
|
||||||
await sut.onConfigUpdate({
|
await sut.onConfigUpdate({
|
||||||
newConfig: {
|
newConfig: {
|
||||||
@ -106,13 +106,13 @@ describe(SmartInfoService.name, () => {
|
|||||||
} as SystemConfig,
|
} as SystemConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
|
expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
|
||||||
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update DB dimension size if model and DB have different values', async () => {
|
it('should update DB dimension size if model and DB have different values', async () => {
|
||||||
mocks.search.getDimensionSize.mockResolvedValue(512);
|
mocks.database.getDimensionSize.mockResolvedValue(512);
|
||||||
|
|
||||||
await sut.onConfigUpdate({
|
await sut.onConfigUpdate({
|
||||||
newConfig: {
|
newConfig: {
|
||||||
@ -123,12 +123,12 @@ describe(SmartInfoService.name, () => {
|
|||||||
} as SystemConfig,
|
} as SystemConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
|
expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(768);
|
expect(mocks.database.setDimensionSize).toHaveBeenCalledWith(768);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear embeddings if old and new models are different', async () => {
|
it('should clear embeddings if old and new models are different', async () => {
|
||||||
mocks.search.getDimensionSize.mockResolvedValue(512);
|
mocks.database.getDimensionSize.mockResolvedValue(512);
|
||||||
|
|
||||||
await sut.onConfigUpdate({
|
await sut.onConfigUpdate({
|
||||||
newConfig: {
|
newConfig: {
|
||||||
@ -139,9 +139,9 @@ describe(SmartInfoService.name, () => {
|
|||||||
} as SystemConfig,
|
} as SystemConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled();
|
expect(mocks.database.deleteAllSearchEmbeddings).toHaveBeenCalled();
|
||||||
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
|
expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -151,7 +151,7 @@ describe(SmartInfoService.name, () => {
|
|||||||
|
|
||||||
await sut.handleQueueEncodeClip({});
|
await sut.handleQueueEncodeClip({});
|
||||||
|
|
||||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should queue the assets without clip embeddings', async () => {
|
it('should queue the assets without clip embeddings', async () => {
|
||||||
@ -163,7 +163,7 @@ describe(SmartInfoService.name, () => {
|
|||||||
{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } },
|
{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } },
|
||||||
]);
|
]);
|
||||||
expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(false);
|
expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(false);
|
||||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should queue all the assets', async () => {
|
it('should queue all the assets', async () => {
|
||||||
@ -175,7 +175,7 @@ describe(SmartInfoService.name, () => {
|
|||||||
{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } },
|
{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } },
|
||||||
]);
|
]);
|
||||||
expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(true);
|
expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(true);
|
||||||
expect(mocks.search.setDimensionSize).toHaveBeenCalledExactlyOnceWith(512);
|
expect(mocks.database.setDimensionSize).toHaveBeenCalledExactlyOnceWith(512);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ export class SmartInfoService extends BaseService {
|
|||||||
|
|
||||||
await this.databaseRepository.withLock(DatabaseLock.CLIPDimSize, async () => {
|
await this.databaseRepository.withLock(DatabaseLock.CLIPDimSize, async () => {
|
||||||
const { dimSize } = getCLIPModelInfo(newConfig.machineLearning.clip.modelName);
|
const { dimSize } = getCLIPModelInfo(newConfig.machineLearning.clip.modelName);
|
||||||
const dbDimSize = await this.searchRepository.getDimensionSize();
|
const dbDimSize = await this.databaseRepository.getDimensionSize('smart_search');
|
||||||
this.logger.verbose(`Current database CLIP dimension size is ${dbDimSize}`);
|
this.logger.verbose(`Current database CLIP dimension size is ${dbDimSize}`);
|
||||||
|
|
||||||
const modelChange =
|
const modelChange =
|
||||||
@ -53,10 +53,10 @@ export class SmartInfoService extends BaseService {
|
|||||||
`Dimension size of model ${newConfig.machineLearning.clip.modelName} is ${dimSize}, but database expects ${dbDimSize}.`,
|
`Dimension size of model ${newConfig.machineLearning.clip.modelName} is ${dimSize}, but database expects ${dbDimSize}.`,
|
||||||
);
|
);
|
||||||
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
|
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
|
||||||
await this.searchRepository.setDimensionSize(dimSize);
|
await this.databaseRepository.setDimensionSize(dimSize);
|
||||||
this.logger.log(`Successfully updated database CLIP dimension size from ${dbDimSize} to ${dimSize}.`);
|
this.logger.log(`Successfully updated database CLIP dimension size from ${dbDimSize} to ${dimSize}.`);
|
||||||
} else {
|
} else {
|
||||||
await this.searchRepository.deleteAllSearchEmbeddings();
|
await this.databaseRepository.deleteAllSearchEmbeddings();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: A job to reindex all assets should be scheduled, though user
|
// TODO: A job to reindex all assets should be scheduled, though user
|
||||||
@ -74,7 +74,7 @@ export class SmartInfoService extends BaseService {
|
|||||||
if (force) {
|
if (force) {
|
||||||
const { dimSize } = getCLIPModelInfo(machineLearning.clip.modelName);
|
const { dimSize } = getCLIPModelInfo(machineLearning.clip.modelName);
|
||||||
// in addition to deleting embeddings, update the dimension size in case it failed earlier
|
// in addition to deleting embeddings, update the dimension size in case it failed earlier
|
||||||
await this.searchRepository.setDimensionSize(dimSize);
|
await this.databaseRepository.setDimensionSize(dimSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
let queue: JobItem[] = [];
|
let queue: JobItem[] = [];
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
|
import { VECTOR_EXTENSIONS } from 'src/constants';
|
||||||
import {
|
import {
|
||||||
AssetType,
|
AssetType,
|
||||||
DatabaseExtension,
|
|
||||||
DatabaseSslMode,
|
DatabaseSslMode,
|
||||||
ExifOrientation,
|
ExifOrientation,
|
||||||
ImageFormat,
|
ImageFormat,
|
||||||
@ -363,7 +363,7 @@ export type JobItem =
|
|||||||
// Version check
|
// Version check
|
||||||
| { name: JobName.VERSION_CHECK; data: IBaseJob };
|
| { name: JobName.VERSION_CHECK; data: IBaseJob };
|
||||||
|
|
||||||
export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS;
|
export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number];
|
||||||
|
|
||||||
export type DatabaseConnectionURL = {
|
export type DatabaseConnectionURL = {
|
||||||
connectionType: 'url';
|
connectionType: 'url';
|
||||||
|
@ -384,14 +384,28 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
|||||||
.$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null));
|
.$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null));
|
||||||
}
|
}
|
||||||
|
|
||||||
type VectorIndexOptions = { vectorExtension: VectorExtension; table: string; indexName: string };
|
export type ReindexVectorIndexOptions = { indexName: string; lists?: number };
|
||||||
|
|
||||||
export function vectorIndexQuery({ vectorExtension, table, indexName }: VectorIndexOptions): string {
|
type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions;
|
||||||
|
|
||||||
|
export function vectorIndexQuery({ vectorExtension, table, indexName, lists }: VectorIndexQueryOptions): string {
|
||||||
switch (vectorExtension) {
|
switch (vectorExtension) {
|
||||||
|
case DatabaseExtension.VECTORCHORD: {
|
||||||
|
return `
|
||||||
|
CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} USING vchordrq (embedding vector_cosine_ops) WITH (options = $$
|
||||||
|
residual_quantization = false
|
||||||
|
[build.internal]
|
||||||
|
lists = [${lists ?? 1}]
|
||||||
|
spherical_centroids = true
|
||||||
|
build_threads = 4
|
||||||
|
sampling_factor = 1024
|
||||||
|
$$)`;
|
||||||
|
}
|
||||||
case DatabaseExtension.VECTORS: {
|
case DatabaseExtension.VECTORS: {
|
||||||
return `
|
return `
|
||||||
CREATE INDEX IF NOT EXISTS ${indexName} ON ${table}
|
CREATE INDEX IF NOT EXISTS ${indexName} ON ${table}
|
||||||
USING vectors (embedding vector_cos_ops) WITH (options = $$
|
USING vectors (embedding vector_cos_ops) WITH (options = $$
|
||||||
|
optimizing.optimizing_threads = 4
|
||||||
[indexing.hnsw]
|
[indexing.hnsw]
|
||||||
m = 16
|
m = 16
|
||||||
ef_construction = 300
|
ef_construction = 300
|
||||||
|
@ -170,7 +170,7 @@ export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kys
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'search': {
|
case 'search': {
|
||||||
return new SearchRepository(db, new ConfigRepository());
|
return new SearchRepository(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'session': {
|
case 'session': {
|
||||||
|
@ -7,7 +7,7 @@ import { getKyselyConfig } from 'src/utils/database';
|
|||||||
import { GenericContainer, Wait } from 'testcontainers';
|
import { GenericContainer, Wait } from 'testcontainers';
|
||||||
|
|
||||||
const globalSetup = async () => {
|
const globalSetup = async () => {
|
||||||
const postgresContainer = await new GenericContainer('tensorchord/pgvecto-rs:pg14-v0.2.0')
|
const postgresContainer = await new GenericContainer('ghcr.io/immich-app/postgres:14')
|
||||||
.withExposedPorts(5432)
|
.withExposedPorts(5432)
|
||||||
.withEnvironment({
|
.withEnvironment({
|
||||||
POSTGRES_PASSWORD: 'postgres',
|
POSTGRES_PASSWORD: 'postgres',
|
||||||
@ -17,9 +17,7 @@ const globalSetup = async () => {
|
|||||||
.withCommand([
|
.withCommand([
|
||||||
'postgres',
|
'postgres',
|
||||||
'-c',
|
'-c',
|
||||||
'shared_preload_libraries=vectors.so',
|
'shared_preload_libraries=vchord.so',
|
||||||
'-c',
|
|
||||||
'search_path="$$user", public, vectors',
|
|
||||||
'-c',
|
'-c',
|
||||||
'max_wal_size=2GB',
|
'max_wal_size=2GB',
|
||||||
'-c',
|
'-c',
|
||||||
@ -30,6 +28,8 @@ const globalSetup = async () => {
|
|||||||
'full_page_writes=off',
|
'full_page_writes=off',
|
||||||
'-c',
|
'-c',
|
||||||
'synchronous_commit=off',
|
'synchronous_commit=off',
|
||||||
|
'-c',
|
||||||
|
'config_file=/var/lib/postgresql/data/postgresql.conf',
|
||||||
])
|
])
|
||||||
.withWaitStrategy(Wait.forAll([Wait.forLogMessage('database system is ready to accept connections', 2)]))
|
.withWaitStrategy(Wait.forAll([Wait.forLogMessage('database system is ready to accept connections', 2)]))
|
||||||
.start();
|
.start();
|
||||||
|
@ -6,13 +6,17 @@ export const newDatabaseRepositoryMock = (): Mocked<RepositoryInterface<Database
|
|||||||
return {
|
return {
|
||||||
shutdown: vitest.fn(),
|
shutdown: vitest.fn(),
|
||||||
getExtensionVersion: vitest.fn(),
|
getExtensionVersion: vitest.fn(),
|
||||||
|
getVectorExtension: vitest.fn(),
|
||||||
getExtensionVersionRange: vitest.fn(),
|
getExtensionVersionRange: vitest.fn(),
|
||||||
getPostgresVersion: vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'),
|
getPostgresVersion: vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'),
|
||||||
getPostgresVersionRange: vitest.fn().mockReturnValue('>=14.0.0'),
|
getPostgresVersionRange: vitest.fn().mockReturnValue('>=14.0.0'),
|
||||||
createExtension: vitest.fn().mockResolvedValue(void 0),
|
createExtension: vitest.fn().mockResolvedValue(void 0),
|
||||||
updateVectorExtension: vitest.fn(),
|
updateVectorExtension: vitest.fn(),
|
||||||
reindex: vitest.fn(),
|
reindexVectorsIfNeeded: vitest.fn(),
|
||||||
shouldReindex: vitest.fn(),
|
getDimensionSize: vitest.fn(),
|
||||||
|
setDimensionSize: vitest.fn(),
|
||||||
|
deleteAllSearchEmbeddings: vitest.fn(),
|
||||||
|
prewarm: vitest.fn(),
|
||||||
runMigrations: vitest.fn(),
|
runMigrations: vitest.fn(),
|
||||||
withLock: vitest.fn().mockImplementation((_, function_: <R>() => Promise<R>) => function_()),
|
withLock: vitest.fn().mockImplementation((_, function_: <R>() => Promise<R>) => function_()),
|
||||||
tryLock: vitest.fn(),
|
tryLock: vitest.fn(),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user