Appearance
Platforms Worker
The Platforms Worker is a background job runner and Azure Service Bus consumer that handles cross-service orchestration, scheduled maintenance tasks, and event-driven reactions that should not run inside a synchronous HTTP request. It is the operational backbone that keeps all tenants and services in a consistent state.
Overview
| Property | Value |
|---|---|
| Service | PlatformsWorker |
| CAE placement | Private CAE |
| Service type | .NET Worker Service (no HTTP endpoints) |
| Source | Platforms/Src/ (Worker project) |
| Job scheduling | Quartz.NET |
| Event consumption | Azure Service Bus |
| Auth | Managed Identity (no Keycloak — no user context) |
No HTTP Endpoints
The Platforms Worker is a pure background service. It does not expose REST endpoints and is not routed through the Gateway. It communicates outbound via typed HTTP clients to other services and inbound via Azure Service Bus subscriptions.
Responsibilities
Architecture
Azure Service Bus Consumers
The Worker subscribes to the microtec-events topic on a dedicated subscription (platforms-worker-subscription):
TenantProvisionedEvent
Triggered by BusinessOwners.Apis when a new tenant database has been created.
csharp
public class TenantProvisionedEventConsumer : IConsumer<TenantProvisionedEvent>
{
public async Task Consume(ConsumeContext<TenantProvisionedEvent> context)
{
var msg = context.Message;
// 1. Seed lookup data (currencies, countries, units of measure)
await seedingService.SeedLookupDataAsync(msg.TenantId, msg.TenantCode);
// 2. Seed system templates
await seedingService.SeedTemplatesAsync(msg.TenantId);
// 3. Seed default chart of accounts (if accounting module enabled)
if (msg.EnabledModules.Contains("accounting"))
await seedingService.SeedChartOfAccountsAsync(msg.TenantId);
// 4. Send welcome email to admin
await notificationApi.SendAsync(new SendNotificationRequest(
TenantId: msg.TenantId,
RecipientUserId: msg.AdminUserId,
RecipientEmail: msg.AdminEmail,
RecipientPhone: null,
Channel: NotificationChannel.Email,
TemplateCode: "TENANT_WELCOME",
TemplateData: new()
{
["AdminName"] = msg.AdminName,
["TenantName"] = msg.TenantNameAr,
["LoginUrl"] = $"https://*.onlinemicrotec.com.sa/{msg.TenantCode}"
},
Language: "ar"
));
// 5. Callback to BO — mark tenant Active
await businessOwnersApi.SetTenantActiveAsync(msg.TenantId);
}
}SubscriptionExpiredEvent
Triggered by BusinessOwners.Apis when a subscription passes its end date:
csharp
public class SubscriptionExpiredEventConsumer : IConsumer<SubscriptionExpiredEvent>
{
public async Task Consume(ConsumeContext<SubscriptionExpiredEvent> context)
{
var msg = context.Message;
// Auto-suspend the tenant
await businessOwnersApi.SuspendTenantAsync(msg.TenantId, "Subscription expired");
// Notify tenant admin
await notificationApi.SendAsync(new SendNotificationRequest(
TenantId: msg.TenantId,
RecipientUserId: msg.AdminUserId,
RecipientEmail: msg.AdminEmail,
RecipientPhone: null,
Channel: NotificationChannel.Email,
TemplateCode: "SUBSCRIPTION_EXPIRY_WARNING",
TemplateData: new()
{
["TenantName"] = msg.TenantNameAr,
["ExpiredDate"] = msg.EndDate.ToString("yyyy-MM-dd"),
["RenewalUrl"] = "https://owners.onlinemicrotec.com.sa/renew"
},
Language: "ar"
));
}
}WorkflowStepTimedOut
When an approval step exceeds its timeout:
csharp
public class WorkflowStepTimedOutConsumer : IConsumer<WorkflowStepTimedOutEvent>
{
public async Task Consume(ConsumeContext<WorkflowStepTimedOutEvent> context)
{
var msg = context.Message;
// Escalate to supervisor
if (msg.EscalateTo is not null)
{
await workflowApi.ReassignStepAsync(msg.InstanceId, msg.StepId, msg.EscalateTo.Value);
await notificationApi.SendAsync(new SendNotificationRequest(
TenantId: msg.TenantId,
RecipientUserId: msg.EscalateTo.Value,
RecipientEmail: msg.EscalateToEmail ?? string.Empty,
RecipientPhone: null,
Channel: NotificationChannel.WebPush,
TemplateCode: "WORKFLOW_APPROVAL_REQUIRED",
TemplateData: new()
{
["EntityType"] = msg.EntityType,
["EntityNumber"] = msg.EntityNumber,
["Note"] = "تمت إحالة الطلب بسبب انتهاء مهلة الموافقة"
},
Language: "ar"
));
}
else
{
// No escalation configured — auto-reject
await workflowApi.AutoRejectAsync(msg.InstanceId, "Timed out with no escalation path");
}
}
}Scheduled Jobs (Quartz.NET)
| Job | Schedule | Description |
|---|---|---|
SubscriptionExpiryCheckJob | Daily at 01:00 UTC | Detect subscriptions expiring in 7/3/1 days and send warnings |
StaleWorkflowCleanupJob | Daily at 02:00 UTC | Cancel instances stuck in Provisioning for >24h |
DailyReportDispatchJob | Daily at 06:00 UTC | Generate and email scheduled reports to subscribers |
DatabaseMaintenanceJob | Weekly (Sunday 03:00 UTC) | Rebuild fragmented indexes, update statistics |
InAppNotificationCleanupJob | Weekly (Saturday 04:00 UTC) | Archive in-app notifications older than 90 days |
AuditLogArchiveJob | Monthly (1st day 05:00 UTC) | Move old audit records to cold storage table |
Job Registration
csharp
// Program.cs
builder.Services.AddQuartz(q =>
{
q.UseMicrosoftDependencyInjectionJobFactory();
// Subscription expiry — daily at 01:00 UTC
q.AddJobAndTrigger<SubscriptionExpiryCheckJob>(
"0 0 1 * * ?");
// Stale workflow cleanup — daily at 02:00 UTC
q.AddJobAndTrigger<StaleWorkflowCleanupJob>(
"0 0 2 * * ?");
// Daily report dispatch — daily at 06:00 UTC
q.AddJobAndTrigger<DailyReportDispatchJob>(
"0 0 6 * * ?");
});
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);Example Scheduled Job
csharp
[DisallowConcurrentExecution]
public class SubscriptionExpiryCheckJob(
IBusinessOwnersPublicApi businessOwnersApi,
INotificationPublicApi notificationApi,
ILogger<SubscriptionExpiryCheckJob> logger) : IJob
{
private static readonly int[] WarningDays = [7, 3, 1];
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting subscription expiry check...");
foreach (var days in WarningDays)
{
var expiringSubscriptions = await businessOwnersApi
.GetSubscriptionsExpiringInDaysAsync(days);
foreach (var sub in expiringSubscriptions)
{
await notificationApi.SendAsync(new SendNotificationRequest(
TenantId: sub.TenantId,
RecipientUserId: sub.AdminUserId,
RecipientEmail: sub.AdminEmail,
RecipientPhone: null,
Channel: NotificationChannel.Email,
TemplateCode: "SUBSCRIPTION_EXPIRY_WARNING",
TemplateData: new()
{
["DaysRemaining"] = days.ToString(),
["ExpiryDate"] = sub.EndDate.ToString("yyyy-MM-dd"),
["TenantName"] = sub.TenantNameAr
},
Language: "ar"
));
logger.LogInformation(
"Sent expiry warning to tenant {TenantId} ({Days} days remaining)",
sub.TenantId, days);
}
}
}
}Service Registration Pattern
The Worker uses the same shared infrastructure packages as other services but registers no HTTP pipeline:
csharp
// Program.cs
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices((ctx, services) =>
{
var config = ctx.Configuration;
// ASB consumers via MassTransit
services.AddMassTransit(x =>
{
x.AddConsumer<TenantProvisionedEventConsumer>();
x.AddConsumer<SubscriptionExpiredEventConsumer>();
x.AddConsumer<WorkflowStepTimedOutConsumer>();
x.AddConsumer<ImportJobCompletedConsumer>();
x.UsingAzureServiceBus((ctx, cfg) =>
{
cfg.Host(config["ServiceBus:ConnectionString"]);
cfg.SubscriptionEndpoint<TenantProvisionedEvent>(
"platforms-worker-subscription", e => e.ConfigureConsumer<TenantProvisionedEventConsumer>(ctx));
cfg.SubscriptionEndpoint<SubscriptionExpiredEvent>(
"platforms-worker-subscription", e => e.ConfigureConsumer<SubscriptionExpiredEventConsumer>(ctx));
});
});
// Quartz scheduler
services.AddQuartz(ConfigureJobs);
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
// Typed HTTP clients (outbound)
services.AddHttpClient<IBusinessOwnersPublicApi, BusinessOwnersPublicApiClient>(client =>
client.BaseAddress = new Uri(config["Services:BusinessOwners"]!));
services.AddHttpClient<INotificationPublicApi, NotificationPublicApiClient>(client =>
client.BaseAddress = new Uri(config["Services:Notification"]!));
services.AddHttpClient<IWorkflowPublicApi, WorkflowPublicApiClient>(client =>
client.BaseAddress = new Uri(config["Services:Workflow"]!));
services.AddHttpClient<IReportingPublicApi, ReportingPublicApiClient>(client =>
client.BaseAddress = new Uri(config["Services:Reporting"]!));
})
.Build();
await host.RunAsync();Outbound Service Clients
| Client Interface | Target | Purpose |
|---|---|---|
IBusinessOwnersPublicApi | BusinessOwners.Apis | Tenant status callbacks, subscription queries |
INotificationPublicApi | Notification.Apis | Send emails, SMS, push |
IWorkflowPublicApi | Workflow.Apis | Reassign/auto-reject timed-out steps |
IReportingPublicApi | Reporting.Apis | Trigger report generation |
IAttachmentPublicApi | Attachment.Apis | Delete orphaned attachments |
Resilience Policy
All outbound HTTP calls use Polly retry policies:
csharp
// Applied to all typed HTTP clients
services.AddResilienceHandler("worker-retry", builder =>
{
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromSeconds(2)
});
builder.AddTimeout(TimeSpan.FromSeconds(30));
});Configuration
json
// appsettings.json (PlatformsWorker)
{
"ServiceBus": {
"ConnectionString": "Endpoint=sb://...",
"TopicName": "microtec-events",
"SubscriptionName": "platforms-worker-subscription"
},
"Services": {
"BusinessOwners": "https://mic-erp-be-businessowners.internal.<env>.azurecontainerapps.io",
"Notification": "https://mic-erp-be-notification.internal.<env>.azurecontainerapps.io",
"Workflow": "https://mic-erp-be-workflow.internal.<env>.azurecontainerapps.io",
"Reporting": "https://mic-erp-be-reporting.internal.<env>.azurecontainerapps.io",
"Attachment": "https://mic-erp-be-attachment.internal.<env>.azurecontainerapps.io"
},
"Quartz": {
"TimeZone": "UTC"
}
}Managed Identity
The Platforms Worker uses Azure Managed Identity for Service Bus authentication in production. The connection string format uses the Endpoint=sb://... form for local development and SAS token auth. In production Container Apps, the managed identity is granted Azure Service Bus Data Receiver and Azure Service Bus Data Sender roles on the namespace.
Deployment
The Platforms Worker is listed in the pipeline service config:
Devops/azure/config/container-backend/services-config.json → "platforms-worker"Unlike API services, the Worker does not need an ingress (HTTP listener) — its Container App is configured with ingress: null. It consumes events from ASB and calls other services outbound only.
Singleton Constraint
Schedule-based jobs use [DisallowConcurrentExecution] to prevent double-firing if a previous instance is still running. For environments with multiple replicas, the Quartz scheduler is configured with a clustered JDBC store to coordinate job assignment across replicas. Ensure the Worker database connection string is set when running with more than one replica.