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 ¶
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.
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.