@lenne.tech/nest-server 11.32.4 → 11.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/architecture.md +16 -0
- package/.claude/rules/configurable-features.md +11 -3
- package/.claude/rules/testing.md +101 -1
- package/CLAUDE.md +5 -0
- package/FRAMEWORK-API.md +58 -3
- package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
- package/dist/core/common/helpers/content-disposition.helper.js +14 -0
- package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
- package/dist/core/common/helpers/file.helper.d.ts +7 -0
- package/dist/core/common/helpers/file.helper.js +21 -4
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
- package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
- package/dist/core/common/helpers/project-name.helper.js +38 -0
- package/dist/core/common/helpers/project-name.helper.js.map +1 -0
- package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
- package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
- package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
- package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
- package/dist/core/common/services/core-cron-jobs.service.js +278 -27
- package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
- package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
- package/dist/core/common/services/core-redis-pubsub.js +112 -0
- package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
- package/dist/core/common/services/core-redis.service.d.ts +25 -0
- package/dist/core/common/services/core-redis.service.js +154 -0
- package/dist/core/common/services/core-redis.service.js.map +1 -0
- package/dist/core/common/services/core-s3.service.d.ts +51 -0
- package/dist/core/common/services/core-s3.service.js +261 -0
- package/dist/core/common/services/core-s3.service.js.map +1 -0
- package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
- package/dist/core/common/services/rate-limit-store.d.ts +61 -0
- package/dist/core/common/services/rate-limit-store.js +235 -0
- package/dist/core/common/services/rate-limit-store.js.map +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
- package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
- package/dist/core/modules/ai/services/core-ai.service.js +24 -20
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +4 -1
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/file/core-file.controller.d.ts +11 -2
- package/dist/core/modules/file/core-file.controller.js +52 -15
- package/dist/core/modules/file/core-file.controller.js.map +1 -1
- package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
- package/dist/core/modules/file/core-file.resolver.js +23 -16
- package/dist/core/modules/file/core-file.resolver.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +41 -7
- package/dist/core/modules/file/core-file.service.js +227 -7
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
- package/dist/core/modules/file/file-metadata.helper.js +33 -0
- package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
- package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
- package/dist/core/modules/file/file-roles.helper.js +53 -0
- package/dist/core/modules/file/file-roles.helper.js.map +1 -0
- package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
- package/dist/core/modules/file/file-storage.helper.js +65 -0
- package/dist/core/modules/file/file-storage.helper.js.map +1 -0
- package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
- package/dist/core/modules/file/filesystem-file.helper.js +77 -0
- package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
- package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
- package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
- package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
- package/dist/core/modules/file/s3-file.helper.js +81 -0
- package/dist/core/modules/file/s3-file.helper.js.map +1 -0
- package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
- package/dist/core/modules/hub/core-hub.controller.js +24 -22
- package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
- package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
- package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
- package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
- package/dist/core/modules/hub/hub-buffer.js +101 -0
- package/dist/core/modules/hub/hub-buffer.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
- package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
- package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
- package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
- package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
- package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
- package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
- package/dist/core/modules/migrate/migration-runner.js +4 -0
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
- package/dist/core/modules/migrate/mongo-state-store.js +98 -9
- package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
- package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
- package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.module.js +3 -4
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
- package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
- package/dist/core/modules/tus/core-tus.controller.js +29 -3
- package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
- package/dist/core/modules/tus/core-tus.service.js +182 -28
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
- package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
- package/dist/core/modules/tus/tus-redis-locker.js +87 -0
- package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
- package/dist/core/modules/tus/tus.module.d.ts +1 -0
- package/dist/core/modules/tus/tus.module.js +30 -4
- package/dist/core/modules/tus/tus.module.js.map +1 -1
- package/dist/core.module.js +18 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/server/modules/file/file.controller.js.map +1 -1
- package/dist/server/modules/file/file.resolver.js +1 -12
- package/dist/server/modules/file/file.resolver.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +5 -1
- package/dist/server/modules/file/file.service.js +12 -3
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/server/modules/user/avatar.controller.d.ts +5 -1
- package/dist/server/modules/user/avatar.controller.js +26 -10
- package/dist/server/modules/user/avatar.controller.js.map +1 -1
- package/dist/server/modules/user/user.model.js +3 -2
- package/dist/server/modules/user/user.model.js.map +1 -1
- package/dist/server/modules/user/user.module.js +6 -2
- package/dist/server/modules/user/user.module.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +1 -1
- package/dist/server/modules/user/user.service.js +6 -13
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +57 -8
- package/docs/security-overrides.md +21 -3
- package/migration-guides/11.32.3-to-11.32.4.md +15 -3
- package/migration-guides/11.32.x-to-11.33.x.md +1551 -0
- package/package.json +74 -42
- package/src/core/common/helpers/content-disposition.helper.ts +90 -0
- package/src/core/common/helpers/file.helper.ts +53 -10
- package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
- package/src/core/common/helpers/project-name.helper.ts +71 -0
- package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
- package/src/core/common/interfaces/server-options.interface.ts +439 -2
- package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
- package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
- package/src/core/common/services/core-cron-jobs.service.ts +589 -47
- package/src/core/common/services/core-redis-pubsub.ts +183 -0
- package/src/core/common/services/core-redis.service.ts +249 -0
- package/src/core/common/services/core-s3.service.ts +465 -0
- package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
- package/src/core/common/services/rate-limit-store.ts +492 -0
- package/src/core/modules/ai/README.md +25 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
- package/src/core/modules/ai/services/core-ai.service.ts +33 -24
- package/src/core/modules/auth/core-auth.module.ts +7 -1
- package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
- package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
- package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +108 -0
- package/src/core/modules/file/README.md +277 -53
- package/src/core/modules/file/core-file.controller.ts +168 -12
- package/src/core/modules/file/core-file.resolver.ts +46 -12
- package/src/core/modules/file/core-file.service.ts +482 -16
- package/src/core/modules/file/file-metadata.helper.ts +142 -0
- package/src/core/modules/file/file-roles.helper.ts +110 -0
- package/src/core/modules/file/file-storage.helper.ts +163 -0
- package/src/core/modules/file/filesystem-file.helper.ts +184 -0
- package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
- package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
- package/src/core/modules/file/s3-file.helper.ts +209 -0
- package/src/core/modules/hub/README.md +48 -0
- package/src/core/modules/hub/core-hub.controller.ts +18 -16
- package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
- package/src/core/modules/hub/hub-buffer.ts +198 -0
- package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
- package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
- package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
- package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
- package/src/core/modules/migrate/README.md +33 -6
- package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
- package/src/core/modules/migrate/migration-runner.ts +10 -1
- package/src/core/modules/migrate/mongo-state-store.ts +179 -11
- package/src/core/modules/system-setup/README.md +7 -2
- package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
- package/src/core/modules/tenant/README.md +21 -1
- package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
- package/src/core/modules/tenant/core-tenant.module.ts +8 -4
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
- package/src/core/modules/tus/README.md +35 -2
- package/src/core/modules/tus/core-tus.controller.ts +64 -12
- package/src/core/modules/tus/core-tus.service.ts +358 -34
- package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
- package/src/core/modules/tus/tus-redis-locker.ts +139 -0
- package/src/core/modules/tus/tus.module.ts +78 -6
- package/src/core.module.ts +26 -1
- package/src/index.ts +10 -0
- package/src/main.ts +7 -1
- package/src/server/modules/file/file.controller.ts +7 -3
- package/src/server/modules/file/file.resolver.ts +5 -17
- package/src/server/modules/file/file.service.ts +42 -3
- package/src/server/modules/user/avatar.controller.ts +39 -9
- package/src/server/modules/user/user.model.ts +9 -3
- package/src/server/modules/user/user.module.ts +12 -3
- package/src/server/modules/user/user.service.ts +20 -17
- package/src/test/README.md +7 -2
- package/src/core/common/helpers/file.helper.spec.ts +0 -145
- package/src/core/common/helpers/logging.helper.spec.ts +0 -61
- package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
- package/src/core/common/services/brevo.service.spec.ts +0 -266
- package/src/core/modules/file/core-file.controller.spec.ts +0 -164
- package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
- package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
- package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
- package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
- package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
- package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
- package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
- package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
- package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
- package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
|
@@ -1,15 +1,87 @@
|
|
|
1
|
-
import { OnApplicationBootstrap } from '@nestjs/common';
|
|
1
|
+
import { OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common';
|
|
2
2
|
import { CronExpression, SchedulerRegistry } from '@nestjs/schedule';
|
|
3
3
|
import { CronJob } from 'cron';
|
|
4
4
|
|
|
5
5
|
import { CronJobConfigWithTimeZone } from '../interfaces/cron-job-config-with-time-zone.interface';
|
|
6
6
|
import { CronJobConfigWithUtcOffset } from '../interfaces/cron-job-config-with-utc-offset.interface';
|
|
7
7
|
import { Falsy } from '../types/falsy.type';
|
|
8
|
+
import { getCronJobsInfrastructure } from './core-cron-jobs.registry';
|
|
9
|
+
|
|
10
|
+
import type { CoreRedisService } from './core-redis.service';
|
|
11
|
+
import type { Redis } from 'ioredis';
|
|
12
|
+
import type { Connection } from 'mongoose';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Collection holding the per-tick cron leases.
|
|
16
|
+
*
|
|
17
|
+
* It has no Mongoose schema, so the native driver is the correct access path here
|
|
18
|
+
* (see docs/native-driver-security.md).
|
|
19
|
+
*/
|
|
20
|
+
const CRON_LOCK_COLLECTION = 'cron-locks';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Lifetime of a cron lease: long enough to outlive any tick, short enough to keep
|
|
24
|
+
* the lock collection / keyspace small.
|
|
25
|
+
*/
|
|
26
|
+
const CRON_LOCK_TTL_SECONDS = 3600;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Lifetime of the `runOnInit` lease.
|
|
30
|
+
*
|
|
31
|
+
* A scheduled tick has an instant every replica agrees on, so its lease key can be derived
|
|
32
|
+
* from the schedule. A STARTUP has no such instant: replicas boot milliseconds to minutes
|
|
33
|
+
* apart, so a key built from each replica's own clock is different on every replica and
|
|
34
|
+
* deduplicates nothing — which, since `runOnInit` defaults to true, would leave the most
|
|
35
|
+
* common path undeduplicated.
|
|
36
|
+
*
|
|
37
|
+
* The init lease therefore uses one fixed key per job plus this TTL, which defines the
|
|
38
|
+
* window in which a fleet counts as "starting up": replicas that boot within it run the
|
|
39
|
+
* init tick once between them, and a replica joining later (autoscaling, a much later
|
|
40
|
+
* restart) runs it again — which is the intended behavior for a newly started instance.
|
|
41
|
+
*/
|
|
42
|
+
const CRON_INIT_LOCK_TTL_SECONDS = 300;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Name of the shared BullMQ queue carrying all distributed cron jobs
|
|
46
|
+
*/
|
|
47
|
+
const CRON_QUEUE_NAME = 'cron';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Optional dependencies of CoreCronJobs
|
|
51
|
+
*
|
|
52
|
+
* CoreCronJobs is abstract and instantiated by the consuming project, so its
|
|
53
|
+
* dependencies cannot be injected by the framework. A subclass MAY pass them here
|
|
54
|
+
* (see .claude/rules/core-modules.md → "Optional Constructor Parameters"); existing
|
|
55
|
+
* `super(schedulerRegistry, cronJobs, { log })` calls keep compiling unchanged.
|
|
56
|
+
*
|
|
57
|
+
* Passing them is optional: what is not given here is looked up in
|
|
58
|
+
* `core-cron-jobs.registry.ts`, which CoreModule populates — so deduplication works
|
|
59
|
+
* without any change to an existing subclass. Values passed here take precedence.
|
|
60
|
+
*
|
|
61
|
+
* With neither source the service behaves exactly as before — every replica runs
|
|
62
|
+
* every tick — and warns once.
|
|
63
|
+
*/
|
|
64
|
+
export interface CoreCronJobsOptions {
|
|
65
|
+
/** Mongoose connection used for the per-tick MongoDB lease (fallback mode) */
|
|
66
|
+
connection?: Connection;
|
|
67
|
+
|
|
68
|
+
/** Whether cron job initialization is logged */
|
|
69
|
+
log?: boolean;
|
|
70
|
+
|
|
71
|
+
/** Central Redis service; enables the BullMQ job-scheduler mode */
|
|
72
|
+
redisService?: CoreRedisService;
|
|
73
|
+
}
|
|
8
74
|
|
|
9
75
|
/**
|
|
10
76
|
* Cron jobs service to extend
|
|
77
|
+
*
|
|
78
|
+
* Jobs are deduplicated across replicas unless `distributed: false` is set:
|
|
79
|
+
* - Redis configured + `bullmq` installed: the job becomes a BullMQ job scheduler,
|
|
80
|
+
* so exactly one worker in the cluster picks up each tick.
|
|
81
|
+
* - Otherwise: the local cron timer stays, but each tick first acquires a lease
|
|
82
|
+
* (Redis `SET NX` when available, else a `cron-locks` document in MongoDB).
|
|
11
83
|
*/
|
|
12
|
-
export abstract class CoreCronJobs implements OnApplicationBootstrap {
|
|
84
|
+
export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicationShutdown {
|
|
13
85
|
/**
|
|
14
86
|
* Config for cron jobs
|
|
15
87
|
*/
|
|
@@ -23,6 +95,21 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap {
|
|
|
23
95
|
*/
|
|
24
96
|
runningJobs: Record<string, Date[]> = {};
|
|
25
97
|
|
|
98
|
+
/** Shared BullMQ queue, created only in BullMQ mode */
|
|
99
|
+
protected bullQueue?: any;
|
|
100
|
+
|
|
101
|
+
/** Shared BullMQ worker, created only in BullMQ mode */
|
|
102
|
+
protected bullWorker?: any;
|
|
103
|
+
|
|
104
|
+
/** Whether the "deduplication inactive" warning was already emitted */
|
|
105
|
+
protected dedupWarned = false;
|
|
106
|
+
|
|
107
|
+
/** Normalized config per job name, used by the tick wrapper of both modes */
|
|
108
|
+
protected jobConfigs: Record<string, CronJobConfigWithTimeZone> = {};
|
|
109
|
+
|
|
110
|
+
/** Cached creation of the TTL index on the lock collection */
|
|
111
|
+
protected leaseIndexReady?: Promise<void>;
|
|
112
|
+
|
|
26
113
|
// ===================================================================================================================
|
|
27
114
|
// Initializations
|
|
28
115
|
// ===================================================================================================================
|
|
@@ -36,7 +123,7 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap {
|
|
|
36
123
|
string,
|
|
37
124
|
CronExpression | CronJobConfigWithTimeZone | CronJobConfigWithUtcOffset | Date | Falsy | string
|
|
38
125
|
>,
|
|
39
|
-
|
|
126
|
+
protected readonly options?: CoreCronJobsOptions,
|
|
40
127
|
) {
|
|
41
128
|
this.config = {
|
|
42
129
|
log: true,
|
|
@@ -48,22 +135,44 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap {
|
|
|
48
135
|
* Lifecycle hook method: Called once all modules have been initialized, but before listening for connections.
|
|
49
136
|
* Required to ensure that all services have been previously initiated
|
|
50
137
|
*/
|
|
51
|
-
onApplicationBootstrap() {
|
|
138
|
+
async onApplicationBootstrap(): Promise<void> {
|
|
52
139
|
if (this.config.log) {
|
|
53
140
|
console.info('Init CronJobs after application bootstrap');
|
|
54
141
|
}
|
|
55
|
-
this.initCronJobs();
|
|
142
|
+
await this.initCronJobs();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Close the BullMQ worker and queue
|
|
147
|
+
*/
|
|
148
|
+
async onApplicationShutdown(): Promise<void> {
|
|
149
|
+
await this.drainRunningJobs();
|
|
150
|
+
|
|
151
|
+
// Settled, not sequential-and-throwing: a rejection from the worker's close would abort the
|
|
152
|
+
// whole chain, leaving the queue open and its Redis connections dangling — so a shutdown
|
|
153
|
+
// hiccup in one component would strand the others.
|
|
154
|
+
const results = await Promise.allSettled([this.bullWorker?.close(), this.bullQueue?.close()]);
|
|
155
|
+
for (const result of results) {
|
|
156
|
+
if (result.status === 'rejected') {
|
|
157
|
+
console.error('BullMQ shutdown failed', result.reason);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
this.bullWorker = undefined;
|
|
161
|
+
this.bullQueue = undefined;
|
|
56
162
|
}
|
|
57
163
|
|
|
58
164
|
/**
|
|
59
165
|
* Init cron jobs
|
|
60
166
|
*/
|
|
61
|
-
protected initCronJobs() {
|
|
167
|
+
protected async initCronJobs(): Promise<void> {
|
|
62
168
|
// Get cron jobs
|
|
63
169
|
if (!this.cronJobs) {
|
|
64
170
|
return;
|
|
65
171
|
}
|
|
66
172
|
|
|
173
|
+
const bullMqActive = await this.initBullMq();
|
|
174
|
+
const bullScheduled = new Set<string>();
|
|
175
|
+
|
|
67
176
|
// Init cron jobs
|
|
68
177
|
for (const [name, CronExpressionOrConfig] of Object.entries(this.cronJobs)) {
|
|
69
178
|
// Check config
|
|
@@ -87,6 +196,12 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap {
|
|
|
87
196
|
// Set defaults
|
|
88
197
|
// Declared as CronJobConfigWithTimeZone to avoid type errors, but it can also be CronJobConfigWithUtcOffset
|
|
89
198
|
const config: CronJobConfigWithTimeZone = {
|
|
199
|
+
// Deduplication follows "presence implies enabled": without a `redis` config the
|
|
200
|
+
// service must behave exactly as before, so a single-replica project that upgrades
|
|
201
|
+
// does not silently gain a `cron-locks` collection, a lease write per tick, and a
|
|
202
|
+
// new way for a tick to be skipped. The MongoDB lease stays available for a
|
|
203
|
+
// multi-replica fleet that runs without Redis — via an explicit `distributed: true`.
|
|
204
|
+
distributed: !!this.getRedisService()?.enabled,
|
|
90
205
|
runOnInit: true,
|
|
91
206
|
runParallel: true,
|
|
92
207
|
throwException: true,
|
|
@@ -107,56 +222,483 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap {
|
|
|
107
222
|
continue;
|
|
108
223
|
}
|
|
109
224
|
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
config.cronTime,
|
|
113
|
-
async () => {
|
|
114
|
-
// Get current processes of cron job
|
|
115
|
-
const dates = this.runningJobs[name];
|
|
116
|
-
|
|
117
|
-
// Check if parallel execution is allowed and if so how many can run in parallel
|
|
118
|
-
if (
|
|
119
|
-
dates?.length &&
|
|
120
|
-
(!config.runParallel || (typeof config.runParallel === 'number' && dates.length >= config.runParallel))
|
|
121
|
-
) {
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
225
|
+
this.jobConfigs[name] = config;
|
|
226
|
+
const distributed = config.distributed !== false;
|
|
124
227
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
228
|
+
// Init cron job as BullMQ job scheduler, when possible
|
|
229
|
+
if (bullMqActive && distributed) {
|
|
230
|
+
if (typeof config.cronTime === 'string' && !config.utcOffset) {
|
|
231
|
+
await this.registerBullJob(name, config);
|
|
232
|
+
bullScheduled.add(name);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
console.debug(
|
|
236
|
+
`CronJob ${name} cannot be expressed as BullMQ scheduler (${
|
|
237
|
+
typeof config.cronTime === 'string' ? 'utcOffset is set' : 'cronTime is a Date'
|
|
238
|
+
}), falling back to a local timer with lease`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Init local cron job
|
|
243
|
+
this.registerLocalJob(name, config, distributed);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
await this.removeStaleBullSchedulers(bullScheduled);
|
|
247
|
+
this.startBullWorker();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Acquire the lease for a single tick.
|
|
252
|
+
*
|
|
253
|
+
* Returns `true` when this replica may run the tick — including when no lease
|
|
254
|
+
* backend is available at all (fail open: a missing lock must never stop the job).
|
|
255
|
+
*/
|
|
256
|
+
protected async acquireLease(name: string, fireTime: Date | 'init' | 'manual'): Promise<boolean> {
|
|
257
|
+
// 'manual' is an operator-triggered run: they asked for THIS execution, so it must not be
|
|
258
|
+
// deduplicated against a scheduled tick, and it is one replica acting deliberately rather
|
|
259
|
+
// than N replicas racing. Hence a unique key rather than a shared one.
|
|
260
|
+
const leaseKey =
|
|
261
|
+
fireTime === 'manual'
|
|
262
|
+
? `${name}:manual:${Date.now()}:${process.pid}`
|
|
263
|
+
: fireTime === 'init'
|
|
264
|
+
? `${name}:init`
|
|
265
|
+
: `${name}:${new Date(Math.floor(fireTime.getTime() / 1000) * 1000).toISOString()}`;
|
|
266
|
+
const ttlSeconds = fireTime === 'init' ? CRON_INIT_LOCK_TTL_SECONDS : CRON_LOCK_TTL_SECONDS;
|
|
267
|
+
|
|
268
|
+
const redis = this.getRedisClient();
|
|
269
|
+
if (redis) {
|
|
270
|
+
try {
|
|
271
|
+
const acquired = await redis.set(
|
|
272
|
+
this.getRedisService().key('cron-lock', leaseKey),
|
|
273
|
+
'1',
|
|
274
|
+
'EX',
|
|
275
|
+
ttlSeconds,
|
|
276
|
+
'NX',
|
|
277
|
+
);
|
|
278
|
+
if (acquired === null) {
|
|
279
|
+
this.logSkippedTick(leaseKey);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
return true;
|
|
283
|
+
} catch (e) {
|
|
284
|
+
// Fail open, exactly like the Mongo branch below: a lease is there to prevent a
|
|
285
|
+
// DUPLICATE run, and treating an unreachable Redis as "someone else won" would
|
|
286
|
+
// instead stop every scheduled job on every replica — a silent, fleet-wide outage
|
|
287
|
+
// of all cron work, which is the worse failure of the two.
|
|
288
|
+
console.error(`Cron lease for ${leaseKey} failed on Redis, running the tick anyway`, e);
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const connection = this.getConnection();
|
|
294
|
+
if (!connection) {
|
|
295
|
+
this.warnDedupInactive();
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await this.ensureLeaseIndex(connection);
|
|
300
|
+
try {
|
|
301
|
+
await connection.db.collection(CRON_LOCK_COLLECTION).insertOne({
|
|
302
|
+
_id: leaseKey as any,
|
|
303
|
+
createdAt: new Date(),
|
|
304
|
+
// Per-document expiry, so the short init window and the long tick window can
|
|
305
|
+
// share one collection (a fixed expireAfterSeconds could only express one).
|
|
306
|
+
expiresAt: new Date(Date.now() + ttlSeconds * 1000),
|
|
307
|
+
});
|
|
308
|
+
return true;
|
|
309
|
+
} catch (e: any) {
|
|
310
|
+
if (e?.code === 11000) {
|
|
311
|
+
this.logSkippedTick(leaseKey);
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
console.error(`Cron lease for ${leaseKey} failed, running the tick anyway`, e);
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Create the TTL index on the lock collection once
|
|
321
|
+
*/
|
|
322
|
+
protected async ensureLeaseIndex(connection: Connection): Promise<void> {
|
|
323
|
+
if (!this.leaseIndexReady) {
|
|
324
|
+
// expireAfterSeconds: 0 means "expire AT the date in this field", which lets each
|
|
325
|
+
// lease carry its own lifetime (see the insert in acquireLease).
|
|
326
|
+
this.leaseIndexReady = connection.db
|
|
327
|
+
.collection(CRON_LOCK_COLLECTION)
|
|
328
|
+
.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 })
|
|
329
|
+
.then(() => undefined)
|
|
330
|
+
.catch((e) => {
|
|
331
|
+
console.error('Could not create TTL index on cron lock collection', e);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return this.leaseIndexReady;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Mongoose connection from the options object, else from the registry.
|
|
339
|
+
* Resolved on every access so a late registration still takes effect.
|
|
340
|
+
*/
|
|
341
|
+
protected getConnection(): Connection | undefined {
|
|
342
|
+
return this.options?.connection ?? getCronJobsInfrastructure().connection;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Shared Redis client, or undefined when Redis is not available
|
|
347
|
+
*/
|
|
348
|
+
protected getRedisClient(): Redis | undefined {
|
|
349
|
+
const service = this.getRedisService();
|
|
350
|
+
if (!service?.enabled) {
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
return service.getClient();
|
|
355
|
+
} catch {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Redis service from the options object, else from the registry.
|
|
362
|
+
* Resolved on every access so a late registration still takes effect.
|
|
363
|
+
*/
|
|
364
|
+
protected getRedisService(): CoreRedisService | undefined {
|
|
365
|
+
return this.options?.redisService ?? getCronJobsInfrastructure().redisService;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Import the optional peer dependency `bullmq`.
|
|
370
|
+
* Separate method so tests can substitute the module.
|
|
371
|
+
*/
|
|
372
|
+
protected importBullMq(): Promise<any> {
|
|
373
|
+
return import('bullmq');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Set up the shared BullMQ queue and worker.
|
|
378
|
+
* Returns whether the BullMQ mode is active.
|
|
379
|
+
*/
|
|
380
|
+
protected async initBullMq(): Promise<boolean> {
|
|
381
|
+
const service = this.getRedisService();
|
|
382
|
+
if (!service?.enabled) {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let bullmq: any;
|
|
387
|
+
try {
|
|
388
|
+
bullmq = await this.importBullMq();
|
|
389
|
+
} catch {
|
|
390
|
+
console.warn(
|
|
391
|
+
'Redis is configured but the optional peer dependency "bullmq" is not installed — cron jobs fall back to ' +
|
|
392
|
+
'per-tick MongoDB leases. Run: pnpm add bullmq',
|
|
393
|
+
);
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const prefix = `${service.getConfig().keyPrefix}:bull`;
|
|
398
|
+
|
|
399
|
+
// BullMQ owns the lifetime of its commands and must be exempt from the shared defaults:
|
|
400
|
+
// its fetch loop issues a BLOCKING BZPOPMIN that waits up to 10s on purpose, so the
|
|
401
|
+
// `commandTimeout` every other connection carries would abort it. BullMQ then classifies
|
|
402
|
+
// the abort as a real error, emits it, and stops fetching — silently ending all cron work
|
|
403
|
+
// in the fleet. It also requires unlimited retries on a blocking connection.
|
|
404
|
+
const blockingOptions = { commandTimeout: undefined, maxRetriesPerRequest: null };
|
|
405
|
+
|
|
406
|
+
// The QUEUE issues ordinary, non-blocking commands and must NOT inherit the opt-out above.
|
|
407
|
+
// `maxRetriesPerRequest: null` tells ioredis to retry forever and never flush the offline
|
|
408
|
+
// queue with an error — so against an unreachable Redis `upsertJobScheduler()` neither
|
|
409
|
+
// resolves nor rejects. Since initCronJobs() is awaited from onApplicationBootstrap, which
|
|
410
|
+
// runs BEFORE app.listen(), that turns a Redis outage at boot into a process that hangs
|
|
411
|
+
// silently: no health endpoint, no readiness, no error, no log. Bounded retries make the
|
|
412
|
+
// same outage a real rejection that the caller can report.
|
|
413
|
+
const queueOptions = { commandTimeout: 10_000, maxRetriesPerRequest: 3 };
|
|
144
414
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
415
|
+
this.bullQueue = new bullmq.Queue(CRON_QUEUE_NAME, {
|
|
416
|
+
connection: service.createClient('cron-queue', queueOptions),
|
|
417
|
+
prefix,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const workerConnection = service.createClient('cron-worker', blockingOptions);
|
|
421
|
+
// autorun: false — initBullMq() runs BEFORE the loop that fills `jobConfigs`, so a worker
|
|
422
|
+
// that starts consuming here would hand `runTick` a job it has no config for. That path
|
|
423
|
+
// returns without throwing, which BullMQ records as COMPLETED: the tick is lost silently
|
|
424
|
+
// and never retried. Consumption starts in startBullWorker(), after registration.
|
|
425
|
+
this.bullWorker = new bullmq.Worker(CRON_QUEUE_NAME, async (job: any) => this.runTick(job.name), {
|
|
426
|
+
autorun: false,
|
|
427
|
+
connection: workerConnection,
|
|
428
|
+
prefix,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Without a listener an emitted 'error' is an UNHANDLED 'error' event, which rejects the
|
|
432
|
+
// promise from run() and takes the fetch loop down with it — after which no replica runs
|
|
433
|
+
// any job again. A transient error must stay transient.
|
|
434
|
+
this.bullWorker.on('error', (error: Error) => {
|
|
435
|
+
console.error(`BullMQ cron worker error: ${error.message}`);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Give a tick that is already running a bounded chance to finish.
|
|
443
|
+
*
|
|
444
|
+
* `runningJobs` is the only record that a job is mid-execution. Closing the worker out from
|
|
445
|
+
* under it abandons the work AND leaves its lease held for the rest of the TTL, so the next
|
|
446
|
+
* replica skips that tick too — the job silently does not happen. Waiting is bounded because a
|
|
447
|
+
* job that runs longer than the orchestrator's grace period cannot be saved either way; it is
|
|
448
|
+
* better to say so than to hang the shutdown.
|
|
449
|
+
*/
|
|
450
|
+
protected async drainRunningJobs(timeoutMs = 10_000): Promise<void> {
|
|
451
|
+
const deadline = Date.now() + timeoutMs;
|
|
452
|
+
const running = () => Object.values(this.runningJobs).reduce((sum, dates) => sum + (dates?.length ?? 0), 0);
|
|
453
|
+
|
|
454
|
+
if (!running()) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
console.info(`Waiting up to ${timeoutMs}ms for ${running()} running cron job(s) to finish`);
|
|
458
|
+
|
|
459
|
+
while (running() && Date.now() < deadline) {
|
|
460
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (running()) {
|
|
464
|
+
console.warn(`${running()} cron job(s) still running after ${timeoutMs}ms — shutting down anyway`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Drop schedulers in the shared queue that this version no longer registers.
|
|
470
|
+
*
|
|
471
|
+
* `upsertJobScheduler` writes PERMANENT state into Redis, while the config that justifies it
|
|
472
|
+
* lives only in the process-local `jobConfigs`. Disabling a job, renaming it, removing it from
|
|
473
|
+
* `cronJobs`, or switching it to `distributed: false` therefore only stops re-registering it —
|
|
474
|
+
* the scheduler keeps producing queue jobs forever. Every replica then drops them with the
|
|
475
|
+
* "no registered config" warning, and in the `distributed: false` case they duplicate the
|
|
476
|
+
* local timer.
|
|
477
|
+
*/
|
|
478
|
+
protected async removeStaleBullSchedulers(active: Set<string>): Promise<void> {
|
|
479
|
+
if (!this.bullQueue) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
// Only schedulers this application configured are candidates. The queue name and prefix
|
|
484
|
+
// derive from `keyPrefix`, whose default is shared — two apps (or a dev and a staging
|
|
485
|
+
// deployment) pointing at one Redis without overriding it would otherwise wipe each
|
|
486
|
+
// other's schedulers on every boot. `known` is every job name in OUR config, so a job that
|
|
487
|
+
// exists but is currently disabled or non-distributed is still recognised as ours.
|
|
488
|
+
const known = new Set(Object.keys(this.cronJobs ?? {}));
|
|
489
|
+
for (const scheduler of await this.bullQueue.getJobSchedulers()) {
|
|
490
|
+
if (scheduler?.key && known.has(scheduler.key) && !active.has(scheduler.key)) {
|
|
491
|
+
await this.bullQueue.removeJobScheduler(scheduler.key);
|
|
492
|
+
console.info(`CronJob scheduler ${scheduler.key} removed — no longer configured`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
} catch (error) {
|
|
496
|
+
// Reconciliation is housekeeping: a failure here must not stop the jobs from starting.
|
|
497
|
+
console.warn('Failed to reconcile BullMQ job schedulers', error);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Start consuming once every job config is registered.
|
|
503
|
+
*
|
|
504
|
+
* Concurrency matches the number of registered jobs: BullMQ defaults to 1, which would put
|
|
505
|
+
* every job behind a single slow one — something one-timer-per-job never did, and which
|
|
506
|
+
* would also make `runParallel` unreachable.
|
|
507
|
+
*/
|
|
508
|
+
protected startBullWorker(): void {
|
|
509
|
+
if (!this.bullWorker) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
this.bullWorker.concurrency = Math.max(1, Object.keys(this.jobConfigs).length);
|
|
513
|
+
this.bullWorker.run().catch((e: unknown) => console.error('BullMQ cron worker stopped', e));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Debug log for a tick that another replica has already claimed
|
|
518
|
+
*/
|
|
519
|
+
protected logSkippedTick(leaseKey: string): void {
|
|
520
|
+
if (this.config.log) {
|
|
521
|
+
console.debug(`CronJob tick ${leaseKey} skipped, lease held by another replica`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Register a job as BullMQ job scheduler
|
|
527
|
+
*/
|
|
528
|
+
protected async registerBullJob(name: string, config: CronJobConfigWithTimeZone): Promise<void> {
|
|
529
|
+
await this.bullQueue.upsertJobScheduler(
|
|
530
|
+
name,
|
|
531
|
+
{ pattern: config.cronTime as string, tz: config.timeZone ?? undefined },
|
|
532
|
+
{ name },
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
// A BullMQ scheduler never fires immediately, so runOnInit is emulated locally under a lease.
|
|
536
|
+
// Not awaited, so bootstrap is not blocked by the first run — same as the local timer path.
|
|
537
|
+
// That also means nothing is there to catch a rejection, and `runTick` re-throws when
|
|
538
|
+
// `throwException` is set (the default): without the catch below, a job that throws on
|
|
539
|
+
// its startup run takes the process down with an unhandled rejection.
|
|
540
|
+
if (config.runOnInit) {
|
|
541
|
+
this.runInitTick(name);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Register a STOPPED local CronJob as the registry entry for this job.
|
|
545
|
+
//
|
|
546
|
+
// `SchedulerRegistry` is the only thing the Hub's cron panel reads, so without an entry a
|
|
547
|
+
// BullMQ-scheduled job is invisible there and every start/stop/trigger action throws for a
|
|
548
|
+
// job that plainly exists. It is created with `start = false` so it never fires on its own —
|
|
549
|
+
// the BullMQ scheduler owns the schedule — while `nextDate()`/`lastDate()` still answer for
|
|
550
|
+
// the panel, and a manual trigger goes through the same leased tick as everywhere else.
|
|
551
|
+
if (!this.schedulerRegistry.doesExist('cron', name)) {
|
|
552
|
+
const registryJob = new CronJob(
|
|
553
|
+
config.cronTime,
|
|
554
|
+
() => this.runInitTick(name),
|
|
148
555
|
null,
|
|
149
|
-
|
|
556
|
+
false,
|
|
150
557
|
config.timeZone,
|
|
151
558
|
config.context,
|
|
152
|
-
|
|
559
|
+
false,
|
|
153
560
|
config.utcOffset,
|
|
154
561
|
config.unrefTimeout,
|
|
155
562
|
);
|
|
156
|
-
this.schedulerRegistry.addCronJob(name,
|
|
157
|
-
|
|
158
|
-
|
|
563
|
+
this.schedulerRegistry.addCronJob(name, registryJob);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (this.config.log) {
|
|
567
|
+
console.info(`CronJob ${name} initialized with "${config.cronTime}" (BullMQ scheduler)`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Register a job as local cron timer
|
|
573
|
+
*/
|
|
574
|
+
protected registerLocalJob(name: string, config: CronJobConfigWithTimeZone, distributed: boolean): void {
|
|
575
|
+
// Filled after construction; runOnInit fires DURING construction, where the fire time is simply "now"
|
|
576
|
+
const ref: { job?: CronJob } = {};
|
|
577
|
+
/** Fire time of the last SCHEDULED tick, to tell a manual trigger apart from a repeat */
|
|
578
|
+
let lastLeasedFireTime: number | undefined;
|
|
579
|
+
|
|
580
|
+
const job = new CronJob(
|
|
581
|
+
config.cronTime,
|
|
582
|
+
async () => {
|
|
583
|
+
if (distributed) {
|
|
584
|
+
// The runOnInit fire happens DURING construction, so `ref.job` is not assigned
|
|
585
|
+
// yet — that absence is exactly how the startup run identifies itself. It takes
|
|
586
|
+
// the fleet-wide init lease, because "startup" is not an instant the replicas
|
|
587
|
+
// agree on; a scheduled tick takes a lease keyed on the schedule instant, which
|
|
588
|
+
// every replica computes identically.
|
|
589
|
+
const scheduled = ref.job?.lastDate();
|
|
590
|
+
if (!scheduled) {
|
|
591
|
+
this.runInitTick(name);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
// `cron` only advances lastDate() on a SCHEDULED fire, so a manual fireOnTick() (the
|
|
595
|
+
// Hub's "Run now") reports the previous tick's instant — whose lease this replica
|
|
596
|
+
// already holds. Reusing it would lose the race against itself and silently do
|
|
597
|
+
// nothing, while the operator sees a success response. A manual run gets its own key.
|
|
598
|
+
if (scheduled.getTime() === lastLeasedFireTime) {
|
|
599
|
+
await this.runDistributedTick(name, 'manual');
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
lastLeasedFireTime = scheduled.getTime();
|
|
603
|
+
await this.runDistributedTick(name, scheduled);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
await this.runTick(name);
|
|
607
|
+
},
|
|
608
|
+
null,
|
|
609
|
+
true,
|
|
610
|
+
config.timeZone,
|
|
611
|
+
config.context,
|
|
612
|
+
config.runOnInit,
|
|
613
|
+
config.utcOffset,
|
|
614
|
+
config.unrefTimeout,
|
|
615
|
+
);
|
|
616
|
+
ref.job = job;
|
|
617
|
+
this.schedulerRegistry.addCronJob(name, job);
|
|
618
|
+
if (this.config.log && this.schedulerRegistry.getCronJob(name)) {
|
|
619
|
+
console.info(`CronJob ${name} initialized with "${config.cronTime}"`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Run a tick only if this replica wins the lease for it
|
|
625
|
+
*/
|
|
626
|
+
protected async runDistributedTick(name: string, fireTime: Date | 'init' | 'manual'): Promise<void> {
|
|
627
|
+
if (!(await this.acquireLease(name, fireTime))) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
await this.runTick(name);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Fire the `runOnInit` tick without blocking bootstrap and without letting a failure
|
|
635
|
+
* escape as an unhandled rejection.
|
|
636
|
+
*/
|
|
637
|
+
protected runInitTick(name: string): void {
|
|
638
|
+
this.runDistributedTick(name, 'init').catch((e) => {
|
|
639
|
+
console.error(`CronJob ${name} failed on its startup run`, e);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Execute the cron job function, honoring runParallel and throwException.
|
|
645
|
+
* Shared by the local timer and the BullMQ worker.
|
|
646
|
+
*/
|
|
647
|
+
protected async runTick(name: string): Promise<void> {
|
|
648
|
+
const config = this.jobConfigs[name];
|
|
649
|
+
if (!config) {
|
|
650
|
+
// Returning quietly here is how a mis-timed BullMQ delivery got recorded as a
|
|
651
|
+
// successful, completed tick. The guard stays, but it no longer stays silent.
|
|
652
|
+
console.warn(`CronJob tick for "${name}" dropped — no registered config`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Get current processes of cron job
|
|
657
|
+
const dates = this.runningJobs[name];
|
|
658
|
+
|
|
659
|
+
// Check if parallel execution is allowed and if so how many can run in parallel
|
|
660
|
+
if (
|
|
661
|
+
dates?.length &&
|
|
662
|
+
(!config.runParallel || (typeof config.runParallel === 'number' && dates.length >= config.runParallel))
|
|
663
|
+
) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Prepare the acquisition of parallel job executions
|
|
668
|
+
if (!this.runningJobs[name]) {
|
|
669
|
+
this.runningJobs[name] = [];
|
|
670
|
+
}
|
|
671
|
+
const date = new Date();
|
|
672
|
+
this.runningJobs[name].push(date);
|
|
673
|
+
|
|
674
|
+
// Execute the job and wait until job process is done
|
|
675
|
+
try {
|
|
676
|
+
await this[name]();
|
|
677
|
+
} catch (e) {
|
|
678
|
+
// Remove job from running list
|
|
679
|
+
this.runningJobs[name] = this.runningJobs[name].filter((item) => item !== date);
|
|
680
|
+
if (config.throwException) {
|
|
681
|
+
throw e;
|
|
682
|
+
} else {
|
|
683
|
+
console.error(e);
|
|
159
684
|
}
|
|
160
685
|
}
|
|
686
|
+
|
|
687
|
+
// Remove job from running list
|
|
688
|
+
this.runningJobs[name] = this.runningJobs[name].filter((item) => item !== date);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Warn once that cron deduplication is not active
|
|
693
|
+
*/
|
|
694
|
+
protected warnDedupInactive(): void {
|
|
695
|
+
if (this.dedupWarned) {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
this.dedupWarned = true;
|
|
699
|
+
console.warn(
|
|
700
|
+
'Cron job deduplication is inactive: neither a Redis service nor a Mongoose connection was passed to ' +
|
|
701
|
+
'CoreCronJobs. Every replica runs every tick.',
|
|
702
|
+
);
|
|
161
703
|
}
|
|
162
704
|
}
|