Help us shape the future of Go! Take the 2026 Go Developer Survey

gaardrail

module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 26, 2026 License: MPL-2.0

README

gaardrail

CI Release

gaardrail is a back-pressure controller for message queue consumers. It uses a discrete PID controller to regulate how fast messages are drained from a queue, targeting a configurable resource utilization setpoint (e.g. database CPU at 50%).

How it works

  Producer
     │
     ▼
 ┌────────┐   enqueue    ┌──────────────────────────────────────────┐
 │  Queue │ ──────────►  │              Orchestrator                │
 │(Kafka) │              │                                          │
 └────────┘              │  rate.Limiter(drainRate msgs/s)          │
                         │       │                                  │
                         │       ▼  (workers)                       │
                         │  ConsumeMessage                          │
                         │    Dequeue → Push(target) → Ack          │
                         └──────────────────────────────────────────┘
                                                  ▲
                                                  │ SetDrainRate(output)
                                    ┌─────────────┴──────────────┐
                                    │       PID Controller        │
                                    │                             │
                                    │  error = setpoint - CPU%    │
                                    │  output = P + I + D         │
                                    └─────────────────────────────┘
                                                  ▲
                                                  │ measured CPU%
                                    ┌─────────────┴──────────────┐
                                    │      Metrics Poller         │
                                    │  (polls Prometheus API)     │
                                    └─────────────────────────────┘

Quickstart

gaardrail sits between a queue and a target, so you need:

  • a SQL target it will push messages into (MySQL supported today);
  • a metrics source exposing the resource you want to protect (any Prometheus-compatible query API);
  • optionally a Kafka queue — the default config uses queue.protocol: constant, a synthetic queue that always emits the same query, so you can watch the control loop work without any broker.

Run the published image, mounting your config:

docker run -d -p 8080:8080 \
  -v $(pwd)/config.yaml:/app/config/config.yaml \
  adrianozdp/gaardrail:latest

Or from source:

git clone https://github.com/adrianozp/gaardrail
cd gaardrail
make run        # go run ./cmd/api — reads config/config.yaml

To use a real Kafka queue locally:

make kafka/up && make kafka/setup   # single-node Kafka via docker compose
# then set queue.protocol: kafka in config.yaml

gaardrail is headless — an example web panel (live tuning, controller switching, embedded Grafana) lives in the gaardrail-flood-test repo under panel/, alongside disturbance-generation scripts for experiments.

Orchestrator

The orchestrator drives message consumption at a controlled rate. It holds a rate.Limiter that gates how many messages can be consumed per second — the drain rate.

On each tick, each worker:

  1. Waits for a token from the rate.Limiter
  2. Calls Consumer.Consume() — dequeues, pushes to the target, and acks

The drain rate starts at the value defined in config and is updated in real time by the PID controller. A background poller also tracks the current queue lag (Consumer.Size()) and publishes it as a metric.

PID Controller

The controller is a discrete position-form PID that runs on each metrics poll tick:

e(t)      = setpoint − measured
P         = Kp × e(t)
I(t)      = clamp(I(t-1) + Ki × e(t) × dt,  −IClamp, +IClamp)
D         = Kd × (e(t) − e(t-1)) / dt
output(t) = clamp(P + I + D,  Min,  Max)

The output is the new drain rate in messages/second, clamped to [Min, Max].

Two configurable filters shape the loop's signals. The measurement filter lives on the metrics chain (metrics_poller.filter_type / filter_size) and smooths the process variable before it reaches whichever controller is active. The setpoint filter is per controller (setpoint_filter_type / setpoint_filter_size) and turns setpoint changes into a gradual trajectory, removing startup overshoot.

Both accept the types none, moving_average and exponential, with sizes in samples: a moving-average setpoint filter turns a step into a linear ramp that completes in exactly N samples; the exponential type is a first-order lag with a time constant of N samples (a = e^(−1/N)). The legacy setpoint_filter_tau (seconds) is still honored and maps to the equivalent exponential.

Controller types

The active controller is selected by controller.type and can be switched at runtime with PUT /controller/type:

Type Description
pid Discrete PI (feedforward off) — the baseline.
pidff Same PI with feedforward (u_ff = setpoint/ff_gain).
smith Smith predictor (PI + internal FOPDT model) for dead-time compensation.
step Open-loop constant output (max), used for identification experiments.

Library

The controllers are importable on their own, with no global metrics and no app config:

import (
    "github.com/adrianozp/gaardrail/pkg/control"
    "github.com/adrianozp/gaardrail/pkg/control/filter"
)

pi, err := control.NewPID(control.PIDParams{
    Kp: 0.058, Ki: 0.009, Setpoint: 50,
    SetpointFilter: filter.Spec{Kind: filter.MovingAverage, Size: 4},
    Limits:         control.Limits{Min: 0, Max: 50, IClamp: 40},
})
rate, terms, err := pi.Compute(cpuPercent, time.Now())

Every controller implements control.Controller:

Method Description
Compute(measured, at) (output, Terms, error) One control step. The first sample never integrates; a sample older than the previous one returns ErrOutdatedMeasurement.
SetSetpoint(v) error Moves the setpoint through the setpoint filter.
Reset() Clears the integral, filters and history.
Constructor Params
NewPID(PIDParams) Kp, Ki, Kd, FFGain (0 disables feedforward), Setpoint, SetpointFilter, Limits
NewSmith(SmithParams) Kp, Ki, Setpoint, SetpointFilter, Model{K, Tau, Theta}, SampleSeconds, Limits
NewStep(output) constant open-loop output

PID and Smith also expose Params() and SetParams(...); all methods are safe for concurrent use. Terms reports Setpoint, Measured, Error, P, I, D, FF, Output, Feedback and Prediction for your own metrics. Invalid params return ErrInvalidLimits, ErrNegativeIClamp, ErrInvalidModel, ErrInvalidSetpoint, ErrInvalidMeasurement or ErrInvalidOutput.

filter.NewMeasurement(Spec) and filter.NewReference(Spec) build the measurement and setpoint filters as *filter.Signal (Filter, Seed, Reset, Spec).

Message consumption

Each Consume() call follows a strict sequence:

Dequeue  →  Push(target)  →  Ack

A message is only acked after the target confirms receipt. If either Push or Ack fails, the message is not acked and will be redelivered.

Interfaces

gaardrail keeps transport and storage details outside the core logic:

type Queue interface {
    Enqueue(entities.Message) (string, error)
    Dequeue(context.Context) (entities.Message, error)
    Ack(context.Context, entities.Message) error
    Size() (int64, error)
}

type Target interface {
    Push(context.Context, entities.Message) (entities.Response, error)
}

type MetricsReader interface {
    Read(ctx context.Context) (entities.Metrics, error)
}

Current implementations: Kafka and constant (queue), MySQL (target); Prometheus HTTP API, Prometheus exporter (text-format scrape), JSON metrics endpoint and AWS CloudWatch (metrics readers).

Configuration

config/config.yaml is the reference; any field can be overridden with an APP_* environment variable (pid.kp → APP_PID_KP).

Section Field Meaning
queue protocol constant (synthetic queue that always emits query, no broker needed) or kafka
target protocol, driver, dsn where messages are pushed; sql + mysql today
target.tls enabled encrypt the database connection (mysql only)
skip_verify encrypt without verifying the server certificate (self-signed, tests)
ca_file, server_name optional PEM CA bundle and name for full verification
metrics_poller interval_ms control period T (5000 locally; 60000 for CloudWatch-class providers)
protocol, endpoint prometheusapi, exporter, json or cloudwatch
mappings query → process variable (cpu). Map the raw value, as a real provider delivers it: irate over a window of at least 3× the scrape interval ([15s] for a 5s scrape, [3m] for 60s). Smoothing belongs to filter_type / filter_size.
controller type pid, pidff, smith or step
orchestrator rate, workers, burst initial drain rate, consumer goroutines, limiter burst
pid setpoint target utilization (%)
kp, ki, kd gains; the defaults are the PI+FF baseline identified at T=10s
min, max drain-rate bounds (msgs/s); leave headroom above the steady-state drain (~22 at setpoint 50%)
i_clamp anti-windup bound; must reach the steady-state drain, since the PID is position-form
ff_gain feedforward u_ff = setpoint / ff_gain, the identified static gain; 0 disables
setpoint_filter_tau soft-start in seconds (~2T); or setpoint_filter_type / setpoint_filter_size
smith kp, ki keep them gentle: high gains oscillate at T=60s
model_k static gain of the identified FOPDT (%CPU per msg/s)
model_tau time constant (s)
model_theta effective delay: continuous θ plus about T of sampling/ZOH
sample_seconds sampling period T; must match metrics_poller.interval_ms

HTTP API

Endpoint Description
GET /ping Health check
GET /metrics Prometheus metrics (drain rate, queue lag, PID terms…)
POST /messages Enqueue a message
GET /pid Read active controller parameters
PATCH /pid Update parameters at runtime (only present fields change)
PUT /controller/type Switch the active controller
curl -X PATCH http://localhost:8080/pid \
  -H "Content-Type: application/json" \
  -d '{"kp": 0.15, "setpoint": 55.0}'

Changing any parameter resets the integral accumulator to prevent windup artifacts. The main endpoints are described in openapi.yaml.

Validation

gaardrail's control loop was validated experimentally — system identification (FOPDT), closed-loop campaigns at T=10s and T=60s, disturbance rejection, and a PI / PI+feedforward / Smith predictor comparison. The full rig (Docker Compose stack, Grafana dashboards, experiment data and analysis scripts) lives at gaardrail-flood-test.

The project started as a final thesis (PFC) in Control and Automation Engineering at UFSC (Brazil).

Contributing

See CONTRIBUTING.md. Security reports: SECURITY.md.

License

MPL-2.0

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL