Repository navigation
[#157] Save the XML document in linear time - #176
maximthomas merged 16 commits into
Conversation
…n integration test
…the XML save walks stay linear
…clarations written by hand
vharseko
left a comment
There was a problem hiding this comment.
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.
…one when normalizing
… no namespace, as before
… list or a buffer
…n root is not an ancestor
|
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 2. 3. A Saxon factory per save. The cost is real but small. On a warm JVM, 4. Allocation in 5. The CRC-32 and 6. 7. #177 is rebased onto bd2fe56. In its commit that loads and saves only on change, the new test from point 6 becomes |
vharseko
left a comment
There was a problem hiding this comment.
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.
|
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 ( The same defect has two more triggers, both there since 6f83fa3, and the fix covers both:
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 For the mutation record: the check in 2. The walk after an entity reference. Confirmed: with
|
vharseko
left a comment
There was a problem hiding this comment.
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.
vharseko
left a comment
There was a problem hiding this comment.
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:
- 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. - 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.
|
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
2. The line wrap. d4fe809 rewraps
|
## 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
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
XMLConnectoris not poolable, so an operation that does not overlap with another one on the same file ends inXMLHandlerImpl.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__throughConnectorFacadetook 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, andDOMSenderwhenTransformerFactoryImplserializes aDOMSource. Both go throughNodeList.item(i). On Xerces 2.6.2 the document'sNodeListCachefree list can become a self-cycle (freeNodeListCachepushes a cache that is already on the list), after which everyitem(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
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 whatnormalize-spacestrips; 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.NodeList;normalizeTextsteps with the newXmlHandlerUtil.following(node, root)(the next sibling, or the next sibling of the nearest ancestor belowroot; arootthat is not an ancestor acts asnull).XMLHandlerImpl.dispose()callsnormalizeTextandwriteinstead of the XPath and theDOMSourcetransform. It takes the document throughgetDocument()and no longer locks the document's monitor: every caller holdsConcurrentXMLHandler's write lock, and nothing else locks on the document. A handler without a document now fails withgetDocument()'sConnectorExceptioninstead of aNullPointerException; the connector does not reach that case, because a failedinit()is never followed by the handler'sdispose(). A failed save is still an ERROR log line, thenConnectorException. Two cases change: anIOExceptionfrom closing the file was logged as WARN and the save counted as successful, and is now a failed save (A1 row 21), and theXPathExpressionExceptionthat the old code ignored is gone with the XPath. The old closinglog.info("Entry {0}", method)now saysExit.createDocument()setsxmlns:icfon the root beforexmlns:xsi, so a new file declaresicf,ri,xsiin the same order as before.createElementis written in no namespace, as before, unless it has anxmlnsattribute of its own (the connector creates its elements withcreateElementNS). The tests comparewrite's output byte by byte with the oldDOMSourceoutput of the same document. A hand-edited store can come out with its namespace declarations rearranged:writedeclares an element'sxmlnsattributes 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: anxmlnsattribute that contradicts the namespace of the element or of one of its attributes, or twoxmlnsattributes, one set withsetAttributeand one withsetAttributeNS.writedeclares such a prefix once, and the first binding wins: the element'sxmlnsattributes, 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'sxmlns:pattribute. 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. Withxmlns:p="urn:x"on an element, its attributesp:cinurn:yandq:cinurn:xare both written ascinurn:x; the old serializer declaredpasurn:ythere and wrote a readable file. When the clash comes from the element's own prefix (p:binurn:xwith the same two attributes), the old serializer writes the same unreadable file aswrite, byte for byte.//text()[normalize-space(.) = '']removed that node and left the rest;normalizeTextremoves 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:<icf:OpenICFContainer .../>, not one that holds a line break;<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-firstBundleClassLoader. Inside the bundlejavax.xml.transform.TransformerFactoryandorg.w3c.dom.*therefore come from xml-apis 1.3.04. That copy has noTransformerFactory.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 createsnet.sf.saxon.TransformerFactoryImpldirectly.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 withmaven-failsafe-plugin(**/*BundleIT.java, system propertybundleJar). To check that the IT is not vacuous,writewas switched to(SAXTransformerFactory) javax.xml.transform.TransformerFactory.newDefaultInstance(): the IT then fails withNoSuchMethodError.Measurements
dispose()time, oneXMLHandlerImplper run:init()parses the generated file, thendispose()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, anormalizeTextpass 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.dispose()before, median (min-max of 3 runs)dispose()after, median (min-max of 3 runs)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) coversfollowing,normalizeText, andwrite, which is compared with the oldDOMSourceoutput (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.XMLConnectorTestsgains cases fordispose(): 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 logsExit serialize, a save without a document throwsConnectorException, and a new file declares its namespaces in the old order.XmlConnectorTestUtilgains store-file helpers (writeAccounts,namesInFile,account) forXMLConnectorTestsandXMLConnectorBundleIT. It also gainssetModified(File, long), which has no caller here: its callers come with [#157] Load and save the XML file only when it has changed #177.valuesSurviveASaveAndAReload(a characterization of what Saxon wrote; green on the olddispose()too);commentsSplitTextRuns(kills "isTextcounts comments", a mutant no row's tree contains);normalizeAndWriteNeverUseNodeLists(the purpose of the writer at unit level: kills aNodeList.item(i)walk insendElement; green from its first run, because every row walks by sibling pointers);normalizeAndWriteMakeALinearNumberOfDomCalls(counts the DOM calls ofnormalizeText+writethroughCountingDom, test helper: 63,035 for 1,000 entries, 126,035 for 2,000; kills a walk that counts the earlier siblings of each child, insendElementor innormalizeText, which takes about 3.8 times the calls for twice the entries and whichnormalizeAndWriteNeverUseNodeListsmisses; 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 on6f83fa3e, the writer without thedispose()change, and rows 2-4 on6f83fa3eplus row 1'sdispose(), which only callswriteand rethrows, because the olddispose()already does what they assert; A3: on4fbcc475, 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: onbd2fe56f, 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)file:line— function — condition)XmlHandlerUtil.java:92-94—following—sibling != null: return the next siblingfollowingIsTheNextSiblingfollowing(a, document)isb, nota's childxfollowingnot defined33485269XmlHandlerUtil.java:91—following— no sibling:n = n.getParentNode(), try againfollowingClimbsToTheNextSiblingOfAnAncestorfollowing(x, document)isbalthoughxhas no siblingreturn node.getNextSibling();(no climb)33485269XmlHandlerUtil.java:91—following— loop boundn != rootfollowingStopsAtRootfollowing(x, a)isnullalthoughahas the siblingbn != null(the walk leavesroot)33485269XmlDocumentWriter.java:81-85—normalizeText— keep side: a lone non-blank Text node stays where it isnormalizedDocumentIsLeftAlonea's Text node in<r><a>x</a><b/></r>is the same object after the callnormalizeTextnot defined (afterwards: every lone Text node replaced by a new one; the other 138 tests stay green on it)33485269XmlDocumentWriter.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 parentwhitespaceOnlyTextIsRemovedaempty, every child ofran element (also after the emptyc)33485269XmlDocumentWriter.java:248-256—normalizeText—isXmlWhitespace: only space, tab, CR, LF make a run blankpaddedAndNonXmlSpaceValuesAreKeptisXmlWhitespace=value.toString().isBlank()(Character.isWhitespace)33485269, re-run after reviewXmlDocumentWriter.java:243-246—normalizeText—isText:CDATA_SECTION_NODEis textwhitespaceOnlyCdataIsRemovedisText=TEXT_NODEonly33485269XmlDocumentWriter.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)adjacentTextAndCdataBecomeOneTextNodeaholds one Text node" x<y "(after == null || !isText(after))(A5 row 1 also fails on it — verified)33485269XmlDocumentWriter.java:81—normalizeText—node.getNodeType() == Node.TEXT_NODE: a lone non-blank CDATA section is not a lone Text node and becomes a Text nodesingleCdataBecomesATextNodeb's only child is aTEXT_NODE"z"node.getNodeType() == Node.TEXT_NODE(verified)33485269XmlDocumentWriter.java:111-130—write— the road every document takes: SaxonTransformerHandlerwithINDENT=yes,startDocument, the document's children,endDocument;send:ELEMENT_NODE,TEXT_NODE;sendElement:xmlns:pattributes declared, element and attributes named throughbindoutputIsTheSameAsBeforeDOMSourceoutputwritenot defined (also killed here:INDENTdropped,xmlns:pnot declared — verified)33485269XmlDocumentWriter.java:119-129—write—CheckedOutputStream: returns the CRC-32 of the bytes writtenwriteReturnsTheChecksumOfTheFilereturn 0L;33485269XmlDocumentWriter.java:162,206-217—sendElement/bind/declare— namespace scope:pushContext,declarePrefix,getURI,!uri.equals(inScope)→declare,popContextsiblingsDeclareTheirOwnNamespacesp:aandp:bread back inurn:1bindreturns the node's namespace without declaring it (also killed here:popContextdropped — verified)33485269XmlDocumentWriter.java:138—send—case Node.CDATA_SECTION_NODEcdataIsWrittenAsTextrreads back with the textx<ydefault)33485269XmlDocumentWriter.java:149-153—send—case Node.ENTITY_REFERENCE_NODE: the children are sententityReferencesAndDoctypeAreWrittenAsBefore<r>v<x/>…</r>, equal to the old output<r/>)33485269XmlDocumentWriter.java:209-210—bind—uri == nullwith a prefix: the namespace in scope (prefixed DOM level 1 nodes such ascreateDocument()'sxsi:schemaLocation)prefixedAttributeWithoutANamespaceTakesTheOneInScopexsi:schemaLocationreads back in the XSI namespacereturn "";for that branch33485269XmlDocumentWriter.java:168-169—sendElement—XMLNS.equals(name): the default namespace declaration,xmlns=""includeddefaultNamespaceUndeclarationIsKeptareads back in no namespacexmlns:p33485269XmlDocumentWriter.java:182-183—sendElement—prefix.isEmpty() ? "": an unprefixed attribute is in no namespace and declares nothingnamespacedAttributeWithoutAPrefixDeclaresNoDefaultNamespacerreads back in no namespacebind(declaresxmlns="urn:x"onr)33485269XmlDocumentWriter.java:142-144—send—case Node.COMMENT_NODEcreatedDocumentReadsBackTheSame" c "reads back33485269XmlDocumentWriter.java:146-148—send—case Node.PROCESSING_INSTRUCTION_NODEhandEditedDocumentReadsBackTheSame<?pi data?>reads back33485269XmlDocumentWriter.java:116-118—write—METHODxml,ENCODINGUTF-8,{http://xml.apache.org/xslt}indent-amountxmland UTF-8 and ignores the Xalanindent-amountkey, so deleting any of the three lines leaves every output byte the same (verified); kept as "the output settings used before"XmlDocumentWriter.java:120—write— try-with-resources closes the stream; anIOExceptionfromclose()is a failed saveendDocumentreturns, so an unclosedFileOutputStreamleaves the same file (verified); the leak shows only as an open descriptor, and a test cannot makeclose()failXmlDocumentWriter.java:192-194—sendElement—endPrefixMappingfor each declared prefixTransformerHandlerignoresendPrefixMapping; dropping the loop leaves the output the same (verified); kept for the SAX contractXmlDocumentWriter.java:180—sendElement— the second loop skipsxmlnsandxmlns:*attributesTransformerHandlerdropsxmlnsattributes passed instartElement'sAttributes; passing them changes nothing (verified); kept for the SAX contractXmlDocumentWriter.java:154-156—send—default: break;(document types and the like are not written)defaultdoes 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)file:line— function — condition)XMLHandlerImpl.java:390—dispose— the save goes throughXmlDocumentWriter.writeinstead of Saxon's XPath//text()andDOMSenderdisposeNeverUsesNodeListsinit()+dispose()of a loaded file,XercesNodeLists.used(getDocument())isfalsedispose()body (both Saxon walks go throughNodeList.item(i))6f83fa3eXMLHandlerImpl.java:389—dispose—XmlDocumentWriter.normalizeText(document)beforewritewhitespaceOnlyValueIsEmptiedBySavefirstname" \t "is an empty element in the file, as before #157writewithoutnormalizeText6f83fa3e+ row 1's write-onlydispose()XMLHandlerImpl.java:392-395—dispose—catch (TransformerException | SAXException | IOException ex): ERROR line, thenConnectorExceptionfailedSaveIsLoggedAndThrownSystem.errhasFailed saving changes to xml file: java.io.FileNotFoundException, anddispose()throwsConnectorExceptionlog.error(row 1's catch only rethrows; the compiler requires it from row 1 on)6f83fa3e+ row 1's write-onlydispose()XMLHandlerImpl.java:475—createDocument—xmlns:icfset on the root beforexmlns:xsinewFileDeclaresNamespacesAsBeforeicf,ri,xsiin that orderxmlns:icfattribute (the writer then declaresicflast:ri, xsi, icf)6f83fa3e+ row 1's write-onlydispose()XMLHandlerImpl.java:397—dispose— the closing log line of a successful save saysExit {0}successfulSaveLogsItsExitSystem.outhas a line ending inExit serializelog.info("Entry {0}", method)dca40471A3: IT on the packaged bundle (dca4047)
file:line— function — condition)OpenICF-xml-connector/pom.xml:124-144—maven-failsafe-pluginexecution (integration-test,verify; include**/*BundleIT.java; system propertybundleJar)XMLConnectorBundleIT#bundleSavesLoadsAndFindsEntriesTests run: 1forXMLConnectorBundleIT4fbcc475XmlDocumentWriter.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 apartXMLConnectorBundleIT#bundleSavesLoadsAndFindsEntrieswritetakes the JDK's transformer(SAXTransformerFactory) javax.xml.transform.TransformerFactory.newDefaultInstance()inwrite: under surefire only the format tests notice, the bundle's xml-apis 1.3.04 has no such method4fbcc475A4: a whitespace-only run is removed whole (d5236ca)
file:line— function — condition)XmlDocumentWriter.java:93,96-98—normalizeText— a blank run: every node of the run is removed, not only the firstwhitespaceOnlyRunOfSeveralNodesIsRemovedWholeain<r><a> <![CDATA[ ]]> </a></r>has no children6f83fa3eXMLHandlerImpl.java:389—dispose—normalizeTextremoves the two whitespace-only Text nodes that a deleted entry leaves side by sidedeletingTheLastEntryLeavesAnEmptyContainernormalizeText: the file keeps a line break (disposeNeverUsesNodeListsalso fails, through its node-list probe); row 1's mutant fails it too4fbcc475A5: a merged run of text keeps its place (7b13f6f)
file:line— function — condition)XmlDocumentWriter.java:94—normalizeText— the merged Text node is inserted before the first node of its runmergedRunKeepsItsPlacexin<r><x>a<![CDATA[b]]><!--c--></x></r>holds the Text nodeab, then the commentparent.appendChild(…)in place ofinsertBefore(…, node): the value moves after the comment (the other 134 tests stay green on it)6f83fa3eA6: review fixes (1f97256..bd2fe56)
file:line— function — condition)XmlDocumentWriter.java:75—normalizeText— an entity reference is not descended into: its children are read-onlytextInsideAnEntityReferenceIsLeftAlonenormalizeTextreturns and the reference keeps its blank Text node and its CDATA sectionNode child = node.getFirstChild();(removeChildthrowsDOMExceptionNO_MODIFICATION_ALLOWED_ERR)7a1a5eb5XmlDocumentWriter.java:209,212—bind— an unprefixed DOM level 1 element (createElement) is in no namespace, undeclaring a default namespace in scope withxmlns=""unprefixedDomLevel1ElementIsInNoNamespaceDOMSourceoutput (<b xmlns=""/>), with and without anxmlnsattribute on the parentif (uri == null)(the element takes the default namespace in scope)1f97256eXmlDocumentWriter.java:81-85—normalizeText— a lone Text node is checked in place, without a list or a bufferXMLHandlerImpl.java:387—dispose— the document comes fromgetDocument(), with no monitor on itsaveWithoutADocumentThrowsConnectorExceptiondispose()on a handler whoseinit()never ran throwsConnectorExceptionData file does not exists: …synchronized (document) { … }around the save, as before (aNullPointerException)f238a5a3XmlHandlerUtil.java:91—following— loop boundn != null: arootthat is not an ancestor acts asnullfollowingWithARootOutsideTheAncestorsEndsAtTheTopfollowing(b, a)isnullin<r><a><x/></a><b/></r>n != rootonly (aNullPointerExceptionabove the document)e851b4abA7: second review fixes (b3fba18, faf5d15)
file:line— function — condition)XmlDocumentWriter.java:226-228—declare— a prefix the element already declares is not declared againtwoXmlnsAttributesDeclareTheDefaultNamespaceOncerwith twoxmlnsattributes (setAttributeurn:y, thensetAttributeNSurn:z, which Xerces puts first) reads back from the file, inurn:zxmlnsdeclared twice, and the parser rejects the file; the other 141 tests stay green on it)bd2fe56fXmlDocumentWriter.java:209—bind—declared.contains(prefix): a prefix the element already declares, by an attribute or for its name, keeps that namespaceaPrefixIsDeclaredOncePerElementAsBeforeDOMSourceoutput 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"/>)bd2fe56f(row 1's case fails too). Thebindcheck alone is unpinnable: Saxon 9.4's serializer writes the declarations it got fromstartPrefixMappingand does not compare them with the namespace passed tostartElementoraddAttribute, 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 itbd2fe56fXmlDocumentWriter.java:75-76—normalizeText— after an entity reference the walk goes on withfollowing(node, root)textAfterAnEntityReferenceIsNormalized<r>&e; <c> </c></r>, the blank Text node after the reference and the one insidecare removedreturnat the first entity reference (the other 141 tests stay green on it; atbd2fe56fall 139 did)1f97256eA1 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
bindcheck 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 ondca40471with 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).