Repository navigation
Conversation
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
4 open findings
The earlyreturncauses the test to be reported as passing (not skipped) when the backend doesn’t… · New This comment is internally inconsistent with the data:1.5fis included inremoved, although… · New Using anObject[][]for heterogeneous test parameters is brittle (index-based access with… · New@ValueSource(strings = ...)relies on implicit String→enum conversion for theCardinality… · New
What changed in this PR
Fixes Lucene mixed-index value removals for LIST/SET keys by aligning removal-time comparisons with Lucene’s stored representations (notably float widening to double and UTF-8 replacement for lone surrogates).
Changes:
- Update
LuceneIndex.convertToStringValueto compare values in the stored/read-back form (double-widened numbers, UTF-8-roundtripped strings). - Add Lucene-level and cross-backend tests covering removed floats and strings with unpaired surrogates for
LIST/SETcardinalities. - Add a changelog entry describing the behavior change and operational notes about previously-indexed residual values.
| File | Description |
|---|---|
| janusgraph-lucene/src/main/java/org/janusgraph/diskstorage/lucene/LuceneIndex.java | Adjusts value-to-string conversion to match Lucene stored forms for accurate removals/deduplication. |
| janusgraph-lucene/src/test/java/org/janusgraph/diskstorage/lucene/LuceneIndexValueRemovalTest.java | Adds focused LuceneIndex tests validating stored-field contents and query behavior after removals. |
| janusgraph-backend-testutils/src/main/java/org/janusgraph/graphdb/JanusGraphIndexTest.java | Adds integration tests ensuring removals are not queryable across backends for LIST/SET. |
| docs/changelog.md | Documents the behavior change and trade-offs for Lucene mixed indexes. |
🧠 Review effort: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
4 open findings
The comment says floats are stored as the widened double, but the implementation uses… · New The section heading only mentions floats, but the entry also describes (and this PR implements)… · New Fix grammar in the comment: plural subject should use “have” instead of “has”. · New UsingObject[][]for strongly-typed test metadata forces pervasive casting and makes it easy to… · New
4 resolved since last review
The earlyreturncauses the test to be reported as passing (not skipped) when the backend doesn’t…@ValueSource(strings = ...)relies on implicit String→enum conversion for theCardinality… Using anObject[][]for heterogeneous test parameters is brittle (index-based access with… This comment is internally inconsistent with the data:1.5fis included inremoved, although…
🧠 Review effort: Lite
… keys Fixes JanusGraph#5109 LuceneIndex removes a value of a key of several values from a document by comparing the string which convertToStringValue gives for the value with the stored values of the field, as Lucene reads them back. For two kinds of values that string wasn't the stored one: - A float is stored as the double which it widens to, whose decimal string differs from the float's for most floats: "0.1" against "0.10000000149011612" for 0.1f. The zeros, NaN, the infinities and floats such as 1.5f have the same string either way. - A string is stored in UTF-8, which has no encoding for an unpaired surrogate, so Lucene writes U+FFFD in its place and reads that back. Such a value stayed in the document when it was removed, and eq, the ranges and the text predicates kept finding the element by it, also once the element held no value of the key. So did the value which an edge held before it was set to another one. And a value given again to a SET key whose document held it was held twice, as the addition didn't find the entry which it replaces. convertToStringValue now gives a number as buildStoreField stores it, a whole number as a long and a decimal as a double, and a string as it reads back from UTF-8. What Lucene writes doesn't change. A string longer than 16,000 UTF-16 units is removed only where storage gives it back with its unpaired surrogates, which JanusGraph#5098 makes it do: master reads it back with question marks in their place. Values which Lucene stores alike, such as strings which differ only in an unpaired surrogate, or in one and U+FFFD, now always share one entry where a SET key holds both, as two instants of one millisecond did: the addition of the second replaces the entry of the first, as it compares equal now, so removing either leaves the other unfound. Values which an earlier version left in a document stay there until the document is written whole again: a reindex writes the document of each element which holds a value of the index, and leaves the others. Signed-off-by: Oleksandr Porunov <alexandr.porunov@gmail.com> Co-authored-by: Oleksandr Porunov <alexandr.porunov@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
d6d7b15 to
5222731
Compare
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
2 open findings
4 resolved since last review
The comment says floats are stored as the widened double, but the implementation uses… UsingObject[][]for strongly-typed test metadata forces pervasive casting and makes it easy to… Fix grammar in the comment: plural subject should use “have” instead of “has”. The section heading only mentions floats, but the entry also describes (and this PR implements)…
🧠 Review effort: Lite
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
3 open issues
2 resolved since last review
Review effort: Lite
| private static List<String> storedValues(String document, String field) throws IOException { | ||
| try (FSDirectory directory = FSDirectory.open(new File(StorageSetup.getHomeDir("lucene"), STORE).toPath()); | ||
| DirectoryReader reader = DirectoryReader.open(directory)) { | ||
| final IndexSearcher searcher = new IndexSearcher(reader); | ||
| final TopDocs hits = searcher.search(new TermQuery(new Term(LuceneIndex.DOCID, document)), 2); | ||
| assertEquals(1, hits.scoreDocs.length, document); | ||
| return Arrays.asList(searcher.storedFields().document(hits.scoreDocs[0].doc).getValues(field)); | ||
| } | ||
| } |
| } | ||
| return converted; | ||
| //Lucene stores a string in UTF-8, with U+FFFD in place of each unpaired surrogate | ||
| return AttributeUtils.isString(value) ? new BytesRef(converted).utf8ToString() : converted; |
| @ParameterizedTest | ||
| @ValueSource(strings = {"LIST", "SET"}) | ||
| public void testRemovedFloatsLeaveTheDocument(Cardinality cardinality) throws Exception { |



Fixes #5109
On a Lucene mixed index, most floats removed from a key of
LISTorSETcardinality stayed in the element's document, soeqand the ranges kept finding the element, also once it held no value of the key: afterg.V(v).properties("weights").drop(),g.V().has("weights", 0.1f)still returnedv. The same happened to the float which such a key of an edge held before it was set to another one, and to strings with an unpaired surrogate, whatever their mapping. Lucene stores a float as the double which it widens to, and a string in UTF-8, with U+FFFD in place of an unpaired surrogate, but it compared a removed value with the stored ones by the float's own decimal string,0.1against0.10000000149011612, and by the string with its surrogate. This PR compares a value in the form in which Lucene stores it and reads it back. What Lucene writes doesn't change. A string longer than 16,000 UTF-16 units with an unpaired surrogate is removed only with #5098, as master's storage gives it back with a question mark in place of the surrogate.Changes
LuceneIndex.convertToStringValue, whose stringremoveFieldIfNeededcompares with thestringValue()of each stored field of the key, for a removal and for the addition of a value which aSETkey may hold already:buildStoreFieldstores it: a whole number as along, and a decimal,FloatorDouble, as adouble. So a float's string is that of the double which it widens to.new BytesRef(converted).utf8ToString(), which is how Lucene's stored fields encode it: with U+FFFD in place of each unpaired surrogate. Every other string stays as it is.StringSerializercompresses a string longer than 16,000 UTF-16 units fromgetBytes(UTF_8), which writes a question mark in place of an unpaired surrogate, so such a value reads back with question marks and still doesn't match the stored U+FFFD. Keep the unpaired surrogates of strings longer than 16,000 characters #5098 keeps the surrogates in storage, and with it such a string is removed too.Tests
JanusGraphIndexTest.testRemovedFloatsOfAKeyOfSeveralValuesAreNotFound(new), forLISTandSETon every index backend, in the indexesmixed(vertices) andedges:0.1f,-0.3f,3.14f,Float.MIN_VALUE,Float.MAX_VALUEand1.5f, and one of them-2.5ftoo; all but-2.5fare removed. An edge's0.1fis set to3.14f, and another edge's0.1fis removed while the edge keeps its document by a value ofkind.eqof a removed float finds no vertex, and itscount()is 0.-2.5ffinds its vertex, and a range over every finite float,gte(-Float.MAX_VALUE), finds and counts that vertex alone.0.1ffinds and counts no edge;3.14fand the range find the first edge, andkindthe second.JanusGraphIndexTest.testRemovedStringsWithUnpairedSurrogatesOfAKeyOfSeveralValuesAreNotFound(new), forLISTandSETon every index backend: keys mapped asDEFAULT,TEXT,STRINGandTEXTSTRING, as far as the backend supports them, in the indexmixed. Two vertices hold"Ab\uD800c Removed", and one of them"Kept"too. Before the removal,textContains("removed")on the keys of words andtextPrefix("Ab")on the keys of whole strings find and count both vertices; after it, neither, while"kept"and"Kept"find the other vertex. The whole strings are checked by a prefix aseqof a string with an unpaired surrogate finds nothing on Elasticsearch and Solr.LuceneIndexValueRemovalTest(new), onLuceneIndexitself, forLISTandSET, reading the stored values of the document back from the store's directory:testRemovedFloatsLeaveTheDocument: twelve floats, among themFloat.MIN_NORMAL, the zeros, NaN and the infinities, are removed from two documents, of which one keeps2.5f. That one holds2.5alone and the other nothing, andeqof a removed float finds and counts no document.testAFloatAddedAgainIsHeldOnceByASetAndTwiceByAList:0.1fadded twice is held once by aSETand twice by aLIST, and a removal leaves none and one.testRemovedStringsWithUnpairedSurrogatesLeaveTheDocument, for each mapping:"Ab\uD800c","\uDC00"and"x\uD83D\uDE00y", whose surrogates are a pair, are added beside"Kept". Before the removal,eqof each of them, on the keys of whole strings, andtextContains("ab"), on the keys of words, find the document; after it, they find nothing, and"Kept"alone stays, in the field of the words and in the field of the whole strings of aTEXTSTRINGkey.testRemovedValuesOfEachDataTypeLeaveTheDocument: a key of each data type,Byte,Short,Integer,Long,Float,Double,Date,Instant,Boolean,UUIDand strings of each mapping, holds a value which it keeps, then one which is removed, and keeps the first as Lucene reads it back. Pairs such as-0.0and0.0, andLong.MAX_VALUEandLong.MIN_VALUE, check that the comparison takes neither value for the other.Without the fix, with master's
LuceneIndex, 18 of the 20 runs of these tests fail on Lucene (the other two are those oftestRemovedValueOfADualMappedKeyOfSeveralValuesIsNotFound, which-Dtest=...#testRemoved*picks too):testRemovedFloatsOfAKeyOfSeveralValuesAreNotFound,LISTandSET0.1 ==> expected: <false> but was: <true>: the removed0.1ffinds the vertices, and both edgestestRemovedStringsWithUnpairedSurrogatesOfAKeyOfSeveralValuesAreNotFound,LISTandSETdefaultString ==> expected: <[]>, buttextContains("removed")finds both verticestestRemovedFloatsLeaveTheDocument,LISTandSET0.10000000149011612,-0.30000001192092896,3.140000104904175,1.401298464324817E-45,1.1754943508222875E-38and3.4028234663852886E38besides2.5testAFloatAddedAgainIsHeldOnceByASetAndTwiceByAListSET:0.10000000149011612twice after the second addition;LIST: still twice after the removaltestRemovedStringsWithUnpairedSurrogatesLeaveTheDocument, all 8ab\uFFFDcand\uFFFDbesideskepttestRemovedValuesOfEachDataTypeLeaveTheDocument,LISTandSETfloatkey holds0.10000000149011612besides-0.0; the keys before it passThe two halves fail their own tests alone, and the tests tell other ways of comparing apart:
testRemovedValuesOfEachDataTypeLeaveTheDocument:bytekeeps-128.testRemovedFloatsLeaveTheDocument, which keeps NaN, andtestRemovedValuesOfEachDataTypeLeaveTheDocument, whosedoublekey loses-0.0to the removal of0.0.Verification
Locally, Java 11, on master
dd4f0f845:BerkeleyLuceneTest(108),LuceneIndexTest(263, 148 skipped),LuceneIndexValueRemovalTest(14),MixedIndexUpdateApplierTest(14),CdcSkipMutationTest(9),LuceneIndexKeyInformationTest(2) andBerkeleyLuceneIndexMetricTest(1).BerkeleyElasticsearchTest's runs of the two new tests (4), withtestRemovedValueOfADualMappedKeyOfSeveralValuesIsNotFound(2).MiniSolrCloudCluster): the same 6 runs.BerkeleyLuceneTest(114),LuceneIndexTest(270, 148 skipped),LuceneIndexExistsQueryTest(6) andLuceneIndexValueRemovalTest(14) pass, also withhas(key)checks added to the two new tests:has("weights")finds and counts the vertex which keeps-2.5falone, and the edge set to3.14f, andhas(key)of each string key the vertex which keeps"Kept". With master'sLuceneIndexon the merge,has("weights")finds both vertices and both edges.LISTkey mapped asTEXTand on one mapped asSTRING, is found bytextContainsandtextPrefixbefore its removal and by neither after it. Without Keep the unpaired surrogates of strings longer than 16,000 characters #5098 it reads back from storage with a question mark in place of the surrogate, and is still found after the removal.Notes
SETkey held when the value was set again, of which a removal now takes out one. A reindex writes the document of each element which holds a value of the index anew, from storage, without them.IndexRepairJobwrites nothing for an element which holds no value of the index, so its document keeps them: on master, after0.1fwas removed from three vertices, of which one kept-2.5f, one kept a value of another key of the index and one kept nothing,REINDEXleft0.1fin the third one's document alone. The changelog says so, and how to clear those too: delete the directories of the mixed indexes concerned while no instance runs, and reindex them, during which their queries miss what isn't written yet.restore, which writes a document whole without comparing values, and soREINDEX, the transaction recovery and the CDC applier (MixedIndexUpdateApplier), which write through it. Keys ofSINGLEcardinality, whose value is replaced without a comparison. Geoshapes, which Lucene supports only on keys ofSINGLEcardinality.SETkey holds two such values, adding the second replaces the entry of the first, so that once either is removed the other isn't found, though the element still holds it:"Ab\uD800c", then"Ab\uDC00c", then removing the first, leaves the vertex unfound bytextPrefix("Ab")andtextContainsPrefix("ab"), which master found.DEFAULTandTEXTkeep the two apart; keys mapped asSTRING, and the field of the whole strings of aTEXTSTRINGkey, still merge them. The changelog describes 1.2.0 with Fix text predicates and SET keys on Lucene and Solr #5077.new BytesRef(s).utf8ToString()differs from it, and maybe an instant with a part of a millisecond.has(key)of aLISTorSETkey of numbers fails on Lucene until Fix has(key) on Lucene for LIST and SET keys of numbers, dates and booleans #5104, so the test of floats checks for a vertex without values by a range over every finite float rather than byhas(key).convertToStringValuestill mirrorsbuildStoreFieldby hand, which is how the two drifted apart. Deriving it frombuildStoreField(...).stringValue()gives the same strings, but rewrites the lines which Fix text predicates and SET keys on Lucene and Solr #5077 changes and conflicts with it, so it's left for after Fix text predicates and SET keys on Lucene and Solr #5077.BytesRefand a string more for each string compared, for keys of several values only: once for each removal of a string, and for each string added to aSETkey. Numbers take the same work as before.eqof a string with an unpaired surrogate on a key mapped asSTRINGfound nothing on Elasticsearch 9.5.4 and Solr 9.10.1, also before the removal. Left out.🤖 Generated with Claude Code