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:
@@ -1,5 +1,5 @@
|
||||
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 { VectorExtension } from 'src/types';
|
||||
import { mockEnvData } from 'test/repositories/config.repository.mock';
|
||||
@@ -47,8 +47,10 @@ describe(DatabaseService.name, () => {
|
||||
describe.each(<Array<{ extension: VectorExtension; extensionName: string }>>[
|
||||
{ extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] },
|
||||
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
|
||||
{ extension: DatabaseExtension.VECTORCHORD, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORCHORD] },
|
||||
])('should work with $extensionName', ({ extension, extensionName }) => {
|
||||
beforeEach(() => {
|
||||
mocks.database.getVectorExtension.mockResolvedValue(extension);
|
||||
mocks.config.getEnv.mockReturnValue(
|
||||
mockEnvData({
|
||||
database: {
|
||||
@@ -240,41 +242,32 @@ describe(DatabaseService.name, () => {
|
||||
});
|
||||
|
||||
it(`should reindex ${extension} indices if needed`, async () => {
|
||||
mocks.database.shouldReindex.mockResolvedValue(true);
|
||||
|
||||
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.database.reindex).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledExactlyOnceWith([
|
||||
VectorIndex.CLIP,
|
||||
VectorIndex.FACE,
|
||||
]);
|
||||
expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should throw an error if reindexing fails`, async () => {
|
||||
mocks.database.shouldReindex.mockResolvedValue(true);
|
||||
mocks.database.reindex.mockRejectedValue(new Error('Error reindexing'));
|
||||
mocks.database.reindexVectorsIfNeeded.mockRejectedValue(new Error('Error reindexing'));
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toBeDefined();
|
||||
|
||||
expect(mocks.database.shouldReindex).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.reindex).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledExactlyOnceWith([
|
||||
VectorIndex.CLIP,
|
||||
VectorIndex.FACE,
|
||||
]);
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
expect(mocks.logger.fatal).not.toHaveBeenCalled();
|
||||
expect(mocks.logger.warn).toHaveBeenCalledWith(
|
||||
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 () => {
|
||||
@@ -300,23 +293,7 @@ describe(DatabaseService.name, () => {
|
||||
expect(mocks.database.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should throw error if pgvector 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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
it(`should throw error if extension could not be created`, async () => {
|
||||
mocks.database.getExtensionVersion.mockResolvedValue({
|
||||
installedVersion: null,
|
||||
availableVersion: minVersionInRange,
|
||||
@@ -328,26 +305,7 @@ describe(DatabaseService.name, () => {
|
||||
|
||||
expect(mocks.logger.fatal).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.logger.fatal.mock.calls[0][0]).toContain(
|
||||
`Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`,
|
||||
);
|
||||
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`,
|
||||
`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();
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BootstrapEventPriority, DatabaseExtension, DatabaseLock, VectorIndex }
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
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 RestartRequiredArgs = { name: string; availableVersion: string };
|
||||
type NightlyVersionArgs = { name: string; extension: string; version: string };
|
||||
@@ -25,18 +25,15 @@ const messages = {
|
||||
outOfRange: ({ name, version, range }: OutOfRangeArgs) =>
|
||||
`The ${name} extension version is ${version}, but Immich only supports ${range}.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
||||
Alternatively, if your Postgres instance has ${otherName}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${otherName}'.
|
||||
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.`,
|
||||
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>'.`,
|
||||
updateFailed: ({ name, extension, availableVersion }: UpdateFailedArgs) =>
|
||||
`The ${name} extension can be updated to ${availableVersion}.
|
||||
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 () => {
|
||||
const envData = this.configRepository.getEnv();
|
||||
const extension = envData.database.vectorExtension;
|
||||
const extension = await this.databaseRepository.getVectorExtension();
|
||||
const name = EXTENSION_NAMES[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 }));
|
||||
}
|
||||
|
||||
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();
|
||||
if (!database.skipMigrations) {
|
||||
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 {
|
||||
await this.databaseRepository.createExtension(extension);
|
||||
} catch (error) {
|
||||
const otherExtension =
|
||||
extension === DatabaseExtension.VECTORS ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS;
|
||||
const otherExtensions = [
|
||||
DatabaseExtension.VECTOR,
|
||||
DatabaseExtension.VECTORS,
|
||||
DatabaseExtension.VECTORCHORD,
|
||||
].filter((ext) => ext !== 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;
|
||||
}
|
||||
}
|
||||
@@ -130,21 +140,4 @@ export class DatabaseService extends BaseService {
|
||||
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,
|
||||
SourceType,
|
||||
SystemMetadataKey,
|
||||
VectorIndex,
|
||||
} from 'src/enum';
|
||||
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
||||
import { UpdateFacesData } from 'src/repositories/person.repository';
|
||||
@@ -418,6 +419,8 @@ export class PersonService extends BaseService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
await this.databaseRepository.prewarm(VectorIndex.FACE);
|
||||
|
||||
const lastRun = new Date().toISOString();
|
||||
const facePagination = this.personRepository.getAllFaces(
|
||||
force ? undefined : { personId: null, sourceType: SourceType.MACHINE_LEARNING },
|
||||
|
||||
@@ -54,28 +54,28 @@ describe(SmartInfoService.name, () => {
|
||||
it('should return if machine learning is disabled', async () => {
|
||||
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningDisabled as SystemConfig });
|
||||
|
||||
expect(mocks.search.getDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
||||
expect(mocks.database.getDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
||||
expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512);
|
||||
expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.setDimensionSize).toHaveBeenCalledWith(512);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,13 +89,13 @@ describe(SmartInfoService.name, () => {
|
||||
});
|
||||
|
||||
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
|
||||
expect(mocks.search.getDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
||||
expect(mocks.database.getDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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({
|
||||
newConfig: {
|
||||
@@ -106,13 +106,13 @@ describe(SmartInfoService.name, () => {
|
||||
} as SystemConfig,
|
||||
});
|
||||
|
||||
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
||||
expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.database.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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({
|
||||
newConfig: {
|
||||
@@ -123,12 +123,12 @@ describe(SmartInfoService.name, () => {
|
||||
} as SystemConfig,
|
||||
});
|
||||
|
||||
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(768);
|
||||
expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.setDimensionSize).toHaveBeenCalledWith(768);
|
||||
});
|
||||
|
||||
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({
|
||||
newConfig: {
|
||||
@@ -139,9 +139,9 @@ describe(SmartInfoService.name, () => {
|
||||
} as SystemConfig,
|
||||
});
|
||||
|
||||
expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled();
|
||||
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.database.deleteAllSearchEmbeddings).toHaveBeenCalled();
|
||||
expect(mocks.database.getDimensionSize).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ describe(SmartInfoService.name, () => {
|
||||
|
||||
await sut.handleQueueEncodeClip({});
|
||||
|
||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
||||
expect(mocks.database.setDimensionSize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 } },
|
||||
]);
|
||||
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 () => {
|
||||
@@ -175,7 +175,7 @@ describe(SmartInfoService.name, () => {
|
||||
{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } },
|
||||
]);
|
||||
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 () => {
|
||||
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}`);
|
||||
|
||||
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}.`,
|
||||
);
|
||||
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}.`);
|
||||
} else {
|
||||
await this.searchRepository.deleteAllSearchEmbeddings();
|
||||
await this.databaseRepository.deleteAllSearchEmbeddings();
|
||||
}
|
||||
|
||||
// TODO: A job to reindex all assets should be scheduled, though user
|
||||
@@ -74,7 +74,7 @@ export class SmartInfoService extends BaseService {
|
||||
if (force) {
|
||||
const { dimSize } = getCLIPModelInfo(machineLearning.clip.modelName);
|
||||
// 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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user