# Побудова системи робочого процесу за допомогою HTMX і ядра ASP.NET - Частина 4: Інтеграція та автоматизація Hangfire

<!--category-- ASP.NET, Hangfire, Workflow, Background Jobs -->
<datetime class="hidden">2025-01-15T18:00</datetime>

## Вступ

Вхід[Частина 3](/blog/workflowsystem-part3-visual-editor)Ми побудували чудовий візуальний редактор.

- **Але наші робочі потоки працюють тільки тоді, коли ми вручну їх провокуємо.**У цьому фіналі, ми зробимо потік дійсно автономним за допомогою Hangfire для:
- **Запланована виконання**- Запустити робочі дані за розкладом
- **Опитування API**- Слідкувати за зовнішніми API і увімкнути зміни
- **Керування станами**- Стан запуску доріжки під час виконання

[TOC]

## Панель приладів

- Слідкувати за всіма фоновими завданнями

- Чому пожежа?
- Повіска ідеальна для наших потреб, тому що:
- Зберігає завдання у нашій існуючій базі даних PostgreSQL
- Надає вбудовану панель приладів
- Підтримує регулярні завдання

## Має автоматичну спробу логіки

Відображає горизонтальні масштаби

```csharp
[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; }
}
```

Модель стану вмикання

- По-перше, давайте спробуємо зрозуміти наш елемент державного стану (ми вже створили це у частині 2):
- Цей елемент слідкує за всім, що пов' язано з увімкненням процесу:
- Коли востаннє було запущено
- Що є її конфігурацією

## У якому стані він (для потужних стимулів)

### Всі помилки, які сталися

```csharp
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; }
}
```

### Заплановані робочі дані

```csharp
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
        };
    }
}
```

**Модель налаштування**

1. Завдання планувальника`ExecuteScheduledWorkflowsAsync()`
2. Як це працює:
3. Каждую минуту, по телефону "Ханґфайр"
4. Запит щодо увімкнення вмикання розкладу
5. Для кожного з сигналів перевірте, чи пройшло достатньо часу

## Якщо так, виконати процес

Оновити стан вмикання під час останнього запуску

### Опитування API

```csharp
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; }
}
```

### Опитування API цікавіше: ми стежимо за зовнішніми API і запуском робочих потоків, коли змінюється вміст!

```csharp
[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);
}
```

**Модель налаштування**

1. Запилення
2. Як це працює:
3. Кожної хвилини перевіряйте всі причини опитування API
4. Для кожного з сигналів перевірте, чи пройшло достатньо часу з часу останнього опитування
5. Опитування налаштованої адреси
6. Обчислити хеш вмісту відповіді`AlwaysTrigger`Порівняти з попереднім хешом, збереженим у стані
7. Якщо змінено (або
8. true), виконати процес

### Передати відповідь API як вхідні дані робочому процесу

**Оновити стан новим хешом**

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

Приклад випадків використання

## Спостерігати за випусками GitHub:

Це опитує API GitHub щогодини.`Program.cs`Після опублікування нового випуску вміст хеш змінюється, а процес виконується з даними випуску!

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

builder.Services.AddHangfireServer();

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

Зареєстровані роботи повіски

```csharp
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`:

- **Після запуску програми реєструйте завдання, що повторюються:**Дошка з приладами протигазового вогню
- **Пожежа включає вбудовану панель приладів, доступ до якої можна отримати**Завдання
- **: Див. всі черги, обробку і завершені завдання**Повторні завдання
- **: Керуйте нашими планувальниками роботи**Повторні спроби

### : Перегляд і повторення невдалих завдань

```csharp
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;
    }
}
```

## Сервери

: Слідкувати за серверами Hangfire

```csharp
[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();
}
```

### Забезпечення рейками

```html
<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>
```

## Керування ефектами за допомогою інтерфейсу користувача

Давайте додамо UI для створення і керування сигналами:

1. Компонент інтерфейсу користувачаName
2. Реальний приклад робочого потоку
3. Давайте побудуємо повну автоматичну роботу, яка:
4. Опитування програмного інтерфейсу GitHub для нових випусків

### Перевіряє, чи версія новіша, ніж те, що ми бачили

```json
{
  "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"
    }
  ]
}
```

### Записує повідомлення до журналу

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

(Можливо, надішліть електронну пошту, надішліть повідомлення Slack тощо)

1. Крок 1: Створити процес
2. Крок 2: Створіть ефект оподаткування API
3. Сейчас, каждая часа, Honfire будет:
4. Опитування API GitHub

## Порівняти вміст хеш з попереднім опитуванням

### Якщо буде змінено, виконати процес

Процес аналізує JSON і записує інформацію про випуск.

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

### Спостереження і спостереження

Журналювання

```csharp
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();
```

### Записуються всі виконання робіт:

Метрики

- Ми можемо додати метри "Прометей":
- Попередження
- Встановити попередження для:
- Спроба обробки зазнала невдачі (Status=пошкоджено)

## Процеси роботи затримуються

### Помилка опитування API

Ефекти, які не були звільнені під час очікуваного часового блоку

**Обмірковування швидкодії**

```csharp
// 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();
```

### Завантажити базу даних

Оскільки часто проводиться опитування багатьох робочих даних, завантаження бази даних може бути значним:

**Вирішення: пакетні запити**

```csharp
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");
}
```

## Обмеження швидкості API

### Під час опитування зовнішніх програм API:

Вирішення: Expential зворотний зв' язок

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

### Додаткові можливості

Умовні наслідки

```csharp
// 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}");
    }
}
```

## Запустити, лише якщо виконано певні умови:

Залежності вмикання

```csharp
[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);
}
```

## Запобігання ланцюгу - одна докінчена робота доводить до другої:

Перевірка завдань з повіскою

✅ **Одиниця Перевірити ваші завдання:**Висновки
✅ **Ми побудували повну систему автоматизації!**Наш робочий потік тепер може:
✅ **Запустити на розкладах**- Щогодини, щодня або через певні проміжки часу.
✅ **Опитування APILImage/ info menu item (should be translated)**- Слідкувати за зовнішніми службами за змінами
✅ **Стан доріжки**- Пам'ятай, що ми бачили раніше.
✅ **Автоповторення**- Керуйте транзитними помилками

## Монітор

- Дошка для всіх завдань

- **Масштаб**- Балансування вантажу за допомогою Hangfire
- **Повна серія**Ми побудували робочу систему із самого початку:
- **Частина 1**: Вступ і архітектура
- **Частина 2**: Основний рушій потоку

Частина 3

- : Наочний редактор робочого процесу
- Частина 4
- : Інтеграція з вогнем (цей пост)
- Тепер маємо:
- Могутній рушій потокуName

## Чудовий візуальний редактор

Автоматизоване виконання

- **Спостереження за API**Повночасність
- **Що далі?**Можливі покращення:
- **Веб- гачки**: Запускати робочі потоки за допомогою кінцевих точок HTTP
- **Вузли ел. пошти**: Надсилати листи з робочого потоку
- **Вузли бази даних**: Опитати бази даних
- **Вузли комп' ютерного гравця**: Інтегрувати з LLM

## Підробка

: Скомпонувати робочі місця разом

- Ринок праці`Mostlylucid.SchedulerService/Jobs/`
- : Шаблони спільного використання`Mostlylucid.Workflow.Shared/`
- Джерельний код`Mostlylucid.Workflow.Engine/`

Thank you for following this series! Happy workflow building! 🎉