From 13bce80da2ee71c39b8cb79813374479a8ade1c2 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Mon, 8 Sep 2025 20:43:59 +0200 Subject: [PATCH] Implementing ProjectedPersonCacheEntity #1015 --- benchmark/BenchmarkRunner.ts | 12 +- ...coped-Search-Context-and-Search-Sharing.md | 10 +- src/backend/middlewares/PersonMWs.ts | 78 ++--- src/backend/model/database/PersonManager.ts | 301 ++++++++++++++---- src/backend/model/database/SQLConnection.ts | 4 +- .../model/database/enitites/PersonEntry.ts | 15 +- .../enitites/ProjectedPersonCacheEntity.ts | 41 +++ .../model/jobs/jobs/AlbumCoverFillingJob.ts | 4 +- src/common/entities/PersonDTO.ts | 5 +- .../unit/model/sql/PersonManager.spec.ts | 30 +- 10 files changed, 361 insertions(+), 139 deletions(-) create mode 100644 src/backend/model/database/enitites/ProjectedPersonCacheEntity.ts diff --git a/benchmark/BenchmarkRunner.ts b/benchmark/BenchmarkRunner.ts index a48ea817..7ce421c5 100644 --- a/benchmark/BenchmarkRunner.ts +++ b/benchmark/BenchmarkRunner.ts @@ -28,8 +28,9 @@ import { TextSearchQueryMatchTypes, TextSearchQueryTypes } from '../src/common/entities/SearchQueryDTO'; -import {defaultQueryKeywords, QueryKeywords, SearchQueryParser} from '../src/common/SearchQueryParser'; +import {defaultQueryKeywords, SearchQueryParser} from '../src/common/SearchQueryParser'; import {ParentDirectoryDTO} from '../src/common/entities/DirectoryDTO'; +import {SessionManager} from '../src/backend/model/database/SessionManager'; import {SessionContext} from '../src/backend/model/SessionContext'; @@ -79,9 +80,11 @@ export class BenchmarkRunner { query: {}, session: {} }; + private session: SessionContext; constructor(public RUNS: number) { Config.Users.authenticationRequired = false; + this.session = {user: {projectionKey: SessionManager.NO_PROJECTION_KEY}} as SessionContext; } async bmSaveDirectory(): Promise { @@ -150,7 +153,7 @@ export class BenchmarkRunner { await this.setupDB(); const queryParser = new SearchQueryParser(defaultQueryKeywords); - const names = (await ObjectManagers.getInstance().PersonManager.getAll()).sort((a, b) => b.count - a.count); + const names = (await ObjectManagers.getInstance().PersonManager.getAll(this.session)).sort((a, b) => b.count - a.count); const queries: { query: SearchQueryDTO, description: string }[] = TextSearchQueryTypes.map(t => { const q = { type: t, text: 'a' @@ -255,7 +258,7 @@ export class BenchmarkRunner { ', videos: ' + await gm.countVideos() + ', diskUsage : ' + Utils.renderDataSize(await gm.countMediaSize()) + ', persons : ' + await pm.countFaces() + - ', unique persons (faces): ' + (await pm.getAll()).length; + ', unique persons (faces): ' + (await pm.getAll(this.session)).length; } @@ -269,8 +272,7 @@ export class BenchmarkRunner { const queue = ['/']; while (queue.length > 0) { const dirPath = queue.shift(); - const session = new SessionContext(); - const dir = await gm.listDirectory(session,dirPath); + const dir = await gm.listDirectory(this.session, dirPath); dir.directories.forEach((d): number => queue.push(path.join(d.path + d.name))); if (biggest < dir.media.length) { biggestPath = path.join(dir.path + dir.name); diff --git a/docs/designs/DESIGN-Scoped-Search-Context-and-Search-Sharing.md b/docs/designs/DESIGN-Scoped-Search-Context-and-Search-Sharing.md index df813fe3..1987bb57 100644 --- a/docs/designs/DESIGN-Scoped-Search-Context-and-Search-Sharing.md +++ b/docs/designs/DESIGN-Scoped-Search-Context-and-Search-Sharing.md @@ -4,7 +4,7 @@ Author: Junie (JetBrains autonomous programmer) Date: 2025-08-13 Status: Updated after maintainer clarifications -Terminology: The project uses the term projection. Earlier drafts and filenames may still say “scoped”; whenever you see “scoped,” read it as “projection.” Benchmark file names keep “scoped” for historical reasons. +Terminology: The project uses the term projection. Earlier drafts and filenames may still say “projected”; whenever you see “projected,” read it as “projection.” Benchmark file names keep “projected” for historical reasons. ## Overview This document proposes a design to support: @@ -302,11 +302,11 @@ Purpose - Variant B (alternative): separate ProjectionKey table (unique key hash), projection tables reference it via projectionId (FK ON DELETE CASCADE). Benchmark harness -- Location: benchmark\\scoped-cache-bench.ts (historical filename) -- Run: npm run bench:scoped-cache +- Location: benchmark\\projected-cache-bench.ts (historical filename) +- Run: npm run bench:projected-cache - Params (env vars): BENCH_SCOPES, BENCH_DIRS, BENCH_PERSONS, BENCH_LOOKUPS. Example (Windows cmd): - - set BENCH_SCOPES=20&&set BENCH_DIRS=120&&set BENCH_PERSONS=90&&set BENCH_LOOKUPS=1000&&npm run bench:scoped-cache -- DB: temporary SQLite file at db\\scoped_bench.sqlite (does not touch app DB) + - set BENCH_SCOPES=20&&set BENCH_DIRS=120&&set BENCH_PERSONS=90&&set BENCH_LOOKUPS=1000&&npm run bench:projected-cache +- DB: temporary SQLite file at db\\projected_bench.sqlite (does not touch app DB) - Measures: upsert throughput for dir/person tables, lookup latency by (projection, directory), cascade delete performance, file size delta. Sample results (SQLite, N=20, D=120, P=90, lookups=1000) diff --git a/src/backend/middlewares/PersonMWs.ts b/src/backend/middlewares/PersonMWs.ts index 65bc3eee..639a868a 100644 --- a/src/backend/middlewares/PersonMWs.ts +++ b/src/backend/middlewares/PersonMWs.ts @@ -7,9 +7,9 @@ import {PersonEntry} from '../model/database/enitites/PersonEntry'; export class PersonMWs { public static async updatePerson( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { if (!req.params['name']) { return next(); @@ -17,72 +17,72 @@ export class PersonMWs { try { req.resultPipe = - await ObjectManagers.getInstance().PersonManager.updatePerson( - req.params['name'] as string, - req.body as PersonDTO - ); + await ObjectManagers.getInstance().PersonManager.updatePerson( + req.params['name'] as string, + req.body as PersonDTO + ); return next(); } catch (err) { return next( - new ErrorDTO( - ErrorCodes.PERSON_ERROR, - 'Error during updating a person', - err - ) + new ErrorDTO( + ErrorCodes.PERSON_ERROR, + 'Error during updating a person', + err + ) ); } } public static async getPerson( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { if (!req.params['name']) { return next(); } try { - req.resultPipe = await ObjectManagers.getInstance().PersonManager.get( - req.params['name'] as string + req.resultPipe = await ObjectManagers.getInstance().PersonManager.get(req.session.context, + req.params['name'] as string ); return next(); } catch (err) { return next( - new ErrorDTO( - ErrorCodes.PERSON_ERROR, - 'Error during updating a person', - err - ) + new ErrorDTO( + ErrorCodes.PERSON_ERROR, + 'Error during updating a person', + err + ) ); } } public static async listPersons( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { try { req.resultPipe = - await ObjectManagers.getInstance().PersonManager.getAll(); + await ObjectManagers.getInstance().PersonManager.getAll(req.session.context); return next(); } catch (err) { return next( - new ErrorDTO( - ErrorCodes.PERSON_ERROR, - 'Error during listing persons', - err - ) + new ErrorDTO( + ErrorCodes.PERSON_ERROR, + 'Error during listing persons', + err + ) ); } } public static async cleanUpPersonResults( - req: Request, - res: Response, - next: NextFunction + req: Request, + res: Response, + next: NextFunction ): Promise { if (!req.resultPipe) { return next(); @@ -96,11 +96,11 @@ export class PersonMWs { return next(); } catch (err) { return next( - new ErrorDTO( - ErrorCodes.PERSON_ERROR, - 'Error during removing sample photo from all persons', - err - ) + new ErrorDTO( + ErrorCodes.PERSON_ERROR, + 'Error during removing sample photo from all persons', + err + ) ); } } diff --git a/src/backend/model/database/PersonManager.ts b/src/backend/model/database/PersonManager.ts index fe3f05ec..56e8d88c 100644 --- a/src/backend/model/database/PersonManager.ts +++ b/src/backend/model/database/PersonManager.ts @@ -5,57 +5,181 @@ import {Logger} from '../../Logger'; import {SQL_COLLATE} from './enitites/EntityUtils'; import {PersonJunctionTable} from './enitites/PersonJunctionTable'; import {IObjectManager} from './IObjectManager'; +import {ParentDirectoryDTO} from '../../../common/entities/DirectoryDTO'; +import {ProjectedPersonCacheEntity} from './enitites/ProjectedPersonCacheEntity'; +import {SessionContext} from '../SessionContext'; const LOG_TAG = '[PersonManager]'; export class PersonManager implements IObjectManager { - persons: PersonEntry[] = null; - /** - * Person table contains denormalized data that needs to update when isDBValid = false - */ - private isDBValid = false; + personsCache: Record = null; private static async updateCounts(): Promise { const connection = await SQLConnection.getConnection(); await connection.query( - 'UPDATE person_entry SET count = ' + - ' (SELECT COUNT(1) FROM person_junction_table WHERE person_junction_table.personId = person_entry.id)' + 'UPDATE person_entry SET count = ' + + ' (SELECT COUNT(1) FROM person_junction_table WHERE person_junction_table.personId = person_entry.id)' ); // remove persons without photo await connection - .createQueryBuilder() - .delete() - .from(PersonEntry) - .where('count = 0') - .execute(); + .createQueryBuilder() + .delete() + .from(PersonEntry) + .where('count = 0') + .execute(); } private static async updateSamplePhotos(): Promise { const connection = await SQLConnection.getConnection(); await connection.query( - 'update person_entry set sampleRegionId = ' + - '(Select person_junction_table.id from media_entity ' + - 'left join person_junction_table on media_entity.id = person_junction_table.mediaId ' + - 'where person_junction_table.personId=person_entry.id ' + - 'order by media_entity.metadataRating desc, ' + - 'media_entity.metadataCreationdate desc ' + - 'limit 1)' + 'update person_entry set sampleRegionId = ' + + '(Select person_junction_table.id from media_entity ' + + 'left join person_junction_table on media_entity.id = person_junction_table.mediaId ' + + 'where person_junction_table.personId=person_entry.id ' + + 'order by media_entity.metadataRating desc, ' + + 'media_entity.metadataCreationdate desc ' + + 'limit 1)' ); } + private async updateCacheForAll(session: SessionContext): Promise { + const connection = await SQLConnection.getConnection(); + const projectionKey = session.user.projectionKey; + + // Get all persons that need cache updates (either missing or invalid) + const personsNeedingUpdate = await connection + .getRepository(PersonEntry) + .createQueryBuilder('person') + .leftJoin( + ProjectedPersonCacheEntity, + 'cache', + 'cache.person = person.id AND cache.projectionKey = :projectionKey', + {projectionKey} + ) + .where('cache.id IS NULL OR cache.valid = false') + .getMany(); + + if (personsNeedingUpdate.length === 0) { + return; + } + + // Process persons in batches to avoid memory issues + const batchSize = 200; + for (let i = 0; i < personsNeedingUpdate.length; i += batchSize) { + const batch = personsNeedingUpdate.slice(i, i + batchSize); + const personIds = batch.map(p => p.id); + + // Build base query for person junction table with projection constraints + const baseQb = connection + .getRepository(PersonJunctionTable) + .createQueryBuilder('pjt') + .innerJoin('pjt.media', 'media') + .where('pjt.person IN (:...personIds)', {personIds}); + + // Apply projection query if it exists + if (session.projectionQuery) { + baseQb.andWhere(session.projectionQuery); + } + + // Compute counts per person + const countResults = await baseQb + .clone() + .select(['pjt.person as personId', 'COUNT(*) as count']) + .groupBy('pjt.person') + .getRawMany(); + + // Compute sample regions per person (best rated/newest photo) + // Use individual queries per person to ensure compatibility with older SQLite versions + const topSamples: Record = {}; + for (const personId of personIds) { + const sampleQb = connection + .getRepository(PersonJunctionTable) + .createQueryBuilder('pjt') + .innerJoin('pjt.media', 'media') + .where('pjt.person = :personId', {personId}); + + // Apply projection query if it exists + if (session.projectionQuery) { + sampleQb.andWhere(session.projectionQuery); + } + + const sampleResult = await sampleQb + .select('pjt.id') + .orderBy('media.metadataRating', 'DESC') + .addOrderBy('media.metadataCreationdate', 'DESC') + .limit(1) + .getOne(); + + if (sampleResult) { + topSamples[personId] = sampleResult.id; + } + } + + // Build count lookup + const counts = countResults.reduce((acc: Record, r: any) => { + acc[parseInt(r.personId, 10)] = parseInt(r.count, 10); + return acc; + }, {}); + + // Batch upsert cache entries to minimize DB transactions + const cacheRepo = connection.getRepository(ProjectedPersonCacheEntity); + const cacheEntriesToSave: ProjectedPersonCacheEntity[] = []; + + // Get existing cache entries for this batch + const existingEntries = await cacheRepo + .createQueryBuilder('cache') + .where('cache.projectionKey = :projectionKey', {projectionKey}) + .andWhere('cache.person IN (:...personIds)', {personIds}) + .getMany(); + + const existingByPersonId = existingEntries.reduce((acc, entry) => { + acc[entry.person.id] = entry; + return acc; + }, {} as Record); + + for (const person of batch) { + const count = counts[person.id] || 0; + const sampleRegionId = topSamples[person.id] || null; + + let cacheEntry = existingByPersonId[person.id]; + if (cacheEntry) { + // Update existing entry + cacheEntry.count = count; + cacheEntry.sampleRegion = sampleRegionId ? {id: sampleRegionId} as any : null; + cacheEntry.valid = true; + } else { + // Create new entry + cacheEntry = new ProjectedPersonCacheEntity(); + cacheEntry.projectionKey = projectionKey; + cacheEntry.person = {id: person.id} as any; + cacheEntry.count = count; + cacheEntry.sampleRegion = sampleRegionId ? {id: sampleRegionId} as any : null; + cacheEntry.valid = true; + } + + cacheEntriesToSave.push(cacheEntry); + } + + // Batch save all cache entries for this batch + if (cacheEntriesToSave.length > 0) { + await cacheRepo.save(cacheEntriesToSave); + } + } + } + + async updatePerson( - name: string, - partialPerson: PersonDTO + name: string, + partialPerson: PersonDTO ): Promise { - this.isDBValid = false; const connection = await SQLConnection.getConnection(); const repository = connection.getRepository(PersonEntry); const person = await repository - .createQueryBuilder('person') - .limit(1) - .where('person.name LIKE :name COLLATE ' + SQL_COLLATE, {name}) - .getOne(); + .createQueryBuilder('person') + .limit(1) + .where('person.name LIKE :name COLLATE ' + SQL_COLLATE, {name}) + .getOne(); if (typeof partialPerson.name !== 'undefined') { person.name = partialPerson.name; @@ -65,16 +189,16 @@ export class PersonManager implements IObjectManager { } await repository.save(person); - await this.loadAll(); + await this.resetMemoryCache(); return person; } - public async getAll(): Promise { - if (this.persons === null) { - await this.loadAll(); + public async getAll(session: SessionContext): Promise { + if (!this.personsCache?.[session.user.projectionKey]) { + await this.loadAll(session); } - return this.persons; + return this.personsCache[session.user.projectionKey]; } /** @@ -83,20 +207,20 @@ export class PersonManager implements IObjectManager { public async countFaces(): Promise { const connection = await SQLConnection.getConnection(); return await connection - .getRepository(PersonJunctionTable) - .createQueryBuilder('personJunction') - .getCount(); + .getRepository(PersonJunctionTable) + .createQueryBuilder('personJunction') + .getCount(); } - public async get(name: string): Promise { - if (this.persons === null) { - await this.loadAll(); + public async get(session: SessionContext, name: string): Promise { + if (!this.personsCache?.[session.user.projectionKey]) { + await this.loadAll(session); } - return this.persons.find((p): boolean => p.name === name); + return this.personsCache[session.user.projectionKey].find((p): boolean => p.name === name); } public async saveAll( - persons: { name: string; mediaId: number }[] + persons: { name: string; mediaId: number }[] ): Promise { const toSave: { name: string; mediaId: number }[] = []; const connection = await SQLConnection.getConnection(); @@ -107,7 +231,7 @@ export class PersonManager implements IObjectManager { // filter already existing persons for (const personToSave of persons) { const person = savedPersons.find( - (p): boolean => p.name === personToSave.name + (p): boolean => p.name === personToSave.name ); if (!person) { toSave.push(personToSave); @@ -119,50 +243,91 @@ export class PersonManager implements IObjectManager { const saving = toSave.slice(i * 200, (i + 1) * 200); // saving person const inserted = await personRepository.insert( - saving.map((p) => ({name: p.name})) + saving.map((p) => ({name: p.name})) ); // saving junction table const junctionTable = inserted.identifiers.map((idObj, j) => ({person: idObj, media: {id: saving[j].mediaId}})); await personJunction.insert(junctionTable); } } - this.isDBValid = false; } - public async onNewDataVersion(): Promise { - await this.resetPreviews(); + public async onNewDataVersion(changedDir?: ParentDirectoryDTO): Promise { + await this.invalidateCacheForDir(changedDir); + } + + protected async invalidateCacheForDir(changedDir?: ParentDirectoryDTO): Promise { + if (!changedDir || !changedDir.id) { + return; + } + try { + const connection = await SQLConnection.getConnection(); + + // Collect affected person ids from this directory (non-recursive) + const rows = await connection.getRepository(PersonJunctionTable) + .createQueryBuilder('pjt') + .innerJoin('pjt.media', 'm') + .innerJoin('m.directory', 'd') + .innerJoin('pjt.person', 'person') + .where('d.id = :dirId', {dirId: changedDir.id}) + .select('DISTINCT person.id', 'pid') + .getRawMany(); + + const pids = rows.map((r: any) => parseInt(r.pid, 10)).filter((n: number) => !isNaN(n)); + if (pids.length === 0) { + return; + } + + // Mark projection-aware person cache entries invalid for these persons + await connection.getRepository(ProjectedPersonCacheEntity) + .createQueryBuilder() + .update() + .set({valid: false}) + .where('personId IN (:...pids)', {pids}) + .execute(); + this.personsCache = null; + + } catch (err) { + Logger.warn(LOG_TAG, 'Failed to invalidate projected person cache on data change', err); + } + } + + private resetMemoryCache(): void { + this.personsCache = null; } public async resetPreviews(): Promise { - this.persons = null; - this.isDBValid = false; + const connection = await SQLConnection.getConnection(); + await connection.getRepository(ProjectedPersonCacheEntity) + .createQueryBuilder() + .update() + .set({valid: false}) + .execute(); + this.resetMemoryCache(); } - private async loadAll(): Promise { - await this.updateDerivedValues(); + private async loadAll(session: SessionContext): Promise { + await this.updateCacheForAll(session); const connection = await SQLConnection.getConnection(); const personRepository = connection.getRepository(PersonEntry); - this.persons = await personRepository.find({ - relations: [ + this.personsCache = this.personsCache || {}; + this.personsCache[session.user.projectionKey] = await personRepository + .createQueryBuilder('person') + .leftJoin('person.cache', 'cache', 'cache.projectionKey = :pk AND cache.valid = 1', {pk: session.user.projectionKey}) + .leftJoin('cache.sampleRegion', 'sampleRegion') + .leftJoin('sampleRegion.media', 'media') + .leftJoin('media.directory', 'directory') + .select([ + 'person.id', + 'person.name', + 'person.isFavourite', + 'cache.count', 'sampleRegion', - 'sampleRegion.media', - 'sampleRegion.media.directory', - 'sampleRegion.media.metadata', - ], - }); + 'media.name', + 'directory.path', + 'directory.name' + ]) + .getMany(); } - /** - * Person table contains derived, denormalized data for faster select, this needs to be updated after data change - * @private - */ - private async updateDerivedValues(): Promise { - if (this.isDBValid === true) { - return; - } - Logger.debug(LOG_TAG, 'Updating derived persons data'); - await PersonManager.updateCounts(); - await PersonManager.updateSamplePhotos(); - this.isDBValid = false; - } } diff --git a/src/backend/model/database/SQLConnection.ts b/src/backend/model/database/SQLConnection.ts index 9d7e2825..243c19d6 100644 --- a/src/backend/model/database/SQLConnection.ts +++ b/src/backend/model/database/SQLConnection.ts @@ -24,6 +24,7 @@ import {NotificationManager} from '../NotifocationManager'; import {PersonJunctionTable} from './enitites/PersonJunctionTable'; import {MDFileEntity} from './enitites/MDFileEntity'; import { ProjectedDirectoryCacheEntity } from './enitites/ProjectedDirectoryCacheEntity'; +import { ProjectedPersonCacheEntity } from './enitites/ProjectedPersonCacheEntity'; const LOG_TAG = '[SQLConnection]'; @@ -61,7 +62,8 @@ export class SQLConnection { SavedSearchEntity, VersionEntity, // projection-aware cache entries - ProjectedDirectoryCacheEntity + ProjectedDirectoryCacheEntity, + ProjectedPersonCacheEntity ]; private static connection: Connection = null; diff --git a/src/backend/model/database/enitites/PersonEntry.ts b/src/backend/model/database/enitites/PersonEntry.ts index 8512c072..ce9aac04 100644 --- a/src/backend/model/database/enitites/PersonEntry.ts +++ b/src/backend/model/database/enitites/PersonEntry.ts @@ -1,7 +1,8 @@ -import {Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn, Unique,} from 'typeorm'; +import {Column, Entity, Index, OneToMany, PrimaryGeneratedColumn, Unique,} from 'typeorm'; import {PersonJunctionTable} from './PersonJunctionTable'; import {columnCharsetCS} from './EntityUtils'; import {PersonDTO} from '../../../../common/entities/PersonDTO'; +import {ProjectedPersonCacheEntity} from './ProjectedPersonCacheEntity'; @Entity() @Unique(['name']) @@ -16,20 +17,16 @@ export class PersonEntry implements PersonDTO { }) name: string; - @Column('int', {unsigned: true, default: 0}) - count: number; - @Column({default: false}) isFavourite: boolean; @OneToMany(() => PersonJunctionTable, (junctionTable) => junctionTable.person) public faces: PersonJunctionTable[]; - @ManyToOne(() => PersonJunctionTable, { - onDelete: 'SET NULL', - nullable: true, - }) - sampleRegion: PersonJunctionTable; + + @OneToMany(() => ProjectedPersonCacheEntity, (ppc) => ppc.person) + public cache: ProjectedPersonCacheEntity; + // does not store in the DB, temporal field missingThumbnail?: boolean; diff --git a/src/backend/model/database/enitites/ProjectedPersonCacheEntity.ts b/src/backend/model/database/enitites/ProjectedPersonCacheEntity.ts new file mode 100644 index 00000000..92d33155 --- /dev/null +++ b/src/backend/model/database/enitites/ProjectedPersonCacheEntity.ts @@ -0,0 +1,41 @@ +import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; +import { PersonEntry } from './PersonEntry'; +import { PersonJunctionTable } from './PersonJunctionTable'; +import {PersonCacheDTO} from '../../../../common/entities/PersonDTO'; + +// Projection-aware cache for persons, analogous to ProjectedDirectoryCacheEntity +// Stores per-projection aggregates for a given person +@Entity() +@Unique(['projectionKey', 'person']) +export class ProjectedPersonCacheEntity implements PersonCacheDTO{ + @PrimaryGeneratedColumn({ unsigned: true }) + id: number; + + // hash key of the projection (built by SessionManager) + @Index() + @Column({ type: 'text', select: false }) // not needed in payloads; used to select the right cache per session + projectionKey: string; + + // the person this cache row is about + @Index() + @ManyToOne(() => PersonEntry, { + onDelete: 'CASCADE', + nullable: false, + }) + person: PersonEntry; + + // number of visible face regions for this person under the projection + @Column('int', { unsigned: true, default: 0 }) + count: number; + + // a PersonJunctionTable row id under the projection chosen by ranking + @ManyToOne(() => PersonJunctionTable, { + onDelete: 'SET NULL', + nullable: true, + }) + sampleRegion: PersonJunctionTable; + + // if false, sample (or other aggregates) need recomputation + @Column({ type: 'boolean', default: true }) + valid: boolean; +} diff --git a/src/backend/model/jobs/jobs/AlbumCoverFillingJob.ts b/src/backend/model/jobs/jobs/AlbumCoverFillingJob.ts index 94553453..ae803490 100644 --- a/src/backend/model/jobs/jobs/AlbumCoverFillingJob.ts +++ b/src/backend/model/jobs/jobs/AlbumCoverFillingJob.ts @@ -53,7 +53,9 @@ export class AlbumCoverFillingJob extends Job { } private async stepPersonsPreview(): Promise { - await ObjectManagers.getInstance().PersonManager.getAll(); + for (const session of this.availableSessions) { + await ObjectManagers.getInstance().PersonManager.getAll(session); + } this.Progress.log('Updating Persons preview'); this.Progress.Processed++; return false; diff --git a/src/common/entities/PersonDTO.ts b/src/common/entities/PersonDTO.ts index 822ecad2..b0f6da3c 100644 --- a/src/common/entities/PersonDTO.ts +++ b/src/common/entities/PersonDTO.ts @@ -1,9 +1,12 @@ export interface PersonDTO { id: number; name: string; - count: number; missingThumbnail?: boolean; isFavourite: boolean; } +export interface PersonCacheDTO { + count: number; +} + diff --git a/test/backend/unit/model/sql/PersonManager.spec.ts b/test/backend/unit/model/sql/PersonManager.spec.ts index 67614c61..271803ea 100644 --- a/test/backend/unit/model/sql/PersonManager.spec.ts +++ b/test/backend/unit/model/sql/PersonManager.spec.ts @@ -46,11 +46,22 @@ describe('PersonManager', (sqlHelper: DBTestHelper) => { p2 = (dir.media.filter(m => m.name === p2.name)[0] as any); pFaceLess = (dir.media[2] as any); v = (dir.media.filter(m => m.name === v.name)[0] as any); - savedPerson = await (await SQLConnection.getConnection()).getRepository(PersonEntry).find({ - relations: ['sampleRegion', - 'sampleRegion.media', - 'sampleRegion.media.directory'] - }); + savedPerson = await (await SQLConnection.getConnection()).getRepository(PersonEntry).createQueryBuilder('person') + .leftJoin('person.cache', 'cache', 'cache.projectionKey = :pk AND cache.valid = 1', {pk: DBTestHelper.defaultSession.user.projectionKey}) + .leftJoin('cache.sampleRegion', 'sampleRegion') + .leftJoin('sampleRegion.media', 'media') + .leftJoin('media.directory', 'directory') + .select([ + 'person.id', + 'person.name', + 'person.isFavourite', + 'cache.count', + 'sampleRegion', + 'media.name', + 'directory.path', + 'directory.name' + ]) + .getMany(); }; @@ -67,13 +78,12 @@ describe('PersonManager', (sqlHelper: DBTestHelper) => { const pm = new PersonManager(); const person = Utils.clone(savedPerson[0]); - const selected = Utils.clone(await pm.get('Boba Fett')); - delete selected.sampleRegion; - delete person.sampleRegion; - person.count = 1; + const selected = Utils.clone(await pm.get(DBTestHelper.defaultSession, 'Boba Fett')); + expect(selected.cache).to.be.not.undefined; + delete selected.cache; + delete person.cache; expect(selected).to.deep.equal(person); - expect((await pm.get('Boba Fett') as PersonEntry).sampleRegion.media.name).to.deep.equal(p.name); });