guardrail

package
v0.11.0 Latest Latest
Warning

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

Go to latest
Published: Jul 22, 2026 License: MIT Imports: 4 Imported by: 0

Documentation

Overview

Package guardrail holds the server-enforced, per-tenant limits that protect the ingest path from an untrusted client (CONTEXT Guardrails): ingest rate limit, cardinality cap, max payload size, and retention. It is the lowest layer in the ingest data flow: the control plane (which owns plans) depends on guardrail for the shared Limits type, and ingest depends on guardrail for enforcement — never the reverse. Retention itself is enforced as ClickHouse TTL (see storage migrations), not in code.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func SeriesKey

func SeriesKey(method, routeTemplate, statusClass string) string

SeriesKey builds the deterministic per-node series key from the series dimensions that matter for cardinality (instance excluded).

Types

type CachedLimitProvider

type CachedLimitProvider struct {
	// contains filtered or unexported fields
}

CachedLimitProvider is a caching decorator over a LimitProvider. Without it, every ingest Upload resolves plan limits 2–3 times — the rate limiter re-tunes from provider.Limits on every Allow, and the cardinality and payload providers each call it per request — and each resolution is a Postgres query. The decorator sits at the END of the provider chain wired in app.buildIngestWiring, so ALL three per-request consumers (rate re-tune, cardinality cap, payload cap) and any composing build's decorated provider resolve from memory; steady-state cost drops to at most one control-plane query per tenant per TTL window.

Accepted staleness: a plan change (or a composing build's suspend decision) takes effect within limitPositiveTTL. That trade mirrors the key-resolution cache in control/resolver.go, which already accepts the same window for revocations.

func NewCachedLimitProvider

func NewCachedLimitProvider(base LimitProvider) *CachedLimitProvider

NewCachedLimitProvider wraps base with an in-memory TTL cache.

func (*CachedLimitProvider) Limits

func (c *CachedLimitProvider) Limits(ctx context.Context, tenant string) (Limits, bool)

Limits returns the tenant's cached budget when fresh, otherwise resolves through the wrapped provider and caches the result — ok=true for limitPositiveTTL, ok=false for limitNegativeTTL.

type Cardinality

type Cardinality struct {
	// contains filtered or unexported fields
}

Cardinality is a BEST-EFFORT, PER-NODE series-cardinality guard. It tracks the set of distinct series each tenant has produced on THIS node and, once that set reaches the tenant's cap, freezes NEW series while letting already-tracked ones through (CONTEXT: "freeze new series, keep existing"). It is deliberately NOT a globally consistent registry: a true cross-node "freeze exactly the new ones" needs distributed series-key sync and violates the simplicity pillar. Each node freezes independently, so the effective cap is per-node, not per-tenant-cluster-wide. The dashboard surfaces the per-tenant frozen state exposed by Frozen.

The series key is method|route_template|status_class. Instance is excluded so the same endpoint across replicas counts once (matches the CONTEXT series-key intent for a per-node budget); it is kept deterministic for testability.

Memory is bounded by lazy TTL eviction: each tracked series carries a last-seen timestamp, and Allow sweeps a tenant's stale entries (older than seriesTTL) before counting, so the tracked set reflects the active working set. An emptied tenant is dropped entirely (tracked + frozenAt) so a churning key space cannot grow the maps without bound.

func NewCardinality

func NewCardinality() *Cardinality

NewCardinality builds an empty cardinality guard using the wall clock.

func (*Cardinality) Allow

func (c *Cardinality) Allow(tenant, seriesKey string, capacity int) (allowed, frozen bool)

Allow reports whether a series may be ingested for tenant under cap, and whether the tenant is currently in a frozen state. Semantics:

  • already-tracked series -> allowed (existing series keep flowing);
  • untracked series with tracked count < cap -> tracked and allowed;
  • untracked series at/over cap -> rejected (frozen), tenant marked frozen.

A cap <= 0 disables the guard (always allow), so a missing/zero plan value never accidentally blocks ingest.

func (*Cardinality) Frozen

func (c *Cardinality) Frozen(tenant string) bool

Frozen reports whether tenant has had at least one new series frozen on this node. Exposed for dashboard surfacing; read-only.

type LimitProvider

type LimitProvider interface {
	// Limits returns the tenant's budget and ok=false when it cannot be
	// resolved (unknown tenant or provider error).
	Limits(ctx context.Context, tenant string) (Limits, bool)
}

LimitProvider resolves a tenant's Limits. The control plane implements it (plan_limits lookup, cached); when no provider is wired the RateLimiter falls back to DefaultLimits, preserving zero-dependency local dev.

type Limits

type Limits struct {
	// MaxRPS is the sustained ingest request rate per second per tenant.
	MaxRPS float64
	// Burst is the token-bucket burst allowance over MaxRPS.
	Burst int
	// CardinalityCap is the max number of distinct series tracked per tenant
	// per node before new series are frozen (best-effort, see Cardinality).
	CardinalityCap int
	// MaxPayloadBytes caps a single ingest request body.
	MaxPayloadBytes int64
	// RetentionDays is the raw-tier retention, mirrored by ClickHouse TTL.
	RetentionDays int
}

Limits is the resolved per-tenant guardrail budget. It is the shared type the control plane fills from a plan_limits row and the ingest path enforces. It lives here (the lowest layer) so control can depend on guardrail without an import cycle (lang-go layer-dependency direction).

func DefaultLimits

func DefaultLimits() Limits

DefaultLimits returns the fallback budget used when no control plane is wired (local dev / current tests). It mirrors the seeded free-plan row so behavior is identical whether or not Postgres is present.

type RateLimiter

type RateLimiter struct {
	// contains filtered or unexported fields
}

RateLimiter is a per-tenant token-bucket rate limiter whose budget comes from a LimitProvider (the control plane's plan_limits). It generalizes the ingest package's hardcoded tenantLimiter with plan-driven limits. When no provider is set, or the provider cannot resolve a tenant, it applies DefaultLimits so the ingest path never fails open on a missing plan.

func NewRateLimiter

func NewRateLimiter(provider LimitProvider) *RateLimiter

NewRateLimiter builds a RateLimiter. A nil provider means "always use DefaultLimits", which reproduces the fixed 100rps/200burst default.

func (*RateLimiter) Allow

func (r *RateLimiter) Allow(ctx context.Context, tenant string) bool

Allow reports whether a request for tenant may proceed, consuming a token. The tenant's rps/burst come from the provider (falling back to defaults), and the per-tenant limiter is created lazily on first use.

A cached limiter is re-tuned to the currently resolved budget on every call, so a plan change (upgrade/downgrade) or a suspension takes effect immediately without a process restart. In particular a suspended tenant resolves to MaxRPS:0/Burst:0, which SetLimit/SetBurst turn into a hard deny — the enforcement lever the control plane relies on. SetLimit/SetBurst preserve the bucket's accumulated tokens, so re-tuning to an unchanged budget is a no-op.

Jump to

Keyboard shortcuts

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