Skip to content

[FEATURE] Background jobs#745

Merged
guibranco merged 20 commits into
guibranco:mainfrom
ducdaiii:feature/background-jobs
Jul 4, 2026
Merged

[FEATURE] Background jobs#745
guibranco merged 20 commits into
guibranco:mainfrom
ducdaiii:feature/background-jobs

Conversation

@ducdaiii

@ducdaiii ducdaiii commented Aug 16, 2025

Copy link
Copy Markdown
Contributor

User description

📑 Description

This pull request adds a complete, lightweight background job module to the CrispyWaffle project.
It includes support for both in-memory and EF Core persistence, a scoped DI-friendly handler system, retry/backoff policies, scheduling (delayed & cron stub), monitoring, and metrics.

Key highlights:

  • Abstractions: IBackgroundJob, IBackgroundJobHandler<TData>, IJobStore, IJobScheduler, JobEntity, JobPriority, JobStatus
  • Core: BackgroundWorker, BackgroundJobQueue, JobDispatcher, JobHandlerRegistry, JobOptions, JobResult
  • Persistence: InMemoryJobStore, EfCoreJobStore, JobDbContext
  • Scheduling: DelayedJobScheduler, CronJobService (stub)
  • Monitoring: JobMetrics, MonitoringMiddleware
  • Extensions: ServiceCollectionExtensions to register jobs, handlers, and options with DI
  • Examples: SendEmailHandler and SendEmailPayload showing how to define a custom handler
  • Supports retries with exponential backoff and optional delayed scheduling.
  • Fully DI-compatible; handlers can have scoped dependencies.
  • Monitoring middleware exposes /jobs/metrics.

✅ Checks

  • My pull request adheres to the code style of this project
  • My code requires changes to the documentation
  • I have updated the documentation as required
  • All the tests have passed (manual testing with in-memory queue and example handlers)

☢️ Does this introduce a breaking change?

  • Yes
  • No

ℹ Additional Information

  • EF Core store requires JobDbContext registration and migrations.
  • Handlers must be registered via services.AddJobHandler<THandler, TData>("handler-name").
  • Includes in-memory queue for lightweight scenarios; no external DB required.

Note

I'm currently writing a description for your pull request. I should be done shortly (<1 minute). Please don't edit the description field until I'm finished, or we may overwrite each other. If I find nothing to write about, I'll delete this message.


Description

  • Introduced a complete HTTP Client Wrapper module with robust features.
  • Added retry logic with exponential backoff for handling transient failures.
  • Implemented serialization options for HTTP requests and responses.
  • Enhanced dependency injection support for easy integration.

Changes walkthrough 📝

Relevant files
Configuration changes
2 files
CrispyWaffle.HttpClient.csproj
New HTTP Client Wrapper Project Added                                       

Src/CrispyWaffle.HttpClient/CrispyWaffle.HttpClient.csproj

  • Added new project for HTTP Client Wrapper.
+13/-0   
CrispyWaffle.BackgroundJobs.csproj
Background Jobs Project File Updated                                         

Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csproj

  • Updated project file for background jobs.
+20/-0   
Enhancement
7 files
HttpClientWrapper.cs
HTTP Client Wrapper Implementation                                             

