@lenne.tech/nest-server 12.1.0 → 12.2.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.
Files changed (255) hide show
  1. package/.claude/rules/architecture.md +1 -0
  2. package/.claude/rules/better-auth.md +18 -6
  3. package/.claude/rules/configurable-features.md +5 -4
  4. package/.claude/rules/package-management.md +16 -1
  5. package/.claude/rules/persistence.md +26 -0
  6. package/.claude/rules/testing.md +102 -28
  7. package/CLAUDE.md +3 -2
  8. package/FRAMEWORK-API.md +17 -2
  9. package/README.md +24 -3
  10. package/dist/core/common/helpers/file.helper.js +3 -4
  11. package/dist/core/common/helpers/file.helper.js.map +1 -1
  12. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
  13. package/dist/core/common/interfaces/server-options.interface.d.ts +41 -0
  14. package/dist/core/common/interfaces/service-options.interface.d.ts +3 -0
  15. package/dist/core/common/persistence/core-persistence.module.js +12 -2
  16. package/dist/core/common/persistence/core-persistence.module.js.map +1 -1
  17. package/dist/core/common/persistence/data-source.runtime.js +14 -19
  18. package/dist/core/common/persistence/data-source.runtime.js.map +1 -1
  19. package/dist/core/common/persistence/data-store.interface.d.ts +24 -9
  20. package/dist/core/common/persistence/id-format.d.ts +2 -0
  21. package/dist/core/common/persistence/id-format.js +14 -0
  22. package/dist/core/common/persistence/id-format.js.map +1 -1
  23. package/dist/core/common/persistence/model-descriptor.interface.d.ts +6 -0
  24. package/dist/core/common/persistence/model-metadata.registry.d.ts +2 -1
  25. package/dist/core/common/persistence/model-metadata.registry.js +20 -2
  26. package/dist/core/common/persistence/model-metadata.registry.js.map +1 -1
  27. package/dist/core/common/persistence/mongo/sync-schema.plugin.d.ts +18 -0
  28. package/dist/core/common/persistence/mongo/sync-schema.plugin.js +368 -0
  29. package/dist/core/common/persistence/mongo/sync-schema.plugin.js.map +1 -0
  30. package/dist/core/common/persistence/mongoose-data-store.d.ts +19 -9
  31. package/dist/core/common/persistence/mongoose-data-store.js +107 -31
  32. package/dist/core/common/persistence/mongoose-data-store.js.map +1 -1
  33. package/dist/core/common/persistence/persisted-model.decorator.d.ts +3 -0
  34. package/dist/core/common/persistence/persisted-model.decorator.js +9 -0
  35. package/dist/core/common/persistence/persisted-model.decorator.js.map +1 -1
  36. package/dist/core/common/persistence/persistence-runtime.registry.d.ts +5 -1
  37. package/dist/core/common/persistence/persistence-runtime.registry.js.map +1 -1
  38. package/dist/core/common/persistence/policies/sync.policy.d.ts +17 -0
  39. package/dist/core/common/persistence/policies/sync.policy.js +77 -0
  40. package/dist/core/common/persistence/policies/sync.policy.js.map +1 -0
  41. package/dist/core/common/persistence/sql/mysql/mysql-data-store.d.ts +12 -8
  42. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js +82 -29
  43. package/dist/core/common/persistence/sql/mysql/mysql-data-store.js.map +1 -1
  44. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.d.ts +10 -0
  45. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js +61 -4
  46. package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js.map +1 -1
  47. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.d.ts +2 -0
  48. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +145 -4
  49. package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js.map +1 -1
  50. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.d.ts +10 -0
  51. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js +41 -2
  52. package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js.map +1 -1
  53. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js +98 -10
  54. package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js.map +1 -1
  55. package/dist/core/common/persistence/sql/sql-data-store.d.ts +38 -14
  56. package/dist/core/common/persistence/sql/sql-data-store.js +212 -38
  57. package/dist/core/common/persistence/sql/sql-data-store.js.map +1 -1
  58. package/dist/core/common/persistence/sql/sql-dialect.d.ts +2 -0
  59. package/dist/core/common/persistence/sql/sql-dialect.js +14 -2
  60. package/dist/core/common/persistence/sql/sql-dialect.js.map +1 -1
  61. package/dist/core/common/persistence/sql/sql-errors.d.ts +2 -1
  62. package/dist/core/common/persistence/sql/sql-errors.js +3 -1
  63. package/dist/core/common/persistence/sql/sql-errors.js.map +1 -1
  64. package/dist/core/common/persistence/sync-access.registry.d.ts +17 -0
  65. package/dist/core/common/persistence/sync-access.registry.js +8 -0
  66. package/dist/core/common/persistence/sync-access.registry.js.map +1 -0
  67. package/dist/core/common/persistence/sync-model.d.ts +29 -0
  68. package/dist/core/common/persistence/sync-model.js +138 -0
  69. package/dist/core/common/persistence/sync-model.js.map +1 -0
  70. package/dist/core/common/services/core-cron-jobs.service.d.ts +27 -0
  71. package/dist/core/common/services/core-cron-jobs.service.js +269 -13
  72. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  73. package/dist/core/common/services/core-redis-pubsub.js +5 -5
  74. package/dist/core/common/services/core-redis-pubsub.js.map +1 -1
  75. package/dist/core/common/services/core-redis.service.d.ts +6 -0
  76. package/dist/core/common/services/core-redis.service.js +57 -0
  77. package/dist/core/common/services/core-redis.service.js.map +1 -1
  78. package/dist/core/common/services/crud.service.d.ts +13 -1
  79. package/dist/core/common/services/crud.service.js +174 -16
  80. package/dist/core/common/services/crud.service.js.map +1 -1
  81. package/dist/core/modules/better-auth/better-auth-sql.helper.d.ts +1 -0
  82. package/dist/core/modules/better-auth/better-auth-sql.helper.js +41 -14
  83. package/dist/core/modules/better-auth/better-auth-sql.helper.js.map +1 -1
  84. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  85. package/dist/core/modules/better-auth/core-better-auth-sql.store.d.ts +4 -7
  86. package/dist/core/modules/better-auth/core-better-auth-sql.store.js +23 -16
  87. package/dist/core/modules/better-auth/core-better-auth-sql.store.js.map +1 -1
  88. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +12 -2
  89. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +82 -5
  90. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  91. package/dist/core/modules/better-auth/core-better-auth.module.js +1 -1
  92. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  93. package/dist/core/modules/error-code/error-codes.d.ts +54 -0
  94. package/dist/core/modules/error-code/error-codes.js +48 -0
  95. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  96. package/dist/core/modules/hub/hub-buffer.js +3 -4
  97. package/dist/core/modules/hub/hub-buffer.js.map +1 -1
  98. package/dist/core/modules/migrate/index.js.map +1 -1
  99. package/dist/core/modules/migrate/migration-runner.d.ts +6 -0
  100. package/dist/core/modules/migrate/migration-runner.js +28 -2
  101. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  102. package/dist/core/modules/migrate/mongo-state-store.d.ts +0 -1
  103. package/dist/core/modules/migrate/mongo-state-store.js +0 -5
  104. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  105. package/dist/core/modules/migrate/sql-migration.helper.d.ts +15 -1
  106. package/dist/core/modules/migrate/sql-migration.helper.js +90 -5
  107. package/dist/core/modules/migrate/sql-migration.helper.js.map +1 -1
  108. package/dist/core/modules/sync/core-sync-base.controller.d.ts +7 -0
  109. package/dist/core/modules/sync/core-sync-base.controller.js +44 -0
  110. package/dist/core/modules/sync/core-sync-base.controller.js.map +1 -0
  111. package/dist/core/modules/sync/core-sync-cursor.d.ts +19 -0
  112. package/dist/core/modules/sync/core-sync-cursor.js +77 -0
  113. package/dist/core/modules/sync/core-sync-cursor.js.map +1 -0
  114. package/dist/core/modules/sync/core-sync-hints.controller.d.ts +4 -0
  115. package/dist/core/modules/sync/core-sync-hints.controller.js +55 -0
  116. package/dist/core/modules/sync/core-sync-hints.controller.js.map +1 -0
  117. package/dist/core/modules/sync/core-sync-hints.service.d.ts +28 -0
  118. package/dist/core/modules/sync/core-sync-hints.service.js +155 -0
  119. package/dist/core/modules/sync/core-sync-hints.service.js.map +1 -0
  120. package/dist/core/modules/sync/core-sync-infrastructure.models.d.ts +2 -0
  121. package/dist/core/modules/sync/core-sync-infrastructure.models.js +17 -0
  122. package/dist/core/modules/sync/core-sync-infrastructure.models.js.map +1 -0
  123. package/dist/core/modules/sync/core-sync-mutation.store.d.ts +43 -0
  124. package/dist/core/modules/sync/core-sync-mutation.store.js +88 -0
  125. package/dist/core/modules/sync/core-sync-mutation.store.js.map +1 -0
  126. package/dist/core/modules/sync/core-sync-state.store.d.ts +18 -0
  127. package/dist/core/modules/sync/core-sync-state.store.js +64 -0
  128. package/dist/core/modules/sync/core-sync-state.store.js.map +1 -0
  129. package/dist/core/modules/sync/core-sync.constants.d.ts +28 -0
  130. package/dist/core/modules/sync/core-sync.constants.js +23 -0
  131. package/dist/core/modules/sync/core-sync.constants.js.map +1 -0
  132. package/dist/core/modules/sync/core-sync.controller.d.ts +8 -0
  133. package/dist/core/modules/sync/core-sync.controller.js +79 -0
  134. package/dist/core/modules/sync/core-sync.controller.js.map +1 -0
  135. package/dist/core/modules/sync/core-sync.decorators.d.ts +12 -0
  136. package/dist/core/modules/sync/core-sync.decorators.js +28 -0
  137. package/dist/core/modules/sync/core-sync.decorators.js.map +1 -0
  138. package/dist/core/modules/sync/core-sync.module.d.ts +16 -0
  139. package/dist/core/modules/sync/core-sync.module.js +50 -0
  140. package/dist/core/modules/sync/core-sync.module.js.map +1 -0
  141. package/dist/core/modules/sync/core-sync.resolver.d.ts +9 -0
  142. package/dist/core/modules/sync/core-sync.resolver.js +46 -0
  143. package/dist/core/modules/sync/core-sync.resolver.js.map +1 -0
  144. package/dist/core/modules/sync/core-sync.service.d.ts +142 -0
  145. package/dist/core/modules/sync/core-sync.service.js +1208 -0
  146. package/dist/core/modules/sync/core-sync.service.js.map +1 -0
  147. package/dist/core/modules/sync/interfaces/core-sync.interfaces.d.ts +56 -0
  148. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js +2 -0
  149. package/dist/core/modules/sync/interfaces/core-sync.interfaces.js.map +1 -0
  150. package/dist/core/modules/sync/models/core-sync-hint.model.d.ts +4 -0
  151. package/dist/core/modules/sync/models/core-sync-hint.model.js +30 -0
  152. package/dist/core/modules/sync/models/core-sync-hint.model.js.map +1 -0
  153. package/dist/core/modules/tenant/core-tenant.guard.js +8 -3
  154. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  155. package/dist/core.module.js +14 -0
  156. package/dist/core.module.js.map +1 -1
  157. package/dist/index.d.ts +16 -0
  158. package/dist/index.js +16 -0
  159. package/dist/index.js.map +1 -1
  160. package/dist/server/modules/error-code/error-codes.d.ts +6 -0
  161. package/dist/server/modules/sync-note/inputs/sync-note-create.input.d.ts +4 -0
  162. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js +27 -0
  163. package/dist/server/modules/sync-note/inputs/sync-note-create.input.js.map +1 -0
  164. package/dist/server/modules/sync-note/inputs/sync-note.input.d.ts +6 -0
  165. package/dist/server/modules/sync-note/inputs/sync-note.input.js +37 -0
  166. package/dist/server/modules/sync-note/inputs/sync-note.input.js.map +1 -0
  167. package/dist/server/modules/sync-note/sync-note.model.d.ts +10 -0
  168. package/dist/server/modules/sync-note/sync-note.model.js +73 -0
  169. package/dist/server/modules/sync-note/sync-note.model.js.map +1 -0
  170. package/dist/server/modules/sync-note/sync-note.module.d.ts +2 -0
  171. package/dist/server/modules/sync-note/sync-note.module.js +21 -0
  172. package/dist/server/modules/sync-note/sync-note.module.js.map +1 -0
  173. package/dist/server/modules/sync-note/sync-note.service.d.ts +10 -0
  174. package/dist/server/modules/sync-note/sync-note.service.js +35 -0
  175. package/dist/server/modules/sync-note/sync-note.service.js.map +1 -0
  176. package/docs/REQUEST-LIFECYCLE.md +3 -2
  177. package/docs/choosing-a-database.md +7 -4
  178. package/docs/offline-sync-protocol.md +410 -0
  179. package/docs/security-overrides.md +14 -0
  180. package/migration-guides/12.0.x-to-12.1.x.md +9 -4
  181. package/migration-guides/12.1.0-to-12.1.1.md +125 -0
  182. package/migration-guides/12.1.x-to-12.2.x.md +154 -0
  183. package/package.json +10 -8
  184. package/src/core/common/helpers/file.helper.ts +7 -5
  185. package/src/core/common/helpers/graphql-ws-connection.helper.ts +3 -2
  186. package/src/core/common/interfaces/cron-job-config.interface.ts +7 -2
  187. package/src/core/common/interfaces/server-options.interface.ts +149 -0
  188. package/src/core/common/interfaces/service-options.interface.ts +15 -0
  189. package/src/core/common/persistence/core-persistence.module.ts +21 -2
  190. package/src/core/common/persistence/data-source.runtime.ts +22 -23
  191. package/src/core/common/persistence/data-store.interface.ts +65 -12
  192. package/src/core/common/persistence/id-format.ts +26 -0
  193. package/src/core/common/persistence/model-descriptor.interface.ts +12 -0
  194. package/src/core/common/persistence/model-metadata.registry.ts +31 -2
  195. package/src/core/common/persistence/mongo/sync-schema.plugin.ts +508 -0
  196. package/src/core/common/persistence/mongoose-data-store.ts +171 -35
  197. package/src/core/common/persistence/persisted-model.decorator.ts +26 -0
  198. package/src/core/common/persistence/persistence-runtime.registry.ts +16 -5
  199. package/src/core/common/persistence/policies/sync.policy.ts +121 -0
  200. package/src/core/common/persistence/sql/mysql/mysql-data-store.ts +108 -30
  201. package/src/core/common/persistence/sql/mysql/mysql-schema.compiler.ts +107 -4
  202. package/src/core/common/persistence/sql/mysql/mysql-schema.migrator.ts +194 -3
  203. package/src/core/common/persistence/sql/postgres/postgres-schema.compiler.ts +74 -2
  204. package/src/core/common/persistence/sql/postgres/postgres-schema.migrator.ts +139 -7
  205. package/src/core/common/persistence/sql/sql-data-store.ts +325 -44
  206. package/src/core/common/persistence/sql/sql-dialect.ts +38 -1
  207. package/src/core/common/persistence/sql/sql-errors.ts +5 -1
  208. package/src/core/common/persistence/sql/sql-executor.interface.ts +4 -1
  209. package/src/core/common/persistence/sync-access.registry.ts +50 -0
  210. package/src/core/common/persistence/sync-model.ts +270 -0
  211. package/src/core/common/services/core-cron-jobs.service.ts +441 -27
  212. package/src/core/common/services/core-redis-pubsub.ts +8 -6
  213. package/src/core/common/services/core-redis.service.ts +99 -0
  214. package/src/core/common/services/crud.service.ts +257 -19
  215. package/src/core/modules/better-auth/README.md +25 -1
  216. package/src/core/modules/better-auth/better-auth-sql.helper.ts +56 -24
  217. package/src/core/modules/better-auth/better-auth.config.ts +4 -3
  218. package/src/core/modules/better-auth/core-better-auth-sql.store.ts +28 -21
  219. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +122 -6
  220. package/src/core/modules/better-auth/core-better-auth.module.ts +2 -2
  221. package/src/core/modules/error-code/error-codes.ts +59 -0
  222. package/src/core/modules/hub/README.md +5 -2
  223. package/src/core/modules/hub/hub-buffer.ts +13 -7
  224. package/src/core/modules/migrate/README.md +20 -11
  225. package/src/core/modules/migrate/index.ts +1 -0
  226. package/src/core/modules/migrate/migration-runner.ts +68 -3
  227. package/src/core/modules/migrate/mongo-state-store.ts +7 -30
  228. package/src/core/modules/migrate/sql-migration.helper.ts +141 -7
  229. package/src/core/modules/sync/INTEGRATION-CHECKLIST.md +95 -0
  230. package/src/core/modules/sync/README.md +188 -0
  231. package/src/core/modules/sync/SECURITY.md +65 -0
  232. package/src/core/modules/sync/core-sync-base.controller.ts +43 -0
  233. package/src/core/modules/sync/core-sync-cursor.ts +117 -0
  234. package/src/core/modules/sync/core-sync-hints.controller.ts +52 -0
  235. package/src/core/modules/sync/core-sync-hints.service.ts +178 -0
  236. package/src/core/modules/sync/core-sync-infrastructure.models.ts +40 -0
  237. package/src/core/modules/sync/core-sync-mutation.store.ts +146 -0
  238. package/src/core/modules/sync/core-sync-state.store.ts +89 -0
  239. package/src/core/modules/sync/core-sync.constants.ts +51 -0
  240. package/src/core/modules/sync/core-sync.controller.ts +68 -0
  241. package/src/core/modules/sync/core-sync.decorators.ts +84 -0
  242. package/src/core/modules/sync/core-sync.module.ts +75 -0
  243. package/src/core/modules/sync/core-sync.resolver.ts +35 -0
  244. package/src/core/modules/sync/core-sync.service.ts +1728 -0
  245. package/src/core/modules/sync/interfaces/core-sync.interfaces.ts +85 -0
  246. package/src/core/modules/sync/models/core-sync-hint.model.ts +18 -0
  247. package/src/core/modules/tenant/README.md +1 -0
  248. package/src/core/modules/tenant/core-tenant.guard.ts +16 -3
  249. package/src/core.module.ts +25 -0
  250. package/src/index.ts +21 -0
  251. package/src/server/modules/sync-note/inputs/sync-note-create.input.ts +14 -0
  252. package/src/server/modules/sync-note/inputs/sync-note.input.ts +23 -0
  253. package/src/server/modules/sync-note/sync-note.model.ts +77 -0
  254. package/src/server/modules/sync-note/sync-note.module.ts +16 -0
  255. package/src/server/modules/sync-note/sync-note.service.ts +25 -0
