Skip to content

[#157] Save the XML document in linear time - #176

Merged
maximthomas merged 16 commits into
OpenIdentityPlatform:masterfrom
maximthomas:issues/157-xml-linear-save
Oct 11, 2026
Merged

maximthomas merged 16 commits into
OpenIdentityPlatform:masterfrom
maximthomas:issues/157-xml-linear-save

Conversation

@maximthomas

@maximthomas maximthomas commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Part of #157

Merge order

This is the first of two PRs for part 1 of #157; the second, #177 ("[#157] Load and save the XML file only when it has changed"), is built on top of this branch. Please merge them one after the other: this PR first, then #177. After this PR is merged, #177 will be rebased onto the new master.

Problem

XMLConnector is not poolable, so an operation that does not overlap with another one on the same file ends in XMLHandlerImpl.dispose(), which writes the whole document back. That save is quadratic in the number of entries. Measured with 40,000 entries, dispose() alone takes about 7.8 s (see the table below), and in the issue a search for one entry by __NAME__ through ConnectorFacade took 13.5 s at 40,000 entries.

The save walks the Xerces DOM twice through Saxon: the XPath //text()[normalize-space(.) = ''] that removes whitespace-only text, and DOMSender when TransformerFactoryImpl serializes a DOMSource. Both go through NodeList.item(i). On Xerces 2.6.2 the document's NodeListCache free list can become a self-cycle (freeNodeListCache pushes a cache that is already on the list), after which every item(i) on the container restarts from its first child, and a walk over N children costs O(N^2).

This PR fixes the save only. Parsing, the lookups (XQuery) and the decision when to read and write the file are not changed here, so a full operation is still slower than linear. The save still truncates the file before it writes it, as before; making it atomic is #160.

Change

  • New XmlDocumentWriter (package-private).
    • normalizeText(Node) removes whitespace-only text and merges every other run of adjacent Text and CDATA siblings into one Text node. Whitespace means space, tab, CR and LF, which is what normalize-space strips; CDATA counts as text, as it did for the XPath. A lone Text node, the usual case, is checked in place. Text inside an entity reference is left alone, because the DOM makes it read-only; only a DOM parsed with entity references not expanded has such text, and the connector expands them.
    • write(Document, File) walks the DOM with sibling pointers and sends SAX events to the same Saxon serializer (TransformerHandler, same output properties as before). It returns the CRC-32 of the bytes written, which this PR does not use: [#157] Load and save the XML file only when it has changed #177 compares it with the file's content when the file's timestamp is too recent to tell whether the file has changed.
  • Both walks follow sibling pointers, so neither touches a NodeList; normalizeText steps with the new XmlHandlerUtil.following(node, root) (the next sibling, or the next sibling of the nearest ancestor below root; a root that is not an ancestor acts as null).
  • XMLHandlerImpl.dispose() calls normalizeText and write instead of the XPath and the DOMSource transform. It takes the document through getDocument() and no longer locks the document's monitor: every caller holds ConcurrentXMLHandler's write lock, and nothing else locks on the document. A handler without a document now fails with getDocument()'s ConnectorException instead of a NullPointerException; the connector does not reach that case, because a failed init() is never followed by the handler's dispose(). A failed save is still an ERROR log line, then ConnectorException. Two cases change: an IOException from closing the file was logged as WARN and the save counted as successful, and is now a failed save (A1 row 21), and the XPathExpressionException that the old code ignored is gone with the XPath. The old closing log.info("Entry {0}", method) now says Exit.
  • createDocument() sets xmlns:icf on the root before xmlns:xsi, so a new file declares icf, ri, xsi in the same order as before.
  • The file format does not change for a store the connector created: same indentation (the Saxon serializer is the same), same namespace declarations in the same order. An unprefixed element created with DOM level 1 createElement is written in no namespace, as before, unless it has an xmlns attribute of its own (the connector creates its elements with createElementNS). The tests compare write's output byte by byte with the old DOMSource output of the same document. A hand-edited store can come out with its namespace declarations rearranged: write declares an element's xmlns attributes in the order of its attribute map (Xerces sorts them by name) and keeps a redeclaration of a binding already in scope, where the old serializer put the element's own namespace first and dropped such redeclarations. The namespaces of every element and attribute stay the same. A DOM built in code can bind one prefix to two namespaces on one element: an xmlns attribute that contradicts the namespace of the element or of one of its attributes, or two xmlns attributes, one set with setAttribute and one with setAttributeNS. write declares such a prefix once, and the first binding wins: the element's xmlns attributes, then its name, then its attributes. The old serializer did the same for the default namespace; for any other prefix it let the node's namespace win over the element's xmlns:p attribute. When the namespace chosen for a prefix gives an attribute the expanded name of another attribute of the same element, the file is not well-formed. With xmlns:p="urn:x" on an element, its attributes p:c in urn:y and q:c in urn:x are both written as c in urn:x; the old serializer declared p as urn:y there and wrote a readable file. When the clash comes from the element's own prefix (p:b in urn:x with the same two attributes), the old serializer writes the same unreadable file as write, byte for byte.
  • Whitespace-only text is removed more thoroughly than before. Saxon's XPath sees a run of adjacent Text and CDATA siblings as one text node but returns only the first DOM node of the run, so the old //text()[normalize-space(.) = ''] removed that node and left the rest; normalizeText removes the whole run. Such runs appear when elements between two indentation nodes are removed, so a saved file can differ from the old output in whitespace only:
    • deleting the last entry writes an empty <icf:OpenICFContainer .../>, not one that holds a line break;
    • updating a multi-valued attribute that held several values no longer leaves a line of spaces in the entry;
    • a hand-edited value such as <ri:firstname> <![CDATA[ ]]> </ri:firstname> is emptied by the first save, not the second.

Why the writer feeds Saxon and does not use XSLTC

The connector bundle embeds xml-apis-1.3.04.jar, and the connector server loads bundles with a child-first BundleClassLoader. Inside the bundle javax.xml.transform.TransformerFactory and org.w3c.dom.* therefore come from xml-apis 1.3.04. That copy has no TransformerFactory.newDefaultInstance(), and the JDK's XSLTC transformer cannot take the bundle's DOM classes. Surefire cannot see this, because there the JDK classes win. The writer therefore creates net.sf.saxon.TransformerFactoryImpl directly.

A new integration test, XMLConnectorBundleIT, loads the packaged bundle jar with the same child-first loader and runs a save, a reload and a search through it. It is wired into the module with maven-failsafe-plugin (**/*BundleIT.java, system property bundleJar). To check that the IT is not vacuous, write was switched to (SAXTransformerFactory) javax.xml.transform.TransformerFactory.newDefaultInstance(): the IT then fails with NoSuchMethodError.

Measurements

dispose() time, one XMLHandlerImpl per run: init() parses the generated file, then dispose() is timed (normalize + serialize + write). Three runs per size in one JVM, so the first run of each size includes warm-up. Entries are generated by the issue's harness (XmlBreakdown, Gen): ri:__ACCOUNT__ entries with __UID__, __NAME__ and a few more fields. Master is 3348526, "after" is this branch at dca4047. The later commits add tests and Javadoc, and the review fixes in A6 and A7, of which only the lone-Text shortcut changes the cost of a save noticeably: in a separate probe at 40,000 entries, a normalizeText pass over a freshly parsed store took 23-47 ms and 0.1 MB instead of 45-231 ms and 49 MB. JDK 26 (default JDK of the dev machine), Saxon-HE 9.4.0.7, Xerces 2.6.2. All numbers come from one developer machine (macOS) and were run once, so read them as orders of magnitude.

Entries dispose() before, median (min-max of 3 runs) dispose() after, median (min-max of 3 runs)
10,000 1,889 ms (1,150-2,127) 159 ms (85-271)
20,000 2,192 ms (2,192-2,270) 187 ms (171-387)
40,000 7,781 ms (7,647-8,377) 345 ms (323-403)

At 40,000 entries dispose() is about 22 times faster. The 10,000 and 20,000 rows are noisy (the first run of each size is slower); the 40,000 row is the clearest. Parse time is not changed by this PR (45-380 ms in both runs).

Tests

  • OpenICF-xml-connector, mvn -o -pl OpenICF-xml-connector verify: surefire 142 run, 0 failed; failsafe 1 run, 0 failed (XMLConnectorBundleIT).
  • XmlDocumentWriterTests (new) covers following, normalizeText, and write, which is compared with the old DOMSource output (the serializer half; the old XPath is not part of that comparison) and re-read for namespaces (DOM level 1 nodes included), CDATA, comments, processing instructions, entity references and a prefix bound to two namespaces on one element; text inside an unexpanded entity reference is left alone, and the text after it is normalized.
  • XMLConnectorTests gains cases for dispose(): it allocates no Xerces node list caches (XercesNodeLists, test helper), whitespace-only values are saved as empty elements, deleting the last entry leaves an empty container, a failed save is logged and thrown, a successful save logs Exit serialize, a save without a document throws ConnectorException, and a new file declares its namespaces in the old order.
  • XmlConnectorTestUtil gains store-file helpers (writeAccounts, namesInFile, account) for XMLConnectorTests and XMLConnectorBundleIT. It also gains setModified(File, long), which has no caller here: its callers come with [#157] Load and save the XML file only when it has changed #177.
  • Four tests map to no arm row: valuesSurviveASaveAndAReload (a characterization of what Saxon wrote; green on the old dispose() too); commentsSplitTextRuns (kills "isText counts comments", a mutant no row's tree contains); normalizeAndWriteNeverUseNodeLists (the purpose of the writer at unit level: kills a NodeList.item(i) walk in sendElement; green from its first run, because every row walks by sibling pointers); normalizeAndWriteMakeALinearNumberOfDomCalls (counts the DOM calls of normalizeText + write through CountingDom, test helper: 63,035 for 1,000 entries, 126,035 for 2,000; kills a walk that counts the earlier siblings of each child, in sendElement or in normalizeText, which takes about 3.8 times the calls for twice the entries and which normalizeAndWriteNeverUseNodeLists misses; green from its first run for the same reason).

Test strength

Each row is an arm of the production code, the case that reaches it, what only that arm produces, and a mutant that only that case kills. "red at" is the commit where the case was red before its arm was written (A1: on the base 33485269; A2: row 1 on 6f83fa3e, the writer without the dispose() change, and rows 2-4 on 6f83fa3e plus row 1's dispose(), which only calls write and rethrows, because the old dispose() already does what they assert; A3: on 4fbcc475, which had no failsafe execution; A4 and A5: on the mutants named in their rows, because their arms were already written in A1 and A2; A6: on the commit before each arm; A7: on bd2fe56f, or on the mutant named in the row where the arm was already there). Rows marked unpinnable are arms that no test can tell from their absence; the reason is given as found.

A1: XmlDocumentWriter, following (6f83fa3)

# arm (file:line — function — condition) case observable mutant red at
1 XmlHandlerUtil.java:92-94 — following — sibling != null: return the next sibling followingIsTheNextSibling following(a, document) is b, not a's child x following not defined 33485269
2 XmlHandlerUtil.java:91 — following — no sibling: n = n.getParentNode(), try again followingClimbsToTheNextSiblingOfAnAncestor following(x, document) is b although x has no sibling body return node.getNextSibling(); (no climb) 33485269
3 XmlHandlerUtil.java:91 — following — loop bound n != root followingStopsAtRoot following(x, a) is null although a has the sibling b bound n != null (the walk leaves root) 33485269
4 XmlDocumentWriter.java:81-85 — normalizeText — keep side: a lone non-blank Text node stays where it is normalizedDocumentIsLeftAlone a's Text node in <r><a>x</a><b/></r> is the same object after the call normalizeText not defined (afterwards: every lone Text node replaced by a new one; the other 138 tests stay green on it) 33485269
5 XmlDocumentWriter.java:74-100 — normalizeText — a blank lone Text node is removed (:83-84); the walk: descend, following(node, root) after a leaf, after, following(parent, root) after a run that ends its parent whitespaceOnlyTextIsRemoved a empty, every child of r an element (also after the empty c) empty body (also killed here: the walk stops after an empty element, the walk stops after a last run — verified) 33485269
6 XmlDocumentWriter.java:248-256 — normalizeText — isXmlWhitespace: only space, tab, CR, LF make a run blank paddedAndNonXmlSpaceValuesAreKept the U+3000 value is kept isXmlWhitespace = value.toString().isBlank() (Character.isWhitespace) 33485269, re-run after review
7 XmlDocumentWriter.java:243-246 — normalizeText — isText: CDATA_SECTION_NODE is text whitespaceOnlyCdataIsRemoved the whitespace-only CDATA section is removed isText = TEXT_NODE only 33485269
8 XmlDocumentWriter.java:81,86-98 — normalizeText — a Text node with a text sibling after it starts a run: one Text node with the joined value (if (!isXmlWhitespace(value)) insert) adjacentTextAndCdataBecomeOneTextNode a holds one Text node " x<y " the lone-Text condition without (after == null || !isText(after)) (A5 row 1 also fails on it — verified) 33485269
9 XmlDocumentWriter.java:81 — normalizeText — node.getNodeType() == Node.TEXT_NODE: a lone non-blank CDATA section is not a lone Text node and becomes a Text node singleCdataBecomesATextNode b's only child is a TEXT_NODE "z" the lone-Text condition without node.getNodeType() == Node.TEXT_NODE (verified) 33485269
10 XmlDocumentWriter.java:111-130 — write — the road every document takes: Saxon TransformerHandler with INDENT=yes, startDocument, the document's children, endDocument; send: ELEMENT_NODE, TEXT_NODE; sendElement: xmlns:p attributes declared, element and attributes named through bind outputIsTheSameAsBefore file bytes equal the old DOMSource output write not defined (also killed here: INDENT dropped, xmlns:p not declared — verified) 33485269
11 XmlDocumentWriter.java:119-129 — write — CheckedOutputStream: returns the CRC-32 of the bytes written writeReturnsTheChecksumOfTheFile return value = CRC-32 of the file return 0L; 33485269
12 XmlDocumentWriter.java:162,206-217 — sendElement/bind/declare — namespace scope: pushContext, declarePrefix, getURI, !uri.equals(inScope) → declare, popContext siblingsDeclareTheirOwnNamespaces p:a and p:b read back in urn:1 bind returns the node's namespace without declaring it (also killed here: popContext dropped — verified) 33485269
13 XmlDocumentWriter.java:138 — send — case Node.CDATA_SECTION_NODE cdataIsWrittenAsText r reads back with the text x<y the label dropped (CDATA falls to default) 33485269
14 XmlDocumentWriter.java:149-153 — send — case Node.ENTITY_REFERENCE_NODE: the children are sent entityReferencesAndDoctypeAreWrittenAsBefore output <r>v<x/>…</r>, equal to the old output the case dropped (<r/>) 33485269
15 XmlDocumentWriter.java:209-210 — bind — uri == null with a prefix: the namespace in scope (prefixed DOM level 1 nodes such as createDocument()'s xsi:schemaLocation) prefixedAttributeWithoutANamespaceTakesTheOneInScope xsi:schemaLocation reads back in the XSI namespace return ""; for that branch 33485269
16 XmlDocumentWriter.java:168-169 — sendElement — XMLNS.equals(name): the default namespace declaration, xmlns="" included defaultNamespaceUndeclarationIsKept a reads back in no namespace the first loop declares only xmlns:p 33485269
17 XmlDocumentWriter.java:182-183 — sendElement — prefix.isEmpty() ? "": an unprefixed attribute is in no namespace and declares nothing namespacedAttributeWithoutAPrefixDeclaresNoDefaultNamespace r reads back in no namespace every attribute named through bind (declares xmlns="urn:x" on r) 33485269
18 XmlDocumentWriter.java:142-144 — send — case Node.COMMENT_NODE createdDocumentReadsBackTheSame the comment " c " reads back the comment dropped 33485269
19 XmlDocumentWriter.java:146-148 — send — case Node.PROCESSING_INSTRUCTION_NODE handEditedDocumentReadsBackTheSame <?pi data?> reads back the PI dropped 33485269
20 XmlDocumentWriter.java:116-118 — write — METHOD xml, ENCODING UTF-8, {http://xml.apache.org/xslt}indent-amount — unpinnable by construction: Saxon 9.4's serializer defaults to method xml and UTF-8 and ignores the Xalan indent-amount key, so deleting any of the three lines leaves every output byte the same (verified); kept as "the output settings used before" — —
21 XmlDocumentWriter.java:120 — write — try-with-resources closes the stream; an IOException from close() is a failed save — unpinnable by construction: Saxon has flushed every byte when endDocument returns, so an unclosed FileOutputStream leaves the same file (verified); the leak shows only as an open descriptor, and a test cannot make close() fail — —
22 XmlDocumentWriter.java:192-194 — sendElement — endPrefixMapping for each declared prefix — unpinnable by construction: Saxon 9.4's TransformerHandler ignores endPrefixMapping; dropping the loop leaves the output the same (verified); kept for the SAX contract — —
23 XmlDocumentWriter.java:180 — sendElement — the second loop skips xmlns and xmlns:* attributes — unpinnable by construction: Saxon 9.4's TransformerHandler drops xmlns attributes passed in startElement's Attributes; passing them changes nothing (verified); kept for the SAX contract — —
24 XmlDocumentWriter.java:154-156 — send — default: break; (document types and the like are not written) — unpinnable by construction: a no-op; deleting the label is an equivalent mutant (a switch without default does the same). Row 14's exact comparison with the old output already shows "no DOCTYPE" — —

A2: dispose() uses the writer (4fbcc47, row 5's test in d37978d)

# arm (file:line — function — condition) case observable mutant red at
1 XMLHandlerImpl.java:390 — dispose — the save goes through XmlDocumentWriter.write instead of Saxon's XPath //text() and DOMSender disposeNeverUsesNodeLists after init() + dispose() of a loaded file, XercesNodeLists.used(getDocument()) is false the old dispose() body (both Saxon walks go through NodeList.item(i)) 6f83fa3e
2 XMLHandlerImpl.java:389 — dispose — XmlDocumentWriter.normalizeText(document) before write whitespaceOnlyValueIsEmptiedBySave firstname " \t " is an empty element in the file, as before #157 write without normalizeText 6f83fa3e + row 1's write-only dispose()
3 XMLHandlerImpl.java:392-395 — dispose — catch (TransformerException | SAXException | IOException ex): ERROR line, then ConnectorException failedSaveIsLoggedAndThrown System.err has Failed saving changes to xml file: java.io.FileNotFoundException, and dispose() throws ConnectorException the catch without log.error (row 1's catch only rethrows; the compiler requires it from row 1 on) 6f83fa3e + row 1's write-only dispose()
4 XMLHandlerImpl.java:475 — createDocument — xmlns:icf set on the root before xmlns:xsi newFileDeclaresNamespacesAsBefore the new file declares icf, ri, xsi in that order no xmlns:icf attribute (the writer then declares icf last: ri, xsi, icf) 6f83fa3e + row 1's write-only dispose()
5 XMLHandlerImpl.java:397 — dispose — the closing log line of a successful save says Exit {0} successfulSaveLogsItsExit System.out has a line ending in Exit serialize the old text log.info("Entry {0}", method) dca40471

A3: IT on the packaged bundle (dca4047)

# arm (file:line — function — condition) case observable mutant red at
1 OpenICF-xml-connector/pom.xml:124-144 — maven-failsafe-plugin execution (integration-test, verify; include **/*BundleIT.java; system property bundleJar) XMLConnectorBundleIT#bundleSavesLoadsAndFindsEntries failsafe reports Tests run: 1 for XMLConnectorBundleIT no failsafe execution in the module (the IT compiles and never runs) 4fbcc475
2 XmlDocumentWriter.java:112 — write — Saxon's factory created directly (new net.sf.saxon.TransformerFactoryImpl(), A1's code), which only the bundle's child-first loader can tell apart XMLConnectorBundleIT#bundleSavesLoadsAndFindsEntries the IT passes inside the bundle and fails when write takes the JDK's transformer (SAXTransformerFactory) javax.xml.transform.TransformerFactory.newDefaultInstance() in write: under surefire only the format tests notice, the bundle's xml-apis 1.3.04 has no such method 4fbcc475

A4: a whitespace-only run is removed whole (d5236ca)

# arm (file:line — function — condition) case observable mutant red at
1 XmlDocumentWriter.java:93,96-98 — normalizeText — a blank run: every node of the run is removed, not only the first whitespaceOnlyRunOfSeveralNodesIsRemovedWhole a in <r><a> <![CDATA[ ]]> </a></r> has no children a blank run removes only its first node, as the old XPath did (the other 131 tests stay green on it) mutant only, arm from 6f83fa3e
2 XMLHandlerImpl.java:389 — dispose — normalizeText removes the two whitespace-only Text nodes that a deleted entry leaves side by side deletingTheLastEntryLeavesAnEmptyContainer after the only entry of a store file is deleted, the container in the file has no children the old XPath and its removal loop in place of normalizeText: the file keeps a line break (disposeNeverUsesNodeLists also fails, through its node-list probe); row 1's mutant fails it too mutant only, arm from 4fbcc475

A5: a merged run of text keeps its place (7b13f6f)

# arm (file:line — function — condition) case observable mutant red at
1 XmlDocumentWriter.java:94 — normalizeText — the merged Text node is inserted before the first node of its run mergedRunKeepsItsPlace x in <r><x>a<![CDATA[b]]><!--c--></x></r> holds the Text node ab, then the comment parent.appendChild(…) in place of insertBefore(…, node): the value moves after the comment (the other 134 tests stay green on it) mutant only, arm from 6f83fa3e

A6: review fixes (1f97256..bd2fe56)

# arm (file:line — function — condition) case observable mutant red at
1 XmlDocumentWriter.java:75 — normalizeText — an entity reference is not descended into: its children are read-only textInsideAnEntityReferenceIsLeftAlone with Xerces 2.6.2 and entity references not expanded, normalizeText returns and the reference keeps its blank Text node and its CDATA section Node child = node.getFirstChild(); (removeChild throws DOMException NO_MODIFICATION_ALLOWED_ERR) 7a1a5eb5
2 XmlDocumentWriter.java:209,212 — bind — an unprefixed DOM level 1 element (createElement) is in no namespace, undeclaring a default namespace in scope with xmlns="" unprefixedDomLevel1ElementIsInNoNamespace file bytes equal the old DOMSource output (<b xmlns=""/>), with and without an xmlns attribute on the parent if (uri == null) (the element takes the default namespace in scope) 1f97256e
3 XmlDocumentWriter.java:81-85 — normalizeText — a lone Text node is checked in place, without a list or a buffer — unpinnable by construction: the general branch gives the same tree for a lone Text node, so only allocation tells them apart; the conditions that choose the branch are pinned by A1 rows 4, 5, 8 and 9 — —
4 XMLHandlerImpl.java:387 — dispose — the document comes from getDocument(), with no monitor on it saveWithoutADocumentThrowsConnectorException dispose() on a handler whose init() never ran throws ConnectorException Data file does not exists: … synchronized (document) { … } around the save, as before (a NullPointerException) f238a5a3
5 XmlHandlerUtil.java:91 — following — loop bound n != null: a root that is not an ancestor acts as null followingWithARootOutsideTheAncestorsEndsAtTheTop following(b, a) is null in <r><a><x/></a><b/></r> bound n != root only (a NullPointerException above the document) e851b4ab

A7: second review fixes (b3fba18, faf5d15)

# arm (file:line — function — condition) case observable mutant red at
1 XmlDocumentWriter.java:226-228 — declare — a prefix the element already declares is not declared again twoXmlnsAttributesDeclareTheDefaultNamespaceOnce r with two xmlns attributes (setAttribute urn:y, then setAttributeNS urn:z, which Xerces puts first) reads back from the file, in urn:z the check dropped (xmlns declared twice, and the parser rejects the file; the other 141 tests stay green on it) bd2fe56f
2 XmlDocumentWriter.java:209 — bind — declared.contains(prefix): a prefix the element already declares, by an attribute or for its name, keeps that namespace aPrefixIsDeclaredOncePerElementAsBefore file bytes equal the old DOMSource output for four children that bind one prefix to two namespaces (<b xmlns="urn:y"/> three times, <p:b xmlns:p="urn:x" p:c="v"/>) both checks dropped, as at bd2fe56f (row 1's case fails too). The bind check alone is unpinnable: Saxon 9.4's serializer writes the declarations it got from startPrefixMapping and does not compare them with the namespace passed to startElement or addAttribute, so with row 1's check in place, dropping it leaves all 142 tests green (verified); kept so that every name is sent with the namespace the file gives it bd2fe56f
3 XmlDocumentWriter.java:75-76 — normalizeText — after an entity reference the walk goes on with following(node, root) textAfterAnEntityReferenceIsNormalized in <r>&e; <c> </c></r>, the blank Text node after the reference and the one inside c are removed return at the first entity reference (the other 141 tests stay green on it; at bd2fe56f all 139 did) mutant only, arm from 1f97256e

A1 row 6's test was corrected in review (a literal U+3000 became \u3000); its red was re-run with the corrected test on the tree without that arm.

Notes on the rows: A1 has 24 rows, 5 unpinnable (20-24); A2 has 5 rows; A3 has 2 rows; A4 has 2 rows; A5 has 1 row; A6 has 5 rows, 1 unpinnable (3); A7 has 3 rows, and row 2's bind check is unpinnable on its own. A2 row 5 was found at the last read: its arm is in 4fbcc47, its test was added afterwards, and its red ran on dca40471 with that one line reverted. A3 row 2's arm was written in A1, so its "red" is the manual mutant described above (applied, run, reverted).

@maximthomas
maximthomas marked this pull request as draft October 8, 2026 14:22
@maximthomas maximthomas added bug Something isn't working connector:xml XML connector performance Performance and scalability fixes java Pull requests that update java code tests Test additions or fixes build Maven build configuration and plugins labels Oct 8, 2026
@maximthomas
maximthomas marked this pull request as ready for review October 9, 2026 07:28
@maximthomas maximthomas self-assigned this Oct 9, 2026
@maximthomas
maximthomas requested a review from vharseko October 9, 2026 07:28

@vharseko vharseko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I found no correctness problem in the paths the connector actually uses: XMLHandlerImpl parses with entity references expanded, and dispose() runs under ConcurrentXMLHandler's write lock. The points below are two edge cases outside that path, three efficiency items, and two cleanups. I checked them against the PR description and #157 and don't argue with anything decided there: fixing only the save, sending Saxon events instead of using XSLTC, the namespace-order change, and leaving the atomic save to #160.

Edge cases (the connector's own parsing doesn't reach them)

1. normalizeText edits read-only entity-reference children — XmlDocumentWriter.java:95

Under the DOM rules, the children of an EntityReference are read-only. If a DOM is parsed with setExpandEntityReferences(false), as in <!DOCTYPE r [<!ENTITY e '<a/> <b/>'>]><r>&e;</r>, the walk reaches the blank text inside the entity and calls removeChild on it. That throws DOMException (NO_MODIFICATION_ALLOWED_ERR), which dispose()'s catch (TransformerException | SAXException | IOException) doesn't catch. The result: no "Failed saving changes" log line, no ConnectorException.wrap, and the file is never written. write() handles ENTITY_REFERENCE_NODE on purpose (entityReferencesAndDoctypeAreWrittenAsBefore), so normalizeText could skip entity-reference subtrees the same way. Unconfirmed: I haven't reproduced this on Xerces 2.6.2. The JDK parser I tried left the reference with no children.

2. bind() puts DOM level 1 nodes into a namespace the writer invented — XmlDocumentWriter.java:206

Take a parent built with createElementNS("urn:x", "a"), with no prefix and no xmlns attribute, and a child built with createElement("b"). The writer declares xmlns="urn:x" on <a>. For <b>, bind("", null) returns urn:x, so no xmlns="" is written and <b> reads back inside urn:x. The old serializer looked only at real xmlns attributes and left <b> in no namespace. The connector always uses prefixed names, so its own files aren't affected. This only matters to other callers of the writer.

Efficiency

3. A new Saxon factory, and so a new Configuration, on every save — XmlDocumentWriter.java:112

The connector isn't poolable, so nearly every operation ends in dispose() and pays this fixed cost. That matters most for small stores, where #157 measured 45–72 ms per operation. Because dispose() already runs under the write lock, one static factory (or one shared Configuration) would do. The old code had the same cost, but since this line is being rewritten anyway, it's a cheap fix.

4. normalizeText allocates for every text run — XmlDocumentWriter.java:81

Every text run, including the common case of a single TEXT_NODE with no text sibling, gets an ArrayList, a StringBuilder and a copy of its value. At 40,000 entries that is roughly 1M short-lived objects per save. A fast path would avoid most of it: when the node is a TEXT_NODE and getNextSibling() isn't text, check isXmlWhitespace(node.getNodeValue()) directly, and build the list and buffer only for real multi-node or CDATA runs.

5. The CRC-32 and the changed flag are computed but not used — XmlDocumentWriter.java:120

XMLHandlerImpl.dispose() ignores both return values, but every save still runs CRC-32 over the whole file (about 11.7 MB at 40,000 entries). If they are for #177, either add them there or say so here, as the description already does for setModified.

Cleanup

6. synchronized (document) no longer excludes anything — XMLHandlerImpl.java:388

The new comment notes that callers already hold the write lock, and nothing else in the module locks on the document. When init() failed and document is null, this block throws NullPointerException instead of the usual ConnectorException from getDocument(). Dropping the monitor, or going through getDocument(), would fix both.

7. following(node, root) throws NPE when root isn't an ancestor of node — XmlHandlerUtil.java:90

The loop climbs past the Document to null and then calls getNextSibling() on it. The precondition is only in the Javadoc, and this is a public method in a shared util class that #177 adds callers to. With n != null && n != root as the loop condition, misuse returns null instead of crashing.

@maximthomas

Copy link
Copy Markdown
Contributor Author

I checked each point on this branch with the module's own Xerces 2.6.2 and Saxon 9.4.0.7. Fixes are in 1f97256..bd2fe56. For point 3 I left the code as it is; the numbers are below. The description has been updated (A6 rows; the CRC note under "Change").

1. Entity-reference children. Reproduced on Xerces 2.6.2, with deferred and non-deferred DOMs: your '<a/> <b/>' gives NO_MODIFICATION_ALLOWED_ERR. So does a non-blank run of several nodes, such as 'x<![CDATA[y]]>'. The JDK parser leaves the reference empty, as you found. This was not a regression: on master the old XPath strip already failed on any unexpanded entity reference, because Saxon's DOM wrapper throws IllegalArgumentException: Unsupported node type in DOM! 5. That includes 'v<x/>', which this branch saves. normalizeText no longer descends into entity references (1f97256, textInsideAnEntityReferenceIsLeftAlone).

2. bind() and DOM level 1 elements. Confirmed, and the gap was a little wider than described. The old serializer wrote an unprefixed level 1 element in no namespace even when the parent's default namespace came from a real xmlns attribute. In both cases it wrote <a xmlns="urn:x"><b xmlns=""/></a>. The writer now does the same. A prefixed level 1 node still takes the namespace in scope, which matches the old output (cbbee60). unprefixedDomLevel1ElementIsInNoNamespace compares both cases byte for byte with the old DOMSource output.

3. A Saxon factory per save. The cost is real but small. On a warm JVM, new TransformerFactoryImpl() plus newTransformerHandler() takes about 34 µs and 10 KB; with a shared factory it is 2 µs. A save of 1,000 entries takes about 10 ms (1.5 ms in normalizeText, 8-11 ms in write), so the factory is about 0.3% of the save and under 0.1% of a 45-72 ms operation. Each query also builds a new XQueryHandler, and with it a new SaxonXQDataSource and Configuration: one or two per operation. One correction to the reasoning: the write lock belongs to the per-file ConcurrentXMLHandler, so saves of different stores can run in parallel. A shared factory would still be safe, because Saxon 9.4's newTransformerHandler() builds a new Controller on every call. Even so, for 34 µs I would keep the factory local. If you prefer a shared one, it is a one-line change.

4. Allocation in normalizeText. Confirmed. A freshly parsed store of 40,000 entries in the #157 shape has 560,001 text runs, all of them single Text nodes. One pass allocated 49 MB and took 45-231 ms. With the lone-Text shortcut it allocates 0.1 MB and takes 23-47 ms. On an already normalized store, the figures went from 22 MB and 18-34 ms to 0 MB and 7-10 ms, and the resulting trees are identical (f238a5a). normalizeAndWriteMakeALinearNumberOfDomCalls now counts 63,035 and 126,035 DOM calls for 1,000 and 2,000 entries.

5. The CRC-32 and changed. changed had no caller in either PR. It is gone, and normalizeText now returns void. normalizedDocumentIsLeftAlone now checks that the Text node is still the same object (b3b4792). The CRC stays. #177 compares it with the file's content when the file's timestamp is too recent to show a change (fileHasChanged()), and the description now says so, as it does for setModified. It costs 1.2-1.6 ms for 12.3 MB, about 0.3% of the write.

6. synchronized (document). Confirmed. The block is gone, and dispose() takes the document from getDocument() (e851b4a, saveWithoutADocumentThrowsConnectorException). For the record, the NPE was not new, since master had the same block, and the connector could not reach it: when init() fails, ConcurrentXMLHandler never counts the caller, and XMLConnector.dispose() returns early.

7. following(). The NPE is real. The loop condition is now n != null && n != root (bd2fe56, followingWithARootOutsideTheAncestorsEndsAtTheTop), and the Javadoc says that any other root acts as null. One correction: #177 does not add callers of following(), or of anything else in XmlHandlerUtil. Both callers are in normalizeText, and both pass a descendant of root.

#177 is rebased onto bd2fe56. In its commit that loads and saves only on change, the new test from point 6 becomes saveWithoutADocumentSavesNothing. There, dispose() with nothing to save returns before it touches the document.

@maximthomas
maximthomas requested a review from vharseko October 9, 2026 09:27

@vharseko vharseko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I checked the fix round 1f97256..bd2fe56 against the first round and your reply, on Xerces 2.6.2 and Saxon 9.4.0.7. Points 1, 4, 5, 6 and 7 are fixed as described. I accept both corrections in your reply (the write lock is per file; #177 adds no callers of following()), and keeping the factory local for point 3. The lone-Text shortcut builds the same tree as the old general branch for every input I tried. Two things are left, and the first one was introduced in this round.

1. bind() declares a prefix twice on one element, and the file is not well-formed — XmlDocumentWriter.java:205-212

sendElement first declares the element's own xmlns and xmlns:p attributes, then calls bind for the element's name. Since cbbee60, an unprefixed DOM level 1 element (its namespace is null) is bound to "". When that element has its own xmlns attribute with another value, bind declares "" a second time on the same element, and Saxon writes both declarations:

DOM (the parent is createElementNS("urn:x", "a")) old DOMSource output 7a1a5eb bd2fe56
createElement("b") + setAttribute("xmlns", "urn:y") <b xmlns="urn:y"/> <b xmlns="urn:y"/> <b xmlns="urn:y" xmlns=""/>
createElement("b") + setAttributeNS(XMLNS, "xmlns", "urn:y") <b xmlns="urn:y"/> <b xmlns="urn:y"/> <b xmlns="urn:y" xmlns=""/>
createElement("b") + setAttributeNS(XMLNS, "xmlns", "urn:x") <b/> <b xmlns="urn:x"/> <b xmlns="urn:x" xmlns=""/>
createElementNS("urn:x", "b") + setAttributeNS(XMLNS, "xmlns", "urn:y") <b xmlns="urn:y"/> <b xmlns="urn:y" xmlns="urn:x"/> as 7a1a5eb

A namespace-aware parser rejects every bd2fe56 cell, and the 7a1a5eb cell of the last row: Attribute "xmlns" bound to namespace "http://www.w3.org/2000/xmlns/" was already specified for element "b". The first three rows are new in cbbee60. The last row has been there since 6f83fa3, and I missed it in the first round. The connector does not build such a DOM: it parses namespace-aware, and its own elements and attributes don't contradict their xmlns attributes. So this is in the same category as point 2 of the first round, but the outcome is worse: instead of an element in the wrong namespace, the result is a file that cannot be loaded back.

A fix that gives the old output: let a declaration from the element's own attributes win, as DOMSource did.

String inScope = namespaces.getURI(prefix);
if (declared.contains(prefix)) {
    return inScope == null ? "" : inScope;
}

With this fix, rows 1, 2 and 4 are byte-for-byte equal to the old output. Row 3 becomes <b xmlns="urn:x"/>, the kept redeclaration that the description already lists. The module's 139 tests stay green. A case with the element's own xmlns attribute in unprefixedDomLevel1ElementIsInNoNamespace, or a separate test, would pin it.

2. textInsideAnEntityReferenceIsLeftAlone doesn't check that the walk continues after the reference — XmlDocumentWriterTests.java:148

The test kills the mutant in A6 row 1 (Node child = node.getFirstChild();). It does not kill one that stops the walk at the reference: with if (node.getNodeType() == Node.ENTITY_REFERENCE_NODE) { return; } before line 75, all 139 tests stay green, so a document with an unexpanded entity reference would keep all the whitespace after its first reference. Text after the reference would pin it. For example, with <r>&e; <c> </c></r>, assert that the blank Text node after the reference and the one inside c are removed.

@maximthomas

Copy link
Copy Markdown
Contributor Author

Both points are fixed in b3fba18 and faf5d15, checked with the module's Xerces 2.6.2 and Saxon 9.4.0.7. The description is updated: the file-format note under "Change", "Tests", and a new A7 table.

1. A prefix declared twice on one element. Reproduced. Your four DOMs give exactly the outputs in your table, and the parser rejects every bd2fe56 cell. I took your rule: a prefix that the element already declares keeps that namespace (bind, b3fba18). Rows 1, 2 and 4 now match the old output byte for byte. Row 3 is <b xmlns="urn:x"/>, the kept redeclaration that the description already lists.

The same defect has two more triggers, both there since 6f83fa3, and the fix covers both:

  • A prefixed attribute whose namespace contradicts the element's prefix: createElementNS("urn:x", "p:b") + setAttributeNS("urn:y", "p:c", "v"). bd2fe56 wrote xmlns:p twice. The writer now gives <p:b xmlns:p="urn:x" p:c="v"/>, the old output.
  • Two xmlns attributes on one element. setAttributeNS(XMLNS, "xmlns", …) does not find an attribute set with setAttribute("xmlns", …), so Xerces keeps both, and the first loop of sendElement declared both. Your check in bind does not see this case, so declare now skips a prefix that the element already declares, and the first declaration wins (twoXmlnsAttributesDeclareTheDefaultNamespaceOnce). The old serializer kept the other one (urn:y where the writer now writes urn:z). Both outputs are well-formed.

One difference from the old output remains. For a prefix other than the default one, the old serializer let the node's namespace win over the element's own xmlns:p attribute. createElementNS("urn:x", "p:b") + xmlns:p="urn:y" came out as <p:b xmlns:p="urn:x"/>, and a p:c attribute in urn:z next to xmlns:p="urn:y" came out with xmlns:p="urn:z". With the fix, the xmlns:p attribute wins for every prefix, so both now come out with xmlns:p="urn:y". The files are well-formed, and the node is in the declared namespace. bd2fe56 wrote two declarations in both cases, as every commit since 6f83fa3 did. Matching Saxon's split rule would mean holding the declarations back until the names are bound, and I don't think that is worth it for DOMs the connector does not build. The description now says this.

For the mutation record: the check in bind is unpinnable on its own. With the declare check in place, dropping declared.contains(prefix) from bind leaves all 142 tests green. Saxon 9.4's serializer writes the declarations it got from startPrefixMapping and does not compare them with the namespace passed to startElement or addAttribute. I kept the check so that every name is sent with the namespace the file gives it (A7 row 2). aPrefixIsDeclaredOncePerElementAsBefore compares four such children with the old DOMSource output and fails on bd2fe56's code. Dropping the declare check alone fails only the two-xmlns test.

2. The walk after an entity reference. Confirmed: with return at the first entity reference, all 139 tests at bd2fe56 stay green. textAfterAnEntityReferenceIsNormalized (faf5d15) parses <r>&e; <c> </c></r> with entity references not expanded, and asserts that the blank Text node after the reference and the one inside c are removed. It fails on that mutant, and the other 141 tests stay green on it. I made it a separate test, so that textInsideAnEntityReferenceIsLeftAlone still checks one thing.

mvn -o -pl OpenICF-xml-connector verify: 142 unit tests and 1 IT, green on JDK 26 and on JDK 11. #177 is rebased onto the new tip (946df85; 191 unit tests and 1 IT, green on both JDKs). It does not touch XmlDocumentWriter or its tests.

@vharseko vharseko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I checked the third round, b3fba18 and faf5d15, against my second review and your reply, with the module's Xerces 2.6.2 and Saxon 9.4.0.7. Both points are fixed as described. The four DOMs from my table and your two extra triggers now give well-formed output, rows 1, 2 and 4 match the old output byte for byte, and textAfterAnEntityReferenceIsNormalized kills the mutant that returns at the first entity reference. I accept letting the xmlns:p attribute win for every prefix instead of copying Saxon's split rule. I also accept that the bind check can't be pinned on its own: with the declare check in place, I get the same bytes without it. Every CI job that has finished is green; the two Windows jobs are still pending.

One claim in the reply doesn't hold, and there's one nit.

1. An attribute moved into the declared namespace can clash with another attribute — XmlDocumentWriter.java:209

The reply says that once the xmlns:p attribute wins for every prefix, "the files are well-formed". That holds for the element, but not for attributes. An attribute moved into the declared namespace can end up with the same expanded name as another attribute of the same element.

DOM: b = createElementNS("urn:r", "b"), then setAttributeNS(XMLNS, "xmlns:p", "urn:x"), setAttributeNS("urn:y", "p:c", "1") and setAttributeNS("urn:x", "q:c", "2").

b in the output namespace-aware parse
old DOMSource <b xmlns:p="urn:y" xmlns:q="urn:x" p:c="1" q:c="2"/> ok: {urn:y}c, {urn:x}c
bd2fe56 <b xmlns:p="urn:x" xmlns:p="urn:y" xmlns:q="urn:x" p:c="1" q:c="2"/> rejected: xmlns:p twice
faf5d15 <b xmlns:p="urn:x" xmlns:q="urn:x" p:c="1" q:c="2"/> rejected: Attribute "c" bound to namespace "urn:x" was already specified for element "b"

This round didn't introduce it: bd2fe56 already wrote an unreadable file here. Still, of the "any other prefix" differences you list, this is the only case I found where the old output was readable and the new one isn't. It's the same category as before: the connector doesn't build such a DOM, and a parsed store can't contain one. The old serializer has the same defect when the clash comes from the element's own prefix. With createElementNS("urn:x", "p:b") + p:c in urn:y + q:c in urn:x, both outputs are <p:b xmlns:p="urn:x" xmlns:q="urn:x" p:c="1" q:c="2"/>. The same holds for two p:c attributes in two namespaces, and in both cases the writer matches the old output byte for byte.

I'm not asking you to change the rule; closing this would need a fresh prefix for the moved attribute, which isn't worth it for such DOMs. A sentence in the description's file-format note is enough. It should say that when the namespace chosen for a prefix gives an attribute the expanded name of another attribute of the same element, the file is not well-formed, as with the old serializer when the clash comes from the element's prefix.

2. Nit: a broken line wrap in bind's Javadoc — XmlDocumentWriter.java:202-203

Line 202 breaks after "A DOM level 1 node has", well before the margin, and the sentence continues on line 203.

With the note in point 1 (or without it, if this thread is record enough), I have nothing else on this PR.

@maximthomas
maximthomas requested a review from vharseko October 9, 2026 16:50

@vharseko vharseko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Repeating review 5472693782 (of faf5d158) as a change request. The branch is still at faf5d158, nothing in the thread answers that review, and both of its points are still open:

  1. The description note on attribute clashes (XmlDocumentWriter.java:209). The file-format note under "Change" still has no sentence on it. It should say that when the namespace chosen for a prefix gives an attribute the expanded name of another attribute of the same element, the file is not well-formed. The old serializer does the same when the clash comes from the element's own prefix. The rule itself stays as it is; only the note is asked for. The DOM and the outputs are in 5472693782.
  2. Nit: the line wrap in bind's Javadoc (XmlDocumentWriter.java:202-203). Line 202 still breaks after "A DOM level 1 node has", and the sentence goes on in line 203.

With these two, I have nothing else on this PR. #177 has been approved and waits on this one.

@maximthomas

Copy link
Copy Markdown
Contributor Author

Both points of 5472693782, repeated in this review, are done. That review arrived after my reply to the second one (issuecomment-6084596815), and I missed it.

1. The attribute clash. I reproduced it with the module's Xerces 2.6.2 and Saxon 9.4.0.7. Your DOM gives exactly the three outputs in your table. With createElementNS("urn:x", "p:b"), the old serializer and write give the same bytes, and the parser rejects both. The same goes for two p:c attributes in two namespaces, with or without an xmlns:p attribute on the element. The rule stays as it is. The file-format note under "Change" now ends with:

When the namespace chosen for a prefix gives an attribute the expanded name of another attribute of the same element, the file is not well-formed. With xmlns:p="urn:x" on an element, its attributes p:c in urn:y and q:c in urn:x are both written as c in urn:x; the old serializer declared p as urn:y there and wrote a readable file. When the clash comes from the element's own prefix (p:b in urn:x with the same two attributes), the old serializer writes the same unreadable file as write, byte for byte.

2. The line wrap. d4fe809 rewraps bind's Javadoc. The words and the file's line count stay the same, so the file:line references in the description still point to the right lines.

mvn -o -pl OpenICF-xml-connector verify: 142 unit tests and 1 IT, green on JDK 26 and on JDK 11. #177 does not touch XmlDocumentWriter, so it stays at c7f327f, on top of faf5d15. After this PR is merged, it will be rebased onto master, as planned.

@maximthomas
maximthomas requested a review from vharseko October 11, 2026 10:03
@maximthomas
maximthomas merged commit dda8961 into OpenIdentityPlatform:master Oct 11, 2026
15 checks passed
@maximthomas
maximthomas deleted the issues/157-xml-linear-save branch October 11, 2026 15:28
maximthomas added a commit that referenced this pull request Oct 11, 2026
## Problem

`XMLConnector` is not poolable, so the framework runs `init()`, one
operation and `dispose()` per call. `ConcurrentXMLHandler` parses the
whole file when the number of invokers goes from 0 to 1 and writes it
back when it goes from 1 to 0, whatever the operation was. A search, an
`authenticate`, a `test` and a `schema` call therefore parse and rewrite
the file although nothing in it changed, and the cost is proportional to
the file size. #176 made the save linear; this PR removes the save, and
most parses, from the calls that do not need them.

Related defects found on the way:

- The old collision check (`version != lastModified &&
isExternallyModified()`) could never be true, because `version` and
`lastModified` were always assigned together, so an outside edit was
overwritten without a log line.
- Xerces' deferred DOM builds a node on its first read, and
`NodeList.item(i)` changes the document-wide `NodeListCache`. `search`
and `authenticate` run in parallel under the read lock, so the
connector's own reads wrote to the shared DOM. This PR removes those
writes. It does not remove all of them: Saxon's XQuery walks behind
`search` and `getEntry` (so `authenticate` too) still call
`NodeList.item(i)`, which updates the document's node list cache under
the read lock. Part 2 replaces the lookups by identifier and the
unfiltered search; searches with other filters stay on XQuery.
- `XMLConnector.init()` held the class monitor while it loaded a file,
or while it waited for another call to finish saving one, so one slow
file blocked connectors on every other file.

Lookups (the Saxon XQuery behind `search`) are not changed here and stay
as slow as they are; part 2 of #157 replaces the lookups by identifier
and the unfiltered search, and searches with other filters stay on
XQuery.

## Change

- Read-only operations do not write the file. `dispose()` returns early
(`Exit serialize: nothing to save`) unless the document holds a user
change (`dirty`) or is a new document that has not been saved yet
(`unsavedNewFile`). `dirty` is reset after a successful write.
- Reload by stamp. New `FileStamp` (package-private) records the
modification time, size and file key, or that the file does not exist.
`init()` parses again only when the stamp differs: the modification time
is compared for inequality (an older time counts, because `cp -p` sets
one), so is the size, so is the file key (a replaced file). Under a racy
stamp the CRC-32 of the file is compared with the CRC-32 of the bytes
last read or written. The racy flag is computed when the stamp is taken:
the modification time was less than 2 s before the clock, or after it.
That is stricter than the rule in the issue, which misses a write in the
same tick. After the connector's own save the stamp is always racy, so
without the content check the next `init()` would always parse again.
- `loadDocument()` parses the file once, through a `CheckedInputStream`,
so the CRC-32 is of the bytes parsed and no copy of the file stays in
memory through the parse. The source has the file's system id,
`xmlFile.toURI().toASCIIString()` as `DocumentBuilder.parse(File)` used
(a relative DTD still resolves, also under a non-ASCII path: the raw
non-ASCII URI made Xerces 2.6.2 fail with `MalformedURLException: no
protocol: store.dtd`). Right after the parse it calls
`XmlDocumentWriter.normalizeText(loaded)`. This PR keeps a loaded
document in memory and saves only on change, so without it a
hand-written or generated store would be served un-normalised for as
long as only reads happen (a CDATA value missing or cut short, a
whitespace-only value as `" "`); before this PR every save rewrote the
file normalised and the next call reparsed it. The normalisation does
not write the file and does not mark the document dirty. The stamp, the
checksum and the document are recorded only after a successful parse, so
a malformed file keeps failing until it is fixed.
- The bundle IT creates a store and loads an outside edit. Once loads
became conditional, #176's `XMLConnectorBundleIT` no longer parsed a
file inside the bundle (the first call created the store and no later
call reloaded it). It still starts without a file, so the first call
creates a new document under the bundle's child-first loader; after its
changes it rewrites the file from outside (`carol`, mtime an hour back)
and reads it through the facade, so a load runs under that loader too.
- The dirty flag.
- `markDirty()` runs before the mutation in `create` and `update`: a
mutation that fails midway is still saved, so the file agrees with
memory.
- In `delete` it runs after `removeChild`: that removal is
all-or-nothing, so a delete that fails marks nothing.
- A failed save keeps the change in memory, and the next `dispose()`
retries it. `init()` never reloads over an unsaved change to a loaded
document.
- A new document is not a user change. The empty document created when
the file is missing (`createFileIfNotExists`) has its own flag,
`unsavedNewFile`. A store file that appears before the new document is
saved wins over it, whether it appears after a failed first save, during
the first call, or while the new document is being created (the new
document starts from `FileStamp.MISSING`, what `buildDocument()`'s check
found, not from a second read): the connector never loaded that file, so
a save would replace a whole store. The new document is dropped together
with any changes made to it, which an ERROR line reports (`<file>
appeared before the new document was saved; keeping the file and
dropping the changes made to the new document`); the next call loads the
file, or starts a new document if the file is gone by then. Before this
PR such changes were lost as well when the file appeared between calls
(every call reloaded), and a file that appeared during the first call
was overwritten without a log line. The partial write of the connector's
own failed save is not such a file (the save takes the stamp again; if
that stamp cannot be read, the file is still taken for the partial
write, because only a save leaves a new document with an unreadable
stamp), and neither is a directory in the file's place, so those saves
are retried. For a new document a file that appears is the only reason
to reload: the content check behind a racy stamp applies to loaded
documents only, so an unchanged new document does not parse its own
partial write either.
- The collision check now fires. `dispose()` compares the stamp taken at
the last load or save attempt with the file's current stamp and logs
`UPDATE COLLISION: <file> has changed since it was loaded or saved;
overwriting it with the data in memory.` The stamp is refreshed in a
`finally` after every save attempt, so the retry of a failed, partly
written save does not report its own write. The line is not logged where
the path holds no file, because nothing is overwritten there (the retry
after a save failed on a directory that has since gone).
- A DOM without deferred expansion. The parser runs with
`http://apache.org/xml/features/dom/defer-node-expansion` set to
`false`, so every node exists after the parse and the connector's own
reads do not write to the document. (Saxon's XQuery lookups still update
the node list cache under the read lock; part 2 replaces the lookups by
identifier and the unfiltered search, and searches with other filters
keep them.) A parser that rejects the attribute is logged at WARN and
loading goes on. `ConnectorObjectCreator.createConnectorObject(Node)`
reads an entry through sibling pointers and never through a `NodeList`.
- The narrowed monitor. `XMLConnector.init()` keeps the handler cache
lookup and creation inside `synchronized (XMLConnector.class)` and calls
`handler.init()` (the load) outside it. `XMLHandlerCache` is
package-private and `ConcurrentXMLHandler` has a package-private
constructor taking an `XMLHandler`, both only so a test can block `init`
on one file and watch another.

### Known limits

- The collision check compares only the stamp, without a CRC: a
same-size write in the same tick is overwritten without a log line.
- The racy flag is computed when the stamp is taken, so a save that
crosses a 2-second tick boundary on FAT can yield a non-racy stamp.
- With `createFileIfNotExists=false`, a repeated save of a dirty
document recreates a deleted file.
- A store file deleted during a call that changes it is recreated with
the data in memory, without an `UPDATE COLLISION` line.
- If the stamp after a new document's failed save cannot be read (an
`IOException` other than `NoSuchFileException`), any file at the path is
taken for the connector's own partial write: the stamp cannot tell whose
write the file holds. The retry then logs `UPDATE COLLISION` against
that partial write. A store that someone else wrote there before the
retry is overwritten, with the same line; before d10cd2a that store was
kept and the new document was dropped. The connector's side is taken
because losing a store this way needs both failures and an outside
writer in between, while giving way made every later call parse the
connector's own partial write and fail (V4).
- A store shared by several connector servers, for example over NFS, is
not supported. The stamp comes from the file's attributes, and an NFS
client may answer those from its attribute cache (up to `acregmax`, 60 s
by default) where an open would revalidate, so another host's write can
go unseen and be overwritten by the next save. Before this PR every call
opened and parsed the file, but nothing locked it across hosts either.

## Measurements

Time through `ConnectorFacade`, one call per measurement (`init()`, the
operation, `dispose()`), after one warm-up size (1,000 entries, not
shown). The harness is the issue's `XmlPerf` with `-Dreps=3`:
`ri:__ACCOUNT__` entries with `__UID__`, `__NAME__` and a few more
fields, and a facade built over the generated file. "Before" is #176 at
d37978d (classes), "after" is this branch at 8334844, before its
rebases onto 7a1a5eb, bd2fe56, faf5d15 and, once #176 was merged,
master at dda8961 (the #176 commits added since d37978d change tests
and Javadoc, plus #176's review fixes (its A6 and A7 rows), of which
only a cheaper `normalizeText` touches these timings; the tables were
not measured again). Each cell is the median of the three runs' medians
(each run is one JVM with 3 repetitions per cell), with the smallest and
the largest of the three runs in parentheses. The six runs alternated
before/after on an otherwise idle machine (16:56 to 17:09, all exited
0). JDK 26 (Zulu 26.28), Intel Core i7-4850HQ, 8 logical CPUs, 16 GB,
macOS; all numbers from one developer machine, so read them as orders of
magnitude.

| Entries | search `__NAME__` before | after | `authenticate` before |
after | `update` before | after |
|---|---|---|---|---|---|---|
| 1,000 | 28 ms (23-30) | 9 ms (8-9) | 19 ms (16-24) | 5 ms (5-6) | 22
ms (19-29) | 15 ms (12-18) |
| 10,000 | 467 ms (408-536) | 155 ms (150-302) | 272 ms (208-507) | 45
ms (41-56) | 295 ms (292-342) | 147 ms (143-181) |
| 20,000 | 1,487 ms (1,285-1,496) | 597 ms (561-721) | 562 ms (510-793)
| 159 ms (147-160) | 869 ms (817-922) | 423 ms (409-443) |
| 40,000 | 5,075 ms (4,970-5,110) | 2,138 ms (1,972-2,206) | 1,615 ms
(1,556-1,623) | 597 ms (561-763) | 2,781 ms (2,663-3,216) | 1,346 ms
(1,295-1,375) |

| Entries | `create` before | after | search all before | after |
|---|---|---|---|---|
| 1,000 | 24 ms (21-30) | 16 ms (12-19) | 47 ms (46-55) | 26 ms (22-28)
|
| 10,000 | 422 ms (398-424) | 251 ms (238-274) | 538 ms (512-658) | 334
ms (334-340) |
| 20,000 | 1,358 ms (1,312-1,376) | 683 ms (664-698) | 1,540 ms
(1,517-1,574) | 732 ms (687-746) |
| 40,000 | 5,022 ms (4,989-5,084) | 2,283 ms (2,274-2,353) | 5,895 ms
(5,245-6,161) | 2,459 ms (2,400-2,578) |

`DOM pinned` is what the same operations cost on a document that is
already in memory, with no reload and no rewrite (the lower bound for a
lookup that still goes through XQuery). At 40,000 entries (median of the
three runs), before and after: search 4,483 and 2,164 ms, update 2,195
and 1,110 ms, create 4,455 and 2,093 ms.

Did a search rewrite the file? Before: yes, at every size, in all three
runs. After: no, at every size, in all three runs (`file rewritten by a
search: false`). That is the part of the change that does not depend on
the machine.

What the numbers show:

- "After" is faster than "before" in every cell of both tables, and at
40,000 entries the ranges of the three runs do not overlap in any column
(for example `authenticate` 1,556-1,623 ms before and 561-763 ms after;
`update` 2,663-3,216 ms and 1,295-1,375 ms).
- `authenticate` at 40,000 entries is 1,615 ms before and 597 ms after.
It no longer writes the file or parses it again. Its remaining cost is
the XQuery lookup, which this PR does not touch.
- A search by `__NAME__` at 40,000 entries is 5,075 ms before and 2,138
ms after, and it sits at the `DOM pinned` search (2,164 ms): what is
left is the lookup, which is quadratic and is part 2. At 10,000 entries
the three "after" runs scatter most for this operation (150 to 302 ms,
the median is 155 ms). The XQuery lookup is not changed by this PR, and
its cost depends on the state that earlier walks left in Xerces' node
list cache (the issue's probe shows one cache object on a free list that
points to itself); the numbers here do not show more than that the
scatter is in the search and not in the load or the save. `authenticate`
before the change scatters the same way at 10,000 entries (208 to 507
ms).
- `update` and `create` at 40,000 entries are about 2.1 times faster
(2,781 to 1,346 ms, 5,022 to 2,283 ms); both still pay for a save and
for the XQuery lookup of the entry, and `create` sits near its `DOM
pinned` value (2,093 ms).
- The numbers in the issue (13.5 s for a search at 40,000 entries) were
taken on master, before #176; #176 alone ("before" above) is already at
5.1 s on this machine.

## Tests

`mvn -o -pl OpenICF-xml-connector verify`, whole module, at `0db055fe`,
on JDK 26 and on JDK 11: surefire 196 run, 0 failed, 0 skipped; failsafe
1 run, 0 failed, 0 skipped (`XMLConnectorBundleIT`, which creates a
store and then loads an outside edit). (master at `dda89610`, which has
#176: surefire 142.)

New test classes and cases:

- `FileStampTests` (11): an unchanged file matches; an older mtime, a
size change under the same mtime, a replaced file, a deleted file and an
unreadable path (a symbolic link to itself) do not match; a missing file
matches `MISSING`; the racy window, on both sides of the clock (an mtime
an hour ahead, and one 500 ms old); the checksum; which stamps are known
(`readableAndMissingFilesAreKnown`).
- `XMLHandlerReloadTests` (41): read-only calls do not write the file; a
change is in the file when the last user leaves (create, update,
delete); a failed update, a failed delete of a nested entry, a failed
save and its retry; an unchanged file is served from memory; an outside
copy with an older mtime, a same-size edit in the racy window, and a
malformed edit; a racy load is not parsed again; an unreadable file
behind a racy stamp; a relative DTD; the `UPDATE COLLISION` line and its
absence on an own save, on a recreated deleted file, on the retry of a
partial write, and on the retry that creates the file; an own save is
not parsed again; a new file, a saved new file, and an unsaved new
document that gives way to a file that appears; the DOM is fully
expanded; `ConnectorObjectCreator` uses no node lists; a parser without
the defer attribute still loads and is reported; the CDATA value of a
loaded file is read (`cdataValueOfALoadedFileIsRead`); a relative DTD is
resolved under a directory with a non-ASCII name
(`relativeDtdIsResolvedUnderANonAsciiDirectory`); a store file that
appears before a new document is saved wins over it, after a failed save
of a change and during the first call with and without a change, while a
freed path and the connector's own partial write are saved again; a
whitespace-only value of a loaded file is absent
(`whitespaceOnlyValueOfALoadedFileIsAbsent`); an unchanged new document
whose save failed midway does not parse its partial write and saves
again (`ownPartialWriteOfAnUnchangedNewDocumentIsNotLoaded`); the
changes dropped for a file that appears stay dropped when the file goes
(`changesDroppedForAFileThatAppearsStayDroppedWhenItGoes`); a file that
appears while a new document is created, and a loaded store moved back
before the new document is saved, are kept
(`fileThatAppearsAsTheNewDocumentIsCreatedIsNotOverwritten`,
`loadedStoreMovedBackBeforeTheNewDocumentIsSavedIsKept`); the
connector's own partial write behind an unreadable stamp is saved again
(`ownPartialWriteBehindAnUnreadableStampIsSavedAgain`); a racy load with
100 KB after the root is not parsed again
(`racyLoadWithMarkupAfterTheRootIsNotParsedAgain`).
- `XMLConnectorTests` gains `readOnlyOperationsDoNotRewriteTheFile` and
`initOnOneFileDoesNotWaitForAnotherFile`; `disposeNeverUsesNodeLists` is
rewritten (see B2 row 28).
- Two tests map to no arm row: `FileStampTests#deletedFileIsAChange`
(every stamp of an existing file has an mtime and `MISSING` has none, so
it dies only on the first row's `return true`; kept as a readable case)
and `XMLHandlerReloadTests#externalEditWithANewerMtimeIsLoaded` (row 8's
mutant dies on `externalCopyWithAnOlderMtimeIsLoaded` as well; kept as
the plain case).

## Test strength

Each row is an arm of the production code, the case that reaches it,
what only that arm produces, and a mutant that only that case kills.
"red at" is the commit where the case was red before its arm existed
(B1: on `d37978d2`, #176 before its review commits; B2 rows 1-23 and 28:
on `93329684`; B3: on `2e6c4c7a`; B4: on `6eaed53f`; the final review
fixes: on the commit named in their rows; the second review's fixes:
`bd73b5eb`, or mutant only where the case was already green there; the
third review's fixes: `6533361b`, likewise; the fourth review's fixes:
the commit before each, or mutant only). Rows marked unpinnable are arms
that no test can tell from their absence; the reason is given as found.
Line numbers are at `0db055fe`. The reds were observed before this
branch was rebased onto `7a1a5eb5`, then onto `bd2fe56f`, then onto
`faf5d158`, then, once #176 was merged, onto master at `dda89610`. The
first, the third and the fourth rebase left every patch unchanged. The
second left every patch unchanged except `2e6c4c7a`: its `dispose()`
takes the document from `getDocument()` without the document monitor, as
#176 now does, and it turns #176's new
`saveWithoutADocumentThrowsConnectorException` into
`saveWithoutADocumentSavesNothing`, because here `dispose()` with
nothing to save returns before it touches the document. The #176 commits
added underneath since `d37978d2` (squashed into `dda89610`) change
tests and Javadoc, plus #176's review fixes (its A6 and A7 rows); the
other commits master gained since (#169, #170, #173) touch no file of
this module.

### B1: `FileStamp` (9332968)

| # | arm (`file:line` — function — condition) | case | observable |
mutant | red at |
|---|---|---|---|---|---|
| 1 | `FileStamp.java:63-69,102-104` — `read`, `sameState` — mtime, size
and file key are recorded; an unchanged file matches |
`unchangedFileMatches` | two reads of one unchanged file match |
`FileStamp` not defined (afterwards: `FileTime` or file key compared
with `==`) | `d37978d2` |
| 2 | `FileStamp.java:102` — `sameState` — `Objects.equals(lastModified,
other.lastModified)` | `olderMtimeIsAChange` | an older mtime does not
match | `sameState` without the mtime (returns `true`) | `d37978d2` |
| 3 | `FileStamp.java:103` — `sameState` — `size == other.size` |
`sizeChangeWithTheSameMtimeIsAChange` | a size change under the same
mtime does not match | no size comparison | `d37978d2` |
| 4 | `FileStamp.java:104` — `sameState` — `Objects.equals(fileKey,
other.fileKey)` | `replacedFileIsAChange` (SKIP where the file system
has no file keys) | a replaced file with the same size and mtime does
not match | no file key comparison | `d37978d2` |
| 5 | `FileStamp.java:72-73,99-101` — `read`, `sameState` — `catch
(IOException e)` returns `UNKNOWN`, and `this == UNKNOWN \|\| other ==
UNKNOWN` returns `false` (one road: `UNKNOWN` differs from `MISSING`
only through this guard) | `unreadableStampMatchesNothing` (a symbolic
link to itself: `ELOOP`; SKIP where links cannot be made, e.g. Windows
without the privilege) | the stamp of an unreadable path matches neither
itself nor `MISSING`, in either direction | `catch (IOException e) {
return MISSING; }`; also killed here: the guard dropped, the guard on
`this` only, on `other` only, `&&` for `\|\|` (verified) | `d37978d2` |
| 6 | `FileStamp.java:70-71` — `read` — `catch (NoSuchFileException e)`
returns `MISSING` | `missingFileMatchesMissing` | a missing file matches
`MISSING` | every `IOException` returns `UNKNOWN` | `d37978d2` |
| 7 | `FileStamp.java:69` — `read` — racy: `now - modified.toMillis() <
RACY_WINDOW_MILLIS` | `stampIsRacyOnlyNearTheClock`;
`stampIsRacyJustAfterAWrite` (37b4a3e) | an mtime an hour ahead is
racy, an hour old is not; an mtime 500 ms old is racy, 3 s old is not |
`racy` always `false`; only the second case kills `< 0` (only future
mtimes racy) and a window of 400 ms; a window of 4 s dies on it and on
`stampConfirmedByContentStopsBeingRacy` | `d37978d2`; the second case
mutant only (green at `6533361b`) |
| 8 | `FileStamp.java:78-86` — `checksum` — CRC-32 of the content |
`checksumFollowsTheContent` | equals the CRC-32 of `abc`, changes with
the content | `checksum` not defined | `d37978d2` |

`UNKNOWN`'s `racy = true` is never read (`fileHasChanged` asks
`isRacy()` only after `sameState` returned `true`); it is an equivalent
mutant and has no row. The first draft had an `exists` field; it is
gone, because an existing file always has a non-null
`lastModifiedTime()` and `exists == other.exists` could never decide
anything the mtime comparison did not. Rows 4 and 5 are SKIPPED where
their condition does not hold, so those arms are unpinned on such
runners (Windows CI).

### B2: reload and save only when needed (2e6c4c7)

| # | arm (`file:line` — function — condition) | case | observable |
mutant | red at |
|---|---|---|---|---|---|
| 1 | `XMLHandlerImpl.java:421-424` — `dispose` — keep side of the guard
`!dirty && !unsavedNewFile`: nothing to save, return |
`readOnlyCallsDoNotWriteTheFile` | mtime and bytes unchanged after
`search` and `authenticate` | the unconditional save (A2's `dispose`) |
`93329684` |
| 2 | `XMLHandlerImpl.java:543-545,421` — `createDocument`, `dispose` —
the document made for a missing file is saved by the first call (written
as `dirty = true` first; row 21 replaced it with `unsavedNewFile`) |
`newFileIsCreatedByTheFirstCall` | the file exists after one
`init`/`dispose` | nothing marks a new document (row 1's guard skips its
save) | `93329684` |
| 3 | `XMLHandlerImpl.java:277-278` — `create` — `markDirty()` before
`getDocument().getDocumentElement().appendChild(objElement)` |
`changeIsInTheFileWhenTheLastUserLeaves` | file names `[alice, bob]` |
the call deleted | `93329684` |
| 4 | `XMLHandlerImpl.java:333-334` — `update` — `markDirty()` before
`removeChildrenFromElement(entry, …)`, the entry's first change |
`failedUpdateLeavesTheFileInAgreementWithMemory` | after an update that
throws past the removal, the file's last names equal memory's `[null]` |
the call deleted; the call moved after the attribute's appends or after
the loop (verified red). The mutant "`markDirty()` one statement down,
right after `removeChildrenFromElement`" survives this case: that call
throws midway only on an XSD-invalid store whose entry holds a
same-named element below another child (`getElementsByTagName` searches
descendants), and no case builds such a store. The failure this case
uses, values removed before the single-valued check, is a defect master
has too, filed as #178; once it is fixed, this row needs another
mid-mutation failure | `93329684` |
| 5 | `XMLHandlerImpl.java:372-373` — `delete` — `markDirty()` after
`getDocument().getDocumentElement().removeChild(elementToRemove)` |
`deleteIsInTheFileWhenTheLastUserLeaves` | file names `[alice]` | the
call deleted | `93329684` |
| 6 | `XMLHandlerImpl.java:440` — `dispose` — `dirty = false` after a
successful write |
`XMLConnectorTests#readOnlyOperationsDoNotRewriteTheFile` | mtime and
bytes unchanged by read-only facade calls after a `create` | the reset
deleted: every later `dispose` writes again (verified on the final code
too; no other case kills it) | `93329684` |
| 7 | `XMLHandlerImpl.java:126` — `init` — keep side: a loaded document
is not parsed again | `unchangedFileIsServedFromMemory` | `[alice]`
after a same-size, same-mtime edit under a non-racy stamp | `init()`
calls `buildDocument()` every time | `93329684` |
| 8 | `XMLHandlerImpl.java:157-158,589` — `fileHasChanged`,
`loadDocument` — `!stamp.sameState(current)` reloads; `loadDocument`
records the stamp | `externalCopyWithAnOlderMtimeIsLoaded` | `[carol]`
after a copy with an older mtime | `init` reloads only when `document ==
null` | `93329684` |
| 9 | `XMLHandlerImpl.java:587-591` — `loadDocument` — stamp and
document recorded only after a successful parse |
`malformedEditFailsEveryCallUntilFixed` | the second `init()` on the
malformed file throws too | the stamp recorded before the parse |
`93329684` |
| 10 | `XMLHandlerImpl.java:163-166` — `fileHasChanged` — racy stamp,
different CRC-32 → reload | `sameSizeEditInsideTheRacyWindowIsLoaded` |
`[bobby]` after a same-size edit under a racy stamp | `fileHasChanged` =
`!stamp.sameState(current)` only | `93329684` |
| 11 | `XMLHandlerImpl.java:164,170-171,577-584,590` — `fileHasChanged`,
`loadDocument` — racy stamp, same CRC-32 → no reparse; `loadDocument`
records the CRC-32 of the bytes it parses (of a byte array until
19962a6, of the parser's stream since) | `racyLoadIsNotParsedAgain` |
the second call logs no `Loading XML document from` (the first call's
log has it, which proves the literal) | racy → always reload |
`93329684` |
| 12 | `XMLHandlerImpl.java:167-168` — `fileHasChanged` — `catch
(IOException e)` returns `true`: a file that cannot be read behind a
racy stamp is reloaded (and the load fails), not served from memory |
`unreadableFileBehindARacyStampFails` (SKIP as root or where permissions
cannot deny reads, e.g. Windows) | `init()` throws `ConnectorException`
| `catch … { return false; }` (row 11's stub) | `93329684` |
| 13 | `XMLHandlerImpl.java:170` — `fileHasChanged` — `stamp = current`
after the content matched | `stampConfirmedByContentStopsBeingRacy`
(same SKIP, and SKIP when the first load falls outside the racy window:
a slow load, or a file system with second-granularity mtimes) | the
third call serves `[alice]` without reading the now unreadable file |
the refresh deleted: every later `init` reads and checksums the whole
file | `93329684`, re-run after review |
| 14 | `XMLHandlerImpl.java:582` — `loadDocument` —
`source.setSystemId(xmlFile.toURI().toASCIIString())` |
`relativeDtdIsResolvedAgainstTheFile` | a DTD named relative to the
store file resolves | no system id: parsing from bytes resolves against
the working directory | `93329684` |
| 15 | `XMLHandlerImpl.java:432-434` — `dispose` — ERROR `UPDATE
COLLISION: {0} has changed since it was loaded or saved; overwriting it
with the data in memory.` |
`outsideEditDuringAChangeIsReportedAndOverwritten` | `System.err` has
`UPDATE COLLISION: <file> has changed since it was loaded or saved`, and
the file then holds `[alice, bob]` | the old check (`version !=
lastModified`, never true) | `93329684` |
| 16 | `XMLHandlerImpl.java:432` — `dispose` — keep side of
`!stamp.sameState(FileStamp.read(xmlFile))` |
`ownChangeIsSavedWithoutACollision` | no `UPDATE COLLISION` on an
ordinary save | the line logged on every save | `93329684` |
| 17 | `XMLHandlerImpl.java:543` — `createDocument` — the stamp is set
(read again until 03753ae, `FileStamp.MISSING` since) |
`deletedFileIsRecreatedWithoutACollision`; since `3c4cfc17` (T3) the
collision check skips a path with no file, so this case no longer kills
the mutant, and V3 pins the arm | no `UPDATE COLLISION` when a deleted
file is recreated empty | the write deleted (the deleted file's stamp
stays) | `93329684` |
| 18 | `XMLHandlerImpl.java:446-448` — `dispose` — `finally { stamp =
FileStamp.read(xmlFile); }`: the stamp follows every save attempt |
`retryAfterAFailedSaveDoesNotReportItsOwnPartialWrite` | no `UPDATE
COLLISION` when a save that failed mid-write (unpaired surrogate) is
retried | the read deleted, or done after a successful write only |
`93329684` |
| 19 | `XMLHandlerImpl.java:439` — `dispose` — `checksum =
XmlDocumentWriter.write(…)` | `ownSaveIsNotParsedAgain` | no `Loading
XML document from` after the connector's own save | the assignment
deleted | `93329684` |
| 20 | `XMLHandlerImpl.java:126` — `init` — `!dirty &&`: never reload
over an unsaved change to a loaded document (R1 is the exception for a
new document) | `failedSaveKeepsTheChangeInMemoryAndRetriesIt` |
`[alice, bob]` in memory and then in the file after a failed save and an
outside file | `document == null \|\| fileHasChanged()` | `93329684` |
| 21 | `XMLHandlerImpl.java:545,421` — `createDocument`, `dispose` —
`unsavedNewFile = true` instead of row 2's `dirty = true`; the guard
`!dirty && !unsavedNewFile` |
`unsavedNewDocumentGivesWayToAFileThatAppears` (its `names()` assertion)
| `[alice]` from a file that appeared after the new document's save
failed | a new document marked dirty (the issue's "treat a new document
as dirty") | `93329684` |
| 22 | `XMLHandlerImpl.java:441` — `dispose` — `unsavedNewFile = false`
after a successful write | `savedNewFileIsNotWrittenAgain` | the second
call logs `Exit serialize: nothing to save` | the reset deleted (every
later `dispose` writes again) | `93329684` |
| 23 | `XMLHandlerImpl.java:591` — `loadDocument` — `unsavedNewFile =
false` | `unsavedNewDocumentGivesWayToAFileThatAppears` (its bytes
assertion) | the file that appeared keeps its bytes | the reset deleted:
the loaded file is written back, reformatted | `93329684` |
| 24 | `XMLHandlerImpl.java:277-278` — `create` — placement of
`markDirty()` before `appendChild` | — | unpinnable by construction:
appending a detached new element to the document element cannot throw,
so "before" and "after" are the same state; moving the call after
`appendChild` keeps every case green (verified). Row 3 pins the call
itself | — | — |
| 25 | `XMLHandlerImpl.java:372-373` — `delete` — `markDirty()` after
`removeChild`: the removal is all-or-nothing, so a delete that fails
marks nothing | `failedDeleteOfANestedEntryLeavesTheFileAlone` | an
entry nested below another element (an XSD-invalid hand edit that
`getEntry`'s descendant query finds and `removeChild` on the document
element rejects with `NOT_FOUND_ERR`): after the failed delete the
file's bytes and mtime are unchanged | `markDirty()` before
`removeChild`: the failed delete marks the document dirty and
`dispose()` rewrites the file | red in the review round, on this
branch's commit before the fix (the first draft had declared this row
unpinnable; its reason, "cannot throw", was false) |
| 26 | `XMLHandlerImpl.java:576-580` — `loadDocument` — `FileStamp.read`
before the file is opened (before `Files.readAllBytes` until 19962a6) |
— | unpinnable by construction: only a write that lands between the two
statements tells the orders apart, and a test has no hook there | — | —
|
| 27 | `XMLHandlerImpl.java:577-584` — `loadDocument` — checksum the
bytes that are parsed, not the file a second time (since 19962a6 the
stream the parser reads) | — | unpinnable by construction: only a write
between two reads of the file tells them apart; no hook | — | — |
| 28 | `XMLHandlerImpl.java:435-439` — `dispose` — the save road (A2 row
1: `XmlDocumentWriter.normalizeText`/`write` instead of Saxon's
`//text()` XPath and `DOMSender`), which row 1's guard now skips for a
loaded, unchanged document |
`XMLConnectorTests#disposeNeverUsesNodeLists`, rewritten | the file
exists after `dispose()`, and `XercesNodeLists.used(getDocument())` is
`false` | a `NodeList` walk on the save road:
`document.getDocumentElement().getChildNodes().getLength();` inserted
right after the `if (!dirty && !unsavedNewFile) { … }` guard in
`dispose()` (stands for A2 row 1's mutant, the old Saxon walks) |
`93329684` |

Row 28 is an addition made after row 1 landed: A2's
`disposeNeverUsesNodeLists` loaded a two-entry file and called `init()`
+ `dispose()`, which now returns "nothing to save", so it would pass
whatever the save road does (checked: A2's version stays green with the
mutant line injected). A new document with only its root does not help
either, because Xerces 2.6.2 allocates no node list cache for a parent
with fewer than two children. The rewritten case makes a new document,
appends two entries through `getDocument()` with DOM calls that allocate
no cache, and lets `dispose()` save it; with the mutant it is red.

### B3: the connector's own reads do not write to the DOM (6eaed53)

| # | arm (`file:line` — function — condition) | case | observable |
mutant | red at |
|---|---|---|---|---|---|
| 1 | `XMLHandlerImpl.java:568` — `loadDocument` —
`docBuilderFactory.setAttribute(DEFER_NODE_EXPANSION, Boolean.FALSE)` |
`loadedDocumentIsFullyExpanded` | `getDocument()` is not a
`DeferredDocumentImpl` | the attribute not set | `2e6c4c7a` |
| 2 | `ConnectorObjectCreator.java:79` — `addAllAttributesToBuilder(Node
entry)` — attributes by `getFirstChild()`/`getNextSibling()` (with its
one call site, `XMLHandlerImpl.java:401` in `search(String, …)`, which
the signature change forces; the existing `XMLHandlerTests` value
assertions pin what `search` returns) |
`connectorObjectIsReadWithoutNodeLists` | uid, name and last name read,
and `XercesNodeLists.used` still `false` afterwards | the walk through
`entry.getChildNodes().item(i)` | `2e6c4c7a` |
| 3 | `XMLHandlerImpl.java:569` — `loadDocument` — `catch
(IllegalArgumentException ex)` around `setAttribute`: loading goes on |
`parserWithoutTheDeferAttributeStillLoads`: the JVM-wide setting
`javax.xml.parsers.DocumentBuilderFactory` names a JAXP parser whose
`setAttribute` throws `IllegalArgumentException`, which is what the JAXP
contract specifies for an attribute the parser does not recognise |
`[alice]` loaded | no catch: `IllegalArgumentException: Not supported:
http://apache.org/xml/features/dom/defer-node-expansion` out of `init()`
| `2e6c4c7a` |
| 4 | `XMLHandlerImpl.java:570` — `loadDocument` — the catch's WARN `The
XML parser {0} does not support {1}` |
`parserWithoutTheDeferAttributeIsReported` (same fixture) | `System.out`
has `The XML parser <factory class> does not support
http://apache.org/xml/features/dom/defer-node-expansion` | the catch
without the WARN | `2e6c4c7a` |

### B4: the class monitor does not cover the load (0111d43)

| # | arm (`file:line` — function — condition) | case | observable |
mutant | red at |
|---|---|---|---|---|---|
| 1 | `XMLConnector.java:97` — `init` — `handler.init()` runs after the
`synchronized (XMLConnector.class)` block |
`XMLConnectorTests#initOnOneFileDoesNotWaitForAnotherFile` | `init` on
another file returns within 10 s while a `BlockingHandler` holds `init`
on the first file | `handler.init()` inside the class monitor
(`TimeoutException` at `other.get`) | `6eaed53f` |
| 2 | `XMLConnector.java:84-90` — `init` — lookup and creation in
`XMLHandlerCache` stay inside the class monitor | — | unpinnable by
construction: dropping the monitor matters only when two `init()` calls
on one new path interleave between `get` and `put`, and a test cannot
force that interleaving | — | — |

### Final review fixes (84df879, d0efc9c, bd73b5e)

| # | arm (`file:line` — function — condition) | case | observable |
mutant | red at |
|---|---|---|---|---|---|
| F1 | `XMLHandlerImpl.java:586` — `loadDocument` —
`XmlDocumentWriter.normalizeText(loaded)` right after the parse |
`XMLHandlerReloadTests#cdataValueOfALoadedFileIsRead` | a store whose
`lastname` is a CDATA section, mtime an hour back: two read-only calls
each return `Last-alice` | the call absent: the first call returns
`[null]` | red at `0111d437` |
| F2 | `XMLHandlerImpl.java:582` — `loadDocument` —
`setSystemId(xmlFile.toURI().toASCIIString())` |
`XMLHandlerReloadTests#relativeDtdIsResolvedUnderANonAsciiDirectory`
(SKIP where a directory with a non-ASCII name cannot be created; since
1b76414 the directory is named after the test's store file, so one left
by a killed run cannot cause the SKIP) | a DTD next to a store in a
directory named with Cyrillic and accented letters resolves; `names()`
is `[alice]` | `toURI().toString()`: `MalformedURLException: no
protocol: store.dtd` | red at `84df8799` |
| F3 | `XMLHandlerImpl.java:561-591` — `loadDocument` under the bundle's
child-first loader (xml-apis 1.3.04, the bundle's Xerces) |
`XMLConnectorBundleIT#bundleSavesLoadsAndFindsEntries` | after the
facade's own changes (`[alice]` in the file), an outside edit (`carol`,
mtime an hour back) comes back from `getObject`; until 9661a0c the
store was written before the first call |
`DocumentBuilderFactory.newDefaultInstance()` in `loadDocument` (absent
from xml-apis 1.3.04: green under surefire, `NoSuchMethodError` only in
the bundle; the IT before this commit stayed green with it, with no
`Loading XML document from` line) | red at `d0efc9c0` |

### Second review fixes (529d3c6, 9661a0c, e30c0ae, 4625e87)

| # | arm (`file:line` — function — condition) | case | observable |
mutant | red at |
|---|---|---|---|---|---|
| R1 | `XMLHandlerImpl.java:123-124,139-141` — `init` —
`fileAppearedOverNewDocument()`: a store file that appeared after the
failed first save of a changed new document is loaded |
`XMLHandlerReloadTests#changedNewDocumentGivesWayToAFileThatAppears` |
the next search returns the file's `[alice, carol]`, and the file's
bytes are unchanged | `init()` without the call: the search returns
`[bob]`, and the next save replaces the store | `bd73b5eb` |
| R2 | `XMLHandlerImpl.java:426-430` — `dispose` — the same check: a
file that appeared during the call is kept |
`fileThatAppearsDuringAChangeToANewDocumentIsKept` (with a change),
`fileThatAppearsDuringTheFirstCallIsNotOverwritten` (an empty new
document) | the file's bytes are unchanged after `dispose()`, and the
next search returns its entries | `dispose()` without the check: `UPDATE
COLLISION`, and the file is overwritten | `bd73b5eb` |
| R3 | `XMLHandlerImpl.java:141` — `fileAppearedOverNewDocument` —
`!unsavedNewFile`: only a new document gives way |
`failedSaveKeepsTheChangeInMemoryAndRetriesIt`,
`outsideEditDuringAChangeIsReportedAndOverwritten` | a loaded document's
change still overwrites an outside edit, with `UPDATE COLLISION` | the
condition dropped: a loaded document gives way as well | mutant only
(green at `bd73b5eb`) |
| R4 | `XMLHandlerImpl.java:141` — `fileAppearedOverNewDocument` —
`!xmlFile.isFile()`: a directory in the file's place, or no file at all,
is not a file that appeared |
`changeToANewDocumentIsSavedOnceThePathIsFree`,
`changedNewDocumentGivesWayToAFileThatAppears`,
`unsavedNewDocumentGivesWayToAFileThatAppears` | after a save failed on
a directory in the file's place and the directory is gone, the next call
saves `[bob]` | the condition dropped: the change is dropped although no
file appeared; `exists()` for `isFile()`: the save onto the directory is
skipped instead of failing | mutant only (green at `bd73b5eb`) |
| R5 | `XMLHandlerImpl.java:141` — `fileAppearedOverNewDocument` —
`stamp.sameState(...)`: the partial write of the connector's own failed
save is not a file that appeared |
`ownPartialWriteOfANewDocumentIsSavedAgain` | the second `dispose()`
tries the save again (and fails again: the value cannot be encoded) |
the comparison dropped: the second `dispose()` drops the change and
returns | mutant only (green at `bd73b5eb`) |
| R6 | `XMLHandlerImpl.java:146-148` — `dropNewDocument` (in
`fileAppearedOverNewDocument` until 5934cdd) — the ERROR line, and
`dirty = false` | `changedNewDocumentGivesWayToAFileThatAppears`,
`fileThatAppearsDuringAChangeToANewDocumentIsKept` | stderr has `<file>
appeared before the new document was saved`; after the reload the file
is not written | the line removed: no ERROR; `dirty` kept: the reloaded
document is saved over the file | mutant only |
| R7 | `XMLHandlerImpl.java:586` — `loadDocument` — F1's
`normalizeText`, its whitespace-only half |
`whitespaceOnlyValueOfALoadedFileIsAbsent` | a `lastname` of one space
comes back absent, as after a save and a reload | `setCoalescing(true)`
on the factory instead of `normalizeText` (which fixes CDATA only): the
value comes back as `" "` | mutant only (green at `bd73b5eb`) |
| R8 | `XMLHandlerImpl.java:511` — `createDocument` under the bundle's
child-first loader |
`XMLConnectorBundleIT#bundleSavesLoadsAndFindsEntries` | the first
facade call creates the store inside the bundle (`Creating new xml
storage file`), and a later one loads the outside edit (`Loading XML
document from`) | `DocumentBuilderFactory.newDefaultInstance()` in
`createDocument`: `NoSuchMethodError` in the bundle only; the IT of
`bd73b5eb`, which started from an existing store, stayed green with it |
mutant only (green at `bd73b5eb`) |

### Third review fixes (40104db, bf79edd, 3c4cfc1)

| # | arm (`file:line` — function — condition) | case | observable |
mutant | red at |
|---|---|---|---|---|---|
| T1 | `XMLHandlerImpl.java:126` — `init` — `!unsavedNewFile &&`: a new
document reloads only for a file that appears, not for its own partial
write | `ownPartialWriteOfAnUnchangedNewDocumentIsNotLoaded` | a store
is loaded and then deleted, so the new document keeps the loaded file's
checksum; its save fails midway with no user change (an unpaired
surrogate added to the DOM); once that node is removed, the next
`init()` succeeds and `dispose()` writes an empty store | the condition
dropped: `init()` parses the partial file and throws `SAXParseException:
Premature end of file`, on every later call too | `6533361b` |
| T2 | `XMLHandlerImpl.java:150` — `dropNewDocument` (in
`fileAppearedOverNewDocument` until 5934cdd) — `document = null`: the
new document goes with the changes the ERROR line drops |
`changesDroppedForAFileThatAppearsStayDroppedWhenItGoes` | a store file
appears during a call that created `bob` in a new document and is
deleted afterwards: the next search returns `[]`, and the file that call
writes holds no entry | the line removed: the search returns `[bob]` and
the save writes it | `6533361b` |
| T3 | `XMLHandlerImpl.java:432` — `dispose` — `xmlFile.isFile() &&`: no
`UPDATE COLLISION` where no file is overwritten |
`changeToANewDocumentIsSavedOnceThePathIsFree` (its new stderr
assertion) | after a save failed on a directory in the file's place and
the directory is gone, the retry that creates the file logs no `UPDATE
COLLISION` | the condition dropped: the retry logs `UPDATE COLLISION`
against the directory's stamp | `6533361b` |

### Fourth review fixes (5934cdd, 03753ae, d10cd2a, 19962a6,
0736d45, a3d352d)

| # | arm (`file:line` — function — condition) | case | observable |
mutant | red at |
|---|---|---|---|---|---|
| V1 | `XMLHandlerImpl.java:123-124,426-427` — `init`, `dispose` —
`dropNewDocument()` at each call site; `fileAppearedOverNewDocument()`
(`:139-142`) is a plain check |
`changedNewDocumentGivesWayToAFileThatAppears`,
`unsavedNewDocumentGivesWayToAFileThatAppears` (`init`);
`changesDroppedForAFileThatAppearsStayDroppedWhenItGoes`,
`fileThatAppearsDuringAChangeToANewDocumentIsKept` (`dispose`) | as in
R1, R2 and T2 | the call removed from `init()`: the two `init` cases
fail; removed from `dispose()`: the two `dispose` cases fail | mutant
only (no behaviour change: all four were green before 5934cdd) |
| V2 | `XMLHandlerImpl.java:543` — `createDocument` — `stamp =
FileStamp.MISSING`, not a second read: a file that appears between
`buildDocument()`'s `exists()` and the stamp is not taken for the
connector's | `fileThatAppearsAsTheNewDocumentIsCreatedIsNotOverwritten`
(a store path whose `exists()` writes a store and returns `false`) | the
store's bytes are unchanged after `dispose()`, and the next search
returns `[alice]` | `FileStamp.read(config.getXmlFilePath())` as before:
the first `dispose()` writes the empty document over the store, with no
log line | `5934cdd3` |
| V3 | `XMLHandlerImpl.java:543` — `createDocument` — the assignment
itself (B2 row 17's arm) |
`loadedStoreMovedBackBeforeTheNewDocumentIsSavedIsKept` | a loaded store
is renamed away, a call starts a new document, and the store is renamed
back (same file key, size and mtime) before `dispose()`: the file still
holds `[alice]` | the assignment removed: the new document keeps the
loaded file's stamp, takes the store for its own and overwrites it, with
no log line (the whole module was green with this mutant before the
case) | mutant only (green at `3c4cfc17`, whose second read returned
`MISSING` here) |
| V4 | `XMLHandlerImpl.java:141` — `fileAppearedOverNewDocument` —
`stamp.isKnown() &&`: a file behind an unreadable stamp of the new
document's own save is its partial write |
`ownPartialWriteBehindAnUnreadableStampIsSavedAgain` (the stamp is read
through a link to itself while the save fails; SKIP where links cannot
be made or the file system does not report the loop as an error) | after
the save that failed midway, the next `init()` keeps the new document
and `dispose()` writes `[bob]`, with no ERROR about an appeared file |
the condition dropped: `init()` drops the document and throws
`SAXParseException: Premature end of file` | `03753ae3` |
| V5 | `FileStamp.java:93-96` — `isKnown` — `this != UNKNOWN` |
`unreadableStampMatchesNothing` (its new assertion),
`readableAndMissingFilesAreKnown` | the stamp of an unreadable path is
not known; those of an existing and of a missing file are | `return
true`: `unreadableStampMatchesNothing` and V4's case fail; `return
false`: `readableAndMissingFilesAreKnown` and six handler cases fail
(those of R1, R2, B2 row 21, V2 and V3) | mutant only (new in d10cd2a)
|
| V6 | `XMLHandlerImpl.java:577-584,590` — `loadDocument` — the CRC-32
comes from the stream the parser reads (`CheckedInputStream`), not from
a byte array kept through the parse | `racyLoadIsNotParsedAgain` (B2 row
11), `racyLoadWithMarkupAfterTheRootIsNotParsedAgain` (100 KB of comment
and a processing instruction after the root) | the second call logs no
`Loading XML document from` | the stream without the file's last byte;
the CRC-32 taken into a `CRC32` that is not recorded: both cases fail |
mutant only (behaviour unchanged: both green at `d10cd2a1`) |

Notes on the rows: B1 has 8 rows, none unpinnable; B2 has 28 rows, 3
unpinnable (24, 26, 27); B3 has 4 rows; B4 has 2 rows, 1 unpinnable; the
final review fixes have 3 rows; the second review's fixes have 8 rows,
none unpinnable; the third review's fixes have 3 rows, none unpinnable;
the fourth review's fixes have 6 rows, none unpinnable. The fifth review
(of `a3d352d9`) found no code defect. It asked for the second
consequence of the `UNKNOWN` rule under Known limits, and for
`StatFailingFile`'s Javadoc to name the callers it diverts (0db055f):
with a writer that opened `toPath()`, V4's case fails at
`assertTrue(file.exists())` (observed). The fourth review (of
`de0ab50a`) found that an unreadable stamp after a new document's failed
save made the next call drop the document and parse its partial write
(V4), and that `fileAppearedOverNewDocument()` changed state inside
`init()`'s `||` chain (V1); it also suggested the streamed load (V6).
Answering it found that a file that appeared while a new document was
created was overwritten (V2), and that B2 row 17 had lost its pin with
T3 (V3). Each V mutant was run alone at `a3d352d9` against
`XMLHandlerReloadTests` and `FileStampTests` (V3's also against the
whole module) and failed only the cases named in its row; the R3-R6 and
T1 mutants were run again at `a3d352d9` and still fail their cases.
Without `document != null &&` in `init()` every case stays green, and
that mutant is equivalent: with no document `dirty` is always false, so
the drop changes nothing, and the guard only saves a stat. 0736d45
changes only V4's case: its SKIP asked `isKnown()`, so V5's `return
true` mutant made the case a SKIP instead of a failure. V6's second case
pins what no code mutant can change, that the parser reads to the end of
the file. Xerces 2.6.2 does: in a probe of 48 files (with and without a
BOM, ISO-8859-1, 100 KB of trailing white space, comments and a
processing instruction after the root), the CRC-32 of the bytes it read
equalled the file's in every one. A parser that stopped earlier would
cost reloads inside the racy window, not a missed change, since a
different CRC-32 reads as a change. The third review (of `6533361b`)
found that an unchanged new document whose save failed midway parsed its
own partial write on every later call (T1), that the changes the ERROR
line drops stayed in the document and came back once the appeared file
was gone (T2), and a false `UPDATE COLLISION` on the retry that creates
the file (T3); it also found that B1 row 7 pinned only the future half
of the racy window. The T1-T3 mutants and B1 row 7's `< 0` and 400 ms
mutants were each run alone, and each failed only its case among
`XMLHandlerReloadTests` and `FileStampTests`. The second review (of
`bd73b5eb`) found that a changed new document overwrote a store file
that appeared after its first save failed (R1; R2 also covers a file
that appears during the first call, the earlier review's M2) and that
the IT no longer created a store inside the bundle (R8); R3-R7 pin
conditions with mutants that were each run alone. The final review found
I1 (the bundle IT no longer parsed a file, F3) and I2 (a loaded file was
served un-normalised, F1), both pinned above; it also led to F2 and to
the narrower reader claim. Platform SKIPs: B1 row 4 (no file keys), B1
row 5 and V4 (symbolic links cannot be made), B2 rows 12 and 13 (root,
or Windows where permissions cannot deny reads), and B2 row 13 also when
the first load falls outside the racy window. Those arms are unpinned on
such runners, which includes the Windows CI job; the module run above
had none of them skipped (0 skipped on JDK 26 and JDK 11). B2 row 4's
surviving mutant and B2 row 25 are described in the rows: row 25 started
as unpinnable in the first draft, a review showed that reason was false,
`markDirty()` moved after `removeChild`, and the case was added. The red
for B2 row 13 was re-run after the review added its second SKIP. The
reds were observed by running each case on the tree without its arm (B1:
on `d37978d2`; B2: rows built in order on `93329684`).

Part of #157
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working build Maven build configuration and plugins connector:xml XML connector java Pull requests that update java code performance Performance and scalability fixes tests Test additions or fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants