Skip to content

CAMEL-23938: add Camel-managed session lock renewal for session-enabled Service Bus consumers - #25001

Closed
atiaomar1978-hub wants to merge 2 commits into
apache:mainfrom
atiaomar1978-hub:CAMEL-23938-servicebus-session-lock-renewal
Closed

CAMEL-23938: add Camel-managed session lock renewal for session-enabled Service Bus consumers#25001
atiaomar1978-hub wants to merge 2 commits into
apache:mainfrom
atiaomar1978-hub:CAMEL-23938-servicebus-session-lock-renewal

Conversation

@atiaomar1978-hub

Copy link
Copy Markdown
Contributor

CAMEL-23938: Camel-managed session lock renewal for session-enabled Service Bus consumers

Follow-up from CAMEL-23937.

Problem

CAMEL-23937 added Camel-managed lock renewal for non-session Azure Service Bus consumers using ServiceBusReceiverAsyncClient.renewMessageLock(). Session-enabled consumers were explicitly excluded, because session locks require a different API:

  • Session locks must be renewed via renewSessionLock() instead of renewMessageLock()
  • This requires a session-bound receiver (acceptSession(sessionId))
  • The session id is only known at message-receive time, not at consumer startup

As a result, session-enabled consumers in async routes still suffered from silent session-lock expiry mid-processing.

Solution

Added a SessionLockRenewer in ServiceBusConsumer that:

  1. Creates session-bound async receivers on demand, keyed by session id, via ServiceBusSessionReceiverAsyncClient.acceptSession(sessionId)
  2. Renews session locks with renewSessionLock() as they approach expiry
  3. Reference-counts in-flight exchanges per session and closes the session receiver once the last exchange for that session completes
  4. Honours maxAutoLockRenewDuration per exchange

Also added ServiceBusClientFactory.createServiceBusSessionReceiverAsyncClient().

The behavior activates automatically when: no custom processorClient, PEEK_LOCK mode, maxAutoLockRenewDuration > 0, and session mode is enabled.

Tests

Added AssertJ test coverage in ServiceBusConsumerTest:

  • Session lock renewer created for session-enabled PEEK_LOCK, not for RECEIVE_AND_DELETE
  • Exchange tracking and cleanup (session receiver closed after completion)
  • renewSessionLock() invoked when the lock is expiring
  • Session receiver reused across messages of the same session
  • Session receiver kept open until the last exchange for the session completes
  • Stop/start lifecycle recreates and cleans up the session renewer

mvn test -pl components/camel-azure/camel-azure-servicebus passes.

Docs

Updated the 4.22 upgrade guide entry to cover session lock renewal.


Claude Opus 4.8 on behalf of atiaomar1978-hub

…ed Service Bus consumers

Session-enabled Azure Service Bus consumers were excluded from the
Camel-managed lock renewal added in CAMEL-23937, because session locks
must be renewed via renewSessionLock() on a session-bound receiver
rather than renewMessageLock().

This adds a SessionLockRenewer that:
- creates session-bound async receivers on demand via acceptSession(sessionId)
- renews session locks with renewSessionLock() as they approach expiry
- reference-counts in-flight exchanges per session and closes the
  session receiver once the last exchange for a session completes
- honours maxAutoLockRenewDuration per exchange

Also adds ServiceBusClientFactory.createServiceBusSessionReceiverAsyncClient(),
AssertJ test coverage, and an upgrade-guide entry.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

@gnodet gnodet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code on behalf of gnodet

Nice extension — session lock renewal completes the picture

Good follow-up to CAMEL-23937. The architecture mirrors the existing LockRenewer cleanly, with the added complexity of session-bound receivers and reference counting properly justified by the Azure SDK's session lock API requirements. Tests are thorough (7 new tests covering the key scenarios). Upgrade guide updated.

One bug to fix

Session receiver leak when maxRenewDuration is exceeded or renewSessionLock() fails

In SessionLockRenewer.run(), two code paths remove entries from the entries map without calling releaseSessionReceiver():

  1. Max duration exceeded (line ~178):
