mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user