@yolo-croket-dev/core 1.10.0-jun.2-beta.1 → 1.10.1
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 +16 -0
- package/filters/grpc-exception.filter.js +2 -2
- package/functions/amqp-call-manager.function.js +7 -2
- package/functions/amqp-handler.function.js +66 -11
- package/functions/grpc-param-metadata.function.d.ts +1 -1
- package/functions/index.d.ts +1 -0
- package/functions/index.js +1 -0
- package/functions/rmq-diagnostics.function.d.ts +89 -0
- package/functions/rmq-diagnostics.function.js +300 -0
- package/interceptors/logging-service.interceptor.js +51 -14
- package/interceptors/rpc.interceptor.js +29 -7
- package/interfaces/exception.js +3 -3
- package/interfaces/http.d.ts +3 -2
- package/interfaces/log-level.enum.js +1 -1
- package/interfaces/msa-exception.d.ts +11 -1
- package/interfaces/msa-exception.js +6 -1
- package/modules/graceful-shutdown/graceful-shutdown.module.js +2 -2
- package/modules/graceful-shutdown/graceful-shutdown.rmq.d.ts +12 -4
- package/modules/graceful-shutdown/graceful-shutdown.rmq.js +79 -12
- package/modules/graceful-shutdown/graceful-shutdown.service.js +2 -2
- package/modules/redis.module.js +2 -2
- package/package.json +2 -2
- package/pipes/rpc-validation.pipe.js +2 -2
- package/tests/amqp-handler-timeout.test.cjs +47 -0
- package/tests/graceful-shutdown-rmq.test.cjs +102 -0
- package/tests/rmq-consumer-diagnostics.test.cjs +150 -0
- package/tests/rmq-diagnostics.test.cjs +177 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
GracefulServerRMQ,
|
|
7
|
+
} = require('../modules/graceful-shutdown/graceful-shutdown.rmq.js');
|
|
8
|
+
|
|
9
|
+
const CONNECTION_NAME_ENV_KEYS = [
|
|
10
|
+
'ENV',
|
|
11
|
+
'NODE_ENV',
|
|
12
|
+
'RMQ_OWNER',
|
|
13
|
+
'USER',
|
|
14
|
+
'USERNAME',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function getClientProperties(options) {
|
|
18
|
+
const server = new GracefulServerRMQ(options);
|
|
19
|
+
return server.options.socketOptions.connectionOptions.clientProperties;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function runWithEnvironment(environment, callback) {
|
|
23
|
+
const originalEnvironment = Object.fromEntries(
|
|
24
|
+
CONNECTION_NAME_ENV_KEYS.map((key) => [key, process.env[key]]),
|
|
25
|
+
);
|
|
26
|
+
try {
|
|
27
|
+
for (const key of CONNECTION_NAME_ENV_KEYS) {
|
|
28
|
+
delete process.env[key];
|
|
29
|
+
}
|
|
30
|
+
Object.assign(process.env, environment);
|
|
31
|
+
callback();
|
|
32
|
+
} finally {
|
|
33
|
+
for (const key of CONNECTION_NAME_ENV_KEYS) {
|
|
34
|
+
const originalValue = originalEnvironment[key];
|
|
35
|
+
if (originalValue === undefined) {
|
|
36
|
+
delete process.env[key];
|
|
37
|
+
} else {
|
|
38
|
+
process.env[key] = originalValue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('자동 connection_name에 큐와 실행 주체 정보를 포함한다', () => {
|
|
45
|
+
runWithEnvironment({ ENV: 'release', RMQ_OWNER: 'jun' }, () => {
|
|
46
|
+
const actualClientProperties = getClientProperties({ queue: 'STORE_ITEM_SERVER' });
|
|
47
|
+
const expectedConnectionName = `STORE_ITEM_SERVER/release/jun/${os.hostname()}/pid-${process.pid}`;
|
|
48
|
+
assert.equal(actualClientProperties.connection_name, expectedConnectionName);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('기존 connectionOptions와 clientProperties를 보존한다', () => {
|
|
53
|
+
runWithEnvironment({ NODE_ENV: 'production', USER: 'deploy' }, () => {
|
|
54
|
+
const inputOptions = {
|
|
55
|
+
queue: 'USER_SERVICE',
|
|
56
|
+
socketOptions: {
|
|
57
|
+
noDelay: true,
|
|
58
|
+
connectionOptions: {
|
|
59
|
+
locale: 'ko_KR',
|
|
60
|
+
clientProperties: {
|
|
61
|
+
product: 'croket',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const server = new GracefulServerRMQ(inputOptions);
|
|
67
|
+
assert.equal(server.options.socketOptions.noDelay, true);
|
|
68
|
+
assert.equal(server.options.socketOptions.connectionOptions.locale, 'ko_KR');
|
|
69
|
+
assert.equal(server.options.socketOptions.connectionOptions.clientProperties.product, 'croket');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('명시적으로 설정한 문자열 connection_name을 보존한다', () => {
|
|
74
|
+
const actualClientProperties = getClientProperties({
|
|
75
|
+
queue: 'USER_SERVICE',
|
|
76
|
+
socketOptions: {
|
|
77
|
+
connectionOptions: {
|
|
78
|
+
clientProperties: {
|
|
79
|
+
connection_name: 'custom-connection-name',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
assert.equal(actualClientProperties.connection_name, 'custom-connection-name');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('문자열이 아닌 connection_name은 자동 생성값으로 대체한다', () => {
|
|
88
|
+
runWithEnvironment({ ENV: 'release', RMQ_OWNER: 'jun' }, () => {
|
|
89
|
+
const actualClientProperties = getClientProperties({
|
|
90
|
+
queue: 'STORE_ITEM_SERVER',
|
|
91
|
+
socketOptions: {
|
|
92
|
+
connectionOptions: {
|
|
93
|
+
clientProperties: {
|
|
94
|
+
connection_name: 123,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const expectedConnectionName = `STORE_ITEM_SERVER/release/jun/${os.hostname()}/pid-${process.pid}`;
|
|
100
|
+
assert.equal(actualClientProperties.connection_name, expectedConnectionName);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { EventEmitter } = require('node:events');
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const { ServerRMQ, RmqContext } = require('@nestjs/microservices');
|
|
5
|
+
const { lastValueFrom, throwError } = require('rxjs');
|
|
6
|
+
const { MsaLogger } = require('@yolo-croket-dev/msa-logger');
|
|
7
|
+
const { GracefulServerRMQ } = require('../modules/graceful-shutdown/graceful-shutdown.rmq');
|
|
8
|
+
const { LoggingServiceInterceptor } = require('../interceptors/logging-service.interceptor');
|
|
9
|
+
const { RPCInterceptor } = require('../interceptors/rpc.interceptor');
|
|
10
|
+
const { getRmqDiagnosticSnapshot, RmqConnectionObserver, registerRmqObservation } = require('../functions/rmq-diagnostics.function');
|
|
11
|
+
|
|
12
|
+
test('default/manage consumers preserve QoS/ACK, correlate failures and dispose observers on close', async t => {
|
|
13
|
+
const flag = process.env.RMQ_DIAGNOSTICS_ENABLED;
|
|
14
|
+
process.env.RMQ_DIAGNOSTICS_ENABLED = 'true';
|
|
15
|
+
t.after(() => { if (flag === undefined) delete process.env.RMQ_DIAGNOSTICS_ENABLED; else process.env.RMQ_DIAGNOSTICS_ENABLED = flag; });
|
|
16
|
+
const managers = [];
|
|
17
|
+
t.mock.method(ServerRMQ.prototype, 'createClient', () => {
|
|
18
|
+
const manager = new EventEmitter(); managers.push(manager); return manager;
|
|
19
|
+
});
|
|
20
|
+
const close = t.mock.method(ServerRMQ.prototype, 'close', () => {});
|
|
21
|
+
const errors = [];
|
|
22
|
+
for (const method of ['info', 'warn', 'printInputInterceptorService', 'printOutService']) t.mock.method(MsaLogger.prototype, method, () => {});
|
|
23
|
+
t.mock.method(MsaLogger.prototype, 'printExceptionFliterService', (info, details) => errors.push({ info, details }));
|
|
24
|
+
const identities = new Set();
|
|
25
|
+
for (const queue of ['USER_SERVICE', 'MANAGE_SERVICE']) {
|
|
26
|
+
const calls = [];
|
|
27
|
+
const channel = {
|
|
28
|
+
assertQueue: async (...args) => calls.push(['assert', ...args]),
|
|
29
|
+
prefetch: async (...args) => calls.push(['prefetch', ...args]),
|
|
30
|
+
consume: async (...args) => { calls.push(['consume', ...args]); return { consumerTag: 'tag' }; },
|
|
31
|
+
cancel: async tag => calls.push(['cancel', tag]),
|
|
32
|
+
};
|
|
33
|
+
const server = new GracefulServerRMQ({ queue, noAck: false, prefetchCount: 7, queueOptions: { durable: true, deadLetterExchange: 'existing.dlx' }, waitingEndingHandlersTimeoutMs: 1 });
|
|
34
|
+
const manager = server.createClient(); manager.emit('connect');
|
|
35
|
+
await server.setupChannel(channel, () => {});
|
|
36
|
+
assert.deepEqual(calls[0], ['assert', queue, { durable: true, deadLetterExchange: 'existing.dlx' }]);
|
|
37
|
+
assert.deepEqual(calls[1], ['prefetch', 7, undefined]);
|
|
38
|
+
assert.deepEqual(calls[2][3], { noAck: false });
|
|
39
|
+
const snapshot = getRmqDiagnosticSnapshot(channel);
|
|
40
|
+
identities.add(snapshot.connectionId);
|
|
41
|
+
const data = { payload: { ok: true }, param: { traceId: 'consumer-trace' } };
|
|
42
|
+
const context = new RmqContext([{ content: Buffer.from(JSON.stringify({ pattern: 'test.receive' })) }, channel, 'test.receive']);
|
|
43
|
+
const execution = { getArgs: () => [data, context], switchToRpc: () => ({ getContext: () => context, getData: () => data }), switchToHttp: () => ({ getRequest: () => data }) };
|
|
44
|
+
const error = Object.assign(Error('handler failed'), { status: 409, errOption: { logErrorLevel: 'warn' } });
|
|
45
|
+
await assert.rejects(lastValueFrom(new LoggingServiceInterceptor().intercept(execution, { handle: () => throwError(() => error) })), actual => actual === error);
|
|
46
|
+
const record = errors.at(-1);
|
|
47
|
+
assert.equal(record.info.diagnosticInfo.connectionId, snapshot.connectionId);
|
|
48
|
+
assert.equal(record.info.diagnosticInfo.targetService, queue);
|
|
49
|
+
assert.equal(record.info.diagnosticInfo.direction, 'consumer');
|
|
50
|
+
assert.equal(record.info.diagnosticInfo.kind, 'service_error');
|
|
51
|
+
assert.equal(record.info.errInfo.errOption.logErrorLevel, 'warn');
|
|
52
|
+
server.channel = { removeSetup: async (_, cancel) => cancel(channel) };
|
|
53
|
+
await server.close();
|
|
54
|
+
assert.deepEqual(calls.at(-1), ['cancel', 'tag']);
|
|
55
|
+
assert.equal(manager.eventNames().length, 0);
|
|
56
|
+
}
|
|
57
|
+
assert.equal(identities.size, 2);
|
|
58
|
+
assert.equal(close.mock.callCount(), 2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('primitive and getter failures retain the original rejection and emit diagnostics', async t => {
|
|
62
|
+
const output = [];
|
|
63
|
+
for (const method of ['printInputInterceptorService', 'printOutService']) t.mock.method(MsaLogger.prototype, method, () => {});
|
|
64
|
+
t.mock.method(MsaLogger.prototype, 'printExceptionFliterService', (info, details) => output.push({ info, details }));
|
|
65
|
+
const channel = {};
|
|
66
|
+
registerRmqObservation(channel, new RmqConnectionObserver('USER_SERVICE', 'consumer', undefined, () => {}));
|
|
67
|
+
const data = { payload: {}, param: { traceId: 'primitive-error' } };
|
|
68
|
+
const context = new RmqContext([{ content: Buffer.from(JSON.stringify({ pattern: 'test.receive' })) }, channel, 'test.receive']);
|
|
69
|
+
const execution = { getArgs: () => [data, context], switchToRpc: () => ({ getContext: () => context, getData: () => data }), switchToHttp: () => ({ getRequest: () => data }) };
|
|
70
|
+
const throwingGetter = new Proxy({}, { get() { throw Error('getter failed'); } });
|
|
71
|
+
for (const original of [null, 'remote failed', throwingGetter]) {
|
|
72
|
+
const settled = await Promise.allSettled([lastValueFrom(new LoggingServiceInterceptor().intercept(execution, { handle: () => throwError(() => original) }))]);
|
|
73
|
+
assert.equal(settled[0].status, 'rejected');
|
|
74
|
+
assert.equal(settled[0].reason, original);
|
|
75
|
+
assert.equal(output.at(-1).info.traceId, 'primitive-error');
|
|
76
|
+
assert.equal(output.at(-1).info.diagnosticInfo.event, 'handler_failed');
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('consumer observer is disposed even when channel setup removal fails', async t => {
|
|
81
|
+
const flag = process.env.RMQ_DIAGNOSTICS_ENABLED;
|
|
82
|
+
process.env.RMQ_DIAGNOSTICS_ENABLED = 'true';
|
|
83
|
+
t.after(() => { if (flag === undefined) delete process.env.RMQ_DIAGNOSTICS_ENABLED; else process.env.RMQ_DIAGNOSTICS_ENABLED = flag; });
|
|
84
|
+
const manager = new EventEmitter();
|
|
85
|
+
t.mock.method(ServerRMQ.prototype, 'createClient', () => manager);
|
|
86
|
+
t.mock.method(ServerRMQ.prototype, 'close', () => {});
|
|
87
|
+
const server = new GracefulServerRMQ({ queue: 'USER_SERVICE' });
|
|
88
|
+
server.createClient();
|
|
89
|
+
server.channel = { removeSetup: async () => { throw Error('channel closed'); } };
|
|
90
|
+
await assert.rejects(server.close(), /channel closed/);
|
|
91
|
+
assert.equal(manager.listenerCount('connect'), 0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('RPC-only consumer failure keeps the existing reply and adds connection diagnostics to its warning', async t => {
|
|
95
|
+
const warnings = [];
|
|
96
|
+
t.mock.method(MsaLogger.prototype, 'warn', (message, info, details) => warnings.push({ message, info, details }));
|
|
97
|
+
const replies = [];
|
|
98
|
+
const channel = {
|
|
99
|
+
sendToQueue: (...args) => replies.push(args),
|
|
100
|
+
ack: () => {},
|
|
101
|
+
};
|
|
102
|
+
const observer = new RmqConnectionObserver('MANAGE_SERVICE', 'consumer', undefined, () => {});
|
|
103
|
+
registerRmqObservation(channel, observer);
|
|
104
|
+
const request = { pattern: 'manage.fail', data: { payload: {}, param: { traceId: 'rpc-only' } } };
|
|
105
|
+
const message = {
|
|
106
|
+
content: Buffer.from(JSON.stringify(request)),
|
|
107
|
+
properties: { headers: {}, correlationId: 'correlation', replyTo: 'reply.queue' },
|
|
108
|
+
fields: { routingKey: 'MANAGE_SERVICE', consumerTag: 'consumer' },
|
|
109
|
+
};
|
|
110
|
+
const context = { getArgs: () => [{}, new RmqContext([message, channel, 'manage.fail'])], getHandler: () => function handler() {} };
|
|
111
|
+
const interceptor = new RPCInterceptor({ get: () => ({ isAdminGuard: true }) }, { isApplicationExit: false });
|
|
112
|
+
await interceptor.intercept(context, { handle: () => { throw Error('guard should reject first'); } });
|
|
113
|
+
assert.equal(warnings.length, 1);
|
|
114
|
+
assert.equal(warnings[0].info.traceId, 'rpc-only');
|
|
115
|
+
assert.equal(warnings[0].info.diagnosticInfo.connectionId, observer.connectionId);
|
|
116
|
+
assert.equal(warnings[0].info.diagnosticInfo.targetService, 'MANAGE_SERVICE');
|
|
117
|
+
assert.equal(warnings[0].info.diagnosticInfo.messagePattern, 'manage.fail');
|
|
118
|
+
assert.equal(warnings[0].info.diagnosticInfo.event, 'handler_failed');
|
|
119
|
+
assert.equal(replies.length, 1);
|
|
120
|
+
assert.equal(JSON.parse(replies[0][1].toString()).isError, true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('RPC-only handler failure records its trace and channel on the existing warning', async t => {
|
|
124
|
+
const warnings = [];
|
|
125
|
+
t.mock.method(MsaLogger.prototype, 'warn', (message, info) => warnings.push({ message, info }));
|
|
126
|
+
const acknowledgements = [];
|
|
127
|
+
const channel = {
|
|
128
|
+
sendToQueue: () => {},
|
|
129
|
+
ack: message => acknowledgements.push(message),
|
|
130
|
+
};
|
|
131
|
+
const observer = new RmqConnectionObserver('MANAGE_SERVICE', 'consumer', undefined, () => {});
|
|
132
|
+
registerRmqObservation(channel, observer);
|
|
133
|
+
const request = { pattern: 'manage.fail', data: { payload: {}, param: { traceId: 'handler-trace' } } };
|
|
134
|
+
const message = {
|
|
135
|
+
content: Buffer.from(JSON.stringify(request)),
|
|
136
|
+
properties: { headers: {}, correlationId: 'correlation', replyTo: 'reply.queue' },
|
|
137
|
+
fields: { routingKey: 'MANAGE_SERVICE', consumerTag: 'consumer' },
|
|
138
|
+
};
|
|
139
|
+
const context = { getArgs: () => [{}, new RmqContext([message, channel, 'manage.fail'])], getHandler: () => function handler() {} };
|
|
140
|
+
const shutdown = { isApplicationExit: false, pushActiveInterceptors: () => {}, popActiveInterceptors: () => {} };
|
|
141
|
+
const interceptor = new RPCInterceptor({ get: () => undefined }, shutdown);
|
|
142
|
+
const failure = Error('handler failed');
|
|
143
|
+
const stream = await interceptor.intercept(context, { handle: () => throwError(() => failure) });
|
|
144
|
+
await assert.rejects(lastValueFrom(stream), actual => actual === failure);
|
|
145
|
+
assert.equal(warnings.length, 1);
|
|
146
|
+
assert.equal(warnings[0].info.traceId, 'handler-trace');
|
|
147
|
+
assert.equal(warnings[0].info.diagnosticInfo.connectionId, observer.connectionId);
|
|
148
|
+
assert.equal(warnings[0].info.diagnosticInfo.event, 'handler_failed');
|
|
149
|
+
assert.equal(acknowledgements.length, 1);
|
|
150
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { EventEmitter } = require('node:events');
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const { Observable, of, throwError } = require('rxjs');
|
|
5
|
+
const { ClientRMQ, Transport } = require('@nestjs/microservices');
|
|
6
|
+
const { MsaLogger } = require('@yolo-croket-dev/msa-logger');
|
|
7
|
+
const {
|
|
8
|
+
RmqConnectionObserver, createDiagnosticRmqClient, registerRmqObservation, getRmqDiagnosticSnapshot,
|
|
9
|
+
} = require('../functions/rmq-diagnostics.function');
|
|
10
|
+
const { AmqpHandler, HttpExceptionCustom } = require('../functions/amqp-handler.function');
|
|
11
|
+
const { AmqpManagement } = require('../functions/amqp-call-manager.function');
|
|
12
|
+
|
|
13
|
+
test('connection events have no request context; generation, summary and cleanup follow lifecycle', t => {
|
|
14
|
+
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
|
15
|
+
const records = [];
|
|
16
|
+
const observer = new RmqConnectionObserver('USER_SERVICE', 'client', undefined, (info, details) => records.push({ info, details }));
|
|
17
|
+
const manager = new EventEmitter();
|
|
18
|
+
observer.observe(manager); observer.observe(manager);
|
|
19
|
+
assert.equal(manager.listenerCount('connectFailed'), 1);
|
|
20
|
+
assert.equal(observer.snapshot().connectionState, 'not_connected');
|
|
21
|
+
const error = Object.assign(Error('refused amqp://user:password@host'), { code: 'ECONNREFUSED' });
|
|
22
|
+
manager.emit('connectFailed', { err: error });
|
|
23
|
+
for (let i = 0; i < 100; i += 1) manager.emit('connectFailed', { err: error });
|
|
24
|
+
assert.equal(records.length, 1);
|
|
25
|
+
t.mock.timers.tick(60_000);
|
|
26
|
+
assert.equal(records[1].info.repeatCount, 100);
|
|
27
|
+
manager.emit('connectFailed', { err: error });
|
|
28
|
+
manager.emit('connect', {});
|
|
29
|
+
assert.equal(records[2].info.repeatCount, 1);
|
|
30
|
+
assert.equal(observer.snapshot().generation, 1);
|
|
31
|
+
manager.emit('blocked', { reason: 'private' });
|
|
32
|
+
assert.equal(observer.snapshot().connectionState, 'blocked');
|
|
33
|
+
manager.emit('unblocked');
|
|
34
|
+
manager.emit('disconnect', { err: Error('closed') });
|
|
35
|
+
assert.equal(records.at(-1).info.kind, 'connection_lost');
|
|
36
|
+
manager.emit('connect', {});
|
|
37
|
+
assert.equal(observer.snapshot().generation, 2);
|
|
38
|
+
manager.emit('connectFailed', { err: error });
|
|
39
|
+
manager.emit('connectFailed', { err: error });
|
|
40
|
+
observer.close();
|
|
41
|
+
const count = records.length;
|
|
42
|
+
t.mock.timers.tick(120_000);
|
|
43
|
+
manager.emit('connect', {});
|
|
44
|
+
assert.equal(records.length, count);
|
|
45
|
+
assert.equal(manager.eventNames().length, 0);
|
|
46
|
+
assert.ok(!JSON.stringify(records).includes('user:password'));
|
|
47
|
+
assert.ok(!JSON.stringify(records).includes('private'));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('factory is lazy, preserves transport options and configured names, opt-in defaults off', async t => {
|
|
51
|
+
const previous = process.env.RMQ_DIAGNOSTICS_ENABLED;
|
|
52
|
+
t.after(() => { if (previous === undefined) delete process.env.RMQ_DIAGNOSTICS_ENABLED; else process.env.RMQ_DIAGNOSTICS_ENABLED = previous; });
|
|
53
|
+
const createSpy = t.mock.method(ClientRMQ.prototype, 'createClient', () => { throw Error('must remain lazy'); });
|
|
54
|
+
const options = {
|
|
55
|
+
transport: Transport.RMQ,
|
|
56
|
+
options: { queue: 'USER_SERVICE', noAck: true, queueOptions: { noAssert: true, durable: true },
|
|
57
|
+
socketOptions: { heartbeatIntervalInSeconds: 60, connectionOptions: { clientProperties: { connection_name: 'explicit' } } } },
|
|
58
|
+
};
|
|
59
|
+
const copy = JSON.stringify(options);
|
|
60
|
+
delete process.env.RMQ_DIAGNOSTICS_ENABLED;
|
|
61
|
+
const disabled = createDiagnosticRmqClient(options);
|
|
62
|
+
assert.equal(getRmqDiagnosticSnapshot(disabled), undefined);
|
|
63
|
+
process.env.RMQ_DIAGNOSTICS_ENABLED = 'true';
|
|
64
|
+
const client = createDiagnosticRmqClient(options);
|
|
65
|
+
const other = createDiagnosticRmqClient(options);
|
|
66
|
+
assert.equal(createSpy.mock.callCount(), 0);
|
|
67
|
+
assert.equal(getRmqDiagnosticSnapshot(client).connectionState, 'not_connected');
|
|
68
|
+
assert.equal(getRmqDiagnosticSnapshot(client).connectionName, 'explicit');
|
|
69
|
+
assert.notEqual(getRmqDiagnosticSnapshot(client).connectionId, getRmqDiagnosticSnapshot(other).connectionId);
|
|
70
|
+
assert.equal(JSON.stringify(options), copy);
|
|
71
|
+
assert.deepEqual(client.options, options.options);
|
|
72
|
+
await disabled.close(); await client.close(); await other.close();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('same trace and shared error retain each call target and start/failure generation', async t => {
|
|
76
|
+
const records = [];
|
|
77
|
+
t.mock.method(MsaLogger.prototype, 'warn', (message, info, details) => records.push({ message, info, details }));
|
|
78
|
+
const error = { err: Object.assign(Error('reset'), { code: 'ECONNRESET' }) };
|
|
79
|
+
const subscribers = [];
|
|
80
|
+
const calls = ['USER_SERVICE', 'PAYMENT_SERVICE'].map(target => {
|
|
81
|
+
const observer = new RmqConnectionObserver(target, 'client', undefined, () => {});
|
|
82
|
+
const manager = new EventEmitter(); observer.observe(manager);
|
|
83
|
+
const client = { send: () => new Observable(subscriber => { subscribers.push(subscriber); }) };
|
|
84
|
+
registerRmqObservation(client, observer);
|
|
85
|
+
const promise = AmqpHandler(client, `test.${target}`, { payload: { secret: 'not logged' }, param: { traceId: 'same', timeout: 1000 } });
|
|
86
|
+
manager.emit('connect'); manager.emit('disconnect', error); manager.emit('connect');
|
|
87
|
+
return promise;
|
|
88
|
+
});
|
|
89
|
+
const results = Promise.allSettled(calls);
|
|
90
|
+
subscribers.forEach(subscriber => subscriber.error(error));
|
|
91
|
+
assert.ok((await results).every(result => result.reason === error));
|
|
92
|
+
assert.equal(records.length, 2);
|
|
93
|
+
assert.deepEqual(records.map(record => record.info.diagnosticInfo.targetService), ['USER_SERVICE', 'PAYMENT_SERVICE']);
|
|
94
|
+
for (const record of records) {
|
|
95
|
+
assert.equal(record.info.traceId, 'same');
|
|
96
|
+
assert.equal(record.info.diagnosticInfo.startGeneration, 0);
|
|
97
|
+
assert.equal(record.info.diagnosticInfo.startConnectionState, 'not_connected');
|
|
98
|
+
assert.equal(record.info.diagnosticInfo.generation, 2);
|
|
99
|
+
assert.equal(record.info.diagnosticInfo.kind, 'connection_lost');
|
|
100
|
+
assert.equal(record.info.diagnosticInfo.timeoutMs, 1000);
|
|
101
|
+
}
|
|
102
|
+
assert.ok(!JSON.stringify(records).includes('not logged'));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('success adds no logs; initial connect failure, timeout, service and no-handler stay distinct', async t => {
|
|
106
|
+
const records = [];
|
|
107
|
+
for (const method of ['info', 'warn', 'error']) t.mock.method(MsaLogger.prototype, method, (message, info) => records.push(info));
|
|
108
|
+
const observer = new RmqConnectionObserver('USER_SERVICE', 'client', undefined, () => {});
|
|
109
|
+
const cases = [
|
|
110
|
+
[() => throwError(() => ({ err: Object.assign(Error('refused'), { code: 'ECONNREFUSED' }) })), 'connection_failed'],
|
|
111
|
+
[() => new Observable(() => {}), 'response_timeout'],
|
|
112
|
+
[() => of({ isError: true, status: 400, errType: 'BUSINESS', errMessage: 'no' }), 'service_error'],
|
|
113
|
+
[() => throwError(() => 'There is no matching message handler defined in the remote service.'), 'handler_not_found'],
|
|
114
|
+
];
|
|
115
|
+
for (const [send, kind] of cases) {
|
|
116
|
+
const client = { send }; registerRmqObservation(client, observer);
|
|
117
|
+
await assert.rejects(AmqpHandler(client, 'test.call', { payload: {}, param: { traceId: kind, timeout: 5 } }));
|
|
118
|
+
assert.equal(records.at(-1).diagnosticInfo.kind, kind);
|
|
119
|
+
}
|
|
120
|
+
const client = { send: () => of({ data: 42 }) }; registerRmqObservation(client, observer);
|
|
121
|
+
const count = records.length;
|
|
122
|
+
assert.deepEqual(await AmqpHandler(client, 'ok', { payload: {}, param: { traceId: 'ok' } }), { data: 42 });
|
|
123
|
+
assert.equal(records.length, count);
|
|
124
|
+
const business = { send: () => of({ isError: true, status: 409, errType: 'EXISTS', errMessage: 'exists' }) };
|
|
125
|
+
registerRmqObservation(business, observer);
|
|
126
|
+
await assert.rejects(AmqpHandler(business, 'test.call', { payload: {}, param: { traceId: 'business' } }), err => {
|
|
127
|
+
assert.ok(err instanceof HttpExceptionCustom); assert.equal(err.getStatus(), 409); assert.equal(err.errInfo.type, 'EXISTS'); return true;
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('legacy unobserved clients report failed pattern without inventing connection state', async t => {
|
|
132
|
+
const previous = process.env.RMQ_DIAGNOSTICS_ENABLED;
|
|
133
|
+
process.env.RMQ_DIAGNOSTICS_ENABLED = 'true';
|
|
134
|
+
t.after(() => { if (previous === undefined) delete process.env.RMQ_DIAGNOSTICS_ENABLED; else process.env.RMQ_DIAGNOSTICS_ENABLED = previous; });
|
|
135
|
+
const records = [];
|
|
136
|
+
t.mock.method(MsaLogger.prototype, 'warn', (message, info) => records.push({ message, info }));
|
|
137
|
+
const client = { send: () => throwError(() => Object.assign(Error('connection reset'), { code: 'ECONNRESET' })) };
|
|
138
|
+
|
|
139
|
+
await assert.rejects(AmqpHandler(client, 'user.lookup', { payload: {}, param: { traceId: 'legacy-call' } }));
|
|
140
|
+
assert.equal(records.length, 1);
|
|
141
|
+
assert.equal(records[0].info.traceId, 'legacy-call');
|
|
142
|
+
assert.equal(records[0].info.diagnosticInfo.messagePattern, 'user.lookup');
|
|
143
|
+
assert.equal(records[0].info.diagnosticInfo.connectionState, 'unobserved');
|
|
144
|
+
assert.equal(records[0].info.diagnosticInfo.connectionId, undefined);
|
|
145
|
+
|
|
146
|
+
const legacyClient = new ClientRMQ({ queue: 'USER_SERVICE' });
|
|
147
|
+
legacyClient.send = client.send;
|
|
148
|
+
await assert.rejects(AmqpHandler(legacyClient, 'user.lookup', { payload: {}, param: { traceId: 'legacy-rmq' } }));
|
|
149
|
+
assert.equal(records[1].info.diagnosticInfo.targetService, 'USER_SERVICE');
|
|
150
|
+
|
|
151
|
+
const manager = new AmqpManagement(legacyClient);
|
|
152
|
+
await assert.rejects(manager.call('lookup', 'user.lookup', {}, { traceId: 'legacy-wrapper' }, { isErrorThrowing: true }));
|
|
153
|
+
assert.equal(records.length, 3);
|
|
154
|
+
assert.equal(records[2].info.traceId, 'legacy-wrapper');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('close waits for native async close and retains its rejection after listener cleanup', async t => {
|
|
158
|
+
const previous = process.env.RMQ_DIAGNOSTICS_ENABLED;
|
|
159
|
+
process.env.RMQ_DIAGNOSTICS_ENABLED = 'true';
|
|
160
|
+
t.after(() => { if (previous === undefined) delete process.env.RMQ_DIAGNOSTICS_ENABLED; else process.env.RMQ_DIAGNOSTICS_ENABLED = previous; });
|
|
161
|
+
const manager = new EventEmitter();
|
|
162
|
+
t.mock.method(ClientRMQ.prototype, 'createClient', () => manager);
|
|
163
|
+
let finish;
|
|
164
|
+
t.mock.method(ClientRMQ.prototype, 'close', () => new Promise((resolve, reject) => { finish = reject; }));
|
|
165
|
+
const client = createDiagnosticRmqClient({ transport: Transport.RMQ, options: { queue: 'TEST' } });
|
|
166
|
+
client.createClient();
|
|
167
|
+
let settled = false;
|
|
168
|
+
const error = Error('close failed');
|
|
169
|
+
const closed = client.close().finally(() => { settled = true; });
|
|
170
|
+
const rejected = assert.rejects(closed, err => err === error);
|
|
171
|
+
await Promise.resolve();
|
|
172
|
+
assert.equal(settled, false);
|
|
173
|
+
assert.equal(manager.listenerCount('connect'), 1);
|
|
174
|
+
finish(error);
|
|
175
|
+
await rejected;
|
|
176
|
+
assert.equal(manager.listenerCount('connect'), 0);
|
|
177
|
+
});
|