This is an automated email from the ASF dual-hosted git repository.

vinishjail97 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-xtable.git


The following commit(s) were added to refs/heads/main by this push:
     new 2b475a59 [886] Add Delta Kernel source/target coverage to 
ITConversionController (#903)
2b475a59 is described below

commit 2b475a59809d508d08061327c5ec140d5cf0737e
Author: Vaibhav <[email protected]>
AuthorDate: Sat Oct 31 02:27:37 2026 +0530

    [886] Add Delta Kernel source/target coverage to ITConversionController 
(#903)
    
    * [886] Add Delta Kernel source/target coverage to ITConversionController
    
    - testVariousOperationsDeltaKernelSource runs the same dataset-equivalence
      checks as testVariousOperations, forcing 
DeltaKernelConversionSourceProvider
      across both sync modes and both partitioning cases.
    - testVariousOperationsDeltaKernelTarget does the same for the DELTA target
      leg, routing through DeltaKernelConversionTarget via
      DeltaConversionTargetConfig.USE_KERNEL, using ConversionTargetFactory's
      normal dispatch path.
    - testVariousOperations body extracted into runVariousOperationsTest so both
      new tests share the same assertions the Standalone paths are validated
      with; existing Standalone matrix is unchanged.
    
    The target-side test currently fails: DeltaKernelConversionTarget only calls
    withSchema() on table creation, so schema evolution on an existing table
    (e.g. a new nested field added inside an array column) never reaches the
    Delta log's Metadata action. Traced to open upstream gap
    delta-io/delta#4305. Follow-up fail-fast guard tracked separately.
    
    * [886] Fail fast on Delta Kernel schema evolution for existing tables
    
    DeltaKernelConversionTarget.commitTransaction() only applies withSchema()
    on CREATE_TABLE (Delta Kernel 4.0.0 limitation, delta-io/delta#4305), so
    schema evolution on an already-existing table was silently dropped: rows
    kept syncing, but a newly-added nested field (e.g. inside a struct that is
    an array column's element type) never reached the Delta log's registered
    schema, making it unreadable even though the underlying Parquet had it.
    
    syncSchema() now compares the incoming schema (as a Kernel StructType, to
    avoid any InternalSchema<->StructType round-trip noise) against the
    existing snapshot's schema, and throws NotSupportedException as soon as
    they differ for an existing table, instead of committing a stale schema.
    ConversionController/TableFormatSync catches this per-target and records
    it as a SyncStatusCode.ERROR, leaving the target at its last-good state
    rather than partially written.
    
    testVariousOperationsDeltaKernelTarget updated accordingly: it no longer
    reuses runVariousOperationsTest's full lifecycle (which assumes success),
    since the exception surfaces as a SyncResult status rather than a thrown
    exception from ConversionController.sync(). It now syncs an initial
    snapshot (expected SUCCESS), evolves the schema, syncs again (expected
    ERROR with the delta-io/delta#4305 reference), and confirms the target's
    row count didn't move.
    
    * [886] Remove incidental schema evolution from TestDeltaKernelSync tests
    
    testCreateSnapshotControlFlow and testTimestampNtz each did two syncSnapshot
    calls to the same existing table, with the second call's schema silently
    adding a field on top of the first (schema2 = schema1 + float_field). This
    is exactly the schema-evolution-on-an-existing-table scenario
    DeltaKernelConversionTarget now rejects (see the syncSchema guard added
    earlier). Before that guard existed, Kernel silently accepted the second
    sync without registering the new schema, and these tests only checked
    physical file presence/size, not schema correctness, so they never caught
    that the second sync's schema change was being dropped.
    
    Neither test is actually about schema evolution: 
testCreateSnapshotControlFlow
    is about the file add/remove control flow across two sequential snapshot
    syncs, and testTimestampNtz is about TIMESTAMP_NTZ handling. Fixed both to
    reuse the same schema for their second snapshot, matching their real intent.
    
    Added testSchemaEvolutionOnExistingTableFailsSync as a dedicated test for
    the guarded behavior: first sync succeeds, second sync (with an evolved
    schema) returns SyncStatusCode.ERROR referencing delta-io/delta#4305, and
    the table is left at its last-good state rather than partially written.
    
    * [886] Minor formatting/unused-import cleanup
    
    * [886] Add unchanged-schema resync assertion to 
testVariousOperationsDeltaKernelTarget
    
    Per PR #903 review feedback: nothing exercised syncing an already-existing
    table twice with an unchanged schema through the Kernel target, which is
    exactly the path the syncSchema() drift guard could false-positive on if
    there's any asymmetry in the InternalSchema<->StructType round trip.
    
    Added a second sync (50 more rows, same schema) to the first block of
    testVariousOperationsDeltaKernelTarget, asserting SyncStatusCode.SUCCESS,
    before moving on to the schema-evolution block that expects ERROR. Covers
    both partitioned and unpartitioned cases via the existing parameterization.
    
    * [886] Add repro test for #779 log truncation safety check on Kernel vs 
Standalone
    
    ITDeltaLogTruncationSafetyCheck reproduces the #779 scenario against a
    protocol-realistic setup: enough commits to force a real Delta checkpoint
    (per PROTOCOL.md's 'Metadata cleanup', retention is required to always
    leave a checkpoint before deleting older commit files), then deletes only
    the pre-checkpoint JSON files, leaving the checkpoint intact.
    
    With that realistic setup, both Standalone and Kernel correctly return
    false from isIncrementalSyncSafeFrom -- no bug in either for that case.
    
    A second pair of tests targets the actual gap: a TOCTOU race between the
    safety check passing and the subsequent read, since retention cleanup can
    run concurrently between the two. Kernel throws immediately with a clear,
    specific exception when this happens. Standalone does not: getCommitsBacklog
    silently returns a truncated commit list (a handful of commits instead of
    the ~13 expected), meaning early commits are dropped with no error and the
    sync would appear to succeed while quietly missing file actions for the
    truncated range.
    
    Filed as a standalone repro (not a fix) per the bar set when #782 (an
    earlier, incomplete attempt at this) was closed: a clear description of
    the failure scenario plus a reproducing test, before any fix is proposed.
    The bug lives in getCommitsBacklog, not isIncrementalSyncSafeFrom itself,
    so brishi19791's mustBeRecreatable suggestion on #782 would not address it.
    
    * [886] Assert truncation safety outcomes instead of only logging them
    
    Converts the log-only observations in ITDeltaLogTruncationSafetyCheck
    into real JUnit assertions, so the suite can actually fail on a
    regression instead of just narrating what happened:
    - assertSafetyCheckDetectsUnsafe now asserts isIncrementalSyncSafeFrom
      returns false post-truncation for both Standalone and Kernel.
    - The check-then-read race helper now returns a RaceOutcome captured
      before/after the concurrent deletion, so each of the two @Test
      methods can assert its own engine-specific expectation: Kernel
      throws KernelException, Standalone silently narrows the backlog to
      commits after the checkpoint.
    
    Verified all three tests still pass against real Standalone/Kernel
    behavior (JDK 11; JDK 17.0.15 hits an unrelated Spark/Hadoop
    UserGroupInformation incompatibility).
    
    Addresses 
https://github.com/apache/incubator-xtable/pull/903/changes#r4010718408
    
    * [886] Cover updates and deletes in the Kernel target matrix
    
    testVariousOperationsDeltaKernelTarget previously only inserted rows
    before exercising schema evolution, so update/delete correctness
    against a Kernel-written Delta target was never checked. Adds an
    unchanged-schema upsert and delete step, using the existing
    checkDatasetEquivalence/checkDatasetEquivalenceWithFilter helpers, so
    incorrect removal handling can no longer leave deleted rows or stale
    values visible without failing the matrix. Carries the new row count
    (130, after the delete) through to the last-good-state assertion in
    the schema-evolution section.
    
    Verified across all 4 parameterized cases (2 sync modes x 2
    partitioning) on JDK 11.
    
    Addresses 
https://github.com/apache/incubator-xtable/pull/903/changes#r4010718413
    
    * [886] Assert non-empty backlog to avoid vacuous allMatch pass
    
    allMatch on an empty stream returns true, so the Standalone race
    assertion would still pass if Standalone regressed to silently
    dropping every commit in the backlog, not just the pre-checkpoint
    ones. Adds an explicit non-empty check so that regression fails
    loudly instead of passing by vacuous truth. Also links #779 in the
    comment above the race test, so a later Standalone fix reads as the
    bug going away rather than as a regression.
    
    Addresses 
https://github.com/apache/incubator-xtable/pull/903/changes#r4108593854
    
    * [886] Flip the Delta target default to Kernel
    
    DeltaConversionTargetConfig.USE_KERNEL now defaults to true instead of
    false, routing Delta targets through DeltaKernelConversionTarget by
    default via ConversionTargetFactory's existing ServiceLoader-based
    dispatch. Setting USE_KERNEL=false falls back to Delta Standalone --
    the escape hatch for the one known regression on the Kernel path
    (schema evolution on an existing table; delta-io/delta#4305).
    
    ITConversionController's getTableSyncConfig previously only set
    USE_KERNEL when explicitly requesting Kernel, leaving it unset (and
    therefore defaulting) otherwise. That would have silently flipped
    every existing "Standalone" test case in testVariousOperations* over
    to Kernel. Now it always pins the property explicitly to true or
    false based on the test's intent, so the existing Standalone-vs-Kernel
    test matrix keeps exercising exactly what it did before.
    
    TestConversionTargetFactory's default-resolution test is renamed and
    flipped to assert Kernel instead of Standalone.
    
    Verified with the Maven build cache explicitly disabled (this repo's
    build cache was found to silently serve stale surefire results for a
    full-class run with no test filter):
    - TestConversionTargetFactory: 9/9 pass.
    - ITConversionController (full class, every source format x sync mode
      x partitioning combination, including the Standalone-pinned cases):
      52/52 pass.
    
    * [886] Flip the Delta source default to Kernel
    
    Delta-as-source had no equivalent of DeltaConversionTargetConfig's
    USE_KERNEL at all -- selection was hardcoded to
    DeltaConversionSourceProvider (Standalone) in three independent
    places, none of which went through the ServiceLoader-based factory
    the target side uses:
    - xtable-conversion-defaults.yaml's DELTA.conversionSourceProviderClass,
      read via reflection by both RunSync and RunCatalogSync (confirmed
      RunCatalogSync reads the same merged TableFormatConverters).
    - ConversionService.initSourceProviders, a separate hardcoded Java map
      in the Quarkus REST service layer.
    
    Flips both to DeltaKernelConversionSourceProvider by default:
    - xtable-conversion-defaults.yaml: changed the default class. The
      escape hatch is the existing --convertersConfig override mechanism,
      verified end-to-end against the real bundled CLI jar (built a
      genuine Delta table, ran the sync to Iceberg with a
      --convertersConfig pointing DELTA.conversionSourceProviderClass back
      at DeltaConversionSourceProvider, and separately with no override;
      both produced identical row counts, and the Kernel run's log carried
      io.delta.kernel's own SnapshotReport/ScanReport entries, confirming
      Kernel's engine actually executed rather than silently falling back).
    - ConversionService/ConversionServiceConfig: added a new
      xtable.delta.source.use_kernel Quarkus @ConfigProperty (default
      true), mirroring USE_KERNEL's shape, since this call site has no
      config-driven mechanism of its own to piggyback on.
    
    TestRunSync's default-config assertion updated to expect Kernel.
    ITConversionService, TestConversionService, and TestConversionResource
    all construct ConversionService via its @VisibleForTesting
    constructor with a manually pre-built provider map, bypassing
    initSourceProviders and the new flag entirely by design -- confirmed
    unaffected, no changes needed.
    
    Verified: xtable-utilities and xtable-service both compile cleanly on
    their respective required JDKs (11 and 17). TestRunSync: 8/8 pass
    (build cache disabled). The xtable-service unit tests
    (TestConversionService, TestConversionResource) currently fail in
    this environment's @BeforeEach Mockito setup before any test body
    runs (JDK 17.0.15 / Mockito inline-mock-maker incompatibility) --
    pre-existing and unrelated to this change, since it fails before ever
    reaching the modified code path.
    
    * [886] Apply spotless formatting to ConversionService
    
    Wraps a comment line that exceeded the configured line length.
    
    * [886] Pin ITParquetConversionSource's Delta target to Standalone
    
    Found via a real CI run of this branch: testSync's two partitioned
    cases failed after the default flip. The MONTH-transform partition
    this test uses is implemented as a Delta generated column, and Delta
    Kernel 4.0.0 does not support the generatedColumns writer table
    feature at all -- writing throws KernelException ("Unsupported Delta
    writer feature"), caught per-target by TableFormatSync and surfaced as
    SyncStatusCode.ERROR rather than corrupting the table. The test never
    checked that result before reading the table back, so it silently
    "passed" under the old Standalone default and only surfaced once
    Kernel became the default.
    
    This was missed by the earlier getTableSyncConfig pinning fix in
    ITConversionController because this is a different, pre-existing test
    file that calls ConversionController.sync() directly rather than
    naming ConversionTargetFactory -- the grep-based audit that found
    ITConversionController's call sites didn't catch it. Pin this test's
    Delta target to Standalone the same way, and document the new
    limitation in DeltaKernelConversionTarget's Javadoc alongside the
    existing schema-evolution one.
    
    Verified: ITParquetConversionSource 6/6 pass (build cache disabled),
    including both previously-failing partitioned cases.
    
    * [886] Pin XTableSyncTool's own use_kernel flag explicitly
    
    Found via a real CI run of this branch: TestXTableSyncTool.testSync
    failed with "Unable to sync to InternalTable for formats: DELTA" after
    the default flip. This is production code, not a test -- a bigger
    deal than the earlier test-only fixes.
    
    XTableSyncTool already has its own documented config property,
    hoodie.xtable.delta.target.use_kernel (default false, "instead of the
    default Delta Standalone implementation"), predating this PR. It only
    set DeltaConversionTargetConfig.USE_KERNEL on the target's
    additionalProperties when its own flag was true, leaving the property
    unset otherwise -- falling through to whatever
    ConversionTargetFactory's own default was. That was harmless while
    the global default was Standalone (false and unset both meant
    Standalone), but now that the global default is Kernel, leaving the
    property unset when this flag is false silently means Kernel too --
    making the flag's documented "default to Standalone" behavior a
    no-op for exactly the users relying on it.
    
    Always set the property explicitly to the flag's actual value (true
    or false), so this tool's own flag keeps meaning what it has always
    documented, independent of whatever ConversionTargetFactory's global
    default is.
    
    This was missed by the earlier searches because they only covered
    xtable-core, xtable-service, and xtable-utilities test directories --
    never xtable-hudi-support/xtable-hudi-support-extensions, which this
    production code lives in.
    
    Verified: TestXTableSyncTool 5/5 pass (build cache disabled),
    including both the default-flag case and the existing
    testSyncWithDeltaKernel case that explicitly opts into Kernel.
    
    * [886] Add missing delta-kernel-defaults dep and a Delta target use_kernel 
toggle to xtable-service
    
    xtable-service's ITConversionService hit NoClassDefFoundError for
    io/delta/kernel/defaults/engine/DefaultEngine because the module declared
    delta-kernel-api but never delta-kernel-defaults (xtable-core only has these
    as provided-scope, which isn't transitive into xtable-service's test
    classpath).
    
    Separately, now that Kernel is the default Delta target, ConversionService
    had no escape hatch to pin a Delta target to Standalone, which is needed
    because Kernel does not yet support schema evolution against an existing
    Delta table. Added xtable.delta.target.use_kernel (default true) mirroring
    the existing source-side toggle, and pin it explicitly on the target's
    additionalProperties rather than only setting it when true.
    
    * [886] Back out the Delta default flip; land coverage, guard and 
truncation test first
    
    Per review, the default flip is deferred to a follow-up PR until two
    Kernel-target failures pass an IT:
    1. An unchanged resync of a Standalone-written Delta table fails the
       schema-change guard for Hudi/Iceberg sources (columnMapping.id and
       nested-nullability asymmetry in fromInternalSchema).
    2. A DAY/MONTH transform partition fails with the unsupported
       generatedColumns writer feature.
    
    Reverts d47d916, 0c12db8, c4bb487, f551397, 525b7e6 and fc82864, so the
    CLI, service, Hudi sync tool and core all default to Delta Standalone
    again, with Kernel still available via the existing opt-in flag.
    ITConversionController keeps upstream's hudiTargetProperties() and goes
    back to only setting USE_KERNEL when a case requests Kernel.
    
    The generatedColumns Known Limitations note in DeltaKernelConversionTarget
    is kept, since it documents finding 2 independently of the default.
---
 .../xtable/kernel/DeltaKernelConversionTarget.java |  61 +++-
 .../org/apache/xtable/ITConversionController.java  | 229 ++++++++++++-
 .../xtable/ITDeltaLogTruncationSafetyCheck.java    | 353 +++++++++++++++++++++
 .../apache/xtable/kernel/TestDeltaKernelSync.java  |  72 +++--
 4 files changed, 677 insertions(+), 38 deletions(-)

diff --git 
a/xtable-core/src/main/java/org/apache/xtable/kernel/DeltaKernelConversionTarget.java
 
b/xtable-core/src/main/java/org/apache/xtable/kernel/DeltaKernelConversionTarget.java
index 70384257..b98b4216 100644
--- 
a/xtable-core/src/main/java/org/apache/xtable/kernel/DeltaKernelConversionTarget.java
+++ 
b/xtable-core/src/main/java/org/apache/xtable/kernel/DeltaKernelConversionTarget.java
@@ -59,6 +59,7 @@ import io.delta.kernel.types.StructType;
 import io.delta.kernel.utils.CloseableIterable;
 
 import org.apache.xtable.conversion.TargetTable;
+import org.apache.xtable.delta.DeltaConversionTargetConfig;
 import org.apache.xtable.exception.NotSupportedException;
 import org.apache.xtable.exception.UpdateException;
 import org.apache.xtable.model.InternalTable;
@@ -102,12 +103,26 @@ import org.apache.xtable.spi.sync.ConversionTarget;
  *   <li><strong>Commit Tags:</strong> Delta Kernel 4.0.0 does not support 
commit tags in commitInfo
  *       (e.g., XTABLE_METADATA tags). This affects source-to-target commit 
identifier mapping.
  *       Tracked in: https://github.com/apache/incubator-xtable/issues/819
- *   <li><strong>Schema Evolution:</strong> Schema changes are handled through 
Delta Kernel's
- *       transaction API, which may have different semantics compared to Delta 
Standalone.
+ *   <li><strong>Schema Evolution:</strong> Delta Kernel 4.0.0 only applies a 
schema via {@code
+ *       TransactionBuilder.withSchema()} on {@code CREATE_TABLE}, not on 
{@code WRITE} to an
+ *       existing table. {@link #syncSchema(InternalSchema)} detects when a 
sync's schema differs
+ *       from an existing table's current schema and throws {@link 
NotSupportedException} rather
+ *       than silently committing a stale schema. Set {@link
+ *       org.apache.xtable.delta.DeltaConversionTargetConfig#USE_KERNEL} to 
{@code false} to fall
+ *       back to the Delta Standalone target, which does support this. Tracked 
upstream:
+ *       https://github.com/delta-io/delta/issues/4305
  *   <li><strong>Internal API Usage:</strong> This implementation casts to 
internal classes
  *       (SnapshotImpl, TableImpl) to access metadata and commit history, as 
Delta Kernel 4.0.0
  *       lacks public APIs for these operations. These casts are brittle and 
may break on version
  *       upgrades. Public API alternatives should be used when available.
+ *   <li><strong>Generated Columns:</strong> Delta Kernel 4.0.0 does not 
support the {@code
+ *       generatedColumns} writer table feature, which XTable's 
schema/partition extraction emits
+ *       for a derived (transform-based, e.g. a date truncation) partition 
column. Writing such a
+ *       schema throws {@link io.delta.kernel.exceptions.KernelException} 
("Unsupported Delta writer
+ *       feature"), caught per-target by {@code TableFormatSync} and surfaced 
as a {@code
+ *       SyncStatusCode.ERROR} entry rather than corrupting the table. Delta 
Standalone does support
+ *       this. Set {@link 
org.apache.xtable.delta.DeltaConversionTargetConfig#USE_KERNEL} to {@code
+ *       false} to fall back to it.
  * </ul>
  *
  * <p><strong>Implementation Choice:</strong> Delta Kernel API was chosen over 
Delta Standalone to:
@@ -229,6 +244,30 @@ public class DeltaKernelConversionTarget implements 
ConversionTarget {
 
   @Override
   public void syncSchema(InternalSchema schema) {
+    if (transactionState.isTableExists()) {
+      // Compare in Kernel's own StructType, not InternalSchema, to avoid 
false positives from
+      // any asymmetry in the InternalSchema<->StructType round trip. 
transactionState's current
+      // latestSchema still holds the schema loaded from the existing snapshot 
at this point,
+      // since it is only overwritten by the setLatestSchema() call below.
+      StructType existingSchema = transactionState.getLatestSchema();
+      StructType newSchema = schemaExtractor.fromInternalSchema(schema);
+      if (!newSchema.equals(existingSchema)) {
+        // LIMITATION: Delta Kernel 4.0.0's TransactionBuilder.withSchema() 
only takes effect for
+        // CREATE_TABLE operations (see commitTransaction() below), so there 
is no supported way
+        // to commit an evolved schema for an existing table today. Committing 
anyway would write
+        // AddFile actions for files that may contain the new columns/fields 
while leaving the
+        // table's registered schema stale, silently making the new data 
unreadable rather than
+        // failing. Fail fast instead. Tracked upstream:
+        // https://github.com/delta-io/delta/issues/4305
+        throw new NotSupportedException(
+            "Schema evolution on an existing Delta table is not supported by "
+                + "DeltaKernelConversionTarget (see 
https://github.com/delta-io/delta/issues/4305). "
+                + "Set "
+                + DeltaConversionTargetConfig.USE_KERNEL
+                + "=false on the target's additional properties to use the 
Delta Standalone "
+                + "target instead.");
+      }
+    }
     transactionState.setLatestSchema(schema);
   }
 
@@ -377,6 +416,14 @@ public class DeltaKernelConversionTarget implements 
ConversionTarget {
       partitionColumns.add(columnName);
     }
 
+    /**
+     * Whether the target table already existed at the start of this sync. 
Package-private to allow
+     * access from outer class.
+     */
+    boolean isTableExists() {
+      return tableExists;
+    }
+
     /**
      * Gets the cached snapshot. Returns null if no snapshot was cached (new 
table). Package-private
      * to allow access from outer class.
@@ -415,11 +462,11 @@ public class DeltaKernelConversionTarget implements 
ConversionTarget {
           table.createTransactionBuilder(engine, "XTable Delta Sync", 
operation);
 
       // LIMITATION: Schema evolution for existing tables is NOT supported in 
Delta Kernel 4.0.0.
-      // The withSchema() method only works during CREATE_TABLE operations. 
For existing tables:
-      // - AddFile/RemoveFile actions are created using the old schema from 
existing snapshot
-      // - If source schema has evolved (columns added/removed/type changed), 
the Delta table
-      //   will have mismatched metadata and data, causing query failures or 
incorrect results
-      // This is a known Delta Kernel limitation: 
https://github.com/delta-io/delta/issues/4305
+      // The withSchema() method only works during CREATE_TABLE operations. 
syncSchema() already
+      // guards against this by throwing NotSupportedException as soon as an 
evolved schema is
+      // detected for an existing table, so by the time we get here, an 
existing table's schema
+      // is guaranteed to be unchanged from the existing snapshot. This is a 
known Delta Kernel
+      // limitation: https://github.com/delta-io/delta/issues/4305
       if (!tableExists) {
         txnBuilder = txnBuilder.withSchema(engine, latestSchema);
 
diff --git 
a/xtable-core/src/test/java/org/apache/xtable/ITConversionController.java 
b/xtable-core/src/test/java/org/apache/xtable/ITConversionController.java
index d282cd0a..c6b097d8 100644
--- a/xtable-core/src/test/java/org/apache/xtable/ITConversionController.java
+++ b/xtable-core/src/test/java/org/apache/xtable/ITConversionController.java
@@ -28,6 +28,7 @@ import static 
org.apache.xtable.model.storage.TableFormat.PAIMON;
 import static org.apache.xtable.model.storage.TableFormat.PARQUET;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.net.URI;
 import java.nio.ByteBuffer;
@@ -101,13 +102,17 @@ import 
org.apache.xtable.conversion.ConversionSourceProvider;
 import org.apache.xtable.conversion.SourceTable;
 import org.apache.xtable.conversion.TargetTable;
 import org.apache.xtable.delta.DeltaConversionSourceProvider;
+import org.apache.xtable.delta.DeltaConversionTargetConfig;
 import org.apache.xtable.hudi.HudiConversionSourceProvider;
 import org.apache.xtable.hudi.HudiTargetConfig;
 import org.apache.xtable.hudi.HudiTestUtil;
 import org.apache.xtable.iceberg.IcebergConversionSourceProvider;
 import org.apache.xtable.iceberg.TestIcebergDataHelper;
+import org.apache.xtable.kernel.DeltaKernelConversionSourceProvider;
 import org.apache.xtable.model.storage.TableFormat;
 import org.apache.xtable.model.sync.SyncMode;
+import org.apache.xtable.model.sync.SyncResult;
+import org.apache.xtable.model.sync.SyncStatusCode;
 import org.apache.xtable.paimon.PaimonConversionSourceProvider;
 
 public class ITConversionController {
@@ -180,6 +185,16 @@ public class ITConversionController {
     return arguments.stream();
   }
 
+  private static Stream<Arguments> 
generateTestParametersForSyncModesAndPartitioning() {
+    List<Arguments> arguments = new ArrayList<>();
+    for (SyncMode syncMode : SyncMode.values()) {
+      for (boolean isPartitioned : new boolean[] {true, false}) {
+        arguments.add(Arguments.of(syncMode, isPartitioned));
+      }
+    }
+    return arguments.stream();
+  }
+
   private static Stream<Arguments> generateTestParametersForUUID() {
     List<Arguments> arguments = new ArrayList<>();
     for (SyncMode syncMode : SyncMode.values()) {
@@ -249,14 +264,148 @@ public class ITConversionController {
       SyncMode syncMode,
       boolean isPartitioned,
       HoodieTableVersion hudiTargetVersion) {
+    runVariousOperationsTest(
+        sourceTableFormat,
+        syncMode,
+        isPartitioned,
+        hudiTargetVersion,
+        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, null, 
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 
exercises upserts
+  // and deletes against the Kernel target with that same unchanged schema 
(since incorrect
+  // removal/update handling could otherwise leave deleted rows or stale 
values visible without
+  // failing this target's matrix), 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)) {
+      List<?> insertRecords = 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);
+
+      // Cover updates and deletes against the Kernel target before moving on 
to schema evolution
+      // below -- still with an unchanged schema, so this isolates 
removal/update handling from
+      // the schema-drift behavior exercised above and below.
+      table.upsertRows(insertRecords.subList(0, 20));
+      results = conversionController.sync(conversionConfig, 
conversionSourceProvider);
+      assertEquals(
+          SyncStatusCode.SUCCESS, 
results.get(DELTA).getTableFormatSyncStatus().getStatusCode());
+      checkDatasetEquivalence(HUDI, table, targetTableFormats, 150);
+
+      table.deleteRows(insertRecords.subList(30, 50));
+      results = conversionController.sync(conversionConfig, 
conversionSourceProvider);
+      assertEquals(
+          SyncStatusCode.SUCCESS, 
results.get(DELTA).getTableFormatSyncStatus().getStatusCode());
+      checkDatasetEquivalence(HUDI, table, targetTableFormats, 130);
+      checkDatasetEquivalenceWithFilter(
+          HUDI, table, targetTableFormats, table.getFilterQuery(), 
Collections.emptyMap());
+    }
+
+    try (GenericTable tableWithUpdatedSchema =
+        GenericTable.getInstanceWithAdditionalColumns(
+            tableName, tempDir, sparkSession, jsc, HUDI, isPartitioned)) {
+      tableWithUpdatedSchema.insertRows(100);
+      ConversionConfig conversionConfig =
+          getTableSyncConfig(
+              HUDI,
+              syncMode,
+              tableName,
+              tableWithUpdatedSchema,
+              targetTableFormats,
+              partitionConfig,
+              null,
+              true);
+      Map<String, SyncResult> results =
+          conversionController.sync(conversionConfig, 
conversionSourceProvider);
+      SyncResult.SyncStatus deltaStatus = 
results.get(DELTA).getTableFormatSyncStatus();
+      assertEquals(SyncStatusCode.ERROR, deltaStatus.getStatusCode());
+      assertTrue(
+          deltaStatus
+              .getErrorDetails()
+              .getErrorMessage()
+              .contains("https://github.com/delta-io/delta/issues/4305";));
+
+      // The target should be left exactly at its last successfully-synced 
state (130 rows, old
+      // schema), not partially written with some but not all of the new 
rows/columns.
+      long targetRowCount =
+          
sparkSession.read().format("delta").load(tableWithUpdatedSchema.getDataPath()).count();
+      assertEquals(130, targetRowCount);
+    }
+  }
+
+  private void runVariousOperationsTest(
+      String sourceTableFormat,
+      SyncMode syncMode,
+      boolean isPartitioned,
+      HoodieTableVersion hudiTargetVersion,
+      ConversionSourceProvider<?> conversionSourceProvider) {
     String tableName = getTableName();
     List<String> targetTableFormats = getOtherFormats(sourceTableFormat);
     String partitionConfig = null;
     if (isPartitioned) {
       partitionConfig = "level:VALUE";
     }
-    ConversionSourceProvider<?> conversionSourceProvider =
-        getConversionSourceProvider(sourceTableFormat);
     List<?> insertRecords;
     try (GenericTable table =
         GenericTable.getInstance(
@@ -1412,7 +1561,8 @@ public class ITConversionController {
         targetTableFormats,
         partitionConfig,
         metadataRetention,
-        null);
+        null,
+        false);
   }
 
   private static ConversionConfig getTableSyncConfig(
@@ -1424,6 +1574,55 @@ public class ITConversionController {
       String partitionConfig,
       Duration metadataRetention,
       HoodieTableVersion hudiTargetVersion) {
+    return getTableSyncConfig(
+        sourceTableFormat,
+        syncMode,
+        tableName,
+        table,
+        targetTableFormats,
+        partitionConfig,
+        metadataRetention,
+        hudiTargetVersion,
+        false);
+  }
+
+  // Same as above, but when useDeltaKernelTarget is true, routes any DELTA 
target through
+  // ConversionTargetFactory's DeltaKernelConversionTarget rather than the 
default Standalone one,
+  // by setting DeltaConversionTargetConfig.USE_KERNEL on that target's 
additional properties. This
+  // lets a test validate the Kernel target writer through the same dispatch 
path (and the same
+  // equivalence assertions) a Standalone target is validated with, per
+  // https://github.com/apache/incubator-xtable/issues/886.
+  private static ConversionConfig getTableSyncConfig(
+      String sourceTableFormat,
+      SyncMode syncMode,
+      String tableName,
+      GenericTable table,
+      List<String> targetTableFormats,
+      String partitionConfig,
+      Duration metadataRetention,
+      boolean useDeltaKernelTarget) {
+    return getTableSyncConfig(
+        sourceTableFormat,
+        syncMode,
+        tableName,
+        table,
+        targetTableFormats,
+        partitionConfig,
+        metadataRetention,
+        null,
+        useDeltaKernelTarget);
+  }
+
+  private static ConversionConfig getTableSyncConfig(
+      String sourceTableFormat,
+      SyncMode syncMode,
+      String tableName,
+      GenericTable table,
+      List<String> targetTableFormats,
+      String partitionConfig,
+      Duration metadataRetention,
+      HoodieTableVersion hudiTargetVersion,
+      boolean useDeltaKernelTarget) {
     Properties sourceProperties = new Properties();
     if (partitionConfig != null) {
       sourceProperties.put(PARTITION_FIELD_SPEC_CONFIG, partitionConfig);
@@ -1440,15 +1639,21 @@ public class ITConversionController {
     List<TargetTable> targetTables =
         targetTableFormats.stream()
             .map(
-                formatName ->
-                    TargetTable.builder()
-                        .name(tableName)
-                        .formatName(formatName)
-                        // set the metadata path to the data path as the 
default (required by Hudi)
-                        .basePath(table.getDataPath())
-                        .metadataRetention(metadataRetention)
-                        .additionalProperties(hudiTargetProperties(formatName, 
hudiTargetVersion))
-                        .build())
+                formatName -> {
+                  TypedProperties targetProperties =
+                      hudiTargetProperties(formatName, hudiTargetVersion);
+                  if (useDeltaKernelTarget && formatName.equals(DELTA)) {
+                    
targetProperties.setProperty(DeltaConversionTargetConfig.USE_KERNEL, "true");
+                  }
+                  return TargetTable.builder()
+                      .name(tableName)
+                      .formatName(formatName)
+                      // set the metadata path to the data path as the default 
(required by Hudi)
+                      .basePath(table.getDataPath())
+                      .metadataRetention(metadataRetention)
+                      .additionalProperties(targetProperties)
+                      .build();
+                })
             .collect(Collectors.toList());
 
     return ConversionConfig.builder()
diff --git 
a/xtable-core/src/test/java/org/apache/xtable/ITDeltaLogTruncationSafetyCheck.java
 
b/xtable-core/src/test/java/org/apache/xtable/ITDeltaLogTruncationSafetyCheck.java
new file mode 100644
index 00000000..fd1da82c
--- /dev/null
+++ 
b/xtable-core/src/test/java/org/apache/xtable/ITDeltaLogTruncationSafetyCheck.java
@@ -0,0 +1,353 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+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.List;
+import java.util.Properties;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import lombok.Value;
+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 io.delta.kernel.exceptions.KernelException;
+
+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);
+    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.
+    assertSafetyCheckDetectsUnsafe(
+        "Standalone", new DeltaConversionSourceProvider(), sourceTable, 
instantAfterV0);
+    assertSafetyCheckDetectsUnsafe(
+        "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.
+  //
+  // The two implementations react differently once the race is hit, so each 
gets its own
+  // assertions on the observed behavior rather than a shared expectation:
+  //  - Kernel resolves the sync instant to a version eagerly and throws a 
KernelException once
+  //    that version's backing file is gone.
+  //  - Standalone does not throw; it silently narrows the backlog to only the 
commits at or
+  //    after the checkpoint, silently dropping the earlier commits it had 
just approved reading.
+  //    If https://github.com/apache/incubator-xtable/issues/779 is ever fixed 
on the Standalone
+  //    path, this assertion should start failing because commitsToProcess 
becomes empty (or the
+  //    read starts throwing) rather than narrowed -- that's the bug going 
away, not a regression.
+  @Test
+  public void testSafetyCheckRaceWithConcurrentCleanup_Standalone() throws 
Exception {
+    RaceOutcome outcome =
+        raceCheckThenReadAfterConcurrentDeletion("Standalone", new 
DeltaConversionSourceProvider());
+    assertTrue(
+        outcome.isSafeBeforeDeletion(),
+        "Standalone isIncrementalSyncSafeFrom should report safe before the 
concurrent cleanup"
+            + " runs");
+    assertNull(
+        outcome.getThrown(),
+        "Standalone's read is not expected to throw after the race; it is 
expected to instead"
+            + " silently narrow the backlog -- see the commitsToProcess 
assertion below. If this"
+            + " now throws, Standalone's behavior has changed and this test 
needs updating: "
+            + outcome.getThrown());
+    assertNotNull(outcome.getCommitsToProcess());
+    // allMatch alone would pass vacuously on an empty backlog, which would 
hide Standalone
+    // silently dropping every commit instead of just the pre-checkpoint ones 
-- assert non-empty
+    // too so that regression fails loudly instead of passing by vacuous truth.
+    assertFalse(
+        outcome.getCommitsToProcess().isEmpty(),
+        "Expected Standalone's backlog to still contain the post-checkpoint 
commits, not be"
+            + " empty");
+    assertTrue(
+        outcome.getCommitsToProcess().stream().allMatch(c -> c > 
outcome.getCheckpointVersion()),
+        "Expected Standalone to silently drop every commit at or before the 
checkpoint version ("
+            + outcome.getCheckpointVersion()
+            + ") once their backing files were deleted mid-race, even though 
the safety check"
+            + " had approved syncing from before those commits; got "
+            + outcome.getCommitsToProcess());
+  }
+
+  @Test
+  public void testSafetyCheckRaceWithConcurrentCleanup_Kernel() throws 
Exception {
+    RaceOutcome outcome =
+        raceCheckThenReadAfterConcurrentDeletion(
+            "Kernel", new DeltaKernelConversionSourceProvider());
+    assertTrue(
+        outcome.isSafeBeforeDeletion(),
+        "Kernel isIncrementalSyncSafeFrom should report safe before the 
concurrent cleanup runs");
+    assertNotNull(
+        outcome.getThrown(),
+        "Expected Kernel's read to throw once concurrent cleanup deletes the 
commit files"
+            + " backing the sync instant the safety check had just approved");
+    assertInstanceOf(
+        KernelException.class,
+        outcome.getThrown(),
+        "Expected a KernelException from Kernel's timestamp-to-version 
resolution, got: "
+            + outcome.getThrown());
+  }
+
+  @Value
+  private static class RaceOutcome {
+    boolean safeBeforeDeletion;
+    Exception thrown;
+    List<Long> commitsToProcess;
+    long checkpointVersion;
+  }
+
+  private RaceOutcome 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);
+    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);
+
+      List<Long> commitsToProcess = null;
+      Exception thrown = null;
+      try {
+        CommitsBacklog<Long> backlog =
+            source.getCommitsBacklog(
+                
InstantsForIncrementalSync.builder().lastSyncInstant(instantAfterV0).build());
+        commitsToProcess = backlog.getCommitsToProcess();
+        log.info(
+            "[{}] getCommitsBacklog after concurrent cleanup returned 
commitsToProcess={},"
+                + " expected commits 1..{} (checkpoint covers 0..{}) if 
nothing was silently"
+                + " skipped",
+            label,
+            commitsToProcess,
+            NUM_COMMITS - 1,
+            checkpointVersion - 1);
+        for (Long commit : commitsToProcess) {
+          source.getTableChangeForCommit(commit);
+          log.info("[{}] getTableChangeForCommit({}) succeeded", label, 
commit);
+        }
+      } catch (Exception e) {
+        thrown = e;
+        log.info("[{}] read after concurrent cleanup threw: {}", label, 
e.toString());
+      }
+      return new RaceOutcome(safeBeforeDeletion, thrown, commitsToProcess, 
checkpointVersion);
+    }
+  }
+
+  /** Returns the highest checkpoint version found in the log directory, or -1 
if none exist. */
+  private long findLatestCheckpointVersion(Path deltaLogDir) throws Exception {
+    Pattern checkpointPattern = 
Pattern.compile("^(\\d{20})\\.checkpoint(\\..*)?\\.parquet$");
+    long latest = -1;
+    try (DirectoryStream<Path> stream = Files.newDirectoryStream(deltaLogDir)) 
{
+      for (Path entry : stream) {
+        Matcher matcher = 
checkpointPattern.matcher(entry.getFileName().toString());
+        if (matcher.matches()) {
+          latest = Math.max(latest, Long.parseLong(matcher.group(1)));
+        }
+      }
+    }
+    return latest;
+  }
+
+  private void assertSafetyCheckDetectsUnsafe(
+      String label,
+      ConversionSourceProvider<Long> provider,
+      SourceTable sourceTable,
+      Instant instant)
+      throws Exception {
+    provider.init(jsc.hadoopConfiguration());
+    try (ConversionSource<Long> source = 
provider.getConversionSourceInstance(sourceTable)) {
+      boolean safe = source.isIncrementalSyncSafeFrom(instant);
+      log.info("[{}] isIncrementalSyncSafeFrom({}) = {}", label, instant, 
safe);
+      assertFalse(
+          safe,
+          "["
+              + label
+              + "] isIncrementalSyncSafeFrom should report false once the 
commit files backing "
+              + instant
+              + " have been removed by protocol-compliant log retention 
cleanup");
+    }
+  }
+}
diff --git 
a/xtable-core/src/test/java/org/apache/xtable/kernel/TestDeltaKernelSync.java 
b/xtable-core/src/test/java/org/apache/xtable/kernel/TestDeltaKernelSync.java
index b67e645f..06f569d7 100644
--- 
a/xtable-core/src/test/java/org/apache/xtable/kernel/TestDeltaKernelSync.java
+++ 
b/xtable-core/src/test/java/org/apache/xtable/kernel/TestDeltaKernelSync.java
@@ -77,6 +77,8 @@ import org.apache.xtable.model.storage.FileFormat;
 import org.apache.xtable.model.storage.InternalDataFile;
 import org.apache.xtable.model.storage.PartitionFileGroup;
 import org.apache.xtable.model.storage.TableFormat;
+import org.apache.xtable.model.sync.SyncResult;
+import org.apache.xtable.model.sync.SyncStatusCode;
 import org.apache.xtable.spi.sync.TableFormatSync;
 
 /**
@@ -115,6 +117,36 @@ public class TestDeltaKernelSync {
 
   @Test
   public void testCreateSnapshotControlFlow() throws Exception {
+    // Both snapshots use the same schema: this test is about the file 
add/remove control flow
+    // across two sequential snapshot syncs to an existing table, not schema 
evolution (which
+    // DeltaKernelConversionTarget does not support for existing tables; see
+    // testSchemaEvolutionOnExistingTableFailsSync below).
+    InternalSchema schema = getInternalSchema();
+    InternalTable table1 = getInternalTable(tableName, basePath, schema, null, 
LAST_COMMIT_TIME);
+    InternalTable table2 = getInternalTable(tableName, basePath, schema, null, 
LAST_COMMIT_TIME);
+
+    InternalDataFile dataFile1 = getDataFile(1, Collections.emptyList(), 
basePath);
+    InternalDataFile dataFile2 = getDataFile(2, Collections.emptyList(), 
basePath);
+    InternalDataFile dataFile3 = getDataFile(3, Collections.emptyList(), 
basePath);
+
+    InternalSnapshot snapshot1 = buildSnapshot(table1, "0", dataFile1, 
dataFile2);
+    InternalSnapshot snapshot2 = buildSnapshot(table2, "1", dataFile2, 
dataFile3);
+
+    TableFormatSync.getInstance()
+        .syncSnapshot(Collections.singletonList(conversionTarget), snapshot1);
+    validateDeltaTable(basePath, new HashSet<>(Arrays.asList(dataFile1, 
dataFile2)));
+
+    TableFormatSync.getInstance()
+        .syncSnapshot(Collections.singletonList(conversionTarget), snapshot2);
+    validateDeltaTable(basePath, new HashSet<>(Arrays.asList(dataFile2, 
dataFile3)));
+  }
+
+  @Test
+  public void testSchemaEvolutionOnExistingTableFailsSync() throws Exception {
+    // DeltaKernelConversionTarget.commitTransaction() only applies 
withSchema() on CREATE_TABLE
+    // (Delta Kernel 4.0.0 limitation, see 
https://github.com/delta-io/delta/issues/4305), so a
+    // second sync that evolves the schema of an already-existing table must 
fail the sync rather
+    // than silently commit files without registering the new schema in the 
Delta log.
     InternalSchema schema1 = getInternalSchema();
     List<InternalField> fields2 = new ArrayList<>(schema1.getFields());
     fields2.add(
@@ -127,7 +159,7 @@ public class TestDeltaKernelSync {
                     .isNullable(true)
                     .build())
             .build());
-    InternalSchema schema2 = 
getInternalSchema().toBuilder().fields(fields2).build();
+    InternalSchema schema2 = schema1.toBuilder().fields(fields2).build();
     InternalTable table1 = getInternalTable(tableName, basePath, schema1, 
null, LAST_COMMIT_TIME);
     InternalTable table2 = getInternalTable(tableName, basePath, schema2, 
null, LAST_COMMIT_TIME);
 
@@ -142,9 +174,19 @@ public class TestDeltaKernelSync {
         .syncSnapshot(Collections.singletonList(conversionTarget), snapshot1);
     validateDeltaTable(basePath, new HashSet<>(Arrays.asList(dataFile1, 
dataFile2)));
 
-    TableFormatSync.getInstance()
-        .syncSnapshot(Collections.singletonList(conversionTarget), snapshot2);
-    validateDeltaTable(basePath, new HashSet<>(Arrays.asList(dataFile2, 
dataFile3)));
+    Map<String, SyncResult> results =
+        TableFormatSync.getInstance()
+            .syncSnapshot(Collections.singletonList(conversionTarget), 
snapshot2);
+    SyncResult.SyncStatus status = 
results.get(TableFormat.DELTA).getTableFormatSyncStatus();
+    assertEquals(SyncStatusCode.ERROR, status.getStatusCode());
+    assertTrue(
+        
status.getErrorDetails().getErrorMessage().contains("delta/issues/4305"),
+        "Expected error message to reference the upstream Kernel limitation, 
but was: "
+            + status.getErrorDetails().getErrorMessage());
+
+    // The table must be left exactly as the first sync left it: the second 
sync's files must not
+    // have been committed alongside a stale schema.
+    validateDeltaTable(basePath, new HashSet<>(Arrays.asList(dataFile1, 
dataFile2)));
   }
 
   @Test
@@ -569,21 +611,13 @@ public class TestDeltaKernelSync {
 
   @Test
   public void testTimestampNtz() throws Exception {
-    InternalSchema schema1 = getInternalSchemaWithTimestampNtz();
-    List<InternalField> fields2 = new ArrayList<>(schema1.getFields());
-    fields2.add(
-        InternalField.builder()
-            .name("float_field")
-            .schema(
-                InternalSchema.builder()
-                    .name("float")
-                    .dataType(InternalType.FLOAT)
-                    .isNullable(true)
-                    .build())
-            .build());
-    InternalSchema schema2 = 
getInternalSchema().toBuilder().fields(fields2).build();
-    InternalTable table1 = getInternalTable(tableName, basePath, schema1, 
null, LAST_COMMIT_TIME);
-    InternalTable table2 = getInternalTable(tableName, basePath, schema2, 
null, LAST_COMMIT_TIME);
+    // Both snapshots use the same TIMESTAMP_NTZ-inclusive schema: this test 
is about that type
+    // being handled correctly across two sequential snapshot syncs to an 
existing table, not
+    // schema evolution (which DeltaKernelConversionTarget does not support 
for existing tables;
+    // see testSchemaEvolutionOnExistingTableFailsSync).
+    InternalSchema schema = getInternalSchemaWithTimestampNtz();
+    InternalTable table1 = getInternalTable(tableName, basePath, schema, null, 
LAST_COMMIT_TIME);
+    InternalTable table2 = getInternalTable(tableName, basePath, schema, null, 
LAST_COMMIT_TIME);
 
     InternalDataFile dataFile1 = getDataFile(1, Collections.emptyList(), 
basePath);
     InternalDataFile dataFile2 = getDataFile(2, Collections.emptyList(), 
basePath);

Reply via email to