Skip to main content

Which pods run jobs (SCHED-002)

The same jar is deployed twice. The API deployment serves requests. A separate jobs deployment runs with SPRING_PROFILES_ACTIVE including scheduler and runs everything on a timer, at replicas: 1.

That split only works if @EnableScheduling appears nowhere without @Profile("scheduler").

Why one ungated copy breaks it everywhere

@EnableScheduling does not enable scheduling for the module that declares it. It registers a context-wide post-processor that scans every bean in the context. In the single jar there is one context, so one ungated copy anywhere switches on the timers for every @Scheduled bean in every module, on every pod.

That is what had happened. NotificationSchedulingConfig gated itself and documented the guarantee it bought — "nothing scheduled exists at all on an API pod, not the post-processor, not the thread pool" — while hl7 and imaging each shipped an ungated @EnableScheduling on a scanned @Configuration. The guarantee was false in the shipped jar, and three ungated jobs ran on every API replica with no lease:

JobModuleWhat running it N times concurrently means
ImagingOutboundSweepConfigimagingduplicate outbound HL7 order messages
CommitmentAgeingSchedulerimagingduplicate storage-commitment ageing
CredentialExpirySchedulerworkforceduplicate expiry processing on every API pod

A second-order effect made it harder to see: workforce's own @EnableScheduling sits on WorkforceApplication, which PlatformApplication excludes, so it contributes nothing to the jar. Its scheduler ran anyway — on the strength of hl7's and imaging's copies. A module's jobs firing because a different module enabled scheduling is not a working design: the answer to "does this module's job run?" lived in another module's file and changed whenever that file did.

The rule

  1. @EnableScheduling goes on a scanned @Configuration, never on a module's *Application class — those are named in PlatformApplication's excludeFilters and contribute nothing to the bundled jar, so the annotation works standalone and silently does nothing in production.
  2. It carries @Profile("scheduler").
  3. Every @Scheduled bean carries @Profile("scheduler") too — belt and braces, so a job cannot be switched on by someone else's enabler.
  4. A job that can run twice must claim its work. The profile decides where jobs run; the claim decides what happens during a rolling restart, when the outgoing and incoming pods overlap.

Each module owns a config saying this for itself: NotificationSchedulingConfig (core), WorkforceSchedulingConfig, Hl7SchedulingConfig, ImagingOutboundSweepConfig.

The gate

ScheduledJobGateTest (app/src/test/java/.../app/) fails the build on an ungated @EnableScheduling or an ungated @Scheduled bean, naming each one.

It lives in app because that is the only module depending on all six — and a context-wide annotation can only be reasoned about at the scope of the whole context. ScheduledJobConventionArchitectureTest already stated this rule, but it analyses com.zhenus.uhp.api.core, so the modules that broke it were exactly the ones it could not see. The module launchers are exempt by name: running a module standalone has no API pod to share a context with.

⚠ Deploying this

These jobs now run only where the scheduler profile is active. If the jobs deployment is not running with it, they stop instead of running everywhere — which is the same condition notification dispatch has always had, so a platform whose notifications are delivering already has that deployment. Verify before rollout, then confirm the imaging outbound sweep is still draining afterwards.