Skip to content

Background-job span gating, deferred arm handle, and jittered poll pacing - #96

Merged
yilmaztayfun merged 3 commits into
masterfrom
feature/background-job-pacing-and-arm-handle
Aug 21, 2026
Merged

Background-job span gating, deferred arm handle, and jittered poll pacing#96
yilmaztayfun merged 3 commits into
masterfrom
feature/background-job-pacing-and-arm-handle

Conversation

@yilmaztayfun

@yilmaztayfun yilmaztayfun commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Three related changes in the background-job and polling area, all driven by measurements taken while investigating why workflow transitions were slow and occasionally stranded.

vNext needs the IBackgroundJobArmHandle API from this PR, so a release is required before the vNext side can be merged.

1. Gate BackgroundJob.* producer spans as diagnostic

Enqueue, Update, Delete, Schedule, Schedule.OneShot, Schedule.Delete and Dispatch called Source.StartActivity directly, bypassing the Verbose gate the rest of the infrastructure instrumentation uses. They exported in the default Business profile, adding depth to traces that are already deep with transition chains and subflows.

BackgroundJob.Execute is deliberately left visible — it is a real service boundary and the ambient parent of the job's work.

2. Deferred arm handle

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 UoW's completion, and both land inside the caller's lock.

Measured on a vNext workflow accept path under load, that external call was the lock hold time — arming p50 214 ms of a 198 ms median hold — serialising every other request on the same instance behind an external round-trip.

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

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

3. Poll pacing: jitter, and errors no longer stall the fleet

Both problems 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 — polled in lockstep. That costs twice: the fleet loses the staggering that makes N replicas pick work up ~N times sooner than one, and every tick becomes a burst of simultaneous claim queries over the same rows. Every delay is now jittered ±25%, plus a random startup offset within the idle interval.

Errors jumped straight to MaxPollingInterval. One transient fault — a brief database hiccup, a single poison message — stalled every replica for a full maximum interval, exactly 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; repeated errors still escalate to the cap.

The rules moved 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.

Testing

  • BBT.Aether.Infrastructure.Tests: 170 passed / 0 failed (was 161; +9).
  • The existing AdaptivePollingTests exercised a private copy of the delay arithmetic, 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.
  • Verified end-to-end against a local vNext stack (orchestration + execution + inbox + outbox workers) consuming a locally packed build of this branch: integration suite 27 passed / 3 failed, identical to the pre-change baseline including the same 3 pre-existing failures. Zero background service error entries in either worker.
  • Deferred arm verified from the database: every accepted transition's job row reached Completed, none stranded in Pending.

Reviewer notes

  • The Enqueue/Update/Delete span gating rides in commit 2 rather than commit 1 because it lives in the same file as the arm handle; splitting it would not have been worth the churn.
  • IBackgroundJobService gains one method. Existing implementors outside this repo would need it — worth confirming there are none before merge.
  • One measurement caveat: gating BackgroundJob.Schedule as diagnostic removes the span that was used to verify "is arming inside the lock" from Business-profile traces. That check now needs Verbose, or the job-row status as a proxy. Worth deciding whether that span should stay visible.

🤖 Generated with Claude Code

Summary by Sourcery

Decouple background-job persistence from scheduler arming and make worker polling more resilient and evenly distributed.

New Features:

  • Add a deferred background-job arming API that persists a job and lets callers invoke scheduler arming after leaving a critical section.

Bug Fixes:

  • Prevent transient polling errors from stalling all worker replicas at the maximum polling interval.

Enhancements:

  • Reduce default business-trace noise by marking background-job producer, scheduler, and dispatch spans as diagnostic.
  • Improve replica polling distribution with startup offsets and ±25% jitter across inbox, outbox, and background-job arming loops.
  • Centralize polling-delay behavior and test the production pacing rules, including backoff floors, caps, jitter, and startup offsets.

Tests:

  • Expand adaptive polling coverage to validate jittered pacing, error recovery, and startup staggering.

yilmaztayfun and others added 3 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>
@yilmaztayfun
yilmaztayfun requested review from a team August 21, 2026 01:25
@sourcery-ai

