Skip to content

GRAPH <g> leaks the default graph after WITH <g> DELETE WHERE on a not-yet-existing named graph followed by an upsert #1317

Description

@acassaignemondeca

Summary

On the ledger-scoped query endpoint, after the sequence (1) WITH <g> DELETE { ?s ?p ?o } WHERE { ?s ?p ?o } on a named graph that does not exist yet, then (2) an upsert of TriG data into <g>, subsequent GRAPH <g> { ?s ?p ?o } queries return the union of <g> and the default graph instead of only the triples of <g>.

The data is stored correctly (the default graph is untouched, the named graph holds only its own triples), so this is a read / graph-resolution bug: named-graph isolation breaks for <g> after this specific write sequence.

Environment

  • Fluree Core main @ fc0303fff (2026-06-11)
  • Single-server mode, local file storage (fluree server run --storage-path store)
  • Endpoint: ledger-scoped POST /v1/fluree/query/{ledger}

Steps to reproduce

The bug only manifests when the default graph is large/complex enough to be indexed across multiple leaves + branches (reliably reproduced with the GEMET thesaurus: ~81.9k triples → reindex stats leaf_count: 12, branch_count: 4). It did not reproduce with 3 triples, nor with 20 000 flat synthetic triples (index tree too shallow).

B=http://localhost:8090/v1/fluree

# 1. Ledger with a large default graph (here: SKOS + GEMET thesaurus, ~81895 triples)
curl -X POST $B/create -H 'Content-Type: application/json' -d '{"ledger":"repro:main"}'
curl -X POST $B/insert/repro:main -H 'Content-Type: application/json' --data-binary @skos.jsonld
curl -X POST $B/insert/repro:main -H 'Content-Type: application/json' --data-binary @GEMET_en_es_fr.jsonld
# (default graph is now indexed across multiple leaves/branches)

# 2. TRIGGER — wipe a NON-EXISTENT named graph, then upsert into it
curl -X POST $B/update/repro:main \
  -H 'Content-Type: application/sparql-update' \
  --data 'WITH <urn:doc:1> DELETE { ?s ?p ?o } WHERE { ?s ?p ?o }'

printf 'GRAPH <urn:doc:1> { <http://ex.org/docA> <http://ex.org/p> "X" . }' | \
curl -X POST $B/upsert/repro:main -H 'Content-Type: application/trig' --data-binary @-

# 3. Read the named graph
curl -X POST $B/query/repro:main \
  -H 'Content-Type: application/sparql-query' -H 'Accept: application/sparql-results+json' \
  --data 'SELECT (COUNT(*) AS ?c) WHERE { GRAPH <urn:doc:1> { ?s ?p ?o } }'

Expected

GRAPH <urn:doc:1> contains 1 triple (<http://ex.org/docA> <http://ex.org/p> "X").

Actual

GRAPH <urn:doc:1> returns ~81896 triples — the 1 named-graph triple plus the entire default graph. A default-graph subject that was never written to the named graph resolves inside it:

ASK { GRAPH <urn:doc:1> { <http://www.eionet.europa.eu/gemet/concept/100> skos:prefLabel ?l } }
# => true   (should be false)

The underlying storage is correct — the default graph is unchanged and the upserted triple is only in <urn:doc:1> at the storage level — so only the read-time graph resolution is wrong.

Once the leaked state has settled, every query shape agrees on it:

Query on GRAPH <urn:doc:1> Result Expected
SELECT (COUNT(*) AS ?c) WHERE { GRAPH <urn:doc:1> { ?s ?p ?o } } 81896 1
SELECT (COUNT(?s) AS ?c) WHERE { GRAPH <urn:doc:1> { ?s a skos:Concept } } 5573 0
ASK { GRAPH <urn:doc:1> { <…/concept/100> skos:prefLabel ?l } } true false
SELECT ?l WHERE { GRAPH <urn:doc:1> { <…/concept/100> skos:prefLabel ?l } } 3 default-graph labels empty

Secondary observation: read-after-write inconsistency on the same path

Immediately after step (2) the upsert, the first COUNT(*) on GRAPH <urn:doc:1> transiently returns 1 (the correct, isolated value); re-running the identical query a moment later returns 81896 (the leaked value), and it stays there. So the same query on an unchanged ledger returns different results depending on timing — the leaked/indexed read path and the just-written novelty appear to be merged inconsistently for a short window. This may be the same root cause surfacing at two stages (write-time graph registration and read-time resolution).

Scope / diagnostic (negative controls)

All on the same large-default-graph ledger:

Sequence GRAPH <g> result
Read a never-written named graph ✅ empty
upsert into a fresh named graph without the preceding WITH … DELETE ✅ isolated (only its own triples)
WITH <g> DELETE WHERE { ?s ?p ?o } alone (no upsert) ✅ empty
WITH <g> DELETE WHERE { ?s ?p ?o } when the graph already has content + upsert ✅ isolated
WITH <g> DELETE WHERE { ?s ?p ?o } on a fresh graph, then upsert returns <g> ∪ default graph

So the trigger is specifically wipe-then-upsert on a not-yet-registered named graph, on a ledger whose default graph spans a multi-level index. Wiping an already-existing graph is fine; a plain first upsert is fine.

Impact

This breaks per-document named-graph workflows (one named graph per document, wiped-then-rewritten on each (re)publish). The first publish of any document corrupts that document's named graph so that every read of it also returns the whole default graph (the shared model / ontology), making per-document reads unusable.

Suspected area

Likely in named-graph resolution on the indexed read path vs. graph_registry g_id assignment (fluree-db-core/src/graph_registry.rs, FIRST_USER_GRAPH_ID = 3): a WITH <iri> DELETE WHERE that references a not-yet-registered graph appears to leave <iri> resolving to / aliasing the default partition (g_id = 0 / DEFAULT_GRAPH_ID) at read time once the graph is materialized by the subsequent upsert. Same area as #1279 (single-db GRAPH resolution). It only surfaces once the default graph is indexed across multiple leaves/branches, so the novelty-only read path is unaffected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions