11/*
2- * Copyright (c) 2024- 2026 Sonu Kumar
2+ * Copyright (c) 2026 Sonu Kumar
33 *
44 * Licensed under the Apache License, Version 2.0 (the "License");
55 * You may not use this file except in compliance with the License.
@@ -65,13 +65,13 @@ public class JetStreamMessageBroker implements MessageBroker, AutoCloseable {
6565 private static final Capabilities CAPS = new Capabilities (false , false , false , false );
6666
6767 /**
68- * Translation of {@link Duration#ZERO} ( the Redis " non-blocking" pop convention used by
69- * {@code RqueueMessagePoller}) into the smallest positive duration JetStream will accept on a
70- * pull fetch. Long enough that messages already buffered for the consumer come back in the same
71- * call, short enough that an empty sub-queue inside a priority group does not stall the poll
72- * cycle .
68+ * Lower bound for fetch wait when the caller passes a non-positive duration. JetStream rejects
69+ * zero on a pull fetch, so any zero/negative wait is rounded up to this minimum. Callers that
70+ * want long-poll semantics should pass the desired wait explicitly (e.g. the listener
71+ * container's {@code pollingInterval}); this constant only guards against accidental zero waits
72+ * from non-listener callers .
7373 */
74- private static final Duration NON_BLOCKING_FETCH_WAIT = Duration .ofMillis (50 );
74+ private static final Duration MIN_FETCH_WAIT = Duration .ofMillis (50 );
7575
7676 private final Connection connection ;
7777 private final JetStream js ;
@@ -115,7 +115,6 @@ public static Builder builder() {
115115
116116 // ---- subject / stream naming -------------------------------------------
117117
118- // TODO: once Phase 1 lands, read additive QueueDetail.getNatsSubject() / getNatsStream() if set.
119118 private String subjectFor (QueueDetail q ) {
120119 return config .getSubjectPrefix () + q .getName ();
121120 }
@@ -149,15 +148,11 @@ private String streamFor(QueueDetail q, String priority) {
149148 }
150149
151150 private String dlqStreamFor (QueueDetail q ) {
152- return q .getNatsDlqStream () != null
153- ? q .getNatsDlqStream ()
154- : streamFor (q ) + config .getDlqStreamSuffix ();
151+ return streamFor (q ) + config .getDlqStreamSuffix ();
155152 }
156153
157154 private String dlqSubjectFor (QueueDetail q ) {
158- return q .getNatsDlqSubject () != null
159- ? q .getNatsDlqSubject ()
160- : subjectFor (q ) + config .getDlqSubjectSuffix ();
155+ return subjectFor (q ) + config .getDlqSubjectSuffix ();
161156 }
162157
163158 /** Stream description shown in {@code nats stream info} so operators can map back to rqueue. */
@@ -310,7 +305,13 @@ public Mono<Void> enqueueWithDelayReactive(QueueDetail q, RqueueMessage m, long
310305 @ Override
311306 public List <RqueueMessage > pop (QueueDetail q , String consumerName , int batch , Duration wait ) {
312307 return popInternal (
313- streamFor (q ), subjectFor (q ), resolveConsumerName (q .getName (), consumerName ), batch , wait );
308+ streamFor (q ),
309+ subjectFor (q ),
310+ resolveConsumerName (q .getName (), consumerName ),
311+ batch ,
312+ wait ,
313+ resolveAckWait (q , config ),
314+ resolveMaxDeliver (q , config ));
314315 }
315316
316317 @ Override
@@ -321,26 +322,64 @@ public List<RqueueMessage> pop(
321322 subjectFor (q , priority ),
322323 resolveConsumerName (q .getName (), consumerName ),
323324 batch ,
324- wait );
325+ wait ,
326+ resolveAckWait (q , config ),
327+ resolveMaxDeliver (q , config ));
325328 }
326329
327330 private static String resolveConsumerName (String queueName , String consumerName ) {
328331 return (consumerName != null && !consumerName .isEmpty ()) ? consumerName : "rqueue-" + queueName ;
329332 }
330333
334+ /**
335+ * Resolve the JetStream {@code ackWait} for this queue's pull consumer: per-queue
336+ * {@link QueueDetail#getVisibilityTimeout()} (when positive), else the global
337+ * {@link RqueueNatsConfig.ConsumerDefaults#getAckWait()}. Honouring visibilityTimeout makes the
338+ * NATS backend match the contract every other rqueue backend exposes: a message stays invisible
339+ * to other consumers for that window and is redelivered if not acked in time.
340+ */
341+ public static Duration resolveAckWait (QueueDetail q , RqueueNatsConfig config ) {
342+ long vt = q .getVisibilityTimeout ();
343+ if (vt > 0 ) {
344+ return Duration .ofMillis (vt );
345+ }
346+ return config .getConsumerDefaults ().getAckWait ();
347+ }
348+
349+ /**
350+ * Resolve the JetStream {@code maxDeliver} from per-queue {@link QueueDetail#getNumRetry()}
351+ * (counted as initial delivery + N retries = numRetry + 1). The {@link Integer#MAX_VALUE}
352+ * "retry forever" sentinel maps to JetStream's unlimited value ({@code -1}); non-positive
353+ * numRetry falls back to {@link RqueueNatsConfig.ConsumerDefaults#getMaxDeliver()}.
354+ */
355+ public static long resolveMaxDeliver (QueueDetail q , RqueueNatsConfig config ) {
356+ int numRetry = q .getNumRetry ();
357+ if (numRetry == Integer .MAX_VALUE ) {
358+ return -1L ;
359+ }
360+ if (numRetry > 0 ) {
361+ return numRetry + 1L ;
362+ }
363+ return config .getConsumerDefaults ().getMaxDeliver ();
364+ }
365+
331366 private List <RqueueMessage > popInternal (
332- String stream , String subject , String consumerName , int batch , Duration wait ) {
333- // Use default fetch wait if none provided. If zero duration is passed (Redis "non-blocking"
334- // pop convention used by RqueueMessagePoller), translate to a short but positive duration:
335- // JetStream rejects zero, but using the multi-second defaultFetchWait blocks empty pulls long
336- // enough that under Weighted/Strict priority polling the cycle starves real queues — a single
337- // empty sub-queue in a priority group can absorb the whole 30s test budget. Use the smallest
338- // value JetStream still tolerates so empty sub-queues yield back to polling immediately.
367+ String stream ,
368+ String subject ,
369+ String consumerName ,
370+ int batch ,
371+ Duration wait ,
372+ Duration ackWait ,
373+ long maxDeliver ) {
374+ // Honour the caller-supplied wait — this is the listener container's pollingInterval for
375+ // RqueueMessagePoller, and lets JetStream long-poll instead of the broker firing a steady
376+ // stream of $JS.API.CONSUMER.MSG.NEXT requests. Only fall back when the caller didn't
377+ // express a preference; zero/negative waits are rounded up to the JetStream minimum.
339378 Duration fetchWait ;
340379 if (wait == null ) {
341380 fetchWait = config .getDefaultFetchWait ();
342- } else if (wait .isZero ()) {
343- fetchWait = NON_BLOCKING_FETCH_WAIT ;
381+ } else if (wait .isZero () || wait . isNegative () ) {
382+ fetchWait = MIN_FETCH_WAIT ;
344383 } else {
345384 fetchWait = wait ;
346385 }
@@ -352,8 +391,8 @@ private List<RqueueMessage> popInternal(
352391 String actualConsumerName = provisioner .ensureConsumer (
353392 stream ,
354393 consumerName ,
355- config . getConsumerDefaults (). getAckWait () ,
356- config . getConsumerDefaults (). getMaxDeliver () ,
394+ ackWait ,
395+ maxDeliver ,
357396 config .getConsumerDefaults ().getMaxAckPending ());
358397 PullSubscribeOptions opts = PullSubscribeOptions .bind (stream , actualConsumerName );
359398 // Consumer has no filter subject; pass null so the NATS client doesn't validate
0 commit comments