import { Injectable } from '@nestjs/common'; import path from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { createDatabaseBackup, isFailedDatabaseBackupName, isValidDatabaseRoutineBackupName, UnsupportedPostgresError, } from 'src/utils/database-backups'; import { handlePromiseError } from 'src/utils/misc'; @Injectable() export class BackupService extends BaseService { private backupLock = false; @OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.Microservices] }) async onConfigInit({ newConfig: { backup: { database }, }, }: ArgOf<'ConfigInit'>) { this.backupLock = await this.databaseRepository.tryLock(DatabaseLock.BackupDatabase); if (this.backupLock) { this.cronRepository.create({ name: 'backupDatabase', expression: database.cronExpression, onTick: () => handlePromiseError(this.jobRepository.queue({ name: JobName.DatabaseBackup }), this.logger), start: database.enabled, }); } } @OnEvent({ name: 'ConfigUpdate', server: true }) onConfigUpdate({ newConfig: { backup } }: ArgOf<'ConfigUpdate'>) { if (!this.backupLock) { return; } this.cronRepository.update({ name: 'backupDatabase', expression: backup.database.cronExpression, start: backup.database.enabled, }); } async cleanupDatabaseBackups() { this.logger.debug(`Database Backup Cleanup Started`); const { backup: { database: config }, } = await this.getConfig({ withCache: false }); const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); const files = await this.storageRepository.readdir(backupsFolder); const backups = files .filter((filename) => isValidDatabaseRoutineBackupName(filename)) .toSorted() .toReversed(); const failedBackups = files.filter((filename) => isFailedDatabaseBackupName(filename)); const toDelete = backups.slice(config.keepLastAmount); toDelete.push(...failedBackups); for (const file of toDelete) { await this.storageRepository.unlink(path.join(backupsFolder, file)); } this.logger.debug(`Database Backup Cleanup Finished, deleted ${toDelete.length} backups`); } @OnJob({ name: JobName.DatabaseBackup, queue: QueueName.BackupDatabase }) async handleBackupDatabase(): Promise { try { await createDatabaseBackup(this.backupRepos); } catch (error) { if (error instanceof UnsupportedPostgresError) { return JobStatus.Failed; } throw error; } await this.cleanupDatabaseBackups(); return JobStatus.Success; } private get backupRepos() { return { logger: this.logger, storage: this.storageRepository, config: this.configRepository, process: this.processRepository, database: this.databaseRepository, }; } }