From 0d0ce12622f108425f01a4893f86112050800bbf Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Sat, 5 Sep 2026 09:21:06 -0400 Subject: [PATCH] fix(schedule): register ScheduleModule.forRoot() exactly once ScheduleModule.forRoot() was called in four separate modules (matrix, metrics, external-calendar, atproto-publisher). @nestjs/core does not dedupe dynamic modules by shape: NestContainer builds a ByReferenceModuleOpaqueKeyFactory with keyGenerationStrategy 'random' (injector/container.js:26-32), which keys a dynamic module on the identity of the DynamicModule object literal (by-reference-module-opaque-key-factory.js:17-33, via compiler.js:14-16). Every forRoot() call returns a fresh object literal (@nestjs/schedule schedule.module.js:26-38), so four call sites produced four distinct ScheduleModule instances, each with its own ScheduleExplorer, SchedulerOrchestrator and cronJobs map. Each explorer scans every provider in the application, so each @Cron method was mounted as four independent CronJob instances and fired four times per schedule. forRoot() sets global: true, so a single registration in AppModule covers the whole app; feature modules now import the bare ScheduleModule (matching the existing DatabaseMetricsModule convention) and MatrixModule drops the import entirely because it declares no scheduled methods. Affects all four crons, not just the metrics rollup: the atproto sync retry, the calendar periodic sync and the database pool collector were each running 4x as well. --- src/app.module.ts | 10 ++++++++++ src/atproto-publisher/atproto-publisher.module.ts | 4 ++-- src/database/database-metrics.module.ts | 2 -- src/external-calendar/external-calendar.module.ts | 5 +++-- src/matrix/matrix.module.ts | 2 -- src/metrics/metrics.module.ts | 4 ++-- 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index fc16e241..fa029cc8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -72,6 +72,7 @@ import { UserAtprotoIdentityModule } from './user-atproto-identity/user-atproto- import { AtprotoIdentityModule } from './atproto-identity/atproto-identity.module'; import { MeModule } from './me/me.module'; import { DIDApiModule } from './did-api/did-api.module'; +import { ScheduleModule } from '@nestjs/schedule'; const infrastructureDatabaseModule = TypeOrmModule.forRootAsync({ useClass: TypeOrmConfigService, @@ -123,6 +124,15 @@ const infrastructureDatabaseModule = TypeOrmModule.forRootAsync({ inject: [ConfigService], }), EventEmitterModule.forRoot(), + // The ONLY ScheduleModule registration in the application. forRoot() sets + // global: true and its ScheduleExplorer scans every provider in the + // container, so feature modules import nothing and their @Cron methods are + // still discovered. Two rules follow: calling forRoot() in more than one + // module creates one ScheduleExplorer per call site and fires every cron + // once per call site; and importing the BARE ScheduleModule creates a + // second module whose SchedulerOrchestrator cannot resolve + // SchedulerRegistry (only forRoot() provides it), which fails bootstrap. + ScheduleModule.forRoot(), ThrottlerModule.forRoot([ { ttl: 60000, // 60 seconds diff --git a/src/atproto-publisher/atproto-publisher.module.ts b/src/atproto-publisher/atproto-publisher.module.ts index 80eda38e..3b44c6a9 100644 --- a/src/atproto-publisher/atproto-publisher.module.ts +++ b/src/atproto-publisher/atproto-publisher.module.ts @@ -1,5 +1,4 @@ import { Module, forwardRef } from '@nestjs/common'; -import { ScheduleModule } from '@nestjs/schedule'; import { AtprotoPublisherService } from './atproto-publisher.service'; import { AtprotoSyncScheduler } from './atproto-sync-scheduler'; import { PdsModule } from '../pds/pds.module'; @@ -31,7 +30,8 @@ import { TenantModule } from '../tenant/tenant.module'; */ @Module({ imports: [ - ScheduleModule.forRoot(), + // @Cron methods are discovered by the single global + // ScheduleModule.forRoot() in AppModule; no import is needed here. PdsModule, forwardRef(() => BlueskyModule), AtprotoIdentityModule, diff --git a/src/database/database-metrics.module.ts b/src/database/database-metrics.module.ts index e739b3a5..c442d0c5 100644 --- a/src/database/database-metrics.module.ts +++ b/src/database/database-metrics.module.ts @@ -1,5 +1,4 @@ import { Module } from '@nestjs/common'; -import { ScheduleModule } from '@nestjs/schedule'; import { DatabaseMetricsService } from './database-metrics.service'; import { makeGaugeProvider, @@ -57,7 +56,6 @@ const databaseMetricsProviders = [ ]; @Module({ - imports: [ScheduleModule], providers: [DatabaseMetricsService, ...databaseMetricsProviders], exports: [DatabaseMetricsService, ...databaseMetricsProviders], }) diff --git a/src/external-calendar/external-calendar.module.ts b/src/external-calendar/external-calendar.module.ts index 9a8bd69c..c1e11ff9 100644 --- a/src/external-calendar/external-calendar.module.ts +++ b/src/external-calendar/external-calendar.module.ts @@ -1,5 +1,4 @@ import { Module } from '@nestjs/common'; -import { ScheduleModule } from '@nestjs/schedule'; import { ExternalCalendarService } from './external-calendar.service'; import { ExternalCalendarController } from './external-calendar.controller'; import { AvailabilityController } from './availability.controller'; @@ -10,7 +9,9 @@ import { CalendarSourceModule } from '../calendar-source/calendar-source.module' import { TenantModule } from '../tenant/tenant.module'; @Module({ - imports: [ScheduleModule.forRoot(), TenantModule, CalendarSourceModule], + // @Cron methods are discovered by the single global + // ScheduleModule.forRoot() in AppModule; no import is needed here. + imports: [TenantModule, CalendarSourceModule], controllers: [ExternalCalendarController, AvailabilityController], providers: [ ExternalCalendarService, diff --git a/src/matrix/matrix.module.ts b/src/matrix/matrix.module.ts index fbca1ae9..6e4b72cf 100644 --- a/src/matrix/matrix.module.ts +++ b/src/matrix/matrix.module.ts @@ -1,6 +1,5 @@ import { Module, forwardRef } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { ScheduleModule } from '@nestjs/schedule'; import { TerminusModule } from '@nestjs/terminus'; import { MatrixController } from './matrix.controller'; import { MatrixAppServiceController } from './controllers/matrix-appservice.controller'; @@ -37,7 +36,6 @@ configureMatrixLogging(); imports: [ ConfigModule.forFeature(matrixConfig), TerminusModule, - ScheduleModule.forRoot(), forwardRef(() => UserModule), TenantModule, forwardRef(() => AuthModule), diff --git a/src/metrics/metrics.module.ts b/src/metrics/metrics.module.ts index b149d149..7914e575 100644 --- a/src/metrics/metrics.module.ts +++ b/src/metrics/metrics.module.ts @@ -1,6 +1,5 @@ import { Module, OnModuleInit } from '@nestjs/common'; import { MetricsService } from './metrics.service'; -import { ScheduleModule } from '@nestjs/schedule'; import { PrometheusModule, makeGaugeProvider, @@ -149,7 +148,8 @@ const atprotoHandleMetrics = [ @Module({ imports: [ - ScheduleModule.forRoot(), + // @Cron methods are discovered by the single global + // ScheduleModule.forRoot() in AppModule; no import is needed here. PrometheusModule.register({ defaultMetrics: { enabled: true,