Back to "Att bygga ett arbetsruttsystem med HTMX och ASP.NET Core | - | Del |4: | Hangfire Integration och Automatisering"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

ASP.NET Background Jobs Hangfire Workflow

Att bygga ett arbetsruttsystem med HTMX och ASP.NET Core | - | Del |4: | Hangfire Integration och Automatisering

Wednesday, 15 January 2025

Inleiding

I Del 3, vi byggde en vacker visuell redigerare . Men våra arbetsflöden körs bara när vi manuell aktiverar dem . I den här sista punkten M SK3 kommer vi att göra arbetsflödena verkligt autonoma med hjälp av Hangfire för

  • Avstängd exécution - Loopa arbetsflöden på ett plan
  • API-röstningar - övervaka externa APIs och aktivera förändringar
  • Stadsstyre - Stäng utlösande tillstånd över uitvoeringer
  • Tavlansbord - övervaka alla bakgrundsjobb

Varför Hangfire?

Hangfire är perfekt för våra behov eftersom det

  • Speicherar jobb i vår PostgreSQL-databas
  • Geer en inbyggd dashboard
  • Ondersteuner återhämtade jobb
  • Har en automatisk återproberad logik
  • Skalar horisontellt

Trigger-Étatmodellen

Först ",", låt "'" förstå vår utlösande statenitet , (", vi har redan skapat den i Part .

[Table("workflow_trigger_states")]
public class WorkflowTriggerStateEntity
{
    public int Id { get; set; }
    public int WorkflowDefinitionId { get; set; }

    // Type: "Schedule", "ApiPoll", "Webhook"
    public string TriggerType { get; set; } = string.Empty;

    // Configuration as JSON
    public string ConfigJson { get; set; } = "{}";

    // Current state as JSON (stores last poll time, content hash, etc.)
    public string StateJson { get; set; } = "{}";

    public bool IsEnabled { get; set; } = true;
    public DateTime? LastCheckedAt { get; set; }
    public DateTime? LastFiredAt { get; set; }
    public int FireCount { get; set; } = 0;
    public string? LastError { get; set; }
}

Den här enheten följer allting om en arbetsströmbrytare:

  • När den sist kördes
  • Vad är dess konfiguration?
  • Vad står det för? 's i ( för statfulla utlösande apparater
  • Inga fel som inträffat

Timmattade arbetsflöden

Konfigurationsmodell

public class ScheduleTriggerConfig
{
    public string IntervalType { get; set; } = "minutes"; // minutes, hours, days
    public int IntervalValue { get; set; } = 60;
    public Dictionary<string, object>? InputData { get; set; }
}

Timmaverkets jobb

public class WorkflowSchedulerJob
{
    private readonly MostlylucidDbContext _context;
    private readonly WorkflowExecutionService _executionService;
    private readonly ILogger<WorkflowSchedulerJob> _logger;

    [AutomaticRetry(Attempts = 3)]
    public async Task ExecuteScheduledWorkflowsAsync()
    {
        _logger.LogInformation("Checking for scheduled workflows");

        // Get all enabled schedule triggers
        var triggers = await _context.WorkflowTriggerStates
            .Include(t => t.WorkflowDefinition)
            .Where(t => t.IsEnabled && t.TriggerType == "Schedule")
            .ToListAsync();

        foreach (var trigger in triggers)
        {
            try
            {
                var config = JsonSerializer.Deserialize<ScheduleTriggerConfig>(
                    trigger.ConfigJson);

                if (config == null) continue;

                // Check if it's time to run
                if (!ShouldRunScheduledWorkflow(trigger, config))
                    continue;

                _logger.LogInformation(
                    "Executing scheduled workflow {WorkflowId}",
                    trigger.WorkflowDefinition.WorkflowId);

                // Execute the workflow
                await _executionService.ExecuteWorkflowAsync(
                    trigger.WorkflowDefinition.WorkflowId,
                    config.InputData,
                    "Scheduler");

                // Update trigger state
                trigger.LastCheckedAt = DateTime.UtcNow;
                trigger.LastFiredAt = DateTime.UtcNow;
                trigger.FireCount++;

                var state = JsonSerializer.Deserialize<Dictionary<string, object>>(
                                trigger.StateJson) ?? new();
                state["lastRun"] = DateTime.UtcNow.ToString("O");
                trigger.StateJson = JsonSerializer.Serialize(state);

                await _context.SaveChangesAsync();
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Error executing scheduled workflow {TriggerId}",
                    trigger.Id);
                trigger.LastError = ex.Message;
                await _context.SaveChangesAsync();
            }
        }
    }

    private bool ShouldRunScheduledWorkflow(
        WorkflowTriggerStateEntity trigger,
        ScheduleTriggerConfig config)
    {
        // First run?
        if (!trigger.LastFiredAt.HasValue)
            return true;

        var timeSinceLastRun = DateTime.UtcNow - trigger.LastFiredAt.Value;

        return config.IntervalType.ToLower() switch
        {
            "minutes" => timeSinceLastRun.TotalMinutes >= config.IntervalValue,
            "hours" => timeSinceLastRun.TotalHours >= config.IntervalValue,
            "days" => timeSinceLastRun.TotalDays >= config.IntervalValue,
            _ => false
        };
    }
}

