Hunt-runner scaling: deterministic-due, no engine dependency
The hunt runner is pull-based and multi-pod. Default deployment is ONE steady pod
(no ScaledObject at all) - the runner is a thin orchestrator (all hunt load
runs in ClickHouse), so one pod covers the large majority of deployments.
Autoscaling is BETA and opt-in (huntRunner.autoscaling.enabled). When
turned on, KEDA scales the pod count on the deterministic-due backlog this doc
explains, via the fail-safe dfe-keda-shim - never the engine, and no longer
a MySQL-on-ClickHouse scaler (retired, see "KEDA ScaledObject" below).
Keep autoscaling off (the default) on 2.2.2. It reads the due backlog from
the hunt_schedule table, and nothing in the 2.2.2 charts writes that table:
there is no materialise Job, so the backlog always reads 0. With autoscaling on
the runner never scales out, and at minReplicaCount: 0 no hunt ever fires.
To try it anyway, run dfe-hunt-runner materialise after every hunt-config
change.
This doc is the SCALING contract: how the due backlog is computed with zero ambiguity, how the beta ScaledObject wakes/grows/shrinks the pod count from it, and the property that matters most - the runner has NO operational dependency on dfe-engine.
The one hard requirement: due-detection with zero workers
This is the contract the BETA autoscaling relies on (see "Default deployment,
beta autoscaling, and disable" below for when it applies - it also governs
correctness if you opt into minReplicaCount: 0, i.e. scale-to-zero). KEDA
can only wake the first worker if "is any hunt due right now?" is answerable
with no worker running - i.e. as a query KEDA itself (via the shim) runs
against ClickHouse. But the per-hunt schedule (interval + stable phase offset)
otherwise lives only in a running worker's memory (loaded from the gitops
hunt configs). At zero workers there is nobody to ask.
So the schedule is MATERIALISED into a small ClickHouse table, hunt_schedule, at
config-deploy time. The scaler then reads it directly. Three tables already exist
for coordination (ch_coordinator.py): hunt_lease, hunt_watermark,
hunt_state. hunt_schedule is the fourth, and the only one written outside the
worker loop.
hunt_schedule(hunt_id, interval_seconds, phase_offset, enabled, updated)
ReplacingMergeTree(updated) ORDER BY hunt_id
enabled is a soft tombstone: a deleted hunt is written enabled=0 so it stops
waking KEDA, without a DELETE.
The scaler query (deterministic-due)
KEDA runs schedule.due_query(db) against ClickHouse (via the fail-safe
dfe-keda-shim's /keda/hunt-backlog endpoint - see "KEDA ScaledObject"
below) - the count of hunts that are due-and-unclaimed. It uses the EXACT
arithmetic the worker uses (schedule.latest_fire):
boundary = intDiv(now(), interval) * interval -- start of this interval
current = boundary + phase_offset -- this interval's scheduled fire
fire = if(now() >= current, current, -- the latest fire at or before now
current - interval)
due = watermark < fire -- that fire not already completed
AND lease_until <= now() -- not currently running
due_count > 0 -> scale up, and == 0 -> scale down (to zero only where
minReplicaCount: 0 has been opted in - the default floor is 1 pod). The
enabled=1 filter is applied in an inner subquery so a tombstone
(interval=0) can never reach the intDiv (no divide-by-zero).
Why the watermark comparison is exact
The worker writes watermark = scheduled_start (the fire time) after a run commits
(worker.py: window(...) returns end = scheduled_start, then
set_watermark(end)). So the watermark is in the SAME epoch-fire scale as the
boundary arithmetic - watermark < fire means precisely "this fire has not been
completed". No unit mismatch, no data-time-vs-fire-time skew.
Why it is RELIABLE
Autoscaling (when the BETA is enabled) is only safe if KEDA's view of "due" never diverges from a worker's. It cannot, because both compute the same thing from the same inputs:
- Parity, tested. The scaler predicate equals
runner.tick's own gate (viaschedule.latest_fire+ the watermark + lease checks) across a full matrix of (hunt_id, interval, now, watermark, lease), and the SQL implements that predicate against real ClickHouse - counting exactly the runnable hunts, excluding tombstoned / future-watermark / leased hunts, and returning 0 when every hunt is caught up. - Single source of truth for the offset. The materialiser computes
phase_offsetwith the sameschedule.phase_offsetthe worker uses, so the two can never drift. Change the offset algorithm once and both move together. - Gate on the signal, not a timer. KEDA polls the backlog query (via the
shim - a real readiness signal), it does not race a clock. Worst-case wake
latency is
pollingInterval + pod-start. For hunts (minute-plus intervals) that is immaterial. This is the "gate on the real condition" rule from the testing standard applied to autoscaling. - Self-healing. If a worker claims a hunt then dies before writing the watermark, its lease expires (
lease_seconds, default 300) and the fire is still owed (watermark < fire), so the scaler keeps >=1 worker and the hunt is reclaimed. Nothing double-runs (a hunt run twice writes and notifies twice): a claim stands only if acknowledged within a settle of its free read and still the latest row after it. A later claim is never released, as it may sit over the winner, but it holds nothing, so the next poll claims again. - No thundering herd. The phase offset spreads same-interval hunts across the
interval, so
due_countrises gradually and KEDA scales out smoothly rather than all-at-once at the boundary. The offset falls in the firsthunts.phase_spread_fractionof the interval (default 0.8, envDFE_ENGINE_HUNTS_PHASE_SPREAD_FRACTION). The runner, the API'snext_dueand the materialised schedule all read it, so republish the schedule (dfe-hunt-runner materialise) after changing it.
Bounded catch-up: a fire stays owed until its watermark is written, so a worker
that ticks inside [boundary, boundary+offset) runs the previous interval's fire
if nothing ran it, and the scaler counts it the same way. Without that, a hunt
whose offset sits between two poll ticks (a 46s offset polled at :00/:15/:30/:45)
never fires at all. Max lateness for any fire is under one interval - inherent
to the phase-offset design, and identical whether the decision is made by KEDA
or a resident worker.
No operational dependency on dfe-engine
This is the point of the pull-based design. Once deployed, the running system needs only ClickHouse:
- Workers read hunt defs from the gitops config (mounted / cloned), coordinate through the four CH tables, and execute queries against CH. No engine call on the hot path.
- KEDA reads
hunt_schedule+ the coordination tables from CH via thedfe-keda-shim- a separate, distinctly-labelled pod running the SAME engine container image under a different entrypoint (dfe-keda-shim run, not the livedfe-engineAPI service). No call to the running engine API. - The engine's only role is at CONFIG-DEPLOY time, not runtime: materialise
hunt_schedulewhen the hunt config changes, with the one-shot, idempotentdfe-hunt-runner materialise. The 2.2.2 charts do not run it (see below).
So if the engine pod is down, hunts keep firing, and so does KEDA scaling once the schedule is materialised. The only thing you lose while the engine is down is the ability to CHANGE configs - which needs the engine anyway. ClickHouse is DFE's single operational store, and the runner holds to that (no Postgres, no engine, no convenience store on the hot path), which is also what lets the same runner work on a non-k8s single deploy (dfe-docker) where only ClickHouse is guaranteed present.
Coordination needs a SINGLE LOGICAL ClickHouse (scale-tier requirement)
The four coordination tables are the shared source of truth every worker and KEDA
read/write. That only works if all of them see the SAME tables. On the scale
tier's multi-node cluster behind a round-robin load balancer, a plain
ReplacingMergeTree (and a plain CREATE DATABASE) would land on one node, so
workers whose connections land on other nodes would see an empty or absent table and
coordination would silently break (UNKNOWN_DATABASE, missing leases). The schema
phase therefore creates the coordination tables as ReplicatedReplacingMergeTree
inside the Replicated dfe database on every deployment, so lease, watermark and
schedule state is shared across nodes (https://github.com/hyperi-io/dfe-schemas/blob/main/docs/clickhouse-platform.md).
Materialisation (config-deploy time)
schedule.publish_schedule(ch, db, specs) is the ONLY writer of hunt_schedule, and
dfe-hunt-runner materialise is its only caller. It is idempotent and diff-aware
(tombstones hunts that vanished from config). The 2.2.2 charts run no Job for it,
so on Kubernetes the table stays empty unless an operator runs the command after
each hunt-config change. Run that way, a brand-new hunt added while scaled to zero
still gets a hunt_schedule row, so KEDA can wake a worker for it.
KEDA ScaledObject (shape)
KEDA has no native ClickHouse scaler. The earlier approach - KEDA's mysql
scaler pointed at ClickHouse's MySQL-compatible interface (mysql_port, 9004) -
has been RETIRED: KEDA's only failure knob for a DB scaler is a fixed
fallbackReplicas count, not "hold at current", so a broken metric could scale
the wrong way and blow cloud spend. It is replaced by a small OWNED, fail-safe
adapter: the dfe-keda-shim, its own pod on the engine image (dfe-keda-shim run), labelled so the engine Service never routes to it. The published engine
chart (oci://ghcr.io/hyperi-io/charts/dfe-engine) renders only the engine pod. The
shim comes from dfe-infra helm/charts/dfe-extras/templates/engine-keda-shim.yaml,
on the same values. KEDA's metrics-api scaler polls the shim over HTTP, and the
shim runs schedule.due_query(db) against ClickHouse - the SAME SQL the worker
gate uses (see "The scaler query" above, imported directly from
dfe_engine.hunts.schedule.due_query, so it cannot drift) - and hands
back one integer.
Ships with the hunt-runner Deployment, gated by huntRunner.autoscaling.enabled
(default false, BETA). When enabled, the Deployment's replicas field is
OMITTED (the ScaledObject owns the count instead) and a dfe-hunt-runner-scaler
ScaledObject is rendered alongside it
(dfe-infra helm/charts/dfe-extras/templates/engine-hunt-runner.yaml):
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: dfe-hunt-runner-scaler
spec:
scaleTargetRef:
name: dfe-hunt-runner
minReplicaCount: 1 # default 1, NOT 0 - set 0 via values to opt into scale-to-zero
maxReplicaCount: 5 # default; huntRunner.autoscaling.maxReplicaCount
cooldownPeriod: 300
pollingInterval: 30
triggers:
- type: metrics-api
metricType: AverageValue # ceil(due / targetBacklog) workers
metadata:
targetValue: "10" # huntRunner.autoscaling.targetBacklog
# huntRunner.autoscaling.shimAddress, else the shim in the release's own namespace
url: "http://dfe-keda-shim.<release-namespace>.svc.cluster.local:8080/keda/hunt-backlog"
valueLocation: "value"
The fail-safe (dfe_engine/keda_shim/shim.py, QueryShim.run): every query
caches its last-good integer per (query, params). On ANY failure - CH
unreachable, timeout, a bad query - the shim returns the CACHED value, so KEDA
sees no change and the pod count FREEZES at current, so it can never run
replicas up on a broken metric. A cold start with no cache yet, plus a
failure, falls back to a conservative cold_hold (0 for the backlog query -
hold at minReplicaCount). This cache-or-cold-hold behaviour is exactly why
the shim replaced the zero-code MySQL-on-ClickHouse route: KEDA's native DB
scalers cannot express "hold at current", only a fixed fallback count.
Other notes: the shim is a separate, distinctly-labelled pod
(app.kubernetes.io/name: dfe-keda-shim) running the same engine container
image under a different entrypoint - it is NOT the live engine API, so
hunt-runner scaling still has no dependency on the engine service being up.
Queries (including the backlog query's clamp/cold-hold/timeout) are
config-driven (keda_shim/queries.yaml + an optional mounted override), so a
schema change or a tuning change is a config edit, not a shim rebuild.
Proving it: the artificial scale test
bootstrap/keda-scale-test.sh (dfe-infra) is the mechanised proof that the
shim -> KEDA scale-out/scale-in path actually works, rather than an
aspirational claim: it forces the path on demand with a disposable target
instead of waiting for real hunt load - stand up a throwaway Deployment +
ScaledObject wired to the shim's pressure query for a test ServiceName,
INSERT a high dfe_scaling_pressure row into otel_metrics_gauge (any
<prefix>_scaling_pressure spelling matches - see
keda-scaling), poll
(bounded, 120s) for scale-OUT to >= 2 replicas, drag the rolling average back
down, poll (bounded, 180s) for scale-IN to 1, then self-clean. It rides the
SAME shim endpoint and metrics-api mechanism the hunt-runner's
/keda/hunt-backlog trigger uses, so a pass proves the mechanism the
hunt-runner beta relies on, not a mock. Wired into
bootstrap/run-all-smoke-tests.sh (gated DFE_KEDA_SCALE_TEST, default on).
Default deployment, beta autoscaling, and disable
The hunt runner is a thin orchestrator - virtually all hunt load runs IN ClickHouse (the queries + coordination), so the pod itself is rarely resource-bound. Its real ceiling is architectural (how many hunts one pod can submit and coordinate concurrently and reliably), not CPU/memory - and the large majority of deployments need exactly one pod. So the default is deliberately simple:
huntRunner.enabled(defaulttrueon every tier): deploysdfe-hunt-runneras ONE steady pod (replicas: 1, no ScaledObject at all). This is the resting state for the large majority of deployments - a single always-on pod, not scale-to-zero.huntRunner.autoscaling.enabled(defaultfalse, BETA, keep it off on 2.2.2 - see the top of this page): opts into the ScaledObject described above.replicasis then omitted from the Deployment (KEDA owns the count) and the pod count floats betweenminReplicaCount(default 1, not 0 - set it to0explicitly via values to additionally opt into scale-to-zero) andmaxReplicaCount(default 5), driven by the due backlog. Work still divvies up safely across N pods via the existinghunt_leasemutual exclusion - KEDA picks the pod count, the leases decide who does what, so nothing double-runs.- Disabled (operator choice): set
huntRunner.enabled: falsein the deploy overlay. No pod and no ScaledObject are rendered, so hunts do not fire. Re-enable by restoring the dial - the schedule + watermarks are still in ClickHouse, so the runner resumes cleanly from the last committed window.
Either way the mechanism is a values dial, turned through the gitops overlay, never a live cluster mutation.