BitzOrcas uses Quartz.NET for background job scheduling, running in a separate JobHost process to keep the API Host AOT-compatible and trim-safe.
Architecture
┌───────────────────────┐ ┌───────────────────────┐│ API Host │ │ JobHost ││ (trimmed, AOT-safe) │ │ (untrimmed Worker) ││ │ │ ││ No Quartz reference │ │ Quartz.NET ││ No CAP consumer │ │ SqlSugar audit store ││ No background jobs │ │ CAP consumers │└───────────┬───────────┘ └───────────┬───────────┘ │ │ └─────────────┬───────────────┘ ▼ ┌─────────────┐ │ SQL Server │ │ (shared) │ └─────────────┘JobHost composition
The JobHost (BitzOrcas.JobHost) is a minimal .NET Worker service:
var builder = Host.CreateApplicationBuilder(args);
// Clock + Audit pipelinebuilder.Services.AddSingleton<IAppClock, SystemClock>();builder.Services.AddBitzOrcasAuditDefaults();builder.Services.AddBitzOrcasAuditPipeline();
// SqlSugar + Audit store (if connection string configured)builder.Services.AddBitzOrcasSqlSugar(opt => opt.ConnectionString = connectionString);builder.Services.AddBitzOrcasSqlSugarAuditStore();
// Quartz schedulingbuilder.Services.AddQuartz(q =>{ var retentionOptions = new AuditPersistenceOptions(); builder.Configuration.GetSection("Audit").Bind(retentionOptions);
if (retentionOptions.Retention.Enabled) { var jobKey = new JobKey("audit-retention"); q.AddJob<AuditRetentionQuartzJob>(opts => opts.WithIdentity(jobKey)); q.AddTrigger(opts => opts .ForJob(jobKey) .WithCronSchedule(retentionOptions.Retention.CronExpression)); }});builder.Services.AddQuartzHostedService(opt => opt.WaitForJobsToComplete = true);
// OpenTelemetry + Health Checks (shared with API Host)builder.AddServiceDefaults();Registered jobs
| Job | Trigger | Purpose |
|---|---|---|
AuditRetentionQuartzJob | Cron (configurable) | Periodic cleanup of audit log records per retention policy |
Audit retention configuration
{ "Audit": { "Retention": { "Enabled": true, "CronExpression": "0 0 2 * * ?", // Daily at 2 AM "MaxAgeDays": 90 } }}Job identity in tenant context
The JobHost has no HTTP context, so it uses AnonymousCurrentUser by default. For jobs that need tenant-scoped operations:
// Use ICurrentUserAccessor.BeginScope to temporarily set a user contextawait currentUserAccessor.BeginScopeAsync(userId, tenantId, async () =>{ // Job code runs as the specified user/tenant await auditStore.CleanupAsync(tenantId, cutoffDate);});Adding a new job
- Create a class implementing
IJobinBitzOrcas.JobHost - Register it in
Program.cswithAddJob<T>()andAddTrigger() - Add Cron schedule to configuration
- Job has full access to SqlSugar repositories and the audit pipeline
ServiceDefaults shared infrastructure
Both API Host and JobHost share the same BitzOrcas.ServiceDefaults:
- OpenTelemetry — traces + metrics via OTLP exporter
- Health Checks — liveness/readiness probes
- Resource attributes — service.name, service.version, deployment.environment
Assembly reference
BitzOrcas.JobHost├── Program.cs → Host setup + Quartz registration├── AuditRetentionQuartzJob.cs → Audit retention cleanup job└── BitzOrcas.JobHost.csproj → Worker SDK + Quartz packages
BitzOrcas.ServiceDefaults├── Extensions/ServiceDefaultsExtensions.cs → AddServiceDefaults()└── Extensions/BitzOrcasActivitySources.cs → Custom ActivitySource names