Skip to content

Feature: SPARQL observability — query logger, cache hit-rate, query counter (Ship 3) - #195

Merged
mdorf merged 12 commits into
developmentfrom
feature/sparql-observability
Aug 5, 2026
Merged

mdorf merged 12 commits into
developmentfrom
feature/sparql-observability

Conversation

@alexskr

@alexskr alexskr commented Aug 1, 2026

Copy link
Copy Markdown
Member

What this does

Phase 2 of the sparql-client de-fork. Ship 1 (#194) moved the NCBO customizations into goo; this adds the observability that was deliberately left out of that parity-pure port: a reworked SPARQL query logger, a cache hit-rate, a store-bound query counter, and per-test query-count reporting for the test suite.

Everything here is off by default. Logging is a process-wide env opt-in (OP_QUERIES_LOGGING), and with it off the logger is a return yield unless @enabled passthrough. The query counter is a nil-check per query.

What's in it

  • Goo::SPARQL::QueryLogger (lib/goo/sparql/query_logger.rb) — records the generated SPARQL, timing, row/byte counts, cache-hit flag and user. Redis storage is a ZSET index scored by epoch-microseconds plus JSON entries, giving O(log N) time-window queries and a cheap ring-buffer trim — no KEYS scans, no timestamp-from-key parsing, no Marshal-of-JSON (the rough edges of the fork's logger).
  • Back-compat shim for AgroPortal. Goo.logger plus get_logs / queries_last_n_seconds / users_query_count, which AgroPortal's Admin::LoggingController calls. De-forked goo stays a drop-in there.
  • Cache hit-rate — lifetime hit/miss counters that survive the ring-buffer trim, counted only for cache-eligible reads so the ratio measures cache effectiveness rather than whether caching is switched on.
  • Store-bound query counter — Goo.count_sparql_queries { } plus assert_max_sparql_queries / assert_sparql_queries (lib/goo/test_helpers.rb, opt-in for downstream suites). The count is deterministic for a given code path and fixture, unlike wall time, so it catches N+1 / fan-out regressions identically on a laptop and in CI.
  • Per-test reporting — OP_SPARQL_QUERY_COUNTS=1 prints each test's query count inline via a minitest reporter and writes a name-sorted sparql_query_counts.txt for diffing two runs.

Review findings fixed in this branch

The branch was written against the pre-merge de-fork tip. Rebasing it onto current development surfaced five defects, two of them proven by post-merge fixes on development:

  • The per-request counter never armed in production. It lived in Goo::Debug, which the API mounts only behind if Goo.queries_debug? (app.rb:130) — and no environment config sets that. This is the same defect, in the same middleware, that 0a0478f fixed for the equivalent-predicates cache. Arming moved to RequestStore (mounted unconditionally at app.rb:148, cleared per request, guarded by RequestStore.active? so cron and scripts stay inert).

    Collection and exposure are now deliberately separate: the count is always collected, while the ncbo-sparql-query-count response header stays behind QUERIES_DEBUG, because a header is visible to every API caller and publishing internal query fan-out by default isn't worth the convenience.

  • D6a was unimplemented and the code claimed the opposite. query_logger.rb asserted that disjoint goo:qlog:* vs sparql:* keys meant "log volume never evicts cache memory". §7.2 of the review establishes that's wrong: maxmemory/allkeys-lru is per-instance and ignores key prefixes and db numbers. Added Goo.add_log_redis_backend / Goo.log_redis_client, defaulting to the cache Redis so existing deployments are unchanged — but that fallback is only safe while logging is off, and the startup banner says so when the two are shared.

  • ~6 unpipelined Redis round-trips per logged query, against a baseline §2A measured at ~2000 Redis ops for a single tree request. Batched into one pipeline (the trim's ZCARD rides along, so trim costs nothing until actually over max_logs): 1.10 ms → 0.22 ms per logged query, 500 iterations against a live Redis.

  • OP_QUERIES_LOGGING=false turned logging ON — the raw env string is truthy. Now parsed for truthiness, matching the use_cache line two lines below it.

  • Gemspec under-declared its dependencies. activesupport and request_store are both required from lib but only the Gemfile declared them, so consumers resolving goo through the gemspec got them by luck of the transitive graph.

Verification

  • Full suite green on all four backends: 4store, AllegroGraph, Virtuoso, GraphDB — 252 tests, 0 failures each (skips are backend-specific).
  • Per-test query counts diffed against the pre-rebase tip with the seed pinned: no regressions. Every apparent delta in an unpinned comparison was test-ordering; process-wide store-bound totals are identical.
  • OP_QUERIES_LOGGING verified across false/0/off/no/true/1/yes/empty.
  • Cross-stack against this branch (both at their master tips, via an uncommitted path: override):
    • ontologies_linked_data — 325 runs, 18024 assertions, 0 failures, 0 errors
    • ontologies_api — 302 tests, 12486 assertions, 0 failures, 0 errors
  • Logger behaviour exercised against a live Redis: ring-buffer trim, newest-first ordering, hit-rate surviving trim, per-user counts, no orphaned entry keys, clear.

Follow-ups (not in this PR)

  • Reading the log in production. AgroPortal exposes it through an admin-gated controller; porting a read-only equivalent to ncbo/ontologies_api, extended with cache_hit_rate, is the recommended NCBO channel — 403-by-default rather than public-by-default. Recorded as §7.7 in the review doc.
  • Vendor-neutral instrumentation. An ActiveSupport::Notifications seam consumed by a New Relic or OpenTelemetry subscriber is the right long-term shape for a library used by five apps (and ncbo_cron has no APM at all today), but it's a separate change from stabilizing what exists.
  • feature/sparql-resilience (Ship 2) is stacked on this branch and adds a breaker for the logger's Redis writes.

alexskr and others added 11 commits August 1, 2026 00:52
…nter

Goo-owned observability hung off the single client seam (Client#query/#update), all
off/inert by default.

- Goo::SPARQL::QueryLogger: records generated SPARQL, execution time, result size
  (rows + response bytes), cache hit/miss, and user; Redis ZSET ring buffer in a
  separate goo:qlog:* keyspace; OP_QUERIES_LOGGING / OP_QUERIES_LOGGING_FILE (off by
  default). Includes a back-compat shim (Goo.logger, get_logs, queries_last_n_seconds,
  users_query_count, info) for AgroPortal's Admin::LoggingController.
- cache_hit_rate: lifetime hit/miss tally, gated to cache-eligible reads.
- query counter: Goo.count_sparql_queries + assert_max_sparql_queries / assert_sparql_queries
  (Goo::TestHelpers, reusable by downstream suites) — a deterministic, env-independent
  signal for N+1 / query-fan-out regressions. Run totals (store-bound queries + cache
  hits) printed at end of the test run.
- fix the queries_debug timing path (process_query_intl -> process_query_init) and
  repair Goo::Debug (emit ncbo-sparql-query-count; drop the dead :sparql_queries branch).

Re-implemented onto ncbo's minitest-6 base (tests use Goo::TestCase + Minitest::Hooks,
rubocop-minitest clean), not merged from the alexskr POC fork.
…OUNTS)

Capture each test's store-bound SPARQL query delta (before_setup..after_teardown
snapshot of Goo.query_count_total) into Goo::SparqlQueryStats. At end of run print a
top-15 outliers list to stderr and write a full name-sorted sparql_query_counts.txt —
diff that file between two runs (pre/post an optimization) to see per-test deltas.

Off by default; OP_SPARQL_QUERY_COUNTS=1 enables, OP_SPARQL_QUERY_COUNTS_FILE overrides
the path. Now feasible in goo's own suite since the ncbo port put it on minitest 6
(Goo::TestCase + Minitest::Hooks); previously this lived only in ontologies_linked_data.
…g cache guard

Add TestSafety.ensure_backends_reachable! (called at suite startup, before any test):
pings redis and runs a trivial SELECT against the triplestore, aborting fast with a
clear message if either is unreachable — instead of erroring mid-suite. (Solr is
already checked when config.test loads.)

Remove test_cache.rb#before_all's `raise if redis.dbsize > 100` guard: it false-tripped
mid-run once earlier suites had legitimately filled the shared redis. Production safety
is already enforced by the startup host check (localhost/-ut only) plus the new
reachability pre-flight, so before_all just flushes the (confirmed test) redis.
Add Goo::SparqlQueryReporter (a Minitest::AbstractReporter, minitest 5+) that prints
each test's store-bound SPARQL query count inline as the test completes — a live
per-test view alongside minitest's normal output, rather than only the end-of-run
summary. Reads the per-test tally SparqlQueryStats already captures in after_teardown;
joins the reporter chain via a minitest plugin hook. Opt-in via OP_SPARQL_QUERY_COUNTS
(same flag); zero-query tests are skipped. Now possible since the ncbo port put goo on
minitest 6 (reporters are a 5+ feature).
…rop plugin-hook hack)

Replace the hand-rolled Minitest plugin-hook registration
(plugin_goo_sparql_query_counts_init + Minitest.extensions <<, which pokes at minitest
internals) with the documented Minitest::Reporters.use! API. SparqlQueryReporter now
subclasses Minitest::Reporters::BaseReporter and is appended to the reporter list when
OP_SPARQL_QUERY_COUNTS is set; otherwise just the progress reporter runs.

Also gives nicer default output and makes adding MeanTimeReporter (slow-test dashboard)
or JUnitReporter (CI test reporting) a one-line change later. Aligns goo with
ontologies_api, which already uses minitest-reporters.
Both are required from lib -- activesupport by
base/settings/settings.rb (core_ext/object/blank), request_store by
base/where.rb -- but only the Gemfile declared them, so consumers
resolving goo through the gemspec got them by luck of the transitive
graph rather than by contract.
Goo::Debug both armed the per-request counter and emitted the
ncbo-sparql-query-count header, but the API mounts it only behind
`if Goo.queries_debug?` (app.rb:130), which no environment config
sets -- so the counter was dead in a default production deploy. Same
defect, same middleware, as the equivalent-predicates cache fixed in
0a0478f.

Split the two responsibilities. tick_query_count now also feeds a
RequestStore-backed per-request tally, exposed as
Goo.request_query_count: RequestStore::Middleware is mounted
unconditionally by the API and clears per request, and the
RequestStore.active? guard keeps cron and scripts inert. Goo::Debug
keeps emitting the header, so publishing per-request fan-out to API
callers stays opt-in behind QUERIES_DEBUG while collection is
unconditional. The header is omitted rather than emitted empty when no
request is in flight.

