Skip to content

[improve][offload] Coalesce automatic offload triggers to reduce retry loops and ledger scans - #25793

Merged
lhotari merged 2 commits into
apache:masterfrom
void-ptr974:fix_ml_auto_offload_coalesce
Jun 3, 2026
Merged

[improve][offload] Coalesce automatic offload triggers to reduce retry loops and ledger scans#25793
lhotari merged 2 commits into
apache:masterfrom
void-ptr974:fix_ml_auto_offload_coalesce

Conversation

@void-ptr974

@void-ptr974 void-ptr974 commented May 16, 2026

Copy link
Copy Markdown
Contributor

Related issue

Fixes #25859

Motivation

Automatic managed ledger offload can be triggered repeatedly while a previous offload is still running, for example around ledger rollover or topic load.

Before this change, every automatic trigger could independently enter the offload path:

  1. read offload policies
  2. scan the managed ledger's ledger list
  3. try to acquire the offload mutex
  4. fail because another offload is already running
  5. schedule another retry after 100ms

When offload is slow, or when a managed ledger has many ledgers, repeated automatic triggers can build up unnecessary scheduler/executor work. These retries do not improve the final offload
result because only one offload can run at a time.

Modifications

This change coalesces automatic offload triggers in ManagedLedgerImpl.

After this change:

  • there is at most one in-flight automatic offload
  • repeated automatic triggers during an in-flight offload are merged into one pending rerun
  • after the current automatic offload completes, one follow-up pass runs if any trigger arrived meanwhile
  • explicit/manual offload requests keep their existing CompletableFuture<Position> behavior
  • the automatic offload sentinel is renamed to make it clear that its Position value is not consumed
  • duplicate getOffloadPolicies() calls are avoided in the appendable offloader policy lookup path

Impact

The final automatic offload progression is preserved: if new ledgers become eligible while an offload is running, the follow-up pass still picks them up.

The main benefit is reducing unnecessary work under slow offload or large-ledger workloads:

  • avoids repeated 100ms automatic offload retry loops
  • reduces redundant offload policy reads
  • reduces redundant ledger-list scans
  • lowers scheduler and executor pressure while offload is already in progress
  • keeps explicit/manual offload behavior unchanged

In practice, repeated automatic triggers while one offload is running are reduced from many independent retry loops to one active run plus one pending rerun.

Verifying this change

  • Make sure that the change passes the CI checks.

This change added tests and can be verified as follows:

  • Added a test that repeated automatic triggers during an in-flight offload do not create independent retry loops.
  • Added a test that a coalesced automatic trigger causes one follow-up offload pass.
  • Added a test that automatic offload state is released when offload thresholds are disabled, so later valid triggers can still run.

Local verification:

git diff --check
./gradlew :managed-ledger:test --tests org.apache.bookkeeper.mledger.impl.OffloadPrefixTest
./gradlew :managed-ledger:test --tests org.apache.bookkeeper.mledger.impl.OffloadLedgerDeleteTest

Does this pull request potentially affect one of the following parts:

  • Dependencies (add or upgrade a dependency)
  • The public API
  • The schema
  • The default values of configurations
  • The threading model
  • The binary protocol
  • The REST endpoints
  • The admin CLI options
  • The metrics
  • Anything that affects deployment

This only changes the internal scheduling behavior of automatic managed ledger offload. Public APIs, configs, metrics, explicit/manual offload behavior, and offload metadata semantics are
unchanged.

  Automatic offload can be triggered repeatedly while a previous offload is still
  running. Each trigger may read offload policies, scan the ledger list, fail to
  acquire the offload mutex, and schedule another 100ms retry. With slow offload or
  many ledgers, this can create unnecessary scheduler and executor pressure.

  Coalesce automatic triggers so there is at most one in-flight automatic offload
  and one pending rerun. If another trigger arrives during an in-flight offload,
  run one follow-up pass after the current offload completes. This keeps the final
  offload progression behavior while avoiding repeated retry loops, policy reads,
  and ledger scans.

  Keep explicit offload requests unchanged, and rename the automatic sentinel to
  make it clear that its Position value is not consumed.

  Add tests for trigger coalescing, coalesced reruns, and automatic state release
  when offload thresholds are disabled.
@void-ptr974 void-ptr974 changed the title [improve][ml] Coalesce automatic offload triggers to reduce retry loops and ledger scans [improve][offload] Coalesce automatic offload triggers to reduce retry loops and ledger scans May 16, 2026
@lhotari

lhotari commented Jun 2, 2026

Copy link
Copy Markdown
Member

The following are findings from a local Claude Code review of this PR (human-reviewed before posting). Sharing them here in case they're useful — please treat them as review input, not blockers to be applied verbatim.

Summary

The change does what it claims: automatic offload triggers (from ledgerClosed and factory init) are funneled through the AUTOMATIC_OFFLOAD_TRIGGER identity sentinel and gated by two AtomicBooleans so at most one automatic offload runs with at most one pending rerun coalesced behind it. Explicit/manual callers keep their previous CompletableFuture<Position> semantics, and the getOffloadThresholds() extraction + getOffloadPoliciesIfAppendable() cleanup correctly remove the duplicate getOffloadPolicies() calls. Intent matches #25859 well.

There is one concurrency race worth addressing, plus a few robustness/quality notes.

