Repository navigation
Persistent node layer can hand back an attribute QName with a corrupted prefix on a second read within one request #6781
Description
Summary
While fixing #3417 (XSLTServlet corrupting xml:-prefixed attributes), I traced the root cause to a deeper, still-unidentified bug in eXist's persistent node layer: within a single HTTP request, a second SAX serialization of the same stored document/attribute can come back with a corrupted attribute prefix, even though the attribute's namespace URI and local name stay correct.
The fix landed in #3417's PR works around this defensively (see below), but the actual corruption mechanism was not found and could plausibly affect other code paths that serialize the same document twice within one request.
Reproduction context
Using the exact scenario from #3417 (a controller.xql forwarding a .xml request to XSLTServlet, with an identity-transform stylesheet), instrumenting ReceiverToSAX.startElement showed the following sequence for a single HTTP request against /db/apps/test3417/test.xml (<test><strange xml:id="foo"/></test>):
DEBUG attr uri=[http://www.w3.org/XML/1998/namespace] local=[id] qName=[xml:id] prefix=[xml] <- first serialization: correct
... <- stylesheet compiled in between (xsl:stylesheet, xsl:mode)
DEBUG attr uri=[http://www.w3.org/XML/1998/namespace] local=[id] qName=[xm:id] prefix=[xm] <- second serialization: corrupted
The first serialization of test.xml's xml:id attribute is correct (prefix=xml). The second serialization of the same document, after the XSLT stylesheet is compiled in between, comes back with the prefix truncated to xm.
I instrumented AttrImpl.deserialize() and AttrImpl.addToList() (the two places that decode an attribute's prefix from raw persistent storage bytes and are the only callers of SymbolTable.getQName(...)) and confirmed neither is invoked for the corrupted second read — no matching debug line appears for it at all. So the corrupted AttrImpl/QName is coming from some node-object reuse/caching path that skips raw byte deserialization entirely, not from a byte-decoding bug (QName itself is immutable — all fields are final — so a shared static instance being mutated is also ruled out).
What's already ruled out
QNamePool(org.exist.dom.persistent.QNamePool): its hash/equals keys include the prefix, so a lookup for("id", XML_NS, "xml")cannot return a cached("id", XML_NS, "xm")entry through normal hash-collision handling.Namespaces.XML_ID_QNAMEand friends: these areQName, which is immutable (localPart/namespaceURI/prefixare allfinal), so no aliasing mutation is possible.AttrImpl.deserialize()/AttrImpl.addToList(): instrumented directly; not invoked for the corrupted read.
Suspected area
Given the corrupted read skips both known deserialization entry points, the likely culprit is some node-object cache/pool that hands back an already-materialized AttrImpl (or its containing element's attribute list) without re-deserializing -- e.g. org.exist.util.pool.NodePool, or a DOMFile node buffer cache -- combined with something that constructs a fresh, wrongly-prefixed QName and installs it via NamedNode.setQName()/setNodeName() on what turns out to be a shared/reused object, in a way that isn't visible from the two known deserialize entry points.
Workaround shipped in #3417's fix
Saxon12CompatSAXFilter.startElement() now unconditionally rewrites any attribute whose namespace URI is http://www.w3.org/XML/1998/namespace to use the canonical xml: prefix, regardless of what prefix arrives -- this is safe per the XML namespaces spec (that binding can never legally be anything else) and fully resolves the externally-visible bug, but it papers over the root cause rather than fixing it.
Suggested next steps
- Reproduce with a smaller, targeted Java unit test that serializes the same stored document twice within one broker session/transaction, with something else (e.g. compiling an unrelated XSLT stylesheet, or storing/reading a second document) happening in between, to isolate the exact interleaving that triggers it.
- Audit
NodePool(exist-core/src/main/java/org/exist/util/pool/NodePool.java) andNativeSerializer's node iteration path for object-pooling/reuse bugs where a released node is handed back to a new caller while still referenced elsewhere. - Check whether this can corrupt attribute prefixes on any namespace (not just the reserved
xmlone), since the workaround above only guards the one namespace where the correct prefix is statically known.
Environment
developbranch, commitc0b5b3ab0b0b7971cb958bd991c4ebefc08fcee8- macOS, Java 21 (Zulu/Temurin), Saxon-HE 12.5
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
duncdrum commentedon Sep 28, 2026
Follow-up on isolating a minimal reproduction for a pending test: I tried four different hypotheses for the mechanism, none of which reproduced the corruption outside the full live HTTP request through
XSLTServlet:Serializer.toSAX(DocumentImpl), with an XSLT stylesheet compiled in between (mirroring the original debug trace's timing) — came back correct (xml:id) both times.Serializer.serialize(DocumentImpl)to a string, then re-parsed with a plain (non-pooled)SAXParserFactoryparser — came back correct.XSLTServlet(amodel.xqlreturning the document, aviewstep'secho.xqlreading it back viarequest:get-data()) — came back correct, suggesting this isn't a general URL-rewrite pipeline issue.XMLReaderround-trip:Serializer.serialize(...)to text, then re-parsed viaBrokerPool.getParserPool().borrowXMLReader()/returnXMLReader()(the exact mechanismXSLTServlet.doPostuses forreader.parse(new InputSource(stream))), repeated 4x with a stylesheet compile in between — still came back correct every time.So the trigger requires something specific to the actual live HTTP request path through a real Jetty container that none of these narrower reproductions capture -- possibly something about the real
HttpServletRequest.getInputStream()(vs. aStringReader), theGZIPInputStreamdetection/fallback logic inXSLTServlet.doPost,CachingResponseWrapper/XQueryURLRewrite's response buffering between the model and view steps, or genuine request-level state I'm not replicating withExistEmbeddedServer.Given the difficulty isolating this, I did not add a
@Ignored unit test to the codebase for this issue, since anything I could get to compile either didn't reproduce (misleading) or would need to attach directly to the live HTTP path, which is significantly more machinery than a "pending" test typically carries. Whoever picks this up will likely need to instrument the real HTTP servlet path directly (e.g. viaExistWebServer, with temporary debug logging like the original trace in the issue description) rather than trying to shrink it to a broker-level unit test first.[bugfix] Fix orphaned endPrefixMapping and misleading javadoc in Saxo…
wylovan commentedon Oct 4, 2026
I stumbled across an eXist bug, and Claude has written the below comment that suggests that this bug is also related...
I think the cause is
QNamePool.insert(), and that the "ruled out" note above holds forget()but not forinsert(). Fix and test in #6795.The mechanism
SymbolTable.getQName()callsnamePool.get(...), and on a miss callsnamePool.add(...)→insert(...):get()compares namespace, local name, prefix and name type.insert()detects duplicates withQName.equals(QName), which compares only namespace and local name.So when
xml:idis requested and the pool already holdsxm:id:get()misses, because the prefixes differ.insert()probes from thexml:idslot. If the probe sequence runs throughxm:id's slot,QName.equalscalls it a duplicate and returns it.xm:id, and keeps getting it on every later request for that name, until the pool is cleared or its layout changes.The two names never hash to the same slot, so this needs a well-filled pool, where another collision starts a probe sequence that passes through. That would explain why the corrupted read skipped
AttrImpl.deserialize(), and why the smaller reproductions above came back correct.Reproduced
QNamePoolTestin the PR fills the default pool with 380 pairs of names that differ only in prefix or type. Against the currentinsert()it fails three ways:prefix of attribute 'name213' expected:<xml> but was:<xm>: this issue's symptomname type of element 'name336' expected:<0> but was:<1>name type of attribute 'name374' expected:<1> but was:<0>The name-type case is how we hit it, on a development instance and in CI: an element
manifestwas stored and indexed as the attributemanifest. Name tests on it then found nothing, with no error raised:Fix
insert()now uses the same full comparison asget(). With it, all fourQNamePoolTesttests pass, as do theorg.exist.dom.persistenttests,SymbolTableTest,XPathQueryTestandXQueryTest. We also built a full distribution from the branch, restored our development database into it, and our application's test suites pass on it.I haven't run #3417's XSLT reproduction against the fix. If the
xm:idcorruption is gone with it, theSaxon12CompatSAXFilterworkaround could probably go too.duncdrum commentedon Oct 5, 2026
A data point from a different workload, in case it is useful for the discussion of #6795.
While making the XQSuite test files of the Lucene module run in parallel (33 files against one database), we see wrong reads of persistent nodes that look like this issue's symptom but are rarer and different in detail: a query over one document returns text that belongs to another test file's document, a child-axis test is true for an element that has no children, a hit's node resolves to nothing (
NullPointerExceptioninRecordPos.getPage()), orft:fieldreturns null for one document, and the wrong data then stays for the rest of that file. It never happens in serial runs, which fits a shared structure whose contents depend on the order in which names arrive, soQNamePoollooked like a good candidate.I tried it: the two-line change of #6795 on top of our branch, 40 runs of the suite at 8 files at a time. 33 of the 40 runs were fully green. Earlier runs of the same suite without it had a failure not involving the statistics tests in at least 28 percent of the runs at that setting (9 of 32, collected over several probes with slightly different branches), against 17.5 percent here (7 of 40). With these numbers that is not a difference I can claim, and the same failures still occurred (the cascade in one facets file, nested-element tests in another, a reindex count). So #6795 did not remove what we see; that does not say anything against #6795 itself, which fixes a real defect that the unit test in the PR shows.
I could not reproduce our wrong reads outside the suite either: two stress tests (3 writer threads creating, filling and removing collections so that document ids are freed and reused, 3 readers re-reading fixed documents; once without an index, once with a Lucene index on every collection) ran 41049 and 285 reads with no difference. If anyone sees this issue's corruption together with concurrent writes to other collections, a suite like ours (the Lucene XQSuite files with parallel execution switched on) might help as a reproducer once the branch is merged; the branch is #6796.
[Commented by Duncan, drafted by Claude Code.]
[bugfix] Compare the full key when inserting into QNamePool
[bugfix] Fix orphaned endPrefixMapping and misleading javadoc in Saxo…
[bugfix] Fix orphaned endPrefixMapping and misleading javadoc in Saxo…