@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 +11 -0
- package/interceptors/rpc.interceptor.d.ts +1 -0
- package/interceptors/rpc.interceptor.js +15 -3
- package/modules/graceful-shutdown/graceful-shutdown.module.js +1 -2
- package/modules/graceful-shutdown/graceful-shutdown.rmq.d.ts +9 -2
- package/modules/graceful-shutdown/graceful-shutdown.rmq.js +71 -4
- package/modules/graceful-shutdown/graceful-shutdown.service.d.ts +8 -0
- package/modules/graceful-shutdown/graceful-shutdown.service.js +11 -2
- package/package.json +1 -1
- package/tests/rmq-consumer-diagnostics.test.cjs +110 -4
- package/tests/rmq-shutdown-requeue.test.cjs +250 -0
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
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
|
|
139
|
+
await super.handleMessage(message, channel).finally(() => {
|
|
102
140
|
this.runningMessages -= 1;
|
|
103
141
|
});
|
|
104
142
|
}
|
|
105
|
-
|
|
106
|
-
|
|
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) =>
|
|
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
|
@@ -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
|
|
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.
|
|
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
|
+
});
|