Documentation
¶
Overview ¶
Package redirect resolves an alias to a destination.
Everything here runs inside a 20ms budget, so the design is shaped by what the hot path must NOT do: no joins, no session lookup, no template rendering, no synchronous write, and no dependency whose failure can take the path down.
Index ¶
- Constants
- type BroadcastHostInvalidator
- type BroadcastRootInvalidator
- type Choice
- type HostCache
- type Options
- type Outcome
- type Resolver
- func (r *Resolver) CacheSize() int
- func (r *Resolver) InvalidateAlias(ctx context.Context, domainID uuid.UUID, alias string)
- func (r *Resolver) InvalidateDomain(ctx context.Context, domainID uuid.UUID)
- func (r *Resolver) PublishHostInvalidation(ctx context.Context)
- func (r *Resolver) PublishRootInvalidation(ctx context.Context)
- func (r *Resolver) Resolve(ctx context.Context, domainID uuid.UUID, alias string) (Result, error)
- func (r *Resolver) ResolveCached(domainID uuid.UUID, alias string) (Result, bool)
- type Result
- type RootCache
- type Snapshot
- func (s *Snapshot) CacheTTL(now time.Time, base, negative time.Duration) time.Duration
- func (s *Snapshot) Decide(now time.Time) Outcome
- func (s *Snapshot) Fallback() (Choice, bool)
- func (s *Snapshot) Gated() bool
- func (s *Snapshot) Route(subject domain.RuleSubject) (Choice, bool)
- func (s *Snapshot) RuleNeeds() domain.RuleNeeds
- func (s *Snapshot) Split() (kind string, arms []Choice)
- func (s *Snapshot) Weights(arms []Choice) []int32
- type SnapshotDest
- type SnapshotRule
- type Source
- type Subscriber
- type VerifiedDomain
Constants ¶
const CacheKeyVersion = "v3"
CacheKeyVersion is bumped only when the Snapshot encoding changes incompatibly. Including it in the key means an upgrade cannot read a stale payload written by the previous version, which would otherwise deserialize into a plausible-looking wrong answer rather than failing.
**v2 is M34's, and it is the phase's one deliberate bump.** Routing rules are the first snapshot field whose *absence* means something different from its zero value in a way a visitor can observe: an entry written by the previous build carries no rules, and a link whose owner has since routed British traffic somewhere else would go on sending it to the link's own destination for up to REDIRECT_TTL. Every earlier Phase 2 field could argue its way out of a bump because the stale reading was the behaviour the link already had — bot blocking off, path forwarding off. This one cannot: the stale reading is a rule not being applied, which is the control the owner configured being silently absent, and that is precisely what a cold cache is for.
The consequence is stated in the CHANGELOG rather than only here: upgrading to this version abandons every cached snapshot at once, so the first request for each alias after the upgrade reads Postgres. That is one query per live alias, spread over however long it takes traffic to arrive, and the singleflight in Resolve is what keeps a popular alias from turning into a stampede while it happens.
**v3 is M36's**, and it is the same argument twice rather than a second kind of argument. Split testing changes the destination list from `[]string` to a list of objects carrying an id and a weight, so a v2 payload does not decode into the new shape at all — but that alone would only have cost a discarded entry, which decodeSnapshot already handles. What forces the bump is the same thing that forced v2: a v2 entry carries no split arms, and a link whose owner has since divided its traffic between two destinations would keep sending all of it to one of them for up to REDIRECT_TTL. The stale reading is a control the owner configured being silently absent, and a cold cache is what that costs.
Both bumps land in the same unreleased minor, so no deployed instance ever holds a v2 entry this build could meet.
const InvalidationChannel = "lc:inval:" + CacheKeyVersion
InvalidationChannel carries cache invalidations between replicas.
Versioned with the cache key, so a mixed-version deployment mid-rolling-update cannot have one replica interpret another's message under different rules. A replica on the old version simply never hears the new channel, which degrades to the TTL staleness that existed before this milestone rather than to a misapplied invalidation.
const SSLStatusPending = sslStatusPending
SSLStatusPending is what a freshly verified domain carries: this instance will answer Caddy's ask for it, and nothing more is known.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type BroadcastHostInvalidator ¶ added in v0.2.0
BroadcastHostInvalidator refreshes this replica's host cache and tells the others to refresh theirs.
The same shape as BroadcastRootInvalidator and for the same reason: the link service changes a domain's verification without knowing that pub/sub exists. Local first, so the replica the operator is about to reload is already right if the publish fails.
func (BroadcastHostInvalidator) InvalidateHosts ¶ added in v0.2.0
func (b BroadcastHostInvalidator) InvalidateHosts(ctx context.Context)
type BroadcastRootInvalidator ¶ added in v0.2.0
BroadcastRootInvalidator clears the local root cache and tells every other replica to do the same.
It exists because the two halves live in different layers: the cache is an HTTP handler and the Redis connection belongs to the resolver. The link service holds one of these instead of the handler directly, so a setting change reaches every replica without the service knowing pub/sub exists.
Local first, then publish. If the publish fails the operator's own instance is still correct, which is the one they are about to reload to check their work.
func (BroadcastRootInvalidator) InvalidateRoot ¶ added in v0.2.0
func (b BroadcastRootInvalidator) InvalidateRoot()
type Choice ¶ added in v0.2.0
Choice is a destination the redirect path settled on.
ID is the destinations row the click is attributed to, and the zero uuid means the link's own destination. A struct rather than two returns, because "which URL" and "which row" must not be able to disagree — every place that decides one decides the other in the same statement.
type HostCache ¶ added in v0.2.0
type HostCache struct {
// OnReload runs after every successful load, including the first.
//
// One caller: the link service's id-to-hostname map, which builds short
// URLs and would otherwise keep printing a renamed domain's old name until
// the process restarted. It hangs off this rather than off a second
// subscription because the two are the same event — the set of hostnames
// changed — and two listeners for one event is how they come to disagree
// about whether it happened.
OnReload func()
// contains filtered or unexported fields
}
HostCache holds every verified hostname this instance serves.
func NewHostCache ¶ added in v0.2.0
func (*HostCache) Lookup ¶ added in v0.2.0
func (c *HostCache) Lookup(host string) (VerifiedDomain, bool)
Lookup answers whether this Host header names a verified custom domain.
This is on the redirect path and it is a read lock plus a map lookup. It never queries, never falls back, and never consults Redis.
Keyed through config.HostOnly rather than through a local copy of the same operations, and that is not tidiness — it is the same rule this comment always stated, applied to the right function. A second *spelling* here would mean a Host header that matches one router and not the other, and the direction that fails silently is the dangerous one: a name normalized differently stops being served while every page goes on saying it is verified.
**It was config.CanonicalHost until F88, and that was the narrower question.** CanonicalHost keeps a non-default port because SplitHosts compares two configured origins through it, so `Host: go.customer.example:8080` did not match a `domains.hostname` that is stored, validated and served bare — it fell through to the tree behind this one, which on a single-host deployment is the dashboard, the API and the default domain's aliases. HostOnly is the same normalization with the port dropped as well, which is the only spelling that answers "is this a verified hostname".
The collision that widening could create is worth naming and is not new: a verified hostname equal to one of this instance's own is served by this router before either of them, at any port spelling rather than only at the configured one. Reaching it still means publishing a DNS TXT record under the operator's own name, which is the whole of M40's verification, so the precondition is unchanged and only the set of Host spellings it covers is.
func (*HostCache) MarkTLSActive ¶ added in v0.2.0
MarkTLSActive records locally that the on-demand ask has been answered, so a second handshake does not repeat the write. Storage is the caller's.
func (*HostCache) Refresh ¶ added in v0.2.0
Refresh reloads in the background, collapsing a burst into one query.
Used by the subscriber, which must not block its read loop on Postgres: a reload that stalled would stop the replica hearing every *other* invalidation as well, which is a much larger fault than the one being applied.
type Options ¶
type Options struct {
TTL time.Duration
NegativeTTL time.Duration
// RedisTimeout is how long the hot path waits for the cache before giving
// up and going to Postgres. Short by design: a stalled Redis should cost a
// few milliseconds, not the request.
RedisTimeout time.Duration
// InvalidateBudget bounds a whole invalidation — every attempt and every
// pause between them — rather than each attempt separately. A per-attempt
// budget multiplies: three attempts at RedisTimeout each meant an operator
// raising RedisTimeout raised the worst case on their own form submission
// by three times as much (M26.6, D26).
InvalidateBudget time.Duration
// DBTimeout bounds the Postgres fallback. Zero leaves it bounded only by the
// request context, which for a redirect is no bound worth having: the target
// is 100ms uncached, and a query still running after a second is not going to
// produce a useful answer — it is going to hold a connection from the small
// redirect pool while more requests queue behind it.
DBTimeout time.Duration
MemCacheSize int
Logger *slog.Logger
}
type Outcome ¶
type Outcome int
Outcome is what the handler should do with a snapshot.
const ( // OutcomeRedirect sends the visitor onward. OutcomeRedirect Outcome = iota // OutcomeNotFound covers unknown, archived and disabled links. They are // deliberately indistinguishable: telling a scanner that an alias exists // but is archived is information it has no use for. OutcomeNotFound // OutcomeGone is an expired link. Distinct from not-found because the // alias really did exist, and 410 tells crawlers to stop asking. OutcomeGone )
type Resolver ¶
type Resolver struct {
// contains filtered or unexported fields
}
Resolver turns (domain, alias) into a Snapshot.
func NewResolver ¶
func (*Resolver) InvalidateAlias ¶
InvalidateAlias drops a cached entry. Implements link.Invalidator.
Called on create as well as update and delete. Create matters because of negative caching: an alias somebody probed before it existed would otherwise keep returning 404 for the whole negative TTL, and the link would look broken the moment it was made.
func (*Resolver) InvalidateDomain ¶ added in v0.2.0
InvalidateDomain drops every cached entry on a domain. Implements link.Invalidator.
This is the expensive invalidation and there is no cheap version of it. A snapshot carries the domain's bot policy so that a cache hit can answer without a second lookup (see fromDatabase), and the price of that is paid here: changing the policy changes the answer for every alias underneath, and every cached copy of every one of them is now wrong.
Same order as InvalidateAlias, for the same reasons. Memory first so this replica is correct immediately — it is the one the operator is about to reload. Then Redis, waited on, because an entry left there is authoritative and refills the memory tier this call just cleared. Then the broadcast, so the other replicas clear their own memory tiers after the shared copy is already gone.
func (*Resolver) PublishHostInvalidation ¶ added in v0.2.0
PublishHostInvalidation tells every replica that the verified set has changed.
Exported for the same reason PublishRootInvalidation is: the set lives beside the link service, and the Redis connection lives here.
func (*Resolver) PublishRootInvalidation ¶ added in v0.2.0
PublishRootInvalidation tells every replica to drop its cached root redirect.
Exported because the root cache lives in the HTTP layer, above this package, while the Redis connection lives here. The local drop is the caller's job; this is only the broadcast.
func (*Resolver) Resolve ¶
Resolve returns the snapshot for an alias, consulting memory, then Redis, then Postgres.
func (*Resolver) ResolveCached ¶
ResolveCached answers only from the in-process cache. It never touches Redis or Postgres, and never populates anything.
This exists for one caller: the redirect handler serving a request that the 404-probe limit has throttled. Refusing such a request outright would mean an address that tripped the limit could no longer follow a working link — and with a proxy misconfigured so that every visitor shares one address, that is the whole site. Serving from memory keeps live links working at the cost of a single map lookup, while an alias nobody is using still cannot be turned into a database query. It is the cheapest operation in the package, which is what makes it safe to offer to a client being throttled.
type Result ¶
Result is a resolved alias plus how it was resolved. The source drives the cache-hit-ratio metric, which is the leading indicator for the latency SLO: a falling ratio predicts an SLO breach before p99 moves.
type RootCache ¶ added in v0.2.0
type RootCache interface {
InvalidateRoot()
}
RootCache is the in-process root-redirect cache, as the subscriber sees it.
An interface declared here rather than an import because the implementation is in the HTTP layer, which imports this package. Nil is valid and means this process does not serve the link host's root.
type Snapshot ¶
type Snapshot struct {
LinkID uuid.UUID `json:"i"`
WorkspaceID uuid.UUID `json:"w"`
URL string `json:"u"`
Status string `json:"s"`
ExpiresAt *time.Time `json:"e,omitempty"`
ForwardQuery bool `json:"q,omitempty"`
// The gates (M35). Three of these four fields have been in the struct since
// Phase 1 for exactly this milestone — adding them later would have meant a
// cache-key bump of their own — and M35 is where something finally reads
// them.
//
// **HasPassword is a boolean and never the hash.** That is the one property
// of this struct worth stating as a rule rather than as a comment on a
// field: this value is serialized into Redis on every cache write, so
// carrying the argon2id hash would put an offline cracking target for every
// password link on the instance into an optional dependency that is
// routinely dumped, replicated and inspected. The hash is read from Postgres
// on the submit path only — see internal/gate — and
// TestCachedSnapshotCarriesNoPasswordHash asserts the payload.
//
// MaxClicks and OneTime say what the *limit* is, never how much of it is
// left. The remaining budget is a durable Postgres counter, because a cached
// count that vanishes with Redis re-opens every spent link at once.
//
// RequireSignature is M35's own addition, and it rides M34's v2 bump rather
// than asking for one of its own: it ships in the same release, so no
// instance can ever hold a v2 entry written without it. Its absence would
// decode as false — "this link needs no signature" — which is the direction
// that would matter if it ever were stale, so the shared bump is doing real
// work rather than being borrowed for convenience.
HasPassword bool `json:"p,omitempty"`
MaxClicks *int64 `json:"m,omitempty"`
OneTime bool `json:"o,omitempty"`
RequireSignature bool `json:"sg,omitempty"`
// Routing rules (M34) and split testing (M36), and the two milestones that
// bumped CacheKeyVersion — to v2 and then to v3.
//
// Two fields, because the same destination is routinely the target of more
// than one rule and because "the destination list" is a thing in its own
// right. Destinations holds the rule targets and the split arms,
// deduplicated; the link's own destination is URL above and is where a
// request that neither matched a rule nor found an arm goes. Rules index
// into it.
//
// **The slice order is the evaluation order.** Nothing here carries a
// priority number, because the query that built this list already applied
// it: match rules come back ordered by (priority, created_at), split arms by
// position, disabled rules filtered out, and first match short-circuits.
// Storing the priority as well would be a second copy of the ordering that a
// re-sort could disagree with — and the only correct thing to do with it here
// would be to sort by it again.
//
// Both are omitempty, so a link with no rules — the default, and the
// overwhelming majority — carries exactly the payload it carried before
// either milestone existed.
Destinations []SnapshotDest `json:"d,omitempty"`
Rules []SnapshotRule `json:"r,omitempty"`
// Deep-link path forwarding (M33). Added without bumping CacheKeyVersion,
// and the reason is narrower than the one written below for bot blocking.
//
// It is not that nobody could have set the column yet. A rolling restart
// runs migrations at boot and then serves from old and new containers at
// once, so an old binary goes on writing entries without this field while
// the feature is already switched on somewhere — that is F41, recorded
// against the paragraph below, and it is a real sequence rather than a
// hypothetical one.
//
// What makes the omitted bump safe here is which way the zero value falls.
// An absent `fp` decodes as false, false means *do not forward*, and that is
// exactly what this alias did before the milestone existed. A visitor whose
// deep link lands on a stale entry gets the 404 they would have got
// yesterday, for at most REDIRECT_TTL, and the next fetch fixes it. The
// failure is a feature not yet working, not a control not being applied —
// so there is nothing here a cold cache would buy. A field whose absence
// meant "forward" would have needed the bump, because then the stale
// reading would send somebody somewhere the owner never configured.
//
// This held only while the cache key was v1 for this build and the previous
// one. M34 has now bumped it to v2, which is what that ordering claimed
// would happen; the reasoning above is what carried the field safely across
// the one release where it was not yet true.
ForwardPath bool `json:"fp,omitempty"`
// Bot blocking (M32.5). Both halves of the precedence rule travel together,
// because the whole point is that a cache hit answers the question without
// asking anything: a link's setting alone cannot decide, and fetching the
// domain's separately would be the round trip this design exists to avoid.
//
// Adding them did NOT bump CacheKeyVersion, and that is a claim worth being
// explicit about. Both are omitempty, so an entry written by the previous
// build decodes with the zero values — inherit and off — which is exactly
// "no blocking".
//
// **The reason originally given for that being safe was wrong** (F41). It
// read: on any instance holding such an entry the columns did not exist a
// moment ago, so nobody can have switched blocking on yet. That assumes the
// old build stops before the new one starts. Migrations run at boot before
// the listener opens (docs/releasing.md), so a rolling restart has both
// builds serving at once: the new one switches blocking on, the old one goes
// on writing bot-less entries under the same key, and a bot is answered 302
// for up to REDIRECT_TTL. Reproduced at the Redis layer — three 302s to a
// bot against a link whose row said block_bots. SetBotBlocking's
// unconditional InvalidateDomain sweeps entries written *before* blocking is
// switched on, which is why only the concurrent-write case survives.
//
// **What actually makes it safe is arithmetic, not reasoning.** 0.1.0 is the
// only release that exists and it keys on v1; this build keys on v3, so no
// entry any released build ever wrote is one this build can read. M32.5, M34
// and M36 all land inside the same unreleased minor. The residue is empty
// because there is nowhere for it to be, exactly as F132's was.
//
// **The rule for next time, which is what this comment is really for.** A
// new omitempty field may skip the bump only when the zero value means the
// same thing to a visitor as the true value would — not when nobody has had
// time to configure it yet, because two builds serving at once is a state a
// rolling restart produces on purpose. If the stale reading is a control the
// owner configured being silently absent, bump it and pay for the cold
// cache; that is the argument v2 and v3 were both bumped on.
BotPolicy domain.BotPolicy `json:"bb,omitempty"`
DomainBotPolicy domain.DomainBotPolicy `json:"db,omitempty"`
// NotFound marks a negative cache entry. Storing misses matters: an
// unknown alias is the single most common request a public shortener
// receives, mostly from scanners, and without this every one of them is a
// database query.
NotFound bool `json:"n,omitempty"`
}
Snapshot is everything the redirect handler needs, in one cacheable value.
Deliberately not the full link row. It carries only what a decision depends on, so the cached payload stays small and a schema change to columns the hot path ignores does not invalidate the cache.
The Phase 2 fields are present now because they change what must be cached, not what is currently enforced: adding them later would mean a cache-key version bump and a cold cache on upgrade.
func (*Snapshot) CacheTTL ¶
CacheTTL returns how long this snapshot may be cached.
Clamped to the expiry: caching a link for 24h when it expires in 5 minutes would keep serving it for hours after it should have stopped. This is the kind of bug that only shows up in production, on the one link that mattered.
func (*Snapshot) Decide ¶
Decide reports what to do with a snapshot at a given time.
Expiry is evaluated here rather than filtered in SQL so that an expired link yields 410 rather than 404, and so the decision is identical whether the snapshot came from cache or from the database.
func (*Snapshot) Fallback ¶ added in v0.2.0
Fallback is where this link sends anybody no rule and no arm claimed (M36).
The first enabled fallback rule wins if a payload somehow carries two; the service permits only one, so this is the same "survive a payload we did not write" rule the rest of this file follows.
func (*Snapshot) Gated ¶ added in v0.2.0
Gated reports whether this link has anything in front of its destination (M35).
One call, on a value the resolver already produced, and it is false for every link on a default instance. It is what keeps the gate machinery — a Postgres read for a signature key, an argon2 verification, a durable counter write — out of the path of a link that asked for none of it.
func (*Snapshot) Route ¶ added in v0.2.0
func (s *Snapshot) Route(subject domain.RuleSubject) (Choice, bool)
Route returns the destination this request should be sent to by a *match* rule, and reports whether one decided it.
The whole of first-match evaluation, and it is short because everything that makes it correct happened earlier: the query ordered the rules and dropped the disabled ones, the resolver put them in the snapshot, and domain.Match decides one rule against one request without reading anything.
A snapshot with no rules — the default state of every link on a default instance — returns on the length check without touching the subject, which is what makes "links without rules resolve through the unchanged fast path" structural rather than a promise. Split arms are skipped here rather than filtered out beforehand, because they share the slice with the match rules and walking past them costs a string comparison on a list bounded by domain.MaxRulesPerLink.
func (*Snapshot) RuleNeeds ¶ added in v0.2.0
RuleNeeds summarizes which lookups this link's rules can ask for, so the caller resolves a city only for a link that mentions one.
Split arms are skipped: their condition set is empty by construction — a variant is chosen, never matched — and including it would be asking domain.NeedsOf about a struct that is always zero.
func (*Snapshot) Split ¶ added in v0.2.0
Split is this link's split test: the enabled arms, in rotation order, and their kind (M36).
Returns an empty kind for every link that has none, which is every link on a default instance — and returns it after a walk over a slice that is nil for such a link, so the loop below never starts.
Disabled arms are already absent: the resolver's query filters on `enabled`, which is what makes the `enabled` toggle a feature flag rather than a field somebody has to remember to check. An arm switched off stops receiving traffic on the next resolve, and the remaining arms re-share it.
func (*Snapshot) Weights ¶ added in v0.2.0
Weights are the arms' weights, in the same order Split returns them.
Separate from Split because a sequential rotation must not pay for a slice it will not read.
A nested scan rather than a map, and that is the right shape at this size: both slices are bounded by domain.MaxRulesPerLink, so the worst case is a few hundred integer comparisons against one map allocation and one hash per arm. This runs on the redirect path for every request to a weighted link, and an allocation there costs more than the comparisons it saves.
type SnapshotDest ¶ added in v0.2.0
type SnapshotDest struct {
ID uuid.UUID `json:"i"`
URL string `json:"u"`
// Weight is meaningful only for a weighted arm. Zero for a match rule's
// target, where nothing reads it.
Weight int32 `json:"w,omitempty"`
}
SnapshotDest is one destination the redirect path may send somebody to.
The id is here so a click can be attributed to it — click_events.destination_id (M36) — and the weight so a weighted arm's share can be computed without asking the database anything. Both are new in v3; before it this was a bare string.
The link's own destination is deliberately **not** in this list. It is Snapshot.URL, and a click that goes there records a NULL destination_id, which the breakdown reads as "the link's own destination". Carrying its id as well would put a uuid in the payload of every link on the instance to say something the link already says.
type SnapshotRule ¶ added in v0.2.0
type SnapshotRule struct {
Dest int `json:"d"`
Cond domain.RuleConditions `json:"c"`
Kind string `json:"k,omitempty"`
}
SnapshotRule is one rule as the redirect path evaluates it.
Dest is an index into Snapshot.Destinations rather than a URL, so two rules pointing at the same place cost one copy of the string. Out-of-range is treated as "no destination" by Route rather than as a panic: the hot path must survive a payload it did not write, and a rule that cannot be honoured falling through to the link's own destination is a survivable answer where a panic on a redirect is not.
Kind is absent for a match rule and omitempty, so M34's rules encode exactly as they did — the empty string is `match`, which is what KindOf returns for one. That keeps the payload of a link with only match rules the size it was, while a split arm pays four bytes to say what it is.
func (SnapshotRule) KindOf ¶ added in v0.2.0
func (r SnapshotRule) KindOf() string
KindOf is the rule's kind, with the empty string read as `match`.
type Subscriber ¶ added in v0.2.0
type Subscriber struct {
// Redis is the connection to subscribe on. Nil disables the subscriber
// entirely, which is the cache-disabled and Redis-absent deployment: every
// replica then falls back to TTL staleness, exactly as before this existed.
Redis *goredis.Client
// Resolver owns the alias tier this clears.
Resolver *Resolver
// Root is the root-redirect cache. Nil on a single-host deployment, where
// the link root belongs to the dashboard and there is nothing to cache.
Root RootCache
// Hosts is the verified-hostname set (M40). Nil where no custom domain can
// be served, and then a kindHost message is a no-op rather than an error.
Hosts *HostCache
Log *slog.Logger
// ReconnectBackoff bounds how fast a subscriber retries a dead connection.
// Zero uses defaultReconnectBackoff.
ReconnectBackoff time.Duration
// ReadTimeout bounds how long the subscriber will sit in one read before it
// makes Redis prove the subscription is still delivering. Zero uses
// defaultReadTimeout. Set from REDIS_SUBSCRIBER_READ_TIMEOUT.
ReadTimeout time.Duration
}
Subscriber applies invalidations published by other replicas.
It runs in its own goroutine and never touches the request path: a redirect reads the in-process tier, and this only ever deletes from it. That is what keeps the cached p99 unaffected by however much invalidation traffic there is.
func (*Subscriber) Run ¶ added in v0.2.0
func (s *Subscriber) Run(ctx context.Context)
Run subscribes and applies invalidations until the context is cancelled.
The shape of this loop is the milestone's whole risk, and it has two failure modes rather than one. go-redis returns a read *error* to the caller and re-establishes the connection underneath, which means a dropped subscriber is observable exactly once — on the failing read — and silent afterwards. A loop that simply retried would resubscribe successfully and carry on serving entries whose invalidations were published into the gap, with nothing reporting a problem.
The other mode has no error at all. A Redis that holds the connection open and stops answering produces silence, and silence is also what a channel nobody has published on looks like. Reading with no deadline never has to separate them, and never does: F30 measured `ReceiveMessage` blocked for 40s while Redis reported delivering an invalidation to this subscriber. So the read is bounded, and a read that expires is not treated as either outcome until the connection has been asked a question it must answer — see probe.
Every establishment, including the first, flushes both in-process tiers (decision D20). Redis pub/sub does not replay and the process cannot know which keys it missed, so the only sound answer is to trust none of them.
type VerifiedDomain ¶ added in v0.2.0
type VerifiedDomain struct {
ID uuid.UUID
// Hostname is already lowered, matching config.CanonicalHost's spelling.
Hostname string
// RootRedirectURL is where this hostname's own root sends a visitor. Empty
// means 404, which is the default and the state that says nothing about the
// instance.
RootRedirectURL string
// SSLStatus is what this instance last recorded about the certificate. The
// app never speaks ACME (decision D3), so this says whether the on-demand
// ask has been answered for the name, and nothing about the certificate
// itself — which is Caddy's.
SSLStatus string
}
VerifiedDomain is one hostname the router may serve, as the hot path needs it.