felipepessoto commented on code in PR #12647:
URL: https://github.com/apache/gluten/pull/12647#discussion_r3669964820


##########
cpp/velox/jni/VeloxJniWrapper.cc:
##########
@@ -717,6 +717,72 @@ JNIEXPORT jlong JNICALL 
Java_org_apache_gluten_columnarbatch_VeloxColumnarBatchJ
   JNI_METHOD_END(kInvalidObjectHandle)
 }
 
+JNIEXPORT jint JNICALL 
Java_org_apache_gluten_columnarbatch_VeloxColumnarBatchJniWrapper_firstNullColumnIndex(
 // NOLINT
+    JNIEnv* env,
+    jobject wrapper,
+    jlong veloxBatchHandle,
+    jintArray columnOrdinals) {
+  JNI_METHOD_START
+  auto ctx = getRuntime(env, wrapper);
+  auto runtime = dynamic_cast<VeloxRuntime*>(ctx);
+  auto batch = ObjectStore::retrieve<ColumnarBatch>(veloxBatchHandle);
+  auto safeColumnOrdinals = getIntArrayElementsSafe(env, columnOrdinals);
+
+  const auto numColumnsToCheck = safeColumnOrdinals.length();
+  if (numColumnsToCheck == 0 || batch->numRows() == 0) {
+    return -1;
+  }
+
+  auto veloxBatch = 
VeloxColumnarBatch::from(runtime->memoryManager()->getLeafMemoryPool().get(), 
batch);
+  auto rowVector = veloxBatch->getRowVector();
+  const auto rowCount = rowVector->size();
+  const auto columnCount = static_cast<int32_t>(rowVector->childrenSize());
+
+  for (int i = 0; i < numColumnsToCheck; ++i) {
+    const auto ordinal = safeColumnOrdinals.elems()[i];
+    GLUTEN_CHECK(ordinal >= 0 && ordinal < columnCount, "Column ordinal 
overflow when checking Delta invariant");
+  }
+
+  if (rowVector->mayHaveNulls()) {
+    const auto nullCount = rowVector->getNullCount();
+    if (nullCount.has_value()) {
+      if (*nullCount > 0) {
+        return 0;
+      }
+    } else {
+      for (auto row = 0; row < rowCount; ++row) {
+        if (rowVector->isNullAt(row)) {
+          return 0;
+        }
+      }
+    }
+  }
+
+  for (int i = 0; i < numColumnsToCheck; ++i) {
+    const auto ordinal = safeColumnOrdinals.elems()[i];
+    const auto child = rowVector->childAt(ordinal);
+    VELOX_CHECK_NOT_NULL(child, "Column vector is null when checking Delta 
invariant.");
+    if (!child->mayHaveNulls()) {
+      continue;
+    }
+    const auto nullCount = child->getNullCount();
+    if (nullCount.has_value()) {
+      if (*nullCount > 0) {
+        return i;
+      }
+      continue;
+    }
+    for (auto row = 0; row < rowCount; ++row) {
+      if (child->isNullAt(row)) {
+        return i;
+      }
+    }

Review Comment:
   **The advertised fast path rarely fires, and this fallback is the expensive 
one.**
   
   `nullCount_` is `std::nullopt` for essentially everything a Velox pipeline 
hands us: `wrapInDictionary()` leaves it unset, and `ensureWritable()` / 
`prepareForReuse()` reset it to `nullopt`. So in practice this per-row loop 
*is* the common path, not the "near-free metadata read" described in the PR 
body — a virtual `isNullAt()` per row per constrained column, plus dictionary 
indirection for dictionary-encoded columns (which is exactly what you get 
reading Parquet/Delta sources). That probably explains why the benchmark came 
out neutral.
   
   Decoding once and counting bits is both sound (fixes the previous comment) 
and considerably faster:
   
   ```cpp
   DecodedVector decoded(*child, allRows);
   if (decoded.mayHaveNulls() &&
       bits::countNulls(decoded.nulls(&allRows), 0, rowCount) > 0) {
     return i;
   }
   ```
   
   (`DecodedVector::nulls(const SelectivityVector*)` — 
`velox/vector/DecodedVector.h:146`; `bits::countNulls` — 
`velox/common/base/Nulls.h:59`.) Or, if you'd rather not build a 
`DecodedVector`, `bits::countNulls(child->rawNulls(), 0, rowCount)` for the 
flat case. Either way it's a word-at-a-time scan instead of per-row virtual 
dispatch, and it removes the need to trust `getNullCount()` at all.



##########
cpp/velox/jni/VeloxJniWrapper.cc:
##########
@@ -717,6 +717,72 @@ JNIEXPORT jlong JNICALL 
Java_org_apache_gluten_columnarbatch_VeloxColumnarBatchJ
   JNI_METHOD_END(kInvalidObjectHandle)
 }
 
+JNIEXPORT jint JNICALL 
Java_org_apache_gluten_columnarbatch_VeloxColumnarBatchJniWrapper_firstNullColumnIndex(
 // NOLINT
+    JNIEnv* env,
+    jobject wrapper,
+    jlong veloxBatchHandle,
+    jintArray columnOrdinals) {
+  JNI_METHOD_START
+  auto ctx = getRuntime(env, wrapper);
+  auto runtime = dynamic_cast<VeloxRuntime*>(ctx);
+  auto batch = ObjectStore::retrieve<ColumnarBatch>(veloxBatchHandle);
+  auto safeColumnOrdinals = getIntArrayElementsSafe(env, columnOrdinals);
+
+  const auto numColumnsToCheck = safeColumnOrdinals.length();
+  if (numColumnsToCheck == 0 || batch->numRows() == 0) {
+    return -1;
+  }
+
+  auto veloxBatch = 
VeloxColumnarBatch::from(runtime->memoryManager()->getLeafMemoryPool().get(), 
batch);
+  auto rowVector = veloxBatch->getRowVector();
+  const auto rowCount = rowVector->size();
+  const auto columnCount = static_cast<int32_t>(rowVector->childrenSize());
+
+  for (int i = 0; i < numColumnsToCheck; ++i) {
+    const auto ordinal = safeColumnOrdinals.elems()[i];
+    GLUTEN_CHECK(ordinal >= 0 && ordinal < columnCount, "Column ordinal 
overflow when checking Delta invariant");
+  }
+
+  if (rowVector->mayHaveNulls()) {
+    const auto nullCount = rowVector->getNullCount();
+    if (nullCount.has_value()) {
+      if (*nullCount > 0) {
+        return 0;
+      }
+    } else {
+      for (auto row = 0; row < rowCount; ++row) {
+        if (rowVector->isNullAt(row)) {
+          return 0;
+        }
+      }
+    }
+  }
+
+  for (int i = 0; i < numColumnsToCheck; ++i) {
+    const auto ordinal = safeColumnOrdinals.elems()[i];
+    const auto child = rowVector->childAt(ordinal);
+    VELOX_CHECK_NOT_NULL(child, "Column vector is null when checking Delta 
invariant.");
+    if (!child->mayHaveNulls()) {
+      continue;
+    }
+    const auto nullCount = child->getNullCount();
+    if (nullCount.has_value()) {
+      if (*nullCount > 0) {
+        return i;
+      }
+      continue;
+    }

Review Comment:
   **Correctness risk: `getNullCount()` isn't a sound gate for a NOT NULL 
check.**
   
   Both early-outs in this function (the `rowVector` one here, the `child` one 
below) treat `getNullCount() == 0` as "no nulls" and skip the scan. Two 
problems, checked against the Velox revision we vendor:
   
   1. **Wrapped vectors.** `DictionaryVector::mayHaveNulls()` is overridden to 
recurse into the base values (`velox/vector/DictionaryVector.h:72-81`), but 
`getNullCount()` is *not* — it returns `BaseVector::nullCount_`, i.e. only the 
wrapper's own nulls buffer (`velox/vector/BaseVector.h:198`). A dictionary 
whose wrapper nulls were reset (`setNulls(nullptr)` / `clearNulls()` set 
`nullCount_ = 0`, see `velox/vector/BaseVector.cpp:620,642,660`) but whose base 
values contain nulls at referenced indices passes `mayHaveNulls()` and then 
takes the `continue` branch.
   2. **Staleness.** `nullCount_` is a cached data-dependent statistic, not a 
maintained invariant. `setNull()` does not invalidate it 
(`BaseVector.h:416-423`); only `resetDataDependentFlags()` does, and that is 
called just from `ensureWritable()` / `prepareForReuse()` 
(`BaseVector.h:980-993`).
   
   A wrapper-local or stale `0` means we `continue` and **silently write a NULL 
into a NOT NULL column** — no exception, table invariant broken, nothing 
downstream catches it. The opposite (a stale `> 0`) fails a valid write with a 
spurious `DeltaInvariantViolationException`. Velox itself only uses the 
`mayHaveNulls() || getNullCount() == 0` shortcut on flat vectors it owns 
(`velox/vector/arrow/Bridge.cpp:510`), where it is an optimization rather than 
a correctness gate.
   
   Since this is enforcing a table constraint, I don't think we can trust the 
cached count. Either gate the early-out on `child->isFlatEncoding()`, or drop 
it entirely and always scan — per the next comment, the scan can be made 
cheaper than it is today anyway.



##########
backends-velox/src-delta33/main/scala/org/apache/spark/sql/delta/constraints/GlutenDeltaInvariantChecker.scala:
##########
@@ -0,0 +1,107 @@
+/*
+ * 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.spark.sql.delta.constraints

Review Comment:
   This file is byte-identical to 
`backends-velox/src-delta40/main/scala/org/apache/spark/sql/delta/constraints/GlutenDeltaInvariantChecker.scala`
 — I diffed the two copies and there isn't a single character of difference.
   
   `backends-velox/src-delta/main/scala` is already registered as a shared 
source root across Delta versions (`pom.xml:1699`), and everything this file 
touches (`Constraints.NotNull`, `SchemaUtils.DELTA_COL_RESOLVER`, 
`DeltaInvariantViolationException`) is identical on 3.3 and 4.0 — otherwise the 
two copies couldn't be identical. Could this live in `src-delta` once instead 
of twice?
   
   Worth doing while the file is new: the delta33/delta40 copies of 
`GlutenOptimisticTransaction` have already drifted 
(`DeltaInvariantCheckerExec(spark, ...)` vs `DeltaInvariantCheckerExec(...)`), 
and a duplicated checker will drift the same way — except here a divergence 
means one Delta version enforces constraints differently from the other.



##########
backends-velox/src-delta33/main/scala/org/apache/spark/sql/delta/files/GlutenDeltaFileFormatWriter.scala:
##########
@@ -225,7 +233,8 @@ object GlutenDeltaFileFormatWriter extends LoggingShims {
         partitionColumns,
         sortColumns,
         orderingMatched,
-        isNativeWritable
+        isNativeWritable,
+        nativeInvariantChecker

Review Comment:
   **The checker is silently dropped in the sibling write branch.**
   
   This call is the `else` of `if (writeFilesOpt.isDefined)` (L204). The 
`WriteFilesExec` branch calls the other `executeWrite(sparkSession, plan, 
writeSpec, job)` overload, which has no `nativeInvariantChecker` parameter — so 
if we ever reach it holding a checker, **no** NOT NULL constraint is enforced 
anywhere: the plan no longer carries `DeltaInvariantCheckerExec` either, and 
the write silently succeeds with invalid data.
   
   Today that's unreachable — `GlutenOptimisticTransaction` only builds the 
checker when `V1WritesUtils.getWriteFilesOpt(empty2NullPlan).isEmpty`, and 
nothing applied between there and here (`Transitions.toBatchPlan`, 
`GlutenDeltaOptimizedWriterExec`) can introduce a `WriteFilesExec`. But that's 
an invariant spread across two files with nothing enforcing it, and the failure 
mode is silent data corruption rather than an error.
   
   Could we make it fail loudly — e.g. `require(nativeInvariantChecker.isEmpty, 
"...")` in the `writeFilesOpt.isDefined` branch, or thread the checker through 
that overload as well? Everything else in this PR fails safe; this is the one 
path that doesn't.



##########
backends-velox/src-delta33/main/scala/org/apache/spark/sql/delta/constraints/GlutenDeltaInvariantChecker.scala:
##########
@@ -0,0 +1,107 @@
+/*
+ * 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.spark.sql.delta.constraints
+
+import org.apache.gluten.columnarbatch.VeloxColumnarBatches
+import org.apache.gluten.execution.{PlaceholderRow, TerminalRow}
+
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.Attribute
+import org.apache.spark.sql.delta.constraints.Constraints.NotNull
+import org.apache.spark.sql.delta.schema.{DeltaInvariantViolationException, 
SchemaUtils}
+import org.apache.spark.sql.vectorized.ColumnarBatch
+
+/**
+ * Native write-time invariant checker for constraints that can be validated 
without converting
+ * Velox batches back to Spark rows.
+ *
+ * Violations are detected natively from batch null metadata (no per-row 
expression evaluation, no
+ * extra operator in the write plan) but thrown on the JVM side, preserving 
Delta's typed
+ * [[DeltaInvariantViolationException]] error contract. Offloading Delta's 
CheckDeltaInvariant
+ * expression to Velox instead would lose that contract: exceptions raised 
inside Velox surface only
+ * as GlutenException with a message string. CHECK constraints and nested NOT 
NULL constraints fall
+ * back to Delta's row-based DeltaInvariantCheckerExec.
+ */
+private[delta] case class GlutenDeltaInvariantChecker private (
+    notNullConstraints: Seq[(Int, NotNull)])
+  extends Serializable {
+
+  @transient private lazy val columnOrdinals: Array[Int] =
+    notNullConstraints.map(_._1).toArray
+
+  def wrap(rows: Iterator[InternalRow]): Iterator[InternalRow] = {
+    rows.map {
+      row =>
+        check(row)
+        row
+    }
+  }
+
+  private def check(row: InternalRow): Unit = row match {
+    case _: PlaceholderRow =>
+    case terminal: TerminalRow => check(terminal.batch())
+    case other => checkRow(other)
+  }
+
+  private def check(batch: ColumnarBatch): Unit = {
+    val failedConstraintIndex = 
VeloxColumnarBatches.firstNullColumnIndex(batch, columnOrdinals)
+    if (failedConstraintIndex >= 0) {
+      throw 
DeltaInvariantViolationException(notNullConstraints(failedConstraintIndex)._2)
+    }
+  }
+
+  private def checkRow(row: InternalRow): Unit = {
+    var i = 0
+    while (i < notNullConstraints.size) {
+      val (ordinal, constraint) = notNullConstraints(i)
+      if (row.isNullAt(ordinal)) {
+        throw DeltaInvariantViolationException(constraint)
+      }
+      i += 1
+    }
+  }

Review Comment:
   `notNullConstraints` is a `Seq` — a `List` in practice, since `create` 
builds it with `collect`/`map` — so both `.size` and `apply(i)` are O(n), and 
`.size` is re-evaluated on every iteration, making this O(n^2) per row. Small 
`n`, but this runs for every row of every non-offloaded write, so it seems 
worth precomputing.
   
   `columnOrdinals` is already materialized as an `Array` above; a parallel 
array of constraints would make this allocation- and traversal-free:
   
   ```scala
   @transient private lazy val constraints: Array[NotNull] =
     notNullConstraints.map(_._2).toArray
   
   private def checkRow(row: InternalRow): Unit = {
     var i = 0
     while (i < columnOrdinals.length) {
       if (row.isNullAt(columnOrdinals(i))) {
         throw DeltaInvariantViolationException(constraints(i))
       }
       i += 1
     }
   }
   ```
   
   That would also let the batch path at L63 use 
`constraints(failedConstraintIndex)` instead of 
`notNullConstraints(failedConstraintIndex)._2`, which pays the same O(n) 
`apply` cost.



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

To unsubscribe, e-mail: [email protected]

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


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

Reply via email to