Src/CrispyWaffle.HttpClient/HttpClientWrapper.cs

  • Implemented robust HTTP client wrapper with retry and serialization.
  • Added methods for GET, POST, PUT, DELETE requests.
  • +302/-0 
    HttpRequestOptions.cs
    HTTP Request Options Class Created                                             

    Src/CrispyWaffle.HttpClient/HttpRequestOptions.cs

  • Defined options for HTTP requests including timeout and retry
    settings.
  • +44/-0   
    HttpResponse{T}.cs
    Generic HTTP Response Wrapper Added                                           

    Src/CrispyWaffle.HttpClient/HttpResponse{T}.cs

    • Created a generic response wrapper for HTTP responses.
    +46/-0   
    ServiceCollectionExtensions.cs
    Service Collection Extensions for HTTP Client                       

    Src/CrispyWaffle.HttpClient/ServiceCollectionExtensions.cs

    • Added extension method to register the HTTP client wrapper in DI.
    +47/-0   
    RetryPolicy.cs
    Retry Policy for HTTP Requests                                                     

    Src/CrispyWaffle.HttpClient/RetryPolicy.cs

  • Implemented retry logic with exponential backoff for HTTP requests.
  • +129/-0 
    IJsonSerializer.cs
    JSON Serializer Interface Added                                                   

    Src/CrispyWaffle.HttpClient/Serialization/IJsonSerializer.cs

    • Created interface for JSON serialization.
    +11/-0   
    SystemTextJsonSerializer.cs
    System.Text.Json Serializer Implementation                             

    Src/CrispyWaffle.HttpClient/Serialization/SystemTextJsonSerializer.cs

    • Implemented JSON serializer using System.Text.Json.
    +37/-0   

    💡 Penify usage:
    Comment /help on the PR to get a list of all available Penify tools and their descriptions

    Summary by CodeRabbit

    • New Features
      • Added a background job processing system with scheduling (delays), priorities, retries, and concurrent hosted workers.
      • Support for pluggable persistence: in-memory or EF Core–backed storage.
      • Runtime registration and execution of typed job handlers with scoped DI.
      • Added job metrics reporting via an HTTP endpoint at /jobs/metrics.
      • Introduced a hosted-service cron runner scaffold for periodic execution.
      • Added configuration options for worker count, polling interval, and backoff behavior.
    • Chores
      • Updated pinned dependency versions.

    @sourcery-ai

    sourcery-ai Bot commented Aug 16, 2025

    Copy link
    Copy Markdown

    Reviewer's Guide

    This PR introduces a complete lightweight background job processing framework—supporting in-memory and EF Core persistence, DI-scoped handlers, scheduling, retry/backoff, and monitoring—alongside a robust HttpClient wrapper with configurable retry policies, pluggable serialization, and seamless DI integration.

    Sequence diagram for job scheduling and execution (background job framework)

    sequenceDiagram
        participant User as actor User
        participant API as API/Service
        participant JobDispatcher
        participant JobStore
        participant BackgroundWorker
        participant JobHandler
        User->>API: Request to enqueue job
        API->>JobDispatcher: EnqueueAsync(handlerName, payload)
        alt Persistent store
            JobDispatcher->>JobStore: SaveAsync(job)
        else In-memory
            JobDispatcher->>BackgroundJobQueue: Enqueue(job)
        end
        BackgroundWorker->>JobStore: FetchNextAsync()
        BackgroundWorker->>JobHandler: HandleAsync(payload)
        alt Success
            BackgroundWorker->>JobStore: MarkCompletedAsync(jobId)
        else Failure with retry
            BackgroundWorker->>JobStore: MarkRetryAsync(jobId, nextAttempt, attemptCount)
        else Failure, no retry
            BackgroundWorker->>JobStore: MarkFailedAsync(jobId, error)
        end
    
    Loading

    Class diagram for the new background job framework

    classDiagram
        class JobEntity {
            +Guid Id
            +string HandlerName
            +string Payload
            +JobPriority Priority
            +JobStatus Status
            +DateTimeOffset? ScheduledAt
            +int Attempt
            +int MaxAttempt
            +string? LastError
            +DateTimeOffset CreatedAt
            +DateTimeOffset UpdatedAt
            +int RetryDelaySeconds
        }
        class IBackgroundJob {
            +Task<JobResult> ExecuteAsync(CancellationToken)
        }
        class IBackgroundJobHandler~TData~ {
            +Task<JobResult> HandleAsync(TData, CancellationToken)
        }
        class IJobStore {
            +Task SaveAsync(JobEntity, CancellationToken)
            +Task<JobEntity?> FetchNextAsync(CancellationToken)
            +Task MarkCompletedAsync(Guid, CancellationToken)
            +Task MarkFailedAsync(Guid, string, CancellationToken)
            +Task MarkRetryAsync(Guid, DateTimeOffset?, int, CancellationToken)
        }
        class IJobScheduler {
            +Task ScheduleAsync(string, object, TimeSpan, int, JobPriority)
            +Task EnqueueAsync(string, object, int, JobPriority)
        }
        class JobHandlerRegistry {
            +void Register<THandler, TData>(string)
            +bool TryGet(string, out Type?, out Type?)
        }
        class BackgroundWorker {
            +Task ExecuteAsync(CancellationToken)
        }
        class BackgroundJobQueue {
            +void Enqueue(JobEntity)
            +Task<JobEntity?> DequeueAsync(CancellationToken)
        }
        class JobDispatcher {
            +Task<Guid> EnqueueAsync(string, object, int?, JobPriority)
            +Task ScheduleAsync(string, object, TimeSpan, int?, JobPriority)
        }
        class InMemoryJobStore {
            +Task SaveAsync(JobEntity, CancellationToken)
            +Task<JobEntity?> FetchNextAsync(CancellationToken)
            +Task MarkCompletedAsync(Guid, CancellationToken)
            +Task MarkFailedAsync(Guid, string, CancellationToken)
            +Task MarkRetryAsync(Guid, DateTimeOffset?, int, CancellationToken)
        }
        class EfCoreJobStore {
            +Task SaveAsync(JobEntity, CancellationToken)
            +Task<JobEntity?> FetchNextAsync(CancellationToken)
            +Task MarkCompletedAsync(Guid, CancellationToken)
            +Task MarkFailedAsync(Guid, string, CancellationToken)
            +Task MarkRetryAsync(Guid, DateTimeOffset?, int, CancellationToken)
        }
        class JobDbContext {
            +DbSet<JobEntity> Jobs
        }
        class JobOptions {
            +int WorkerCount
            +int DefaultMaxAttempt
            +TimeSpan PollingInterval
            +int MaxBackoffSeconds
        }
        class JobResult {
            +bool Success
            +bool Retry
            +string? ErrorMessage
            +static JobResult Ok()
            +static JobResult Fail(string, bool)
        }
        class JobMetrics {
            +void Increment(string)
            +long Get(string)
            +IDictionary<string, long> Snapshot()
        }
        class MonitoringMiddleware {
            +Task InvokeAsync(HttpContext)
        }
        JobDbContext --> JobEntity
        InMemoryJobStore --|> IJobStore
        EfCoreJobStore --|> IJobStore
        DelayedJobScheduler --|> IJobScheduler
        JobHandlerRegistry --|> IJobHandlerRegistry
        BackgroundWorker --> BackgroundJobQueue
        BackgroundWorker --> IJobStore
        BackgroundWorker --> JobHandlerRegistry
        BackgroundWorker --> JobOptions
        BackgroundWorker --> JobDispatcher
        JobDispatcher --> BackgroundJobQueue
        JobDispatcher --> IJobStore
        MonitoringMiddleware --> JobMetrics
        BackgroundJobQueue --> JobEntity
        IBackgroundJobHandler <|.. JobHandlerRegistry
        IBackgroundJob <|.. BackgroundWorker
        JobEntity --> JobPriority
        JobEntity --> JobStatus
    
    Loading

    Class diagram for the new HttpClient wrapper

    classDiagram
        class IHttpClientWrapper {
            +Task<HttpResponse<T>> GetAsync<T>(string, HttpRequestOptions?, CancellationToken)
            +Task<HttpResponse<TResponse>> PostAsync<TRequest, TResponse>(string, TRequest?, HttpRequestOptions?, CancellationToken)
            +Task<HttpResponse<TResponse>> PutAsync<TRequest, TResponse>(string, TRequest?, HttpRequestOptions?, CancellationToken)
            +Task<HttpResponse<object?>> DeleteAsync(string, HttpRequestOptions?, CancellationToken)
            +HttpResponse<T> Get<T>(string, HttpRequestOptions?)
            +HttpResponse<TResponse> Post<TRequest, TResponse>(string, TRequest?, HttpRequestOptions?)
            +HttpResponse<TResponse> Put<TRequest, TResponse>(string, TRequest?, HttpRequestOptions?)
            +HttpResponse<object?> Delete(string, HttpRequestOptions?)
        }
        class HttpClientWrapper {
            +Task<HttpResponse<T>> GetAsync<T>(...)
            +Task<HttpResponse<TResponse>> PostAsync<TRequest, TResponse>(...)
            +Task<HttpResponse<TResponse>> PutAsync<TRequest, TResponse>(...)
            +Task<HttpResponse<object?>> DeleteAsync(...)
            +HttpResponse<T> Get<T>(...)
            +HttpResponse<TResponse> Post<TRequest, TResponse>(...)
            +HttpResponse<TResponse> Put<TRequest, TResponse>(...)
            +HttpResponse<object?> Delete(...)
        }
        class HttpRequestOptions {
            +string? BaseAddress
            +int TimeoutSeconds
            +int RetryCount
            +int RetryBaseDelayMs
            +int MaxRetryDelayMs
            +bool ThrowOnError
            +IDictionary<string, string>? DefaultRequestHeaders
            +string? NamedHttpClient
            +bool UseJsonContentType
            +IJsonSerializer? Serializer
        }
        class HttpResponse~T~ {
            +bool IsSuccess
            +HttpStatusCode? StatusCode
            +T? Data
            +string? RawContent
            +IList<string>? Errors
            +IDictionary<string, IEnumerable<string>>? Headers
            +TimeSpan Duration
            +static HttpResponse<T> Success(...)
            +static HttpResponse<T> Failure(...)
        }
        class IJsonSerializer {
            +string Serialize<T>(T)
            +T? Deserialize<T>(string)
            +object? Deserialize(string, Type)
        }
        class SystemTextJsonSerializer {
            +string Serialize<T>(T)
            +T? Deserialize<T>(string)
            +object? Deserialize(string, Type)
        }
        class HttpClientException {
            +HttpStatusCode? StatusCode
            +string? ResponseContent
        }
        HttpClientWrapper --|> IHttpClientWrapper
        SystemTextJsonSerializer --|> IJsonSerializer
        HttpClientWrapper --> HttpRequestOptions
        HttpClientWrapper --> IJsonSerializer
        HttpClientWrapper --> HttpResponse~T~
        HttpClientWrapper --> HttpClientException
    
    Loading

    File-Level Changes

    Change Details Files
    Introduce Background Jobs framework
    • Implement BackgroundWorker with scoped DI, job fetching, retry/backoff, and scheduling logic
    • Provide JobDispatcher and DelayedJobScheduler to enqueue and schedule jobs
    • Add priority queue and abstract IJobStore with in-memory and EF Core implementations
    • Create JobHandlerRegistry and ServiceCollectionExtensions to register handlers at runtime
    • Expose monitoring via JobMetrics counters and MonitoringMiddleware
    Src/CrispyWaffle.BackgroundJobs/Core/BackgroundWorker.cs
    Src/CrispyWaffle.BackgroundJobs/Core/BackgroundJobQueue.cs
    Src/CrispyWaffle.BackgroundJobs/Core/JobDispatcher.cs
    Src/CrispyWaffle.BackgroundJobs/Core/JobHandlerRegistry.cs
    Src/CrispyWaffle.BackgroundJobs/Core/JobOptions.cs
    Src/CrispyWaffle.BackgroundJobs/Core/JobResult.cs
    Src/CrispyWaffle.BackgroundJobs/Persistence/InMemoryJobStore.cs
    Src/CrispyWaffle.BackgroundJobs/Persistence/EfCoreJobStore.cs
    Src/CrispyWaffle.BackgroundJobs/Persistence/JobDbContext.cs
    Src/CrispyWaffle.BackgroundJobs/Scheduling/DelayedJobScheduler.cs
    Src/CrispyWaffle.BackgroundJobs/Scheduling/CronJobScheduler.cs
    Src/CrispyWaffle.BackgroundJobs/Extensions/ServiceCollectionExtensions.cs
    Src/CrispyWaffle.BackgroundJobs/Abstractions/IJobStore.cs
    Src/CrispyWaffle.BackgroundJobs/Abstractions/IJobScheduler.cs
    Src/CrispyWaffle.BackgroundJobs/Abstractions/IBackgroundJobHandler.cs
    Src/CrispyWaffle.BackgroundJobs/Abstractions/IBackgroundJob.cs
    Src/CrispyWaffle.BackgroundJobs/Abstractions/JobEntity.cs
    Src/CrispyWaffle.BackgroundJobs/Abstractions/JobPriority.cs
    Src/CrispyWaffle.BackgroundJobs/Abstractions/JobStatus.cs
    Src/CrispyWaffle.BackgroundJobs/Monitoring/JobMetrics.cs
    Src/CrispyWaffle.BackgroundJobs/Monitoring/MonitoringMiddleware.cs
    Add robust HttpClient wrapper
    • Implement HttpClientWrapper with default and per-request HttpRequestOptions, header merging, and sync/async methods
    • Create RetryPolicy with exponential backoff, jitter, and 429 Retry-After support
    • Define HttpResponse and HttpRequestOptions models for standardized responses
    • Introduce IJsonSerializer and SystemTextJsonSerializer for pluggable serialization
    • Provide IServiceCollectionExtensions to register the wrapper, options, and serializer in DI
    • Add IHttpClientWrapper interface and HttpClientException for error handling
    Src/CrispyWaffle.HttpClient/HttpClientWrapper.cs
    Src/CrispyWaffle.HttpClient/RetryPolicy.cs
    Src/CrispyWaffle.HttpClient/ServiceCollectionExtensions.cs
    Src/CrispyWaffle.HttpClient/HttpResponse{T}.cs
    Src/CrispyWaffle.HttpClient/HttpRequestOptions.cs
    Src/CrispyWaffle.HttpClient/IHttpClientWrapper.cs
    Src/CrispyWaffle.HttpClient/Exceptions/HttpClientException.cs
    Src/CrispyWaffle.HttpClient/Serialization/IJsonSerializer.cs
    Src/CrispyWaffle.HttpClient/Serialization/SystemTextJsonSerializer.cs
    Update solution and package configuration
    • Add new BackgroundJobs and HttpClient projects to the solution
    • Update Directory.Packages.props to align package versions
    CrispyWaffle.sln
    Directory.Packages.props

    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 16, 2025

    Copy link
    Copy Markdown
    Contributor

    Review Change Stack

    Warning

    Review limit reached

    @guibranco, you've reached your PR review limit, so we couldn't start this review.

    Next review available in: 34 minutes

    Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
    You're only billed for reviews past your plan's rate limits ($0.25/file).

    How can I continue?

    After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

    To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

    How do review limits work?

    CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

    For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

    Please refer docs for additional details.

    Review details
    ⚙️ Run configuration

    Configuration used: Organization UI

    Review profile: CHILL

    Plan: Pro

    Run ID: d6209be8-32b8-4cb6-984c-f9ca11ff56a1

    📥 Commits

    Reviewing files that changed from the base of the PR and between a824470 and f4af0a2.

    📒 Files selected for processing (24)
    • Directory.Packages.props
    • Src/CrispyWaffle.BackgroundJobs/Abstractions/IBackgroundJob.cs
    • Src/CrispyWaffle.BackgroundJobs/Abstractions/IJobScheduler.cs
    • Src/CrispyWaffle.BackgroundJobs/Abstractions/IJobStore.cs
    • Src/CrispyWaffle.BackgroundJobs/Abstractions/JobEntity.cs
    • Src/CrispyWaffle.BackgroundJobs/Abstractions/JobPriority.cs
    • Src/CrispyWaffle.BackgroundJobs/Abstractions/JobStatus.cs
    • Src/CrispyWaffle.BackgroundJobs/Core/BackgroundJobQueue.cs
    • Src/CrispyWaffle.BackgroundJobs/Core/BackgroundWorker.cs
    • Src/CrispyWaffle.BackgroundJobs/Core/IJobHandlerRegistry.cs
    • Src/CrispyWaffle.BackgroundJobs/Core/JobDispatcher.cs
    • Src/CrispyWaffle.BackgroundJobs/Core/JobHandlerRegistry.cs
    • Src/CrispyWaffle.BackgroundJobs/Core/JobResult.cs
    • Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csproj
    • Src/CrispyWaffle.BackgroundJobs/Exceptions/JobFailedException.cs
    • Src/CrispyWaffle.BackgroundJobs/Extensions/ServiceCollectionExtensions.cs
    • Src/CrispyWaffle.BackgroundJobs/Monitoring/JobMetrics.cs
    • Src/CrispyWaffle.BackgroundJobs/Monitoring/MonitoringMiddleware.cs
    • Src/CrispyWaffle.BackgroundJobs/Persistence/EfCoreJobStore.cs
    • Src/CrispyWaffle.BackgroundJobs/Persistence/InMemoryJobStore.cs
    • Src/CrispyWaffle.BackgroundJobs/Persistence/JobDbContext.cs
    • Src/CrispyWaffle.BackgroundJobs/Scheduling/CronJobScheduler.cs
    • Src/CrispyWaffle.BackgroundJobs/Scheduling/DelayedJobScheduler.cs
    • Src/CrispyWaffle.HttpClient/HttpClientWrapper.cs

    Walkthrough

    Updates centralized dependency pins in Directory.Packages.props, adding several Microsoft and EF Core package versions, changing two existing versions, and adjusting file ending formatting.

    Changes

    Package Versions

    Layer / File(s) Summary
    Updated centralized package pins
    Directory.Packages.props
    Replaced the existing Microsoft.Extensions entries with a broader pinned set, including ASP.NET Core hosting abstractions, EF Core, DI, hosting abstractions, and options; changed Microsoft.Extensions.Http to 10.0.2 and Microsoft.Extensions.Logging to 9.0.8; removed the final newline.

    Estimated code review effort: 1 (Trivial) | ~2 minutes

    Poem

    🐰 I nibble pins with careful cheer,
    New versions lined up, clear and near.
    A tiny hop, a tidy tune,
    The package garden blooms this June.

    🚥 Pre-merge checks | ✅ 5
    ✅ Passed checks (5 passed)
    Check name Status Explanation
    Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
    Title check ✅ Passed The title clearly matches the main change: a new background jobs feature was added.
    Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
    Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
    Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
    ✨ Finishing Touches
    🧪 Generate unit tests (beta)
    • Create PR with unit tests

    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.

    @gstraccini
    gstraccini Bot requested a review from guibranco August 16, 2025 07:04
    @gstraccini gstraccini Bot added the 🚦 awaiting triage Items that are awaiting triage or categorization label Aug 16, 2025
    @penify-dev

    penify-dev Bot commented Aug 16, 2025

    Copy link
    Copy Markdown
    Contributor

    PR Review 🔍

    ⏱️ Estimated effort to review [1-5]

    5, because this PR introduces a significant amount of new code for a background job module, including multiple interfaces, classes, and a complete architecture for job handling, scheduling, and persistence. The complexity and the number of files involved will require thorough review.

    🧪 Relevant tests

    Yes

    ⚡ Possible issues

    Possible Bug: The implementation of the retry logic may lead to infinite retries if not handled correctly, especially if the job fails continuously without a proper backoff strategy.

    Possible Bug: Ensure that the job fetching logic in the EfCoreJobStore and InMemoryJobStore correctly handles concurrency to avoid race conditions.

    🔒 Security concerns

    No

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

    Prompt for AI Agents
    Please address the comments from this code review:
    ## Individual Comments
    
    ### Comment 1
    <location> `Src/CrispyWaffle.BackgroundJobs/Core/BackgroundWorker.cs:78` </location>
    <code_context>
    +                    if (job == null) continue;
    +
    +                    // If job has ScheduledAt in future, re-enqueue / re-schedule.
    +                    if (job.ScheduledAt.HasValue && job.ScheduledAt.Value > DateTimeOffset.UtcNow)
    +                    {
    +                        // For persistence, mark back to pending with same ScheduledAt; for in-memory, just re-enqueue.
    +                        if (_store != null)
    +                        {
    +                            // mark as Pending again (store.FetchNextAsync should have returned only due jobs, this is defensive)
    +                            await _store.MarkRetryAsync(job.Id, job.ScheduledAt, job.Attempt, stoppingToken);
    +                        }
    +                        else
    +                        {
    +                            _queue!.Enqueue(job);
    +                        }
    +
    +                        continue;
    +                    }
    +
    </code_context>
    
    <issue_to_address>
    Jobs scheduled in the future are immediately re-enqueued, which may cause tight polling.
    
    The worker loop does not wait before rechecking jobs scheduled for the future, leading to unnecessary CPU usage. Consider implementing a delay based on the job's scheduled time to reduce tight polling.
    </issue_to_address>
    
    <suggested_fix>
    <<<<<<< SEARCH
                        // If job has ScheduledAt in future, re-enqueue / re-schedule.
                        if (job.ScheduledAt.HasValue && job.ScheduledAt.Value > DateTimeOffset.UtcNow)
                        {
                            // For persistence, mark back to pending with same ScheduledAt; for in-memory, just re-enqueue.
                            if (_store != null)
                            {
                                // mark as Pending again (store.FetchNextAsync should have returned only due jobs, this is defensive)
                                await _store.MarkRetryAsync(job.Id, job.ScheduledAt, job.Attempt, stoppingToken);
                            }
                            else
                            {
                                _queue!.Enqueue(job);
                            }
    
                            continue;
                        }
    =======
                        // If job has ScheduledAt in future, delay until due, then re-enqueue / re-schedule.
                        if (job.ScheduledAt.HasValue && job.ScheduledAt.Value > DateTimeOffset.UtcNow)
                        {
                            var delay = job.ScheduledAt.Value - DateTimeOffset.UtcNow;
                            if (delay > TimeSpan.Zero)
                            {
                                await Task.Delay(delay, stoppingToken);
                            }
    
                            // For persistence, mark back to pending with same ScheduledAt; for in-memory, just re-enqueue.
                            if (_store != null)
                            {
                                // mark as Pending again (store.FetchNextAsync should have returned only due jobs, this is defensive)
                                await _store.MarkRetryAsync(job.Id, job.ScheduledAt, job.Attempt, stoppingToken);
                            }
                            else
                            {
                                _queue!.Enqueue(job);
                            }
    
                            continue;
                        }
    >>>>>>> REPLACE
    
    </suggested_fix>
    
    ### Comment 2
    <location> `Src/CrispyWaffle.BackgroundJobs/Core/BackgroundWorker.cs:221` </location>
    <code_context>
    +            }
    +        }
    +
    +        private static int ComputeBackoffSeconds(int attempt)
    +        {
    +            // exponential backoff with cap
    +            var seconds = (int)Math.Pow(2, Math.Min(attempt, 10));
    +            return Math.Min(seconds, 300);
    +        }
    +
    </code_context>
    
    <issue_to_address>
    Backoff calculation is capped at 300 seconds, but this is hardcoded.
    
    If JobOptions.MaxBackoffSeconds is meant to be configurable, use it here instead of the hardcoded 300 to support flexible retry strategies.
    
    Suggested implementation:
    
    ```csharp
                        job.Attempt++;
                        job.ScheduledAt = DateTimeOffset.UtcNow.AddSeconds(ComputeBackoffSeconds(job.Attempt, _options.MaxBackoffSeconds));
                        _queue!.Enqueue(job);
    
    ```
    
    ```csharp
            private static int ComputeBackoffSeconds(int attempt, int maxBackoffSeconds)
            {
                // exponential backoff with cap
                var seconds = (int)Math.Pow(2, Math.Min(attempt, 10));
                return Math.Min(seconds, maxBackoffSeconds);
            }
    
    ```
    </issue_to_address>
    
    ### Comment 3
    <location> `Src/CrispyWaffle.HttpClient/HttpClientWrapper.cs:35` </location>
    <code_context>
    +        private System.Net.Http.HttpClient CreateClient(HttpRequestOptions? perRequest)
    </code_context>
    
    <issue_to_address>
    Headers are merged and set on HttpClient, which may persist across requests.
    
    Setting headers on DefaultRequestHeaders can cause them to persist across requests when reusing the client. To avoid this, set headers on HttpRequestMessage for per-request isolation.
    </issue_to_address>
    
    ### Comment 4
    <location> `Src/CrispyWaffle.HttpClient/RetryPolicy.cs:85` </location>
    <code_context>
    +            return false;
    +        }
    +
    +        private static bool IsTransientException(Exception ex)
    +        {
    +            // HttpRequestException and TaskCanceled (timeout) are considered transient
    +            return ex is HttpRequestException || ex is OperationCanceledException || ex is TimeoutException;
    +        }
    +
    </code_context>
    
    <issue_to_address>
    OperationCanceledException is treated as transient, which may mask cancellation.
    
    Check the cancellation token before retrying to ensure user-initiated cancellations are not retried as transient errors.
    </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 +77 to +92
    // If job has ScheduledAt in future, re-enqueue / re-schedule.
    if (job.ScheduledAt.HasValue && job.ScheduledAt.Value > DateTimeOffset.UtcNow)
    {
    // For persistence, mark back to pending with same ScheduledAt; for in-memory, just re-enqueue.
    if (_store != null)
    {
    // mark as Pending again (store.FetchNextAsync should have returned only due jobs, this is defensive)
    await _store.MarkRetryAsync(job.Id, job.ScheduledAt, job.Attempt, stoppingToken);
    }
    else
    {
    _queue!.Enqueue(job);
    }

    continue;
    }

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

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

    suggestion (performance): Jobs scheduled in the future are immediately re-enqueued, which may cause tight polling.

    The worker loop does not wait before rechecking jobs scheduled for the future, leading to unnecessary CPU usage. Consider implementing a delay based on the job's scheduled time to reduce tight polling.

    Suggested change
    // If job has ScheduledAt in future, re-enqueue / re-schedule.
    if (job.ScheduledAt.HasValue && job.ScheduledAt.Value > DateTimeOffset.UtcNow)
    {
    // For persistence, mark back to pending with same ScheduledAt; for in-memory, just re-enqueue.
    if (_store != null)
    {
    // mark as Pending again (store.FetchNextAsync should have returned only due jobs, this is defensive)
    await _store.MarkRetryAsync(job.Id, job.ScheduledAt, job.Attempt, stoppingToken);
    }
    else
    {
    _queue!.Enqueue(job);
    }
    continue;
    }
    // If job has ScheduledAt in future, delay until due, then re-enqueue / re-schedule.
    if (job.ScheduledAt.HasValue && job.ScheduledAt.Value > DateTimeOffset.UtcNow)
    {
    var delay = job.ScheduledAt.Value - DateTimeOffset.UtcNow;
    if (delay > TimeSpan.Zero)
    {
    await Task.Delay(delay, stoppingToken);
    }
    // For persistence, mark back to pending with same ScheduledAt; for in-memory, just re-enqueue.
    if (_store != null)
    {
    // mark as Pending again (store.FetchNextAsync should have returned only due jobs, this is defensive)
    await _store.MarkRetryAsync(job.Id, job.ScheduledAt, job.Attempt, stoppingToken);
    }
    else
    {
    _queue!.Enqueue(job);
    }
    continue;
    }

    Comment on lines +221 to +225
    private static int ComputeBackoffSeconds(int attempt)
    {
    // exponential backoff with cap
    var seconds = (int)Math.Pow(2, Math.Min(attempt, 10));
    return Math.Min(seconds, 300);

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

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

    suggestion: Backoff calculation is capped at 300 seconds, but this is hardcoded.

    If JobOptions.MaxBackoffSeconds is meant to be configurable, use it here instead of the hardcoded 300 to support flexible retry strategies.

    Suggested implementation:

                        job.Attempt++;
                        job.ScheduledAt = DateTimeOffset.UtcNow.AddSeconds(ComputeBackoffSeconds(job.Attempt, _options.MaxBackoffSeconds));
                        _queue!.Enqueue(job);
            private static int ComputeBackoffSeconds(int attempt, int maxBackoffSeconds)
            {
                // exponential backoff with cap
                var seconds = (int)Math.Pow(2, Math.Min(attempt, 10));
                return Math.Min(seconds, maxBackoffSeconds);
            }

    Comment on lines +35 to +44
    private System.Net.Http.HttpClient CreateClient(HttpRequestOptions? perRequest)
    {
    var name = perRequest?.NamedHttpClient ?? _defaults.NamedHttpClient;
    System.Net.Http.HttpClient client = string.IsNullOrEmpty(name)
    ? _httpClientFactory.CreateClient()
    : _httpClientFactory.CreateClient(name);

    var baseAddr = perRequest?.BaseAddress ?? _defaults.BaseAddress;
    if (!string.IsNullOrEmpty(baseAddr) && client.BaseAddress == null)
    {

    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): Headers are merged and set on HttpClient, which may persist across requests.

    Setting headers on DefaultRequestHeaders can cause them to persist across requests when reusing the client. To avoid this, set headers on HttpRequestMessage for per-request isolation.

    Comment on lines +85 to +88
    private static bool IsTransientException(Exception ex)
    {
    // HttpRequestException and TaskCanceled (timeout) are considered transient
    return ex is HttpRequestException || ex is OperationCanceledException || ex is TimeoutException;

    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): OperationCanceledException is treated as transient, which may mask cancellation.

    Check the cancellation token before retrying to ensure user-initiated cancellations are not retried as transient errors.

    @penify-dev

    penify-dev Bot commented Aug 16, 2025

    Copy link
    Copy Markdown
    Contributor

    PR Code Suggestions ✨

    CategorySuggestion                                                                                                                                    Score
    Maintainability
    Wrap job processing in a try-catch to log errors without crashing the worker

    Ensure that the job processing logic handles potential exceptions from the job handler
    gracefully.

    Src/CrispyWaffle.BackgroundJobs/Core/BackgroundWorker.cs [100-101]

    -success = await ProcessJobAsync(job, scopedProvider, stoppingToken);
    +try { success = await ProcessJobAsync(job, scopedProvider, stoppingToken); } catch (Exception ex) { _logger.LogError(ex, "Error processing job {JobId}", job.Id); }
     
    Suggestion importance[1-10]: 9

    Why: Wrapping job processing in a try-catch block improves error handling and prevents the worker from crashing, which is crucial for maintainability and robustness.

    9
    Rename the property to enhance clarity regarding its function

    Consider using a more descriptive name for the WorkerCount property to clarify its
    purpose.

    Src/CrispyWaffle.BackgroundJobs/Core/JobOptions.cs [9]

    -public int WorkerCount { get; set; } = Environment.ProcessorCount;
    +public int ConcurrentWorkerCount { get; set; } = Environment.ProcessorCount;
     
    Suggestion importance[1-10]: 7

    Why: While renaming WorkerCount to ConcurrentWorkerCount enhances clarity, it is a minor improvement and does not address a critical issue.

    7
    Prevent modifications to the JobOptions instance after registration as a singleton

    Ensure that the JobOptions instance is not modified after being registered as a singleton.

    Src/CrispyWaffle.BackgroundJobs/Extensions/ServiceCollectionExtensions.cs [21]

    +var options = new JobOptions();
     services.AddSingleton(options);
    +configureOptions?.Invoke(options); // Ensure options are configured before registration.
     
    Suggestion importance[1-10]: 6

    Why: The suggestion improves maintainability by ensuring that the JobOptions instance remains immutable after registration, but it is a minor improvement compared to others.

    6
    Error handling
    Enhance error handling for database operations to manage exceptions effectively

    Implement error handling for database operations to avoid unhandled exceptions.

    Src/CrispyWaffle.BackgroundJobs/Persistence/EfCoreJobStore.cs [19]

    -await _db.SaveChangesAsync(cancellationToken);
    +try
    +{
    +    await _db.SaveChangesAsync(cancellationToken);
    +}
    +catch (DbUpdateException ex)
    +{
    +    // Handle the exception (e.g., log it or rethrow)
    +    throw new Exception("Database update failed.", ex);
    +}
     
    Suggestion importance[1-10]: 9

    Why: This suggestion addresses a significant issue by enhancing error handling for database operations, which is crucial for maintaining application stability.

    9
    Best practice
    Use a using statement to ensure proper disposal of the HttpClient

    Ensure that the HttpClient is disposed properly to avoid socket exhaustion issues.

    Src/CrispyWaffle.HttpClient/HttpClientWrapper.cs [38-39]

    -System.Net.Http.HttpClient client = string.IsNullOrEmpty(name)
    +using var client = string.IsNullOrEmpty(name)
     
    Suggestion importance[1-10]: 8

    Why: Using a using statement for HttpClient ensures proper disposal, preventing potential socket exhaustion issues, which is a significant concern in long-running applications.

    8
    Improve cancellation token handling during delays in the retry mechanism

    Ensure that the retry mechanism respects the cancellation token in all scenarios,
    especially during delays.

    Src/CrispyWaffle.HttpClient/RetryPolicy.cs [59]

    -await Task.Delay(delay, ct).ConfigureAwait(false);
    +await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
     
    Suggestion importance[1-10]: 8

    Why: The suggestion correctly identifies a potential issue with cancellation token handling during delays, which is important for responsiveness in asynchronous operations.

    8
    Possible bug
    Add a null check before updating the job storage

    Consider adding a check to ensure that the job being saved is not null before attempting
    to update the storage, to prevent potential null reference exceptions.

    Src/CrispyWaffle.BackgroundJobs/Persistence/InMemoryJobStore.cs [13]

    -_storage[job.Id] = job;
    +if (job != null) _storage[job.Id] = job;
     
    Suggestion importance[1-10]: 8

    Why: The suggestion addresses a potential null reference exception by ensuring the job is not null before updating the storage, which is a crucial improvement for robustness.

    8
    Add a null check for the payload before serialization

    Ensure that the payload being serialized is not null to avoid potential exceptions during
    serialization.

    Src/CrispyWaffle.BackgroundJobs/Core/JobDispatcher.cs [26]

    -Payload = JsonSerializer.Serialize(payload),
    +Payload = payload != null ? JsonSerializer.Serialize(payload) : throw new ArgumentNullException(nameof(payload)),
     
    Suggestion importance[1-10]: 8

    Why: This suggestion prevents potential exceptions during serialization by checking if the payload is null, which is important for maintaining application stability.

    8
    Possible issue
    Implement validation to prevent excessively high backoff values

    Validate the MaxBackoffSeconds property to ensure it does not exceed a reasonable limit.

    Src/CrispyWaffle.BackgroundJobs/Core/JobOptions.cs [24]

    -public int MaxBackoffSeconds { get; set; } = 300; // 5 minutes
    +public int MaxBackoffSeconds { get; set; } = 300; // 5 minutes, ensure this does not exceed a predefined limit
     
    Suggestion importance[1-10]: 8

    Why: Implementing validation for MaxBackoffSeconds is important to prevent potential issues with excessively high values, making this a significant suggestion.

    8
    Performance
    Add a cap to the TimeoutSeconds value to prevent excessively long timeouts

    Consider adding a check for the TimeoutSeconds value to ensure it is not set to an
    excessively high value.

    Src/CrispyWaffle.HttpClient/HttpClientWrapper.cs [49]

    -client.Timeout = TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds));
    +client.Timeout = TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, 1, 100)); // Example cap at 100 seconds
     
    Suggestion importance[1-10]: 7

    Why: Adding a cap to the TimeoutSeconds value can prevent excessively long timeouts, which is a good practice for performance and reliability, though it may not be critical.

    7
    Suggest using a concurrent collection for thread safety in the priority queue

    Consider using a more robust locking mechanism or a concurrent collection to handle the
    priority queue to avoid potential race conditions.

    Src/CrispyWaffle.BackgroundJobs/Core/BackgroundJobQueue.cs [13]

    -private readonly object _lock = new();
    +// Consider using a concurrent collection instead of manual locking
     
    Suggestion importance[1-10]: 7

    Why: While the suggestion to use a concurrent collection is valid for improving thread safety, the current implementation may still function correctly under certain conditions, hence a slightly lower score.

    7
    Use ConfigureAwait(false) to avoid deadlocks in asynchronous job processing

    Consider implementing a cancellation token in the job processing to allow for graceful
    shutdowns.

    Src/CrispyWaffle.BackgroundJobs/Core/BackgroundWorker.cs [101]

    -var success = await ProcessJobAsync(job, scopedProvider, stoppingToken);
    +var success = await ProcessJobAsync(job, scopedProvider, stoppingToken).ConfigureAwait(false);
     
    Suggestion importance[1-10]: 5

    Why: Using ConfigureAwait(false) can help avoid deadlocks, but it is a minor improvement compared to the other suggestions, as the cancellation token is already being used effectively in the existing code.

    5
    Adjust the default polling interval to optimize job processing

    Consider using a default value for PollingInterval that is more suitable for various job
    processing scenarios.

    Src/CrispyWaffle.BackgroundJobs/Core/JobOptions.cs [19]

    -public TimeSpan PollingInterval { get; set; } = TimeSpan.FromSeconds(2);
    +public TimeSpan PollingInterval { get; set; } = TimeSpan.FromSeconds(5); // Adjusted for better performance
     
    Suggestion importance[1-10]: 4

    Why: Changing the default value of PollingInterval may not be necessary without specific performance issues identified, making this a less critical suggestion.

    4
    Validation
    Add validation to ensure the timeout value is positive

    Consider validating the TimeoutSeconds property to ensure it is a positive value.

    Src/CrispyWaffle.HttpClient/HttpRequestOptions.cs [18]

    -public int TimeoutSeconds { get; set; } = 30;
    +private int _timeoutSeconds = 30;
    +public int TimeoutSeconds
    +{
    +    get => _timeoutSeconds;
    +    set
    +    {
    +        if (value <= 0) throw new ArgumentOutOfRangeException(nameof(TimeoutSeconds), "Timeout must be positive.");
    +        _timeoutSeconds = value;
    +    }
    +}
     
    Suggestion importance[1-10]: 7

    Why: The suggestion adds validation for the TimeoutSeconds property, which is a good practice to prevent invalid configurations, although it is not critical.

    7
    Enhancement
    Implement a cancellation mechanism for scheduled jobs

    Consider implementing a cancellation mechanism for the scheduled job to allow for graceful
    shutdowns when the application is stopping.

    Src/CrispyWaffle.BackgroundJobs/Scheduling/CronJobScheduler.cs [23]

     while (!stoppingToken.IsCancellationRequested)
    +{
    +    // Add cancellation logic here
    +}
     
    Suggestion importance[1-10]: 6

    Why: The suggestion to implement a cancellation mechanism is beneficial for graceful shutdowns, but it is more of an enhancement than a critical fix, resulting in a moderate score.

    6
    Make the retry attempts configurable for better adaptability

    Ensure that the DefaultMaxAttempt property is configurable to allow for flexibility in job
    retry logic.

    Src/CrispyWaffle.BackgroundJobs/Core/JobOptions.cs [14]

    -public int DefaultMaxAttempt { get; set; } = 3;
    +public int DefaultMaxAttempt { get; set; } = 3; // Consider allowing this to be set externally
     
    Suggestion importance[1-10]: 5

    Why: The suggestion to make DefaultMaxAttempt configurable is valid, but the current implementation is functional as is, making this a minor enhancement.

    5

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

    Actionable comments posted: 17

    ♻️ Duplicate comments (4)
    Src/CrispyWaffle.HttpClient/RetryPolicy.cs (2)

    62-70: Respect cancellation: don’t treat OperationCanceledException as transient when the token is canceled.

    Retrying after user-requested cancellation is incorrect and may mask cancellations. Also, include a dedicated catch for cancellation.

    Apply this diff:

    -                catch (Exception ex) when (IsTransientException(ex))
    +                // If the CancellationToken was canceled, propagate immediately
    +                catch (OperationCanceledException oce) when (ct.IsCancellationRequested)
    +                {
    +                    return (null, oce);
    +                }
    +                // Treat timeouts/cancelled tasks (not due to ct) and HttpRequestException as transient
    +                catch (OperationCanceledException oce) when (!ct.IsCancellationRequested)
    +                {
    +                    lastEx = oce;
    +                    if (attempt == retryCount) break;
    +                    var delay = ComputeDelay(attempt, baseDelayMs, maxDelayMs, rnd);
    +                    await Task.Delay(delay, ct).ConfigureAwait(false);
    +                    continue;
    +                }
    +                catch (Exception ex) when (IsTransientException(ex))
                     {
                         lastEx = ex;
                         if (attempt == retryCount) break;
     
                         var delay = ComputeDelay(attempt, baseDelayMs, maxDelayMs, rnd);
                         await Task.Delay(delay, ct).ConfigureAwait(false);
                         continue;
                     }

    And narrow IsTransientException accordingly (see next comment).


    85-89: Narrow transient exceptions to avoid conflating cancellations.

    Move OperationCanceledException handling to explicit catch blocks and keep only true transient types here.

    Apply this diff:

    -        private static bool IsTransientException(Exception ex)
    +        private static bool IsTransientException(Exception ex)
             {
    -            // HttpRequestException and TaskCanceled (timeout) are considered transient
    -            return ex is HttpRequestException || ex is OperationCanceledException || ex is TimeoutException;
    +            // HttpRequestException and Timeout are considered transient
    +            return ex is HttpRequestException || ex is TimeoutException;
             }
    Src/CrispyWaffle.BackgroundJobs/Core/BackgroundWorker.cs (1)

    77-92: Re-check “future scheduled job” handling (duplicate of earlier feedback)

    Re-enqueueing a not-due job without delay can cause unnecessary churn. The retry path fix above addresses the main spinner; consider applying a short Task.Delay until due here as well, or simply continue processing after an awaited delay.

    Src/CrispyWaffle.HttpClient/HttpClientWrapper.cs (1)

    35-87: Avoid mutating shared HttpClient (BaseAddress, Timeout, DefaultRequestHeaders) per request

    Clients from IHttpClientFactory are pooled and reused. Setting BaseAddress, Timeout, or DefaultRequestHeaders here causes cross-request side effects and header “bleed” between callers.

    Refactor CreateClient to only select the named client and leave configuration to named-client setup; move per-request concerns to the request message:

    -        private System.Net.Http.HttpClient CreateClient(HttpRequestOptions? perRequest)
    -        {
    -            var name = perRequest?.NamedHttpClient ?? _defaults.NamedHttpClient;
    -            System.Net.Http.HttpClient client = string.IsNullOrEmpty(name)
    -                ? _httpClientFactory.CreateClient()
    -                : _httpClientFactory.CreateClient(name);
    -
    -            var baseAddr = perRequest?.BaseAddress ?? _defaults.BaseAddress;
    -            if (!string.IsNullOrEmpty(baseAddr) && client.BaseAddress == null)
    -            {
    -                client.BaseAddress = new Uri(baseAddr);
    -            }
    -
    -            var timeoutSeconds = perRequest?.TimeoutSeconds ?? _defaults.TimeoutSeconds;
    -            client.Timeout = TimeSpan.FromSeconds(Math.Max(1, timeoutSeconds));
    -
    -            // Merge default headers (global) and per-request headers
    -            var headers = new Dictionary<string, string>();
    -            if (_defaults.DefaultRequestHeaders != null)
    -            {
    -                foreach (var kv in _defaults.DefaultRequestHeaders)
    -                    headers[kv.Key] = kv.Value;
    -            }
    -            if (perRequest?.DefaultRequestHeaders != null)
    -            {
    -                foreach (var kv in perRequest.DefaultRequestHeaders)
    -                    headers[kv.Key] = kv.Value;
    -            }
    -
    -            // Set headers (be careful to not duplicate)
    -            foreach (var kv in headers)
    -            {
    -                if (!client.DefaultRequestHeaders.Contains(kv.Key))
    -                {
    -                    try
    -                    {
    -                        client.DefaultRequestHeaders.Add(kv.Key, kv.Value);
    -                    }
    -                    catch (Exception ex)
    -                    {
    -                        _logger?.LogWarning(ex, "Failed to set default header {Header}", kv.Key);
    -                    }
    -                }
    -            }
    -
    -            // Ensure Accept header
    -            if (client.DefaultRequestHeaders.Accept.Count == 0)
    -            {
    -                client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
    -            }
    -
    -            return client;
    -        }
    +        private System.Net.Http.HttpClient CreateClient(HttpRequestOptions? perRequest)
    +        {
    +            var name = perRequest?.NamedHttpClient ?? _defaults.NamedHttpClient;
    +            return string.IsNullOrEmpty(name)
    +                ? _httpClientFactory.CreateClient()
    +                : _httpClientFactory.CreateClient(name);
    +        }

    Follow-up changes for per-request BaseAddress/Timeout/Headers are proposed below.

    🧹 Nitpick comments (51)
    Src/CrispyWaffle.BackgroundJobs/Scheduling/CronJobScheduler.cs (5)

    7-10: Clarify XML docs to match the current behavior (interval-based stub, not CRON).

    The summary mentions CRON expressions, but the implementation is an every-minute loop. Adjust the docs to avoid misleading consumers.

    -    /// CronJobScheduler is a lightweight helper that triggers an action on a CRON expression.
    -    /// Note: to support CRON syntax you'd typically add a dependency such as NCrontab or Cronos.
    -    /// This is a stub showing how to wire a hosted service cron runner.
    +    /// CronJobService is a lightweight base that runs ExecuteOnce on a fixed interval (default: 1 minute).
    +    /// To support real CRON expressions, consider a library such as NCrontab or Cronos.
    +    /// This is a stub demonstrating how to wire a hosted-service "cron-like" runner.

    25-34: Graceful cancellation: don’t log errors on shutdown; include job name in logs.

    If ExecuteOnce honors cancellation and throws OperationCanceledException, we shouldn’t log it as an error. Also, include the job name (derived type) in logs for better observability.

    -                try
    -                {
    -                    await ExecuteOnce(stoppingToken);
    -                }
    -                catch (Exception ex)
    -                {
    -                    _logger.LogError(ex, "Cron job execution error");
    -                }
    +                try
    +                {
    +                    await ExecuteOnce(stoppingToken);
    +                }
    +                catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
    +                {
    +                    // Graceful shutdown
    +                    _logger.LogInformation("Cron job {Job} was cancelled", GetType().Name);
    +                    break;
    +                }
    +                catch (Exception ex)
    +                {
    +                    _logger.LogError(ex, "Cron job {Job} execution error");
    +                }

    11-19: Make the run interval configurable (avoid hard-coding one minute).

    Give derived jobs flexibility to choose their cadence. Expose a virtual Interval property with a sensible default and use it in the delay.

         public abstract class CronJobService : BackgroundService
         {
    -        private readonly ILogger _logger;
    +        private readonly ILogger<CronJobService> _logger;
     
             protected CronJobService(ILogger logger)
             {
                 _logger = logger;
             }
    +
    +        /// <summary>
    +        /// The interval between ExecuteOnce runs. Override to customize cadence.
    +        /// </summary>
    +        protected virtual TimeSpan Interval => TimeSpan.FromMinutes(1);

    And update the delay below:

    -                await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
    +                await Task.Delay(Interval, stoppingToken);

    20-36: Optional: Use PeriodicTimer for more precise, drift-resistant intervals.

    PeriodicTimer avoids accumulating drift that can happen with Task.Delay after work completes and simplifies cancellation.

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(Interval);
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                await ExecuteOnce(stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                _logger.LogInformation("Cron job {Job} was cancelled", GetType().Name);
                break;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Cron job {Job} execution error");
            }
        }
    }

    Note: Requires using System.Threading; and targeting .NET 6+.


    4-4: Nit: file/class naming consistency.

    The file is CronJobScheduler.cs but the class is CronJobService. Consider renaming the file to match the type for consistency with common C# conventions.

    Src/CrispyWaffle.HttpClient/CrispyWaffle.HttpClient.csproj (2)

    3-7: Consider enabling XML docs for the package (if you plan to publish).

    If this library is intended for NuGet distribution, generating XML docs helps consumers with IntelliSense and API docs.

    Apply this diff:

       <PropertyGroup>
         <TargetFramework>net8.0</TargetFramework>
         <ImplicitUsings>enable</ImplicitUsings>
         <Nullable>enable</Nullable>
    +    <GenerateDocumentationFile>true</GenerateDocumentationFile>
       </PropertyGroup>

    10-10: If you plan to use policy-based retries, add Microsoft.Extensions.Http.Polly.

    Given the PR mentions retry/backoff for the HTTP client wrapper, verify whether you’re relying purely on custom logic or want to leverage Polly handlers via IHttpClientFactory. If the latter, add the Polly package.

    Apply this diff:

       <ItemGroup>
         <PackageReference Include="Microsoft.Extensions.Http" />
    +    <PackageReference Include="Microsoft.Extensions.Http.Polly" />
       </ItemGroup>
    Src/CrispyWaffle.BackgroundJobs/Abstractions/JobPriority.cs (2)

    3-8: Default enum value 0 maps to High; confirm this is intentional.

    In C#, enum default is 0. With High = 0, any uninitialized JobPriority will be High. The PR summary indicates Normal as the default when scheduling. If there’s any code path that doesn’t explicitly set Priority (e.g., constructing JobEntity directly), jobs could be inadvertently enqueued as High priority.

    Two options:

    • Keep the current values and ensure all JobEntity instances default the property to Normal explicitly.
    • Or, make Normal = 0 so the default is Normal (but then adjust queue semantics accordingly).

    If you keep the current enum values, ensure JobEntity sets a default:

    // In JobEntity.cs
    public JobPriority Priority { get; set; } = JobPriority.Normal;

    3-8: Add XML docs to clarify priority ordering.

    Documenting that lower numeric values represent higher priority will avoid confusion when reading or maintaining the code.

    Apply this diff:

     namespace CrispyWaffle.BackgroundJobs.Abstractions
     {
         public enum JobPriority
         {
    -        High = 0,
    -        Normal = 1,
    -        Low = 2
    +        /// <summary>
    +        /// Highest priority. Lower numeric value means higher priority in the queue.
    +        /// </summary>
    +        High = 0,
    +        /// <summary>
    +        /// Default priority for most jobs.
    +        /// </summary>
    +        Normal = 1,
    +        /// <summary>
    +        /// Lowest priority.
    +        /// </summary>
    +        Low = 2
         }
     }
    Src/CrispyWaffle.BackgroundJobs/Exceptions/JobFailedException.cs (1)

    3-6: Provide standard exception constructors and seal the type.

    Adding the default and inner-exception constructors improves ergonomics and preserves exception causality. Sealing avoids unintended inheritance for a domain-specific exception.

    Apply this diff:

     namespace CrispyWaffle.BackgroundJobs.Exceptions
     {
    -    public class JobFailedException : Exception
    +    /// <summary>
    +    /// Represents a failure in a background job execution pipeline.
    +    /// </summary>
    +    public sealed class JobFailedException : Exception
         {
    -        public JobFailedException(string message) : base(message) { }
    +        /// <summary>
    +        /// Initializes a new instance of the <see cref="JobFailedException"/> class.
    +        /// </summary>
    +        public JobFailedException() { }
    +
    +        /// <summary>
    +        /// Initializes a new instance of the <see cref="JobFailedException"/> class with a specified error message.
    +        /// </summary>
    +        public JobFailedException(string message) : base(message) { }
    +
    +        /// <summary>
    +        /// Initializes a new instance of the <see cref="JobFailedException"/> class with a specified error message and a reference to the inner exception that is the cause of this exception.
    +        /// </summary>
    +        public JobFailedException(string message, Exception? innerException) : base(message, innerException) { }
         }
     }
    Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csproj (1)

    10-18: Trim dependencies if possible (prefer abstractions, avoid duplicates).

    • You reference both Microsoft.Extensions.DependencyInjection and Microsoft.Extensions.DependencyInjection.Abstractions. If you only need IServiceCollection and define your own extension methods without calling framework extension methods (like AddHostedService/AddOptions), Abstractions alone is sufficient. Otherwise, keep the full package.
    • For logging, consider depending on Microsoft.Extensions.Logging.Abstractions to reduce transitive dependencies if you only use ILogger.

    Option A (keep as-is if using DI/hosting extension methods):

    • No change.

    Option B (if not needed, swap to abstractions-only):

       <ItemGroup>
         <PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" />
         <PackageReference Include="Microsoft.EntityFrameworkCore" />
    -    <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
         <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
         <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
         <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
    -    <PackageReference Include="Microsoft.Extensions.Logging" />
    +    <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
         <PackageReference Include="Microsoft.Extensions.Options" />
       </ItemGroup>
    Src/CrispyWaffle.BackgroundJobs/Core/JobOptions.cs (3)

    20-23: Unify time-based options as TimeSpan (avoid raw seconds).

    MaxBackoffSeconds as an int diverges from PollingInterval (TimeSpan). Using TimeSpan for both improves clarity and prevents unit mismatch.

    Apply this diff:

             /// <summary>
    -        /// Maximum exponential backoff seconds for retry.
    +        /// Maximum exponential backoff duration for retry.
             /// </summary>
    -        public int MaxBackoffSeconds { get; set; } = 300; // 5 minutes
    +        public TimeSpan MaxBackoff { get; set; } = TimeSpan.FromMinutes(5);

    Follow-up: update call sites to use JobOptions.MaxBackoff instead of MaxBackoffSeconds.


    3-24: Add options validation to guard against misconfiguration.

    Guard rails like WorkerCount >= 1, DefaultMaxAttempt >= 0, PollingInterval > 0, and MaxBackoff >= PollingInterval (optional) help avoid runtime surprises.

    You can add a validator in a separate file and register it via services.AddOptions().Validate(...) or IValidateOptions:

    // e.g., Src/CrispyWaffle.BackgroundJobs/Core/JobOptionsValidation.cs
    using Microsoft.Extensions.Options;
    
    namespace CrispyWaffle.BackgroundJobs.Core;
    
    public sealed class JobOptionsValidation : IValidateOptions<JobOptions>
    {
        public ValidateOptionsResult Validate(string? name, JobOptions options)
        {
            if (options.WorkerCount < 1)
                return ValidateOptionsResult.Fail("WorkerCount must be >= 1.");
    
            if (options.DefaultMaxAttempt < 0)
                return ValidateOptionsResult.Fail("DefaultMaxAttempt must be >= 0.");
    
            if (options.PollingInterval <= TimeSpan.Zero)
                return ValidateOptionsResult.Fail("PollingInterval must be > 0.");
    
            // If you adopt TimeSpan MaxBackoff:
            // if (options.MaxBackoff <= TimeSpan.Zero)
            //     return ValidateOptionsResult.Fail("MaxBackoff must be > 0.");
    
            return ValidateOptionsResult.Success;
        }
    }

    And register in DI:

    services.AddOptions<JobOptions>();
    services.AddSingleton<IValidateOptions<JobOptions>, JobOptionsValidation>();

    8-8: Double-check default WorkerCount in containerized environments.

    Environment.ProcessorCount reports host cores, not CPU limits in containers. That can oversubscribe threads. Consider documenting this and encouraging configuration override, or cap with a sane maximum for defaults.

    Src/CrispyWaffle.BackgroundJobs/Core/JobResult.cs (1)

    3-7: Optional: add a dedicated factory for retryable failures to improve call-site readability

    Avoiding a boolean flag at call sites makes intent clearer.

    Apply this diff to add a convenience factory:

         public sealed record JobResult(bool Success, bool Retry = false, string? ErrorMessage = null)
         {
             public static JobResult Ok() => new(true);
             public static JobResult Fail(string message, bool retry = false) => new(false, retry, message);
    +        public static JobResult RetryableFail(string message) => new(false, true, message);
         }
    Src/CrispyWaffle.BackgroundJobs/Abstractions/IBackgroundJobHandler.cs (1)

    9-12: Consider contravariance and non-null constraint on TData

    This improves flexibility (allowing handlers for base types) and documents nullability intent of payloads.

    Apply this diff:

    -    public interface IBackgroundJobHandler<TData>
    +    public interface IBackgroundJobHandler<in TData>
    +        where TData : notnull
         {
             Task<JobResult> HandleAsync(TData data, CancellationToken cancellationToken);
         }
    Src/CrispyWaffle.BackgroundJobs/Abstractions/IBackgroundJob.cs (1)

    9-12: Optional: consider ValueTask if many implementations complete synchronously

    If common implementations are fast/synchronous, ValueTask can reduce allocations on hot paths. If most are truly async (I/O), keep Task.

    Src/CrispyWaffle.HttpClient/Exceptions/HttpClientException.cs (1)

    6-16: Base on HttpRequestException and seal the type

    Deriving from HttpRequestException makes it easier for callers to handle all HTTP-related failures uniformly. Sealing prevents accidental subclassing of an exception type.

    Apply this diff:

    -    public class HttpClientException : Exception
    +    public sealed class HttpClientException : System.Net.Http.HttpRequestException
         {
             public HttpStatusCode? StatusCode { get; }
             public string? ResponseContent { get; }
    
             public HttpClientException(string message, HttpStatusCode? statusCode = null, string? responseContent = null, Exception? inner = null)
    -            : base(message, inner)
    +            : base(message, inner)
             {
                 StatusCode = statusCode;
                 ResponseContent = responseContent;
             }
         }
    Src/CrispyWaffle.BackgroundJobs/Monitoring/JobMetrics.cs (2)

    9-16: Validate keys and return a read-only snapshot to harden the API

    Adds guardrails and expresses immutability to consumers. Minor change with low risk if usages rely on enumeration only.

    Apply this diff:

    -        public void Increment(string key)
    +        public void Increment(string key)
             {
    -            _counters.AddOrUpdate(key, 1, (_, v) => v + 1);
    +            if (string.IsNullOrWhiteSpace(key))
    +            {
    +                throw new System.ArgumentException("Key cannot be null or whitespace.", nameof(key));
    +            }
    +            _counters.AddOrUpdate(key, 1, (_, v) => v + 1);
             }
    @@
    -        public System.Collections.Generic.IDictionary<string, long> Snapshot() => new System.Collections.Generic.Dictionary<string, long>(_counters);
    +        public System.Collections.Generic.IReadOnlyDictionary<string, long> Snapshot() =>
    +            new System.Collections.Generic.Dictionary<string, long>(_counters);

    7-16: Watch out for unbounded key cardinality in production

    If keys include dynamic values (e.g., job IDs), the dictionary can grow unbounded. Prefer a curated, finite key set (e.g., by job type/status) or add eviction/TTL if needed.

    Src/CrispyWaffle.BackgroundJobs/Monitoring/MonitoringMiddleware.cs (3)

    11-15: Guard against null dependencies in middleware constructor

    Add argument validation to fail fast if DI wiring is incorrect, preventing later null reference exceptions.

    -        public MonitoringMiddleware(RequestDelegate next, JobMetrics metrics)
    -        {
    -            _next = next;
    -            _metrics = metrics;
    -        }
    +        public MonitoringMiddleware(RequestDelegate next, JobMetrics metrics)
    +        {
    +            _next = next ?? throw new ArgumentNullException(nameof(next));
    +            _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
    +        }

    19-23: Limit endpoint to GET and honor request abort token

    Constrain the metrics endpoint to GET requests and pass the RequestAborted token to WriteAsync for cooperative cancellation.

    -            if (context.Request.Path.StartsWithSegments("/jobs/metrics"))
    +            if (HttpMethods.IsGet(context.Request.Method) &&
    +                context.Request.Path.StartsWithSegments("/jobs/metrics"))
                 {
                     context.Response.ContentType = "application/json";
    -                await context.Response.WriteAsync(JsonSerializer.Serialize(_metrics.Snapshot()));
    +                await context.Response.WriteAsync(
    +                    JsonSerializer.Serialize(_metrics.Snapshot()),
    +                    context.RequestAborted);
                     return;
                 }

    17-27: Consider securing or making the metrics endpoint configurable

    Exposing /jobs/metrics unprotected can leak operational data in production. Consider one or more:

    • Make the path configurable via options (e.g., MonitoringOptions.Path).
    • Allow injection of an auth predicate or require an authorization policy.
    • Optionally disable by default and require explicit opt-in.

    I can sketch an options class and the minimal wiring (IOptions) if you’d like.

    Src/CrispyWaffle.HttpClient/HttpResponse{T}.cs (2)

    10-18: Prefer immutability: seal the type and switch to init-only setters

    Sealing and using init-only setters make the response immutable after construction, preventing accidental mutation by consumers.

    -    public class HttpResponse<T>
    +    public sealed class HttpResponse<T>
         {
    -        public bool IsSuccess { get; set; }
    -        public HttpStatusCode? StatusCode { get; set; }
    -        public T? Data { get; set; }
    -        public string? RawContent { get; set; }
    -        public IList<string>? Errors { get; set; }
    -        public IDictionary<string, IEnumerable<string>>? Headers { get; set; }
    -        public TimeSpan Duration { get; set; }
    +        public bool IsSuccess { get; init; }
    +        public HttpStatusCode? StatusCode { get; init; }
    +        public T? Data { get; init; }
    +        public string? RawContent { get; init; }
    +        public IList<string>? Errors { get; init; }
    +        public IDictionary<string, IEnumerable<string>>? Headers { get; init; }
    +        public TimeSpan Duration { get; init; }
         }

    1-1: Nit: consider renaming the file

    Using braces in filenames (HttpResponse{T}.cs) can be awkward with tooling. Consider HttpResponse.T.cs or HttpResponse`1.cs.

    Src/CrispyWaffle.HttpClient/Serialization/IJsonSerializer.cs (1)

    5-10: Interface shape looks solid; aligns with the provided SystemTextJsonSerializer implementation

    The members cover the required use cases for the HttpClient wrapper. No functional concerns.

    If you want to improve discoverability, consider adding XML docs to the interface methods (Serialize/Deserialize) describing null-handling semantics and expected behavior for empty inputs.

    Src/CrispyWaffle.BackgroundJobs/Abstractions/IJobScheduler.cs (1)

    1-9: Add missing usings; consider cancellation tokens for scheduler APIs

    • The interface uses TimeSpan and Task but lacks explicit imports. Add them to avoid relying on ImplicitUsings.
    • Optional: Expose CancellationToken in both methods; scheduling and enqueueing often touch external stores and should be cancelable. Since this is a new interface, adding the token now prevents an immediate breaking change later.

    Minimal fix (explicit usings):

    -namespace CrispyWaffle.BackgroundJobs.Abstractions
    +using System;
    +using System.Threading.Tasks;
    +
    +namespace CrispyWaffle.BackgroundJobs.Abstractions
     {
         public interface IJobScheduler
         {
             Task ScheduleAsync(string handlerName, object payload, TimeSpan delay, int maxAttempts = 3, JobPriority priority = JobPriority.Normal);
    
             Task EnqueueAsync(string handlerName, object payload, int maxAttempts = 3, JobPriority priority = JobPriority.Normal);
         }
     }

    Optional API enhancement (cancellable operations):

    -        Task ScheduleAsync(string handlerName, object payload, TimeSpan delay, int maxAttempts = 3, JobPriority priority = JobPriority.Normal);
    +        Task ScheduleAsync(string handlerName, object payload, TimeSpan delay, int maxAttempts = 3, JobPriority priority = JobPriority.Normal, System.Threading.CancellationToken cancellationToken = default);
    
    -        Task EnqueueAsync(string handlerName, object payload, int maxAttempts = 3, JobPriority priority = JobPriority.Normal);
    +        Task EnqueueAsync(string handlerName, object payload, int maxAttempts = 3, JobPriority priority = JobPriority.Normal, System.Threading.CancellationToken cancellationToken = default);

    If you choose the cancellable signatures, remember to update DelayedJobScheduler (and any other IJobScheduler implementations) accordingly.

    Src/CrispyWaffle.BackgroundJobs/Core/BackgroundJobQueue.cs (2)

    15-16: Channel created as unbounded: set SingleReader if only one consumer reads

    If BackgroundWorker is the only consumer, use SingleReader = true for minor perf gains.

    -        private readonly Channel<JobEntity> _signal = Channel.CreateUnbounded<JobEntity>(new UnboundedChannelOptions { SingleReader = false, SingleWriter = false });
    +        private readonly Channel<JobEntity> _signal = Channel.CreateUnbounded<JobEntity>(
    +            new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });

    If you expect multiple concurrent consumers, keep SingleReader = false.


    23-25: Use TryWrite on unbounded channel to avoid unnecessary async machinery

    For unbounded channels, TryWrite is preferable and avoids allocating/awaiting a ValueTask. Current code discards the ValueTask anyway.

    -            // Always write to signal channel to notify consumers.
    -            _ = _signal.Writer.WriteAsync(job);
    +            // Always write to signal channel to notify consumers.
    +            _signal.Writer.TryWrite(job);
    Src/CrispyWaffle.HttpClient/ServiceCollectionExtensions.cs (2)

    37-43: Use TryAddSingleton to respect prior registrations (extensibility)

    Switch to TryAddSingleton so apps can override the serializer or wrapper before calling this extension. This is a common DI best practice for library-provided defaults.

    -            // Register serializer as a singleton for thread-safety.
    -            // This assumes the serializer implementation is stateless or internally thread-safe.
    -            services.AddSingleton<Serialization.IJsonSerializer, Serialization.SystemTextJsonSerializer>();
    +            // Register serializer as a singleton for thread-safety (allow override).
    +            // This assumes the serializer implementation is stateless or internally thread-safe.
    +            services.TryAddSingleton<Serialization.IJsonSerializer, Serialization.SystemTextJsonSerializer>();
    
    -            // Register the HttpClient wrapper.
    -            services.AddSingleton<IHttpClientWrapper, HttpClientWrapper>();
    +            // Register the HttpClient wrapper (allow override).
    +            services.TryAddSingleton<IHttpClientWrapper, HttpClientWrapper>();

    You’ll need the Extensions namespace import:

     using Microsoft.Extensions.DependencyInjection;
    +using Microsoft.Extensions.DependencyInjection.Extensions;

    26-35: Options registration looks good; minor nit

    Using Configure with a no-op delegate is fine. Alternatively, you could call AddOptions() and only Configure when a delegate is provided, but it’s functionally equivalent.

    No change required.

    Src/CrispyWaffle.BackgroundJobs/Persistence/JobDbContext.cs (1)

    1-1: Remove unused and suspicious import (System.Reflection.Emit).

    This import is not used and is out of place in a DbContext file.

    Apply this diff:

    -using System.Reflection.Emit;
     using CrispyWaffle.BackgroundJobs.Abstractions;
     using Microsoft.EntityFrameworkCore;
    Src/CrispyWaffle.BackgroundJobs/Core/JobHandlerRegistry.cs (1)

    7-13: Use case-insensitive keys and prevent accidental re-registration.

    Handler names are logical identifiers often treated case-insensitively. Also, silently overwriting an existing mapping can hide misconfiguration.

    Apply this diff:

    -        private readonly ConcurrentDictionary<string, (Type handlerType, Type dataType)> _map = new();
    +        private readonly ConcurrentDictionary<string, (Type handlerType, Type dataType)> _map =
    +            new(System.StringComparer.OrdinalIgnoreCase);
     
             public void Register<THandler, TData>(string handlerName) where THandler : class, CrispyWaffle.BackgroundJobs.Abstractions.IBackgroundJobHandler<TData>
             {
                 if (string.IsNullOrWhiteSpace(handlerName)) throw new ArgumentException("handlerName required", nameof(handlerName));
    -            _map[handlerName] = (typeof(THandler), typeof(TData));
    +            if (!_map.TryAdd(handlerName, (typeof(THandler), typeof(TData))))
    +            {
    +                throw new InvalidOperationException($"Handler '{handlerName}' is already registered.");
    +            }
             }
    Src/CrispyWaffle.HttpClient/RetryPolicy.cs (2)

    104-114: Standardize comments to English for consistency.

    Non-English comments can confuse readers and tooling. Suggest updating these lines.

    Apply this diff:

    -            // 1. Chuẩn HTTP (HttpResponseHeaders)
    +            // 1. Standard HTTP header (HttpResponseHeaders)

    114-126: Standardize comments to English for consistency.

    Same here; keep comments consistent across the codebase.

    Apply this diff:

    -            // 2. Fallback - parse thủ công từ mọi header (cả ContentHeaders)
    +            // 2. Fallback - manual parse from any header (including ContentHeaders)
    Src/CrispyWaffle.BackgroundJobs/Core/JobDispatcher.cs (2)

    21-43: Validate inputs and normalize maxAttempts

    • Ensure handlerName is not null/empty.
    • Normalize maxAttempts to a positive integer to avoid non-sensical configurations.

    Apply this diff:

    - public async Task<Guid> EnqueueAsync(string handlerName, object payload, int? maxAttempts = null, JobPriority priority = JobPriority.Normal)
    + public async Task<Guid> EnqueueAsync(string handlerName, object payload, int? maxAttempts = null, JobPriority priority = JobPriority.Normal)
     {
    +    if (string.IsNullOrWhiteSpace(handlerName))
    +        throw new ArgumentException("Handler name is required.", nameof(handlerName));
    +    var effectiveMaxAttempts = (maxAttempts ?? _options.DefaultMaxAttempt);
    +    if (effectiveMaxAttempts <= 0) effectiveMaxAttempts = _options.DefaultMaxAttempt;
         var job = new JobEntity
         {
             HandlerName = handlerName,
             Payload = JsonSerializer.Serialize(payload),
             Priority = priority,
    -        MaxAttempt = maxAttempts ?? _options.DefaultMaxAttempt,
    +        MaxAttempt = effectiveMaxAttempts,
             Status = JobStatus.Pending,
             CreatedAt = DateTimeOffset.UtcNow
         };
    - public async Task ScheduleAsync(string handlerName, object payload, TimeSpan delay, int? maxAttempts = null, JobPriority priority = JobPriority.Normal)
    + public async Task ScheduleAsync(string handlerName, object payload, TimeSpan delay, int? maxAttempts = null, JobPriority priority = JobPriority.Normal)
     {
    +    if (string.IsNullOrWhiteSpace(handlerName))
    +        throw new ArgumentException("Handler name is required.", nameof(handlerName));
    +    var effectiveMaxAttempts = (maxAttempts ?? _options.DefaultMaxAttempt);
    +    if (effectiveMaxAttempts <= 0) effectiveMaxAttempts = _options.DefaultMaxAttempt;
         var job = new JobEntity
         {
             HandlerName = handlerName,
             Payload = JsonSerializer.Serialize(payload),
             Priority = priority,
    -        MaxAttempt = maxAttempts ?? _options.DefaultMaxAttempt,
    +        MaxAttempt = effectiveMaxAttempts,
             Status = JobStatus.Pending,
             ScheduledAt = DateTimeOffset.UtcNow.Add(delay),
             CreatedAt = DateTimeOffset.UtcNow
         };

    Also applies to: 45-75


    64-73: Fire-and-forget delay swallows exceptions; consider logging and cancellation integration

    The in-memory scheduling path suppresses all exceptions. Consider injecting ILogger<JobDispatcher> and logging failures, and optionally wiring a CancellationToken (e.g., via IHostApplicationLifetime) for graceful shutdown.

    Would you like a patch that adds ILogger<JobDispatcher> and logs the suppressed exceptions?

    Src/CrispyWaffle.BackgroundJobs/Core/BackgroundWorker.cs (4)

    221-226: Backoff cap is hardcoded; use configurable JobOptions.MaxBackoffSeconds

    Make the backoff cap configurable per options instead of a constant 300 seconds.

    Apply this diff:

    -        private static int ComputeBackoffSeconds(int attempt)
    +        private int ComputeBackoffSeconds(int attempt)
             {
                 // exponential backoff with cap
                 var seconds = (int)Math.Pow(2, Math.Min(attempt, 10));
    -            return Math.Min(seconds, 300);
    +            return Math.Min(seconds, _options.MaxBackoffSeconds);
             }

    Note: this changes the method to instance; call sites remain the same.


    146-151: Treat deserialization errors as non-retriable failures

    A malformed payload will currently throw and be retried indefinitely until MaxAttempt is hit. Consider marking as failed on JsonException (bad input) instead of retrying.

    Apply this diff:

    -                if (!string.IsNullOrWhiteSpace(job.Payload))
    -                {
    -                    data = JsonSerializer.Deserialize(job.Payload, dataType ?? typeof(object));
    -                }
    +                if (!string.IsNullOrWhiteSpace(job.Payload))
    +                {
    +                    try
    +                    {
    +                        data = JsonSerializer.Deserialize(job.Payload, dataType ?? typeof(object));
    +                    }
    +                    catch (JsonException jex)
    +                    {
    +                        var msg = $"Payload deserialization failed for handler '{job.HandlerName}': {jex.Message}";
    +                        _logger.LogError(jex, msg);
    +                        if (_store != null)
    +                        {
    +                            await _store.MarkFailedAsync(job.Id, msg, cancellationToken);
    +                        }
    +                        return false;
    +                    }
    +                }

    Also applies to: 198-209


    228-276: Broaden handler invocation to support IBackgroundJob.ExecuteAsync(CancellationToken)

    Current logic only looks for HandleAsync. If you intend to support both IBackgroundJobHandler<TData> and IBackgroundJob, add a fallback to ExecuteAsync(CancellationToken) and support (CancellationToken) only signatures.

    Proposed replacement for method discovery:

    -            var method = handler.GetType().GetMethod("HandleAsync");
    -            if (method == null) throw new InvalidOperationException("Handler does not implement HandleAsync");
    +            var t = handler.GetType();
    +            var method = t.GetMethod("HandleAsync")
    +                         ?? t.GetMethod("ExecuteAsync");
    +            if (method == null) throw new InvalidOperationException("Handler does not implement HandleAsync/ExecuteAsync");

    And augment parameter binding to handle the (CancellationToken)-only case:

    -            if (parameters.Length == 2)
    +            if (parameters.Length == 2)
                 {
                     invokeArg1 = data;
                     invokeArg2 = cancellationToken;
                 }
    -            else if (parameters.Length == 1)
    +            else if (parameters.Length == 1)
                 {
    -                invokeArg1 = data ?? cancellationToken;
    -                if (invokeArg1 is CancellationToken)
    -                {
    -                    invokeArg2 = null;
    -                }
    +                // either (TData) or (CancellationToken)
    +                if (parameters[0].ParameterType == typeof(CancellationToken))
    +                {
    +                    invokeArg1 = cancellationToken;
    +                }
    +                else
    +                {
    +                    invokeArg1 = data;
    +                }
                 }

    40-132: Break down complex methods to improve readability and testability

    WorkerLoopAsync and ProcessJobAsync are doing a lot. Consider extracting:

    • FetchOrWaitAsync (store/queue selection, polling delay)
    • HandleScheduledFutureAsync
    • HandleFailureAsync (retry/exhaust)
    • ResolveAndInvokeAsync
      This will also ease unit testing.

    Also applies to: 133-220

    Src/CrispyWaffle.BackgroundJobs/Persistence/EfCoreJobStore.cs (1)

    28-33: Add supporting indexes to reduce scan cost

    Fetching next job by (Status, ScheduledAt, Priority, CreatedAt) benefits from indexes. Add composite indexes in JobDbContext:

    For example (in JobDbContext OnModelCreating):

    modelBuilder.Entity<JobEntity>()
        .HasIndex(j => new { j.Status, j.ScheduledAt, j.Priority, j.CreatedAt })
        .HasDatabaseName("IX_Jobs_NextDue");
    
    modelBuilder.Entity<JobEntity>()
        .HasIndex(j => j.Status)
        .HasDatabaseName("IX_Jobs_Status");
    Src/CrispyWaffle.HttpClient/HttpRequestOptions.cs (1)

    10-10: Typo: “overriden” → “overridden”

    Apply this diff:

    -    /// Options used as defaults (via IOptions) and can be overriden per-request.
    +    /// Options used as defaults (via IOptions) and can be overridden per-request.
    Src/CrispyWaffle.BackgroundJobs/Persistence/InMemoryJobStore.cs (1)

    38-47: Optional: Avoid unbounded memory growth in the in-memory store

    Completed/failed jobs are retained indefinitely. For long-running processes this can leak memory.

    Options:

    • Remove terminal jobs on completion/failure:
    @@
    -        public Task MarkCompletedAsync(Guid jobId, CancellationToken cancellationToken = default)
    +        public Task MarkCompletedAsync(Guid jobId, CancellationToken cancellationToken = default)
             {
    -            if (_storage.TryGetValue(jobId, out var job))
    -            {
    -                job.Status = JobStatus.Completed;
    -                job.UpdatedAt = DateTimeOffset.UtcNow;
    -                _storage[jobId] = job;
    -            }
    +            _storage.TryRemove(jobId, out _);
                 return Task.CompletedTask;
             }
    @@
    -        public Task MarkFailedAsync(Guid jobId, string error, CancellationToken cancellationToken = default)
    +        public Task MarkFailedAsync(Guid jobId, string error, CancellationToken cancellationToken = default)
             {
    -            if (_storage.TryGetValue(jobId, out var job))
    -            {
    -                job.Status = JobStatus.Failed;
    -                job.LastError = error;
    -                job.UpdatedAt = DateTimeOffset.UtcNow;
    -                _storage[jobId] = job;
    -            }
    +            if (_storage.TryGetValue(jobId, out var job))
    +            {
    +                job.Status = JobStatus.Failed;
    +                job.LastError = error;
    +                job.UpdatedAt = DateTimeOffset.UtcNow;
    +                _storage[jobId] = job;
    +            }
                 return Task.CompletedTask;
             }
    • Or implement a simple TTL/background cleanup for terminal jobs. I can sketch this if you prefer to keep terminal state for monitoring.

    Also applies to: 49-59, 61-72

    Src/CrispyWaffle.BackgroundJobs/Extensions/ServiceCollectionExtensions.cs (3)

    14-16: XML doc/parameter naming mismatch

    Doc says “pass useEfCore=true” but the parameter is named useEfCoreStore.

    -/// Register core background job services. By default uses InMemoryJobStore. To use EF Core, call AddDbContext<JobDbContext> before this and pass useEfCore=true.
    +/// Register core background job services. By default uses InMemoryJobStore. To use EF Core, call AddDbContext<JobDbContext> before this and pass useEfCoreStore=true.

    21-25: Consider using the Options pattern for JobOptions

    Registering a concrete JobOptions singleton works, but using IOptions integrates better with configuration binding and validation.

    If interested, I can provide a minimal refactor to:

    • services.Configure(configureOptions ?? (_ => {}));
    • Inject IOptions where needed.

    77-83: Reflection-based Register() is brittle

    Relying on a generic method name via reflection couples to a concrete registry implementation. Prefer a non-generic API on IJobHandlerRegistry, e.g., Register(string handlerName, Type handlerType, Type dataType).

    I can add this method to the interface and registry to remove reflection here.

    Src/CrispyWaffle.BackgroundJobs/Abstractions/JobEntity.cs (1)

    11-46: Looks solid; consider adding a concurrency token for EF store

    Entity defaults and fields cover the lifecycle well.

    If EF Core is used for claiming work with optimistic concurrency, adding a RowVersion (byte[] timestamp) and mapping it as a concurrency token strengthens “claim” semantics in EfCoreJobStore. Optional:

    public byte[]? RowVersion { get; set; }

    Then configure .IsRowVersion() in JobDbContext.

    Src/CrispyWaffle.HttpClient/IHttpClientWrapper.cs (2)

    18-23: Sync wrappers can deadlock; consider isolating or removing from the primary interface

    Blocking on async with GetAwaiter().GetResult() can deadlock under certain sync contexts (ASP.NET classic, UI). You already warn in comments, but exposing them on the primary interface invites misuse.

    Options:

    • Drop sync methods from the interface and keep only async.
    • Move sync methods to an extension class or separate interface to discourage routine use.

    13-16: Optional: Add typed DeleteAsync for symmetry

    You support typed Get/Post/Put but Delete returns object?. A typed variant is often useful.

    Example signature:

    Task<HttpResponse<T>> DeleteAsync<T>(string url, HttpRequestOptions? options = null, CancellationToken cancellationToken = default);

    I can wire this through HttpClientWrapper if desired.

    Src/CrispyWaffle.HttpClient/HttpClientWrapper.cs (1)

    123-260: Method complexity is high; consider extracting helpers

    SendAsync handles URI composition, request construction, retries, content reading, header aggregation, deserialization, and error shaping. Extracting a few helpers will improve maintainability and testability.

    Candidates:

    • BuildRequestMessage(method, url, body, effectiveOptions, serializer)
    • ReadRawContentAsync(HttpResponseMessage, logger, throwOnError)
    • DeserializeOrFailure(raw, serializer, headers, status, duration, throwOnError)

    Comment thread Directory.Packages.props
    Comment thread Directory.Packages.props
    Comment on lines +184 to +188
    else
    {
    job.ScheduledAt = nextAttempt;
    _queue!.Enqueue(job);
    }

    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.

    ⚠️ Potential issue

    In-memory retry re-enqueues immediately → tight loop CPU spin

    When using the in-memory queue, failed jobs are re-enqueued immediately with a future ScheduledAt, causing the worker to pick them up again right away and spin. Schedule with a delay instead of immediate enqueue.

    Apply this diff:

    -                    if (_store != null)
    -                    {
    -                        await _store.MarkRetryAsync(job.Id, nextAttempt, job.Attempt, cancellationToken);
    -                    }
    -                    else
    -                    {
    -                        job.ScheduledAt = nextAttempt;
    -                        _queue!.Enqueue(job);
    -                    }
    +                    if (_store != null)
    +                    {
    +                        await _store.MarkRetryAsync(job.Id, nextAttempt, job.Attempt, cancellationToken);
    +                    }
    +                    else
    +                    {
    +                        job.ScheduledAt = nextAttempt;
    +                        _ = Task.Run(async () =>
    +                        {
    +                            try
    +                            {
    +                                var delay = nextAttempt - DateTimeOffset.UtcNow;
    +                                if (delay > TimeSpan.Zero) await Task.Delay(delay, cancellationToken);
    +                                _queue!.Enqueue(job);
    +                            }
    +                            catch (OperationCanceledException) { /* shutting down */ }
    +                            catch { /* suppressed */ }
    +                        });
    +                    }
    -                else
    -                {
    -                    job.Attempt++;
    -                    job.ScheduledAt = DateTimeOffset.UtcNow.AddSeconds(ComputeBackoffSeconds(job.Attempt));
    -                    _queue!.Enqueue(job);
    -                }
    +                else
    +                {
    +                    job.Attempt++;
    +                    var nextAttempt = DateTimeOffset.UtcNow.AddSeconds(ComputeBackoffSeconds(job.Attempt));
    +                    job.ScheduledAt = nextAttempt;
    +                    _ = Task.Run(async () =>
    +                    {
    +                        try
    +                        {
    +                            var delay = nextAttempt - DateTimeOffset.UtcNow;
    +                            if (delay > TimeSpan.Zero) await Task.Delay(delay, cancellationToken);
    +                            _queue!.Enqueue(job);
    +                        }
    +                        catch (OperationCanceledException) { /* shutting down */ }
    +                        catch { /* suppressed */ }
    +                    });
    +                }

    Also applies to: 211-215

    🤖 Prompt for AI Agents
    In Src/CrispyWaffle.BackgroundJobs/Core/BackgroundWorker.cs around lines 184-188
    and 211-215, failed jobs are being re-enqueued immediately into the in-memory
    queue even when their ScheduledAt is set in the future, causing the worker to
    pick them up repeatedly and spin the CPU; update the logic to schedule the job
    with a delay instead of immediate Enqueue by using a delayed scheduling
    mechanism (e.g., compute delay = job.ScheduledAt - DateTime.UtcNow and call a
    queue method that accepts a delay or use Task.Delay to enqueue after that delay)
    and ensure null checks on _queue remain, so jobs with future ScheduledAt are not
    available to consumers until the delay elapses.
    

    Comment on lines +13 to +19
    public JobDispatcher(IServiceProvider provider, BackgroundJobQueue? queue, IJobStore? store, JobOptions options)
    {
    _provider = provider;
    _queue = queue;
    _store = store;
    _options = options;
    }

    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.

    ⚠️ Potential issue

    Guard against missing queue/store to prevent silently dropped jobs

    If both _store and _queue are null, jobs are effectively lost (no persistence and no enqueue). Enforce a guard in the constructor.

    Apply this diff:

     public JobDispatcher(IServiceProvider provider, BackgroundJobQueue? queue, IJobStore? store, JobOptions options)
     {
         _provider = provider;
         _queue = queue;
         _store = store;
         _options = options;
    +
    +    if (_queue is null && _store is null)
    +    {
    +        throw new InvalidOperationException("JobDispatcher requires either IJobStore or BackgroundJobQueue to be registered.");
    +    }
     }
    📝 Committable suggestion

    ‼️ IMPORTANT
    Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    Suggested change
    public JobDispatcher(IServiceProvider provider, BackgroundJobQueue? queue, IJobStore? store, JobOptions options)
    {
    _provider = provider;
    _queue = queue;
    _store = store;
    _options = options;
    }
    public JobDispatcher(IServiceProvider provider, BackgroundJobQueue? queue, IJobStore? store, JobOptions options)
    {
    _provider = provider;
    _queue = queue;
    _store = store;
    _options = options;
    if (_queue is null && _store is null)
    {
    throw new InvalidOperationException("JobDispatcher requires either IJobStore or BackgroundJobQueue to be registered.");
    }
    }
    🤖 Prompt for AI Agents
    In Src/CrispyWaffle.BackgroundJobs/Core/JobDispatcher.cs around lines 13 to 19,
    the constructor currently allows both _queue and _store to be null which would
    silently drop jobs; add a guard that validates at least one of queue or store is
    provided and throw an ArgumentException (or ArgumentNullException) if both are
    null. Update the constructor to check if queue == null && store == null and
    throw a clear exception message like "Either queue or store must be provided" so
    the failure is explicit and prevents silent job loss.
    

    Comment on lines +60 to +88
    // StartupFilter ensures the registry is populated after DI container is built
    private class JobHandlerStartupFilter : Microsoft.AspNetCore.Hosting.IStartupFilter
    {
    private readonly IServiceProvider _sp;
    private readonly string _handlerName;
    private readonly Type _handlerType;
    private readonly Type _dataType;

    public JobHandlerStartupFilter(IServiceProvider sp, string handlerName, Type handlerType, Type dataType)
    {
    _sp = sp; _handlerName = handlerName; _handlerType = handlerType; _dataType = dataType;
    }

    public Action<Microsoft.AspNetCore.Builder.IApplicationBuilder> Configure(Action<Microsoft.AspNetCore.Builder.IApplicationBuilder> next)
    {
    return app =>
    {
    var registry = _sp.GetRequiredService<IJobHandlerRegistry>();
    var registerMethod = registry.GetType().GetMethod("Register")?.MakeGenericMethod(_handlerType, _dataType);
    // If Register<THandler, TData>(string) exists we call via reflection
    if (registerMethod != null)
    {
    registerMethod.Invoke(registry, new object[] { _handlerName });
    }

    next(app);
    };
    }
    }

    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.

    💡 Verification agent

    🧩 Analysis chain

    Avoid ASP.NET-only IStartupFilter for handler registration; support Generic Host

    IStartupFilter runs only in ASP.NET Core pipelines. Consumers using Generic Host (e.g., WorkerService without a web app) won’t trigger this, and handlers won’t be registered.

    Safer alternative: register via an IHostedService that runs at startup, resolves IJobHandlerRegistry, and performs the registration. This works in both web and worker hosts.

    I can replace JobHandlerStartupFilter with a small HostedService (JobHandlerRegistrationHostedService<THandler,TData>) and wire it in AddJobHandler. Would you like me to generate that patch?


    🏁 Script executed:

    #!/bin/bash
    set -e
    echo "Searching for JobHandlerStartupFilter usage..."
    rg -n "JobHandlerStartupFilter" -C3
    
    echo
    echo "Searching for AddJobHandler extension..."
    rg -n "AddJobHandler" -C3

    Length of output: 3714


    Use IHostedService instead of IStartupFilter for handler registration to support Generic Host

    IStartupFilter only fires in the ASP.NET Core request pipeline—any app running on Generic Host (e.g. a WorkerService) will never invoke it, so your job handlers won’t be registered. To fix this:

    • Remove the IStartupFilter registration in AddJobHandler<THandler,TData>.
    • Introduce a generic IHostedService (e.g. JobHandlerRegistrationHostedService<THandler,TData>) that, in StartAsync, resolves IJobHandlerRegistry and calls its Register<THandler,TData>(handlerName).
    • In AddJobHandler, register that hosted service:

    services.AddSingleton<IHostedService>(sp =>
        new JobHandlerRegistrationHostedService<THandler, TData>(
            sp.GetRequiredService<IJobHandlerRegistry>(),
            handlerName));

    This approach works in both Web and Worker hosts. Let me know if you’d like a sample implementation.

    🤖 Prompt for AI Agents
    In Src/CrispyWaffle.BackgroundJobs/Extensions/ServiceCollectionExtensions.cs
    around lines 60 to 88, the current JobHandlerStartupFilter implementing
    IStartupFilter won't run for Generic Host apps (e.g. WorkerService) so handlers
    may never be registered; replace this approach by removing the IStartupFilter
    registration and adding a generic IHostedService (e.g.
    JobHandlerRegistrationHostedService<THandler,TData>) that takes an
    IJobHandlerRegistry and handlerName and calls
    registry.Register<THandler,TData>(handlerName) in StartAsync, then in
    AddJobHandler<THandler,TData> register that hosted service as a singleton
    IHostedService via a factory (resolving IJobHandlerRegistry and passing
    handlerName) so registration runs on both Web and Worker hosts.
    

    Comment on lines +136 to +162
    // The action will create a NEW HttpRequestMessage each time it's invoked (important for retries).
    Func<CancellationToken, Task<HttpResponseMessage>> action = async ct =>
    {
    // Create a new request per attempt - ensures headers/content are fresh for retries.
    using var req = new HttpRequestMessage(method, url);

    if (body != null)
    {
    // Serialize the body (synchronous serializer expected here).
    string payload = serializer.Serialize(body);
    req.Content = new StringContent(payload, Encoding.UTF8, effectiveOptions.UseJsonContentType ? "application/json" : "text/plain");
    }

    // Apply per-request (merged) headers to the request message.
    if (effectiveOptions.DefaultRequestHeaders != null)
    {
    foreach (var kv in effectiveOptions.DefaultRequestHeaders)
    {
    if (!req.Headers.Contains(kv.Key))
    req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
    }
    }

    // Send with ResponseHeadersRead to start streaming the body (we will read it explicitly afterward).
    // This avoids buffering the entire response in some handlers.
    return await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
    };

    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.

    🛠️ Refactor suggestion

    Build request URI from per-request BaseAddress and set Accept/header values on the request, not the client

    Compose the request URI using BaseAddress when provided, and ensure Accept header is set per request to avoid pollution.

    -                // Create a new request per attempt - ensures headers/content are fresh for retries.
    -                using var req = new HttpRequestMessage(method, url);
    +                // Build request URI from optional BaseAddress + relative path
    +                var requestUri = BuildRequestUri(url, effectiveOptions.BaseAddress);
    +                // Create a new request per attempt - ensures headers/content are fresh for retries.
    +                using var req = new HttpRequestMessage(method, requestUri);
    @@
    -                if (effectiveOptions.DefaultRequestHeaders != null)
    +                if (effectiveOptions.DefaultRequestHeaders != null)
                     {
                         foreach (var kv in effectiveOptions.DefaultRequestHeaders)
                         {
                             if (!req.Headers.Contains(kv.Key))
                                 req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
                         }
                     }
    +
    +                // Ensure an Accept header per request (avoid mutating HttpClient.DefaultRequestHeaders)
    +                if (req.Headers.Accept == null || req.Headers.Accept.Count == 0)
    +                {
    +                    req.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
    +                }

    Add this helper in the Helpers region (outside this range):

    private static Uri BuildRequestUri(string url, string? baseAddress)
    {
        if (Uri.TryCreate(url, UriKind.Absolute, out var absolute))
            return absolute;
        if (!string.IsNullOrWhiteSpace(baseAddress) && Uri.TryCreate(baseAddress, UriKind.Absolute, out var baseUri))
            return new Uri(baseUri, url);
        // fall back: try as absolute; will throw if invalid
        return new Uri(url, UriKind.RelativeOrAbsolute);
    }
    🤖 Prompt for AI Agents
    In Src/CrispyWaffle.HttpClient/HttpClientWrapper.cs around lines 136 to 162, the
    request is created directly from the url and headers like Accept may be added to
    the shared HttpClient; instead, use a per-request URI built from the optional
    BaseAddress and apply headers (including Accept) to the HttpRequestMessage so
    you don't mutate client defaults. Replace new HttpRequestMessage(method, url)
    with new HttpRequestMessage(method, BuildRequestUri(url,
    effectiveOptions.BaseAddress)) and set Accept and any other per-request headers
    on req.Headers (not on client.DefaultRequestHeaders), preserving existing logic
    for DefaultRequestHeaders merging; also add the provided BuildRequestUri helper
    method in the Helpers region (outside this block) to resolve absolute or
    base-relative URIs.
    

    Comment on lines +164 to +171
    // Execute the action with retry policy. The policy is expected to return the final response (or null + exception).
    var (resp, ex) = await RetryPolicy.ExecuteAsync(
    action,
    effectiveOptions.RetryCount,
    effectiveOptions.RetryBaseDelayMs,
    effectiveOptions.MaxRetryDelayMs,
    cancellationToken).ConfigureAwait(false);

    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.

    🛠️ Refactor suggestion

    Use a per-request timeout via a linked CancellationTokenSource instead of mutating HttpClient.Timeout

    This preserves isolation between callers and avoids cross-request side effects.

    -            var (resp, ex) = await RetryPolicy.ExecuteAsync(
    +            using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    +            timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, effectiveOptions.TimeoutSeconds)));
    +
    +            var (resp, ex) = await RetryPolicy.ExecuteAsync(
                     action,
                     effectiveOptions.RetryCount,
                     effectiveOptions.RetryBaseDelayMs,
                     effectiveOptions.MaxRetryDelayMs,
    -                cancellationToken).ConfigureAwait(false);
    +                timeoutCts.Token).ConfigureAwait(false);
    📝 Committable suggestion

    ‼️ IMPORTANT
    Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    Suggested change
    // Execute the action with retry policy. The policy is expected to return the final response (or null + exception).
    var (resp, ex) = await RetryPolicy.ExecuteAsync(
    action,
    effectiveOptions.RetryCount,
    effectiveOptions.RetryBaseDelayMs,
    effectiveOptions.MaxRetryDelayMs,
    cancellationToken).ConfigureAwait(false);
    // Execute the action with retry policy. The policy is expected to return the final response (or null + exception).
    using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(1, effectiveOptions.TimeoutSeconds)));
    var (resp, ex) = await RetryPolicy.ExecuteAsync(
    action,
    effectiveOptions.RetryCount,
    effectiveOptions.RetryBaseDelayMs,
    effectiveOptions.MaxRetryDelayMs,
    timeoutCts.Token).ConfigureAwait(false);
    🤖 Prompt for AI Agents
    In Src/CrispyWaffle.HttpClient/HttpClientWrapper.cs around lines 164 to 171, do
    not change HttpClient.Timeout; instead create a linked CancellationTokenSource
    using the incoming cancellationToken and the per-request timeout from
    effectiveOptions (e.g. TimeoutMs), then pass linkedCts.Token into
    RetryPolicy.ExecuteAsync (and/or into the delegated action) so the timeout
    cancels only this request; ensure you Dispose the linked CTS (use using or
    try/finally) and propagate OperationCanceledException as before.
    

    Comment on lines +29 to +43
    /// <summary>Gets or sets a value indicating whether if true, methods throw HttpClientException on final failure instead of returning unsuccessful HttpResponse&lt;T&gt;.</summary>
    public bool ThrowOnError { get; set; } = false;

    /// <summary>Gets or sets default request headers to add to each request if present.</summary>
    public IDictionary<string, string>? DefaultRequestHeaders { get; set; }

    /// <summary>Gets or sets optional specific named client to create from IHttpClientFactory. If null, factory.CreateClient() is used.</summary>
    public string? NamedHttpClient { get; set; }

    /// <summary>Gets or sets a value indicating whether if true, sends content type application/json when body present.</summary>
    public bool UseJsonContentType { get; set; } = true;

    /// <summary>Gets or sets optional serializer override per-request. If null will use DI resolved one.</summary>
    public IJsonSerializer? Serializer { get; set; }
    }

    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.

    🛠️ Refactor suggestion

    Bool overrides cannot express “unset”; they always override defaults

    ThrowOnError and UseJsonContentType are plain bools. In the merge logic, a provided per-request options instance will always override defaults (even if the caller didn’t intend to change these). Make them nullable and only override when set.

    Apply this diff:

    -        public bool ThrowOnError { get; set; } = false;
    +        public bool? ThrowOnError { get; set; }
    -        public bool UseJsonContentType { get; set; } = true;
    +        public bool? UseJsonContentType { get; set; }

    And update the merge logic in HttpClientWrapper (for reference, outside this file):

    ThrowOnError = perRequest.ThrowOnError ?? defaults.ThrowOnError,
    UseJsonContentType = perRequest.UseJsonContentType ?? defaults.UseJsonContentType,
    🤖 Prompt for AI Agents
    In Src/CrispyWaffle.HttpClient/HttpRequestOptions.cs around lines 29-43, change
    the two plain bool properties ThrowOnError and UseJsonContentType to nullable
    booleans (bool?) and remove their current default initializers so they can
    express "unset"; then update the merge logic in HttpClientWrapper to use
    null-coalescing (e.g., ThrowOnError = perRequest.ThrowOnError ??
    defaults.ThrowOnError and UseJsonContentType = perRequest.UseJsonContentType ??
    defaults.UseJsonContentType) so per-request options only override when
    explicitly set.
    

    Comment thread Src/CrispyWaffle.HttpClient/RetryPolicy.cs
    Comment on lines +91 to +99
    private static int ComputeDelay(int attempt, int baseDelayMs, int maxDelayMs, Random rnd)
    {
    // exponential backoff with jitter
    // formula: min(maxDelay, base * 2^attempt) +/- jitter up to 20%
    var exponential = baseDelayMs * (1 << Math.Min(attempt, 10)); // cap shift
    var capped = Math.Min(exponential, maxDelayMs);
    var jitter = (int)(capped * 0.2 * (rnd.NextDouble() - 0.5)); // ±10%
    var result = Math.Max(0, capped + jitter);
    return result;

    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.

    ⚠️ Potential issue

    Fix integer overflow in exponential backoff and align jitter with comment.

    Using int math can overflow for larger attempts/base delays. Compute in long and clamp. Also make jitter ±20% as the comment states.

    Apply this diff:

    -        private static int ComputeDelay(int attempt, int baseDelayMs, int maxDelayMs, Random rnd)
    +        private static int ComputeDelay(int attempt, int baseDelayMs, int maxDelayMs, Random rnd)
             {
                 // exponential backoff with jitter
    -            // formula: min(maxDelay, base * 2^attempt) +/- jitter up to 20%
    -            var exponential = baseDelayMs * (1 << Math.Min(attempt, 10)); // cap shift
    -            var capped = Math.Min(exponential, maxDelayMs);
    -            var jitter = (int)(capped * 0.2 * (rnd.NextDouble() - 0.5)); // ±10%
    -            var result = Math.Max(0, capped + jitter);
    -            return result;
    +            // formula: min(maxDelay, base * 2^attempt) +/- jitter up to 20%
    +            var shift = 1L << Math.Min(attempt, 10); // cap shift
    +            var exponential = (long)baseDelayMs * shift;
    +            var capped = (int)Math.Min(exponential, (long)maxDelayMs);
    +            var jitter = (int)(capped * 0.2 * (rnd.NextDouble() * 2 - 1)); // ±20%
    +            return Math.Max(0, capped + jitter);
             }
    📝 Committable suggestion

    ‼️ IMPORTANT
    Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    Suggested change
    private static int ComputeDelay(int attempt, int baseDelayMs, int maxDelayMs, Random rnd)
    {
    // exponential backoff with jitter
    // formula: min(maxDelay, base * 2^attempt) +/- jitter up to 20%
    var exponential = baseDelayMs * (1 << Math.Min(attempt, 10)); // cap shift
    var capped = Math.Min(exponential, maxDelayMs);
    var jitter = (int)(capped * 0.2 * (rnd.NextDouble() - 0.5)); // ±10%
    var result = Math.Max(0, capped + jitter);
    return result;
    private static int ComputeDelay(int attempt, int baseDelayMs, int maxDelayMs, Random rnd)
    {
    // exponential backoff with jitter
    // formula: min(maxDelay, base * 2^attempt) +/- jitter up to 20%
    var shift = 1L << Math.Min(attempt, 10); // cap shift
    var exponential = (long)baseDelayMs * shift;
    var capped = (int)Math.Min(exponential, (long)maxDelayMs);
    var jitter = (int)(capped * 0.2 * (rnd.NextDouble() * 2 - 1)); // ±20%
    return Math.Max(0, capped + jitter);
    }
    🤖 Prompt for AI Agents
    In Src/CrispyWaffle.HttpClient/RetryPolicy.cs around lines 91–99, the
    exponential backoff uses int math which can overflow and the jitter calculation
    implements ±10% despite the comment saying ±20%; change the math to use long for
    the exponential calculation (e.g., use 1L << Math.Min(attempt, 10) and multiply
    baseDelayMs as long), clamp the long result against maxDelayMs (also treated as
    long), compute jitter as ±20% of the capped value using a symmetric random
    factor (e.g., (rnd.NextDouble()*2 - 1) * 0.2 * capped), convert the final
    clamped value safely back to int ensuring it stays within 0..int.MaxValue and
    not above maxDelayMs, and return that int.
    

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

    Review by Korbit AI

    Korbit automatically attempts to detect when you fix issues in new commits.
    Category Issue Status
    Readability Overly Generic Exception Name ▹ view
    Readability Missing Context in Exception ▹ view
    Design Insufficient Exception Context ▹ view
    Functionality Invalid State Combinations Possible ▹ view
    Design Interface merges distinct responsibilities ▹ view
    Security Unsafe JSON Deserialization ▹ view
    Error Handling Undefined null handling in serialization ▹ view
    Design Unnecessary Namespace Qualification in Constraint ▹ view
    Functionality Nullable Types in Registry Lookup ▹ view
    Readability Verbose namespace qualification ▹ view
    Files scanned
    File Path Reviewed
    Src/CrispyWaffle.BackgroundJobs/Abstractions/JobPriority.cs
    Src/CrispyWaffle.BackgroundJobs/Exceptions/JobFailedException.cs
    Src/CrispyWaffle.BackgroundJobs/Abstractions/JobStatus.cs
    Src/CrispyWaffle.HttpClient/Serialization/IJsonSerializer.cs
    Src/CrispyWaffle.BackgroundJobs/Core/JobResult.cs
    Src/CrispyWaffle.BackgroundJobs/Core/IJobHandlerRegistry.cs
    Src/CrispyWaffle.BackgroundJobs/Abstractions/IJobScheduler.cs
    Src/CrispyWaffle.BackgroundJobs/Abstractions/IBackgroundJobHandler.cs
    Src/CrispyWaffle.BackgroundJobs/Abstractions/IBackgroundJob.cs
    Src/CrispyWaffle.HttpClient/Exceptions/HttpClientException.cs
    Src/CrispyWaffle.BackgroundJobs/Monitoring/JobMetrics.cs
    Src/CrispyWaffle.BackgroundJobs/Core/JobOptions.cs
    Src/CrispyWaffle.BackgroundJobs/Monitoring/MonitoringMiddleware.cs
    Src/CrispyWaffle.BackgroundJobs/Abstractions/IJobStore.cs
    Src/CrispyWaffle.BackgroundJobs/Core/JobHandlerRegistry.cs
    Src/CrispyWaffle.BackgroundJobs/Persistence/JobDbContext.cs
    Src/CrispyWaffle.HttpClient/Serialization/SystemTextJsonSerializer.cs
    Src/CrispyWaffle.BackgroundJobs/Scheduling/DelayedJobScheduler.cs
    Src/CrispyWaffle.BackgroundJobs/Scheduling/CronJobScheduler.cs
    Src/CrispyWaffle.BackgroundJobs/Abstractions/JobEntity.cs
    Src/CrispyWaffle.HttpClient/IHttpClientWrapper.cs
    Src/CrispyWaffle.BackgroundJobs/Core/BackgroundJobQueue.cs
    Src/CrispyWaffle.HttpClient/HttpResponse{T}.cs
    Src/CrispyWaffle.HttpClient/ServiceCollectionExtensions.cs
    Src/CrispyWaffle.HttpClient/HttpRequestOptions.cs
    Src/CrispyWaffle.BackgroundJobs/Core/JobDispatcher.cs
    Src/CrispyWaffle.BackgroundJobs/Persistence/InMemoryJobStore.cs
    Src/CrispyWaffle.BackgroundJobs/Persistence/EfCoreJobStore.cs
    Src/CrispyWaffle.BackgroundJobs/Extensions/ServiceCollectionExtensions.cs
    Src/CrispyWaffle.HttpClient/RetryPolicy.cs
    Src/CrispyWaffle.BackgroundJobs/Core/BackgroundWorker.cs
    Src/CrispyWaffle.HttpClient/HttpClientWrapper.cs

    Explore our documentation to understand the languages and file types we support and the files we ignore.

    Check out our docs on how you can make Korbit work best for you and your team.

    Loving Korbit!? Share us on LinkedIn Reddit and X

    Comment thread Src/CrispyWaffle.BackgroundJobs/Exceptions/JobFailedException.cs
    Comment thread Src/CrispyWaffle.BackgroundJobs/Exceptions/JobFailedException.cs Outdated
    Comment thread Src/CrispyWaffle.BackgroundJobs/Exceptions/JobFailedException.cs
    Comment thread Src/CrispyWaffle.BackgroundJobs/Core/JobResult.cs
    Comment thread Src/CrispyWaffle.HttpClient/Serialization/IJsonSerializer.cs
    Comment thread Src/CrispyWaffle.HttpClient/Serialization/IJsonSerializer.cs
    Comment thread Src/CrispyWaffle.HttpClient/Serialization/IJsonSerializer.cs
    Comment thread Src/CrispyWaffle.BackgroundJobs/Core/IJobHandlerRegistry.cs Outdated
    Comment thread Src/CrispyWaffle.BackgroundJobs/Core/IJobHandlerRegistry.cs
    Comment thread Src/CrispyWaffle.HttpClient/Serialization/SystemTextJsonSerializer.cs Outdated
    @guibranco

    Copy link
    Copy Markdown
    Owner

    Hi @ducdaiii 👋,

    Thank you so much for your pull request! 🙌

    I appreciate the time and effort you put into this contribution.
    I'll review it shortly, and if everything looks good, I'll approve it as soon as possible.

    Thanks again for your valuable contribution! 🚀

    @guibranco guibranco removed the 🚦 awaiting triage Items that are awaiting triage or categorization label Aug 29, 2025
    @socket-security

    socket-security Bot commented Dec 4, 2025

    Copy link
    Copy Markdown

    Caution

    Review the following alerts detected in dependencies.

    According to your organization's Security Policy, you must resolve all "Block" alerts before proceeding. Learn more about Socket for GitHub.

    Action Severity Alert  (click "▶" to expand/collapse)
    Block Critical
    Critical CVE: .NET Core Remote Code Execution Vulnerability in nuget system.text.encodings.web

    CVE: GHSA-ghhp-997w-qr28 .NET Core Remote Code Execution Vulnerability (CRITICAL)

    Affected versions: = 5.0.0; >= 4.0.0 < 4.5.1; >= 4.6.0 < 4.7.2; >= 5.0.0 < 5.0.1

    Patched version: 4.5.1

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.aspnetcore.hosting.abstractions@2.2.0nuget/elmahcore@2.1.2nuget/system.text.encodings.web@4.5.0

    ℹ Read more on: This package | This alert | What is a critical CVE?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: Remove or replace dependencies that include known critical CVEs. Consumers can use dependency overrides or npm audit fix --force to remove vulnerable dependencies.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/system.text.encodings.web@4.5.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    Block Medium
    Dynamic code execution: nuget microsoft.entityframeworkcore.abstractions

    Location: Package overview

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.entityframeworkcore@9.0.8nuget/microsoft.entityframeworkcore.abstractions@9.0.8

    ℹ Read more on: This package | This alert | What is dynamic code execution?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: Avoid packages that use dynamic code execution like eval(), since this could potentially execute any code.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/microsoft.entityframeworkcore.abstractions@9.0.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    Block Medium
    Dynamic code execution: nuget microsoft.entityframeworkcore.analyzers

    Location: Package overview

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.entityframeworkcore@9.0.8nuget/microsoft.entityframeworkcore.analyzers@9.0.8

    ℹ Read more on: This package | This alert | What is dynamic code execution?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: Avoid packages that use dynamic code execution like eval(), since this could potentially execute any code.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/microsoft.entityframeworkcore.analyzers@9.0.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    Block Medium
    System shell access: nuget microsoft.entityframeworkcore.relational

    Location: Package overview

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.entityframeworkcore.relational@9.0.8

    ℹ Read more on: This package | This alert | What is shell access?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: Packages should avoid accessing the shell which can reduce portability, and make it easier for malicious shell access to be introduced.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/microsoft.entityframeworkcore.relational@9.0.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    Block Medium
    Dynamic code execution: nuget microsoft.entityframeworkcore.relational

    Location: Package overview

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.entityframeworkcore.relational@9.0.8

    ℹ Read more on: This package | This alert | What is dynamic code execution?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: Avoid packages that use dynamic code execution like eval(), since this could potentially execute any code.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/microsoft.entityframeworkcore.relational@9.0.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    Block Medium
    Network access: nuget microsoft.entityframeworkcore

    Location: Package overview

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.entityframeworkcore@9.0.8

    ℹ Read more on: This package | This alert | What is network access?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: Packages should remove all network access that is functionally unnecessary. Consumers should audit network access to ensure legitimate use.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/microsoft.entityframeworkcore@9.0.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    Block Medium
    System shell access: nuget microsoft.entityframeworkcore

    Location: Package overview

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.entityframeworkcore@9.0.8

    ℹ Read more on: This package | This alert | What is shell access?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: Packages should avoid accessing the shell which can reduce portability, and make it easier for malicious shell access to be introduced.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/microsoft.entityframeworkcore@9.0.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    Block Medium
    Dynamic code execution: nuget microsoft.entityframeworkcore

    Location: Package overview

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.entityframeworkcore@9.0.8

    ℹ Read more on: This package | This alert | What is dynamic code execution?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: Avoid packages that use dynamic code execution like eval(), since this could potentially execute any code.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/microsoft.entityframeworkcore@9.0.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    Block Medium
    Dynamic code execution: nuget microsoft.extensions.caching.abstractions

    Location: Package overview

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.entityframeworkcore@9.0.8nuget/microsoft.entityframeworkcore.relational@9.0.8nuget/microsoft.extensions.caching.abstractions@9.0.8

    ℹ Read more on: This package | This alert | What is dynamic code execution?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: Avoid packages that use dynamic code execution like eval(), since this could potentially execute any code.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/microsoft.extensions.caching.abstractions@9.0.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    Block Medium
    Dynamic code execution: nuget microsoft.extensions.caching.memory

    Location: Package overview

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.entityframeworkcore@9.0.8nuget/microsoft.entityframeworkcore.relational@9.0.8nuget/microsoft.extensions.caching.memory@9.0.8

    ℹ Read more on: This package | This alert | What is dynamic code execution?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: Avoid packages that use dynamic code execution like eval(), since this could potentially execute any code.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/microsoft.extensions.caching.memory@9.0.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    Block Low
    Filesystem access: nuget microsoft.entityframeworkcore.relational

    Location: Package overview

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.entityframeworkcore.relational@9.0.8

    ℹ Read more on: This package | This alert | What is filesystem access?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: If a package must read the file system, clarify what it will read and ensure it reads only what it claims to. If appropriate, packages can leave file system access to consumers and operate on data passed to it instead.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/microsoft.entityframeworkcore.relational@9.0.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    Block Low
    Filesystem access: nuget microsoft.entityframeworkcore

    Location: Package overview

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.entityframeworkcore@9.0.8

    ℹ Read more on: This package | This alert | What is filesystem access?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: If a package must read the file system, clarify what it will read and ensure it reads only what it claims to. If appropriate, packages can leave file system access to consumers and operate on data passed to it instead.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/microsoft.entityframeworkcore@9.0.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    Block Low
    License exception: nuget microsoft.extensions.caching.abstractions with Classpath-exception-2.0

    Exception: Classpath-exception-2.0

    Comments:

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.entityframeworkcore@9.0.8nuget/microsoft.entityframeworkcore.relational@9.0.8nuget/microsoft.extensions.caching.abstractions@9.0.8

    ℹ Read more on: This package | This alert | What is a license exception?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: License exceptions should be carefully reviewed.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/microsoft.extensions.caching.abstractions@9.0.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    Block Low
    License exception: nuget microsoft.extensions.caching.memory with Classpath-exception-2.0

    Exception: Classpath-exception-2.0

    Comments:

    From: Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csprojnuget/microsoft.entityframeworkcore@9.0.8nuget/microsoft.entityframeworkcore.relational@9.0.8nuget/microsoft.extensions.caching.memory@9.0.8

    ℹ Read more on: This package | This alert | What is a license exception?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: License exceptions should be carefully reviewed.

    Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore nuget/microsoft.extensions.caching.memory@9.0.8. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

    View full report

    @guibranco guibranco changed the title Feature/background jobs [FEATURE] Background jobs Dec 4, 2025
    @guibranco

    Copy link
    Copy Markdown
    Owner

    @gstraccini csharpier

    @gstraccini

    gstraccini Bot commented Dec 4, 2025

    Copy link
    Copy Markdown
    Contributor

    Running CSharpier on this branch! 🔧

    @guibranco guibranco changed the title [FEATURE] Background job [FEATURE] Background jobs Jan 12, 2026
    @AppVeyorBot

    Copy link
    Copy Markdown

    @github-advanced-security github-advanced-security AI 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.

    Sonarcsharp (reported by Codacy) found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

    @gitguardian

    gitguardian Bot commented May 19, 2026

    Copy link
    Copy Markdown

    ⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

    Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

    Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
    Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.

    🔎 Detected hardcoded secret in your pull request
    GitGuardian id GitGuardian status Secret Commit Filename
    13768420 Triggered Generic Password b1fde96 .github/workflows/release.yml View secret
    🛠 Guidelines to remediate hardcoded secrets
    1. Understand the implications of revoking this secret by investigating where it is used in your code.
    2. Replace and store your secret safely. Learn here the best practices.
    3. Revoke and rotate this secret.
    4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

    To avoid such incidents in the future consider


    🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

    @guibranco

    Copy link
    Copy Markdown
    Owner

    @gstraccini csharpier

    @gstraccini

    gstraccini Bot commented Jul 4, 2026

    Copy link
    Copy Markdown
    Contributor

    Running CSharpier on this branch! 🔧

    @gstraccini

    gstraccini Bot commented Jul 4, 2026

    Copy link
    Copy Markdown
    Contributor

    CSharpier failed!

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

    ♻️ Duplicate comments (2)
    Directory.Packages.props (2)

    22-22: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

    Microsoft.Extensions.Hosting.Abstractions still pinned to legacy 2.2.0.

    A prior review requested bumping this to 9.0.8 to match the rest of the Microsoft.Extensions.* pins added in this same change (DependencyInjection, EntityFrameworkCore, Options, Logging are all 9.0.8). The current diff still leaves it at 2.2.0, which is inconsistent with the newly added pins on adjacent lines.

    ♻️ Suggested fix
    -    <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="2.2.0" />
    +    <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.8" />
    🤖 Prompt for AI Agents
    Verify each finding against current code. Fix only still-valid issues, skip the
    rest with a brief reason, keep changes minimal, and validate.
    
    In `@Directory.Packages.props` at line 22, The
    Microsoft.Extensions.Hosting.Abstractions package version is still pinned to the
    legacy 2.2.0 value and should be aligned with the other Microsoft.Extensions.*
    package pins in Directory.Packages.props. Update the PackageVersion entry for
    Microsoft.Extensions.Hosting.Abstractions to the same 9.0.8 version used by the
    adjacent DependencyInjection, EntityFrameworkCore, Options, and Logging entries.
    

    17-17: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

    Legacy 2.2.0 package still present despite prior "addressed" note.

    A previous review flagged Microsoft.AspNetCore.Hosting.Abstractions at Version="2.2.0" as conflicting with the newer .NET target packages and requested its removal, noting it was marked addressed in a later commit. However, the current diff still pins it at 2.2.0. Since Src/CrispyWaffle.BackgroundJobs/CrispyWaffle.BackgroundJobs.csproj references this package versionlessly against a net8.0 target, this legacy 2.x package remains mixed with 9.x/10.x Extensions packages in the same dependency graph.

    ♻️ Suggested fix
    -    <PackageVersion Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0" />
    🤖 Prompt for AI Agents
    Verify each finding against current code. Fix only still-valid issues, skip the
    rest with a brief reason, keep changes minimal, and validate.
    
    In `@Directory.Packages.props` at line 17, The package version pin for
    Microsoft.AspNetCore.Hosting.Abstractions is still left at the legacy 2.2.0
    release and should be removed or updated to match the modern net8.0 dependency
    set. Update the shared version in Directory.Packages.props and verify the
    CrispyWaffle.BackgroundJobs project continues to resolve through the intended
    ASP.NET Core/Extensions package family without mixing in the old 2.x package.
    
    🧹 Nitpick comments (1)
    Directory.Packages.props (1)

    23-23: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

    Align Microsoft.Extensions.Http with the rest of the Microsoft.Extensions.* pins
    This is the only 10.x package in the set; if that version isn’t intentional, pin it to 9.0.8 to keep the central package graph consistent.

    🤖 Prompt for AI Agents
    Verify each finding against current code. Fix only still-valid issues, skip the
    rest with a brief reason, keep changes minimal, and validate.
    
    In `@Directory.Packages.props` at line 23, The Microsoft.Extensions.Http package
    version is inconsistent with the rest of the Microsoft.Extensions.* pins. Update
    the central PackageVersion entry for Microsoft.Extensions.Http to match the
    9.0.8 baseline used by the other Microsoft.Extensions packages, unless the 10.x
    upgrade is intentional, and keep the change limited to the PackageVersion
    declaration.
    
    🤖 Prompt for all review comments with AI agents
    Verify each finding against current code. Fix only still-valid issues, skip the
    rest with a brief reason, keep changes minimal, and validate.
    
    Duplicate comments:
    In `@Directory.Packages.props`:
    - Line 22: The Microsoft.Extensions.Hosting.Abstractions package version is
    still pinned to the legacy 2.2.0 value and should be aligned with the other
    Microsoft.Extensions.* package pins in Directory.Packages.props. Update the
    PackageVersion entry for Microsoft.Extensions.Hosting.Abstractions to the same
    9.0.8 version used by the adjacent DependencyInjection, EntityFrameworkCore,
    Options, and Logging entries.
    - Line 17: The package version pin for Microsoft.AspNetCore.Hosting.Abstractions
    is still left at the legacy 2.2.0 release and should be removed or updated to
    match the modern net8.0 dependency set. Update the shared version in
    Directory.Packages.props and verify the CrispyWaffle.BackgroundJobs project
    continues to resolve through the intended ASP.NET Core/Extensions package family
    without mixing in the old 2.x package.
    
    ---
    
    Nitpick comments:
    In `@Directory.Packages.props`:
    - Line 23: The Microsoft.Extensions.Http package version is inconsistent with
    the rest of the Microsoft.Extensions.* pins. Update the central PackageVersion
    entry for Microsoft.Extensions.Http to match the 9.0.8 baseline used by the
    other Microsoft.Extensions packages, unless the 10.x upgrade is intentional, and
    keep the change limited to the PackageVersion declaration.
    

    ℹ️ Review info
    ⚙️ Run configuration

    Configuration used: Organization UI

    Review profile: CHILL

    Plan: Pro

    Run ID: 2d988723-197d-46a5-950b-f6b034b88c83

    📥 Commits

    Reviewing files that changed from the base of the PR and between 61dcd96 and a824470.

    📒 Files selected for processing (1)
    • Directory.Packages.props

    guibranco added 3 commits July 4, 2026 22:50
    Reformat multiline interface and class methods to enhance code
    readability. Align method parameters across multiple lines
    consistently in all files. Add missing newline characters at the end
    of files for better standards compliance. These changes improve
    maintainability without altering functionality, ensuring code
    consistency across the project.
    Refactor `BackgroundWorker` by extracting methods for fetching and
    processing jobs, improving code readability and maintenance. This change
    introduces the following methods: `FetchNextJobAsync`, `RescheduleIfNotDueAsync`,
    and `ProcessJobWithScopeAsync`.
    
    In `HttpClientWrapper`, extracted methods to configure and apply
    HTTP client settings, enhancing clarity. New helper methods include
    `ConfigureBaseAddress`, `ConfigureTimeout`, `ApplyDefaultHeaders`,
    `EnsureAcceptHeader`, and `MergeRequestHeaders`.
    
    These changes promote better code organization and ease of understanding
    while maintaining existing functionality.
    Improve code readability by reformatting method signatures and block
    structures to follow consistent line breaking and indentation
    practices. This affects the BackgroundWorker and HttpClientWrapper
    classes, where method headers are now more uniformly formatted,
    enhancing maintainability. These changes were done to ensure that the
    code follows better styling conventions and makes the codebase more
    consistent and easier to read.
    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.

    5 participants