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.

LayerOwnsCreated by
Originone address, its weight, its Host header, its drain statePOST /v1/pools/{pool_id}/origins
OriginPoolthe origin list, the LbPolicy, minimum_healthy, retry_next_on_failurePOST /v1/zones/{zone_id}/pools
LoadBalancera hostname, an ordered pool list, a PoolSteering mode, a fallback poolnothing — 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.

policyBehaviour and the trade-off
round_robinWeighted round robin. Predictable, and the default.
least_connectionsFewest 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_hashHash the client address, so a visitor sticks to one origin. Session affinity without a cookie, and it breaks the moment the pool changes.
key_hashHash 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.
failoverFirst enabled and healthy origin, in configuration order. Active/passive.
random_two_choicesRandom 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:

crates/guardyn-core/src/origin.rs
self.origins
    .iter()
    .filter(|o| o.enabled && !o.in_maintenance && o.weight > 0 && is_healthy(o.id))
    .collect()
StateMeansWhy it is its own field
weight = 0drained: takes no new traffic, stays in the configthe weight can be put back without re-entering the address
in_maintenance = truemarked down by an operator regardless of healtha maintenance drain is visibly different from a config change, and whoever is on call can reverse it
enabled = falsenot part of the pool at alla 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.

IngredientDefaultNote
schemeoffwith always_https on, http never reaches the cache; including it doubles the key space for nothing
hostoneffectively mandatory: off means two zones share entries
pathonalready normalised, no query string
queryQueryKeyMode::Allfour modes, below
headersnonelowercased, kept sorted
cookiesnoneby name, kept sorted
device_typeoffthree buckets from the user agent — mobile, tablet, desktop — because every extra bucket divides the hit rate
countryoffnecessary for geo-varied content, catastrophic for hit rate otherwise
encodingoffnormally 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.

  1. compute the base key from the zone's specification;
  2. ask Cache::vary_hint what a previous response for that base key recorded, and recompute the key including those headers;
  3. on a store, record the Vary against 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.

the explained key, as it appears in a trace
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 fieldDefault in guardyn-edgeSet by
memory_capacity_bytes256 MiB--cache-memory-bytes / GUARDYN_CACHE_MEMORY_BYTES
memory_threshold_bytes512 KiBhard-coded in main.rs
disk_dir<data-dir>/cache, or None when the disk budget is 0derived from --data-dir
disk_capacity_bytes8 GiB--cache-disk-bytes / GUARDYN_CACHE_DISK_BYTES
max_object_bytes512 MiBhard-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.

ValueMeansReaches a response today
HITfresh in cache; the origin was not touchedyes
MISSnot in cacheno — the trace and the response-phase rules only
STALEpast TTL, served while a revalidation runs behind itno — see below
STALE-ERRORserved stale because the origin was unreachable or erroredyes
REVALIDATEDconditional request, origin answered 304yes
BYPASSa rule, a cookie or a method said do not cache — your configyes
DYNAMICnothing forbade caching; the response was not cacheable on its own terms — your originyes
MISS-STOREDa miss to the visitor, but stored on the way out, so fill rate is visibleyes
COLLAPSEDcollapsed onto an in-flight fetch for the same keyno — 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:

StatusTtlRuleWhy
2xxRespectOriginthe only rule that cannot surprise a customer
3xxRespectOriginthe same
404Override { seconds: 5 }an origin under load emitting 404s should not be asked again 10,000 times a second
4xxNoStorea client error is per-request
5xxNoStorenever 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 Authorization header, which makes a response private per RFC 9111 §3.5;
  • the request carried a cookie matching bypass_cookies — one trailing * is supported, which covers the wordpress_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.

PurgeScopeCovers
Urlsexact 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
Tagsanything 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
Prefixeseverything under a normalised path prefix
Hostsone hostname of a zone serving several
Everythingthe 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.

creating a pool and an origin
# 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.