Skip to content

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

PropertyValue
ServicePlatformsWorker
CAE placementPrivate CAE
Service type.NET Worker Service (no HTTP endpoints)
SourcePlatforms/Src/ (Worker project)
Job schedulingQuartz.NET
Event consumptionAzure Service Bus
AuthManaged 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)

JobScheduleDescription
SubscriptionExpiryCheckJobDaily at 01:00 UTCDetect subscriptions expiring in 7/3/1 days and send warnings
StaleWorkflowCleanupJobDaily at 02:00 UTCCancel instances stuck in Provisioning for >24h
DailyReportDispatchJobDaily at 06:00 UTCGenerate and email scheduled reports to subscribers
DatabaseMaintenanceJobWeekly (Sunday 03:00 UTC)Rebuild fragmented indexes, update statistics
InAppNotificationCleanupJobWeekly (Saturday 04:00 UTC)Archive in-app notifications older than 90 days
AuditLogArchiveJobMonthly (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 InterfaceTargetPurpose
IBusinessOwnersPublicApiBusinessOwners.ApisTenant status callbacks, subscription queries
INotificationPublicApiNotification.ApisSend emails, SMS, push
IWorkflowPublicApiWorkflow.ApisReassign/auto-reject timed-out steps
IReportingPublicApiReporting.ApisTrigger report generation
IAttachmentPublicApiAttachment.ApisDelete 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.

Internal Documentation — Microtec Platform Team