Hatchet — Workers, Job Scheduling & Observability¶
This page covers how ASK application workers connect to Hatchet, the end-to-end job scheduling
path from trigger to execution, and how to observe the state of the system in both the Hatchet UI
and from kubectl.
Worker Architecture — Who Does the Work¶
Hatchet itself (hatchet-api, hatchet-engine) is the orchestrator — it does not execute
business logic. The actual work is done by worker deployments in the application's own pods.
For ASK these live in core-service:
┌─────────────────────────────────────────────────────────────────────────┐
│ ASK core-service (ask namespace) │
│ │
│ bulk-operations-worker × 3 replicas ─────────────────────────────┐ │
│ billing-metering-worker × 1 replica ──────────────────────────┐ │ │
│ cron-scheduler-worker × 1 replica ───────────────────────┐ │ │ │
│ cron-run-worker × 3 replicas ────────────────────┐ │ │ │ │
│ (HPA: 3–20) │ │ │ │ │
└─────────────────────────────────────────────────────────────┼──┼──┼──┼─┘
│ │ │ │
gRPC (TLS) over port 443 │ │ │ │
HATCHET__CLIENT_TOKEN auth │ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Hatchet (ask namespace) │
│ │
│ hatchet-engine (gRPC :7070) ◄── receives workflow dispatch │
│ hatchet-api (HTTP :8080) ◄── REST for UI + token auth │
│ rabbitmq ◄── task queue (AMQP) │
│ hatchet-pg (CNPG) ◄── state store │
└─────────────────────────────────────────────────────────────────────────┘
Workers are long-running Deployments, not Jobs. They connect to Hatchet engine over gRPC on startup, register their workflow handlers, and then block waiting for tasks. Hatchet dispatches step executions to workers over that persistent gRPC connection.
Worker Inventory (ASK Core Service)¶
| Worker | Replicas | HPA | Purpose |
|---|---|---|---|
bulk-operations-worker |
3 | — | Bulk document/data operations (CSV, batch indexing) |
billing-metering-worker |
1 | — | Usage metering, billing event processing |
cron-scheduler-worker |
1 | — | Registers and manages cron-triggered workflow schedules |
cron-run-worker |
3–20 | ✓ | Executes cron-triggered job runs |
Worker capacity model¶
Each worker pod has workerSlots (configured in values) — the number of step executions it can
handle concurrently. With 3 replicas × 5 slots, bulk-operations-worker handles 15 concurrent
steps.
# core-service values.yaml
bulkOperationsWorker:
replicaCount: 3
workerSlots: 5 # concurrent step executions per pod
maxRunsPerOrg: 20 # application-level throttle (not a Hatchet setting)
resources:
requests:
cpu: 1000m
memory: 4Gi
limits:
memory: 4Gi
cronRunWorker:
replicaCount: 3
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 20 # scales out under load
resources:
requests:
cpu: 500m
memory: 2Gi
limits:
memory: 2Gi
Worker Authentication — How the Token is Wired¶
Workers authenticate to Hatchet using a worker API token (HATCHET__CLIENT_TOKEN). This is
not a user JWT — it is a service-level token created during Hatchet setup.
Token generation (one-time, setup job)¶
When the Hatchet setup job runs hatchet-admin k8s quickstart, it:
1. Generates JWT signing keys + encryption keysets → stores in hatchet-config K8s secret
2. Creates a default worker token → stores in a K8s secret (chart-specific; typically
hatchet-worker-token or written to hatchet-config)
How core-service workers receive the token¶
Vault KV: ask/core-service/<env>/HATCHET__CLIENT_TOKEN
│
▼ (ESO ExternalSecret)
K8s Secret: core-service-secret
(namespace: ask)
│
▼ (envFrom.secretRef in Deployment)
env: HATCHET__CLIENT_TOKEN=<token>
│
▼ (app config: config.toml)
[ask.hatchet]
client_token = "@format {env[HATCHET__CLIENT_TOKEN]}"
The token is never hardcoded — it lives in Vault and is pulled by ESO. All workers in a
namespace share the same core-service-secret, which aggregates all env vars including the
Hatchet token.
Getting the current token¶
# From Vault
export VAULT_ADDR=https://vault.cl1.sq4.aegis.internal
vault kv get secret/ask/core-service/auh1-dev/HATCHET__CLIENT_TOKEN
# From the K8s secret
kubectl -n ask get secret core-service-secret \
-o jsonpath='{.data.HATCHET__CLIENT_TOKEN}' | base64 -d
Creating a new token via Hatchet UI¶
- Open
https://hatchet.mod.auh1.dev.dir - Navigate to Settings → API Tokens
- Click Create Token → give it a name (e.g.
core-service-auh1-dev) - Copy the token and store it in Vault:
- Force ESO to refresh:
kubectl -n ask annotate externalsecret core-service-secret \ force-sync=$(date +%s) --overwrite
gRPC Connection Config¶
Workers use two environment variables to reach the engine:
| Env Var | Value in this deployment |
|---|---|
HATCHET_CLIENT_TLS_STRATEGY |
none (TLS terminated at ingress; gRPC is plain inside cluster) |
HATCHET__CLIENT_TOKEN |
worker API token |
The gRPC endpoint is set in Hatchet's sharedConfig.grpcBroadcastAddress:
Workers connect to hatchet.mod.auh1.dev.dir:443 (through nginx ingress, TLS-terminated),
then the engine routes their connections internally.
TLS strategy
HATCHET_CLIENT_TLS_STRATEGY: none does NOT mean no TLS. It means the Hatchet SDK does not
manage its own mTLS — TLS is handled by the ingress layer (cert-manager / internal CA). This
is the correct setting for an ingress-fronted deployment.
Job Scheduling — End-to-End Path¶
Trigger types¶
| Trigger | Where configured | Example |
|---|---|---|
| API trigger (on-demand) | Application code calls Hatchet SDK .run() |
User uploads a document → triggers bulk-index workflow |
| Cron | Hatchet UI or SDK .schedule() |
Daily billing rollup at 02:00 |
| Event-driven | SDK .on_event() |
document.uploaded event triggers processing workflow |
Scheduling path (step by step)¶
1. Trigger
Application (or cron) → hatchet-api HTTP POST /api/v1/workflows/{id}/trigger
2. Dispatch
hatchet-engine receives trigger → writes WorkflowRun row to PostgreSQL
→ pushes step execution message to RabbitMQ queue
3. Worker pickup
Worker pod (long-running) has active gRPC stream open to hatchet-engine
Engine pops message from RabbitMQ → pushes step dispatch to worker over gRPC
(uses worker's registered handler for that workflow name)
4. Execution
Worker runs the step function → reports SUCCESS/FAILURE back to engine over gRPC
5. State update
Engine writes step result to PostgreSQL (step_runs, workflow_runs tables)
→ triggers next step in DAG (if multi-step)
6. Completion
All steps complete → WorkflowRun marked SUCCEEDED or FAILED
→ optional webhook/event emitted
DAG (multi-step workflow) execution¶
Hatchet supports directed acyclic graph workflows where each step can depend on previous steps:
WorkflowRun
└── Step 1: validate_input (runs immediately)
└── Step 2: process_data (runs after Step 1 succeeds)
├── Step 3a: index (runs in parallel after Step 2)
└── Step 3b: notify (runs in parallel after Step 2)
The engine manages the DAG state in PostgreSQL and dispatches each step to available workers as its dependencies complete.
Observability — What to Watch¶
1. Hatchet UI (https://hatchet.mod.auh1.dev.dir)¶
| Section | What it shows |
|---|---|
| Dashboard | Active workflow runs, recent completions, failure rate |
| Workflows | All registered workflow definitions + last run status |
| Runs | All WorkflowRun instances — filter by workflow, status, date |
| Workers | Active worker connections — which pods are registered, heartbeat time, slots available |
| Events | Event log — all triggers (cron, API, event-driven) |
| Crons | Active cron schedules — last run, next run, status |
| Settings → API Tokens | Manage worker tokens |
Workers page is the first place to check when jobs stop processing — it shows whether any worker pods are actually connected. Zero workers = no dispatch possible.
2. kubectl — live state¶
# All hatchet-related pods
kubectl -n ask get pods | grep -E "hatchet|bulk-operations|billing-metering|cron"
# Worker pod logs (real-time step execution)
kubectl -n ask logs -l app.kubernetes.io/name=bulk-operations-worker -f --tail=100
# Is the worker connected? (look for "worker registered" / "heartbeat" in logs)
kubectl -n ask logs deployment/core-service-bulk-operations-worker --tail=50 | grep -i "hatchet\|worker\|registered\|heartbeat"
# Engine logs — step dispatch + RabbitMQ consumption
kubectl -n ask logs -l app.kubernetes.io/name=engine -f --tail=100
# API logs — incoming triggers
kubectl -n ask logs -l app.kubernetes.io/name=api -f --tail=50
# RabbitMQ queue depths (backlog check)
kubectl -n ask exec hatchet-rabbitmq-0 -- rabbitmqctl list_queues name messages consumers
3. PostgreSQL — workflow run state¶
# Connect to hatchet DB
kubectl -n ask exec hatchet-pg-1 -- psql -U postgres -d hatchet
-- Recent workflow runs (last 1 hour)
SELECT id, workflow_version_id, status, created_at, finished_at,
EXTRACT(EPOCH FROM (finished_at - created_at))::int AS duration_s
FROM workflow_runs
WHERE created_at > NOW() - INTERVAL '1 hour'
ORDER BY created_at DESC
LIMIT 20;
-- Failed runs
SELECT wr.id, wv.workflow_id, wr.status, wr.error, wr.created_at
FROM workflow_runs wr
JOIN workflow_versions wv ON wr.workflow_version_id = wv.id
WHERE wr.status = 'FAILED'
ORDER BY wr.created_at DESC
LIMIT 10;
-- Step run durations (spot slow steps)
SELECT sr.id, sr.step_id, sr.status, sr.started_at,
EXTRACT(EPOCH FROM (sr.finished_at - sr.started_at))::int AS duration_s
FROM step_runs sr
WHERE sr.started_at > NOW() - INTERVAL '1 hour'
ORDER BY duration_s DESC NULLS LAST
LIMIT 20;
-- Active (running) step runs
SELECT sr.id, sr.step_id, sr.status, sr.started_at,
EXTRACT(EPOCH FROM (NOW() - sr.started_at))::int AS age_s
FROM step_runs sr
WHERE sr.status = 'RUNNING'
ORDER BY age_s DESC;
-- Queue depth (pending steps not yet dispatched)
SELECT COUNT(*) AS pending_steps
FROM step_runs
WHERE status = 'PENDING';
4. RabbitMQ — queue health¶
# Management UI (port-forward — no ingress in this deployment)
kubectl -n ask port-forward svc/hatchet-rabbitmq 15672:15672 &
# Open: http://localhost:15672 login: hatchet / <from hatchet-secret>
# CLI checks
kubectl -n ask exec hatchet-rabbitmq-0 -- rabbitmqctl list_queues name messages consumers ready
# Memory and alarm state
kubectl -n ask exec hatchet-rabbitmq-0 -- rabbitmqctl status | grep -E "memory|alarm|watermark"
# Connections from workers + engine
kubectl -n ask exec hatchet-rabbitmq-0 -- rabbitmqctl list_connections peer_host peer_port state channels
Healthy RabbitMQ output (list_queues):
name messages consumers
hatchet.task.queue.bulk-operations 0 3
hatchet.task.queue.cron-run 0 3
hatchet.task.queue.billing 0 1
messages = 0 with consumers > 0 = queue being drained normally.
messages > 0 with consumers = 0 = workers disconnected — queue backing up.
Common Issues — Workers¶
| Symptom | Diagnosis | Fix |
|---|---|---|
| Workers page in UI shows no workers | Worker pods not connecting — check token or gRPC address | kubectl logs deployment/bulk-operations-worker | grep -i error; verify HATCHET__CLIENT_TOKEN is valid |
| Workflow runs stuck in RUNNING | Worker registered but step not completing | Check worker pod logs for the step; look for panics or timeouts |
| Queue depth growing | Workers too slow or crashed; check list_queues |
Scale replicas or check worker resource limits (OOM) |
HATCHET_CLIENT_TLS_STRATEGY error |
Misconfigured TLS strategy | Set HATCHET_CLIENT_TLS_STRATEGY: none in worker extraEnv |
Token 401 Unauthorized |
Worker token revoked or wrong env pointing to different Hatchet | Re-generate token via UI, update Vault, force ESO sync |
| Cron not firing | cron-scheduler-worker crashed or not registered |
kubectl get pods | grep cron-scheduler; check it shows Running |
Viewing Registered Workers — UI Walkthrough¶
- Open
https://hatchet.mod.auh1.dev.dir - Login with
admin@example.com/Admin123!! - Left sidebar → Workers
- Each row = one worker pod. Columns:
- Name: worker process name (set by the SDK, typically
{service}-{hostname}) - Status:
ACTIVE(heartbeating) orINACTIVE(disconnected) - Last Heartbeat: how long ago the pod checked in
- Max Runs:
workerSlotsvalue for that worker - Available Slots: slots not currently executing a step
Correlate pods to workers
The worker Name in the UI includes the pod name (hostname). Use this to correlate a specific
worker slot to a specific pod: kubectl -n ask get pods -o wide | grep bulk-operations.
Cron Schedule Management¶
Cron schedules are registered by cron-scheduler-worker on startup. They are stored in
Hatchet's PostgreSQL, not as Kubernetes CronJobs.
# View active cron schedules via psql
kubectl -n ask exec hatchet-pg-1 -- psql -U postgres -d hatchet \
-c "SELECT cron_name, cron_expr, last_run, next_run, status
FROM workflow_cron_triggers
ORDER BY next_run;"
Via UI: Left sidebar → Crons — shows name, expression, last/next run, and recent run status.
To trigger a cron workflow manually (without waiting for the schedule):