Hur det fungerar:

  1. Varje minut, Hangfire ringer ExecuteScheduledWorkflowsAsync()
  2. Vi frågar efter aktiverade schematrigger
  3. För varje aktivator checka om tillräckligt mycket tid har passerat
  4. Om ja, sätter igång arbetsflödet
  5. Dateer upp aktiveringslägen med den sista körningens tid

API-röstning

API-undersökningar är mer intressanta - vi övervakar externa APIs och aktiverar arbetsflöden när innehållet förändras!

Konfigurationsmodell

public class ApiPollTriggerConfig
{
    public string Url { get; set; } = string.Empty;
    public int IntervalSeconds { get; set; } = 300; // 5 minutes
    public bool AlwaysTrigger { get; set; } = false;
    public Dictionary<string, string>? Headers { get; set; }
}

Rörningens jobb

[AutomaticRetry(Attempts = 3)]
public async Task PollApiTriggersAsync()
{
    _logger.LogInformation("Polling API triggers");

    var triggers = await _context.WorkflowTriggerStates
        .Include(t => t.WorkflowDefinition)
        .Where(t => t.IsEnabled && t.TriggerType == "ApiPoll")
        .ToListAsync();

    foreach (var trigger in triggers)
    {
        try
        {
            var config = JsonSerializer.Deserialize<ApiPollTriggerConfig>(
                trigger.ConfigJson);

            if (config == null) continue;

            // Check if it's time to poll
            if (trigger.LastCheckedAt.HasValue)
            {
                var timeSinceLastCheck = DateTime.UtcNow - trigger.LastCheckedAt.Value;
                if (timeSinceLastCheck.TotalSeconds < config.IntervalSeconds)
                    continue;
            }

            _logger.LogInformation("Polling API for workflow {WorkflowId}",
                trigger.WorkflowDefinition.WorkflowId);

            // Poll the API
            using var httpClient = new HttpClient();
            var response = await httpClient.GetAsync(config.Url);
            var content = await response.Content.ReadAsStringAsync();

            // Get previous state
            var state = JsonSerializer.Deserialize<Dictionary<string, object>>(
                            trigger.StateJson) ?? new();

            var previousHash = state.GetValueOrDefault("contentHash")?.ToString();
            var currentHash = ComputeHash(content);

            // Has content changed?
            if (previousHash != currentHash || config.AlwaysTrigger)
            {
                _logger.LogInformation(
                    "API content changed, triggering workflow {WorkflowId}",
                    trigger.WorkflowDefinition.WorkflowId);

                // Pass response as input to workflow
                var inputData = new Dictionary<string, object>
                {
                    ["apiResponse"] = content,
                    ["statusCode"] = (int)response.StatusCode,
                    ["previousHash"] = previousHash ?? string.Empty,
                    ["currentHash"] = currentHash
                };

                // Execute the workflow
                await _executionService.ExecuteWorkflowAsync(
                    trigger.WorkflowDefinition.WorkflowId,
                    inputData,
                    $"ApiPoll:{config.Url}");

                trigger.LastFiredAt = DateTime.UtcNow;
                trigger.FireCount++;

                // Update state
                state["contentHash"] = currentHash;
                state["lastContent"] = content.Length > 1000
                    ? content.Substring(0, 1000)
                    : content;
                state["lastPoll"] = DateTime.UtcNow.ToString("O");
            }

            trigger.LastCheckedAt = DateTime.UtcNow;
            trigger.StateJson = JsonSerializer.Serialize(state);
            trigger.LastError = null;

            await _context.SaveChangesAsync();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error polling API trigger {TriggerId}",
                trigger.Id);
            trigger.LastError = ex.Message;
            trigger.LastCheckedAt = DateTime.UtcNow;
            await _context.SaveChangesAsync();
        }
    }
}

private string ComputeHash(string content)
{
    using var sha256 = System.Security.Cryptography.SHA256.Create();
    var bytes = System.Text.Encoding.UTF8.GetBytes(content);
    var hash = sha256.ComputeHash(bytes);
    return Convert.ToBase64String(hash);
}

Hur det fungerar:

  1. Varje minut, kolla alla API-röstningstryglar
  2. För varje aktivator checka om tillräckligt mycket tid har passerat sedan förra undersökningen
  3. Poll den konfigurerade URL: n
  4. Beräkna en hash av svarets innehåll
  5. jämför med den tidigare haschen som lagrats i tillståndet
  6. Om den ändras ( eller AlwaysTrigger är sant), sätter igång arbetsflödet
  7. Släng API-responsen som inputdata till arbetsflödet
  8. uppdatera staten med en ny hash

Ta ett exempel

Monitora releases av GitHub:

{
  "triggerType": "ApiPoll",
  "config": {
    "url": "https://api.github.com/repos/dotnet/aspnetcore/releases/latest",
    "intervalSeconds": 3600,
    "alwaysTrigger": false
  }
}

Den här undersökningen mäter GitHubs API varje timme. När en ny Veröffentlichung är publicerad, ändras innehållshashet , och arbetsflödet utförs med VeröffentlichungsdataM SK3

Att registrera Hangfire jobb

I din Program.cs eller startkonfigurationen:

// Add Hangfire services
builder.Services.AddHangfire(config =>
{
    config.UsePostgreSqlStorage(
        builder.Configuration.GetConnectionString("DefaultConnection"));
});

builder.Services.AddHangfireServer();

// Register our job
builder.Services.AddScoped<WorkflowSchedulerJob>();

Sedan , efter appstarten, registrera återhämtade jobb

app.UseHangfireDashboard("/hangfire");

// Register recurring jobs
RecurringJob.AddOrUpdate<WorkflowSchedulerJob>(
    "scheduled-workflows",
    job => job.ExecuteScheduledWorkflowsAsync(),
    Cron.Minutely);

RecurringJob.AddOrUpdate<WorkflowSchedulerJob>(
    "api-poll-triggers",
    job => job.PollApiTriggersAsync(),
    Cron.Minutely);

Hangfire-brädan

Hangfire inkluderar en inbyggd dashboard som är tillgänglig på: /hangfire:

  • jobb: Se alla uppreptade , bearbetande , och färdiga jobb
  • Redaktiva jobb: Mana våra arbetsflödesplaner
  • Förtjänningar: Se och prova igen misslyckade jobb
  • Server: Monitor Hangfire-server

Att skydda tavlan

app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    Authorization = new[]
    {
        new HangfireAuthorizationFilter()
    }
});

public class HangfireAuthorizationFilter : IDashboardAuthorizationFilter
{
    public bool Authorize(DashboardContext context)
    {
        var httpContext = context.GetHttpContext();

        // Only allow authenticated users
        return httpContext.User.Identity?.IsAuthenticated == true;
    }
}

Att hantera trigger via gränssnitt

Låt's lägga till gränssnittet för att skapa och sköta utlösande

[HttpPost("workflow/{id}/triggers")]
public async Task<IActionResult> CreateTrigger(
    string id,
    [FromBody] TriggerCreateRequest request)
{
    var workflow = await _context.WorkflowDefinitions
        .FirstOrDefaultAsync(w => w.WorkflowId == id);

    if (workflow == null)
        return NotFound();

    var trigger = new WorkflowTriggerStateEntity
    {
        WorkflowDefinitionId = workflow.Id,
        TriggerType = request.Type,
        ConfigJson = JsonSerializer.Serialize(request.Config),
        StateJson = "{}",
        IsEnabled = true
    };

    await _context.WorkflowTriggerStates.AddAsync(trigger);
    await _context.SaveChangesAsync();

    return Json(new { success = true, triggerId = trigger.Id });
}

public class TriggerCreateRequest
{
    public string Type { get; set; } = string.Empty; // Schedule, ApiPoll
    public object Config { get; set; } = new();
}

UI-komponenter

<div class="card bg-base-100 shadow-xl">
    <div class="card-body">
        <h2 class="card-title">⏰ Add Trigger</h2>

        <div class="form-control">
            <label class="label">Trigger Type</label>
            <select class="select select-bordered" x-model="triggerType">
                <option value="Schedule">Schedule</option>
                <option value="ApiPoll">API Poll</option>
            </select>
        </div>

        <!-- Schedule Config -->
        <template x-if="triggerType === 'Schedule'">
            <div class="space-y-4">
                <div class="form-control">
                    <label class="label">Interval</label>
                    <div class="flex gap-2">
                        <input type="number"
                               x-model="scheduleConfig.intervalValue"
                               class="input input-bordered flex-1" />
                        <select x-model="scheduleConfig.intervalType"
                                class="select select-bordered">
                            <option value="minutes">Minutes</option>
                            <option value="hours">Hours</option>
                            <option value="days">Days</option>
                        </select>
                    </div>
                </div>
            </div>
        </template>

        <!-- API Poll Config -->
        <template x-if="triggerType === 'ApiPoll'">
            <div class="space-y-4">
                <div class="form-control">
                    <label class="label">API URL</label>
                    <input type="url"
                           x-model="apiConfig.url"
                           class="input input-bordered"
                           placeholder="https://api.example.com/data" />
                </div>

                <div class="form-control">
                    <label class="label">Poll Interval (seconds)</label>
                    <input type="number"
                           x-model="apiConfig.intervalSeconds"
                           class="input input-bordered"
                           value="300" />
                </div>
            </div>
        </template>

        <button @click="createTrigger()" class="btn btn-primary mt-4">
            Create Trigger
        </button>
    </div>
</div>

Real-World Example Workflow

Låt oss bygga en komplett automatiserad arbetsström som

  1. Pollar GitHubs API för nya versioner
  2. Kontrollerar om versionen är nyare än vad vi sett i '
  3. Protokollerar ett meddelande
  4. (Kan skicka ett e-mail , meddelande till Slack

steg 1: skapa arbetsprocessen

{
  "name": "GitHub Release Monitor",
  "startNodeId": "parse-data",
  "nodes": [
    {
      "id": "parse-data",
      "type": "Transform",
      "name": "Extract Version",
      "inputs": {
        "operation": "json_parse",
        "data": "{{apiResponse}}"
      }
    },
    {
      "id": "log-release",
      "type": "Log",
      "name": "Log New Release",
      "inputs": {
        "message": "New release: {{tag_name}} - {{name}}",
        "level": "info"
      }
    }
  ],
  "connections": [
    {
      "sourceNodeId": "parse-data",
      "targetNodeId": "log-release"
    }
  ]
}

steg 2: skapa API Poll Trigger

{
  "type": "ApiPoll",
  "config": {
    "url": "https://api.github.com/repos/dotnet/aspnetcore/releases/latest",
    "intervalSeconds": 3600
  }
}

Nu , varje timme, Hangfire kommer:

  1. Polla GitHubs API
  2. Vergleicha innehållshash med förra undersökningen
  3. Om den ändras, löser arbetsflödet
  4. arbetsflödet analyserar JSON och registrerar utsläppsinformationen

övervakning och observationalitet

Logging

Alla arbetsflöden är registrerade:

_logger.LogInformation(
    "Workflow {WorkflowId} execution {ExecutionId} completed in {Duration}ms with status {Status}",
    execution.WorkflowId,
    execution.Id,
    execution.DurationMs,
    execution.Status);

Metrik

Vi kan lägga till Prometheus mätningar:

private static readonly Counter WorkflowExecutions = Metrics
    .CreateCounter("workflow_executions_total",
        "Total workflow executions",
        new CounterConfiguration
        {
            LabelNames = new[] { "workflow_id", "status" }
        });

// In execution service
WorkflowExecutions
    .WithLabels(workflow.Id, execution.Status.ToString())
    .Inc();

Alerter

Inställ alarmer för:

  • Misslyckade arbetsflöden (Status | == | Misslyckad
  • Arbetsflöden tar för lång tid
  • API-röstningsfel
  • Trigger som inte har skjutit upp ' i den förväntade tidsramen

Tillvägagångspunkter

Databasislagning

Med många arbetsflöden som röstar ofta, kan datamängden vara signifikant.

Lösning: Batch-sökningar

// Instead of querying per trigger
var triggers = await _context.WorkflowTriggerStates
    .Include(t => t.WorkflowDefinition)
    .Where(t => t.IsEnabled && t.TriggerType == "ApiPoll")
    .AsNoTracking() // Read-only
    .ToListAsync();

API-hastighetsbegränsning

När man bedömer externa APIs:

Lösningen: Exponensella bakslag

catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
    // Back off
    var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromMinutes(5);
    state["backoffUntil"] = DateTime.UtcNow.Add(retryAfter).ToString("O");
}

avancerade egenskaper

Bedingande Trigger

Bara utlösa om vissa krav är uppfyllda:

public class ConditionalTriggerConfig : ApiPollTriggerConfig
{
    public string? Condition { get; set; } // e.g., "{{stars}} > 1000"
}

Trigger beroenden

Ketten utlöser - en arbetsström ' slutningen utlöses med en annan :

// After workflow completes
if (execution.Status == WorkflowExecutionStatus.Completed)
{
    var dependentTriggers = await _context.WorkflowTriggerStates
        .Where(t => t.TriggerType == "WorkflowComplete" &&
                    t.ConfigJson.Contains(execution.WorkflowId))
        .ToListAsync();

    foreach (var trigger in dependentTriggers)
    {
        await _executionService.ExecuteWorkflowAsync(
            trigger.WorkflowDefinition.WorkflowId,
            execution.OutputData,
            $"Triggered by {execution.WorkflowId}");
    }
}

Att testa Hangfire-arbeten

Unit test your jobs:

[Fact]
public async Task ExecuteScheduledWorkflows_ShouldExecuteWhenIntervalPassed()
{
    // Arrange
    var mockContext = CreateMockContext();
    var mockExecutionService = new Mock<IWorkflowExecutionService>();
    var job = new WorkflowSchedulerJob(mockContext.Object,
        mockExecutionService.Object, Mock.Of<ILogger>());

    // Act
    await job.ExecuteScheduledWorkflowsAsync();

    // Assert
    mockExecutionService.Verify(s => s.ExecuteWorkflowAsync(
        It.IsAny<string>(),
        It.IsAny<Dictionary<string, object>>(),
        "Scheduler",
        It.IsAny<CancellationToken>()), Times.Once);
}

Slutsatsen

Vi har byggt ett komplett automatiserat system.

✅ Bättra på schema - timmer , dagligen M SK2 eller individuella intervall ✅ Poll APIs - övervaka externa tjänster efter förändringar ✅ Stängsläge - Kom ihåg vad vi ✅ Auto-retry - Handle transient fel ✅ Monitor - Dashboard för alla jobb ✅ Skala - Hangfire styr lastbalansering

Den kompletta serien

Vi har byggt ett företag

  • Del 1: Inleiding och arkitektur
  • Del 2: Core workflow-motor
  • Del 3: Visuella arbetsflödesredigerare
  • Del 4: Hangfire integration (this postM SK2

Du har nu:

  • En kraftfull arbetsflödesmotor
  • En vacker visuell redigerare
  • Automatiserad utföring
  • API-övervakning
  • Full observability

Vad's Nästa?

Möjliga förbättringar:

  • Webhooks: Triggerar arbetsflöden via HTTP-terminer
  • E-postpunkter: Senda e-mail från arbetsflöden
  • Databasisknoder: sökdatabaser
  • AI-knoder: Integrera med LLMs
  • Sub-flöden: Komponera arbetsflöden tillsammans
  • Workflow-marknaden: dela templater för arbetsflödet

källkod

Alla koder är tillgängliga i arkiven:

  • Hangfire jobb: Mostlylucid.SchedulerService/Jobs/
  • Workflow-modeller: Mostlylucid.Workflow.Shared/
  • Workflowmotorn: Mostlylucid.Workflow.Engine/

Tack för att ni följde den här serien

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.