salvatore-campagna commented on code in PR #16623:
URL: https://github.com/apache/lucene/pull/16623#discussion_r4166709152


##########
lucene/core/src/java/org/apache/lucene/util/LiveDocs.java:
##########
@@ -82,4 +82,13 @@ public interface LiveDocs extends Bits {
    * @return the number of deleted documents in this segment
    */
   int deletedCount();
+
+  /**
+   * Materializes this LiveDocs as a {@link FixedBitSet} where set bits 
represent live documents.
+   *
+   * <p>Used by {@link FixedBitSet#copyOf(Bits)} to avoid the per-bit generic 
loop.
+   *
+   * @return a new {@link FixedBitSet} with set bits for live documents
+   */
+  FixedBitSet toFixedBitSet();

Review Comment:
   I think this method and all its implementations are not necessary. 
`SparseLiveDocs.toFixedBitSet` is already just `set(0, maxDoc)` plus 
`applyMask(result, 0)`, which is generic, so `copyOf` can call 
`bits.applyMask(bitSet, 0)` instead of the per bit loop. `bits` is already a 
`Bits` there, so no `instanceof` and no new method, and dense gets `andRange` 
for free. Right?



##########
lucene/core/src/java/org/apache/lucene/util/LiveDocs.java:
##########
@@ -82,4 +82,13 @@ public interface LiveDocs extends Bits {
    * @return the number of deleted documents in this segment
    */
   int deletedCount();
+
+  /**

Review Comment:
   (if the method stays, see: 
https://github.com/apache/lucene/pull/16623/changes#r4166709152)
   
   Both implementations return a fresh bit set today, but to avoid a possible 
future issue can we state in the Javadoc that the returned `FixedBitSet` is the 
caller's to mutate and must not share state with this instance? 
`PendingDeletes#getMutableBits` publishes what `copyOf` returns as the 
segment's live docs and then clears bits in it, so an implementation returning 
its backing set directly would mutate live docs shared with open readers. It is 
a tempting shortcut in DenseLiveDocs in particular, since it already holds 
exactly the right object.



##########
lucene/core/src/test/org/apache/lucene/util/TestLiveDocsCopyOf.java:
##########
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.lucene.util;
+
+import org.apache.lucene.tests.util.LuceneTestCase;
+
+/** Tests for {@link LiveDocs#toFixedBitSet()} via {@link 
FixedBitSet#copyOf(Bits)}. */
+public class TestLiveDocsCopyOf extends LuceneTestCase {
+
+  public void testDenseLiveDocsCopyOf() {
+    int maxDoc = 1000;
+    FixedBitSet liveBits = new FixedBitSet(maxDoc);
+    liveBits.set(0, maxDoc);
+    // Delete some known positions
+    liveBits.clear(0);
+    liveBits.clear(42);
+    liveBits.clear(999);
+
+    DenseLiveDocs dense = DenseLiveDocs.builder(liveBits, maxDoc).build();
+    FixedBitSet copy = FixedBitSet.copyOf(dense);
+
+    assertEquals(maxDoc, copy.length());
+    for (int i = 0; i < maxDoc; i++) {
+      assertEquals("mismatch at doc " + i, dense.get(i), copy.get(i));
+    }
+  }
+
+  public void testSparseLiveDocsCopyOf() {
+    int maxDoc = 1000;
+    SparseFixedBitSet deletedDocs = new SparseFixedBitSet(maxDoc);
+    deletedDocs.set(0);
+    deletedDocs.set(42);
+    deletedDocs.set(999);
+
+    SparseLiveDocs sparse = SparseLiveDocs.builder(deletedDocs, 
maxDoc).build();
+    FixedBitSet copy = FixedBitSet.copyOf(sparse);
+
+    assertEquals(maxDoc, copy.length());
+    for (int i = 0; i < maxDoc; i++) {
+      assertEquals("mismatch at doc " + i, sparse.get(i), copy.get(i));
+    }
+  }
+
+  public void testRandomized() {
+    for (int iter = 0; iter < 50; iter++) {

Review Comment:
   Nit: could we use `atLeast(50)` here and `atLeast(1000)` for the fixed 
`maxDoc` values, so nightly runs widen with `tests.multiplier`? Also `maxDoc = 
1000` sits inside a single 4096 bit `SparseFixedBitSet` block, so 
`andNotRange`'s first block and last block cases are only hit incidentally. 
Boundary values like 1, 63, 64, 65, 4095, 4096, 4097 plus all deleted and 
exactly one deleted would be worth pinning.



##########
lucene/core/src/java/org/apache/lucene/util/DenseLiveDocs.java:
##########
@@ -182,6 +182,11 @@ public int deletedCount() {
     return deletedCount;
   }
 
+  @Override
+  public FixedBitSet toFixedBitSet() {
+    return liveDocs.clone();

Review Comment:
   `clone()` keeps `liveDocs.length()`, but this instance's `length()` is 
maxDoc, and the constructor only asserts `liveDocs.length() >= maxDoc`.
   
   I wrote a test to reproduce a possible issue that happens when the backing 
bit set is longer than maxDoc:
   
   ```
   public void testCopyOfPaddedDenseLiveDocsPreservesLength() {
      int maxDoc = 100;
      // DenseLiveDocs only requires liveDocs.length() >= maxDoc (see 
constructor)
      FixedBitSet backing = new FixedBitSet(256);
      backing.set(0, maxDoc);
      backing.clear(7);
      DenseLiveDocs dense = DenseLiveDocs.builder(backing, maxDoc).build();
      assertEquals(maxDoc, dense.length());
      FixedBitSet copy = FixedBitSet.copyOf(dense);
     
      assertEquals(dense.length(), copy.length());
      assertEquals(dense.length() - dense.deletedCount(), copy.cardinality());
   }
   ```



##########
lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/SoftDeletesReaderBenchmark.java:
##########
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.lucene.benchmark.jmh;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.NumericDocValuesField;
+import org.apache.lucene.document.StringField;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.SoftDeletesDirectoryReaderWrapper;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.MMapDirectory;
+import org.apache.lucene.util.Bits;
+import org.apache.lucene.util.IOUtils;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+
+/**
+ * Measures the cost of wrapping a reader with {@link 
SoftDeletesDirectoryReaderWrapper}, which
+ * calls {@link org.apache.lucene.util.FixedBitSet#copyOf(Bits)} on each 
leaf's live docs. Since
+ * #15413, live docs are {@link org.apache.lucene.util.DenseLiveDocs} or {@link
+ * org.apache.lucene.util.SparseLiveDocs} which fall through to the per-bit 
generic loop.
+ */
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@State(Scope.Benchmark)
+@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 3, timeUnit = TimeUnit.SECONDS)
+@Fork(value = 3, jvmArgsPrepend = "--add-modules=jdk.incubator.vector")
+public class SoftDeletesReaderBenchmark {
+
+  private static final String SOFT_DELETE_FIELD = "__soft_delete";
+
+  @Param({"200000", "1000000"})
+  int numDocs;
+
+  @Param({"0.01", "0.05"})
+  double softDeleteRate;
+
+  private Directory dir;
+  private DirectoryReader baseReader;
+  private Path tempDir;
+
+  @Setup(Level.Trial)
+  public void setup() throws Exception {
+    tempDir = Path.of(System.getProperty("java.io.tmpdir"), "softdel-bench-" + 
System.nanoTime());
+    dir = MMapDirectory.open(tempDir);
+
+    IndexWriterConfig config =
+        new 
IndexWriterConfig().setSoftDeletesField(SOFT_DELETE_FIELD).setRAMBufferSizeMB(256);
+    try (IndexWriter w = new IndexWriter(dir, config)) {
+      for (int i = 0; i < numDocs; i++) {
+        Document doc = new Document();
+        doc.add(new StringField("id", Integer.toString(i), Field.Store.NO));
+        doc.add(new NumericDocValuesField("val", i));
+        w.addDocument(doc);
+      }
+      w.flush();
+      int softDeleted = (int) (numDocs * softDeleteRate);
+      for (int i = 0; i < softDeleted; i++) {
+        int docId = (int) (((long) i * 7919) % numDocs);
+        Document replacement = new Document();
+        replacement.add(new StringField("id", Integer.toString(docId), 
Field.Store.NO));
+        replacement.add(new NumericDocValuesField("val", -1));
+        w.softUpdateDocument(
+            new Term("id", Integer.toString(docId)),
+            replacement,
+            new NumericDocValuesField(SOFT_DELETE_FIELD, 1));
+      }
+      int hardDeleted = Math.max(1, (int) (numDocs * 0.01));

Review Comment:
   In `Lucene90LiveDocsFormat.readLiveDocs` the choice of representation 
depends on hard deletes only: we call `info.getDelCount()`, which does not 
include soft deletes, and compare `deletionRate` against 
`SPARSE_DENSE_THRESHOLD = 0.01`.
   
   Here the hard delete rate is hardcoded at `numDocs * 0.01`, right on that 
threshold, and `softDeleteRate` cannot move it, so the deletion rate stays just 
under 0.01 and `SparseLiveDocs` is selected. I get that `softDeleteRate` varies 
how much of the wrap cost is `copyOf` versus `applySoftDeletes`, but 
`DenseLiveDocs` never gets exercised end to end, and with `TieredMergePolicy` 
allowing 20% deletes by default that is the common representation. Could we 
make the hard delete rate a `@Param` with a value above 0.01 so that we see the 
effect for both representations?



##########
lucene/core/src/test/org/apache/lucene/util/TestLiveDocsCopyOf.java:
##########
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.lucene.util;
+
+import org.apache.lucene.tests.util.LuceneTestCase;
+
+/** Tests for {@link LiveDocs#toFixedBitSet()} via {@link 
FixedBitSet#copyOf(Bits)}. */
+public class TestLiveDocsCopyOf extends LuceneTestCase {
+
+  public void testDenseLiveDocsCopyOf() {
+    int maxDoc = 1000;
+    FixedBitSet liveBits = new FixedBitSet(maxDoc);
+    liveBits.set(0, maxDoc);
+    // Delete some known positions
+    liveBits.clear(0);
+    liveBits.clear(42);
+    liveBits.clear(999);
+
+    DenseLiveDocs dense = DenseLiveDocs.builder(liveBits, maxDoc).build();
+    FixedBitSet copy = FixedBitSet.copyOf(dense);
+
+    assertEquals(maxDoc, copy.length());
+    for (int i = 0; i < maxDoc; i++) {
+      assertEquals("mismatch at doc " + i, dense.get(i), copy.get(i));
+    }
+  }
+
+  public void testSparseLiveDocsCopyOf() {
+    int maxDoc = 1000;
+    SparseFixedBitSet deletedDocs = new SparseFixedBitSet(maxDoc);
+    deletedDocs.set(0);
+    deletedDocs.set(42);
+    deletedDocs.set(999);
+
+    SparseLiveDocs sparse = SparseLiveDocs.builder(deletedDocs, 
maxDoc).build();
+    FixedBitSet copy = FixedBitSet.copyOf(sparse);
+
+    assertEquals(maxDoc, copy.length());
+    for (int i = 0; i < maxDoc; i++) {
+      assertEquals("mismatch at doc " + i, sparse.get(i), copy.get(i));
+    }
+  }
+
+  public void testRandomized() {
+    for (int iter = 0; iter < 50; iter++) {
+      int maxDoc = random().nextInt(10_000) + 1;
+      double deletionRate = random().nextDouble() * 0.5;
+      int numDeleted = (int) (maxDoc * deletionRate);
+
+      // Build both representations
+      FixedBitSet liveBits = new FixedBitSet(maxDoc);
+      liveBits.set(0, maxDoc);
+      SparseFixedBitSet deletedDocs = new SparseFixedBitSet(maxDoc);
+
+      for (int i = 0; i < numDeleted; i++) {
+        int docId = random().nextInt(maxDoc);
+        liveBits.clear(docId);
+        deletedDocs.set(docId);
+      }
+
+      DenseLiveDocs dense = DenseLiveDocs.builder(liveBits, maxDoc).build();
+      SparseLiveDocs sparse = SparseLiveDocs.builder(deletedDocs, 
maxDoc).build();
+
+      // Build per-bit reference
+      FixedBitSet reference = new FixedBitSet(maxDoc);
+      for (int i = 0; i < maxDoc; i++) {
+        if (dense.get(i)) {

Review Comment:
   Nit: the reference comes from `dense.get(i)`, so a bug in 
`DenseLiveDocs.get()` would make both assertions agree with each other and 
pass. Building it from the deleted doc ids instead would make it independent, 
right?



##########
lucene/core/src/test/org/apache/lucene/util/TestLiveDocsCopyOf.java:
##########
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.lucene.util;
+
+import org.apache.lucene.tests.util.LuceneTestCase;
+
+/** Tests for {@link LiveDocs#toFixedBitSet()} via {@link 
FixedBitSet#copyOf(Bits)}. */
+public class TestLiveDocsCopyOf extends LuceneTestCase {
+
+  public void testDenseLiveDocsCopyOf() {
+    int maxDoc = 1000;
+    FixedBitSet liveBits = new FixedBitSet(maxDoc);
+    liveBits.set(0, maxDoc);
+    // Delete some known positions
+    liveBits.clear(0);
+    liveBits.clear(42);
+    liveBits.clear(999);
+
+    DenseLiveDocs dense = DenseLiveDocs.builder(liveBits, maxDoc).build();
+    FixedBitSet copy = FixedBitSet.copyOf(dense);

Review Comment:
   All three tests check content but none checks that the copy is detached from 
the source, which is what `copyOf` is for: `PendingDeletes#getMutableBits` 
mutates the result and publishes it as the segment's live docs. Could we add 
something like `copy.clear(100); assertTrue(dense.get(100));` plus the same for 
sparse? Today it would catch a change from `liveDocs.clone()` to return 
`liveDocs;`, which otherwise leaves the suite green.
   
   Related to comment: 
https://github.com/apache/lucene/pull/16623/changes#r4166959798



##########
lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:
##########
@@ -1959,5 +1959,10 @@ public DocIdSetIterator liveDocsIterator() {
     public DocIdSetIterator deletedDocsIterator() {
       return liveDocs.deletedDocsIterator();
     }
+
+    @Override
+    public FixedBitSet toFixedBitSet() {

Review Comment:
   This override does not assert anything, unlike the others here. 
`getLiveDocs()` above already asserts `maxDoc() == liveDocs.length()` for the 
source. Maybe we can assert the same for the result here? Also, it would catch 
the padded case I mentioned on `DenseLiveDocs`. Delegating also means `copyOf` 
no longer goes through the asserting `get()`, so it loses the `assertThread` 
check it used to get. Same for AssertingLiveDocsFormat.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to