Feature: Circuit breakers for the Redis cache and SPARQL endpoint (Ship 2) - #196
Merged
Merged
Conversation
… (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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_readandprotect_best_effortare 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
#querytoo. 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.CircuitOpenError; best-effort operations (cache writes, invalidation, log writes) degrade to a no-op and can never fail the query they accompany.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).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.Two defects found while reviewing this branch are fixed here (
f26ab50):invalidate_with_backoffswallowed 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.CircuitOpenErrorso 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:
Goo::SPARQL::Resilience::CircuitOpenErrorto a 503. There is no handler today, so an open breaker would surface as a 500 rather than shedding load cleanly.on_state_changehook 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
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.