@yolo-croket-dev/core 1.10.2 → 1.10.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.10.4
4
+
5
+ - 종료 중 consumer 취소나 채널 설정이 실패해도 기존 RPC 완료 대기와 최종 연결 정리를 계속합니다.
6
+ - 취소 실패는 민감 정보가 제거된 진단 로그로 한 번 기록합니다.
7
+
8
+ ## 1.10.3
9
+
10
+ - 종료 시작 시 등록한 RMQ consumer를 먼저 중단하고 실행 중인 RPC는 완료까지 기다립니다.
11
+ - 종료 중 도착한 미실행 메시지와 재전달 확인 중 종료가 시작된 요청을 즉시 재큐잉합니다.
12
+ - channel setup과 종료가 겹쳐도 새 consumer를 남기지 않습니다.
13
+
3
14
  ## 1.10.0
4
15
 
5
16
  - 비회원 인증 로그 마스킹·검증 오류 보호·재시도 정보 필드를 정식 발행합니다.
@@ -12,5 +12,6 @@ export declare class RPCInterceptor implements NestInterceptor {
12
12
  /** 메시지가 재전송되었을때에, 재시도 Count가 명시되어 있지 않을때에 처리 할지 여부를 결정하는 함수 */
13
13
  isRedeliveryMessage(replyTo: string, traceId: string): Promise<boolean>;
14
14
  private updateMaxLengthArrayShorten;
15
+ private requeueMessageOnShutdown;
15
16
  intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<unknown>>;
16
17
  }
@@ -148,6 +148,13 @@ let RPCInterceptor = class RPCInterceptor {
148
148
  }
149
149
  return obj;
150
150
  }
151
+ async requeueMessageOnShutdown(channel, message) {
152
+ msaLogger.debug('RPCInterceptor isApplicationExit called', { traceId: 'IS_APPLICATION_EXIT' });
153
+ await channel.cancel(message.fields.consumerTag);
154
+ channel.nack(message, false, true);
155
+ // 기존 서버에서 응답을 완료하면 재전달된 요청의 실제 응답을 클라이언트가 기다릴 수 없다.
156
+ return new rxjs_1.Observable();
157
+ }
151
158
  async intercept(context, next) {
152
159
  var _a, _b, _c, _d, _e;
153
160
  const options = this.reflector.get('rpcInterceptorScope', context.getHandler());
@@ -175,9 +182,7 @@ let RPCInterceptor = class RPCInterceptor {
175
182
  let abortController = new AbortController();
176
183
  /** consume 메시지 전부 cancel */
177
184
  if (this.gracefulShutdownService.isApplicationExit) {
178
- msaLogger.debug('RPCInterceptor isApplicationExit called', { traceId: 'IS_APPLICATION_EXIT' });
179
- channel.cancel(message.fields.consumerTag);
180
- return new rxjs_1.Observable();
185
+ return this.requeueMessageOnShutdown(channel, message);
181
186
  }
182
187
  if (!staticRetryCount && reDeliveryCount > 0) {
183
188
  msaLogger.debug('RETRY_COUNT_ZERO_OR_NO_RETRY 발생 전, redelivery 발생', { traceId: ((_b = content.data.param) === null || _b === void 0 ? void 0 : _b.traceId) || 'RETRY_COUNT_ZERO_OR_NO_RETRY' }, {
@@ -189,6 +194,9 @@ let RPCInterceptor = class RPCInterceptor {
189
194
  staticRetryCount,
190
195
  });
191
196
  const isRedeliveryMessage = await this.isRedeliveryMessage(message.properties.replyTo, execInterceptor.traceId);
197
+ if (this.gracefulShutdownService.isApplicationExit) {
198
+ return this.requeueMessageOnShutdown(channel, message);
199
+ }
192
200
  if (!isRedeliveryMessage || reDeliveryCount > 5) {
193
201
  msaLogger.debug('isRedeliveryMessage RETRY_COUNT_ZERO_OR_NO_RETRY false', { traceId: ((_c = content.data.param) === null || _c === void 0 ? void 0 : _c.traceId) || 'RETRY_COUNT_ZERO_OR_NO_RETRY' });
194
202
  channel.sendToQueue(message.properties.replyTo, Buffer.from(JSON.stringify({
@@ -358,6 +366,10 @@ let RPCInterceptor = class RPCInterceptor {
358
366
  return new rxjs_1.Observable();
359
367
  }
360
368
  }
369
+ // 재전달 여부를 비동기로 확인하는 동안 종료가 시작될 수 있으므로 실행 직전에 다시 확인한다.
370
+ if (this.gracefulShutdownService.isApplicationExit) {
371
+ return this.requeueMessageOnShutdown(channel, message);
372
+ }
361
373
  this.gracefulShutdownService.pushActiveInterceptors(execInterceptor);
362
374
  // eslint-disable-next-line consistent-return
363
375
  return functions_1.AbortContext.run(abortController.signal, execInterceptor.traceId, () => next
@@ -42,8 +42,7 @@ let GracefulShutdownModule = class GracefulShutdownModule {
42
42
  this.gracefulShutdownService = gracefulShutdownService;
43
43
  }
44
44
  async onModuleDestroy() {
45
- // onApplicationShutdown에서 모든 activeInterceptors 완료를 기다림
46
- // 이렇게 하면 onModuleDestroy가 호출되기 전에 모든 이벤트가 처리됨
45
+ // provider의 onModuleDestroy에서 신규 수신을 중단한 뒤 기존 요청만 완료까지 기다린다.
47
46
  await this.gracefulShutdownService.waitForActiveInterceptors();
48
47
  msaLogger.info('GracefulShutdownService.onModuleDestroy: all interceptors finished, shutting down', { traceId: 'ON_MODULE_DESTROY' });
49
48
  }
@@ -1,7 +1,6 @@
1
1
  import { ServerRMQ } from '@nestjs/microservices';
2
2
  import type { RmqOptions } from '@nestjs/microservices';
3
3
  import type { Channel } from 'amqp-connection-manager';
4
- import type { ConsumeMessage } from 'amqplib';
5
4
  export type GracefulRmqOptions = RmqOptions['options'] & {
6
5
  waitingEndingHandlersTimeoutMs?: number;
7
6
  waitingEndingHandlersIntervalMs?: number;
@@ -17,12 +16,20 @@ export declare class GracefulServerRMQ extends ServerRMQ {
17
16
  protected runningMessages: number;
18
17
  protected closing: boolean;
19
18
  protected consumerTag: string | null;
19
+ private stopConsumingPromise?;
20
20
  protected readonly waitingEndingHandlersTimeoutMs: number;
21
21
  protected readonly waitingEndingHandlersIntervalMs: number;
22
22
  constructor(options: GracefulRmqOptions);
23
23
  createClient(): any;
24
24
  setupChannel(channel: Channel, callback: () => void): Promise<void>;
25
25
  protected waitingHandlers(): Promise<void>;
26
- handleMessage(message: ConsumeMessage, channel: Channel): Promise<void>;
26
+ handleMessage(message: Parameters<ServerRMQ['handleMessage']>[0] | null, channel: Channel): Promise<void>;
27
+ /**
28
+ * @author jun
29
+ * @description 실행 중인 요청의 채널을 유지하면서 신규 메시지 수신을 중단한다.
30
+ * @process 종료 플래그 설정 → consumer 취소 → 이후 도착한 미실행 메시지는 재전달한다.
31
+ */
32
+ stopConsuming(): Promise<void>;
33
+ private cancelConsumer;
27
34
  close(): Promise<void>;
28
35
  }
@@ -1,14 +1,39 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
2
25
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
26
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
27
  };
5
28
  Object.defineProperty(exports, "__esModule", { value: true });
6
29
  exports.GracefulServerRMQ = void 0;
7
30
  const microservices_1 = require("@nestjs/microservices");
31
+ const msa_logger_1 = __importStar(require("@yolo-croket-dev/msa-logger"));
8
32
  const os_1 = __importDefault(require("os"));
9
33
  const node_util_1 = require("node:util");
10
34
  const rmq_diagnostics_function_1 = require("../../functions/rmq-diagnostics.function");
11
35
  const sleep = (0, node_util_1.promisify)(setTimeout);
36
+ const msaLogger = new msa_logger_1.default(msa_logger_1.ServerTypeEnum.SERVICE);
12
37
  const RMQ_DEFAULT_WAITING_ENDING_HANDLERS_TIMEOUT_MS = 5000;
13
38
  const RMQ_DEFAULT_WAITING_ENDING_HANDLERS_INTERVAL_MS = 500;
14
39
  const RMQ_UNKNOWN_CONNECTION_IDENTITY = 'unknown';
@@ -87,8 +112,15 @@ class GracefulServerRMQ extends microservices_1.ServerRMQ {
87
112
  await channel.assertQueue(this.queue, this.queueOptions);
88
113
  }
89
114
  await channel.prefetch(this.options.prefetchCount || 0, this.options.isGlobalPrefetchCount);
115
+ if (this.closing)
116
+ return;
90
117
  const { consumerTag } = await channel.consume(this.queue, (msg) => this.handleMessage(msg, channel), { noAck: false });
91
118
  this.consumerTag = consumerTag;
119
+ if (this.closing) {
120
+ await channel.cancel(consumerTag);
121
+ this.consumerTag = null;
122
+ return;
123
+ }
92
124
  callback();
93
125
  }
94
126
  async waitingHandlers() {
@@ -97,19 +129,54 @@ class GracefulServerRMQ extends microservices_1.ServerRMQ {
97
129
  }
98
130
  }
99
131
  async handleMessage(message, channel) {
132
+ if (!message)
133
+ return;
134
+ if (this.closing) {
135
+ channel.nack(message, false, true);
136
+ return;
137
+ }
100
138
  this.runningMessages += 1;
101
- return super.handleMessage(message, channel).finally(() => {
139
+ await super.handleMessage(message, channel).finally(() => {
102
140
  this.runningMessages -= 1;
103
141
  });
104
142
  }
105
- async close() {
106
- var _a;
143
+ /**
144
+ * @author jun
145
+ * @description 실행 중인 요청의 채널을 유지하면서 신규 메시지 수신을 중단한다.
146
+ * @process 종료 플래그 설정 → consumer 취소 → 이후 도착한 미실행 메시지는 재전달한다.
147
+ */
148
+ stopConsuming() {
107
149
  this.closing = true;
150
+ if (!this.stopConsumingPromise)
151
+ this.stopConsumingPromise = this.cancelConsumer();
152
+ return this.stopConsumingPromise;
153
+ }
154
+ async cancelConsumer() {
155
+ const consumerTag = this.consumerTag;
108
156
  try {
109
157
  if (this.channel) {
110
- await this.channel.removeSetup(undefined, (channel) => channel.cancel(this.consumerTag));
158
+ await this.channel.removeSetup(undefined, async (channel) => {
159
+ if (consumerTag)
160
+ await channel.cancel(consumerTag);
161
+ });
111
162
  }
163
+ }
164
+ catch (err) {
165
+ // 취소 실패가 Nest 종료 훅을 중단하면 실행 중인 요청 완료 대기와 연결 정리가 생략된다.
166
+ const diagnostic = (0, msa_logger_1.normalizeDiagnosticError)(err);
167
+ msaLogger.warn('GracefulServerRMQ consumer 취소 실패: 기존 요청 완료 후 연결 정리', {
168
+ traceId: 'RMQ_SHUTDOWN_CANCEL_FAILED',
169
+ diagnosticInfo: diagnostic.diagnosticInfo,
170
+ }, diagnostic.details);
171
+ }
172
+ finally {
112
173
  this.consumerTag = null;
174
+ }
175
+ }
176
+ async close() {
177
+ var _a;
178
+ try {
179
+ await this.stopConsuming();
113
180
  await Promise.race([
114
181
  this.waitingHandlers(),
115
182
  this.waitingEndingHandlersTimeoutMs > 0
@@ -1,4 +1,5 @@
1
1
  import { OnApplicationShutdown, OnModuleDestroy, OnApplicationBootstrap } from '@nestjs/common';
2
+ import type { GracefulServerRMQ } from './graceful-shutdown.rmq';
2
3
  interface GracefulExecInterceptor {
3
4
  method: string;
4
5
  traceId?: string;
@@ -8,10 +9,17 @@ export declare class GracefulShutdownService implements OnApplicationShutdown, O
8
9
  private isExecLocal?;
9
10
  private activeInterceptors;
10
11
  isApplicationExit: boolean;
12
+ private readonly rmqServers;
11
13
  private readonly HEALTHY_FILE_PATH;
12
14
  private shutdownSignal?;
13
15
  private isShutdownInProgress;
14
16
  onApplicationBootstrap(): void;
17
+ /**
18
+ * @author jun
19
+ * @description 종료 시 신규 수신을 중단할 RMQ 서버를 등록한다.
20
+ * @process bootstrap에서 등록한 서버를 요청 drain보다 먼저 중단한다.
21
+ */
22
+ registerRmqServer(server: GracefulServerRMQ): void;
15
23
  pushActiveInterceptors(execInterceptor: GracefulExecInterceptor): void;
16
24
  popActiveInterceptors(execInterceptor: GracefulExecInterceptor): void;
17
25
  getActiveInterceptorsCount(): number;
@@ -38,6 +38,7 @@ let GracefulShutdownService = class GracefulShutdownService {
38
38
  constructor() {
39
39
  this.activeInterceptors = [];
40
40
  this.isApplicationExit = false;
41
+ this.rmqServers = new Set();
41
42
  this.HEALTHY_FILE_PATH = '/tmp/healthy';
42
43
  this.isShutdownInProgress = false;
43
44
  }
@@ -58,6 +59,14 @@ let GracefulShutdownService = class GracefulShutdownService {
58
59
  // eslint-disable-next-line max-len
59
60
  msaLogger.debug(`GracefulShutdownService.onApplicationBootstrap isExecLocal=${this.isExecLocal}`, { traceId: 'ON_APPLICATION_BOOTSTRAP' }, { isHealthyFileExist });
60
61
  }
62
+ /**
63
+ * @author jun
64
+ * @description 종료 시 신규 수신을 중단할 RMQ 서버를 등록한다.
65
+ * @process bootstrap에서 등록한 서버를 요청 drain보다 먼저 중단한다.
66
+ */
67
+ registerRmqServer(server) {
68
+ this.rmqServers.add(server);
69
+ }
61
70
  pushActiveInterceptors(execInterceptor) {
62
71
  this.activeInterceptors.push(execInterceptor);
63
72
  }
@@ -85,9 +94,9 @@ let GracefulShutdownService = class GracefulShutdownService {
85
94
  }
86
95
  async onModuleDestroy() {
87
96
  msaLogger.debug('GracefulShutdownService.onModuleDestroy', { traceId: 'ON_MODULE_DESTROY' });
88
- // onModuleDestroy에서는 단순히 플래그만 설정
89
- // 실제 대기는 onApplicationShutdown에서 처리됨
97
+ // 신규 수신을 먼저 중단하고, module의 onModuleDestroy에서 기존 요청 완료를 기다린다.
90
98
  this.isApplicationExit = true;
99
+ await Promise.all([...this.rmqServers].map(server => server.stopConsuming()));
91
100
  }
92
101
  async waitForActiveInterceptors() {
93
102
  if (this.isExecLocal) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-croket-dev/core",
3
- "version": "1.10.2",
3
+ "version": "1.10.4",
4
4
  "author": "Yolo Co., Ltd.",
5
5
  "description": "Yolo commonization module",
6
6
  "main": "index.js",
@@ -2,7 +2,8 @@ const assert = require('node:assert/strict');
2
2
  const { EventEmitter } = require('node:events');
3
3
  const test = require('node:test');
4
4
  const { ServerRMQ, RmqContext } = require('@nestjs/microservices');
5
- const { lastValueFrom, throwError } = require('rxjs');
5
+ const { from, lastValueFrom, throwError } = require('rxjs');
6
+ const { mergeAll } = require('rxjs/operators');
6
7
  const { MsaLogger } = require('@yolo-croket-dev/msa-logger');
7
8
  const { GracefulServerRMQ } = require('../modules/graceful-shutdown/graceful-shutdown.rmq');
8
9
  const { LoggingServiceInterceptor } = require('../interceptors/logging-service.interceptor');
@@ -77,17 +78,35 @@ test('primitive and getter failures retain the original rejection and emit diagn
77
78
  }
78
79
  });
79
80
 
80
- test('consumer observer is disposed even when channel setup removal fails', async t => {
81
+ test('consumer setup removal failure is logged and native close still disposes the observer', async t => {
81
82
  const flag = process.env.RMQ_DIAGNOSTICS_ENABLED;
82
83
  process.env.RMQ_DIAGNOSTICS_ENABLED = 'true';
83
84
  t.after(() => { if (flag === undefined) delete process.env.RMQ_DIAGNOSTICS_ENABLED; else process.env.RMQ_DIAGNOSTICS_ENABLED = flag; });
84
85
  const manager = new EventEmitter();
85
86
  t.mock.method(ServerRMQ.prototype, 'createClient', () => manager);
86
- t.mock.method(ServerRMQ.prototype, 'close', () => {});
87
+ const close = t.mock.method(ServerRMQ.prototype, 'close', () => {});
88
+ const warning = t.mock.method(MsaLogger.prototype, 'warn', () => {});
87
89
  const server = new GracefulServerRMQ({ queue: 'USER_SERVICE' });
88
90
  server.createClient();
89
91
  server.channel = { removeSetup: async () => { throw Error('channel closed'); } };
90
- await assert.rejects(server.close(), /channel closed/);
92
+ await assert.doesNotReject(server.close());
93
+ assert.equal(close.mock.callCount(), 1);
94
+ assert.equal(warning.mock.callCount(), 1);
95
+ assert.equal(manager.listenerCount('connect'), 0);
96
+ });
97
+
98
+ test('native server close failure retains its rejection after observer disposal', async t => {
99
+ const flag = process.env.RMQ_DIAGNOSTICS_ENABLED;
100
+ process.env.RMQ_DIAGNOSTICS_ENABLED = 'true';
101
+ t.after(() => { if (flag === undefined) delete process.env.RMQ_DIAGNOSTICS_ENABLED; else process.env.RMQ_DIAGNOSTICS_ENABLED = flag; });
102
+ const manager = new EventEmitter();
103
+ const failure = Error('native close failed');
104
+ t.mock.method(ServerRMQ.prototype, 'createClient', () => manager);
105
+ t.mock.method(ServerRMQ.prototype, 'close', async () => { throw failure; });
106
+ const server = new GracefulServerRMQ({ queue: 'USER_SERVICE' });
107
+ server.createClient();
108
+ server.channel = { removeSetup: async () => {} };
109
+ await assert.rejects(server.close(), err => err === failure);
91
110
  assert.equal(manager.listenerCount('connect'), 0);
92
111
  });
93
112
 
@@ -148,3 +167,90 @@ test('RPC-only handler failure records its trace and channel on the existing war
148
167
  assert.equal(warnings[0].info.diagnosticInfo.event, 'handler_failed');
149
168
  assert.equal(acknowledgements.length, 1);
150
169
  });
170
+
171
+ test('combined consumer interceptors record one detailed failure in either order', async t => {
172
+ const warnings = [];
173
+ const errors = [];
174
+ t.mock.method(MsaLogger.prototype, 'warn', (message, info, details) => warnings.push({ message, info, details }));
175
+ t.mock.method(MsaLogger.prototype, 'printExceptionFliterService', (info, details) => errors.push({ info, details }));
176
+ for (const method of ['printInputInterceptorService', 'printOutService']) t.mock.method(MsaLogger.prototype, method, () => {});
177
+
178
+ for (const order of ['logging-first', 'rpc-first']) {
179
+ warnings.length = 0;
180
+ errors.length = 0;
181
+ const acknowledgements = [];
182
+ const replies = [];
183
+ const channel = {
184
+ sendToQueue: (...args) => replies.push(args),
185
+ ack: message => acknowledgements.push(message),
186
+ };
187
+ const observer = new RmqConnectionObserver('USER_SERVICE', 'consumer', undefined, () => {});
188
+ registerRmqObservation(channel, observer);
189
+ const data = { payload: {}, param: { traceId: `combined-${order}` } };
190
+ const message = {
191
+ content: Buffer.from(JSON.stringify({ pattern: 'user.fail', data })),
192
+ properties: { headers: {}, correlationId: 'correlation', replyTo: 'reply.queue' },
193
+ fields: { routingKey: 'USER_SERVICE', consumerTag: 'consumer' },
194
+ };
195
+ const rmqContext = new RmqContext([message, channel, 'user.fail']);
196
+ const execution = {
197
+ getArgs: () => [data, rmqContext],
198
+ getHandler: () => function handler() {},
199
+ switchToRpc: () => ({ getContext: () => rmqContext, getData: () => data }),
200
+ switchToHttp: () => ({ getRequest: () => data }),
201
+ };
202
+ const shutdown = { isApplicationExit: false, pushActiveInterceptors: () => {}, popActiveInterceptors: () => {} };
203
+ const rpc = new RPCInterceptor({ get: () => undefined }, shutdown);
204
+ const logging = new LoggingServiceInterceptor();
205
+ const failure = Error(`handler failed ${order}`);
206
+ const handler = { handle: () => throwError(() => failure) };
207
+ const stream = order === 'logging-first'
208
+ ? logging.intercept(execution, { handle: () => from(rpc.intercept(execution, handler)).pipe(mergeAll()) })
209
+ : await rpc.intercept(execution, { handle: () => logging.intercept(execution, handler) });
210
+
211
+ await assert.rejects(lastValueFrom(stream), actual => actual === failure);
212
+ assert.equal(errors.length, 1, `${order}: detailed error must be logged once`);
213
+ assert.equal(errors[0].info.traceId, data.param.traceId);
214
+ assert.equal(errors[0].info.diagnosticInfo.connectionId, observer.connectionId);
215
+ assert.equal(errors[0].details.errors[0].message, failure.message);
216
+ assert.equal(warnings.filter(record => record.info.diagnosticInfo).length, 0, `${order}: RPC must not duplicate diagnostics`);
217
+ assert.equal(replies.length, 1);
218
+ assert.equal(acknowledgements.length, 1);
219
+ }
220
+ });
221
+
222
+ test('RPC guard failure retains diagnostics when a logging interceptor surrounds it', async t => {
223
+ const warnings = [];
224
+ t.mock.method(MsaLogger.prototype, 'warn', (message, info, details) => warnings.push({ message, info, details }));
225
+ for (const method of ['printInputInterceptorService', 'printOutService']) t.mock.method(MsaLogger.prototype, method, () => {});
226
+ const replies = [];
227
+ const channel = { sendToQueue: (...args) => replies.push(args), ack: () => {} };
228
+ registerRmqObservation(channel, new RmqConnectionObserver('USER_SERVICE', 'consumer', undefined, () => {}));
229
+ const data = { payload: {}, param: { traceId: 'combined-guard' } };
230
+ const message = {
231
+ content: Buffer.from(JSON.stringify({ pattern: 'user.guard', data })),
232
+ properties: { headers: {}, correlationId: 'correlation', replyTo: 'reply.queue' },
233
+ fields: { routingKey: 'USER_SERVICE', consumerTag: 'consumer' },
234
+ };
235
+ const rmqContext = new RmqContext([message, channel, 'user.guard']);
236
+ const execution = {
237
+ getArgs: () => [data, rmqContext],
238
+ getHandler: () => function handler() {},
239
+ switchToRpc: () => ({ getContext: () => rmqContext, getData: () => data }),
240
+ switchToHttp: () => ({ getRequest: () => data }),
241
+ };
242
+ const rpc = new RPCInterceptor({ get: () => ({ isAdminGuard: true }) }, { isApplicationExit: false });
243
+ let guardProcess;
244
+ new LoggingServiceInterceptor().intercept(execution, {
245
+ handle: () => {
246
+ guardProcess = rpc.intercept(execution, { handle: () => { throw Error('guard should reject first'); } });
247
+ return from(guardProcess).pipe(mergeAll());
248
+ },
249
+ });
250
+ await guardProcess;
251
+ assert.equal(warnings.length, 1);
252
+ assert.equal(warnings[0].info.traceId, 'combined-guard');
253
+ assert.equal(warnings[0].info.diagnosticInfo.event, 'handler_failed');
254
+ assert.equal(warnings[0].details.error.errors[0].message, 'ADMIN_ONLY_ACCESS');
255
+ assert.equal(replies.length, 1);
256
+ });
@@ -0,0 +1,250 @@
1
+ const assert = require('node:assert/strict');
2
+ const test = require('node:test');
3
+ const { createRequire } = require('node:module');
4
+ const { RmqContext, ServerRMQ } = require('@nestjs/microservices');
5
+ const { callModuleDestroyHook } = require('@nestjs/core/hooks/on-module-destroy.hook');
6
+ const { InstanceWrapper } = require('@nestjs/core/injector/instance-wrapper');
7
+ const { Subject } = require('rxjs');
8
+ const coreRequire = createRequire(require.resolve('../modules/graceful-shutdown/graceful-shutdown.rmq'));
9
+ const { MsaLogger } = coreRequire('@yolo-croket-dev/msa-logger');
10
+ const { RPCInterceptor } = require('../interceptors/rpc.interceptor');
11
+ const { GracefulShutdownService } = require('../modules/graceful-shutdown/graceful-shutdown.service');
12
+ const { GracefulShutdownModule } = require('../modules/graceful-shutdown/graceful-shutdown.module');
13
+ const { GracefulServerRMQ } = require('../modules/graceful-shutdown/graceful-shutdown.rmq');
14
+
15
+ function createRequest(channel, headers = {}) {
16
+ const data = { payload: {}, param: { traceId: 'shutdown-test' } };
17
+ const message = {
18
+ content: Buffer.from(JSON.stringify({ pattern: 'test.read', data })),
19
+ properties: { headers, correlationId: 'request-1', replyTo: 'reply-queue' },
20
+ fields: { consumerTag: 'consumer-1', routingKey: 'test.read' },
21
+ };
22
+ const rmq = new RmqContext([message, channel, 'test.read']);
23
+ return {
24
+ message,
25
+ context: { getHandler: () => 'test.read', getArgs: () => [data, rmq] },
26
+ };
27
+ }
28
+
29
+ test('종료 중 받은 미실행 RPC는 즉시 재전달하고 기존 요청의 ACK에는 손대지 않는다', async t => {
30
+ t.mock.method(MsaLogger.prototype, 'debug', () => {});
31
+ const shutdown = new GracefulShutdownService();
32
+ shutdown.isApplicationExit = true;
33
+ const calls = [];
34
+ const channel = {
35
+ cancel: async tag => { calls.push(['cancel', tag]); },
36
+ nack: (...args) => { calls.push(['nack', ...args]); },
37
+ ack: (...args) => { calls.push(['ack', ...args]); },
38
+ sendToQueue: (...args) => { calls.push(['reply', ...args]); },
39
+ };
40
+ const { message, context } = createRequest(channel);
41
+ const next = { handle: t.mock.fn(() => { throw Error('실행하면 안 되는 신규 요청'); }) };
42
+ const interceptor = new RPCInterceptor({ get: () => undefined }, shutdown);
43
+ await interceptor.intercept(context, next);
44
+ assert.deepEqual(calls, [
45
+ ['cancel', 'consumer-1'],
46
+ ['nack', message, false, true],
47
+ ]);
48
+ assert.equal(next.handle.mock.callCount(), 0);
49
+ assert.equal(shutdown.getActiveInterceptorsCount(), 0);
50
+ });
51
+
52
+ test('종료 전에 실행한 RPC는 계속 처리하고 종료 이후 도착한 메시지만 반환한다', async t => {
53
+ t.mock.method(MsaLogger.prototype, 'debug', () => {});
54
+ const shutdown = new GracefulShutdownService();
55
+ const calls = [];
56
+ let consume;
57
+ const channel = {
58
+ assertQueue: async () => {},
59
+ prefetch: async () => {},
60
+ consume: async (_queue, callback) => { consume = callback; return { consumerTag: 'consumer-1' }; },
61
+ cancel: async tag => { calls.push(['cancel', tag]); },
62
+ nack: (...args) => { calls.push(['nack', ...args]); },
63
+ ack: (...args) => { calls.push(['ack', ...args]); },
64
+ };
65
+ const server = new GracefulServerRMQ({ queue: 'TEST', noAck: false });
66
+ server.channel = { removeSetup: async (_setup, teardown) => teardown(channel) };
67
+ await server.setupChannel(channel, () => {});
68
+ const { message: runningMessage, context } = createRequest(channel);
69
+ const result = new Subject();
70
+ t.after(() => result.complete());
71
+ const interceptor = new RPCInterceptor({ get: () => undefined }, shutdown);
72
+ const response = await interceptor.intercept(context, { handle: () => result });
73
+ const values = [];
74
+ response.subscribe(value => values.push(value));
75
+ assert.equal(shutdown.getActiveInterceptorsCount(), 1);
76
+
77
+ shutdown.registerRmqServer(server);
78
+ await shutdown.onModuleDestroy();
79
+ assert.equal(shutdown.isApplicationExit, true);
80
+ assert.equal(shutdown.getActiveInterceptorsCount(), 1);
81
+ assert.deepEqual(calls, [['cancel', 'consumer-1']]);
82
+ const { message: newMessage } = createRequest(channel);
83
+ await consume(newMessage);
84
+ await consume(null);
85
+ assert.deepEqual(calls, [['cancel', 'consumer-1'], ['nack', newMessage, false, true]]);
86
+
87
+ result.next({ data: { ok: true } });
88
+ result.complete();
89
+ assert.deepEqual(values, [{ data: { ok: true }, isError: false }]);
90
+ assert.deepEqual(calls.at(-1), ['ack', runningMessage]);
91
+ assert.equal(shutdown.getActiveInterceptorsCount(), 0);
92
+ });
93
+
94
+ test('재전달 확인 중 종료가 시작되면 실행 직전에 요청을 반환한다', async t => {
95
+ t.mock.method(MsaLogger.prototype, 'debug', () => {});
96
+ const shutdown = new GracefulShutdownService();
97
+ const calls = [];
98
+ const channel = {
99
+ cancel: async tag => { calls.push(['cancel', tag]); },
100
+ nack: (...args) => { calls.push(['nack', ...args]); },
101
+ };
102
+ const { message, context } = createRequest(channel, { 'x-delivery-count': 1 });
103
+ const next = { handle: t.mock.fn(() => { throw Error('실행하면 안 되는 신규 요청'); }) };
104
+ const interceptor = new RPCInterceptor({ get: () => undefined }, shutdown);
105
+ t.mock.method(interceptor, 'isRedeliveryMessage', async () => {
106
+ shutdown.isApplicationExit = true;
107
+ return true;
108
+ });
109
+ await interceptor.intercept(context, next);
110
+ assert.deepEqual(calls, [['cancel', 'consumer-1'], ['nack', message, false, true]]);
111
+ assert.equal(next.handle.mock.callCount(), 0);
112
+ assert.equal(shutdown.getActiveInterceptorsCount(), 0);
113
+ });
114
+
115
+ for (const scenario of [
116
+ { result: false, deliveryCount: 1, name: '재전달 확인 실패' },
117
+ { result: true, deliveryCount: 6, name: '재전달 횟수 초과' },
118
+ ]) {
119
+ test(`종료가 시작되면 ${scenario.name} 요청도 DLQ 대신 재큐잉한다`, async t => {
120
+ t.mock.method(MsaLogger.prototype, 'debug', () => {});
121
+ const shutdown = new GracefulShutdownService();
122
+ const calls = [];
123
+ const channel = {
124
+ cancel: async tag => { calls.push(['cancel', tag]); },
125
+ nack: (...args) => { calls.push(['nack', ...args]); },
126
+ sendToQueue: () => { calls.push(['reply']); },
127
+ };
128
+ const { message, context } = createRequest(channel, { 'x-delivery-count': scenario.deliveryCount });
129
+ const next = { handle: t.mock.fn(() => { throw Error('실행하면 안 되는 신규 요청'); }) };
130
+ const interceptor = new RPCInterceptor({ get: () => undefined }, shutdown);
131
+ t.mock.method(interceptor, 'isRedeliveryMessage', async () => {
132
+ shutdown.isApplicationExit = true;
133
+ return scenario.result;
134
+ });
135
+ await interceptor.intercept(context, next);
136
+ assert.deepEqual(calls, [['cancel', 'consumer-1'], ['nack', message, false, true]]);
137
+ assert.equal(next.handle.mock.callCount(), 0);
138
+ assert.equal(shutdown.getActiveInterceptorsCount(), 0);
139
+ });
140
+ }
141
+
142
+ test('수신 중단과 서버 close가 겹쳐도 consumer는 한 번만 취소한다', async t => {
143
+ const calls = [];
144
+ let finishCancel;
145
+ const pendingCancel = new Promise(resolve => { finishCancel = resolve; });
146
+ t.after(() => finishCancel());
147
+ const channel = { cancel: async tag => { calls.push(['cancel', tag]); await pendingCancel; } };
148
+ const server = new GracefulServerRMQ({ queue: 'TEST', waitingEndingHandlersTimeoutMs: 1 });
149
+ server.consumerTag = 'consumer-1';
150
+ server.channel = { removeSetup: async (_setup, teardown) => teardown(channel) };
151
+ t.mock.method(ServerRMQ.prototype, 'close', async () => { calls.push(['close']); });
152
+ const stopping = server.stopConsuming();
153
+ const closing = server.close();
154
+ assert.deepEqual(calls, [['cancel', 'consumer-1']]);
155
+ finishCancel();
156
+ await Promise.all([stopping, closing]);
157
+ assert.deepEqual(calls, [['cancel', 'consumer-1'], ['close']]);
158
+ });
159
+
160
+ for (const failurePoint of ['cancel', 'setup']) {
161
+ test(`${failurePoint} 실패 후에도 Nest 종료 훅은 기존 RPC 완료를 기다리고 연결을 정리한다`, async t => {
162
+ t.mock.method(MsaLogger.prototype, 'debug', () => {});
163
+ t.mock.method(MsaLogger.prototype, 'info', () => {});
164
+ const warning = t.mock.method(MsaLogger.prototype, 'warn', () => {});
165
+ const shutdown = new GracefulShutdownService();
166
+ const server = new GracefulServerRMQ({ queue: 'TEST', noAck: false });
167
+ const calls = [];
168
+ const failure = new Error('Channel ended during consumer cancellation');
169
+ server.consumerTag = 'consumer-1';
170
+ server.channel = {
171
+ removeSetup: async (_setup, teardown) => {
172
+ if (failurePoint === 'setup') throw failure;
173
+ await teardown({ cancel: async () => { calls.push('cancel'); throw failure; } });
174
+ },
175
+ };
176
+ const active = { method: 'test.payment', traceId: 'shutdown-test', correlationId: 'active-request' };
177
+ shutdown.pushActiveInterceptors(active);
178
+ t.after(() => shutdown.popActiveInterceptors(active));
179
+ shutdown.registerRmqServer(server);
180
+ let enteredDrain;
181
+ const startedDrain = new Promise(resolve => { enteredDrain = resolve; });
182
+ const originalDrain = shutdown.waitForActiveInterceptors.bind(shutdown);
183
+ t.mock.method(shutdown, 'waitForActiveInterceptors', async () => {
184
+ calls.push('drain');
185
+ enteredDrain();
186
+ await originalDrain();
187
+ calls.push('finished');
188
+ });
189
+ t.mock.method(ServerRMQ.prototype, 'close', async () => { calls.push('close'); });
190
+ const moduleInstance = new GracefulShutdownModule(shutdown);
191
+ const moduleFixture = {
192
+ getNonAliasProviders: () => [
193
+ ['module', new InstanceWrapper({ instance: moduleInstance, metatype: GracefulShutdownModule })],
194
+ ['service', new InstanceWrapper({ instance: shutdown, metatype: GracefulShutdownService })],
195
+ ],
196
+ controllers: new Map(), injectables: new Map(), middlewares: new Map(),
197
+ };
198
+ const destroying = callModuleDestroyHook(moduleFixture);
199
+ await Promise.race([startedDrain, destroying]);
200
+ assert.equal(shutdown.isApplicationExit, true);
201
+ assert.equal(shutdown.getActiveInterceptorsCount(), 1);
202
+ assert.equal(calls.includes('close'), false);
203
+ shutdown.popActiveInterceptors(active);
204
+ await destroying;
205
+ await server.close();
206
+ assert.deepEqual(calls, failurePoint === 'cancel'
207
+ ? ['cancel', 'drain', 'finished', 'close'] : ['drain', 'finished', 'close']);
208
+ assert.equal(warning.mock.callCount(), 1);
209
+ assert.equal(server.consumerTag, null);
210
+ assert.equal(shutdown.getActiveInterceptorsCount(), 0);
211
+ });
212
+ }
213
+
214
+ test('채널 설정 중 종료가 시작되면 새 consumer를 만들지 않는다', async () => {
215
+ let finishPrefetch;
216
+ const prefetch = new Promise(resolve => { finishPrefetch = resolve; });
217
+ const calls = [];
218
+ const channel = {
219
+ assertQueue: async () => {},
220
+ prefetch: async () => prefetch,
221
+ consume: async () => { calls.push('consume'); return { consumerTag: 'new-consumer' }; },
222
+ };
223
+ const server = new GracefulServerRMQ({ queue: 'TEST' });
224
+ const setup = server.setupChannel(channel, () => calls.push('ready'));
225
+ await server.stopConsuming();
226
+ finishPrefetch();
227
+ await setup;
228
+ assert.deepEqual(calls, []);
229
+ });
230
+
231
+ test('consumer 등록이 끝나기 전에 종료가 시작되면 해당 consumer도 취소한다', async () => {
232
+ let finishConsume;
233
+ let enteredConsume;
234
+ const started = new Promise(resolve => { enteredConsume = resolve; });
235
+ const consumer = new Promise(resolve => { finishConsume = resolve; });
236
+ const calls = [];
237
+ const channel = {
238
+ assertQueue: async () => {},
239
+ prefetch: async () => {},
240
+ consume: async () => { enteredConsume(); return consumer; },
241
+ cancel: async tag => { calls.push(['cancel', tag]); },
242
+ };
243
+ const server = new GracefulServerRMQ({ queue: 'TEST' });
244
+ const setup = server.setupChannel(channel, () => calls.push(['ready']));
245
+ await started;
246
+ await server.stopConsuming();
247
+ finishConsume({ consumerTag: 'new-consumer' });
248
+ await setup;
249
+ assert.deepEqual(calls, [['cancel', 'new-consumer']]);
250
+ });