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

pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-connectors.git


The following commit(s) were added to refs/heads/main by this push:
     new 18033ce10 HDFS: close the writer when the stream does not complete 
normally (#1878)
18033ce10 is described below

commit 18033ce105f4a9ceb29cdf423361443cd690eabd
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Sep 18 09:37:49 2026 +0100

    HDFS: close the writer when the stream does not complete normally (#1878)
    
    Motivation:
    HdfsFlowLogic had no postStop, and the writer's output is closed only by
    HdfsWriter.rotate, which runs on the normal completion path. On any other
    stop the output stayed open:
    
      - upstream fails: onUpstreamFailure calls failStage and nothing else
      - downstream cancels: the default onDownstreamFinish completes the stage
      - a write throws: supervision fails the stage
    
    Each of those leaves an FSDataOutputStream or SequenceFile.Writer open on
    an HDFS lease. The file stays "under construction", so the bytes written
    before the failure are never committed and the NameNode reports length 0
    until the lease expires.
    
    CompressedDataWriter additionally took a Compressor from CodecPool and
    never returned it. rotate() builds a new writer that takes another one,
    so the pool drained on the normal path too, and each rotation allocated
    fresh native memory.
    
    Modification:
    Add HdfsWriter.close, implemented by the three writers, and call it from
    a new postStop. `output` is lazy, so HdfsWriter records whether it was
    forced and close skips it otherwise — a stage that stopped before writing
    must not create a file just to close it. This also makes the post-rotation
    case a no-op, since rotate returns a fresh writer whose output is unset.
    
    CompressedDataWriter.close and rotate now return the compressor to
    CodecPool. Failures are logged rather than rethrown: the stage has already
    stopped, so failing here would only mask why.
    
    The temp file is deliberately left in place, as it already is when a
    rotation fails.
    
    Result:
    Output streams and compressors are released when the stage stops, so the
    lease is freed and the bytes written before a failure are committed.
    
    Tests:
    Added a test to HdfsWriterSpec that fails a stream after it has written,
    then asserts the temp file has a committed non-zero length. Verified it
    fails without the postStop ("0 was not greater than 0") and passes with
    it. It filters temp files by modification time because other tests in the
    suite leave their own behind. Full hdfs suite passes, 19 tests. Ran
    hdfs/mimaReportBinaryIssues (clean) and the module scalafmt checks.
    
    References:
    Compare HdfsWriter.rotate, the only other place output is closed.
---
 .../connectors/hdfs/impl/HdfsFlowStage.scala       | 15 ++++++-
 .../hdfs/impl/writer/CompressedDataWriter.scala    |  9 ++++
 .../connectors/hdfs/impl/writer/DataWriter.scala   |  3 ++
 .../connectors/hdfs/impl/writer/HdfsWriter.scala   | 22 +++++++++-
 .../hdfs/impl/writer/SequenceWriter.scala          |  3 ++
 .../test/scala/docs/scaladsl/HdfsWriterSpec.scala  | 49 +++++++++++++++++++++-
 6 files changed, 98 insertions(+), 3 deletions(-)

diff --git 
a/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/HdfsFlowStage.scala
 
b/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/HdfsFlowStage.scala
index fcf8410b2..c48a1e2b7 100644
--- 
a/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/HdfsFlowStage.scala
+++ 
b/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/HdfsFlowStage.scala
@@ -25,6 +25,7 @@ import pekko.stream.stage._
 import cats.data.State
 
 import scala.concurrent.duration.FiniteDuration
+import scala.util.control.NonFatal
 
 /**
  * Internal API
@@ -61,7 +62,8 @@ private[hdfs] final class HdfsFlowLogic[W, I, C](
     outlet: Outlet[OutgoingMessage[C]],
     shape: FlowShape[HdfsWriteMessage[I, C], OutgoingMessage[C]]) extends 
TimerGraphStageLogic(shape)
     with InHandler
-    with OutHandler {
+    with OutHandler
+    with StageLogging {
 
   private var state = FlowState(initialHdfsWriter, initialRotationStrategy, 
initialSyncStrategy)
 
@@ -92,6 +94,17 @@ private[hdfs] final class HdfsFlowLogic[W, I, C](
   override def onUpstreamFailure(ex: Throwable): Unit =
     failStage(ex)
 
+  // The writer's output is only closed by `rotate`, which runs on the normal
+  // completion path. On any other stop — upstream failure, downstream cancel,
+  // or a write throwing — the output would stay open on an HDFS lease, so it
+  // is released here. Closing twice is harmless; the temp file is deliberately
+  // left in place, as it is on a failed rotation.
+  override def postStop(): Unit =
+    try state.writer.close()
+    catch {
+      case NonFatal(ex) => log.error(ex, "Problem occurred during writer 
close")
+    }
+
   override def onUpstreamFinish(): Unit =
     state.logicState match {
       case LogicState.Writing =>
diff --git 
a/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/CompressedDataWriter.scala
 
b/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/CompressedDataWriter.scala
index 66d8ec94c..ffd318006 100644
--- 
a/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/CompressedDataWriter.scala
+++ 
b/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/CompressedDataWriter.scala
@@ -52,9 +52,18 @@ private[writer] final case class CompressedDataWriter(
   override def rotate(rotationCount: Long): CompressedDataWriter = {
     cmpOutput.finish()
     output.close()
+    // the compressor came from CodecPool and holds native memory; a rotation
+    // builds a new writer that takes another one, so this one has to go back
+    CodecPool.returnCompressor(compressor)
     copy(maybeTargetPath = Some(outputFileWithExtension(rotationCount)))
   }
 
+  override def close(): Unit = {
+    // `cmpOutput` wraps `output`, so creating it already forced the lazy value
+    if (isOutputOpened) output.close()
+    CodecPool.returnCompressor(compressor)
+  }
+
   override protected def create(fs: FileSystem, file: Path): 
FSDataOutputStream = fs.create(file, overwrite)
 
   private def outputFileWithExtension(rotationCount: Long): Path = {
diff --git 
a/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/DataWriter.scala
 
b/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/DataWriter.scala
index bc43884a3..db02b4277 100644
--- 
a/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/DataWriter.scala
+++ 
b/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/DataWriter.scala
@@ -47,6 +47,9 @@ private[writer] final case class DataWriter(
     copy(maybeTargetPath = Some(createTargetPath(pathGenerator, 
rotationCount)))
   }
 
+  override def close(): Unit =
+    if (isOutputOpened) output.close()
+
   override def create(fs: FileSystem, file: Path): FSDataOutputStream = 
fs.create(file, overwrite)
 
 }
diff --git 
a/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/HdfsWriter.scala
 
b/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/HdfsWriter.scala
index 70e1e174c..5e5a74fb3 100644
--- 
a/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/HdfsWriter.scala
+++ 
b/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/HdfsWriter.scala
@@ -25,7 +25,17 @@ import org.apache.hadoop.fs.{ FileSystem, Path }
 @InternalApi
 private[hdfs] trait HdfsWriter[W, I] {
 
-  protected lazy val output: W = create(fs, temp)
+  // `outputOpened` records whether the lazy `output` was forced, so `close`
+  // can skip it rather than creating a file only to close it.
+  private var outputOpened = false
+
+  protected lazy val output: W = {
+    outputOpened = true
+    create(fs, temp)
+  }
+
+  /** Whether `output` has been forced. */
+  protected def isOutputOpened: Boolean = outputOpened
 
   protected lazy val temp: Path = tempFromTarget(pathGenerator, target)
 
@@ -45,6 +55,16 @@ private[hdfs] trait HdfsWriter[W, I] {
 
   def rotate(rotationCount: Long): HdfsWriter[W, I]
 
+  /**
+   * Release the underlying output without moving anything to the target.
+   *
+   * Called when the stage stops without completing normally, where `rotate` is
+   * never reached and the output would otherwise stay open on an HDFS lease.
+   * Must be safe to call when nothing was ever written: `output` is lazy, so
+   * implementations have to avoid forcing it.
+   */
+  def close(): Unit
+
   protected def target: Path
 
   protected def fs: FileSystem
diff --git 
a/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/SequenceWriter.scala
 
b/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/SequenceWriter.scala
index 07aec4662..7fe173d36 100644
--- 
a/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/SequenceWriter.scala
+++ 
b/hdfs/src/main/scala/org/apache/pekko/stream/connectors/hdfs/impl/writer/SequenceWriter.scala
@@ -48,6 +48,9 @@ private[writer] final case class SequenceWriter[K <: 
Writable, V <: Writable](
     copy(maybeTargetPath = Some(createTargetPath(pathGenerator, 
rotationCount)))
   }
 
+  override def close(): Unit =
+    if (isOutputOpened) output.close()
+
   override protected def create(fs: FileSystem, file: Path): 
SequenceFile.Writer = {
     val ops = SequenceFile.Writer.file(file) +: writerOptions
     SequenceFile.createWriter(fs.getConf, ops: _*)
diff --git a/hdfs/src/test/scala/docs/scaladsl/HdfsWriterSpec.scala 
b/hdfs/src/test/scala/docs/scaladsl/HdfsWriterSpec.scala
index 79aafa51c..78cf7d3fa 100644
--- a/hdfs/src/test/scala/docs/scaladsl/HdfsWriterSpec.scala
+++ b/hdfs/src/test/scala/docs/scaladsl/HdfsWriterSpec.scala
@@ -14,6 +14,7 @@
 package docs.scaladsl
 
 import org.apache.pekko
+import pekko.NotUsed
 import pekko.actor.ActorSystem
 import pekko.stream.connectors.hdfs._
 import pekko.stream.connectors.hdfs.scaladsl.HdfsFlow
@@ -28,9 +29,10 @@ import org.apache.hadoop.io.Text
 import org.apache.hadoop.io.compress._
 import org.apache.hadoop.io.compress.zlib.ZlibCompressor.CompressionLevel
 import org.scalatest._
+import org.scalatest.concurrent.Eventually
 
 import scala.concurrent.duration.{ Duration, _ }
-import scala.concurrent.{ Await, ExecutionContextExecutor }
+import scala.concurrent.{ Await, ExecutionContextExecutor, Future }
 import org.scalatest.matchers.should.Matchers
 import org.scalatest.wordspec.AnyWordSpecLike
 
@@ -39,6 +41,7 @@ class HdfsWriterSpec
     with Matchers
     with BeforeAndAfterAll
     with BeforeAndAfterEach
+    with Eventually
     with LogCapturing {
 
   private var hdfsCluster: MiniDFSCluster = null
@@ -493,6 +496,50 @@ class HdfsWriterSpec
     }
   }
 
+  "HdfsFlowStage" should {
+    "release the writer when the stream fails" in {
+      // Regression test: the output is only closed by `rotate`, which runs on
+      // the normal completion path. Without a postStop, a stream that fails
+      // mid-write leaves the temp file open on an HDFS lease, so the file 
stays
+      // "under construction" and the bytes written are never committed.
+      val flow = HdfsFlow.data(
+        fs,
+        SyncStrategy.none,
+        RotationStrategy.size(1, FileUnit.GB),
+        settings)
+
+      val startedAt = System.currentTimeMillis()
+      val failure = new RuntimeException("boom")
+      val result = Source(books)
+        .map(HdfsWriteMessage(_))
+        
.concat(Source.future(pekko.pattern.after(200.millis)(Future.failed(failure))))
+        .via(flow)
+        .runWith(Sink.ignore)
+
+      Await.ready(result, 10.seconds)
+      result.value.get.isFailure shouldBe true
+
+      // only the file this stage wrote: other tests in this suite leave their
+      // own temp files behind, and afterEach does not always clear them
+      val tempDir = new Path(settings.pathGenerator.tempDirectory)
+      val leftOver =
+        if (fs.exists(tempDir)) 
fs.listStatus(tempDir).toList.filter(_.getModificationTime >= startedAt)
+        else Nil
+      withClue("the stage should have written a temp file before failing: ") {
+        leftOver should not be empty
+      }
+
+      // A file still held open for write reports length 0 until the lease is
+      // released, because the last block is not committed. Closing the writer
+      // in postStop commits it, so the bytes written before the failure show 
up.
+      leftOver.foreach { st =>
+        withClue(s"temp file ${st.getPath} was not closed, so its length is 
not committed: ") {
+          fs.getFileStatus(st.getPath).getLen should be > 0L
+        }
+      }
+    }
+  }
+
   override protected def afterEach(): Unit = {
     fs.delete(new Path(destination), true)
     fs.delete(settings.pathGenerator(0, 0).getParent, true)


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

Reply via email to