Skip to content

feat(graphile-cache,server): heap-safe instance cache, memory governor, build admission control (2/6)#1331

Open
yyyyaaa wants to merge 1 commit into
feat/scale-s1-plugin-fixesfrom
feat/scale-s2-cache-hardening
Open

feat(graphile-cache,server): heap-safe instance cache, memory governor, build admission control (2/6)#1331
yyyyaaa wants to merge 1 commit into
feat/scale-s1-plugin-fixesfrom
feat/scale-s2-cache-hardening

Conversation

@yyyyaaa

@yyyyaaa yyyyaaa commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 2/6 of the #1325 split. Makes PostGraphile instance residency heap-aware and turns every memory degradation mode into a 503 + Retry-After instead of a process abort. No pooling here — this hardens the existing per-service cache and is independently shippable; it's the "server stays alive" layer. Every mechanism traces to a specific measured crash in the validation program (scripts/scale-validate/VALIDATION-REPORT.md, lands with PR 6).

Why: on a production-shaped catalog (61k pg_class rows), one cached instance retains up to ~1.3GB. The previous cache admitted a fixed 50 entries with a 1-year TTL and disposed entries that still had requests in flight — on catalogs this size that configuration is the OOM, not a cache.

graphile-cache

  • Heap-aware capacity replaces max=50: R = clamp(min(⌊(H−B)/I⌋, ⌊(H−B−T)/I⌋+1), 1, 50) with B=256MB server baseline, T=768MB build transient, I = per-instance budget (GRAPHILE_CACHE_INSTANCE_HEAP_BYTES).
  • In-flight refcounting + eviction drain (invokeEntryHandler): disposal waits (bounded) for the entry's live requests instead of closing pools under them.
  • Memory governor: background eviction at 85% pressure, build refusal at 92%. Pressure is used / (used + total_available_size) — the earlier heap_size_limit denominator was unreachable in practice (measured death at ratio 0.69 on a 512MB heap, long before a 0.92 trigger could fire).
  • Entries carry their dbname so pool disposal evicts exactly the entries it strands.

graphql/server

  • Process-wide build semaphore (GRAPHILE_BUILD_CONCURRENCY, default 1): the single-flight map only dedupes builds of the same key, so N cold-starting tenants used to stack N several-hundred-MB build transients. Plus evict-before-build, drain-wait, and a late pressure re-check with elevated-pressure spacing (GRAPHILE_BUILD_PRESSURE_SPACING_MS) so build starts can't outrun GC.
  • Request build-wait timeout (GRAPHILE_BUILD_TIMEOUT_MS, default 180s) via raceBuildAgainstTimeout, which always clears its timer. This is load-bearing: an armed Promise.race timeout pins the freshly built instance through the timer's reaction chain for the full 180s. Under eviction churn that stacked ~2 builds/s × retained instance × 180s and OOM'd the process — root-caused by heap-snapshot retainer chains, and .unref() demonstrably does not help (it removes event-loop keepalive, not GC reachability).
  • Build settles own the cache: the single-flight slot is cleared when the build settles, not when the first requester stops waiting — timed-out waiters leave coalescing intact for followers.
  • Uniform 503 taxonomy: SERVICE_OVERLOADED (pressure refusal — at the request gate, the build owner path, and re-coalesced waiters alike), BUILD_TIMEOUT (wait expired; build continues in background and fills the cache for retries), SERVICE_ROTATING (mid-disposal hit). Never a generic 500 for a governed condition.
  • Connection-class error guard: a PostgreSQL restart degrades requests instead of killing the server process.
  • Observability: /metrics endpoint (GRAPHILE_METRICS_ENDPOINT=1, loopback-gated), JSON-line sampler (GRAPHILE_DEBUG_METRICS) with GC pause tracking and build/cache counters. GraphiQL (ruru) assets are now dev-only (GRAPHILE_GRAPHIQL=true to force) — they were per-instance overhead and an unnecessary prod surface.

