Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -0,0 +1,33 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace BBT.Aether.BackgroundJob;

/// <summary>
/// A deferred arm for an already-persisted background job: everything the external scheduler needs is
/// captured in memory, so arming later costs exactly one scheduler call — no re-read of the job row and
/// no extra status write.
/// <para>
/// Exists for callers that must persist the job inside a critical section but cannot afford to make the
/// scheduler round-trip there. Holding a distributed lock across an external call makes that call the
/// lock's hold time, serializing every other contender behind it.
/// </para>
/// <para>
/// The row is already <c>Scheduled</c> when the handle is issued — optimistically, because the common
/// case succeeds. <see cref="ArmAsync"/> reconciles a failure by rolling the row back to
/// <c>Pending</c> so the arming poller reclaims it, which is the same contract the inline arm has.
/// </para>
/// </summary>
public interface IBackgroundJobArmHandle
{
/// <summary>The id of the persisted job this handle arms.</summary>
Guid JobId { get; }

/// <summary>
/// Arms the job in the external scheduler. Never throws: a failure is logged and the row is rolled
/// back to <c>Pending</c> for the arming poller. Safe to call once; calling it again re-schedules
/// the same job name, which the scheduler treats as an overwrite.
/// </summary>
Task ArmAsync(CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,17 @@
/// </returns>
/// <exception cref="ArgumentNullException">Thrown when any required parameter is null.</exception>
/// <exception cref="InvalidOperationException">Thrown when the job scheduling service is unavailable.</exception>
Task<Guid> EnqueueAsync<TPayload>(
string handlerName,
string jobName,
TPayload payload,
string schedule,
Dictionary<string, object>? metadata = null,
JobScheduleFailurePolicy? failurePolicyOptions = null,
bool directly = false,
Guid? jobId = null,
BBT.Aether.Domain.Entities.JobKind? kind = null,
CancellationToken cancellationToken = default);

Check warning on line 67 in framework/src/BBT.Aether.Core/BBT/Aether/BackgroundJob/IBackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method has 10 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wY1Wgrn0400v96m&open=AaAh7wY1Wgrn0400v96m&pullRequest=96

/// <summary>
/// Atomically updates the schedule of a Pending, Scheduled, or Retrying background job.
Expand Down Expand Up @@ -101,4 +101,33 @@
/// </returns>
/// <exception cref="ArgumentException">Thrown when id is empty.</exception>
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);

/// <summary>
/// Persists the job and returns a handle that arms it later, instead of arming as part of this call.
/// <para>
/// For callers that write the job inside a critical section (a distributed lock, a short
/// transaction) but must keep the scheduler round-trip out of it. Persisting is cheap and local;
/// the scheduler call is neither, and inside a lock it becomes the lock's hold time.
/// </para>
/// <para>
/// Same parameters and same persistence semantics as <see cref="EnqueueAsync{TPayload}"/> with
/// <c>directly: true</c> — the row lands <c>Scheduled</c> and an arm failure rolls it back to
/// <c>Pending</c> for the arming poller. The only difference is WHEN the scheduler is called, which
/// the caller now decides by invoking <see cref="IBackgroundJobArmHandle.ArmAsync"/>.
/// </para>
/// <para>
/// Call <c>ArmAsync</c> only after the work that justified the critical section has committed. The
/// handle carries the payload in memory, so arming costs one scheduler call and no database access.
/// </para>
/// </summary>
Task<IBackgroundJobArmHandle> EnqueueWithDeferredArmAsync<TPayload>(
string handlerName,
string jobName,
TPayload payload,
string schedule,
Dictionary<string, object>? metadata = null,
JobScheduleFailurePolicy? failurePolicyOptions = null,
Guid? jobId = null,
BBT.Aether.Domain.Entities.JobKind? kind = null,
CancellationToken cancellationToken = default);

Check warning on line 132 in framework/src/BBT.Aether.Core/BBT/Aether/BackgroundJob/IBackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method has 9 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wY2Wgrn0400v96n&open=AaAh7wY2Wgrn0400v96n&pullRequest=96
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,16 @@
/// Uses UoW pattern for transactional consistency.
/// Wraps job payloads in CloudEventEnvelope to carry schema context and metadata.
/// </summary>
public sealed class BackgroundJobService(
IJobStore jobStore,
IJobScheduler jobScheduler,
IUnitOfWorkManager uowManager,
IGuidGenerator guidGenerator,
IClock clock,
ICurrentSchema currentSchema,
IEventSerializer eventSerializer,
BackgroundJobOptions options,
ILogger<BackgroundJobService> logger)

Check warning on line 35 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Constructor has 9 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v96y&open=AaAh7wb6Wgrn0400v96y&pullRequest=96
: IBackgroundJobService
{
private const string Source = "urn:background-job";
Expand Down Expand Up @@ -78,6 +78,46 @@
Guid? jobId = null,
JobKind? kind = null,
CancellationToken cancellationToken = default)
{
var (enqueuedId, _) = await EnqueueCoreAsync(
handlerName, jobName, payload, schedule, metadata, failurePolicyOptions,
directly, jobId, kind, deferArm: false, cancellationToken);
return enqueuedId;
}

/// <inheritdoc/>
public async Task<IBackgroundJobArmHandle> EnqueueWithDeferredArmAsync<TPayload>(
string handlerName,
string jobName,
TPayload payload,
string schedule,
Dictionary<string, object>? metadata = null,
JobScheduleFailurePolicy? failurePolicyOptions = null,
Guid? jobId = null,
JobKind? kind = null,
CancellationToken cancellationToken = default)
{
// directly: true so the row lands Scheduled, exactly as the inline path leaves it — no later
// status write is needed. The one difference is that the scheduler is not called here; the
// returned handle calls it, carrying the payload in memory so no re-read is needed either.
var (deferredId, arm) = await EnqueueCoreAsync(
handlerName, jobName, payload, schedule, metadata, failurePolicyOptions,
directly: true, jobId, kind, deferArm: true, cancellationToken);
return new DeferredArmHandle(deferredId, arm!);
}

private async Task<(Guid JobId, Func<CancellationToken, Task>? Arm)> EnqueueCoreAsync<TPayload>(

Check failure on line 109 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v96z&open=AaAh7wb6Wgrn0400v96z&pullRequest=96
string handlerName,
string jobName,
TPayload payload,
string schedule,
Dictionary<string, object>? metadata,
JobScheduleFailurePolicy? failurePolicyOptions,
bool directly,
Guid? jobId,
JobKind? kind,
bool deferArm,
CancellationToken cancellationToken)

Check warning on line 120 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method has 11 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v962&open=AaAh7wb6Wgrn0400v962&pullRequest=96
{
if (string.IsNullOrWhiteSpace(handlerName))
throw new ArgumentNullException(nameof(handlerName));
Expand All @@ -85,13 +125,16 @@
if (string.IsNullOrWhiteSpace(jobName))
throw new ArgumentNullException(nameof(jobName));

if (payload == null)

Check warning on line 128 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use a comparison to 'default(TPayload)' instead or add a constraint to 'TPayload' so that it can't be a value type.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v961&open=AaAh7wb6Wgrn0400v961&pullRequest=96
throw new ArgumentNullException(nameof(payload));

if (string.IsNullOrWhiteSpace(schedule))
throw new ArgumentNullException(nameof(schedule));

using var activity = InfrastructureActivitySource.Source.StartActivity(
// Diagnostic, not business: enqueueing a job is plumbing around the work, and in the
// default Business profile it only adds depth to an already deep trace. The job's own
// execution span (BackgroundJob.Execute) is the one that carries meaning and stays.
using var activity = InfrastructureActivitySource.StartDiagnosticActivity(
"BackgroundJob.Enqueue",
ActivityKind.Producer,
Activity.Current?.Context ?? default);
Expand All @@ -100,9 +143,9 @@
activity?.SetTag("job.name", jobName);
activity?.SetTag("job.schedule", schedule);

logger.LogInformation(
"Enqueueing job handler '{HandlerName}' with job name '{JobName}' and schedule '{Schedule}'",
handlerName, jobName, schedule);

Check warning on line 148 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v96-&open=AaAh7wb6Wgrn0400v96-&pullRequest=96

// Create job entity. A caller-supplied id (when present) lets the caller reuse a single
// correlation id for its own tracking row; otherwise generate one.
Expand Down Expand Up @@ -147,7 +190,7 @@
ExtraProperties = extraProperties
};

// TODO(jobs): thread failurePolicyOptions through the arming poller. The poller currently arms

Check warning on line 193 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this 'TODO' comment.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v96x&open=AaAh7wb6Wgrn0400v96x&pullRequest=96
// with a default failure policy; the caller-supplied policy is not yet persisted/honored. Kept in
// the signature so callers don't break and so a later task can wire it through ExtraProperties.
// The `directly` path below DOES honor failurePolicyOptions, via ScheduleAsync.
Expand All @@ -155,30 +198,37 @@
// Bytes for the scheduler (the `directly` arm path). Equivalent to the JSON the poller arms with.
var payloadBytes = eventSerializer.Serialize(envelope);

// Deferred arm: hand the caller a closure over everything the scheduler needs. Same call the
// inline path makes, same failure handling — only the timing is the caller's to choose.
Func<CancellationToken, Task>? armAction = deferArm
? ct => ArmNowAsync(handlerName, jobName, schedule, payloadBytes,
failurePolicyOptions, effectiveJobId, ct)
: null;

// Atomic-ambient: when the caller has an ambient UoW, persist into it (commits with their business
// transaction — a rollback discards the row). Otherwise open a short own transaction.
if (uowManager.Current is { } ambient)
{
await jobStore.SaveAsync(jobInfo, cancellationToken);
if (directly)
if (directly && !deferArm)
ambient.OnCompleted(_ => ArmNowAsync(handlerName, jobName, schedule, payloadBytes,
failurePolicyOptions, effectiveJobId, CancellationToken.None));
logger.LogInformation(
"Enqueued {Status} job '{HandlerName}'/'{JobName}' into ambient UoW. Id: {Id}",
jobInfo.Status, handlerName, jobName, effectiveJobId);

Check warning on line 218 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v96_&open=AaAh7wb6Wgrn0400v96_&pullRequest=96
activity?.SetStatus(ActivityStatusCode.Ok);
return effectiveJobId;
return (effectiveJobId, armAction);
}

await using (var uow = uowManager.Begin(
new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, IsTransactional = true }))

Check warning on line 224 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Await BeginAsync instead.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v963&open=AaAh7wb6Wgrn0400v963&pullRequest=96
{
try
{
await jobStore.SaveAsync(jobInfo, cancellationToken);
await uow.CommitAsync(cancellationToken);
}
catch (Exception ex)

Check warning on line 231 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either log this exception and handle it, or rethrow it with some contextual information.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v960&open=AaAh7wb6Wgrn0400v960&pullRequest=96
{
logger.LogError(ex, "Failed to enqueue job '{HandlerName}'/'{JobName}'", handlerName, jobName);
RecordException(activity, ex);
Expand All @@ -187,14 +237,26 @@
}
}

if (directly)
if (directly && !deferArm)
await ArmNowAsync(handlerName, jobName, schedule, payloadBytes, failurePolicyOptions,
effectiveJobId, cancellationToken);
logger.LogInformation(
"Enqueued {Status} job '{HandlerName}'/'{JobName}'. Id: {Id}",
jobInfo.Status, handlerName, jobName, effectiveJobId);

Check warning on line 245 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v97A&open=AaAh7wb6Wgrn0400v97A&pullRequest=96
activity?.SetStatus(ActivityStatusCode.Ok);
return effectiveJobId;
return (effectiveJobId, armAction);
}

