1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-24 08:52:28 +02:00

feat(server): conditionally run facial recognition nightly (#11080)

* only run nightly if new person

* add tests

* use string instead of date

* update sql

* update tests

* simplify condition
This commit is contained in:
Mert 2024-07-14 18:53:42 -04:00 committed by GitHub
parent 8863bd4e7d
commit 8193416230
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 107 additions and 5 deletions

View File

@ -12,6 +12,7 @@ export class SystemMetadataEntity<T extends keyof SystemMetadata = SystemMetadat
export enum SystemMetadataKey {
REVERSE_GEOCODING_STATE = 'reverse-geocoding-state',
FACIAL_RECOGNITION_STATE = 'facial-recognition-state',
ADMIN_ONBOARDING = 'admin-onboarding',
SYSTEM_CONFIG = 'system-config',
VERSION_CHECK_STATE = 'version-check-state',
@ -22,6 +23,7 @@ export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string }
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
[SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string };
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;

View File

@ -153,6 +153,10 @@ export interface IDeferrableJob extends IEntityJob {
deferred?: boolean;
}
export interface INightlyJob extends IBaseJob {
nightly?: boolean;
}
export interface IEmailJob {
to: string;
subject: string;
@ -229,7 +233,7 @@ export type JobItem =
// Facial Recognition
| { name: JobName.QUEUE_FACE_DETECTION; data: IBaseJob }
| { name: JobName.FACE_DETECTION; data: IEntityJob }
| { name: JobName.QUEUE_FACIAL_RECOGNITION; data: IBaseJob }
| { name: JobName.QUEUE_FACIAL_RECOGNITION; data: INightlyJob }
| { name: JobName.FACIAL_RECOGNITION; data: IDeferrableJob }
| { name: JobName.GENERATE_PERSON_THUMBNAIL; data: IEntityJob }

View File

@ -64,4 +64,5 @@ export interface IPersonRepository {
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
reassignFaces(data: UpdateFacesData): Promise<number>;
update(entity: Partial<PersonEntity>): Promise<PersonEntity>;
getLatestFaceDate(): Promise<string | undefined>;
}

View File

@ -434,3 +434,9 @@ WHERE
(("AssetFaceEntity"."personId" = $1))
LIMIT
1
-- PersonRepository.getLatestFaceDate
SELECT
MAX("jobStatus"."facesRecognizedAt")::text AS "latestDate"
FROM
"asset_job_status" "jobStatus"

View File

@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import _ from 'lodash';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity';
import {
@ -25,6 +26,7 @@ export class PersonRepository implements IPersonRepository {
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository<AssetJobStatusEntity>,
) {}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
@ -267,4 +269,13 @@ export class PersonRepository implements IPersonRepository {
async getRandomFace(personId: string): Promise<AssetFaceEntity | null> {
return this.assetFaceRepository.findOneBy({ personId });
}
@GenerateSql()
async getLatestFaceDate(): Promise<string | undefined> {
const result: { latestDate?: string } | undefined = await this.jobStatusRepository
.createQueryBuilder('jobStatus')
.select('MAX(jobStatus.facesRecognizedAt)::text', 'latestDate')
.getRawOne();
return result?.latestDate;
}
}

View File

@ -71,7 +71,7 @@ describe(JobService.name, () => {
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
{ name: JobName.CLEAN_OLD_AUDIT_LOGS },
{ name: JobName.USER_SYNC_USAGE },
{ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } },
{ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false, nightly: true } },
{ name: JobName.CLEAN_OLD_SESSION_TOKENS },
]);
});

View File

@ -210,7 +210,7 @@ export class JobService {
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
{ name: JobName.CLEAN_OLD_AUDIT_LOGS },
{ name: JobName.USER_SYNC_USAGE },
{ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } },
{ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false, nightly: true } },
{ name: JobName.CLEAN_OLD_SESSION_TOKENS },
]);
}

View File