entries.remove(exchangeId);
continue;
  1. Renewal error handler (line ~199):
entries.remove(exchangeId);

When run() removes the entry first, the SynchronizationAdapter.onComplete/onFailure callback later gets null from entries.remove() and skips releaseSessionReceiver(). This means the session receiver's inFlightCount is never decremented, so the session receiver is never closed — a resource leak.

The existing LockRenewer has the same entries.remove() pattern, but it doesn't matter there because it uses a single shared client with no reference counting. SessionLockRenewer has reference-counted SessionReceiverHolders that need explicit release.

Fix: call releaseSessionReceiver(entry.sessionId) alongside each entries.remove(exchangeId) in run():

// max duration exceeded
entries.remove(exchangeId);
releaseSessionReceiver(entry.sessionId);
continue;
// error handler
error -> {
    LOG.warn(...);
    entries.remove(exchangeId);
    releaseSessionReceiver(entry.sessionId);
}

Minor observation (non-blocking)

The acquireSessionReceiver fast path (outside the synchronized block) can theoretically race with releaseSessionReceiver: thread A decrements to 0 and is about to remove + close, while thread B reads the holder, increments to 1, and returns the soon-to-be-closed client. In practice this requires a new message for the same session arriving at the exact instant the last exchange completes — unlikely but possible under load. The synchronized block only guards creation, not the acquire-vs-release interplay. Worth noting for a future hardening pass, but not a blocker for this PR.

Everything else looks good

  • ServiceBusClientFactory.createServiceBusSessionReceiverAsyncClient() correctly mirrors the receiver client setup with sessionReceiver() builder, disableAutoComplete(), and maxAutoLockRenewDuration(Duration.ZERO)
  • doStop() and doShutdown() properly clean up both session and non-session resources (consistent with existing pattern)
  • Boolean.TRUE.equals(isSessionEnabled()) is null-safe ✓
  • Test coverage: session receiver reuse, reference counting lifecycle, stop/start cycle recreation — all the important scenarios

Address review feedback from gnodet:

- Fix session receiver leak: SessionLockRenewer.run() removed entries from
  the tracking map without releasing the reference-counted session receiver
  on the max-renew-duration-exceeded and renewal-failure paths. The later
  completion callback then saw a null entry and skipped release, so
  inFlightCount never reached zero and the session receiver was never closed.
  run() now calls releaseSessionReceiver() when it wins the entries.remove().

- Harden acquire/release race: both acquireSessionReceiver() and
  releaseSessionReceiver() are now guarded by the same lock so the
  increment/create and decrement/close operations are atomic relative to each
  other, preventing a newly arriving message from incrementing a holder that a
  concurrently completing exchange is about to close.

- Add test covering session receiver release on renewal failure.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@atiaomar1978-hub

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, @gnodet!

Session receiver leak (fixed) — you're right. SessionLockRenewer.run() removed entries from the tracking map on the max-duration and renewal-error paths without releasing the reference-counted session receiver, so the completion callback later saw a null entry and skipped releaseSessionReceiver(), leaving inFlightCount above zero and the receiver open. Both paths now call releaseSessionReceiver(entry.sessionId) guarded by entries.remove(exchangeId) != null so the atomic remove guarantees exactly one release regardless of whether run() or the completion callback wins the race.

Acquire/release race (hardened) — also addressed. acquireSessionReceiver() and releaseSessionReceiver() are now both guarded by the same sessionReceiverCreationLock, making the increment/create and decrement/close operations atomic relative to each other. A newly arriving message can no longer increment a holder that a concurrently completing exchange is about to close.

Added a test (sessionLockRenewerReleasesSessionReceiverOnRenewalFailure) verifying the receiver is closed when renewSessionLock() fails.

Pushed in b2042a5. mvn test -pl components/camel-azure/camel-azure-servicebus passes.

Claude Opus 4.8 on behalf of atiaomar1978-hub

@davsclaus davsclaus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean follow-up to CAMEL-23937 that fills the explicitly-documented gap for session-enabled consumers.

