Repository navigation
[#157] Find XML entries by __UID__ and __NAME__ through an index - #181
maximthomas wants to merge 16 commits into
Conversation
… __NAME__ Identifier lookups in the XML connector walk the whole DOM today, which is quadratic on Xerces. EntryIndex keeps entries by class and by __UID__ and __NAME__ so these lookups no longer walk the document. It mirrors the XQuery /icf:OpenICFContainer//ri:CLASS[$x/icf:FIELD = 'VALUE'] in result and order.
…d authenticate through the index Lookups of a single entry by __UID__ or __NAME__ now go through the in-memory EntryIndex instead of an XQuery over the DOM. The index is rebuilt whenever the document is built, and create, update and delete report their changes to it. update reports in a finally block because a rejected attribute may have changed the entry already.
…from the index XMLHandler gains search(Query, ObjectClass). A single equality on __UID__ or __NAME__, or no filter at all, is answered from the entry index in document order; every other query is handed to XQuery as before. ComparisonQuery now exposes its name, operator and raw value so the handler can tell. The save rebuilds the index after normalizing text, because the normalization can change an identifier's value.
The scale test times 61 identifier lookups (search by __NAME__, getObject, authenticate) over a 20 000-entry file after the index is built. With the index they take 343 ms; with identifierField disabled they take about 25 s, and with the search routed through XQuery about 30 s, so the 10 s limit separates the index from the quadratic XQuery walk.
…xt normalization changed the tree normalizeText now reports whether it changed anything, so dispose() rebuilds the index only then. A test pins the rebuild through the handler by blanking an identifier that the save empties.
… searches A handler test and the bundle IT now search on a non-identifier attribute, which still runs XQuery; the IT turns off the framework's result filter so a connector that ignores the query is seen. Without it, Saxon XQJ no longer ran under the bundle loader in any test.
…ts limit The comment claimed figures that were not measured. The limit goes from 10 s to 5 s: the green run takes about 0.3 s and the routing mutants 25-30 s.
…vadoc The first sentence now says the index gives the same entries, in the same order, as the XQuery it replaces.
… no container root EntryIndex.rebuild now remembers whether the root matched, and added() does nothing otherwise, as the old XQuery found nothing in such a file.
…through the index indexedEntries now calls getDocument() first, so a search without a document throws the same ConnectorException as before instead of returning nothing.
…updated entry An element inside an entry that is named like an object class is an entry for the XQuery's //ri:CLASS, and rebuild() indexes it. added() now walks the new entry's subtree the same way, and rekeyed() rebuilds the index when the entry holds an entry it does not know, so the answer no longer depends on whether the file was reloaded since. The class Javadoc notes the one shape the index does not follow: an entry inside another entry's identifier element.
… rename, '&' and per-handler namespaces The reload and failed-rename tests read through the XQuery string search, which never uses the index; they now also look the entry up through it. New tests cover a name that contains '&' (create failed on it before) and identifier lookups after another handler with another ri namespace was configured. The search without a document now checks the message for the empty query too.
…static cache XMLConnector.XMLHandlerCache is static, so the DOM and the index of 20,000 entries stayed reachable for the rest of the surefire fork. The test now removes its handler and deletes its file at the end.
vharseko
left a comment
There was a problem hiding this comment.
Reviewed at 6beb334. Nothing blocking from me; a few small points.
Not from this PR, filed separately as #182. update, delete and authenticate break on an entry below another element: removeChildrenFromElement removes same-named descendants as children (DOMException NOT_FOUND_ERR midway, and the outer entry is saved without its __NAME__), delete calls removeChild on the container only, and authenticate reads __PASSWORD__ from any descendant (an inner entry's password is accepted, a missing one gives an NPE). I reproduced all of it on master as well as on this branch, so the PR neither causes nor needs to fix it; it just keeps finding these entries, as the // XQuery did.
Small points:
XMLHandlerImpl.identifierElement(L716-L720) repeatsXMLFilterTranslator.getAttributeName's__UID__→__NAME__rewrite, as its comment says. Before,getEntrybuilt its lookup through the translator, so the rule lived in one place. A static helper used by both would keepcreate/update/delete/authenticateand the search keyed on the same identifier if the rule ever changes.EntryIndexhas the same pre-order walk withfollowing()three times (addAll,removed,holdsUnindexedEntry). One walk helper taking a per-node callback would leave one copy to keep right.- Optional, not measured: of the ~600 B per entry you measured, part is the
LinkedHashSet<Key>per entry for usually one or two keys; a small array orArrayListwould do, sincekeysOfonly needs to drop a repeated key.
… the translator and the lookups XMLHandlerImpl.identifierElement repeated XMLFilterTranslator.getAttributeName's rule that __UID__ is the __NAME__ element for an object class without __UID__. Both now call XMLFilterTranslator.elementName, so create, update, delete and authenticate stay keyed on the same element as a search if the rule changes.
…e in EntryIndex addAll, removed and holdsUnindexedEntry each had a copy of the pre-order walk with following(). They now loop over entriesIn(subtree), which gives the subtree's entries in document order.
… of a LinkedHashSet An entry usually has one or two identifier keys, and a LinkedHashSet with its map, table and linked entries was the largest part of the index. keysOf now drops a repeated key with a contains check and returns an array. A second index over 40,000 entries of two keys each takes about 13,750 KiB of heap instead of about 23,050 KiB (JDK 26, heap after a full GC).
|
Answers to review 5487963111 (of #182. Agreed, it stays out of this PR. The index finds those entries where the 1. 2. Three copies of the walk. Done in 3e9db1e. 3. The PR body has the new memory figures, the shared rule under Plumbing, and a note that the parse, timed at |
vharseko
left a comment
There was a problem hiding this comment.
Re-reviewed the fixes at ccd2125f (6beb334a..ccd2125f). All three points are resolved:
XMLFilterTranslator.elementNameis now the single place for the__UID__→__NAME__rule, andidentifierElementcalls it withisSupportUid(objClass), which is the same flagXMLConnector.createFilterTranslatorbuilds the translator with. Same result as before.entriesIn(subtree)is the one walk.removed()no longer skips theisEntrycheck, but only entries are ever put intoentries, so it behaves the same.Key[]per entry:link/unlinkwork unchanged, and the drop in heap from ~600 B to ~350 B per entry is a good result.
Checked locally on JDK 21: the five index-related test classes are green at ccd2125f. With the contains check in keysOf removed, EntryIndexTests.everyIdentifierValueFindsTheEntryOnce fails as you described.
Nothing further from me. LGTM.
Problem
After #176 and #177 the save is linear and read-only calls no longer write the file, but every lookup of an entry is still an XQuery over the live Xerces DOM. Saxon walks the container through
NodeList.item(i), and on Xerces 2.6.2 that makes finding the entry at position k cost O(k^2), and a lookup that finds nothing walks the whole document.create(duplicate check),updateanddelete(two lookups each),authenticateand every search by__UID__or__NAME__pay this, so a facade call on a store of 40,000 entries still takes seconds although nothing else in it is slow.Change
EntryIndex(package-private). It holds the entries by object class,__UID__and__NAME__, and answers with the entries that the XQuery/icf:OpenICFContainer//ri:CLASS[$x/icf:FIELD = 'VALUE']gives, in the same order:__NAME__from another namespace does not);create,update,deleteandauthenticateuse, as before;create,updateanddeletereport their changes to it under the handler's write lock.updatereports in afinally, because a rejected update may already have changed the entry (a failed rename leaves no stale name);//ri:CLASS, and so for the index:createalso indexes the entries inside the new entry, and anupdatethat writes such an element rebuilds the index, because an entry in the middle of the document has no place in its document order yet;dispose()rebuilds it only ifXmlDocumentWriter.normalizeTextreports that it changed the tree (it now returnsboolean). A loaded document is normalized at load, andcreateandupdatewrite one text node per value, so this happens only when a whitespace-only value was written in memory (an optional attribute set to spaces; for the index it matters only when that attribute is an identifier).create, and the lookups ofupdate,deleteandauthenticate;updateanddeletedo one lookup instead of two;getObject, and searches whose filter is a single=on__UID__or__NAME__(identifierFieldrecognizes the translated comparison; for an object class without__UID__the framework's__UID__filter is already translated to__NAME__);&no longer fails with an XQuery syntax error, and identifier lookups use the namespaces of their own configuration, not the JVM-wide ones that the last configured handler set. Searches on other attributes still build the XQuery as before.QueryBuilderover the DOM and stays quadratic (the issue's step 5, a Java evaluator for the other filters, is not part of this PR). Those searches also still callNodeList.item(i)inside Saxon, which updates the document's node list cache under the read lock; this PR does not remove those writes, it only takes the identifier lookups and the unfiltered search out of them. A search on a connector without a loaded document fails with the same message as before.XMLHandler.search(Query, ObjectClass)(also onConcurrentXMLHandler, under the read lock), whichXMLConnector.executeQuerynow calls instead of building the XQuery string itself;ComparisonQuerykeeps the raw value and its getters;XMLFilterTranslatorhands the raw value over and its XQuery text is unchanged; its rule that__UID__is the__NAME__element for an object class without__UID__is now the staticXMLFilterTranslator.elementName, which the handler's own lookups use too.Measurements
Time through
ConnectorFacade, one call per measurement (init(), the operation,dispose()), with the issue'sXmlPerfharness (-Dreps=3,ri:__ACCOUNT__entries with__UID__,__NAME__and a few more fields; the lookups are for the middle entry). "Before" is master with #176 and #177 (ef2f7ee4). "After" isa848dc38of this branch, which differs from the final branch in that everydispose()of a changed document rebuilt the index (about 20 to 85 ms at 10,000 to 40,000 entries);updateandcreateend in such a dispose, so their "after" cells include the rebuild. The final branch also walks the elements of each created or updated entry once more, to find entries inside it; that was not measured. JDK 26 (OpenJDK Zulu26.28+59-CA) on macOS. The machine was not idle (load average 4 to 6), so the absolute values are noisy; the gap between the sides is not. Each cell is the median of three runs, alternating before and after.__NAME__beforeauthenticatebeforeupdatebeforecreatebeforegetObjectbefore(
getObjectwas timed by a separate small probe, three calls per size and run, nine samples per cell.)Growth per doubling of the number of entries (median at 2N over median at N, 10,000 to 20,000, then 20,000 to 40,000):
__NAME__authenticateupdatecreateThrough the facade, identifier lookups take 2 to 5 ms at every size. With the document held by another instance, which leaves out the facade's per-call work, a search takes 1 ms at every size, an
update0 to 1 ms and acreate1 to 2 ms, so the lookups themselves no longer grow with the file.updateandcreatestill grow by about 1.8 per doubling: what is left in them is the save, which writes the whole file. A search without a filter returns every entry, so it stays proportional to N; it no longer walks the document through Saxon.dispose()after a change, at39504d62, whosedispose()is the same as on the final branch (handler API,updateof one entry and thendispose()on a document of N entries; four disposes per run, three runs per side, median of the nine from the second dispose on; a clean document returns fromdispose()at once on both sides, 0 ms), and the parse, ata848dc38with the issue'sXmlBreakdown(three runs of three handler instances, the first of each run cold; the final branch builds the index during the parse with fewer objects per entry, see the memory figures below, and that was not timed):dispose()beforeThe dirty
dispose()is not faster, and in this measurement it is 15 to 78 ms slower than before, with ranges that overlap (40,000 entries: 188 to 296 ms before, 193 to 389 ms after). The index is not rebuilt in these disposes, because the loaded document is already normalized, so the difference is not the rebuild; I did not isolate its cause. The parse now also builds the index and is about 15% slower at 40,000 entries (228 to 264 ms). The facade tables above include the parse and a dispose. The index also takes memory: about 350 bytes per entry, 13.4 MiB at 40,000 entries with a__UID__and a__NAME__each, next to about 27 MiB for the DOM (heap after a full GC on JDK 26, a second index built over the loaded document, two runs of three measurements: 13,708 to 13,755 KiB; with aLinkedHashSetof keys per entry, as the branch had before, it took 23,040 to 24,048 KiB).The numbers in #157 (a search by
__NAME__at 40,000 entries took 13.5 s, anauthenticate9.7 s, anupdate10.5 s, acreate12.0 s; with the document loaded, 4.4 s, 2.1 s and 4.2 s for search, update and create) were taken before #176, on another host and JDK, so they are not comparable cell by cell. The baseline here already contains part 1, which is why "before" is at 2 s and not at 13 s. The change that the issue predicts for its steps 1 to 4 shows up as expected: identifier lookups are O(1), and a change still pays for one write of the file (about a quarter of a second at 40,000 entries here).Tests
mvn -o -pl OpenICF-xml-connector verifyover the whole module on JDK 26 and on JDK 11: surefire 227 run, 0 failed, 0 skipped; failsafe 1 run, 0 failed (XMLConnectorBundleIT), on both.EntryIndexTests(15): entries by name and uid in document order; nested entries are found and what is not an entry is ignored; object classes are separate; every identifier value finds the entry once; values are compared as they are; the root must be the container, and an entry added to a document with another root is not indexed;addedputs an entry last and indexes the entries inside it;removedexposes the next duplicate and takes the entries inside a removed wrapper along;rekeyedkeeps the entry's place in document order, an entry whose name was removed is found by uid only, an entry written into an earlier entry takes its place in document order, and without such a new entry the rest of the index is left as it is.XMLHandlerIndexTests(14, on a fixture with nested entries, duplicates and a foreign-namespace__NAME__):authenticatefinds the entry by name; a name that contains&is found by a search and byauthenticate(createfailed on it before this change); identifier lookups use the handler's own namespaces after another handler with anotherrinamespace was configured; a replaced value takes the entries inside it along; a class without__UID__is found by name;indexAgreesWithXQueryAfterChangescompares the index with the XQuery for 24 identifier values (every name in the fixture, a sample of uids, the empty and a missing value), for the search without a filter and for the empty query, at each state of the document (loaded; after a create, a rename back to a duplicate, a delete, the replacement of a value that holds an entry, a rejected rename, and a save);=on an identifier goes to the index (identifierField: a negated=,startsWithand>=do not); a query on another attribute falls back to XQuery;lookupsAndSavesNeverUseNodeListsasserts that identifier lookups and the save never callNodeList.item; a search without a document fails as before.XMLHandlerReloadTests.externalCopyWithAnOlderMtimeIsLoadedalso authenticates after the reload, through the index; the other reload tests read through the XQuery string search, which does not use it.XMLFilterTranslatorTests.comparisonExpressionsAreUnchanged: the XQuery text of the translated comparisons is the same as before the change (it was green on the unchanged translator first).XMLConnectorScaleTests: 20,000 entries, then for 20 names a search by__NAME__, agetObjectand anauthenticate, plus one search for a missing name (61 lookups), all through the facade after an untimed first call. The limit is 5 s. It takes 343 ms. With the identifier routing inXMLHandlerImplswitched off the same run takes 24.8 s, and withXMLConnector.executeQuerysending the XQuery string again it takes 30.0 s, so both fail the limit. The test removes its handler from the static handler cache at the end.XMLConnectorBundleIT: a facade search onlastname(a non-identifier attribute) on the packaged bundle, with the framework's result filter switched off so that a connector that ignored the query would show. This is the one call that still runs Saxon XQJ under the bundle's class loader. It is the only test that fails ifexecuteQueryhands the handler a null query; the unit and scale tests do not see that, because the framework filters the results again.XmlDocumentWriterTests:normalizeTextreturnsfalsefor a normalized document andtruewhen it removed blank text or merged CDATA and adjacent text.Fixes #157