Skip to content

Commit 6074743

Browse files
feat(servicebus): add listSessions to session receiver clients (#48956)
* feat(servicebus): add listSessions to session receiver clients - Add OPERATION_GET_MESSAGE_SESSIONS and related constants - Add getMessageSessions to ServiceBusManagementNode interface - Implement AMQP request/response in ManagementChannel with year-cap and 404 handling - Add listSessions() overloads on ServiceBusSessionReceiverAsyncClient with Flux pagination - Add sync wrappers on ServiceBusSessionReceiverClient - Add unit tests for request, response, 204, 404, year-cap, and active-sessions mode * fix: simplify pagination with takeWhile + map for skip tracking * fix(servicebus): address review feedback on listSessions - ManagementChannel: validate lastUpdatedTime is non-null and cap year-9999 sentinel to UTC so the wire timestamp matches DateTime.MaxValue regardless of source offset. - ServiceBusManagementNode: drop fully-qualified types in signature; document the UTC sentinel in JavaDoc. - ServiceBusSessionReceiverAsyncClient: use fluxError(LOGGER,...) for null updatedAfter, drop unused imports. - ManagementChannelTests: set the SESSION_NOT_FOUND error-condition so the 404 test exercises the actual sendWithVerify pass-through path; remove unused List import. - Apply spotless formatting. * fix(servicebus): align listSessions with Track 1 semantics + use PagedFlux Round 2 review feedback. Aligns with Track 1's SessionBrowser/MiscRequestResponseOperationHandler so the implementation tracks proven service behaviour, and adopts PagedFlux/PagedIterable per Azure SDK guidelines for paginated collection APIs. Service-side alignment: - Active-messages sentinel now matches Track 1's SessionBrowser.MAXDATE (new Date(253402300800000L), 10000-01-01T00:00:00Z UTC). The wire value is the one the broker has been validated against for years; using 9999-12-31T23:59:59.999Z (1 ms earlier) risked the broker not switching into active-messages mode. - Pagination cursor now uses the server-returned skip plus the last session ID of the previous page, matching Track 1. Track 2 previously incremented skip locally and always passed lastSessionId=null, which can skip or duplicate sessions when the broker filters expired entries between pages. Surface change: - ServiceBusManagementNode.getMessageSessions now returns Mono<MessageSessionsResult> (sessionIds + nextSkip) so callers can thread the cursor. - listSessions / listSessions(OffsetDateTime) now return PagedFlux<String> (async) and PagedIterable<String> (sync), matching the Azure SDK conventions enforced by the ServiceClient checkstyle rule. - Renamed ManagementConstants.SESSIONS_IDS to SESSION_IDS (wire value 'sessions-ids' unchanged) so the constant reads consistently with SESSION_ID and SEQUENCE_NUMBERS. Tests: - Updated assertions to MessageSessionsResult and the Track 1 sentinel value (253402300800000L). - Added getMessageSessionsHonoursServerReturnedSkip covering the case where the server returns a skip greater than requestSkip + page.size(). * fix(servicebus): validate listSessions continuation tokens + spelling Round 4 review feedback. - listSessionsInternal now wraps continuation-token parsing so a caller passing an invalid or opaque token via PagedFlux.byPage(String) sees a clear monoError(LOGGER, IllegalArgumentException) instead of NumberFormatException / StringIndexOutOfBoundsException. - Renamed getMessageSessionsHonoursServerReturnedSkip to getMessageSessionsHonorsServerReturnedSkip to match the American-English spelling used in the rest of the module. * fix(servicebus): harden listSessions response parsing + cursor encoding Round 5 review feedback. - ManagementChannel.getMessageSessions now defensively guards the AmqpValue body so a malformed broker/proxy response (null body, non-AmqpValue, non-Map value) returns an empty page instead of NPE/CCE breaking pagination. - readResponseSkip now takes the parsed page size and falls back to requestSkip + pageSize when the server omits the skip field, so a malformed response advances the cursor instead of stalling on the same value. - listSessionsInternal cursor encoding switched to <decimal-skip>|<base64url-utf8(lastSessionId)>. The URL-safe Base64 alphabet does not contain '|', so any byte sequence in the session ID round-trips without escaping. Decode failures now also surface through monoError(LOGGER, IllegalArgumentException) with descriptive messages (no embedded control characters). - ACTIVE_MESSAGES_SENTINEL moved to ManagementConstants so the implementation and the public client reference a single source of truth for the broker-contract sentinel. * test(servicebus): cover listSessions cursor + sync delegation paths Round 6 review feedback: add unit coverage for the new listSessions APIs. ServiceBusSessionReceiverAsyncClientTest: - listSessionsActiveModeStreamsAllPagesUntilEmpty: verifies the no-arg overload uses ManagementConstants.ACTIVE_MESSAGES_SENTINEL and walks every page until the broker returns an empty page. - listSessionsHonorsServerSkipAndLastSessionId: verifies the cursor uses the server-returned skip and lastSessionId of the previous page (Track 1 SessionBrowser semantics, even when the broker filters entries between pages). - listSessionsRejectsNullUpdatedAfter: verifies null updatedAfter surfaces as NullPointerException on subscription. - listSessionsRoundTripsArbitrarySessionIdsThroughCursor: verifies a session ID containing the cursor separator '|' round-trips correctly thanks to base64url encoding. - listSessionsRejectsInvalidContinuationToken: verifies PagedFlux.byPage(invalid) surfaces IllegalArgumentException. ServiceBusSessionReceiverClientTest: - listSessionsDelegatesToAsync, listSessionsWithUpdatedAfterDelegatesToAsync, listSessionsPropagatesAsyncError: verify the sync overloads delegate to the async client, thread the timestamp through, and surface async errors during iteration. * fix(servicebus): drop unreachable null check in listSessions cursor lambda Round 7 review feedback. PagedFlux only invokes the next-page function when the previous PagedResponse carried a non-null continuation token (byPage(null) is routed to the first-page supplier instead), so the lambda never receives a null token. Removed the dead branch and added a comment explaining the contract. * fix(servicebus): tighten listSessions argument + cursor validation Round 8 review feedback. - ServiceBusSessionReceiverClient.listSessions(OffsetDateTime) now validates updatedAfter via Objects.requireNonNull eagerly (matching the JavaDoc), so the NullPointerException surfaces on the call instead of being deferred until the PagedIterable is iterated. New test listSessionsRejectsNullUpdatedAfterEagerly covers this. - listSessionsInternal now rejects continuation tokens whose decoded skip is negative with a logged IllegalArgumentException, so byPage("-1|...") fails fast instead of sending an invalid management request. New test listSessionsRejectsNegativeSkipInContinuationToken covers this. - MessageSessionsResult JavaDoc now explicitly notes it is internal (the public modifier is required only because ServiceBusManagementNode.getMessageSessions is consumed from a different package). The implementation/ package signals internal status in this repo by convention. * fix(servicebus): validate listSessions paging args + harden response skip Round 9 review feedback. - ManagementChannel.getMessageSessions now rejects negative skip and non-positive top with monoError(logger, IllegalArgumentException) before building the management request, so an invalid call fails fast instead of producing a confusing broker error. Added test getMessageSessionsRejectsInvalidPagingArgs covering skip=-1, top=0, top=-10. - readResponseSkip now reads the server-returned skip as a long, requires 0 <= responseSkip <= Integer.MAX_VALUE, and falls back to a saturating requestSkip + pageSize otherwise. This protects against a negative or out-of-range broker value wrapping the cursor into a negative int. Extracted the fallback into computeFallbackSkip so the saturation logic is shared and individually testable. Added test getMessageSessionsFallsBackOnInvalidServerSkip covering the negative-skip case. * fix(servicebus): preserve empty-string lastSessionId cursor Round 10 review feedback. - ManagementChannel.getMessageSessions now uses `lastSessionId != null` to decide whether to forward the `last-session-id` cursor, instead of `!CoreUtils.isNullOrEmpty(...)` which collapsed both null and "". Service Bus permits an empty session ID, so an empty-string cursor must round-trip the same way any other ID does. Otherwise pagination would silently restart from the first page when the previous page ended on an empty-ID session. Added test getMessageSessionsForwardsEmptyLastSessionIdAsCursor verifying the empty string is sent through to the broker rather than being omitted. * fix(servicebus): accept Iterable session-IDs payload + tighten fallback comment Round 11 review feedback. - ManagementChannel.getMessageSessions response parsing now also accepts Iterable<?> (List, Set, ...) in addition to Object[] for the sessions-ids payload. Previously, if a future broker or codec change surfaced the payload as a List the code would silently produce an empty page and prematurely terminate pagination. Added test getMessageSessionsParsesIterableSessionIds feeding a List as the payload and asserting both items + cursor round-trip. - computeFallbackSkip JavaDoc tightened: requestSkip is validated as non-negative at the call site and the addition runs in long, so the only thing to guard against is overflow past Integer.MAX_VALUE. Dropped the "wraps negative" branch (and its now-impossible <= 0 clamp) since it could never trigger. * fix(servicebus): use FluxUtil.pagedFluxError for listSessions null guard Round 12 review feedback. Switched the null-updatedAfter guard in ServiceBusSessionReceiverAsyncClient.listSessions(OffsetDateTime) from `new PagedFlux<>(() -> monoError(LOGGER, ...))` to the standard helper `pagedFluxError(LOGGER, ...)` from FluxUtil. Same observable behaviour for callers (the PagedFlux errors with NullPointerException on subscribe), but matches the convention every other Azure SDK PagedFlux argument-validation site uses. * test(servicebus): isolate getMessageSessionsNotFound from shared state Round 13 review feedback. Use a local copy of the shared applicationProperties field when adding the SessionNotFound error-condition so the entry doesn't leak into subsequent 404 tests that share the field. The setup() method initializes applicationProperties with STATUS_CODE=OK and never resets it between tests, so a put() inside this test would have permanently mutated the field. * fix(servicebus): tighten timestamp assertion + sentinel comparison contract Round 14 review feedback. - ManagementChannel.getMessageSessions: changed the sentinel cap comparison from `> 0` to `>= 0` so the comment ("at or beyond that instant is clamped") matches the code. Equality is a no-op (clamping to the same value), but routing it through the clamp explicitly keeps the documented contract precise. - ManagementChannelTests.getMessageSessionsUpdatedAfterMode: the assertion only checked that the wire timestamp was a Date instance. Tightened to assertEquals(Date.from(lastUpdated.toInstant()), ...) so a regression in the OffsetDateTime -> Date conversion (e.g., a rounding bug or wrong zone offset) would actually fail the test. * fix(servicebus): strict UTF-8 cursor decoding + clearer async-error test Round 15 review feedback. - ServiceBusSessionReceiverAsyncClient.listSessionsInternal now uses a strict UTF-8 CharsetDecoder (CodingErrorAction.REPORT for malformed and unmappable input) when decoding the base64url-encoded lastSessionId payload. Previously, new String(decoded, UTF_8) would silently substitute U+FFFD for invalid UTF-8 sequences, which could send a corrupted session ID to the broker as the cursor. Decode failures now surface as logged IllegalArgumentException with a clear "not valid UTF-8" message. Added test listSessionsRejectsNonUtf8ContinuationToken (token "0|__4" decodes to bytes 0xFF 0xFE, which isn't valid UTF-8). - ServiceBusSessionReceiverClientTest.listSessionsPropagatesAsyncError: switched the stub failure from NullPointerException("'updatedAfter' cannot be null.") to RuntimeException("Transient async failure."). The test passes a non-null updatedAfter, so the previous exception type and message were misleading — they suggested the test was checking the eager null-validation path (which has its own dedicated test). The new wording makes it clear that this test is about an unrelated transient async failure propagating through the PagedIterable iteration. * fix(servicebus): allocate fresh HttpHeaders per listSessions page Round 16 review feedback. Replaced the shared static EMPTY_HEADERS field with a per-response `new HttpHeaders()` allocation in ServiceBusSessionReceiverAsyncClient.listSessionsInternal. HttpHeaders is mutable and PagedResponseBase#getHeaders() exposes the stored reference, so a static shared instance could leak mutations across every page and every client globally. The per-page allocation is negligible and matches the pattern used elsewhere in the SDK. * fix(servicebus): wrap listSessions page fetch in tracing span Round 17 review feedback. Wrapped fetchSessionPage in tracer.traceMono("ServiceBus.listSessions", ...) so each page fetch emits a distributed-tracing span. This matches the tracing pattern used by acceptSession and acceptNextSession in the same client and makes the paged listSessions/listSessions(updatedAfter) calls observable in distributed traces. * docs(servicebus): add CHANGELOG entry for listSessions feature Round 18 review feedback. Added an entry under "7.18.0-beta.2 (Unreleased) > Features Added" in sdk/servicebus/azure-messaging-servicebus/CHANGELOG.md describing the new listSessions API on both async and sync session receiver clients, and referencing PR #48956. * test(servicebus): correct getMessageSessionsNotFound rationale comment Round 19 review feedback. JUnit 5's default lifecycle is PER_METHOD (no @testinstance(PER_CLASS) on this class), so the applicationProperties field is recreated per test instance and a put() couldn't actually leak into subsequent tests. The local-copy approach is still useful for keeping this test's response setup self-contained, so the code stays; only the rationale comment is corrected to match the actual lifecycle. * docs(servicebus): document active-messages sentinel clamp on listSessions Round 20 review feedback. Added javadoc to listSessions(OffsetDateTime) on both ServiceBusSessionReceiverAsyncClient and ServiceBusSessionReceiverClient explaining that values at or beyond the active-messages sentinel (10000-01-01T00:00:00Z UTC, matching Track 1's SessionBrowser.MAXDATE) are clamped to that sentinel and behave the same as the no-arg listSessions() overload. The clamp is implemented in ManagementChannel and applies to both async and sync paths (sync delegates to async). * fix(servicebus): address round 21 review feedback on listSessions Round 21 review feedback batch: - ManagementChannel.getMessageSessions: reject null entries in the sessions-ids array/iterable with IllegalStateException instead of surfacing the literal string "null" as a session ID via String.valueOf. - ServiceBusManagementNode.getMessageSessions javadoc: removed the .NET- specific phrasing ("DateTime.MaxValue + 1ms") and replaced it with the Track 1 Java sentinel reference (new Date(253402300800000L)). - ManagementChannelTests.getMessageSessionsActiveSessionsMode: renamed to getMessageSessionsActiveMessagesMode for terminology consistency with the broker mode the test exercises. - ServiceBusSessionReceiverClientTest.listSessionsRejectsNullUpdatedAfter Eagerly: spelling "behaviour" -> "behavior" (American English). - Added ManagementChannelTests.getMessageSessionsRejectsNullSessionIdEntry to cover the new null-entry rejection path. * test(servicebus): use local copy in getMessageSessionsNoContent Round 22 review feedback. Mirror the round-19 fix: use a local HashMap<>(applicationProperties) in getMessageSessionsNoContent instead of mutating the shared field in-place. This matches the pattern already used by getMessageSessionsNotFound and keeps the test's response setup self-contained. (JUnit 5's default PER_METHOD lifecycle means the field is recreated per test instance, so a put() on the shared map can't actually leak across tests; the local-copy approach is purely for in-test self-containment.) * fix(servicebus): treat empty listSessions continuation token as end of pages Round 23 review feedback. listSessionsInternal now returns Mono.empty() when the continuation token passed to PagedFlux's next-page function is an empty string, matching the convention used by ServiceBusAdministrationAsyncClient.listQueuesNextPage and listRulesNextPage in this same package (and the wider Azure SDK paging contract: null or empty token = no more pages). This makes the API tolerant of callers that persist the token to storage and read back an empty string. Added test listSessionsEmptyContinuationTokenCompletes to lock the behavior in. Existing invalid-token tests still pass. * feat(servicebus): propagate caller page size to listSessions broker request Round 24 review feedback. Switch listSessionsInternal to the page-size- aware PagedFlux constructor (Function<Integer, ...>, BiFunction<String, Integer, ...>) so a caller's byPage(int) value flows through to the management request's `top` parameter. When the caller doesn't request a specific size (or requests a non-positive value), fall back to the default of 100. Renamed LIST_SESSIONS_PAGE_SIZE to LIST_SESSIONS_DEFAULT_PAGE_SIZE to reflect its new role and added a helper resolvePageSize(Integer). Also documented the page-size behavior on the public listSessions overloads of both ServiceBusSessionReceiverAsyncClient (PagedFlux) and ServiceBusSessionReceiverClient (PagedIterable). Added test ServiceBusSessionReceiverAsyncClientTest.listSessionsHonors CallerPageSize: caller asks for 25-item pages, mock asserts the top=25 flowed through to both first-page and next-page management calls. * docs(servicebus): correct listSessionsInternal byPage comment Round 25 review feedback. The comment on the PagedFlux constructor in listSessionsInternal incorrectly stated that byPage(null) is routed to the first-page supplier; in azure-core, byPage(null) actually returns Flux.empty() (see ContinuablePagedFluxCore.byPage(C)). The comment also overlooked that the next-page retriever can be invoked directly via byPage(token) without any previous PagedResponse, so it must validate the token it receives. Comment-only change; behavior is unchanged. * docs(servicebus): clarify active-messages sentinel literal in javadoc Round 26 review feedback. Java's OffsetDateTime.toString() renders the year-10000 sentinel as "+10000-01-01T00:00Z" - the leading "+" is required by ISO 8601 for years with more than four digits, so the previous "10000-01-01T00:00:00Z" literal in the javadoc would not be parseable via OffsetDateTime.parse(). Updated the docs to reference the ManagementConstants.ACTIVE_MESSAGES_SENTINEL constant and the actual +10000-01-01T00:00Z literal so callers don't get tripped up trying to parse the documented value. Comment-only change across: - ManagementConstants (the constant's own javadoc) - ServiceBusSessionReceiverAsyncClient (listSessions(OffsetDateTime)) - ServiceBusSessionReceiverClient (listSessions(OffsetDateTime)) - ServiceBusManagementNode (getMessageSessions parameter) - ManagementChannel (internal clamp comment, for consistency) * docs(servicebus): drop implementation-package link from listSessions docs Round 27 review feedback. Removed the {@link ManagementConstants#ACTIVE_MESSAGES_SENTINEL} reference from the public javadoc on listSessions(OffsetDateTime) of both ServiceBusSessionReceiverAsyncClient and ServiceBusSessionReceiverClient. Public API javadoc shouldn't link to types in the implementation subpackage; instead, the sentinel value is now described in prose (`new Date(253402300800000L)`, rendered by OffsetDateTime.toString() as `+10000-01-01T00:00Z`, matching Track 1's SessionBrowser.MAXDATE). * fix(servicebus): surface protocol errors and harden MessageSessionsResult immutability Round 28 review feedback: - ManagementChannel.getMessageSessions: a 200 OK with an unexpected body type (null body, non-AmqpValue body, or AmqpValue whose payload isn't a Map) now fails the operation with a clear IllegalStateException instead of returning an empty page. Silently turning a protocol/ compatibility issue into an empty page would terminate pagination and drop any remaining results. The "no sessions-ids key" branch still returns an empty page, which is the legitimate "no more pages" signal. Added test getMessageSessionsRejectsUnexpectedBodyType. - MessageSessionsResult: the ctor now defensively copies the incoming sessionIds list and exposes it via Collections.unmodifiableList(...), so external mutation of the source list cannot alter the result after construction. A null sessionIds argument is now rejected eagerly with NullPointerException instead of NPE'ing at iteration time. * fix(servicebus): protocol-error on unexpected sessions-ids payload type Round 29 review feedback. ManagementChannel.getMessageSessions now fails the operation with IllegalStateException (logged via logExceptionAsWarning, matching the null-entry path) when the sessions-ids value is non-null but neither Object[] nor Iterable<?>, e.g. a String. Previously this silently fell back to an empty list, which would have terminated pagination and dropped any remaining results on a broker/library payload-shape change. Mirrors the round-28 fix for the body-shape branch. Added test getMessageSessionsRejectsUnexpectedSessionIdsPayloadType. * fix(servicebus): require strict monotonic forward progress on response skip Round 30 review feedback. ManagementChannel.readResponseSkip now requires the broker-returned skip to be strictly greater than requestSkip; equal would re-fetch the same page, smaller would cursor backwards, and either case risks infinite loops or duplicate results. Both cases now route through computeFallbackSkip(requestSkip, pageSize) just like the missing/non-numeric/negative/out-of-range cases. (The previous check was responseSkip >= 0; the new check responseSkip > requestSkip subsumes that guard since requestSkip is validated as non-negative at the call site.) Added test getMessageSessionsFallsBackWhenServerSkipIsNotMonotonic covering the equal-skip stall case. * test(servicebus): use OffsetDateTime.MAX in getMessageSessionsCapsYear Round 31 review feedback. Replaced the hand-rolled "year 99999" value with OffsetDateTime.MAX so the test would actually fail (with ArithmeticException from Date.from) if the clamp were moved after the java.util.Date conversion or removed altogether. Year 99999 doesn't overflow Date.from, so the previous setup didn't actually exercise the overflow protection that the test was meant to lock in. The clamping assertion (cap to 253_402_300_800_000 ms) is unchanged. * fix(servicebus): rename updatedAfter to sessionStateUpdatedAfter for cross-SDK parity --------- Co-authored-by: Eldert Grootenboer (from Dev Box) <egrootenboer@microsoft.com>
1 parent 8f0429d commit 6074743

