Skip to content

fix: multi-ledger JSON-LD queries through /v1/fluree/query (dispatcher + formatter)#1267

Merged
bplatz merged 8 commits into
mainfrom
fix/multi-ledger-query-dispatch-and-formatting
Jun 3, 2026
Merged

fix: multi-ledger JSON-LD queries through /v1/fluree/query (dispatcher + formatter)#1267
bplatz merged 8 commits into
mainfrom
fix/multi-ledger-query-dispatch-and-formatting

Conversation

@bplatz

@bplatz bplatz commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Closes #1259.

Summary

Multi-ledger JSON-LD queries did not work correctly through the connection-scoped
/v1/fluree/query route. The reported issue covered three bugs; investigating it
surfaced a fourth, deeper one (cross-ledger hydration expansion). This PR fixes
all four, end to end, and adds regression coverage that reproduces each against
ledgers with divergent namespace vocabularies (the condition the existing tests
never exercised).

The work is split into four reviewable commits, smallest → largest:

Commit Fixes Layer
correct multi-ledger JSON-LD query formatting Issue 2 (root @id), Issue 3-layer-2 fluree-db-api formatter
accept array/object from and fromNamed-only in query dispatcher Issue 1, Issue 3-layer-1 fluree-db-server route
route hydration roots to their home ledger cross-ledger expansion, slice 1 fluree-db-api formatter
expand cross-ledger refs against their home ledger cross-ledger expansion, slice 2 fluree-db-api formatter

Background — why it broke

The connection path was built around single-ledger queries with a string from.
Multi-ledger support (from array, fromNamed, GRAPH alias) was wired into the
engine and parse_dataset_spec — where it works — but neither the HTTP route's
get_ledger_id nor the result formatter were updated to match. The formatter,
in particular, threw away the multi-view DataSetDb after execution and reloaded a
single ledger view, so every result IRI was decoded against one ledger's
namespace dictionary. Because each ledger numbers its namespaces independently, a
SID from another ledger silently decoded to the wrong prefix — or its properties
didn't resolve at all. The existing multi-ledger tests only passed because they
shared schema.org/wikidata.org across every ledger, so the colliding codes
happened to line up.

What changed

1. Formatter — wrong cross-graph @id (Issue 2, silent data corruption)

Multi-ledger queries already stamp result bindings as Binding::IriMatch, which
carries the canonical IRI decoded against the source ledger. The flat SELECT /
CONSTRUCT formatters use it; the hydration formatter discarded it and re-decoded
the foreign SID against the primary dict. Hydration now emits the root @id from
the canonical IRI (compact_iri, which is namespace-dict-independent), and the
expansion cache key gained the active ledger so a view-local SID can't collide
across ledgers.

2. Formatter — fromNamed-only formatting error (Issue 3-layer-2)

FromQueryBuilder::execute_formatted errored "No default graph for formatting"
when a query had only named graphs. It now falls back to the first named graph
(default_graphs.first().or_else(|| named_graphs.first())), matching the
default-then-named precedence the sibling DatasetQueryBuilder already used.

3. HTTP dispatcher — array/object from and fromNamed-only rejected (Issues 1 & 3-layer-1)

get_ledger_id only matched a bare string from, so array from (multi-default-graph
union), object from (time travel / graph selectors), and fromNamed-only queries
were rejected with 400 MissingLedger before the dataset path could run. A new
first_ledger_identifier extracts a representative source from any supported shape;
it is used only for the conservative bearer scope check and span recording, while
per-ledger policy and routing are resolved downstream by parse_dataset_spec. A
request with no path/header/from/fromNamed is still 400.

4. Formatter — cross-ledger hydration expansion (the deeper bug)

Fixing #2 made the cross-graph @id correct but did not make a foreign
subject's nested properties expand: hydration scanned only the primary snapshot
(plus a composite novelty overlay keyed by raw ledger-local SID), so a ref into
another ledger collapsed to a bare {"@id": ...} — and indexed foreign data was
unreachable entirely. The formatter is now dataset-aware:

  • It keeps the DataSetDb alive (query_connection_jsonld_returning_dataset) and
    builds one (GraphDbRef, IriCompactor, policy) view per ledger.
  • A hydration root is routed to its home ledger via the IriMatch.ledger_alias
    provenance, so its properties, predicate keys, and @id decode against the ledger
    that stores them — under that ledger's own policy enforcer, never the primary's.
  • A nested ref is decoded to its canonical IRI in the current view, then
    re-encoded (encode_iri_strict) and expanded in the target ledger(s). A ref inside
    the default-graph union resolves across all default views, merging each
    contributing ledger's view of the subject (RDF merge, de-duplicating identical
    triples) under that ledger's own policy; a ref inside a named graph stays in that
    graph. Views whose namespace dict can't encode the IRI contribute nothing and are
    skipped without a scan.

Single-ledger queries are unchanged: one view, every lookup falls back to it. Flat
SELECT / CONSTRUCT / SPARQL already carry IriMatch.iri and stay on the primary
view.

Design decisions

  • Per-ledger policy is preserved. A foreign subject is read under its own view's
    policy enforcer — reusing the primary's would be both semantically wrong and a
    plausible leak path (policy checks and class caches are SID-local).
  • Default-graph union semantics. A from: [A, B] default graph is a union, so a
    subject visible in both is merged from both (each under its own policy), rather
    than taking the first match (order-dependent, hides data) or guessing ownership
    from vocabulary.
  • Identity is the canonical IRI. A Sid is view-local; cache and routing keys
    incorporate the active ledger / canonical IRI so one ledger's rendering can never
    be served for another's.

Testing

New fluree-db-api/tests/it_multi_ledger_formatting.rs reproduces every issue
against ledgers with divergent entity/predicate vocabularies:

  • cross_graph_projection_iri_decode — foreign-ledger root @id (Issue 2)
  • flat_select_cross_graph_iri_decode — guards the already-correct flat path
  • from_named_only_formatsfromNamed-only formats (Issue 3-layer-2)
  • cross_graph_root_properties_decode_in_home_ledger — foreign root's divergent
    predicate decodes in its home ledger
  • cross_graph_hydration_expansion_divergent_vocab — full depth-3
    movie → book → author chain across three ledgers that share no entity
    vocabulary

New fluree-db-server/tests/multi_ledger_query_dispatch.rs drives the Axum router
end to end for from: [array], fromNamed-only, and the still-rejected no-ledger
case.

No regressions across it_query_connection (including the shared-vocab depth-3
hydration test), it_query_dataset, it_named_graphs, all it_query_jsonld* /
hydration / reverse / construct / typed / sparql suites, the format unit tests, and
server integration / cross_ledger_http_integration / data_auth_integration.

Compatibility & risk

  • Single-ledger and shared-vocab multi-ledger behavior is unchanged.
  • Path- and header-scoped query routes are unaffected (path/header take priority
    before from is consulted).
  • No QueryResult schema change, no core/index changes; the dataset-aware work is
    contained in fluree-db-api's formatter + connection builder, plus the
    server dispatcher relaxation.
  • Docs: docs/api/endpoints.md now documents the dataset from / fromNamed forms
    on the connection route.

Out of scope / follow-ups

  • An indexed-data integration variant (background-indexer harness per ledger):
    the cross-ledger routing path is identical for indexed vs novelty data (both go
    through encode_iri_strict + the target view's db.range), so the divergent-vocab
    test already exercises the logic; a dedicated indexed test is belt-and-suspenders.
  • Cross-ledger cycle detection currently relies on the per-column depth budget; it
    cannot loop infinitely, but a pathological cross-ledger cycle within budget could
    re-expand rather than collapse. Tightening visited to canonical-IRI keying is a
    possible hardening follow-up.

bplatz added 4 commits June 1, 2026 22:25
Two API/engine bugs in the connection-scoped formatting path
(query_from().execute_formatted), both surfacing only across ledgers
with divergent namespace vocabularies:

Issue 2 — hydration emitted the wrong @id for cross-ledger subjects.
Multi-ledger queries stamp subject/ref bindings as Binding::IriMatch
carrying the IRI decoded against the source ledger. The flat formatters
use that canonical IRI, but the hydration formatter discarded it and
re-decoded the foreign SID against the single primary-snapshot dict,
silently producing an IRI with the wrong prefix. Thread the root's
canonical IRI through format_subject and emit the root @id via
compact_iri (a pure context op, independent of the namespace dict) when
present; nested refs keep compact_sid. The canonical IRI is added to
CacheKey so two ledgers minting the same (namespace_code, name) SID for
different IRIs cannot share a cache entry.

Issue 3 (formatter layer) — FromQueryBuilder::execute_formatted errored
"No default graph for formatting" on fromNamed-only queries. Fall back
to the first named graph (default_graphs.first().or_else(named_graphs
.first())), mirroring DataSetDb::primary()'s default-then-named
precedence.

Tests: it_multi_ledger_formatting.rs reproduces both bugs with divergent
per-ledger prefixes; a flat-select case guards the already-correct
IriMatch path. Cross-ledger hydration *expansion* under divergent vocab
remains a deeper limitation (composite overlay is keyed by raw
ledger-local SID) and is captured as an ignored follow-up test.
…her (#1259)

The connection-scoped JSON-LD query route (`POST /v1/fluree/query`)
extracted the ledger id via `get_ledger_id`, which only matched a bare
string `from`. Array `from` (multi-default-graph union), object `from`
(time travel / graph selectors), and `fromNamed`-only queries were
rejected with `400 MissingLedger` before `requires_dataset_features`
could route them to the dataset execution path — even though the engine
and `parse_dataset_spec` accept all of them.

Add `first_ledger_identifier`, which pulls a representative source from
any supported `from` / `fromNamed` shape (string, array, `{@id}` object,
or `fromNamed` alias map). It is used only for the conservative bearer
scope check and span recording; the full multi-graph dataset and
per-ledger policy are resolved downstream by `parse_dataset_spec`. The
`@t:` / `#graph` suffix is stripped so auth scopes to the base ledger.
A request with no path/header/`from`/`fromNamed` is still `400`.

Path- and header-scoped routes are unaffected (path/header take priority
before `from` is consulted).

Tests: multi_ledger_query_dispatch.rs drives the router end to end for
`from: [array]`, `fromNamed`-only, and the still-rejected no-ledger case.
Docs: note the dataset `from`/`fromNamed` forms on the connection route.
Dataset-aware formatting, slice 1 of cross-ledger hydration expansion.

The connection formatter dropped the DataSetDb and reloaded a single
view, so hydration always scanned and decoded against the primary
ledger. A hydration root that lives in a non-primary ledger therefore
had its properties fetched from the wrong snapshot and its predicate /
value IRIs decoded against the wrong namespace dict — silently renaming
a foreign ledger's vocabulary to the primary's (e.g. p:fullName became
cat:fullName when their codes collided).

Keep the DataSetDb alive through execute_formatted (new
query_connection_jsonld_returning_dataset returns it for the
multi-ledger case) and add a dataset-aware formatter entry that builds
one (GraphDbRef, IriCompactor, policy) view per ledger. A hydration
column's root is routed to its home ledger via the IriMatch.ledger_alias
provenance, so the subject's properties, predicate keys, and @id all
decode against the ledger that stores them — under that ledger's own
policy enforcer, never the primary's. The expansion cache key gains the
active ledger so a view-local SID can't collide across ledgers.

Single-ledger queries are unchanged: one view, every lookup falls back
to it. Flat SELECT / CONSTRUCT / SPARQL already carry IriMatch.iri and
stay on the primary view. Nested cross-ledger refs still expand within
the root's view; the union-resolution slice (and the ignored
divergent-vocab expansion test) follows next.

Adds cross_graph_root_properties_decode_in_home_ledger.
Dataset-aware formatting, slice 2: nested-ref union resolution.

After slice 1 routed hydration roots to their home ledger, nested refs
that crossed into another ledger still expanded within the parent's
view, so they collapsed to a bare {"@id": ...} whenever the two ledgers
encoded the IRI under different namespace codes (the previously-ignored
divergent-vocab case). The single primary snapshot also meant a foreign
ref's target was unreachable once indexed, regardless of vocabulary.

Give the hydration formatter a dataset context holding every ledger's
view. When expansion follows a ref, decode it to its canonical IRI in
the current view, then re-encode (encode_iri_strict) and expand it in
the target ledger(s): a ref inside the default-graph union resolves
across all default views, merging each contributing ledger's view of the
subject under that ledger's own policy and de-duplicating identical
triples (RDF merge); a ref inside a named graph stays in that graph.
Views whose namespace dict can't encode the IRI contribute nothing and
are skipped without a scan.

Un-ignores cross_graph_hydration_expansion_divergent_vocab, now asserting
the full depth-3 chain (movie → book → author) hydrates across three
ledgers that share no entity vocabulary.
@bplatz bplatz requested review from aaj3f and zonotope June 2, 2026 09:54
@Jackamus29

Copy link
Copy Markdown
Contributor

Awesome! Thanks for picking up this issue, Brian.
I ran my reproduction tests against your fixes and there's still one test that fails.

Here's Claude's description of the remaining work with the failing test included

One gap I want to flag before this merges. The fix routes per IriMatch.ledger_alias, which works for from: [array] multi-default-graph union because DatasetOperator::stamp_provenance stamps bindings when multiple ledgers are active in the same scan. But for the fromNamed: {alias: {"@id": ...}} + ["graph", "<alias>", ...] shape — which is the exact pattern the original reporter used — the inner scope has only one active graph, DatasetOperator::needs_provenance stays false, and bindings come out as plain Binding::Sid. With no IriMatch to consult, the formatter falls back to the primary view and the original wrong-IRI symptom returns.

Repro

#[tokio::test]
async fn dataset_nested_projection_cross_graph_iri_resolution_via_connection() {
    let fluree = FlureeBuilder::memory().build_memory();

    let catalog0 = genesis_ledger(&fluree, "catalog:main");
    let _catalog = fluree.insert(catalog0, &json!({
        "@context": {"@vocab": "http://example.org/catalog/"},
        "@graph": [
            {"@id": "http://example.org/items/q1", "@type": "Item",
             "name": "Item One", "isbn": "0001"}
        ]
    })).await.unwrap().ledger;

    let lists0 = genesis_ledger(&fluree, "lists:main");
    let _lists = fluree.insert(lists0, &json!({
        "@context": {"@vocab": "http://example.org/lists/"},
        "@graph": [
            {"@id": "http://example.org/lists/summer", "@type": "List",
             "name": "Summer",
             "contains": [{"@id": "http://example.org/items/q1"}]}
        ]
    })).await.unwrap().ledger;

    let query = json!({
        "from": "catalog:main",
        "fromNamed": { "lists_g": { "@id": "lists:main" } },
        "@context": {
            "@vocab": "http://example.org/lists/",
            "catalog": "http://example.org/catalog/"
        },
        "select": {"?list": ["@id", "name",
            {"contains": ["@id", "catalog:name", "catalog:isbn"]}
        ]},
        "where": [["graph", "lists_g", {"@id": "?list", "@type": "List"}]]
    });

    let result = fluree.query_from().jsonld(&query).execute_formatted().await
        .expect("nested cross-graph projection should succeed");

    let s = result.to_string();
    assert!(
        s.contains("http://example.org/lists/summer"),
        "expected @id `http://example.org/lists/summer`; got: {s}"
    );
}

Observed on this branch:

[{"@id": "http://example.org/items/summer"}]

The summer SID encoded against lists's namespace dict gets decoded against catalog's dict at format time — catalog's code-for-the-same-number happens to map to http://example.org/items/, hence the silent mis-decode.

Why this shape matters

This isn't an edge case — it's the canonical multi-ledger pattern the repo's own docs document at docs/query/datasets.md:

  • L87-99: fromNamed: {<alias>: {"@id": "ledger"}} object form, with ["graph", "?g", ...] clauses, in the headline example.
  • L257: object form is called the "preferred format" over the legacy array.
  • L249-253: "Object keys in fromNamed serve as dataset-local aliases for use in GRAPH patterns" — the literal documented contract.
  • L289-314: a worked cross-ledger example specifically shows using aliases to disambiguate two different @id ledgers under separate alias keys, queried with ["graph", "?g", ...]. This is exactly the reporter's pattern.
  • L231-247: combining a string from with aliased fromNamed + ["graph", "<alias>", ...] — structurally identical to the reporter's query.

Where the gap sits

fluree-db-query/src/graph.rs::GraphOperator::execute_in_graph reads batches from the inner pattern executor against a single active graph and merges them into the parent without stamping. The flake's predicate and subject Sids end up as plain Binding::Sid — no ledger_alias for the formatter to route by.

Two fix shapes

I'm not sure which fits your architecture better:

  1. Stamp in GraphOperator. Extend GraphOperator::execute_in_graph to run stamp_provenance on inner batches when the broader dataset spans multiple ledgers (detect via ctx.dataset.iter().any(|g| g.ledger_id != first.ledger_id)). stamp_provenance would need pub(crate) exposure. Single-ledger queries with GRAPH patterns short-circuit on the multi-ledger check.

  2. Recover provenance at format-time. When a hydration root resolves to a plain Binding::Sid rather than IriMatch, look up which view in the DataSetDb actually contains the subject (try the active graph from the WHERE-clause GRAPH context if available) and route to that view. Doesn't touch the engine but adds a fallback to the formatter's routing.

(1) is what I prototyped in the work we'd been carrying for the same issue and it's the smaller change — engine produces consistent IriMatch provenance regardless of single-graph vs multi-graph scope, and the formatter contract stays uniform. Happy to push that as a delta on top of this PR if helpful.

Either way — the substantive piece is that the originally-reported shape isn't covered by the new test fixtures (which all use from: [array] / fromNamed: [array]). One additional regression test using fromNamed: {alias: {"@id": ...}} + ["graph", "<alias>", ...] would catch it.

@bplatz bplatz force-pushed the fix/multi-ledger-query-dispatch-and-formatting branch from f5af776 to 44b82ea Compare June 2, 2026 23:01

@aaj3f aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree w/ Jack's assessment above and have a few additional notes.

A connection query with "from": ["A", "B"] only seems to bearer-auth check on the first ledger. If the check passes on A the response can include B without a check on B (also seems true for fromNamed). The valid / correct pattern does already seem to exist within the path for multi-query e.g. fluree-db-server/src/routes/query.rs:2847:

        // Bearer ledger-scope enforcement — parity with single-query
        // /query and /query/:ledger. Unsigned bearer tokens may carry a
        // scope that limits which ledgers they can read; any envelope
        // referencing a ledger outside that scope is rejected with 404
        // (avoiding existence leak), matching the single-query response.
        if let Some(principal) = bearer.0.as_ref() {
            if !credential.is_signed() {
                for ledger_id in &distinct_ledgers {
                    if !principal.can_read(ledger_id) {
                        set_span_error_code(&span, "error:Forbidden");
                        return Err(ServerError::not_found("Ledger not found"));
                    }
                }
            }
        }

Some additional perf-related comments line-by-line.

Approving so you can merge when ready, but maybe worth considering a few of these first.

Comment on lines +949 to +1013
/// Expand a referenced subject, routing across ledgers when needed.
///
/// In single-ledger mode this is just `format_subject` against the current
/// view. In dataset mode the ref's SID is decoded (via the current view) to
/// its canonical IRI, then re-encoded and expanded in the target ledger(s):
/// a ref inside a default-graph view resolves across the **whole
/// default-graph union** (merging each contributing ledger's view of the
/// subject under that ledger's own policy); a ref inside a named graph stays
/// in that graph. This is what makes a ref that points into another ledger
/// hydrate its properties instead of rendering as a bare `{"@id": ...}`
/// (issue #1259).
fn expand_ref<'b>(
&'b self,
ref_sid: &'b Sid,
level: &'b NestedSelectSpec,
depth: DepthBudget,
visited: &'b mut HashSet<Sid>,
cache: &'b mut HashMap<CacheKey, JsonValue>,
) -> BoxFuture<'b, Result<JsonValue>> {
async move {
let Some(ctx) = self.dataset else {
// Single-ledger: stay in the current view.
return self
.format_subject(ref_sid, None, level, depth, visited, cache)
.await;
};

// Decode against the CURRENT view (where the flake lives) to recover
// the canonical IRI, then resolve it in the target ledger(s).
let iri: Arc<str> = Arc::from(self.compactor.decode_sid(ref_sid)?.as_str());

// Scope: default-graph refs resolve across the default-graph union;
// a named-graph ref stays within its graph.
let targets: Vec<usize> = if ctx.default_indices.contains(&self.active_idx) {
ctx.default_indices.clone()
} else {
vec![self.active_idx]
};

let mut merged: Option<JsonValue> = None;
for tidx in targets {
let tview = &ctx.views[tidx];
// Only a view whose namespace dict can encode the IRI can hold
// the subject — skip the rest (no scan, no error).
let Some(tsid) = tview.db.snapshot.encode_iri_strict(&iri) else {
continue;
};
let tfmt = ctx.formatter_for(tidx);
let obj = tfmt
.format_subject(&tsid, Some(Arc::clone(&iri)), level, depth, visited, cache)
.await?;
merged = Some(match merged {
None => obj,
Some(acc) => merge_subject_objects(acc, obj),
});
}

// No view could resolve the subject → bare canonical @id.
match merged {
Some(v) => Ok(v),
None => Ok(json!({ "@id": self.compactor.compact_iri(&iri)? })),
}
}
.boxed()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though expand_ref() tries to short-circuit for the most common single-ledger graph-crawl paths, the fact that all nested ref expansion now always paths through expand_ref() AND the fact that this short-circuit returns the .boxed() heap alloc means that the short-circuit for single-ledger graph crawls now always allocates 2 boxed futures where it previously only allocated one. I think the best solution is, at the call sites where self.expand_ref() currently gets called, we should be checking THERE whether self.dataset.is_some(). If true, then expand_ref() is fine. Where false, we should continue straight to self.format_subject()

