diff --git a/changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml b/changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml
new file mode 100644
index 000000000000..69fd1515ce73
--- /dev/null
+++ b/changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml
@@ -0,0 +1,8 @@
+
+title: Support blank/zero-byte files in configset zip uploads
+type: fixed
+authors:
+ - name: Eric Pugh
+links:
+ - name: SOLR-16341
+ url: https://issues.apache.org/jira/browse/SOLR-16341
diff --git a/changelog/unreleased/SOLR-18189.yml b/changelog/unreleased/SOLR-18189.yml
new file mode 100644
index 000000000000..638acdf99508
--- /dev/null
+++ b/changelog/unreleased/SOLR-18189.yml
@@ -0,0 +1,8 @@
+title: New ContentHashVersionProcessor to avoid index churn when adding same-content documents.
+type: added
+authors:
+ - name: Francois Huaulme
+ - name: David Smiley
+links:
+ - name: SOLR-18189
+ url: https://issues.apache.org/jira/browse/SOLR-18189
diff --git a/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java b/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java
index 6728b17ef103..bb9ca94c761a 100644
--- a/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java
+++ b/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java
@@ -22,11 +22,15 @@
import java.io.IOException;
import java.io.InputStream;
import java.lang.invoke.MethodHandles;
-import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
+import java.util.Enumeration;
import java.util.List;
import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
+import java.util.zip.ZipException;
+import java.util.zip.ZipFile;
import org.apache.solr.client.api.endpoint.ConfigsetsApi;
import org.apache.solr.client.api.model.SolrJerseyResponse;
import org.apache.solr.client.solrj.util.SolrIdentifierValidator;
@@ -85,22 +89,41 @@ public SolrJerseyResponse uploadConfigSet(
filesToDelete = new ArrayList<>();
}
- try (ZipInputStream zis = new ZipInputStream(requestBody, StandardCharsets.UTF_8)) {
- boolean hasEntry = false;
- ZipEntry zipEntry;
- while ((zipEntry = zis.getNextEntry()) != null) {
- hasEntry = true;
- String filePath = zipEntry.getName();
- filesToDelete.remove(filePath);
- if (!zipEntry.isDirectory()) {
- configSetService.uploadFileToConfig(configSetName, filePath, zis.readAllBytes(), true);
+ // Write the request body to a temp file so we can use ZipFile, which reads the central
+ // directory and correctly handles entries that use the STORED method with an EXT (data
+ // descriptor) flag — a combination that ZipInputStream cannot process. This allows
+ // zero-byte files (e.g. created with `touch`) to be included in the uploaded configset.
+ final Path tempZip = Files.createTempFile("solr-configset-upload-", ".zip");
+ try {
+ Files.copy(requestBody, tempZip, StandardCopyOption.REPLACE_EXISTING);
+ try (ZipFile zipFile = new ZipFile(tempZip.toFile())) {
+ boolean hasEntry = false;
+ Enumeration extends ZipEntry> entries = zipFile.entries();
+ while (entries.hasMoreElements()) {
+ ZipEntry zipEntry = entries.nextElement();
+ hasEntry = true;
+ String filePath = zipEntry.getName();
+ filesToDelete.remove(filePath);
+ if (!zipEntry.isDirectory()) {
+ try (InputStream entryStream = zipFile.getInputStream(zipEntry)) {
+ configSetService.uploadFileToConfig(
+ configSetName, filePath, entryStream.readAllBytes(), true);
+ }
+ }
}
- }
- if (!hasEntry) {
+ if (!hasEntry) {
+ throw new SolrException(
+ SolrException.ErrorCode.BAD_REQUEST,
+ "Either empty zipped data, or non-zipped data was uploaded. In order to upload a configSet, you must zip a non-empty directory to upload.");
+ }
+ } catch (ZipException e) {
throw new SolrException(
SolrException.ErrorCode.BAD_REQUEST,
- "Either empty zipped data, or non-zipped data was uploaded. In order to upload a configSet, you must zip a non-empty directory to upload.");
+ "Failed to read the uploaded zip file: " + e.getMessage(),
+ e);
}
+ } finally {
+ Files.deleteIfExists(tempZip);
}
deleteUnusedFiles(configSetService, configSetName, filesToDelete);
diff --git a/solr/core/src/java/org/apache/solr/update/processor/ContentHashVersionProcessor.java b/solr/core/src/java/org/apache/solr/update/processor/ContentHashVersionProcessor.java
new file mode 100644
index 000000000000..ec59596ab8a2
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/update/processor/ContentHashVersionProcessor.java
@@ -0,0 +1,178 @@
+/*
+ * 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.solr.update.processor;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+import org.apache.lucene.util.BytesRef;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.SolrInputField;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.handler.component.RealTimeGetComponent;
+import org.apache.solr.handler.component.RealTimeGetComponent.Resolution;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.update.AddUpdateCommand;
+
+/**
+ * An implementation of {@link UpdateRequestProcessor} which computes a hash of field values, and
+ * uses this hash to reject/accept document updates.
+ *
+ *
+ * - When no corresponding document with same id exists (create), the computed hash is added to
+ * the document.
+ *
- When a previous document exists (update), a new hash is computed from the incoming field
+ * values and compared with the stored hash.
+ *
+ *
+ * Depending on {#dropSameDocuments} value, this processor may drop or accept document updates.
+ * This implementation can be used for monitoring or dropping no-op updates (updates that do not
+ * change the Solr document content).
+ *
+ *
Note: the hash is computed using {@link Lookup3Signature} and must be stored in a field with
+ * docValues enabled for retrieval.
+ *
+ * @see Lookup3Signature
+ */
+public class ContentHashVersionProcessor extends UpdateRequestProcessor {
+ private final SchemaField hashField;
+ private final SolrQueryResponse rsp;
+ private final SolrCore core;
+ private final Predicate includedFields; // Matcher for included fields in hash
+ private final Predicate excludedFields; // Matcher for excluded fields from hash
+ private boolean dropSameDocuments;
+ private int sameCount = 0;
+ private int differentCount = 0;
+
+ public ContentHashVersionProcessor(
+ Predicate hashIncludedFields,
+ Predicate hashExcludedFields,
+ String hashFieldName,
+ boolean dropSameDocuments,
+ SolrQueryRequest req,
+ SolrQueryResponse rsp,
+ UpdateRequestProcessor next) {
+ super(next);
+ this.core = req.getCore();
+
+ IndexSchema schema = core.getLatestSchema();
+ this.hashField = schema.getField(hashFieldName);
+ this.dropSameDocuments = dropSameDocuments;
+ this.rsp = rsp;
+ this.includedFields = hashIncludedFields;
+ this.excludedFields = hashExcludedFields;
+ }
+
+ @Override
+ public void processAdd(AddUpdateCommand cmd) throws IOException {
+ SolrInputDocument newDoc = cmd.getSolrInputDocument();
+ byte[] newHash = computeDocHash(newDoc);
+ newDoc.setField(hashField.getName(), newHash);
+
+ if (!isHashAcceptable(cmd.getIndexedId(), newHash)) {
+ return;
+ }
+ super.processAdd(cmd);
+ }
+
+ @Override
+ public void finish() throws IOException {
+ try {
+ super.finish();
+ } finally {
+ if (sameCount + differentCount > 0) {
+ if (dropSameDocuments) {
+ rsp.addToLog("contentHash.duplicatesDropped", sameCount);
+ } else {
+ rsp.addToLog("contentHash.duplicatesDetected", sameCount);
+ }
+ }
+ }
+ }
+
+ private boolean isHashAcceptable(BytesRef indexedDocId, byte[] newHash) throws IOException {
+ assert null != indexedDocId;
+
+ Optional oldDocHash = getOldDocHash(indexedDocId);
+ if (oldDocHash.isPresent()) {
+ if (Arrays.equals(newHash, oldDocHash.get())) {
+ sameCount++;
+ return !dropSameDocuments;
+ } else {
+ differentCount++;
+ return true;
+ }
+ }
+ return true; // Doc not found
+ }
+
+ /** Retrieves the hash value from the old document identified by the given ID. */
+ private Optional getOldDocHash(BytesRef indexedDocId) throws IOException {
+ SolrInputDocument oldDoc =
+ RealTimeGetComponent.getInputDocument(
+ core, indexedDocId, indexedDocId, null, Set.of(hashField.getName()), Resolution.DOC);
+ if (oldDoc == null) {
+ return Optional.empty();
+ }
+ Object o = oldDoc.getFieldValue(hashField.getName());
+ if (o instanceof byte[] bytes) {
+ return Optional.of(bytes);
+ } else if (o instanceof ByteBuffer buf) {
+ byte[] bytes = new byte[buf.remaining()];
+ buf.duplicate().get(bytes);
+ return Optional.of(bytes);
+ }
+ return Optional.empty();
+ }
+
+ byte[] computeDocHash(SolrInputDocument doc) {
+ final Signature sig = new Lookup3Signature();
+
+ // Stream field names, filter, sort, and process in a single pass
+ doc.values().stream()
+ .filter(includedFields) // Keep fields that match 'included fields' matcher
+ .filter(excludedFields.negate()) // Exclude fields that match 'excluded fields' matcher
+ .sorted(
+ Comparator.comparing(
+ SolrInputField
+ ::getName)) // Sort to ensure consistent field order across different doc field
+ // orders
+ .forEach(
+ inputField -> {
+ sig.add(inputField.getName());
+ Object o = inputField.getValue();
+ if (o instanceof Collection) {
+ for (Object oo : (Collection>) o) {
+ sig.add(String.valueOf(oo));
+ }
+ } else {
+ sig.add(String.valueOf(o));
+ }
+ });
+
+ return sig.getSignature();
+ }
+}
diff --git a/solr/core/src/java/org/apache/solr/update/processor/ContentHashVersionProcessorFactory.java b/solr/core/src/java/org/apache/solr/update/processor/ContentHashVersionProcessorFactory.java
new file mode 100644
index 000000000000..479ce33e987e
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/update/processor/ContentHashVersionProcessorFactory.java
@@ -0,0 +1,219 @@
+/*
+ * 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.solr.update.processor;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.SolrInputField;
+import org.apache.solr.common.util.NamedList;
+import org.apache.solr.common.util.StrUtils;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+
+/**
+ * Factory for {@link ContentHashVersionProcessor} instances.
+ *
+ * This processor computes a hash from the document content and sees if an existing (indexed)
+ * document also has this hash, stored in a designated field. If so, it drops the document to avoid
+ * needless index churn. Alternatively, it can be configured to merely log the fact.
+ *
+ *
Configuration
+ *
+ *
+ * - hashField (required): The name of the field where the computed hash will be stored.
+ * It should be of type BinaryField. This field must have docValues enabled for hash
+ * retrieval. The hash field is automatically excluded from hash computation.
+ *
- includeFields (optional, default="*"): Comma-separated list of fields to include in
+ * hash computation. Supports wildcard patterns (e.g., "name*"). Use "*" to include all
+ * fields.
+ *
- excludeFields (optional): Comma-separated list of fields to exclude from hash
+ * computation. Supports wildcard patterns. Cannot be "*" (cannot exclude all fields).
+ *
- hashCompareStrategy (optional, default="drop"): Controls behavior when duplicate
+ * content is detected:
+ *
+ * - "drop": Silently drops documents with matching hash (no-op updates)
+ *
- "log": Logs duplicate detection but still processes the update
+ *
+ *
+ *
+ * Configuration Example
+ *
+ *
+ * <processor class="solr.ContentHashVersionProcessorFactory">
+ * <str name="hashField">content_hash</str>
+ * <str name="includeFields">title,body,author</str>
+ * <str name="excludeFields">timestamp,version</str>
+ * <str name="hashCompareStrategy">drop</str>
+ * </processor>
+ *
+ *
+ * Important Considerations
+ *
+ *
+ * - In-Place Updates: Fields updated via in-place (partial) updates should be excluded
+ * from hash computation using
excludeFields, as these are updated independently
+ * and should not affect duplicate detection.
+ *
+ *
+ * Monitoring
+ *
+ * The processor logs duplicate statistics in the response:
+ *
+ *
+ * contentHash.duplicatesDropped: Count of duplicates dropped (when
+ * hashCompareStrategy=drop)
+ * contentHash.duplicatesDetected: Count of duplicates detected (when
+ * hashCompareStrategy=log)
+ *
+ *
+ * @see ContentHashVersionProcessor
+ * @see Lookup3Signature
+ */
+public class ContentHashVersionProcessorFactory extends UpdateRequestProcessorFactory {
+ private static final char SEPARATOR = ','; // Separator for included/excluded fields
+ private List includeFields = List.of("*"); // Included fields defaults to 'all'
+ private List excludeFields = new ArrayList<>();
+ private String hashField; // Must be explicitly configured
+ private boolean dropSameDocuments = true;
+
+ public ContentHashVersionProcessorFactory() {}
+
+ @Override
+ public void init(NamedList> args) {
+ Object tmp = args.remove("includeFields");
+ if (tmp != null) {
+ if (!(tmp instanceof String)) {
+ throw new SolrException(
+ SolrException.ErrorCode.SERVER_ERROR, "'includeFields' must be configured as a ");
+ }
+ // Include fields support comma separated list of fields (e.g. "field1,field2,field3").
+ // Also supports "*" to include all fields
+ this.includeFields =
+ StrUtils.splitSmart((String) tmp, SEPARATOR).stream()
+ .map(String::trim)
+ .collect(Collectors.toList());
+ }
+ tmp = args.remove("hashField");
+ if (tmp == null) {
+ throw new SolrException(
+ SolrException.ErrorCode.SERVER_ERROR,
+ "'hashField' is required and must be explicitly configured");
+ }
+ if (!(tmp instanceof String)) {
+ throw new SolrException(
+ SolrException.ErrorCode.SERVER_ERROR, "'hashField' must be configured as a ");
+ }
+ this.hashField = (String) tmp;
+
+ tmp = args.remove("excludeFields");
+ if (tmp != null) {
+ if (!(tmp instanceof String)) {
+ throw new SolrException(
+ SolrException.ErrorCode.SERVER_ERROR, "'excludeFields' must be configured as a ");
+ }
+ if ("*".equals(((String) tmp).trim())) {
+ throw new SolrException(
+ SolrException.ErrorCode.SERVER_ERROR, "'excludeFields' can't exclude all fields.");
+ }
+ // Exclude fields support comma separated list of fields (e.g.
+ // "excluded_field1,excluded_field2").
+ // Also supports "*" to exclude all fields
+ this.excludeFields =
+ StrUtils.splitSmart((String) tmp, SEPARATOR).stream()
+ .map(String::trim)
+ .collect(Collectors.toList());
+ }
+ excludeFields.add(hashField); // Hash field name is excluded from hash computation
+
+ tmp = args.remove("hashCompareStrategy");
+ if (tmp != null) {
+ if (!(tmp instanceof String)) {
+ throw new SolrException(
+ SolrException.ErrorCode.SERVER_ERROR,
+ "'hashCompareStrategy' must be configured as a ");
+ }
+ String value = ((String) tmp).toLowerCase(Locale.ROOT);
+ if ("drop".equalsIgnoreCase(value)) {
+ dropSameDocuments = true;
+ } else if ("log".equalsIgnoreCase(value)) {
+ dropSameDocuments = false;
+ } else {
+ throw new SolrException(
+ SolrException.ErrorCode.SERVER_ERROR,
+ "Value '"
+ + value
+ + "' is unsupported for 'hashCompareStrategy', only 'drop' and 'log' are supported.");
+ }
+ }
+
+ super.init(args);
+ }
+
+ @Override
+ public UpdateRequestProcessor getInstance(
+ SolrQueryRequest req, SolrQueryResponse rsp, UpdateRequestProcessor next) {
+ return new ContentHashVersionProcessor(
+ buildFieldMatcher(includeFields),
+ buildFieldMatcher(excludeFields),
+ hashField,
+ dropSameDocuments,
+ req,
+ rsp,
+ next);
+ }
+
+ public String getHashField() {
+ return hashField;
+ }
+
+ public List getIncludeFields() {
+ return includeFields;
+ }
+
+ public List getExcludeFields() {
+ return excludeFields;
+ }
+
+ public boolean dropSameDocuments() {
+ return dropSameDocuments;
+ }
+
+ static Predicate buildFieldMatcher(List fieldNames) {
+ return inputField -> {
+ for (String currentFieldName : fieldNames) {
+ if ("*".equals(currentFieldName)) {
+ return true;
+ }
+ final String fieldName = inputField.getName();
+ if (fieldName.equals(currentFieldName)) {
+ return true;
+ }
+ if (currentFieldName.length() > 1
+ && currentFieldName.endsWith("*")
+ && fieldName.startsWith(currentFieldName.substring(0, currentFieldName.length() - 1))) {
+ return true;
+ }
+ }
+ return false;
+ };
+ }
+}
diff --git a/solr/core/src/test-files/solr/collection1/conf/schema16.xml b/solr/core/src/test-files/solr/collection1/conf/schema16.xml
new file mode 100644
index 000000000000..f34bfed2e4c0
--- /dev/null
+++ b/solr/core/src/test-files/solr/collection1/conf/schema16.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ _id
+
diff --git a/solr/core/src/test-files/solr/collection1/conf/solrconfig-contenthashversion.xml b/solr/core/src/test-files/solr/collection1/conf/solrconfig-contenthashversion.xml
new file mode 100644
index 000000000000..1308c63a9727
--- /dev/null
+++ b/solr/core/src/test-files/solr/collection1/conf/solrconfig-contenthashversion.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+ ${tests.luceneMatchVersion:LATEST}
+
+
+
+
+ ${solr.data.dir:}
+
+
+
+
+
+ ${solr.ulog.dir:}
+
+
+
+
+
+ _hash_
+ _id
+
+
+
+
+
+
+ _hash_
+ _id
+ log
+
+
+
+
+
+
+ _hash_
+ _id
+ drop
+
+
+
+
+
+
+
diff --git a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
index 6736af93b686..29c17139d2a4 100644
--- a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
+++ b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
@@ -25,6 +25,7 @@
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
+import java.io.DataOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
@@ -1002,6 +1003,32 @@ public void testUploadWithForbiddenContent() throws Exception {
assertEquals(400, res);
}
+ @Test
+ public void testUploadWithBlankFile() throws Exception {
+ // Uploads a zip containing a blank (0-byte) file using STORED method with an EXT descriptor.
+ // Java's ZipInputStream cannot read this format, but ZipFile can.
+ // Verifies the upload succeeds and the empty file is stored in the configset.
+ final String configSetName = "blank-file-configset";
+ final String suffix = "-suffix";
+ final Path zipFile = createTempZipWithStoredEntryAndExtDescriptor();
+ try (SolrZkClient zkClient =
+ new SolrZkClient.Builder()
+ .withUrl(cluster.getZkServer().getZkAddress())
+ .withTimeout(AbstractZkTestCase.TIMEOUT, TimeUnit.MILLISECONDS)
+ .withConnTimeOut(45000, TimeUnit.MILLISECONDS)
+ .build()) {
+ long res = uploadGivenConfigSet(zipFile, configSetName, suffix, null, true, false, true);
+ assertEquals("Upload of configset with blank file should succeed", 0L, res);
+ assertTrue(
+ "blank.txt should have been uploaded to the configset",
+ zkClient.exists("/configs/" + configSetName + suffix + "/blank.txt"));
+ assertArrayEquals(
+ "blank.txt in configset should be empty",
+ new byte[0],
+ zkClient.getData("/configs/" + configSetName + suffix + "/blank.txt", null, null));
+ }
+ }
+
@Test
public void testGetFile() throws Exception {
String configSetName = "regular";
@@ -1331,6 +1358,99 @@ private Path createTempZipFileWithForbiddenContent(String resourcePath) {
}
}
+ /**
+ * Creates a zip file (in the temp directory) containing an empty file entry that uses the STORED
+ * compression method with the EXT descriptor flag set. Some zip tools produce this format for
+ * empty (0-byte) files, e.g., when using {@code touch conf/blank.txt} followed by {@code zip -r
+ * ...}. Java's {@link java.util.zip.ZipInputStream} cannot read this combination, but {@link
+ * java.util.zip.ZipFile} handles it correctly by reading from the central directory.
+ */
+ private Path createTempZipWithStoredEntryAndExtDescriptor() throws IOException {
+ final Path zipFile = createTempFile("configset-blank", "zip");
+ // Build a valid ZIP file manually with one STORED entry that has the EXT (data descriptor)
+ // flag set (flag bit 3 = 0x08). Java's ZipInputStream rejects this combination.
+ // All multi-byte fields are little-endian.
+ byte[] fileName = "blank.txt".getBytes(UTF_8);
+ int fileNameLen = fileName.length; // 9
+
+ // Offsets for computing central directory offset
+ // Local file header size: 30 + fileNameLen
+ int localHeaderSize = 30 + fileNameLen;
+ // Data descriptor size: 16 (with signature)
+ int dataDescriptorSize = 16;
+ // Central directory header size: 46 + fileNameLen
+ int centralDirHeaderSize = 46 + fileNameLen;
+ int centralDirOffset = localHeaderSize + dataDescriptorSize; // = 55
+
+ try (DataOutputStream dos = new DataOutputStream(Files.newOutputStream(zipFile))) {
+ // --- Local file header ---
+ dos.write(new byte[] {0x50, 0x4b, 0x03, 0x04}); // signature PK\x03\x04
+ dos.write(new byte[] {0x14, 0x00}); // version needed = 20
+ dos.write(new byte[] {0x08, 0x00}); // flag: bit 3 (data descriptor / EXT)
+ dos.write(new byte[] {0x00, 0x00}); // compression method: STORED
+ dos.write(new byte[] {0x00, 0x00}); // last mod time
+ dos.write(new byte[] {0x00, 0x00}); // last mod date
+ dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // CRC-32 (0, deferred to data descriptor)
+ dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // compressed size (deferred)
+ dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // uncompressed size (deferred)
+ dos.write(new byte[] {(byte) fileNameLen, 0x00}); // file name length
+ dos.write(new byte[] {0x00, 0x00}); // extra field length
+ dos.write(fileName); // file name "blank.txt"
+ // (no file data — the file is empty)
+
+ // --- Data descriptor (EXT record) ---
+ dos.write(new byte[] {0x50, 0x4b, 0x07, 0x08}); // signature PK\x07\x08
+ dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // CRC-32 (0 for empty file)
+ dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // compressed size
+ dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // uncompressed size
+
+ // --- Central directory header ---
+ dos.write(new byte[] {0x50, 0x4b, 0x01, 0x02}); // signature PK\x01\x02
+ dos.write(new byte[] {0x14, 0x00}); // version made by
+ dos.write(new byte[] {0x14, 0x00}); // version needed
+ dos.write(new byte[] {0x08, 0x00}); // flag (same as local header)
+ dos.write(new byte[] {0x00, 0x00}); // compression method: STORED
+ dos.write(new byte[] {0x00, 0x00}); // last mod time
+ dos.write(new byte[] {0x00, 0x00}); // last mod date
+ dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // CRC-32
+ dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // compressed size
+ dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // uncompressed size
+ dos.write(new byte[] {(byte) fileNameLen, 0x00}); // file name length
+ dos.write(new byte[] {0x00, 0x00}); // extra field length
+ dos.write(new byte[] {0x00, 0x00}); // file comment length
+ dos.write(new byte[] {0x00, 0x00}); // disk number start
+ dos.write(new byte[] {0x00, 0x00}); // internal file attributes
+ dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // external file attributes
+ dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // local header relative offset (= 0)
+ dos.write(fileName); // file name "blank.txt"
+
+ // --- End of central directory record ---
+ dos.write(new byte[] {0x50, 0x4b, 0x05, 0x06}); // signature PK\x05\x06
+ dos.write(new byte[] {0x00, 0x00}); // disk number
+ dos.write(new byte[] {0x00, 0x00}); // disk with start of central directory
+ dos.write(new byte[] {0x01, 0x00}); // entries on this disk
+ dos.write(new byte[] {0x01, 0x00}); // total entries
+ // size of central directory
+ dos.write(
+ new byte[] {
+ (byte) (centralDirHeaderSize & 0xFF),
+ (byte) ((centralDirHeaderSize >> 8) & 0xFF),
+ (byte) ((centralDirHeaderSize >> 16) & 0xFF),
+ (byte) ((centralDirHeaderSize >> 24) & 0xFF)
+ });
+ // offset of central directory
+ dos.write(
+ new byte[] {
+ (byte) (centralDirOffset & 0xFF),
+ (byte) ((centralDirOffset >> 8) & 0xFF),
+ (byte) ((centralDirOffset >> 16) & 0xFF),
+ (byte) ((centralDirOffset >> 24) & 0xFF)
+ });
+ dos.write(new byte[] {0x00, 0x00}); // comment length
+ }
+ return zipFile;
+ }
+
private static void zipWithForbiddenContent(Path directory, Path zipfile) throws IOException {
OutputStream out = Files.newOutputStream(zipfile);
assertTrue(Files.isDirectory(directory));
diff --git a/solr/core/src/test/org/apache/solr/update/processor/ContentHashVersionProcessorFactoryTest.java b/solr/core/src/test/org/apache/solr/update/processor/ContentHashVersionProcessorFactoryTest.java
new file mode 100644
index 000000000000..b278838de9b7
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/update/processor/ContentHashVersionProcessorFactoryTest.java
@@ -0,0 +1,156 @@
+/*
+ * 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.solr.update.processor;
+
+import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.util.List;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.NamedList;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class ContentHashVersionProcessorFactoryTest {
+
+ @BeforeClass
+ public static void beforeClass() throws Exception {
+ assumeWorkingMockito();
+ }
+
+ @Test
+ public void shouldHaveSensibleDefaultValues() {
+ ContentHashVersionProcessorFactory factory = new ContentHashVersionProcessorFactory();
+ assertEquals(List.of("*"), factory.getIncludeFields());
+ assertTrue(factory.dropSameDocuments());
+ }
+
+ @Test
+ public void shouldInitWithHashFieldName() {
+ ContentHashVersionProcessorFactory factory = new ContentHashVersionProcessorFactory();
+ NamedList args = new NamedList<>();
+ args.add("hashField", "_hash_field_");
+ factory.init(args);
+
+ assertEquals("_hash_field_", factory.getHashField());
+ }
+
+ @Test
+ public void shouldInitWithAllField() {
+ ContentHashVersionProcessorFactory factory = new ContentHashVersionProcessorFactory();
+ NamedList args = new NamedList<>();
+ args.add("hashField", "content_hash");
+ args.add("includeFields", "*");
+ factory.init(args);
+
+ assertEquals(1, factory.getIncludeFields().size());
+ assertEquals("*", factory.getIncludeFields().getFirst());
+ }
+
+ @Test
+ public void shouldInitWithIncludedFields() {
+ ContentHashVersionProcessorFactory factory = new ContentHashVersionProcessorFactory();
+ NamedList args = new NamedList<>();
+ args.add("hashField", "content_hash");
+ args.add("includeFields", " field1,field2 , field3 ");
+ factory.init(args);
+
+ assertEquals(3, factory.getIncludeFields().size());
+ assertEquals(List.of("field1", "field2", "field3"), factory.getIncludeFields());
+ }
+
+ @Test
+ public void shouldInitWithExcludedFields() {
+ ContentHashVersionProcessorFactory factory = new ContentHashVersionProcessorFactory();
+ NamedList args = new NamedList<>();
+ args.add("hashField", "content_hash");
+ args.add("excludeFields", " field1,field2 , field3 ");
+ factory.init(args);
+
+ assertEquals(4, factory.getExcludeFields().size());
+ assertEquals(List.of("field1", "field2", "field3", "content_hash"), factory.getExcludeFields());
+ }
+
+ @Test
+ public void shouldSelectDropStrategy() {
+ ContentHashVersionProcessorFactory factory = new ContentHashVersionProcessorFactory();
+ NamedList args = new NamedList<>();
+ args.add("hashField", "content_hash");
+ args.add("hashCompareStrategy", "drop");
+ factory.init(args);
+
+ assertTrue(factory.dropSameDocuments());
+ }
+
+ @Test
+ public void shouldSelectLogStrategy() {
+ ContentHashVersionProcessorFactory factory = new ContentHashVersionProcessorFactory();
+ NamedList args = new NamedList<>();
+ args.add("hashField", "content_hash");
+ args.add("hashCompareStrategy", "log");
+ factory.init(args);
+
+ assertFalse(factory.dropSameDocuments());
+ }
+
+ @Test(expected = SolrException.class)
+ public void shouldSelectUnsupportedStrategy() {
+ ContentHashVersionProcessorFactory factory = new ContentHashVersionProcessorFactory();
+ NamedList args = new NamedList<>();
+ args.add("hashField", "content_hash");
+ args.add("hashCompareStrategy", "unsupported value");
+ factory.init(args);
+ }
+
+ @Test(expected = SolrException.class)
+ public void shouldRejectExcludeAllFields() {
+ ContentHashVersionProcessorFactory factory = new ContentHashVersionProcessorFactory();
+ NamedList args = new NamedList<>();
+ args.add("hashField", "content_hash");
+ args.add("excludeFields", "*");
+ factory.init(args);
+ }
+
+ @Test(expected = SolrException.class)
+ public void shouldRequireExplicitHashFieldName() {
+ ContentHashVersionProcessorFactory factory = new ContentHashVersionProcessorFactory();
+ NamedList args = new NamedList<>();
+ // Intentionally not setting hashField
+ factory.init(args);
+ }
+
+ @Test
+ public void shouldAutoExcludeHashFieldFromHashComputation() {
+ ContentHashVersionProcessorFactory factory = new ContentHashVersionProcessorFactory();
+ NamedList args = new NamedList<>();
+ args.add("hashField", "my_hash_field");
+ args.add("excludeFields", "field1,field2");
+ factory.init(args);
+
+ // Hash field should be automatically added to excludeFields
+ assertEquals(3, factory.getExcludeFields().size());
+ assertTrue(
+ "Should contain explicitly excluded field1", factory.getExcludeFields().contains("field1"));
+ assertTrue(
+ "Should contain explicitly excluded field2", factory.getExcludeFields().contains("field2"));
+ assertTrue(
+ "Should auto-exclude hash field name",
+ factory.getExcludeFields().contains("my_hash_field"));
+ }
+}
diff --git a/solr/core/src/test/org/apache/solr/update/processor/ContentHashVersionProcessorTest.java b/solr/core/src/test/org/apache/solr/update/processor/ContentHashVersionProcessorTest.java
new file mode 100644
index 000000000000..4a3764ed6a76
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/update/processor/ContentHashVersionProcessorTest.java
@@ -0,0 +1,452 @@
+/*
+ * 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.solr.update.processor;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.List;
+import java.util.UUID;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.schema.BinaryField;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.SchemaField;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+public class ContentHashVersionProcessorTest extends UpdateProcessorTestBase {
+
+ private static final String ID_FIELD = "_id";
+ private static final String FIRST_FIELD = "field1";
+ private static final String SECOND_FIELD = "field2";
+ private static final String THIRD_FIELD = "docField3";
+ private static final String FOURTH_FIELD = "field4";
+
+ private static final String INITIAL_DOC_ID = "1";
+ private static final String INITIAL_FIELD1_VALUE = "Initial values used to compute initial hash";
+ private static final String INITIAL_FIELD2_VALUE =
+ "This a constant value for testing include/exclude fields";
+ private static final String INITIAL_DOC =
+ adoc(
+ ID_FIELD, INITIAL_DOC_ID,
+ FIRST_FIELD, INITIAL_FIELD1_VALUE,
+ SECOND_FIELD, INITIAL_FIELD2_VALUE);
+ private String initialDocHash;
+
+ @BeforeClass
+ public static void beforeClass() throws Exception {
+ assumeWorkingMockito();
+ initCore("solrconfig-contenthashversion.xml", "schema16.xml");
+ }
+
+ @Before
+ @Override
+ public void setUp() throws Exception {
+ super.setUp();
+ assertU(delQ("*:*"));
+ addDoc(INITIAL_DOC, "contenthashversion-default");
+ assertU(commit());
+
+ // Query for the document and extract _hash_ field value
+ initialDocHash = getHashFieldValue(INITIAL_DOC_ID);
+ }
+
+ private static String getHashFieldValue(String docId) throws Exception {
+ String response = h.query(req("q", ID_FIELD + ":" + docId, "fl", "_hash_"));
+
+ // Parse XML response to extract _hash_ field value
+ // Response format: value
+ String hashPattern = "";
+ int startIdx = response.indexOf(hashPattern);
+ if (startIdx == -1) {
+ fail("Hash field not found in document " + docId);
+ }
+ startIdx += hashPattern.length();
+ int endIdx = response.indexOf("", startIdx);
+ if (endIdx == -1) {
+ fail("Hash field closing tag not found");
+ }
+ return response.substring(startIdx, endIdx);
+ }
+
+ private ContentHashVersionProcessor getContentHashVersionProcessor(
+ List includedFields, List excludedFields) {
+ final SolrQueryRequest req = mock(SolrQueryRequest.class);
+ final SolrCore solrCore = mock(SolrCore.class);
+ final IndexSchema indexSchema = mock(IndexSchema.class);
+ when(indexSchema.getField("_hash_")).thenReturn(new SchemaField("_hash_", new BinaryField()));
+
+ when(solrCore.getLatestSchema()).thenReturn(indexSchema);
+ when(req.getCore()).thenReturn(solrCore);
+ return new ContentHashVersionProcessor(
+ ContentHashVersionProcessorFactory.buildFieldMatcher(includedFields),
+ ContentHashVersionProcessorFactory.buildFieldMatcher(excludedFields),
+ "_hash_",
+ false,
+ req,
+ mock(SolrQueryResponse.class),
+ mock(UpdateRequestProcessor.class));
+ }
+
+ @Test
+ public void shouldUseExcludedFieldsWildcard() {
+ // Given
+ ContentHashVersionProcessor processor =
+ getContentHashVersionProcessor(List.of("*"), List.of("field*"));
+
+ // Given (doc for update)
+ SolrInputDocument inputDocument =
+ doc(
+ f(ID_FIELD, "0000000001"),
+ f(FIRST_FIELD, UUID.randomUUID().toString()),
+ f(SECOND_FIELD, UUID.randomUUID().toString()),
+ f(THIRD_FIELD, "constant to have a constant hash"),
+ f(FOURTH_FIELD, UUID.randomUUID().toString()));
+
+ // Then (only ID and THIRD_FIELD is used in hash, other fields contain random values)
+ assertArrayEquals(
+ Base64.getDecoder().decode("bwE8Zjq0aOs="),
+ processor.computeDocHash(inputDocument)); // Hash if only ID field was used
+ }
+
+ @Test
+ public void shouldUseIncludedFieldsWildcard() {
+ // Given
+ ContentHashVersionProcessor processor =
+ getContentHashVersionProcessor(List.of("field*"), List.of(THIRD_FIELD));
+
+ // Given (doc for update)
+ SolrInputDocument inputDocument =
+ doc(
+ f(ID_FIELD, "0000000001"),
+ f(FIRST_FIELD, "constant to have a constant hash for field1"),
+ f(SECOND_FIELD, "constant to have a constant hash for field2"),
+ f(THIRD_FIELD, UUID.randomUUID().toString()),
+ f(FOURTH_FIELD, "constant to have a constant hash for field4"));
+
+ // Then
+ assertArrayEquals(
+ Base64.getDecoder().decode("PozPs2qZQtw="), processor.computeDocHash(inputDocument));
+ }
+
+ @Test
+ public void shouldUseIncludedFieldsWildcard2() {
+ // Given (variant of previous shouldUseIncludedFieldsWildcard, without the excludedField config)
+ ContentHashVersionProcessor processor =
+ getContentHashVersionProcessor(List.of("field*"), List.of());
+
+ // Given (doc for update)
+ SolrInputDocument inputDocument =
+ doc(
+ f(ID_FIELD, "0000000001"),
+ f(FIRST_FIELD, "constant to have a constant hash for field1"),
+ f(SECOND_FIELD, "constant to have a constant hash for field2"),
+ f(THIRD_FIELD, UUID.randomUUID().toString()),
+ f(FOURTH_FIELD, "constant to have a constant hash for field4"));
+
+ // Then
+ assertArrayEquals(
+ Base64.getDecoder().decode("PozPs2qZQtw="), processor.computeDocHash(inputDocument));
+ }
+
+ @Test
+ public void shouldDedupIncludedFields() {
+ // Given (processor to include field1 and field2 only)
+ ContentHashVersionProcessor processorWithDuplicatedFieldName =
+ getContentHashVersionProcessor(List.of(FIRST_FIELD, FIRST_FIELD, SECOND_FIELD), List.of());
+ ContentHashVersionProcessor processorWithWildcard =
+ getContentHashVersionProcessor(
+ List.of( // Also change order of config (test reorder of field names)
+ SECOND_FIELD, FIRST_FIELD, "field1*"),
+ List.of());
+
+ // Given (doc for update)
+ SolrInputDocument inputDocument =
+ doc(
+ f(ID_FIELD, "0000000001"),
+ f(FIRST_FIELD, "constant to have a constant hash for field1"),
+ f(SECOND_FIELD, "constant to have a constant hash for field2"),
+ f(THIRD_FIELD, UUID.randomUUID().toString()),
+ f(FOURTH_FIELD, "constant to have a constant hash for field4"));
+
+ // Then
+ assertArrayEquals(
+ Base64.getDecoder().decode("XavrOYGlkXM="),
+ processorWithDuplicatedFieldName.computeDocHash(inputDocument));
+ assertArrayEquals(
+ Base64.getDecoder().decode("XavrOYGlkXM="),
+ processorWithWildcard.computeDocHash(inputDocument));
+ }
+
+ @Test
+ public void shouldCreateSignatureForNewDoc() throws Exception {
+ // When (update)
+ final String newDocId = UUID.randomUUID().toString();
+ assertU(
+ adoc(
+ ID_FIELD, newDocId,
+ FIRST_FIELD, INITIAL_FIELD1_VALUE,
+ SECOND_FIELD, INITIAL_FIELD2_VALUE));
+ assertU(commit());
+
+ // Then
+ final String hashFieldValueForNewDoc = getHashFieldValue(newDocId);
+ assertEquals(initialDocHash, hashFieldValueForNewDoc);
+ }
+
+ @Test
+ public void shouldAddToResponseLog() throws Exception {
+ // Given (command to update existing doc)
+ final String newDocId = UUID.randomUUID().toString();
+ final SolrQueryResponse update1 =
+ addDoc(
+ adoc(
+ ID_FIELD, newDocId,
+ FIRST_FIELD, INITIAL_FIELD1_VALUE,
+ SECOND_FIELD, INITIAL_FIELD2_VALUE),
+ "contenthashversion-default");
+ final SolrQueryResponse update2 =
+ addDoc(
+ adoc(
+ ID_FIELD, newDocId,
+ FIRST_FIELD, "This is a doc with values",
+ SECOND_FIELD, "that differs from stored doc, so it's considered new"),
+ "contenthashversion-default");
+ assertU(commit());
+
+ // Then
+ assertResponse(update1, -1, -1);
+ assertResponse(update2, 0, -1);
+ }
+
+ @Test
+ public void shouldKeepDuplicateDocumentsInLogMode() throws Exception {
+ // Given: Use log chain which detects but does NOT drop duplicates
+ final String docId = UUID.randomUUID().toString();
+
+ // When: Add a document
+ addDoc(
+ adoc(
+ ID_FIELD, docId,
+ FIRST_FIELD, "original value",
+ SECOND_FIELD, "original value 2"),
+ "contenthashversion-log");
+ assertU(commit());
+ String originalHash = getHashFieldValue(docId);
+
+ // When: Try to add the same content again (duplicate)
+ SolrQueryResponse duplicateResponse =
+ addDoc(
+ adoc(
+ ID_FIELD, docId,
+ FIRST_FIELD, "original value",
+ SECOND_FIELD, "original value 2"),
+ "contenthashversion-log");
+ assertU(commit());
+
+ // Then: Response should show duplicate was detected but NOT dropped
+ assertResponse(duplicateResponse, -1, 1);
+
+ // Then: Document should still exist in index
+ assertQ(req("q", ID_FIELD + ":" + docId), "//result[@numFound='1']");
+
+ // Then: Document hash should remain unchanged (duplicate was processed)
+ String currentHash = getHashFieldValue(docId);
+ assertEquals("Hash should remain unchanged for duplicate", originalHash, currentHash);
+
+ // When: Update with different content
+ SolrQueryResponse changedResponse =
+ addDoc(
+ adoc(
+ ID_FIELD, docId,
+ FIRST_FIELD, "changed value",
+ SECOND_FIELD, "changed value 2"),
+ "contenthashversion-log");
+ assertU(commit());
+
+ // Then: Response should show content changed
+ assertResponse(changedResponse, -1, 0);
+
+ // Then: Hash should be updated
+ String newHash = getHashFieldValue(docId);
+ assertNotEquals("Hash should change for different content", originalHash, newHash);
+ }
+
+ @Test
+ public void shouldExcludeFieldsUpdateSignatureForNewDoc() throws Exception {
+ // Given (update using URP chain WITHOUT drop doc (log mode))
+ final String newDocId = UUID.randomUUID().toString();
+ addDoc(
+ adoc(
+ ID_FIELD, newDocId,
+ FIRST_FIELD, INITIAL_FIELD1_VALUE,
+ SECOND_FIELD, INITIAL_FIELD2_VALUE),
+ "contenthashversion-default");
+ assertU(commit());
+
+ // Then
+ final String hashFieldValue = getHashFieldValue(newDocId);
+ assertEquals(initialDocHash, hashFieldValue);
+ }
+
+ @Test
+ public void shouldCommitWithDropModeEnabled() throws Exception {
+ // Initial document already exists from setUp()
+ // When: Try to add the same document again (duplicate content) using URP chain WITH drop doc
+ // (drop mode)
+ SolrQueryResponse solrQueryResponse =
+ addDoc(
+ adoc(
+ ID_FIELD, INITIAL_DOC_ID,
+ FIRST_FIELD, INITIAL_FIELD1_VALUE,
+ SECOND_FIELD, INITIAL_FIELD2_VALUE),
+ "contenthashversion-drop");
+ assertU(commit());
+
+ // Then: Verify response shows duplicate was dropped
+ assertResponse(solrQueryResponse, 1, -1);
+
+ // Then: Verify document was NOT actually added/updated (still only 1 doc in index)
+ assertQ(req("q", "*:*"), "//result[@numFound='1']");
+
+ // Verify the document still has the original hash
+ String currentHash = getHashFieldValue(INITIAL_DOC_ID);
+ assertEquals("Document hash should not have changed", initialDocHash, currentHash);
+ }
+
+ @Test
+ public void shouldHandleDocumentWithOnlyIdField() {
+ // Given: Document with only ID field (no other fields to hash)
+ ContentHashVersionProcessor processor =
+ getContentHashVersionProcessor(List.of("*"), List.of(ID_FIELD));
+
+ // When: Compute hash for document with only ID
+ SolrInputDocument doc = doc(f(ID_FIELD, "only-id-doc"));
+
+ // Then: Should compute hash (even if empty field set)
+ byte[] hash = processor.computeDocHash(doc);
+ assertNotNull("Hash should not be null for ID-only document", hash);
+ assertTrue("Hash should not be empty", hash.length > 0);
+ }
+
+ @Test
+ public void shouldHandleMultiValueFields() {
+ // Given: Processor that includes multi-value fields
+ ContentHashVersionProcessor processor =
+ getContentHashVersionProcessor(List.of("*"), List.of(ID_FIELD));
+
+ // When: Document with multi-value field
+ SolrInputDocument doc1 = doc(f(ID_FIELD, "doc1"), f(FIRST_FIELD, "value1", "value2", "value3"));
+
+ // Then: Should compute consistent hash
+ byte[] hash1 = processor.computeDocHash(doc1);
+ assertNotNull(hash1);
+
+ // Same values in same order should produce same hash
+ SolrInputDocument doc2 = doc(f(ID_FIELD, "doc2"), f(FIRST_FIELD, "value1", "value2", "value3"));
+ byte[] hash2 = processor.computeDocHash(doc2);
+ assertArrayEquals("Same multi-value field should produce same hash", hash1, hash2);
+
+ // Different order should produce different hash (collection order matters)
+ SolrInputDocument doc3 = doc(f(ID_FIELD, "doc3"), f(FIRST_FIELD, "value3", "value1", "value2"));
+ byte[] hash3 = processor.computeDocHash(doc3);
+ assertFalse("Different order should produce different hash", Arrays.equals(hash1, hash3));
+ }
+
+ @Test
+ public void shouldHandleNullFieldValues() {
+ // Given: Processor that handles null values
+ ContentHashVersionProcessor processor =
+ getContentHashVersionProcessor(List.of("*"), List.of(ID_FIELD));
+
+ // When: Document with null field value (represented as "null" string)
+ SolrInputDocument doc = doc(f(ID_FIELD, "null-doc"), f(FIRST_FIELD, (Object) null));
+
+ // Then: Should compute hash without error
+ byte[] hash = processor.computeDocHash(doc);
+ assertNotNull("Should handle null values", hash);
+ assertTrue("Hash should not be empty", hash.length > 0);
+ }
+
+ @Test
+ public void shouldProduceSameHashRegardlessOfFieldOrder() {
+ // Given: Documents with same fields in different order
+ ContentHashVersionProcessor processor =
+ getContentHashVersionProcessor(List.of("*"), List.of(ID_FIELD));
+
+ // When: Create docs with fields in different order
+ SolrInputDocument doc1 =
+ doc(
+ f(ID_FIELD, "doc1"),
+ f(FIRST_FIELD, "value1"),
+ f(SECOND_FIELD, "value2"),
+ f(THIRD_FIELD, "value3"));
+
+ SolrInputDocument doc2 =
+ doc(
+ f(ID_FIELD, "doc2"),
+ f(THIRD_FIELD, "value3"),
+ f(FIRST_FIELD, "value1"),
+ f(SECOND_FIELD, "value2"));
+
+ // Then: Hashes should be identical (fields are sorted before hashing)
+ byte[] hash1 = processor.computeDocHash(doc1);
+ byte[] hash2 = processor.computeDocHash(doc2);
+ assertArrayEquals("Hash should be same regardless of field order", hash1, hash2);
+ }
+
+ @Test
+ public void shouldHandleEmptyFieldValues() {
+ // Given: Document with empty string values
+ ContentHashVersionProcessor processor =
+ getContentHashVersionProcessor(List.of("*"), List.of(ID_FIELD));
+
+ SolrInputDocument doc1 = doc(f(ID_FIELD, "empty-doc"), f(FIRST_FIELD, ""), f(SECOND_FIELD, ""));
+
+ // When: Compute hash
+ byte[] hash1 = processor.computeDocHash(doc1);
+
+ // Then: Should produce valid hash
+ assertNotNull("Should handle empty values", hash1);
+ assertTrue("Hash should not be empty", hash1.length > 0);
+
+ // Empty strings should produce different hash than no fields
+ SolrInputDocument doc2 = doc(f(ID_FIELD, "empty-doc"));
+ byte[] hash2 = processor.computeDocHash(doc2);
+ assertFalse("Empty string fields should differ from no fields", Arrays.equals(hash1, hash2));
+ }
+
+ private static void assertResponse(
+ SolrQueryResponse solrQueryResponse, int droppedDocCount, int duplicateDocCount) {
+ if (droppedDocCount >= 0) {
+ assertNotNull(solrQueryResponse.getToLog().get("contentHash.duplicatesDropped"));
+ int droppedDocs = (int) solrQueryResponse.getToLog().get("contentHash.duplicatesDropped");
+ assertEquals(droppedDocCount, droppedDocs);
+ }
+ if (duplicateDocCount >= 0) {
+ assertNotNull(solrQueryResponse.getToLog().get("contentHash.duplicatesDetected"));
+ int duplicateDocs = (int) solrQueryResponse.getToLog().get("contentHash.duplicatesDetected");
+ assertEquals(duplicateDocCount, duplicateDocs);
+ }
+ }
+}
diff --git a/solr/solr-ref-guide/modules/configuration-guide/pages/update-request-processors.adoc b/solr/solr-ref-guide/modules/configuration-guide/pages/update-request-processors.adoc
index 674b0b49360e..4d67da152419 100644
--- a/solr/solr-ref-guide/modules/configuration-guide/pages/update-request-processors.adoc
+++ b/solr/solr-ref-guide/modules/configuration-guide/pages/update-request-processors.adoc
@@ -410,6 +410,12 @@ When using any of these factories, please consult the {solr-javadocs}/core/org/a
{solr-javadocs}/core/org/apache/solr/update/processor/UniqFieldsUpdateProcessorFactory.html[UniqFieldsUpdateProcessorFactory]:: Removes duplicate values found in fields matching the specified conditions.
+{solr-javadocs}/core/org/apache/solr/update/processor/ContentHashVersionProcessorFactory.html[ContentHashVersionProcessorFactory]:: Removes duplicate documents detected using a configurable hash of field values.
+[WARNING]
+====
+This URP should be configured to exclude fields that are updated with xref:indexing-guide:partial-document-updates.adoc[in-place updates] from the content hash calculation, as these fields are updated independently and should not affect duplicate detection.
+====
+
=== Update Processor Factories That Can Be Loaded as Plugins
These processors are included in Solr releases as "module", and require additional jars loaded at runtime.
diff --git a/solr/test-framework/src/java/org/apache/solr/SolrTestCaseJ4.java b/solr/test-framework/src/java/org/apache/solr/SolrTestCaseJ4.java
index ca7677a7e787..b450cff5bd71 100644
--- a/solr/test-framework/src/java/org/apache/solr/SolrTestCaseJ4.java
+++ b/solr/test-framework/src/java/org/apache/solr/SolrTestCaseJ4.java
@@ -1159,7 +1159,8 @@ public static String adoc(SolrInputDocument sdoc) {
return out.toString();
}
- public static void addDoc(String doc, String updateRequestProcessorChain) throws Exception {
+ public static SolrQueryResponse addDoc(String doc, String updateRequestProcessorChain)
+ throws Exception {
Map params = new HashMap<>();
MultiMapSolrParams mmparams = new MultiMapSolrParams(params);
params.put(UpdateParams.UPDATE_CHAIN, new String[] {updateRequestProcessorChain});
@@ -1168,8 +1169,11 @@ public static void addDoc(String doc, String updateRequestProcessorChain) throws
UpdateRequestHandler handler = new UpdateRequestHandler();
handler.init(null);
req.setContentStreams(List.of(new ContentStreamBase.StringStream(doc)));
- handler.handleRequestBody(req, new SolrQueryResponse());
+ final SolrQueryResponse rsp = new SolrQueryResponse();
+ handler.handleRequestBody(req, rsp);
req.close();
+
+ return rsp;
}
/**