Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
a3faf26
Add tiered send/receive recovery matching Go SDK parity
EldertGrootenboerMS Mar 18, 2026
5e28c33
fix(ci): build azure-core-amqp from source for tiered recovery PR
EldertGrootenboerMS Mar 18, 2026
f0c6e53
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Mar 18, 2026
0bd7282
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Mar 18, 2026
6116b7b
fix(review): address Copilot PR review feedback on tiered recovery
EldertGrootenboerMS Mar 18, 2026
f3a29a7
fix(review): address second Copilot review on tiered recovery
EldertGrootenboerMS Mar 18, 2026
79c52f9
fix(review): address third Copilot review on tiered recovery
EldertGrootenboerMS Mar 18, 2026
1fec9e5
fix(servicebus): make link/connection force-close non-blocking in rec…
EldertGrootenboerMS Mar 18, 2026
d4c64fd
fix(amqp): prevent NONE failure from consuming quick-retry flag
EldertGrootenboerMS Mar 18, 2026
52d4435
fix(amqp): classify disposed-link IllegalStateException as LINK recovery
EldertGrootenboerMS Mar 18, 2026
ce10aa2
fix(amqp): narrow disposed-ISE match to prevent tier misclassification
EldertGrootenboerMS Mar 18, 2026
5448c97
fix(amqp): clarify virtual-time reason in retry tests
EldertGrootenboerMS Mar 18, 2026
4bd93bb
fix(amqp): tie forceCloseConnection invalidation to specific connecti…
EldertGrootenboerMS Mar 18, 2026
2e80396
fix(amqp): rename test methods to camelCase for checkstyle compliance
EldertGrootenboerMS Mar 18, 2026
1860dd3
fix(amqp): use distinct log message for force-invalidation path
EldertGrootenboerMS Mar 18, 2026
c2d2086
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Mar 19, 2026
bf2b560
fix(amqp): guard backoff overflow and normalize RecoveryKind message …
EldertGrootenboerMS Mar 19, 2026
e76c2d8
fix(servicebus): scope send-link recovery to per-operation reference,…
EldertGrootenboerMS Mar 19, 2026
3eea3b2
fix(servicebus): include exception in connection-recovery warning log
EldertGrootenboerMS Mar 19, 2026
1f9bf92
fix(ci): move azure-core-amqp source comment to correct entry
EldertGrootenboerMS Mar 19, 2026
c0e2d8b
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Mar 19, 2026
0c6cb7a
refactor: address reviewer feedback on tiered recovery
EldertGrootenboerMS Apr 2, 2026
cb1e86a
fix(servicebus): restore comment explaining Mono.delay(Duration.ZERO)…
EldertGrootenboerMS Apr 9, 2026
a3b639c
fix(servicebus): include exception in recovery warning logs
EldertGrootenboerMS Apr 9, 2026
ea2961b
docs(servicebus): clarify invalidateConnection v1/v2 behavior in javadoc
EldertGrootenboerMS Apr 9, 2026
591a8c7
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboerMS May 6, 2026
43a27f4
test(amqp): add unit tests for ReactorConnectionCache.invalidateConne…
EldertGrootenboerMS May 6, 2026
43d5ff1
fix(servicebus): wrap getLinkSize() in retry+recovery boundary
EldertGrootenboerMS May 6, 2026
e1b382a
fix(servicebus): remove unused static import for RetryUtil.withRetry
EldertGrootenboerMS May 6, 2026
95e9070
docs(servicebus): clarify session-removal recovery comment for LINK a…
EldertGrootenboerMS May 7, 2026
9494fe5
Merge remote-tracking branch 'origin/main' into fix/servicebus-tiered…
EldertGrootenboerMS May 7, 2026
36d9848
refactor(servicebus): extract shared RecoveryUtils.asRetriable helper
EldertGrootenboerMS May 7, 2026
1ce02a1
fix(servicebus): remove unused RetryUtil import in RecoveryUtils
EldertGrootenboerMS May 7, 2026
c5ef09b
merge(servicebus): resolve conflicts with #48214 batch-size cap
EldertGrootenboerMS May 13, 2026
e469d49
ci: retrigger after infrastructure flakes
EldertGrootenboerMS May 13, 2026
a94a0b6
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer May 13, 2026
65f9d34
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer May 14, 2026
41e92f1
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer May 14, 2026
4c64da5
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Jun 17, 2026
2cd3326
Use current version tag for azure-core-amqp dependency
EldertGrootenboerMS Jun 17, 2026
d8f7153
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Jun 18, 2026
abb8ee4
Merge remote-tracking branch 'origin/main' into fix/servicebus-tiered…
EldertGrootenboer Jul 11, 2026
4961959
Apply spotless formatting to ServiceBusSenderAsyncClient
EldertGrootenboer Jul 11, 2026
1c1a60d
Merge remote-tracking branch 'origin/main' into fix/servicebus-tiered…
EldertGrootenboer Jul 27, 2026
8302361
style: wrap over-length comment in RecoveryKindTest
EldertGrootenboer Jul 27, 2026
56fa8bb
fix: space session-manager link/connection retries by the configured …
EldertGrootenboer Jul 27, 2026
017b846
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Jul 27, 2026
bd7ef4e
Merge remote-tracking branch 'origin/main' into fix/servicebus-tiered…
EldertGrootenboer Aug 26, 2026
b7b01db
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Oct 28, 2026
e6c96e0
fix: restore in-flight AMQP dependency for tiered recovery
EldertGrootenboer Oct 28, 2026
46f8acc
fix: align management playback with supported azure-core
EldertGrootenboer Oct 28, 2026
60ca59a
fix: preserve recovery ownership and retry failed acquisitions
EldertGrootenboer Oct 29, 2026
d797c09
Merge branch 'main' into fix/servicebus-tiered-send-recovery
EldertGrootenboer Oct 29, 2026
4fbc7dc
fix: complete Service Bus tiered recovery retry cleanup
EldertGrootenboer Oct 29, 2026
c22a043
fix: preserve replacement sessions during receive recovery
EldertGrootenboer Oct 29, 2026
6ede6b3
test: open AMQP internals to Service Bus recovery tests
EldertGrootenboer Oct 29, 2026
cff83c0
test: warm up Vert.x before Expect Continue timing checks
EldertGrootenboer Oct 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -140,15 +140,19 @@ public void onNext(T amqpChannel) {
logger.info("Channel is now active.");
}
}, error -> {
setAndClearChannel();
onError(error);
if (setAndClearChannel(amqpChannel)) {
onError(error);
} else {
logger.verbose("Ignoring error from a replaced channel.", error);
}
}, () -> {
if (isDisposed()) {
logger.info("Channel is disposed.");
} else {
logger.info("Channel is closed. Requesting upstream.");
setAndClearChannel();
requestUpstream();
if (setAndClearChannel(amqpChannel)) {
logger.info("Channel is closed. Requesting upstream.");
requestUpstream();
}
}
});
}
Expand Down Expand Up @@ -347,6 +351,36 @@ private void setAndClearChannel() {
close(oldChannel);
}

/**
* Force-closes the current cached channel so that the next subscriber receives a fresh one.
* This is used for connection-level recovery when the current connection is stale
* but the processor has not detected it (e.g., heartbeats echoed by intermediate infrastructure).
*/
public void forceCloseChannel() {
setAndClearChannel();
}

/**
* Closes the failed channel only if it is still the current channel.
*
* @param failedChannel The channel used by the failed operation.
*/
public void forceCloseChannel(T failedChannel) {
Objects.requireNonNull(failedChannel, "'failedChannel' cannot be null.");
setAndClearChannel(failedChannel);
}

private boolean setAndClearChannel(T failedChannel) {
synchronized (lock) {
if (currentChannel != failedChannel) {
return false;
}
currentChannel = null;
}
close(failedChannel);
return true;
}

/**
* Checks the current state of the channel for this channel and returns true if the channel is null or if this
* processor is disposed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ public Mono<AmqpSession> createSession(String sessionName) {
logger.atInfo()
.addKeyValue(SESSION_NAME_KEY, sessionName)
.log("Error occurred. Removing and disposing session", error);
removeSession(key);
removeSession(key, amqpSession);
}, () -> {
// If we were already disposing of the connection, the session would be removed.
if (isDisposed.get()) {
Expand All @@ -372,7 +372,7 @@ public Mono<AmqpSession> createSession(String sessionName) {
logger.atVerbose()
.addKeyValue(SESSION_NAME_KEY, sessionName)
.log("Complete. Removing and disposing session.");
removeSession(key);
removeSession(key, amqpSession);
});

return new SessionSubscription(amqpSession, subscription);
Expand All @@ -396,8 +396,7 @@ public Mono<AmqpSession> createSession(String sessionName) {

final AmqpException amqpException = (AmqpException) error;
if (amqpException.getErrorCondition() == TIMEOUT_ERROR) {
final SessionSubscription removed = sessionMap.remove(sessionName);
removed.dispose();
removeSession(sessionName, sessionSubscription.getSession());
}
});

Expand Down Expand Up @@ -443,6 +442,28 @@ public boolean removeSession(String sessionName) {
return removed != null;
}

/**
* Removes a session only if it is still the cached instance.
*
* @param sessionName The name used to cache the session.
* @param expectedSession The session owned by the failed operation.
* @return Whether the expected session was removed.
*/
protected boolean removeSession(String sessionName, AmqpSession expectedSession) {
if (sessionName == null || expectedSession == null) {
return false;
}
if (useSessionChannelCache) {
return sessionCache.evict(sessionName, expectedSession);
}
final SessionSubscription entry = sessionMap.get(sessionName);
if (entry == null || entry.getSession() != expectedSession || !sessionMap.remove(sessionName, entry)) {
return false;
}
entry.dispose();
return true;
}

@Override
public boolean isDisposed() {
return isDisposed.get();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ public final class ReactorConnectionCache<T extends ReactorConnection> implement
// any dependent type; instead, the dependent type must acquire Connection only through the cache route,
// i.e., by subscribing to 'createOrGetCachedConnection' via 'get()' getter.
private volatile T currentConnection;
Comment thread
EldertGrootenboer marked this conversation as resolved.
Resolved
// Holds the ID of the connection that invalidateConnection() asked to force-invalidate.
// Only the connection whose getId() matches this value will be invalidated by cacheInvalidateIf;
// a freshly created connection with a different ID is never accidentally invalidated.
private final AtomicReference<String> forceInvalidateConnectionId = new AtomicReference<>(null);
private final State state = new State();

/**
Expand Down Expand Up @@ -113,12 +117,25 @@ public ReactorConnectionCache(Supplier<T> connectionSupplier, String fullyQualif
}
}).cacheInvalidateIf(c -> {
if (c.isDisposed()) {
// Connection disposed for any reason. Clean up the force-invalidate marker if it
// was targeting this connection so it is not accidentally consumed by a future
// connection that happens to have the same ID.
forceInvalidateConnectionId.compareAndSet(c.getId(), null);
withConnectionId(logger, c.getId()).log("The connection is closed, requesting a new connection.");
return true;
} else {
// Emit cached connection.
return false;
}
final String targetId = forceInvalidateConnectionId.get();
Comment thread
EldertGrootenboer marked this conversation as resolved.
Resolved
if (targetId != null
&& targetId.equals(c.getId())
&& forceInvalidateConnectionId.compareAndSet(targetId, null)) {
// invalidateConnection() marked this connection dirty.
// Close it here — the cache owns the connection lifecycle.
withConnectionId(logger, c.getId()).log("Force-invalidating and closing connection for recovery.");
closeConnection(c, logger, "Force-invalidated for recovery.");
return true;
}
// No forced invalidation targeted this connection — emit it from cache.
return false;
});
}

Expand Down Expand Up @@ -172,6 +189,49 @@ public boolean isCurrentConnectionClosed() {
return (currentConnection != null && currentConnection.isDisposed()) || terminated;
}

/**
* Marks the current cached connection for invalidation so that the next {@link #get()} call
* closes it and creates a fresh connection. This is used for connection-level recovery when
* the current connection is in a stale state that the cache's normal error detection (via
* endpoint state signals) has not detected — for example, when intermediate infrastructure
* (load balancers, NAT gateways) is echoing AMQP heartbeats on behalf of a dead connection.
*
* <p>The actual close is deferred to the cache chain's {@code cacheInvalidateIf} predicate
* inside {@link #get()}, keeping the connection lifecycle local to the cache. This avoids
* the concurrency concern of two threads touching the cached connection simultaneously.</p>
*
* <p>This is modeled after the Go SDK's {@code Namespace.Recover()} which explicitly closes
* the old connection and increments the connection revision.</p>
*
* <p>This method is safe to call concurrently. If the connection is already closed or being
* closed, this is a no-op.</p>
*/
public void invalidateConnection() {
final T connection = currentConnection;
if (connection != null && !connection.isDisposed()) {
Comment thread
EldertGrootenboer marked this conversation as resolved.
Resolved
withConnectionId(logger, connection.getId())
.log("Marking connection for force-invalidation. Next get() will close and replace it.");
Comment thread
EldertGrootenboer marked this conversation as resolved.
Resolved
forceInvalidateConnectionId.set(connection.getId());
Comment thread
EldertGrootenboer marked this conversation as resolved.
Resolved
}
Comment thread
EldertGrootenboer marked this conversation as resolved.
Resolved
}

/**
* Marks the failed connection for invalidation only if it is still cached.
* A delayed failure must not invalidate a healthy replacement connection.
*
* @param connectionId The identifier of the connection used by the failed operation.
*/
public void invalidateConnection(String connectionId) {
Objects.requireNonNull(connectionId, "'connectionId' cannot be null.");
synchronized (lock) {
final T connection = currentConnection;
if (connection != null && connectionId.equals(connection.getId()) && !connection.isDisposed()) {
withConnectionId(logger, connectionId).log("Marking failed connection for force-invalidation.");
forceInvalidateConnectionId.set(connectionId);
}
}
Comment thread
EldertGrootenboer marked this conversation as resolved.
Resolved
Comment thread
EldertGrootenboer marked this conversation as resolved.
Resolved
}

/**
* Terminate so that consumers will no longer be able to request connection. If there is a current (cached)
* connection then it will be closed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

package com.azure.core.amqp.implementation;

import com.azure.core.amqp.AmqpSession;
import com.azure.core.util.logging.ClientLogger;
import org.apache.qpid.proton.engine.Connection;
import reactor.core.Disposable;
Expand Down Expand Up @@ -119,6 +120,18 @@ boolean evict(String name) {
return removed != null;
}

boolean evict(String name, AmqpSession expectedSession) {
if (name == null || expectedSession == null) {
return false;
}
final Entry entry = entries.get(name);
if (entry == null || entry.getSession() != expectedSession || !entries.remove(name, entry)) {
return false;
}
entry.dispose();
return true;
}

/**
* Signal that the owner ({@link ReactorConnection}) of the cache is disposed of.
*/
Expand Down Expand Up @@ -193,7 +206,7 @@ private void evict(ReactorSession session, String message, Throwable error) {
} else {
logger.atInfo().addKeyValue(SESSION_NAME_KEY, name).addKeyValue(SESSION_ID_KEY, id).log(message);
}
evict(name);
evict(name, session);
}

/**
Expand Down
Loading
Loading