@@ -8,6 +8,7 @@ import { CRON_LOCK_COLLECTION, cronMongoLocks, defaultSqlLockStore } from '../pe
8
8
  import { Falsy } from '../types/falsy.type.js';
9
9
  import { getCronJobsInfrastructure } from './core-cron-jobs.registry.js';
10
10
 
11
+ import type { LockStore } from '../persistence/lock-store.interface.js';
11
12
  import type { CoreRedisService } from './core-redis.service.js';
12
13
  import type { Redis } from 'ioredis';
13
14
  import type { Connection } from 'mongoose';
@@ -44,6 +45,28 @@ const CRON_LOCK_TTL_SECONDS = 3600;
44
45
  */
45
46
  const CRON_INIT_LOCK_TTL_SECONDS = 300;
46
47
 
48
+ /**
49
+ * Lifetime of the lease a `runParallel: false` job holds while it RUNS, renewed every third of it.
50
+ *
51
+ * Short, so a replica that dies mid-run frees the job within a minute; the heartbeat keeps it held for as long as the
52
+ * run takes.
53
+ */
54
+ const CRON_RUNNING_LOCK_TTL_SECONDS = 60;
55
+
56
+ /** Extends a Redis lease only for its owner */
57
+ const REDIS_RENEW_SCRIPT =
58
+ "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end";
59
+
60
+ /** Deletes a Redis lease only for its owner */
61
+ const REDIS_RELEASE_SCRIPT =
62
+ "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
63
+
64
+ /**
65
+ * Jobs one `clean()` call removes when the backlog of earlier versions is trimmed: about 6.5 ms of Redis time each, so
66
+ * Redis keeps answering other clients between the calls
67
+ */
68
+ const CRON_JOB_TRIM_BATCH = 1000;
69
+
47
70
  /**
48
71
  * Name of the shared BullMQ queue carrying all distributed cron jobs
49
72
  */
