@yolo-croket-dev/core 1.10.4 → 1.10.5

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.10.5
4
+
5
+ - 종료 중 RPC의 consumer 취소나 메시지 반환이 실패해도 기존 Pod의 오류·완료 응답으로 전파하지 않습니다.
6
+ - 취소 실패 후에도 메시지 반환을 시도하며 실패한 작업은 요청 traceId와 정리된 진단 정보로 한 번 기록합니다.
7
+
3
8
  ## 1.10.4
4
9
 
5
10
  - 종료 중 consumer 취소나 채널 설정이 실패해도 기존 RPC 완료 대기와 최종 연결 정리를 계속합니다.
@@ -148,10 +148,39 @@ let RPCInterceptor = class RPCInterceptor {
148
148
  }
149
149
  return obj;
150
150
  }
151
- async requeueMessageOnShutdown(channel, message) {
151
+ async requeueMessageOnShutdown(channel, message, traceId) {
152
152
  msaLogger.debug('RPCInterceptor isApplicationExit called', { traceId: 'IS_APPLICATION_EXIT' });
153
- await channel.cancel(message.fields.consumerTag);
154
- channel.nack(message, false, true);
153
+ const failures = [];
154
+ try {
155
+ await channel.cancel(message.fields.consumerTag);
156
+ }
157
+ catch (err) {
158
+ failures.push({
159
+ operation: 'cancel',
160
+ diagnostic: (0, msa_logger_1.normalizeDiagnosticError)(err),
161
+ });
162
+ }
163
+ try {
164
+ channel.nack(message, false, true);
165
+ }
166
+ catch (err) {
167
+ // 닫힌 채널의 미확인 메시지는 브로커가 재전달하므로 기존 Pod에서 오류 응답을 보내지 않는다.
168
+ failures.push({
169
+ operation: 'nack',
170
+ diagnostic: (0, msa_logger_1.normalizeDiagnosticError)(err),
171
+ });
172
+ }
173
+ if (failures.length) {
174
+ msaLogger.warn('RPCInterceptor 종료 중 메시지 재전달 처리 실패', {
175
+ traceId,
176
+ diagnosticInfo: failures[0].diagnostic.diagnosticInfo,
177
+ }, {
178
+ failures: failures.map(({ operation, diagnostic }) => ({
179
+ operation,
180
+ ...diagnostic.details,
181
+ })),
182
+ });
183
+ }
155
184
  // 기존 서버에서 응답을 완료하면 재전달된 요청의 실제 응답을 클라이언트가 기다릴 수 없다.
156
185
  return new rxjs_1.Observable();
157
186
  }
@@ -182,7 +211,7 @@ let RPCInterceptor = class RPCInterceptor {
182
211
  let abortController = new AbortController();
183
212
  /** consume 메시지 전부 cancel */
184
213
  if (this.gracefulShutdownService.isApplicationExit) {
185
- return this.requeueMessageOnShutdown(channel, message);
214
+ return this.requeueMessageOnShutdown(channel, message, execInterceptor.traceId);
186
215
  }
187
216
  if (!staticRetryCount && reDeliveryCount > 0) {
188
217
  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' }, {
@@ -195,7 +224,7 @@ let RPCInterceptor = class RPCInterceptor {
195
224
  });
196
225
  const isRedeliveryMessage = await this.isRedeliveryMessage(message.properties.replyTo, execInterceptor.traceId);
197
226
  if (this.gracefulShutdownService.isApplicationExit) {
198
- return this.requeueMessageOnShutdown(channel, message);
227
+ return this.requeueMessageOnShutdown(channel, message, execInterceptor.traceId);
199
228
  }
200
229
  if (!isRedeliveryMessage || reDeliveryCount > 5) {
201
230
  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' });
@@ -368,7 +397,7 @@ let RPCInterceptor = class RPCInterceptor {
368
397
  }
369
398
  // 재전달 여부를 비동기로 확인하는 동안 종료가 시작될 수 있으므로 실행 직전에 다시 확인한다.
370
399
  if (this.gracefulShutdownService.isApplicationExit) {
371
- return this.requeueMessageOnShutdown(channel, message);
400
+ return this.requeueMessageOnShutdown(channel, message, execInterceptor.traceId);
372
401
  }
373
402
  this.gracefulShutdownService.pushActiveInterceptors(execInterceptor);
374
403
  // eslint-disable-next-line consistent-return
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yolo-croket-dev/core",
3
- "version": "1.10.4",
3
+ "version": "1.10.5",
4
4
  "author": "Yolo Co., Ltd.",
5
5
  "description": "Yolo commonization module",
6
6
  "main": "index.js",
@@ -2,6 +2,9 @@ const assert = require('node:assert/strict');
2
2
  const test = require('node:test');
3
3
  const { createRequire } = require('node:module');
4
4
  const { RmqContext, ServerRMQ } = require('@nestjs/microservices');
5
+ const { RpcProxy } = require('@nestjs/microservices/context/rpc-proxy');
6
+ const { RpcExceptionsHandler } = require('@nestjs/microservices/exceptions/rpc-exceptions-handler');
7
+ const { Logger } = require('@nestjs/common');
5
8
  const { callModuleDestroyHook } = require('@nestjs/core/hooks/on-module-destroy.hook');
6
9
  const { InstanceWrapper } = require('@nestjs/core/injector/instance-wrapper');
7
10
  const { Subject } = require('rxjs');
@@ -49,6 +52,60 @@ test('종료 중 받은 미실행 RPC는 즉시 재전달하고 기존 요청의
49
52
  assert.equal(shutdown.getActiveInterceptorsCount(), 0);
50
53
  });
51
54
 
55
+ for (const scenario of [
56
+ { name: '취소 실패', cancelFails: true, nackFails: false, duringRedelivery: false },
57
+ { name: '반환 실패', cancelFails: false, nackFails: true, duringRedelivery: false },
58
+ { name: '취소·반환 실패', cancelFails: true, nackFails: true, duringRedelivery: false },
59
+ { name: '재전달 확인 중 종료와 취소 실패', cancelFails: true, nackFails: false, duringRedelivery: true },
60
+ ]) {
61
+ test(`${scenario.name} 시에도 기존 Pod는 RPC 오류나 완료 응답을 보내지 않는다`, async t => {
62
+ t.mock.method(MsaLogger.prototype, 'debug', () => {});
63
+ t.mock.method(Logger.prototype, 'error', () => {});
64
+ const warning = t.mock.method(MsaLogger.prototype, 'warn', () => {});
65
+ const shutdown = new GracefulShutdownService();
66
+ shutdown.isApplicationExit = !scenario.duringRedelivery;
67
+ const calls = [];
68
+ const channel = {
69
+ cancel: async () => {
70
+ calls.push('cancel');
71
+ if (scenario.cancelFails) throw Error('channel closed during cancel');
72
+ },
73
+ nack: (_message, multiple, requeue) => {
74
+ calls.push(['nack', multiple, requeue]);
75
+ if (scenario.nackFails) throw Error('channel closed during nack');
76
+ },
77
+ };
78
+ const { message, context } = createRequest(channel, scenario.duringRedelivery ? { 'x-delivery-count': 1 } : {});
79
+ const interceptor = new RPCInterceptor({ get: () => undefined }, shutdown);
80
+ if (scenario.duringRedelivery) {
81
+ const server = new GracefulServerRMQ({ queue: 'TEST', noAck: false });
82
+ shutdown.registerRmqServer(server);
83
+ t.mock.method(interceptor, 'isRedeliveryMessage', async () => {
84
+ await shutdown.onModuleDestroy();
85
+ return true;
86
+ });
87
+ }
88
+ const next = { handle: t.mock.fn(() => { throw Error('실행하면 안 되는 신규 요청'); }) };
89
+ const handler = new RpcProxy().create(() => interceptor.intercept(context, next), new RpcExceptionsHandler());
90
+ const response = await handler(...context.getArgs());
91
+ const replies = [];
92
+ const subscription = new ServerRMQ({ queue: 'TEST', noAck: false }).send(response, value => replies.push(value));
93
+ t.after(() => subscription.unsubscribe());
94
+ await new Promise(resolve => setImmediate(resolve));
95
+ assert.deepEqual(replies, []);
96
+ assert.deepEqual(calls, ['cancel', ['nack', false, true]]);
97
+ assert.equal(next.handle.mock.callCount(), 0);
98
+ assert.equal(shutdown.getActiveInterceptorsCount(), 0);
99
+ assert.equal(warning.mock.callCount(), 1);
100
+ const [logMessage, logInfo, logDetails] = warning.mock.calls[0].arguments;
101
+ assert.match(logMessage, /종료/);
102
+ assert.equal(logInfo.traceId, 'shutdown-test');
103
+ assert.deepEqual(logDetails.failures.map(item => item.operation),
104
+ [scenario.cancelFails ? 'cancel' : null, scenario.nackFails ? 'nack' : null].filter(Boolean));
105
+ assert.equal(message.properties.replyTo, 'reply-queue');
106
+ });
107
+ }
108
+
52
109
  test('종료 전에 실행한 RPC는 계속 처리하고 종료 이후 도착한 메시지만 반환한다', async t => {
53
110
  t.mock.method(MsaLogger.prototype, 'debug', () => {});
54
111
  const shutdown = new GracefulShutdownService();