fix(server): tolerate invalid system timezone in cron jobs

Fall back to UTC when the runtime reports an unusable timezone so scheduled jobs cannot abort application bootstrap.

Fixes #1415
This commit is contained in:
neon
2026-09-18 16:07:56 +00:00
parent 6cbf8f5c35
commit c99f9689e8
15 changed files with 91 additions and 22 deletions
@@ -0,0 +1,19 @@
import { SCHEDULE_CRON_OPTIONS } from '@nestjs/schedule/dist/schedule.constants';
import { describe, expect, it } from 'vitest';
import { SYSTEM_TIME_ZONE } from '../utils/timezone.utils';
import { SystemCron } from './system-cron.decorator';
describe('SystemCron', () => {
it('registers cron jobs with the validated system timezone', () => {
class TestJob {
@SystemCron('0 3 * * *')
run(): void {}
}
expect(Reflect.getMetadata(SCHEDULE_CRON_OPTIONS, TestJob.prototype.run)).toEqual({
cronTime: '0 3 * * *',
timeZone: SYSTEM_TIME_ZONE,
});
});
});
@@ -0,0 +1,10 @@
import { Cron } from '@nestjs/schedule';
import type { CronOptions } from '@nestjs/schedule';
import { SYSTEM_TIME_ZONE } from '../utils/timezone.utils';
type SystemCronOptions = Omit<CronOptions, 'timeZone' | 'utcOffset'>;
export function SystemCron(cronTime: Parameters<typeof Cron>[0], options: SystemCronOptions = {}): MethodDecorator {
return Cron(cronTime, { ...options, timeZone: SYSTEM_TIME_ZONE });
}
+25 -2
View File
@@ -1,8 +1,20 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getYearInTimeZone, isDateKey, isValidTimeZone, resolveTimeZone, toDateKeyInTimeZone, toTimeZoneStartOfDay } from './timezone.utils';
import {
getYearInTimeZone,
isDateKey,
isValidTimeZone,
resolveSystemTimeZone,
resolveTimeZone,
toDateKeyInTimeZone,
toTimeZoneStartOfDay,
} from './timezone.utils';
describe('timezone utils', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('validates IANA timezones and rejects invalid values', () => {
expect(isValidTimeZone('UTC')).toBe(true);
expect(isValidTimeZone('America/Denver')).toBe(true);
@@ -17,6 +29,17 @@ describe('timezone utils', () => {
expect(resolveTimeZone('Invalid/Zone', 'America/Denver')).toBe('America/Denver');
});
it('falls back to UTC when the system timezone is unusable', () => {
vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({
locale: 'en-US',
calendar: 'gregory',
numberingSystem: 'latn',
timeZone: 'Etc/Unknown',
});
expect(resolveSystemTimeZone()).toBe('UTC');
});
it('validates date keys with strict calendar checks', () => {
expect(isDateKey('0000-01-01')).toBe(false);
expect(isDateKey('0001-01-01')).toBe(true);
+10
View File
@@ -30,6 +30,16 @@ export function resolveTimeZone(value: unknown, fallback = 'UTC'): string {
return isValidTimeZone(normalized) ? normalized : fallback;
}
export function resolveSystemTimeZone(): string {
try {
return resolveTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
} catch {
return 'UTC';
}
}
export const SYSTEM_TIME_ZONE = resolveSystemTimeZone();
export function isDateKey(value: string): boolean {
const match = DATE_KEY_RE.exec(value);
if (!match) return false;
@@ -1,13 +1,14 @@
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { CronExpression } from '@nestjs/schedule';
import { SystemCron } from '../../common/decorators/system-cron.decorator';
import { AuditService } from './audit.service';
@Injectable()
export class AuditCleanupJob {
constructor(private readonly auditService: AuditService) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
@SystemCron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async runCleanup() {
try {
await this.auditService.runRetentionCleanup();
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { CronExpression } from '@nestjs/schedule';
import { SystemCron } from '../../../common/decorators/system-cron.decorator';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import { OidcCleanupService } from './oidc-cleanup.service';
@@ -10,7 +11,7 @@ export class OidcCleanupJob {
constructor(private readonly oidcCleanupService: OidcCleanupService) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
@SystemCron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async runCleanup(): Promise<void> {
const start = Date.now();
this.logger.log('[auth.oidc_cleanup] [start] - OIDC cleanup started');
@@ -1,8 +1,8 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ACTIVE_BOOK_REQUEST_DOWNLOAD_STATUSES } from '@bookorbit/types';
import type { BookRequestDownloadStatus, BookRequestStatus } from '@bookorbit/types';
import { SystemCron } from '../../../common/decorators/system-cron.decorator';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import type { BookRequestDownloadRow } from '../../../db/schema';
import { BookRequestGateway } from '../book-request.gateway';
@@ -94,7 +94,7 @@ export class DownloadMonitorService implements OnModuleDestroy {
}
/** Direct transfers report once a second; external torrent clients are polled every five. */
@Cron('* * * * * *')
@SystemCron('* * * * * *')
async tick(): Promise<void> {
if (this.running) return;
this.running = true;
@@ -1,5 +1,5 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { CronExpression } from '@nestjs/schedule';
import {
AUTO_SEARCH_BACKOFF_WEEK_MS,
compareByTier,
@@ -20,6 +20,7 @@ import type {
ReleaseCandidateItem,
} from '@bookorbit/types';
import { SystemCron } from '../../../common/decorators/system-cron.decorator';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import type { BookRequestRow } from '../../../db/schema';
import { BOOK_REQUEST_DOWNLOAD_FAILED, BookRequestEventsService } from '../book-request-events.service';
@@ -133,7 +134,7 @@ export class RequestAutomationService implements OnModuleInit {
* conditionally and is safe to re-enter, so a request an approver acts on mid-sweep is left
* alone by the pass that reaches it.
*/
@Cron(CronExpression.EVERY_HOUR)
@SystemCron(CronExpression.EVERY_HOUR)
async sweepUnfulfilled(): Promise<void> {
if (this.sweeping) return;
const startedAt = Date.now();
@@ -1,8 +1,9 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { CronExpression } from '@nestjs/schedule';
import { AuditAction, AuditResource } from '@bookorbit/types';
import type { BookRequestDownloadStatus, BookRequestStatus } from '@bookorbit/types';
import { SystemCron } from '../../../common/decorators/system-cron.decorator';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import { AUDIT_EVENT, AuditEventsService } from '../../audit/audit-events.service';
import { BookRequestGateway } from '../book-request.gateway';
@@ -108,7 +109,7 @@ export class RequestWatchdogService implements OnApplicationBootstrap {
* conditional at the repository: `failDownload` refuses an attempt or a request that settled in
* the meantime, and `cancelAbandoned` refuses a row somebody picked a release for.
*/
@Cron(CronExpression.EVERY_10_MINUTES)
@SystemCron(CronExpression.EVERY_10_MINUTES)
async sweep(): Promise<void> {
try {
await this.failStalled();
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { SystemCron } from '../../../common/decorators/system-cron.decorator';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
import { withDeadline } from '../../../common/utils/with-deadline.utils';
import { IndexerConfigService } from './indexer-config.service';
@@ -37,7 +37,7 @@ export class IndexerKeepaliveService {
private readonly operationLock: IndexerOperationLock,
) {}
@Cron('0 0 */6 * * *')
@SystemCron('0 0 */6 * * *')
async tick(): Promise<void> {
if (this.running) return;
this.running = true;
@@ -1,6 +1,7 @@
import { ConflictException, Logger } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { SYSTEM_TIME_ZONE } from '../../common/utils/timezone.utils';
import { LibraryScanSchedulerService } from './library-scan-scheduler.service';
describe('LibraryScanSchedulerService', () => {
@@ -46,7 +47,7 @@ describe('LibraryScanSchedulerService', () => {
expect(service.syncSchedule(7, '0 4 * * *')).toBe(true);
const job = schedulerRegistry.getCronJob('library-auto-scan:7');
expect(job.nextDate().zoneName).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone);
expect(job.nextDate().zoneName).toBe(SYSTEM_TIME_ZONE);
await job.fireOnTick();
expect(scannerService.startScan).toHaveBeenCalledWith(7, 'schedule');
@@ -3,6 +3,7 @@ import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { SYSTEM_TIME_ZONE } from '../../common/utils/timezone.utils';
import { ScannerService } from '../scanner/scanner.service';
import { LibraryRepository } from './library.repository';
@@ -12,7 +13,7 @@ const LIBRARY_SCAN_JOB_PREFIX = 'library-auto-scan:';
export class LibraryScanSchedulerService implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger(LibraryScanSchedulerService.name);
private readonly registeredJobNames = new Set<string>();
private readonly timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
private readonly timeZone = SYSTEM_TIME_ZONE;
constructor(
private readonly libraryRepo: LibraryRepository,
@@ -1,13 +1,14 @@
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { CronExpression } from '@nestjs/schedule';
import { SystemCron } from '../../common/decorators/system-cron.decorator';
import { NotificationService } from './notification.service';
@Injectable()
export class NotificationCleanupJob {
constructor(private readonly notificationService: NotificationService) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
@SystemCron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async runCleanup() {
try {
await this.notificationService.runRetentionCleanup();
+2 -2
View File
@@ -1,9 +1,9 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { sql } from 'drizzle-orm';
import { eq, isNotNull, lt, or } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { SystemCron } from '../../common/decorators/system-cron.decorator';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { getSanitizedErrorInfo } from './seed-log.util';
@@ -38,7 +38,7 @@ export class CleanupService implements OnApplicationBootstrap {
return result.rows.length === 3;
}
@Cron('0 3 * * *')
@SystemCron('0 3 * * *')
async cleanup() {
const startedAt = Date.now();
const event = 'seed.cleanup_auth_state';
@@ -1,6 +1,6 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { SystemCron } from '../../common/decorators/system-cron.decorator';
import { UserStatisticsService } from './user-statistics.service';
@Injectable()
@@ -13,7 +13,7 @@ export class UserStatisticsAggregationJob implements OnApplicationBootstrap {
await this.recomputeRecent();
}
@Cron('15 * * * *')
@SystemCron('15 * * * *')
async runHourlyAggregation() {
await this.recomputeRecent();
}