From 1e0ccf79502910856489212060ad08ca5c55d0e2 Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Sun, 3 May 2026 01:08:36 +0530 Subject: [PATCH 01/19] ci: compile main sources in coverage_report job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage_report job was producing an effectively empty jacocoTestReport.xml (3.4KB vs ~1.1MB locally) because no .class files existed when coverageReportOnly ran — the job checked out source code and downloaded .exec artifacts, but never compiled. JaCoCo's report generator skips packages/classes it cannot resolve, so the merged XML ended up with only entries and no elements. That made coverallsJacoco silently no-op via the "source file set empty, skipping" branch in CoverallsReporter, so "Push coverage to Coveralls" reported success without uploading. Verified by downloading the coverage-report artifact from a recent run and comparing its XML structure against a local build's report. Assisted-By: Claude Code --- .github/workflows/java-ci.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/java-ci.yaml b/.github/workflows/java-ci.yaml index 767cd567..1a284494 100644 --- a/.github/workflows/java-ci.yaml +++ b/.github/workflows/java-ci.yaml @@ -445,6 +445,9 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 + - name: Compile main sources + run: ./gradlew compileJava + - name: Download coverage artifacts uses: actions/download-artifact@v4 with: From 51861374f75351a8785fb9d3b098ee7460836642 Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Tue, 5 May 2026 11:19:40 +0530 Subject: [PATCH 02/19] nats-web: implement pause / soft-delete admin ops and capability-aware Q-detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the all-stub `NatsRqueueUtilityService` with real impls for the operations JetStream can model: `pauseUnpauseQueue` persists the `paused` flag on `QueueConfig` in the queue-config KV bucket and notifies the local listener container so the poller stops dispatching; `deleteMessage` is a soft delete via `MessageMetadataService` (stream message persists, dashboard hides via the metadata flag); `getDataType` reports `STREAM`. `moveMessage`, `enqueueMessage`, and `makeEmpty` deliberately remain "not supported" — there is no JetStream primitive for those. Update `RqueueQDetailServiceImpl.getRunningTasks` / `getScheduledTasks` to return header-only tables when the broker capabilities suppress those sections, instead of emitting zero rows or 501s on NATS. 20 new unit tests cover the pause/delete paths and lock in the still-unsupported operations. Updates `nats-task.md` / `nats-task-v2.md` to reflect what landed. Assisted-By: Claude Code --- nats-task-v2.md | 17 +- nats-task.md | 11 +- .../service/NatsRqueueUtilityService.java | 150 ++++++++- .../service/NatsRqueueUtilityServiceTest.java | 286 ++++++++++++++++++ .../web/service/RqueueQDetailServiceImpl.java | 22 ++ 5 files changed, 463 insertions(+), 23 deletions(-) create mode 100644 rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityServiceTest.java diff --git a/nats-task-v2.md b/nats-task-v2.md index ec224e8b..6510992a 100644 --- a/nats-task-v2.md +++ b/nats-task-v2.md @@ -8,18 +8,21 @@ All v1 items are done and 360 unit tests pass. Branch `nats-backend` is ready to ## v2 pending items -### 1. Web dashboard — NATS gaps +### 1. Web dashboard — NATS gaps *(IN PROGRESS — pause/delete/explore landed)* Controllers are no longer Redis-gated but several operations throw `BackendCapabilityException` (HTTP 501) on NATS. The front-end should hide unsupported panels proactively instead of relying on 501s. -- Expose `GET /rqueue/api/capabilities` returning the `Capabilities` record so the UI can conditionally hide panels. -- Extend `Capabilities` with dashboard-op flags: `supportsCharts`, `supportsMessageBrowse`, `supportsAdminMove`. -- Wire the flags into Pebble templates (scheduled panel, cron jobs panel, chart panel already have `hideScheduledPanel` / `hideCronJobs` hooks in `DataViewResponse`). +- ✅ `GET /rqueue/api/capabilities` already returns the `Capabilities` record so the UI can conditionally hide panels. +- ✅ `RqueueQDetailServiceImpl.getRunningTasks()` / `getScheduledTasks()` now return header-only tables on NATS instead of zero rows / 501s. Pending queue browsing routes through `MessageBroker.peek()`. +- ✅ `NatsRqueueUtilityService` implements `pauseUnpauseQueue` (persists flag + notifies local `RqueueMessageListenerContainer`), soft `deleteMessage` (KV metadata flag), `getDataType` (returns `"STREAM"`), `aggregateDataCounter`. 20 unit tests cover the path. +- ⏳ Pause-event multi-instance fan-out: `RqueueInternalPubSubChannel` is Redis-only. NATS bridge follow-up: subscribe to `rqueue.internal.` via `MessageBroker.subscribe/publish` and rebroadcast pause requests across worker JVMs. +- ⏳ Extend `Capabilities` with dashboard-op flags: `supportsCharts`, `supportsMessageBrowse`, `supportsAdminMove` (not yet — current flags suffice for the panels we hide today). +- ⏳ Pebble templates: `hideScheduledPanel` / `hideCronJobs` already wired into `DataViewResponse`. Front-end hides those panels; chart and message-browse hides still TBD. Affected services that throw on NATS today: -- `RqueueDashboardChartServiceImpl` — time-series charts (no equivalent in JetStream) -- `RqueueUtilityServiceImpl` — move/enqueue admin ops -- `NatsMessageBrowsingRepository.viewData` — positional message browse +- `RqueueDashboardChartServiceImpl` — time-series charts (no equivalent in JetStream) — still pending +- `RqueueUtilityServiceImpl` — move/enqueue admin ops — `moveMessage`, `enqueueMessage`, `makeEmpty` deliberately remain `notSupported` (no JetStream primitive); `pauseUnpauseQueue` and `deleteMessage` now implemented +- `NatsMessageBrowsingRepository.viewData` — positional message browse (Redis-only by design) ### 2. Reactive listener container diff --git a/nats-task.md b/nats-task.md index c56a5acd..14d6a3fe 100644 --- a/nats-task.md +++ b/nats-task.md @@ -154,9 +154,16 @@ Then re-run: ./gradlew :rqueue-spring-boot-starter:test --tests "com.github.sonus21.rqueue.spring.boot.integration.NatsBackendEndToEndIT" ``` -### Web-layer NATS dashboard gap (new follow-up) +### Web-layer NATS dashboard gap (new follow-up) *(PARTIAL — admin write ops landing)* -All 4 controllers and the 5 web service impls (`RqueueDashboardChartService*`, `RqueueQDetailService*`, `RqueueJobService*`, `RqueueSystemManagerService*`, `RqueueUtilityService*`) are still gated `@Conditional(RedisBackendCondition)`. On NATS the dashboard reports broker-derived sizes only; no charts, no message browse, no admin ops. Plan to fix: +All 4 controllers and the 5 web service impls (`RqueueDashboardChartService*`, `RqueueQDetailService*`, `RqueueJobService*`, `RqueueSystemManagerService*`, `RqueueUtilityService*`) are still gated `@Conditional(RedisBackendCondition)`. On NATS the dashboard reports broker-derived sizes only; no charts, no message browse, no admin ops. + +Status: +- ✅ `NatsRqueueUtilityService` (rqueue-nats `@Conditional(NatsBackendCondition)`) replaces the all-stub impl: `pauseUnpauseQueue`, soft `deleteMessage`, `getDataType`, `aggregateDataCounter` work end-to-end. `moveMessage` / `enqueueMessage` / `makeEmpty` are deliberately `notSupported` (no JetStream equivalent). +- ✅ `RqueueQDetailServiceImpl` returns header-only tables for `getRunningTasks` / `getScheduledTasks` when the broker capabilities suppress those sections, instead of rendering 0-rows / 501s. +- ⏳ Charts (`RqueueDashboardChartService`), message browse, and `moveMessage` on NATS — still pending. + +Plan to fix the rest: 1. Introduce repository interfaces in `rqueue-core/repository/` for the few storage primitives the web services share (queue browsing, time-series counters, atomic move). Web service impls move into core / `rqueue-web` and depend only on the repos. 2. Redis impls of the repos stay in `rqueue-redis`; NATS impls go in `rqueue-nats` and throw `BackendCapabilityException("nats", "operation", "reason")` for primitives JetStream can't model (positional message moves, time-bucket charts). diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityService.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityService.java index b7fdc3f3..ed83b176 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityService.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityService.java @@ -12,7 +12,10 @@ import com.github.sonus21.rqueue.config.NatsBackendCondition; import com.github.sonus21.rqueue.config.RqueueWebConfig; +import com.github.sonus21.rqueue.dao.RqueueSystemConfigDao; +import com.github.sonus21.rqueue.listener.RqueueMessageListenerContainer; import com.github.sonus21.rqueue.models.Pair; +import com.github.sonus21.rqueue.models.db.QueueConfig; import com.github.sonus21.rqueue.models.enums.AggregationType; import com.github.sonus21.rqueue.models.request.MessageMoveRequest; import com.github.sonus21.rqueue.models.request.PauseUnpauseQueueRequest; @@ -21,48 +24,125 @@ import com.github.sonus21.rqueue.models.response.DataSelectorResponse; import com.github.sonus21.rqueue.models.response.MessageMoveResponse; import com.github.sonus21.rqueue.models.response.StringResponse; +import com.github.sonus21.rqueue.service.RqueueMessageMetadataService; import com.github.sonus21.rqueue.service.RqueueUtilityService; import com.github.sonus21.rqueue.utils.Constants; +import com.github.sonus21.rqueue.utils.StringUtils; +import java.time.Duration; import java.util.LinkedList; import java.util.List; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Conditional; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; /** - * NATS-backend stub for {@link RqueueUtilityService}. Admin/dashboard utility methods are - * Redis-only in v1; this stub returns "not supported" responses uniformly so the rest of the - * bean graph stays consistent. Replace with a NATS-native implementation in a follow-up. + * NATS-backend implementation of {@link RqueueUtilityService}. + * + *

