Skip to content

Source Code — Background Jobs

Four job classes, three independent schedulers, and two of the four never run on a schedule. This page covers the code; Architecture — External Services covers why three schedulers coexist.

The four job classes

Class File Lines Scheduler Runs when
UpdateNextUserJob ScheduledTasks/UpdateNextUserJob.cs 470 Quartz Daily 03:00 (cron) + on demand
ExportHistoryJob ScheduledTasks/ExportHistoryJob.cs 41 Quartz On demand only — durable, no trigger
BirthdayGreetingsJob Infrastructure/BirthdayGreetingsJob.cs 112 IHostedService + Cronos Cron from configuration, default 0 9 * * *
LoanJob Infrastructure/LoanJob.cs 97 (none) Never — not registered, and short-circuits in DEBUG

Quartz — registered in Program.cs

Uniquely, Quartz is wired in Program.cs, before the web host, while the other two schedulers are set up inside Startup.

services.AddQuartz(q =>
{
    q.UseMicrosoftDependencyInjectionJobFactory();

    var jobKey = new JobKey(nameof(ExportHistoryJob));
    q.AddJob<ExportHistoryJob>(opts => opts.WithIdentity(jobKey).StoreDurably());

    var jobKey5 = new JobKey(nameof(UpdateNextUserJob));
    q.AddJob<UpdateNextUserJob>(opt => opt.WithIdentity(nameof(UpdateNextUserJob)));
    q.AddTrigger(opt => opt.ForJob(jobKey5).WithCronSchedule("0 0 3 ? * * *"));
});
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

Storage is in-memory — no UsePersistentStore. A restart loses any in-flight state, and in a multi-instance deployment every instance would fire the 03:00 trigger.

UpdateNextUserJob

IJob, 470 lines, constructor-injected with IServiceProvider (not the services themselves) so it can create its own scope per run — the correct pattern for a singleton-ish job touching a scoped DbContext.

public class UpdateNextUserJob : IJob
{
    public UpdateNextUserJob(IServiceProvider serviceProvider) { … }
    public async Task Execute(IJobExecutionContext context) { … }
    private async Task<int> SetNextUser(int nextUserId) { … }
}

For each document type it loads every request that is still open (not cancelled, refused or accepted), re-resolves the next approver through the matching NextUserHelper.Get<Doc>NextUser call, then passes the result through SetNextUser:

private async Task<int> SetNextUser(int nextUserId)
{
    var substituteUserId = await userObject.GetSubstituteUserId(nextUserId);
    return substituteUserId.HasValue && substituteUserId.Value != nextUserId
        ? substituteUserId.Value : nextUserId;
}

So it does two jobs: it picks up org changes (a new manager, a changed branch role-holder) and it applies delegation — if the approver has a substitute configured, typically while on leave, the document is re-pointed at the substitute. It writes NextUserId only when the value changes. Its length comes from repeating that block once per document type.

It reads an optional requestType from the scheduler context (context.Scheduler.Context), limiting the sweep to one document type when set, and all types when null. If Get<Doc>NextUser returns null — for instance because a role-holder cannot be resolved (R6-10) — the document is skipped and keeps its old NextUserId.

It is also triggerable by hand from /Admin/TriggerNextUser ("Refresh Next users", key AdminIndex), which calls RunNextUserService(type):

// AdminController.cs:71-76
if (type != null)
{
    scheduler.Context.Put("requestType", (int)type);
}
await scheduler.TriggerJob(new JobKey(nameof(UpdateNextUserJob)));

A filtered manual run narrows every later run — R8-11

The scheduler context is scheduler-wide and never cleared: nothing calls Remove, and choosing All sends null, which skips the Put. So after an administrator refreshes a single document type once, the 03:00 run — and every later manual All run — re-routes only that type until the application restarts.

ExportHistoryJob

41 lines. Registered .StoreDurably() with no trigger — a durable job can exist without one, which is exactly what lets it be fired on demand. It is not dead code:

// AdminController.cs:2777-2793
var jobKey = new JobKey(nameof(ExportHistoryJob));
…
await scheduler.TriggerJob(jobKey, jobDataMap);

The admin history screen packs its filters into a JobDataMap, which the job unpacks:

Key Type
exeptUserIds List<int>
userId, currentUser, method, system int / int?
isLogin bool?
username, content, fromDate, toDate string

It calls BUser.GetFilteredHistory(...), then either RealTimeNotifier.SendLink(username, -1) when there are no rows, or BUser.ExportUserHistory(...) to build the file and push a download link over SignalR. This is the only place a background job talks back to the browser.

Correcting a common reading of R1-3

"No trigger" is often taken to mean "never runs". For ExportHistoryJob that is wrong — durable registration plus TriggerJob is the intended on-demand pattern, and the admin export depends on it. The genuinely dead job is LoanJob, below.

Hangfire — registered in Startup.cs

SQL Server storage on HangfireConnection, PrepareSchemaIfNecessary = true, plus AddHangfireServer(). Used only for fire-and-forget, never for recurring work.

Site Call
WarehouseControllers/ReportController.cs:331 BackgroundJob.Enqueue(…) — stock report export
WarehouseControllers/DestructionController.cs:234 BackgroundJob.Enqueue(…)
WarehouseControllers/AssetController.cs:230 BackgroundJob.Enqueue(…)
WarehouseControllers/StockController.cs:144 BackgroundJob.Enqueue(…)
BAL/BPurchaseRequest.cs:659 BackgroundJob.Delete(rfq.ScheduledJob)
BAL/BRFQ.cs:2747 BackgroundJob.Delete(oldRfQ.ScheduledJob)

The two Delete calls are dead code. Both read RFQs.ScheduledJob and guard on != null — but nothing anywhere assigns that column, and there is no BackgroundJob.Schedule call in the solution. The column is always null, so neither Delete ever executes. It reads as the remnant of a removed feature that once scheduled an RFQ-closing job.

A fifth enqueue in ReportController.cs:649 is commented out.

No dashboard is mapped

HangFireAuthorizationFilter.cs exists in NKO.Shared/Helpers/ (13 lines) — written to guard a dashboard — but no UseHangfireDashboard() call exists anywhere. A failed Hangfire job cannot be inspected, retried or cancelled from the application. Tracked as R1-3.

The custom IHostedService scheduler

// Infrastructure/AddScheduledJobsExtention.cs — the whole file
public static void AddScheduledJobs(this IServiceCollection service)
{
    service.AddHostedService<BirthdayGreetingsJob>();
}

BirthdayGreetingsJob

IHostedService, IDisposable, 112 lines. Hand-rolled cron using Cronos and System.Timers.Timer:

  1. Constructor parses ScheduledJobsSettings:BirthdayGreetingsCronExpression, defaulting to "0 9 * * *" (09:00 daily).
  2. StartAsync returns immediately if BirthdayGreetingsIsActive == false — the one job with a configuration kill switch.
  3. ScheduleJob computes the delay to the next occurrence, guards against a non-positive delay by recursing, then sets a one-shot Timer.
  4. On elapse: DoWork, then ScheduleJob again to arm the next run.
  5. DoWork calls BUser.GetTodayUsersBirthday() and sends a bilingual (Arabic + English) greeting through EmailManager.

Because it re-arms itself rather than using a repeating timer, a long-running execution shifts the next run instead of stacking. There is no persistence and no catch-up, though: if the application is not running at the cron time — a deploy, an app-pool recycle or IIS idle shutdown — that day's greetings are simply skipped. (A restart before 09:00 is fine: GetNextOccurrence(Now) schedules the same day.) The guard against a non-positive delay calls ScheduleJob again but does not return, so it falls through to new Timer(delay) with a non-positive interval, which throws. See R1-3.

LoanJob — never runs

Same IHostedService shape, 97 lines, intended to settle pending loans automatically. Two independent reasons it does nothing:

  1. It is never registered. AddScheduledJobs() adds only BirthdayGreetingsJob.
  2. Its body short-circuits:
#if DEBUG
    return;
#endif

BAdvance.CompletePendingLoans is therefore reachable only by hand. The risk is the impression that loan settlement is automated when it is not. Tracked as R7-4.

Summary

flowchart TD
    P["Program.cs"] --> Q["Quartz — in-memory"]
    Q --> U["UpdateNextUserJob<br/>cron 0 0 3 ? * * * + TriggerJob"]
    Q --> E["ExportHistoryJob<br/>durable, TriggerJob only"]

    S["Startup.cs"] --> H["Hangfire — SQL Server"]
    H --> W["4 warehouse exports<br/>Enqueue only"]

    S --> C["AddScheduledJobs()"]
    C --> B["BirthdayGreetingsJob<br/>Cronos + Timer, config kill switch"]

    X["LoanJob — not registered,<br/>#if DEBUG return"]

Three schedulers share no configuration, no storage and no dashboard. Quartz state is in memory, Hangfire state is in SQL, and the hosted service keeps state only in a Timer field.