Tests drive the middleware stack in the API's order (Goo::Debug outside
RequestStore::Middleware) and close the response body -- RequestStore
clears from a Rack::BodyProxy on close, so skipping it leaks the store
into the next request.
Each logged query cost ~6 sequential round-trips (SET, ZADD, ZINCRBY,
EXPIRE, INCR, ZCARD) on a path that already runs thousands of Redis ops
per request (review section 2A). Batch every write plus the trim's ZCARD
into a single pipeline; the cache hit/miss tally folds in via record's
new cache_stat: argument instead of taking its own round-trip, and trim
consumes the cardinality the pipeline already returned.

Measured against a live Redis, 500 logged queries: 1.10 ms/query
unpipelined -> 0.22 ms/query (5.1x). Trim still costs a ZRANGE plus one
pipelined ZREM+DEL, but only when actually over max_logs.

with_redis still wraps the lot, so a Redis hiccup degrades to file-only
rather than breaking the query.
query_logger.rb claimed that disjoint goo:qlog:* vs sparql:* keys meant
"log volume never evicts cache memory". Section 7.2 of the review
establishes that is wrong: maxmemory/allkeys-lru is per-INSTANCE and
ignores both key prefixes and db numbers, so on a shared Redis the log
and the cache evict each other. Separate DBs would not fix it either.

Add Goo.add_log_redis_backend + Goo.log_redis_client and point
QueryLogger at the latter, falling back to the cache Redis when
unconfigured so existing deployments are unchanged. add_redis_backend
re-runs set_query_logging to pick up that fallback, without clobbering a
dedicated handle. Configurable via OP_QUERIES_LOGGING_REDIS_HOST/_PORT;
the startup banner names the log Redis and flags when it is shared.

Also parse OP_QUERIES_LOGGING for truthiness instead of taking the raw
string: OP_QUERIES_LOGGING=false is a non-empty String and was turning
logging ON. Same treatment use_cache already had.

Correct the comment to say what is true: disjoint prefixes stop
collisions, not cross-eviction.
Records what the observability pass changed and why:

- Section 7.4 / section 8 item 4: the query counter was armed in
  Goo::Debug, which is not a production mount point -- the same lesson
  as 0a0478f. Arming moved to RequestStore; collection is now
  unconditional while the ncbo-sparql-query-count header stays behind
  QUERIES_DEBUG, since a response header is visible to every API caller.
- Section 6 / 7.2 / section 8 item 12 (D6a): implemented, and the
  "disjoint keyspaces => no interference" claim is corrected in the doc
  and in query_logger.rb. Note the unconfigured fallback is still the
  cache Redis, so the second instance must be provisioned before
  enabling logging where the cache is load-bearing.
- Section 5: records the one benchmark taken -- query-log write
  amplification, 1.10 -> 0.22 ms per logged query after pipelining.
- New section 7.7: AgroPortal's admin-gated Admin::LoggingController as
  the precedent for reading the log in production, and the recommended
  NCBO exposure channel over a public response header.
…_backend

Two review findings on Ship 3:

* QueryLogger#record calls Time#iso8601 but the file required only json,
  benchmark, securerandom and logger. It worked solely because a
  dependency happened to load 'time'; in a bare context it raises
  NoMethodError. Require it explicitly.

* add_sparql_backend called set_sparql_cache (the D5 ordering fix) but not
  set_query_logging, so a host that enabled logging BEFORE registering its
  backends kept the inert logger built in Client#initialize and logged
  nothing while the flag said on. Verified: Goo.query_logger.enabled was
  false in that order before this change, true after.
@mdorf mdorf self-assigned this Aug 4, 2026
Review finding on Ship 3. max_logs defaulted to 1000 while §2A measured a
single class-tree request at ~2000 reads, so the one request the log exists
to explain (attributing that fan-out, checklist #16/#17) trimmed away its
own entries before anyone could read them. Neither max_logs nor ttl was
reachable from configuration, so raising it needed a code change.

* default max_logs 1000 -> 10_000, comfortably outlasting one tree request.
  Roughly 15-20 MB of JSON-with-SPARQL-text on the log's own Redis instance
  (D6a), bounded further by the TTL, and only paid while logging is on --
  which is off by default.
* both settable via Goo.enable_query_logging(max_logs:, ttl:), config
  (query_logging_max_logs / query_logging_ttl) and env
  (OP_QUERIES_LOGGING_MAX_LOGS / OP_QUERIES_LOGGING_TTL); documented in
  config/config.rb.sample.
* test asserts the default outlasts a tree request and that a configured
  depth is both plumbed through and enforced.
@mdorf
mdorf merged commit b721fbc 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