voonhous commented on code in PR #18776:
URL: https://github.com/apache/hudi/pull/18776#discussion_r4059224987
##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieNativeLogAppendHandle.java:
##########
@@ -163,8 +163,11 @@ protected void flushAppend() {
@Override
protected void closeLogWriter() {
- if (writer != null) {
- writer.close();
+ try {
+ if (writer != null) {
+ writer.close();
+ }
+ } finally {
writer = null;
Review Comment:
**minor:** The native handle is the default log writer from table version 10
on, but the new failure test only drives `HoodieInlineLogAppendHandle` on table
version SIX with `flushAppend` mocked. This `closeLogWriter` change, the
`writeRecord` failure branch (`HoodieAppendHandle:401`) and `write(Map)` are
not reached by any test. Not blocking, but could
`TestHoodieNativeLogAppendHandle` (it already has a `createWriter` harness) get
a case where `writer.close()` throws, asserting `isClosed()`, a null `writer`
and a single close?
##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/SparkHelpers.scala:
##########
@@ -72,14 +72,17 @@ object SparkHelpers {
conf.unwrap().setClassLoader(Thread.currentThread.getContextClassLoader)
val writer = new HoodieAvroParquetWriter(destinationFile, parquetConfig,
instantTime, new SparkTaskContextSupplier(), true)
- for (rec <- sourceRecords) {
- val key: String =
rec.get(HoodieRecord.RECORD_KEY_METADATA_FIELD).toString
- if (!keysToSkip.contains(key)) {
+ try {
+ for (rec <- sourceRecords) {
+ val key: String =
rec.get(HoodieRecord.RECORD_KEY_METADATA_FIELD).toString
+ if (!keysToSkip.contains(key)) {
- writer.writeAvro(key, rec)
+ writer.writeAvro(key, rec)
+ }
}
+ } finally {
+ writer.close()
Review Comment:
**minor:** A Scala `finally { writer.close() }` replaces the original write
exception with any exception from `close()`, which is likely on a writer that
just failed. The Java handles in this PR use `CloseableUtils.closeSuppressing`
for exactly this. Not blocking, but could this be `catch { case t: Throwable =>
CloseableUtils.closeSuppressing(writer, t); throw t }` with `writer.close()` on
the success path?
##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieBinaryCopyHandle.java:
##########
@@ -120,18 +121,31 @@ public void write() {
log.info("Schema evolution enabled for binary copy: {}",
schemaEvolutionEnabled);
records = this.writer.binaryCopy(inputFiles,
Collections.singletonList(path), writeScheMessageType, schemaEvolutionEnabled);
} catch (IOException e) {
+ closeWriterQuietly(e);
throw new HoodieIOException(e.getMessage(), e);
+ } catch (RuntimeException e) {
+ closeWriterQuietly(e);
+ throw e;
} finally {
this.recordsWritten = records;
this.insertRecordsWritten = records;
}
log.info("Finish rewriting {}. Using {} mills", this.path,
timer.endTimer());
}
+ private void closeWriterQuietly(Throwable failure) {
+ markClosed();
+ CloseableUtils.closeSuppressing(writer::close, failure);
Review Comment:
**minor:** `HoodieParquetFileBinaryCopier.close()` (lines 250-257) calls
`super.close()` before `prefetchExecutor.shutdownNow()` with no `finally`, so
when `end()` throws here the non-daemon executor thread (created at line 202)
and the current input `reader` leak once per failed handle. This path now
reaches that `close()`. Not blocking, but could the executor shutdown and
reader close move into a `finally` around `super.close()`?
##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieBinaryCopyHandle.java:
##########
@@ -120,18 +121,31 @@ public void write() {
log.info("Schema evolution enabled for binary copy: {}",
schemaEvolutionEnabled);
records = this.writer.binaryCopy(inputFiles,
Collections.singletonList(path), writeScheMessageType, schemaEvolutionEnabled);
} catch (IOException e) {
+ closeWriterQuietly(e);
Review Comment:
**major:** This failure path calls `copier.close()`, which is the success
finalizer (`finalizeMetadata()` + `writer.end()`). If the failure happens
between row groups (e.g. the prefetch `IOException` in
`HoodieParquetFileBinaryCopier.initNextReader`), `end()` succeeds and leaves a
partial file with a valid footer. This handle creates no write marker, and
`SparkBinaryCopyClusteringExecutionStrategy:136` gives a retry a new fileId, so
nothing reconciles the orphan; before this change it was at least unreadable.
Could the failure path abort instead, releasing the stream and deleting `path`
without calling `end()`?
##########
hudi-client/hudi-client-common/src/test/java/org/apache/hudi/io/TestHoodieCreateHandle.java:
##########
@@ -392,6 +393,40 @@ protected HoodieFileWriter initializeFileWriter() throws
IOException {
assertDoesNotThrow(createHandle::close);
}
+ @Test
+ void testFileWriterClosedWhenDoWriteFails() throws Exception {
+ HoodieWriteConfig failOnWriteConfig = HoodieWriteConfig.newBuilder()
+ .withProps(writeConfig.getProps())
+ .withWriteIgnoreFailed(false)
+ .build();
+ HoodieTable failOnWriteTable = new TestBaseHoodieTable(failOnWriteConfig,
getEngineContext(), metaClient);
+ CreateHandleWithFileWriterWriteFailure createHandle = new
CreateHandleWithFileWriterWriteFailure(
+ failOnWriteConfig, TEST_INSTANT_TIME, failOnWriteTable,
TEST_PARTITION_PATH, TEST_FILE_ID, taskContextSupplier);
+ HoodieRecord testRecord = dataGen.generateInserts(TEST_INSTANT_TIME,
1).get(0);
+
+ HoodieException exception = assertThrows(HoodieException.class, () ->
+ createHandle.doWrite(testRecord, TEST_SCHEMA, new TypedProperties()));
+
+ assertEquals("Simulated file writer write failure",
exception.getMessage());
+ assertNull(createHandle.fileWriter);
Review Comment:
**minor:** This test still passes if the
`CloseableUtils.closeSuppressing(fileWriter, failure)` line in
`BaseCreateHandle` is deleted: it only checks that the field is null and the
handle is marked closed. Not blocking, but could it capture `HoodieFileWriter
fileWriter = createHandle.fileWriter;` before the `assertThrows` and then
`assertFalse(fileWriter.canWrite())` (`TestFileWriter.canWrite()` returns
`!closed`)?
##########
hudi-common/src/main/java/org/apache/hudi/common/bootstrap/index/hfile/HFileBootstrapIndexWriter.java:
##########
@@ -174,28 +176,60 @@ private void commit() {
* Close Writer Handles.
*/
public void close() {
- try {
- if (!closed) {
- indexByPartitionWriter.close();
- indexByFileIdWriter.close();
- closed = true;
- }
- } catch (IOException ioe) {
- throw new HoodieIOException(ioe.getMessage(), ioe);
+ if (closed) {
+ return;
+ }
+ Exception failure = closeHFileWriter(indexByPartitionWriter, null);
+ failure = closeHFileWriter(indexByFileIdWriter, failure);
+ indexByPartitionWriter = null;
+ indexByFileIdWriter = null;
+ closed = true;
+ if (failure != null) {
+ throw new HoodieException(failure.getMessage(), failure);
Review Comment:
**nit:** `close()` used to throw `HoodieIOException` for an `IOException`
and now throws `HoodieException`, including on the success path through
`finish()`. No in-repo caller depends on it, so feel free to ignore, but could
this keep `HoodieIOException` when `failure` is an `IOException`?
##########
hudi-client/hudi-client-common/src/test/java/org/apache/hudi/io/TestHoodieAppendHandle.java:
##########
@@ -87,6 +100,38 @@ private void mockMethodsNeededByConstructor() {
when(mockHoodieTable.getMetaClient()).thenReturn(metaClient);
}
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void testFailedFlushClosesWriterAndPreventsAnotherFlush(boolean closeFails)
throws IOException {
+ writeConfig = HoodieWriteConfig.newBuilder()
+ .withProps(writeConfig.getProps())
+ .withWriteTableVersion(HoodieTableVersion.SIX.versionCode())
+ .build();
+ when(mockHoodieTable.getStorage()).thenReturn(metaClient.getStorage());
+ HoodieInlineLogAppendHandle<Object, Object, Object, Object> handle = spy(
+ new HoodieInlineLogAppendHandle<>(writeConfig, TEST_INSTANT_TIME,
mockHoodieTable,
+ TEST_PARTITION_PATH, TEST_FILE_ID, taskContextSupplier));
+ HoodieLogFormat.Writer writer = mock(HoodieLogFormat.Writer.class);
+ handle.writer = writer;
+ handle.recordItr = Collections.emptyIterator();
+ handle.recordList.add(mock(HoodieRecord.class));
Review Comment:
**nit:** This setup line is dead: `flushAppend()` is stubbed to throw and
the later `close()` returns early on `isClosed()`, so the buffered record is
never touched. Feel free to ignore.
##########
hudi-hadoop-common/src/test/java/org/apache/hudi/parquet/io/TestHoodieParquetBinaryCopyBaseSchemaEvolution.java:
##########
@@ -290,6 +299,62 @@ public void
testSchemaEvolutionEnabled_AllowsLegacyConversion() throws Exception
assertEquals(true, legacyConversionAttempted, "Legacy conversion should be
attempted when schema evolution is enabled");
}
+ @Test
+ public void
testCloseParquetFileWriterQuietlyIgnoresWriterWithoutCloseMethod() {
Review Comment:
**nit:** These two reflection tests of the private
`closeParquetFileWriterQuietly` are already covered by the public `close()`
tests below (`testCloseClearsWriterWhenEndFails` uses a non-Closeable mock,
`testCloseReleasesWriterWhenMetadataFails` a Closeable one). On spark4.x a
plain `mock(ParquetFileWriter.class)` is `AutoCloseable`, so this test's name
no longer holds there. Feel free to ignore, but could we drop these two plus
the `getDeclaredMethod`/`Field` helpers in favor of a package-private
`@VisibleForTesting` accessor, maybe with the close tests in their own class?
##########
hudi-client/hudi-client-common/src/test/java/org/apache/hudi/io/TestHoodieCreateHandle.java:
##########
@@ -392,6 +393,40 @@ protected HoodieFileWriter initializeFileWriter() throws
IOException {
assertDoesNotThrow(createHandle::close);
}
+ @Test
+ void testFileWriterClosedWhenDoWriteFails() throws Exception {
+ HoodieWriteConfig failOnWriteConfig = HoodieWriteConfig.newBuilder()
+ .withProps(writeConfig.getProps())
+ .withWriteIgnoreFailed(false)
+ .build();
+ HoodieTable failOnWriteTable = new TestBaseHoodieTable(failOnWriteConfig,
getEngineContext(), metaClient);
+ CreateHandleWithFileWriterWriteFailure createHandle = new
CreateHandleWithFileWriterWriteFailure(
+ failOnWriteConfig, TEST_INSTANT_TIME, failOnWriteTable,
TEST_PARTITION_PATH, TEST_FILE_ID, taskContextSupplier);
+ HoodieRecord testRecord = dataGen.generateInserts(TEST_INSTANT_TIME,
1).get(0);
+
+ HoodieException exception = assertThrows(HoodieException.class, () ->
+ createHandle.doWrite(testRecord, TEST_SCHEMA, new TypedProperties()));
+
+ assertEquals("Simulated file writer write failure",
exception.getMessage());
+ assertNull(createHandle.fileWriter);
+ assertTrue(createHandle.isClosed());
+ assertDoesNotThrow(createHandle::close);
+ }
+
+ private static class CreateHandleWithFileWriterWriteFailure extends
HoodieCreateHandle<Object, Object, Object, Object> {
Review Comment:
**nit:** This member class duplicates the same-named local class inside
`testMarkerFileCreatedWhenFileWriterWriteFails` (line 347), which shadows it.
Feel free to ignore, but could we drop the local class so both tests use this
one?
##########
hudi-hadoop-common/src/main/java/org/apache/hudi/parquet/io/HoodieParquetBinaryCopyBase.java:
##########
@@ -159,24 +160,44 @@ protected void initFileWriter(Path outPutFile,
CompressionCodecName newCodecName
writer.start();
log.info("init writer ");
} catch (Exception e) {
+ closeParquetFileWriterQuietly(e);
log.error("failed to init parquet writer", e);
throw new HoodieException(e);
}
}
@Override
public void close() throws IOException {
- Map<String, String> extraMetaData = finalizeMetadata();
- extraMetaData = extraMetaData == null ? new HashMap<>() : extraMetaData;
- extraMetaData.remove("parquet.avro.schema");
- extraMetaData.remove("org.apache.spark.sql.parquet.row.metadata");
- writer.end(extraMetaData);
- // Release the buffer
- reusableBlockBuffer = null;
+ if (writer == null) {
+ return;
+ }
+ try {
+ Map<String, String> extraMetaData = finalizeMetadata();
+ extraMetaData = extraMetaData == null ? new HashMap<>() : extraMetaData;
+ extraMetaData.remove("parquet.avro.schema");
+ extraMetaData.remove("org.apache.spark.sql.parquet.row.metadata");
+ writer.end(extraMetaData);
+ } catch (IOException | RuntimeException e) {
+ closeParquetFileWriterQuietly(e);
+ throw e;
+ } finally {
+ writer = null;
+ // Release the buffer
+ reusableBlockBuffer = null;
+ }
}
protected abstract Map<String, String> finalizeMetadata();
+ private void closeParquetFileWriterQuietly(Throwable failure) {
+ // Parquet 1.12.x/1.13.x have no close(); newer versions implement
AutoCloseable.
+ ParquetFileWriter parquetFileWriter = writer;
+ writer = null;
+ if (parquetFileWriter instanceof AutoCloseable) {
Review Comment:
**minor:** On the default build this branch never fires: `ParquetFileWriter`
implements `AutoCloseable` only from parquet 1.14, while the default
`parquet.version` is 1.13.1 and spark3.3/3.4 use 1.12.x (only the spark4.x and
flink2.x profiles are on 1.14+). The PR description already notes this. The
tests reach the branch only via `extraInterfaces(Closeable.class)` mocks. Not
blocking, but could `initFileWriter` wrap the `OutputFile` to keep the
`PositionOutputStream` it creates, so this can close the stream directly on
every parquet version?
##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieAppendHandle.java:
##########
@@ -531,6 +532,7 @@ protected void doWrite(HoodieRecord record, HoodieSchema
schema, TypedProperties
} catch (Throwable t) {
log.error("Error writing record " + record, t);
if (!config.getIgnoreWriteFailed()) {
+ closeLogWriterQuietly(t);
Review Comment:
**blocker:** With `hoodie.write.executor.type=DISRUPTOR`, marking the handle
closed on failure turns one lost record into the rest of the handle lost, and
the write still succeeds. `DisruptorMessageQueue.setHandlers` swallows the
consumer exception (`markAsFailed`), `BaseHoodieQueueBasedExecutor.execute()`
still calls `finish()`, and `close()` returns a `writeStatus` that never went
through its setup. Same shape in `BaseCreateHandle:132`. Could `close()` on a
handle closed by a failure rethrow that failure instead of returning a status?
<details><summary>Trace (code read, not executed)</summary>
1. `CopyOnWriteInsertHandler.consume` > `BaseCreateHandle.doWrite` fails;
the catch at :132 marks the handle closed, closes and nulls `fileWriter`, and
throws.
2. `DisruptorMessageQueue.setHandlers` catches it, calls `markAsFailed`, and
keeps consuming.
3. Each later record: `CopyOnWriteInsertHandler:105` calls `handle.canWrite`
> `BaseCreateHandle:96` NPEs on the null `fileWriter`, swallowed the same way.
4. `execute()` completes normally > `finish()` > `closeOpenHandles()` >
`close()` takes the `isClosed()` early return with `writeStatus` never passed
through `setupWriteStatus`.
On master the handle kept writing, so only the failing record was lost. The
append path behaves the same for `HoodieEarlyConflictDetectionException`, which
throws even with ignore.failed=true, and its `statuses` skip the log-offset
size fix-up.
</details>
--
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]