sourcery-ai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduces a deferred background job arm handle and API, gates background job producer/scheduler/dispatcher spans as diagnostic-only, and centralizes adaptive, jittered polling delay logic (including startup offsets and improved error handling) for background job arming, inbox, and outbox services with updated tests.

Sequence diagram for deferred background job arm handle

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

    Caller->>BackgroundJobService: EnqueueWithDeferredArmAsync(handlerName, jobName, payload, schedule, ...)
    activate BackgroundJobService
    BackgroundJobService->>BackgroundJobService: EnqueueCoreAsync(handlerName, jobName, payload, schedule, metadata, failurePolicyOptions, directly: true, jobId, kind, deferArm: true, cancellationToken)
    BackgroundJobService->>JobStore: SaveAsync(jobInfo, cancellationToken)
    JobStore-->>BackgroundJobService: job persisted with Status Scheduled
    BackgroundJobService-->>Caller: DeferredArmHandle(JobId, ArmAsync)
    deactivate BackgroundJobService

    Note over Caller,DeferredArmHandle: later, outside critical section
    Caller->>DeferredArmHandle: ArmAsync(cancellationToken)
    activate DeferredArmHandle
    DeferredArmHandle->>BackgroundJobService: ArmNowAsync(handlerName, jobName, schedule, payloadBytes, failurePolicyOptions, JobId, cancellationToken)
    BackgroundJobService->>Scheduler: ScheduleJobAsync(...)
    Scheduler-->>BackgroundJobService: result
    BackgroundJobService-->>DeferredArmHandle: Task completed (or row rolled back to Pending)
    deactivate DeferredArmHandle
Loading

File-Level Changes

Change Details Files
Add deferred background job arm handle API and implementation to allow arming outside critical sections without changing enqueue semantics.
  • Introduced IBackgroundJobArmHandle interface that encapsulates deferred arming of an already-persisted job and exposes JobId and ArmAsync.
  • Extended IBackgroundJobService with EnqueueWithDeferredArmAsync that persists a job like EnqueueAsync(directly: true) but returns an arm handle instead of calling the scheduler immediately.
  • Refactored BackgroundJobService.EnqueueAsync into a shared EnqueueCoreAsync that returns the job id plus an optional arm closure, and added DeferredArmHandle to invoke that closure later while preserving existing behaviour when deferArm is false.
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
Gate background job producer, scheduler, and dispatch spans as diagnostic rather than business-level traces.
  • Replaced direct InfrastructureActivitySource.Source.StartActivity calls in enqueue, update, delete, and dispatch paths with InfrastructureActivitySource.StartDiagnosticActivity to mark them as diagnostic.
  • Updated DaprJobScheduler to start client activities via StartDiagnosticActivity so Schedule/Delete round-trips no longer appear as business spans.
  • Left BackgroundJob.Execute spans unchanged to remain as the main business-visible boundary for job execution.
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, jittered polling delay logic and apply it to background job arming, inbox, and outbox background services.
  • Added internal PollingDelay helper that encapsulates OnProcessed, OnEmpty, OnError, Jitter, and StartupOffset behaviours with jitter fraction and floors and uses Random.Shared.
  • Updated BackgroundJobArmingHostedService to apply a random startup offset and jittered fixed arming interval via PollingDelay, while keeping no adaptive backoff semantics for arming.
  • Updated InboxBackgroundService and OutboxBackgroundService to use PollingDelay for startup offsets, exponential backoff for empty rounds, moderated backoff on errors (no immediate jump to max), and jittered delays instead of deterministic doubling.
  • Reworked AdaptivePollingTests to exercise PollingDelay directly (including jitter, error floor, escalation, and startup offset) ensuring test coverage matches production pacing rules.
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
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

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b4d02f37-270f-4638-bd6b-fe433dc82231


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.

@yilmaztayfun yilmaztayfun self-assigned this Aug 21, 2026
@yilmaztayfun
yilmaztayfun merged commit aeb01ad into master Aug 21, 2026
5 of 6 checks passed

@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 reviewed your changes and they look great!


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.

@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.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
0.0% Coverage on New Code (required ≥ 80%)
12.9% Duplication on New Code (required ≤ 3%)
B Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

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