The SessionLockRenewer correctly follows the patterns established by the LockRenewer in CAMEL-23937:

  • Same PeriodTaskScheduler integration and maxRenewDuration honoring
  • Same SynchronizationAdapter for exchange completion cleanup
  • Cleanup in both doStop() and doShutdown() matches the existing defensive pattern

Observations (non-blocking):

  1. acceptSession(sessionId).block() in acquireSessionReceiver — synchronous blocking call inside the ServiceBusProcessorClient callback. The Azure SDK dispatches this callback on its own thread pool (not reactor threads), so .block() is safe. The first message for each unique session will block briefly on the acceptSession handshake, serialized through sessionReceiverCreationLock; subsequent messages for the same session hit the fast path (cached receiver). Under extreme load with many distinct sessions arriving concurrently this could become a bottleneck, but unlikely in practice.

  2. Reference-counting is correctly synchronizedacquireSessionReceiver / releaseSessionReceiver are both guarded by the same lock, preventing the race where an arriving message increments a holder that a concurrently completing exchange is about to close.

  3. Renewal failure handling is correct — when renewSessionLock() fails, only the affected exchange's entry is removed and the ref count decremented. Other exchanges on the same session keep their entries and the session receiver stays open.

  4. Test coverage is thorough — 8 new tests covering creation conditions, exchange tracking/cleanup, renewal invocation, failure handling, session receiver reuse, reference counting, and stop/start lifecycle.

  5. Upgrade guide — correctly updates the existing CAMEL-23937 entry rather than creating a separate section.

This review was generated by an AI agent (Claude Code on behalf of davsclaus) and may contain inaccuracies. Please verify all suggestions before applying.

@github-actions

Copy link
Copy Markdown
Contributor

🌟 Thank you for your contribution to the Apache Camel project! 🌟
🤖 CI automation will test this PR automatically.

🐫 Apache Camel Committers, please review the following items:

  • First-time contributors require MANUAL approval for the GitHub Actions to run
  • You can use the command /component-test (camel-)component-name1 (camel-)component-name2.. to request a test from the test bot although they are normally detected and executed by CI.
  • You can label PRs using skip-tests and test-dependents to fine-tune the checks executed by this PR.
  • Build and test logs are available in the summary page. Only Apache Camel committers have access to the summary.

⚠️ Be careful when sharing logs. Review their contents before sharing them publicly.

@github-actions

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • components/camel-azure/camel-azure-servicebus
  • docs

🔬 Scalpel shadow comparison — Scalpel: 10 tested, 28 compile-only — current: 9 all tested

Maveniverse Scalpel detected 38 affected modules (current approach: 9).

⚠️ Modules only in Scalpel (29)
  • apache-camel
  • camel-allcomponents
  • camel-catalog
  • camel-catalog-console
  • camel-catalog-lucene
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-csimple-maven-plugin
  • camel-endpointdsl
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • docs
  • dummy-component

Skip-tests mode would test 10 modules (2 direct + 8 downstream), skip tests for 28 (generated code, meta-modules)

Modules Scalpel would test (10)
  • camel-azure-servicebus
  • camel-jbang-mcp
  • camel-jbang-plugin-mcp
  • camel-jbang-plugin-route-parser
  • camel-jbang-plugin-tui
  • camel-jbang-plugin-validate
  • camel-launcher-container
  • camel-yaml-dsl-validator
  • camel-yaml-dsl-validator-maven-plugin
  • docs
Modules with tests skipped (28)
  • apache-camel
  • camel-allcomponents
  • camel-catalog
  • camel-catalog-console
  • camel-catalog-lucene
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-csimple-maven-plugin
  • camel-endpointdsl
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • dummy-component

ℹ️ Shadow mode — Scalpel observes but does not affect test execution. Learn more