10 files changed

Lines changed: 1184 additions & 1 deletion

File tree

sdk/servicebus/azure-messaging-servicebus/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Features Added
66

7+
- Added `listSessions()` and `listSessions(OffsetDateTime sessionStateUpdatedAfter)` to `ServiceBusSessionReceiverAsyncClient` (returning `PagedFlux<String>`) and `ServiceBusSessionReceiverClient` (returning `PagedIterable<String>`). The no-arg overload returns sessions with active messages; the `sessionStateUpdatedAfter` overload returns sessions whose session state was updated after the given timestamp. Implements the `com.microsoft:get-message-sessions` AMQP management operation. ([#48956](https://github.com/Azure/azure-sdk-for-java/pull/48956))
8+
79
### Breaking Changes
810

911
### Bugs Fixed

sdk/servicebus/azure-messaging-servicebus/src/main/java/com/azure/messaging/servicebus/ServiceBusSessionReceiverAsyncClient.java

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,30 @@
99
import com.azure.core.annotation.ReturnType;
1010
import com.azure.core.annotation.ServiceClient;
1111
import com.azure.core.annotation.ServiceMethod;
12+
import com.azure.core.http.HttpHeaders;
13+
import com.azure.core.http.rest.PagedFlux;
14+
import com.azure.core.http.rest.PagedResponse;
15+
import com.azure.core.http.rest.PagedResponseBase;
1216
import com.azure.core.util.CoreUtils;
1317
import com.azure.core.util.logging.ClientLogger;
18+
import com.azure.messaging.servicebus.implementation.ManagementConstants;
1419
import com.azure.messaging.servicebus.implementation.MessagingEntityType;
1520
import com.azure.messaging.servicebus.implementation.ServiceBusConstants;
1621
import com.azure.messaging.servicebus.implementation.instrumentation.ServiceBusReceiverInstrumentation;
1722
import com.azure.messaging.servicebus.implementation.instrumentation.ServiceBusTracer;
1823
import com.azure.messaging.servicebus.models.ServiceBusReceiveMode;
1924
import reactor.core.publisher.Mono;
2025

26+
import java.nio.ByteBuffer;
27+
import java.nio.charset.CharacterCodingException;
28+
import java.nio.charset.CodingErrorAction;
29+
import java.nio.charset.StandardCharsets;
30+
import java.time.OffsetDateTime;
31+
import java.util.Base64;
2132
import java.util.Objects;
2233

2334
import static com.azure.core.util.FluxUtil.monoError;
35+
import static com.azure.core.util.FluxUtil.pagedFluxError;
2436
import static com.azure.messaging.servicebus.ReceiverOptions.createNamedSessionOptions;
2537

2638
/**
@@ -149,6 +161,14 @@
149161
@ServiceClient(builder = ServiceBusClientBuilder.class, isAsync = true)
150162
public final class ServiceBusSessionReceiverAsyncClient implements AutoCloseable {
151163
private static final ClientLogger LOGGER = new ClientLogger(ServiceBusSessionReceiverAsyncClient.class);
164+
private static final int LIST_SESSIONS_DEFAULT_PAGE_SIZE = 100;
165+
/**
166+
* Continuation-token format is {@code <decimal-skip>|<base64url-utf8(lastSessionId)>}. The
167+
* {@code |} is safe as a separator because the URL-safe Base64 alphabet (A-Z, a-z, 0-9, '-',
168+
* '_') does not contain it, so any byte sequence in {@code lastSessionId} survives a round
169+
* trip without escaping.
170+
*/
171+
private static final char CURSOR_SEPARATOR = '|';
152172

153173
private final String fullyQualifiedNamespace;
154174
private final String entityPath;
@@ -292,6 +312,157 @@ private Mono<ServiceBusReceiverAsyncClient> acquireSpecificOrNextSession(String
292312
return tracer.traceMono("ServiceBus.acceptSession", acquireSessionReceiver);
293313
}
294314

315+
/**
316+
* Lists the IDs of sessions that have active messages in this entity.
317+
*
318+
* <p>Only sessions with active messages in the queue or subscription are returned.
319+
* Sessions on the dead-letter queue or sessions having only a session state (but no messages)
320+
* are not returned.</p>
321+
*
322+
* <p>The returned {@link PagedFlux} fetches additional pages from the broker on demand using
323+
* cursor-based pagination (server-returned {@code skip} plus {@code lastSessionId} of the
324+
* previous page) and terminates when the broker returns an empty page. The default page size
325+
* is 100; callers can request a different size via
326+
* {@link PagedFlux#byPage(int)}.</p>
327+
*
328+
* @return A {@link PagedFlux} of session ID strings.
329+
*/
330+
@ServiceMethod(returns = ReturnType.COLLECTION)
331+
public PagedFlux<String> listSessions() {
332+
// Wire value matches Track 1's SessionBrowser.MAXDATE so the broker switches into the
333+
// active-messages mode it has historically been validated against.
334+
return listSessionsInternal(ManagementConstants.ACTIVE_MESSAGES_SENTINEL);
335+
}
336+
337+
/**
338+
* Lists the IDs of sessions whose state was updated after the specified time.
339+
*
340+
* <p>The returned {@link PagedFlux} fetches additional pages from the broker on demand using
341+
* cursor-based pagination (server-returned {@code skip} plus {@code lastSessionId} of the
342+
* previous page) and terminates when the broker returns an empty page. The default page size
343+
* is 100; callers can request a different size via
344+
* {@link PagedFlux#byPage(int)}.</p>
345+
*
346+
* <p>Values at or beyond the active-messages sentinel value
347+
* ({@code new Date(253402300800000L)}, rendered by {@code OffsetDateTime.toString()} as
348+
* {@code +10000-01-01T00:00Z}, matching Track 1's {@code SessionBrowser.MAXDATE}) are clamped
349+
* to that sentinel and behave the same as {@link #listSessions()}, returning sessions that
350+
* have active messages.</p>
351+
*
352+
* @param sessionStateUpdatedAfter Only sessions whose session state was updated after this time are returned.
353+
* @return A {@link PagedFlux} of session ID strings.
354+
* @throws NullPointerException if {@code sessionStateUpdatedAfter} is null.
355+
*/
356+
@ServiceMethod(returns = ReturnType.COLLECTION)
357+
public PagedFlux<String> listSessions(OffsetDateTime sessionStateUpdatedAfter) {
358+
if (sessionStateUpdatedAfter == null) {
359+
return pagedFluxError(LOGGER, new NullPointerException("'sessionStateUpdatedAfter' cannot be null."));
360+
}
361+
return listSessionsInternal(sessionStateUpdatedAfter);
362+
}
363+
364+
private PagedFlux<String> listSessionsInternal(OffsetDateTime lastUpdatedTime) {
365+
// Use the page-size-aware PagedFlux constructor so a caller's byPage(int) value flows
366+
// through to the management request's `top` parameter. When the caller doesn't request a
367+
// specific page size, pageSize is null and we fall back to the default. The first lambda
368+
// is the first-page retriever. The next-page retriever may also be invoked directly when
369+
// a caller starts from a continuation token via byPage(token) without going through any
370+
// previous PagedResponse, so it must validate the token it receives. Note: in azure-core,
371+
// byPage(null) returns Flux.empty() rather than routing to the first-page retriever, so a
372+
// null continuation token never reaches this lambda.
373+
return new PagedFlux<>(pageSize -> fetchSessionPage(lastUpdatedTime, 0, null, resolvePageSize(pageSize)),
374+
(continuationToken, pageSize) -> {
375+
// Treat an empty continuation token as "no more pages", matching
376+
// ServiceBusAdministrationAsyncClient.listQueuesNextPage / listRulesNextPage and
377+
// the wider Azure SDK paging convention. This is tolerant of callers that persist
378+
// the token to storage and read back an empty string.
379+
if (continuationToken.isEmpty()) {
380+
return Mono.empty();
381+
}
382+
final int separator = continuationToken.indexOf(CURSOR_SEPARATOR);
383+
if (separator < 0) {
384+
return monoError(LOGGER, new IllegalArgumentException(
385+
"Invalid continuation token. Expected format '<skip>|<base64url(lastSessionId)>'."));
386+
}
387+
388+
final int nextSkip;
389+
try {
390+
nextSkip = Integer.parseInt(continuationToken.substring(0, separator));
391+
} catch (NumberFormatException ex) {
392+
return monoError(LOGGER, new IllegalArgumentException(
393+
"Invalid continuation token. Expected a numeric skip value before the '|' separator.", ex));
394+
}
395+
if (nextSkip < 0) {
396+
return monoError(LOGGER, new IllegalArgumentException(
397+
"Invalid continuation token. Skip value must be non-negative; got " + nextSkip + "."));
398+
}
399+
400+
final String lastSessionId;
401+
try {
402+
final byte[] decoded = Base64.getUrlDecoder().decode(continuationToken.substring(separator + 1));
403+
// Strict UTF-8 decoding: a token whose payload base64-decodes cleanly but isn't valid
404+
// UTF-8 must be rejected, otherwise new String(decoded, UTF_8) silently substitutes
405+
// U+FFFD and we'd send a corrupted session ID to the broker as the cursor.
406+
lastSessionId = StandardCharsets.UTF_8.newDecoder()
407+
.onMalformedInput(CodingErrorAction.REPORT)
408+
.onUnmappableCharacter(CodingErrorAction.REPORT)
409+
.decode(ByteBuffer.wrap(decoded))
410+
.toString();
411+
} catch (IllegalArgumentException ex) {
412+
return monoError(LOGGER, new IllegalArgumentException(
413+
"Invalid continuation token. Expected base64url-encoded UTF-8 bytes after the '|' separator.",
414+
ex));
415+
} catch (CharacterCodingException ex) {
416+
return monoError(LOGGER, new IllegalArgumentException(
417+
"Invalid continuation token. Decoded bytes after the '|' separator are not valid UTF-8.", ex));
418+
}
419+
420+
return fetchSessionPage(lastUpdatedTime, nextSkip, lastSessionId, resolvePageSize(pageSize));
421+
});
422+
}
423+
424+
/**
425+
* Resolves the per-page request size: a positive caller-supplied value (via {@code byPage(int)})
426+
* is honored, anything else (null or non-positive) falls back to the default.
427+
*/
428+
private static int resolvePageSize(Integer requested) {
429+
return requested != null && requested > 0 ? requested : LIST_SESSIONS_DEFAULT_PAGE_SIZE;
430+
}
431+
432+
private Mono<PagedResponse<String>> fetchSessionPage(OffsetDateTime lastUpdatedTime, int skip, String lastSessionId,
433+
int pageSize) {
434+
// Wrap each page fetch in a tracing span so distributed traces show one
435+
// "ServiceBus.listSessions" span per page, matching the tracing pattern used by
436+
// acceptSession/acceptNextSession in this client.
437+
final Mono<PagedResponse<String>> pageMono = connectionCacheWrapper.getConnection()
438+
.flatMap(connection -> connection.getManagementNode(entityPath, entityType))
439+
.flatMap(
440+
managementNode -> managementNode.getMessageSessions(lastUpdatedTime, skip, pageSize, lastSessionId))
441+
.map(result -> {
442+
final java.util.List<String> sessionIds = result.getSessionIds();
443+
// Empty page terminates pagination (matches Track 1's SessionBrowser loop and the
444+
// broker contract). Continuation token encodes the server-returned skip and the
445+
// last session ID of the page so the next call uses the same cursor Track 1 does.
446+
// Base64url-encode the session ID so arbitrary byte sequences (including the '|'
447+
// separator) round-trip without escaping.
448+
final String continuationToken;
449+
if (sessionIds.isEmpty()) {
450+
continuationToken = null;
451+
} else {
452+
final String last = sessionIds.get(sessionIds.size() - 1);
453+
final String encoded
454+
= Base64.getUrlEncoder().withoutPadding().encodeToString(last.getBytes(StandardCharsets.UTF_8));
455+
continuationToken = result.getNextSkip() + String.valueOf(CURSOR_SEPARATOR) + encoded;
456+
}
457+
// Allocate a fresh HttpHeaders per page so callers cannot mutate a shared
458+
// instance (HttpHeaders is mutable and PagedResponseBase exposes the reference
459+
// via getHeaders()).
460+
return new PagedResponseBase<Void, String>(null, 200, new HttpHeaders(), sessionIds, continuationToken,
461+
null);
462+
});
463+
return tracer.traceMono("ServiceBus.listSessions", pageMono);
464+
}
465+
295466
@Override
296467
public void close() {
297468
if (this.unNamedSessionManager != null) {

sdk/servicebus/azure-messaging-servicebus/src/main/java/com/azure/messaging/servicebus/ServiceBusSessionReceiverClient.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
import com.azure.core.annotation.ReturnType;
99
import com.azure.core.annotation.ServiceClient;
1010
import com.azure.core.annotation.ServiceMethod;
11+
import com.azure.core.http.rest.PagedIterable;
1112
import com.azure.messaging.servicebus.models.ServiceBusReceiveMode;
1213
import reactor.core.publisher.Mono;
1314

1415
import java.time.Duration;
16+
import java.time.OffsetDateTime;
1517
import java.util.Objects;
1618
import java.util.concurrent.TimeUnit;
1719
import java.util.concurrent.TimeoutException;
@@ -201,6 +203,47 @@ public ServiceBusReceiverClient acceptSession(String sessionId) {
201203
.block();
202204
}
203205

206+
/**
207+
* Lists the IDs of sessions that have active messages in this entity.
208+
*
209+
* <p>The returned {@link PagedIterable} fetches additional pages from the broker on demand;
210+
* iterate the {@code PagedIterable} (or call {@link PagedIterable#stream()}) to receive every
211+
* session ID. Pages are fetched lazily as the iterator advances. The default page size is 100;
212+
* callers can request a different size via {@link PagedIterable#iterableByPage(int)} (or the
213+
* equivalent on the underlying {@code PagedFlux}).</p>
214+
*
215+
* @return A {@link PagedIterable} of session ID strings.
216+
*/
217+
@ServiceMethod(returns = ReturnType.COLLECTION)
218+
public PagedIterable<String> listSessions() {
219+
return new PagedIterable<>(sessionAsyncClient.listSessions());
220+
}
221+
222+
/**
223+
* Lists the IDs of sessions whose state was updated after the specified time.
224+
*
225+
* <p>The returned {@link PagedIterable} fetches additional pages from the broker on demand;
226+
* iterate the {@code PagedIterable} (or call {@link PagedIterable#stream()}) to receive every
227+
* session ID. Pages are fetched lazily as the iterator advances. The default page size is 100;
228+
* callers can request a different size via {@link PagedIterable#iterableByPage(int)} (or the
229+
* equivalent on the underlying {@code PagedFlux}).</p>
230+
*
231+
* <p>Values at or beyond the active-messages sentinel value
232+
* ({@code new Date(253402300800000L)}, rendered by {@code OffsetDateTime.toString()} as
233+
* {@code +10000-01-01T00:00Z}, matching Track 1's {@code SessionBrowser.MAXDATE}) are clamped
234+
* to that sentinel and behave the same as {@link #listSessions()}, returning sessions that
235+
* have active messages.</p>
236+
*
237+
* @param sessionStateUpdatedAfter Only sessions whose session state was updated after this time are returned.
238+
* @return A {@link PagedIterable} of session ID strings.
239+
* @throws NullPointerException if {@code sessionStateUpdatedAfter} is null.
240+
*/
241+
@ServiceMethod(returns = ReturnType.COLLECTION)
242+
public PagedIterable<String> listSessions(OffsetDateTime sessionStateUpdatedAfter) {
243+
Objects.requireNonNull(sessionStateUpdatedAfter, "'sessionStateUpdatedAfter' cannot be null.");
244+
return new PagedIterable<>(sessionAsyncClient.listSessions(sessionStateUpdatedAfter));
245+
}
246+
204247
@Override
205248
public void close() {
206249
sessionAsyncClient.close();

0 commit comments

Comments
 (0)