Found while building the provenance tests for #1641 and deliberately kept out of that PR's scope — filing separately as promised there.
A SERVICE body carrying two triple patterns on the same subject returns no rows at all, while either pattern alone matches, and the identical two-pattern body evaluated via GRAPH returns the expected rows. Since the GRAPH control passes over the same dataset wiring, it seems the defect lives in the SERVICE evaluation lane's handling of the intra-body BGP (the join/merge across the body's patterns) rather than in the data or dataset plumbing — though I haven't traced the exact locus, so I may be wrong about where it goes wrong, just not about the behavior.
Consequence: any cross-ledger query whose SERVICE body has more than one triple pattern silently returns empty — no error, just missing rows — which is exactly the kind of failure a user will attribute to their own data. It fails soft.
Repro — validated on main @ c8c1088 (clean detached worktree)
Setup: two in-memory ledgers, mp-beta:main queried via SERVICE.
mp-alpha:main: {"@id":"http://alpha.example/a1","http://alpha.example/tag":"shared"}
mp-beta:main: {"@id":"http://beta.example/b1","http://beta.example/tag":"shared","http://beta.example/rank":2}, {"@id":"http://beta.example/b2","http://beta.example/tag":"shared","http://beta.example/rank":1}
- Dataset:
mp-alpha:main default graph, mp-beta:main named graph (DatasetSpec::new().with_default(...).with_named(...), fluree.build_dataset_view).
-- 2 rows:
SELECT ?s WHERE { SERVICE <fluree:ledger:mp-beta:main> { ?s ?p "shared" } }
-- 2 rows:
SELECT ?s ?r WHERE { SERVICE <fluree:ledger:mp-beta:main> { ?s <http://beta.example/rank> ?r } }
-- 2 rows (control — same two-pattern body via GRAPH):
SELECT ?s ?r WHERE { GRAPH <mp-beta:main> { ?s ?p "shared" . ?s <http://beta.example/rank> ?r } }
-- 0 rows (the bug):
SELECT ?s ?r WHERE { SERVICE <fluree:ledger:mp-beta:main> { ?s ?p "shared" . ?s <http://beta.example/rank> ?r } }
Observed on c8c108838023c335398924469b1b240f87afcb90: the two-pattern SERVICE query returns [] while the GRAPH control returns [["http://beta.example/b1",2],["http://beta.example/b2",1]].
Integration test
The duplicate-issue sweep and the worktree validation at c8c1088 were Claude's work; pasting the test from that run as-is (drop into fluree-db-api/tests/ plus a [[test]] entry since autotests=false; the harness mirrors #1641's it_service_cross_ledger_iri.rs):
#[path = "support/mod.rs"]
mod support;
use fluree_db_api::{DataSetDb, DatasetSpec, FlureeBuilder, GraphSource};
use serde_json::{json, Value as JsonValue};
use support::{genesis_ledger, MemoryFluree};
const BETA: &str = "http://beta.example/";
async fn seed(fluree: &MemoryFluree, ledger_id: &str, graph: JsonValue) {
let ledger0 = genesis_ledger(fluree, ledger_id);
fluree.insert(ledger0, &json!({ "@graph": graph })).await.expect("seed");
}
async fn rows(fluree: &MemoryFluree, dataset: &DataSetDb, q: &str) -> Vec<JsonValue> {
let result = fluree.query_dataset(dataset, q).await.unwrap_or_else(|e| panic!("query failed: {e}\n{q}"));
let jsonld = result.to_jsonld(dataset.primary().unwrap().snapshot.as_ref()).expect("to_jsonld");
let mut v = jsonld.as_array().cloned().unwrap_or_default();
v.sort_by_key(|r| serde_json::to_string(r).unwrap_or_default());
v
}
#[tokio::test]
async fn service_multi_pattern_body_returns_rows() {
let fluree = FlureeBuilder::memory().build_memory();
seed(&fluree, "mp-alpha:main",
json!([{"@id": "http://alpha.example/a1", "http://alpha.example/tag": "shared"}])).await;
seed(&fluree, "mp-beta:main",
json!([
{"@id": format!("{BETA}b1"), format!("{BETA}tag"): "shared", format!("{BETA}rank"): 2},
{"@id": format!("{BETA}b2"), format!("{BETA}tag"): "shared", format!("{BETA}rank"): 1}
])).await;
let spec = DatasetSpec::new()
.with_default(GraphSource::new("mp-alpha:main"))
.with_named(GraphSource::new("mp-beta:main"));
let dataset = fluree.build_dataset_view(&spec).await.expect("build_dataset_view");
let single_tag = rows(&fluree, &dataset,
r#"SELECT ?s WHERE { SERVICE <fluree:ledger:mp-beta:main> { ?s ?p "shared" } }"#).await;
let single_rank = rows(&fluree, &dataset,
&format!(r#"SELECT ?s ?r WHERE {{ SERVICE <fluree:ledger:mp-beta:main> {{ ?s <{BETA}rank> ?r }} }}"#)).await;
let graph_two = rows(&fluree, &dataset,
&format!(r#"SELECT ?s ?r WHERE {{ GRAPH <mp-beta:main> {{ ?s ?p "shared" . ?s <{BETA}rank> ?r }} }}"#)).await;
let service_two = rows(&fluree, &dataset,
&format!(r#"SELECT ?s ?r WHERE {{ SERVICE <fluree:ledger:mp-beta:main> {{ ?s ?p "shared" . ?s <{BETA}rank> ?r }} }}"#)).await;
assert_eq!(single_tag.len(), 2);
assert_eq!(single_rank.len(), 2);
assert_eq!(graph_two.len(), 2);
assert_eq!(service_two, graph_two, "two-pattern SERVICE body must match its GRAPH control");
// On origin/main c8c108838: service_two == [] while graph_two has 2 rows.
}
Two notes for whoever picks this up
Found while building the provenance tests for #1641 and deliberately kept out of that PR's scope — filing separately as promised there.
A
SERVICEbody carrying two triple patterns on the same subject returns no rows at all, while either pattern alone matches, and the identical two-pattern body evaluated viaGRAPHreturns the expected rows. Since theGRAPHcontrol passes over the same dataset wiring, it seems the defect lives in the SERVICE evaluation lane's handling of the intra-body BGP (the join/merge across the body's patterns) rather than in the data or dataset plumbing — though I haven't traced the exact locus, so I may be wrong about where it goes wrong, just not about the behavior.Consequence: any cross-ledger query whose
SERVICEbody has more than one triple pattern silently returns empty — no error, just missing rows — which is exactly the kind of failure a user will attribute to their own data. It fails soft.Repro — validated on
main@c8c1088(clean detached worktree)Setup: two in-memory ledgers,
mp-beta:mainqueried via SERVICE.mp-alpha:main:{"@id":"http://alpha.example/a1","http://alpha.example/tag":"shared"}mp-beta:main:{"@id":"http://beta.example/b1","http://beta.example/tag":"shared","http://beta.example/rank":2},{"@id":"http://beta.example/b2","http://beta.example/tag":"shared","http://beta.example/rank":1}mp-alpha:maindefault graph,mp-beta:mainnamed graph (DatasetSpec::new().with_default(...).with_named(...),fluree.build_dataset_view).Observed on
c8c108838023c335398924469b1b240f87afcb90: the two-pattern SERVICE query returns[]while the GRAPH control returns[["http://beta.example/b1",2],["http://beta.example/b2",1]].Integration test
The duplicate-issue sweep and the worktree validation at
c8c1088were Claude's work; pasting the test from that run as-is (drop intofluree-db-api/tests/plus a[[test]]entry sinceautotests=false; the harness mirrors #1641'sit_service_cross_ledger_iri.rs):Two notes for whoever picks this up
ORDER BYcase there uses a single-triple body precisely so this defect wouldn't mask it.main, the single-pattern SERVICE rows also come back decoded against the requesting ledger's namespace (http://alpha.example/b1instead ofbeta) — that's SERVICE against another ledger returns IRIs decoded against the requesting ledger's namespace table #1639, fixed by stamp provenance on rows leaving — and joining within — a cross-ledger SERVICE #1641 and unmerged as of this writing. Orthogonal to this bug, but it will show up in any repro run atmainuntil stamp provenance on rows leaving — and joining within — a cross-ledger SERVICE #1641 lands.