Repository navigation
[#157] Load and save the XML file only when it has changed - #177
maximthomas merged 24 commits into
Conversation
8334844 to
dfcd754
Compare
f7716f5 to
de0ab50
Compare
vharseko
left a comment
There was a problem hiding this comment.
Review of the PR's own 17 commits (from 000bba1b to de0ab50a), checked against the description and the three earlier review rounds. I found no new correctness bug in the stamp, racy-window or dirty logic. Points the description already settles (dropping a new document's changes when a file appears, the stamp-only collision check, the package-private XMLHandlerCache) are not raised again.
1. Pre-existing: update() validates after it removes the old values (XMLHandlerImpl.java:328-336)
update() checks the single-valued rule (!isMultiValued() && values.size() > 1) only after removeChildrenFromElement() has deleted the entry's existing values. This is not a regression: master has the same order (lines 280-286), and its dispose() saved on every call, so the loss already reached the disk there. This PR keeps that on purpose ("file agrees with memory"), and failedUpdateLeavesTheFileInAgreementWithMemory uses the bug as its failure scenario.
Scenario: update(alice, lastname=["A","B"]) on a single-valued attribute throws IllegalArgumentException, but alice's lastname is already gone from memory and then from the file.
Suggestion: a separate issue, not a change in this PR. Compute values and run the size check before the removal, as create() validates before appendChild. Once that is fixed, B2 row 4 needs a different mid-mutation failure to pin markDirty().
2. A stamp of UNKNOWN after a failed save drops the new document (XMLHandlerImpl.java:137, :441)
If FileStamp.read() in dispose()'s finally hits an IOException other than NoSuchFileException, stamp becomes UNKNOWN. sameState(UNKNOWN) is always false, so fileAppearedOverNewDocument() takes the connector's own partial write for a foreign file.
Scenario: a new document's first save fails midway and leaves a partial file, and readAttributes in finally fails transiently. On the next init(), isFile() is true and the stamp does not match, so the change is dropped with the ERROR "appeared before the new document was saved", and the truncated file is parsed (SAXParseException until someone fixes it). This takes two failures in a row, so it is unlikely. A cheap guard: in finally, keep the previous stamp when the new read returns UNKNOWN, or do not treat UNKNOWN as "appeared" after the connector's own failed save.
3. Optional: loadDocument keeps the whole file as a byte[] during the parse (XMLHandlerImpl.java:570-573)
Files.readAllBytes holds a file-sized buffer through the parse, and the CRC-32 is computed inline, apart from FileStamp.checksum. Passing a CheckedInputStream over Files.newInputStream as the InputSource byte stream keeps the property from rows 26-27 (the parsed bytes are the checksummed bytes) without the buffer, and gives one CRC path. The saving is small next to the DOM, so this is a minor point. Caveat: the CRC matches FileStamp.checksum only if the parser reads to EOF. Xerces does, because it scans trailing Misc, but a test should cover it.
4. Optional: a predicate with side effects inside || (XMLHandlerImpl.java:122, :421)
fileAppearedOverNewDocument() returns a boolean but also nulls the document, clears dirty and logs an ERROR, and it is called from init()'s || chain and from dispose()'s guard. Whether the state changes depends on where the call sits in the short-circuit: reordering the arms of init(), or one more call for logging, silently changes behaviour. A pure fileAppeared() check plus an explicit dropNewDocument() at each of the two call sites would be clearer. This is a style point.
de0ab50 to
946df85
Compare
|
Answers to review 5471660670 (of 1. 2.
3. Streamed load. Done in 42b24ec: the parser reads the file through a
4. Predicate with side effects. Done in 57df639: While answering point 2 I found that B2 row 17 (the stamp The PR body has a new "Fourth review fixes" table (V1-V6), with updated line numbers, counts and Known limits. |
vharseko
left a comment
There was a problem hiding this comment.
Review of the fixes for review 5471660670: the six commits from 57df639c to ef7e35b0 on top of 946df85c, checked against the answer in 6085280873 and the "Fourth review fixes" table. All four points are closed, and I found no new correctness bug.
- 2 (
UNKNOWNstamp).fileAppearedOverNewDocument()now hasstamp.isKnown(). 16fc375 makes the Javadoc's premise true:createDocument()starts fromFileStamp.MISSING, so the only way a new document gets anUNKNOWNstamp is thefinallyof a failed save. It also closes theexists()-to-read window (V2). - 3 (streamed load). The
CheckedInputStreamsees the bytes the parser reads, the stamp is still taken before the open, and a parser that stops early only costs a reload. - 4 (side effects).
init()split in two has the same truth table as the old||chain. Thedocument != nullguard is equivalent, as the body says. - V3. It restores the pin that B2 row 17 lost with
946df85c.
Checked locally at ef7e35b0: XMLHandlerReloadTests + FileStampTests, 52 run, 0 failed, 0 skipped. With stamp.isKnown() && removed, only ownPartialWriteBehindAnUnreadableStampIsSavedAgain fails (SAXParseException: Premature end of file). With createDocument() reading the stamp again, only fileThatAppearsAsTheNewDocumentIsCreatedIsNotOverwritten fails. Both match rows V4 and V2.
Two points to address:
1. Known limits names only one side of the UNKNOWN rule
After a new document's save fails and its stamp cannot be read, any file at the path is taken for the connector's own partial write. The body lists the harmless consequence: the retry logs a false UPDATE COLLISION. It does not list the other one. A store written there by someone else in that window is overwritten, with an UPDATE COLLISION line. Before 1d220ab that store was kept and the new document was dropped. Choosing the connector's side is reasonable: it takes two failures plus an outside writer. I'd still add one sentence under Known limits, so the trade-off is written down.
2. StatFailingFile redirects every toPath(), not only the stamp
ownPartialWriteBehindAnUnreadableStampIsSavedAgain works because FileStamp.read goes through toPath(), while XmlDocumentWriter.write opens the file with new FileOutputStream(File) and isFile() uses the path string. If the writer ever moves to Files.newOutputStream(file.toPath()), the save fails on ELOOP before writing a byte. The case then fails at assertTrue(file.exists()) with no hint why. One line in the fixture's Javadoc saying which callers it diverts would help whoever hits that.
|
Answers to review 5480057341 (of 1. Known limits. Added. The 2. The PR body has the new Known limits text, a note on this review, the commit count (24) and the tip. |
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: #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 #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).
…ile loading a file
…w document is saved
…t in the bundle IT
…ed file is absent
…e path cannot be encoded
stampIsRacyOnlyNearTheClock used fixtures an hour away from the clock, so a racy check that covered only future modification times passed every test. The new test puts one fixture inside the window (500 ms ago) and one just past it (3 s ago).
…e test's store file A run killed before the cleanup left the fixed directory behind, and every later local run reported the only pin of the ASCII system id as SKIP until mvn clean.
…ged new document A save of a new document that failed after writing part of the file took that file's stamp but kept the old checksum. With nothing changed in memory, the next init() compared the content under the racy stamp, parsed the partial file and failed, on every later call. For a new document only a file that appears is a reason to reload, so the content check now applies to loaded documents only, and dispose() saves again.
…over it The ERROR line said the changes made to the new document were dropped, but only the dirty flag was cleared: the document kept them until a load succeeded. If the file that appeared was then removed, or failed to parse and was removed, the next init() saw no change, served the dropped entries and saved them. The document is now dropped with the flag, so the next init() loads the file or starts a new document.
…re to overwrite A save that failed on a directory at the store path took the directory's stamp. Once the directory was gone, the retry compared that stamp with the missing file and logged UPDATE COLLISION, though it only created the file. Recreating a missing file is not a collision here, as after a reload; changeToANewDocumentIsSavedOnceThePathIsFree now checks the retry's log.
…ot inside the check fileAppearedOverNewDocument() returned a boolean but also dropped the new document, cleared the dirty flag and logged the ERROR, and it ran inside init()'s || chain and dispose()'s guard: reordering init()'s arms, or one more call for logging, would have changed the state. It is now a plain check, and init() and dispose() drop the document through dropNewDocument(). Behaviour is unchanged.
…t from a second look buildDocument() starts a new document when exists() finds no file, and createDocument() then read the stamp again. A file that appeared between the two, while the parser factory loaded, became the stamp of the new document, so the first dispose() overwrote it with the empty document and logged nothing. The stamp is now MISSING, what the check found, and such a file wins like any file that appears before the first save. A new document can now hold an unreadable stamp only after a save of its own.
… a file behind an unreadable stamp A failed save takes the stamp again, so its partial write does not count as a file that appeared. If that stamp could not be read (an IOException other than NoSuchFileException), it matched nothing: the next init() dropped the new document with the ERROR for an appeared file and parsed the partial write, which failed until someone fixed the file. Only a save leaves a new document with an unreadable stamp, so the file is now taken for its partial write and saved over; that retry logs UPDATE COLLISION, as the stamp cannot tell whose write the file holds. The test reads the stamp through a link to itself.
…hout a copy in memory loadDocument() read the whole file into a byte array, checksummed the array and parsed it, so a file-sized buffer stayed alive through the parse, next to the new DOM and the old one. The parser now reads the file through a CheckedInputStream. The checksum still covers exactly the bytes parsed, because Xerces reads on to the end of the file for what may follow the root; a parser that stopped earlier would cost reloads inside the racy window, not a missed change. The new test puts 100 KB of comment after the root.
…em, not from isKnown() ownPartialWriteBehindAnUnreadableStampIsSavedAgain skipped when FileStamp.read() of its link to itself was known, so an isKnown() that always returned true made the test a SKIP instead of a failure. It now asks Files.readAttributes(), as unreadableStampMatchesNothing does.
Since 946df85 the collision check skips a path that holds no file, so deletedFileIsRecreatedWithoutACollision no longer failed when createDocument() left the stamp of the deleted file in place: with the assignment removed, the whole module stayed green. A store moved away and back during the first call of the new document returns with that old stamp, and the new case keeps it only if the new document starts from MISSING.
The fixture redirects every toPath(), not only the stamp's. ownPartialWriteBehindAnUnreadableStampIsSavedAgain works because XmlDocumentWriter.write opens a FileOutputStream on the path string and isFile() uses it too. A writer that opened toPath() would fail on the link before writing a byte, and the case would fail at assertTrue(file.exists()) with no hint why.
c7f327f to
0db055f
Compare
Problem
XMLConnectoris not poolable, so the framework runsinit(), one operation anddispose()per call.ConcurrentXMLHandlerparses 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, anauthenticate, atestand aschemacall 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:
version != lastModified && isExternallyModified()) could never be true, becauseversionandlastModifiedwere always assigned together, so an outside edit was overwritten without a log line.NodeList.item(i)changes the document-wideNodeListCache.searchandauthenticaterun 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 behindsearchandgetEntry(soauthenticatetoo) still callNodeList.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
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).dirtyis reset after a successful write.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, becausecp -psets 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 nextinit()would always parse again.loadDocument()parses the file once, through aCheckedInputStream, 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()asDocumentBuilder.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 withMalformedURLException: no protocol: store.dtd). Right after the parse it callsXmlDocumentWriter.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.XMLConnectorBundleITno 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.markDirty()runs before the mutation increateandupdate: a mutation that fails midway is still saved, so the file agrees with memory.deleteit runs afterremoveChild: that removal is all-or-nothing, so a delete that fails marks nothing.dispose()retries it.init()never reloads over an unsaved change to a loaded document.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 fromFileStamp.MISSING, whatbuildDocument()'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.dispose()compares the stamp taken at the last load or save attempt with the file's current stamp and logsUPDATE COLLISION: <file> has changed since it was loaded or saved; overwriting it with the data in memory.The stamp is refreshed in afinallyafter 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).http://apache.org/xml/features/dom/defer-node-expansionset tofalse, 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 aNodeList.XMLConnector.init()keeps the handler cache lookup and creation insidesynchronized (XMLConnector.class)and callshandler.init()(the load) outside it.XMLHandlerCacheis package-private andConcurrentXMLHandlerhas a package-private constructor taking anXMLHandler, both only so a test can blockiniton one file and watch another.Known limits
createFileIfNotExists=false, a repeated save of a dirty document recreates a deleted file.UPDATE COLLISIONline.IOExceptionother thanNoSuchFileException), 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 logsUPDATE COLLISIONagainst 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).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'sXmlPerfwith-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 cheapernormalizeTexttouches 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.__NAME__beforeauthenticatebeforeupdatebeforecreatebeforeDOM pinnedis 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:
authenticate1,556-1,623 ms before and 561-763 ms after;update2,663-3,216 ms and 1,295-1,375 ms).authenticateat 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.__NAME__at 40,000 entries is 5,075 ms before and 2,138 ms after, and it sits at theDOM pinnedsearch (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.authenticatebefore the change scatters the same way at 10,000 entries (208 to 507 ms).updateandcreateat 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, andcreatesits near itsDOM pinnedvalue (2,093 ms).Tests
mvn -o -pl OpenICF-xml-connector verify, whole module, at0db055fe, 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 atdda89610, 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 matchesMISSING; 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; theUPDATE COLLISIONline 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;ConnectorObjectCreatoruses 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).XMLConnectorTestsgainsreadOnlyOperationsDoNotRewriteTheFileandinitOnOneFileDoesNotWaitForAnotherFile;disposeNeverUsesNodeListsis rewritten (see B2 row 28).FileStampTests#deletedFileIsAChange(every stamp of an existing file has an mtime andMISSINGhas none, so it dies only on the first row'sreturn true; kept as a readable case) andXMLHandlerReloadTests#externalEditWithANewerMtimeIsLoaded(row 8's mutant dies onexternalCopyWithAnOlderMtimeIsLoadedas 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: on93329684; B3: on2e6c4c7a; B4: on6eaed53f; 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 at0db055fe. The reds were observed before this branch was rebased onto7a1a5eb5, then ontobd2fe56f, then ontofaf5d158, then, once #176 was merged, onto master atdda89610. The first, the third and the fourth rebase left every patch unchanged. The second left every patch unchanged except2e6c4c7a: itsdispose()takes the document fromgetDocument()without the document monitor, as #176 now does, and it turns #176's newsaveWithoutADocumentThrowsConnectorExceptionintosaveWithoutADocumentSavesNothing, because heredispose()with nothing to save returns before it touches the document. The #176 commits added underneath sinced37978d2(squashed intodda89610) 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)file:line— function — condition)FileStamp.java:63-69,102-104—read,sameState— mtime, size and file key are recorded; an unchanged file matchesunchangedFileMatchesFileStampnot defined (afterwards:FileTimeor file key compared with==)d37978d2FileStamp.java:102—sameState—Objects.equals(lastModified, other.lastModified)olderMtimeIsAChangesameStatewithout the mtime (returnstrue)d37978d2FileStamp.java:103—sameState—size == other.sizesizeChangeWithTheSameMtimeIsAChanged37978d2FileStamp.java:104—sameState—Objects.equals(fileKey, other.fileKey)replacedFileIsAChange(SKIP where the file system has no file keys)d37978d2FileStamp.java:72-73,99-101—read,sameState—catch (IOException e)returnsUNKNOWN, andthis == UNKNOWN || other == UNKNOWNreturnsfalse(one road:UNKNOWNdiffers fromMISSINGonly through this guard)unreadableStampMatchesNothing(a symbolic link to itself:ELOOP; SKIP where links cannot be made, e.g. Windows without the privilege)MISSING, in either directioncatch (IOException e) { return MISSING; }; also killed here: the guard dropped, the guard onthisonly, onotheronly,&&for||(verified)d37978d2FileStamp.java:70-71—read—catch (NoSuchFileException e)returnsMISSINGmissingFileMatchesMissingMISSINGIOExceptionreturnsUNKNOWNd37978d2FileStamp.java:69—read— racy:now - modified.toMillis() < RACY_WINDOW_MILLISstampIsRacyOnlyNearTheClock;stampIsRacyJustAfterAWrite(37b4a3e)racyalwaysfalse; 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 onstampConfirmedByContentStopsBeingRacyd37978d2; the second case mutant only (green at6533361b)FileStamp.java:78-86—checksum— CRC-32 of the contentchecksumFollowsTheContentabc, changes with the contentchecksumnot definedd37978d2UNKNOWN'sracy = trueis never read (fileHasChangedasksisRacy()only aftersameStatereturnedtrue); it is an equivalent mutant and has no row. The first draft had anexistsfield; it is gone, because an existing file always has a non-nulllastModifiedTime()andexists == other.existscould 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)
file:line— function — condition)XMLHandlerImpl.java:421-424—dispose— keep side of the guard!dirty && !unsavedNewFile: nothing to save, returnreadOnlyCallsDoNotWriteTheFilesearchandauthenticatedispose)93329684XMLHandlerImpl.java:543-545,421—createDocument,dispose— the document made for a missing file is saved by the first call (written asdirty = truefirst; row 21 replaced it withunsavedNewFile)newFileIsCreatedByTheFirstCallinit/dispose93329684XMLHandlerImpl.java:277-278—create—markDirty()beforegetDocument().getDocumentElement().appendChild(objElement)changeIsInTheFileWhenTheLastUserLeaves[alice, bob]93329684XMLHandlerImpl.java:333-334—update—markDirty()beforeremoveChildrenFromElement(entry, …), the entry's first changefailedUpdateLeavesTheFileInAgreementWithMemory[null]markDirty()one statement down, right afterremoveChildrenFromElement" survives this case: that call throws midway only on an XSD-invalid store whose entry holds a same-named element below another child (getElementsByTagNamesearches 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 failure93329684XMLHandlerImpl.java:372-373—delete—markDirty()aftergetDocument().getDocumentElement().removeChild(elementToRemove)deleteIsInTheFileWhenTheLastUserLeaves[alice]93329684XMLHandlerImpl.java:440—dispose—dirty = falseafter a successful writeXMLConnectorTests#readOnlyOperationsDoNotRewriteTheFilecreatedisposewrites again (verified on the final code too; no other case kills it)93329684XMLHandlerImpl.java:126—init— keep side: a loaded document is not parsed againunchangedFileIsServedFromMemory[alice]after a same-size, same-mtime edit under a non-racy stampinit()callsbuildDocument()every time93329684XMLHandlerImpl.java:157-158,589—fileHasChanged,loadDocument—!stamp.sameState(current)reloads;loadDocumentrecords the stampexternalCopyWithAnOlderMtimeIsLoaded[carol]after a copy with an older mtimeinitreloads only whendocument == null93329684XMLHandlerImpl.java:587-591—loadDocument— stamp and document recorded only after a successful parsemalformedEditFailsEveryCallUntilFixedinit()on the malformed file throws too93329684XMLHandlerImpl.java:163-166—fileHasChanged— racy stamp, different CRC-32 → reloadsameSizeEditInsideTheRacyWindowIsLoaded[bobby]after a same-size edit under a racy stampfileHasChanged=!stamp.sameState(current)only93329684XMLHandlerImpl.java:164,170-171,577-584,590—fileHasChanged,loadDocument— racy stamp, same CRC-32 → no reparse;loadDocumentrecords the CRC-32 of the bytes it parses (of a byte array until 19962a6, of the parser's stream since)racyLoadIsNotParsedAgainLoading XML document from(the first call's log has it, which proves the literal)93329684XMLHandlerImpl.java:167-168—fileHasChanged—catch (IOException e)returnstrue: a file that cannot be read behind a racy stamp is reloaded (and the load fails), not served from memoryunreadableFileBehindARacyStampFails(SKIP as root or where permissions cannot deny reads, e.g. Windows)init()throwsConnectorExceptioncatch … { return false; }(row 11's stub)93329684XMLHandlerImpl.java:170—fileHasChanged—stamp = currentafter the content matchedstampConfirmedByContentStopsBeingRacy(same SKIP, and SKIP when the first load falls outside the racy window: a slow load, or a file system with second-granularity mtimes)[alice]without reading the now unreadable fileinitreads and checksums the whole file93329684, re-run after reviewXMLHandlerImpl.java:582—loadDocument—source.setSystemId(xmlFile.toURI().toASCIIString())relativeDtdIsResolvedAgainstTheFile93329684XMLHandlerImpl.java:432-434—dispose— ERRORUPDATE COLLISION: {0} has changed since it was loaded or saved; overwriting it with the data in memory.outsideEditDuringAChangeIsReportedAndOverwrittenSystem.errhasUPDATE COLLISION: <file> has changed since it was loaded or saved, and the file then holds[alice, bob]version != lastModified, never true)93329684XMLHandlerImpl.java:432—dispose— keep side of!stamp.sameState(FileStamp.read(xmlFile))ownChangeIsSavedWithoutACollisionUPDATE COLLISIONon an ordinary save93329684XMLHandlerImpl.java:543—createDocument— the stamp is set (read again until 03753ae,FileStamp.MISSINGsince)deletedFileIsRecreatedWithoutACollision; since3c4cfc17(T3) the collision check skips a path with no file, so this case no longer kills the mutant, and V3 pins the armUPDATE COLLISIONwhen a deleted file is recreated empty93329684XMLHandlerImpl.java:446-448—dispose—finally { stamp = FileStamp.read(xmlFile); }: the stamp follows every save attemptretryAfterAFailedSaveDoesNotReportItsOwnPartialWriteUPDATE COLLISIONwhen a save that failed mid-write (unpaired surrogate) is retried93329684XMLHandlerImpl.java:439—dispose—checksum = XmlDocumentWriter.write(…)ownSaveIsNotParsedAgainLoading XML document fromafter the connector's own save93329684XMLHandlerImpl.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 filedocument == null || fileHasChanged()93329684XMLHandlerImpl.java:545,421—createDocument,dispose—unsavedNewFile = trueinstead of row 2'sdirty = true; the guard!dirty && !unsavedNewFileunsavedNewDocumentGivesWayToAFileThatAppears(itsnames()assertion)[alice]from a file that appeared after the new document's save failed93329684XMLHandlerImpl.java:441—dispose—unsavedNewFile = falseafter a successful writesavedNewFileIsNotWrittenAgainExit serialize: nothing to savedisposewrites again)93329684XMLHandlerImpl.java:591—loadDocument—unsavedNewFile = falseunsavedNewDocumentGivesWayToAFileThatAppears(its bytes assertion)93329684XMLHandlerImpl.java:277-278—create— placement ofmarkDirty()beforeappendChildappendChildkeeps every case green (verified). Row 3 pins the call itselfXMLHandlerImpl.java:372-373—delete—markDirty()afterremoveChild: the removal is all-or-nothing, so a delete that fails marks nothingfailedDeleteOfANestedEntryLeavesTheFileAlonegetEntry's descendant query finds andremoveChildon the document element rejects withNOT_FOUND_ERR): after the failed delete the file's bytes and mtime are unchangedmarkDirty()beforeremoveChild: the failed delete marks the document dirty anddispose()rewrites the fileXMLHandlerImpl.java:576-580—loadDocument—FileStamp.readbefore the file is opened (beforeFiles.readAllBytesuntil 19962a6)XMLHandlerImpl.java:577-584—loadDocument— checksum the bytes that are parsed, not the file a second time (since 19962a6 the stream the parser reads)XMLHandlerImpl.java:435-439—dispose— the save road (A2 row 1:XmlDocumentWriter.normalizeText/writeinstead of Saxon's//text()XPath andDOMSender), which row 1's guard now skips for a loaded, unchanged documentXMLConnectorTests#disposeNeverUsesNodeLists, rewrittendispose(), andXercesNodeLists.used(getDocument())isfalseNodeListwalk on the save road:document.getDocumentElement().getChildNodes().getLength();inserted right after theif (!dirty && !unsavedNewFile) { … }guard indispose()(stands for A2 row 1's mutant, the old Saxon walks)93329684Row 28 is an addition made after row 1 landed: A2's
disposeNeverUsesNodeListsloaded a two-entry file and calledinit()+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 throughgetDocument()with DOM calls that allocate no cache, and letsdispose()save it; with the mutant it is red.B3: the connector's own reads do not write to the DOM (6eaed53)
file:line— function — condition)XMLHandlerImpl.java:568—loadDocument—docBuilderFactory.setAttribute(DEFER_NODE_EXPANSION, Boolean.FALSE)loadedDocumentIsFullyExpandedgetDocument()is not aDeferredDocumentImpl2e6c4c7aConnectorObjectCreator.java:79—addAllAttributesToBuilder(Node entry)— attributes bygetFirstChild()/getNextSibling()(with its one call site,XMLHandlerImpl.java:401insearch(String, …), which the signature change forces; the existingXMLHandlerTestsvalue assertions pin whatsearchreturns)connectorObjectIsReadWithoutNodeListsXercesNodeLists.usedstillfalseafterwardsentry.getChildNodes().item(i)2e6c4c7aXMLHandlerImpl.java:569—loadDocument—catch (IllegalArgumentException ex)aroundsetAttribute: loading goes onparserWithoutTheDeferAttributeStillLoads: the JVM-wide settingjavax.xml.parsers.DocumentBuilderFactorynames a JAXP parser whosesetAttributethrowsIllegalArgumentException, which is what the JAXP contract specifies for an attribute the parser does not recognise[alice]loadedIllegalArgumentException: Not supported: http://apache.org/xml/features/dom/defer-node-expansionout ofinit()2e6c4c7aXMLHandlerImpl.java:570—loadDocument— the catch's WARNThe XML parser {0} does not support {1}parserWithoutTheDeferAttributeIsReported(same fixture)System.outhasThe XML parser <factory class> does not support http://apache.org/xml/features/dom/defer-node-expansion2e6c4c7aB4: the class monitor does not cover the load (0111d43)
file:line— function — condition)XMLConnector.java:97—init—handler.init()runs after thesynchronized (XMLConnector.class)blockXMLConnectorTests#initOnOneFileDoesNotWaitForAnotherFileiniton another file returns within 10 s while aBlockingHandlerholdsiniton the first filehandler.init()inside the class monitor (TimeoutExceptionatother.get)6eaed53fXMLConnector.java:84-90—init— lookup and creation inXMLHandlerCachestay inside the class monitorinit()calls on one new path interleave betweengetandput, and a test cannot force that interleavingFinal review fixes (84df879, d0efc9c, bd73b5e)
file:line— function — condition)XMLHandlerImpl.java:586—loadDocument—XmlDocumentWriter.normalizeText(loaded)right after the parseXMLHandlerReloadTests#cdataValueOfALoadedFileIsReadlastnameis a CDATA section, mtime an hour back: two read-only calls each returnLast-alice[null]0111d437XMLHandlerImpl.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)names()is[alice]toURI().toString():MalformedURLException: no protocol: store.dtd84df8799XMLHandlerImpl.java:561-591—loadDocumentunder the bundle's child-first loader (xml-apis 1.3.04, the bundle's Xerces)XMLConnectorBundleIT#bundleSavesLoadsAndFindsEntries[alice]in the file), an outside edit (carol, mtime an hour back) comes back fromgetObject; until 9661a0c the store was written before the first callDocumentBuilderFactory.newDefaultInstance()inloadDocument(absent from xml-apis 1.3.04: green under surefire,NoSuchMethodErroronly in the bundle; the IT before this commit stayed green with it, with noLoading XML document fromline)d0efc9c0Second review fixes (529d3c6, 9661a0c, e30c0ae, 4625e87)
file:line— function — condition)XMLHandlerImpl.java:123-124,139-141—init—fileAppearedOverNewDocument(): a store file that appeared after the failed first save of a changed new document is loadedXMLHandlerReloadTests#changedNewDocumentGivesWayToAFileThatAppears[alice, carol], and the file's bytes are unchangedinit()without the call: the search returns[bob], and the next save replaces the storebd73b5ebXMLHandlerImpl.java:426-430—dispose— the same check: a file that appeared during the call is keptfileThatAppearsDuringAChangeToANewDocumentIsKept(with a change),fileThatAppearsDuringTheFirstCallIsNotOverwritten(an empty new document)dispose(), and the next search returns its entriesdispose()without the check:UPDATE COLLISION, and the file is overwrittenbd73b5ebXMLHandlerImpl.java:141—fileAppearedOverNewDocument—!unsavedNewFile: only a new document gives wayfailedSaveKeepsTheChangeInMemoryAndRetriesIt,outsideEditDuringAChangeIsReportedAndOverwrittenUPDATE COLLISIONbd73b5eb)XMLHandlerImpl.java:141—fileAppearedOverNewDocument—!xmlFile.isFile(): a directory in the file's place, or no file at all, is not a file that appearedchangeToANewDocumentIsSavedOnceThePathIsFree,changedNewDocumentGivesWayToAFileThatAppears,unsavedNewDocumentGivesWayToAFileThatAppears[bob]exists()forisFile(): the save onto the directory is skipped instead of failingbd73b5eb)XMLHandlerImpl.java:141—fileAppearedOverNewDocument—stamp.sameState(...): the partial write of the connector's own failed save is not a file that appearedownPartialWriteOfANewDocumentIsSavedAgaindispose()tries the save again (and fails again: the value cannot be encoded)dispose()drops the change and returnsbd73b5eb)XMLHandlerImpl.java:146-148—dropNewDocument(infileAppearedOverNewDocumentuntil 5934cdd) — the ERROR line, anddirty = falsechangedNewDocumentGivesWayToAFileThatAppears,fileThatAppearsDuringAChangeToANewDocumentIsKept<file> appeared before the new document was saved; after the reload the file is not writtendirtykept: the reloaded document is saved over the fileXMLHandlerImpl.java:586—loadDocument— F1'snormalizeText, its whitespace-only halfwhitespaceOnlyValueOfALoadedFileIsAbsentlastnameof one space comes back absent, as after a save and a reloadsetCoalescing(true)on the factory instead ofnormalizeText(which fixes CDATA only): the value comes back as" "bd73b5eb)XMLHandlerImpl.java:511—createDocumentunder the bundle's child-first loaderXMLConnectorBundleIT#bundleSavesLoadsAndFindsEntriesCreating new xml storage file), and a later one loads the outside edit (Loading XML document from)DocumentBuilderFactory.newDefaultInstance()increateDocument:NoSuchMethodErrorin the bundle only; the IT ofbd73b5eb, which started from an existing store, stayed green with itbd73b5eb)Third review fixes (40104db, bf79edd, 3c4cfc1)
file:line— function — condition)XMLHandlerImpl.java:126—init—!unsavedNewFile &&: a new document reloads only for a file that appears, not for its own partial writeownPartialWriteOfAnUnchangedNewDocumentIsNotLoadedinit()succeeds anddispose()writes an empty storeinit()parses the partial file and throwsSAXParseException: Premature end of file, on every later call too6533361bXMLHandlerImpl.java:150—dropNewDocument(infileAppearedOverNewDocumentuntil 5934cdd) —document = null: the new document goes with the changes the ERROR line dropschangesDroppedForAFileThatAppearsStayDroppedWhenItGoesbobin a new document and is deleted afterwards: the next search returns[], and the file that call writes holds no entry[bob]and the save writes it6533361bXMLHandlerImpl.java:432—dispose—xmlFile.isFile() &&: noUPDATE COLLISIONwhere no file is overwrittenchangeToANewDocumentIsSavedOnceThePathIsFree(its new stderr assertion)UPDATE COLLISIONUPDATE COLLISIONagainst the directory's stamp6533361bFourth review fixes (5934cdd, 03753ae, d10cd2a, 19962a6, 0736d45, a3d352d)
file:line— function — condition)XMLHandlerImpl.java:123-124,426-427—init,dispose—dropNewDocument()at each call site;fileAppearedOverNewDocument()(:139-142) is a plain checkchangedNewDocumentGivesWayToAFileThatAppears,unsavedNewDocumentGivesWayToAFileThatAppears(init);changesDroppedForAFileThatAppearsStayDroppedWhenItGoes,fileThatAppearsDuringAChangeToANewDocumentIsKept(dispose)init(): the twoinitcases fail; removed fromdispose(): the twodisposecases failXMLHandlerImpl.java:543—createDocument—stamp = FileStamp.MISSING, not a second read: a file that appears betweenbuildDocument()'sexists()and the stamp is not taken for the connector'sfileThatAppearsAsTheNewDocumentIsCreatedIsNotOverwritten(a store path whoseexists()writes a store and returnsfalse)dispose(), and the next search returns[alice]FileStamp.read(config.getXmlFilePath())as before: the firstdispose()writes the empty document over the store, with no log line5934cdd3XMLHandlerImpl.java:543—createDocument— the assignment itself (B2 row 17's arm)loadedStoreMovedBackBeforeTheNewDocumentIsSavedIsKeptdispose(): the file still holds[alice]3c4cfc17, whose second read returnedMISSINGhere)XMLHandlerImpl.java:141—fileAppearedOverNewDocument—stamp.isKnown() &&: a file behind an unreadable stamp of the new document's own save is its partial writeownPartialWriteBehindAnUnreadableStampIsSavedAgain(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)init()keeps the new document anddispose()writes[bob], with no ERROR about an appeared fileinit()drops the document and throwsSAXParseException: Premature end of file03753ae3FileStamp.java:93-96—isKnown—this != UNKNOWNunreadableStampMatchesNothing(its new assertion),readableAndMissingFilesAreKnownreturn true:unreadableStampMatchesNothingand V4's case fail;return false:readableAndMissingFilesAreKnownand six handler cases fail (those of R1, R2, B2 row 21, V2 and V3)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 parseracyLoadIsNotParsedAgain(B2 row 11),racyLoadWithMarkupAfterTheRootIsNotParsedAgain(100 KB of comment and a processing instruction after the root)Loading XML document fromCRC32that is not recorded: both cases faild10cd2a1)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 theUNKNOWNrule under Known limits, and forStatFailingFile's Javadoc to name the callers it diverts (0db055f): with a writer that openedtoPath(), V4's case fails atassertTrue(file.exists())(observed). The fourth review (ofde0ab50a) 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 thatfileAppearedOverNewDocument()changed state insideinit()'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 ata3d352d9againstXMLHandlerReloadTestsandFileStampTests(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 ata3d352d9and still fail their cases. Withoutdocument != null &&ininit()every case stays green, and that mutant is equivalent: with no documentdirtyis always false, so the drop changes nothing, and the guard only saves a stat. 0736d45 changes only V4's case: its SKIP askedisKnown(), so V5'sreturn truemutant 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 (of6533361b) 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 falseUPDATE COLLISIONon 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< 0and 400 ms mutants were each run alone, and each failed only its case amongXMLHandlerReloadTestsandFileStampTests. The second review (ofbd73b5eb) 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 afterremoveChild, 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: ond37978d2; B2: rows built in order on93329684).Part of #157