AutoMate Technisch Help

IAM HR Sync — Externe Opslag Transfer

Architectuur

De externe opslag transfer is een zelfstandig subsysteem naast de sync pipeline. Het exporteert gefinaliseerde audit log batches nachtelijks naar een externe opslaglocatie (blob storage of compatibel). Het is losstaand van de sync pipeline zelf: een mislukte transfer blokkeert nooit een sync run, en een mislukte sync blokkeert nooit een transfer.

Het systeem bestaat uit twee Hangfire background jobs in API/Src/AutoMate.API/Features/IAM/Jobs/Storage/IamEmployeeAuditLogStorageJobs.cs:

Job

Rol

Schedule

IamEmployeeAuditLogStorageDispatcherJob

Beheert nachtelijke recurring jobs per tenant

Dagelijks om 00:00 UTC (maakt/actualiseert per-tenant jobs om 02:00 lokale tijd)

IamEmployeeAuditLogStorageTransferJob

Voert de daadwerkelijke overdracht uit

Nachtelijk om 02:00 in de tijdzone van de tenant

Waarom een aparte dispatcher?

De transfer draait op tenant-tijdzone, wat betekent dat elke tenant een eigen Hangfire recurring job nodig heeft met een eigen CRON expression. De dispatcher is verantwoordelijk voor het levenscyclusbeheer van die recurring jobs:

  • Registreert een nieuwe recurring job wanneer een tenant ExternalStorageEnabled zet op true

  • Verwijdert de recurring job wanneer een tenant externe opslag uitschakelt (cleanup van stale jobs)

Dit patroon houdt de Hangfire job store consistent met de actuele tenant configuratie, zonder handmatige tussenkomst.

Job 1: Dispatcher

Job: IamEmployeeAuditLogStorageDispatcherJob

Verantwoordelijkheden:

  1. Loop over alle tenants waarvoor ExternalStorageEnabled = true is

  2. Registreer een Hangfire recurring job voor IamEmployeeAuditLogStorageTransferJob met CRON 0 2 * * * in de timezone van de tenant, indien die nog niet bestaat of verouderd is

  3. Verwijder bestaande recurring jobs voor tenants die externe opslag inmiddels hebben uitgeschakeld

Idempotentie: De dispatcher kan vaker draaien dan noodzakelijk; dubbele registraties worden door Hangfire onderdrukt als de CRON en job-key al bestaan.

Job 2: Transfer

Job: IamEmployeeAuditLogStorageTransferJob

Hangfire attributes:

  • [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Fail)]

  • [DisableConcurrentExecution(timeoutInSeconds: 900)]

Injectie via Hangfire: PerformContext wordt geïnjecteerd door Hangfire (niet geserialiseerd) en levert de huidige RetryCount.

Volledige flow

Nee

Ja

Nee

Ja

Fout

OK

Nee

Ja

Upload fout

Nee

Ja

Alle batches geslaagd

Start: stel tenant context in

Laad config via GetExternalStorageConfigAsync

SyncActive?

Log info: sync inactive, skip

ExternalStorageEnabled?

Log info: storage disabled, skip

Parse SystemEmails

TestConnectionAsync

Log error + stuur AuditLogStorageConnectionFailed email

Return, geen Hangfire retry

GetFinalizedBatchesSinceAsync LastTransferredAt

Batches beschikbaar?

Log info: geen nieuwe batches, skip

Voor elke batch: StreamBatchAsync → JSON → UploadAsync

RetryCount >= 3?

Log error, rethrow → Hangfire herprobeert

Log error + stuur AuditLogStorageUploadFailed email, rethrow

UpdateExternalStorageWatermarkAsync

Klaar

Stap-voor-stap breakdown

Stap 1 — Tenant context

_multiTenantService.SetTenantId(tenantId);

Elke job-invocatie ontvangt de tenantId als parameter. Het instellen van de tenant context zorgt dat alle downstream services (repository, email, storage) correct scopten op de juiste tenant.

Stap 2 — Configuratie laden

var (externalStorageEnabled, connectionId, lastTransferredAt, systemEmails, syncActive) = await _settingsRepository.GetExternalStorageConfigAsync(ct);

De repository voert een enkele SELECT TOP 1 uit op IAM_Employee_Sync_Settings:

SELECT TOP 1 [ExternalStorageEnabled], [ExternalStorageConnectionId], [ExternalStorageLastTransferredAt], [SystemEmails], ISNULL([SyncActive], 0) AS [SyncActive] FROM [IAM_Employee_Sync_Settings] WHERE [TenantId] = @TenantId

Stap 3 — Silent-skip guards

Twee condities leiden tot een stille skip (log op info-niveau, geen exception, geen notificatie):

Conditie

Reden

SyncActive == false

Sync is door de tenant of admin uitgeschakeld; er is geen actuele data om over te dragen

ExternalStorageEnabled == false

Race condition: de dispatcher had al een recurring job gepland, maar externe opslag is sindsdien uitgeschakeld

Een stille skip is bewust gekozen boven een exception. Een exception zou Hangfire aanzetten tot herproberen, wat zinloos is wanneer de conditie bedoeld uitgeschakeld is.

Stap 4 — SystemEmails parsen

var recipients = systemEmails?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToList() ?? [];

SystemEmails is een comma-separated string opgeslagen in IAM_Employee_Sync_Settings.SystemEmails. Ontbrekende of lege kolom resulteert in een lege lijst; notificaties worden dan enkel gelogd maar niet verstuurd.

Stap 5 — Connection precheck

var connectionOk = await _externalStorageService.TestConnectionAsync(connectionId, ct);

Voordat data opgehaald wordt, wordt de opslagverbinding getest. Bij een mislukte precheck:

  1. Log op error-niveau

  2. Verstuur AuditLogStorageConnectionFailed email (best-effort — emailfouten worden gecatcht en gelogd als warning)

  3. Return zonder exception

Waarom geen Hangfire retry bij connection failure?

Een verbindingsfout wijst op een configuratieprobleem (verkeerde credentials, endpoint niet beschikbaar) dat niet binnen 15 minuten vanzelf herstelt. Automatisch herproberen zou leiden tot herhaalde mislukte pogingen en noise in het dashboard. De operateur ontvangt een e-mailnotificatie en kan het probleem oplossen; de volgende nacht hervatten de transfers automatisch.

Stap 6 — Batches ophalen

var batches = await _auditLogRepository.GetFinalizedBatchesSinceAsync(lastTransferredAt, ct);

Alleen gefinaliseerde batches worden opgehaald — batches die volledig zijn afgerond na het laatste watermark (LastTransferredAt). Lopende of gefaalde sync batches zijn uitgesloten.

Stap 7 — Uploaden per batch

Voor elke batch:

await foreach (var record in _auditLog.StreamBatchAsync(batch.Id, ct)) { // serialize to JSON } var path = $"{batch.Date:yyyy-MM-dd}/{batch.FinishedAt:HH-mm-ss}_iam-sync-audit-log.json"; await _externalStorageService.UploadAsync(connectionId, path, content, ct);

Het pad yyyy-MM-dd/HH-mm-ss_iam-sync-audit-log.json groepeert bestanden per dag, zodat navigatie in de opslaglocatie intuïtief is.

Stap 8 — Watermark update

await _settingsRepository.UpdateExternalStorageWatermarkAsync(DateTimeOffset.UtcNow, ct);

Het watermark wordt uitsluitend bijgewerkt nadat alle batches succesvol zijn geüpload. Als er geen nieuwe batches zijn, wordt het watermark niet aangepast — de volgende run gebruikt hetzelfde startpunt. Bij een gedeeltelijke mislukking blijft het watermark eveneens op de vorige waarde staan, zodat de volgende run alle niet-overgedragen batches opnieuw oppakt. Dit garandeert at-least-once delivery zonder gaten in de exporthistorie.

Retry-gedrag en notificaties

Upload failures

Upload mislukt │ ├─ RetryCount < 3 → rethrow (Hangfire herprobeert stil) │ └─ RetryCount >= 3 → stuur AuditLogStorageUploadFailed email → rethrow (Hangfire markeert job als Failed)

MaxUploadAttempts = 3 is gelijk aan het Attempts-getal in [AutomaticRetry]. De e-mail wordt dus precies één keer verstuurd: bij de laatste uitgeputte poging.

Waarom alleen op de laatste poging?

Tijdelijke opslagfouten (netwerktimeout, throttling) herstellen gewoonlijk binnen enkele minuten. Een e-mail bij elke poging zou ruis creëren. Door te wachten op de definitief mislukte poging ontvangt de operateur één actionable melding.

Notificatiematrix

Situatie

Email verstuurd?

Hangfire retry?

SyncActive == false

Nee

Nee

ExternalStorageEnabled == false

Nee

Nee

Connection precheck mislukt

Ja — AuditLogStorageConnectionFailed

Nee

Upload mislukt, retries resterend

Nee

Ja

Upload mislukt, retries uitgeput

Ja — AuditLogStorageUploadFailed

Nee (Fail)

Email templates

Template

Omschrijving

SystemEmailTemplate.AuditLogStorageConnectionFailed

Verbinding naar externe opslag kon niet worden geverifieerd

SystemEmailTemplate.AuditLogStorageUploadFailed

Upload van audit log naar externe opslag is definitief mislukt na alle herpogingen

E-mails worden verstuurd via IEmailSender.SendEmailAsync (uit AutoMate.API.Plugins.Email). Emailfouten worden gecatcht, gelogd als warning, en blokkeren nooit de rethrow die Hangfire nodig heeft voor retry/fail-afhandeling.

Repository: GetExternalStorageConfigAsync

De methode op IIamEmployeeSyncSettingsRepository retourneert een 5-tuple:

Task<(bool ExternalStorageEnabled, Guid? ConnectionId, DateTimeOffset? LastTransferredAt, string? SystemEmails, bool SyncActive)> GetExternalStorageConfigAsync(CancellationToken ct);

De uitbreiding van de oorspronkelijke 3-tuple met SystemEmails en SyncActive is backward-compatible: de SQL query leest beide kolommen uit in dezelfde SELECT TOP 1, zonder bestaande callers te wijzigen.

Monitoring

Via Hangfire Dashboard

Zoek op job name IamEmployeeAuditLogStorageTransferJob of display name in het Hangfire dashboard:

  • Succeeded: batch correct overgezet, watermark bijgewerkt

  • Failed: alle retries uitgeput — check error message en raadpleeg de ontvangen e-mailmelding

  • Retries: tijdelijke uploadfout — wacht op volgende poging

Watermark controleren

SELECT TenantId, ExternalStorageEnabled, ExternalStorageLastTransferredAt, SystemEmails, SyncActive FROM IAM_Employee_Sync_Settings WHERE TenantId = @TenantId

Een ExternalStorageLastTransferredAt die meerdere dagen achterloopt duidt op een structureel probleem.

Gerelateerde logs

[IAM AuditLogStorage] Tenant {TenantId}: sync not active, skipping transfer [IAM AuditLogStorage] Tenant {TenantId}: external storage disabled, skipping transfer [IAM AuditLogStorage] Tenant {TenantId}: connection precheck failed for connection {ConnectionId} [IAM AuditLogStorage] Tenant {TenantId}: uploaded batch {BatchId} to {Path} [IAM AuditLogStorage] Tenant {TenantId}: watermark updated to {Timestamp}
Last modified: 04 June 2026