-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathIbmMqConsumer.cs
More file actions
575 lines (494 loc) · 23.9 KB
/
Copy pathIbmMqConsumer.cs
File metadata and controls
575 lines (494 loc) · 23.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
using System.Collections.Concurrent;
using System.Diagnostics;
using IBM.WMQ;
using Microsoft.Extensions.Logging;
using redb.Route.Abstractions;
using redb.Route.Core;
using redb.Route.Telemetry;
using RouteMessage = redb.Route.Core.Message;
namespace redb.Route.IbmMq;
/// <summary>
/// IBM MQ consumer. Polls messages from a queue (MQGET) or subscribes to a topic,
/// with support for concurrent processing, transacted ack, backout/dead-letter,
/// and W3C distributed tracing.
/// </summary>
public sealed class IbmMqConsumer : IConsumer
{
private readonly IbmMqEndpoint _endpoint;
public IEndpoint Endpoint => _endpoint;
private readonly IProcessor _processor;
private readonly IbmMqEndpointOptions _options;
private ILogger? _logger;
// ConcurrentConsumers is honoured as N REAL competing consumers, each with its OWN dedicated
// MQQueueManager connection + destination handle + serial receive loop. The MQ managed client is
// not thread-safe per connection and its syncpoint (commit/backout) is connection-scoped, so
// concurrency MUST come from N independent connections, never from fanning out one connection.
// NOTE: this applies to QUEUE destinations (opened INPUT_SHARED = competing consumers). For a
// TOPIC, N managed non-durable subscriptions would each receive a COPY of every message, so
// ConcurrentConsumers is clamped to 1 for topics (see Start).
private readonly List<Worker> _workers = new();
private CancellationTokenSource? _cts;
private readonly InflightDrainGuard _drain = new();
// Event-driven receive path (ReceiveMode=Listener). When set, Start/Stop delegate entirely to
// the XMS engine and the poll workers above are never created. Opt-in; default stays poll.
private IbmMqXmsConsumerEngine? _xmsEngine;
private long _processedCount;
/// <summary>Number of messages successfully processed (summed across all worker loops).</summary>
public long ProcessedCount => Interlocked.Read(ref _processedCount);
/// <summary>One competing consumer: its own queue-manager connection, destination handle and loop.</summary>
private sealed class Worker
{
public Worker(MQQueueManager qm) { Qm = qm; }
public MQQueueManager Qm { get; }
public MQQueue? Queue { get; set; }
public MQTopic? Topic { get; set; }
public Task? Loop { get; set; }
}
/// <summary>Creates an IBM MQ consumer.</summary>
public IbmMqConsumer(IbmMqEndpoint endpoint, IProcessor processor, IbmMqEndpointOptions options)
{
_endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint));
_processor = processor ?? throw new ArgumentNullException(nameof(processor));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = endpoint.Logger;
}
/// <inheritdoc />
public async Task Start(CancellationToken ct = default)
{
// Event-driven path: hand off to the XMS listener engine and skip the poll workers entirely.
if (_options.ReceiveMode == IbmMqReceiveMode.Listener)
{
_logger ??= (_endpoint.Component as ComponentBase)?.Logger;
_xmsEngine = new IbmMqXmsConsumerEngine(
_endpoint, _processor, _options, _logger,
() => Interlocked.Increment(ref _processedCount));
await _xmsEngine.StartAsync(ct).ConfigureAwait(false);
return;
}
var isTopic = _options.DestinationType == IbmMqDestinationType.Topic;
var workerCount = Math.Max(1, _options.ConcurrentConsumers);
// Topics can't be load-balanced across competing subscribers: each managed non-durable
// subscription gets its OWN copy of every message, so N subscriptions would DUPLICATE
// delivery, not share it. Clamp to a single subscriber and tell the operator why.
if (isTopic && workerCount > 1)
{
_logger?.LogWarning(
"IBM MQ: ConcurrentConsumers({N}) ignored for TOPIC destination {Destination} — parallel " +
"subscribers would each receive a full copy of every message (duplicate delivery), not share " +
"the load. Running a single subscriber. Use a QUEUE destination for competing consumers.",
workerCount, _endpoint.Destination);
workerCount = 1;
}
_cts = new CancellationTokenSource();
_drain.Start(ct);
try
{
for (var i = 0; i < workerCount; i++)
{
// Each worker owns its own MQQueueManager: the MQ managed client serialises MQI calls
// per connection, and the transacted syncpoint is connection-scoped — so real
// concurrency requires one dedicated connection per worker.
var qm = await _endpoint
.CreateDedicatedQueueManagerAsync($"consumer:{_endpoint.Destination}#{i}", ct)
.ConfigureAwait(false);
var worker = new Worker(qm);
if (isTopic)
{
var subName = $"REDB.{_endpoint.Destination}.{Guid.NewGuid():N}";
worker.Topic = qm.AccessTopic(
_endpoint.Destination, null,
MQC.MQSO_CREATE | MQC.MQSO_NON_DURABLE | MQC.MQSO_MANAGED,
null, subName);
}
else
{
var openOptions = MQC.MQOO_INPUT_SHARED | MQC.MQOO_FAIL_IF_QUIESCING;
worker.Queue = qm.AccessQueue(_endpoint.Destination, openOptions);
}
worker.Loop = Task.Run(() => ReceiveLoopAsync(worker, _cts.Token), _cts.Token);
_workers.Add(worker);
}
}
catch
{
// Partial start — tear down whatever came up so we don't leak connections/handles.
await StopWorkersAsync(ct).ConfigureAwait(false);
_drain.Dispose();
_cts?.Dispose();
_cts = null;
throw;
}
_logger ??= (_endpoint.Component as ComponentBase)?.Logger;
_logger?.LogInformation(
"IBM MQ consumer started: destination={Destination}, type={Type}, concurrent={Concurrent}",
_endpoint.Destination, _options.DestinationType, workerCount);
}
/// <inheritdoc />
public async Task Stop(CancellationToken ct = default)
{
if (_xmsEngine != null)
{
await _xmsEngine.StopAsync(ct).ConfigureAwait(false);
_xmsEngine = null;
return;
}
_cts?.Cancel();
// Drain in-flight processing first (the loops may still be blocked in MQGET WAIT — they exit
// when the current get returns/times out; StopWorkersAsync awaits them).
await _drain.DrainAsync(ct, _logger, $"wmq:{_endpoint.Destination}").ConfigureAwait(false);
await StopWorkersAsync(ct).ConfigureAwait(false);
_cts?.Dispose();
_cts = null;
_drain.Dispose();
_logger?.LogInformation("IBM MQ consumer stopped: destination={Destination}", _endpoint.Destination);
}
/// <summary>Awaits every worker loop, closes its destination handle and disconnects its queue manager.</summary>
private async Task StopWorkersAsync(CancellationToken ct)
{
foreach (var w in _workers)
{
if (w.Loop != null)
{
try { await w.Loop.ConfigureAwait(false); }
catch (OperationCanceledException) { /* expected */ }
catch (Exception ex) { _logger?.LogDebug(ex, "IBM MQ worker loop faulted during stop"); }
}
CloseWorkerDestination(w);
try { if (w.Qm.IsConnected) w.Qm.Disconnect(); }
catch (Exception ex) { _logger?.LogDebug(ex, "IBM MQ: error disconnecting consumer queue manager during stop"); }
}
_workers.Clear();
}
// ── Receive loop ──
private async Task ReceiveLoopAsync(Worker worker, CancellationToken ct)
{
// Note: MQGMO_PROPERTIES_IN_HANDLE requires gmo.MessageHandle to be set via
// qm.CreateMessageHandle(). Without it, GetStringProperty calls in helpers can
// misbehave on the managed .NET client. Use queue-default property handling.
var gmo = new MQGetMessageOptions
{
Options = MQC.MQGMO_WAIT | MQC.MQGMO_FAIL_IF_QUIESCING,
WaitInterval = _options.WaitInterval,
};
if (_options.Convert)
gmo.Options |= MQC.MQGMO_CONVERT;
if (_options.Transacted)
gmo.Options |= MQC.MQGMO_SYNCPOINT;
else
gmo.Options |= MQC.MQGMO_NO_SYNCPOINT;
if (!string.IsNullOrEmpty(_options.Selector))
gmo.MatchOptions = MQC.MQMO_MATCH_MSG_ID;
while (!ct.IsCancellationRequested)
{
try
{
var msg = new MQMessage();
var getSw = Stopwatch.StartNew();
bool gotMessage = false;
try
{
if (_options.DestinationType == IbmMqDestinationType.Topic)
worker.Topic!.Get(msg, gmo);
else
worker.Queue!.Get(msg, gmo);
gotMessage = true;
}
catch (MQException ex) when (ex.ReasonCode == MQC.MQRC_NO_MSG_AVAILABLE)
{
// No message within WaitInterval — loop back
continue;
}
catch (MQException ex) when (ex.ReasonCode == MQC.MQRC_FORMAT_ERROR)
{
// MQGMO_CONVERT can't convert binary (MQFMT_NONE) data — proceed with unconverted payload
gotMessage = true;
}
catch (MQException ex) when (ex.ReasonCode == MQC.MQRC_Q_MGR_QUIESCING ||
ex.ReasonCode == MQC.MQRC_CONNECTION_BROKEN)
{
if (!ct.IsCancellationRequested)
_logger?.LogWarning("IBM MQ connection interrupted: RC={ReasonCode}", ex.ReasonCode);
break;
}
finally
{
getSw.Stop();
}
// ─────────────────────────────────────────────────────────────
// KNOWN ISSUE — IBM MQ managed .NET client (amqmdnetstd.dll)
// ─────────────────────────────────────────────────────────────
// The managed IBM MQ client is NOT event-driven on MQGET with
// MQGMO_WAIT. It carries an internal polling tick of ~500 ms
// that is INDEPENDENT of the WaitInterval supplied in MQGMO:
// WaitInterval only governs the upper timeout, not the lower
// delivery-granularity bound. As a result the typical
// producer→consumer latency observed on this transport is
// ~500 ms even with SHARECNV(1) on the channel.
//
// The native (unmanaged) client is event-driven but requires
// the IBM MQ Client redistributable to be installed on the
// host — not viable for self-contained .NET deployments.
//
// Proper fix: rewrite ReceiveLoopAsync to use the managed
// async-consume API (MQQueue.Cb(...) + MQQueueManager.Ctl(
// MQOP_START, ...)). With the callback path the broker pushes
// messages to us and the per-message latency drops to ~0.
// This is a non-trivial refactor (callback-driven instead of
// poll-driven loop, different cancellation/back-pressure
// model) — tracked for a future release.
//
// The Debug log below lets ops confirm the diagnosis in the
// field: if "MQGET blocked for ~500 ms" appears consistently,
// it is the managed-client tick (need MQCB); if values are
// <50 ms while end-to-end latency is still ~500 ms, the
// bottleneck is on the producer side instead.
// ─────────────────────────────────────────────────────────────
if (gotMessage && getSw.ElapsedMilliseconds > 50)
{
_logger?.LogDebug(
"IBM MQ MQGET blocked for {ElapsedMs} ms before delivering message (destination={Destination})",
getSw.ElapsedMilliseconds, _endpoint.Destination);
}
_drain.Increment();
try
{
await ProcessMessageAsync(worker, msg, _drain.ProcessingToken).ConfigureAwait(false);
}
finally
{
_drain.Decrement();
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
catch (Exception ex)
{
_logger?.LogError(ex, "Error in IBM MQ receive loop: destination={Destination}",
_endpoint.Destination);
// Brief delay to avoid tight error loops
try { await Task.Delay(1000, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
}
private async Task ProcessMessageAsync(Worker worker, MQMessage mqMsg, CancellationToken ct)
{
using var activity = StartConsumerActivity(mqMsg);
_logger?.LogDebug(
"IBM MQ consumer: GOT message destination={Destination}, msgId={MsgId}, replyTo={ReplyTo}, msgType={MsgType}",
_endpoint.Destination,
IbmMqMessageHelper.BytesToHex(mqMsg.MessageId),
mqMsg.ReplyToQueueName?.Trim(),
mqMsg.MessageType);
var exchange = CreateExchange(mqMsg);
_logger?.LogDebug(
"IBM MQ consumer: exchange CREATED, pattern={Pattern}, about to invoke route processor",
exchange.Pattern);
// Register transacted ack action bound to THIS worker's queue-manager connection —
// the syncpoint (commit/backout) is connection-scoped, so it must be the worker's own qm.
if (_options.Transacted)
{
var ackAction = new IbmMqAckAction(worker.Qm, _logger);
RegisterTransactedAction(exchange, $"ibmmq-ack-{Guid.NewGuid():N}", ackAction);
}
try
{
await _processor.Process(exchange, ct).ConfigureAwait(false);
_logger?.LogDebug(
"IBM MQ consumer: route processor RETURNED, hasOut={HasOut}, replyTo={ReplyTo}",
exchange.HasOut, mqMsg.ReplyToQueueName?.Trim());
// RPC reply: if the incoming message had ReplyTo, send the Out message back
if (!string.IsNullOrWhiteSpace(mqMsg.ReplyToQueueName))
{
await SendReplyAsync(worker, exchange, mqMsg, ct).ConfigureAwait(false);
}
// Handle backout threshold
if (_options.BackoutThreshold > 0 && mqMsg.BackoutCount >= _options.BackoutThreshold)
{
await MoveToBackoutQueueAsync(worker, mqMsg, ct).ConfigureAwait(false);
}
// Commit in non-transacted mode is implicitly done by MQGET without syncpoint.
// For transacted mode, commit/rollback is handled by IbmMqAckAction via route processor.
Interlocked.Increment(ref _processedCount);
}
catch (Exception ex)
{
_logger?.LogError(ex, "IBM MQ message processing error: destination={Destination}, msgId={MsgId}",
_endpoint.Destination, IbmMqMessageHelper.BytesToHex(mqMsg.MessageId));
if (_options.Transacted)
{
try
{
// Backout is connection-scoped — roll back only THIS worker's syncpoint.
worker.Qm.Backout();
_logger?.LogDebug("IBM MQ message rolled back after processing error");
}
catch (Exception rollbackEx)
{
_logger?.LogWarning(rollbackEx, "Failed to rollback IBM MQ message");
}
}
}
finally
{
await exchange.DisposeAsync().ConfigureAwait(false);
}
}
// ── Trace context propagation ──
private Activity? StartConsumerActivity(MQMessage mqMsg)
{
// Extract W3C trace context from MQ message properties (RFH2 usr folder)
string? traceParent = null;
string? traceState = null;
try { traceParent = mqMsg.GetStringProperty("traceparent"); } catch { /* not present */ }
try { traceState = mqMsg.GetStringProperty("tracestate"); } catch { /* not present */ }
ActivityContext parentContext = default;
if (!string.IsNullOrEmpty(traceParent))
ActivityContext.TryParse(traceParent, traceState, out parentContext);
var activity = RouteActivitySource.Source.StartActivity(
$"{_endpoint.Destination} receive",
ActivityKind.Consumer,
parentContext);
if (activity is { IsAllDataRequested: true })
{
activity.SetTag("messaging.system", "wmq");
activity.SetTag("messaging.operation", "receive");
activity.SetTag("messaging.destination.name", _endpoint.Destination);
activity.SetTag("messaging.ibmmq.queue_manager", _options.QueueManager);
if (mqMsg.MessageId is { Length: > 0 })
activity.SetTag("messaging.message.id", IbmMqMessageHelper.BytesToHex(mqMsg.MessageId));
}
return activity;
}
// ── Exchange creation ──
private Exchange CreateExchange(MQMessage mqMsg)
{
var body = IbmMqMessageHelper.ExtractBody(mqMsg);
var routeMsg = new RouteMessage(body);
// Map MQMD Format → ContentType
routeMsg.ContentType = IbmMqMessageHelper.FormatToContentType(mqMsg.Format);
// Copy MQMD → headers
if (_options.MqmdReadEnabled)
IbmMqMessageHelper.CopyMqmdToHeaders(mqMsg, routeMsg, _endpoint.Destination, _options.QueueManager);
// Copy RFH2 user properties → headers
IbmMqMessageHelper.CopyRfh2UserProperties(mqMsg, routeMsg);
// Determine exchange pattern
var hasReplyTo = !string.IsNullOrWhiteSpace(mqMsg.ReplyToQueueName);
var isRequest = mqMsg.MessageType == MQC.MQMT_REQUEST;
var pattern = hasReplyTo && isRequest ? ExchangePattern.InOut : ExchangePattern.InOnly;
var exchange = Exchange.Create(routeMsg, _endpoint.ScopeFactory);
exchange.Pattern = pattern;
return exchange;
}
// ── RPC reply ──
private async Task SendReplyAsync(Worker worker, IExchange exchange, MQMessage originalMsg, CancellationToken ct)
{
try
{
var responseBody = exchange.HasOut
? exchange.Out!.Body
: exchange.In.Body;
var reply = new MQMessage();
if (responseBody is byte[] bytes)
{
reply.Write(bytes);
reply.Format = MQC.MQFMT_NONE;
}
else
{
var text = responseBody?.ToString() ?? string.Empty;
reply.WriteString(text);
reply.Format = MQC.MQFMT_STRING;
}
reply.CorrelationId = originalMsg.MessageId;
reply.MessageType = MQC.MQMT_REPLY;
// Round-trip user headers via RFH2 (matches producer-side BuildOutgoingMessage)
var headerSource = exchange.HasOut ? exchange.Out! : exchange.In;
IbmMqMessageHelper.CopyHeadersToRfh2(reply, headerSource);
var replyQueueName = originalMsg.ReplyToQueueName.Trim();
var replyQmName = originalMsg.ReplyToQueueManagerName?.Trim();
var qm = worker.Qm;
var openOptions = MQC.MQOO_OUTPUT | MQC.MQOO_FAIL_IF_QUIESCING;
MQQueue replyQueue;
if (!string.IsNullOrEmpty(replyQmName) && replyQmName != qm.Name.Trim())
replyQueue = qm.AccessQueue(replyQueueName, openOptions, replyQmName, null, null);
else
replyQueue = qm.AccessQueue(replyQueueName, openOptions);
try
{
var pmo = new MQPutMessageOptions { Options = MQC.MQPMO_NO_SYNCPOINT };
replyQueue.Put(reply, pmo);
_logger?.LogDebug(
"IBM MQ RPC reply sent: replyQueue={ReplyQueue}, correlationId={CorrelationId}",
replyQueueName, IbmMqMessageHelper.BytesToHex(reply.CorrelationId));
}
finally
{
try { replyQueue.Close(); }
catch (Exception closeEx)
{
_logger?.LogDebug(closeEx, "Error closing reply queue");
}
}
}
catch (Exception ex)
{
// Swallow: failed reply must not block the consumer slot. The original message
// has already been destructively read (MQGMO_NO_SYNCPOINT) or will be committed
// on the transactional path — the client will detect failure via its own RPC timeout.
var reason = (ex as MQException)?.ReasonCode;
_logger?.LogWarning(ex,
"IBM MQ: failed to send RPC reply to {ReplyQueue} (reason={Reason}): {Message} — original message will still be settled",
originalMsg.ReplyToQueueName?.Trim(), reason, ex.Message);
}
}
// ── Backout queue ──
private async Task MoveToBackoutQueueAsync(Worker worker, MQMessage mqMsg, CancellationToken ct)
{
var boqName = _options.BackoutQueue;
if (string.IsNullOrWhiteSpace(boqName))
{
_logger?.LogWarning("Message exceeded backout threshold but no backout queue configured");
return;
}
try
{
var qm = worker.Qm;
using var boq = qm.AccessQueue(boqName, MQC.MQOO_OUTPUT | MQC.MQOO_FAIL_IF_QUIESCING);
var pmo = new MQPutMessageOptions { Options = MQC.MQPMO_NO_SYNCPOINT };
boq.Put(mqMsg, pmo);
_logger?.LogInformation(
"Poison message moved to backout queue: destination={Destination}, boq={BackoutQueue}, backoutCount={BackoutCount}",
_endpoint.Destination, boqName, mqMsg.BackoutCount);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to move message to backout queue {BackoutQueue}", boqName);
}
}
private void CloseWorkerDestination(Worker w)
{
if (w.Queue != null)
{
try { w.Queue.Close(); }
catch (Exception ex) { _logger?.LogWarning(ex, "Error closing IBM MQ queue"); }
w.Queue = null;
}
if (w.Topic != null)
{
try { w.Topic.Close(); }
catch (Exception ex) { _logger?.LogWarning(ex, "Error closing IBM MQ topic"); }
w.Topic = null;
}
}
// ── Transacted action registration ──
private static void RegisterTransactedAction(IExchange exchange, string key, ITransactedAction action)
{
if (!exchange.Properties.TryGetValue("TRANSACT_ACTION", out var raw) ||
raw is not ConcurrentDictionary<string, ITransactedAction> dict)
{
dict = new ConcurrentDictionary<string, ITransactedAction>(StringComparer.OrdinalIgnoreCase);
exchange.Properties["TRANSACT_ACTION"] = dict;
}
dict[key] = action;
}
}