fix: accept time-travel from in explain endpoints#1236
Conversation
Both `POST /v1/fluree/explain` (connection-level) and
`POST /v1/fluree/explain/{ledger}` (ledger-scoped) used to load the
ledger at HEAD and run explain there, regardless of any time-travel
`from` / SPARQL `FROM <ledger@t:N>` in the body. So `--at`-style
explains silently returned the wrong-snapshot plan — the symmetric
query path has honored time-travel for a while, the explain path
hadn't.
Two related fixes:
1. **Time-travel from for explain.** Added
`Fluree::explain_connection` and `Fluree::explain_connection_sparql`
to `fluree-db-api`, mirroring `query_connection`* but emitting the
plan against a properly time-traveled `GraphDb`. Both server
handlers now route through these when the body carries dataset
features (time-travel `from`, FROM with `@t:N`, etc.) and keep the
simple HEAD fast path otherwise. Auth checks compare against the
base ledger id (sans `@t:N` suffix) so scoped read tokens authorize
time-travel explains.
2. **SPARQL FROM/FROM NAMED on ledger-scoped explain.** The previous
blanket rejection in `explain_ledger` is gone. SPARQL `FROM` is
accepted when it targets the same ledger as the URL path (with
optional time-travel suffix) and is routed through the new
`explain_connection_sparql`. Cross-ledger FROM returns the same
`400 Ledger mismatch` shape `normalize_ledger_scoped_from` produces
for JSON-LD.
Tests: four new integration tests in `fluree-db-server/tests/integration.rs`
cover ledger-scoped JSON-LD explain with time-travel, ledger-scoped
SPARQL explain with same-ledger time-travel FROM (was previously 400),
ledger-scoped SPARQL explain with cross-ledger FROM (still 400 but
with the mismatch shape), and connection-level JSON-LD explain with
time-travel.
Drive-by: `#[allow(clippy::len_without_is_empty)]` on `NonEmpty::len`
to unblock `cargo clippy -D warnings` on the workspace — `NonEmpty`
can never be empty so the suggested `is_empty` method would always
return `false`.
| // dataset-aware connection-explain path so snapshot selection | ||
| // honors `@t:N` / ISO / commit-prefix. Otherwise keep the | ||
| // simple HEAD-load fast path. | ||
| if ledger_id_raw != ledger_id { |
There was a problem hiding this comment.
This check fails when the ledger header is set to foo:main but the body includes FROM <foo:main@t:5>. ledger_id_raw == ledger_id, so HEAD will be loaded, but the user still specified a time travel suffix in the query body. is this intended?
There was a problem hiding this comment.
@zonotope good catch — but I went a different direction. The premise of the historical-routing arm was wrong: the planner is a current-state planner using cached/indexed stats from the live ledger view, not historical stats at @t, so the time-travel branch was building a separate code path to produce the same plan execution would have used anyway. The reworked PR removes Fluree::explain_connection* and the dataset-aware routing entirely (the case you flagged can't happen because the branch no longer exists), keeps the real fixes (base_ledger_id for auth, same-base SPARQL FROM accepted), and adds header/body ledger-mismatch validation across SPARQL FROM and JSON-LD from string/object/array forms with tests asserting @t:N returns a byte-identical plan to HEAD.
Two bugs in the explain handlers, plus a small consistency cleanup. The fix is about not rejecting valid requests and not getting auth wrong.
Bug 1: scoped read tokens fail on
--atexplain (connection-level/explain)The bearer scope check used the raw
body.fromvalue as the ledger id. Whenfromcarried a time-travel suffix ("mydb:main@t:5"), the check effectively asked "does this token have read onmydb:main@t:5?" — which never matches a token scoped tomydb:main. Only unscoped/root tokens could explain at a historicalt.Bug 2: SPARQL
FROM/FROM NAMEDrejected outright on ledger-scoped/explain/{ledger}explain_ledgerreturned400 "SPARQL FROM/FROM NAMED is not supported for explain on the ledger-scoped endpoint"whenever any FROM was present. That meantSELECT … FROM <mydb:main@t:5> WHERE { … }— the natural way to ask for a time-travel explain over SPARQL — was rejected before it could be planned. The JSON-LD side already accepts same-ledgerfromvianormalize_ledger_scoped_from; SPARQL didn't.Cleanup: time-travel
fromwas silently droppedBoth explain handlers loaded the path/header ledger at HEAD and ran explain there, regardless of any time-travel suffix in the body. Plans don't materially differ across
t(see "What this PR is not" below), so this wasn't producing wrong-shape plans — but the server was quietly ignoring an explicit request parameter. Routing through a dataset-aware path keeps the contract honest and aligns explain's plumbing with the query path (which already takesfromthroughquery_connection).What changed
fluree-db-apiTwo new public methods on
Fluree, mirroringquery_connection/query_connection_sparqlbut emitting the plan instead of executing:Fluree::explain_connection(query_json)— parses dataset spec, builds a view viaprepare_single_view_for_connection, calls existingFluree::explainagainst it.Fluree::explain_connection_sparql(sparql)— SPARQL counterpart; requires exactly oneFROM(with optional time-travel suffix).Multi-ledger /
FROM NAMEDexplain is rejected —Fluree::explaintakes a single-ledgerGraphDb, so it can't honestly explain across multiple datasets.fluree-db-serverroutes/query.rs::explain_ledgerandroutes/query.rs::explain:Auth uses base ledger id. When body's
fromor SPARQLFROMcarries a@t:N/@iso:/@commit:suffix, the bearer scope check now compares against the base ledger id (sans suffix). Fixes Bug 1.SPARQL same-ledger
FROMaccepted on ledger-scoped explain. The blanket rejection is gone. Cross-ledgerFROMreturns the same400 Ledger mismatchshapenormalize_ledger_scoped_fromproduces for JSON-LD. Fixes Bug 2.Time-travel routing. When the body has dataset features (
requires_dataset_features(query_json)or a time-suffixed SPARQL FROM), handlers route through the newexplain_connection*methods so the request is processed via the same plumbing the query path uses. HEAD requests still use the simple fast path.fluree-db-core(drive-by)#[allow(clippy::len_without_is_empty)]onNonEmpty::len.NonEmptycan never be empty by construction, so the suggestedis_emptymethod would always returnfalse. This was failingcargo clippy -D warningsworkspace-wide onorigin/main.Tests
Four new integration tests in
fluree-db-server/tests/integration.rs:explain_ledger_scoped_jsonld_with_time_travel_returns_planPOST /explain/{ledger}with body{ "from": "tt:main@t:1", ... }returns 200explain_ledger_scoped_sparql_with_same_ledger_from_no_longer_rejectedSELECT … FROM <tt:main@t:1> …against/explain/tt:mainreturns 200 (was 400 before — Bug 2)explain_ledger_scoped_sparql_with_cross_ledger_from_rejectedFROM <otherdb:main>returns 400"Ledger mismatch"(JSON-LD parity)explain_connection_jsonld_with_time_travel_returns_planPOST /explainwith body'sfromcarrying@t:Nreturns 200Full suite:
cargo test -p fluree-db-server(204 tests) andcargo test -p fluree-db-api --lib(425 tests) both pass.cargo clippy -p fluree-db-server -p fluree-db-api --all-targets -- -D warningsis clean.Performance
The query handler is untouched. Query latency is unchanged.
For explain itself:
from): unchanged hot path — sameload_ledger_for_query+Fluree::explaincalls as before. Zero new work.--atexplain: routes throughFluree::explain_connection, which builds a historical view viaload_graph_db_at_t. That involves one nameservice lookup, one index-root read from CAS (cacheable via the sharedleaflet_cache), and a dict-novelty overlay setup. Only fires for explicit historical requests. Possible future micro-optimization: a metadata-only loader for explain-only callers, since explain doesn't actually consume the binary store / range provider that the loader builds.Compatibility
from: "ledger@t:N"to/explainwill start getting 200s where they got 403/400 before./explainno longer 400s SPARQLFROMwhen it targets the same ledger. Cross-ledgerFROMkeeps returning 400.