GITHUB#9967: document the Fields#iterator() order contract and assert it in tests - #16475
Open
serhiy-bzhezytskyy wants to merge 4 commits into
Open
GITHUB#9967: document the Fields#iterator() order contract and assert it in tests#16475serhiy-bzhezytskyy wants to merge 4 commits into
serhiy-bzhezytskyy wants to merge 4 commits into
Conversation
CheckIndex rejects a reader whose Fields#iterator() returns field names out of order, but the requirement is stated nowhere and checked nowhere else: - Fields#iterator() javadoc says only "Returns an iterator that will step through all fields names", with nothing about order. - What relies on the order is MergedIterator, used by MultiFields#iterator and by PerFieldPostingsFormat#merge. It documents "the behavior is undefined if the iterators are not actually sorted" rather than checking, so an unsorted producer yields wrong results with no error anywhere. - The comment on the CheckIndex check cites MultiFieldsEnum, which no longer exists. So a new PostingsFormat -- a public extension point -- can return HashMap#keySet(), pass the whole test suite, and only be caught later by CheckIndex, if anyone runs it. All thirteen implementations in the repository do honour the order today, but by four different means: a TreeMap in four of them, an explicit sort on the way out in two, and in FreqProxFields a LinkedHashMap plus a comment relying on the caller having sorted first. Changes: - Fields#iterator() documents the requirement, why it exists, and what breaks. - AssertingPostingsFormat checks it, so any codec exercised by the test suite fails at the violation rather than in CheckIndex afterwards. State is per iterator() call, since a producer may be iterated more than once. - The CheckIndex comment names MultiFields and MergedIterator. - TestAssertingPostingsFormat covers the new assertion directly: unsorted, duplicate, sorted, and two independent iterations. - TestFieldsOrder covers the CheckIndex side, and pins that this order is not the order fields were added: FieldInfos keeps insertion order and assigns field numbers from it, across commits and through a merge, while the terms dictionary sorts names when it is opened. Those are different views and only the sorted one is the contract. This answers the question asked on GITHUB#9967 in 2019. The check cannot be removed -- MultiFields still exists and still merges these iterators -- but "should we make this more explicit and robust then?" was never addressed.
…NE-5123 AssertingFieldsConsumer#write has carried this TODO since 2013 (017a3bf): // TODO: more asserts? can we somehow run a // "limited" CheckIndex here??? Or ... can we improve // AssertingFieldsProducer and us it also to wrap the // incoming Fields here? Doing the last part literally does not work, and that is worth recording: AssertingFieldsProducer wraps Terms in AssertingLeafReader.AssertingTerms, which encodes read-side expectations. On the write side the consumer pulls a PostingsEnum straight out of the incoming Fields and drives it differently, so reusing the producer trips "assert super.docID() == nextDoc" in AssertingPostingsEnum and fails three tests in BasePostingsFormatTestCase. So the wrapper is split instead: - AssertingFields checks only the Fields#iterator() contract and leaves Terms alone, which makes it safe on both sides. - AssertingReadFields adds the Terms wrapping, and only the producer uses it. - write() now wraps the incoming Fields, so an out-of-order producer fails during the write that caused it rather than in a later reader or in CheckIndex. The existing per-field assertions in write() are unchanged; the wrapper is additional, not a replacement. The comparison in the wrapper is strict, matching CheckIndex#checkFields (since 2012) and the assertion in write() (since 2013). MergedIterator would merely fail to deduplicate a repeated name rather than break, but every Fields implementation in the repository derives its iterator from a map's key set, and a duplicate was measured to be unreachable even when the same field is added three times to one document. The "limited CheckIndex" half of the TODO is left in place, unaddressed.
…er each claim
The contract was added to Fields#iterator() in the previous commit, but the two
abstractions that sit either side of it said nothing:
- FieldsConsumer#write has a "Notes" list of what an implementation must do and
may assume, and did not mention that field names arrive sorted, although
Lucene103BlockTreeTermsWriter relies on it and AssertingFieldsConsumer has
asserted it since 2013.
- FieldsProducer, which every postings reader extends, had no mention either.
Both now state it, the consumer as a fourth note and the producer as a pointer.
Every claim the javadoc makes is now pinned by a test, since a documented
invariant that no test exercises is how the stale MultiFieldsEnum comment
survived for thirteen years:
claim test
ascending order from a real reader testCheckIndexAcceptsSortedFields
CheckIndex verifies it testCheckIndexDetectsFieldsOutOfOrder
MultiFields merges with MergedIterator testMultiFieldsMergesInOrder
PerFieldPostingsFormat does too testPerFieldMergePreservesFieldsExactlyOnce
unsorted input stops deduplicating, and a name testUnsortedInputBreaksDeduplication
in two sub-iterators comes back twice
the write side receives sorted names testWriteSideReceivesSortedFields
the order is not the insertion order testOrderIsIndependentOfInsertionOrder
testUnsortedInputBreaksDeduplication is the one that matters for the wording: it
measures the harm rather than restating it. Sorted inputs [a,b]+[a,c] give
[a,b,c], but [b,a,c]+[a,z] gives [a,b,a,c,z] -- "a" twice, from two different
sub-iterators -- and [a,a,b] stays [a,a,b].
Each of the six was mutation-checked: inverting the expectation, or removing the
check in CheckIndex, or reversing the names the recording consumer observes, makes
the corresponding test fail.
The order assertion also moved into AssertingLeafReader.AssertingFields, which
already wrapped the Fields that term vectors expose, so it now covers that path
too; AssertingPostingsFormat calls the same helper rather than carrying a second
copy under a clashing name.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
CheckIndexrejects a reader whoseFields#iterator()returns field names out of order, but the requirement is stated nowhere and checked nowhere else.What relies on the order is
MergedIterator, used byMultiFields#iteratorand byPerFieldPostingsFormat#merge. It documents "the behavior is undefined if the iterators are not actually sorted" rather than checking, so an unsorted producer yields wrong results with no error anywhere. Measured, withremoveDuplicates=trueas both callers use:[a,b]+[a,c]— sorted[a, b, c]— the shared name is deduplicated[b,a,c]+[a,z]— one unsorted[a, b, a, c, z]—atwice, from two different sub-iteratorsMeanwhile:
Fields#iterator()said only "Returns an iterator that will step through all fields names".FieldsConsumer#writehas aNoteslist of what an implementation must do and may assume, and did not mention the order, althoughLucene103BlockTreeTermsWriterrelies on it andAssertingFieldsConsumerhas asserted it since 2013.FieldsProducer, which every postings reader extends, had no mention either.CheckIndexcheck citesMultiFieldsEnum, which no longer exists.So a new
PostingsFormat— a public extension point — can returnHashMap#keySet(), pass the whole test suite, and only be caught later byCheckIndex, if anyone runs it.All thirteen
Fieldsimplementations in the repository do honour the order today, but by four different means: aTreeMapin four of them, an explicit sort on the way out in two, and inFreqProxFieldsaLinkedHashMapplus a comment relying on the caller having sorted first.Changes
Fields#iterator(),FieldsConsumer#writeandFieldsProducerstate the requirement, andFields#iterator()also says what breaks without it.AssertingLeafReader.AssertingFieldsasserts it. That class already wrapped theFieldsthat term vectors expose, so the check covers that path too;AssertingPostingsFormatcalls the same helper rather than carrying a second copy.AssertingFieldsConsumer#writenow wraps the incomingFields, so a violation fails during the write that caused it rather than in a later reader. This is part of the TODO that has sat in that method since 2013 (017a3bf6281).CheckIndexcomment namesMultiFieldsandMergedIterator.Doing the TODO's last part literally does not work, which is worth recording:
AssertingFieldsProducerwrapsTermsinAssertingLeafReader.AssertingTerms, which encodes read-side expectations, and on the write side the consumer pulls aPostingsEnumstraight out of the incomingFieldsand drives it differently. Reusing the producer tripsassert super.docID() == nextDocinAssertingPostingsEnumand fails three tests inBasePostingsFormatTestCase. So the wrapper is split: one class checks only theiterator()contract and is safe on both sides, a subclass adds theTermswrapping for the read side. The "limited CheckIndex" half of the TODO is left in place, unaddressed.Relationship to #9967
That issue asks whether the order check can be removed from
CheckIndex. It cannot:MultiFieldsstill exists and still merges these iterators, so @jpountz's 2019 answer — "We rely on the order for merging, see MultiFields" — still holds. But the follow-up question in the same thread was never answered:This is an attempt at that. The check stays; the contract behind it is now written down and enforced where a codec author will see it.
Verification
Every claim the javadoc makes is pinned by a test, since a documented invariant that no test exercises is how the stale
MultiFieldsEnumcomment survived for thirteen years:testCheckIndexAcceptsSortedFieldsCheckIndexverifies ittestCheckIndexDetectsFieldsOutOfOrderMultiFieldsmerges withMergedIteratortestMultiFieldsMergesInOrderPerFieldPostingsFormatdoes tootestPerFieldMergePreservesFieldsExactlyOncetestUnsortedInputBreaksDeduplicationtestWriteSideReceivesSortedFieldstestOrderIsIndependentOfInsertionOrderTestAssertingPostingsFormat,TestAssertingLeafReaderEach was mutation-checked: inverting the expectation, removing the
CheckIndexcheck, bypassing the assertion, or reversing the names the recording consumer observes makes the corresponding test fail.:lucene:core:test,:lucene:test-framework:test,:lucene:codecs:test,:lucene:backward-codecs:test,:lucene:memory:testpass (11,452 tests), as do:lucene:core:checkandtidy. Also run with-Ptests.nightly=true -Ptests.iters=3 -Ptests.asserts=trueon the postings tests.One thing worth flagging
The assertion is in
test-framework, so a third-partyPostingsFormatthat violates the contract will start failing its own tests. That is what the assertion is for, but it is a behavioural change for downstream, so it is worth a deliberate decision rather than slipping in.I checked what is reachable: Solr has no
Fieldsimplementation of its own (SchemaCodecFactoryonly resolves format names through SPI). Of the implementations in Elasticsearch and OpenSearch,DelegatingBloomFilterFieldsProducerinherits its delegate's order,TSDBSyntheticIdFieldsProducerexposes a single field, andLucene40BlockTreeTermsReadersorts explicitly. Two places have no guarantee either way —XPerFieldMergeState'snew ArrayList<>(filterFields), andTermVectorsFieldsbacked by aHashMapfromStreamInput#readMap, though that one is on the term-vectors path. This is from reading their code, not from running their tests.