The implementation supports operations that map cleanly onto JetStream's model: + *

    + *
  • {@link #pauseUnpauseQueue(PauseUnpauseQueueRequest)} — flips the {@code paused} flag on + * {@link QueueConfig} in the queue-config KV bucket and propagates the change to the local + * {@link RqueueMessageListenerContainer} so the poller stops polling. Multi-instance fan-out + * is a follow-up (NATS pub/sub bridge). + *
  • {@link #deleteMessage(String, String)} — soft delete: marks the metadata record in the + * message-metadata KV bucket. The stream message persists; the dashboard hides it via the + * {@code deleted} flag, matching the Redis impl's semantics. + *
  • {@link #aggregateDataCounter(AggregationType)} — pure date-selector logic, no backend + * dependency. + *
  • {@link #getDataType(String)} — reports {@code "STREAM"} since JetStream subjects map to + * stream messages, not Redis-shaped data structures. + *
+ * + *

Operations that have no JetStream equivalent return a structured "not supported" response: + * {@link #moveMessage(MessageMoveRequest)}, {@link #enqueueMessage(String, String, String)} (no + * scheduled-queue ZSET to re-enqueue from), and {@link #makeEmpty(String, String)} (would require + * stream re-creation, which is destructive and out-of-band). */ @Service @Conditional(NatsBackendCondition.class) +@Slf4j public class NatsRqueueUtilityService implements RqueueUtilityService { + private static final String NOT_SUPPORTED_SUFFIX = + " is not supported with rqueue.backend=nats in v1"; + + private final RqueueWebConfig rqueueWebConfig; + private final RqueueSystemConfigDao systemConfigDao; + private final RqueueMessageMetadataService messageMetadataService; + private final RqueueMessageListenerContainer rqueueMessageListenerContainer; + @Autowired - private RqueueWebConfig rqueueWebConfig; + public NatsRqueueUtilityService( + RqueueWebConfig rqueueWebConfig, + RqueueSystemConfigDao systemConfigDao, + RqueueMessageMetadataService messageMetadataService, + RqueueMessageListenerContainer rqueueMessageListenerContainer) { + this.rqueueWebConfig = rqueueWebConfig; + this.systemConfigDao = systemConfigDao; + this.messageMetadataService = messageMetadataService; + this.rqueueMessageListenerContainer = rqueueMessageListenerContainer; + } private static T notSupported(T response, String op) { response.setCode(1); - response.setMessage(op + " is not supported with rqueue.backend=nats in v1"); + response.setMessage(op + NOT_SUPPORTED_SUFFIX); return response; } + /** + * Soft-delete: marks the message metadata as deleted. The underlying stream message persists + * (JetStream streams are immutable), but the dashboard and consumers honor the deleted flag. + */ @Override public BooleanResponse deleteMessage(String queueName, String id) { - return notSupported(new BooleanResponse(), "deleteMessage"); + BooleanResponse response = new BooleanResponse(); + if (StringUtils.isEmpty(queueName) || StringUtils.isEmpty(id)) { + response.setCode(1); + response.setMessage("queueName and id are required"); + return response; + } + try { + boolean ok = messageMetadataService.deleteMessage( + queueName, id, Duration.ofDays(Constants.DAYS_IN_A_MONTH)); + if (!ok) { + response.setCode(1); + response.setMessage("Message metadata not found for queue=" + queueName + " id=" + id); + return response; + } + response.setValue(true); + return response; + } catch (Exception e) { + log.warn("deleteMessage failed for queue={} id={}", queueName, id, e); + response.setCode(1); + response.setMessage("deleteMessage failed: " + e.getMessage()); + return response; + } } + /** + * NATS does not support arbitrary message re-enqueue: stream sequences are immutable and there + * is no scheduled-queue ZSET to pull from. Surfaces a structured "not supported" response. + */ @Override public BooleanResponse enqueueMessage(String queueName, String id, String position) { return notSupported(new BooleanResponse(), "enqueueMessage"); } + /** + * NATS does not support cross-queue positional moves: streams are independent, sequences are + * immutable. Surfaces a structured "not supported" response. + */ @Override public MessageMoveResponse moveMessage(MessageMoveRequest messageMoveRequest) { return notSupported(new MessageMoveResponse(), "moveMessage"); } + /** + * NATS would require destructive stream re-creation to empty a queue. Out-of-band admin op + * (e.g. {@code nats stream purge}) is the recommended path; surfaces "not supported" for now. + */ @Override public BooleanResponse makeEmpty(String queueName, String dataName) { return notSupported(new BooleanResponse(), "makeEmpty"); @@ -73,45 +153,87 @@ public Pair getLatestVersion() { return new Pair<>("", ""); } + /** + * NATS-backed queues are always JetStream streams; report a fixed type rather than probing the + * KV / stream layer per call. + */ @Override public StringResponse getDataType(String name) { - return notSupported(new StringResponse(), "getDataType"); + StringResponse response = new StringResponse(); + response.setVal("STREAM"); + return response; } @Override public Mono makeEmptyReactive(String queueName, String datasetName) { - return Mono.just(notSupported(new BooleanResponse(), "makeEmptyReactive")); + return Mono.just(makeEmpty(queueName, datasetName)); } @Override public Mono deleteReactiveMessage(String queueName, String messageId) { - return Mono.just(notSupported(new BooleanResponse(), "deleteReactiveMessage")); + return Mono.just(deleteMessage(queueName, messageId)); } @Override public Mono enqueueReactiveMessage( String queueName, String messageId, String position) { - return Mono.just(notSupported(new BooleanResponse(), "enqueueReactiveMessage")); + return Mono.just(enqueueMessage(queueName, messageId, position)); } @Override public Mono getReactiveDataType(String name) { - return Mono.just(notSupported(new StringResponse(), "getReactiveDataType")); + return Mono.just(getDataType(name)); } @Override public Mono moveReactiveMessage(MessageMoveRequest request) { - return Mono.just(notSupported(new MessageMoveResponse(), "moveReactiveMessage")); + return Mono.just(moveMessage(request)); } @Override public Mono reactivePauseUnpauseQueue(PauseUnpauseQueueRequest request) { - return Mono.just(notSupported(new BaseResponse(), "reactivePauseUnpauseQueue")); + return Mono.just(pauseUnpauseQueue(request)); } + /** + * Toggle the {@code paused} flag on the queue's {@link QueueConfig} (persisted in the + * {@code rqueue-queue-config} KV bucket) and propagate the change to the local listener + * container so the poller stops dispatching new work. + * + *

Multi-instance fan-out (i.e. propagating the pause across worker JVMs) is a follow-up; + * single-instance deployments are fully covered by this path. + */ @Override public BaseResponse pauseUnpauseQueue(PauseUnpauseQueueRequest request) { - return notSupported(new BaseResponse(), "pauseUnpauseQueue"); + log.info("Queue PauseUnpause request {}", request); + BaseResponse response = new BaseResponse(); + if (request == null || StringUtils.isEmpty(request.getName())) { + response.set(400, "Queue name is required"); + return response; + } + QueueConfig queueConfig = systemConfigDao.getConfigByName(request.getName(), true); + if (queueConfig == null) { + response.set(404, "Queue does not exist"); + return response; + } + boolean targetState = request.isPause(); + if (queueConfig.isPaused() == targetState) { + // No-op: state already matches; respond OK and skip the listener call to avoid the + // "duplicate pause" / "not paused but unpause" warnings in QueueStateMgr. + return response; + } + queueConfig.setPaused(targetState); + systemConfigDao.saveQConfig(queueConfig); + try { + rqueueMessageListenerContainer.pauseUnpauseQueue(request.getName(), targetState); + } catch (Exception e) { + // QueueConfig is already persisted; surface the pause-propagation failure to the caller + // but do not roll back — the next listener restart will pick up the persisted flag. + log.warn( + "pauseUnpauseQueue listener notification failed for queue={}", request.getName(), e); + response.set(500, "Persisted but listener notification failed: " + e.getMessage()); + } + return response; } @Override diff --git a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityServiceTest.java b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityServiceTest.java new file mode 100644 index 00000000..481ac38e --- /dev/null +++ b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityServiceTest.java @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2026 Sonu Kumar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + */ + +package com.github.sonus21.rqueue.nats.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.github.sonus21.rqueue.config.RqueueWebConfig; +import com.github.sonus21.rqueue.dao.RqueueSystemConfigDao; +import com.github.sonus21.rqueue.listener.RqueueMessageListenerContainer; +import com.github.sonus21.rqueue.models.db.QueueConfig; +import com.github.sonus21.rqueue.models.enums.AggregationType; +import com.github.sonus21.rqueue.models.request.MessageMoveRequest; +import com.github.sonus21.rqueue.models.request.PauseUnpauseQueueRequest; +import com.github.sonus21.rqueue.models.response.BaseResponse; +import com.github.sonus21.rqueue.models.response.BooleanResponse; +import com.github.sonus21.rqueue.models.response.DataSelectorResponse; +import com.github.sonus21.rqueue.models.response.MessageMoveResponse; +import com.github.sonus21.rqueue.models.response.StringResponse; +import com.github.sonus21.rqueue.nats.NatsUnitTest; +import com.github.sonus21.rqueue.service.RqueueMessageMetadataService; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +/** + * Unit tests for {@link NatsRqueueUtilityService}. Covers the soft-delete, pause/unpause, and + * "not supported" stubs to lock in v1 behavior. + */ +@NatsUnitTest +class NatsRqueueUtilityServiceTest { + + private RqueueWebConfig webConfig; + private RqueueSystemConfigDao systemConfigDao; + private RqueueMessageMetadataService metadataService; + private RqueueMessageListenerContainer listenerContainer; + private NatsRqueueUtilityService service; + + @BeforeEach + void setup() { + webConfig = Mockito.mock(RqueueWebConfig.class); + when(webConfig.getHistoryDay()).thenReturn(7); + systemConfigDao = Mockito.mock(RqueueSystemConfigDao.class); + metadataService = Mockito.mock(RqueueMessageMetadataService.class); + listenerContainer = Mockito.mock(RqueueMessageListenerContainer.class); + service = + new NatsRqueueUtilityService(webConfig, systemConfigDao, metadataService, listenerContainer); + } + + // --- deleteMessage -------------------------------------------------------- + + @Test + void deleteMessage_softDeletesMetadata_returnsValueTrue() { + when(metadataService.deleteMessage(eq("q"), eq("m1"), any(Duration.class))).thenReturn(true); + BooleanResponse response = service.deleteMessage("q", "m1"); + assertEquals(0, response.getCode()); + assertTrue(response.isValue()); + verify(metadataService).deleteMessage(eq("q"), eq("m1"), any(Duration.class)); + } + + @Test + void deleteMessage_metadataMissing_returnsErrorCode() { + when(metadataService.deleteMessage(anyString(), anyString(), any(Duration.class))) + .thenReturn(false); + BooleanResponse response = service.deleteMessage("q", "missing"); + assertEquals(1, response.getCode()); + assertNotNull(response.getMessage()); + assertFalse(response.isValue()); + } + + @Test + void deleteMessage_emptyQueueName_returnsValidationError() { + BooleanResponse response = service.deleteMessage("", "m1"); + assertEquals(1, response.getCode()); + verify(metadataService, never()).deleteMessage(anyString(), anyString(), any(Duration.class)); + } + + @Test + void deleteMessage_metadataServiceThrows_returnsErrorCode() { + when(metadataService.deleteMessage(anyString(), anyString(), any(Duration.class))) + .thenThrow(new RuntimeException("kv unavailable")); + BooleanResponse response = service.deleteMessage("q", "m1"); + assertEquals(1, response.getCode()); + assertNotNull(response.getMessage()); + assertTrue(response.getMessage().contains("kv unavailable")); + } + + // --- pauseUnpauseQueue ---------------------------------------------------- + + @Test + void pauseUnpauseQueue_persistsFlagAndNotifiesListener() { + QueueConfig config = QueueConfig.builder().name("q").queueName("q").paused(false).build(); + when(systemConfigDao.getConfigByName("q", true)).thenReturn(config); + PauseUnpauseQueueRequest request = new PauseUnpauseQueueRequest(); + request.setName("q"); + request.setPause(true); + + BaseResponse response = service.pauseUnpauseQueue(request); + + assertEquals(0, response.getCode()); + ArgumentCaptor captor = ArgumentCaptor.forClass(QueueConfig.class); + verify(systemConfigDao).saveQConfig(captor.capture()); + assertTrue(captor.getValue().isPaused(), "QueueConfig should be persisted with paused=true"); + verify(listenerContainer).pauseUnpauseQueue("q", true); + } + + @Test + void pauseUnpauseQueue_unpause_propagatesFalse() { + QueueConfig config = QueueConfig.builder().name("q").queueName("q").paused(true).build(); + when(systemConfigDao.getConfigByName("q", true)).thenReturn(config); + PauseUnpauseQueueRequest request = new PauseUnpauseQueueRequest(); + request.setName("q"); + request.setPause(false); + + BaseResponse response = service.pauseUnpauseQueue(request); + + assertEquals(0, response.getCode()); + verify(listenerContainer).pauseUnpauseQueue("q", false); + } + + @Test + void pauseUnpauseQueue_unknownQueue_returns404() { + when(systemConfigDao.getConfigByName(anyString(), anyBoolean())).thenReturn(null); + PauseUnpauseQueueRequest request = new PauseUnpauseQueueRequest(); + request.setName("missing"); + request.setPause(true); + + BaseResponse response = service.pauseUnpauseQueue(request); + + assertEquals(404, response.getCode()); + verify(systemConfigDao, never()).saveQConfig(any(QueueConfig.class)); + verify(listenerContainer, never()).pauseUnpauseQueue(anyString(), anyBoolean()); + } + + @Test + void pauseUnpauseQueue_alreadyInTargetState_isNoOp() { + QueueConfig config = QueueConfig.builder().name("q").queueName("q").paused(true).build(); + when(systemConfigDao.getConfigByName("q", true)).thenReturn(config); + PauseUnpauseQueueRequest request = new PauseUnpauseQueueRequest(); + request.setName("q"); + request.setPause(true); + + BaseResponse response = service.pauseUnpauseQueue(request); + + assertEquals(0, response.getCode()); + // No save and no listener notification when state is already correct. + verify(systemConfigDao, never()).saveQConfig(any(QueueConfig.class)); + verify(listenerContainer, never()).pauseUnpauseQueue(anyString(), anyBoolean()); + } + + @Test + void pauseUnpauseQueue_emptyName_returns400() { + PauseUnpauseQueueRequest request = new PauseUnpauseQueueRequest(); + request.setName(""); + request.setPause(true); + + BaseResponse response = service.pauseUnpauseQueue(request); + + assertEquals(400, response.getCode()); + } + + @Test + void pauseUnpauseQueue_listenerThrows_persistsButReports500() { + QueueConfig config = QueueConfig.builder().name("q").queueName("q").paused(false).build(); + when(systemConfigDao.getConfigByName("q", true)).thenReturn(config); + Mockito.doThrow(new RuntimeException("listener offline")) + .when(listenerContainer) + .pauseUnpauseQueue(anyString(), anyBoolean()); + PauseUnpauseQueueRequest request = new PauseUnpauseQueueRequest(); + request.setName("q"); + request.setPause(true); + + BaseResponse response = service.pauseUnpauseQueue(request); + + assertEquals(500, response.getCode()); + verify(systemConfigDao, times(1)).saveQConfig(any(QueueConfig.class)); + } + + // --- unsupported operations ---------------------------------------------- + + @Test + void enqueueMessage_returnsNotSupported() { + BooleanResponse response = service.enqueueMessage("q", "m1", "FRONT"); + assertEquals(1, response.getCode()); + assertNotNull(response.getMessage()); + assertTrue(response.getMessage().contains("not supported")); + } + + @Test + void moveMessage_returnsNotSupported() { + MessageMoveResponse response = service.moveMessage(new MessageMoveRequest()); + assertEquals(1, response.getCode()); + assertTrue(response.getMessage().contains("not supported")); + } + + @Test + void makeEmpty_returnsNotSupported() { + BooleanResponse response = service.makeEmpty("q", "queue:q"); + assertEquals(1, response.getCode()); + assertTrue(response.getMessage().contains("not supported")); + } + + // --- backend-agnostic operations ----------------------------------------- + + @Test + void getDataType_alwaysReportsStream() { + StringResponse response = service.getDataType("anything"); + assertEquals(0, response.getCode()); + assertEquals("STREAM", response.getVal()); + } + + @Test + void getLatestVersion_returnsEmptyPair() { + assertNotNull(service.getLatestVersion()); + } + + @Test + void aggregateDataCounter_dailyHasSelectionEntry() { + DataSelectorResponse response = service.aggregateDataCounter(AggregationType.DAILY); + assertNotNull(response); + assertEquals("Select Number of Days", response.getTitle()); + assertNotEquals(0, response.getData().size()); + } + + @Test + void aggregateDataCounter_weeklyHasSelectionEntry() { + DataSelectorResponse response = service.aggregateDataCounter(AggregationType.WEEKLY); + assertEquals("Select Number of Weeks", response.getTitle()); + assertNotEquals(0, response.getData().size()); + } + + @Test + void aggregateDataCounter_monthlyHasSelectionEntry() { + DataSelectorResponse response = service.aggregateDataCounter(AggregationType.MONTHLY); + assertEquals("Select Number of Months", response.getTitle()); + assertNotEquals(0, response.getData().size()); + } + + // --- reactive wrappers ---------------------------------------------------- + + @Test + void reactivePauseUnpauseQueue_delegatesToSync() { + QueueConfig config = QueueConfig.builder().name("q").queueName("q").paused(false).build(); + when(systemConfigDao.getConfigByName("q", true)).thenReturn(config); + PauseUnpauseQueueRequest request = new PauseUnpauseQueueRequest(); + request.setName("q"); + request.setPause(true); + + BaseResponse response = service.reactivePauseUnpauseQueue(request).block(); + + assertNotNull(response); + assertEquals(0, response.getCode()); + verify(listenerContainer).pauseUnpauseQueue("q", true); + } + + @Test + void deleteReactiveMessage_delegatesToSync() { + when(metadataService.deleteMessage(eq("q"), eq("m1"), any(Duration.class))).thenReturn(true); + + BooleanResponse response = service.deleteReactiveMessage("q", "m1").block(); + + assertNotNull(response); + assertTrue(response.isValue()); + } +} diff --git a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java index 5200e1e6..5ea69a29 100644 --- a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java +++ b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java @@ -465,6 +465,12 @@ private void setHeadersIfRequired( @Override public List> getRunningTasks() { + // Brokers that manage in-flight tracking internally (e.g. NATS JetStream durable consumers) + // have no separate processing ZSET to report on. Surface an empty table with just the header + // row so the home dashboard shows the section but doesn't render a column of zeros. + if (brokerHidesRunning()) { + return emptyTable("Processing"); + } return bulkSizeTable( rqueueSystemManagerService.getSortedQueueConfigs(), QueueConfig::getProcessingQueueName, @@ -483,6 +489,11 @@ public List> getWaitingTasks() { @Override public List> getScheduledTasks() { + // Brokers without scheduled-queue introspection (e.g. NATS JetStream) have no scheduled ZSET. + // Return an empty table so the home dashboard doesn't query an absent data structure. + if (brokerHidesScheduled()) { + return emptyTable("Scheduled"); + } return bulkSizeTable( rqueueSystemManagerService.getSortedQueueConfigs(), QueueConfig::getScheduledQueueName, @@ -490,6 +501,17 @@ public List> getScheduledTasks() { "Scheduled [ZSET]"); } + /** + * Header-only table used when a broker capability suppresses an entire section (e.g. + * NATS hiding the running / scheduled rows). The frontend renders the column header and + * no body rows. + */ + private List> emptyTable(String section) { + List> rows = new ArrayList<>(); + rows.add(Arrays.asList("Queue", section, "Number of Messages")); + return rows; + } + /** * Render the home-dashboard "queue / data-name / count" 3-column table for a per-queue data * structure. The repository's {@link MessageBrowsingRepository#getDataSizes(List, List)} is From 42eb61ed16f1dd7e9e96c73683d7e62847eede52 Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Tue, 5 May 2026 11:48:56 +0530 Subject: [PATCH 03/19] nats-web: capability-aware nav / charts and stream-based peek End-to-end browser-tested the NATS dashboard and shipped the templates + broker fixes uncovered by it: - `RqueueViewControllerServiceImpl.addBasicDetails` now propagates the active broker's `Capabilities` to every template via `hideRunningPanel`, `hideScheduledPanel`, `hideCronJobs`, and `hideCharts`. Templates default to "show" when these are absent so the legacy Redis path is unchanged. - `base.html` hides the Running tab when `hideRunningPanel` is set; Scheduled was already gated. - `index.html` and `queue_detail.html` skip the stats / latency chart panels (and their JS bootstrap) when `hideCharts` is set, replacing the home charts with a friendly backend-aware blurb. - `queues.html` swaps the hard-coded "backing Redis structures" copy for the broker-supplied `storageKicker`. - `JetStreamMessageBroker.peek` rewritten to read messages directly from the stream via `JetStreamManagement.getMessage(streamName, seq)` instead of creating an ephemeral pull consumer. NATS 2.12+ rejects `AckPolicy.None` on WorkQueue streams (10084) and rejects mixing filtered + non-filtered consumers (10100), so the consumer-based approach can't coexist with the durable poller. Sequence-based reads sidestep both. - `NatsRqueueMessageMetadataService.deleteMessage` now creates a tombstone metadata entry when no record exists (NATS skips the storeMessageMetadata path at enqueue time), so dashboard-driven deletes always succeed and the next peek renders the row as deleted. - `rqueue.js`'s `deleteMessage` / `enqueueMessage` button handlers now use `closest('tr')` instead of two `.parent()` hops. The recent `explorer-action-group` div wrapper added an extra level of nesting; the old walk landed on the action cell and read "Delete" as the message id. Assisted-By: Claude Code --- .../nats/js/JetStreamMessageBroker.java | 64 ++++++++++--------- .../NatsRqueueMessageMetadataService.java | 8 ++- .../RqueueViewControllerServiceImpl.java | 34 ++++++++++ .../main/resources/public/rqueue/js/rqueue.js | 10 ++- .../main/resources/templates/rqueue/base.html | 5 ++ .../resources/templates/rqueue/index.html | 22 ++++++- .../templates/rqueue/queue_detail.html | 8 +++ .../resources/templates/rqueue/queues.html | 2 +- 8 files changed, 117 insertions(+), 36 deletions(-) diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java index 3e64ca0a..ea98c8b4 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java @@ -38,6 +38,7 @@ import java.io.IOException; import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; @@ -511,44 +512,49 @@ public long moveExpired(QueueDetail q, long now, int batch) { @Override public List peek(QueueDetail q, long offset, long count) { String stream = streamFor(q); - String subject = subjectFor(q); - JetStreamSubscription sub = null; + if (count <= 0) { + return Collections.emptyList(); + } try { - ConsumerConfiguration.Builder cb = ConsumerConfiguration.builder() - .ackPolicy(AckPolicy.None) - .filterSubject(subject) - .name("rqueue-js-peek-" + UUID.randomUUID()); - if (offset > 0) { - cb.deliverPolicy(DeliverPolicy.ByStartSequence).startSequence(Math.max(1L, offset)); - } else { - cb.deliverPolicy(DeliverPolicy.All); + // Read messages directly from the stream by sequence number via the JetStream + // Management API. This avoids creating any consumer, which sidesteps two NATS 2.12+ + // restrictions on WorkQueue-retention streams: + // 1. Pull consumers require AckPolicy.Explicit (error 10084). + // 2. Multiple consumers on a WorkQueue stream must be mutually exclusive via + // filter subjects (error 10100) — incompatible with the always-on durable + // consumer that the listener container uses. + // Reading by sequence is purely non-destructive and works regardless of retention + // policy or what other consumers exist on the stream. + io.nats.client.api.StreamInfo info = jsm.getStreamInfo(stream); + long firstSeq = info.getStreamState().getFirstSequence(); + long lastSeq = info.getStreamState().getLastSequence(); + if (lastSeq < firstSeq) { + return Collections.emptyList(); } - PullSubscribeOptions opts = PullSubscribeOptions.builder().stream(stream) - .configuration(cb.build()) - .build(); - sub = js.subscribe(subject, opts); - int n = (int) Math.min(Integer.MAX_VALUE, Math.max(0L, count)); - List msgs = sub.fetch(n, Duration.ofSeconds(2)); - List out = new ArrayList<>(msgs.size()); - for (Message nm : msgs) { + long startSeq = Math.max(firstSeq, firstSeq + Math.max(0L, offset)); + long endSeq = Math.min(lastSeq, startSeq + count - 1); + List out = new ArrayList<>(); + for (long seq = startSeq; seq <= endSeq && out.size() < count; seq++) { try { - out.add(serdes.deserialize(nm.getData(), RqueueMessage.class)); - } catch (Exception e) { - log.log(Level.WARNING, "peek: skipping undeserializable message", e); + io.nats.client.api.MessageInfo mi = jsm.getMessage(stream, seq); + if (mi == null || mi.getData() == null) { + continue; + } + out.add(serdes.deserialize(mi.getData(), RqueueMessage.class)); + } catch (JetStreamApiException notFound) { + // Sequence may have been purged or skipped (e.g. WorkQueue acks); keep walking. + log.log( + Level.FINE, + "peek: skipping missing seq=" + seq + " on stream=" + stream, + notFound); + } catch (Exception deserErr) { + log.log(Level.WARNING, "peek: skipping undeserializable seq=" + seq, deserErr); } } return out; } catch (IOException | JetStreamApiException e) { throw new RqueueNatsException( "Failed to peek queue=" + q.getName() + " offset=" + offset + " count=" + count, e); - } finally { - if (sub != null) { - try { - sub.unsubscribe(); - } catch (RuntimeException ignored) { - // ephemeral consumer is auto-reaped server-side; ignore - } - } } } diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/service/NatsRqueueMessageMetadataService.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/service/NatsRqueueMessageMetadataService.java index 25240262..24db30df 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/service/NatsRqueueMessageMetadataService.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/service/NatsRqueueMessageMetadataService.java @@ -122,10 +122,16 @@ public boolean deleteMessage(String queueName, String messageId, Duration ttl) { String metaId = RqueueMessageUtils.getMessageMetaId(queueName, messageId); MessageMetadata m = get(metaId); if (m == null) { - return false; + // NATS doesn't store metadata at enqueue time (storeMessageMetadata short-circuits in + // BaseMessageSender for brokers that don't use primary-handler dispatch). So a delete + // request from the dashboard for a stream-resident message will see no metadata. Create + // a tombstone entry keyed by metaId so subsequent peeks render the row as "deleted". + m = new MessageMetadata(metaId, MessageStatus.DELETED); } m.setDeleted(true); + m.setStatus(MessageStatus.DELETED); m.setDeletedOn(System.currentTimeMillis()); + m.setUpdatedOn(System.currentTimeMillis()); save(m, ttl, false); return true; } diff --git a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueViewControllerServiceImpl.java b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueViewControllerServiceImpl.java index 7f993cc2..72d0f517 100644 --- a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueViewControllerServiceImpl.java +++ b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueViewControllerServiceImpl.java @@ -18,6 +18,8 @@ import com.github.sonus21.rqueue.config.RqueueConfig; import com.github.sonus21.rqueue.config.RqueueWebConfig; +import com.github.sonus21.rqueue.core.spi.Capabilities; +import com.github.sonus21.rqueue.core.spi.MessageBroker; import com.github.sonus21.rqueue.models.Pair; import com.github.sonus21.rqueue.models.db.QueueConfig; import com.github.sonus21.rqueue.models.enums.AggregationType; @@ -52,6 +54,13 @@ public class RqueueViewControllerServiceImpl implements RqueueViewControllerServ private final RqueueUtilityService rqueueUtilityService; private final RqueueSystemManagerService rqueueSystemManagerService; + /** + * Optional broker SPI. When set (non-Redis backend), {@link #addBasicDetails(Model, String)} + * propagates {@link Capabilities} flags to every view template so the navigation, charts, and + * other panels can hide unsupported sections globally. + */ + private MessageBroker messageBroker; + @Autowired public RqueueViewControllerServiceImpl( RqueueConfig rqueueConfig, @@ -66,6 +75,11 @@ public RqueueViewControllerServiceImpl( this.rqueueSystemManagerService = rqueueSystemManagerService; } + @Autowired(required = false) + public void setMessageBroker(MessageBroker messageBroker) { + this.messageBroker = messageBroker; + } + private void addNavData(Model model, NavTab tab) { for (NavTab navTab : NavTab.values()) { String name = navTab.name().toLowerCase() + "Active"; @@ -73,6 +87,14 @@ private void addNavData(Model model, NavTab tab) { } } + /** + * Resolved capabilities for the active broker. Defaults to {@link Capabilities#REDIS_DEFAULTS} + * (everything supported) so the legacy no-broker path keeps the historical UI. + */ + private Capabilities capabilities() { + return messageBroker != null ? messageBroker.capabilities() : Capabilities.REDIS_DEFAULTS; + } + private void addBasicDetails(Model model, String xForwardedPrefix) { Pair releaseAndVersion = rqueueUtilityService.getLatestVersion(); model.addAttribute("releaseLink", releaseAndVersion.getFirst()); @@ -81,6 +103,18 @@ private void addBasicDetails(Model model, String xForwardedPrefix) { model.addAttribute("timeInMilli", System.currentTimeMillis()); model.addAttribute("version", rqueueConfig.getLibVersion()); model.addAttribute("urlPrefix", rqueueWebConfig.getUrlPrefix(xForwardedPrefix)); + // Capability-driven UI hide flags. Templates default to "show" when these are absent / + // false, matching the historical Redis behavior. + Capabilities caps = capabilities(); + model.addAttribute("hideScheduledPanel", !caps.supportsScheduledIntrospection()); + model.addAttribute("hideRunningPanel", !caps.usesPrimaryHandlerDispatch()); + model.addAttribute("hideCronJobs", !caps.supportsCronJobs()); + // Charts (stats / latency) require time-series counters. Brokers without scheduled + // introspection (e.g. NATS) don't track them; hide the chart panels rather than render + // an empty Google Charts canvas. + model.addAttribute("hideCharts", !caps.supportsScheduledIntrospection()); + model.addAttribute("storageKicker", rqueueQDetailService.storageKicker()); + model.addAttribute("storageDescription", rqueueQDetailService.storageDescription()); } @Override diff --git a/rqueue-web/src/main/resources/public/rqueue/js/rqueue.js b/rqueue-web/src/main/resources/public/rqueue/js/rqueue.js index e140efba..10e4de96 100644 --- a/rqueue-web/src/main/resources/public/rqueue/js/rqueue.js +++ b/rqueue-web/src/main/resources/public/rqueue/js/rqueue.js @@ -569,7 +569,11 @@ function updateDeleteModal() { } function deleteMessage() { - let id = $($($($(this).parent()).parent()).children()[0]).text(); + // The delete button is wrapped in inside the action cell, + // which is a direct child of the . Walk up: button → div → td → tr, then read the first + // cell (the message id). The earlier two-parent walk landed on the td and read "Delete" + // (the wrapping div's text) as the id. + let id = $($(this).closest('tr').children()[0]).text().trim(); let payload = { "queue": queueName, "message_id": id, @@ -588,11 +592,11 @@ function deleteMessage() { } function enqueueMessage() { - enqueueMessageAtPosition($($(this).parent()).parent(), 'FRONT'); + enqueueMessageAtPosition($(this).closest('tr'), 'FRONT'); } function enqueueRearMessage() { - enqueueMessageAtPosition($($(this).parent()).parent(), 'REAR'); + enqueueMessageAtPosition($(this).closest('tr'), 'REAR'); } function enqueueMessageAtPosition(rowEl, position) { diff --git a/rqueue-web/src/main/resources/templates/rqueue/base.html b/rqueue-web/src/main/resources/templates/rqueue/base.html index add22e71..a3cfcf38 100644 --- a/rqueue-web/src/main/resources/templates/rqueue/base.html +++ b/rqueue-web/src/main/resources/templates/rqueue/base.html @@ -53,12 +53,17 @@

Rqueue

Workers + {# Hidden when the active broker reports !usesPrimaryHandlerDispatch (e.g. JetStream + durable consumers manage in-flight tracking internally — there is no separate + processing ZSET to inspect). Defaults to visible for the Redis backend. #} + {% if not hideRunningPanel %}
  • Running
  • + {% endif %} {# Hidden when the active broker reports !supportsScheduledIntrospection (e.g. JetStream). Defaults to visible (hideScheduledPanel == null/false) for the Redis backend. #} {% if not hideScheduledPanel %} diff --git a/rqueue-web/src/main/resources/templates/rqueue/index.html b/rqueue-web/src/main/resources/templates/rqueue/index.html index 91aa30a0..cb950224 100644 --- a/rqueue-web/src/main/resources/templates/rqueue/index.html +++ b/rqueue-web/src/main/resources/templates/rqueue/index.html @@ -1,8 +1,7 @@ {% extends 'base' %} {% block main %} -{% include 'stats_chart' %} +{# Charts depend on time-series counters (`rqueue::queue-stat::`). Brokers that report + !supportsScheduledIntrospection (e.g. NATS JetStream) don't maintain those counters, so + the chart panels would render an empty Google Charts canvas. Hide them entirely. #} +{% if not hideCharts %} +{% include 'stats_chart' %}
    {% include 'latency_chart' %} +{% else %} +
    +
    +

    {{ storageKicker }} Dashboard

    +

    {{ storageDescription }}

    +

    + Time-series charts (stats & latency) are not available for this backend. + Use the navigation above to browse queues, workers, pending messages, and the dead-letter queue. +

    +
    +
    +{% endif %} {% endblock %} {% block additional_script %} +{% if not hideCharts %} +{% endif %} {% endblock %} diff --git a/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html b/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html index 7e97f934..1a23d116 100644 --- a/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html +++ b/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html @@ -172,10 +172,14 @@

    Queue Pollers

    {% endif %} +{# Charts depend on time-series counters which brokers like NATS JetStream don't track. + Hide them when the active broker reports the unsupported capability. #} +{% if not hideCharts %}
    {% include 'stats_chart' %}
    {% include 'latency_chart' %} +{% endif %} {% include 'data_explorer_modal' %} {% endblock %} @@ -184,9 +188,12 @@

    Queue Pollers

    -{% endif %} {% endblock %} - diff --git a/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html b/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html index eef5052c..a22ea482 100644 --- a/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html +++ b/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html @@ -72,126 +72,133 @@ + + +{# --------------------------------------------------------------------------- + Subscribers — one row per @RqueueListener consumer. + • Redis: every handler is a row; Pending / In-Flight are shared sizes + (marked "(shared)") and folded with last-poll info from the worker + registry. + • NATS WorkQueue: every durable consumer is a row; Pending is the shared + stream msgCount, In-Flight is the consumer's exclusive numAckPending. + • NATS Limits: every durable consumer is a row; Pending is the consumer's + exact numPending, In-Flight is its numAckPending. + --------------------------------------------------------------------------- #} +
    +

    Subscribers

    +
    + {% if subscribers is empty %} + + {% else %} - - - - + + + + + + + + - {% for queueData in queueRedisDataDetails %} + {% for sub in subscribers %} - + + + + + {% endfor %}
    Job TypeData TypeNameSizeConsumerTypeStoragePendingIn-FlightStatusHost / PIDLast Poll
    - - {{queueData.key.name}} - {% if queueData.value.consumerName %} - [{{queueData.value.consumerName}}] - {% endif %} + {{sub.consumerName}} {{ queueData.value.typeLabel | default(queueData.value.type) }}{{ sub.typeLabel | default(sub.dataType) }}{{sub.storageName}} - {{queueData.value.name}} - {% if queueData.value.consumerName %} - / {{queueData.value.consumerName}} - {% endif %} + {{sub.pending}} + {% if sub.pendingShared %}(shared){% endif %} {{sub.inFlight}}{% if sub.status %}{{sub.status}}{% else %}{% endif %} - {% if queueData.value.size < 0 %} - Queue-backed - {% elseif queueData.value.approximate %} - ~ {{queueData.value.size}} + {% if sub.host %}{{sub.host}}{% if sub.pid %} / {{sub.pid}}{% endif %}{% else %}{% endif %} + + {% if sub.lastPollAt > 0 %} + {{ time(sub.lastPollAt) }} + {% if sub.lastPollAge %}({{sub.lastPollAge}} ago){% endif %} {% else %} - {{queueData.value.size}} + {% endif %}
    + {% endif %}
    -
    -{% if workerRegistryEnabled %} + +{# --------------------------------------------------------------------------- + Terminal Storage — COMPLETED set + DLQs. These are shared across subscribers + so they get their own table rather than being repeated per row. + --------------------------------------------------------------------------- #} +{% if terminalRows is not empty %}
    -

    Queue Pollers

    +

    Terminal Storage

    - - - - - - - - - - - - -
    Active PollersStale PollersRecent Exhaustion
    {{activeQueueWorkers}}{{staleQueueWorkers}}{{queueWorkerRecentCapacityExhausted}}
    - {% if queueWorkers is empty %} - - {% else %} - - - - - - - - - - - - - - - + + + + - {% for worker in queueWorkers %} + {% for row in terminalRows %} - - - - - - - - - - - - + + + + {% endfor %}
    Worker IdConsumer NameStatusHostPidLast PollLast Poll AgeLast MessageLast Message AgeLast ExhaustedExhausted AgeExhausted CountBucketTypeStorageSize
    {{worker.workerId}}{% if worker.consumerName %}{{worker.consumerName}}{% endif %}{{worker.status}}{{worker.host}}{{worker.pid}}{% if worker.lastPollAt > 0 %}{{ time(worker.lastPollAt) }}{% endif %}{{worker.lastPollAge}}{{ time(worker.lastMessageAt) }}{{worker.lastMessageAge}}{{ time(worker.lastCapacityExhaustedAt) }}{{worker.lastCapacityExhaustedAge}}{{worker.capacityExhaustedCount}} + + {{row.tab}} + + {{ row.typeLabel | default(row.dataType) }}{{row.storageName}} + {% if row.size < 0 %} + Queue-backed + {% elseif row.approximate %} + ~ {{row.size}} + {% else %} + {{row.size}} + {% endif %} +
    - {% endif %}
    {% endif %} -{# Charts depend on time-series counters which brokers like NATS JetStream don't track. - Hide them when the active broker reports the unsupported capability. #} -{% if not hideCharts %}
    {% include 'stats_chart' %}
    {% include 'latency_chart' %} -{% endif %} {% include 'data_explorer_modal' %} {% endblock %} @@ -200,12 +207,9 @@

    Queue Pollers

    {% endblock %} From a6a9e69a2e4b3aecbc1809c4c4df26d9c0acc298 Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Tue, 5 May 2026 13:06:27 +0530 Subject: [PATCH 09/19] fix: reuse single consumer for workqueue streams For workqueue streams, NATS rejects multiple non-filtered consumers (error 10099). When multiple listeners were registered on the same workqueue queue without custom consumer names, each listener tried to create its own consumer, causing the provisioning to fail. Fix: For workqueue streams with no custom consumer name, use a consistent `queueName-consumer` name so all listeners share a single consumer. This matches the workqueue semantics where only one consumer can receive each message. - NatsStreamValidator: Resolve consumer name based on queue type, using `queueName-consumer` for workqueues without custom names - JetStreamMessageBroker: Use the same resolution logic in pop() to ensure validator and poller use the same consumer name Assisted-By: Claude Code --- .../nats/js/JetStreamMessageBroker.java | 30 +++++++++---------- .../rqueue/nats/js/NatsStreamValidator.java | 13 +++++++- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java index 8fd40e71..47a0c2c4 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java @@ -31,9 +31,6 @@ import io.nats.client.JetStreamSubscription; import io.nats.client.Message; import io.nats.client.PullSubscribeOptions; -import io.nats.client.api.AckPolicy; -import io.nats.client.api.ConsumerConfiguration; -import io.nats.client.api.DeliverPolicy; import io.nats.client.impl.Headers; import java.io.IOException; import java.time.Duration; @@ -41,7 +38,6 @@ import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.logging.Level; @@ -308,7 +304,7 @@ public List pop(QueueDetail q, String consumerName, int batch, Du return popInternal( streamFor(q), subjectFor(q), - resolveConsumerName(q.getName(), consumerName), + resolveConsumerName(q, q.getConsumerName()), batch, wait, resolveAckWait(q, config), @@ -321,15 +317,21 @@ public List pop( return popInternal( streamFor(q, priority), subjectFor(q, priority), - resolveConsumerName(q.getName(), consumerName), + resolveConsumerName(q, q.getConsumerName()), batch, wait, resolveAckWait(q, config), resolveMaxDeliver(q, config)); } - private static String resolveConsumerName(String queueName, String consumerName) { - return (consumerName != null && !consumerName.isEmpty()) ? consumerName : "rqueue-" + queueName; + private static String resolveConsumerName(QueueDetail q, String consumerName) { + if (q.getType() == QueueType.QUEUE && (consumerName == null || consumerName.isEmpty())) { + String sanitized = q.getName().replaceAll("[^A-Za-z0-9_-]", "-"); + return sanitized + "-consumer"; + } + return (consumerName != null && !consumerName.isEmpty()) + ? consumerName + : "rqueue-" + q.getName(); } /** @@ -544,9 +546,7 @@ public List peek(QueueDetail q, long offset, long count) { } catch (JetStreamApiException notFound) { // Sequence may have been purged or skipped (e.g. WorkQueue acks); keep walking. log.log( - Level.FINE, - "peek: skipping missing seq=" + seq + " on stream=" + stream, - notFound); + Level.FINE, "peek: skipping missing seq=" + seq + " on stream=" + stream, notFound); } catch (Exception deserErr) { log.log(Level.WARNING, "peek: skipping undeserializable seq=" + seq, deserErr); } @@ -588,8 +588,7 @@ public long size(QueueDetail q) { * Returns {@code msgCount} as a fallback when no consumers exist or the enumeration * fails, so the dashboard never misses a non-zero queue. */ - private long approximateLimitsPending( - String stream, io.nats.client.api.StreamInfo info) { + private long approximateLimitsPending(String stream, io.nats.client.api.StreamInfo info) { long lastSeq = info.getStreamState().getLastSequence(); if (lastSeq <= 0) { return 0L; @@ -676,9 +675,8 @@ public java.util.List subscri } long pending = pendingIsShared ? sharedPending : ci.getNumPending(); long inFlight = ci.getNumAckPending(); - out.add( - new com.github.sonus21.rqueue.core.spi.SubscriberView( - consumer, pending, inFlight, pendingIsShared)); + out.add(new com.github.sonus21.rqueue.core.spi.SubscriberView( + consumer, pending, inFlight, pendingIsShared)); } catch (IOException | JetStreamApiException ignore) { // best-effort; skip consumers that disappear mid-walk } diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/NatsStreamValidator.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/NatsStreamValidator.java index c99a5e33..b4c772e1 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/NatsStreamValidator.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/NatsStreamValidator.java @@ -106,7 +106,8 @@ public void afterSingletonsInstantiated() { String mainSubject = config.getSubjectPrefix() + q.getName(); total += tryEnsure(failures, mainStream, mainSubject, q); if (!producerOnly) { - tryEnsureConsumer(failures, mainStream, q.resolvedConsumerName(), q, cd); + String consumerName = resolveConsumerName(q); + tryEnsureConsumer(failures, mainStream, consumerName, q, cd); } if (q.getPriority() != null) { @@ -162,6 +163,16 @@ public void afterSingletonsInstantiated() { new Object[] {total, queues.size()}); } + private String resolveConsumerName(QueueDetail q) { + String customName = q.getConsumerName(); + if (q.getType() == com.github.sonus21.rqueue.enums.QueueType.QUEUE + && (customName == null || customName.isEmpty())) { + String sanitized = q.getName().replaceAll("[^A-Za-z0-9_-]", "-"); + return sanitized + "-consumer"; + } + return q.resolvedConsumerName(); + } + private void tryEnsureConsumer( List failures, String streamName, From a7a305401a81f8be48041175abec04e8cb843feb Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Tue, 5 May 2026 13:08:01 +0530 Subject: [PATCH 10/19] Revert "fix: reuse single consumer for workqueue streams" This reverts commit a6a9e69a2e4b3aecbc1809c4c4df26d9c0acc298. --- .../nats/js/JetStreamMessageBroker.java | 30 ++++++++++--------- .../rqueue/nats/js/NatsStreamValidator.java | 13 +------- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java index 47a0c2c4..8fd40e71 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java @@ -31,6 +31,9 @@ import io.nats.client.JetStreamSubscription; import io.nats.client.Message; import io.nats.client.PullSubscribeOptions; +import io.nats.client.api.AckPolicy; +import io.nats.client.api.ConsumerConfiguration; +import io.nats.client.api.DeliverPolicy; import io.nats.client.impl.Headers; import java.io.IOException; import java.time.Duration; @@ -38,6 +41,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.logging.Level; @@ -304,7 +308,7 @@ public List pop(QueueDetail q, String consumerName, int batch, Du return popInternal( streamFor(q), subjectFor(q), - resolveConsumerName(q, q.getConsumerName()), + resolveConsumerName(q.getName(), consumerName), batch, wait, resolveAckWait(q, config), @@ -317,21 +321,15 @@ public List pop( return popInternal( streamFor(q, priority), subjectFor(q, priority), - resolveConsumerName(q, q.getConsumerName()), + resolveConsumerName(q.getName(), consumerName), batch, wait, resolveAckWait(q, config), resolveMaxDeliver(q, config)); } - private static String resolveConsumerName(QueueDetail q, String consumerName) { - if (q.getType() == QueueType.QUEUE && (consumerName == null || consumerName.isEmpty())) { - String sanitized = q.getName().replaceAll("[^A-Za-z0-9_-]", "-"); - return sanitized + "-consumer"; - } - return (consumerName != null && !consumerName.isEmpty()) - ? consumerName - : "rqueue-" + q.getName(); + private static String resolveConsumerName(String queueName, String consumerName) { + return (consumerName != null && !consumerName.isEmpty()) ? consumerName : "rqueue-" + queueName; } /** @@ -546,7 +544,9 @@ public List peek(QueueDetail q, long offset, long count) { } catch (JetStreamApiException notFound) { // Sequence may have been purged or skipped (e.g. WorkQueue acks); keep walking. log.log( - Level.FINE, "peek: skipping missing seq=" + seq + " on stream=" + stream, notFound); + Level.FINE, + "peek: skipping missing seq=" + seq + " on stream=" + stream, + notFound); } catch (Exception deserErr) { log.log(Level.WARNING, "peek: skipping undeserializable seq=" + seq, deserErr); } @@ -588,7 +588,8 @@ public long size(QueueDetail q) { * Returns {@code msgCount} as a fallback when no consumers exist or the enumeration * fails, so the dashboard never misses a non-zero queue. */ - private long approximateLimitsPending(String stream, io.nats.client.api.StreamInfo info) { + private long approximateLimitsPending( + String stream, io.nats.client.api.StreamInfo info) { long lastSeq = info.getStreamState().getLastSequence(); if (lastSeq <= 0) { return 0L; @@ -675,8 +676,9 @@ public java.util.List subscri } long pending = pendingIsShared ? sharedPending : ci.getNumPending(); long inFlight = ci.getNumAckPending(); - out.add(new com.github.sonus21.rqueue.core.spi.SubscriberView( - consumer, pending, inFlight, pendingIsShared)); + out.add( + new com.github.sonus21.rqueue.core.spi.SubscriberView( + consumer, pending, inFlight, pendingIsShared)); } catch (IOException | JetStreamApiException ignore) { // best-effort; skip consumers that disappear mid-walk } diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/NatsStreamValidator.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/NatsStreamValidator.java index b4c772e1..c99a5e33 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/NatsStreamValidator.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/NatsStreamValidator.java @@ -106,8 +106,7 @@ public void afterSingletonsInstantiated() { String mainSubject = config.getSubjectPrefix() + q.getName(); total += tryEnsure(failures, mainStream, mainSubject, q); if (!producerOnly) { - String consumerName = resolveConsumerName(q); - tryEnsureConsumer(failures, mainStream, consumerName, q, cd); + tryEnsureConsumer(failures, mainStream, q.resolvedConsumerName(), q, cd); } if (q.getPriority() != null) { @@ -163,16 +162,6 @@ public void afterSingletonsInstantiated() { new Object[] {total, queues.size()}); } - private String resolveConsumerName(QueueDetail q) { - String customName = q.getConsumerName(); - if (q.getType() == com.github.sonus21.rqueue.enums.QueueType.QUEUE - && (customName == null || customName.isEmpty())) { - String sanitized = q.getName().replaceAll("[^A-Za-z0-9_-]", "-"); - return sanitized + "-consumer"; - } - return q.resolvedConsumerName(); - } - private void tryEnsureConsumer( List failures, String streamName, From 4fdd11b851b353cbba26100ee2369a5c871f84a5 Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Tue, 5 May 2026 13:09:21 +0530 Subject: [PATCH 11/19] nats-web: tighten queue-detail layout, add play/pause action button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback was blunt: too much wasted whitespace and no clear pause/play control. This rewrite collapses the queue-detail page into a single header bar plus dense table sections so the operator can see and act on everything without scrolling. Layout changes: - Header: queue name + state pill (LIVE / PAUSED) + actionable pause/play toggle button + summary stats ("N subscribers · N pending · N in-flight") on the right, all on one row. Replaces the big hero block. - Configuration chip strip: 6 inline cells (Concurrency · Retries · Visibility · DLQ · Created · Updated) with dashed dividers — fits everything in ~60px of vertical space. - Subscribers and Terminal Storage as compact tables with light row dividers and pill-styled type labels. No more card-grid whitespace. - Stats & Latency stays behind a
    disclosure so charts don't pin the actionable rows off-screen. Pause/play action: - The state pill is paired with a button that reuses the existing `pause-queue-btn` JS handler (POSTs to /pause-unpause-queue and reloads). Click toggles the queue state and the pill / button icon swap accordingly. Browser-tested: pausing the queue switches to "PAUSED" + play icon; clicking play unpauses, queue resumes processing pending messages. Bug caught while testing: - `QueueDetail.resolvedConsumerName()` previously returned different names for system-generated vs. primary queues (`-consumer-primary` vs `-consumer`). NATS WorkQueue streams reject multiple non-filtered consumers (10099) so the poller's runtime consumer name had to match whatever the bootstrap validator created. Unified to a single `{name}-consumer` suffix. Files touched: - queue_detail.html — full template rewrite (compact tables, header bar, inline config strip, charts disclosure) - rqueue.css — replaced the previous card-heavy queue-detail CSS block with a tighter `qd-*` namespaced ruleset (~250 lines, was ~430) - QueueDetail.java — consumer-name suffix fix - (assorted formatter cleanups across files touched in earlier commits) Assisted-By: Claude Code --- .../core/spi/redis/RedisMessageBroker.java | 5 +- .../sonus21/rqueue/listener/QueueDetail.java | 16 +- .../nats/js/JetStreamMessageBroker.java | 16 +- .../service/NatsRqueueUtilityService.java | 3 +- .../service/NatsRqueueUtilityServiceTest.java | 21 +- .../web/service/RqueueQDetailServiceImpl.java | 16 +- .../resources/public/rqueue/css/rqueue.css | 577 +++++++++--------- .../templates/rqueue/queue_detail.html | 377 +++++------- 8 files changed, 469 insertions(+), 562 deletions(-) diff --git a/rqueue-core/src/main/java/com/github/sonus21/rqueue/core/spi/redis/RedisMessageBroker.java b/rqueue-core/src/main/java/com/github/sonus21/rqueue/core/spi/redis/RedisMessageBroker.java index 7b9a53f3..5ac2f868 100644 --- a/rqueue-core/src/main/java/com/github/sonus21/rqueue/core/spi/redis/RedisMessageBroker.java +++ b/rqueue-core/src/main/java/com/github/sonus21/rqueue/core/spi/redis/RedisMessageBroker.java @@ -176,9 +176,8 @@ public java.util.List subscri java.util.List out = new java.util.ArrayList<>(registered.size()); for (QueueDetail qd : registered) { - out.add( - new com.github.sonus21.rqueue.core.spi.SubscriberView( - qd.resolvedConsumerName(), sharedPending, sharedInFlight, true)); + out.add(new com.github.sonus21.rqueue.core.spi.SubscriberView( + qd.resolvedConsumerName(), sharedPending, sharedInFlight, true)); } return out; } diff --git a/rqueue-core/src/main/java/com/github/sonus21/rqueue/listener/QueueDetail.java b/rqueue-core/src/main/java/com/github/sonus21/rqueue/listener/QueueDetail.java index 9cd159c4..baaeb5c1 100644 --- a/rqueue-core/src/main/java/com/github/sonus21/rqueue/listener/QueueDetail.java +++ b/rqueue-core/src/main/java/com/github/sonus21/rqueue/listener/QueueDetail.java @@ -175,18 +175,22 @@ public Duration visibilityDuration() { /** * Returns the effective JetStream consumer name for this queue. When {@link #consumerName} is - * explicitly set it is returned as-is. Otherwise a default is derived from the queue name: - * primary (non-system-generated) queues get {@code {name}-consumer-primary}; system-generated - * priority sub-queues get {@code {name}-consumer}. The name is sanitized so that characters - * outside {@code [A-Za-z0-9_-]} (e.g. the {@code ::} priority suffix separator) are replaced - * with {@code -}, producing a valid NATS consumer name in all cases. + * explicitly set it is returned as-is. Otherwise the default is derived from the queue name as + * {@code {name}-consumer}. The name is sanitized so that characters outside + * {@code [A-Za-z0-9_-]} (e.g. the {@code ::} priority suffix separator) are replaced with + * {@code -}, producing a valid NATS consumer name in all cases. + * + *

    A single suffix is used regardless of {@code systemGenerated} so that the bootstrap + * validator and the runtime poller agree on the consumer name. NATS workqueue streams reject + * multiple non-filtered consumers (error 10099); using two different suffixes would cause the + * poller to try creating a second consumer with a different name, failing on workqueue streams. */ public String resolvedConsumerName() { if (consumerName != null && !consumerName.isEmpty()) { return consumerName; } String sanitized = name.replaceAll("[^A-Za-z0-9_-]", "-"); - return systemGenerated ? sanitized + "-consumer" : sanitized + "-consumer-primary"; + return sanitized + "-consumer"; } public boolean isDoNotRetryError(Throwable throwable) { diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java index 8fd40e71..b6ae4e7f 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java @@ -31,9 +31,6 @@ import io.nats.client.JetStreamSubscription; import io.nats.client.Message; import io.nats.client.PullSubscribeOptions; -import io.nats.client.api.AckPolicy; -import io.nats.client.api.ConsumerConfiguration; -import io.nats.client.api.DeliverPolicy; import io.nats.client.impl.Headers; import java.io.IOException; import java.time.Duration; @@ -41,7 +38,6 @@ import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; import java.util.logging.Level; @@ -544,9 +540,7 @@ public List peek(QueueDetail q, long offset, long count) { } catch (JetStreamApiException notFound) { // Sequence may have been purged or skipped (e.g. WorkQueue acks); keep walking. log.log( - Level.FINE, - "peek: skipping missing seq=" + seq + " on stream=" + stream, - notFound); + Level.FINE, "peek: skipping missing seq=" + seq + " on stream=" + stream, notFound); } catch (Exception deserErr) { log.log(Level.WARNING, "peek: skipping undeserializable seq=" + seq, deserErr); } @@ -588,8 +582,7 @@ public long size(QueueDetail q) { * Returns {@code msgCount} as a fallback when no consumers exist or the enumeration * fails, so the dashboard never misses a non-zero queue. */ - private long approximateLimitsPending( - String stream, io.nats.client.api.StreamInfo info) { + private long approximateLimitsPending(String stream, io.nats.client.api.StreamInfo info) { long lastSeq = info.getStreamState().getLastSequence(); if (lastSeq <= 0) { return 0L; @@ -676,9 +669,8 @@ public java.util.List subscri } long pending = pendingIsShared ? sharedPending : ci.getNumPending(); long inFlight = ci.getNumAckPending(); - out.add( - new com.github.sonus21.rqueue.core.spi.SubscriberView( - consumer, pending, inFlight, pendingIsShared)); + out.add(new com.github.sonus21.rqueue.core.spi.SubscriberView( + consumer, pending, inFlight, pendingIsShared)); } catch (IOException | JetStreamApiException ignore) { // best-effort; skip consumers that disappear mid-walk } diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityService.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityService.java index ed83b176..792f6ffc 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityService.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityService.java @@ -229,8 +229,7 @@ public BaseResponse pauseUnpauseQueue(PauseUnpauseQueueRequest request) { } catch (Exception e) { // QueueConfig is already persisted; surface the pause-propagation failure to the caller // but do not roll back — the next listener restart will pick up the persisted flag. - log.warn( - "pauseUnpauseQueue listener notification failed for queue={}", request.getName(), e); + log.warn("pauseUnpauseQueue listener notification failed for queue={}", request.getName(), e); response.set(500, "Persisted but listener notification failed: " + e.getMessage()); } return response; diff --git a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityServiceTest.java b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityServiceTest.java index 481ac38e..6a353b1d 100644 --- a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityServiceTest.java +++ b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/service/NatsRqueueUtilityServiceTest.java @@ -12,8 +12,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -64,8 +64,8 @@ void setup() { systemConfigDao = Mockito.mock(RqueueSystemConfigDao.class); metadataService = Mockito.mock(RqueueMessageMetadataService.class); listenerContainer = Mockito.mock(RqueueMessageListenerContainer.class); - service = - new NatsRqueueUtilityService(webConfig, systemConfigDao, metadataService, listenerContainer); + service = new NatsRqueueUtilityService( + webConfig, systemConfigDao, metadataService, listenerContainer); } // --- deleteMessage -------------------------------------------------------- @@ -110,7 +110,8 @@ void deleteMessage_metadataServiceThrows_returnsErrorCode() { @Test void pauseUnpauseQueue_persistsFlagAndNotifiesListener() { - QueueConfig config = QueueConfig.builder().name("q").queueName("q").paused(false).build(); + QueueConfig config = + QueueConfig.builder().name("q").queueName("q").paused(false).build(); when(systemConfigDao.getConfigByName("q", true)).thenReturn(config); PauseUnpauseQueueRequest request = new PauseUnpauseQueueRequest(); request.setName("q"); @@ -127,7 +128,8 @@ void pauseUnpauseQueue_persistsFlagAndNotifiesListener() { @Test void pauseUnpauseQueue_unpause_propagatesFalse() { - QueueConfig config = QueueConfig.builder().name("q").queueName("q").paused(true).build(); + QueueConfig config = + QueueConfig.builder().name("q").queueName("q").paused(true).build(); when(systemConfigDao.getConfigByName("q", true)).thenReturn(config); PauseUnpauseQueueRequest request = new PauseUnpauseQueueRequest(); request.setName("q"); @@ -155,7 +157,8 @@ void pauseUnpauseQueue_unknownQueue_returns404() { @Test void pauseUnpauseQueue_alreadyInTargetState_isNoOp() { - QueueConfig config = QueueConfig.builder().name("q").queueName("q").paused(true).build(); + QueueConfig config = + QueueConfig.builder().name("q").queueName("q").paused(true).build(); when(systemConfigDao.getConfigByName("q", true)).thenReturn(config); PauseUnpauseQueueRequest request = new PauseUnpauseQueueRequest(); request.setName("q"); @@ -182,7 +185,8 @@ void pauseUnpauseQueue_emptyName_returns400() { @Test void pauseUnpauseQueue_listenerThrows_persistsButReports500() { - QueueConfig config = QueueConfig.builder().name("q").queueName("q").paused(false).build(); + QueueConfig config = + QueueConfig.builder().name("q").queueName("q").paused(false).build(); when(systemConfigDao.getConfigByName("q", true)).thenReturn(config); Mockito.doThrow(new RuntimeException("listener offline")) .when(listenerContainer) @@ -261,7 +265,8 @@ void aggregateDataCounter_monthlyHasSelectionEntry() { @Test void reactivePauseUnpauseQueue_delegatesToSync() { - QueueConfig config = QueueConfig.builder().name("q").queueName("q").paused(false).build(); + QueueConfig config = + QueueConfig.builder().name("q").queueName("q").paused(false).build(); when(systemConfigDao.getConfigByName("q", true)).thenReturn(config); PauseUnpauseQueueRequest request = new PauseUnpauseQueueRequest(); request.setName("q"); diff --git a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java index 8f1b498a..0519d195 100644 --- a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java +++ b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java @@ -24,6 +24,7 @@ import com.github.sonus21.rqueue.core.RqueueMessage; import com.github.sonus21.rqueue.core.RqueueMessageTemplate; import com.github.sonus21.rqueue.core.spi.MessageBroker; +import com.github.sonus21.rqueue.core.spi.SubscriberView; import com.github.sonus21.rqueue.core.support.RqueueMessageUtils; import com.github.sonus21.rqueue.exception.UnknownSwitchCase; import com.github.sonus21.rqueue.listener.QueueDetail; @@ -35,7 +36,6 @@ import com.github.sonus21.rqueue.models.enums.NavTab; import com.github.sonus21.rqueue.models.enums.TableColumnType; import com.github.sonus21.rqueue.models.registry.RqueueWorkerPollerView; -import com.github.sonus21.rqueue.core.spi.SubscriberView; import com.github.sonus21.rqueue.models.response.Action; import com.github.sonus21.rqueue.models.response.DataViewResponse; import com.github.sonus21.rqueue.models.response.RedisDataDetail; @@ -227,8 +227,7 @@ public List> getQueueDataStructureDetail(QueueCon if (!brokerHidesScheduled()) { Long scheduled = messageBrowsingRepository.getDataSize(scheduledQueueName, DataType.ZSET); RedisDataDetail scheduledDetail = - new RedisDataDetail( - scheduledQueueName, DataType.ZSET, scheduled == null ? 0 : scheduled); + new RedisDataDetail(scheduledQueueName, DataType.ZSET, scheduled == null ? 0 : scheduled); scheduledDetail.setTypeLabel(brokerLabel(NavTab.SCHEDULED, DataType.ZSET)); queueRedisDataDetails.add(new HashMap.SimpleEntry<>(NavTab.SCHEDULED, scheduledDetail)); } @@ -259,9 +258,8 @@ public List> getQueueDataStructureDetail(QueueCon brokerQueueDetail != null && messageBroker.storageDisplayName(brokerQueueDetail) != null ? messageBroker.storageDisplayName(brokerQueueDetail) : queueConfig.getCompletedQueueName(); - RedisDataDetail completedDetail = - new RedisDataDetail( - completedDisplayName, DataType.ZSET, completed == null ? 0 : completed); + RedisDataDetail completedDetail = new RedisDataDetail( + completedDisplayName, DataType.ZSET, completed == null ? 0 : completed); completedDetail.setTypeLabel(brokerLabel(NavTab.COMPLETED, DataType.ZSET)); queueRedisDataDetails.add(new HashMap.SimpleEntry<>(NavTab.COMPLETED, completedDetail)); } @@ -706,13 +704,11 @@ private List brokerSubscribers( } // No active QueueDetail registered (producer-only or shutdown). Surface a single row so // the operator at least sees the queue's pending count from the repository fallback. - Long pending = - messageBrowsingRepository.getDataSize(queueConfig.getQueueName(), DataType.LIST); + Long pending = messageBrowsingRepository.getDataSize(queueConfig.getQueueName(), DataType.LIST); if (pending == null || pending <= 0) { return Collections.emptyList(); } - return Collections.singletonList( - new SubscriberView(queueConfig.getName(), pending, 0L, true)); + return Collections.singletonList(new SubscriberView(queueConfig.getName(), pending, 0L, true)); } private Map indexWorkersByConsumer(String queueName) { diff --git a/rqueue-web/src/main/resources/public/rqueue/css/rqueue.css b/rqueue-web/src/main/resources/public/rqueue/css/rqueue.css index d9dd6ba1..1f7e68d6 100644 --- a/rqueue-web/src/main/resources/public/rqueue/css/rqueue.css +++ b/rqueue-web/src/main/resources/public/rqueue/css/rqueue.css @@ -1947,460 +1947,431 @@ section { cursor: pointer; } + /* ============================================================================ - Queue Detail (post-redesign) - - Hero header with paused/live badge and stat chips - - Configuration metric strip - - Subscriber cards (one per @RqueueListener consumer) - - Terminal Storage cards (COMPLETED + DLQ) - - Charts collapsed by default behind a

    disclosure + Queue Detail — compact, table-driven, action-first ========================================================================== */ -.queue-detail { - padding-bottom: 48px; +.qd { + padding-bottom: 32px; } -.queue-detail-hero .queue-hero-title { +/* ----- Header -------------------------------------------------------------- + Single horizontal bar: name + state pill + pause toggle, summary stats on + the right. Replaces the big hero so the data is visible without scrolling. +*/ +.qd-header { align-items: center; + border-bottom: 1px solid #e0e3d4; display: flex; flex-wrap: wrap; gap: 16px; + justify-content: space-between; + margin: 8px 0 16px; + padding-bottom: 12px; } -.queue-detail-state { +.qd-header-title { align-items: center; - display: inline-flex; - font-size: 13px; - gap: 6px; - letter-spacing: 0.08em; - padding: 4px 12px; - text-transform: uppercase; + display: flex; + flex-wrap: wrap; + gap: 12px; } -.queue-detail-state .bx { - font-size: 16px; +.qd-name { + color: #273126; + font-size: 26px; + font-weight: 800; + letter-spacing: -0.01em; + margin: 0; + word-break: break-word; } -.queue-detail-hero-meta { - align-items: stretch; +.qd-state { + align-items: center; + border-radius: 999px; + display: inline-flex; + font-size: 12px; + font-weight: 700; + gap: 4px; + letter-spacing: 0.06em; + padding: 3px 10px; + text-transform: uppercase; } -/* ----- Configuration chip strip ----- */ -.queue-detail-config { - background: #ffffff; - border: 1px solid #e0e3d4; - border-radius: 18px; - box-shadow: 0 4px 14px rgba(31, 47, 27, 0.04); - margin: 24px 0 32px; - padding: 22px 26px; +.qd-state .bx { + font-size: 14px; } -.queue-detail-config-row { - display: grid; - gap: 18px; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +.qd-state-live { + background: #e7efdb; + color: #4a6b35; } -.queue-detail-metric { - display: flex; - flex-direction: column; - gap: 6px; +.qd-state-paused { + background: #fbe5d9; + color: #b45433; } -.queue-detail-metric-label { +/* Pause / play action button next to the state pill. Reuses the existing + .pause-queue-btn handler so a click POSTs to /pause-unpause-queue. */ +.qd-pause-btn { align-items: center; - color: #6d7561; + background: #f6f7ee; + border: 1px solid #d8dbcb; + border-radius: 8px; + color: #4a5d44; + cursor: pointer; display: inline-flex; - font-size: 11px; - font-weight: 700; - gap: 6px; - letter-spacing: 0.1em; - text-transform: uppercase; + height: 34px; + justify-content: center; + padding: 0; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; + width: 34px; } -.queue-detail-metric-label .bx { - font-size: 16px; +.qd-pause-btn:hover { + background: #eef0e3; + border-color: #b4bba0; + color: #2f3d2b; } -.queue-detail-metric-value { - color: #273126; - font-size: 17px; - font-weight: 700; +.qd-pause-btn:focus-visible { + outline: 2px solid #6d8a52; + outline-offset: 2px; } -.queue-detail-metric-value .bx { - vertical-align: middle; +.qd-pause-btn .pause-queue-btn { + cursor: pointer; + font-size: 22px; } -.queue-detail-config-meta { - border-top: 1px dashed #e3e7d8; - color: #6d7561; - display: flex; +.qd-header-stats { + align-items: baseline; + color: #4a5d44; + display: inline-flex; flex-wrap: wrap; - font-size: 13px; - gap: 24px; - margin-top: 18px; - padding-top: 14px; + font-size: 14px; + gap: 8px; } -.queue-detail-config-meta strong { +.qd-stat strong { color: #273126; - font-weight: 600; - margin-right: 6px; -} - -/* ----- Section spacing ----- */ -.queue-detail-section { - margin: 36px 0; -} - -.queue-detail-section .queue-section-header { - margin-bottom: 16px; + font-size: 17px; + font-weight: 800; + margin-right: 4px; } -.queue-detail-empty { - margin-top: 0; +.qd-stat-sep { + color: #c0c5b0; + font-weight: 700; } -/* ----- Subscriber cards ----- */ -.subscriber-grid { +/* ----- Configuration chip strip ------------------------------------------- */ +.qd-config { + background: #fbfbf6; + border: 1px solid #e0e3d4; + border-radius: 10px; display: grid; - gap: 18px; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 0; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + margin-bottom: 20px; + padding: 12px 16px; } -.subscriber-card { - background: #ffffff; - border: 1px solid #e0e3d4; - border-radius: 18px; - box-shadow: 0 4px 14px rgba(31, 47, 27, 0.04); +.qd-config-cell { + border-right: 1px dashed #e3e7d8; display: flex; flex-direction: column; - gap: 16px; - padding: 22px; - transition: box-shadow 0.18s ease, transform 0.18s ease; + gap: 2px; + padding: 4px 12px 4px 0; } -.subscriber-card:hover { - box-shadow: 0 8px 22px rgba(31, 47, 27, 0.08); - transform: translateY(-1px); -} - -.subscriber-card-head { - align-items: flex-start; - display: flex; - gap: 12px; - justify-content: space-between; +.qd-config-cell:last-child { + border-right: none; } -.subscriber-card-title-row { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; +.qd-config-cell-meta .qd-config-value { + font-size: 12px; + font-weight: 600; } -.subscriber-card-kicker { +.qd-config-label { + align-items: center; color: #6d7561; - font-size: 11px; + display: inline-flex; + font-size: 10px; font-weight: 700; - letter-spacing: 0.1em; + gap: 4px; + letter-spacing: 0.08em; text-transform: uppercase; } -.subscriber-card-title { - font-size: 18px; - font-weight: 700; - margin: 0; - word-break: break-word; -} - -.subscriber-card-title a { - color: #4a5d44; - text-decoration: none; - transition: color 0.15s ease; -} - -.subscriber-card-title a:hover, -.subscriber-card-title a:focus { - color: #2f3d2b; - text-decoration: underline; +.qd-config-label .bx { + font-size: 14px; } -.subscriber-card-type { - background: #f1f3e8; - border-radius: 999px; - color: #4a5d44; - display: inline-block; - font-size: 11px; +.qd-config-value { + color: #273126; + font-size: 14px; font-weight: 700; - letter-spacing: 0.06em; - margin-top: 4px; - padding: 3px 10px; - text-transform: uppercase; - width: fit-content; } -.subscriber-stat-row { - display: grid; - gap: 10px; - grid-template-columns: 1fr 1fr; +.qd-config-value .bx { + vertical-align: middle; } -.subscriber-stat { - background: linear-gradient(180deg, #f6f7ee 0%, #f0f2e2 100%); - border-radius: 12px; - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px 14px; +/* ----- Section ------------------------------------------------------------- + Tight section header (no kicker pill) and a table directly under it. +*/ +.qd-section { + margin: 18px 0; } -.subscriber-stat-label { - color: #6d7561; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; +.qd-section-head { + align-items: baseline; + border-bottom: 1px solid #eef0e3; + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: space-between; + margin-bottom: 8px; + padding-bottom: 6px; } -.subscriber-stat-value { +.qd-section-title { color: #273126; - font-size: 22px; + font-size: 16px; font-weight: 800; - line-height: 1; + letter-spacing: -0.005em; + margin: 0; } -.subscriber-stat-hint { - color: #6d7561; +.qd-count { + background: #f1f3e8; + border-radius: 999px; + color: #4a5d44; font-size: 11px; - font-style: italic; -} - -.subscriber-meta { - border-top: 1px dashed #e3e7d8; - display: flex; - flex-direction: column; - gap: 8px; - margin: 0; - padding-top: 12px; + font-weight: 700; + margin-left: 6px; + padding: 2px 8px; } -.subscriber-meta-row { - display: grid; - gap: 10px; - grid-template-columns: 90px 1fr; +.qd-section-hint { + color: #6d7561; + font-size: 12px; } -.subscriber-meta-row dt { +.qd-empty { + background: #fbfbf6; + border: 1px dashed #d8dbcb; + border-radius: 8px; color: #6d7561; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.04em; - margin: 0; - text-transform: uppercase; + font-size: 13px; + padding: 16px; + text-align: center; } -.subscriber-meta-row dd { - color: #273126; +/* ----- Tables -------------------------------------------------------------- */ +.qd-table { + border-collapse: separate; + border-spacing: 0; font-size: 13px; - margin: 0; - word-break: break-word; + width: 100%; } -.subscriber-meta-row dd code { +.qd-table thead th { background: #f6f7ee; - border-radius: 6px; - color: #2f3d2b; - display: inline-block; - font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; - font-size: 12px; - padding: 2px 6px; + border-bottom: 1px solid #d8dbcb; + border-top: 1px solid #d8dbcb; + color: #4a5d44; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + padding: 8px 10px; + text-align: left; + text-transform: uppercase; } -.subscriber-meta-row dd small.text-muted { - color: #6d7561 !important; - display: block; - font-size: 11px; - margin-top: 2px; +.qd-table thead th:first-child { + border-top-left-radius: 8px; } -/* ----- Terminal storage cards ----- */ -.terminal-grid { - display: grid; - gap: 16px; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +.qd-table thead th:last-child { + border-top-right-radius: 8px; } -.terminal-card { - background: #ffffff; - border: 1px solid #e0e3d4; - border-radius: 16px; - box-shadow: 0 4px 14px rgba(31, 47, 27, 0.04); - display: flex; - flex-direction: column; - gap: 14px; - padding: 20px; - position: relative; +.qd-table thead th.qd-num, +.qd-table tbody td.qd-num { + text-align: right; + white-space: nowrap; } -.terminal-card-completed { - border-left: 4px solid #6d8a52; +.qd-table tbody td { + border-bottom: 1px solid #eef0e3; + color: #273126; + padding: 10px; + vertical-align: top; } -.terminal-card-dead { - border-left: 4px solid #c97a4f; +.qd-table tbody tr:hover { + background: #fafbf3; } -.terminal-card-head { - display: flex; - flex-direction: column; - gap: 4px; +.qd-table tbody tr:last-child td:first-child { + border-bottom-left-radius: 8px; } -.terminal-card-kicker { - color: #6d7561; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; +.qd-table tbody tr:last-child td:last-child { + border-bottom-right-radius: 8px; } -.terminal-card-title { - font-size: 16px; - font-weight: 700; - margin: 0; - word-break: break-word; +.qd-num strong { + color: #273126; + font-size: 15px; + font-weight: 800; +} + +.qd-num .qd-muted { + display: inline-block; + margin-left: 4px; } -.terminal-card-title a { +.qd-link { color: #4a5d44; + font-weight: 700; text-decoration: none; } -.terminal-card-title a:hover, -.terminal-card-title a:focus { +.qd-link:hover, +.qd-link:focus { color: #2f3d2b; text-decoration: underline; } -.terminal-card-type { +.qd-pill { background: #f1f3e8; border-radius: 999px; color: #4a5d44; display: inline-block; font-size: 11px; font-weight: 700; - letter-spacing: 0.06em; - margin-top: 4px; - padding: 3px 10px; - text-transform: uppercase; - width: fit-content; + letter-spacing: 0.04em; + padding: 2px 8px; + white-space: nowrap; } -.terminal-card-size { - align-items: baseline; - display: flex; - gap: 6px; +.qd-code { + background: #f6f7ee; + border-radius: 4px; + color: #2f3d2b; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 12px; + padding: 1px 6px; + word-break: break-all; } -.terminal-card-size-value { - color: #273126; - font-size: 28px; - font-weight: 800; - line-height: 1; +.qd-muted { + color: #6d7561; + font-size: 11px; } -.terminal-card-approx { - color: #6d7561; - font-size: 22px; +/* Status badges (inline-table version of worker-status-*) */ +.qd-status { + border-radius: 999px; + display: inline-block; + font-size: 11px; font-weight: 700; + letter-spacing: 0.06em; + padding: 2px 8px; + text-transform: uppercase; } -.terminal-card-size-label { - color: #6d7561; - font-size: 12px; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; +.qd-status-active { + background: #d6e7c5; + color: #3e5a2c; } -/* ----- Charts disclosure ----- */ -.queue-detail-charts { - background: #ffffff; - border: 1px solid #e0e3d4; - border-radius: 18px; - box-shadow: 0 4px 14px rgba(31, 47, 27, 0.04); - margin-top: 36px; - padding: 0; +.qd-status-stale { + background: #f4e3c5; + color: #7a5a26; } -.queue-detail-charts-summary { - align-items: center; - cursor: pointer; - display: flex; - gap: 16px; - justify-content: space-between; - list-style: none; - padding: 22px 26px; +.qd-status-paused { + background: #fbe5d9; + color: #b45433; } -.queue-detail-charts-summary::-webkit-details-marker { - display: none; +.qd-poll-time { + color: #273126; + font-weight: 600; } -.queue-detail-charts-summary > div .queue-section-title { - margin-top: 4px; +/* Bucket label (Terminal Storage table) */ +.qd-bucket { + border-radius: 4px; + display: inline-block; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + padding: 2px 8px; + text-transform: uppercase; } -.queue-detail-charts-toggle { - height: 16px; - position: relative; - width: 16px; +.qd-bucket-completed { + background: #d6e7c5; + color: #3e5a2c; } -.queue-detail-charts-toggle::before, -.queue-detail-charts-toggle::after { - background: #4a5d44; - border-radius: 1px; - content: ""; - position: absolute; - transition: transform 0.18s ease; +.qd-bucket-dead { + background: #fbe5d9; + color: #b45433; } -.queue-detail-charts-toggle::before { - height: 2px; - left: 0; - top: 7px; - width: 16px; +/* ----- Charts disclosure --------------------------------------------------- */ +.qd-charts { + background: #fbfbf6; + border: 1px solid #e0e3d4; + border-radius: 10px; + margin-top: 20px; } -.queue-detail-charts-toggle::after { - height: 16px; - left: 7px; - top: 0; - width: 2px; +.qd-charts-head { + align-items: baseline; + cursor: pointer; + display: flex; + gap: 12px; + justify-content: space-between; + list-style: none; + padding: 12px 16px; } -.queue-detail-charts[open] .queue-detail-charts-toggle::after { - transform: rotate(90deg); +.qd-charts-head::-webkit-details-marker { + display: none; +} + +.qd-charts[open] .qd-charts-head { + border-bottom: 1px solid #eef0e3; } -.queue-detail-charts-body { - border-top: 1px solid #eef0e3; - padding: 22px 26px; +.qd-charts-body { + padding: 16px; } @media (max-width: 720px) { - .subscriber-card-head { - flex-direction: column; - } - .queue-detail-charts-summary { + .qd-header { flex-direction: column; align-items: flex-start; } - .subscriber-meta-row { - grid-template-columns: 1fr; - gap: 2px; + .qd-config-cell { + border-right: none; + border-bottom: 1px dashed #e3e7d8; + padding: 6px 0; + } + .qd-config-cell:last-child { + border-bottom: none; + } + .qd-table { + font-size: 12px; } } diff --git a/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html b/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html index e6d0dc93..112fae16 100644 --- a/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html +++ b/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html @@ -16,257 +16,200 @@ ~ --> -
    - {# ----- Hero ------------------------------------------------------------- #} -
    -
    - Queue -

    +
    + + {# ----- Compact header: name, state pill, pause toggle, key stats -------- #} +
    +
    +

    {% if config != null %}{{config.name}}{% else %}{{queueName}}{% endif %} - {% if config != null and config.paused %} - - Paused - - {% else %} - - Live - - {% endif %}

    -

    - Live consumers, pending work, and terminal storage for this {{ storageKicker | default('Redis') }} queue. -

    + + + {% if config != null and config.paused %}Paused{% else %}Live{% endif %} + + {% if config != null %} + + {% endif %}
    -
    -
    - Subscribers - {{ subscribers | length }} -
    -
    - Pending - - {% if subscribers is not empty %}{{ subscribers[0].pending }}{% else %}0{% endif %} - -
    -
    - In-Flight - +
    + {{ subscribers | length }} subscribers + · + {% if subscribers is not empty %}{{ subscribers[0].pending }}{% else %}0{% endif %} pending + · + + {% set totalInFlight = 0 %} {% for sub in subscribers %}{% set totalInFlight = totalInFlight + sub.inFlight %}{% endfor %} {{ totalInFlight }} -
    + in-flight +
    -

    + {# ----- Configuration chip strip ---------------------------------------- #} {% if config != null %} -
    -
    -
    - - Concurrency - - - {% if config.concurrency.min == -1 and config.concurrency.max == -1 %} - Unbounded - {% else %} - {{config.concurrency.min}} – {{config.concurrency.max}} - {% endif %} - -
    -
    - - Retries - - - {% if config.unlimitedRetry %} - Unlimited - {% else %} - {{config.numRetry}} - {% endif %} - -
    -
    - - Visibility - - {{duration(config.visibilityTimeout)}} -
    -
    - - Dead Letter - - - {% if config.deadLetterQueues is empty %} - - {% else %} - {{ dlq(config.deadLetterQueues) }} - {% endif %} - -
    +
    +
    + Concurrency + + {% if config.concurrency.min == -1 and config.concurrency.max == -1 %} + Unbounded + {% else %} + {{config.concurrency.min}}–{{config.concurrency.max}} + {% endif %} +
    -
    - Created {{ time(config.createdOn) }} - Updated {{ time(config.updatedOn) }} +
    + Retries + + {% if config.unlimitedRetry %} Unlimited{% else %}{{config.numRetry}}{% endif %} +
    -
    +
    + Visibility + {{duration(config.visibilityTimeout)}} +
    +
    + DLQ + + {% if config.deadLetterQueues is empty %}{% else %}{{ dlq(config.deadLetterQueues) }}{% endif %} + +
    +
    + Created + {{ time(config.createdOn) }} +
    +
    + Updated + {{ time(config.updatedOn) }} +
    +
    {% endif %} - {# ----- Subscribers ------------------------------------------------------ - One row per @RqueueListener consumer. Folds in worker-registry status - so the standalone "Queue Pollers" section is no longer needed. - ----------------------------------------------------------------------- #} -
    -
    -
    - Live -

    Subscribers

    -
    -

    - One entry per registered listener. Click a consumer to browse its messages. -

    + {# ----- Subscribers table ----------------------------------------------- #} +
    +
    +

    Subscribers {{ subscribers | length }}

    + Click a consumer to browse its messages.
    - {% if subscribers is empty %} -
    -

    No subscribers attached.

    -

    Listeners will appear here after they register a poll heartbeat.

    -
    +
    No subscribers attached yet.
    {% else %} -
    + + + + + + + + + + + + + + {% for sub in subscribers %} - + + + + + + + + + {% endfor %} - + +
    ConsumerTypeStoragePendingIn-FlightStatusHostLast Poll
    + {{sub.consumerName}} + {{ sub.typeLabel | default(sub.dataType) }}{{sub.storageName}} + {{sub.pending}} + {% if sub.pendingShared %}shared{% endif %} + {{sub.inFlight}} {% if sub.status %} - - {{sub.status}} - - {% else %} - UNKNOWN - {% endif %} - - -
    -
    - Pending - {{sub.pending}} - {% if sub.pendingShared %} - shared - {% endif %} -
    -
    - In-Flight - {{sub.inFlight}} -
    -
    - -
    -
    -
    Storage
    -
    {{sub.storageName}}
    -
    -
    -
    Host
    -
    - {% if sub.host %} - {{sub.host}}{% if sub.pid %} / pid {{sub.pid}}{% endif %} - {% else %} - - {% endif %} -
    -
    -
    -
    Last Poll
    -
    - {% if sub.lastPollAt > 0 %} - {{ time(sub.lastPollAt) }} - {% if sub.lastPollAge %} - {{sub.lastPollAge}} ago - {% endif %} - {% else %} - no poll yet - {% endif %} -
    -
    -
    - + {{sub.status}} + {% else %}{% endif %} +
    + {% if sub.host %}{{sub.host}}{% if sub.pid %} / {{sub.pid}}{% endif %}{% else %}{% endif %} + + {% if sub.lastPollAt > 0 %} +
    {{ time(sub.lastPollAt) }}
    + {% if sub.lastPollAge %}{{sub.lastPollAge}} ago{% endif %} + {% else %}{% endif %} +
    {% endif %}
    - {# ----- Terminal Storage ------------------------------------------------ #} + {# ----- Terminal storage table ------------------------------------------ #} {% if terminalRows is not empty %} -
    -
    -
    - Shared -

    Terminal Storage

    -
    -

    - Buckets shared across subscribers — completed messages and dead-letter queues. -

    +
    +
    +

    Terminal Storage {{ terminalRows | length }}

    + Shared buckets — completed and dead-letter messages.
    -
    + + + + + + + + + + {% for row in terminalRows %} -
    -
    - {{row.tab}} -

    - {{row.storageName}} -

    - {{ row.typeLabel | default(row.dataType) }} -
    -
    +
    + + + + + {% endfor %} - + +
    BucketTypeStorageSize
    + {{row.tab}} + {{ row.typeLabel | default(row.dataType) }} + {{row.storageName}} + {% if row.size < 0 %} - Queue-backed + Queue-backed {% else %} - {% if row.approximate %}~{% endif %} - {{row.size}} - messages + {% if row.approximate %}~{% endif %} + {{row.size}} {% endif %} - - +
    {% endif %} - {# ----- Charts (collapsed by default to keep the fold useful) --------- #} -
    - -
    - Telemetry -

    Stats & Latency

    -
    - + {# ----- Charts (collapsed) ---------------------------------------------- #} +
    + + Stats & Latency + Click to expand -
    +
    {% include 'stats_chart' %}
    {% include 'latency_chart' %} @@ -298,8 +241,6 @@

    Stats & Latency

    }); attachChartEventListeners(); } - // Lazy-render charts only when the disclosure opens, so the first paint - // stays tight on the subscriber + terminal cards. $('#queue-detail-charts').on('toggle', function () { if (this.open) renderChartsOnce(); }); From 21ecfb5aeedcdb975f503c123e011ceb03ede988 Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Tue, 5 May 2026 13:13:07 +0530 Subject: [PATCH 12/19] fix: use single consumer-name suffix in resolvedConsumerName resolvedConsumerName() returned different suffixes (-consumer vs -consumer-primary) based on systemGenerated. The bootstrap validator and runtime poller therefore disagreed on the consumer name when systemGenerated was false, and the second creation attempt failed on NATS workqueue streams with error 10099 (multiple non-filtered consumers not allowed). Use {name}-consumer in both cases. The custom consumerName from @RqueueListener is still honoured when set; only the generated default loses the -primary distinction. Assisted-By: Claude Code --- .../sonus21/rqueue/listener/QueueDetail.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/rqueue-core/src/main/java/com/github/sonus21/rqueue/listener/QueueDetail.java b/rqueue-core/src/main/java/com/github/sonus21/rqueue/listener/QueueDetail.java index baaeb5c1..c89bcb2d 100644 --- a/rqueue-core/src/main/java/com/github/sonus21/rqueue/listener/QueueDetail.java +++ b/rqueue-core/src/main/java/com/github/sonus21/rqueue/listener/QueueDetail.java @@ -174,16 +174,16 @@ public Duration visibilityDuration() { } /** - * Returns the effective JetStream consumer name for this queue. When {@link #consumerName} is - * explicitly set it is returned as-is. Otherwise the default is derived from the queue name as - * {@code {name}-consumer}. The name is sanitized so that characters outside - * {@code [A-Za-z0-9_-]} (e.g. the {@code ::} priority suffix separator) are replaced with - * {@code -}, producing a valid NATS consumer name in all cases. + * Returns the effective broker-level consumer name for this queue. When {@link #consumerName} is + * explicitly set on the {@code @RqueueListener} it is returned as-is. Otherwise the default is + * derived from the queue name as {@code {name}-consumer}. The name is sanitized so characters + * outside {@code [A-Za-z0-9_-]} (e.g. the {@code ::} priority suffix separator) are replaced + * with {@code -}, producing a valid NATS consumer name in all cases. * - *

    A single suffix is used regardless of {@code systemGenerated} so that the bootstrap - * validator and the runtime poller agree on the consumer name. NATS workqueue streams reject - * multiple non-filtered consumers (error 10099); using two different suffixes would cause the - * poller to try creating a second consumer with a different name, failing on workqueue streams. + *

    A single suffix is used regardless of {@code systemGenerated} so the bootstrap validator + * and the runtime poller agree on the consumer name. NATS workqueue streams reject multiple + * non-filtered consumers (error 10099); using two different suffixes would cause the poller to + * try creating a second consumer with a different name and fail. */ public String resolvedConsumerName() { if (consumerName != null && !consumerName.isEmpty()) { From 7bcc30dd4b8fa0fe2b28902b82b0cfa991c6d095 Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Tue, 5 May 2026 13:46:49 +0530 Subject: [PATCH 13/19] nats-web: consumer-aware peek for Limits-retention streams The queue-detail explorer was paginating from the stream's first sequence regardless of which subscriber the operator clicked on. For Limits-retention streams with competing consumers each consumer has its own delivered offset, so the explorer was showing messages the selected consumer had already acked instead of what is still pending for that subscriber. Wire consumerName end-to-end: - QueueExploreRequest: new optional consumerName field. - MessageBroker.peek: new consumer-aware overload, default delegates to the existing peek so non-NATS backends keep working. - JetStreamMessageBroker.peek: when consumerName is set on a Limits stream, base the start sequence on getConsumerInfo(stream, consumer).getDelivered().getStreamSequence()+1. WorkQueue and the no-consumer call site are unchanged. - RqueueQDetailService(.Impl): propagate consumerName into the broker peek call. - Rest controllers (blocking + reactive): forward consumerName from the request into the service. - queue_detail.html: subscribers table emits data-consumer on each consumer link. - rqueue.js: read data-consumer in exploreData() and include it in the queue-data POST body. Assisted-By: Claude Code --- .../rqueue/core/spi/MessageBroker.java | 12 +++++++++ .../models/request/QueueExploreRequest.java | 8 ++++++ .../rqueue/web/RqueueQDetailService.java | 4 +-- .../nats/js/JetStreamMessageBroker.java | 27 ++++++++++++++++++- .../ReactiveRqueueRestController.java | 1 + .../web/controller/RqueueRestController.java | 1 + .../web/service/RqueueQDetailServiceImpl.java | 18 ++++++++++--- .../main/resources/public/rqueue/js/rqueue.js | 8 +++++- .../templates/rqueue/queue_detail.html | 1 + 9 files changed, 72 insertions(+), 8 deletions(-) diff --git a/rqueue-core/src/main/java/com/github/sonus21/rqueue/core/spi/MessageBroker.java b/rqueue-core/src/main/java/com/github/sonus21/rqueue/core/spi/MessageBroker.java index ad5dddaa..1f368f5a 100644 --- a/rqueue-core/src/main/java/com/github/sonus21/rqueue/core/spi/MessageBroker.java +++ b/rqueue-core/src/main/java/com/github/sonus21/rqueue/core/spi/MessageBroker.java @@ -110,6 +110,18 @@ default List pop( List peek(QueueDetail q, long offset, long count); + /** + * Consumer-aware peek overload. When {@code consumerName} is non-null and the backend has + * per-consumer offsets (e.g. NATS Limits-retention streams), the implementation starts + * pagination from that consumer's next undelivered sequence so the dashboard shows messages + * still pending for that specific subscriber instead of the entire retained window. The + * default delegates to {@link #peek(QueueDetail, long, long)} for backends with a single + * shared pool. + */ + default List peek(QueueDetail q, String consumerName, long offset, long count) { + return peek(q, offset, count); + } + /** * Remove {@code old} from the processing store and re-enqueue {@code updated} for retry. * {@code delayMs <= 0} means immediate; {@code delayMs > 0} means schedule after that delay. diff --git a/rqueue-core/src/main/java/com/github/sonus21/rqueue/models/request/QueueExploreRequest.java b/rqueue-core/src/main/java/com/github/sonus21/rqueue/models/request/QueueExploreRequest.java index 76350e28..05500cf6 100644 --- a/rqueue-core/src/main/java/com/github/sonus21/rqueue/models/request/QueueExploreRequest.java +++ b/rqueue-core/src/main/java/com/github/sonus21/rqueue/models/request/QueueExploreRequest.java @@ -43,4 +43,12 @@ public class QueueExploreRequest extends SerializableBase { @JsonProperty("count") private int itemPerPage = 20; + + /** + * Optional consumer / subscriber name. When set on Limits-retention streams the broker + * starts the peek from that consumer's next undelivered sequence instead of the stream's + * first sequence, so the explorer shows messages that are still pending for this specific + * consumer rather than the entire retained window. + */ + private String consumerName; } diff --git a/rqueue-core/src/main/java/com/github/sonus21/rqueue/web/RqueueQDetailService.java b/rqueue-core/src/main/java/com/github/sonus21/rqueue/web/RqueueQDetailService.java index ce1caa19..7e5f8706 100644 --- a/rqueue-core/src/main/java/com/github/sonus21/rqueue/web/RqueueQDetailService.java +++ b/rqueue-core/src/main/java/com/github/sonus21/rqueue/web/RqueueQDetailService.java @@ -58,7 +58,7 @@ Map>> getQueueDataStructureDetails( List getNavTabs(QueueConfig queueConfig); DataViewResponse getExplorePageData( - String src, String name, DataType type, int pageNumber, int itemPerPage); + String src, String name, DataType type, String consumerName, int pageNumber, int itemPerPage); DataViewResponse viewData( String name, DataType type, String key, int pageNumber, int itemPerPage); @@ -74,7 +74,7 @@ DataViewResponse viewData( List getQueueWorkers(String queueName); Mono getReactiveExplorePageData( - String src, String name, DataType type, int pageNumber, int itemPerPage); + String src, String name, DataType type, String consumerName, int pageNumber, int itemPerPage); Mono viewReactiveData( String name, DataType type, String key, int pageNumber, int itemPerPage); diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java index b6ae4e7f..db6487ea 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java @@ -507,6 +507,11 @@ public long moveExpired(QueueDetail q, long now, int batch) { @Override public List peek(QueueDetail q, long offset, long count) { + return peek(q, null, offset, count); + } + + @Override + public List peek(QueueDetail q, String consumerName, long offset, long count) { String stream = streamFor(q); if (count <= 0) { return Collections.emptyList(); @@ -527,7 +532,27 @@ public List peek(QueueDetail q, long offset, long count) { if (lastSeq < firstSeq) { return Collections.emptyList(); } - long startSeq = Math.max(firstSeq, firstSeq + Math.max(0L, offset)); + // Consumer-aware base sequence for Limits-retention streams: when a consumerName is + // provided, start from that consumer's next undelivered sequence so the dashboard + // shows messages still pending for this subscriber instead of the entire retained + // window. WorkQueue streams have a single shared consumer (msgs are removed on ack) + // so the stream's firstSeq is already the right base — skip the lookup. + long base = firstSeq; + if (consumerName != null + && !consumerName.isEmpty() + && info.getConfiguration() != null + && info.getConfiguration().getRetentionPolicy() + == io.nats.client.api.RetentionPolicy.Limits) { + try { + io.nats.client.api.ConsumerInfo ci = jsm.getConsumerInfo(stream, consumerName); + if (ci != null && ci.getDelivered() != null) { + base = Math.max(firstSeq, ci.getDelivered().getStreamSequence() + 1); + } + } catch (JetStreamApiException ignore) { + // consumer may have disappeared mid-walk; fall back to stream firstSeq + } + } + long startSeq = base + Math.max(0L, offset); long endSeq = Math.min(lastSeq, startSeq + count - 1); List out = new ArrayList<>(); for (long seq = startSeq; seq <= endSeq && out.size() < count; seq++) { diff --git a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/controller/ReactiveRqueueRestController.java b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/controller/ReactiveRqueueRestController.java index 440ec408..1b905396 100644 --- a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/controller/ReactiveRqueueRestController.java +++ b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/controller/ReactiveRqueueRestController.java @@ -119,6 +119,7 @@ public Mono exploreQueue( request.getSrc(), request.getName(), request.getType(), + request.getConsumerName(), request.getPageNumber(), request.getItemPerPage()); } diff --git a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/controller/RqueueRestController.java b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/controller/RqueueRestController.java index 3436ffab..351a885f 100644 --- a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/controller/RqueueRestController.java +++ b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/controller/RqueueRestController.java @@ -117,6 +117,7 @@ public DataViewResponse exploreQueue( request.getSrc(), request.getName(), request.getType(), + request.getConsumerName(), request.getPageNumber(), request.getItemPerPage()); } diff --git a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java index 0519d195..dd81b222 100644 --- a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java +++ b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java @@ -389,7 +389,12 @@ private void addActionsIfRequired( @Override public DataViewResponse getExplorePageData( - String src, String name, DataType type, int pageNumber, int itemPerPage) { + String src, + String name, + DataType type, + String consumerName, + int pageNumber, + int itemPerPage) { QueueConfig queueConfig = rqueueSystemManagerService.getQueueConfig(src); DataViewResponse response = new DataViewResponse(); boolean deadLetterQueue = queueConfig.isDeadLetterQueue(name); @@ -413,7 +418,7 @@ public DataViewResponse getExplorePageData( QueueDetail qd = lookupQueueDetail(queueConfig.getName()); if (qd != null) { long offset = (long) pageNumber * itemPerPage; - List peeked = messageBroker.peek(qd, offset, itemPerPage); + List peeked = messageBroker.peek(qd, consumerName, offset, itemPerPage); List> tuples = peeked.stream() .map(m -> (TypedTuple) new DefaultTypedTuple<>(m, null)) .collect(Collectors.toList()); @@ -785,8 +790,13 @@ public List getTerminalRows(QueueConfig queueConfig) { @Override public Mono getReactiveExplorePageData( - String src, String name, DataType type, int pageNumber, int itemPerPage) { - return Mono.just(getExplorePageData(src, name, type, pageNumber, itemPerPage)); + String src, + String name, + DataType type, + String consumerName, + int pageNumber, + int itemPerPage) { + return Mono.just(getExplorePageData(src, name, type, consumerName, pageNumber, itemPerPage)); } @Override diff --git a/rqueue-web/src/main/resources/public/rqueue/js/rqueue.js b/rqueue-web/src/main/resources/public/rqueue/js/rqueue.js index 2d8f430e..f1aab5d3 100644 --- a/rqueue-web/src/main/resources/public/rqueue/js/rqueue.js +++ b/rqueue-web/src/main/resources/public/rqueue/js/rqueue.js @@ -19,6 +19,7 @@ var dataPageUrl = null; var dataKey = null; var dataName = null; var dataTypeLabel = null; +var dataConsumer = null; var deleteActionMessage = null; var dataType = null; var currentPage = 0; @@ -190,6 +191,10 @@ function exploreData() { // Falls back to the Redis-shaped DataType when the attribute isn't present. dataTypeLabel = element.data('type-label') || element.data('type'); dataKey = element.data('key'); + // Optional per-subscriber consumer name. When the queue has competing consumers + // (Limits-retention streams) this lets the server start pagination from this + // consumer's next undelivered sequence instead of the stream's first sequence. + dataConsumer = element.data('consumer'); } function displayHeader(response, displayPageNumberEl, pageSize) { @@ -389,7 +394,8 @@ function displayTable(nextOrPrev) { 'page': pageNumber, 'type': dataType, 'name': dataName, - 'key': dataKey + 'key': dataKey, + 'consumerName': dataConsumer }; ajaxRequest(getAbsoluteUrl(dataPageUrl), 'POST', data, function (response) { diff --git a/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html b/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html index 112fae16..c0da8279 100644 --- a/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html +++ b/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html @@ -122,6 +122,7 @@

    Subscribers {{ subscribers | Date: Tue, 5 May 2026 14:14:01 +0530 Subject: [PATCH 14/19] nats-web: peek from ackFloor, not delivered.streamSeq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consumer-aware peek was starting from delivered.streamSeq + 1, which skipped over the in-flight window (sequences delivered but not yet acked). Operators looking at a row with pending=0, in-flight=15 clicked through and got an empty explorer because all 15 in-flight sequences were <= delivered.streamSeq. Use ackFloor.streamSeq + 1 instead, so the explorer includes both in-flight and not-yet-delivered messages — i.e. everything this consumer still has work to do on. Matches the operator's mental model of "what is this consumer still chewing on". Assisted-By: Claude Code --- .../rqueue/nats/js/JetStreamMessageBroker.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java index db6487ea..91321029 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java @@ -533,10 +533,13 @@ public List peek(QueueDetail q, String consumerName, long offset, return Collections.emptyList(); } // Consumer-aware base sequence for Limits-retention streams: when a consumerName is - // provided, start from that consumer's next undelivered sequence so the dashboard - // shows messages still pending for this subscriber instead of the entire retained - // window. WorkQueue streams have a single shared consumer (msgs are removed on ack) - // so the stream's firstSeq is already the right base — skip the lookup. + // provided, start from that consumer's lowest unacked sequence (ackFloor + 1) so the + // dashboard shows everything this subscriber still has work to do on — both messages + // already delivered but not yet acked (in-flight) and messages still to be delivered + // (pending). Using delivered.streamSeq + 1 would hide the in-flight window, which + // surprises operators who see "in-flight = 15" but get an empty explorer. + // WorkQueue streams have a single shared consumer (msgs are removed on ack) so the + // stream's firstSeq is already the right base — skip the lookup. long base = firstSeq; if (consumerName != null && !consumerName.isEmpty() @@ -545,8 +548,8 @@ public List peek(QueueDetail q, String consumerName, long offset, == io.nats.client.api.RetentionPolicy.Limits) { try { io.nats.client.api.ConsumerInfo ci = jsm.getConsumerInfo(stream, consumerName); - if (ci != null && ci.getDelivered() != null) { - base = Math.max(firstSeq, ci.getDelivered().getStreamSequence() + 1); + if (ci != null && ci.getAckFloor() != null) { + base = Math.max(firstSeq, ci.getAckFloor().getStreamSequence() + 1); } } catch (JetStreamApiException ignore) { // consumer may have disappeared mid-walk; fall back to stream firstSeq From 524fc5ce5bc8c2b243f578431ccae14fe269b913 Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Tue, 5 May 2026 14:24:06 +0530 Subject: [PATCH 15/19] fix: ack/nack target wrong NATS Message under multi-consumer fan-out The inFlight map was keyed only on RqueueMessage.id. For Limits- retention streams with two or more durable consumers (e.g. one @RqueueListener per handler with consumerName="linkedin-search" and consumerName="google-search" on the same queue), each consumer receives its own copy of every message and the second pop's inFlight.put silently overwrote the first's NATS Message handle. When the first worker later called broker.ack/nack, it picked up the wrong consumer's NATS Message and acknowledged that delivery instead of its own. The original delivery stayed in numAckPending forever, and Outstanding Acks grew without bound (e.g. 92 -> 101 -> 112 over 30s with no new produces). Key the inFlight map on "::" so each consumer's delivery is tracked independently. Pop uses its consumerName arg; ack/nack/moveToDlq use q.resolvedConsumerName(), which matches what the poller passed at fetch time. Verified: with two @RqueueListener on a Limits stream, Outstanding Acks now drains to 0 and Ack Floor advances to lastSeq instead of sticking near the start. Assisted-By: Claude Code --- .../nats/js/JetStreamMessageBroker.java | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java index 91321029..1bc85087 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java @@ -78,10 +78,19 @@ public class JetStreamMessageBroker implements MessageBroker, AutoCloseable { private final NatsProvisioner provisioner; /** - * keyed by RqueueMessage.id, value is the underlying NATS Message for ack/nak. + * keyed by {@code "::"}, value is the underlying NATS + * Message for ack/nak. The consumer prefix is required for Limits-retention streams where + * multiple durable consumers each receive their own copy of every message — keying on + * just the message id would let one consumer's {@code put} overwrite another's, and the + * subsequent ack would target the wrong NATS Message handle (leaving the original delivery + * stuck in {@code numAckPending} until {@code AckWait} expires). */ private final ConcurrentHashMap inFlight = new ConcurrentHashMap<>(); + private static String inFlightKey(String consumerName, String messageId) { + return (consumerName == null ? "" : consumerName) + "::" + messageId; + } + /** * Cached pull subscriptions keyed by stream + consumerName so we don't re-bind on every pop. */ @@ -414,7 +423,7 @@ private List popInternal( // defensive: metadata unavailable on non-JetStream messages } if (rm.getId() != null) { - inFlight.put(rm.getId(), nm); + inFlight.put(inFlightKey(consumerName, rm.getId()), nm); } out.add(rm); } catch (RuntimeException | IOException e) { @@ -439,7 +448,7 @@ public boolean ack(QueueDetail q, RqueueMessage m) { if (m.getId() == null) { return false; } - Message nm = inFlight.remove(m.getId()); + Message nm = inFlight.remove(inFlightKey(q.resolvedConsumerName(), m.getId())); if (nm == null) { return false; } @@ -452,7 +461,7 @@ public boolean nack(QueueDetail q, RqueueMessage m, long retryDelayMs) { if (m.getId() == null) { return false; } - Message nm = inFlight.remove(m.getId()); + Message nm = inFlight.remove(inFlightKey(q.resolvedConsumerName(), m.getId())); if (nm == null) { return false; } @@ -469,7 +478,7 @@ public void moveToDlq( long delayMs) { // Ack the original NATS message so it is removed from the source stream. if (old.getId() != null) { - Message nm = inFlight.remove(old.getId()); + Message nm = inFlight.remove(inFlightKey(source.resolvedConsumerName(), old.getId())); if (nm != null) { nm.ack(); } From f83a821201870fc137d1e17a0e50f2929f7d4f28 Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Tue, 5 May 2026 14:38:04 +0530 Subject: [PATCH 16/19] nats: regression test for inFlight key-collision under fan-out Adds JetStreamMessageBrokerMultiConsumerAckIT, which catches the bug fixed in 524fc5c: under multi-consumer fan-out (Limits-retention stream + two durable consumers) the broker's inFlight map was keyed on RqueueMessage.id alone, so consumer-b's pop silently overwrote consumer-a's NATS Message handle and consumer-a's ack later targeted the wrong delivery. The trigger is timing-sensitive: pops on both consumers must occur before either acks. A sequential drain-then-drain pattern hides the bug because the inFlight key is removed before the second pop repopulates it. Verified that the test fails ("ack(consumer-b, m-0) must succeed ==> expected: but was: ") against the reverted broker and passes once the fix is restored. Also updates four existing ITs to set q.resolvedConsumerName() to match the consumer name passed to pop, since the fix makes ack/nack key on (consumerName, messageId) and the contract is that callers keep the two in sync. mockQueue gains an overload that takes a consumerName for tests that need this explicitly. Assisted-By: Claude Code --- .../rqueue/nats/AbstractJetStreamIT.java | 11 ++ ...reamMessageBrokerCompetingConsumersIT.java | 3 +- .../JetStreamMessageBrokerEnqueueAckIT.java | 3 +- ...amMessageBrokerIndependentConsumersIT.java | 14 +- ...StreamMessageBrokerMultiConsumerAckIT.java | 133 ++++++++++++++++++ .../JetStreamMessageBrokerRetryDlqIT.java | 2 +- 6 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerMultiConsumerAckIT.java diff --git a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/AbstractJetStreamIT.java b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/AbstractJetStreamIT.java index 2831edb9..70097635 100644 --- a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/AbstractJetStreamIT.java +++ b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/AbstractJetStreamIT.java @@ -79,9 +79,20 @@ protected QueueDetail mockQueue(String name) { } protected QueueDetail mockQueue(String name, QueueType type) { + return mockQueue(name, type, null); + } + + /** + * Build a mock QueueDetail whose {@code resolvedConsumerName()} returns the given consumer + * name. Used by tests that exercise multi-consumer flows where pop's {@code consumerName} + * argument must match what ack/nack derive from the QueueDetail. + */ + protected QueueDetail mockQueue(String name, QueueType type, String consumerName) { QueueDetail q = mock(QueueDetail.class); when(q.getName()).thenReturn(name); when(q.getType()).thenReturn(type); + String resolved = consumerName != null ? consumerName : name + "-consumer"; + when(q.resolvedConsumerName()).thenReturn(resolved); return q; } } diff --git a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerCompetingConsumersIT.java b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerCompetingConsumersIT.java index b253d230..1a2438a4 100644 --- a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerCompetingConsumersIT.java +++ b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerCompetingConsumersIT.java @@ -27,7 +27,8 @@ class JetStreamMessageBrokerCompetingConsumersIT extends AbstractJetStreamIT { @Test void twoWorkersSharingDurable_eachMessageDeliveredOnce() throws Exception { - QueueDetail q = mockQueue("ccq-" + System.nanoTime()); + QueueDetail q = mockQueue( + "ccq-" + System.nanoTime(), com.github.sonus21.rqueue.enums.QueueType.QUEUE, "shared"); int total = 20; try (JetStreamMessageBroker broker = JetStreamMessageBroker.builder().connection(connection).build()) { diff --git a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerEnqueueAckIT.java b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerEnqueueAckIT.java index accef105..56e2c708 100644 --- a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerEnqueueAckIT.java +++ b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerEnqueueAckIT.java @@ -25,7 +25,8 @@ class JetStreamMessageBrokerEnqueueAckIT extends AbstractJetStreamIT { @Test void enqueuePopAck_drainsStream() throws Exception { - QueueDetail q = mockQueue("eaq-" + System.nanoTime()); + QueueDetail q = mockQueue( + "eaq-" + System.nanoTime(), com.github.sonus21.rqueue.enums.QueueType.QUEUE, "worker"); RqueueNatsConfig cfg = RqueueNatsConfig.defaults(); cfg.getStreamDefaults().setRetention(io.nats.client.api.RetentionPolicy.WorkQueue); try (JetStreamMessageBroker broker = diff --git a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerIndependentConsumersIT.java b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerIndependentConsumersIT.java index cf25cf19..7f342fb3 100644 --- a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerIndependentConsumersIT.java +++ b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerIndependentConsumersIT.java @@ -26,18 +26,24 @@ class JetStreamMessageBrokerIndependentConsumersIT extends AbstractJetStreamIT { @Test void twoDurables_eachReceiveAllMessages() throws Exception { - QueueDetail q = mockQueue("icq-" + System.nanoTime(), QueueType.STREAM); + String name = "icq-" + System.nanoTime(); + // Same stream, two QueueDetail facets (one per @RqueueListener) — mirrors how production + // builds a separate QueueDetail per listener with its own resolvedConsumerName. + QueueDetail enqueueFacet = mockQueue(name, QueueType.STREAM); + QueueDetail qa = mockQueue(name, QueueType.STREAM, "consumer-a"); + QueueDetail qb = mockQueue(name, QueueType.STREAM, "consumer-b"); int total = 5; try (JetStreamMessageBroker broker = JetStreamMessageBroker.builder().connection(connection).build()) { for (int i = 0; i < total; i++) { - broker.enqueue(q, RqueueMessage.builder().id("m-" + i).message("p" + i).build()); + broker.enqueue( + enqueueFacet, RqueueMessage.builder().id("m-" + i).message("p" + i).build()); } Set aSeen = new HashSet<>(); Set bSeen = new HashSet<>(); - drainInto(broker, q, "consumer-a", aSeen); - drainInto(broker, q, "consumer-b", bSeen); + drainInto(broker, qa, "consumer-a", aSeen); + drainInto(broker, qb, "consumer-b", bSeen); assertEquals(total, aSeen.size()); assertEquals(total, bSeen.size()); diff --git a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerMultiConsumerAckIT.java b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerMultiConsumerAckIT.java new file mode 100644 index 00000000..d6cda836 --- /dev/null +++ b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerMultiConsumerAckIT.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026 Sonu Kumar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + */ +package com.github.sonus21.rqueue.nats; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.github.sonus21.rqueue.core.RqueueMessage; +import com.github.sonus21.rqueue.enums.QueueType; +import com.github.sonus21.rqueue.listener.QueueDetail; +import com.github.sonus21.rqueue.nats.js.JetStreamMessageBroker; +import io.nats.client.api.ConsumerInfo; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Regression test for the inFlight key-collision bug. + * + *

    On a Limits-retention stream with two durable consumers (multi-listener fan-out), each + * consumer receives its own NATS Message handle for every published message. The broker's + * {@code inFlight} map was previously keyed only on {@code RqueueMessage.id}, so the second + * consumer's pop overwrote the first's handle, and the first's later {@code ack} would target + * the wrong NATS Message — leaving the original delivery stuck in {@code numAckPending} until + * AckWait expired. + * + *

    The test pops on both consumers before acking either, which is the only timing + * that triggers the collision: a sequential drain-then-drain hides the bug because the inFlight + * key is removed before the second pop populates it. After acking each consumer's deliveries, + * the test asserts that both consumers reach {@code numAckPending == 0} and their ack + * floors advance to the stream's last sequence. + */ +@NatsIntegrationTest +class JetStreamMessageBrokerMultiConsumerAckIT extends AbstractJetStreamIT { + + @Test + void twoDurables_bothAckTheirOwnDeliveries() throws Exception { + String name = "mca-" + System.nanoTime(); + QueueDetail enqueueFacet = mockQueue(name, QueueType.STREAM); + QueueDetail qa = mockQueue(name, QueueType.STREAM, "consumer-a"); + QueueDetail qb = mockQueue(name, QueueType.STREAM, "consumer-b"); + int total = 6; + + RqueueNatsConfig cfg = RqueueNatsConfig.defaults(); + try (JetStreamMessageBroker broker = + JetStreamMessageBroker.builder().connection(connection).config(cfg).build()) { + for (int i = 0; i < total; i++) { + broker.enqueue( + enqueueFacet, RqueueMessage.builder().id("m-" + i).message("p" + i).build()); + } + + // Pop on BOTH consumers BEFORE acking either — this is the key to triggering the + // collision: consumer-b's pop overwrites consumer-a's inFlight entry, so when + // consumer-a later calls ack, the buggy implementation reaches for consumer-b's + // NATS Message handle. Acking sequentially (drain-then-drain) hides the bug + // because the inFlight key is removed before the second pop populates it. + List aPopped = pop(broker, qa, "consumer-a", total); + List bPopped = pop(broker, qb, "consumer-b", total); + + assertEquals(total, aPopped.size(), "consumer-a should see every published message"); + assertEquals(total, bPopped.size(), "consumer-b should see every published message"); + + // Ack both — under the buggy implementation, consumer-a's ack resolves to consumer-b's + // NATS Message and acks that one; consumer-a's original delivery stays stuck in + // numAckPending and consumer-b's ack returns false (entry already removed by a). + for (RqueueMessage m : aPopped) { + assertTrue(broker.ack(qa, m), "ack(consumer-a, " + m.getId() + ") must succeed"); + } + for (RqueueMessage m : bPopped) { + assertTrue(broker.ack(qb, m), "ack(consumer-b, " + m.getId() + ") must succeed"); + } + + String stream = cfg.getStreamPrefix() + name; + // Acks are async — the server applies them after the broker returns. Poll for drain. + ConsumerInfo aInfo = waitForAckPendingZero(stream, "consumer-a"); + ConsumerInfo bInfo = waitForAckPendingZero(stream, "consumer-b"); + + assertEquals( + 0L, + aInfo.getNumAckPending(), + "consumer-a numAckPending must drain to 0; was " + aInfo.getNumAckPending() + + " — indicates ack went to the wrong NATS handle"); + assertEquals( + 0L, + bInfo.getNumAckPending(), + "consumer-b numAckPending must drain to 0; was " + bInfo.getNumAckPending()); + + long lastSeq = connection + .jetStreamManagement() + .getStreamInfo(stream) + .getStreamState() + .getLastSequence(); + assertTrue( + aInfo.getAckFloor().getStreamSequence() >= lastSeq, + "consumer-a ackFloor should reach lastSeq=" + lastSeq + " but was " + + aInfo.getAckFloor().getStreamSequence()); + assertTrue( + bInfo.getAckFloor().getStreamSequence() >= lastSeq, + "consumer-b ackFloor should reach lastSeq=" + lastSeq + " but was " + + bInfo.getAckFloor().getStreamSequence()); + } + } + + private List pop( + JetStreamMessageBroker broker, QueueDetail q, String consumer, int expected) + throws Exception { + List all = new ArrayList<>(expected); + long deadline = System.currentTimeMillis() + 5000; + while (all.size() < expected && System.currentTimeMillis() < deadline) { + List batch = broker.pop(q, consumer, expected, Duration.ofMillis(500)); + all.addAll(batch); + } + return all; + } + + private ConsumerInfo waitForAckPendingZero(String stream, String consumer) throws Exception { + long deadline = System.currentTimeMillis() + 5000; + ConsumerInfo last = connection.jetStreamManagement().getConsumerInfo(stream, consumer); + while (last.getNumAckPending() > 0 && System.currentTimeMillis() < deadline) { + Thread.sleep(50L); + last = connection.jetStreamManagement().getConsumerInfo(stream, consumer); + } + return last; + } +} diff --git a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerRetryDlqIT.java b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerRetryDlqIT.java index 6a37ea67..3200aee0 100644 --- a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerRetryDlqIT.java +++ b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerRetryDlqIT.java @@ -24,7 +24,7 @@ class JetStreamMessageBrokerRetryDlqIT extends AbstractJetStreamIT { @Test void exhaustedMessage_landsOnDlqStream() throws Exception { String name = "rdq-" + System.nanoTime(); - QueueDetail q = mockQueue(name); + QueueDetail q = mockQueue(name, com.github.sonus21.rqueue.enums.QueueType.QUEUE, "worker"); RqueueNatsConfig cfg = RqueueNatsConfig.defaults(); cfg.getConsumerDefaults().setMaxDeliver(2); cfg.getConsumerDefaults().setAckWait(Duration.ofMillis(500)); From ec99465a0ab07152a78c169cd975eaaae59b3d0a Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Tue, 5 May 2026 16:16:45 +0530 Subject: [PATCH 17/19] nats-web: align Pending column with explorer + add Workers column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Subscribers-table refinements after the consumer-aware peek landed: 1. Pending column = outstanding work, not just yet-to-deliver. Previously the column showed numPending (yet-to-deliver), but clicking the consumer link opens the explorer with peek starting at ackFloor + 1 — which includes in-flight messages. Operators saw "Pending: 0 / In-flight: 15" and got 15 rows in the explorer, confusing the column meaning. Switch the per-row pending count to numPending + numAckPending so the column matches what the explorer renders. WorkQueue retention is unaffected — its msgCount already represents outstanding work. Same shift applied to the queue-level approximateLimitsPending: base on min(ackFloor.streamSeq) instead of min(delivered.streamSeq) so the queue-wide "size" agrees with per-consumer pending semantics. 2. Workers column on Subscribers table. The registry stores one heartbeat per (JVM, consumer) pair, so multi-instance deployments show >1; thread-level fanout from concurrency = "10-20" lives inside a single instance and is not reflected. Javadoc spells this out so operators don't expect a thread count. 3. Example listener restored to mode = QueueType.STREAM on both job-queue listeners. Without it, two distinct consumerNames on the same queue would land on a WorkQueue stream and trip NATS error 10099. The mode change makes the multi-listener fan-out demonstrable end-to-end (and matches the scenario the new ITs exercise). Assisted-By: Claude Code --- .../rqueue/models/response/SubscriberRow.java | 11 ++++ .../nats/js/JetStreamMessageBroker.java | 60 +++++++++++-------- .../rqueue/example/MessageListener.java | 7 +++ .../web/service/RqueueQDetailServiceImpl.java | 26 +++++++- .../templates/rqueue/queue_detail.html | 6 ++ 5 files changed, 84 insertions(+), 26 deletions(-) diff --git a/rqueue-core/src/main/java/com/github/sonus21/rqueue/models/response/SubscriberRow.java b/rqueue-core/src/main/java/com/github/sonus21/rqueue/models/response/SubscriberRow.java index c85fd1ed..4f21819f 100644 --- a/rqueue-core/src/main/java/com/github/sonus21/rqueue/models/response/SubscriberRow.java +++ b/rqueue-core/src/main/java/com/github/sonus21/rqueue/models/response/SubscriberRow.java @@ -73,6 +73,17 @@ public class SubscriberRow extends SerializableBase { /** Worker status: ACTIVE / STALE / UNKNOWN. */ private String status; + /** + * Number of distinct {@code (JVM, consumer)} heartbeats currently active in the worker + * registry — i.e. how many polling worker instances are attached to this consumer across + * the cluster. The row's {@link #host}/{@link #pid}/{@link #lastPollAt} fields describe + * only the most-recently polling instance, so the dashboard surfaces the total count + * separately. Thread-level fanout from {@code @RqueueListener(concurrency = "10-20")} + * lives inside a single instance and is not reflected here — the registry tracks + * heartbeats per JVM, not per thread. + */ + private int workerCount; + /** Host running the active worker, when known. */ private String host; diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java index 1bc85087..f247bba5 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java @@ -597,13 +597,14 @@ public long size(QueueDetail q) { io.nats.client.api.RetentionPolicy retention = info.getConfiguration() != null ? info.getConfiguration().getRetentionPolicy() : null; // WorkQueue retention removes messages on ack, so streamState.msgCount is the exact - // count of messages still pending consumption — the natural "pending size" for queue - // mode. For Limits retention msgCount is the total retained messages regardless of - // consumer progress, so we compute the worst-case pending from stream position math: - // pending ≈ lastSeq - min(consumer.delivered.streamSeq) - // which is the number of messages the slowest durable consumer has yet to receive. - // This is mathematically equivalent to max(consumer.numPending) but expressed via - // sequence offsets, which is what the user-facing "~ N" approximation reflects. + // count of outstanding work — the natural "pending size" for queue mode. For Limits + // retention msgCount is the total retained messages regardless of consumer progress, + // so we compute the worst-case outstanding work from stream position math: + // outstanding ≈ lastSeq - min(consumer.ackFloor.streamSeq) + // which is the messages the slowest durable consumer has not yet acked. This matches + // the per-consumer pending semantic in subscribers() (numPending + numAckPending) so + // the queue-level "size" and the per-row pending counts agree on what "outstanding" + // means. if (retention == io.nats.client.api.RetentionPolicy.Limits) { return approximateLimitsPending(stream, info); } @@ -614,10 +615,11 @@ public long size(QueueDetail q) { } /** - * Position-based pending estimate for a Limits-retention stream: - * {@code lastSeq - min(consumer.delivered.streamSeq)} across all durable consumers. - * Returns {@code msgCount} as a fallback when no consumers exist or the enumeration - * fails, so the dashboard never misses a non-zero queue. + * Position-based outstanding-work estimate for a Limits-retention stream: + * {@code lastSeq - min(consumer.ackFloor.streamSeq)} across all durable consumers — i.e. the + * size of the unacked window for the slowest consumer (counts both yet-to-deliver and + * delivered-but-unacked messages). Returns {@code msgCount} as a fallback when no consumers + * exist or the enumeration fails, so the dashboard never misses a non-zero queue. */ private long approximateLimitsPending(String stream, io.nats.client.api.StreamInfo info) { long lastSeq = info.getStreamState().getLastSequence(); @@ -627,29 +629,29 @@ private long approximateLimitsPending(String stream, io.nats.client.api.StreamIn try { List consumers = jsm.getConsumerNames(stream); if (consumers == null || consumers.isEmpty()) { - // No consumers attached: the entire retained range is "pending" from the perspective + // No consumers attached: the entire retained range is outstanding from the perspective // of any future consumer. Stream's msgCount is the right approximation. return info.getStreamState().getMsgCount(); } - long minDelivered = Long.MAX_VALUE; + long minAckFloor = Long.MAX_VALUE; for (String consumer : consumers) { try { io.nats.client.api.ConsumerInfo ci = jsm.getConsumerInfo(stream, consumer); - if (ci == null || ci.getDelivered() == null) { + if (ci == null || ci.getAckFloor() == null) { continue; } - long delivered = ci.getDelivered().getStreamSequence(); - if (delivered < minDelivered) { - minDelivered = delivered; + long ackFloor = ci.getAckFloor().getStreamSequence(); + if (ackFloor < minAckFloor) { + minAckFloor = ackFloor; } } catch (IOException | JetStreamApiException ignore) { // best-effort; skip consumers that disappear mid-walk } } - if (minDelivered == Long.MAX_VALUE) { + if (minAckFloor == Long.MAX_VALUE) { return info.getStreamState().getMsgCount(); } - return Math.max(0L, lastSeq - minDelivered); + return Math.max(0L, lastSeq - minAckFloor); } catch (IOException | JetStreamApiException ignore) { return info.getStreamState().getMsgCount(); } @@ -673,11 +675,16 @@ public boolean isSizeApproximate(QueueDetail q) { /** * Per-consumer subscriber view used by the queue-detail dashboard. Walks all durable - * consumers on the queue's stream and reports each one's pending + in-flight counts. - * For WorkQueue retention {@code pending} is the shared {@code msgCount} (every row - * shows the same number, marked {@code pendingShared = true}); for Limits retention - * {@code pending} is the consumer's exact {@code numPending}. {@code inFlight} is - * always the consumer's exclusive {@code numAckPending}. + * consumers on the queue's stream and reports each one's outstanding work + in-flight counts. + * + *

    Pending semantics. {@code pending} represents outstanding work — every + * message this consumer has not yet completed (acked) — so the column matches what the + * explorer renders when the operator clicks on the row. For WorkQueue retention this is the + * stream's shared {@code msgCount} (every row shows the same number, marked + * {@code pendingShared = true}). For Limits retention it is per-consumer + * {@code numPending + numAckPending}: the messages still to be delivered plus those delivered + * but not yet acked. {@code inFlight} is always the consumer's exclusive {@code numAckPending} + * — a strict subset of {@code pending} for Limits, useful for spotting stuck handlers. * *

    If consumer enumeration fails or the stream is unprovisioned, falls back to the * default single-row implementation so the dashboard still renders something useful. @@ -704,8 +711,11 @@ public java.util.List subscri if (ci == null) { continue; } - long pending = pendingIsShared ? sharedPending : ci.getNumPending(); long inFlight = ci.getNumAckPending(); + // Outstanding work: yet-to-deliver + delivered-but-unacked. Matches what the + // explorer pulls when the operator clicks the consumer link (peek bases on + // ackFloor + 1, which spans both buckets). + long pending = pendingIsShared ? sharedPending : ci.getNumPending() + inFlight; out.add(new com.github.sonus21.rqueue.core.spi.SubscriberView( consumer, pending, inFlight, pendingIsShared)); } catch (IOException | JetStreamApiException ignore) { diff --git a/rqueue-spring-boot-nats-example/src/main/java/com/github/sonus21/rqueue/example/MessageListener.java b/rqueue-spring-boot-nats-example/src/main/java/com/github/sonus21/rqueue/example/MessageListener.java index 38b5cd7c..70b0b923 100644 --- a/rqueue-spring-boot-nats-example/src/main/java/com/github/sonus21/rqueue/example/MessageListener.java +++ b/rqueue-spring-boot-nats-example/src/main/java/com/github/sonus21/rqueue/example/MessageListener.java @@ -17,6 +17,7 @@ package com.github.sonus21.rqueue.example; import com.github.sonus21.rqueue.annotation.RqueueListener; +import com.github.sonus21.rqueue.enums.QueueType; import com.github.sonus21.rqueue.utils.TimeoutUtils; import java.util.Random; import lombok.extern.slf4j.Slf4j; @@ -63,11 +64,16 @@ public void onSimpleMessage(String message) { execute("simple: {}", message, false); } + // Two listeners on the same queue with distinct consumerNames — exercises multi-listener + // fan-out. mode = QueueType.STREAM makes the underlying JetStream stream Limits-retention so + // both consumers can coexist (NATS rejects multiple non-filtered consumers on a WorkQueue + // stream with error 10099). @RqueueListener( value = "job-queue", deadLetterQueue = "job-queue-linkedin-dlq", numRetries = "2", concurrency = "10-20", + mode = QueueType.STREAM, consumerName = "linkedin-search") public void onJobMessage(Job job) { execute("job-queue-linkedin: {}", job, true); @@ -78,6 +84,7 @@ public void onJobMessage(Job job) { numRetries = "2", deadLetterQueue = "job-queue-google-dlq", concurrency = "10-20", + mode = QueueType.STREAM, consumerName = "google-search") public void onJobMessageGooglSearch(Job job) { execute("job-queue-google: {}", job, true); diff --git a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java index dd81b222..8f15d382 100644 --- a/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java +++ b/rqueue-web/src/main/java/com/github/sonus21/rqueue/web/service/RqueueQDetailServiceImpl.java @@ -668,6 +668,7 @@ public List getSubscriberRows(QueueConfig queueConfig) { String label = brokerLabel(NavTab.PENDING, DataType.LIST); Map workersByConsumer = indexWorkersByConsumer(queueConfig.getName()); + Map workerCountByConsumer = countWorkersByConsumer(queueConfig.getName()); List rows = new ArrayList<>(views.size()); long now = System.currentTimeMillis(); for (SubscriberView v : views) { @@ -678,7 +679,8 @@ public List getSubscriberRows(QueueConfig queueConfig) { .dataType(DataType.LIST) .pending(v.pending()) .pendingShared(v.pendingShared()) - .inFlight(v.inFlight()); + .inFlight(v.inFlight()) + .workerCount(workerCountByConsumer.getOrDefault(v.consumerName(), 0)); RqueueWorkerPollerView w = workersByConsumer.get(v.consumerName()); if (w != null) { builder @@ -695,6 +697,28 @@ public List getSubscriberRows(QueueConfig queueConfig) { return rows; } + /** + * Count the live worker threads bucketed by {@code consumerName}. Mirrors the bucketing that + * {@link #indexWorkersByConsumer(String)} does but keeps the count instead of collapsing to + * the most-recently polling worker — surfaces the {@code @RqueueListener.concurrency} fanout + * separately from the row's representative worker (host / pid / lastPollAt). + */ + private Map countWorkersByConsumer(String queueName) { + List workers = rqueueWorkerRegistry.getQueueWorkers(queueName); + if (CollectionUtils.isEmpty(workers)) { + return Collections.emptyMap(); + } + Map out = new HashMap<>(); + for (RqueueWorkerPollerView w : workers) { + String key = w.getConsumerName(); + if (key == null || key.isEmpty()) { + continue; + } + out.merge(key, 1, Integer::sum); + } + return out; + } + private List brokerSubscribers( QueueConfig queueConfig, QueueDetail brokerQueueDetail) { if (brokerQueueDetail != null && messageBroker != null) { diff --git a/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html b/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html index c0da8279..780a711b 100644 --- a/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html +++ b/rqueue-web/src/main/resources/templates/rqueue/queue_detail.html @@ -111,6 +111,7 @@

    Subscribers {{ subscribers | Storage Pending In-Flight + Workers Status Host Last Poll @@ -136,6 +137,11 @@

    Subscribers {{ subscribers | {% if sub.pendingShared %}shared{% endif %} {{sub.inFlight}} + + {% if sub.workerCount > 0 %} + {{sub.workerCount}} + {% else %}{% endif %} + {% if sub.status %} {{sub.status}} From deb10409923e302f865407e25cc80c5f29c166ea Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Tue, 5 May 2026 16:16:59 +0530 Subject: [PATCH 18/19] nats: tests for consumer-aware peek + adapt QueueModeIT to new contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two test changes: 1. New JetStreamMessageBrokerConsumerAwarePeekIT covers the SPI overload peek(QueueDetail, consumerName, offset, count) on a Limits-retention stream with two durables at different ack positions. Asserts: - per-consumer peek for the fast consumer skips the acked range and returns only the un-acked tail - per-consumer peek for the untouched consumer returns the full stream (its ackFloor is 0) - the no-consumer overload bases on the stream's first sequence and is unaffected by per-consumer state 2. JetStreamQueueModeIT updated for the new (consumerName, id) inFlight keying contract. Three tests touched, all swap mockQueue(name, type) for mockQueue(name, type, consumerName) so q.resolvedConsumerName() matches what the test passes to broker.pop(...). Without the matching name, ack/nack lookups miss, the consumer's delivery-position assertions break, and consumerReuse falsely reports "5 messages remaining instead of 2." The fan-out test was also restructured to use one QueueDetail per listener — mirrors how production builds a separate QueueDetail per @RqueueListener with its own resolvedConsumerName. Assisted-By: Claude Code --- ...treamMessageBrokerConsumerAwarePeekIT.java | 131 ++++++++++++++++++ .../rqueue/nats/JetStreamQueueModeIT.java | 20 ++- 2 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerConsumerAwarePeekIT.java diff --git a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerConsumerAwarePeekIT.java b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerConsumerAwarePeekIT.java new file mode 100644 index 00000000..d8583a2d --- /dev/null +++ b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamMessageBrokerConsumerAwarePeekIT.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026 Sonu Kumar + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * You may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + */ +package com.github.sonus21.rqueue.nats; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.github.sonus21.rqueue.core.RqueueMessage; +import com.github.sonus21.rqueue.enums.QueueType; +import com.github.sonus21.rqueue.listener.QueueDetail; +import com.github.sonus21.rqueue.nats.js.JetStreamMessageBroker; +import java.time.Duration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +/** + * Covers {@link com.github.sonus21.rqueue.core.spi.MessageBroker#peek(QueueDetail, String, long, + * long)} on a Limits-retention stream with two durable consumers at different progress levels. + * + *

    The dashboard explorer wires the {@code consumerName} so each row's "browse" action shows + * messages still outstanding for that specific subscriber instead of the entire retained + * window. This test asserts that contract: + * + *

      + *
    • consumer-fast pops + acks the first half of the stream, advancing its {@code ackFloor}. + *
    • consumer-slow does nothing, so its {@code ackFloor} stays at 0. + *
    • {@code peek(q, "consumer-fast", 0, total)} returns only the second half (skips acked). + *
    • {@code peek(q, "consumer-slow", 0, total)} returns the whole stream. + *
    • {@code peek(q, null, 0, total)} also returns the whole stream — the no-consumer + * overload bases on the stream's first sequence and is unchanged by per-consumer state. + *
    + */ +@NatsIntegrationTest +class JetStreamMessageBrokerConsumerAwarePeekIT extends AbstractJetStreamIT { + + @Test + void peek_skipsAlreadyAckedRangeForSpecificConsumer() throws Exception { + String name = "cap-" + System.nanoTime(); + QueueDetail enqueueFacet = mockQueue(name, QueueType.STREAM); + QueueDetail qFast = mockQueue(name, QueueType.STREAM, "consumer-fast"); + QueueDetail qSlow = mockQueue(name, QueueType.STREAM, "consumer-slow"); + int total = 8; + int firstHalf = total / 2; + + RqueueNatsConfig cfg = RqueueNatsConfig.defaults(); + try (JetStreamMessageBroker broker = + JetStreamMessageBroker.builder().connection(connection).config(cfg).build()) { + // Enqueue first to create the stream — pop's ensureConsumer needs the stream to exist. + // DeliverPolicy.All on the consumer means it starts at the stream's first sequence, so + // both durables created after the publish still see every message. + for (int i = 0; i < total; i++) { + broker.enqueue( + enqueueFacet, RqueueMessage.builder().id("m-" + i).message("p" + i).build()); + } + + // Drain the first half on consumer-fast — pop + ack so its ackFloor advances. + Set fastSeen = new HashSet<>(); + long deadline = System.currentTimeMillis() + 5000; + while (fastSeen.size() < firstHalf && System.currentTimeMillis() < deadline) { + List batch = + broker.pop(qFast, "consumer-fast", firstHalf - fastSeen.size(), Duration.ofMillis(500)); + for (RqueueMessage m : batch) { + if (fastSeen.add(m.getId())) { + assertTrue(broker.ack(qFast, m), "ack must succeed for " + m.getId()); + } + } + } + assertEquals(firstHalf, fastSeen.size(), "consumer-fast should have drained first half"); + // Wait for the server to apply the acks before peeking — getConsumerInfo's ackFloor is + // observed asynchronously after nm.ack(). + waitForAckFloorAtLeast(cfg.getStreamPrefix() + name, "consumer-fast", firstHalf); + + // Per-consumer peek for consumer-fast must SKIP the acked range and return only the + // second half (msgs whose stream seq > ackFloor). + List fastPeek = broker.peek(qFast, "consumer-fast", 0, total); + Set fastIds = fastPeek.stream().map(RqueueMessage::getId).collect(Collectors.toSet()); + assertEquals( + total - firstHalf, + fastPeek.size(), + "consumer-fast peek should return only the un-acked tail; got " + fastIds); + for (String acked : fastSeen) { + assertFalse( + fastIds.contains(acked), + "consumer-fast peek must not include already-acked id=" + acked); + } + + // Per-consumer peek for consumer-slow should still see every message — its ackFloor is 0. + List slowPeek = broker.peek(qSlow, "consumer-slow", 0, total); + assertEquals( + total, + slowPeek.size(), + "consumer-slow peek should return the full stream — its ackFloor hasn't advanced"); + + // No-consumer peek behaves identically regardless of per-consumer progress — bases on + // the stream's first sequence (this is the legacy 2-arg overload's contract). + List globalPeek = broker.peek(qFast, 0, total); + assertEquals( + total, + globalPeek.size(), + "global (no-consumer) peek should ignore per-consumer ackFloor"); + } + } + + private void waitForAckFloorAtLeast(String stream, String consumer, long minStreamSeq) + throws Exception { + long deadline = System.currentTimeMillis() + 5000; + while (System.currentTimeMillis() < deadline) { + io.nats.client.api.ConsumerInfo ci = + connection.jetStreamManagement().getConsumerInfo(stream, consumer); + if (ci != null + && ci.getAckFloor() != null + && ci.getAckFloor().getStreamSequence() >= minStreamSeq) { + return; + } + Thread.sleep(50L); + } + throw new AssertionError( + "Timed out waiting for " + consumer + " ackFloor to reach streamSeq " + minStreamSeq); + } +} diff --git a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamQueueModeIT.java b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamQueueModeIT.java index fc9917a0..b80ddfeb 100644 --- a/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamQueueModeIT.java +++ b/rqueue-nats/src/test/java/com/github/sonus21/rqueue/nats/JetStreamQueueModeIT.java @@ -95,8 +95,8 @@ void queueMode_stream_createsLimitsStream() throws Exception { */ @Test void queueMode_consumerReuse_preservesDeliveryPosition() throws Exception { - QueueDetail q = mockQueue("qm-reuse-" + System.nanoTime(), QueueType.QUEUE); String consumerName = "c1-reuse"; + QueueDetail q = mockQueue("qm-reuse-" + System.nanoTime(), QueueType.QUEUE, consumerName); int total = 5; int firstBatch = 3; @@ -156,7 +156,8 @@ void queueMode_consumerReuse_preservesDeliveryPosition() throws Exception { @Test void queueMode_queue_competingConsumers_eachMessageDeliveredOnce() throws Exception { - QueueDetail q = mockQueue("qm-cc-" + System.nanoTime(), QueueType.QUEUE); + String sharedConsumer = "shared-cc"; + QueueDetail q = mockQueue("qm-cc-" + System.nanoTime(), QueueType.QUEUE, sharedConsumer); int total = 20; try (JetStreamMessageBroker broker = @@ -167,7 +168,6 @@ void queueMode_queue_competingConsumers_eachMessageDeliveredOnce() throws Except Set seen = ConcurrentHashMap.newKeySet(); CountDownLatch done = new CountDownLatch(total); - String sharedConsumer = "shared-cc"; var pool = Executors.newFixedThreadPool(2); for (int t = 0; t < 2; t++) { pool.submit(() -> { @@ -194,19 +194,25 @@ void queueMode_queue_competingConsumers_eachMessageDeliveredOnce() throws Except @Test void queueMode_stream_fanOut_everyConsumerReceivesAllMessages() throws Exception { - QueueDetail q = mockQueue("qm-fo-" + System.nanoTime(), QueueType.STREAM); + String name = "qm-fo-" + System.nanoTime(); + // Same stream, two QueueDetail facets — one per @RqueueListener with its own + // resolvedConsumerName so ack/nack key on the right (consumer, id) pair. + QueueDetail enqueueFacet = mockQueue(name, QueueType.STREAM); + QueueDetail q1 = mockQueue(name, QueueType.STREAM, "listener-svc-1"); + QueueDetail q2 = mockQueue(name, QueueType.STREAM, "listener-svc-2"); int total = 8; try (JetStreamMessageBroker broker = JetStreamMessageBroker.builder().connection(connection).build()) { for (int i = 0; i < total; i++) { - broker.enqueue(q, RqueueMessage.builder().id("fo-" + i).message("p" + i).build()); + broker.enqueue( + enqueueFacet, RqueueMessage.builder().id("fo-" + i).message("p" + i).build()); } // Each listener group uses a distinct consumer name — they are independent on a // Limits-retention stream and each track their own delivery position. - Set listenerOneSeen = drain(broker, q, "listener-svc-1", total); - Set listenerTwoSeen = drain(broker, q, "listener-svc-2", total); + Set listenerOneSeen = drain(broker, q1, "listener-svc-1", total); + Set listenerTwoSeen = drain(broker, q2, "listener-svc-2", total); assertEquals( total, listenerOneSeen.size(), "STREAM mode: listener-svc-1 must receive all messages"); From 1fc9693e5775cb0738d2e75c3b27a6e7c08535b4 Mon Sep 17 00:00:00 2001 From: Sonu Kumar Date: Tue, 5 May 2026 16:23:25 +0530 Subject: [PATCH 19/19] nats-web: keep Pending column as numPending (yet-to-deliver only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier ec99465 changed JetStreamMessageBroker.subscribers() to report pending = numPending + numAckPending so the Pending column would equal the explorer's row count. That collapsed the per-row pending vs in-flight split and made NATS disagree with Redis on what "Pending" means. The Subscribers table already has a separate In-Flight column, so the operator can see both numbers at a glance. Restoring pending to numPending keeps the columns disjoint (yet-to-deliver vs being- processed), aligns NATS Limits behaviour with the Redis backend's LIST-vs-processing-ZSET split, and makes the Pending column match NATS CLI's "Unprocessed" report. The columns and the explorer answer different questions on purpose: columns split outstanding work into pending + in-flight; clicking the consumer link browses everything outstanding (peek bases on ackFloor + 1, which spans both buckets). Operators see a 0-pending / 15-in-flight row and click through to see the 15 in-flight messages, which was the original UX motivation for consumer-aware peek. The queue-level approximateLimitsPending stays based on min(ackFloor.streamSeq) — that's the queue's total outstanding work, which is the right size for the queue listing. Assisted-By: Claude Code --- .../nats/js/JetStreamMessageBroker.java | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java index f247bba5..8a17cb4e 100644 --- a/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java +++ b/rqueue-nats/src/main/java/com/github/sonus21/rqueue/nats/js/JetStreamMessageBroker.java @@ -675,16 +675,18 @@ public boolean isSizeApproximate(QueueDetail q) { /** * Per-consumer subscriber view used by the queue-detail dashboard. Walks all durable - * consumers on the queue's stream and reports each one's outstanding work + in-flight counts. + * consumers on the queue's stream and reports each one's pending + in-flight counts as + * separate columns — same split as the Redis backend's processing ZSET vs ready LIST. * - *

    Pending semantics. {@code pending} represents outstanding work — every - * message this consumer has not yet completed (acked) — so the column matches what the - * explorer renders when the operator clicks on the row. For WorkQueue retention this is the - * stream's shared {@code msgCount} (every row shows the same number, marked - * {@code pendingShared = true}). For Limits retention it is per-consumer - * {@code numPending + numAckPending}: the messages still to be delivered plus those delivered - * but not yet acked. {@code inFlight} is always the consumer's exclusive {@code numAckPending} - * — a strict subset of {@code pending} for Limits, useful for spotting stuck handlers. + *

    Pending semantics. {@code pending} is yet-to-deliver work for this consumer. + * For WorkQueue retention this is the stream's shared {@code msgCount} (every row shows the + * same number, marked {@code pendingShared = true}); for Limits retention it is the + * consumer's exact {@code numPending}. {@code inFlight} is always the consumer's exclusive + * {@code numAckPending}: messages delivered but not yet acked. The two are disjoint — + * {@code pending} excludes anything currently in flight — so an operator reading the row + * sees the work split between "still to dispatch" and "currently being processed". Total + * outstanding work for the consumer is the sum of the two, which is what the explorer + * surfaces when the operator clicks the consumer link. * *

    If consumer enumeration fails or the stream is unprovisioned, falls back to the * default single-row implementation so the dashboard still renders something useful. @@ -711,11 +713,8 @@ public java.util.List subscri if (ci == null) { continue; } + long pending = pendingIsShared ? sharedPending : ci.getNumPending(); long inFlight = ci.getNumAckPending(); - // Outstanding work: yet-to-deliver + delivered-but-unacked. Matches what the - // explorer pulls when the operator clicks the consumer link (peek bases on - // ackFloor + 1, which spans both buckets). - long pending = pendingIsShared ? sharedPending : ci.getNumPending() + inFlight; out.add(new com.github.sonus21.rqueue.core.spi.SubscriberView( consumer, pending, inFlight, pendingIsShared)); } catch (IOException | JetStreamApiException ignore) {