diff --git a/.github/workflows/Lucene-Net-Index-Compatibility.yml b/.github/workflows/Lucene-Net-Index-Compatibility.yml new file mode 100644 index 0000000000..0ece2a8da8 --- /dev/null +++ b/.github/workflows/Lucene-Net-Index-Compatibility.yml @@ -0,0 +1,170 @@ +# 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. + +# Verifies two-way index/codec compatibility between Lucene.NET and Apache +# Lucene 4.8.1 (Java), per issue #270. Each runtime writes an index that the +# other reads back and validates with CheckIndex. See src/java/index-compat. +# +# This workflow is hand-maintained (NOT generated by Generate-TestWorkflows.ps1), +# because it needs a JDK and runs a Maven <-> dotnet orchestration that does not +# fit the generated per-project test template. + +name: 'Index Compatibility' + +on: + workflow_dispatch: + pull_request: + paths: + # The compatibility harness itself + - 'src/java/index-compat/**/*' + - 'src/Lucene.Net.Tests/Support/Index/CompatDocs.cs' + - 'src/Lucene.Net.Tests/Support/Index/TestJavaCompatibility.cs' + # Production code whose on-disk format the harness validates + - 'src/Lucene.Net/Codecs/**/*' + - 'src/Lucene.Net/Index/**/*' + - 'src/Lucene.Net/Store/**/*' + # Build/runtime inputs that can affect the produced index + - 'src/Lucene.Net.Analysis.Common/**/*' + - 'src/Lucene.Net.TestFramework/**/*' + - 'TestTargetFrameworks.*' + - 'Directory.Build.*' + - 'src/Directory.Build.*' + - '.github/workflows/Lucene-Net-Index-Compatibility.yml' + - '!**/*.md' + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: 1 + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages + BUILD_FOR_ALL_TEST_TARGET_FRAMEWORKS: 'true' + # The _J-S shard owns Support/Index/*.cs, so it contains TestJavaCompatibility. + project_path: './src/Lucene.Net.Tests._J-S/Lucene.Net.Tests._J-S.csproj' + compat_dir: './src/java/index-compat' + +jobs: + + Compatibility: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + framework: [net10.0, net8.0, net48, net472] + platform: [x64] + configuration: [Release] + exclude: + - os: ubuntu-latest + framework: net48 + - os: ubuntu-latest + framework: net472 + + steps: + - name: Checkout Source Code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Setup Java (JDK) + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: 'zulu' + java-version: '21' + + - name: Setup .NET 8 SDK + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + with: + dotnet-version: '8.0.x' + if: ${{ startswith(matrix.framework, 'net8.') }} + + - name: Setup .NET 10 SDK + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + with: + dotnet-version: '10.0.x' + + - name: Cache NuGet Packages + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + # Use a 'compat-' prefixed key so this cache lives in its own namespace. + # This job only restores the _J-S project, a smaller package closure than + # the generated workflows' full-solution restore. Sharing their 'nuget-...' + # key would let whichever job runs first populate a partial cache the other + # then gets a (wrong-sized) hit on. A separate key avoids that interaction. + key: compat-nuget-${{ runner.os }}-${{ env.BUILD_FOR_ALL_TEST_TARGET_FRAMEWORKS }}-${{ hashFiles('**/*.*proj', '**/*.props', '**/*.targets', '**/*.sln', '*.sln', 'global.json') }} + path: ${{ env.NUGET_PACKAGES }} + + - name: Cache Maven Packages + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + key: maven-${{ runner.os }}-${{ hashFiles('src/java/index-compat/pom.xml') }} + restore-keys: | + maven-${{ runner.os }}- + path: ~/.m2/repository + + - name: Restore .NET + run: dotnet restore "${{ env.project_path }}" /p:TestFrameworks=${{ env.BUILD_FOR_ALL_TEST_TARGET_FRAMEWORKS }} + + - name: Build .NET test shard + run: dotnet build "${{ env.project_path }}" --configuration "${{ matrix.configuration }}" --framework "${{ matrix.framework }}" --no-restore -p:TestFrameworks=${{ env.BUILD_FOR_ALL_TEST_TARGET_FRAMEWORKS }} + shell: bash + + # --------------------------------------------------------------------- + # Direction 1: .NET writes, Java reads + # --------------------------------------------------------------------- + # dotnet test exits 0 when a --filter matches nothing or a test is skipped, + # which would hide a misconfiguration. Capture the output and fail unless + # exactly the expected test ran and passed. + - name: '.NET writes index' + # The test reads its target via the env var "lucenenet.compat.write.dir". + # Names with dots are not valid bash identifiers, so we apply it with `env` + # rather than a plain assignment. Using `shell: bash` keeps this identical + # on Windows (Git Bash) and Linux. + run: | + set -o pipefail + out=$(env "lucenenet.compat.write.dir=$WRITE_DIR" dotnet test "${{ env.project_path }}" --configuration "${{ matrix.configuration }}" --framework "${{ matrix.framework }}" --no-build --no-restore --logger:"console;verbosity=normal" --filter "FullyQualifiedName~TestJavaCompatibility.TestWriteIndexForJava" 2>&1) + echo "$out" + echo "$out" | grep -Eq 'Passed:[[:space:]]*1\b' || { echo "::error::TestWriteIndexForJava did not run/pass"; exit 1; } + shell: bash + env: + WRITE_DIR: ${{ github.workspace }}/src/java/index-compat/work/dotnet + + - name: 'Java reads .NET index (nocfs)' + run: ./mvnw -q test "-Dlucenenet.index.dir=work/dotnet/index.481.nocfs" + working-directory: ${{ env.compat_dir }} + shell: bash + + - name: 'Java reads .NET index (cfs)' + run: ./mvnw -q test "-Dlucenenet.index.dir=work/dotnet/index.481.cfs" + working-directory: ${{ env.compat_dir }} + shell: bash + + # --------------------------------------------------------------------- + # Direction 2: Java writes, .NET reads + # --------------------------------------------------------------------- + - name: 'Java writes index' + run: ./mvnw -q compile exec:java "-Dexec.args=work/java" + working-directory: ${{ env.compat_dir }} + shell: bash + + # Guard against a false green: TestReadJavaIndex self-skips (Inconclusive) when + # it can't find the index, and a skipped test still exits 0. Require Passed: 1. + - name: '.NET reads Java index' + run: | + set -o pipefail + out=$(env "lucenenet.compat.read.dir=$READ_DIR" dotnet test "${{ env.project_path }}" --configuration "${{ matrix.configuration }}" --framework "${{ matrix.framework }}" --no-build --no-restore --logger:"console;verbosity=normal" --filter "FullyQualifiedName~TestJavaCompatibility.TestReadJavaIndex" 2>&1) + echo "$out" + echo "$out" | grep -Eq 'Passed:[[:space:]]*1\b' || { echo "::error::TestReadJavaIndex did not run/pass (skipped or filtered out)"; exit 1; } + shell: bash + env: + READ_DIR: ${{ github.workspace }}/src/java/index-compat/work/java diff --git a/.gitignore b/.gitignore index 26d638ff11..879b68a209 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,10 @@ svn-*/ # Visual Studio Code/Rider files .vscode/ .idea/ + +# Index compatibility harness (src/java/index-compat): generated, never committed +src/java/**/target/ +src/java/**/work/ + +# macOS files +.DS_Store diff --git a/.rat-excludes b/.rat-excludes index 21a7051b95..d2e94e7690 100644 --- a/.rat-excludes +++ b/.rat-excludes @@ -16,6 +16,11 @@ _site/* svn-dev/* svn-release/* +# Maven wrapper scripts (src/java/index-compat) - generated, no ASF header +mvnw +mvnw\.cmd +maven-wrapper\.properties + # Exclude auto-generated designers .*\.Designer\.cs diff --git a/src/Lucene.Net.Tests/Support/Index/CompatDocs.cs b/src/Lucene.Net.Tests/Support/Index/CompatDocs.cs new file mode 100644 index 0000000000..9ca102d557 --- /dev/null +++ b/src/Lucene.Net.Tests/Support/Index/CompatDocs.cs @@ -0,0 +1,202 @@ +using Lucene.Net.Analysis; +using Lucene.Net.Analysis.Standard; +using Lucene.Net.Documents; +using Lucene.Net.Index.Extensions; +using Lucene.Net.Util; +using System; +using System.Collections.Generic; +using System.IO; +using JCG = J2N.Collections.Generic; +using Directory = Lucene.Net.Store.Directory; + +namespace Lucene.Net.Index +{ + /* + * 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. + */ + + /// + /// The single source of truth for the deterministic document set shared between + /// Lucene.NET and Apache Lucene 4.8.1 (Java). This intentionally mirrors, field + /// for field, the document schema produced by + /// TestBackwardsCompatibility.AddDoc / AddNoProxDoc and the read + /// back verification in TestBackwardsCompatibility.SearchIndex, so that an + /// index written by either runtime can be validated by the other. See issue #270. + /// + /// Nothing here may use randomness: both runtimes must produce semantically + /// identical indexes from the same inputs. + /// + internal static class CompatDocs + { + /// Number of "normal" (prox) documents indexed by . + public const int DocCount = 35; + + /// The id that is deleted, matching the Java harness. + public const int DeletedId = 7; + + // Exactly matches the Java literal: Lu + U+1D11E + ce + U+1D160 + ne + NUL + skull + astral + cd. + public const string Utf8Value = "Lu\uD834\uDD1Ece\uD834\uDD60ne \u0000 \u2620 ab\uD917\uDC17cd"; + public const string Content2Value = "here is more content with aaa aaa aaa"; + public const string NonAsciiFieldName = "fie\u2C77ld"; + public const string NonAsciiFieldValue = "field with non-ascii name"; + + /// + /// Writes the deterministic compatibility index into . The + /// result has 35 documents, with id 7 deleted, term vectors, offsets, norms, and + /// the full DocValues matrix, in either compound-file or non-compound-file form. + /// + public static void WriteIndex(Directory dir, bool useCompoundFile) + { + Analyzer analyzer = new StandardAnalyzer(LuceneVersion.LUCENE_48); + + LogByteSizeMergePolicy mp = new LogByteSizeMergePolicy + { + NoCFSRatio = useCompoundFile ? 1.0 : 0.0, + MaxCFSSegmentSizeMB = double.PositiveInfinity + }; + + IndexWriterConfig conf = new IndexWriterConfig(LuceneVersion.LUCENE_48, analyzer) + // Force the real default 4.8.x codec. LuceneTestCase otherwise + // randomizes Codec.Default to test-only impostors (e.g. a postings + // format named "NestedPulsing") that a stock Lucene cannot load. + .SetCodec(new Codecs.Lucene46.Lucene46Codec()) + .SetUseCompoundFile(useCompoundFile) + .SetMaxBufferedDocs(10) + .SetMergePolicy(mp); + + using (IndexWriter writer = new IndexWriter(dir, conf)) + { + for (int i = 0; i < DocCount; i++) + { + AddDoc(writer, i); + } + } + + // Delete id 7 in a fresh writer so the layout matches the Java harness. + conf = new IndexWriterConfig(LuceneVersion.LUCENE_48, analyzer) + .SetCodec(new Codecs.Lucene46.Lucene46Codec()) + .SetUseCompoundFile(useCompoundFile) + .SetMaxBufferedDocs(10) + .SetOpenMode(OpenMode.APPEND); + + using (IndexWriter writer = new IndexWriter(dir, conf)) + { + writer.DeleteDocuments(new Term("id", Convert.ToString(DeletedId))); + } + } + + private static void AddDoc(IndexWriter writer, int id) + { + Document doc = new Document(); + doc.Add(new TextField("content", "aaa", Field.Store.NO)); + doc.Add(new StringField("id", Convert.ToString(id), Field.Store.YES)); + + FieldType customType2 = new FieldType(TextField.TYPE_STORED) + { + StoreTermVectors = true, + StoreTermVectorPositions = true, + StoreTermVectorOffsets = true + }; + doc.Add(new Field("autf8", Utf8Value, customType2)); + doc.Add(new Field("utf8", Utf8Value, customType2)); + doc.Add(new Field("content2", Content2Value, customType2)); + doc.Add(new Field(NonAsciiFieldName, NonAsciiFieldValue, customType2)); + + // numeric fields + doc.Add(new Int32Field("trieInt", id, Field.Store.NO)); + doc.Add(new Int64Field("trieLong", id, Field.Store.NO)); + + // docvalues fields + doc.Add(new NumericDocValuesField("dvByte", (sbyte)id)); + sbyte[] bytes = { (sbyte)(id >>> 24), (sbyte)(id >>> 16), (sbyte)(id >>> 8), (sbyte)id }; + BytesRef @ref = new BytesRef((byte[])(Array)bytes); + doc.Add(new BinaryDocValuesField("dvBytesDerefFixed", @ref)); + doc.Add(new BinaryDocValuesField("dvBytesDerefVar", @ref)); + doc.Add(new SortedDocValuesField("dvBytesSortedFixed", @ref)); + doc.Add(new SortedDocValuesField("dvBytesSortedVar", @ref)); + doc.Add(new BinaryDocValuesField("dvBytesStraightFixed", @ref)); + doc.Add(new BinaryDocValuesField("dvBytesStraightVar", @ref)); + doc.Add(new DoubleDocValuesField("dvDouble", id)); + doc.Add(new SingleDocValuesField("dvFloat", id)); + doc.Add(new NumericDocValuesField("dvInt", id)); + doc.Add(new NumericDocValuesField("dvLong", id)); + doc.Add(new NumericDocValuesField("dvPacked", id)); + doc.Add(new NumericDocValuesField("dvShort", (short)id)); + doc.Add(new SortedSetDocValuesField("dvSortedSet", @ref)); + + // a field with both offsets and term vectors for a cross-check + FieldType customType3 = new FieldType(TextField.TYPE_STORED) + { + StoreTermVectors = true, + StoreTermVectorPositions = true, + StoreTermVectorOffsets = true, + IndexOptions = IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS + }; + doc.Add(new Field("content5", Content2Value, customType3)); + + // a field that omits only positions + FieldType customType4 = new FieldType(TextField.TYPE_STORED) + { + StoreTermVectors = true, + StoreTermVectorPositions = false, + StoreTermVectorOffsets = true, + IndexOptions = IndexOptions.DOCS_AND_FREQS + }; + doc.Add(new Field("content6", Content2Value, customType4)); + + writer.AddDocument(doc); + } + + /// + /// Runs the Lucene CheckIndex tool against and throws if + /// the index reports any problem. This is the codec integrity gate; it validates + /// the stored per-file checksums written by the codec. + /// + public static void CheckIndex(Directory dir) + { + TestUtil.CheckIndex(dir); + } + + /// + /// The sorted, unique set of terms that StandardAnalyzer(LUCENE_48) + /// produces for . An index written by either runtime must + /// contain exactly this term set in the utf8 field, which is the + /// cross-runtime contract for the UTF-8 edge cases. Computed by re-running the + /// analyzer so it stays correct if the constant changes. + /// + public static IList ExpectedUtf8Terms() + { + var terms = new JCG.SortedSet(StringComparer.Ordinal); + using Analyzer analyzer = new StandardAnalyzer(LuceneVersion.LUCENE_48); + TokenStream ts = analyzer.GetTokenStream("utf8", new StringReader(Utf8Value)); + try + { + var termAttr = ts.AddAttribute(); + ts.Reset(); + while (ts.IncrementToken()) + { + terms.Add(termAttr.ToString()); + } + ts.End(); + } + finally + { + ts.Close(); + } + return new JCG.List(terms); + } + } +} diff --git a/src/Lucene.Net.Tests/Support/Index/TestJavaCompatibility.cs b/src/Lucene.Net.Tests/Support/Index/TestJavaCompatibility.cs new file mode 100644 index 0000000000..2bd7705ef3 --- /dev/null +++ b/src/Lucene.Net.Tests/Support/Index/TestJavaCompatibility.cs @@ -0,0 +1,299 @@ +using Lucene.Net.Attributes; +using Lucene.Net.Documents; +using Lucene.Net.Search; +using Lucene.Net.Store; +using Lucene.Net.Util; +using NUnit.Framework; +using System; +using System.IO; +using JCG = J2N.Collections.Generic; +using Assert = Lucene.Net.TestFramework.Assert; +using Directory = Lucene.Net.Store.Directory; + +namespace Lucene.Net.Index +{ + /* + * 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. + */ + + /// + /// Verifies two-way index/codec compatibility between Lucene.NET and Apache + /// Lucene 4.8.1 (Java). + /// + /// This is the .NET half of the harness in src/java/index-compat. The + /// shared, deterministic document set lives in , which + /// mirrors, field for field, the Java CompatDocs class (which in turn + /// mirrors the schema in TestBackwardsCompatibility). Each runtime writes + /// an index that the other opens, validates with , and + /// reads back. + /// + /// We do not do byte-for-byte file comparison: some header fields legitimately + /// differ between runtimes (for example java.vendor), so + /// plus a semantic read back is the agreed approach. + /// + /// Two environment variables drive the cross-runtime directions, both set by + /// the run-compat driver script: + /// + /// lucenenet.compat.read.dir - a Java-written index to + /// read. If unset or missing, the read test is inconclusive (skipped), + /// because a JDK may not be present in every environment. + /// lucenenet.compat.write.dir - where to write the .NET + /// index for Java to read. Defaults to a temp directory. + /// + /// + [LuceneNetSpecific] + [TestFixture] + public class TestJavaCompatibility : LuceneTestCase + { + private const string ReadDirEnvVar = "lucenenet.compat.read.dir"; + private const string WriteDirEnvVar = "lucenenet.compat.write.dir"; + + // ------------------------------------------------------------------ + // Verification (mirror of Java TestDotNetCompatibility.assertContents) + // ------------------------------------------------------------------ + + internal static void AssertContents(Directory dir) + { + // Codec integrity gate (validates the per-file checksums the codec wrote). + CompatDocs.CheckIndex(dir); + + using DirectoryReader reader = DirectoryReader.Open(dir); + IndexSearcher searcher = NewSearcher(reader); + + IBits liveDocs = MultiFields.GetLiveDocs(reader); + + for (int i = 0; i < CompatDocs.DocCount; i++) + { + bool live = liveDocs is null || liveDocs.Get(i); + if (!live) + { + Assert.AreEqual(CompatDocs.DeletedId, i, "only id 7 should be deleted"); + continue; + } + + Document d = reader.Document(i); + Assert.AreEqual(Convert.ToString(i), d.Get("id"), "id"); + Assert.AreEqual(CompatDocs.Utf8Value, d.Get("utf8"), "utf8"); + Assert.AreEqual(CompatDocs.Utf8Value, d.Get("autf8"), "autf8"); + Assert.AreEqual(CompatDocs.Content2Value, d.Get("content2"), "content2"); + Assert.AreEqual(CompatDocs.NonAsciiFieldValue, d.Get(CompatDocs.NonAsciiFieldName), CompatDocs.NonAsciiFieldName); + + Fields tvFields = reader.GetTermVectors(i); + Assert.IsNotNull(tvFields, "term vectors missing for doc " + i); + Assert.IsNotNull(tvFields.GetTerms("utf8"), "utf8 term vector missing for doc " + i); + } + + AssertDocValues(reader, liveDocs); + + // content term should match every live doc (34 of 35). + ScoreDoc[] hits = searcher.Search(new TermQuery(new Term("content", "aaa")), null, 1000).ScoreDocs; + Assert.AreEqual(CompatDocs.DocCount - 1, hits.Length); + Assert.AreEqual("0", searcher.IndexReader.Document(hits[0].Doc).Get("id"), "first hit should be id 0"); + + // offsets/positions-bearing fields (StandardAnalyzer keeps "aaa" verbatim). + Assert.AreEqual(CompatDocs.DocCount - 1, + searcher.Search(new TermQuery(new Term("content5", "aaa")), null, 1000).ScoreDocs.Length); + Assert.AreEqual(CompatDocs.DocCount - 1, + searcher.Search(new TermQuery(new Term("content6", "aaa")), null, 1000).ScoreDocs.Length); + + // The utf8 field's term dictionary must be identical to what the writing + // runtime produced. This is the cross-runtime contract for the UTF-8 edge + // cases (astral planes, the skull, etc.). + Assert.AreEqual(CompatDocs.ExpectedUtf8Terms(), CollectTerms(reader, "utf8"), "utf8 term set"); + } + + private static JCG.List CollectTerms(IndexReader reader, string field) + { + var result = new JCG.List(); + Terms terms = MultiFields.GetTerms(reader, field); + if (terms is null) + { + return result; + } + TermsEnum te = terms.GetEnumerator(); + while (te.MoveNext()) + { + result.Add(te.Term.Utf8ToString()); + } + result.Sort(StringComparer.Ordinal); + return result; + } + + private static void AssertDocValues(IndexReader reader, IBits liveDocs) + { + NumericDocValues dvByte = MultiDocValues.GetNumericValues(reader, "dvByte"); + BinaryDocValues dvBytesDerefFixed = MultiDocValues.GetBinaryValues(reader, "dvBytesDerefFixed"); + BinaryDocValues dvBytesDerefVar = MultiDocValues.GetBinaryValues(reader, "dvBytesDerefVar"); + SortedDocValues dvBytesSortedFixed = MultiDocValues.GetSortedValues(reader, "dvBytesSortedFixed"); + SortedDocValues dvBytesSortedVar = MultiDocValues.GetSortedValues(reader, "dvBytesSortedVar"); + BinaryDocValues dvBytesStraightFixed = MultiDocValues.GetBinaryValues(reader, "dvBytesStraightFixed"); + BinaryDocValues dvBytesStraightVar = MultiDocValues.GetBinaryValues(reader, "dvBytesStraightVar"); + NumericDocValues dvDouble = MultiDocValues.GetNumericValues(reader, "dvDouble"); + NumericDocValues dvFloat = MultiDocValues.GetNumericValues(reader, "dvFloat"); + NumericDocValues dvInt = MultiDocValues.GetNumericValues(reader, "dvInt"); + NumericDocValues dvLong = MultiDocValues.GetNumericValues(reader, "dvLong"); + NumericDocValues dvPacked = MultiDocValues.GetNumericValues(reader, "dvPacked"); + NumericDocValues dvShort = MultiDocValues.GetNumericValues(reader, "dvShort"); + SortedSetDocValues dvSortedSet = MultiDocValues.GetSortedSetValues(reader, "dvSortedSet"); + + Assert.IsNotNull(dvByte, "dvByte"); + Assert.IsNotNull(dvSortedSet, "dvSortedSet"); + + for (int i = 0; i < CompatDocs.DocCount; i++) + { + bool live = liveDocs is null || liveDocs.Get(i); + if (!live) + { + continue; + } + int id = Convert.ToInt32(reader.Document(i).Get("id")); + Assert.AreEqual(id, dvByte.Get(i), "dvByte"); + + sbyte[] bytes = { (sbyte)(id >>> 24), (sbyte)(id >>> 16), (sbyte)(id >>> 8), (sbyte)id }; + BytesRef expectedRef = new BytesRef((byte[])(Array)bytes); + BytesRef scratch = new BytesRef(); + + dvBytesDerefFixed.Get(i, scratch); + Assert.AreEqual(expectedRef, scratch, "dvBytesDerefFixed"); + dvBytesDerefVar.Get(i, scratch); + Assert.AreEqual(expectedRef, scratch, "dvBytesDerefVar"); + dvBytesSortedFixed.Get(i, scratch); + Assert.AreEqual(expectedRef, scratch, "dvBytesSortedFixed"); + dvBytesSortedVar.Get(i, scratch); + Assert.AreEqual(expectedRef, scratch, "dvBytesSortedVar"); + dvBytesStraightFixed.Get(i, scratch); + Assert.AreEqual(expectedRef, scratch, "dvBytesStraightFixed"); + dvBytesStraightVar.Get(i, scratch); + Assert.AreEqual(expectedRef, scratch, "dvBytesStraightVar"); + + Assert.AreEqual(id, J2N.BitConversion.Int64BitsToDouble(dvDouble.Get(i)), 0D, "dvDouble"); + Assert.AreEqual(id, J2N.BitConversion.Int32BitsToSingle((int)dvFloat.Get(i)), 0F, "dvFloat"); + Assert.AreEqual(id, dvInt.Get(i), "dvInt"); + Assert.AreEqual(id, dvLong.Get(i), "dvLong"); + Assert.AreEqual(id, dvPacked.Get(i), "dvPacked"); + Assert.AreEqual(id, dvShort.Get(i), "dvShort"); + + dvSortedSet.SetDocument(i); + long ord = dvSortedSet.NextOrd(); + Assert.AreEqual(SortedSetDocValues.NO_MORE_ORDS, dvSortedSet.NextOrd(), "dvSortedSet single ord"); + dvSortedSet.LookupOrd(ord, scratch); + Assert.AreEqual(expectedRef, scratch, "dvSortedSet value"); + } + } + + // ------------------------------------------------------------------ + // Tests + // ------------------------------------------------------------------ + + /// + /// Pure-.NET round trip: write the shared doc set with the default 4.8.x + /// codec, reopen it, run CheckIndex, and assert the contents. This is the + /// CI-safe baseline that requires no JDK and guards the shared contract. + /// + [Test] + public virtual void TestDotNetRoundTrip() + { + foreach (bool useCompoundFile in new[] { true, false }) + { + using Directory dir = NewDirectory(); + CompatDocs.WriteIndex(dir, useCompoundFile); + AssertContents(dir); + } + } + + /// + /// Java -> .NET direction: open a Java 4.8.1-written index (path from the + /// lucenenet.compat.read.dir environment variable), run CheckIndex, + /// and assert the contents. If the variable is unset or the index is missing, + /// the test is inconclusive (skipped): a JDK may not be present here. + /// + [Test] + public virtual void TestReadJavaIndex() + { + string baseDir = Environment.GetEnvironmentVariable(ReadDirEnvVar); + if (string.IsNullOrEmpty(baseDir) || !System.IO.Directory.Exists(baseDir)) + { + NUnit.Framework.Assert.Inconclusive($"No Java index to read. Set the '{ReadDirEnvVar}' environment " + + "variable to a Java-generated index directory (see src/java/index-compat/README.md)."); + return; + } + + bool readAny = false; + foreach (string name in new[] { "index.481.nocfs", "index.481.cfs" }) + { + string indexDir = Path.Combine(baseDir, name); + if (!System.IO.Directory.Exists(indexDir) || !HasSegments(indexDir)) + { + continue; + } + readAny = true; + using Directory dir = new SimpleFSDirectory(indexDir); + AssertContents(dir); + } + + if (!readAny) + { + NUnit.Framework.Assert.Inconclusive($"No Lucene index found under '{baseDir}'. " + + "Generate the Java index first (see src/java/index-compat/README.md)."); + } + } + + /// + /// .NET -> Java direction (writer half): write the shared doc set into the + /// lucenenet.compat.write.dir folder (or a temp folder) so the Java + /// JUnit test can open it. The Java side performs the cross-runtime + /// assertions; this test just produces the index and self-validates it. + /// + [Test] + public virtual void TestWriteIndexForJava() + { + string baseDir = Environment.GetEnvironmentVariable(WriteDirEnvVar); + if (string.IsNullOrEmpty(baseDir)) + { + baseDir = Path.Combine(Path.GetTempPath(), "lucenenet-compat-" + Guid.NewGuid().ToString("N")); + } + System.IO.Directory.CreateDirectory(baseDir); + + foreach ((string name, bool useCompoundFile) in new[] { ("index.481.cfs", true), ("index.481.nocfs", false) }) + { + string indexDir = Path.Combine(baseDir, name); + if (System.IO.Directory.Exists(indexDir)) + { + System.IO.Directory.Delete(indexDir, recursive: true); + } + System.IO.Directory.CreateDirectory(indexDir); + using Directory dir = new SimpleFSDirectory(indexDir); + CompatDocs.WriteIndex(dir, useCompoundFile); + AssertContents(dir); + } + + TestContext.Progress.WriteLine("Wrote .NET 4.8.x compatibility indexes under: " + baseDir); + } + + private static bool HasSegments(string indexDir) + { + foreach (string f in System.IO.Directory.EnumerateFiles(indexDir)) + { + if (Path.GetFileName(f).StartsWith("segments_", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + } +} diff --git a/src/java/index-compat/.mvn/wrapper/maven-wrapper.properties b/src/java/index-compat/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000000..216df05897 --- /dev/null +++ b/src/java/index-compat/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/src/java/index-compat/README.md b/src/java/index-compat/README.md new file mode 100644 index 0000000000..0ad388df8b --- /dev/null +++ b/src/java/index-compat/README.md @@ -0,0 +1,92 @@ + + +# Index compatibility harness (Lucene 4.8.1 <-> Lucene.NET) + +This self-contained Maven project proves **two-way** index/codec compatibility +between Apache Lucene 4.8.1 (Java) and Lucene.NET. + +Each runtime can write an index that the **other** runtime opens, validates with +`CheckIndex` (the codec integrity gate, which verifies the per-file checksums the +codec wrote), and then reads back to confirm the contents match. We do **not** +do a byte-for-byte file comparison: some header fields legitimately differ +between runtimes (e.g. `java.vendor`), so `CheckIndex` + semantic read back is +the common approach. + +The shared, deterministic document set is defined once, in two mirror +implementations that must stay in sync: + +- Java: [`CompatDocs.java`](src/main/java/org/apache/lucenenet/compat/CompatDocs.java) +- .NET: [`CompatDocs.cs`](../../../src/Lucene.Net.Tests/Support/Index/CompatDocs.cs) + +And the corresponding tests: +- Java: [`TestDotNetCompatibility.java`](src/test/java/org/apache/lucenenet/compat/TestDotNetCompatibility.java) +- .NET: [`TestJavaCompatibility.cs`](../../../src/Lucene.Net.Tests/Support/Index/TestJavaCompatibility.cs) + +## Requirements + +- A JDK (8 or newer). Lucene 4.8.1 targets Java 7; we compile to release 8. +- No system Maven needed: use the bundled wrapper `./mvnw` (or `mvnw.cmd` on Windows). +- The .NET SDK, to build/run the Lucene.NET side. + +## No committed fixtures + +Indexes are **never** committed to the repo. Every index is generated fresh into +the gitignored `work/` folder on demand: + +- `work/java/` - indexes written by Java (read by .NET) +- `work/dotnet/` - indexes written by .NET (read by Java) + +## Running + +The easiest path is the driver script from this directory, which runs **both** +directions end to end: + +```sh +./run-compat.sh # macOS / Linux +.\run-compat.bat # Windows (cmd) +.\run-compat.ps1 # any platform (PowerShell Core) +``` + +All the logic lives in `run-compat.ps1`; the `.sh` and `.bat` files are thin +wrappers that locate PowerShell Core (`pwsh`) and forward their arguments, so +`pwsh` must be installed to run any of them. + +### Direction 1: Java writes, .NET reads + +```sh +./mvnw -q compile exec:java # writes work/java/index.481.{cfs,nocfs} +``` + +Then run the .NET `TestJavaCompatibility` tests (see the driver script) pointed at +`work/java`. In .NET, a **missing** Java index makes the cross-runtime test +**inconclusive** (skipped), because the JDK may not be present in every +environment. + +### Direction 2: .NET writes, Java reads + +First have .NET write its index into `work/dotnet` (the driver does this), then: + +```sh +./mvnw -q test -Dlucenenet.index.dir=work/dotnet/index.481.nocfs +``` + +In Java, a **missing** .NET index makes the test **fail** (not skip): when Java is +asked to read a .NET index, the absence of that index means the pipeline that was +supposed to produce it is broken. diff --git a/src/java/index-compat/mvnw b/src/java/index-compat/mvnw new file mode 100755 index 0000000000..bd8896bf22 --- /dev/null +++ b/src/java/index-compat/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/src/java/index-compat/mvnw.cmd b/src/java/index-compat/mvnw.cmd new file mode 100644 index 0000000000..92450f9327 --- /dev/null +++ b/src/java/index-compat/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/src/java/index-compat/pom.xml b/src/java/index-compat/pom.xml new file mode 100644 index 0000000000..0eff83b243 --- /dev/null +++ b/src/java/index-compat/pom.xml @@ -0,0 +1,102 @@ + + + + 4.0.0 + + org.apache.lucenenet + index-compat + 4.8.1 + jar + + Lucene.NET index compatibility harness + + Generates and reads Apache Lucene 4.8.1 indexes to verify two-way index/codec + compatibility with Lucene.NET. See README.md and issue #270. + + + + UTF-8 + + 8 + 4.8.1 + + + + + + + org.apache.lucene + lucene-core + ${lucene.version} + + + org.apache.lucene + lucene-analyzers-common + ${lucene.version} + + + org.apache.lucene + lucene-codecs + ${lucene.version} + + + junit + junit + 4.13.2 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + + ${lucenenet.index.dir} + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.2.0 + + org.apache.lucenenet.compat.GenerateIndex + + + + + diff --git a/src/java/index-compat/run-compat.bat b/src/java/index-compat/run-compat.bat new file mode 100644 index 0000000000..8090afaf98 --- /dev/null +++ b/src/java/index-compat/run-compat.bat @@ -0,0 +1,30 @@ +@echo off +GOTO endcommentblock +:: ----------------------------------------------------------------------------------- +:: +:: 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. +:: +:: ----------------------------------------------------------------------------------- +:: +:: Thin wrapper that forwards to run-compat.ps1, which contains all the business +:: logic. See run-compat.ps1 for what the harness does (both directions of the +:: Lucene 4.8.1 <-> Lucene.NET index compatibility check, issue #270) and the +:: COMPAT_TFM environment variable. +:: +:: ----------------------------------------------------------------------------------- +:endcommentblock +where pwsh >nul 2>nul +if %ERRORLEVEL% NEQ 0 (echo "PowerShell could not be found. Please install version 3 or higher." & exit /b 1) else (pwsh -ExecutionPolicy bypass -Command "& '%~dpn0.ps1'" %*) diff --git a/src/java/index-compat/run-compat.ps1 b/src/java/index-compat/run-compat.ps1 new file mode 100644 index 0000000000..afa335ce5e --- /dev/null +++ b/src/java/index-compat/run-compat.ps1 @@ -0,0 +1,106 @@ +# 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. + +# Runs both directions of the Lucene 4.8.1 <-> Lucene.NET index compatibility +# check (issue #270). Cross-platform (Windows / macOS / Linux PowerShell). Requires +# a JDK and the .NET SDK. All generated indexes go under the gitignored work/ +# folder; nothing is committed. +# +# By default the test shard builds with its own default target framework. Set +# $env:COMPAT_TFM (e.g. net10.0, net8.0) to force a specific one. + +$ErrorActionPreference = 'Stop' + +# Guarantee a non-zero exit code on any failure so CI fails the job. A bare throw +# is not always reflected in the process exit code across PowerShell hosts/versions. +trap { + Write-Host "==> Compatibility check FAILED: $_" -ForegroundColor Red + exit 1 +} + +$Here = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Resolve-Path (Join-Path $Here '..' '..' '..') +$Work = Join-Path $Here 'work' +$Shard = Join-Path $RepoRoot 'src' 'Lucene.Net.Tests._J-S' 'Lucene.Net.Tests._J-S.csproj' + +$JavaIndex = Join-Path $Work 'java' +$DotNetIndex = Join-Path $Work 'dotnet' + +# Pick the correct Maven wrapper for the OS (mvnw.cmd is the Windows batch file). +$Mvnw = if ($IsWindows) { Join-Path $Here 'mvnw.cmd' } else { Join-Path $Here 'mvnw' } + +# Only pass -f when the caller explicitly forces a target framework. +$TfmArgs = if ($env:COMPAT_TFM) { @('-f', $env:COMPAT_TFM) } else { @() } + +function Invoke-DotNetTest([string]$Filter) { + # dotnet test exits 0 when a --filter matches nothing, which would hide a + # misconfiguration. Capture output, surface it, and fail on "matched 0". + $output = & dotnet test $Shard @TfmArgs -c Release --no-build --filter $Filter 2>&1 + $exit = $LASTEXITCODE + $output | ForEach-Object { Write-Host $_ } + if ($exit -ne 0) { throw "dotnet test failed for filter '$Filter'" } + if ($output -match 'no test is available|matches the given testcase filter|total:\s*0\b') { + throw "No tests ran for filter '$Filter'. Is the shard built for this target framework?" + } +} + +function Invoke-Maven([string[]]$MvnArgs) { + Push-Location $Here + try { + & $Mvnw @MvnArgs + if ($LASTEXITCODE -ne 0) { throw "Maven failed: $($MvnArgs -join ' ')" } + } finally { + Pop-Location + } +} + +Write-Host "==> Building the .NET test shard$(if ($env:COMPAT_TFM) { " ($env:COMPAT_TFM)" })" +& dotnet build $Shard @TfmArgs -c Release +if ($LASTEXITCODE -ne 0) { throw "dotnet build failed" } + +Write-Host "" +Write-Host "==> Direction 1: .NET writes, Java reads" +Write-Host " .NET writing index into $DotNetIndex" +[Environment]::SetEnvironmentVariable('lucenenet.compat.write.dir', $DotNetIndex, 'Process') +try { + Invoke-DotNetTest 'FullyQualifiedName~TestJavaCompatibility.TestWriteIndexForJava' +} finally { + [Environment]::SetEnvironmentVariable('lucenenet.compat.write.dir', $null, 'Process') +} + +foreach ($variant in @('index.481.nocfs', 'index.481.cfs')) { + $indexDir = Join-Path $DotNetIndex $variant + Write-Host " Java reading $indexDir" + Invoke-Maven @('-q', 'test', "-Dlucenenet.index.dir=$indexDir") +} + +Write-Host "" +Write-Host "==> Direction 2: Java writes, .NET reads" +Write-Host " Java writing index into $JavaIndex" +Invoke-Maven @('-q', 'compile', 'exec:java', "-Dexec.args=$JavaIndex") + +Write-Host " .NET reading from $JavaIndex" +[Environment]::SetEnvironmentVariable('lucenenet.compat.read.dir', $JavaIndex, 'Process') +try { + Invoke-DotNetTest 'FullyQualifiedName~TestJavaCompatibility.TestReadJavaIndex' +} finally { + [Environment]::SetEnvironmentVariable('lucenenet.compat.read.dir', $null, 'Process') +} + +Write-Host "" +Write-Host "==> Both directions passed." +exit 0 diff --git a/src/java/index-compat/run-compat.sh b/src/java/index-compat/run-compat.sh new file mode 100755 index 0000000000..d15b1589c5 --- /dev/null +++ b/src/java/index-compat/run-compat.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# 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. + +# Thin wrapper that forwards to run-compat.ps1, which contains all the business +# logic. This keeps the cross-platform logic in a single place; the .sh, .bat, +# and .ps1 entry points differ only in how they locate and invoke PowerShell. +# +# See run-compat.ps1 for what the harness does (both directions of the Lucene +# 4.8.1 <-> Lucene.NET index compatibility check, issue #270) and the +# COMPAT_TFM environment variable. + +if ! command -v pwsh &> /dev/null +then + echo "PowerShell Core could not be found. Please install version 3 or higher." + exit 1 +fi + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +pwsh -ExecutionPolicy bypass -Command "& '$HERE/run-compat.ps1'" "$@" diff --git a/src/java/index-compat/src/main/java/org/apache/lucenenet/compat/CompatDocs.java b/src/java/index-compat/src/main/java/org/apache/lucenenet/compat/CompatDocs.java new file mode 100644 index 0000000000..33c3d51d68 --- /dev/null +++ b/src/java/index-compat/src/main/java/org/apache/lucenenet/compat/CompatDocs.java @@ -0,0 +1,203 @@ +/* + * 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.lucenenet.compat; + +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.analysis.TokenStream; +import org.apache.lucene.analysis.standard.StandardAnalyzer; +import org.apache.lucene.analysis.tokenattributes.CharTermAttribute; +import org.apache.lucene.document.BinaryDocValuesField; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.DoubleDocValuesField; +import org.apache.lucene.document.Field; +import org.apache.lucene.document.FieldType; +import org.apache.lucene.document.FloatDocValuesField; +import org.apache.lucene.document.IntField; +import org.apache.lucene.document.LongField; +import org.apache.lucene.document.NumericDocValuesField; +import org.apache.lucene.document.SortedDocValuesField; +import org.apache.lucene.document.SortedSetDocValuesField; +import org.apache.lucene.document.StringField; +import org.apache.lucene.document.TextField; +import org.apache.lucene.index.*; +import org.apache.lucene.index.FieldInfo.IndexOptions; +import org.apache.lucene.store.Directory; +import org.apache.lucene.util.BytesRef; +import org.apache.lucene.util.Version; + +import java.io.PrintStream; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeSet; + +/** + * The single source of truth for the deterministic document set shared between + * Java (Apache Lucene 4.8.1) and Lucene.NET. This intentionally mirrors, field + * for field, the document schema produced by + * {@code TestBackwardsCompatibility.AddDoc} / {@code AddNoProxDoc} and the read + * back verification in {@code TestBackwardsCompatibility.SearchIndex} on the + * .NET side, so that an index written by either runtime can be validated by the + * other. See issue #270. + * + *

Nothing here may use randomness: both runtimes must produce semantically + * identical indexes from the same inputs. + */ +public final class CompatDocs { + + private CompatDocs() { + } + + /** Number of "normal" (prox) documents indexed by {@link #writeIndex}. */ + public static final int DOC_COUNT = 35; + + /** The id that is deleted, matching the .NET harness. */ + public static final int DELETED_ID = 7; + + // Exactly matches the .NET literal: Lu + U+1D11E + ce + U+1D160 + ne + NUL + skull + astral + cd. + public static final String UTF8_VALUE = "Lu\uD834\uDD1Ece\uD834\uDD60ne \u0000 \u2620 ab\uD917\uDC17cd"; + public static final String CONTENT2_VALUE = "here is more content with aaa aaa aaa"; + public static final String NON_ASCII_FIELD_NAME = "fie\u2C77ld"; + public static final String NON_ASCII_FIELD_VALUE = "field with non-ascii name"; + + /** + * Writes the deterministic compatibility index into {@code dir}. The result + * has 35 documents, with id 7 deleted, term vectors, offsets, norms, and the + * full DocValues matrix, in either compound-file or non-compound-file form. + */ + public static void writeIndex(Directory dir, boolean useCompoundFile) throws Exception { + Analyzer analyzer = new StandardAnalyzer(Version.LUCENE_48); + + LogByteSizeMergePolicy mp = new LogByteSizeMergePolicy(); + mp.setNoCFSRatio(useCompoundFile ? 1.0 : 0.0); + mp.setMaxCFSSegmentSizeMB(Double.POSITIVE_INFINITY); + + IndexWriterConfig conf = new IndexWriterConfig(Version.LUCENE_48, analyzer) + .setUseCompoundFile(useCompoundFile) + .setMaxBufferedDocs(10) + .setMergePolicy(mp); + IndexWriter writer = new IndexWriter(dir, conf); + for (int i = 0; i < DOC_COUNT; i++) { + addDoc(writer, i); + } + writer.close(); + + // Delete id 7 in a fresh writer so the layout matches the .NET harness. + conf = new IndexWriterConfig(Version.LUCENE_48, analyzer) + .setUseCompoundFile(useCompoundFile) + .setMaxBufferedDocs(10) + .setOpenMode(IndexWriterConfig.OpenMode.APPEND); + writer = new IndexWriter(dir, conf); + writer.deleteDocuments(new Term("id", Integer.toString(DELETED_ID))); + writer.close(); + } + + private static void addDoc(IndexWriter writer, int id) throws Exception { + Document doc = new Document(); + doc.add(new TextField("content", "aaa", Field.Store.NO)); + doc.add(new StringField("id", Integer.toString(id), Field.Store.YES)); + + FieldType customType2 = new FieldType(TextField.TYPE_STORED); + customType2.setStoreTermVectors(true); + customType2.setStoreTermVectorPositions(true); + customType2.setStoreTermVectorOffsets(true); + doc.add(new Field("autf8", UTF8_VALUE, customType2)); + doc.add(new Field("utf8", UTF8_VALUE, customType2)); + doc.add(new Field("content2", CONTENT2_VALUE, customType2)); + doc.add(new Field(NON_ASCII_FIELD_NAME, NON_ASCII_FIELD_VALUE, customType2)); + + // numeric fields + doc.add(new IntField("trieInt", id, Field.Store.NO)); + doc.add(new LongField("trieLong", (long) id, Field.Store.NO)); + + // docvalues fields + doc.add(new NumericDocValuesField("dvByte", (byte) id)); + byte[] bytes = new byte[] { + (byte) (id >>> 24), (byte) (id >>> 16), (byte) (id >>> 8), (byte) id + }; + BytesRef ref = new BytesRef(bytes); + doc.add(new BinaryDocValuesField("dvBytesDerefFixed", ref)); + doc.add(new BinaryDocValuesField("dvBytesDerefVar", ref)); + doc.add(new SortedDocValuesField("dvBytesSortedFixed", ref)); + doc.add(new SortedDocValuesField("dvBytesSortedVar", ref)); + doc.add(new BinaryDocValuesField("dvBytesStraightFixed", ref)); + doc.add(new BinaryDocValuesField("dvBytesStraightVar", ref)); + doc.add(new DoubleDocValuesField("dvDouble", (double) id)); + doc.add(new FloatDocValuesField("dvFloat", (float) id)); + doc.add(new NumericDocValuesField("dvInt", id)); + doc.add(new NumericDocValuesField("dvLong", id)); + doc.add(new NumericDocValuesField("dvPacked", id)); + doc.add(new NumericDocValuesField("dvShort", (short) id)); + doc.add(new SortedSetDocValuesField("dvSortedSet", ref)); + + // a field with both offsets and term vectors for a cross-check + FieldType customType3 = new FieldType(TextField.TYPE_STORED); + customType3.setStoreTermVectors(true); + customType3.setStoreTermVectorPositions(true); + customType3.setStoreTermVectorOffsets(true); + customType3.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS); + doc.add(new Field("content5", CONTENT2_VALUE, customType3)); + + // a field that omits only positions + FieldType customType4 = new FieldType(TextField.TYPE_STORED); + customType4.setStoreTermVectors(true); + customType4.setStoreTermVectorPositions(false); + customType4.setStoreTermVectorOffsets(true); + customType4.setIndexOptions(IndexOptions.DOCS_AND_FREQS); + doc.add(new Field("content6", CONTENT2_VALUE, customType4)); + + writer.addDocument(doc); + } + + /** + * Runs the Lucene {@link CheckIndex} tool against {@code dir} and throws if + * the index reports any problem. This is the codec integrity gate; it + * validates the stored per file checksums written by the codec. + */ + public static void checkIndex(Directory dir, PrintStream infoStream) throws Exception { + CheckIndex checker = new CheckIndex(dir); + checker.setCrossCheckTermVectors(true); + if (infoStream != null) { + checker.setInfoStream(infoStream); + } + CheckIndex.Status status = checker.checkIndex(); + if (!status.clean) { + throw new IllegalStateException("CheckIndex reported the index at " + dir + " is not clean"); + } + } + + /** + * The sorted, unique set of terms that {@code StandardAnalyzer(LUCENE_48)} + * produces for {@link #UTF8_VALUE}. An index written by either runtime must + * contain exactly this term set in the {@code utf8} field, which is the + * cross-runtime contract for the UTF-8 edge cases. Computed by re-running the + * analyzer so it stays correct if the constant changes. + */ + public static List expectedUtf8Terms() throws Exception { + TreeSet terms = new TreeSet<>(); + try (Analyzer analyzer = new StandardAnalyzer(Version.LUCENE_48); + TokenStream ts = analyzer.tokenStream("utf8", new StringReader(UTF8_VALUE))) { + CharTermAttribute termAttr = ts.addAttribute(CharTermAttribute.class); + ts.reset(); + while (ts.incrementToken()) { + terms.add(termAttr.toString()); + } + ts.end(); + } + return new ArrayList<>(terms); + } +} diff --git a/src/java/index-compat/src/main/java/org/apache/lucenenet/compat/GenerateIndex.java b/src/java/index-compat/src/main/java/org/apache/lucenenet/compat/GenerateIndex.java new file mode 100644 index 0000000000..86da5443d1 --- /dev/null +++ b/src/java/index-compat/src/main/java/org/apache/lucenenet/compat/GenerateIndex.java @@ -0,0 +1,87 @@ +/* + * 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.lucenenet.compat; + +import org.apache.lucene.store.Directory; +import org.apache.lucene.store.SimpleFSDirectory; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Writes the deterministic compatibility index (both compound-file and + * non-compound-file variants) with Apache Lucene 4.8.1, for Lucene.NET to read + * back. This is the Java side of the "Java -> .NET" direction of issue #270. + * + *

Usage (from the {@code src/java/index-compat} directory): + *

+ *   ./mvnw -q compile exec:java
+ *   ./mvnw -q compile exec:java -Dexec.args="/path/to/output"
+ * 
+ * + *

The output is written under a temporary, gitignored {@code work/java} + * folder by default (or the directory named by the {@code lucenenet.work.dir} + * system property, or the first command-line argument). Two subdirectories are + * created: {@code index.481.cfs} and {@code index.481.nocfs}. + */ +public final class GenerateIndex { + + private GenerateIndex() { + } + + public static void main(String[] args) throws Exception { + Path baseDir; + if (args.length > 0 && args[0] != null && !args[0].isEmpty()) { + baseDir = Paths.get(args[0]); + } else { + String prop = System.getProperty("lucenenet.work.dir"); + baseDir = (prop != null && !prop.isEmpty()) + ? Paths.get(prop) + : Paths.get("work", "java"); + } + Files.createDirectories(baseDir); + + write(baseDir.resolve("index.481.cfs"), true); + write(baseDir.resolve("index.481.nocfs"), false); + + System.out.println("Wrote Java 4.8.1 compatibility indexes under: " + baseDir.toAbsolutePath()); + } + + private static void write(Path indexPath, boolean useCompoundFile) throws Exception { + File dirFile = indexPath.toFile(); + if (dirFile.exists()) { + File[] files = dirFile.listFiles(); + if (files == null) { + throw new IllegalStateException("Index path exists but is not a directory: " + dirFile.getAbsolutePath()); + } + for (File f : files) { + if (!f.delete()) { + throw new IllegalStateException("Failed to delete existing file: " + f.getAbsolutePath()); + } + } + } else { + Files.createDirectories(indexPath); + } + try (Directory dir = new SimpleFSDirectory(dirFile)) { + CompatDocs.writeIndex(dir, useCompoundFile); + CompatDocs.checkIndex(dir, System.out); + } + System.out.println(" " + (useCompoundFile ? "cfs " : "nocfs ") + "-> " + indexPath.toAbsolutePath()); + } +} diff --git a/src/java/index-compat/src/test/java/org/apache/lucenenet/compat/TestDotNetCompatibility.java b/src/java/index-compat/src/test/java/org/apache/lucenenet/compat/TestDotNetCompatibility.java new file mode 100644 index 0000000000..671a962cfd --- /dev/null +++ b/src/java/index-compat/src/test/java/org/apache/lucenenet/compat/TestDotNetCompatibility.java @@ -0,0 +1,226 @@ +/* + * 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.lucenenet.compat; + +import org.apache.lucene.document.Document; +import org.apache.lucene.index.BinaryDocValues; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.Fields; +import org.apache.lucene.index.IndexReader; +import org.apache.lucene.index.MultiDocValues; +import org.apache.lucene.index.MultiFields; +import org.apache.lucene.index.NumericDocValues; +import org.apache.lucene.index.SortedDocValues; +import org.apache.lucene.index.SortedSetDocValues; +import org.apache.lucene.index.Term; +import org.apache.lucene.index.Terms; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.ScoreDoc; +import org.apache.lucene.search.TermQuery; +import org.apache.lucene.store.Directory; +import org.apache.lucene.store.SimpleFSDirectory; +import org.apache.lucene.util.Bits; +import org.apache.lucene.util.BytesRef; +import org.junit.Test; + +import java.io.File; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +/** + * Verifies that Apache Lucene 4.8.1 can open an index that was written by + * Lucene.NET, that {@code CheckIndex} passes, and that the contents match the + * shared {@link CompatDocs} contract. This is the ".NET -> Java" direction of + * issue #270. + * + *

The index location is provided via {@code -Dlucenenet.index.dir=...}. Per + * the harness contract, this test fails (it does not skip) if no path is + * supplied or the index is missing: when Java is asked to read a .NET index, the + * absence of that index is a failure of the pipeline that was supposed to + * produce it. + */ +public class TestDotNetCompatibility { + + @Test + public void dotNetIndexReadsCleanlyInJava() throws Exception { + String path = System.getProperty("lucenenet.index.dir"); + if (path == null || path.trim().isEmpty()) { + fail("System property 'lucenenet.index.dir' is not set. Generate the " + + ".NET index first and pass -Dlucenenet.index.dir=."); + } + + File dirFile = new File(path); + if (!dirFile.isDirectory() || !new File(dirFile, "segments.gen").exists() + && segmentsFile(dirFile) == null) { + fail("No Lucene index found at '" + dirFile.getAbsolutePath() + "'. " + + "Generate the .NET index before running this test."); + } + + try (Directory dir = new SimpleFSDirectory(dirFile)) { + // Codec integrity gate. + CompatDocs.checkIndex(dir, System.out); + + // Semantic read back. + try (IndexReader reader = DirectoryReader.open(dir)) { + assertContents(reader); + } + } + } + + private static File segmentsFile(File dir) { + File[] files = dir.listFiles(); + if (files != null) { + for (File f : files) { + if (f.getName().startsWith("segments_")) { + return f; + } + } + } + return null; + } + + private static void assertContents(IndexReader reader) throws Exception { + IndexSearcher searcher = new IndexSearcher(reader); + + Bits liveDocs = MultiFields.getLiveDocs(reader); + + for (int i = 0; i < CompatDocs.DOC_COUNT; i++) { + boolean live = liveDocs == null || liveDocs.get(i); + if (!live) { + assertEquals("only id 7 should be deleted", CompatDocs.DELETED_ID, i); + continue; + } + + Document d = reader.document(i); + assertEquals("id", Integer.toString(i), d.get("id")); + assertEquals("utf8", CompatDocs.UTF8_VALUE, d.get("utf8")); + assertEquals("autf8", CompatDocs.UTF8_VALUE, d.get("autf8")); + assertEquals("content2", CompatDocs.CONTENT2_VALUE, d.get("content2")); + assertEquals(CompatDocs.NON_ASCII_FIELD_NAME, + CompatDocs.NON_ASCII_FIELD_VALUE, d.get(CompatDocs.NON_ASCII_FIELD_NAME)); + + Fields tvFields = reader.getTermVectors(i); + assertNotNull("term vectors missing for doc " + i, tvFields); + assertNotNull("utf8 term vector missing for doc " + i, tvFields.terms("utf8")); + } + + assertDocValues(reader, liveDocs); + + // content term should match every live doc (34 of 35). + ScoreDoc[] hits = searcher.search(new TermQuery(new Term("content", "aaa")), 1000).scoreDocs; + assertEquals(CompatDocs.DOC_COUNT - 1, hits.length); + assertEquals("first hit should be id 0", "0", + searcher.getIndexReader().document(hits[0].doc).get("id")); + + // offsets/positions-bearing fields. + assertEquals(CompatDocs.DOC_COUNT - 1, + searcher.search(new TermQuery(new Term("content5", "aaa")), 1000).scoreDocs.length); + assertEquals(CompatDocs.DOC_COUNT - 1, + searcher.search(new TermQuery(new Term("content6", "aaa")), 1000).scoreDocs.length); + + // The utf8 field's term dictionary must be identical to what the writing + // runtime produced. Both sides analyze the same string with the same + // StandardAnalyzer, so the produced term set is the cross-runtime contract + // for the UTF-8 edge cases (astral planes, the skull, etc.). + assertEquals("utf8 term set", CompatDocs.expectedUtf8Terms(), + collectTerms(reader, "utf8")); + + // sanity: the content terms enum has exactly the single term "aaa". + Terms contentTerms = MultiFields.getTerms(reader, "content"); + assertNotNull(contentTerms); + } + + + private static java.util.List collectTerms(IndexReader reader, String field) throws Exception { + java.util.List result = new java.util.ArrayList<>(); + Terms terms = MultiFields.getTerms(reader, field); + if (terms == null) { + return result; + } + org.apache.lucene.index.TermsEnum te = terms.iterator(null); + BytesRef term; + while ((term = te.next()) != null) { + result.add(term.utf8ToString()); + } + java.util.Collections.sort(result); + return result; + } + + private static void assertDocValues(IndexReader reader, Bits liveDocs) throws Exception { + NumericDocValues dvByte = MultiDocValues.getNumericValues(reader, "dvByte"); + BinaryDocValues dvBytesDerefFixed = MultiDocValues.getBinaryValues(reader, "dvBytesDerefFixed"); + BinaryDocValues dvBytesDerefVar = MultiDocValues.getBinaryValues(reader, "dvBytesDerefVar"); + SortedDocValues dvBytesSortedFixed = MultiDocValues.getSortedValues(reader, "dvBytesSortedFixed"); + SortedDocValues dvBytesSortedVar = MultiDocValues.getSortedValues(reader, "dvBytesSortedVar"); + BinaryDocValues dvBytesStraightFixed = MultiDocValues.getBinaryValues(reader, "dvBytesStraightFixed"); + BinaryDocValues dvBytesStraightVar = MultiDocValues.getBinaryValues(reader, "dvBytesStraightVar"); + NumericDocValues dvDouble = MultiDocValues.getNumericValues(reader, "dvDouble"); + NumericDocValues dvFloat = MultiDocValues.getNumericValues(reader, "dvFloat"); + NumericDocValues dvInt = MultiDocValues.getNumericValues(reader, "dvInt"); + NumericDocValues dvLong = MultiDocValues.getNumericValues(reader, "dvLong"); + NumericDocValues dvPacked = MultiDocValues.getNumericValues(reader, "dvPacked"); + NumericDocValues dvShort = MultiDocValues.getNumericValues(reader, "dvShort"); + SortedSetDocValues dvSortedSet = MultiDocValues.getSortedSetValues(reader, "dvSortedSet"); + + assertNotNull("dvByte", dvByte); + assertNotNull("dvSortedSet", dvSortedSet); + + for (int i = 0; i < CompatDocs.DOC_COUNT; i++) { + boolean live = liveDocs == null || liveDocs.get(i); + if (!live) { + continue; + } + int id = Integer.parseInt(reader.document(i).get("id")); + assertEquals("dvByte", id, dvByte.get(i)); + + byte[] bytes = new byte[] { + (byte) (id >>> 24), (byte) (id >>> 16), (byte) (id >>> 8), (byte) id + }; + BytesRef expected = new BytesRef(bytes); + BytesRef scratch = new BytesRef(); + + dvBytesDerefFixed.get(i, scratch); + assertEquals("dvBytesDerefFixed", expected, scratch); + dvBytesDerefVar.get(i, scratch); + assertEquals("dvBytesDerefVar", expected, scratch); + dvBytesSortedFixed.get(i, scratch); + assertEquals("dvBytesSortedFixed", expected, scratch); + dvBytesSortedVar.get(i, scratch); + assertEquals("dvBytesSortedVar", expected, scratch); + dvBytesStraightFixed.get(i, scratch); + assertEquals("dvBytesStraightFixed", expected, scratch); + dvBytesStraightVar.get(i, scratch); + assertEquals("dvBytesStraightVar", expected, scratch); + + assertEquals("dvDouble", (double) id, Double.longBitsToDouble(dvDouble.get(i)), 0D); + assertEquals("dvFloat", (float) id, Float.intBitsToFloat((int) dvFloat.get(i)), 0F); + assertEquals("dvInt", id, dvInt.get(i)); + assertEquals("dvLong", id, dvLong.get(i)); + assertEquals("dvPacked", id, dvPacked.get(i)); + assertEquals("dvShort", id, dvShort.get(i)); + + dvSortedSet.setDocument(i); + long ord = dvSortedSet.nextOrd(); + assertEquals("dvSortedSet single ord", + SortedSetDocValues.NO_MORE_ORDS, dvSortedSet.nextOrd()); + dvSortedSet.lookupOrd(ord, scratch); + assertEquals("dvSortedSet value", expected, scratch); + } + } +}