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:
| Job | Module | What running it N times concurrently means |
|---|---|---|
ImagingOutboundSweepConfig | imaging | duplicate outbound HL7 order messages |
CommitmentAgeingScheduler | imaging | duplicate storage-commitment ageing |
CredentialExpiryScheduler | workforce | duplicate 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
@EnableSchedulinggoes on a scanned@Configuration, never on a module's*Applicationclass — those are named inPlatformApplication'sexcludeFiltersand contribute nothing to the bundled jar, so the annotation works standalone and silently does nothing in production.- It carries
@Profile("scheduler"). - Every
@Scheduledbean carries@Profile("scheduler")too — belt and braces, so a job cannot be switched on by someone else's enabler. - 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.