Documentation
¶
Overview ¶
Package cache implements GoBeyond's request-time caching primitives: per-request memoization (cache.Memo), the data cache (cache.Load), the route props cache (cache.LoadRoute), their invalidation entry points (RevalidateTag / RevalidatePath), the byte Store tiers those sit on, the shared privacy predicate that gates every cache layer, and the key/envelope contracts the action-refresh client is built against.
RequestScope ¶
Every cache primitive in this package requires a *RequestScope on the context, not a bare context.Context. The runtime attaches one at each request entry point (runtime.serveDocument, runtime's applyMiddleware, which covers APIs/actions/soft-nav "runtime" requests) via WithRequestScope. context.WithTimeout / context.WithValue children (e.g. the loader and action deadlines runtime.Server wraps requests in) still resolve RequestScopeFrom because context value lookups walk the parent chain; no second attachment is needed past the entry point.
A RequestScope holds three things for the lifetime of one request:
- the request's privacy flag (the Get-gate result, computed once from request headers before any middleware or loader runs);
- a refresh recorder actions use to accumulate RevalidatePath / RevalidateTag calls (the paths/tags land in the action envelope's "refresh" field);
- the per-request memo bag cache.Memo reads and writes.
Store tiers ¶
A Store is a byte tier: Get/Set/Delete plus the tag-version primitives that make invalidation possible. Tiered composes the two implementations into the shape a deployment runs. Prefer the supported constructor:
config, close, err := openfromenv.OpenFromEnv() // config.DeployPrefix = cmp.Or(DeployPrefixFromEnv(), "local") // config.Store = Tiered(memstore L1, optional redisstore L2)
cache/openfromenv.OpenFromEnv builds bounded L1 (memstore defaults), attaches redisstore.FromEnv when GOBEYOND_CACHE_ENDPOINT is set (absent endpoint is success), starts WatchTagBumps when L2 is present, and returns a Close that cancels the watcher and closes the Redis client.
Manual assembly remains valid for tests:
store := cache.Tiered(memstore.New(memstore.Options{}), shared, cache.TieredOptions{})
cache/memstore is the bounded in-process L1 (TTL + LRU, synchronous writes). cache/redisstore is the shared L2 (ElastiCache/Valkey, write-behind through a bounded worker pool, write-time compare-and-set on tag versions). Passing a nil L2 is the supported degraded mode for a deployment with no cache endpoint configured - redisstore.FromEnv reports exactly that case, and everything above the Store behaves identically either way.
Invalidation is versioned rather than broadcast-dependent. Every entry records the version of each of its tags at the moment its value was computed. A bump makes the shared counter disagree with the entry, which both refuses the entry on read and refuses a late write of a value computed before the bump (Locked decision 13). L1 additionally drops matching entries synchronously on a bump, and bounds its own TTL, so the only thing the redisstore pub/sub channel buys is dropping another instance's L1 copies sooner - it is an optimization, never a correctness requirement.
Runtime handle: deploy prefix and BuildID ¶
cache.Load builds its key from the deploy prefix and the BuildID, neither of which a call site can be trusted to supply. They live on a *Runtime built once at startup and installed on each request's RequestScope (WithRuntimeHandle), which the runtime server does for every document, runtime-data, API, and action request:
runtime.Config{BuildID: buildID, Cache: &cache.RuntimeConfig{DeployPrefix: prefix, Store: store}}
The handle rides on the scope rather than a package-level global for the same reason the memo bag does: two servers in one process (or two builds in one test binary) must not share a cache, and a scope-less context must fall through to the uncached path rather than reach for ambient state. The server owns Cache.BuildID and rejects a handle configured for a different build, so keys can never claim a build the process is not serving.
Privacy: Get-gate vs Set-gate ¶
IsPrivateRequest is the Get-gate: it inspects only request headers and answers "must this request be treated as carrying viewer identity" before any cache read or origin load happens. IsPrivateResponse is the Set-gate: it additionally inspects the loaded response's Set-Cookie header, because a response can mint viewer identity even when the inbound request had none. Every cache layer must fail closed - private wins over any requested CachePublic policy. cache.Load applies the Get gate by skipping the store entirely on a private request and computing the value instead; invalidation is deliberately not gated, because a mutation made by an authenticated viewer still has to evict the public data it changed.
Keys ¶
Route keys and cache.Load ("data") keys share one schema root:
{deployPrefix}/{buildId}/route/{routeId}?{path}{rawQuery}@{publicOrigin}
{deployPrefix}/{buildId}/data/{name}/{argsDigest}
deployPrefix namespaces one tenant/deploy inside a possibly shared Redis instance (Locked decision 15 - no app-level encryption, the prefix is the isolation boundary). buildId namespaces one build's schema/shape inside a deploy: a new build must never read a previous build's cached shapes, so buildId is part of the key, not a value that gets version-checked after the fact. Route keys additionally fold in routeId, the normalized request path, the raw query, and PublicOrigin so that two hosts/origins served by the same build never collide. Data keys combine cache.Load's caller-chosen Name (deploy-unique, e.g. "catalog.product") with a canonical encoding of Args - see DataKey and canonicalArgs.
Data cache ¶
cache.Load is the request-time data cache:
product, err := cache.Load(ctx, cache.Options{
Name: "catalog.product",
Args: []any{slug},
Revalidate: 60 * time.Second,
Tags: []string{"products", "product:" + slug},
}, cache.JSONCodec[Product](), fetchProduct)
A fresh entry is returned as-is. An entry past its Revalidate deadline but inside RuntimeConfig.MaxStale is returned immediately while one background goroutine - detached from the request, time-bounded, panic-guarded, and holding a distributed lease so one instance rather than all of them does the work - recomputes it. Past that window the entry is gone and the caller waits for a fill, deduplicated in-process by singleflight.
Revalidate must be positive for anything to be cached. "Cache until a tag bump" is deliberately not what an omitted Revalidate means: the zero value of an Options literal must not be the one that pins data in a shared cache indefinitely.
RevalidateTag and RevalidatePath bump a tag's version synchronously before returning, so an action can respond as soon as they do, and record the tag/path on the RequestScope for the action envelope's "refresh" field. RevalidatePath bumps PathTag(path) - one normalized path, not everything a route ever produced - so an entry that must react to it has to list that tag in Options.Tags.
Route cache (props ISR) ¶
cache.LoadRoute is the same read path keyed by URL instead of by name. The runtime calls it for a page route whose definePage declared a revalidate window, so the route's loader runs once per window per URL rather than once per request:
page, err := cache.LoadRoute(ctx, cache.RouteOptions{
RouteID: routeID, Path: request.URL.Path, RawQuery: request.URL.RawQuery,
PublicOrigin: origin, Revalidate: 60 * time.Second, Tags: []string{"products"},
}, codec, storable, load)
It differs from Load in two ways. It adds PathTag(Path) to the entry's tags itself, so RevalidatePath("/products/widget") drops exactly that page while its siblings survive, without every route author remembering the tag. And it takes a storable predicate consulted after the loader ran, because whether a page may be shared is only knowable from its result: the runtime uses it to keep non-OK results, and responses that mint a cookie, out of a store other visitors read. What gets stored is the loader's data, never its response headers and never rendered HTML - each request re-renders so it gets its own CSP nonce and render clock.
Action envelope ¶
ActionEnvelope is the frozen wire shape runtime.serveAction emits on every successful action, and the packages/react client parses and refreshes any paths the action recorded.
Edge mapping guidance for schema revalidate ¶
definePage's schema-level `revalidate` describes origin props ISR: how often the Go loader is allowed to recompute props for a route (see LoadRoute). It is not, by itself, an HTTP cache directive - a loader's gb.CachePolicy is. When a route sets both, document the edge policy as an explicit function of the schema revalidate window rather than inventing an automatic mapping the runtime cannot verify was intentional:
gb.PublicRevalidate(revalidate, k*revalidate, staleIfError)
e.g. shared max-age equal to the origin revalidate window, stale-while- revalidate a small multiple k of it (so the edge serves stale content for roughly one extra origin-refresh cycle while a background revalidation is in flight), and an explicit stale-if-error window. Authors set gb.CachePolicy themselves; the runtime never infers it from revalidate, because gb.OK always sets CachePrivateNoStore and there is no sentinel that distinguishes "author omitted CachePolicy" from "author chose private" (Locked decision 7).
Dual-TTL lint rule (design, lint itself ships later) ¶
Because CachePolicy has no "unset" sentinel, the runtime cannot detect when a route's schema `revalidate` and its loader's gb.CachePolicy disagree by accident versus by design. The intended lint is warn-only: for any route that sets both a schema `revalidate` and a public gb.CachePolicy, compare the schema window against the policy's SharedMaxAge and warn when they diverge by more than a small tolerance. It must never auto-correct or auto-default either value - silently rewriting an explicit choice is worse than a noisy warning, and an "auto-default" would have to guess at the very omitted-vs-chosen distinction this section says is undetectable.
Example (Wiring) ¶
Example_wiring shows the supported cache assembly: OpenFromEnv builds a bounded local tier, attaches the shared Redis tier only when the platform injected an endpoint, and starts the tag-bump subscription that drops local copies early. Nothing here changes shape when the shared tier is absent.
package main
import (
"log"
"github.com/Origens-Dev/gobeyond/cache/openfromenv"
"github.com/Origens-Dev/gobeyond/runtime"
)
func main() {
cacheConfig, closeCache, err := openfromenv.OpenFromEnv()
if err != nil {
log.Fatal(err)
}
defer closeCache()
_, err = runtime.New(runtime.Config{
BuildID: "build-1",
PublicOrigin: "https://example.com",
Cache: cacheConfig,
})
if err != nil {
log.Print(err)
}
}
Output:
Index ¶
- Constants
- Variables
- func DataKey(deployPrefix, buildID, name string, args []any) (string, error)
- func DataKeyWithGeneration(deployPrefix, buildID, generation, name string, args []any) (string, error)
- func DeployPrefixFromEnv() string
- func GenerationFromEnv() string
- func IsPrivateRequest(header http.Header) bool
- func IsPrivateResponse(requestHeader, responseHeader http.Header) bool
- func Load[T any](ctx context.Context, options Options, codec Codec[T], ...) (T, error)
- func LoadRoute[T any](ctx context.Context, options RouteOptions, codec Codec[T], ...) (T, error)
- func Memo[T any](ctx context.Context, key string, fn func(context.Context) (T, error)) (T, error)
- func NormalizePath(path string) (string, error)
- func PathTag(path string) (string, error)
- func RevalidatePath(ctx context.Context, path string) error
- func RevalidateTag(ctx context.Context, tag string) error
- func RouteKey(deployPrefix, buildID, routeID, path, rawQuery, publicOrigin string) (string, error)
- func RouteKeyWithGeneration(...) (string, error)
- func WatchTagBumps(ctx context.Context, l1, l2 Store) error
- func WithRequestScope(ctx context.Context, scope *RequestScope) context.Context
- type ActionEnvelope
- type ActionRefresh
- type Codec
- type Leaser
- type Options
- type Profile
- type Record
- type RemoteInvalidationOptions
- type RemoteInvalidationResult
- type RequestScope
- func (s *RequestScope) DependencyTags() []string
- func (s *RequestScope) Private() bool
- func (s *RequestScope) RecordDependencyTags(tags ...string)
- func (s *RequestScope) RecordRefreshPath(path string)
- func (s *RequestScope) RecordRefreshTag(tag string)
- func (s *RequestScope) RefreshPaths() []string
- func (s *RequestScope) RefreshTags() []string
- type RouteOptions
- type Runtime
- type RuntimeConfig
- type ScopeOption
- type Store
- type TagBumpPublisher
- type TagVersionAdopter
- type TieredOptions
Examples ¶
Constants ¶
const ( AuthContextHeader = "X-Gobeyond-Auth-Context" WorkloadIdentityHeader = "X-Origens-Oidc-Token" // OIDCTokenHeader is retained for source compatibility. The token is // workload identity, not a viewer-privacy signal. // Deprecated: use WorkloadIdentityHeader. OIDCTokenHeader = WorkloadIdentityHeader )
Header names shared with the hosted request pipeline. AuthContextHeader is asserted exclusively by application middleware and represents viewer identity. WorkloadIdentityHeader carries a platform-issued credential for the deployed application itself; it does not identify the viewer and therefore must not make otherwise-public content private.
The header names are a stable wire contract, not an implementation detail of any transport package.
const ( DefaultMaxStale = 60 * time.Second DefaultRefreshTimeout = 15 * time.Second EnvCacheGeneration = "GOBEYOND_CACHE_GENERATION" )
Runtime defaults. MaxStale bounds how long a stale entry may be served while a refresh runs; RefreshTimeout bounds the detached refresh itself and doubles as the TTL of the lease that keeps other instances from refreshing the same key at the same time.
const ActionAPIVersion = "gobeyond.action/v1alpha1"
ActionAPIVersion identifies the wire shape of ActionEnvelope. Bump it when the envelope's shape changes in a way a client must branch on; purely additive optional fields do not require a bump.
const EnvDeployPrefix = "GOBEYOND_CACHE_KEY_PREFIX"
EnvDeployPrefix names the environment variable the deployment injects with this deploy's cache namespace (see infra/opentofu/compute.tf). The prefix is the tenant isolation boundary inside a possibly shared cache instance (Locked decision 15).
const KeySchemaVersion = "gobeyond.cache.keys/v1alpha1"
KeySchemaVersion identifies the key layout implemented by RouteKey and DataKey. Bump it (and branch on it in readers) if the layout ever changes shape rather than just namespace values.
const PathTagPrefix = "path:"
PathTagPrefix namespaces the per-path invalidation tags RevalidatePath bumps, keeping them from colliding with author-chosen tags.
const SafetyTTL = 31 * 24 * time.Hour
Variables ¶
var ErrNoRequestScope = errors.New("cache: context has no RequestScope; the runtime must call cache.WithRequestScope at the request entry point")
ErrNoRequestScope is returned by Memo when ctx has no RequestScope attached. Memo cannot fall back to an unscoped bag: the memo bag's lifetime is the request's, so there must be a request to scope it to.
var ErrStaleWrite = errors.New("cache: entry was invalidated while it was being computed")
ErrStaleWrite is returned by a Store.Set whose Record carries tag versions that no longer match the store's current versions: some Revalidate* call bumped one of the entry's tags while the caller was computing the value, so persisting it would resurrect data the bump was meant to invalidate. It is an expected outcome of the write-time compare-and-set, not a failure - callers log it at most and move on with the value they computed.
Functions ¶
func DataKey ¶
DataKey builds the store key for one cache.Load entry (Locked decision 12). name is the caller-chosen, deploy-unique identifier passed as cache.Options.Name (e.g. "catalog.product"); args are canonically encoded so that equal argument values always produce the same key regardless of map key insertion order, and values that cannot be encoded deterministically are rejected rather than silently coerced.
Schema: {deployPrefix}/{buildId}/data/{name}/{argsDigest}
func DataKeyWithGeneration ¶
func DeployPrefixFromEnv ¶
func DeployPrefixFromEnv() string
DeployPrefixFromEnv returns the deploy cache namespace the platform injected, or "" when this deployment has no shared cache configured.
func GenerationFromEnv ¶
func GenerationFromEnv() string
func IsPrivateRequest ¶
IsPrivateRequest is the Get-gate (Locked decision 6): it reports whether request headers carry any signal that this request is bound to viewer identity. It must be evaluated before any cache read, and its result should be captured once, before middleware or a loader can strip or add headers, so later privacy checks stay consistent for the whole request.
A request is private when it carries a Cookie, an Authorization header, or a non-empty AuthContextHeader. Forged or stray copies of viewer-auth headers still trip the gate: fail-closed privacy treats "we cannot prove this is anonymous" as private (design principle: forged auth headers => private).
WorkloadIdentityHeader is intentionally excluded. Hosting layers inject it after stripping any inbound copy, and it authenticates the application to downstream services rather than personalizing the response for a viewer.
func IsPrivateResponse ¶
IsPrivateResponse is the Set-gate (Locked decision 6): the Get-gate signals plus the loaded response's Set-Cookie header. A response that mints a cookie establishes viewer identity even when the inbound request carried none, so anything gated on IsPrivateResponse (writing to a cache layer, serving a public Cache-Control) must fail closed here too.
func Load ¶
func Load[T any](ctx context.Context, options Options, codec Codec[T], fn func(context.Context) (T, error)) (T, error)
Load returns the cached value for options, computing it with fn on a miss.
Caching is opt-in and fails closed. When ctx carries no RequestScope, when the request is private (see IsPrivateRequest), when no cache runtime is installed, or when Revalidate is non-positive, Load simply calls fn: a missing or untrustworthy caching context degrades to the uncached behaviour instead of erroring, so adding cache.Load to a loader can never turn a working page into a failing one. Misconfiguration that is always a bug - an empty Name, a nil codec or fn, arguments that cannot be canonically encoded - does error, everywhere, so it surfaces on the first run rather than only on cache-enabled deployments.
A hit past its revalidate deadline but inside the runtime's stale window is returned immediately while one background goroutine refreshes it, guarded by a distributed lease so the refresh runs on one instance rather than all of them.
func LoadRoute ¶
func LoadRoute[T any](ctx context.Context, options RouteOptions, codec Codec[T], storable func(T) bool, fn func(context.Context) (T, error)) (T, error)
LoadRoute returns the cached value for one route path, computing it with fn on a miss. It is the props-ISR coordinator behind runtime page loads; the runtime, not this package, decides what a "value" is (props, metadata, status, and kind - never response headers or rendered HTML).
Like Load, it fails open: without a RequestScope, on a private request, with no cache runtime installed, or with a non-positive Revalidate, it simply calls fn. Unlike Load, the write is additionally gated on storable, because a page's cacheability is only known after the loader ran: the runtime uses it to keep responses that mint a cookie, and results that are not a plain OK, out of a store other visitors read.
A hit past its revalidate deadline but inside the runtime's stale window is served immediately while one leased background goroutine refreshes it.
func Memo ¶
Memo runs fn at most once per (RequestScope, key) pair and returns its result to every caller for that key during the request, deduplicating concurrent calls. Memo is a package function, not a method, because Go forbids type parameters on methods (Locked decision 2).
Reusing key with a different T on the same RequestScope is a programming error: it returns a descriptive error rather than silently corrupting the cached value's type.
func NormalizePath ¶
NormalizePath validates and normalizes an absolute request path for use in RouteKey: it requires a leading "/", rejects "." / ".." traversal segments, and collapses duplicate slashes and a single trailing slash (except the root path) so that equivalent paths always map to the same route key.
func PathTag ¶
PathTag returns the invalidation tag for one route path. Paths are normalized the same way RouteKey normalizes them, so "/products/widget/" and "/products//widget" revalidate the same entries.
Revalidating a path is deliberately a tag bump on one path rather than a wipe of everything a route ever produced: a route serves many paths, and publishing one product must not evict the other thousand.
func RevalidatePath ¶
RevalidatePath invalidates the entries tagged with path's PathTag and records the normalized path on the request's RequestScope. It has the same synchronous guarantee as RevalidateTag.
Only entries that opted into the path tag are affected: a cache.Load whose value backs one page must list PathTag for that page in its Options.Tags to participate.
func RevalidateTag ¶
RevalidateTag invalidates every cached entry carrying tag and records the tag on the request's RequestScope for the action envelope's "refresh" field.
The store bump is synchronous: when RevalidateTag returns without error, no tier can serve an entry built under the old version, so an action can safely respond immediately after calling it. Invalidation is not gated on privacy - a mutation made by an authenticated viewer still invalidates public data.
func RouteKey ¶
RouteKey builds the store key for one page route's cached response (Locked decision 12). Keys are stable strings intentionally kept human-readable for operability; they are not a security boundary by themselves - deployPrefix plus fail-closed privacy (IsPrivateRequest / IsPrivateResponse) are.
Schema: {deployPrefix}/{buildId}/route/{routeId}?{normalizedPath}{rawQuery}@{publicOrigin}
func RouteKeyWithGeneration ¶
func WatchTagBumps ¶
WatchTagBumps forwards l2's tag-bump broadcasts into l1 so a bump on another instance drops this instance's L1 entries early. It blocks until ctx is done and returns nil when either store does not support broadcasting, because the pub/sub path is an optimization on top of L1's TTL bound and per-Get version check, not a correctness requirement.
func WithRequestScope ¶
func WithRequestScope(ctx context.Context, scope *RequestScope) context.Context
WithRequestScope attaches scope to ctx. Runtime request entry points call this once per request; every derived context (including context.WithTimeout children created for loader/action/API deadlines) resolves the same scope via RequestScopeFrom.
Types ¶
type ActionEnvelope ¶
type ActionEnvelope struct {
APIVersion string `json:"apiVersion"`
BuildID string `json:"buildId"`
Data any `json:"data,omitempty"`
Refresh *ActionRefresh `json:"refresh,omitempty"`
}
ActionEnvelope is the frozen action-response wire shape (Locked decision 9). runtime.serveAction emits this shape on every successful action, and the packages/react client (see fetchActionEnvelope in packages/react/src/actions.ts) parses it and refreshes any paths the action recorded, shipped together per Locked decision 9.
JSON shape:
{
"apiVersion": "gobeyond.action/v1alpha1",
"buildId": "<build id>",
"data": <opaque per-action result>,
"refresh": {
"paths": ["/products/widget"],
"tags": ["products", "product:widget"]
}
}
"refresh" is omitted entirely when the action recorded no RevalidatePath / RevalidateTag calls on its RequestScope.
type ActionRefresh ¶
type ActionRefresh struct {
Paths []string `json:"paths,omitempty"`
Tags []string `json:"tags,omitempty"`
}
ActionRefresh lists the paths and tags an action wants the client to refresh, e.g. by re-fetching the current route's runtime JSON or dropping its router cache entry. Field names are part of the frozen wire contract; see ActionEnvelope's JSON shape.
func ActionRefreshFromScope ¶
func ActionRefreshFromScope(scope *RequestScope) *ActionRefresh
ActionRefreshFromScope builds the ActionEnvelope "refresh" field from the paths/tags recorded on scope during one action request. It returns nil when nothing was recorded, so the field is omitted from the JSON body entirely rather than serialized as an empty object.
type Codec ¶
Codec converts a cached value to and from the bytes a Store holds. Encoding is explicit rather than reflective because entries outlive the process that wrote them: the encoding is a wire format shared across instances of one build.
type Leaser ¶
type Leaser interface {
// AcquireLease reports whether the caller now holds key's lease for ttl.
// The lease is never released explicitly - it expires - so ttl must bound
// the work it guards.
AcquireLease(ctx context.Context, key string, ttl time.Duration) (bool, error)
}
Leaser is implemented by stores that can hand out short-lived exclusive leases. cache.Load uses one to keep a stale-while-revalidate refresh from running on every instance at once; a store that does not implement it simply falls back to in-process deduplication.
type Options ¶
type Options struct {
// Profile supplies a named duration when Revalidate is zero. Revalidate
// remains available for precise application-specific windows.
Profile Profile
// Name identifies the value across the whole deploy, e.g.
// "catalog.product". Two call sites sharing a Name share cache entries, so
// it must be unique per logical value, not per package.
Name string
// Args are the inputs the value depends on. They are canonically encoded
// into the key (see DataKey), so they must be JSON-compatible and must
// include everything fn reads that varies.
Args []any
// Revalidate is how long a computed value stays fresh. A non-positive
// Revalidate disables caching for the call: Load computes the value and
// returns it without touching the store. Caching until a tag bump is
// deliberately not the zero-value behaviour - an accidentally empty
// Options must not pin data in the cache forever.
Revalidate time.Duration
// Tags are the invalidation handles for this value. RevalidateTag bumps
// them; RevalidatePath bumps PathTag(path), so an entry that must react to
// a path revalidation has to carry that tag too.
Tags []string
}
Options describes one cached value: what it is, what it was computed from, how long it stays fresh, and which tags invalidate it.
type Profile ¶
type Profile string
Profile is a named cache policy for application-owned values. Profiles are explicit so an omitted policy continues to mean "do not cache".
type Record ¶
type Record struct {
Value []byte
TagVersions map[string]int64
FreshUntil time.Time
ExpiresAt time.Time
}
Record is one cache entry: opaque bytes plus the metadata every tier needs to decide whether the bytes may still be served.
TagVersions are the tag counters observed immediately before the value was computed. They are the fence for both directions of staleness: a Store must refuse to persist a Record whose versions have moved on (ErrStaleWrite), and must refuse to return one on Get (Locked decision 13 - L1 invalidation is never TTL-only). A tag absent from the map is treated as version 0.
FreshUntil bounds the stale-while-revalidate window's fresh half and is carried through the store untouched; only cache.Load interprets it. ExpiresAt is the entry's hard expiry: stores populate it on Get and ignore whatever a caller puts there on Set, where the ttl argument is authoritative.
type RemoteInvalidationOptions ¶
type RemoteInvalidationOptions struct {
EnvironmentID string
IdempotencyKey string
Hosts []string
Tags []string
Paths []string
APIURL string
Token string
}
RemoteInvalidationOptions describes an application-owned invalidation event. Applications should use their webhook provider's delivery ID as the idempotency key. Tags and paths are both optional; the platform always advances the deployment generation for correctness.
type RemoteInvalidationResult ¶
type RemoteInvalidationResult struct {
Status string `json:"status"`
WorkflowID string `json:"workflow_id,omitempty"`
RunID string `json:"run_id,omitempty"`
}
func InvalidateRemote ¶
func InvalidateRemote(ctx context.Context, options RemoteInvalidationOptions) (RemoteInvalidationResult, error)
InvalidateRemote starts the platform's durable invalidation workflow. It is intended for an application webhook handler after that handler has verified the source webhook signature. The call is asynchronous: accepted means the invalidation was durably handed to Temporal, not that every cache layer has already observed it.
type RequestScope ¶
type RequestScope struct {
// contains filtered or unexported fields
}
RequestScope is the per-request bag every cache primitive in this package requires. See the package doc for what it holds and where the runtime attaches it.
func NewRequestScope ¶
func NewRequestScope(private bool, options ...ScopeOption) *RequestScope
NewRequestScope creates a RequestScope. private is the Get-gate result computed from the inbound request's headers (see IsPrivateRequest) - callers should compute it once, before any middleware or loader runs, and pass it here.
func RequestScopeFrom ¶
func RequestScopeFrom(ctx context.Context) (*RequestScope, bool)
RequestScopeFrom retrieves the RequestScope attached to ctx, if any.
func (*RequestScope) DependencyTags ¶
func (s *RequestScope) DependencyTags() []string
DependencyTags returns the data invalidation tags observed in this request.
func (*RequestScope) Private ¶
func (s *RequestScope) Private() bool
Private reports the request's Get-gate privacy flag captured at scope creation. Cache layers must treat a private RequestScope as fail-closed: skip reads and skip writes regardless of an otherwise-public CachePolicy.
func (*RequestScope) RecordDependencyTags ¶
func (s *RequestScope) RecordDependencyTags(tags ...string)
RecordDependencyTags records data tags observed while building the current response. The route cache coordinator consumes these tags when it writes route props, so invalidating a data value also invalidates pages that loaded that value. Recording is intentionally request-scoped and fail-closed: an overbroad dependency only causes an extra refetch, never stale shared data.
func (*RequestScope) RecordRefreshPath ¶
func (s *RequestScope) RecordRefreshPath(path string)
RecordRefreshPath accumulates a path a later RevalidatePath call wants the client to refresh. It is safe for concurrent use.
func (*RequestScope) RecordRefreshTag ¶
func (s *RequestScope) RecordRefreshTag(tag string)
RecordRefreshTag accumulates a tag a later RevalidateTag call wants the client to refresh. It is safe for concurrent use.
func (*RequestScope) RefreshPaths ¶
func (s *RequestScope) RefreshPaths() []string
RefreshPaths returns a copy of the paths recorded so far on this scope.
func (*RequestScope) RefreshTags ¶
func (s *RequestScope) RefreshTags() []string
RefreshTags returns a copy of the tags recorded so far on this scope.
type RouteOptions ¶
type RouteOptions struct {
// Profile supplies a named duration when Revalidate is zero.
Profile Profile
RouteID string
// Path is the public request path this entry was computed for, not the
// route's pattern: a route serves many paths and each caches separately.
Path string
RawQuery string
PublicOrigin string
// Revalidate is how long a computed entry stays fresh. A non-positive
// Revalidate disables route caching for the call, matching Load: an
// accidentally empty RouteOptions must not pin a page in the cache until
// something happens to bump one of its tags.
Revalidate time.Duration
// Tags are the route's author-declared invalidation handles. PathTag(Path)
// is always added on top, so cache.RevalidatePath("/products/widget")
// drops exactly that page without touching the rest of the route.
Tags []string
}
RouteOptions describes one page route's cached entry: which route and URL it belongs to, how long the origin may reuse it, and what invalidates it.
RouteID, Path, RawQuery, and PublicOrigin are the identity half; they go into RouteKey together with the deploy prefix and BuildID the installed Runtime owns, so two builds, two origins, or two query strings never share an entry. Revalidate and Tags come from definePage({ revalidate, tags }).
type Runtime ¶
type Runtime struct {
// contains filtered or unexported fields
}
Runtime is the installed cache handle. It is safe for concurrent use and is shared by every request the server serves.
func NewRuntime ¶
func NewRuntime(config RuntimeConfig) (*Runtime, error)
NewRuntime validates config and returns the handle to install on request scopes. It fails rather than defaulting the prefix or BuildID: a cache whose namespace is guessed can serve one deploy's or one build's data to another.
func (*Runtime) BuildID ¶
BuildID returns the build namespace every key this runtime builds carries.
func (*Runtime) DeployPrefix ¶
DeployPrefix returns the namespace every key this runtime builds starts with.
func (*Runtime) Generation ¶
type RuntimeConfig ¶
type RuntimeConfig struct {
// DeployPrefix namespaces this deploy's keys. Required.
DeployPrefix string
// BuildID namespaces this build's value shapes. runtime.New fills it in
// from its own configuration so the two can never disagree.
BuildID string
// Generation changes when application data is invalidated. It is separate
// from BuildID and route topology revisions.
Generation string
// Store is the byte tier to read and write, usually Tiered(l1, l2).
// Required.
Store Store
// MaxStale bounds the stale-while-revalidate window past an entry's
// revalidate deadline. It also sets the entry's hard TTL, which is
// Revalidate + MaxStale.
MaxStale time.Duration
// RefreshTimeout bounds a background refresh and the lease guarding it.
RefreshTimeout time.Duration
Logger *slog.Logger
// Clock overrides time.Now, for tests.
Clock func() time.Time
}
RuntimeConfig describes the cache handle a server installs once at startup. A Runtime built from it is the only thing that knows the deploy prefix and BuildID, which is why cache.Load and cache.Revalidate* need one on the request's RequestScope: those two values are part of every key and must not be re-derived (or guessed) per call site.
type ScopeOption ¶
type ScopeOption func(*RequestScope)
ScopeOption configures a RequestScope at creation time.
func WithRuntimeHandle ¶
func WithRuntimeHandle(runtime *Runtime) ScopeOption
WithRuntimeHandle installs the server's cache handle on the scope, which is what lets Load and the Revalidate* functions reach the byte store and the deploy prefix / BuildID that namespace every key. The handle rides on the scope rather than a package-level global so a process can serve two servers (or a test can run two builds) without them sharing a cache; a nil handle is a no-op, leaving cache.Load to fall through to its uncached path.
type Store ¶
type Store interface {
// Get returns the record stored under key. The boolean reports a usable
// hit; a missing, expired, or tag-invalidated entry is (Record{}, false,
// nil).
Get(ctx context.Context, key string) (Record, bool, error)
// Set stores record under key for at most ttl, subject to the store's own
// TTL bound, and returns ErrStaleWrite when record.TagVersions no longer
// match the store's current tag versions.
Set(ctx context.Context, key string, record Record, ttl time.Duration) error
// Delete removes key. Deleting a missing key is not an error.
Delete(ctx context.Context, key string) error
// TagVersions returns the current version of each requested tag. Tags that
// were never bumped report 0 and are still present in the result.
TagVersions(ctx context.Context, tags []string) (map[string]int64, error)
// BumpTag increments a tag's version, invalidating every record that was
// built under an older version. It is synchronous: once it returns without
// error, no tier this store fronts may serve an entry fenced by the old
// version.
BumpTag(ctx context.Context, tag string) error
}
Store is the byte-level cache tier cache.Load and (in a later phase) route ISR are built on. Implementations live in cache/memstore (bounded in-process L1) and cache/redisstore (shared L2); Tiered composes them.
Implementations must be safe for concurrent use, must never return an entry that is hard-expired or whose tag versions are stale, and must treat a transport failure as an error rather than a miss so callers can tell "not cached" from "cache unreachable".
func Tiered ¶
func Tiered(l1, l2 Store, options TieredOptions) Store
Tiered composes a fast local tier with a shared one: reads go L1 then L2 and populate L1 on the way back, writes go to both (L1 synchronously, L2 however that store chooses - the Redis tier writes behind), and tag bumps go to the shared tier first because it is authoritative.
A nil l2 is the supported degraded mode, not an error: a deployment with no shared cache endpoint configured runs L1-only, and every caller above this point behaves identically.
type TagBumpPublisher ¶
type TagBumpPublisher interface {
SubscribeTagBumps(ctx context.Context, onBump func(tag string, version int64)) error
}
TagBumpPublisher is implemented by shared stores that broadcast their tag bumps to other instances. Delivery is best-effort and must never be relied on for correctness: it only shortens the window in which another instance's L1 still holds an entry its own TTL and version check would eventually reject anyway.
type TagVersionAdopter ¶
TagVersionAdopter is implemented by local stores that can be told a tag's authoritative version out of band, e.g. from a TagBumpPublisher broadcast.
type TieredOptions ¶
type TieredOptions struct {
// Logger receives L1/L2 transport failures, which are degradations rather
// than request failures: a tier that cannot answer is treated as a miss.
Logger *slog.Logger
// Clock overrides time.Now, for tests.
Clock func() time.Time
}
TieredOptions configures the composite store. The zero value is valid.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package memstore implements GoBeyond's in-process L1 cache tier: a bounded TTL + LRU byte store with synchronous writes.
|
Package memstore implements GoBeyond's in-process L1 cache tier: a bounded TTL + LRU byte store with synchronous writes. |
|
Package openfromenv provides the supported cache constructor: a bounded in-process L1, an optional Redis L2 when GOBEYOND_CACHE_* is set, and the tag-bump watcher that drops local copies early when L2 is present.
|
Package openfromenv provides the supported cache constructor: a bounded in-process L1, an optional Redis L2 when GOBEYOND_CACHE_* is set, and the tag-bump watcher that drops local copies early when L2 is present. |
|
Package redisstore implements GoBeyond's shared L2 cache tier on top of Redis (ElastiCache Serverless in the reference deployment): a cache.Store/cache.Leaser/cache.TagBumpPublisher backed by one Redis endpoint, shared across every instance behind a deploy.
|
Package redisstore implements GoBeyond's shared L2 cache tier on top of Redis (ElastiCache Serverless in the reference deployment): a cache.Store/cache.Leaser/cache.TagBumpPublisher backed by one Redis endpoint, shared across every instance behind a deploy. |