1. (Bug) Lost-trigger race between finishAutomaticOffload and maybeOffloadInBackground

The two flags are read/written in a non-atomic check-then-act order:

// finishAutomaticOffload (offload finishing)        // maybeOffloadInBackground (new trigger arrives)
A: automaticOffloadInProgress.set(false);            C: if (!inProgress.compareAndSet(false,true)) {
B: if (rerunRequested.getAndSet(false)) { rerun }    D:     rerunRequested.set(true); return; }

Consider the interleaving C(fail) → A → B(reads false) → D:

  1. Producer C: CAS fails because inProgress is still true (offload still running) → producer will take the rerunRequested.set(true) branch.
  2. Consumer A: inProgress = false.
  3. Consumer B: getAndSet(false) reads false (producer hasn't reached D yet) → no rerun scheduled.
  4. Producer D: rerunRequested = true.

Final state: inProgress = false, rerunRequested = true, no active runner. The coalesced follow-up pass is dropped; it only self-heals on the next external automatic trigger (next ledgerClosed), whose finishAutomaticOffload then observes the stale rerunRequested and does a (now likely redundant) pass.

Impact is bounded for a continuously-rolling topic, but for a topic that goes idle right after the race, threshold-eligible ledgers can remain un-offloaded until the next write/rollover. This breaks the stated invariant from #25859 ("After the current automatic offload completes, one follow-up pass should run if any trigger arrived meanwhile").

The added tests are deterministic and exercise only the happy path, so none reproduces this interleaving. Suggested direction: a single state machine (e.g. an AtomicInteger/enum state IDLE → RUNNING → RUNNING_PENDING) or a re-check-and-re-acquire loop in finishAutomaticOffload after clearing inProgress, validated with a stress / invocationCount concurrency test.

2. (Quality) Automatic offload failures are now logged at WARN where they were previously silenced

Previously every automatic trigger shared the already-completed NULL_OFFLOAD_PROMISE, so completeExceptionally(...) in maybeOffload (e.g. "offloadPolicies is NULL", thresholds < 0) was a no-op and produced no log. Now each automatic run gets a fresh completion that drives log.warn(...).log("Failed to automatically offload ledgers"). This is arguably an improvement in visibility, but it is a behavior change: races where the offloader is reconfigured/disabled between scheduling and execution will now emit WARN noise that didn't exist before. Worth a conscious decision (possibly DEBUG, or filtering the "disabled mid-flight" case).

3. (Quality) Any path that fails to complete the internal completion future strands automaticOffloadInProgress = true

automaticOffloadInProgress is only cleared via finishAutomaticOffload, which only runs when automaticOffloadCompletion completes. If executor.execute(...) throws (e.g. RejectedExecutionException on a shutting-down executor), the completion is never finished and automatic offload is permanently disabled for that managed ledger. The old code had no such latch — a rejected execute simply dropped one trigger. Low severity (mainly shutdown), but a try/finally or completing the internal future on the rejection path would make it robust.

4. (Quality) Tests rely on timing and don't cover the concurrency race

automaticOffloadTriggersAreCoalescedWhileOffloadInProgress uses Thread.sleep(300) plus an offloadPolicyCalls < baseline + 5 heuristic — fragile under load and not a tight assertion. automaticOffloadRunsAgainForCoalescedTrigger is the valuable one (verifies the follow-up pass picks up the second ledger), but it is deterministic and cannot catch finding #1. Consider an explicit concurrency test (many threads hammering maybeOffloadInBackground while an offload is in flight) to guard the coalescing invariant.

Minor / non-blocking

  • AUTOMATIC_OFFLOAD_TRIGGER is a process-wide public static final sentinel compared by == identity across all ManagedLedgerImpl instances. This is correct (its Position value is never consumed; per-instance state lives in the AtomicBooleans) and the renamed name + comment make the intent clear.

Overall: addressing #1 before merge would be the main ask, since it undermines the core guarantee the PR exists to provide; #2#4 are quality/robustness improvements.

🤖 Generated with a local Claude Code review.

@lhotari lhotari left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Great work @void-ptr974. Please check the local Claude Code review comment about the possible bug.

@void-ptr974

Copy link
Copy Markdown
Contributor Author

Thanks @lhotari. I moved the automatic offload coalescing into AutomaticOffloadTriggerController so the state transitions can be tested directly.

I also added a race test for the trigger/completion interleaving, kept integration coverage for the rerun behavior, and removed the old confusing sentinel alias.

@lhotari lhotari left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@lhotari lhotari added this to the 5.0.0-M1 milestone Jun 3, 2026
@lhotari
lhotari merged commit 7ecedb8 into apache:master Jun 3, 2026
42 of 44 checks passed
lhotari pushed a commit that referenced this pull request Jun 3, 2026
…y loops and ledger scans (#25793)

(cherry picked from commit 7ecedb8)
lhotari pushed a commit that referenced this pull request Jun 3, 2026
…y loops and ledger scans (#25793)

(cherry picked from commit 7ecedb8)
priyanshu-ctds pushed a commit to datastax/pulsar that referenced this pull request Jun 9, 2026
…y loops and ledger scans (apache#25793)

(cherry picked from commit 7ecedb8)
(cherry picked from commit dd8ce54)
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.

[Improve][Offload] Automatic managed ledger offload triggers can create redundant retry loops

2 participants