All tested modules (38 modules)
  • Camel :: All Components Sync point
  • Camel :: Assembly
  • Camel :: Azure :: ServiceBus
  • Camel :: Catalog :: CSimple Maven Plugin (deprecated)
  • Camel :: Catalog :: Camel Catalog
  • Camel :: Catalog :: Camel Report Maven Plugin
  • Camel :: Catalog :: Camel Route Parser
  • Camel :: Catalog :: Console
  • Camel :: Catalog :: Dummy Component
  • Camel :: Catalog :: Lucene (deprecated)
  • Camel :: Catalog :: Maven
  • Camel :: Catalog :: Suggest
  • Camel :: Component DSL
  • Camel :: Coverage
  • Camel :: Docs
  • Camel :: Endpoint DSL
  • Camel :: Endpoint DSL :: Support
  • Camel :: Integration Tests
  • Camel :: JBang :: Core
  • Camel :: JBang :: Integration tests
  • Camel :: JBang :: MCP
  • Camel :: JBang :: Main
  • Camel :: JBang :: Plugin :: Edit
  • Camel :: JBang :: Plugin :: Generate
  • Camel :: JBang :: Plugin :: Kubernetes
  • Camel :: JBang :: Plugin :: MCP
  • Camel :: JBang :: Plugin :: Route Parser
  • Camel :: JBang :: Plugin :: TUI
  • Camel :: JBang :: Plugin :: Testing
  • Camel :: JBang :: Plugin :: Validate
  • Camel :: Kamelet Main
  • Camel :: Launcher
  • Camel :: Launcher :: Container
  • Camel :: YAML DSL
  • Camel :: YAML DSL :: Deserializers
  • Camel :: YAML DSL :: Maven Plugins
  • Camel :: YAML DSL :: Validator
  • Camel :: YAML DSL :: Validator Maven Plugin

⚙️ View full build and test results

@davsclaus
davsclaus requested a review from gnodet July 22, 2026 09:07

@gnodet gnodet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fundamental design issue with session lock contention

The SessionLockRenewer architecture has a critical flaw: acceptSession(sessionId).block() will always fail when called from inside the processMessage callback because the ServiceBusProcessorClient already holds an exclusive session lock on that session via its internal session receiver.

Azure Service Bus enforces exclusive session locks — only one receiver can hold a lock on a session at a time (Microsoft docs). A second acceptSession() call for the same session will receive a SessionCannotBeLockedException after exhausting retry attempts (~4 minutes with default AmqpRetryOptions). The catch block silently swallows this exception and returns null, causing add() to skip lock renewal registration — the feature silently does nothing in production.

The unit tests mask this because they mock acceptSession() to return immediately: when(sessionRenewalClient.acceptSession(any())).thenReturn(Mono.just(sessionBoundReceiver)).

Additional confirmed findings

  1. The feature may be unnecessary: The Azure SDK documentation for ServiceBusSessionProcessorClientBuilder.maxAutoLockRenewDuration states that session lock renewal runs as a background task independent of callback duration — unlike per-message locks. The existing createServiceBusSessionProcessorClient() already configures maxAutoLockRenewDuration on the session processor builder.

  2. Redundant API calls (medium): lockedUntil is tracked per-exchange rather than per-session. After a successful renewSessionLock(), only the triggering exchange's entry is updated. Other entries for the same session retain the stale value, producing N redundant API calls per renewal cycle.

  3. Missing timeout on block() (medium): acceptSession(sessionId).block() has no explicit timeout. Under contention, this blocks the processMessage callback thread for ~4 minutes before failing.

  4. cancel() doesn't clear entries map (low): Minor cleanup — cancel() clears sessionReceivers but not entries. Low impact since the renewer is set to null immediately after.

Recommendation

This needs to be verified against a real Azure Service Bus session-enabled queue before merging. Alternative approaches to consider:

  • Rely on the SDK's built-in session lock renewal via maxAutoLockRenewDuration (may already be sufficient)
  • Switch from ServiceBusProcessorClient to a lower-level ServiceBusSessionReceiverAsyncClient for session consumers so Camel controls the session lifecycle directly

The architecture (reference counting, synchronized acquire/release, cleanup in doStop/doShutdown) is well-structured and the tests are thorough — the issue is with the fundamental approach, not the implementation quality.

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

Claude Code on behalf of Guillaume Nodet

@atiaomar1978-hub
atiaomar1978-hub marked this pull request as draft July 22, 2026 16:20
@atiaomar1978-hub

Copy link
Copy Markdown
Contributor Author

Converting to draft — this PR is likely unnecessary, and the current approach cannot work as designed

Thank you @gnodet and @davsclaus for the careful reviews. After digging into the Azure Service Bus SDK (azure-messaging-servicebus 7.17.16), I've confirmed @gnodet's CHANGES_REQUESTED finding is correct on both counts, so I'm converting this to draft rather than pushing a patch that wouldn't address the fundamental issue. Full explanation below.

1. The acceptSession() approach cannot work while the session processor holds the session

Azure Service Bus enforces exclusive session locks — only one receiver may hold a session's lock at a time (Microsoft docs). In this PR, the ServiceBusProcessorClient (built via sessionProcessor()) already holds the lock for each active session. When SessionLockRenewer.acquireSessionReceiver() then calls sessionRenewalClient.acceptSession(sessionId).block() for that same session, the broker rejects it with SessionCannotBeLockedException after exhausting the retry policy (~4 minutes with the default AmqpRetryOptions).

The catch block swallows that exception and returns null, so add() skips renewal registration entirely — meaning the feature would silently do nothing in production. The unit tests pass only because they mock acceptSession() to return immediately:

when(sessionRenewalClient.acceptSession(any())).thenReturn(Mono.just(sessionBoundReceiver));

So the tests validate the bookkeeping, not the one behaviour that actually determines whether the feature works.

2. The feature is very likely unnecessary — the SDK already renews session locks in the background

This is the key difference from CAMEL-23937. For per-message locks, the SDK's auto-renewal is driven by the message-processing pipeline, so async Camel routes (which return from processMessage immediately) lose renewal mid-processing — that was the real bug CAMEL-23937 fixed.

Session locks behave differently. The SDK begins session-lock renewal at session-acquisition time, not per message:

  • SessionsMessagePump creates a ServiceBusSessionReactorReceiver for each rolling session (passing maxSessionLockRenew).
  • That receiver's constructor calls session.beginLockRenew(tracer, maxSessionLockRenew) — a background renewal task tied to the session's lifetime, independent of any processMessage callback duration.
  • maxSessionLockRenew is fed from ServiceBusSessionProcessorClientBuilder.maxAutoLockRenewDuration(...), which Camel already sets in ServiceBusClientFactory.createServiceBusSessionProcessorClient().

Microsoft's docs for the session processor builder confirm this: "maxAutoLockRenewDuration controls how long the background renewal task runs ... the client will have to continuously renew the session lock before its expiration." It is a background task, not one gated on callback return.

In other words, for session-enabled consumers the session lock is already kept alive for maxAutoLockRenewDuration regardless of how quickly processMessage returns — so the gap CAMEL-23937 closed for message locks does not appear to exist for session locks.

Why I'm not just patching the minor items

The other review points (per-session vs per-exchange lockedUntil tracking, missing block() timeout, cancel() not clearing the entries map) are all trivially fixable — but none of them change conclusions (1) or (2). There's no small change that makes a second acceptSession() succeed against a session the processor already owns.

Proposed path forward

I believe CAMEL-23938 should be closed as "Not a Problem": the SDK's session processor already renews session locks in the background via maxAutoLockRenewDuration, which Camel configures. If a real-world case shows session locks still expiring for async session consumers, the correct fix would be a redesign that switches session consumers from ServiceBusProcessorClient to a lower-level ServiceBusSessionReceiverAsyncClient so Camel owns the session lifecycle directly — a much larger change that would need validation against a live session-enabled queue.

@davsclaus @gnodet — could you confirm this reasoning before I close the JIRA? I've moved the PR to draft in the meantime. Happy to close it entirely once you agree.


Claude Opus 4.8 on behalf of atiaomar1978-hub. This analysis was produced by an AI agent and may contain inaccuracies; please verify before acting.

@atiaomar1978-hub
atiaomar1978-hub requested a review from gnodet July 22, 2026 16:23
@davsclaus

Copy link
Copy Markdown
Contributor

okay lets close this and resolve that jira as not a problem

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants