vinishjail97 commented on code in PR #903:
URL: https://github.com/apache/incubator-xtable/pull/903#discussion_r4010718413


##########
xtable-core/src/test/java/org/apache/xtable/ITConversionController.java:
##########
@@ -226,6 +241,116 @@ private ConversionSourceProvider<?> 
getConversionSourceProvider(String sourceTab
   @MethodSource("generateTestParametersForFormatsSyncModesAndPartitioning")
   public void testVariousOperations(
       String sourceTableFormat, SyncMode syncMode, boolean isPartitioned) {
+    runVariousOperationsTest(
+        sourceTableFormat, syncMode, isPartitioned, 
getConversionSourceProvider(sourceTableFormat));
+  }
+
+  // Runs the same equivalence checks as testVariousOperations above, but 
forces the Delta Kernel
+  // conversion source rather than resolving the provider from 
sourceTableFormat. This covers the
+  // Kernel source path against the same assertions the Standalone source is 
validated with today,
+  // per https://github.com/apache/incubator-xtable/issues/886. A targeted 
(sync mode x
+  // partitioning) subset is used here rather than folding DELTA_KERNEL into
+  // generateTestParametersForFormatsSyncModesAndPartitioning, since 
sourceTableFormat there also
+  // drives GenericTable selection, Spark format-name reads, and target-format 
exclusion for every
+  // other format, none of which differ for Kernel vs Standalone.
+  @ParameterizedTest
+  @MethodSource("generateTestParametersForSyncModesAndPartitioning")
+  public void testVariousOperationsDeltaKernelSource(SyncMode syncMode, 
boolean isPartitioned) {
+    ConversionSourceProvider<Long> deltaKernelConversionSourceProvider =
+        new DeltaKernelConversionSourceProvider();
+    deltaKernelConversionSourceProvider.init(jsc.hadoopConfiguration());
+    runVariousOperationsTest(DELTA, syncMode, isPartitioned, 
deltaKernelConversionSourceProvider);
+  }
+
+  // Validates the DELTA target routed through DeltaKernelConversionTarget (via
+  // DeltaConversionTargetConfig.USE_KERNEL on that target's properties, using
+  // ConversionTargetFactory's normal dispatch path). Source is fixed to HUDI, 
a simple,
+  // always-available source unrelated to what's under test here (the 
target-side dispatch).
+  // Covers the "as a target" half of prerequisite #1 in
+  // https://github.com/apache/incubator-xtable/issues/886.
+  //
+  // DeltaKernelConversionTarget does not support schema evolution on an 
already-existing table
+  // (Delta Kernel 4.0.0 only applies withSchema() at CREATE_TABLE; see
+  // https://github.com/delta-io/delta/issues/4305) and throws 
NotSupportedException rather than
+  // silently committing a stale schema. ConversionController.sync() catches 
that per-target
+  // (TableFormatSync#buildResultForError) rather than propagating it, so the 
exception surfaces
+  // as a SyncStatusCode.ERROR entry in the returned result map, not as a 
thrown exception from
+  // sync() itself. This test therefore doesn't reuse 
runVariousOperationsTest: it syncs an
+  // initial snapshot (expected to succeed, verified via the normal 
equivalence check), resyncs
+  // the same unchanged schema to an existing table (expected to still succeed 
-- this guards
+  // against a false positive in syncSchema()'s drift check, since it compares 
in Kernel's
+  // StructType rather than InternalSchema specifically to avoid 
InternalSchema<->StructType
+  // round-trip asymmetry causing an unchanged schema to look "evolved"), then 
evolves the schema
+  // and syncs again, asserting the DELTA target's sync result is ERROR with 
the expected message,
+  // and that the target's data was left at its last-good state rather than 
partially written.
+  @ParameterizedTest
+  @MethodSource("generateTestParametersForSyncModesAndPartitioning")
+  public void testVariousOperationsDeltaKernelTarget(SyncMode syncMode, 
boolean isPartitioned) {
+    String tableName = getTableName();
+    ConversionSourceProvider<?> conversionSourceProvider = 
getConversionSourceProvider(HUDI);
+    String partitionConfig = isPartitioned ? "level:VALUE" : null;
+    List<String> targetTableFormats = Collections.singletonList(DELTA);
+
+    try (GenericTable table =
+        GenericTable.getInstance(tableName, tempDir, sparkSession, jsc, HUDI, 
isPartitioned)) {
+      table.insertRows(100);
+      ConversionConfig conversionConfig =
+          getTableSyncConfig(
+              HUDI, syncMode, tableName, table, targetTableFormats, 
partitionConfig, null, true);
+      Map<String, SyncResult> results =
+          conversionController.sync(conversionConfig, 
conversionSourceProvider);
+      assertEquals(
+          SyncStatusCode.SUCCESS, 
results.get(DELTA).getTableFormatSyncStatus().getStatusCode());
+      checkDatasetEquivalence(HUDI, table, targetTableFormats, 100);
+
+      // Resync the same table with its unchanged schema. The table now 
exists, so this exercises
+      // syncSchema()'s drift check on the existing-table path without any 
real schema evolution.
+      table.insertRows(50);
+      results = conversionController.sync(conversionConfig, 
conversionSourceProvider);
+      assertEquals(
+          SyncStatusCode.SUCCESS,
+          results.get(DELTA).getTableFormatSyncStatus().getStatusCode(),
+          "An unchanged-schema resync of an existing table must not be flagged 
as schema drift");
+      checkDatasetEquivalence(HUDI, table, targetTableFormats, 150);

Review Comment:
   [P2] Cover updates and deletes in the Kernel target matrix
   
   Could we exercise unchanged-schema upserts and deletes here before testing 
schema evolution? `runVariousOperationsTest` validates both operations through 
dataset equivalence, but this target variant only performs two inserts before 
expecting a schema error. The new Kernel source cases exclude DELTA from their 
target list, so they do not cover this target path either. Incorrect removal 
handling can therefore leave deleted rows or old row values visible without 
failing the new target matrix. I would close this coverage gap before treating 
prerequisite 1 of #886 as complete, using the existing upsert/delete assertions 
across both sync modes and partitioning cases.



##########
xtable-core/src/test/java/org/apache/xtable/ITDeltaLogTruncationSafetyCheck.java:
##########
@@ -0,0 +1,316 @@
+/*
+ * 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.xtable;
+
+import java.net.URI;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Instant;
+import java.util.Properties;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import lombok.extern.log4j.Log4j2;
+
+import org.apache.spark.api.java.JavaSparkContext;
+import org.apache.spark.sql.SparkSession;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.xtable.conversion.ConversionSourceProvider;
+import org.apache.xtable.conversion.SourceTable;
+import org.apache.xtable.delta.DeltaConversionSourceProvider;
+import org.apache.xtable.hudi.HudiTestUtil;
+import org.apache.xtable.kernel.DeltaKernelConversionSourceProvider;
+import org.apache.xtable.model.CommitsBacklog;
+import org.apache.xtable.model.InstantsForIncrementalSync;
+import org.apache.xtable.model.storage.TableFormat;
+import org.apache.xtable.spi.extractor.ConversionSource;
+
+/**
+ * Empirical repro for https://github.com/apache/incubator-xtable/issues/779: 
does {@code
+ * isIncrementalSyncSafeFrom} correctly report "not safe" once the underlying 
_delta_log commit
+ * files backing an old instant have been physically removed, for both the 
Standalone and Kernel
+ * Delta sources?
+ *
+ * <p>Per the Delta protocol spec (PROTOCOL.md, "Metadata cleanup"), real log 
retention cleanup is
+ * REQUIRED to leave a checkpoint covering the oldest kept version before 
deleting older JSON commit
+ * files -- readers should never be able to reach a state with commit files 
missing and no
+ * checkpoint to fall back on. To reproduce that realistic, protocol-compliant 
state rather than an
+ * artificial one, this test inserts enough rows to force Delta's default 
checkpoint interval (10
+ * commits) to actually create a checkpoint, then deletes only the JSON commit 
files strictly older
+ * than that checkpoint -- leaving the checkpoint itself and everything after 
it intact.
+ */
+@Log4j2
+public class ITDeltaLogTruncationSafetyCheck {
+  // Delta's default delta.checkpointInterval is 10 commits; insert enough 
rows to comfortably
+  // pass that so a checkpoint actually gets written.
+  private static final int NUM_COMMITS = 14;
+
+  @TempDir public static Path tempDir;
+
+  private static JavaSparkContext jsc;
+  private static SparkSession sparkSession;
+
+  @BeforeAll
+  public static void setupOnce() {
+    sparkSession = 
SparkSession.builder().config(HudiTestUtil.getSparkConf(tempDir)).getOrCreate();
+    jsc = JavaSparkContext.fromSparkContext(sparkSession.sparkContext());
+  }
+
+  @AfterAll
+  public static void teardown() {
+    if (jsc != null) {
+      jsc.close();
+    }
+    if (sparkSession != null) {
+      sparkSession.close();
+    }
+  }
+
+  @Test
+  public void testIsIncrementalSyncSafeFromAfterLogTruncation() throws 
Exception {
+    String tableName = GenericTable.getTableName();
+    Instant instantAfterV0;
+    String basePath;
+    try (TestSparkDeltaTable table =
+        new TestSparkDeltaTable(tableName, tempDir, sparkSession, null, 
false)) {
+      table.insertRows(10); // version 0
+      instantAfterV0 = Instant.now();
+      Thread.sleep(1100);
+
+      for (int i = 1; i < NUM_COMMITS; i++) {
+        table.insertRows(10);
+        Thread.sleep(200);
+      }
+      basePath = table.getBasePath();
+    }
+
+    Path deltaLogDir = Paths.get(URI.create(basePath)).resolve("_delta_log");
+    long checkpointVersion = findLatestCheckpointVersion(deltaLogDir);
+    log.info("Latest checkpoint version found: {}", checkpointVersion);
+    org.junit.jupiter.api.Assertions.assertTrue(
+        checkpointVersion > 0,
+        "Expected a checkpoint to have been created after "
+            + NUM_COMMITS
+            + " commits; increase NUM_COMMITS if delta.checkpointInterval has 
changed.");
+
+    // Delete every JSON commit file strictly older than the checkpoint -- 
exactly what
+    // protocol-compliant log retention cleanup does, per PROTOCOL.md's 
"Metadata cleanup"
+    // section -- leaving the checkpoint (and everything from its version 
onward) intact.
+    int deletedCount = 0;
+    for (long version = 0; version < checkpointVersion; version++) {
+      Path commitFile = deltaLogDir.resolve(String.format("%020d.json", 
version));
+      if (Files.deleteIfExists(commitFile)) {
+        deletedCount++;
+      }
+    }
+    log.info(
+        "Deleted {} commit file(s) older than checkpoint version {}",
+        deletedCount,
+        checkpointVersion);
+
+    SourceTable sourceTable =
+        SourceTable.builder()
+            .name(tableName)
+            .formatName(TableFormat.DELTA)
+            .basePath(basePath)
+            .dataPath(basePath)
+            .additionalProperties(new Properties())
+            .build();
+
+    // instantAfterV0 is before the checkpoint, and its backing commit file 
has been deleted,
+    // while a valid checkpoint covering that deletion exists -- the 
realistic, protocol-compliant
+    // version of the "log truncated past the requested instant" scenario in 
#779.
+    logSafetyCheckResult(
+        "Standalone", new DeltaConversionSourceProvider(), sourceTable, 
instantAfterV0);
+    logSafetyCheckResult(
+        "Kernel", new DeltaKernelConversionSourceProvider(), sourceTable, 
instantAfterV0);
+  }
+
+  // Both isIncrementalSyncSafeFrom implementations correctly return false 
once a valid
+  // checkpoint already covers the deleted commits (see
+  // testIsIncrementalSyncSafeFromAfterLogTruncation
+  // above). Delta's own docs on 
getEarliestDeltaFile/getEarliestRecreatableCommit explicitly warn
+  // that "this version isn't guaranteed to exist when performing an action as 
a concurrent
+  // operation can delete the file during cleanup" -- i.e. a TOCTOU race 
between the safety check
+  // and the actual incremental read that follows it. This test targets that 
race directly: call
+  // the safety check BEFORE any deletion (so it should say "safe"), then 
delete the pre-checkpoint
+  // commit files (simulating retention cleanup running concurrently, right 
after the check
+  // passed), then attempt the actual incremental read the check just approved.
+  @Test
+  public void testSafetyCheckRaceWithConcurrentCleanup_Standalone() throws 
Exception {
+    raceCheckThenReadAfterConcurrentDeletion("Standalone", new 
DeltaConversionSourceProvider());
+  }
+
+  @Test
+  public void testSafetyCheckRaceWithConcurrentCleanup_Kernel() throws 
Exception {
+    raceCheckThenReadAfterConcurrentDeletion("Kernel", new 
DeltaKernelConversionSourceProvider());
+  }
+
+  private void raceCheckThenReadAfterConcurrentDeletion(
+      String label, ConversionSourceProvider<Long> provider) throws Exception {
+    // Each implementation gets its own freshly-built table, so one 
implementation's deletion of
+    // pre-checkpoint files can't contaminate the other's "before deletion" 
baseline.
+    String tableName = GenericTable.getTableName();
+    Instant instantAfterV0;
+    String basePath;
+    try (TestSparkDeltaTable table =
+        new TestSparkDeltaTable(tableName, tempDir, sparkSession, null, 
false)) {
+      table.insertRows(10); // version 0
+      instantAfterV0 = Instant.now();
+      Thread.sleep(1100);
+
+      for (int i = 1; i < NUM_COMMITS; i++) {
+        table.insertRows(10);
+        Thread.sleep(200);
+      }
+      basePath = table.getBasePath();
+    }
+
+    Path deltaLogDir = Paths.get(URI.create(basePath)).resolve("_delta_log");
+    long checkpointVersion = findLatestCheckpointVersion(deltaLogDir);
+    log.info("[{}] Latest checkpoint version found: {}", label, 
checkpointVersion);
+    org.junit.jupiter.api.Assertions.assertTrue(checkpointVersion > 0, 
"Expected a checkpoint");
+
+    SourceTable sourceTable =
+        SourceTable.builder()
+            .name(tableName)
+            .formatName(TableFormat.DELTA)
+            .basePath(basePath)
+            .dataPath(basePath)
+            .additionalProperties(new Properties())
+            .build();
+
+    provider.init(jsc.hadoopConfiguration());
+    try (ConversionSource<Long> source = 
provider.getConversionSourceInstance(sourceTable)) {
+      boolean safeBeforeDeletion = 
source.isIncrementalSyncSafeFrom(instantAfterV0);
+      log.info(
+          "[{}] isIncrementalSyncSafeFrom({}) BEFORE concurrent cleanup = {}",
+          label,
+          instantAfterV0,
+          safeBeforeDeletion);
+
+      // Simulate retention cleanup running concurrently, immediately after 
the check passed.
+      int deletedCount = 0;
+      for (long version = 0; version < checkpointVersion; version++) {
+        Path commitFile = deltaLogDir.resolve(String.format("%020d.json", 
version));
+        if (Files.deleteIfExists(commitFile)) {
+          deletedCount++;
+        }
+      }
+      log.info("[{}] Deleted {} commit file(s) after the safety check ran", 
label, deletedCount);
+
+      if (safeBeforeDeletion) {

Review Comment:
   [P2] Assert the truncation safety outcomes
   
   Could we replace the logging-only checks with assertions before merging? 
`safeBeforeDeletion == false` skips the entire race check, while the catch 
blocks swallow every exception. The non-race helper also accepts either safety 
result. I temporarily made the Kernel safety check return `false` for every 
non-null instant, and all three tests still passed. The catch blocks also allow 
unexpected exceptions and incomplete backlogs to pass. Would it work to assert 
`true` before deletion, `false` after deletion, and the specific expected 
exception for the Kernel cleanup race? The Standalone case could assert its 
observed behavior separately.



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

To unsubscribe, e-mail: [email protected]

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

Reply via email to