fix(target-health): keep sweep timestamp first for atomic alignment

targetHealthLastSweepUnixNano moves to Gateway's first field. It is
read and written only through sync/atomic's 64-bit functions, and
the sync/atomic package guarantees 64-bit alignment on ARM, x86-32,
and 32-bit MIPS only for a struct's first word — Traefik ships armv7
and 386 builds alongside amd64/arm64. Every other field keeps
golangci-lint fieldalignment's pointer-grouped ordering; verified
with the standalone fieldalignment analyzer that this is the only
field out of place and adds no struct-size padding, only a documented
+8 GC-scan-prefix cost.

docs: catches up README's admin `health` object and Target health
"State" bullet to F4 (previous commit, a0c8607) — three ways to
read `state: "unknown"`, and that consecutiveFailures/lastCheck/
lastError/latencyMs/source stay present when a target has been
observed but never succeeded.

Claude-Session: https://claude.ai/code/session_01EebV5jyWvYocByNQ3oss1w
This commit is contained in:
2026-09-03 02:47:51 +01:00
parent 10d1825ed7
commit aad01b8d6f
2 changed files with 78 additions and 39 deletions
+26 -8
View File
@@ -1549,9 +1549,11 @@ or in CI.
`health` is `{"state","lastCheck","lastError","consecutiveFailures","latencyMs","source"}` `health` is `{"state","lastCheck","lastError","consecutiveFailures","latencyMs","source"}`
— see [MCP and A2A](#mcp-and-a2a)'s "Target health" section for the — see [MCP and A2A](#mcp-and-a2a)'s "Target health" section for the
full contract. `state` is `"unknown"`, `"healthy"`, or `"unhealthy"`, full contract. `state` is `"unknown"`, `"healthy"`, or `"unhealthy"`,
and `consecutiveFailures` is always present; every other field is and `consecutiveFailures` is always present. Every other field is
omitted while `state` is `"unknown"` (this target has never been omitted only while this target has never once been observed; `state`
observed). This route always reads existing state; when alone does not decide the omission, since `"unknown"` also covers a
target that has been observed but has not yet succeeded, and that case
keeps every field. This route always reads existing state; when
`targetHealth.enabled`, it additionally triggers a background probe `targetHealth.enabled`, it additionally triggers a background probe
sweep when one is due, so the response itself never waits on that sweep when one is due, so the response itself never waits on that
sweep. Polled every 5 seconds, in the same batch as `overview` and sweep. Polled every 5 seconds, in the same batch as `overview` and
@@ -1921,11 +1923,27 @@ plugin's config.
or the default `/.well-known/agent-card.json`); a `404` still or the default `/.well-known/agent-card.json`); a `404` still
counts as reachable — for example an umbrella agent whose root counts as reachable — for example an umbrella agent whose root
carries no card of its own. carries no card of its own.
- **State**: `"unknown"` (never observed), `"healthy"` (recorded at - **State**: `"healthy"` (succeeded at least once,
least once, `consecutiveFailures` below `targetHealth.failureThreshold`), `consecutiveFailures` below `targetHealth.failureThreshold`), or
or `"unhealthy"` (`consecutiveFailures` at or above the threshold). A `"unhealthy"` (`consecutiveFailures` at or above the threshold,
target with one or two recent failures after a success still reads whether or not the target has ever succeeded). A target with one or
`"healthy"` until the threshold is actually crossed. two recent failures after a success still reads `"healthy"` until the
threshold is actually crossed.
- **`"unknown"` reads three ways**, all sharing one JSON `state`
value:
1. never observed at all — no probe or passive traffic has reached
this target yet. `GET /admin/api/targets` omits every health field
but `state`/`consecutiveFailures` for this case.
2. observed, but never once succeeded, and still below
`targetHealth.failureThreshold` — the threshold's protection
against a single transient first failure still applies, so this
does not jump straight to `"unhealthy"`, but a target this tracker
has never seen answer is not `"healthy"` either. Every other
health field is still present here, since there is a real
observation to show.
3. either way, `llmgateway_target_healthy` emits no sample while
`state` is `"unknown"` — a gap in the series is honest, a
fabricated `1` is not.
## Security notes ## Security notes
+52 -31
View File
@@ -586,7 +586,26 @@ const maxExplicitBodyAdmissionCap = 10_000
// convention (admin.go) for the same reasoning spelled out once; this // convention (admin.go) for the same reasoning spelled out once; this
// struct is the other place it applies. // struct is the other place it applies.
type Gateway struct { type Gateway struct {
next http.Handler // targetHealthLastSweepUnixNano MUST stay the first field (F7,
// feat/target-health review). It backs maybeSweepTargetHealth's
// single-flight gate (target_health.go) and is read/written only via
// sync/atomic's 64-bit functions (atomic.LoadInt64/StoreInt64) — the
// sync/atomic package documents that "on ARM, x86-32, and 32-bit
// MIPS, it is the caller's responsibility to arrange for 64-bit
// alignment of 64-bit words accessed atomically," and guarantees
// that alignment only for "the first word in an allocated struct,
// array, or slice." Traefik ships armv7 and 386 builds alongside
// amd64/arm64, so this is not a hypothetical: any field placed
// ahead of this one could silently misalign it on those platforms.
// Every field below is ordered by golangci-lint's fieldalignment
// linter (pointer-containing fields grouped first, for GC scan
// efficiency, then the rest) — this one field is the sole, deliberate
// exception, verified with `fieldalignment ./...` to add no
// additional padding: an 8-byte int64 transitions cleanly into the
// 8-byte-aligned pointer block that follows on every architecture
// this plugin targets.
targetHealthLastSweepUnixNano int64
next http.Handler
// redisClient is the same instance newGateway hands to both the // redisClient is the same instance newGateway hands to both the
// limiter's redisStore and the response cache (its own doc comment, // limiter's redisStore and the response cache (its own doc comment,
// below, explains why it's built once and shared) — kept here too, // below, explains why it's built once and shared) — kept here too,
@@ -617,19 +636,22 @@ type Gateway struct {
adapters map[string]providerAdapter adapters map[string]providerAdapter
cfg *Config cfg *Config
auth *authStore auth *authStore
// provenance is the in-process usage-accounting-provenance accumulator
// (feat: expose token-accounting provenance, metrics.go's
// provenanceStore) — recordUsageProvenance's only write target, and
// writeProvenanceMetrics/buildAdminProvenanceViews' (admin.go) own
// read source. Always constructed, here, by newGateway, even when
// Config.Metrics is nil or disabled, mirroring g.latency's own
// identical "always-present, usually-empty, nil-receiver-safe"
// convention immediately below — see that field's own doc comment for
// the full reasoning, which applies here unchanged.
provenance *provenanceStore
// cache is nil whenever response caching is not configured or not // cache is nil whenever response caching is not configured or not
// usable (cfg.Cache.Enabled is false, or true with no config.Redis — // usable (cfg.Cache.Enabled is false, or true with no config.Redis —
// see buildResponseCache, cache.go). Every call site checks for nil // see buildResponseCache, cache.go). Every call site checks for nil
// before using it, rather than responseCache having its own // before using it, rather than responseCache having its own
// always-disabled zero value. // always-disabled zero value.
cache *responseCache cache *responseCache
// bodyAdmission is the buffered-channel semaphore acquireBodyAdmission
// (routes_unified.go) claims from and releases: security review
// finding 1b, 2026-08-22. Sized once, here, by newGateway (see
// defaultBodyAdmissionCap/Config.MaxInFlightBodyRequests) — never
// resized afterward, matching a Go channel's own fixed-capacity
// contract.
bodyAdmission chan struct{}
// targetClient is the shared, connection-pooled *http.Client the // targetClient is the shared, connection-pooled *http.Client the
// MCP/A2A target proxy (mcp_a2a.go) issues every upstream request // MCP/A2A target proxy (mcp_a2a.go) issues every upstream request
// through — built once via newAdapterHTTPClient, the same constructor // through — built once via newAdapterHTTPClient, the same constructor
@@ -648,17 +670,14 @@ type Gateway struct {
// identical "safe even off a bare &Gateway{} literal" convention // identical "safe even off a bare &Gateway{} literal" convention
// above. // above.
latency *latencyStore latency *latencyStore
// provenance is the in-process usage-accounting-provenance accumulator // bodyAdmission is the buffered-channel semaphore acquireBodyAdmission
// (feat: expose token-accounting provenance, metrics.go's // (routes_unified.go) claims from and releases: security review
// provenanceStore) — recordUsageProvenance's only write target, and // finding 1b, 2026-08-22. Sized once, here, by newGateway (see
// writeProvenanceMetrics/buildAdminProvenanceViews' (admin.go) own // defaultBodyAdmissionCap/Config.MaxInFlightBodyRequests) — never
// read source. Always constructed, here, by newGateway, even when // resized afterward, matching a Go channel's own fixed-capacity
// Config.Metrics is nil or disabled, mirroring g.latency's own // contract.
// identical "always-present, usually-empty, nil-receiver-safe" bodyAdmission chan struct{}
// convention immediately above — see that field's own doc comment for name string
// the full reasoning, which applies here unchanged.
provenance *provenanceStore
name string
// failoverLogGate rate-limits runMeteredCall's generic "failing over" // failoverLogGate rate-limits runMeteredCall's generic "failing over"
// log line (routes_unified.go) to once per storeErrorLogEvery // log line (routes_unified.go) to once per storeErrorLogEvery
// (adversarial-review fix, F10) — reuses auth.go's own logGate type, // (adversarial-review fix, F10) — reuses auth.go's own logGate type,
@@ -672,13 +691,13 @@ type Gateway struct {
// never re-parses a CIDR string per scrape. nil when Metrics is // never re-parses a CIDR string per scrape. nil when Metrics is
// unconfigured or carries no AllowedCIDRs entries. // unconfigured or carries no AllowedCIDRs entries.
metricsNets []*net.IPNet metricsNets []*net.IPNet
// failover is Config.Failover, validated and resolved once by
// newGateway (validateFailoverConfig, failover.go).
failover failoverConfig
// targetHealthCfg is Config.TargetHealth, validated and resolved once // targetHealthCfg is Config.TargetHealth, validated and resolved once
// by validateTargetHealthConfig (target_health.go) — maybeSweepTargetHealth // by validateTargetHealthConfig (target_health.go) — maybeSweepTargetHealth
// reads probeInterval/enabled from this, never the raw Config. // reads probeInterval/enabled from this, never the raw Config.
targetHealthCfg targetHealthConfig targetHealthCfg targetHealthConfig
// failover is Config.Failover, validated and resolved once by
// newGateway (validateFailoverConfig, failover.go).
failover failoverConfig
// targetTimeout is targetClient's own resolved request timeout — // targetTimeout is targetClient's own resolved request timeout —
// Config.RequestTimeout only; an MCP/A2A target has no per-target // Config.RequestTimeout only; an MCP/A2A target has no per-target
// override the way a provider does. proxyUpstream's own callers // override the way a provider does. proxyUpstream's own callers
@@ -686,15 +705,17 @@ type Gateway struct {
// idle-progress body watchdog (timeout.go) matches the // idle-progress body watchdog (timeout.go) matches the
// ResponseHeaderTimeout already set on targetClient's Transport. // ResponseHeaderTimeout already set on targetClient's Transport.
targetTimeout time.Duration targetTimeout time.Duration
// targetHealthLastSweepUnixNano/targetHealthSweeping back // targetHealthSweeping is maybeSweepTargetHealth's own single-flight
// maybeSweepTargetHealth's own single-flight, at-most-one-sweep-per- // CAS flag (target_health.go) — its sibling, the UnixNano timestamp
// interval gate (target_health.go): a UnixNano timestamp and a CAS // this same gate reads, is targetHealthLastSweepUnixNano, kept as
// flag, both atomic rather than mutex-guarded, since every caller // this struct's FIRST field instead (see that field's own doc
// (serveMetrics, serveAdminTargets, handleMCPServers, handleAgents) // comment, top of this struct, for why). Both are atomic rather than
// reaches maybeSweepTargetHealth on a live request path and must // mutex-guarded, since every caller (serveMetrics, serveAdminTargets,
// never block behind a lock another such call already holds. // handleMCPServers, handleAgents) reaches maybeSweepTargetHealth on a
targetHealthLastSweepUnixNano int64 // live request path and must never block behind a lock another such
targetHealthSweeping int32 // call already holds. A plain int32 needs no special alignment
// treatment the way its int64 sibling does.
targetHealthSweeping int32
} }
// telemetryStartupOnce keeps the anonymous "plugin loaded" ping to one per // telemetryStartupOnce keeps the anonymous "plugin loaded" ping to one per