redirect

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 31, 2026 License: MIT Imports: 14 Imported by: 0

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

View Source
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 NewResolver(pool *pgxpool.Pool, rdb *goredis.Client, opts Options) *Resolver

func (*Resolver) CacheSize

func (r *Resolver) CacheSize() int

CacheSize reports in-process entries, for metrics.

func (*Resolver) InvalidateAlias

func (r *Resolver) InvalidateAlias(ctx context.Context, domainID uuid.UUID, alias string)

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

func (r *Resolver) Resolve(ctx context.Context, domainID uuid.UUID, alias string) (Result, error)

Resolve returns the snapshot for an alias, consulting memory, then Redis, then Postgres.

func (*Resolver) ResolveCached

func (r *Resolver) ResolveCached(domainID uuid.UUID, alias string) (Result, bool)

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

type Result struct {
	Snapshot *Snapshot
	Source   Source
}

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

func (s *Snapshot) CacheTTL(now time.Time, base, negative time.Duration) time.Duration

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

func (s *Snapshot) Decide(now time.Time) Outcome

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.

type Source

type Source string
const (
	SourceMemory   Source = "memory"
	SourceRedis    Source = "redis"
	SourceDatabase Source = "database"
	SourceNegative Source = "negative"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL