@lenne.tech/nest-server 11.32.4 → 11.33.1

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.
Files changed (264) hide show
  1. package/.claude/rules/architecture.md +16 -0
  2. package/.claude/rules/configurable-features.md +11 -3
  3. package/.claude/rules/testing.md +101 -1
  4. package/CLAUDE.md +5 -0
  5. package/FRAMEWORK-API.md +58 -3
  6. package/dist/config.env.js +6 -0
  7. package/dist/config.env.js.map +1 -1
  8. package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
  9. package/dist/core/common/helpers/content-disposition.helper.js +14 -0
  10. package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
  11. package/dist/core/common/helpers/file.helper.d.ts +7 -0
  12. package/dist/core/common/helpers/file.helper.js +21 -4
  13. package/dist/core/common/helpers/file.helper.js.map +1 -1
  14. package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
  15. package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
  16. package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
  17. package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
  18. package/dist/core/common/helpers/project-name.helper.js +38 -0
  19. package/dist/core/common/helpers/project-name.helper.js.map +1 -0
  20. package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
  21. package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
  22. package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
  23. package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
  24. package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
  25. package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
  26. package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
  27. package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
  28. package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
  29. package/dist/core/common/services/core-cron-jobs.service.js +278 -27
  30. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  31. package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
  32. package/dist/core/common/services/core-redis-pubsub.js +112 -0
  33. package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
  34. package/dist/core/common/services/core-redis.service.d.ts +25 -0
  35. package/dist/core/common/services/core-redis.service.js +154 -0
  36. package/dist/core/common/services/core-redis.service.js.map +1 -0
  37. package/dist/core/common/services/core-s3.service.d.ts +51 -0
  38. package/dist/core/common/services/core-s3.service.js +261 -0
  39. package/dist/core/common/services/core-s3.service.js.map +1 -0
  40. package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
  41. package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
  42. package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
  43. package/dist/core/common/services/rate-limit-store.d.ts +61 -0
  44. package/dist/core/common/services/rate-limit-store.js +235 -0
  45. package/dist/core/common/services/rate-limit-store.js.map +1 -0
  46. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
  47. package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
  48. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  49. package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
  50. package/dist/core/modules/ai/services/core-ai.service.js +24 -20
  51. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  52. package/dist/core/modules/auth/core-auth.module.js +4 -1
  53. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  54. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
  55. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
  56. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
  57. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
  58. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
  59. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
  60. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
  61. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
  62. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  63. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
  64. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
  65. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  66. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
  67. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
  68. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  69. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
  70. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  71. package/dist/core/modules/file/core-file.controller.d.ts +11 -2
  72. package/dist/core/modules/file/core-file.controller.js +52 -15
  73. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  74. package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
  75. package/dist/core/modules/file/core-file.resolver.js +23 -16
  76. package/dist/core/modules/file/core-file.resolver.js.map +1 -1
  77. package/dist/core/modules/file/core-file.service.d.ts +41 -7
  78. package/dist/core/modules/file/core-file.service.js +228 -8
  79. package/dist/core/modules/file/core-file.service.js.map +1 -1
  80. package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
  81. package/dist/core/modules/file/file-metadata.helper.js +33 -0
  82. package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
  83. package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
  84. package/dist/core/modules/file/file-roles.helper.js +53 -0
  85. package/dist/core/modules/file/file-roles.helper.js.map +1 -0
  86. package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
  87. package/dist/core/modules/file/file-storage.helper.js +65 -0
  88. package/dist/core/modules/file/file-storage.helper.js.map +1 -0
  89. package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
  90. package/dist/core/modules/file/filesystem-file.helper.js +77 -0
  91. package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
  92. package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
  93. package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
  94. package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
  95. package/dist/core/modules/file/s3-file.helper.js +81 -0
  96. package/dist/core/modules/file/s3-file.helper.js.map +1 -0
  97. package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
  98. package/dist/core/modules/hub/core-hub.controller.js +24 -22
  99. package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
  100. package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
  101. package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
  102. package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
  103. package/dist/core/modules/hub/hub-buffer.js +101 -0
  104. package/dist/core/modules/hub/hub-buffer.js.map +1 -0
  105. package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
  106. package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
  107. package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
  108. package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
  109. package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
  110. package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
  111. package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
  112. package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
  113. package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
  114. package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
  115. package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
  116. package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
  117. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
  118. package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
  119. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  120. package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
  121. package/dist/core/modules/migrate/migration-runner.js +4 -0
  122. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  123. package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
  124. package/dist/core/modules/migrate/mongo-state-store.js +98 -9
  125. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  126. package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
  127. package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
  128. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  129. package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
  130. package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
  131. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  132. package/dist/core/modules/tenant/core-tenant.module.js +3 -4
  133. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  134. package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
  135. package/dist/core/modules/tus/core-tus.controller.js +29 -3
  136. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  137. package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
  138. package/dist/core/modules/tus/core-tus.service.js +182 -28
  139. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  140. package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
  141. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  142. package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
  143. package/dist/core/modules/tus/tus-redis-locker.js +87 -0
  144. package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
  145. package/dist/core/modules/tus/tus.module.d.ts +1 -0
  146. package/dist/core/modules/tus/tus.module.js +30 -4
  147. package/dist/core/modules/tus/tus.module.js.map +1 -1
  148. package/dist/core.module.js +18 -1
  149. package/dist/core.module.js.map +1 -1
  150. package/dist/index.d.ts +10 -0
  151. package/dist/index.js +10 -0
  152. package/dist/index.js.map +1 -1
  153. package/dist/main.js +2 -1
  154. package/dist/main.js.map +1 -1
  155. package/dist/server/modules/file/file.controller.js +2 -2
  156. package/dist/server/modules/file/file.controller.js.map +1 -1
  157. package/dist/server/modules/file/file.resolver.js +3 -14
  158. package/dist/server/modules/file/file.resolver.js.map +1 -1
  159. package/dist/server/modules/file/file.service.d.ts +10 -2
  160. package/dist/server/modules/file/file.service.js +23 -3
  161. package/dist/server/modules/file/file.service.js.map +1 -1
  162. package/dist/server/modules/user/avatar.controller.d.ts +5 -1
  163. package/dist/server/modules/user/avatar.controller.js +26 -10
  164. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  165. package/dist/server/modules/user/user.model.js +3 -2
  166. package/dist/server/modules/user/user.model.js.map +1 -1
  167. package/dist/server/modules/user/user.module.js +6 -2
  168. package/dist/server/modules/user/user.module.js.map +1 -1
  169. package/dist/server/modules/user/user.service.d.ts +1 -1
  170. package/dist/server/modules/user/user.service.js +6 -13
  171. package/dist/server/modules/user/user.service.js.map +1 -1
  172. package/dist/tsconfig.build.tsbuildinfo +1 -1
  173. package/docs/REQUEST-LIFECYCLE.md +57 -8
  174. package/docs/security-overrides.md +21 -3
  175. package/migration-guides/11.32.3-to-11.32.4.md +15 -3
  176. package/migration-guides/11.32.x-to-11.33.x.md +1584 -0
  177. package/package.json +74 -42
  178. package/src/config.env.ts +45 -0
  179. package/src/core/common/helpers/content-disposition.helper.ts +90 -0
  180. package/src/core/common/helpers/file.helper.ts +53 -10
  181. package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
  182. package/src/core/common/helpers/project-name.helper.ts +71 -0
  183. package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
  184. package/src/core/common/interfaces/server-options.interface.ts +439 -2
  185. package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
  186. package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
  187. package/src/core/common/services/core-cron-jobs.service.ts +589 -47
  188. package/src/core/common/services/core-redis-pubsub.ts +183 -0
  189. package/src/core/common/services/core-redis.service.ts +249 -0
  190. package/src/core/common/services/core-s3.service.ts +465 -0
  191. package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
  192. package/src/core/common/services/rate-limit-store.ts +492 -0
  193. package/src/core/modules/ai/README.md +25 -0
  194. package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
  195. package/src/core/modules/ai/services/core-ai.service.ts +33 -24
  196. package/src/core/modules/auth/core-auth.module.ts +7 -1
  197. package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
  198. package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
  199. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
  200. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
  201. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
  202. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
  203. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +120 -0
  204. package/src/core/modules/file/README.md +308 -53
  205. package/src/core/modules/file/core-file.controller.ts +168 -12
  206. package/src/core/modules/file/core-file.resolver.ts +46 -12
  207. package/src/core/modules/file/core-file.service.ts +517 -17
  208. package/src/core/modules/file/file-metadata.helper.ts +142 -0
  209. package/src/core/modules/file/file-roles.helper.ts +110 -0
  210. package/src/core/modules/file/file-storage.helper.ts +163 -0
  211. package/src/core/modules/file/filesystem-file.helper.ts +184 -0
  212. package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
  213. package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
  214. package/src/core/modules/file/s3-file.helper.ts +209 -0
  215. package/src/core/modules/hub/README.md +48 -0
  216. package/src/core/modules/hub/core-hub.controller.ts +18 -16
  217. package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
  218. package/src/core/modules/hub/hub-buffer.ts +198 -0
  219. package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
  220. package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
  221. package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
  222. package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
  223. package/src/core/modules/migrate/README.md +33 -6
  224. package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
  225. package/src/core/modules/migrate/migration-runner.ts +10 -1
  226. package/src/core/modules/migrate/mongo-state-store.ts +179 -11
  227. package/src/core/modules/system-setup/README.md +7 -2
  228. package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
  229. package/src/core/modules/tenant/README.md +21 -1
  230. package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
  231. package/src/core/modules/tenant/core-tenant.module.ts +8 -4
  232. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
  233. package/src/core/modules/tus/README.md +35 -2
  234. package/src/core/modules/tus/core-tus.controller.ts +64 -12
  235. package/src/core/modules/tus/core-tus.service.ts +358 -34
  236. package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
  237. package/src/core/modules/tus/tus-redis-locker.ts +139 -0
  238. package/src/core/modules/tus/tus.module.ts +78 -6
  239. package/src/core.module.ts +26 -1
  240. package/src/index.ts +10 -0
  241. package/src/main.ts +7 -1
  242. package/src/server/modules/file/file.controller.ts +14 -5
  243. package/src/server/modules/file/file.resolver.ts +11 -19
  244. package/src/server/modules/file/file.service.ts +89 -4
  245. package/src/server/modules/user/avatar.controller.ts +46 -9
  246. package/src/server/modules/user/user.model.ts +9 -3
  247. package/src/server/modules/user/user.module.ts +12 -3
  248. package/src/server/modules/user/user.service.ts +20 -17
  249. package/src/test/README.md +7 -2
  250. package/src/core/common/helpers/file.helper.spec.ts +0 -145
  251. package/src/core/common/helpers/logging.helper.spec.ts +0 -61
  252. package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
  253. package/src/core/common/services/brevo.service.spec.ts +0 -266
  254. package/src/core/modules/file/core-file.controller.spec.ts +0 -164
  255. package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
  256. package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
  257. package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
  258. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
  259. package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
  260. package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
  261. package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
  262. package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
  263. package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
  264. 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
- options?: { log?: boolean },
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
- // Init cron job
111
- const job = new CronJob(
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
- // Prepare the acquisition of parallel job executions
126
- if (!this.runningJobs[name]) {
127
- this.runningJobs[name] = [];
128
- }
129
- const date = new Date();
130
- this.runningJobs[name].push(date);
131
-
132
- // Execute the job and wait until job process is done
133
- try {
134
- await this[name]();
135
- } catch (e) {
136
- // Remove job from running list
137
- this.runningJobs[name] = this.runningJobs[name].filter((item) => item !== date);
138
- if (config.throwException) {
139
- throw e;
140
- } else {
141
- console.error(e);
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
- // Remove job from running list
146
- this.runningJobs[name] = this.runningJobs[name].filter((item) => item !== date);
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
- true,
556
+ false,
150
557
  config.timeZone,
151
558
  config.context,
152
- config.runOnInit,
559
+ false,
153
560
  config.utcOffset,
154
561
  config.unrefTimeout,
155
562
  );
156
- this.schedulerRegistry.addCronJob(name, job);
157
- if (this.config.log && this.schedulerRegistry.getCronJob(name)) {
158
- console.info(`CronJob ${name} initialized with "${config.cronTime}"`);
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
  }