@@ -78,11 +101,13 @@ export interface CoreCronJobsOptions {
78
101
  /**
79
102
  * Cron jobs service to extend
80
103
  *
81
- * Jobs are deduplicated across replicas unless `distributed: false` is set:
104
+ * Jobs are deduplicated across replicas when they are `distributed` — the default with `redis` configured; without
105
+ * it a job runs on every replica unless it sets `distributed: true`:
82
106
  * - Redis configured + `bullmq` installed: the job becomes a BullMQ job scheduler,
83
107
  * so exactly one worker in the cluster picks up each tick.
84
108
  * - Otherwise: the local cron timer stays, but each tick first acquires a lease
85
- * (Redis `SET NX` when available, else a `cron-locks` document in MongoDB).
109
+ * (Redis `SET NX` when available, else the SQL lease table of the default source, else a `cron-locks` document in
110
+ * MongoDB).
86
111
  */
87
112
  export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicationShutdown {
88
113
  /**
@@ -98,9 +123,39 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
98
123
  */
99
124
  runningJobs: Record<string, Date[]> = {};
100
125
 
126
+ /**
127
+ * Options of every job a BullMQ scheduler creates (DEV-3896). One attempt: a failed tick is not repeated, as with the
128
+ * local timer — the next tick runs on schedule, and a retry could repeat side effects of a run that failed halfway.
129
+ * Completed and failed jobs are trimmed, one job per tick would otherwise stay in Redis for good. A subclass may set
130
+ * its own.
131
+ */
132
+ protected bullJobOptions: Record<string, unknown> = {
133
+ attempts: 1,
134
+ removeOnComplete: { count: 100 },
135
+ removeOnFail: { count: 500 },
136
+ };
137
+
101
138
  /** Shared BullMQ queue, created only in BullMQ mode */
102
139
  protected bullQueue?: any;
103
140
 
141
+ /** Redis connection of the shared queue; its `ready` event triggers the scheduler check after a reconnect */
142
+ protected bullQueueConnection?: any;
143
+
144
+ /** Names of the jobs registered as BullMQ job schedulers, i.e. the schedules that must exist in Redis */
145
+ protected bullScheduledJobs = new Set<string>();
146
+
147
+ /** Running scheduler check, so a reconnect and the timer never check twice at once */
148
+ protected bullSchedulerCheck?: Promise<void>;
149
+
150
+ /**
151
+ * How often to check that every BullMQ scheduler still exists in Redis, in ms. `0` switches the time-based check
152
+ * off; the check after a reconnect of the queue connection stays. See restoreBullSchedulers().
153
+ */
154
+ protected bullSchedulerCheckIntervalMs = 60_000;
155
+
156
+ /** Timer of the periodic scheduler check */
157
+ protected bullSchedulerCheckTimer?: ReturnType<typeof setInterval>;
158
+
104
159
  /** Shared BullMQ worker, created only in BullMQ mode */
105
160
  protected bullWorker?: any;
106
161
 
@@ -120,6 +175,14 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
120
175
  */
121
176
  protected readonly leaseOwner = `cron:${process.pid}:${Math.random().toString(36).slice(2)}`;
122
177
 
178
+ /**
179
+ * Listener for the `ready` event of the queue connection. An arrow function, so shutdown can remove exactly this
180
+ * listener. `void` is safe: restoreBullSchedulers() catches every failure per job and never rejects.
181
+ */
182
+ protected readonly onBullQueueReady = (): void => {
183
+ void this.restoreBullSchedulers();
184
+ };
185
+
123
186
  // ===================================================================================================================
124
187
  // Initializations
125
188
  // ===================================================================================================================
@@ -156,6 +219,9 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
156
219
  * Close the BullMQ worker and queue
157
220
  */
158
221
  async onApplicationShutdown(): Promise<void> {
222
+ this.stopBullSchedulerCheck();
223
+ // A check already running finishes its current job before the queue closes; it never rejects.
224
+ await this.bullSchedulerCheck;
159
225
  await this.drainRunningJobs();
160
226
 
161
227
  // Settled, not sequential-and-throwing: a rejection from the worker's close would abort the
@@ -181,7 +247,12 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
181
247
  }
182
248
 
183
249
  const bullMqActive = await this.initBullMq();
184
- const bullScheduled = new Set<string>();
250
+ if (bullMqActive) {
251
+ await this.trimBullJobBacklog();
252
+ }
253
+ this.bullScheduledJobs.clear();
254
+ /** Jobs that run on every replica because no redis is configured and they did not choose either way */
255
+ const localByDefault: string[] = [];
185
256
 
186
257
  // Init cron jobs
187
258
  for (const [name, CronExpressionOrConfig] of Object.entries(this.cronJobs)) {
@@ -239,7 +310,7 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
239
310
  if (bullMqActive && distributed) {
240
311
  if (typeof config.cronTime === 'string' && !config.utcOffset) {
241
312
  await this.registerBullJob(name, config);
242
- bullScheduled.add(name);
313
+ this.bullScheduledJobs.add(name);
243
314
  continue;
244
315
  }
245
316
  cronLogger.debug(
@@ -251,10 +322,15 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
251
322
 
252
323
  // Init local cron job
253
324
  this.registerLocalJob(name, config, distributed);
325
+ if (!distributed && conf.distributed === undefined) {
326
+ localByDefault.push(name);
327
+ }
254
328
  }
255
329
 
256
- await this.removeStaleBullSchedulers(bullScheduled);
330
+ this.logLocalJobsHint(localByDefault);
331
+ await this.removeStaleBullSchedulers(this.bullScheduledJobs);
257
332
  this.startBullWorker();
333
+ this.startBullSchedulerCheck();
258
334
  }
259
335
 
260
336
  /**
@@ -352,6 +428,9 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
352
428
  .createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 })
353
429
  .then(() => undefined)
354
430
  .catch((e) => {
431
+ // Tried again on the next tick: cached as done, the index would never exist and `cron-locks` would grow
432
+ // without bound (DEV-3896). The lease itself does not need it — expiry is decided by expiresAt.
433
+ this.leaseIndexReady = undefined;
355
434
  cronLogger.error(`Could not create TTL index on cron lock collection: ${errorMessage(e)}`, errorStack(e));
356
435
  });
357
436
  }
@@ -436,11 +515,17 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
436
515
  // same outage a real rejection that the caller can report.
437
516
  const queueOptions = { commandTimeout: 10_000, maxRetriesPerRequest: 3 };
438
517
 
518
+ const queueConnection = service.createClient('cron-queue', queueOptions);
439
519
  this.bullQueue = new bullmq.Queue(CRON_QUEUE_NAME, {
440
- connection: service.createClient('cron-queue', queueOptions),
520
+ connection: queueConnection,
441
521
  prefix,
442
522
  });
443
523
 
524
+ // A Redis or Valkey restart without persistence loses every scheduler, and the reconnect is the first moment a
525
+ // replica can notice it — see restoreBullSchedulers().
526
+ this.bullQueueConnection = queueConnection;
527
+ queueConnection.on?.('ready', this.onBullQueueReady);
528
+
444
529
  const workerConnection = service.createClient('cron-worker', blockingOptions);
445
530
  // autorun: false — initBullMq() runs BEFORE the loop that fills `jobConfigs`, so a worker
446
531
  // that starts consuming here would hand `runTick` a job it has no config for. That path
@@ -489,6 +574,44 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
489
574
  }
490
575
  }
491
576
 
577
+ /**
578
+ * Trim the finished cron jobs earlier versions kept, in bounded batches, before the schedulers get `bullJobOptions`.
579
+ *
580
+ * Up to 12.1.0 every completed and failed tick stayed in Redis. BullMQ trims by `count` in ONE Lua call without a
581
+ * limit, so the first tick to finish under the new options would delete the whole backlog at once — measured 2 s for
582
+ * 300k jobs and 4.4 s for 700k, during which Redis answers nobody and every command of every replica times out
583
+ * (DEV-3896). `clean()` removes the oldest first and honours its limit. Once the backlog is gone this is one count
584
+ * per type at bootstrap. A failure is reported and does not stop the jobs.
585
+ */
586
+ protected async trimBullJobBacklog(): Promise<void> {
587
+ const keep = {
588
+ completed: (this.bullJobOptions.removeOnComplete as { count?: number } | undefined)?.count,
589
+ failed: (this.bullJobOptions.removeOnFail as { count?: number } | undefined)?.count,
590
+ };
591
+ for (const type of ['completed', 'failed'] as const) {
592
+ const limit = keep[type];
593
+ if (typeof limit !== 'number' || limit < 0) {
594
+ continue;
595
+ }
596
+ try {
597
+ let excess = (await this.bullQueue.getJobCountByTypes(type)) - limit;
598
+ if (excess <= 0) {
599
+ continue;
600
+ }
601
+ cronLogger.log(`Trimming ${excess} ${type} cron jobs an earlier version kept in Redis`);
602
+ while (excess > 0) {
603
+ const removed: string[] = await this.bullQueue.clean(0, Math.min(CRON_JOB_TRIM_BATCH, excess), type);
604
+ if (!removed?.length) {
605
+ break;
606
+ }
607
+ excess -= removed.length;
608
+ }
609
+ } catch (error) {
610
+ cronLogger.warn(`Trimming the ${type} cron jobs failed: ${errorMessage(error)}`);
611
+ }
612
+ }
613
+ }
614
+
492
615
  /**
493
616
  * Drop schedulers in the shared queue that this version no longer registers.
494
617
  *
@@ -513,6 +636,7 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
513
636
  for (const scheduler of await this.bullQueue.getJobSchedulers()) {
514
637
  if (scheduler?.key && known.has(scheduler.key) && !active.has(scheduler.key)) {
515
638
  await this.bullQueue.removeJobScheduler(scheduler.key);
639
+ await this.markBullSchedulerRemoved(scheduler.key);
516
640
  cronLogger.log(`CronJob scheduler ${scheduler.key} removed — no longer configured`);
517
641
  }
518
642
  }
@@ -522,6 +646,121 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
522
646
  }
523
647
  }
524
648
 
649
+ /**
650
+ * Register every BullMQ scheduler again that Redis no longer has.
651
+ *
652
+ * `upsertJobScheduler` writes a schedule into Redis once, at bootstrap. A Redis or Valkey restart without
653
+ * persistence, a FLUSHDB or an eviction under `maxmemory` removes it, and before 12.1.1 nothing registered it
654
+ * again: no replica ran the job until the next deploy, without a log line, because the worker kept polling an empty
655
+ * queue. This runs after every reconnect of the queue connection and every `bullSchedulerCheckIntervalMs`.
656
+ *
657
+ * Only MISSING schedulers are registered. An upsert of an existing one is not free: BullMQ removes the job the
658
+ * scheduler planned last, also one that is already due and waits for a free worker slot, and plans the next one
659
+ * from now, so the due tick would be lost. Replicas that find a scheduler missing at the same moment may both
660
+ * register it; the second upsert replaces the first one's job with an identical one.
661
+ *
662
+ * Which schedulers exist comes from the LISTING, not from `getJobScheduler()`: that method does not exist before
663
+ * bullmq 5.23, and up to 5.40.2 it answers a deleted scheduler with an object instead of `undefined` — both inside
664
+ * the peer range. A scheduler a newer version removed on purpose (see markBullSchedulerRemoved()) stays removed.
665
+ *
666
+ * Only the schedule comes back: `runOnInit` and the registry entry belong to bootstrap. A tick that fell due while
667
+ * the scheduler was gone is not run.
668
+ */
669
+ protected restoreBullSchedulers(): Promise<void> {
670
+ if (!this.bullSchedulerCheck) {
671
+ this.bullSchedulerCheck = (async () => {
672
+ if (!this.bullQueue || !this.bullScheduledJobs.size) {
673
+ return;
674
+ }
675
+ const present = await this.listBullSchedulerKeys();
676
+ if (!present) {
677
+ return;
678
+ }
679
+ for (const name of this.bullScheduledJobs) {
680
+ const config = this.jobConfigs[name];
681
+ if (!this.bullQueue || !config || present.has(name)) {
682
+ continue;
683
+ }
684
+ try {
685
+ if (await this.isBullSchedulerRemoved(name)) {
686
+ continue;
687
+ }
688
+ await this.upsertBullScheduler(name, config);
689
+ cronLogger.warn(`CronJob scheduler ${name} was missing in Redis and has been registered again`);
690
+ } catch (error) {
691
+ cronLogger.warn(`Failed to check the BullMQ scheduler of CronJob ${name}: ${errorMessage(error)}`);
692
+ }
693
+ }
694
+ })().finally(() => {
695
+ this.bullSchedulerCheck = undefined;
696
+ });
697
+ }
698
+ return this.bullSchedulerCheck;
699
+ }
700
+
701
+ /** Keys of the schedulers the shared queue holds, or `undefined` when the listing failed (logged) */
702
+ protected async listBullSchedulerKeys(): Promise<Set<string> | undefined> {
703
+ try {
704
+ const schedulers: { key?: string }[] = await this.bullQueue.getJobSchedulers();
705
+ return new Set(schedulers.map((scheduler) => scheduler?.key).filter((key): key is string => !!key));
706
+ } catch (error) {
707
+ cronLogger.warn(`Failed to list the BullMQ job schedulers: ${errorMessage(error)}`);
708
+ return undefined;
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Redis key marking a scheduler that removeStaleBullSchedulers() removed on purpose
714
+ *
715
+ * In a rolling deploy the new version's bootstrap removes the scheduler of a job it no longer runs through BullMQ,
716
+ * while replicas of the old version still have that job in `bullScheduledJobs`. Their check finds the scheduler
717
+ * missing and would register it again; for a job moved to a local timer that is a second run every tick until the
718
+ * next deploy. The marker tells them the scheduler is gone on purpose. registerBullJob() deletes it, and a real data
719
+ * loss takes it along with the schedulers, so restoring still works then.
720
+ */
721
+ protected bullSchedulerRemovedKey(name: string): string | undefined {
722
+ return this.getRedisService()?.key('cron-scheduler-removed', name);
723
+ }
724
+
725
+ /** Whether a newer configuration removed this job's scheduler on purpose; see bullSchedulerRemovedKey() */
726
+ protected async isBullSchedulerRemoved(name: string): Promise<boolean> {
727
+ const key = this.bullSchedulerRemovedKey(name);
728
+ return !!key && !!(await this.getRedisClient()?.get(key));
729
+ }
730
+
731
+ /** Record that this job's scheduler was removed on purpose; see bullSchedulerRemovedKey() */
732
+ protected async markBullSchedulerRemoved(name: string): Promise<void> {
733
+ const key = this.bullSchedulerRemovedKey(name);
734
+ if (key) {
735
+ await this.getRedisClient()?.set(key, new Date().toISOString());
736
+ }
737
+ }
738
+
739
+ /**
740
+ * Arm the periodic scheduler check. Only the reconnect notices a Redis restart; a FLUSHDB or an eviction closes no
741
+ * connection, so only a timer finds those. Unref'd, so the timer never keeps a process alive on its own.
742
+ */
743
+ protected startBullSchedulerCheck(): void {
744
+ if (!this.bullQueue || !this.bullScheduledJobs.size || !(this.bullSchedulerCheckIntervalMs > 0)) {
745
+ return;
746
+ }
747
+ this.bullSchedulerCheckTimer = setInterval(this.onBullQueueReady, this.bullSchedulerCheckIntervalMs);
748
+ this.bullSchedulerCheckTimer.unref?.();
749
+ }
750
+
751
+ /**
752
+ * Stop the scheduler check before the queue closes, so neither the timer nor a reconnect touches a closed queue
753
+ */
754
+ protected stopBullSchedulerCheck(): void {
755
+ if (this.bullSchedulerCheckTimer) {
756
+ clearInterval(this.bullSchedulerCheckTimer);
757
+ this.bullSchedulerCheckTimer = undefined;
758
+ }
759
+ this.bullQueueConnection?.off?.('ready', this.onBullQueueReady);
760
+ this.bullQueueConnection = undefined;
761
+ this.bullScheduledJobs.clear();
762
+ }
763
+
525
764
  /**
526
765
  * Start consuming once every job config is registered.
527
766
  *
@@ -552,11 +791,12 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
552
791
  * Register a job as BullMQ job scheduler
553
792
  */
554
793
  protected async registerBullJob(name: string, config: CronJobConfigWithTimeZone): Promise<void> {
555
- await this.bullQueue.upsertJobScheduler(
556
- name,
557
- { pattern: config.cronTime as string, tz: config.timeZone ?? undefined },
558
- { name },
559
- );
794
+ // This version runs the job through BullMQ, so a removal marker an earlier configuration left no longer applies.
795
+ const removedKey = this.bullSchedulerRemovedKey(name);
796
+ if (removedKey) {
797
+ await this.getRedisClient()?.del(removedKey);
798
+ }
799
+ await this.upsertBullScheduler(name, config);
560
800
 
561
801
  // A BullMQ scheduler never fires immediately, so runOnInit is emulated locally under a lease.
562
802
  // Not awaited, so bootstrap is not blocked by the first run — same as the local timer path.
@@ -594,14 +834,31 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
594
834
  }
595
835
  }
596
836
 
837
+ /**
838
+ * Write the job's schedule into Redis — at bootstrap and when restoreBullSchedulers() finds it missing
839
+ */
840
+ protected upsertBullScheduler(name: string, config: CronJobConfigWithTimeZone): Promise<unknown> {
841
+ return this.bullQueue.upsertJobScheduler(
842
+ name,
843
+ { pattern: config.cronTime as string, tz: config.timeZone ?? undefined },
844
+ { name, opts: this.bullJobOptions },
845
+ );
846
+ }
847
+
597
848
  /**
598
849
  * Register a job as local cron timer
599
850
  */
600
851
  protected registerLocalJob(name: string, config: CronJobConfigWithTimeZone, distributed: boolean): void {
601
852
  // Filled after construction; runOnInit fires DURING construction, where the fire time is simply "now"
602
853
  const ref: { job?: CronJob } = {};
603
- /** Fire time of the last SCHEDULED tick, to tell a manual trigger apart from a repeat */
604
- let lastLeasedFireTime: number | undefined;
854
+ /**
855
+ * The schedule instant the next scheduled fire belongs to, read when the job is armed and after every scheduled
856
+ * fire. cron's lastDate() is NOT that instant: cron 4 sets it to the moment its timer fired, so two replicas whose
857
+ * timers fire on either side of a second boundary computed different lease keys and both ran the tick (DEV-3896).
858
+ */
859
+ let scheduledFor: number | undefined;
860
+ /** lastDate() as of the last scheduled fire, to tell the next scheduled fire from a manual one */
861
+ let lastFired: number | undefined;
605
862
 
606
863
  const job = new CronJob(
607
864
  config.cronTime,
@@ -612,21 +869,23 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
612
869
  // the fleet-wide init lease, because "startup" is not an instant the replicas
613
870
  // agree on; a scheduled tick takes a lease keyed on the schedule instant, which
614
871
  // every replica computes identically.
615
- const scheduled = ref.job?.lastDate();
616
- if (!scheduled) {
872
+ if (!ref.job) {
617
873
  this.runInitTick(name);
618
874
  return;
619
875
  }
620
- // `cron` only advances lastDate() on a SCHEDULED fire, so a manual fireOnTick() (the
621
- // Hub's "Run now") reports the previous tick's instant — whose lease this replica
622
- // already holds. Reusing it would lose the race against itself and silently do
623
- // nothing, while the operator sees a success response. A manual run gets its own key.
624
- if (scheduled.getTime() === lastLeasedFireTime) {
876
+ // cron advances lastDate() only when its timer fires, never before the instant; a manual fireOnTick() (the
877
+ // Hub's "Run now") leaves it as it was. Keyed on an instant, a manual run would take a lease another replica
878
+ // may already hold (the operator's run silently skipped) or the next tick's (that tick skipped everywhere),
879
+ // so it gets its own key.
880
+ const fired = ref.job.lastDate()?.getTime();
881
+ if (fired === undefined || fired === lastFired || scheduledFor === undefined || fired < scheduledFor) {
625
882
  await this.runDistributedTick(name, 'manual');
626
883
  return;
627
884
  }
628
- lastLeasedFireTime = scheduled.getTime();
629
- await this.runDistributedTick(name, scheduled);
885
+ lastFired = fired;
886
+ const instant = this.currentScheduledInstant(ref.job, scheduledFor, fired);
887
+ scheduledFor = this.nextScheduledInstant(ref.job);
888
+ await this.runDistributedTick(name, new Date(instant));
630
889
  return;
631
890
  }
632
891
  await this.runTick(name);
@@ -640,6 +899,7 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
640
899
  config.unrefTimeout,
641
900
  );
642
901
  ref.job = job;
902
+ scheduledFor = this.nextScheduledInstant(job);
643
903
  this.schedulerRegistry.addCronJob(name, job);
644
904
  if (this.config.log && this.schedulerRegistry.getCronJob(name)) {
645
905
  cronLogger.log(`CronJob ${name} initialized with "${config.cronTime}"`);
@@ -697,21 +957,175 @@ export abstract class CoreCronJobs implements OnApplicationBootstrap, OnApplicat
697
957
  const date = new Date();
698
958
  this.runningJobs[name].push(date);
699
959
 
700
- // Execute the job and wait until job process is done
960
+ let lease: false | undefined | { release: () => Promise<void> };
701
961
  try {
962
+ // runParallel above is per process. A job that must not run twice at once holds a lease for the length of the
963
+ // run, so a second replica skips its next tick instead of starting it in parallel (DEV-3896).
964
+ if (this.runsExclusively(config)) {
965
+ lease = await this.acquireRunningLease(name);
966
+ if (lease === false) {
967
+ this.logSkippedTick(`${name}:running`);
968
+ return;
969
+ }
970
+ }
971
+
972
+ // Execute the job and wait until job process is done
702
973
  await this[name]();
703
974
  } catch (e) {
704
- // Remove job from running list
705
- this.runningJobs[name] = this.runningJobs[name].filter((item) => item !== date);
706
975
  if (config.throwException) {
707
976
  throw e;
708
977
  } else {
709
978
  cronLogger.error(`CronJob ${name} failed: ${errorMessage(e)}`, errorStack(e));
710
979
  }
980
+ } finally {
981
+ // Remove job from running list
982
+ this.runningJobs[name] = this.runningJobs[name].filter((item) => item !== date);
983
+ if (lease) {
984
+ await lease.release();
985
+ }
986
+ }
987
+ }
988
+
989
+ /**
990
+ * Whether a job may run only once at a time across all replicas: a distributed job with `runParallel: false` (or
991
+ * `1`). A larger `runParallel` stays a per-process limit.
992
+ */
993
+ protected runsExclusively(config: CronJobConfigWithTimeZone): boolean {
994
+ return config.distributed !== false && (!config.runParallel || config.runParallel === 1);
995
+ }
996
+
997
+ /**
998
+ * Take the lease a running job holds, kept alive by a heartbeat until `release()`.
999
+ *
1000
+ * Returns `false` when another replica runs the job. Without a lease backend, or when the backend fails, the job runs
1001
+ * anyway (fail open, like the tick lease): a missing lock must never stop the job.
1002
+ */
1003
+ protected async acquireRunningLease(name: string): Promise<false | { release: () => Promise<void> }> {
1004
+ const key = `${name}:running`;
1005
+ const unlocked = { release: async () => undefined };
1006
+ let store: LockStore | undefined;
1007
+ try {
1008
+ store = await this.leaseStore();
1009
+ if (!store) {
1010
+ this.warnDedupInactive();
1011
+ return unlocked;
1012
+ }
1013
+ if (!(await store.tryAcquire(key, this.leaseOwner, CRON_RUNNING_LOCK_TTL_SECONDS))) {
1014
+ return false;
1015
+ }
1016
+ } catch (e) {
1017
+ cronLogger.error(`Cron running lease for ${key} failed, running anyway: ${errorMessage(e)}`, errorStack(e));
1018
+ return unlocked;
711
1019
  }
712
1020
 
713
- // Remove job from running list
714
- this.runningJobs[name] = this.runningJobs[name].filter((item) => item !== date);
1021
+ const held = store;
1022
+ const heartbeat = setInterval(
1023
+ () => {
1024
+ held
1025
+ .renew(key, this.leaseOwner, CRON_RUNNING_LOCK_TTL_SECONDS)
1026
+ .then((renewed) => {
1027
+ if (!renewed) {
1028
+ cronLogger.warn(`Cron running lease for ${key} was lost; another replica may start the job`);
1029
+ }
1030
+ })
1031
+ .catch((e) => cronLogger.warn(`Renewing the cron running lease for ${key} failed: ${errorMessage(e)}`));
1032
+ },
1033
+ (CRON_RUNNING_LOCK_TTL_SECONDS * 1000) / 3,
1034
+ );
1035
+ heartbeat.unref?.();
1036
+
1037
+ return {
1038
+ release: async () => {
1039
+ clearInterval(heartbeat);
1040
+ await held
1041
+ .release(key, this.leaseOwner)
1042
+ .catch((e) => cronLogger.warn(`Releasing the cron running lease for ${key} failed: ${errorMessage(e)}`));
1043
+ },
1044
+ };
1045
+ }
1046
+
1047
+ /**
1048
+ * The store for leases with an owner: Redis when available, else the SQL lease table of the default source, else
1049
+ * `cron-locks` in MongoDB; `undefined` when there is none
1050
+ */
1051
+ protected async leaseStore(): Promise<LockStore | undefined> {
1052
+ const redis = this.getRedisClient();
1053
+ if (redis) {
1054
+ return this.redisLeaseStore(redis);
1055
+ }
1056
+ const sqlLocks = await defaultSqlLockStore('cron');
1057
+ if (sqlLocks) {
1058
+ return sqlLocks;
1059
+ }
1060
+ const connection = this.getConnection();
1061
+ if (!connection) {
1062
+ return undefined;
1063
+ }
1064
+ await this.ensureLeaseIndex(connection);
1065
+ return cronMongoLocks(connection.db.collection(CRON_LOCK_COLLECTION));
1066
+ }
1067
+
1068
+ /** Leases in Redis under `<keyPrefix>:cron-lock:<key>`, renewed and released only by their owner */
1069
+ protected redisLeaseStore(redis: Redis): LockStore {
1070
+ const redisKey = (key: string) => this.getRedisService().key('cron-lock', key);
1071
+ return {
1072
+ release: async (key, owner) => (await redis.eval(REDIS_RELEASE_SCRIPT, 1, redisKey(key), owner)) === 1,
1073
+ renew: async (key, owner, ttlSeconds) =>
1074
+ (await redis.eval(REDIS_RENEW_SCRIPT, 1, redisKey(key), owner, ttlSeconds * 1000)) === 1,
1075
+ tryAcquire: async (key, owner, ttlSeconds) =>
1076
+ (await redis.set(redisKey(key), owner, 'EX', ttlSeconds, 'NX')) === 'OK',
1077
+ };
1078
+ }
1079
+
1080
+ /**
1081
+ * The instant a scheduled fire belongs to: the remembered instant, moved forward to the last one at or before the
1082
+ * fire. It stays as remembered on every regular fire; it lags only when the job did not fire for a while (stopped
1083
+ * in the Hub and started again), and keyed on that old instant the first tick after a restart would run next to
1084
+ * the replica that runs the current one. Not moved for a `utcOffset` job or a one-shot date, whose schedule this
1085
+ * cannot step through.
1086
+ */
1087
+ protected currentScheduledInstant(job: CronJob, remembered: number, fired: number): number {
1088
+ const cronTime = job.cronTime;
1089
+ if (cronTime.utcOffset !== undefined || cronTime.realDate) {
1090
+ return remembered;
1091
+ }
1092
+ let instant = remembered;
1093
+ try {
1094
+ // Bounded: a job stopped for a long time on a per-second schedule must not keep the tick busy
1095
+ for (let step = 0; step < 10_000; step++) {
1096
+ const next = cronTime.getNextDateFrom(new Date(instant), cronTime.timeZone).toMillis();
1097
+ if (next > fired) {
1098
+ break;
1099
+ }
1100
+ instant = next;
1101
+ }
1102
+ } catch {
1103
+ // cron could not step the schedule: keep what is known
1104
+ }
1105
+ return instant;
1106
+ }
1107
+
1108
+ /** The next instant the job's schedule fires at, in ms; `undefined` when cron cannot say (a past one-shot date) */
1109
+ protected nextScheduledInstant(job: CronJob): number | undefined {
1110
+ try {
1111
+ return job.nextDate().toMillis();
1112
+ } catch {
1113
+ return undefined;
1114
+ }
1115
+ }
1116
+
1117
+ /**
1118
+ * Say once at startup which jobs run on every replica because no redis is configured and they did not choose with
1119
+ * `distributed`. Info, not a warning: most projects run one replica, where this is right (DEV-3896).
1120
+ */
1121
+ protected logLocalJobsHint(names: string[]): void {
1122
+ if (!names.length || !this.config.log) {
1123
+ return;
1124
+ }
1125
+ cronLogger.log(
1126
+ `CronJobs ${names.join(', ')} run on every replica: no redis is configured. With more than one replica, ` +
1127
+ 'configure redis, or set distributed: true to take a lease in MongoDB or the SQL default source.',
1128
+ );
715
1129
  }
716
1130
 
717
1131
  /**
@@ -75,14 +75,18 @@ export class CoreRedisPubSub extends PubSubEngine implements OnModuleDestroy {
75
75
  const channel = this.channel(triggerName);
76
76
  let channelHandlers = this.handlers.get(channel);
77
77
 
78
- // First handler for this channel: subscribe on Redis
78
+ // First handler for this channel: subscribe on Redis. Registered with the Redis service, so the subscription is
79
+ // established again after every reconnect.
79
80
  if (!channelHandlers) {
80
81
  channelHandlers = new Map();
81
82
  this.handlers.set(channel, channelHandlers);
82
83
  try {
83
- await this.getSubscriber().subscribe(channel);
84
+ this.getSubscriber();
85
+ await this.redisService.subscribeChannel(channel);
84
86
  } catch (error) {
87
+ // The caller gets the error, as before, so the channel is not kept for a later reconnect either.
85
88
  this.handlers.delete(channel);
89
+ await this.redisService.releaseChannel(channel).catch(() => undefined);
86
90
  throw error;
87
91
  }
88
92
  }
@@ -109,7 +113,7 @@ export class CoreRedisPubSub extends PubSubEngine implements OnModuleDestroy {
109
113
  // Last handler for this channel: unsubscribe on Redis
110
114
  if (channelHandlers.size === 0) {
111
115
  this.handlers.delete(channel);
112
- await this.getSubscriber().unsubscribe(channel);
116
+ await this.redisService.releaseChannel(channel);
113
117
  }
114
118
  }
115
119
 
@@ -163,9 +167,7 @@ export class CoreRedisPubSub extends PubSubEngine implements OnModuleDestroy {
163
167
  subscriber.off('message', this.messageListener);
164
168
  this.messageListener = undefined;
165
169
  }
166
- if (channels.length) {
167
- await subscriber.unsubscribe(...channels);
168
- }
170
+ await Promise.all(channels.map((channel) => this.redisService.releaseChannel(channel)));
169
171
  } catch (error) {
170
172
  // Redis already gone: the connection is being torn down anyway.
171
173
  this.logger.debug(`PubSub teardown skipped: ${error instanceof Error ? error.message : 'Unknown error'}`);