@lenne.tech/nest-server 11.32.4 → 11.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/architecture.md +16 -0
- package/.claude/rules/configurable-features.md +11 -3
- package/.claude/rules/testing.md +101 -1
- package/CLAUDE.md +5 -0
- package/FRAMEWORK-API.md +58 -3
- package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
- package/dist/core/common/helpers/content-disposition.helper.js +14 -0
- package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
- package/dist/core/common/helpers/file.helper.d.ts +7 -0
- package/dist/core/common/helpers/file.helper.js +21 -4
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
- package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
- package/dist/core/common/helpers/project-name.helper.js +38 -0
- package/dist/core/common/helpers/project-name.helper.js.map +1 -0
- package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
- package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
- package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
- package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
- package/dist/core/common/services/core-cron-jobs.service.js +278 -27
- package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
- package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
- package/dist/core/common/services/core-redis-pubsub.js +112 -0
- package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
- package/dist/core/common/services/core-redis.service.d.ts +25 -0
- package/dist/core/common/services/core-redis.service.js +154 -0
- package/dist/core/common/services/core-redis.service.js.map +1 -0
- package/dist/core/common/services/core-s3.service.d.ts +51 -0
- package/dist/core/common/services/core-s3.service.js +261 -0
- package/dist/core/common/services/core-s3.service.js.map +1 -0
- package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
- package/dist/core/common/services/rate-limit-store.d.ts +61 -0
- package/dist/core/common/services/rate-limit-store.js +235 -0
- package/dist/core/common/services/rate-limit-store.js.map +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
- package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
- package/dist/core/modules/ai/services/core-ai.service.js +24 -20
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +4 -1
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/file/core-file.controller.d.ts +11 -2
- package/dist/core/modules/file/core-file.controller.js +52 -15
- package/dist/core/modules/file/core-file.controller.js.map +1 -1
- package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
- package/dist/core/modules/file/core-file.resolver.js +23 -16
- package/dist/core/modules/file/core-file.resolver.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +41 -7
- package/dist/core/modules/file/core-file.service.js +227 -7
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
- package/dist/core/modules/file/file-metadata.helper.js +33 -0
- package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
- package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
- package/dist/core/modules/file/file-roles.helper.js +53 -0
- package/dist/core/modules/file/file-roles.helper.js.map +1 -0
- package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
- package/dist/core/modules/file/file-storage.helper.js +65 -0
- package/dist/core/modules/file/file-storage.helper.js.map +1 -0
- package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
- package/dist/core/modules/file/filesystem-file.helper.js +77 -0
- package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
- package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
- package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
- package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
- package/dist/core/modules/file/s3-file.helper.js +81 -0
- package/dist/core/modules/file/s3-file.helper.js.map +1 -0
- package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
- package/dist/core/modules/hub/core-hub.controller.js +24 -22
- package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
- package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
- package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
- package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
- package/dist/core/modules/hub/hub-buffer.js +101 -0
- package/dist/core/modules/hub/hub-buffer.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
- package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
- package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
- package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
- package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
- package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
- package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
- package/dist/core/modules/migrate/migration-runner.js +4 -0
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
- package/dist/core/modules/migrate/mongo-state-store.js +98 -9
- package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
- package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
- package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.module.js +3 -4
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
- package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
- package/dist/core/modules/tus/core-tus.controller.js +29 -3
- package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
- package/dist/core/modules/tus/core-tus.service.js +182 -28
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
- package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
- package/dist/core/modules/tus/tus-redis-locker.js +87 -0
- package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
- package/dist/core/modules/tus/tus.module.d.ts +1 -0
- package/dist/core/modules/tus/tus.module.js +30 -4
- package/dist/core/modules/tus/tus.module.js.map +1 -1
- package/dist/core.module.js +18 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/server/modules/file/file.controller.js.map +1 -1
- package/dist/server/modules/file/file.resolver.js +1 -12
- package/dist/server/modules/file/file.resolver.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +5 -1
- package/dist/server/modules/file/file.service.js +12 -3
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/server/modules/user/avatar.controller.d.ts +5 -1
- package/dist/server/modules/user/avatar.controller.js +26 -10
- package/dist/server/modules/user/avatar.controller.js.map +1 -1
- package/dist/server/modules/user/user.model.js +3 -2
- package/dist/server/modules/user/user.model.js.map +1 -1
- package/dist/server/modules/user/user.module.js +6 -2
- package/dist/server/modules/user/user.module.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +1 -1
- package/dist/server/modules/user/user.service.js +6 -13
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +57 -8
- package/docs/security-overrides.md +21 -3
- package/migration-guides/11.32.3-to-11.32.4.md +15 -3
- package/migration-guides/11.32.x-to-11.33.x.md +1551 -0
- package/package.json +74 -42
- package/src/core/common/helpers/content-disposition.helper.ts +90 -0
- package/src/core/common/helpers/file.helper.ts +53 -10
- package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
- package/src/core/common/helpers/project-name.helper.ts +71 -0
- package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
- package/src/core/common/interfaces/server-options.interface.ts +439 -2
- package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
- package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
- package/src/core/common/services/core-cron-jobs.service.ts +589 -47
- package/src/core/common/services/core-redis-pubsub.ts +183 -0
- package/src/core/common/services/core-redis.service.ts +249 -0
- package/src/core/common/services/core-s3.service.ts +465 -0
- package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
- package/src/core/common/services/rate-limit-store.ts +492 -0
- package/src/core/modules/ai/README.md +25 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
- package/src/core/modules/ai/services/core-ai.service.ts +33 -24
- package/src/core/modules/auth/core-auth.module.ts +7 -1
- package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
- package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
- package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +108 -0
- package/src/core/modules/file/README.md +277 -53
- package/src/core/modules/file/core-file.controller.ts +168 -12
- package/src/core/modules/file/core-file.resolver.ts +46 -12
- package/src/core/modules/file/core-file.service.ts +482 -16
- package/src/core/modules/file/file-metadata.helper.ts +142 -0
- package/src/core/modules/file/file-roles.helper.ts +110 -0
- package/src/core/modules/file/file-storage.helper.ts +163 -0
- package/src/core/modules/file/filesystem-file.helper.ts +184 -0
- package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
- package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
- package/src/core/modules/file/s3-file.helper.ts +209 -0
- package/src/core/modules/hub/README.md +48 -0
- package/src/core/modules/hub/core-hub.controller.ts +18 -16
- package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
- package/src/core/modules/hub/hub-buffer.ts +198 -0
- package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
- package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
- package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
- package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
- package/src/core/modules/migrate/README.md +33 -6
- package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
- package/src/core/modules/migrate/migration-runner.ts +10 -1
- package/src/core/modules/migrate/mongo-state-store.ts +179 -11
- package/src/core/modules/system-setup/README.md +7 -2
- package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
- package/src/core/modules/tenant/README.md +21 -1
- package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
- package/src/core/modules/tenant/core-tenant.module.ts +8 -4
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
- package/src/core/modules/tus/README.md +35 -2
- package/src/core/modules/tus/core-tus.controller.ts +64 -12
- package/src/core/modules/tus/core-tus.service.ts +358 -34
- package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
- package/src/core/modules/tus/tus-redis-locker.ts +139 -0
- package/src/core/modules/tus/tus.module.ts +78 -6
- package/src/core.module.ts +26 -1
- package/src/index.ts +10 -0
- package/src/main.ts +7 -1
- package/src/server/modules/file/file.controller.ts +7 -3
- package/src/server/modules/file/file.resolver.ts +5 -17
- package/src/server/modules/file/file.service.ts +42 -3
- package/src/server/modules/user/avatar.controller.ts +39 -9
- package/src/server/modules/user/user.model.ts +9 -3
- package/src/server/modules/user/user.module.ts +12 -3
- package/src/server/modules/user/user.service.ts +20 -17
- package/src/test/README.md +7 -2
- package/src/core/common/helpers/file.helper.spec.ts +0 -145
- package/src/core/common/helpers/logging.helper.spec.ts +0 -61
- package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
- package/src/core/common/services/brevo.service.spec.ts +0 -266
- package/src/core/modules/file/core-file.controller.spec.ts +0 -164
- package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
- package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
- package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
- package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
- package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
- package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
- package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
- package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
- package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
- package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
|
@@ -7,12 +7,14 @@ import {
|
|
|
7
7
|
LogLevel,
|
|
8
8
|
OnModuleDestroy,
|
|
9
9
|
OnModuleInit,
|
|
10
|
+
Optional,
|
|
10
11
|
} from '@nestjs/common';
|
|
11
12
|
import { inspect } from 'util';
|
|
12
13
|
|
|
13
14
|
import { redactSensitiveText } from '../../../common/helpers/logging.helper';
|
|
15
|
+
import { CoreRedisService } from '../../../common/services/core-redis.service';
|
|
14
16
|
import { HUB_CONFIG } from '../hub.constants';
|
|
15
|
-
import {
|
|
17
|
+
import { HubBuffer } from '../hub-buffer';
|
|
16
18
|
import { HubLogRecord, HubLogsData } from '../interfaces/hub-panels.interface';
|
|
17
19
|
import { ResolvedHubConfig } from '../interfaces/hub-config.interface';
|
|
18
20
|
|
|
@@ -90,22 +92,32 @@ class HubDelegatingLogger implements LoggerService {
|
|
|
90
92
|
*/
|
|
91
93
|
@Injectable()
|
|
92
94
|
export class HubLogBufferService implements OnModuleDestroy, OnModuleInit {
|
|
93
|
-
private buffer?:
|
|
95
|
+
private buffer?: HubBuffer<BufferedLog>;
|
|
94
96
|
private cfg?: Exclude<ResolvedHubConfig['collectors']['logs'], false>;
|
|
95
97
|
private delegating?: HubDelegatingLogger;
|
|
96
98
|
private excludeContexts = new Set<string>();
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Set by onModuleDestroy. The Nest Logger override is PROCESS-GLOBAL, and with a shutdown
|
|
102
|
+
* delay the HTTP server keeps serving while the app drains — so a Hub poll arriving in that
|
|
103
|
+
* window could re-install a logger belonging to an app that no longer exists.
|
|
104
|
+
*/
|
|
105
|
+
private destroyed = false;
|
|
97
106
|
private previousRef?: LoggerService;
|
|
98
107
|
|
|
99
|
-
constructor(
|
|
108
|
+
constructor(
|
|
109
|
+
@Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig,
|
|
110
|
+
@Optional() protected readonly redis?: CoreRedisService,
|
|
111
|
+
) {}
|
|
100
112
|
|
|
101
113
|
/** Idempotent install of the delegating logger. Public so reads can self-heal after a foreign override. */
|
|
102
114
|
attach(): void {
|
|
103
|
-
if (this.config.collectors.logs === false) {
|
|
115
|
+
if (this.destroyed || this.config.collectors.logs === false) {
|
|
104
116
|
return;
|
|
105
117
|
}
|
|
106
118
|
if (!this.cfg) {
|
|
107
119
|
this.cfg = this.config.collectors.logs;
|
|
108
|
-
this.buffer = new
|
|
120
|
+
this.buffer = new HubBuffer<BufferedLog>(this.cfg.capacity, 'logs', this.redis);
|
|
109
121
|
this.excludeContexts = new Set(this.cfg.excludeContexts);
|
|
110
122
|
}
|
|
111
123
|
const current = this.staticRef();
|
|
@@ -149,13 +161,17 @@ export class HubLogBufferService implements OnModuleDestroy, OnModuleInit {
|
|
|
149
161
|
return this.config.collectors.logs !== false;
|
|
150
162
|
}
|
|
151
163
|
|
|
152
|
-
getData(since?: number): HubLogsData {
|
|
164
|
+
async getData(since?: number): Promise<HubLogsData> {
|
|
153
165
|
this.selfHeal();
|
|
154
|
-
|
|
155
|
-
|
|
166
|
+
if (!this.buffer) {
|
|
167
|
+
return { cursor: -1, dropped: -1, records: [] };
|
|
168
|
+
}
|
|
169
|
+
const { cursor, dropped, entries } = await this.buffer.read(since);
|
|
170
|
+
return { cursor, dropped, records: entries };
|
|
156
171
|
}
|
|
157
172
|
|
|
158
173
|
onModuleDestroy(): void {
|
|
174
|
+
this.destroyed = true;
|
|
159
175
|
this.detach();
|
|
160
176
|
}
|
|
161
177
|
|
|
@@ -3,8 +3,9 @@ import { InjectConnection } from '@nestjs/mongoose';
|
|
|
3
3
|
import { Connection } from 'mongoose';
|
|
4
4
|
|
|
5
5
|
import { redactSensitiveText } from '../../../common/helpers/logging.helper';
|
|
6
|
+
import { CoreRedisService } from '../../../common/services/core-redis.service';
|
|
6
7
|
import { HUB_CONFIG, HUB_QUERY_PENDING_LIMIT } from '../hub.constants';
|
|
7
|
-
import {
|
|
8
|
+
import { HubBuffer } from '../hub-buffer';
|
|
8
9
|
import { normalizeCommandShape } from '../helpers/hub-command-shape.helper';
|
|
9
10
|
import { HubQueriesData, HubQueryRecord, HubQueryTemplate } from '../interfaces/hub-panels.interface';
|
|
10
11
|
import { ResolvedHubConfig } from '../interfaces/hub-config.interface';
|
|
@@ -58,7 +59,7 @@ const SHAPED_FIELDS = ['filter', 'query', 'pipeline', 'sort', 'projection', 'q',
|
|
|
58
59
|
export class HubQueryProfilerService implements OnModuleDestroy, OnModuleInit {
|
|
59
60
|
protected readonly logger = new Logger(HubQueryProfilerService.name);
|
|
60
61
|
|
|
61
|
-
private buffer?:
|
|
62
|
+
private buffer?: HubBuffer<ProfilerRecord>;
|
|
62
63
|
private client?: {
|
|
63
64
|
off?: (e: string, l: (...a: any[]) => void) => void;
|
|
64
65
|
on: (e: string, l: (...a: any[]) => void) => void;
|
|
@@ -78,6 +79,7 @@ export class HubQueryProfilerService implements OnModuleDestroy, OnModuleInit {
|
|
|
78
79
|
constructor(
|
|
79
80
|
@Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig,
|
|
80
81
|
@Optional() @InjectConnection() protected readonly connection?: Connection,
|
|
82
|
+
@Optional() protected readonly redis?: CoreRedisService,
|
|
81
83
|
) {}
|
|
82
84
|
|
|
83
85
|
/** Clear the buffer (Hub action). */
|
|
@@ -90,10 +92,11 @@ export class HubQueryProfilerService implements OnModuleDestroy, OnModuleInit {
|
|
|
90
92
|
return this.config.collectors.queries !== false;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
getData(): HubQueriesData {
|
|
94
|
-
const
|
|
95
|
+
async getData(): Promise<HubQueriesData> {
|
|
96
|
+
const data = this.buffer ? await this.buffer.read() : { cursor: -1, entries: [] };
|
|
97
|
+
const records = data.entries;
|
|
95
98
|
return {
|
|
96
|
-
cursor:
|
|
99
|
+
cursor: data.cursor,
|
|
97
100
|
recent: records.slice(-100),
|
|
98
101
|
slowest: [...records].sort((a, b) => b.durationMs - a.durationMs).slice(0, 10),
|
|
99
102
|
summary: this.summary(records),
|
|
@@ -118,7 +121,7 @@ export class HubQueryProfilerService implements OnModuleDestroy, OnModuleInit {
|
|
|
118
121
|
return; // zero cost when disabled
|
|
119
122
|
}
|
|
120
123
|
this.cfg = this.config.collectors.queries;
|
|
121
|
-
this.buffer = new
|
|
124
|
+
this.buffer = new HubBuffer<ProfilerRecord>(this.cfg.capacity, 'queries', this.redis);
|
|
122
125
|
if (this.cfg.ignoreCommands?.length) {
|
|
123
126
|
this.ignore = new Set(this.cfg.ignoreCommands.map((c) => c.toLowerCase()));
|
|
124
127
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { Inject, Injectable } from '@nestjs/common';
|
|
1
|
+
import { Inject, Injectable, Optional } from '@nestjs/common';
|
|
2
2
|
import { Request, Response } from 'express';
|
|
3
3
|
|
|
4
|
+
import { CoreRedisService } from '../../../common/services/core-redis.service';
|
|
4
5
|
import { HUB_CONFIG } from '../hub.constants';
|
|
5
|
-
import {
|
|
6
|
+
import { HubBuffer } from '../hub-buffer';
|
|
6
7
|
import { HubTraceRecord, HubTracesData } from '../interfaces/hub-panels.interface';
|
|
7
8
|
import { ResolvedHubConfig } from '../interfaces/hub-config.interface';
|
|
8
9
|
|
|
@@ -19,14 +20,17 @@ interface TraceRecord extends HubTraceRecord {
|
|
|
19
20
|
*/
|
|
20
21
|
@Injectable()
|
|
21
22
|
export class HubTraceBufferService {
|
|
22
|
-
private readonly buffer?:
|
|
23
|
+
private readonly buffer?: HubBuffer<TraceRecord>;
|
|
23
24
|
private readonly cfg?: Exclude<ResolvedHubConfig['collectors']['traces'], false>;
|
|
24
25
|
private readonly excludePrefixes: string[];
|
|
25
26
|
|
|
26
|
-
constructor(
|
|
27
|
+
constructor(
|
|
28
|
+
@Inject(HUB_CONFIG) protected readonly config: ResolvedHubConfig,
|
|
29
|
+
@Optional() protected readonly redis?: CoreRedisService,
|
|
30
|
+
) {
|
|
27
31
|
if (config.collectors.traces !== false) {
|
|
28
32
|
this.cfg = config.collectors.traces;
|
|
29
|
-
this.buffer = new
|
|
33
|
+
this.buffer = new HubBuffer<TraceRecord>(this.cfg.capacity, 'traces', redis);
|
|
30
34
|
}
|
|
31
35
|
// Always exclude the Hub's own routes so polling does not flood the trace list with self-noise.
|
|
32
36
|
this.excludePrefixes = [...(this.cfg?.excludePaths ?? []), '/' + config.path];
|
|
@@ -40,8 +44,9 @@ export class HubTraceBufferService {
|
|
|
40
44
|
return this.config.collectors.traces !== false;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
getData(since?: number): HubTracesData {
|
|
44
|
-
const
|
|
47
|
+
async getData(since?: number): Promise<HubTracesData> {
|
|
48
|
+
const data = this.buffer ? await this.buffer.read(since) : { cursor: -1, dropped: -1, entries: [] };
|
|
49
|
+
const records = data.entries;
|
|
45
50
|
let totalMs = 0;
|
|
46
51
|
let slowCount = 0;
|
|
47
52
|
let errorCount = 0;
|
|
@@ -55,8 +60,8 @@ export class HubTraceBufferService {
|
|
|
55
60
|
}
|
|
56
61
|
}
|
|
57
62
|
return {
|
|
58
|
-
cursor:
|
|
59
|
-
dropped:
|
|
63
|
+
cursor: data.cursor,
|
|
64
|
+
dropped: data.dropped,
|
|
60
65
|
summary: { avgMs: records.length ? totalMs / records.length : 0, errorCount, slowCount, total: records.length },
|
|
61
66
|
traces: records,
|
|
62
67
|
};
|
|
@@ -230,7 +230,7 @@ The migration state is stored in a MongoDB collection (default: `migrations`) as
|
|
|
230
230
|
|
|
231
231
|
### Locking Mechanism
|
|
232
232
|
|
|
233
|
-
|
|
233
|
+
How the lock works, regardless of which entry point uses it:
|
|
234
234
|
|
|
235
235
|
1. A unique index is created on the lock collection
|
|
236
236
|
2. The migration process attempts to insert a lock document
|
|
@@ -240,6 +240,27 @@ When using `synchronizedMigration` or `synchronizedUp`:
|
|
|
240
240
|
|
|
241
241
|
This ensures that in a cluster with multiple nodes, migrations run on only one machine at a time.
|
|
242
242
|
|
|
243
|
+
**Active by default for `migrate up`.** Stores built by `createMigrationStore()` use the
|
|
244
|
+
lock collection `migrations_lock` unless another name is given, and `MigrationRunner.up()`
|
|
245
|
+
(the CLI's `up` command) acquires that lock around the whole run. This matters because the
|
|
246
|
+
container entrypoint runs migrations on **every** boot: without the lock, N replicas
|
|
247
|
+
starting together each read the same empty state and apply the same pending migration N
|
|
248
|
+
times. A replica that waited re-reads the state inside the lock and finds nothing pending.
|
|
249
|
+
|
|
250
|
+
With a single replica nothing changes — the lock is acquired and released uncontended.
|
|
251
|
+
|
|
252
|
+
Opt out by passing an empty lock collection name:
|
|
253
|
+
|
|
254
|
+
```javascript
|
|
255
|
+
module.exports = createMigrationStore(uri, 'migrations', ''); // no locking
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
A `MongoStateStore` constructed **directly** still locks only when you pass
|
|
259
|
+
`lockCollectionName` yourself; the default lives in `createMigrationStore()`.
|
|
260
|
+
|
|
261
|
+
The legacy `synchronizedMigration` / `synchronizedUp` helpers use the same lock and still
|
|
262
|
+
require `lockCollectionName` to be set explicitly (they throw otherwise).
|
|
263
|
+
|
|
243
264
|
## Examples
|
|
244
265
|
|
|
245
266
|
### Example: Migration File
|
|
@@ -356,11 +377,17 @@ Factory function to create a migration store class for use with the migrate CLI:
|
|
|
356
377
|
const { createMigrationStore } = require('@lenne.tech/nest-server');
|
|
357
378
|
const config = require('../src/config.env');
|
|
358
379
|
|
|
359
|
-
module.exports = createMigrationStore(
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
380
|
+
module.exports = createMigrationStore(config.default.mongoose.uri);
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
**Locking is on by default.** The third parameter defaults to `'migrations_lock'`, so
|
|
384
|
+
`migrate up` is serialized across replicas without any project change (see
|
|
385
|
+
[Locking Mechanism](#locking-mechanism)):
|
|
386
|
+
|
|
387
|
+
```javascript
|
|
388
|
+
createMigrationStore(uri); // collection 'migrations', lock 'migrations_lock'
|
|
389
|
+
createMigrationStore(uri, 'migrations', 'custom_lock'); // custom lock collection
|
|
390
|
+
createMigrationStore(uri, 'migrations', ''); // opt out: no locking
|
|
364
391
|
```
|
|
365
392
|
|
|
366
393
|
### getDb()
|
|
@@ -2,6 +2,8 @@ import * as fs from 'fs';
|
|
|
2
2
|
import { Db, GridFSBucket, MongoClient, ObjectId } from 'mongodb';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
|
|
5
|
+
import { MongoStateStore } from '../mongo-state-store';
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Migration helper functions for database operations
|
|
7
9
|
*/
|
|
@@ -247,9 +249,13 @@ export const uploadFileToGridFS = async (
|
|
|
247
249
|
/**
|
|
248
250
|
* Create a migration state store factory
|
|
249
251
|
*
|
|
252
|
+
* The lock collection is set by DEFAULT, so `migrate up` from N replicas booting at
|
|
253
|
+
* the same time serializes instead of applying the same migration twice (the container
|
|
254
|
+
* entrypoint runs migrations on every boot). Pass an empty string to opt out.
|
|
255
|
+
*
|
|
250
256
|
* @param mongoUrl - MongoDB connection URI
|
|
251
257
|
* @param collectionName - Optional collection name (default: 'migrations')
|
|
252
|
-
* @param lockCollectionName -
|
|
258
|
+
* @param lockCollectionName - Lock collection name (default: 'migrations_lock'); `''` disables locking
|
|
253
259
|
* @returns MongoStateStore class that can be used with migrate CLI
|
|
254
260
|
*
|
|
255
261
|
* @example
|
|
@@ -264,15 +270,16 @@ export const uploadFileToGridFS = async (
|
|
|
264
270
|
export const createMigrationStore = (
|
|
265
271
|
mongoUrl: string,
|
|
266
272
|
collectionName: string = 'migrations',
|
|
267
|
-
lockCollectionName
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
273
|
+
lockCollectionName: string = 'migrations_lock',
|
|
274
|
+
// Explicit return type: the returned class is anonymous to the declaration emitter, and
|
|
275
|
+
// MongoStateStore has private members, so an inferred type cannot be written to the .d.ts
|
|
276
|
+
// (TS4094).
|
|
277
|
+
): new () => MongoStateStore => {
|
|
271
278
|
return class MigrationStateStore extends MongoStateStore {
|
|
272
279
|
constructor() {
|
|
273
280
|
super({
|
|
274
281
|
collectionName,
|
|
275
|
-
lockCollectionName,
|
|
282
|
+
lockCollectionName: lockCollectionName || undefined,
|
|
276
283
|
uri: mongoUrl,
|
|
277
284
|
});
|
|
278
285
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
|
|
4
|
-
import { MongoStateStore } from './mongo-state-store';
|
|
4
|
+
import { MongoStateStore, withMigrationLock } from './mongo-state-store';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Migration file interface
|
|
@@ -221,8 +221,17 @@ export class MigrationRunner {
|
|
|
221
221
|
|
|
222
222
|
/**
|
|
223
223
|
* Run all pending migrations (up)
|
|
224
|
+
*
|
|
225
|
+
* Serialized via the state store's lock collection when it has one (the default for
|
|
226
|
+
* stores built by `createMigrationStore()`). Reading the state INSIDE the lock is what
|
|
227
|
+
* makes it safe: a replica that waited sees the migrations the holder just applied and
|
|
228
|
+
* finds nothing pending, instead of applying them a second time.
|
|
224
229
|
*/
|
|
225
230
|
async up(): Promise<void> {
|
|
231
|
+
await withMigrationLock(this.options.stateStore, () => this.runUp());
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
protected async runUp(): Promise<void> {
|
|
226
235
|
const { _endMigration, _startMigration } = await import('./helpers/migration.helper');
|
|
227
236
|
|
|
228
237
|
const allMigrations = await this.loadMigrationFiles();
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
1
2
|
import { Db, MongoClient } from 'mongodb';
|
|
3
|
+
import { hostname } from 'os';
|
|
2
4
|
import { promisify } from 'util';
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -181,14 +183,20 @@ export async function synchronizedMigration(
|
|
|
181
183
|
throw new Error('`lockCollectionName` in MongoStateStore is not set');
|
|
182
184
|
}
|
|
183
185
|
|
|
186
|
+
let heartbeat: undefined | { stop: () => void };
|
|
187
|
+
let lockToken: string | undefined;
|
|
184
188
|
try {
|
|
185
|
-
await acquireLock(stateStore.mongodbHost, lockCollectionName);
|
|
189
|
+
lockToken = await acquireLock(stateStore.mongodbHost, lockCollectionName);
|
|
190
|
+
heartbeat = startLockHeartbeat(stateStore.mongodbHost, lockCollectionName, lockToken);
|
|
186
191
|
|
|
187
192
|
// Load migration set using async method
|
|
188
193
|
const set = await stateStore.loadAsync();
|
|
189
194
|
await callback(set);
|
|
190
195
|
} finally {
|
|
191
|
-
|
|
196
|
+
heartbeat?.stop();
|
|
197
|
+
if (lockToken) {
|
|
198
|
+
await releaseLock(stateStore.mongodbHost, lockCollectionName, lockToken);
|
|
199
|
+
}
|
|
192
200
|
}
|
|
193
201
|
}
|
|
194
202
|
|
|
@@ -217,14 +225,119 @@ export async function synchronizedUp(opts: MigrationOptions): Promise<void> {
|
|
|
217
225
|
});
|
|
218
226
|
}
|
|
219
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Runs `fn` while holding the migration lock of the given state store
|
|
230
|
+
*
|
|
231
|
+
* When the store has no `lockCollectionName`, `fn` runs unsynchronized — a single
|
|
232
|
+
* replica keeps behaving exactly as before. Stores built by `createMigrationStore()`
|
|
233
|
+
* carry the default lock collection, so concurrent `migrate up` runs from several
|
|
234
|
+
* replicas serialize here: the second replica waits, then re-reads the migration
|
|
235
|
+
* state and finds nothing pending.
|
|
236
|
+
*
|
|
237
|
+
* @param stateStore - State store holding the connection URI and lock collection name
|
|
238
|
+
* @param fn - Work to execute under the lock
|
|
239
|
+
* @returns Promise with the result of `fn`
|
|
240
|
+
*/
|
|
241
|
+
export async function withMigrationLock<T>(stateStore: MongoStateStore, fn: () => Promise<T>): Promise<T> {
|
|
242
|
+
const lockCollectionName = stateStore.lockCollectionName;
|
|
243
|
+
|
|
244
|
+
if (!lockCollectionName) {
|
|
245
|
+
return fn();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const token = await acquireLock(stateStore.mongodbHost, lockCollectionName);
|
|
249
|
+
const heartbeat = startLockHeartbeat(stateStore.mongodbHost, lockCollectionName, token);
|
|
250
|
+
try {
|
|
251
|
+
return await fn();
|
|
252
|
+
} finally {
|
|
253
|
+
heartbeat.stop();
|
|
254
|
+
await releaseLock(stateStore.mongodbHost, lockCollectionName, token);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* How long a lock may go without a heartbeat before another replica may break it.
|
|
260
|
+
*
|
|
261
|
+
* Only a lock whose holder stopped refreshing it is ever broken, so this is not a cap
|
|
262
|
+
* on migration runtime — a migration running for hours keeps its lock as long as the
|
|
263
|
+
* process lives.
|
|
264
|
+
*/
|
|
265
|
+
const LOCK_STALE_AFTER_MS = 60_000;
|
|
266
|
+
|
|
267
|
+
/** Heartbeat interval — comfortably below {@link LOCK_STALE_AFTER_MS} */
|
|
268
|
+
const LOCK_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
269
|
+
|
|
270
|
+
/** How long a replica waits for a held lock before giving up with a diagnosable error */
|
|
271
|
+
const LOCK_WAIT_TIMEOUT_MS = 15 * 60_000;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Keep refreshing `acquiredAt` while the migration runs.
|
|
275
|
+
*
|
|
276
|
+
* This is what makes lock-breaking safe: a lock is only ever taken over when its holder
|
|
277
|
+
* stopped refreshing it, which for a live process cannot happen. Without the heartbeat,
|
|
278
|
+
* breaking a stale lock and a long-running migration would be indistinguishable.
|
|
279
|
+
*/
|
|
280
|
+
function startLockHeartbeat(url: string, lockCollectionName: string, token: string): { stop: () => void } {
|
|
281
|
+
// ONE connection for the whole heartbeat, rather than `dbRequest()`'s connect-and-close per
|
|
282
|
+
// call: that opened and tore down a MongoClient every 15 seconds for the entire migration run —
|
|
283
|
+
// a full handshake each time, against the database at its busiest moment. The client is created
|
|
284
|
+
// on the first tick (so a heartbeat that never fires costs nothing) and closed by stop().
|
|
285
|
+
let client: MongoClient | undefined;
|
|
286
|
+
let stopped = false;
|
|
287
|
+
|
|
288
|
+
const beat = async (): Promise<void> => {
|
|
289
|
+
if (stopped) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!client) {
|
|
293
|
+
const connected = await MongoClient.connect(url);
|
|
294
|
+
if (stopped) {
|
|
295
|
+
// stop() ran while we were connecting — nothing would ever close this one.
|
|
296
|
+
await connected.close();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
client = connected;
|
|
300
|
+
}
|
|
301
|
+
// Scoped: once our lock has been broken and re-taken, our heartbeat must not keep the NEW
|
|
302
|
+
// holder's lock alive on our behalf.
|
|
303
|
+
await client
|
|
304
|
+
.db()
|
|
305
|
+
.collection(lockCollectionName)
|
|
306
|
+
.updateOne({ lock: 'lock', owner: token }, { $set: { acquiredAt: new Date() } });
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const timer = setInterval(() => {
|
|
310
|
+
beat().catch((error) => {
|
|
311
|
+
// A missed heartbeat is not fatal on its own — the next one may succeed, and only
|
|
312
|
+
// a sustained gap makes the lock breakable. Drop the connection though: it may itself be
|
|
313
|
+
// what failed, and a held-open broken client would fail every remaining tick.
|
|
314
|
+
console.warn(`Migration lock heartbeat failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
315
|
+
const broken = client;
|
|
316
|
+
client = undefined;
|
|
317
|
+
void broken?.close().catch(() => undefined);
|
|
318
|
+
});
|
|
319
|
+
}, LOCK_HEARTBEAT_INTERVAL_MS);
|
|
320
|
+
timer.unref?.();
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
stop: () => {
|
|
324
|
+
stopped = true;
|
|
325
|
+
clearInterval(timer);
|
|
326
|
+
const open = client;
|
|
327
|
+
client = undefined;
|
|
328
|
+
void open?.close().catch(() => undefined);
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
220
333
|
/**
|
|
221
334
|
* Acquires a lock in MongoDB to ensure only one migration runs at a time
|
|
222
335
|
*
|
|
223
336
|
* @param url - MongoDB connection URI
|
|
224
337
|
* @param lockCollectionName - Name of the collection to use for locking
|
|
225
338
|
*/
|
|
226
|
-
async function acquireLock(url: string, lockCollectionName: string): Promise<
|
|
227
|
-
|
|
339
|
+
async function acquireLock(url: string, lockCollectionName: string): Promise<string> {
|
|
340
|
+
return dbRequest(url, async (db) => {
|
|
228
341
|
const collection = db.collection(lockCollectionName);
|
|
229
342
|
|
|
230
343
|
// Create unique index for atomicity
|
|
@@ -233,15 +346,58 @@ async function acquireLock(url: string, lockCollectionName: string): Promise<voi
|
|
|
233
346
|
// https://stackoverflow.com/questions/33346175/mongodb-upsert-operation-seems-not-atomic-which-throws-duplicatekeyexception/34784533
|
|
234
347
|
await collection.createIndex({ lock: 1 }, { unique: true });
|
|
235
348
|
|
|
349
|
+
const token = lockOwnerId();
|
|
350
|
+
const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS;
|
|
236
351
|
let showMessage = true;
|
|
237
352
|
|
|
238
353
|
for (;;) {
|
|
239
|
-
// Use updateOne with upsert for atomic lock acquisition (same as original package)
|
|
240
|
-
|
|
241
|
-
|
|
354
|
+
// Use updateOne with upsert for atomic lock acquisition (same as original package).
|
|
355
|
+
// `$setOnInsert` stamps the acquisition time only for the winner, so a loser's
|
|
356
|
+
// update never refreshes the holder's staleness clock.
|
|
357
|
+
const result = await collection.updateOne(
|
|
358
|
+
{ lock: 'lock' },
|
|
359
|
+
{ $set: { lock: 'lock' }, $setOnInsert: { acquiredAt: new Date(), owner: token } },
|
|
360
|
+
{ upsert: true },
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
if (result.upsertedCount > 0) {
|
|
364
|
+
return token;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// The holder may be gone: migrations run on every container boot, so a replica
|
|
368
|
+
// SIGKILLed mid-migration (OOM, node drain, failed deploy) would otherwise leave a
|
|
369
|
+
// lock nobody holds — and every future boot of every replica would wait on it
|
|
370
|
+
// forever. Only a lock that stopped heart-beating is broken; see LOCK_STALE_AFTER_MS.
|
|
371
|
+
const holder = await collection.findOne({ lock: 'lock' });
|
|
372
|
+
if (holder) {
|
|
373
|
+
const acquiredAt = holder.acquiredAt instanceof Date ? holder.acquiredAt : undefined;
|
|
374
|
+
if (!acquiredAt) {
|
|
375
|
+
// Written by a version that did not stamp the lock. Start its clock now rather
|
|
376
|
+
// than breaking it immediately — the holder may well be alive.
|
|
377
|
+
await collection.updateOne(
|
|
378
|
+
{ _id: holder._id, acquiredAt: { $exists: false } },
|
|
379
|
+
{ $set: { acquiredAt: new Date() } },
|
|
380
|
+
);
|
|
381
|
+
} else if (Date.now() - acquiredAt.getTime() > LOCK_STALE_AFTER_MS) {
|
|
382
|
+
console.warn(
|
|
383
|
+
`Breaking stale migration lock in "${lockCollectionName}" (last heartbeat ${acquiredAt.toISOString()}, ` +
|
|
384
|
+
`owner ${holder.owner ?? 'unknown'}) — its holder is gone.`,
|
|
385
|
+
);
|
|
386
|
+
// Matching on acquiredAt makes the break safe under concurrency: if another
|
|
387
|
+
// waiter already broke and re-acquired the lock, the timestamp differs and this
|
|
388
|
+
// deletes nothing.
|
|
389
|
+
await collection.deleteOne({ _id: holder._id, acquiredAt });
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
242
393
|
|
|
243
|
-
if (
|
|
244
|
-
|
|
394
|
+
if (Date.now() > deadline) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`Timed out after ${Math.round(LOCK_WAIT_TIMEOUT_MS / 60_000)} minutes waiting for the migration lock in ` +
|
|
397
|
+
`collection "${lockCollectionName}". Another replica is still migrating, or the lock is held by a process ` +
|
|
398
|
+
`that is alive but stuck. Inspect it with: db.getCollection("${lockCollectionName}").find({}) — and remove ` +
|
|
399
|
+
`the document only once you are sure no migration is running.`,
|
|
400
|
+
);
|
|
245
401
|
}
|
|
246
402
|
|
|
247
403
|
if (showMessage) {
|
|
@@ -254,6 +410,15 @@ async function acquireLock(url: string, lockCollectionName: string): Promise<voi
|
|
|
254
410
|
});
|
|
255
411
|
}
|
|
256
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Identifies the process holding the lock, so a stale-lock warning names something actionable
|
|
415
|
+
*/
|
|
416
|
+
function lockOwnerId(): string {
|
|
417
|
+
// Random suffix, not just host+pid: a restarted process can reuse a pid, and the token is what
|
|
418
|
+
// decides whose lock a release may delete.
|
|
419
|
+
return `${hostname()}:${process.pid}:${randomUUID()}`;
|
|
420
|
+
}
|
|
421
|
+
|
|
257
422
|
/**
|
|
258
423
|
* Executes database operations with automatic connection management
|
|
259
424
|
*
|
|
@@ -278,6 +443,9 @@ async function dbRequest<T>(url: string, callback: (db: Db) => Promise<T> | T):
|
|
|
278
443
|
* @param url - MongoDB connection URI
|
|
279
444
|
* @param lockCollectionName - Name of the collection used for locking
|
|
280
445
|
*/
|
|
281
|
-
async function releaseLock(url: string, lockCollectionName: string): Promise<void> {
|
|
282
|
-
|
|
446
|
+
async function releaseLock(url: string, lockCollectionName: string, token: string): Promise<void> {
|
|
447
|
+
// Scoped to OUR token. An unscoped delete removes whatever lock exists — so after a missed
|
|
448
|
+
// heartbeat let another replica break and re-take the lock, the original holder's `finally`
|
|
449
|
+
// would delete the NEW holder's lock and let a third replica start a concurrent migration.
|
|
450
|
+
await dbRequest(url, (db) => db.collection(lockCollectionName).deleteOne({ lock: 'lock', owner: token }));
|
|
283
451
|
}
|
|
@@ -164,7 +164,11 @@ NEST_SERVER_CONFIG='{ "systemSetup": { "initialAdmin": { "email": "admin@example
|
|
|
164
164
|
- The admin is created automatically during application bootstrap (`OnApplicationBootstrap`)
|
|
165
165
|
- Same zero-user guard applies: only works when no users exist
|
|
166
166
|
- If users already exist, auto-creation is silently skipped (no error)
|
|
167
|
-
- Race conditions between multiple instances are handled gracefully
|
|
167
|
+
- Race conditions between multiple instances are handled gracefully: the zero-user check alone is
|
|
168
|
+
check-then-act, so N replicas booting at the same time would each pass it. Before creating, an
|
|
169
|
+
instance claims the setup by upserting the marker `{ _id: 'initial-admin' }` into the
|
|
170
|
+
`system-setup-locks` collection — an atomic operation only one instance wins. The others log a
|
|
171
|
+
debug message and skip. If creation fails, the marker is removed again so a later boot can retry.
|
|
168
172
|
|
|
169
173
|
### Security Best Practices
|
|
170
174
|
|
|
@@ -179,7 +183,8 @@ NEST_SERVER_CONFIG='{ "systemSetup": { "initialAdmin": { "email": "admin@example
|
|
|
179
183
|
|
|
180
184
|
1. **Zero-user guard** - Init only works when `countDocuments({}) === 0`
|
|
181
185
|
2. **Enabled by default** - Safe because endpoints are permanently locked once any user exists
|
|
182
|
-
3. **Race condition protection** - MongoDB unique email index prevents duplicates
|
|
186
|
+
3. **Race condition protection** - MongoDB unique email index prevents duplicates; auto-creation on
|
|
187
|
+
bootstrap is additionally claimed atomically via the `system-setup-locks` marker
|
|
183
188
|
4. **Permanent lock** - Once any user exists, init returns 403
|
|
184
189
|
5. **BetterAuth required** - Returns 403 if BetterAuth is not enabled
|
|
185
190
|
|