@ -3,6 +3,7 @@ import { Colorspace } from 'src/config';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@ -539,6 +540,7 @@ describe(PersonService.name, () => {
await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED);
expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(systemMock.get).toHaveBeenCalled();
expect(systemMock.set).not.toHaveBeenCalled();
});
it('should skip if recognition jobs are already queued', async () => {
@ -546,6 +548,7 @@ describe(PersonService.name, () => {
await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED);
expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(systemMock.set).not.toHaveBeenCalled();
});
it('should queue missing assets', async () => {
@ -564,6 +567,9 @@ describe(PersonService.name, () => {
data: { id: faceStub.face1.id, deferred: false },
},
]);
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, {
lastRun: expect.any(String),
});
});
it('should queue all assets', async () => {
@ -586,6 +592,59 @@ describe(PersonService.name, () => {
data: { id: faceStub.face1.id, deferred: false },
},
]);
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, {
lastRun: expect.any(String),
});
});
it('should run nightly if new face has been added since last run', async () => {
personMock.getLatestFaceDate.mockResolvedValue(new Date().toISOString());
personMock.getAllFaces.mockResolvedValue({
items: [faceStub.face1],
hasNextPage: false,
});
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
personMock.getAll.mockResolvedValue({
items: [],
hasNextPage: false,
});
personMock.getAllFaces.mockResolvedValue({
items: [faceStub.face1],
hasNextPage: false,
});
await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE);
expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce();
expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, {});
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
name: JobName.FACIAL_RECOGNITION,
data: { id: faceStub.face1.id, deferred: false },
},
]);
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, {
lastRun: expect.any(String),
});
});
it('should skip nightly if no new face has been added since last run', async () => {
const lastRun = new Date();
systemMock.get.mockResolvedValue({ lastRun: lastRun.toISOString() });
personMock.getLatestFaceDate.mockResolvedValue(new Date(lastRun.getTime() - 1).toISOString());
personMock.getAllFaces.mockResolvedValue({
items: [faceStub.face1],
hasNextPage: false,
});
await sut.handleQueueRecognizeFaces({ force: true, nightly: true });
expect(systemMock.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE);
expect(personMock.getLatestFaceDate).toHaveBeenCalledOnce();
expect(personMock.getAllFaces).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(systemMock.set).not.toHaveBeenCalled();
});
it('should delete existing people and faces if forced', async () => {

View File

@ -26,6 +26,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
import { PersonPathType } from 'src/entities/move.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
@ -34,6 +35,7 @@ import {
IDeferrableJob,
IEntityJob,
IJobRepository,
INightlyJob,
JOBS_ASSET_PAGINATION_SIZE,
JobItem,
JobName,
@ -67,7 +69,7 @@ export class PersonService {
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@Inject(IPersonRepository) private repository: IPersonRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISearchRepository) private smartInfoRepository: ISearchRepository,
@ -376,13 +378,26 @@ export class PersonService {
return JobStatus.SUCCESS;
}
async handleQueueRecognizeFaces({ force }: IBaseJob): Promise<JobStatus> {
async handleQueueRecognizeFaces({ force, nightly }: INightlyJob): Promise<JobStatus> {
const { machineLearning } = await this.configCore.getConfig({ withCache: false });
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED;
}
await this.jobRepository.waitForQueueCompletion(QueueName.THUMBNAIL_GENERATION, QueueName.FACE_DETECTION);
if (nightly) {
const [state, latestFaceDate] = await Promise.all([
this.systemMetadataRepository.get(SystemMetadataKey.FACIAL_RECOGNITION_STATE),
this.repository.getLatestFaceDate(),
]);
if (state?.lastRun && latestFaceDate && state.lastRun > latestFaceDate) {
this.logger.debug('Skipping facial recognition nightly since no face has been added since the last run');
return JobStatus.SKIPPED;
}
}
const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION);
if (force) {
@ -394,6 +409,7 @@ export class PersonService {
return JobStatus.SKIPPED;
}
const lastRun = new Date().toISOString();
const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.repository.getAllFaces(pagination, { where: force ? undefined : { personId: IsNull() } }),
);
@ -404,6 +420,8 @@ export class PersonService {
);
}
await this.systemMetadataRepository.set(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun });
return JobStatus.SUCCESS;
}

View File

@ -29,5 +29,6 @@ export const newPersonRepositoryMock = (): Mocked<IPersonRepository> => {
getFaceById: vitest.fn(),
getFaceByIdWithAssets: vitest.fn(),
getNumberOfPeople: vitest.fn(),
getLatestFaceDate: vitest.fn(),
};
};