@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.
- package/.claude/rules/architecture.md +1 -0
- package/.claude/rules/better-auth.md +18 -6
- package/.claude/rules/configurable-features.md +5 -4
- package/.claude/rules/package-management.md +16 -1
- package/.claude/rules/persistence.md +26 -0
- package/.claude/rules/testing.md +102 -28
- package/CLAUDE.md +3 -2
- package/FRAMEWORK-API.md +17 -2
- package/README.md +24 -3
- package/dist/core/common/helpers/file.helper.js +3 -4
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +41 -0
- package/dist/core/common/interfaces/service-options.interface.d.ts +3 -0
- package/dist/core/common/persistence/core-persistence.module.js +12 -2
- package/dist/core/common/persistence/core-persistence.module.js.map +1 -1
- package/dist/core/common/persistence/data-source.runtime.js +14 -19
- package/dist/core/common/persistence/data-source.runtime.js.map +1 -1
- package/dist/core/common/persistence/data-store.interface.d.ts +24 -9
- package/dist/core/common/persistence/id-format.d.ts +2 -0
- package/dist/core/common/persistence/id-format.js +14 -0
- package/dist/core/common/persistence/id-format.js.map +1 -1
- package/dist/core/common/persistence/model-descriptor.interface.d.ts +6 -0
- package/dist/core/common/persistence/model-metadata.registry.d.ts +2 -1
- package/dist/core/common/persistence/model-metadata.registry.js +20 -2
- package/dist/core/common/persistence/model-metadata.registry.js.map +1 -1
- package/dist/core/common/persistence/mongo/sync-schema.plugin.d.ts +18 -0
- package/dist/core/common/persistence/mongo/sync-schema.plugin.js +368 -0
- package/dist/core/common/persistence/mongo/sync-schema.plugin.js.map +1 -0
- package/dist/core/common/persistence/mongoose-data-store.d.ts +19 -9
- package/dist/core/common/persistence/mongoose-data-store.js +107 -31
- package/dist/core/common/persistence/mongoose-data-store.js.map +1 -1
- package/dist/core/common/persistence/persisted-model.decorator.d.ts +3 -0
- package/dist/core/common/persistence/persisted-model.decorator.js +9 -0
- package/dist/core/common/persistence/persisted-model.decorator.js.map +1 -1
- package/dist/core/common/persistence/persistence-runtime.registry.d.ts +5 -1
- package/dist/core/common/persistence/persistence-runtime.registry.js.map +1 -1
- package/dist/core/common/persistence/policies/sync.policy.d.ts +17 -0
- package/dist/core/common/persistence/policies/sync.policy.js +77 -0
- package/dist/core/common/persistence/policies/sync.policy.js.map +1 -0
- package/dist/core/common/persistence/sql/mysql/mysql-data-store.d.ts +12 -8
- package/dist/core/common/persistence/sql/mysql/mysql-data-store.js +82 -29
- package/dist/core/common/persistence/sql/mysql/mysql-data-store.js.map +1 -1
- package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.d.ts +10 -0
- package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js +61 -4
- package/dist/core/common/persistence/sql/mysql/mysql-schema.compiler.js.map +1 -1
- package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.d.ts +2 -0
- package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js +145 -4
- package/dist/core/common/persistence/sql/mysql/mysql-schema.migrator.js.map +1 -1
- package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.d.ts +10 -0
- package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js +41 -2
- package/dist/core/common/persistence/sql/postgres/postgres-schema.compiler.js.map +1 -1
- package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js +98 -10
- package/dist/core/common/persistence/sql/postgres/postgres-schema.migrator.js.map +1 -1
- package/dist/core/common/persistence/sql/sql-data-store.d.ts +38 -14
- package/dist/core/common/persistence/sql/sql-data-store.js +212 -38
- package/dist/core/common/persistence/sql/sql-data-store.js.map +1 -1
- package/dist/core/common/persistence/sql/sql-dialect.d.ts +2 -0
- package/dist/core/common/persistence/sql/sql-dialect.js +14 -2
- package/dist/core/common/persistence/sql/sql-dialect.js.map +1 -1
- package/dist/core/common/persistence/sql/sql-errors.d.ts +2 -1
- package/dist/core/common/persistence/sql/sql-errors.js +3 -1
- package/dist/core/common/persistence/sql/sql-errors.js.map +1 -1
- package/dist/core/common/persistence/sync-access.registry.d.ts +17 -0
- package/dist/core/common/persistence/sync-access.registry.js +8 -0
- package/dist/core/common/persistence/sync-access.registry.js.map +1 -0
- package/dist/core/common/persistence/sync-model.d.ts +29 -0
- package/dist/core/common/persistence/sync-model.js +138 -0
- package/dist/core/common/persistence/sync-model.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.service.d.ts +27 -0
- package/dist/core/common/services/core-cron-jobs.service.js +269 -13
- package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
- package/dist/core/common/services/core-redis-pubsub.js +5 -5
- package/dist/core/common/services/core-redis-pubsub.js.map +1 -1
- package/dist/core/common/services/core-redis.service.d.ts +6 -0
- package/dist/core/common/services/core-redis.service.js +57 -0
- package/dist/core/common/services/core-redis.service.js.map +1 -1
- package/dist/core/common/services/crud.service.d.ts +13 -1
- package/dist/core/common/services/crud.service.js +174 -16
- package/dist/core/common/services/crud.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth-sql.helper.d.ts +1 -0
- package/dist/core/modules/better-auth/better-auth-sql.helper.js +41 -14
- package/dist/core/modules/better-auth/better-auth-sql.helper.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-sql.store.d.ts +4 -7
- package/dist/core/modules/better-auth/core-better-auth-sql.store.js +23 -16
- package/dist/core/modules/better-auth/core-better-auth-sql.store.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +12 -2
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +82 -5
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js +1 -1
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +54 -0
- package/dist/core/modules/error-code/error-codes.js +48 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/hub/hub-buffer.js +3 -4
- package/dist/core/modules/hub/hub-buffer.js.map +1 -1
- package/dist/core/modules/migrate/index.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +6 -0
- package/dist/core/modules/migrate/migration-runner.js +28 -2
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/core/modules/migrate/mongo-state-store.d.ts +0 -1
- package/dist/core/modules/migrate/mongo-state-store.js +0 -5
- package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
- package/dist/core/modules/migrate/sql-migration.helper.d.ts +15 -1
- package/dist/core/modules/migrate/sql-migration.helper.js +90 -5
- package/dist/core/modules/migrate/sql-migration.helper.js.map +1 -1
- package/dist/core/modules/sync/core-sync-base.controller.d.ts +7 -0
- package/dist/core/modules/sync/core-sync-base.controller.js +44 -0
- package/dist/core/modules/sync/core-sync-base.controller.js.map +1 -0
- package/dist/core/modules/sync/core-sync-cursor.d.ts +19 -0
- package/dist/core/modules/sync/core-sync-cursor.js +77 -0
- package/dist/core/modules/sync/core-sync-cursor.js.map +1 -0
- package/dist/core/modules/sync/core-sync-hints.controller.d.ts +4 -0
- package/dist/core/modules/sync/core-sync-hints.controller.js +55 -0
- package/dist/core/modules/sync/core-sync-hints.controller.js.map +1 -0
- package/dist/core/modules/sync/core-sync-hints.service.d.ts +28 -0
- package/dist/core/modules/sync/core-sync-hints.service.js +155 -0
- package/dist/core/modules/sync/core-sync-hints.service.js.map +1 -0
- package/dist/core/modules/sync/core-sync-infrastructure.models.d.ts +2 -0
- package/dist/core/modules/sync/core-sync-infrastructure.models.js +17 -0
- package/dist/core/modules/sync/core-sync-infrastructure.models.js.map +1 -0
- package/dist/core/modules/sync/core-sync-mutation.store.d.ts +43 -0
- package/dist/core/modules/sync/core-sync-mutation.store.js +88 -0
- package/dist/core/modules/sync/core-sync-mutation.store.js.map +1 -0
- package/dist/core/modules/sync/core-sync-state.store.d.ts +18 -0
- package/dist/core/modules/sync/core-sync-state.store.js +64 -0
- package/dist/core/modules/sync/core-sync-state.store.js.map +1 -0
- package/dist/core/modules/sync/core-sync.constants.d.ts +28 -0
- package/dist/core/modules/sync/core-sync.constants.js +23 -0
- package/dist/core/modules/sync/core-sync.constants.js.map +1 -0
- package/dist/core/modules/sync/core-sync.controller.d.ts +8 -0
- package/dist/core/modules/sync/core-sync.controller.js +79 -0
- package/dist/core/modules/sync/core-sync.controller.js.map +1 -0
- package/dist/core/modules/sync/core-sync.decorators.d.ts +12 -0
- package/dist/core/modules/sync/core-sync.decorators.js +28 -0
- package/dist/core/modules/sync/core-sync.decorators.js.map +1 -0
- package/dist/core/modules/sync/core-sync.module.d.ts +16 -0
- package/dist/core/modules/sync/core-sync.module.js +50 -0
- package/dist/core/modules/sync/core-sync.module.js.map +1 -0
- package/dist/core/modules/sync/core-sync.resolver.d.ts +9 -0
- package/dist/core/modules/sync/core-sync.resolver.js +46 -0
- package/dist/core/modules/sync/core-sync.resolver.js.map +1 -0
- package/dist/core/modules/sync/core-sync.service.d.ts +142 -0
- package/dist/core/modules/sync/core-sync.service.js +1208 -0
- package/dist/core/modules/sync/core-sync.service.js.map +1 -0
- package/dist/core/modules/sync/interfaces/core-sync.interfaces.d.ts +56 -0
- package/dist/core/modules/sync/interfaces/core-sync.interfaces.js +2 -0
- package/dist/core/modules/sync/interfaces/core-sync.interfaces.js.map +1 -0
- package/dist/core/modules/sync/models/core-sync-hint.model.d.ts +4 -0
- package/dist/core/modules/sync/models/core-sync-hint.model.js +30 -0
- package/dist/core/modules/sync/models/core-sync-hint.model.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +8 -3
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core.module.js +14 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +6 -0
- package/dist/server/modules/sync-note/inputs/sync-note-create.input.d.ts +4 -0
- package/dist/server/modules/sync-note/inputs/sync-note-create.input.js +27 -0
- package/dist/server/modules/sync-note/inputs/sync-note-create.input.js.map +1 -0
- package/dist/server/modules/sync-note/inputs/sync-note.input.d.ts +6 -0
- package/dist/server/modules/sync-note/inputs/sync-note.input.js +37 -0
- package/dist/server/modules/sync-note/inputs/sync-note.input.js.map +1 -0
- package/dist/server/modules/sync-note/sync-note.model.d.ts +10 -0
- package/dist/server/modules/sync-note/sync-note.model.js +73 -0
- package/dist/server/modules/sync-note/sync-note.model.js.map +1 -0
- package/dist/server/modules/sync-note/sync-note.module.d.ts +2 -0
- package/dist/server/modules/sync-note/sync-note.module.js +21 -0
- package/dist/server/modules/sync-note/sync-note.module.js.map +1 -0
- package/dist/server/modules/sync-note/sync-note.service.d.ts +10 -0
- package/dist/server/modules/sync-note/sync-note.service.js +35 -0
- package/dist/server/modules/sync-note/sync-note.service.js.map +1 -0
- package/docs/REQUEST-LIFECYCLE.md +3 -2
- package/docs/choosing-a-database.md +7 -4
- package/docs/offline-sync-protocol.md +410 -0
- package/docs/security-overrides.md +14 -0
- package/migration-guides/12.0.x-to-12.1.x.md +9 -4
- package/migration-guides/12.1.0-to-12.1.1.md +125 -0
- package/migration-guides/12.1.x-to-12.2.x.md +154 -0
- package/package.json +10 -8
- package/src/core/common/helpers/file.helper.ts +7 -5
- package/src/core/common/helpers/graphql-ws-connection.helper.ts +3 -2
- package/src/core/common/interfaces/cron-job-config.interface.ts +7 -2
- package/src/core/common/interfaces/server-options.interface.ts +149 -0
- package/src/core/common/interfaces/service-options.interface.ts +15 -0
- package/src/core/common/persistence/core-persistence.module.ts +21 -2
- package/src/core/common/persistence/data-source.runtime.ts +22 -23
- package/src/core/common/persistence/data-store.interface.ts +65 -12
- package/src/core/common/persistence/id-format.ts +26 -0
- package/src/core/common/persistence/model-descriptor.interface.ts +12 -0
- package/src/core/common/persistence/model-metadata.registry.ts +31 -2
- package/src/core/common/persistence/mongo/sync-schema.plugin.ts +508 -0
- package/src/core/common/persistence/mongoose-data-store.ts +171 -35
- package/src/core/common/persistence/persisted-model.decorator.ts +26 -0
- package/src/core/common/persistence/persistence-runtime.registry.ts +16 -5
- package/src/core/common/persistence/policies/sync.policy.ts +121 -0
- package/src/core/common/persistence/sql/mysql/mysql-data-store.ts +108 -30
- package/src/core/common/persistence/sql/mysql/mysql-schema.compiler.ts +107 -4
- package/src/core/common/persistence/sql/mysql/mysql-schema.migrator.ts +194 -3
- package/src/core/common/persistence/sql/postgres/postgres-schema.compiler.ts +74 -2
- package/src/core/common/persistence/sql/postgres/postgres-schema.migrator.ts +139 -7
- package/src/core/common/persistence/sql/sql-data-store.ts +325 -44
- package/src/core/common/persistence/sql/sql-dialect.ts +38 -1
- package/src/core/common/persistence/sql/sql-errors.ts +5 -1
- package/src/core/common/persistence/sql/sql-executor.interface.ts +4 -1
- package/src/core/common/persistence/sync-access.registry.ts +50 -0
- package/src/core/common/persistence/sync-model.ts +270 -0
- package/src/core/common/services/core-cron-jobs.service.ts +441 -27
- package/src/core/common/services/core-redis-pubsub.ts +8 -6
- package/src/core/common/services/core-redis.service.ts +99 -0
- package/src/core/common/services/crud.service.ts +257 -19
- package/src/core/modules/better-auth/README.md +25 -1
- package/src/core/modules/better-auth/better-auth-sql.helper.ts +56 -24
- package/src/core/modules/better-auth/better-auth.config.ts +4 -3
- package/src/core/modules/better-auth/core-better-auth-sql.store.ts +28 -21
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +122 -6
- package/src/core/modules/better-auth/core-better-auth.module.ts +2 -2
- package/src/core/modules/error-code/error-codes.ts +59 -0
- package/src/core/modules/hub/README.md +5 -2
- package/src/core/modules/hub/hub-buffer.ts +13 -7
- package/src/core/modules/migrate/README.md +20 -11
- package/src/core/modules/migrate/index.ts +1 -0
- package/src/core/modules/migrate/migration-runner.ts +68 -3
- package/src/core/modules/migrate/mongo-state-store.ts +7 -30
- package/src/core/modules/migrate/sql-migration.helper.ts +141 -7
- package/src/core/modules/sync/INTEGRATION-CHECKLIST.md +95 -0
- package/src/core/modules/sync/README.md +188 -0
- package/src/core/modules/sync/SECURITY.md +65 -0
- package/src/core/modules/sync/core-sync-base.controller.ts +43 -0
- package/src/core/modules/sync/core-sync-cursor.ts +117 -0
- package/src/core/modules/sync/core-sync-hints.controller.ts +52 -0
- package/src/core/modules/sync/core-sync-hints.service.ts +178 -0
- package/src/core/modules/sync/core-sync-infrastructure.models.ts +40 -0
- package/src/core/modules/sync/core-sync-mutation.store.ts +146 -0
- package/src/core/modules/sync/core-sync-state.store.ts +89 -0
- package/src/core/modules/sync/core-sync.constants.ts +51 -0
- package/src/core/modules/sync/core-sync.controller.ts +68 -0
- package/src/core/modules/sync/core-sync.decorators.ts +84 -0
- package/src/core/modules/sync/core-sync.module.ts +75 -0
- package/src/core/modules/sync/core-sync.resolver.ts +35 -0
- package/src/core/modules/sync/core-sync.service.ts +1728 -0
- package/src/core/modules/sync/interfaces/core-sync.interfaces.ts +85 -0
- package/src/core/modules/sync/models/core-sync-hint.model.ts +18 -0
- package/src/core/modules/tenant/README.md +1 -0
- package/src/core/modules/tenant/core-tenant.guard.ts +16 -3
- package/src/core.module.ts +25 -0
- package/src/index.ts +21 -0
- package/src/server/modules/sync-note/inputs/sync-note-create.input.ts +14 -0
- package/src/server/modules/sync-note/inputs/sync-note.input.ts +23 -0
- package/src/server/modules/sync-note/sync-note.model.ts +77 -0
- package/src/server/modules/sync-note/sync-note.module.ts +16 -0
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
-
/**
|
|
604
|
-
|
|
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
|
-
|
|
616
|
-
if (!scheduled) {
|
|
872
|
+
if (!ref.job) {
|
|
617
873
|
this.runInitTick(name);
|
|
618
874
|
return;
|
|
619
875
|
}
|
|
620
|
-
//
|
|
621
|
-
// Hub's "Run now")
|
|
622
|
-
// already
|
|
623
|
-
//
|
|
624
|
-
|
|
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
|
-
|
|
629
|
-
|
|
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
|
-
|
|
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
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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'}`);
|