/// <summary>
/// Closure-backed <see cref="IBackgroundJobArmHandle"/>. Holds the scheduler arguments captured at
/// enqueue time, so arming needs neither a job-row read nor a status write.
/// </summary>
private sealed class DeferredArmHandle(Guid jobId, Func<CancellationToken, Task> arm)
: IBackgroundJobArmHandle
{
public Guid JobId { get; } = jobId;

public Task ArmAsync(CancellationToken cancellationToken = default) => arm(cancellationToken);
}

/// <summary>
Expand All @@ -216,8 +278,8 @@
IUnitOfWork? rollbackUow = null;
try
{
rollbackUow = uowManager.Begin(
new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, IsTransactional = true });

Check warning on line 282 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Await BeginAsync instead.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v965&open=AaAh7wb6Wgrn0400v965&pullRequest=96
await jobStore.TryTransitionStatusAsync(jobId, BackgroundJobStatus.Scheduled,
BackgroundJobStatus.Pending, ct);
await rollbackUow.CommitAsync(ct);
Expand Down Expand Up @@ -248,7 +310,10 @@
if (string.IsNullOrWhiteSpace(newSchedule))
throw new ArgumentNullException(nameof(newSchedule));

using var activity = InfrastructureActivitySource.Source.StartActivity(
// Diagnostic, not business: enqueueing a job is plumbing around the work, and in the
// default Business profile it only adds depth to an already deep trace. The job's own
// execution span (BackgroundJob.Execute) is the one that carries meaning and stays.
using var activity = InfrastructureActivitySource.StartDiagnosticActivity(
"BackgroundJob.Update",
ActivityKind.Producer,
Activity.Current?.Context ?? default);
Expand All @@ -256,7 +321,7 @@
activity?.SetTag("job.id", id.ToString());
activity?.SetTag("job.schedule", newSchedule);

logger.LogInformation("Updating job with entity id '{Id}' to new schedule '{NewSchedule}'", id, newSchedule);

Check warning on line 324 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v97B&open=AaAh7wb6Wgrn0400v97B&pullRequest=96

if (jobStore is not IJobRescheduleStore rescheduleStore)
{
Expand All @@ -274,13 +339,13 @@
var result = await rescheduleStore.TryRescheduleWaitingAsync(
id, newSchedule, kind, nextRetryAt, cancellationToken);
EnsureRescheduled(id, result, activity);
logger.LogInformation("Successfully updated job with entity id '{Id}'", id);

Check warning on line 342 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v97C&open=AaAh7wb6Wgrn0400v97C&pullRequest=96
activity?.SetStatus(ActivityStatusCode.Ok);
return;
}

await using var uow = uowManager.Begin(
new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, IsTransactional = true });