Comment on lines 1626 to 1654
fn test_cache_key_different_depths() {
let sid = Sid::new(100, "alice");
let spec_hash = 12345u64;

let key1: CacheKey = (sid.clone(), spec_hash, 0);
let key2: CacheKey = (sid.clone(), spec_hash, 1);
let key3: CacheKey = (sid, spec_hash, 0);
let ledger: Arc<str> = Arc::from("a:main");

let key1: CacheKey = (Arc::clone(&ledger), sid.clone(), spec_hash, 0, None);
let key2: CacheKey = (Arc::clone(&ledger), sid.clone(), spec_hash, 1, None);
let key3: CacheKey = (Arc::clone(&ledger), sid.clone(), spec_hash, 0, None);
// Same SID + spec + depth but a different canonical IRI (cross-ledger
// collision) must NOT share a cache entry.
let key4: CacheKey = (
Arc::clone(&ledger),
sid.clone(),
spec_hash,
0,
Some(Arc::from("http://other.example/x")),
);
// Same SID + spec + depth in a DIFFERENT ledger must NOT share an entry.
let key5: CacheKey = (Arc::from("b:main"), sid, spec_hash, 0, None);

// Different depths should produce different keys
assert_ne!(key1, key2);
// Same Sid + spec + depth should be equal
assert_eq!(key1, key3);
// Different canonical IRI → different key
assert_ne!(key1, key4);
// Different active ledger → different key
assert_ne!(key1, key5);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change to CacheKey to move from (Sid,u64,usize) to (Arc<str>, Sid, u64, usize, Option<Arc<str>>) seems right from a correctness perspective, but I'm just thinking about the performance impact of (1) hashing active_ledger str and also Arc cloning it on every format_subject() call. Not sure exactly how to solve for this though. If we had a u32 ledger index to key on instead of Arc<str> this would be cheaper to the point of being effectively free

aaj3f
aaj3f approved these changes Jun 3, 2026
Inside a GRAPH <iri> {..} scope only one graph is active, so
DatasetOperator's multi-ledger check never fires and inner bindings
come out as plain Binding::Sid encoded against the named graph's
namespace. The formatter then decodes them against the primary view,
silently mis-decoding @id for fromNamed:{alias:{@id}} + [graph,alias,..]
queries.

GraphOperator now stamps inner batches with the named graph's home
ledger when the dataset spans multiple ledgers, forcing eager
materialization so SIDs resolve before stamping.
@bplatz

bplatz commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

Good catch on the GRAPH-scope gap, @Jackamus29 — thanks for the detailed repro. Fixed in c5c45e0: GraphOperator now stamps inner GRAPH-pattern results with the named graph's home ledger when the dataset spans multiple ledgers (gated on DataSet::spans_multiple_ledgers(), with eager materialization so SIDs resolve before stamping). Added your fromNamed:{alias:{@id}} + ["graph","<alias>",..] case as a regression test.

Separately, your repro surfaced a non-conformant @vocab-compacts-@id behavior in the formatter (independent of this PR) — filed as #1280.

bplatz added 2 commits June 3, 2026 11:40
Two hot-path allocations flagged in review:

- Ref expansion always routed through expand_ref, whose single-ledger
  short-circuit returns a boxed future and then awaits format_subject's
  boxed future — two heap allocations per ref for the common single-ledger
  crawl. Add expand_or_format_ref, a plain async fn that dispatches to
  expand_ref only when a dataset is present and otherwise calls
  format_subject directly (one boxed future).

- CacheKey led with the active-ledger Arc<str>, hashed and Arc-cloned on
  every format_subject call. Replace it with the view index (active_idx),
  which uniquely identifies the ledger within a hydration's shared cache.
  Drops the per-call string hash and Arc clone; removes the now-unused
  active_ledger and LedgerView::ledger_id fields.
The single-query /query handler derived one representative ledger from
from/fromNamed and only scope-checked that one. A from: ["a","b"] union
(or a fromNamed alias) therefore let a token scoped to the first ledger
read the others.

Add enforce_bearer_dataset_scope, which collects every ledger a query's
from/fromNamed references and rejects the request with 404 (existence-leak
avoiding) if any is outside an unsigned bearer's read scope — parity with
the multi-query envelope path. Wired into both the connection and
ledger-scoped JSON-LD handlers.
@bplatz

bplatz commented Jun 3, 2026

Copy link
Copy Markdown
Contributor Author

@aaj3f addressed your perf + auth notes:

  • Double-boxed future on single-ledger ref crawls — 6f50894
  • CacheKey Arc<str> → view index (active_idx) — 6f50894
  • Bearer scope now checked over every from/fromNamed ledger (404 on any out-of-scope), both connection + ledger-scoped handlers — 318d10b

Tests added for all three.

Apply rustfmt to the multi-ledger formatter/auth changes, and regenerate
testsuite-sparql/Cargo.lock, which was stale relative to fluree-db-api's
flate2/zstd/indexmap deps (added with gz/zst import support) — the separate
testsuite-sparql lockfile is not updated by workspace builds.
@bplatz bplatz merged commit 55ed8bb into main Jun 3, 2026
14 checks passed
@bplatz bplatz deleted the fix/multi-ledger-query-dispatch-and-formatting branch June 3, 2026 16:16
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.

Multi-ledger JSON-LD queries fail through /v1/fluree/query (dispatcher + formatter gaps)

3 participants