Queue Architecture & Event Flow

Overview

HenKaiPan uses Redis + Asynq as the background job queue system. This document explains the job lifecycle, retry strategies, and event flow.


1. Job Lifecycle Flow

flowchart TB subgraph "Job Creation" API[API Handler] -->|Enqueue| Queue[(Redis Queue)] end subgraph "Job Processing" Queue -->|Dequeue| Worker[Worker Process] Worker -->|Execute| Task[Scan Job] end subgraph "Success Path" Task -->|Success| MarkDone[Mark Completed] MarkDone -->|Update DB| DB[(PostgreSQL)] end subgraph "Failure Path" Task -->|Error| RetryCheck{Retry Count < Max?} RetryCheck -->|Yes, retry N| Requeue[Re-enqueue Job] Requeue --> Queue RetryCheck -->|No, exhausted| DeadQueue[(Dead Letter Queue)] DeadQueue --> LogWarning[Log Warning] DeadQueue --> MarkFailed[Mark Scan Failed] MarkFailed --> DB end style DeadQueue fill:#ff6b6b style Queue fill:#4ecdc4 style DB fill:#95e1d3

2. Retry Strategy Configuration

Current Settings

Job Type Max Retries Timeout Dead Queue
scan:run 3 30 min Yes
agent:validate 5 60 sec Yes
agent:summarize 5 60 sec Yes
webhook:send 5 30 sec Yes
email:send 5 30 sec Yes

Retry Timeline Example

Attempt 1: T+0s      ──► Fail
Attempt 2: T+30s     ──► Fail  (exponential backoff)
Attempt 3: T+90s     ──► Fail  (exponential backoff)
Attempt 4: T+270s    ──► Fail  (exponential backoff)
─────────────────────────────────────────────
Result: Job moved to Dead Letter Queue

Code Location

// internal/handlers/scans.go:79
h.queue.Enqueue(
    asynq.NewTask(tasks.TypeScanRun, payload),
    asynq.MaxRetry(3),
    asynq.Timeout(30*time.Minute),
)

3. Dead Letter Queue (DLQ)

What is it?

The Dead Letter Queue is a special Redis queue where Asynq stores jobs that have exhausted all retry attempts. These jobs are:

  • ❌ Not processed automatically anymore
  • 📦 Preserved for inspection (debugging, manual retry)
  • ⚠️ Logged as warnings for monitoring

When does a job go to DLQ?

flowchart LR A[Job Created] --> B[Attempt 1] B -->|Fail| C[Attempt 2] C -->|Fail| D[Attempt 3] D -->|Fail| E[Attempt 4] E -->|Fail| F{Max Retry Reached?} F -->|Yes| G[Dead Letter Queue] style G fill:#ff6b6b,color:#fff

Inspecting the DLQ

Option 1: Using asynqmon (recommended)

# Install asynqmon
go install github.com/hibiken/asynqmon/cmd/asynqmon@latest

# Run web UI
asynqmon --redis-addr localhost:6379

# Open http://localhost:8080

Option 2: Redis CLI

# List all dead queue keys
redis-cli KEYS "asynq:{dead}:*"

# Get job details
redis-cli HGETALL "asynq:{dead}:<job-id>"

Recovering from DLQ

Manual retry via code:

// Get job from DLQ and re-enqueue
client.Enqueue(originalTask, asynq.MaxRetry(3))

Using asynqmon UI:

  1. Open asynqmon web interface
  2. Navigate to "Dead" queue
  3. Click on job
  4. Click "Retry" button

4. Event Types & Flow

Scan Execution Flow

sequenceDiagram participant User participant Frontend participant API participant Queue participant Worker participant Scanner participant DB participant Notif User->>Frontend: Start Scan Frontend->>API: POST /api/scans API->>DB: Create scan record (pending) API->>Queue: Enqueue scan:run job Queue->>Worker: Deliver job Worker->>DB: Mark scan running Worker->>Scanner: Execute scanner binary Scanner-->>Worker: Raw output alt Scan Success Worker->>DB: Insert findings Worker->>DB: Mark scan completed Worker->>Queue: Enqueue agent:validate (optional) Worker->>Notif: Send notifications else Scan Failed Worker->>DB: Mark scan failed Worker->>Notif: Send failure notification end Worker-->>Queue: Acknowledge job Queue-->>API: Job complete API-->>Frontend: Response Frontend-->>User: Show result

Finding Notification Flow

flowchart LR subgraph "Finding Created" F[Finding Inserted] -->|Trigger| SLA[SLA Deadline Set] F -->|Trigger| Val[AI Validation Job] F -->|Trigger| Sum[AI Summary Job] end subgraph "Notification Routing" Val -->|If Critical| WC[Webhook Critical] Val -->|If Critical| EC[Email Critical] Val -->|If High| WH[Webhook High] Val -->|If High| EH[Email High] end subgraph "SLA Monitoring" SLA -->|15min check| Breach{SLA Breached?} Breach -->|Yes| WB[Webhook SLA Breach] Breach -->|Yes| EB[Email SLA Breach] end style F fill:#4ecdc4 style WC fill:#ff6b6b style EC fill:#ff6b6b style WB fill:#ffe66d

Event Types Summary

Event Trigger Webhook Email Retry
finding.critical New critical finding ✅ ✅ 5
finding.high New high finding ✅ ✅ 5
finding.sla_breach SLA deadline passed ✅ ✅ 5
scan.completed Scan finished ✅ ✅ (configurable) N/A
scan.failed Scan failed ✅ ✅ (configurable) N/A

5. Queue Architecture

Redis Queue Structure

asynq:
├── {default}              # Main queue (scan:run jobs)
│   ├── pending            # Jobs waiting to be processed
│   ├── active             # Jobs currently being processed
│   ├── processed          # Successfully completed jobs
│   └── dead               # Failed jobs (DLQ)
├── {critical}             # High-priority queue (optional)
└── {dead}                 # Dead letter queue storage

Worker Configuration

// cmd/worker/main.go
srv := queue.NewServer(cfg.RedisAddr, 5)  // 5 concurrent workers

// internal/queue/queue.go
func NewServer(addr string, concurrency int) *asynq.Server {
    return asynq.NewServer(
        asynq.RedisClientOpt{Addr: addr},
        asynq.Config{
            Concurrency: concurrency,  // Max parallel jobs
        },
    )
}

Concurrency Settings

Setting Value Description
Concurrency 5 Max 5 jobs processed in parallel
Queue default Single queue (no priority tiers)
Strict Priority No FIFO ordering

6. Cleanup Strategies

Temporary Directory Cleanup

flowchart TD Start[cloneRepo called] --> CheckDir{Dir Exists?} CheckDir -->|Yes| CleanOld[os.RemoveAll old dir] CheckDir -->|No| SkipClean[Skip cleanup] CleanOld --> Clone[git clone] SkipClean --> Clone Clone --> CloneSuccess{Clone OK?} CloneSuccess -->|Yes| Return[Return dir path] CloneSuccess -->|No| CleanPartial[os.RemoveAll partial dir] CleanPartial --> ReturnErr[Return error] style CleanOld fill:#ffe66d style CleanPartial fill:#ffe66d style Return fill:#4ecdc4

Code Implementation

// internal/tasks/scan_run.go:176-210
func cloneRepo(...) (dir string, execLog string, err error) {
    dir = filepath.Join(os.TempDir(), "aspm-scan-"+scanID)

    // 1. Cleanup existing directory from previous failed attempts
    if _, err := os.Stat(dir); err == nil {
        os.RemoveAll(dir)  // Clean before retry
    }

    // 2. Clone repository
    cmd := exec.CommandContext(ctx, "git", "clone", "--depth=50", cloneURL, dir)
    out, cloneErr := cmd.CombinedOutput()
    
    // 3. Cleanup partial clone on failure
    if cloneErr != nil {
        os.RemoveAll(dir)  // Clean after failure
        return "", execLog, fmt.Errorf("git clone: %w", cloneErr)
    }
    
    return dir, execLog, nil
}

7. Monitoring & Observability

Current Implementation

✅ Implemented (Business Metrics)

All business metrics are stored in PostgreSQL and exposed via REST API:

// internal/handlers/metrics.go
GET /api/metrics/summary        // Total scans, findings, active scans
GET /api/metrics/trends?days=30 // Finding trends by severity
GET /api/metrics/risk           // Risk scores per repository
GET /api/metrics/sla-compliance // SLA breach statistics
GET /api/metrics/teams          // Team-level aggregated metrics

✅ Implemented (Queue/Infrastructure Metrics - Prometheus)

Queue metrics are exposed via Prometheus at :9090/metrics:

Metric Type Labels Source
asynq_queue_pending_jobs Gauge queue Redis/Asynq
asynq_queue_active_jobs Gauge queue Redis/Asynq
asynq_queue_dead_jobs Gauge queue Redis/Asynq
aspm_scans_total Gauge - PostgreSQL
aspm_scans_running Gauge - PostgreSQL
aspm_scans_failed Gauge - PostgreSQL
aspm_findings_total Gauge severity PostgreSQL

Scrape configuration:

# prometheus.yml
scrape_configs:
  - job_name: 'aspm-worker'
    static_configs:
      - targets: ['worker:9090']
    scrape_interval: 30s

Recommended Alerts (Prometheus)

Example alerting rules for Prometheus:

# alerting_rules.yml
groups:
  - name: aspm
    rules:
      - alert: ASPMQueueBacklog
        expr: asynq_queue_pending_jobs > 100
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "ASPM queue has {{ $value }} pending jobs"
          
      - alert: ASPMDeadQueueNotEmpty
        expr: asynq_queue_dead_jobs > 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "ASPM dead queue has {{ $value }} jobs - check worker logs"
          
      - alert: ASPMHighScanFailureRate
        expr: aspm_scans_failed / aspm_scans_total > 0.2
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "{{ $value | humanizePercentage }} of scans are failing"

8. Best Practices

✅ DO

  • Set explicit MaxRetry for all jobs
  • Use Timeout to prevent hung jobs
  • Log retry exhaustion warnings
  • Clean up temporary resources on failure
  • Monitor dead queue regularly
  • Use defer for cleanup in success paths

❌ DON'T

  • Enqueue jobs without retry limits
  • Ignore dead queue warnings
  • Leave temporary files on failure
  • Use infinite retries (default Asynq behavior)
  • Forget to acknowledge jobs manually if using manual ack

9. Troubleshooting

Common Issues

Issue: Jobs stuck in pending

# Check worker is running
ps aux | grep worker

# Check Redis connection
redis-cli ping

# Check queue depth
asynqmon stats

Issue: High dead queue count

# Inspect dead jobs
asynqmon  # Open web UI

# Check worker logs for patterns
grep "Retry exhausted" /var/log/worker.log

Issue: Worker not processing jobs

# Restart worker
systemctl restart aspm-worker

# Check logs
journalctl -u aspm-worker -f

10. How to Implement Queue Monitoring

Option 1: Add asynqmon to docker-compose (Recommended)

# docker-compose.yml
services:
  asynqmon:
    image: ghcr.io/hibiken/asynqmon:latest
    ports:
      - "8080:8080"
    environment:
      - ASYNQMON_REDIS_ADDR=redis:6379
    depends_on:
      - redis

Quick start:

# Install locally
go install github.com/hibiken/asynqmon/cmd/asynqmon@latest

# Run
asynqmon --redis-addr localhost:6379

# Open http://localhost:8080

Option 2: Add Prometheus metrics endpoint

Implementation (already included):

The worker exposes Prometheus metrics at :9090/metrics:

// cmd/worker/main.go
metrics.StartPrometheusServer(":9090")
metrics.StartQueueMetricsCollector(ctx, inspector, 30*time.Second)
metrics.StartDBMetricsCollector(ctx, getMetrics, 60*time.Second)

Exposed metrics:

Metric Type Labels Description
asynq_queue_pending_jobs Gauge queue Pending jobs per queue
asynq_queue_active_jobs Gauge queue Active jobs being processed
asynq_queue_dead_jobs Gauge queue Archived (dead) jobs
aspm_scans_total Gauge - Total scans in DB
aspm_scans_running Gauge - Scans currently running
aspm_scans_failed Gauge - Failed scans
aspm_findings_total Gauge severity Findings by severity

Scrape config:

# prometheus.yml
scrape_configs:
  - job_name: 'aspm-worker'
    static_configs:
      - targets: ['localhost:9090']
    scrape_interval: 30s

Docker Compose setup:

# docker-compose.yml
services:
  worker:
    build: .
    command: ./bin/worker
    expose:
      - "9090"  # Expose metrics port
    
  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    depends_on:
      - worker

Option 3: Log queue stats periodically

// Add to worker main.go
go func() {
    inspector := asynq.NewInspector(asynq.RedisClientOpt{Addr: cfg.RedisAddr})
    ticker := time.NewTicker(1 * time.Minute)
    for range ticker.C {
        stats, err := inspector.GetQueueInfo("default")
        if err != nil {
            slog.Warn("get queue stats failed", "err", err)
            continue
        }
        slog.Info("queue stats",
            "pending", stats.Pending,
            "active", stats.Active,
            "dead", stats.Dead)
    }
}()

Option 4: Add API endpoint for queue stats

// internal/handlers/metrics.go
func (h *Handler) GetQueueStats(w http.ResponseWriter, r *http.Request) {
    inspector := asynq.NewInspector(asynq.RedisClientOpt{Addr: h.redisAddr})
    info, err := inspector.GetQueueInfo("default")
    if err != nil {
        writeError(w, http.StatusInternalServerError, "failed to get queue stats")
        return
    }
    writeJSON(w, http.StatusOK, map[string]any{
        "pending": info.Pending,
        "active":  info.Active,
        "dead":    info.Dead,
    })
}

11. Related Files

File Purpose
internal/handlers/scans.go Job enqueueing with retry config
internal/handlers/metrics.go Business metrics API endpoints
internal/tasks/scan_run.go Job execution + temp directory cleanup
internal/queue/queue.go Queue client/server setup
cmd/worker/main.go Worker bootstrap + job handler registration
cmd/api/main.go API server + metrics endpoints
internal/repository/scan.go Scan status updates + recovery
internal/repository/metrics.go PostgreSQL metrics queries

Summary

  1. Jobs are enqueued with MaxRetry(3) and Timeout(30min)
  2. Failed jobs retry up to 3 times with exponential backoff
  3. Exhausted jobs go to Dead Letter Queue for inspection
  4. Temporary directories are cleaned on both success and failure
  5. Worker recovers stuck scans on startup
  6. Notifications are sent for critical events (findings, SLA breaches, scan failures)
  7. Business metrics are exposed via REST API (/api/metrics/*)
  8. Queue metrics require asynqmon or custom implementation (not included)