Check warning on line 348 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Await BeginAsync instead.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v966&open=AaAh7wb6Wgrn0400v966&pullRequest=96
try
{
var result = await rescheduleStore.TryRescheduleWaitingAsync(
Expand All @@ -290,10 +355,10 @@
// Commit transaction
await uow.CommitAsync(cancellationToken);

logger.LogInformation("Successfully updated job with entity id '{Id}'", id);

Check warning on line 358 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v97D&open=AaAh7wb6Wgrn0400v97D&pullRequest=96
activity?.SetStatus(ActivityStatusCode.Ok);
}
catch (Exception ex)

Check warning on line 361 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either log this exception and handle it, or rethrow it with some contextual information.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v964&open=AaAh7wb6Wgrn0400v964&pullRequest=96
{
logger.LogError(ex, "Failed to update job with entity id '{Id}'", id);
RecordException(activity, ex);
Expand Down Expand Up @@ -345,11 +410,11 @@

BackgroundJobCancellationSnapshot? snapshot;
BackgroundJobCancellationResult result;
await using (var uow = uowManager.Begin(new UnitOfWorkOptions
{
Scope = UnitOfWorkScopeOption.RequiresNew,
IsTransactional = true
}))

Check warning on line 417 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Await BeginAsync instead.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v967&open=AaAh7wb6Wgrn0400v967&pullRequest=96
{
snapshot = await jobStore.GetCancellationSnapshotAsync(id, cancellationToken);
result = snapshot is null
Expand Down Expand Up @@ -413,14 +478,17 @@
if (id == Guid.Empty)
throw new ArgumentException("Id cannot be empty.", nameof(id));

using var activity = InfrastructureActivitySource.Source.StartActivity(
// Diagnostic, not business: enqueueing a job is plumbing around the work, and in the
// default Business profile it only adds depth to an already deep trace. The job's own
// execution span (BackgroundJob.Execute) is the one that carries meaning and stays.
using var activity = InfrastructureActivitySource.StartDiagnosticActivity(
"BackgroundJob.Delete",
ActivityKind.Producer,
Activity.Current?.Context ?? default);

activity?.SetTag("job.id", id.ToString());

logger.LogInformation("Deleting job with entity id '{Id}'", id);

Check warning on line 491 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v97E&open=AaAh7wb6Wgrn0400v97E&pullRequest=96

if (uowManager.Current is { })
{
Expand All @@ -437,13 +505,13 @@
await jobScheduler.DeleteAsync(jobInfo.HandlerName, jobInfo.JobName, cancellationToken);
await jobStore.UpdateStatusAsync(id, BackgroundJobStatus.Cancelled, clock.UtcNow,
cancellationToken: cancellationToken);
logger.LogInformation("Successfully deleted job with entity id '{Id}'", id);

Check warning on line 508 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v97F&open=AaAh7wb6Wgrn0400v97F&pullRequest=96
activity?.SetStatus(ActivityStatusCode.Ok);
return true;
}

await using var uow = uowManager.Begin(
new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, IsTransactional = true });

Check warning on line 514 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Await BeginAsync instead.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v969&open=AaAh7wb6Wgrn0400v969&pullRequest=96
try
{
// Load job from store
Expand All @@ -468,11 +536,11 @@
// Commit transaction
await uow.CommitAsync(cancellationToken);

logger.LogInformation("Successfully deleted job with entity id '{Id}'", id);

Check warning on line 539 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v97G&open=AaAh7wb6Wgrn0400v97G&pullRequest=96
activity?.SetStatus(ActivityStatusCode.Ok);
return true;
}
catch (Exception ex)

Check warning on line 543 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/BackgroundJobService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either log this exception and handle it, or rethrow it with some contextual information.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wb6Wgrn0400v968&open=AaAh7wb6Wgrn0400v968&pullRequest=96
{
logger.LogError(ex, "Failed to delete job with entity id '{Id}'", id);
RecordException(activity, ex);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ await daprJobsClient.DeleteJobAsync(

private static Activity? StartSchedulerActivity(string operationName, string handlerName, string jobName)
{
var activity = InfrastructureActivitySource.Source.StartActivity(
// Diagnostic: the scheduler round-trip is infrastructure detail. Business traces care that
// the job ran (BackgroundJob.Execute), not about the Schedule/Delete calls that armed it.
var activity = InfrastructureActivitySource.StartDiagnosticActivity(
operationName,
ActivityKind.Client,
Activity.Current?.Context ?? default);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
if (string.IsNullOrWhiteSpace(jobName))
throw new ArgumentNullException(nameof(jobName));

using var activity = InfrastructureActivitySource.Source.StartActivity(
// Diagnostic: pure dispatch plumbing between BackgroundJob.Execute and the handler.
using var activity = InfrastructureActivitySource.StartDiagnosticActivity(
"BackgroundJob.Dispatch",
ActivityKind.Internal,
Activity.Current?.Context ?? default);
Expand Down Expand Up @@ -87,8 +88,8 @@
var recorded = await RecordSuccessAsync(scope, c, jobName, activity, cancellationToken);
if (recorded)
{
logger.LogInformation("Successfully completed handler '{HandlerName}' for job id '{JobId}'",
c.HandlerName, c.JobId);

Check warning on line 92 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/JobDispatcher.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wbvWgrn0400v96v&open=AaAh7wbvWgrn0400v96v&pullRequest=96
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
Expand All @@ -97,11 +98,11 @@
var jobStore = scope.ServiceProvider.GetRequiredService<IJobStore>();
var jobScheduler = scope.ServiceProvider.GetRequiredService<IJobScheduler>();

logger.LogWarning("Handler '{HandlerName}' for job id '{JobId}' was cancelled", c.HandlerName, c.JobId);

Check warning on line 101 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/JobDispatcher.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Logging in a catch clause should pass the caught exception as a parameter.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wbvWgrn0400v96q&open=AaAh7wbvWgrn0400v96q&pullRequest=96
activity?.SetTag("job.status", "cancelled");

Check warning on line 102 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/JobDispatcher.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of using this literal 'job.status' 6 times.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wbvWgrn0400v96p&open=AaAh7wbvWgrn0400v96p&pullRequest=96
activity?.SetStatus(ActivityStatusCode.Ok);
await using (var cancelUow = uowManager.Begin(
new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, IsTransactional = true }))

Check warning on line 105 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/JobDispatcher.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Await BeginAsync instead.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wbvWgrn0400v96r&open=AaAh7wbvWgrn0400v96r&pullRequest=96
{
await jobStore.TryRecordTerminalAsync(c.JobId, c.Token, BackgroundJobStatus.Cancelled,
clock.UtcNow, "Job was cancelled", cancellationToken);
Expand Down Expand Up @@ -142,8 +143,8 @@
var jobStore = scope.ServiceProvider.GetRequiredService<IJobStore>();
var jobScheduler = scope.ServiceProvider.GetRequiredService<IJobScheduler>();

await using var claimUow = uowManager.Begin(
new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, IsTransactional = true });

Check warning on line 147 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/JobDispatcher.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Await BeginAsync instead.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wbvWgrn0400v96s&open=AaAh7wbvWgrn0400v96s&pullRequest=96

var jobInfo = await jobStore.GetByJobNameAsync(jobName, cancellationToken);
if (jobInfo is null)
Expand Down Expand Up @@ -176,8 +177,8 @@
await claimUow.CommitAsync(cancellationToken);
if (!claimed)
{
logger.LogInformation(
"Job id '{JobId}' was not Scheduled (already claimed or late delivery); skipping", jobInfo.Id);

Check warning on line 181 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/JobDispatcher.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wbvWgrn0400v96w&open=AaAh7wbvWgrn0400v96w&pullRequest=96
activity?.SetTag("job.status", "skipped");
activity?.SetStatus(ActivityStatusCode.Ok);
return null;
Expand All @@ -202,8 +203,8 @@
var jobScheduler = scope.ServiceProvider.GetRequiredService<IJobScheduler>();

bool recorded;
await using (var doneUow = uowManager.Begin(
new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, IsTransactional = true }))

Check warning on line 207 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/JobDispatcher.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Await BeginAsync instead.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wbvWgrn0400v96t&open=AaAh7wbvWgrn0400v96t&pullRequest=96
{
recorded = claim.Kind == JobKind.Recurring
? await jobStore.TryReturnToScheduledAsync(claim.JobId, claim.Token, clock.UtcNow, null, cancellationToken)
Expand Down Expand Up @@ -246,8 +247,8 @@
var willRetry = claim.Kind == JobKind.OneShot && claim.RetryCount + 1 <= claim.MaxRetryCount;

bool recorded;
await using (var failUow = uowManager.Begin(
new UnitOfWorkOptions { Scope = UnitOfWorkScopeOption.RequiresNew, IsTransactional = true }))

Check warning on line 251 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/JobDispatcher.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Await BeginAsync instead.

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wbvWgrn0400v96u&open=AaAh7wbvWgrn0400v96u&pullRequest=96
{
if (claim.Kind == JobKind.Recurring)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using BBT.Aether.Polling;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

Expand All @@ -9,8 +10,15 @@
/// <summary>
/// Hosted service that drives <see cref="BackgroundJobArmingProcessor"/> on a timer. Each tick runs one
/// arming pass; exceptions per tick are caught and logged so a transient failure never tears down the
/// loop. The delay between ticks is <see cref="BackgroundJobOptions.ArmingInterval"/>. Registered by the
/// DI wiring (see AddAetherBackgroundJob); not auto-registered here.
/// loop. The delay between ticks is <see cref="BackgroundJobOptions.ArmingInterval"/>, jittered.
/// Registered by the DI wiring (see AddAetherBackgroundJob); not auto-registered here.
/// <para>
/// The interval is fixed — there is no adaptive backoff here, because an unarmed job must be picked up
/// within a bounded time regardless of how quiet the system is. That makes jitter the only thing
/// keeping replicas apart: without it, pods started together by a rolling deployment run every pass in
/// lockstep, turning each tick into a burst of simultaneous claim queries over the same rows. A random
/// startup offset spreads the first pass as well.
/// </para>
/// </summary>
public class BackgroundJobArmingHostedService(
BackgroundJobArmingProcessor processor,
Expand All @@ -20,10 +28,19 @@
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(
"Background-job arming poller started (interval {Interval}, schema {Schema}).",
options.ArmingInterval, options.Schema);

Check warning on line 33 in framework/src/BBT.Aether.Infrastructure/BBT/Aether/BackgroundJob/Processing/BackgroundJobArmingHostedService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Evaluation of this argument may be expensive and unnecessary if logging is disabled

See more on https://sonarcloud.io/project/issues?id=burgan-tech_aether&issues=AaAh7wblWgrn0400v96o&open=AaAh7wblWgrn0400v96o&pullRequest=96

try
{
await Task.Delay(PollingDelay.StartupOffset(options.ArmingInterval), stoppingToken);
}
catch (OperationCanceledException)
{
return;
}

while (!stoppingToken.IsCancellationRequested)
{
try
Expand All @@ -41,7 +58,7 @@

try
{
await Task.Delay(options.ArmingInterval, stoppingToken);
await Task.Delay(PollingDelay.Jitter(options.ArmingInterval), stoppingToken);
}
catch (OperationCanceledException)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using BBT.Aether.Polling;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

Expand All @@ -13,6 +14,18 @@ public sealed class InboxBackgroundService(
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Spread the first poll of replicas that booted together, so a rolling deployment does not
// leave the whole fleet polling on the same tick.
try
{
await Task.Delay(PollingDelay.StartupOffset(options.IdlePollingInterval), stoppingToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}

var delay = options.IdlePollingInterval;

while (!stoppingToken.IsCancellationRequested)
Expand All @@ -21,8 +34,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var processed = await processor.RunAsync(stoppingToken);
delay = processed > 0
? options.BusyPollingInterval
: Min(delay * 2, options.MaxPollingInterval);
? PollingDelay.OnProcessed(options.BusyPollingInterval)
: PollingDelay.OnEmpty(delay, options.MaxPollingInterval);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
Expand All @@ -31,12 +44,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
catch (Exception ex)
{
logger.LogError(ex, "Inbox background service error");
delay = options.MaxPollingInterval;
// One step back, not straight to the cap: a transient fault must not stall every
// replica for a full maximum interval.
delay = PollingDelay.OnError(delay, options.IdlePollingInterval, options.MaxPollingInterval);
}

await Task.Delay(delay, stoppingToken).ConfigureAwait(false);
await Task.Delay(PollingDelay.Jitter(delay), stoppingToken).ConfigureAwait(false);
}
}

private static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using BBT.Aether.Polling;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

Expand All @@ -13,6 +14,18 @@ public sealed class OutboxBackgroundService(
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Spread the first poll of replicas that booted together, so a rolling deployment does not
// leave the whole fleet polling on the same tick.
try
{
await Task.Delay(PollingDelay.StartupOffset(options.IdlePollingInterval), stoppingToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}

var delay = options.IdlePollingInterval;

while (!stoppingToken.IsCancellationRequested)
Expand All @@ -21,8 +34,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var processed = await processor.RunAsync(stoppingToken);
delay = processed > 0
? options.BusyPollingInterval
: Min(delay * 2, options.MaxPollingInterval);
? PollingDelay.OnProcessed(options.BusyPollingInterval)
: PollingDelay.OnEmpty(delay, options.MaxPollingInterval);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
Expand All @@ -31,12 +44,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
catch (Exception ex)
{
logger.LogError(ex, "Outbox background service error");
delay = options.MaxPollingInterval;
// One step back, not straight to the cap: a transient fault must not stall every
// replica for a full maximum interval.
delay = PollingDelay.OnError(delay, options.IdlePollingInterval, options.MaxPollingInterval);
}

await Task.Delay(delay, stoppingToken).ConfigureAwait(false);
await Task.Delay(PollingDelay.Jitter(delay), stoppingToken).ConfigureAwait(false);
}
}

private static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b;
}
Loading
Loading