This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 566efb81eb feat(workflow-operator): report scan rows that fail schema
parsing (#6947)
566efb81eb is described below
commit 566efb81eb33cdcaab1a759ee26951c5514f1b77
Author: Eugene Gu <[email protected]>
AuthorDate: Fri Oct 2 00:23:25 2026 +0000
feat(workflow-operator): report scan rows that fail schema parsing (#6947)
### What changes were proposed in this PR?
CSV/JSONL scan sources infer each column's type from only the first
`INFER_READ_LIMIT` (=100) rows. When a later row held a value that did
not parse as the inferred type, the execs caught the error, mapped the
row to `null`/`None`, and filtered it out — silently dropping the entire
row with no error, warning, count, or log. Every downstream count,
aggregate, and join then ran on a silently truncated dataset. The bug is
the silence, not the rejection.
Following the consensus in discussion #6324 (skip + surface, rather than
the fail-fast approach originally proposed in #6323), a scan now still
skips the unparsable row — the run completes and existing workflows keep
working — but each skipped row is surfaced to the user as a console
warning naming the row number, offending value, column, and expected
type, for example:
> WARNING: skipped row 150 — value '55.5' in column 'age' cannot be read
as INTEGER. Column types were inferred from an initial sample of 100
rows, and this value does not match.
<img width="1137" height="788" alt="Screenshot 2026-08-05 at 2 10 49 PM"
src="https://github.com/user-attachments/assets/171f741e-b11b-4567-8c62-d6939b9faa58"
/>
Changes:
- Add `ScanRowParseError`, which builds the per-row warning by
re-parsing the failing row's fields to identify the offending column,
with a generic fallback message when no single column can be identified
(e.g. a malformed JSON line). The message names the inference sample
size actually used — `INFER_READ_LIMIT` capped by the user-set row limit
(`ScanSourceOpDesc.inferSampleSize`) — rather than a hard-coded 100.
- Add `SkippedRowReporter`, shared by all scan variants: it reports the
first 100 skipped rows individually and then appends a single summary
line carrying the true total, so a heavily malformed file cannot flood
the console or exhaust memory.
- Record-and-skip in `CSVScanSourceOpExec`, `JSONLScanSourceOpExec`,
`ParallelCSVScanSourceOpExec`, and `CSVOldScanSourceOpExec`. The
legitimate null paths (an empty cell parsed to null, a blank/all-null
line, an exhausted block) are untouched and are not reported. Row
numbers: CSV reuses its existing counter, JSONL and csvOld gain absolute
line/data-row numbers (a configured `offset` is added back in, so the
reported number always matches the file), and ParallelCSV reports none
because it is byte-partitioned across workers.
- Add `OperatorExecutor.getWarnings` (defaults to empty), a generic
non-fatal warning channel that any executor can opt into.
- Emit the warnings at `FinalizeExecutor` in `DataProcessor` as PRINT
console messages via the new `ErrorUtils.mkPrintConsoleMessage`. The
titles keep the `WARNING: ` prefix the UI keys on, and, unlike the
executor exception path, this does not pause the run.
Inference logic is unchanged (the 100-row sampling is untouched). Scala
only — no UI change.
Behavior-change note for reviewers: workflows that previously ran to
completion while silently discarding malformed rows now still complete,
but they surface a console warning for each skipped row (capped at 100
detailed entries plus a summary). One case worth calling out: a csvOld
scan over a file with an empty cell in a numeric column previously
dropped that row silently and will now report it — this is exactly the
silent loss this PR exists to surface, not a regression.
Known limitations: warnings are collected in the executor and emitted
only when it finalizes — a run that fails or is killed mid-scan does not
surface them, and nothing is shown while the scan is still running.
Streaming emission as rows are skipped is possible follow-up work; the
finalize-only design follows the consensus in #6324.
### Any related issues, documentation, discussions?
Closes #6279. Design discussion: #6324. This supersedes the fail-fast
approach in #6323, which will be closed in favor of this PR.
### How was this PR tested?
Scan-operator unit tests (skip-and-report naming row/value/column/type,
the 100-detail cap with its summary line, empty-cell and blank-line
non-reporting, the malformed-JSON fallback, absolute line numbers when
an `offset` is set, and the warning naming the actual inference sample
size when a row `limit` caps it):
```
sbt "WorkflowOperator/testOnly \
org.apache.texera.amber.operator.source.scan.csv.CSVScanSourceOpExecSpec \
org.apache.texera.amber.operator.source.scan.json.JSONLScanSourceOpExecSpec \
org.apache.texera.amber.operator.source.scan.csv.ParallelCSVScanSourceOpExecSpec
\
org.apache.texera.amber.operator.source.scan.csvOld.CSVOldScanSourceOpExecSpec"
```
Engine-side emission path (`DataProcessorSpec`): a new test asserts the
warnings reach the coordinator as PRINT console messages at finalize and
that the run is not paused.
Full local gate (lint + format + backend unit tests):
```
sbt "scalafixAll --check" # pass
sbt scalafmtCheckAll # pass
AMBER_TEST_FILTER=skip-integration sbt WorkflowExecutionService/test #
pass
sbt WorkflowOperator/test WorkflowCore/test WorkflowCompilingService/test
# pass
```
Manual, in the Texera UI, over CSV and JSONL datasets derived from a
real 403-row file whose `age` column is inferred as INTEGER: verified a
single bad row (skipped, one warning naming row/value/column/type), many
bad rows (100 detailed warnings plus a summary line), the clean file
(all rows, no warning), an empty cell (kept as a null row, no warning),
and a malformed JSONL line (skipped, generic fallback warning). In every
case the scan completed instead of failing.
Rebased onto main after #7247 (the JSONL worker-slice offset fix) landed
### Was this PR authored or co-authored using generative AI tooling?
Co-authored by: Claude Code (Fable 5)
---------
Signed-off-by: Eugene Gu <[email protected]>
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---
.../engine/architecture/worker/DataProcessor.scala | 11 ++-
.../org/apache/texera/amber/error/ErrorUtils.scala | 18 +++-
.../architecture/worker/DataProcessorSpec.scala | 85 +++++++++++++++++++
.../amber/core/executor/OperatorExecutor.scala | 5 ++
.../operator/source/scan/ScanRowParseError.scala | 99 ++++++++++++++++++++++
.../operator/source/scan/ScanSourceOpDesc.scala | 6 ++
.../operator/source/scan/SkippedRowReporter.scala | 66 +++++++++++++++
.../source/scan/csv/CSVScanSourceOpDesc.scala | 2 +-
.../source/scan/csv/CSVScanSourceOpExec.scala | 13 ++-
.../scan/csv/ParallelCSVScanSourceOpDesc.scala | 2 +-
.../scan/csv/ParallelCSVScanSourceOpExec.scala | 26 +++++-
.../scan/csvOld/CSVOldScanSourceOpDesc.scala | 2 +-
.../scan/csvOld/CSVOldScanSourceOpExec.scala | 47 +++++++---
.../source/scan/json/JSONLScanSourceOpDesc.scala | 2 +-
.../source/scan/json/JSONLScanSourceOpExec.scala | 47 +++++++---
.../source/scan/ScanSourceOpDescSpec.scala | 1 +
.../source/scan/csv/CSVScanSourceOpExecSpec.scala | 64 +++++++++++++-
.../scan/csv/ParallelCSVScanSourceOpExecSpec.scala | 21 ++++-
.../scan/csvOld/CSVOldScanSourceOpExecSpec.scala | 30 +++++++
.../scan/json/JSONLScanSourceOpExecSpec.scala | 40 +++++++++
20 files changed, 550 insertions(+), 37 deletions(-)
diff --git
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessor.scala
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessor.scala
index 618fa73427..bbe1ed84e7 100644
---
a/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessor.scala
+++
b/amber/src/main/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessor.scala
@@ -58,7 +58,7 @@ import
org.apache.texera.amber.engine.architecture.worker.statistics.WorkerStati
import org.apache.texera.amber.engine.common.ambermessage._
import org.apache.texera.amber.engine.common.statetransition.WorkerStateManager
import org.apache.texera.amber.engine.common.virtualidentity.util.COORDINATOR
-import org.apache.texera.amber.error.ErrorUtils.{mkConsoleMessage, safely}
+import org.apache.texera.amber.error.ErrorUtils.{mkConsoleMessage,
mkPrintConsoleMessage, safely}
import java.util.concurrent.LinkedBlockingQueue
@@ -167,6 +167,15 @@ class DataProcessor(
outputTuple match {
case FinalizeExecutor() =>
sendECMToDataChannels(METHOD_END_CHANNEL, PORT_ALIGNMENT)
+ // Surface non-fatal warnings the executor accumulated (e.g. rows a
scan
+ // skipped) as console messages. Unlike handleExecutorException, this
must
+ // not pause the run — the messages are emitted and processing
continues.
+ executor.getWarnings.foreach { warning =>
+ asyncRPCClient.coordinatorInterface.consoleMessageTriggered(
+ ConsoleMessageTriggeredRequest(mkPrintConsoleMessage(actorId,
warning)),
+ asyncRPCClient.mkContext(COORDINATOR)
+ )
+ }
// Send Completed signal to worker actor.
executor.close()
adaptiveBatchingMonitor.stopAdaptiveBatching()
diff --git
a/amber/src/main/scala/org/apache/texera/amber/error/ErrorUtils.scala
b/amber/src/main/scala/org/apache/texera/amber/error/ErrorUtils.scala
index 139f7b41a7..c1ac5090dc 100644
--- a/amber/src/main/scala/org/apache/texera/amber/error/ErrorUtils.scala
+++ b/amber/src/main/scala/org/apache/texera/amber/error/ErrorUtils.scala
@@ -22,7 +22,10 @@ package org.apache.texera.amber.error
import com.google.protobuf.timestamp.Timestamp
import org.apache.texera.amber.core.virtualidentity.ActorVirtualIdentity
import
org.apache.texera.amber.engine.architecture.rpc.controlcommands.ConsoleMessage
-import
org.apache.texera.amber.engine.architecture.rpc.controlcommands.ConsoleMessageType.ERROR
+import
org.apache.texera.amber.engine.architecture.rpc.controlcommands.ConsoleMessageType.{
+ ERROR,
+ PRINT
+}
import
org.apache.texera.amber.engine.architecture.rpc.controlreturns.{ControlError,
ErrorLanguage}
import org.apache.texera.amber.util.{StackTraceUtils, VirtualIdentityUtils}
@@ -57,6 +60,19 @@ object ErrorUtils {
ConsoleMessage(actorId.name, Timestamp(Instant.now), ERROR, source, title,
message)
}
+ /**
+ * Builds a PRINT console message carrying a plain informational/warning
line (no
+ * stack trace). A title starting with "WARNING: " is collected into the
`warnings`
+ * field of the sync-execution API (see SyncExecutionResource), so callers
should
+ * keep that prefix intact.
+ */
+ def mkPrintConsoleMessage(
+ actorId: ActorVirtualIdentity,
+ title: String,
+ source: String = ""
+ ): ConsoleMessage =
+ ConsoleMessage(actorId.name, Timestamp(Instant.now), PRINT, source, title,
"")
+
def mkControlError(err: Throwable): ControlError = {
// Format each stack trace element with "at " prefix
val stacktrace = err.getStackTrace.map(element => s"at
${element}").mkString("\n")
diff --git
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessorSpec.scala
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessorSpec.scala
index 53f167c4ea..ab1b0ad0b3 100644
---
a/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessorSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/amber/engine/architecture/worker/DataProcessorSpec.scala
@@ -30,6 +30,9 @@ import
org.apache.texera.amber.engine.architecture.logreplay.{ReplayLogManager,
import
org.apache.texera.amber.engine.architecture.messaginglayer.WorkerTimerService
import org.apache.texera.amber.engine.architecture.rpc.controlcommands.{
AsyncRPCContext,
+ ConsoleMessageTriggeredRequest,
+ ConsoleMessageType,
+ ControlInvocation => ControlInvocationMessage,
EmbeddedControlMessage,
EmbeddedControlMessageType,
EmptyRequest
@@ -143,6 +146,7 @@ class DataProcessorSpec extends AnyFlatSpec with
MockFactory with Matchers with
)
(adaptiveBatchingMonitor.startAdaptiveBatching
_).expects().anyNumberOfTimes()
(adaptiveBatchingMonitor.stopAdaptiveBatching _).expects().once()
+ (() => executor.getWarnings).expects().returning(Seq.empty)
(executor.close _).expects().once()
(outputHandler.apply _).expects(*).anyNumberOfTimes()
dp.inputManager.addPort(inputPortId, schema, List.empty, List.empty)
@@ -237,6 +241,7 @@ class DataProcessorSpec extends AnyFlatSpec with
MockFactory with Matchers with
dp.continueDataProcessing()
}
(adaptiveBatchingMonitor.stopAdaptiveBatching _).expects().once()
+ (() => executor.getWarnings).expects().returning(Seq.empty)
(executor.close _).expects().once()
dp.processECM(
ChannelIdentity(senderWorkerId, testWorkerId, isControl = false),
@@ -458,4 +463,84 @@ class DataProcessorSpec extends AnyFlatSpec with
MockFactory with Matchers with
dp.outputManager.hasUnfinishedOutput shouldBe false
}
+ "data processor" should "emit executor warnings as PRINT console messages at
finalize without pausing" in {
+ val dp = mkDataProcessor
+ dp.executor = executor
+ dp.stateManager.transitTo(READY)
+ val emitted = scala.collection.mutable.ArrayBuffer[WorkflowFIFOMessage]()
+ (outputHandler.apply _)
+ .expects(*)
+ .onCall { (m: Either[MainThreadDelegateMessage, WorkflowFIFOMessage]) =>
+ m.foreach(emitted += _); ()
+ }
+ .anyNumberOfTimes()
+ (executor.open _).expects().once()
+ (
+ (
+ tuple: Tuple,
+ input: Int
+ ) => executor.processTupleMultiPort(tuple, input)
+ )
+ .expects(tuples.head, 0)
+ (
+ (
+ input: Int
+ ) => executor.produceStateOnFinish(input)
+ )
+ .expects(0)
+ .returning(None)
+ (
+ (
+ input: Int
+ ) => executor.onFinishMultiPort(input)
+ )
+ .expects(0)
+ val warningText = "WARNING: skipped row 3 - value 'oops' cannot be read as
INTEGER"
+ (() => executor.getWarnings).expects().returning(Seq(warningText))
+ (adaptiveBatchingMonitor.startAdaptiveBatching
_).expects().anyNumberOfTimes()
+ (adaptiveBatchingMonitor.stopAdaptiveBatching _).expects().once()
+ (executor.close _).expects().once()
+ dp.inputManager.addPort(inputPortId, schema, List.empty, List.empty)
+ dp.inputGateway
+ .getChannel(ChannelIdentity(senderWorkerId, testWorkerId, isControl =
false))
+ .setPortId(inputPortId)
+ dp.outputManager.addPort(outputPortId, schema, None)
+ dp.processDCM(
+ ChannelIdentity(COORDINATOR, testWorkerId, isControl = true),
+ ControlInvocation(
+ METHOD_OPEN_EXECUTOR,
+ EmptyRequest(),
+ AsyncRPCContext(COORDINATOR, testWorkerId),
+ 0
+ )
+ )
+ dp.processDataPayload(
+ ChannelIdentity(senderWorkerId, testWorkerId, isControl = false),
+ DataFrame(Array(tuples.head))
+ )
+ while (dp.inputManager.hasUnfinishedInput ||
dp.outputManager.hasUnfinishedOutput) {
+ dp.continueDataProcessing()
+ }
+ dp.processECM(
+ ChannelIdentity(senderWorkerId, testWorkerId, isControl = false),
+ endChannelPayload,
+ logManager
+ )
+ while (dp.inputManager.hasUnfinishedInput ||
dp.outputManager.hasUnfinishedOutput) {
+ dp.continueDataProcessing()
+ }
+
+ // The warning must reach the coordinator as a PRINT console message
carrying the
+ // executor's warning line as its title, and it must not pause the run.
+ val consoleMessages = emitted.flatMap(_.payload match {
+ case ControlInvocationMessage(_, req: ConsoleMessageTriggeredRequest, _,
_) =>
+ Some(req.consoleMessage)
+ case _ => None
+ })
+ assert(
+ consoleMessages.exists(m => m.msgType == ConsoleMessageType.PRINT &&
m.title == warningText)
+ )
+ dp.pauseManager.isPaused shouldBe false
+ }
+
}
diff --git
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/executor/OperatorExecutor.scala
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/executor/OperatorExecutor.scala
index 9837213abb..86412fae3b 100644
---
a/common/workflow-core/src/main/scala/org/apache/texera/amber/core/executor/OperatorExecutor.scala
+++
b/common/workflow-core/src/main/scala/org/apache/texera/amber/core/executor/OperatorExecutor.scala
@@ -48,6 +48,11 @@ trait OperatorExecutor {
def onFinish(port: Int): Iterator[TupleLike] = Iterator.empty
+ /** Non-fatal warnings the worker surfaces as console messages once the
executor
+ * completes (e.g. scan rows skipped for not matching the inferred schema).
+ */
+ def getWarnings: Seq[String] = Seq.empty
+
def close(): Unit = {}
}
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/ScanRowParseError.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/ScanRowParseError.scala
new file mode 100644
index 0000000000..f2aa2c0839
--- /dev/null
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/ScanRowParseError.scala
@@ -0,0 +1,99 @@
+/*
+ * 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.texera.amber.operator.source.scan
+
+import org.apache.texera.amber.core.tuple.{Attribute, AttributeTypeUtils,
Schema}
+
+import scala.util.Try
+
+/**
+ * Builds actionable, per-row warnings for scan sources when a row's values
do not
+ * parse into the inferred schema. The scan skips such a row (rather than
failing the
+ * whole run) and surfaces one of these warnings so the user knows exactly
which row
+ * was dropped, and why. Messages lead with the essential facts (row number,
offending
+ * value, column, expected type) because the console title line truncates.
+ */
+object ScanRowParseError {
+
+ /** Prefix that makes a PRINT console message surface as a warning in the
UI. */
+ private val WarningPrefix = "WARNING: "
+
+ /**
+ * Builds the warning for a single skipped row.
+ *
+ * The failing column is identified by re-parsing each raw field
individually; this
+ * only runs on a row that has already failed, so the extra cost is
irrelevant. If no
+ * single column can be identified (e.g. a malformed JSON line or a
structural row
+ * error), a generic fallback carrying the original reason is used instead.
+ *
+ * @param rawFields raw field values of the failing row, in schema
order
+ * (may be empty or shorter than the schema)
+ * @param schema the inferred schema of the scan
+ * @param inferSampleSize cap on the rows read for type inference
+ * (desc.inferSampleSize); a shorter file uses fewer
+ * @param rowNumber 1-based row number, if cheaply available
+ * @param cause the original parse exception
+ */
+ def skipWarning(
+ rawFields: Seq[Any],
+ schema: Schema,
+ inferSampleSize: Int,
+ rowNumber: Option[Int],
+ cause: Throwable
+ ): String = {
+ val where = rowNumber.map(n => s"row $n").getOrElse("a row")
+ findFailingColumn(rawFields, schema) match {
+ case Some((attribute, value)) =>
+ s"${WarningPrefix}skipped $where — value '$value' in column
'${attribute.getName}' " +
+ s"cannot be read as ${attribute.getType.name()}. " +
+ s"Column types were inferred from an initial sample of up to
$inferSampleSize rows, " +
+ "and this value does not match."
+ case None =>
+ val reason =
Option(cause.getMessage).getOrElse(cause.getClass.getSimpleName)
+ s"${WarningPrefix}skipped $where — could not be parsed into the
inferred schema: $reason. " +
+ s"Column types were inferred from an initial sample of up to
$inferSampleSize rows."
+ }
+ }
+
+ /**
+ * Summary warning appended when the per-row detail list is capped, so the
user still
+ * learns the true total even though only the first rows are listed
individually.
+ */
+ def moreSkipped(hidden: Int, total: Int): String =
+ s"${WarningPrefix}...and $hidden more row(s) skipped ($total total). " +
+ "Fix the values in the file, or clean the data before scanning."
+
+ /**
+ * Re-parses each raw field against its attribute type; the first failure
identifies
+ * the offending column. Missing trailing fields are ignored (equivalent to
treating
+ * them as null for failure detection) and thus never fail.
+ */
+ private def findFailingColumn(
+ rawFields: Seq[Any],
+ schema: Schema
+ ): Option[(Attribute, Any)] = {
+ schema.getAttributes.iterator
+ .zip(rawFields.iterator)
+ .find {
+ case (attribute, value) =>
+ Try(AttributeTypeUtils.parseField(value,
attribute.getType)).isFailure
+ }
+ }
+}
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/ScanSourceOpDesc.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/ScanSourceOpDesc.scala
index b0a6ee5e20..20e59f751e 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/ScanSourceOpDesc.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/ScanSourceOpDesc.scala
@@ -80,6 +80,12 @@ abstract class ScanSourceOpDesc extends
SourceOperatorDescriptor {
@JsonIgnore
def windowLimit: Option[Int] = limit.map(_.max(0))
+ /** Rows actually used for type inference: INFER_READ_LIMIT, capped by
`windowLimit`
+ * when smaller.
+ */
+ @JsonIgnore
+ def inferSampleSize: Int =
windowLimit.getOrElse(INFER_READ_LIMIT).min(INFER_READ_LIMIT)
+
override def sourceSchema(): Schema = null
override def operatorInfo: OperatorInfo = {
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/SkippedRowReporter.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/SkippedRowReporter.scala
new file mode 100644
index 0000000000..f84ed9b6e7
--- /dev/null
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/SkippedRowReporter.scala
@@ -0,0 +1,66 @@
+/*
+ * 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.texera.amber.operator.source.scan
+
+import scala.collection.mutable.ArrayBuffer
+
+/**
+ * Accumulates the warnings for rows a scan source skipped because they did
not parse
+ * into the inferred schema. Shared by all scan variants (CSV, JSONL,
ParallelCSV,
+ * csvOld) so they surface skipped rows identically.
+ *
+ * Only the first `maxDetailed` rows are listed individually; the total is
always
+ * tracked, and when it exceeds the cap a single summary line reports how
many more
+ * were skipped. This keeps a pathological file (e.g. hundreds of thousands
of bad
+ * rows) from exhausting memory or flooding the console.
+ *
+ * @param maxDetailed maximum number of rows reported individually
+ */
+class SkippedRowReporter(maxDetailed: Int =
SkippedRowReporter.DefaultMaxDetailed) {
+
+ private val detailed = ArrayBuffer.empty[String]
+ private var total = 0
+
+ /** Records one skipped row. `warning` is evaluated lazily so building the
(relatively
+ * expensive) per-row message is skipped once the detail cap is reached.
+ */
+ def record(warning: => String): Unit = {
+ total += 1
+ if (detailed.size < maxDetailed) detailed += warning
+ }
+
+ /** Total number of rows skipped so far. */
+ def count: Int = total
+
+ /**
+ * The warnings to surface: one line per detailed row, plus a trailing
summary line
+ * when more rows were skipped than the cap allows. Empty when nothing was
skipped.
+ */
+ def warnings: Seq[String] = {
+ if (total == 0) Seq.empty
+ else if (total > detailed.size)
+ detailed.toSeq :+ ScanRowParseError.moreSkipped(total - detailed.size,
total)
+ else detailed.toSeq
+ }
+}
+
+object SkippedRowReporter {
+ val DefaultMaxDetailed: Int = 100
+}
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpDesc.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpDesc.scala
index 3ec9bf9834..38a3d18736 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpDesc.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpDesc.scala
@@ -115,7 +115,7 @@ class CSVScanSourceOpDesc extends ScanSourceOpDesc {
parser.beginParsing(inputReader)
var data: Array[Array[String]] = Array()
- val readLimit =
windowLimit.getOrElse(INFER_READ_LIMIT).min(INFER_READ_LIMIT)
+ val readLimit = inferSampleSize
for (_ <- 0 until readLimit) {
val row = CSVScanSourceOpExec.parseNextRow(parser, maxColumns)
if (row != null) {
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExec.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExec.scala
index 4fe6f72318..26155bbc69 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExec.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExec.scala
@@ -24,6 +24,7 @@ import com.univocity.parsers.csv.{CsvFormat, CsvParser,
CsvParserSettings}
import org.apache.texera.amber.core.executor.SourceOperatorExecutor
import org.apache.texera.amber.core.storage.DocumentFactory
import org.apache.texera.amber.core.tuple.{AttributeTypeUtils, Schema,
TupleLike}
+import org.apache.texera.amber.operator.source.scan.{ScanRowParseError,
SkippedRowReporter}
import org.apache.texera.amber.util.JSONUtils.objectMapper
import org.apache.texera.dao.SiteSettings
@@ -40,6 +41,9 @@ class CSVScanSourceOpExec private[csv] (descString: String)
extends SourceOperat
var numRowGenerated = 0
private var maxColumns: Int = CSVScanSourceOpExec.DEFAULT_MAX_COLUMNS
private val schema: Schema = desc.sourceSchema()
+ private val skippedRows = new SkippedRowReporter()
+
+ override def getWarnings: Seq[String] = skippedRows.warnings
override def produceTuple(): Iterator[TupleLike] = {
@@ -70,7 +74,14 @@ class CSVScanSourceOpExec private[csv] (descString: String)
extends SourceOperat
): _*
)
} catch {
- case _: Throwable => null
+ case e: Throwable =>
+ // Skip the unparsable row but surface it as a warning instead of
+ // dropping it silently. numRowGenerated is the 1-based data-row
number.
+ skippedRows.record(
+ ScanRowParseError
+ .skipWarning(row.toSeq, schema, desc.inferSampleSize,
Some(numRowGenerated), e)
+ )
+ null
}
})
.filter(t => t != null)
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpDesc.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpDesc.scala
index c011a76cdf..dda5cd3eec 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpDesc.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpDesc.scala
@@ -110,7 +110,7 @@ class ParallelCSVScanSourceOpDesc extends ScanSourceOpDesc {
val attributeTypeList: Array[AttributeType] = inferSchemaFromRows(
reader.iterator
- .take(windowLimit.getOrElse(INFER_READ_LIMIT).min(INFER_READ_LIMIT))
+ .take(inferSampleSize)
.map(seq => seq.toArray)
)
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpExec.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpExec.scala
index 1c9377e564..15017db41d 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpExec.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpExec.scala
@@ -23,6 +23,7 @@ import
org.apache.texera.amber.core.executor.SourceOperatorExecutor
import org.apache.texera.amber.core.storage.DocumentFactory
import org.apache.texera.amber.core.tuple.{Attribute, AttributeTypeUtils,
TupleLike}
import org.apache.texera.amber.operator.source.BufferedBlockReader
+import org.apache.texera.amber.operator.source.scan.{ScanRowParseError,
SkippedRowReporter}
import org.apache.texera.amber.util.JSONUtils.objectMapper
import org.tukaani.xz.SeekableFileInputStream
@@ -40,13 +41,17 @@ class ParallelCSVScanSourceOpExec private[csv] (
objectMapper.readValue(descString, classOf[ParallelCSVScanSourceOpDesc])
private var reader: BufferedBlockReader = _
private val schema = desc.sourceSchema()
+ private val skippedRows = new SkippedRowReporter()
+
+ override def getWarnings: Seq[String] = skippedRows.warnings
override def produceTuple(): Iterator[TupleLike] =
new Iterator[TupleLike]() {
override def hasNext: Boolean = reader.hasNext
override def next(): TupleLike = {
-
+ // hoisted so the catch block can report the raw values of a failing
row
+ var fields: Array[AnyRef] = null
try {
// obtain String representation of each field
// a null value will present if omit in between fields, e.g.,
['hello', null, 'world']
@@ -54,7 +59,7 @@ class ParallelCSVScanSourceOpExec private[csv] (
if (line == null) {
return null
}
- var fields: Array[AnyRef] = line.toArray
+ fields = line.toArray
if (fields == null || util.Arrays.stream(fields).noneMatch(s => s !=
null)) {
// discard tuple if it's null or it only contains null
@@ -81,7 +86,22 @@ class ParallelCSVScanSourceOpExec private[csv] (
)
TupleLike(ArraySeq.unsafeWrapArray(parsedFields): _*)
} catch {
- case _: Throwable => null
+ case e: Throwable =>
+ // Skip the unparsable row but surface it as a warning instead of
+ // dropping it silently. The legitimate silent skips above
(exhausted
+ // block, all-null/blank line) return early and never reach here.
+ // rowNumber is None: this scan is byte-partitioned across
workers, so a
+ // per-worker row count would not match the file's actual line
numbers.
+ skippedRows.record(
+ ScanRowParseError.skipWarning(
+ Option(fields).map(_.toSeq).getOrElse(Seq.empty),
+ schema,
+ desc.inferSampleSize,
+ None,
+ e
+ )
+ )
+ null
}
}
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpDesc.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpDesc.scala
index a9eff8534f..b8ec56e4df 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpDesc.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpDesc.scala
@@ -104,7 +104,7 @@ class CSVOldScanSourceOpDesc extends ScanSourceOpDesc {
val startOffset = windowOffset + (if (hasHeader) 1 else 0)
val endOffset =
- startOffset +
windowLimit.getOrElse(INFER_READ_LIMIT).min(INFER_READ_LIMIT)
+ startOffset + inferSampleSize
val attributeTypeList: Array[AttributeType] = inferSchemaFromRows(
reader.iterator
.slice(startOffset, endOffset)
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpExec.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpExec.scala
index 87ac8976e1..8f6535572a 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpExec.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpExec.scala
@@ -23,6 +23,7 @@ import com.github.tototoshi.csv.{CSVReader, DefaultCSVFormat}
import org.apache.texera.amber.core.executor.SourceOperatorExecutor
import org.apache.texera.amber.core.storage.DocumentFactory
import org.apache.texera.amber.core.tuple.{Attribute, AttributeTypeUtils,
Schema, TupleLike}
+import org.apache.texera.amber.operator.source.scan.{ScanRowParseError,
SkippedRowReporter}
import org.apache.texera.amber.util.JSONUtils.objectMapper
import java.net.URI
@@ -36,22 +37,40 @@ class CSVOldScanSourceOpExec private[csvOld] (
var reader: CSVReader = _
var rows: Iterator[Seq[String]] = _
val schema: Schema = desc.sourceSchema()
+ private val skippedRows = new SkippedRowReporter()
+
+ override def getWarnings: Seq[String] = skippedRows.warnings
+
override def produceTuple(): Iterator[TupleLike] = {
- val tuples = rows
- .map(fields =>
- try {
- val parsedFields: Array[Any] = AttributeTypeUtils.parseFields(
- fields.toArray,
- schema.getAttributes
- .map((attr: Attribute) => attr.getType)
- .toArray
- )
- TupleLike(ArraySeq.unsafeWrapArray(parsedFields): _*)
- } catch {
- case _: Throwable => null
- }
- )
+ val tuples = rows.zipWithIndex
+ .map {
+ case (fields, index) =>
+ try {
+ val parsedFields: Array[Any] = AttributeTypeUtils.parseFields(
+ fields.toArray,
+ schema.getAttributes
+ .map((attr: Attribute) => attr.getType)
+ .toArray
+ )
+ TupleLike(ArraySeq.unsafeWrapArray(parsedFields): _*)
+ } catch {
+ case e: Throwable =>
+ // Skip the unparsable row but surface it as a warning instead of
+ // dropping it silently. `rows` already dropped header and
offset, so
+ // the absolute 1-based data-row number adds the offset back.
+ skippedRows.record(
+ ScanRowParseError.skipWarning(
+ fields,
+ schema,
+ desc.inferSampleSize,
+ Some(desc.windowOffset + index + 1),
+ e
+ )
+ )
+ null
+ }
+ }
.filter(tuple => tuple != null)
if (desc.windowLimit.isDefined)
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/json/JSONLScanSourceOpDesc.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/json/JSONLScanSourceOpDesc.scala
index 1b0c928929..88e9ae57d1 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/json/JSONLScanSourceOpDesc.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/json/JSONLScanSourceOpDesc.scala
@@ -88,7 +88,7 @@ class JSONLScanSourceOpDesc extends ScanSourceOpDesc {
val startOffset = windowOffset
val endOffset =
- startOffset +
windowLimit.getOrElse(INFER_READ_LIMIT).min(INFER_READ_LIMIT)
+ startOffset + inferSampleSize
reader
.lines()
.iterator()
diff --git
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/json/JSONLScanSourceOpExec.scala
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/json/JSONLScanSourceOpExec.scala
index 9b732e73c3..b6992ed795 100644
---
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/json/JSONLScanSourceOpExec.scala
+++
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/json/JSONLScanSourceOpExec.scala
@@ -23,6 +23,7 @@ import
org.apache.texera.amber.core.executor.SourceOperatorExecutor
import org.apache.texera.amber.core.storage.DocumentFactory
import org.apache.texera.amber.core.tuple.AttributeTypeUtils.parseField
import org.apache.texera.amber.core.tuple.TupleLike
+import org.apache.texera.amber.operator.source.scan.{ScanRowParseError,
SkippedRowReporter}
import org.apache.texera.amber.util.JSONUtils.{JSONToMap, objectMapper}
import java.io.{BufferedReader, InputStreamReader}
@@ -39,20 +40,43 @@ class JSONLScanSourceOpExec private[json] (
objectMapper.readValue(descString, classOf[JSONLScanSourceOpDesc])
private var rows: Iterator[String] = _
private var reader: BufferedReader = _
+ // 0-based line offset of this worker's first row, captured in open() so
skipped
+ // rows can be reported with their absolute 1-based line number.
+ private var startLine: Int = 0
private val schema = desc.sourceSchema()
+ private val skippedRows = new SkippedRowReporter()
+
+ override def getWarnings: Seq[String] = skippedRows.warnings
override def produceTuple(): Iterator[TupleLike] = {
- rows.flatMap { line =>
- Try {
- val data = JSONToMap(objectMapper.readTree(line),
desc.flatten).withDefaultValue(null)
- val fields = schema.getAttributeNames.map { fieldName =>
- parseField(data(fieldName), schema.getAttribute(fieldName).getType)
+ rows.zipWithIndex.flatMap {
+ case (line, index) =>
+ // Hoisted out of the Try so the failure handler can name the offending
+ // field; stays empty when the JSON line itself is malformed.
+ var rawFields: Seq[Any] = Seq.empty
+ Try {
+ val data = JSONToMap(objectMapper.readTree(line),
desc.flatten).withDefaultValue(null)
+ rawFields = schema.getAttributeNames.map(fieldName =>
data(fieldName))
+ val fields = schema.getAttributeNames.map { fieldName =>
+ parseField(data(fieldName), schema.getAttribute(fieldName).getType)
+ }
+ TupleLike(fields: _*)
+ } match {
+ case Success(tuple) => Some(tuple)
+ case Failure(e) =>
+ // Skip the unparsable line but surface it as a warning instead of
+ // dropping it silently.
+ skippedRows.record(
+ ScanRowParseError.skipWarning(
+ rawFields,
+ schema,
+ desc.inferSampleSize,
+ Some(startLine + index + 1),
+ e
+ )
+ )
+ None
}
- TupleLike(fields: _*)
- } match {
- case Success(tuple) => Some(tuple)
- case Failure(_) => None
- }
}
}
@@ -74,6 +98,9 @@ class JSONLScanSourceOpExec private[json] (
val endOffset: Int =
if (idx != workerCount - 1) count / workerCount * (idx + 1) else count
+ // `startOffset` is relative to the post-offset iterator; add the offset
back
+ // so skipped-row warnings report absolute 1-based file line numbers.
+ startLine = offsetValue + startOffset
rows = it2.iterator.slice(startOffset, endOffset)
}
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/ScanSourceOpDescSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/ScanSourceOpDescSpec.scala
index df016eb5ab..120ec07475 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/ScanSourceOpDescSpec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/ScanSourceOpDescSpec.scala
@@ -206,6 +206,7 @@ class ScanSourceOpDescSpec extends AnyFlatSpec with
Matchers {
tree.has("fileTypeName") shouldBe false
tree.has("windowLimit") shouldBe false
tree.has("windowOffset") shouldBe false
+ tree.has("inferSampleSize") shouldBe false
}
it should "omit unset optional fields entirely, the shape saved workflows
store" in {
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExecSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExecSpec.scala
index 329f6e9f07..210914d40c 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExecSpec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExecSpec.scala
@@ -209,12 +209,12 @@ class CSVScanSourceOpExecSpec extends AnyFlatSpec with
BeforeAndAfterAll {
assert(all.map(_.getFields(0).toString) == List("1", "2", "3"))
}
- it should "silently drop rows that cannot be parsed into the inferred
schema" in {
+ it should "skip rows that cannot be parsed into the inferred schema and
report them" in {
// No header, so every line is data. The schema is inferred from the first
// `limit` rows only (INFER_READ_LIMIT is capped by limit); those are
integers,
// so the column is inferred as INTEGER. `offset` then shifts the output
window
// past that inference sample onto a row whose value ("oops") is not an
integer,
- // so parseFields throws and produceTuple filters that row out instead of
failing.
+ // so parseFields throws and produceTuple skips that row instead of
failing.
val exec =
execOver(
writeTempCsv("1\n2\noops\n3\n4\n"),
@@ -231,5 +231,65 @@ class CSVScanSourceOpExecSpec extends AnyFlatSpec with
BeforeAndAfterAll {
// get the two good ones rather than an exception. Count is below the raw
5 rows.
assert(tuples.size == 2)
assert(tuples.map(_.getFields(0).toString) == List("3", "4"))
+ // The skip is no longer silent: the row is surfaced as a warning naming
the
+ // offending value and its absolute data-row number (offset rows included).
+ assert(exec.getWarnings.size == 1)
+ assert(exec.getWarnings.head.contains("row 3"))
+ assert(exec.getWarnings.head.contains("'oops'"))
+ // The warning names the real inference sample size (limit-capped), not the
+ // default INFER_READ_LIMIT of 100.
+ assert(exec.getWarnings.head.contains("sample of up to 2 rows"))
+ }
+
+ it should "skip a post-inference row that does not parse and report row,
value, column, type" in {
+ // 100 clean integer rows fix the column type at INTEGER
(INFER_READ_LIMIT=100);
+ // data row 101 holds a non-integer and must be skipped but reported.
+ val clean = (1 to 100).map(_.toString).mkString("\n")
+ val exec = execOver(writeTempCsv(s"a\n$clean\noops\n"), hasHeader = true)
+ exec.open()
+ val tuples =
+ try exec.produceTuple().toList
+ finally exec.close()
+
+ assert(tuples.size == 100)
+ val warnings = exec.getWarnings
+ assert(warnings.size == 1)
+ assert(warnings.head.startsWith("WARNING: "))
+ assert(warnings.head.contains("row 101"))
+ assert(warnings.head.contains("'oops'"))
+ assert(warnings.head.contains("column 'a'"))
+ assert(warnings.head.contains("INTEGER"))
+ }
+
+ it should "cap detailed warnings at 100 and append a summary with the true
total" in {
+ val clean = (1 to 100).map(_.toString).mkString("\n")
+ val bad = (1 to 150).map(i => s"bad$i").mkString("\n")
+ val exec = execOver(writeTempCsv(s"a\n$clean\n$bad\n"), hasHeader = true)
+ exec.open()
+ val tuples =
+ try exec.produceTuple().toList
+ finally exec.close()
+
+ assert(tuples.size == 100)
+ val warnings = exec.getWarnings
+ assert(warnings.size == 101) // 100 detail lines + 1 summary line
+ assert(warnings.take(100).forall(_.contains("INTEGER")))
+ assert(warnings.last.contains("50 more"))
+ assert(warnings.last.contains("150 total"))
+ }
+
+ it should "keep a row whose empty cell parses to null instead of skipping
it" in {
+ // An empty INTEGER cell parses to null, which is a legitimate value: the
row
+ // must survive (with the null) and must not be reported as skipped.
+ val exec = execOver(writeTempCsv("a,b\n1,x\n2,y\n,z\n"), hasHeader = true)
+ exec.open()
+ val tuples =
+ try exec.produceTuple().toList
+ finally exec.close()
+
+ assert(tuples.size == 3)
+ assert(tuples(2).getFields(0) == null)
+ assert(tuples(2).getFields(1).toString == "z")
+ assert(exec.getWarnings.isEmpty)
}
}
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpExecSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpExecSpec.scala
index 51f9424454..8016ec40c8 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpExecSpec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpExecSpec.scala
@@ -75,12 +75,31 @@ class ParallelCSVScanSourceOpExecSpec extends AnyFlatSpec {
assert(rows(1)(2) == null)
}
- it should "discard all-null (blank) lines" in {
+ it should "discard all-null (blank) lines without reporting them as parse
failures" in {
val exec =
new
ParallelCSVScanSourceOpExec(descString(writeCsv("name,city\nAlice,NYC\n\nBob,LA\n")))
val rows = drain(exec)
assert(rows.size == 2)
assert(rows.map(_.head) == Seq("Alice", "Bob"))
+ // A blank line is a legitimate silent skip, not a parse failure to
surface.
+ assert(exec.getWarnings.isEmpty)
+ }
+
+ it should "skip a post-inference row that does not parse and report value
and column" in {
+ // 100 clean integer rows fix the column type at INTEGER
(INFER_READ_LIMIT=100);
+ // the later non-integer row must be skipped but reported. This scan is
+ // byte-partitioned across workers, so no row number is expected in the
message.
+ val clean = (1 to 100).map(_.toString).mkString("\n")
+ val exec = new
ParallelCSVScanSourceOpExec(descString(writeCsv(s"a\n$clean\noops\n")))
+ val rows = drain(exec)
+
+ assert(rows.size == 100)
+ val warnings = exec.getWarnings
+ assert(warnings.size == 1)
+ assert(warnings.head.startsWith("WARNING: "))
+ assert(warnings.head.contains("'oops'"))
+ assert(warnings.head.contains("column 'a'"))
+ assert(warnings.head.contains("INTEGER"))
}
it should "partition a headerless file across workers by byte range" in {
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpExecSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpExecSpec.scala
index f4a775fcba..a683b3a1e7 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpExecSpec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpExecSpec.scala
@@ -82,4 +82,34 @@ class CSVOldScanSourceOpExecSpec extends AnyFlatSpec {
val exec = new CSVOldScanSourceOpExec(descString("a\n1\n"))
exec.close() // reader is still null -> guarded, no exception
}
+
+ it should "skip a post-inference row that does not parse and report row,
value, column, type" in {
+ // 100 clean integer rows fix the column type at INTEGER
(INFER_READ_LIMIT=100);
+ // data row 101 holds a non-integer and must be skipped but reported.
+ val clean = (1 to 100).map(_.toString).mkString("\n")
+ val exec = new CSVOldScanSourceOpExec(descString(s"a\n$clean\noops\n"))
+ val rows = drain(exec)
+
+ assert(rows.size == 100)
+ val warnings = exec.getWarnings
+ assert(warnings.size == 1)
+ assert(warnings.head.startsWith("WARNING: "))
+ assert(warnings.head.contains("row 101"))
+ assert(warnings.head.contains("'oops'"))
+ assert(warnings.head.contains("column 'a'"))
+ assert(warnings.head.contains("INTEGER"))
+ }
+
+ it should "report the absolute data-row number when an offset is set" in {
+ // offset=2 shifts both inference and reading to data row 3; the inference
sample
+ // (rows 3..102) is all integers, so "oops" at data row 103 is skipped. The
+ // reported number must count from the start of the data, not from the
offset.
+ val clean = (1 to 102).map(_.toString).mkString("\n")
+ val exec = new CSVOldScanSourceOpExec(descString(s"a\n$clean\noops\n",
offset = Some(2)))
+ val rows = drain(exec)
+
+ assert(rows.size == 100)
+ assert(exec.getWarnings.size == 1)
+ assert(exec.getWarnings.head.contains("row 103"))
+ }
}
diff --git
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/json/JSONLScanSourceOpExecSpec.scala
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/json/JSONLScanSourceOpExecSpec.scala
index a90dcd3640..207b56aa31 100644
---
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/json/JSONLScanSourceOpExecSpec.scala
+++
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/json/JSONLScanSourceOpExecSpec.scala
@@ -120,4 +120,44 @@ class JSONLScanSourceOpExecSpec extends AnyFlatSpec {
(0 until 3).map(i => new JSONLScanSourceOpExec(desc, idx = i,
workerCount = 3))
assert(workers.map(drain(_).map(_.head)) == Seq(Seq(2), Seq(3), Seq(4, 5)))
}
+
+ it should "skip a post-inference line whose value does not match the
inferred type and report it" in {
+ // 100 clean integer lines fix "v" at INTEGER (INFER_READ_LIMIT=100); line
101
+ // holds a string and must be skipped but reported with its absolute line
number.
+ val clean = (1 to 100).map(i => s"""{"v":$i}""")
+ val exec = new JSONLScanSourceOpExec(descString(writeJsonl(clean :+
"""{"v":"oops"}""": _*)))
+ val rows = drain(exec)
+
+ assert(rows.size == 100)
+ val warnings = exec.getWarnings
+ assert(warnings.size == 1)
+ assert(warnings.head.startsWith("WARNING: "))
+ assert(warnings.head.contains("row 101"))
+ assert(warnings.head.contains("'oops'"))
+ assert(warnings.head.contains("column 'v'"))
+ assert(warnings.head.contains("INTEGER"))
+ }
+
+ it should "skip a malformed JSON line and report it with the generic
fallback" in {
+ val clean = (1 to 100).map(i => s"""{"v":$i}""")
+ val exec = new JSONLScanSourceOpExec(descString(writeJsonl(clean :+
"""{"v": not json""": _*)))
+ val rows = drain(exec)
+
+ assert(rows.size == 100)
+ val warnings = exec.getWarnings
+ assert(warnings.size == 1)
+ assert(warnings.head.contains("row 101"))
+ assert(warnings.head.contains("could not be parsed into the inferred
schema"))
+ }
+
+ it should "report the absolute line number when an offset is set" in {
+ // offset=2 skips lines 1-2; inference then samples lines 3-102 (100 clean
+ // integers), and the bad value sits at file line 103.
+ val clean = (1 to 102).map(i => s"""{"v":$i}""")
+ val exec = new JSONLScanSourceOpExec(
+ descString(writeJsonl(clean :+ """{"v":"oops"}""": _*), offset = Some(2))
+ )
+ assert(drain(exec).size == 100)
+ assert(exec.getWarnings.head.contains("row 103"))
+ }
}