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 |
|---|---|---|---|---|
Update |
Scheduled |
470 | Quartz | Daily 03:00 (cron) + on demand |
Export |
Scheduled |
41 | Quartz | On demand only — durable, no trigger |
Birthday |
Infrastructure/ |
112 | IHosted + Cronos |
Cron from configuration, default 0 9 * * * |
Loan |
Infrastructure/ |
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 |
|---|---|
exept |
List< |
user, current, method, system |
int / int? |
is |
bool? |
username, content, from, to |
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 |
|---|---|
Warehouse |
Background — stock report export |
Warehouse |
Background |
Warehouse |
Background |
Warehouse |
Background |
BAL/ |
Background |
BAL/ |
Background |
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:
- Constructor parses
ScheduledJobsSettings:BirthdayGreetingsCronExpression, defaulting to"0 9 * * *"(09:00 daily). StartAsyncreturns immediately ifBirthdayGreetingsIsActive == false— the one job with a configuration kill switch.ScheduleJobcomputes the delay to the next occurrence, guards against a non-positive delay by recursing, then sets a one-shotTimer.- On elapse:
DoWork, thenScheduleJobagain to arm the next run. DoWorkcallsBUser.GetTodayUsersBirthday()and sends a bilingual (Arabic + English) greeting throughEmailManager.
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:
- It is never registered.
AddScheduledJobs()adds onlyBirthdayGreetingsJob. - Its body short-circuits:
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.
Related¶
- Architecture — External Services — the integration view.
- Services & DI · Helpers
- Recommendations — R1-3, R7-4.