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 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) 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 Snapshot
- type Source
Constants ¶
const CacheKeyVersion = "v1"
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.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
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
// 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) 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 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"`
HasPassword bool `json:"p,omitempty"` // PHASE 2
MaxClicks *int64 `json:"m,omitempty"` // PHASE 2
OneTime bool `json:"o,omitempty"` // PHASE 2
// 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.