This is an automated email from the ASF dual-hosted git repository.
david-streamlio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar-connectors.git
The following commit(s) were added to refs/heads/master by this push:
new 661efb55 [fix][io] Flush and sync before closing the HDFS text sink
stream (#100)
661efb55 is described below
commit 661efb55d344360314d02da82a7e789fab44e763
Author: David Kjerrumgaard <[email protected]>
AuthorDate: Fri Jul 10 14:54:45 2026 -0700
[fix][io] Flush and sync before closing the HDFS text sink stream (#100)
* [fix][io] Flush and sync before closing the HDFS text sink stream
HdfsAbstractTextFileSink.close() closed the writer (and its underlying
HDFS stream) before the superclass ran its final hsync()/ack in
HdfsSyncThread.halt(). Whenever records were still unacked at close
time, that hsync() ran against an already-closed stream and threw
ClosedChannelException, so the final batch was never acked or committed.
Flush the writer, let super.close() halt the sync thread and hsync/ack
while the stream is still open, then close the writer.
Add a regression test that closes the sink with records still queued
(a long syncInterval keeps the background thread from draining first):
it fails with ClosedChannelException against the old order and passes
with the fix.
* [fix][test] Address review on the HDFS close-ordering fix
- close() now wraps flush()+super.close() in try/finally so the writer
(and its underlying HDFS stream) is always closed, even if the final
hsync()/ack in super.close() throws.
- The regression test mirrors the defensive close-on-error pattern so a
throwing close() cannot leave the non-daemon HdfsSyncThread running.
- Lower the test's syncInterval from 5s to 1s: close() joins the sync
thread (halt() does not interrupt its sleep), so the interval is dead
suite time; 1s stays comfortably above the millisecond enqueue.
---
.../hdfs3/sink/text/HdfsAbstractTextFileSink.java | 17 ++++-
.../io/hdfs3/sink/HdfsSinkIntegrationTest.java | 75 ++++++++++++++++++++--
2 files changed, 84 insertions(+), 8 deletions(-)
diff --git
a/hdfs3/src/main/java/org/apache/pulsar/io/hdfs3/sink/text/HdfsAbstractTextFileSink.java
b/hdfs3/src/main/java/org/apache/pulsar/io/hdfs3/sink/text/HdfsAbstractTextFileSink.java
index 66fd7a62..1487bdd8 100644
---
a/hdfs3/src/main/java/org/apache/pulsar/io/hdfs3/sink/text/HdfsAbstractTextFileSink.java
+++
b/hdfs3/src/main/java/org/apache/pulsar/io/hdfs3/sink/text/HdfsAbstractTextFileSink.java
@@ -48,8 +48,21 @@ public abstract class HdfsAbstractTextFileSink<K, V> extends
HdfsAbstractSink<K,
@Override
public void close() throws Exception {
- writer.close();
- super.close();
+ // Flush buffered bytes to the HDFS stream, then let the superclass
halt the sync thread and
+ // run its final hsync()/ack while the stream is still open. Closing
the writer first (the
+ // previous order) closed the stream before that final hsync(),
throwing
+ // ClosedChannelException whenever unacked records remained at close
time. The writer is
+ // closed in finally so the underlying stream is released even if
super.close() throws.
+ try {
+ if (writer != null) {
+ writer.flush();
+ }
+ super.close();
+ } finally {
+ if (writer != null) {
+ writer.close();
+ }
+ }
}
@Override
diff --git
a/hdfs3/src/test/java/org/apache/pulsar/io/hdfs3/sink/HdfsSinkIntegrationTest.java
b/hdfs3/src/test/java/org/apache/pulsar/io/hdfs3/sink/HdfsSinkIntegrationTest.java
index 4106a6e3..e7942bd0 100644
---
a/hdfs3/src/test/java/org/apache/pulsar/io/hdfs3/sink/HdfsSinkIntegrationTest.java
+++
b/hdfs3/src/test/java/org/apache/pulsar/io/hdfs3/sink/HdfsSinkIntegrationTest.java
@@ -155,7 +155,7 @@ public class HdfsSinkIntegrationTest {
}
// Read the committed file back from the mini cluster's filesystem
and assert it matches.
- String actual = readSinkOutput();
+ String actual = readSinkOutput(DIRECTORY);
Assert.assertEquals(actual, expected.toString(),
"content read back from HDFS must match what was written
to the sink");
} finally {
@@ -171,12 +171,75 @@ public class HdfsSinkIntegrationTest {
}
/**
- * Reads and concatenates the content of every file produced by the sink
under {@link #DIRECTORY}
- * on the mini cluster's filesystem. The sink writes all records from a
single {@code open()} into
- * one file whose name starts with {@link #FILENAME_PREFIX}.
+ * Regression test for the close ordering bug: closing the sink while
records are still unacked
+ * must flush and commit them, not throw. The old {@code writer.close()}
before the superclass'
+ * final {@code hsync()} closed the stream first, so {@code hsync()} threw
{@code
+ * ClosedChannelException} whenever the sync thread had not yet drained
the queue at close time.
*/
- private String readSinkOutput() throws Exception {
- Path dir = new Path(DIRECTORY);
+ @Test(timeOut = 300_000)
+ public void testCloseWithUnackedRecordsCommitsInsteadOfThrowing() throws
Exception {
+ String directory = "/hdfs-sink-close-ordering-test";
+ List<String> values = new ArrayList<>();
+ for (int i = 0; i < 25; i++) {
+ values.add("close-record-" + i);
+ }
+
+ Map<String, Object> config = new HashMap<>();
+ config.put("hdfsConfigResources", coreSite.getAbsolutePath());
+ config.put("directory", directory);
+ config.put("filenamePrefix", FILENAME_PREFIX);
+ config.put("fileExtension", FILE_EXTENSION);
+ config.put("separator", SEPARATOR);
+ config.put("encoding", "UTF-8");
+ // A sync interval long enough that the background thread does not
tick before we close()
+ // (enqueuing the records takes milliseconds), so close() runs the
final hsync()/ack path
+ // with records still queued — the bug scenario. Kept small because
close() joins the sync
+ // thread, which sleeps this long before exiting, so it directly adds
to the suite runtime.
+ config.put("syncInterval", 1_000L);
+
+ SinkContext sinkContext = mock(SinkContext.class);
+ AtomicInteger ackCount = new AtomicInteger(0);
+
+ HdfsStringSink sink = new HdfsStringSink();
+ sink.open(config, sinkContext);
+ boolean closed = false;
+ try {
+ for (String value : values) {
+ sink.write(mockRecord(value, ackCount));
+ }
+
+ // Under the old ordering this threw ClosedChannelException; it
must now commit cleanly.
+ sink.close();
+ closed = true;
+
+ Assert.assertEquals(ackCount.get(), values.size(),
+ "close() should have flushed and acked every queued
record");
+
+ StringBuilder expected = new StringBuilder();
+ for (String value : values) {
+ expected.append(value).append(SEPARATOR);
+ }
+ Assert.assertEquals(readSinkOutput(directory), expected.toString(),
+ "records written before close() must be committed to
HDFS");
+ } finally {
+ if (!closed) {
+ // Failure path: close so the non-daemon HdfsSyncThread cannot
outlive the test.
+ try {
+ sink.close();
+ } catch (Exception e) {
+ log.warn("Failed to close sink on the error path", e);
+ }
+ }
+ }
+ }
+
+ /**
+ * Reads and concatenates the content of every file the sink produced
under {@code directory}
+ * on the mini cluster's filesystem, matching files whose name starts with
+ * {@link #FILENAME_PREFIX}.
+ */
+ private String readSinkOutput(String directory) throws Exception {
+ Path dir = new Path(directory);
if (!clusterFs.exists(dir)) {
return "";
}