Concepts
Origins and caching
Pools, load-balancing policies, the cache key, and why a HIT is a claim you can check.
guardyn-core declares the shapes and the defaults; guardyn-edge and guardyn-cache pick an origin and store a response. Where the two have not caught up with each other, this page says so.
Three layers, not one
An origin is one server. A pool is a set of interchangeable origins plus a policy for picking between them. A load balancer steers between pools for one hostname.
| Layer | Owns | Created by |
|---|---|---|
| Origin | one address, its weight, its Host header, its drain state | POST /v1/pools/{pool_id}/origins |
| OriginPool | the origin list, the LbPolicy, minimum_healthy, retry_next_on_failure | POST /v1/zones/{zone_id}/pools |
| LoadBalancer | a hostname, an ordered pool list, a PoolSteering mode, a fallback pool | nothing — see below |
Every policy, and what it costs
These are the doc comments on LbPolicy, and the same text ships to the dashboard as the policies field of GET /v1/zones/{zone_id}/pools — a picker that has drifted from what the server accepts is a form that fails on submit.
| policy | Behaviour and the trade-off |
|---|---|
| round_robin | Weighted round robin. Predictable, and the default. |
| least_connections | Fewest in-flight requests. Better under heterogeneous response times, worse when a broken origin answers instantly with 500s — which is why health checking is not optional with this policy. |
| ip_hash | Hash the client address, so a visitor sticks to one origin. Session affinity without a cookie, and it breaks the moment the pool changes. |
| key_hash | Hash a cookie or header value. Affinity that survives pool changes. The only policy for which needs_affinity_key returns true, enforced in the API and as a CHECK constraint. |
| failover | First enabled and healthy origin, in configuration order. Active/passive. |
| random_two_choices | Random with two choices, taking the less loaded. Nearly as good as least-connections with none of the coordination. |
How the edge implements them
order_origins turns a policy into a list, tried in order. Hashing uses blake3, not DefaultHasher, which is randomly seeded per process — two nodes would send one visitor to different origins, which is not affinity. Weight is honoured by repeating an origin in the list, capped at 8: a weight-3 origin appears three times.
Draining, maintenance, disabled — three different things
OriginPool::eligible is one filter, and these states are deliberately not one boolean:
self.origins
.iter()
.filter(|o| o.enabled && !o.in_maintenance && o.weight > 0 && is_healthy(o.id))
.collect()| State | Means | Why it is its own field |
|---|---|---|
| weight = 0 | drained: takes no new traffic, stays in the config | the weight can be put back without re-entering the address |
| in_maintenance = true | marked down by an operator regardless of health | a maintenance drain is visibly different from a config change, and whoever is on call can reverse it |
| enabled = false | not part of the pool at all | a config edit, not an operational action |
When nothing is eligible, the trace names each origin with its own reason — "in maintenance", "disabled", "drained (zero weight)". minimum_healthy is the pool's threshold: below it, a load balancer should move on rather than send traffic to a pool that will collapse under it. Zero is clamped to 1, because zero must not mean "healthy with nothing up".
host_header, sni and the TLS hop
host_header unset forwards the visitor's Host, which is what a normal virtual host wants; setting it is for an origin serving one site under a different name. The edge drops any inbound Host and sets exactly one. Redirect following is off: a CDN that follows its origin's redirects hides them from the visitor and breaks every relative URL.
retry_next_on_failure, and why POST is excluded
With it on, a failed attempt moves to the next origin, up to three attempts; otherwise there is exactly one. It is gated on the method — GET, HEAD, OPTIONS, TRACE, PUT and DELETE are retried, POST and PATCH are not. The reason is phrased the same way in the enum comment, the edge and the test name: retrying a POST that timed out after the origin received it double-charges someone's credit card. A timeout does not say whether the request was processed, so the method is the only safe gate. Each attempt appends its own reason to OriginTrace.skipped, so a trace shows the whole failover chain rather than the last failure.
The cache key
Too coarse and you serve one user's authenticated page to another; too fine and the hit rate collapses. Both failures are silent — one is a security incident found by a customer, the other is a bill. So the key is an explicit, ordered list of ingredients, and the exact string that was hashed goes in the trace.
| Ingredient | Default | Note |
|---|---|---|
| scheme | off | with always_https on, http never reaches the cache; including it doubles the key space for nothing |
| host | on | effectively mandatory: off means two zones share entries |
| path | on | already normalised, no query string |
| query | QueryKeyMode::All | four modes, below |
| headers | none | lowercased, kept sorted |
| cookies | none | by name, kept sorted |
| device_type | off | three buckets from the user agent — mobile, tablet, desktop — because every extra bucket divides the hit rate |
| country | off | necessary for geo-varied content, catastrophic for hit rate otherwise |
| encoding | off | normally handled by Vary instead |
Order is fixed by the struct and every map is a BTreeMap, because a key that differs between two nodes for one request halves the hit rate and is very hard to notice. The encoding is length-prefixed rather than concatenated: without prefixes, a header value containing the delimiter could forge another request's key — a cross-tenant cache-poisoning primitive. An absent header and a present-but-empty one hash differently.
Of the four query modes, All is correct and often wasteful, None is wrong for anything paginated, Include suits most sites (page, sort), and Exclude is the answer when the problem is tracking parameters. COMMON_TRACKING_PARAMS lists 22 of those, offered as a one-click default and not applied automatically, because silently changing someone's cache key is not ours to do. Query strings are not per cent-decoded before splitting: %26 and & differ, and decoding first is how a cache or a WAF gets bypassed.
Vary needs two lookups
A key is computed before the response is seen, so the first request for a URL cannot know what the origin will say it varies on.
- compute the base key from the zone's specification;
- ask
Cache::vary_hintwhat a previous response for that base key recorded, and recompute the key including those headers; - on a store, record the
Varyagainst the base key and store the body under the varying key.
One extra lookup on a varying resource, nothing on everything else. It is what stops a Brotli body reaching a client that cannot read it. Vary: * is never stored.
host:11:example.com path:5:/page q:a:1:1 q:page:1:2
Two tiers, split by size
The memory tier is a moka cache whose weigher is the entry's own byte size, so eviction is by bytes and not by count — a count-based cache holding a thousand 4kB pages and one 400MB video evicts the pages. Anything over memory_threshold_bytes goes to disk instead, one file per entry, written to a temporary name and renamed so a reader sees the whole entry or nothing. With no disk tier configured, an object over the threshold is not cached at all — honest, and better than holding a 400MB object in memory.
| CacheConfig field | Default in guardyn-edge | Set by |
|---|---|---|
| memory_capacity_bytes | 256 MiB | --cache-memory-bytes / GUARDYN_CACHE_MEMORY_BYTES |
| memory_threshold_bytes | 512 KiB | hard-coded in main.rs |
| disk_dir | <data-dir>/cache, or None when the disk budget is 0 | derived from --data-dir |
| disk_capacity_bytes | 8 GiB | --cache-disk-bytes / GUARDYN_CACHE_DISK_BYTES |
| max_object_bytes | 512 MiB | hard-coded in main.rs |
Single-flight
A thousand concurrent misses for one key must produce one origin request, or a cold cache in front of a slow origin is a denial of service you built yourself, on exactly the pages that matter. The mechanism is a map from key to a tokio::sync::broadcast sender: claim returns Ok(()) to the first caller, the leader, and Err(receiver) to everyone after. The leader fetches, stores, and calls finish with Filled::Stored or Filled::NotStored. A leader that dies drops the sender, every waiter's recv errors, and they each fetch for themselves — the thundering herd we were avoiding, which is the right failure direction: slow beats stuck.
The statuses a trace can show
Every proxied response carries guardyn-cache-status; CacheTrace carries the same value plus the explained key, its short hash, and a one-sentence reason.
| Value | Means | Reaches a response today |
|---|---|---|
| HIT | fresh in cache; the origin was not touched | yes |
| MISS | not in cache | no — the trace and the response-phase rules only |
| STALE | past TTL, served while a revalidation runs behind it | no — see below |
| STALE-ERROR | served stale because the origin was unreachable or errored | yes |
| REVALIDATED | conditional request, origin answered 304 | yes |
| BYPASS | a rule, a cookie or a method said do not cache — your config | yes |
| DYNAMIC | nothing forbade caching; the response was not cacheable on its own terms — your origin | yes |
| MISS-STORED | a miss to the visitor, but stored on the way out, so fill rate is visible | yes |
| COLLAPSED | collapsed onto an in-flight fetch for the same key | no — see above |
BYPASS and DYNAMIC being separate is the distinction people ask about: one is your config, the other is your origin. served_from_cache counts HIT, STALE, STALE-ERROR and COLLAPSED — the origin was not touched — and deliberately not REVALIDATED, which did touch it.
What is cached by default
From CachePolicy::default: caching on; cacheable_methods ["GET", "HEAD"], and widening it is an explicit act; cache_with_set_cookie off, and it must stay off because that is the classic way a cache leaks a session; max_object_bytes 512 MiB; no bypass_cookies, no tag_header, both stale windows zero. TTLs are looked up most-specific first — exact status, then class, then respecting the origin:
| Status | TtlRule | Why |
|---|---|---|
| 2xx | RespectOrigin | the only rule that cannot surprise a customer |
| 3xx | RespectOrigin | the same |
| 404 | Override { seconds: 5 } | an origin under load emitting 404s should not be asked again 10,000 times a second |
| 4xx | NoStore | a client error is per-request |
| 5xx | NoStore | never store a failure |
Under RespectOrigin, s-maxage beats max-age because it addresses shared caches specifically. With no directive at all, heuristic caching applies for one hour — but only for unambiguously static types (text/css, JavaScript, image/, video/, audio/, fonts, application/pdf, application/wasm). text/html, application/json and text/plain are excluded deliberately: that is where a per-user response lives, and heuristically caching one is how a logged-in page reaches a stranger.
Before any of that, a lookup is bypassed outright for four reasons:
- caching is off for this zone;
- the method is not in
cacheable_methods; - the request carried an
Authorizationheader, which makes a response private per RFC 9111 §3.5; - the request carried a cookie matching
bypass_cookies— one trailing*is supported, which covers thewordpress_logged_in_*and Drupal patterns that motivate the feature.
Response bodies are buffered, not streamed
The edge reads an origin response fully into memory before it caches or forwards it, and MAX_BODY_BYTES is 64 MiB. A declared Content-Length over the ceiling is refused before the body is read; an undeclared one is checked after. The bound exists because an origin that streams forever would otherwise be an out-of-memory kill. Streaming is the obvious next step, and the code says so rather than pretending it is done.
Purge is a tombstone, not a sweep
A purge records "everything for this zone stored before now, matching this, is invalid". Entries fail the check on their next lookup and are evicted then. Deleting eagerly would mean walking the whole cache — a stall on a node holding millions of objects — and it would race with a request mid-store.
| PurgeScope | Covers |
|---|---|
| Urls | exact URLs, after normalising away the scheme, the host’s case and a trailing slash — but not the path’s case, because a path is case-sensitive |
| Tags | anything the origin tagged, matched case-insensitively on any tag. The one that matters operationally: a customer who changes a price wants every page mentioning it gone, and asking them to enumerate the URLs is asking them to get it wrong |
| Prefixes | everything under a normalised path prefix |
| Hosts | one hostname of a zone serving several |
| Everything | the whole zone |
An entry stored after the tombstone survives — it is the copy somebody fetched immediately afterwards, and invalidating that too would mean a purge under load never converges. A purge is effective immediately for lookups and only eventually for memory, so the resident set does not drop the instant you purge; CacheStats.purged_lazily is the number that explains why. The list is bounded at 4,096, trimmed oldest-first, because a purge older than every entry in the cache cannot invalidate anything.
Changing any of this
Every write is Role::Admin, not member: it can send a site's requests to a server the customer does not control, so each call is audited. An address that parses as an IP is stored as one; anything else stays a hostname and is re-resolved on every connection, so a customer can move their origin without touching our config.
# GUARDYN_API_URL is what gdn reads too; it falls back to http://127.0.0.1:8787
curl -X POST $GUARDYN_API_URL/v1/zones/$ZONE/pools \
-H 'content-type: application/json' \
-d '{"name":"eu-west","policy":"round_robin","minimum_healthy":1}'
curl -X POST $GUARDYN_API_URL/v1/pools/$POOL/origins \
-H 'content-type: application/json' \
-d '{"name":"web-1","address":"origin.example.com","port":443,"weight":1}'Pool defaults: round_robin, minimum_healthy 1, max_connections 200, retry_next_on_failure true. Origin defaults: port 443, weight 1, in_maintenance false. None of it is live until gdn apply builds and signs a new snapshot — see Configuration for that, and Traces for the cache and origin blocks this page keeps pointing at.