From 352f6ecc28d5c4bf719f0e9f10119a97310e64be Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Mon, 13 Apr 2026 13:35:46 +0100 Subject: [PATCH] fix(server): add rate limit and deduplication to version check (#27747) --- server/src/enum.ts | 1 + server/src/repositories/job.repository.ts | 3 ++ server/src/services/version.service.spec.ts | 22 ++++++++++++- server/src/services/version.service.ts | 34 ++++++++++++++------- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/server/src/enum.ts b/server/src/enum.ts index 133d9b2f79..de85d24db3 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -707,6 +707,7 @@ export enum DatabaseLock { BackupDatabase = 42, MaintenanceOperation = 621, MemoryCreation = 777, + VersionCheck = 800, } export enum MaintenanceAction { diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index b12accb68e..142d5e3252 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -233,6 +233,9 @@ export class JobRepository { case JobName.FacialRecognitionQueueAll: { return { jobId: JobName.FacialRecognitionQueueAll }; } + case JobName.VersionCheck: { + return { jobId: JobName.VersionCheck }; + } default: { return null; } diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 122d65d32c..2fbe7292fa 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -47,7 +47,8 @@ describe(VersionService.name, () => { expect(mocks.versionHistory.create).not.toHaveBeenCalled(); }); - it('should create a version check cron job', async () => { + it('should create a version check cron job when the database lock is acquired', async () => { + mocks.database.tryLock.mockResolvedValue(true); mocks.versionHistory.getLatest.mockResolvedValue({ id: 'version-1', createdAt: new Date(), @@ -93,6 +94,25 @@ describe(VersionService.name, () => { await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped); }); + it('should skip if the last check was less than 50 seconds ago', async () => { + mocks.systemMetadata.get.mockResolvedValueOnce(null).mockResolvedValueOnce({ + checkedAt: DateTime.utc().minus({ seconds: 30 }).toISO(), + releaseVersion: '1.0.0', + }); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped); + expect(mocks.serverInfo.getLatestRelease).not.toHaveBeenCalled(); + }); + + it('should run if the last check was more than 50 seconds ago', async () => { + mocks.systemMetadata.get.mockResolvedValueOnce(null).mockResolvedValueOnce({ + checkedAt: DateTime.utc().minus({ seconds: 60 }).toISO(), + releaseVersion: '1.0.0', + }); + mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse(serverVersion.toString())); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success); + expect(mocks.serverInfo.getLatestRelease).toHaveBeenCalled(); + }); + it('should run and notify if a new version is available', async () => { mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse('v100.0.0')); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success); diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 75ad93d4a3..ce6d6d7a6f 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -4,7 +4,7 @@ import semver, { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; import { OnEvent, OnJob } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; -import { CronJob, DatabaseLock, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum'; +import { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { VersionCheckMetadata } from 'src/types'; @@ -21,18 +21,21 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re @Injectable() export class VersionService extends BaseService { - @OnEvent({ name: 'AppBootstrap' }) + @OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Microservices] }) async onBootstrap(): Promise { - await this.handleVersionCheck(); + const hasLock = await this.databaseRepository.tryLock(DatabaseLock.VersionCheck); + if (hasLock) { + await this.handleVersionCheck(); - const randomMinute = Math.floor(Math.random() * 60); - const expression = `${randomMinute} * * * *`; - this.logger.debug(`Scheduling version check for cron ${expression}`); - this.cronRepository.create({ - name: CronJob.VersionCheck, - expression, - onTick: () => handlePromiseError(this.handleQueueVersionCheck(), this.logger), - }); + const randomMinute = Math.floor(Math.random() * 60); + const expression = `${randomMinute} * * * *`; + this.logger.debug(`Scheduling version check for cron ${expression}`); + this.cronRepository.create({ + name: CronJob.VersionCheck, + expression, + onTick: () => handlePromiseError(this.handleQueueVersionCheck(), this.logger), + }); + } await this.databaseRepository.withLock(DatabaseLock.VersionHistory, async () => { const previous = await this.versionRepository.getLatest(); @@ -86,6 +89,15 @@ export class VersionService extends BaseService { return JobStatus.Skipped; } + const versionCheck = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState); + if (versionCheck?.checkedAt) { + const lastUpdate = DateTime.fromISO(versionCheck.checkedAt); + const elapsedTime = DateTime.now().diff(lastUpdate).as('seconds'); + if (elapsedTime < 50) { + return JobStatus.Skipped; + } + } + const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease(); const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion };