# Autoscale a Queue Consumer (/integrations/keda/how-to/autoscale-queue-consumer)



The KubeMQ KEDA external scaler reads the `Waiting` message count from a KubeMQ [queue](/learn/queues) channel and exposes it as a KEDA metric. KEDA's Horizontal Pod Autoscaler (HPA) then drives a queue-consumer Deployment between a minimum and maximum number of replicas, targeting roughly one replica per `targetWaiting` pending messages. The goal of this guide is to wire up that loop: as backlog grows the consumer scales out, and as the queue drains it scales back in.

## Before You Start [#before-you-start]

This guide assumes the scaler is already deployed and you know its Service FQDN. The format is `<service-name>.<namespace>.svc.cluster.local:9090` — for a default Helm install that is `kubemq-keda-scaler.default.svc.cluster.local:9090`. You also need a KubeMQ broker reachable from the cluster (for example `kubemq.default.svc.cluster.local:50000`) and a Deployment that consumes from a queue channel.

## Basic ScaledObject [#basic-scaledobject]

Create a `ScaledObject` that targets your consumer Deployment and points one `external` trigger at the scaler. KEDA calls the scaler every `pollingInterval` seconds, scales toward `targetWaiting` messages per replica, and waits `cooldownPeriod` seconds of inactivity before scaling back down to `minReplicaCount`.

```yaml title="scaled-object-basic.yaml"
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: kubemq-queue-scaler
spec:
  scaleTargetRef:
    name: my-queue-consumer
  pollingInterval: 15
  cooldownPeriod: 60
  minReplicaCount: 1
  maxReplicaCount: 10
  triggers:
    - type: external
      metadata:
        scalerAddress: kubemq-keda-scaler.default.svc.cluster.local:9090
        kubemqAddress: kubemq.default.svc.cluster.local:50000
        queueName: my-queue
        targetWaiting: "10"
```

```bash title="Apply it"
kubectl apply -f scaled-object-basic.yaml
```

With these settings KEDA keeps at least one replica running at all times, scales out to a ceiling of ten, polls the queue depth every 15 seconds, and tolerates a 60-second cooldown before scaling in.

## Trigger Metadata [#trigger-metadata]

Each `external` trigger carries the metadata the scaler needs to find the broker and the queue. KEDA passes this map straight through to the scaler's `ParseScalerMetadata`, so the field names must match exactly.

| Field           | Required | Default | Description                                                                                                                   |
| --------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `scalerAddress` | Yes      | —       | The scaler Service FQDN on port `9090` (this is the KEDA-side address, not a trigger metadata field validated by the scaler). |
| `kubemqAddress` | Yes      | —       | KubeMQ broker address as `host:port`.                                                                                         |
| `queueName`     | Yes      | —       | The queue channel name to monitor.                                                                                            |
| `targetWaiting` | No       | `10`    | Target `Waiting` messages per replica.                                                                                        |

The `scalerAddress` tells KEDA which gRPC endpoint to call; the remaining fields are forwarded to the scaler, which dials `kubemqAddress`, calls `ListQueuesChannels` for `queueName`, and reads the channel's outgoing `Waiting` count. `targetWaiting` becomes the HPA target value for the `kubemq-queue-waiting` metric.

## How the Math Works [#how-the-math-works]

KEDA's HPA divides the current backlog by the per-replica target and rounds up, then clamps the result to your replica bounds:

```text title="Desired replica calculation"
desiredReplicas = clamp(
  ceil(currentWaiting / targetWaiting),
  minReplicaCount,
  maxReplicaCount,
)
```

For example, with `targetWaiting: "10"` and 45 messages waiting, the HPA wants `ceil(45 / 10) = 5` replicas. A lower `targetWaiting` makes scaling more aggressive — each replica is responsible for fewer messages, so the same backlog produces more pods. A higher value packs more work onto each replica and scales more conservatively.

## Activation and the Minimum Floor [#activation-and-the-minimum-floor]

Computing a replica count and deciding whether the workload should be active at all are two separate decisions. The scaler's `IsActive` RPC returns active only when the backlog exceeds `activationTargetWaiting`:

```go title="scaler/scaler.go — IsActive"
return &pb.IsActiveResponse{
	Result: float64(waiting) > meta.ActivationTargetWaiting,
}, nil
```

`activationTargetWaiting` defaults to `0`, so with the basic configuration any single pending message keeps the trigger active. Because `minReplicaCount` is `1` here, the workload never drops below one replica regardless of activation — the floor is held by `minReplicaCount`, while `activationTargetWaiting` becomes meaningful only when you allow the workload to reach zero (see scale-to-zero below).

## Higher-Throughput Variant: ML Inference [#higher-throughput-variant-ml-inference]

For bursty, latency-sensitive workloads you can poll faster, scale to a higher ceiling, and add a fallback so the consumer keeps running even if the scaler is briefly unreachable. This example scales GPU inference workers from zero to twenty based on the inference request queue depth.

```yaml title="scaled-object-ml-inference.yaml"
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: ml-inference-scaler
spec:
  scaleTargetRef:
    name: gpu-inference-worker
  pollingInterval: 5
  cooldownPeriod: 300
  minReplicaCount: 0
  maxReplicaCount: 20
  fallback:
    failureThreshold: 3
    replicas: 2
  triggers:
    - type: external
      metadata:
        scalerAddress: kubemq-keda-scaler.default.svc.cluster.local:9090
        kubemqAddress: kubemq.default.svc.cluster.local:50000
        queueName: inference-requests
        targetWaiting: "5"
        activationTargetWaiting: "1"
```

Compared to the basic object, this variant:

* Polls every 5 seconds for faster reaction to incoming requests.
* Sets `minReplicaCount: 0` so idle GPU workers cost nothing.
* Uses `targetWaiting: "5"` for a tighter per-replica budget.
* Sets `activationTargetWaiting: "1"`. The activation check is strictly greater-than (`Waiting > 1`), so the workload only activates once at least two requests are waiting (`Waiting >= 2`); a backlog of exactly `1` does not wake it.
* Adds `fallback`, so if the scaler returns errors for 3 consecutive polls, the HPA holds the Deployment at 2 replicas rather than guessing.

<Callout type="info">
  The scaler returns a gRPC error (not a fake `Waiting=0`) when the KubeMQ broker is unreachable, unauthenticated, throttled, or times out. That error is what lets KEDA's `fallback` block engage. Without `fallback`, an unreachable scaler leaves the Deployment at its current replica count.
</Callout>

## ScaledJob Variant: One Job per Batch [#scaledjob-variant-one-job-per-batch]

When each message represents an independent unit of work, a `ScaledJob` is often a better fit than a `ScaledObject`: KEDA creates Kubernetes Jobs on demand instead of keeping long-lived pods. With `targetWaiting: "1"` the scaler effectively requests one Job per waiting message, up to `maxReplicaCount`.

```yaml title="scaled-job.yaml"
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
  name: kubemq-batch-job
spec:
  jobTargetRef:
    template:
      spec:
        containers:
          - name: worker
            image: my-batch-worker:latest
            env:
              - name: KUBEMQ_ADDRESS
                value: kubemq.default.svc.cluster.local:50000
              - name: QUEUE_NAME
                value: batch-queue
        restartPolicy: Never
    backoffLimit: 3
  pollingInterval: 10
  maxReplicaCount: 50
  successfulJobsHistoryLimit: 5
  failedJobsHistoryLimit: 3
  triggers:
    - type: external
      metadata:
        scalerAddress: kubemq-keda-scaler.default.svc.cluster.local:9090
        kubemqAddress: kubemq.default.svc.cluster.local:50000
        queueName: batch-queue
        targetWaiting: "1"
```

Note that a `ScaledJob` uses `jobTargetRef` (a Job template) rather than `scaleTargetRef`, and the worker container reads the queue address and name from environment variables so each Job knows which channel to drain.

## Verify Scaling [#verify-scaling]

With a `ScaledObject` applied, push messages onto the queue and watch the consumer react.

<Steps>
  <Step>
    Send messages to the queue so the `Waiting` count climbs above `targetWaiting`.
  </Step>

  <Step>
    Confirm the `ScaledObject` is ready and check the HPA the operator created for it.

    ```bash title="Inspect the ScaledObject and its HPA"
    kubectl get scaledobject kubemq-queue-scaler
    kubectl get hpa
    ```
  </Step>

  <Step>
    Watch the target Deployment scale out as the backlog grows.

    ```bash title="Watch replicas"
    kubectl get deploy my-queue-consumer --watch
    ```
  </Step>

  <Step>
    Drain the queue. After the backlog clears and `cooldownPeriod` (60 seconds in the basic example) elapses with no activity, the Deployment scales back down to `minReplicaCount`.
  </Step>
</Steps>

## Validation Rules [#validation-rules]

The scaler parses `targetWaiting` and `activationTargetWaiting` as 64-bit floats and rejects values that are not finite. `targetWaiting` must be a finite, strictly positive number; `activationTargetWaiting` must be a finite, non-negative number.

```go title="scaler/config.go — validation"
if parsed <= 0 || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
	return nil, status.Error(codes.InvalidArgument, "targetWaiting must be a finite positive number")
}
// ...
if parsed < 0 || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
	return nil, status.Error(codes.InvalidArgument, "activationTargetWaiting must be a finite non-negative number")
}
```

<Callout type="info">
  A value such as `targetWaiting: "0"`, a negative number, or a non-numeric string causes the scaler to return an `InvalidArgument` gRPC status. KEDA surfaces this as an error on the `ScaledObject` and logs it — scaling for that trigger will not start until the metadata is corrected. Always quote numeric metadata values (`"10"`, `"5"`, `"1"`) since trigger metadata is a string map.
</Callout>

## Related [#related]

* [Scale to Zero](/integrations/keda/how-to/scale-to-zero) to let idle consumers drop to no replicas with push-based activation.
* [TLS and Authentication](/integrations/keda/how-to/tls-and-auth) to connect the scaler to a secured KubeMQ broker.
* [ScaledObject metadata](/integrations/keda/reference/scaled-object-metadata) for the full trigger metadata table.
* [Error codes](/integrations/keda/reference/error-codes) for the KubeMQ-error-to-gRPC mapping and KEDA `fallback` behavior.
* [Queues](/learn/queues) for the core queue concept the `Waiting` count comes from.
