mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
wip
auto-detect available extensions auto-recovery, fix reindexing check use original image for ml
This commit is contained in:
parent
81d959a27e
commit
c80b16d24e
@ -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.2 <0.4';
|
||||||
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';
|
||||||
|
@ -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()
|
||||||
|
@ -412,6 +412,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`);
|
||||||
}
|
}
|
||||||
|
@ -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,35 @@ 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';
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
@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 +59,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 +66,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 +81,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 +108,13 @@ 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) {
|
||||||
|
await sql`ALTER DATABASE immich 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 immich 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,102 +133,134 @@ 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.debug(`Prewarming ${index}`);
|
||||||
|
await sql`SELECT vchordrq_prewarm(${index})`.execute(this.db);
|
||||||
}
|
}
|
||||||
this.logger.warn(`Could not reindex index ${index}. Attempting to auto-fix.`);
|
|
||||||
|
|
||||||
const table = await this.getIndexTable(index);
|
async reindexVectorsIfNeeded(names: VectorIndex[]): Promise<void> {
|
||||||
|
const { rows } = await sql<{
|
||||||
|
indexdef: string;
|
||||||
|
indexname: string;
|
||||||
|
}>`SELECT indexdef, indexname FROM pg_indexes WHERE indexname = ANY(ARRAY[${sql.join(names)}])`.execute(this.db);
|
||||||
|
|
||||||
|
let keyword: string;
|
||||||
|
const vectorExtension = await getVectorExtension(this.db);
|
||||||
|
switch (vectorExtension) {
|
||||||
|
case DatabaseExtension.VECTOR: {
|
||||||
|
keyword = 'using hnsw';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DatabaseExtension.VECTORCHORD: {
|
||||||
|
keyword = 'using vchordrq';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DatabaseExtension.VECTORS: {
|
||||||
|
keyword = 'using vectors';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises = [];
|
||||||
|
for (const indexName of names) {
|
||||||
|
const row = rows.find((index) => index.indexname === indexName);
|
||||||
|
const table = VECTOR_INDEX_TABLES[indexName];
|
||||||
|
if (!row) {
|
||||||
|
promises.push(this.reindexVectors(indexName));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (vectorExtension) {
|
||||||
|
case DatabaseExtension.VECTOR:
|
||||||
|
case DatabaseExtension.VECTORS: {
|
||||||
|
if (!row.indexdef.toLowerCase().includes(keyword)) {
|
||||||
|
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(keyword) ||
|
||||||
|
// slack factor is to avoid frequent reindexing if the count is borderline
|
||||||
|
(lists !== targetLists && lists !== this.targetListCount(count * VECTORCHORD_LIST_SLACK_FACTOR))
|
||||||
|
) {
|
||||||
|
return this.reindexVectors(indexName, { lists: targetLists });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
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.getDimSize(table);
|
const dimSize = await this.getDimSize(table);
|
||||||
await this.db.transaction().execute(async (tx) => {
|
await this.db.transaction().execute(async (tx) => {
|
||||||
await this.setSearchPath(tx);
|
await sql`DROP INDEX IF EXISTS ${sql.raw(indexName)}`.execute(tx);
|
||||||
await sql`DROP INDEX IF EXISTS ${sql.raw(index)}`.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);
|
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(
|
const schema = vectorExtension === DatabaseExtension.VECTORS ? 'vectors.' : '';
|
||||||
tx,
|
await sql`
|
||||||
);
|
ALTER TABLE ${sql.raw(table)}
|
||||||
await sql.raw(vectorIndexQuery({ vectorExtension: this.vectorExtension, table, indexName: index })).execute(tx);
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@GenerateSql({ params: [VectorIndex.CLIP] })
|
|
||||||
async shouldReindex(name: VectorIndex): Promise<boolean> {
|
|
||||||
if (this.vectorExtension !== DatabaseExtension.VECTORS) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await sql<{
|
|
||||||
idx_status: string;
|
|
||||||
}>`SELECT idx_status FROM pg_vector_index_stat WHERE indexname = ${name}`.execute(this.db);
|
|
||||||
return rows[0]?.idx_status === 'UPGRADE';
|
|
||||||
} catch (error) {
|
|
||||||
const message: string = (error as any).message;
|
|
||||||
if (message.includes('index is not existing')) {
|
|
||||||
return true;
|
|
||||||
} else if (message.includes('relation "pg_vector_index_stat" does not exist')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
|
||||||
await sql`UPDATE pg_catalog.pg_extension SET extversion = ${version} WHERE extname = ${extName}`.execute(tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getIndexTable(index: VectorIndex): Promise<string> {
|
|
||||||
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> {
|
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
|
||||||
@ -181,17 +268,29 @@ export class DatabaseRepository {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: set probes in queries
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
|
@ -5,8 +5,8 @@ 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 { getVectorExtension } from 'src/repositories/database.repository';
|
||||||
import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database';
|
import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } 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: [
|
||||||
@ -448,14 +445,16 @@ export class SearchRepository {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const vectorExtension = this.configRepository.getEnv().database.vectorExtension;
|
const vectorExtension = await getVectorExtension(this.db);
|
||||||
await this.db.transaction().execute(async (trx) => {
|
await this.db.transaction().execute(async (trx) => {
|
||||||
await sql`drop index if exists clip_index`.execute(trx);
|
await sql`drop index if exists clip_index`.execute(trx);
|
||||||
await trx.schema
|
await trx.schema
|
||||||
.alterTable('smart_search')
|
.alterTable('smart_search')
|
||||||
.alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`)))
|
.alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`)))
|
||||||
.execute();
|
.execute();
|
||||||
await sql.raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' })).execute(trx);
|
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();
|
await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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,6 +24,8 @@ 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);
|
||||||
|
@ -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';
|
||||||
@ -49,6 +49,7 @@ describe(DatabaseService.name, () => {
|
|||||||
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
|
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
|
||||||
])('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 +241,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 () => {
|
||||||
@ -328,7 +320,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.createExtension).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
|
expect(mocks.database.updateVectorExtension).not.toHaveBeenCalled();
|
||||||
@ -347,7 +339,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 pgvector, 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.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,7 +25,7 @@ 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.
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ const messages = {
|
|||||||
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}' 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.
|
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.
|
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.`,
|
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.`,
|
||||||
@ -67,8 +67,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 +96,20 @@ 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.',
|
||||||
|
);
|
||||||
|
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 this.databaseRepository.prewarm(VectorIndex.CLIP);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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';
|
||||||
@ -416,6 +417,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 },
|
||||||
|
@ -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,
|
||||||
@ -367,7 +367,7 @@ export type JobItem =
|
|||||||
| { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob }
|
| { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob }
|
||||||
| { name: JobName.MEMORIES_CREATE; data?: IBaseJob };
|
| { name: JobName.MEMORIES_CREATE; 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';
|
||||||
|
@ -383,10 +383,22 @@ 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
|
||||||
|
$$)`;
|
||||||
|
}
|
||||||
case DatabaseExtension.VECTORS: {
|
case DatabaseExtension.VECTORS: {
|
||||||
return `
|
return `
|
||||||
CREATE INDEX IF NOT EXISTS ${indexName} ON ${table}
|
CREATE INDEX IF NOT EXISTS ${indexName} ON ${table}
|
||||||
|
@ -6,13 +6,14 @@ 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(),
|
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