Skip to content

v1.0.36 - #97

Merged
yilmaztayfun merged 4 commits into
release-v1.0from
master
Aug 21, 2026
Merged

v1.0.36#97
yilmaztayfun merged 4 commits into
release-v1.0from
master

Conversation

@yilmaztayfun

@yilmaztayfun yilmaztayfun commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary by Sourcery

Improve background-job scheduling and event polling to reduce lock contention, desynchronize replicas, and make transient failures recover more quickly.

New Features:

  • Add deferred background-job arming so callers can persist jobs and trigger scheduler registration after critical work completes.

Bug Fixes:

  • Prevent polling replicas from repeatedly synchronizing their work and avoid lengthy stalls after transient polling failures.

Enhancements:

  • Standardize adaptive polling with startup offsets, jitter, capped backoff, and error recovery across inbox, outbox, and background-job arming loops.
  • Classify background-job enqueue, update, delete, scheduling, and dispatch spans as diagnostic infrastructure telemetry.

Tests:

  • Expand polling tests to cover adaptive backoff, error handling, jitter, and startup staggering.

yilmaztayfun and others added 4 commits August 21, 2026 04:23
…stic

BackgroundJob.Schedule, .Schedule.OneShot, .Schedule.Delete and .Dispatch called
Source.StartActivity directly, bypassing the Verbose gate that the rest of the
infrastructure instrumentation goes through. They therefore exported in the
default Business profile, adding depth to traces that are already deep with
transition chains and subflows without telling the reader anything the job's own
execution span does not.

Route them through InfrastructureActivitySource.StartDiagnosticActivity, which is
the existing mechanism for exactly this.

BackgroundJob.Execute is deliberately left alone: it is a real service boundary
and the ambient parent of the work the job performs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A caller that must persist a job inside a critical section had no way to keep the
scheduler round-trip out of it. EnqueueAsync either armed inline or on the ambient
unit of work's completion, and both land inside the caller's lock. Measured on a
workflow accept path, that external call WAS the lock hold time under load
(p50 214 ms of a 198 ms hold), serialising every other request on the same
instance behind it.

EnqueueWithDeferredArmAsync persists with the same semantics as directly: true —
the row lands Scheduled, an arm failure rolls it back to Pending for the arming
poller — and returns an IBackgroundJobArmHandle instead of calling the scheduler.
The handle closes over the scheduler arguments captured at enqueue time, so arming
later costs one scheduler call: no job-row read, no extra status write.

EnqueueAsync's body moved to a shared private core; its behaviour is unchanged.

This commit also gates the Enqueue/Update/Delete producer spans as diagnostic, for
the same reason as the scheduler and dispatch spans: they live in this file and
splitting them into their own commit is not worth the churn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… fleet

Two problems that only bite with several replicas, which is how these workers run
(10 each in nonprod and prod).

No jitter. The backoff was a deterministic doubling, so pods started together —
what a rolling deployment produces — poll in lockstep. That costs twice: the fleet
loses the natural staggering that makes N replicas pick work up roughly N times
sooner than one, and every tick becomes a burst of simultaneous claim queries over
the same rows. Every delay is now jittered by +/-25%, and each loop takes a random
startup offset within its idle interval so the first pass is spread too.

Errors jumped straight to MaxPollingInterval. One transient fault — a brief
database hiccup, a single poison message — stalled every replica for a full
maximum interval, right when it was least affordable. An error now backs off one
step like an empty round, floored at the idle interval so a hard failure is not
retried at the busy cadence, and repeated errors still escalate to the cap.

The pacing rules move into PollingDelay, shared by the outbox, inbox and
background-job arming loops. The arming loop has no adaptive backoff by design —
an unarmed job must be claimed within a bounded time — which makes jitter the only
thing keeping its replicas apart.

The existing AdaptivePollingTests exercised a private copy of the delay arithmetic
rather than the production code, so a change to the real rules could not fail it.
They now drive PollingDelay itself, with coverage for jitter, the error floor and
the startup offset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-and-arm-handle

Background-job span gating, deferred arm handle, and jittered poll pacing
@yilmaztayfun yilmaztayfun self-assigned this Aug 21, 2026
@yilmaztayfun
yilmaztayfun requested review from a team August 21, 2026 01:26
@sourcery-ai

sourcery-ai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds a deferred background-job arming path, centralizes adaptive polling/jitter logic for inbox/outbox/arming loops, and reclassifies various background-job and scheduler spans as diagnostic tracing rather than business spans.

Sequence diagram for deferred background-job arming

sequenceDiagram
    actor Caller
    participant BackgroundJobService
    participant JobStore
    participant DeferredArmHandle as IBackgroundJobArmHandle
    participant Scheduler

    Caller->>BackgroundJobService: EnqueueWithDeferredArmAsync(handlerName, jobName, payload, schedule)
    BackgroundJobService->>JobStore: SaveAsync(jobInfo, cancellationToken)
    BackgroundJobService-->>Caller: IBackgroundJobArmHandle

    Caller->>DeferredArmHandle: ArmAsync(cancellationToken)
    DeferredArmHandle->>BackgroundJobService: ArmNowAsync(handlerName, jobName, schedule, payloadBytes, failurePolicyOptions, jobId, cancellationToken)
    BackgroundJobService->>Scheduler: ScheduleAsync(handlerName, jobName, schedule, payloadBytes)
    Scheduler-->>BackgroundJobService: result
    BackgroundJobService-->>DeferredArmHandle: completion
    DeferredArmHandle-->>Caller: completion
Loading

Flow diagram for adaptive polling delay with jitter

flowchart TD
    A["Start polling loop"] --> B["Task.Delay(StartupOffset)"]
    B --> C["RunAsync"]
    C --> D{"RunAsync threw?"}
    D -- Yes --> E["delay = PollingDelay.OnError(delay, IdlePollingInterval, MaxPollingInterval)"]
    D -- No --> F{"processed > 0?"}
    F -- Yes --> G["delay = PollingDelay.OnProcessed(BusyPollingInterval)"]
    F -- No --> H["delay = PollingDelay.OnEmpty(delay, MaxPollingInterval)"]
    E --> I["Task.Delay(PollingDelay.Jitter(delay))"]
    G --> I
    H --> I
    I --> J["Next polling iteration"] --> C
Loading

File-Level Changes

Change Details Files
Introduce a deferred background-job arming API and internal plumbing so callers can enqueue within a critical section and arm later without re-reading the job row.
  • Add IBackgroundJobArmHandle interface exposing JobId and ArmAsync to represent a deferred arm of an already-persisted job.
  • Extend IBackgroundJobService with EnqueueWithDeferredArmAsync mirroring EnqueueAsync parameters but returning a deferred arm handle instead of arming inline.
  • Refactor BackgroundJobService.EnqueueAsync into a shared EnqueueCoreAsync that returns (JobId, Arm) and use flags (directly, deferArm) to decide whether to arm inline, defer via a closure, or skip arming.
  • Create DeferredArmHandle inner class in BackgroundJobService capturing scheduler arguments in a Func<CancellationToken,Task> so ArmAsync uses the same ArmNowAsync path without extra DB reads or status updates.
  • Ensure ambient and non-ambient UoW paths both return the tuple (effectiveJobId, armAction) and only arm inline when directly && !deferArm to keep deferred handles from double-arming.
framework/src/BBT.Aether.Core/BBT/Aether/BackgroundJob/IBackgroundJobArmHandle.cs
framework/src/BBT.Aether.Core/BBT/Aether/BackgroundJob/IBackgroundJobService.cs
framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs
Reclassify background-job and scheduler tracing spans as diagnostic-only using InfrastructureActivitySource.StartDiagnosticActivity.
  • Replace InfrastructureActivitySource.Source.StartActivity calls in enqueue, update, and delete paths with InfrastructureActivitySource.StartDiagnosticActivity to keep these spans out of the default business profile.
  • Update DaprJobScheduler.StartSchedulerActivity to use StartDiagnosticActivity for Schedule/Delete operations.
  • Adjust JobDispatcher dispatch tracing to use StartDiagnosticActivity and document that it is purely plumbing between job execution and the handler.
framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs
framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/Dapr/DaprJobScheduler.cs
framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/JobDispatcher.cs
Centralize adaptive polling and jitter logic across arming, inbox, and outbox background services via a new PollingDelay helper.
  • Introduce internal PollingDelay utility with OnProcessed, OnEmpty, OnError, Jitter, and StartupOffset methods, including configurable jitter fraction and floors to avoid non-positive delays.
  • Update BackgroundJobArmingHostedService to use PollingDelay.StartupOffset before entering the loop and PollingDelay.Jitter(options.ArmingInterval) between passes, and document why jitter and fixed intervals are used.
  • Change InboxBackgroundService to use PollingDelay for startup offset, exponential backoff on empty rounds, busy interval on work, OnError for fault handling, and jitter on each delay; remove the local Min helper.
  • Apply the same PollingDelay-based pacing to OutboxBackgroundService, including jittered delays and non-maximizing error backoff, and remove duplicated delay logic.
framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/Processing/BackgroundJobArmingHostedService.cs
framework/src/BBT.Aether.Infrastructure/BBT/Aether/Events/Processing/InboxBackgroundService.cs
framework/src/BBT.Aether.Infrastructure/BBT/Aether/Events/Processing/OutboxBackgroundService.cs
framework/src/BBT.Aether.Infrastructure/BBT/Aether/Polling/PollingDelay.cs
Align tests with the new PollingDelay-based adaptive polling rules.
  • Replace the old NextDelay helper and Min logic in AdaptivePollingTests with direct calls into PollingDelay to pin the production pacing behavior.
  • Add coverage for OnProcessed, OnEmpty (including capping), OnError (backoff vs. cap, and floor at idle interval), repeated error escalation, jitter scaling range, jitter floor behavior, replica phase separation, and StartupOffset bounds.
framework/test/BBT.Aether.Infrastructure.Tests/BBT/Aether/Events/Processing/OutboxBackgroundServiceTests.cs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@yilmaztayfun
yilmaztayfun merged commit 0b2f6d1 into release-v1.0 Aug 21, 2026
3 of 5 checks passed
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8b6aed03-a615-48b5-906f-c8051c8da5a1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 27 complexity · 0 duplication

Metric Results
Complexity 27
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • In EnqueueCoreAsync the tuple element Arm is nullable but is immediately dereferenced with arm! when creating DeferredArmHandle; consider making the delegate non-nullable when deferArm is true (e.g., via branching or a Debug.Assert) to avoid relying on the null-forgiving operator for correctness.
  • The XML doc on IBackgroundJobArmHandle.ArmAsync promises that it "never throws", but the implementation simply forwards to the captured ArmNowAsync delegate; it would be good to double-check that this delegate always swallows/logs exceptions so that the observable behavior actually matches the documented contract.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `EnqueueCoreAsync` the tuple element `Arm` is nullable but is immediately dereferenced with `arm!` when creating `DeferredArmHandle`; consider making the delegate non-nullable when `deferArm` is true (e.g., via branching or a Debug.Assert) to avoid relying on the null-forgiving operator for correctness.
- The XML doc on `IBackgroundJobArmHandle.ArmAsync` promises that it "never throws", but the implementation simply forwards to the captured `ArmNowAsync` delegate; it would be good to double-check that this delegate always swallows/logs exceptions so that the observable behavior actually matches the documented contract.

## Individual Comments

### Comment 1
<location path="framework/src/BBT.Aether.Infrastructure/BBT/Aether/Polling/PollingDelay.cs" line_range="31-40" />
<code_context>
+    internal static TimeSpan OnEmpty(TimeSpan current, TimeSpan max) => MinOf(Double(current), max);
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard against TimeSpan ticks overflow when doubling delays.

`Double()` uses `TimeSpan.FromTicks(value.Ticks * 2)`, which can overflow `long` for very large values (near `TimeSpan.MaxValue`) and throw. To keep the backoff robust against misconfiguration, clamp the doubled value to `TimeSpan.MaxValue` or the provided `max` before creating the new `TimeSpan`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +31 to +40
internal static TimeSpan OnEmpty(TimeSpan current, TimeSpan max) => MinOf(Double(current), max);

/// <summary>
/// The delay after a failed round. Backs off one step like an empty round, but never below
/// <paramref name="idleInterval"/> so a hard failure is not retried at the busy cadence.
/// <para>
/// Deliberately NOT a jump straight to <paramref name="max"/>. That is what the loops used to do,
/// and with several replicas a single transient fault — a brief database hiccup, one poison
/// message — stalled the entire fleet for a full maximum interval, right when it was most needed.
/// Escalating instead keeps a one-off blip cheap while a persistent fault still ends up at the cap.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Guard against TimeSpan ticks overflow when doubling delays.

Double() uses TimeSpan.FromTicks(value.Ticks * 2), which can overflow long for very large values (near TimeSpan.MaxValue) and throw. To keep the backoff robust against misconfiguration, clamp the doubled value to TimeSpan.MaxValue or the provided max before creating the new TimeSpan.

@sonarqubecloud

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant