Skip to content

PATTERN Cited by 1 source

Context-threaded SQL tag propagation

Pattern: thread per-request SQLCommenter tags through the application stack via the language's explicit per-request storage primitive — in Go, context.Context — and render them to SQL at the database-wrapper layer. The framework-less counterpart to ORM-layer tag propagation for languages without implicit request-local storage.

Shape (Go)

Three components compose:

(1) Two context helpers — typed key + copy-on-read reader + immutable writer:

type contextKey string
const sqlTagsKey contextKey = "sql_tags"

func tagsFromContext(ctx context.Context) map[string]string {
    if tags, ok := ctx.Value(sqlTagsKey).(map[string]string); ok {
        out := make(map[string]string, len(tags))
        for k, v := range tags { out[k] = v }
        return out
    }
    return make(map[string]string)
}
func contextWithTags(ctx context.Context, tags map[string]string) context.Context {
    return context.WithValue(ctx, sqlTagsKey, tags)
}

(2) One SQL-rendering helper — deterministic SQLCommenter-format append:

func appendTags(query string, tags map[string]string) string {
    if len(tags) == 0 { return query }
    parts := make([]string, 0, len(tags))
    for k, v := range tags {
        parts = append(parts, fmt.Sprintf("%s='%s'", k, url.QueryEscape(v)))
    }
    sort.Strings(parts)
    return query + " /*" + strings.Join(parts, ",") + "*/"
}

(3) Wrapper QueryContext / ExecContext on the database handle that renders on every call:

func (db *DB) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
    return db.sql.QueryContext(ctx, appendTags(query, tagsFromContext(ctx)), args...)
}

Every enrichment layer (HTTP middleware, auth middleware, deployment-tag startup, feature-flag evaluation) reads the map from context, mutates its copy, writes it back via contextWithTags.

Why this shape

The pattern gets five properties right simultaneously:

  1. Coverage: every function that accepts ctx participates automatically; the wrapper renders regardless of call-site.
  2. Extensibility: new enrichment layers just add keys to the map; no ORM hook registration.
  3. Thread-safety: copy-on-read makes mutation local; context itself is immutable.
  4. Deterministic SQL: sort.Strings stabilises the emitted comment bytes for plan-cache stability.
  5. No global state: no process-wide registry, no module- level var; everything rides on the request's context.

Composition with middleware layers

Canonical full stack (Source: sources/2026-04-21-planetscale-patterns-for-postgres-traffic-control):

var handler http.Handler = mux
handler = SQLTagMiddleware(handler)       // route tag
handler = AuthMiddleware(users, handler)  // tier tag
// DEPLOYMENT_TAG env var read at startup → feature tag
// dedicated job DB with application_name  → workload tag

Each middleware layer is a small function: read tag map, add its key, write back. Middleware-ordering matters (the outer middleware's tags must be in context before the inner middleware reads), but the composition primitive is trivial.

Comparison with ORM-layer pattern

Dimension ORM-layer Context-threaded (Go)
Request-local storage Thread-local / ORM hook context.Context
Render point ORM query formatter Wrapper QueryContext
Coverage All ORM-issued queries All ctx-accepting sites
Library queries Captured Captured iff lib takes ctx
Canonical example Rails query_log_tags Go + database/sql wrapper

Same wire format, same observability outcomes; different coverage boundary and different discipline cost.

Generalises beyond Go

  • Rust: request extensions on hyper::Request / axum extractors carry the tag map; a sqlx / tokio-postgres wrapper renders.
  • Python async: contextvars (PEP 567) stores the tag map async-safely; a psycopg3 / asyncpg wrapper reads and renders.
  • Any language with explicit per-request context can instantiate the same three-component shape.

Operational notes

  • Middleware order is significant. Outer middleware runs first; its keys must be present before inner middleware reads. HTTP-handler stacks typically add request-ID → logger → metrics → auth → tagging, in that order.
  • Library functions that drop ctx break the chain. A legacy helper func lookup(db *sql.DB, id int) … that doesn't take ctx bypasses the wrapper and emits untagged SQL. Audit the codebase for ctx-less DB call sites.
  • Per-query overhead is measurable but small. Allocation of the map copy + sort.Strings + string concat runs on every query. For ultra-high-QPS services, memoising the rendered comment per (tag-set-hash) is a valid optimisation; not worth doing for typical services.
  • URL-encoding mismatches can bite. url.QueryEscape encodes / as %2F — some downstream tools expect it unchanged. Verify the log-analysis pipeline handles the encoded form.

Seen in

Last updated · 378 distilled / 1,213 read