Validation (from #1325)

  • Eviction-churn storm at ~23 builds/s for 240s+: bounded heap sawtooth, zero OOM (pre-fix: 2GB heap dead in 65s).
  • Capacity rotation 1444/1444 OK; 47-tenant 30min soak headline in PR 5.

Testing

graphile-cache 24/24 (counters, disposal/cap, governor) · graphql/server 119/119 including new build-timeout (timer-count-zero assertions) and graphile-recoalesce (503 contract, proven 500 pre-fix) suites, plus all pre-existing middleware suites.

Stack

Stacked on #1330 (feat/scale-s1-plugin-fixes); merge after it. Full stack map in #1330. Extracted from #1325 (kept open as the validated reference).

🤖 Generated with Claude Code

https://claude.ai/code/session_0122xqM2VkNbuAmZshK1YNSb

…l, memory governor, build admission control

Each cached PostGraphile instance retains up to ~1.3GB on production-sized
catalogs (61k pg_class rows), while the previous cache admitted a fixed 50
entries with a 1-year TTL and disposed entries that still had requests in
flight. This PR makes residency heap-aware and makes every degradation mode a
503 + Retry-After instead of a process abort. Every mechanism below is
traceable to a specific measured crash in the #1325 validation program
(scripts/scale-validate/VALIDATION-REPORT.md there).

graphile-cache:
- Heap-aware capacity: R = clamp(min(⌊(H−B)/I⌋, ⌊(H−B−T)/I⌋+1), 1, 50) with
  B=256MB server baseline, T=768MB build transient, I=instance budget
  (GRAPHILE_CACHE_INSTANCE_HEAP_BYTES). Replaces the fixed max=50.
- In-flight refcounting (invokeEntryHandler) + eviction DRAIN: disposal waits
  for the entry's live requests before teardown (bounded), instead of closing
  pools under them.
- Memory governor: background eviction at 85% pressure, build refusal at 92%.
  Pressure = used/(used + total_available_size) — reachable, unlike a
  heap_size_limit denominator (measured death at ratio 0.69@512MB before the
  92% trigger could ever fire).
- Per-database entry indexing (dbname on entries) so pool disposal evicts
  exactly the entries it strands.

graphql/server:
- Process-wide build semaphore (GRAPHILE_BUILD_CONCURRENCY, default 1): only
  same-key builds were deduped before, so N tenants cold-starting stacked N
  transient build peaks. Evict-before-build (ensureCacheHeadroom) + drain-wait
  + a late pressure re-check with elevated-pressure spacing
  (GRAPHILE_BUILD_PRESSURE_SPACING_MS) bound the allocation rate.
- Request build-wait timeout (GRAPHILE_BUILD_TIMEOUT_MS, default 180s) via
  raceBuildAgainstTimeout, which ALWAYS clears its timer: an armed race timer
  pins the built instance through the reaction chain for the full timeout —
  heap-snapshot-proven OOM under eviction churn (~2 builds/s × retained
  instance × 180s). .unref() does not help; only clearTimeout releases it.
- Build settle owns cache population (single-flight map cleared when the BUILD
  settles, not when the first requester stops waiting), so timed-out waiters
  leave coalescing intact.
- BuildRefusedError → uniform 503 SERVICE_OVERLOADED + Retry-After at all
  three surfaces (request gate, owner path, re-coalesced waiters);
  mid-disposal cache hits → 503 SERVICE_ROTATING instead of invoking a dying
  handler.
- Connection-class error guard: a PG restart/crash must degrade requests, not
  kill the server process.
- Observability: /metrics endpoint (GRAPHILE_METRICS_ENDPOINT=1, loopback
  gated), JSON-line metrics sampler (GRAPHILE_DEBUG_METRICS), GC pause
  tracking, build/cache counters. GraphiQL assets now dev-only
  (GRAPHILE_GRAPHIQL=true to force).

Validated in #1325: 47 tenants on one 2048MB node, 30min authenticated zipf
@50rps — 104,818/104,818 OK, p99 21ms, heap peak 195.7MB (9.6%), and a
240s+ eviction-churn storm at ~23 builds/s holding a bounded heap sawtooth.

Extracted from #1325 (commits 9f78b69, 9ea25e3, 6745ea2, 620b011,
4659d52, b468b15, 46636d4, 2eeaefb, 3088062).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0122xqM2VkNbuAmZshK1YNSb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant