Skip to content

Feature: Circuit breakers for the Redis cache and SPARQL endpoint (Ship 2) - #196

Merged
mdorf merged 8 commits into
developmentfrom
feature/sparql-resilience
Aug 5, 2026
Merged

mdorf merged 8 commits into
developmentfrom
feature/sparql-resilience

Conversation

@mdorf

@mdorf mdorf commented Aug 5, 2026

Copy link
Copy Markdown
Member

What this does

Adds circuit breakers around the two dependencies goo hits on the read path, the Redis cache and the SPARQL endpoint, so that a slow or dead dependency fails fast instead of making every worker wait out its timeout. Also tightens the timeouts a breaker depends on and replaces a fixed-interval retry with bounded backoff.

Off by default. Everything here is gated on OP_SPARQL_CIRCUIT_BREAKER; unset, protect_read and protect_best_effort are plain pass-throughs and behavior is identical to today.

Why

The cache is not an optimization, it is load-bearing: a single class-tree request is roughly 1 SPARQL query plus ~2000 Redis reads. That makes a Redis outage a first-class failure mode, and it makes the obvious fix wrong, since falling through to the store turns one request into ~2000 queries and takes the triple store down for every tenant. So an open breaker sheds load rather than passing it along. Details in docs/sparql-defork-review.md §2A and decisions D1/D2/D4.

This is an inherited risk, not a new one: the fork let Redis errors propagate out of #query too. Shipping it separately from the de-fork was deliberate (D13).

What's in it

  • Goo::SPARQL::Resilience (new): Stoplight-backed breakers, per-process and in-memory on purpose, since a Redis-backed store would put breaker coordination inside a dependency the breaker exists to protect (D14). Only infra errors count, so a cache miss or a malformed query never trips anything.
  • Two protection modes. Cache-dependent reads fail fast with CircuitOpenError; best-effort operations (cache writes, invalidation, log writes) degrade to a no-op and can never fail the query they accompany.
  • Separate circuits for the cache Redis, the query-log Redis (which lives on its own instance per D6a), and each SPARQL endpoint, so one dependency's outage cannot shed load for another. There's a test for that isolation.
  • Invalidation retry moves from a fixed sleep(5) x3, up to a 15 second stall on a request thread, to capped exponential backoff with full jitter, sub-second worst case (D4).
  • Timeouts are now env-tunable (GOO_REDIS_TIMEOUT, default dropped from 300s to 5s; GOO_SPARQL_READ_TIMEOUT, unchanged at 10s). A breaker cannot trip faster than the call's own timeout.
  • Failure-injection tests (T-6): 15 tests covering breaker states, both protection modes, circuit isolation, and bounded backoff.

Two defects found while reviewing this branch are fixed here (f26ab50):

  • invalidate_with_backoff swallowed tracked Redis errors, so Stoplight saw every invalidation as a success and the Redis circuit could never open from that path. During a write-heavy outage with no concurrent reads to trip it, every write kept paying the full retry ladder against a Redis already known dead. It now re-raises after logging, and the caller still absorbs it, so the write is unaffected.
  • An invalidation dropped by an already-open breaker was completely silent, neither logged nor reported to the metric hook. That is the case where invalidations are being dropped wholesale, and graphs written during the outage keep serving stale entries after Redis recovers until their next successful write. It now reports through the same hook, carrying a CircuitOpenError so a consumer can tell a skip from a failure.

Before enabling it in production

Merging is safe with the flag off, but two things are needed before turning it on, and they are not in this PR:

  1. ontologies_api must map Goo::SPARQL::Resilience::CircuitOpenError to a 503. There is no handler today, so an open breaker would surface as a 500 rather than shedding load cleanly.
  2. An on_state_change hook should be wired to alerting. A load-bearing dependency tripping is page-worthy; goo only writes a transition line to stderr.

Recorded as D15 in the review doc, which also notes that D13's ordering reversed: this depends on the query logger (Ship 3, #195), so it merges second.

Testing

  • CI green on all four backends (4store, AllegroGraph, Virtuoso, GraphDB).
  • Full local suite against 4store: 268 tests, 3345 assertions, 0 failures.
  • The two fixes above are covered by new assertions in test/test_resilience.rb, verified to fail before the fix.

Ship 2 of the de-fork. Work by @alexskr; review, fixes, and shipping by @mdorf.

alexskr and others added 8 commits August 1, 2026 01:24
… (de-fork review D1/D2/D4/§7.3)

Resilience layer so a slow/down dependency fails fast instead of melting the request
pool or amplifying one cached-read request into ~2000 store queries (review §2A).
Opt-in via OP_SPARQL_CIRCUIT_BREAKER (default OFF — shipping this changes no runtime
behavior until a deployment enables it).

- Goo::SPARQL::Resilience (new): per-process Stoplight breakers (in-memory store — never
  Redis-backed, since Redis may be the thing that's down). Two wrappers:
    * protect_read  — cache get + SPARQL query/update: OPEN => raise CircuitOpenError
      (fail fast; the web app maps it to 503/504 + alert). [D1/D2]
    * protect_best_effort — cache store/invalidate: never fail the request; skip fast
      when open, swallow infra errors, but let real (non-infra) bugs surface.
  Only infra errors trip a breaker (Redis::BaseConnectionError; SPARQL 5xx + net/http
  transport/timeout) — a cache miss or a MalformedQuery never does. State transitions
  log once (not per-op) and fire Resilience.on_state_change for alerting/metrics.
- Cache: get => protect_read (fail fast), store/invalidate => protect_best_effort.
  Invalidation retry replaced sleep(5)x3 with capped exponential backoff + full jitter
  (sub-second, desynchronized) + on_invalidation_failure metric hook. [D4]
- Client#query/#update: wrap the store round-trip in the per-endpoint SPARQL breaker. [D2]
- Timeouts env-tunable (§7.3): GOO_SPARQL_READ_TIMEOUT (default 10s, unchanged) and
  GOO_REDIS_TIMEOUT (default lowered 300s -> 5s, so a stalled Redis can't tie up a worker
  for minutes and a breaker can register failure fast).

Bulkhead deferred: under unicorn (process-per-worker) there's no in-process concurrency
to cap; the tightened per-op timeout frees a stuck worker. Revisit for Puma threads.

test/test_resilience.rb: 11 tests (breaker open/fail-fast, best-effort swallow/skip,
only-infra-trips, state-change hook, cache get/store/invalidate under a redis outage,
backoff bounds). Suite green fs, breaker OFF and ON: 218 runs, 0 failures.

Follow-up (ontologies_api): rescue Goo::SPARQL::Resilience::CircuitOpenError -> 503/504
+ wire on_state_change to alerting.
QueryLogger wrote to Redis outside every breaker. With logging enabled
and Redis down, each query paid a full connect/read timeout inside
with_redis even though the cache breaker was already open and shedding
-- logging became the slow path it exists to observe.

Route with_redis through protect_best_effort. Its own circuit, not the
cache's: D6a puts the log on a separate Redis instance, so a log outage
must not shed cache reads (which are load-bearing) and vice versa. The
outer rescue stays, so non-infra errors still degrade to file-only
rather than failing a query.

Note for whoever wires alerting: around() lets CircuitOpenError
propagate, so a query rejected by an open SPARQL breaker is not logged.
Open-circuit failures are visible via on_state_change, not the log.
add_log_redis_backend hardcoded timeout: 300 (goo's pre-Ship-2 default)
while the cache Redis moved to the env-tunable redis_timeout (5s, review
section 7.3). A breaker cannot trip faster than the call's own timeout,
so with logging enabled and its Redis stalled, the first failures would
each block a worker for five minutes before the circuit could open.
…breaker

Review finding on Ship 2. Two related defects on the invalidation path:

* invalidate_with_backoff swallowed tracked Redis errors after its final
  attempt, so Stoplight saw every invalidation as a SUCCESS and the Redis
  circuit could never open from this path. During a write-heavy outage with
  no concurrent reads to trip it (cron parsing, for instance), every write
  kept paying the full retry ladder against a Redis already known dead. It
  now re-raises after logging; protect_best_effort still turns that into a
  no-op, so the write is unaffected.

* An invalidation dropped by an OPEN breaker was completely silent:
  protect_best_effort short-circuits before the block runs, so neither the
  warn nor the on_invalidation_failure hook fired. The case where
  invalidations are being dropped wholesale was the quiet one, while
  graphs written during the outage keep serving stale entries after Redis
  recovers until their next successful write. Now reported through the same
  hook carrying a CircuitOpenError so consumers can tell a skip from a
  failure.

The sentinel deliberately records that the block was ENTERED rather than
that it completed: an exhausted invalidation re-raises and has already
reported itself, so treating 'did not complete' as a skip double-reported.

test/test_resilience.rb covers both: two exhausted invalidations trip the
shared circuit and report once each, then the next is skipped without
touching Redis and reported as CircuitOpenError.
D13 ordered resilience as Ship 2 and observability as Ship 3. The branches
now dictate the opposite: Ship 2 puts a breaker inside the query logger's
Redis writes (LOG_REDIS_CIRCUIT), so it depends on Ship 3's code, and
feature/sparql-resilience is built on feature/sparql-observability.

Accepted rather than unpicked: the breaker is opt-in and off by default, so
merging it second delays no protection that is actually switched on, and
observability is measurement-only. D13's rationale is annotated as
superseded on this point only.

D15 also carries forward the prerequisite for D1/D2 to be in force rather
than merely implemented: ontologies_api must map CircuitOpenError to a 503
(today an open breaker would surface as a 500) and set an on_state_change
alert hook.
@mdorf
mdorf merged commit 8a80f1c into development Aug 5, 2026
10 checks passed
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.

2 participants