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

sodonnel pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git


The following commit(s) were added to refs/heads/master by this push:
     new 1ad6848c3c4 HDDS-16241. gRPC deadline kills long-lived block streams 
after 30 seconds and the client never recovers (#11080)
1ad6848c3c4 is described below

commit 1ad6848c3c4b7fd694dddce10f8f36027bf9182e
Author: Sergey Soldatov <[email protected]>
AuthorDate: Mon Oct 5 13:45:11 2026 -0700

    HDDS-16241. gRPC deadline kills long-lived block streams after 30 seconds 
and the client never recovers (#11080)
---
 .../apache/hadoop/hdds/scm/XceiverClientGrpc.java  |   5 +-
 .../hdds/scm/storage/StreamBlockInputStream.java   |   9 ++
 .../scm/storage/TestStreamBlockInputStream.java    |  46 ++++++
 .../common/statemachine/DatanodeConfiguration.java |  22 +++
 .../transport/server/GrpcXceiverService.java       |  68 ++++++++-
 .../common/transport/server/XceiverServerGrpc.java |   5 +-
 .../transport/server/TestGrpcXceiverService.java   | 169 +++++++++++++++++++++
 7 files changed, 320 insertions(+), 4 deletions(-)

diff --git 
a/hadoop-hdds/client/src/main/java/org/apache/hadoop/hdds/scm/XceiverClientGrpc.java
 
b/hadoop-hdds/client/src/main/java/org/apache/hadoop/hdds/scm/XceiverClientGrpc.java
index 4fe56e6ac09..ab9d0dbcafb 100644
--- 
a/hadoop-hdds/client/src/main/java/org/apache/hadoop/hdds/scm/XceiverClientGrpc.java
+++ 
b/hadoop-hdds/client/src/main/java/org/apache/hadoop/hdds/scm/XceiverClientGrpc.java
@@ -618,8 +618,9 @@ public void initStreamRead(BlockID blockID, 
StreamingReaderSpi streamObserver,
           throw new IOException("Failed to get gRPC stub for DataNode: " + dn);
         }
         LOG.debug("initStreamRead {} on datanode {}", 
blockID.getContainerBlockID(), dn);
-        stub.withDeadlineAfter(timeout, TimeUnit.SECONDS)
-            .send(new ReadyAwareResponseObserver(dn, streamObserver));
+        // No deadline: it would bound the entire long-lived streaming call. 
Per-request timeliness is
+        // enforced by streamReadTimeout in streamRead() and 
StreamingReader.poll().
+        stub.send(new ReadyAwareResponseObserver(dn, streamObserver));
         return;
       } catch (IOException e) {
         LOG.error("Failed to start streaming read to DataNode {}", dn, e);
diff --git 
a/hadoop-hdds/client/src/main/java/org/apache/hadoop/hdds/scm/storage/StreamBlockInputStream.java
 
b/hadoop-hdds/client/src/main/java/org/apache/hadoop/hdds/scm/storage/StreamBlockInputStream.java
index d773701300e..53620567d08 100644
--- 
a/hadoop-hdds/client/src/main/java/org/apache/hadoop/hdds/scm/storage/StreamBlockInputStream.java
+++ 
b/hadoop-hdds/client/src/main/java/org/apache/hadoop/hdds/scm/storage/StreamBlockInputStream.java
@@ -478,6 +478,15 @@ synchronized void readBlockImpl(long length) throws 
IOException {
         blockID, requestedLength, length, responseDataSize, tokenRef.get(), 
pipelineRef.get()), r);
   }
 
+  /**
+   * A stream killed by a gRPC deadline is a transport failure of the 
long-lived call, not a data error:
+   * fail over to another datanode the same way as UNAVAILABLE. The classic 
per-request path is unaffected.
+   */
+  @Override
+  protected boolean isConnectivityIssue(IOException ex) {
+    return super.isConnectivityIssue(ex) || Status.fromThrowable(ex).getCode() 
== Status.DEADLINE_EXCEEDED.getCode();
+  }
+
   private void handleExceptions(IOException cause) throws IOException {
     IOException root = ConnectionFailureUtils.unwrapCause(cause);
     if (root instanceof StorageContainerException || isConnectivityIssue(root) 
||
diff --git 
a/hadoop-hdds/client/src/test/java/org/apache/hadoop/hdds/scm/storage/TestStreamBlockInputStream.java
 
b/hadoop-hdds/client/src/test/java/org/apache/hadoop/hdds/scm/storage/TestStreamBlockInputStream.java
index d162b80ad94..ce4a66dea3a 100644
--- 
a/hadoop-hdds/client/src/test/java/org/apache/hadoop/hdds/scm/storage/TestStreamBlockInputStream.java
+++ 
b/hadoop-hdds/client/src/test/java/org/apache/hadoop/hdds/scm/storage/TestStreamBlockInputStream.java
@@ -484,6 +484,52 @@ blockID, blockLength, mockStandalonePipeline(), null, 
xceiverClientFactory,
     }
   }
 
+  /**
+   * A stream killed with DEADLINE_EXCEEDED (e.g. by a deadline on the 
long-lived call) must be treated
+   * like any other connectivity failure: fail over to a fresh stream instead 
of surfacing the error.
+   */
+  @Test
+  public void testDeadlineExceededFailsOverToFreshStream() throws Exception {
+    OzoneClientConfig clientConfig = newStreamReadConfig();
+    clientConfig.setReadRetryInterval(0);
+    BlockID blockID = new BlockID(1L, 13L);
+    byte[] data = {1, 2, 3, 4};
+    Pipeline pipeline = mockStandalonePipeline();
+    ClientCallStreamObserver<ContainerCommandRequestProto> requestObserver = 
mock(ClientCallStreamObserver.class);
+    StreamingReadResponse streamingReadResponse = 
mock(StreamingReadResponse.class);
+    
when(streamingReadResponse.getRequestObserver()).thenReturn(requestObserver);
+    
when(streamingReadResponse.getDatanodeDetails()).thenReturn(MockDatanodeDetails.randomDatanodeDetails());
+
+    AtomicInteger initCount = new AtomicInteger();
+    XceiverClientGrpc xceiverClient = mock(XceiverClientGrpc.class);
+    doAnswer(inv -> {
+      StreamingReaderSpi reader = inv.getArgument(1);
+      reader.setStreamingReadResponse(streamingReadResponse);
+      if (initCount.incrementAndGet() == 1) {
+        // First stream is killed by the transport.
+        reader.onError(Status.DEADLINE_EXCEEDED.asRuntimeException());
+      } else {
+        reader.onNext(ContainerCommandResponseProto.newBuilder()
+            .setCmdType(Type.ReadBlock)
+            .setResult(ContainerProtos.Result.SUCCESS)
+            .setReadBlock(buildReadBlockResponse(data))
+            .build());
+      }
+      return null;
+    }).when(xceiverClient).initStreamRead(any(BlockID.class), any(), any());
+
+    XceiverClientFactory xceiverClientFactory = 
mock(XceiverClientFactory.class);
+    
when(xceiverClientFactory.acquireClientForReadData(any(Pipeline.class))).thenReturn(xceiverClient);
+
+    try (StreamBlockInputStream sbis = new StreamBlockInputStream(
+        blockID, data.length, pipeline, null, xceiverClientFactory, 
NO_REFRESH, clientConfig)) {
+      ByteBuffer buf = ByteBuffer.allocate(data.length);
+      assertEquals(data.length, sbis.read(buf), "read should succeed after 
failing over to a fresh stream");
+      assertArrayEquals(data, buf.array());
+    }
+    assertEquals(2, initCount.get(), "a fresh stream should have been 
initialized for the retry");
+  }
+
   private OzoneClientConfig newStreamReadConfig() {
     OzoneClientConfig clientConfig = new OzoneClientConfig();
     clientConfig.setChecksumVerify(false);
diff --git 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/statemachine/DatanodeConfiguration.java
 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/statemachine/DatanodeConfiguration.java
index ea9eb4a1bf5..02861721c31 100644
--- 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/statemachine/DatanodeConfiguration.java
+++ 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/statemachine/DatanodeConfiguration.java
@@ -152,6 +152,8 @@ public class DatanodeConfiguration extends 
ReconfigurableConfig {
   public static final String BLOCK_DELETE_COMMAND_WORKER_INTERVAL =
       "hdds.datanode.block.delete.command.worker.interval";
   public static final Duration BLOCK_DELETE_COMMAND_WORKER_INTERVAL_DEFAULT = 
Duration.ofSeconds(2);
+  public static final String STREAM_READ_FILE_IDLE_TIMEOUT_KEY = 
"hdds.datanode.stream.read.file.idle.timeout";
+  public static final Duration STREAM_READ_FILE_IDLE_TIMEOUT_DEFAULT = 
Duration.ofMinutes(1);
 
   /**
    * Number of threads per volume that Datanode will use for chunk read.
@@ -164,6 +166,16 @@ public class DatanodeConfiguration extends 
ReconfigurableConfig {
   )
   private int numReadThreadPerVolume = 10;
 
+  @Config(key = STREAM_READ_FILE_IDLE_TIMEOUT_KEY,
+      type = ConfigType.TIME,
+      defaultValue = "1m",
+      tags = {DATANODE},
+      description = "How long a streaming ReadBlock stream may stay idle 
before the datanode closes the block file "
+          + "held open for it. The stream itself stays open and the file is 
reopened by the next request, so this "
+          + "only bounds the file descriptors pinned by idle streams."
+  )
+  private Duration streamReadFileIdleTimeout = 
STREAM_READ_FILE_IDLE_TIMEOUT_DEFAULT;
+
   /**
    * SO_BACKLOG value for the gRPC server socket.
    */
@@ -855,6 +867,12 @@ public void validate() {
           BLOCK_DELETE_COMMAND_WORKER_INTERVAL_DEFAULT;
     }
 
+    if (streamReadFileIdleTimeout.isNegative() || 
streamReadFileIdleTimeout.isZero()) {
+      LOG.warn(STREAM_READ_FILE_IDLE_TIMEOUT_KEY + " must be greater than zero 
and was set to {}. Defaulting to {}",
+          streamReadFileIdleTimeout, STREAM_READ_FILE_IDLE_TIMEOUT_DEFAULT);
+      streamReadFileIdleTimeout = STREAM_READ_FILE_IDLE_TIMEOUT_DEFAULT;
+    }
+
     if (rocksdbLogMaxFileSize < 0) {
       LOG.warn(ROCKSDB_LOG_MAX_FILE_SIZE_BYTES_KEY +
               " must be no less than zero and was set to {}. Defaulting to {}",
@@ -1211,6 +1229,10 @@ public int getNumReadThreadPerVolume() {
     return numReadThreadPerVolume;
   }
 
+  public Duration getStreamReadFileIdleTimeout() {
+    return streamReadFileIdleTimeout;
+  }
+
   public void setNumReadThreadPerVolume(int threads) {
     this.numReadThreadPerVolume = threads;
   }
diff --git 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/transport/server/GrpcXceiverService.java
 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/transport/server/GrpcXceiverService.java
index 1516a382688..36e7c8b74f6 100644
--- 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/transport/server/GrpcXceiverService.java
+++ 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/transport/server/GrpcXceiverService.java
@@ -19,7 +19,15 @@
 
 import static 
org.apache.hadoop.hdds.protocol.datanode.proto.XceiverClientProtocolServiceGrpc.getSendMethod;
 
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import java.time.Duration;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.ReentrantLock;
 import 
org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.ContainerCommandRequestProto;
 import 
org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.ContainerCommandResponseProto;
 import org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.Type;
@@ -47,12 +55,24 @@ public class GrpcXceiverService extends
       LOG = LoggerFactory.getLogger(GrpcXceiverService.class);
 
   private final ContainerDispatcher dispatcher;
+  // A streaming ReadBlock call has no deadline, so an idle stream would pin 
its block file indefinitely.
+  // This timer closes the file of any stream idle for longer than the 
timeout; the next request reopens it.
+  private final long streamReadFileIdleTimeoutNanos;
+  private final ScheduledExecutorService idleFileCloser;
   private final ZeroCopyMessageMarshaller<ContainerCommandRequestProto>
       zeroCopyMessageMarshaller = new ZeroCopyMessageMarshaller<>(
           ContainerCommandRequestProto.getDefaultInstance());
 
-  public GrpcXceiverService(ContainerDispatcher dispatcher) {
+  public GrpcXceiverService(ContainerDispatcher dispatcher, Duration 
streamReadFileIdleTimeout,
+      String threadNamePrefix) {
     this.dispatcher = dispatcher;
+    this.streamReadFileIdleTimeoutNanos = streamReadFileIdleTimeout.toNanos();
+    this.idleFileCloser = Executors.newSingleThreadScheduledExecutor(new 
ThreadFactoryBuilder().setDaemon(true)
+        .setNameFormat(threadNamePrefix + "ReadBlockIdleFileCloser").build());
+  }
+
+  public void shutdown() {
+    idleFileCloser.shutdownNow();
   }
 
   /**
@@ -99,15 +119,55 @@ public StreamObserver<ContainerCommandRequestProto> send(
     return new StreamObserver<ContainerCommandRequestProto>() {
       private final AtomicBoolean isClosed = new AtomicBoolean(false);
       private final RandomAccessFileChannel blockFile = new 
RandomAccessFileChannel();
+      // Held while a request is served, so the idle closer never closes the 
file under an in-flight read.
+      private final ReentrantLock requestLock = new ReentrantLock();
+      private volatile long lastRequestNanos;
+      private volatile ScheduledFuture<?> idleFileCheck;
 
       boolean close() {
         if (isClosed.compareAndSet(false, true)) {
+          final ScheduledFuture<?> check = idleFileCheck;
+          if (check != null) {
+            check.cancel(false);
+          }
           blockFile.close();
           return true;
         }
         return false;
       }
 
+      private void scheduleIdleFileCheck(long delayNanos) {
+        try {
+          idleFileCheck = idleFileCloser.schedule(this::closeFileIfIdle, 
delayNanos, TimeUnit.NANOSECONDS);
+        } catch (RejectedExecutionException e) {
+          // Server is shutting down; the file is closed when the stream ends.
+          LOG.debug("Idle file closer is shut down, not scheduling check", e);
+        }
+      }
+
+      private void closeFileIfIdle() {
+        if (isClosed.get()) {
+          return;
+        }
+        final long idleNanos = System.nanoTime() - lastRequestNanos;
+        if (idleNanos < streamReadFileIdleTimeoutNanos || 
!requestLock.tryLock()) {
+          // Not idle for long enough, or a request is in flight: check again 
later.
+          scheduleIdleFileCheck(idleNanos < streamReadFileIdleTimeoutNanos
+              ? streamReadFileIdleTimeoutNanos - idleNanos : 
streamReadFileIdleTimeoutNanos);
+          return;
+        }
+        try {
+          if (blockFile.isOpen()) {
+            LOG.debug("Closing block file of streaming read idle for {}ms", 
TimeUnit.NANOSECONDS.toMillis(idleNanos));
+            blockFile.close();
+          }
+          // The next request reopens the file and schedules a new check.
+          idleFileCheck = null;
+        } finally {
+          requestLock.unlock();
+        }
+      }
+
       @Override
       public void onNext(ContainerCommandRequestProto request) {
         final DispatcherContext context = request.getCmdType() != 
Type.ReadChunk ? null
@@ -115,6 +175,7 @@ public void onNext(ContainerCommandRequestProto request) {
             .setReleaseSupported(true)
             .build();
 
+        requestLock.lock();
         try {
           if (request.getCmdType() == Type.ReadBlock) {
             dispatcher.streamDataReadOnly(request, responseObserver, 
blockFile, context);
@@ -132,6 +193,11 @@ public void onNext(ContainerCommandRequestProto request) {
           if (context != null) {
             context.release();
           }
+          lastRequestNanos = System.nanoTime();
+          if (!isClosed.get() && blockFile.isOpen() && idleFileCheck == null) {
+            scheduleIdleFileCheck(streamReadFileIdleTimeoutNanos);
+          }
+          requestLock.unlock();
         }
       }
 
diff --git 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/transport/server/XceiverServerGrpc.java
 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/transport/server/XceiverServerGrpc.java
index 27d697fea75..93078adf2bd 100644
--- 
a/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/transport/server/XceiverServerGrpc.java
+++ 
b/hadoop-hdds/container-service/src/main/java/org/apache/hadoop/ozone/container/common/transport/server/XceiverServerGrpc.java
@@ -76,6 +76,7 @@ public final class XceiverServerGrpc implements 
XceiverServerSpi {
   private DatanodeID id;
   private Server server;
   private final ContainerDispatcher storageContainer;
+  private final GrpcXceiverService xceiverService;
   private boolean isStarted;
   private DatanodeDetails datanodeDetails;
   private EventLoopGroup eventLoopGroup;
@@ -136,7 +137,8 @@ public XceiverServerGrpc(DatanodeDetails datanodeDetails,
     }
 
     LOG.info("GrpcServer channel type {}", channelType.getSimpleName());
-    GrpcXceiverService xceiverService = new GrpcXceiverService(dispatcher);
+    xceiverService = new GrpcXceiverService(dispatcher, 
dnConf.getStreamReadFileIdleTimeout(),
+        datanodeDetails.threadNamePrefix());
     NettyServerBuilder nettyServerBuilder = NettyServerBuilder.forPort(port)
         .maxInboundMessageSize(OzoneConsts.OZONE_SCM_CHUNK_MAX_SIZE)
         .bossEventLoopGroup(eventLoopGroup)
@@ -234,6 +236,7 @@ public void stop() {
       try {
         server.shutdown();
         server.awaitTermination(5, TimeUnit.SECONDS);
+        xceiverService.shutdown();
         eventLoopGroup.shutdownGracefully().sync();
       } catch (InterruptedException e) {
         LOG.error("failed to shutdown XceiverServerGrpc", e);
diff --git 
a/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/transport/server/TestGrpcXceiverService.java
 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/transport/server/TestGrpcXceiverService.java
new file mode 100644
index 00000000000..1d6fad6f533
--- /dev/null
+++ 
b/hadoop-hdds/container-service/src/test/java/org/apache/hadoop/ozone/container/common/transport/server/TestGrpcXceiverService.java
@@ -0,0 +1,169 @@
+/*
+ * 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.hadoop.ozone.container.common.transport.server;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.atomic.AtomicReference;
+import 
org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.ContainerCommandRequestProto;
+import 
org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.ContainerCommandResponseProto;
+import 
org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.DatanodeBlockID;
+import 
org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.ReadBlockRequestProto;
+import org.apache.hadoop.hdds.protocol.datanode.proto.ContainerProtos.Type;
+import org.apache.hadoop.hdds.utils.io.RandomAccessFileChannel;
+import org.apache.hadoop.ozone.container.common.interfaces.ContainerDispatcher;
+import org.apache.ozone.test.GenericTestUtils;
+import org.apache.ratis.thirdparty.io.grpc.stub.StreamObserver;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Tests for the streaming ReadBlock handling in {@link GrpcXceiverService}: 
the block file held open for a
+ * stream is closed once the stream is idle and reopened by the next request.
+ */
+class TestGrpcXceiverService {
+
+  private static final Duration IDLE_TIMEOUT = Duration.ofMillis(200);
+
+  @TempDir
+  private Path tempDir;
+
+  private GrpcXceiverService service;
+
+  @AfterEach
+  void shutdown() {
+    if (service != null) {
+      service.shutdown();
+    }
+  }
+
+  private static ContainerCommandRequestProto readBlockRequest() {
+    return ContainerCommandRequestProto.newBuilder()
+        .setCmdType(Type.ReadBlock)
+        .setContainerID(1)
+        .setDatanodeUuid("dn")
+        .setReadBlock(ReadBlockRequestProto.newBuilder()
+            
.setBlockID(DatanodeBlockID.newBuilder().setContainerID(1).setLocalID(1))
+            .setOffset(0))
+        .build();
+  }
+
+  /**
+   * Mimics {@code KeyValueHandler.readBlockImpl}: open the block file on 
first use, then serve the request.
+   * The optional hook runs while the request is in flight.
+   */
+  private ContainerDispatcher mockDispatcher(File blockFile, 
AtomicReference<RandomAccessFileChannel> channelRef,
+      Runnable inFlight) throws Exception {
+    ContainerDispatcher dispatcher = mock(ContainerDispatcher.class);
+    doAnswer(inv -> {
+      RandomAccessFileChannel channel = inv.getArgument(2);
+      channelRef.set(channel);
+      if (!channel.isOpen()) {
+        channel.open(blockFile);
+      }
+      inFlight.run();
+      return null;
+    }).when(dispatcher).streamDataReadOnly(any(), any(), any(), any());
+    return dispatcher;
+  }
+
+  @Test
+  void idleStreamClosesBlockFileAndNextRequestReopensIt() throws Exception {
+    File blockFile = Files.createFile(tempDir.resolve("block")).toFile();
+    AtomicReference<RandomAccessFileChannel> channelRef = new 
AtomicReference<>();
+    // Checked while the request is in flight: after onNext returns, the idle 
closer may already have run.
+    AtomicReference<Boolean> openInFlight = new AtomicReference<>();
+    Runnable recordOpen = () -> openInFlight.set(channelRef.get().isOpen());
+    service = new GrpcXceiverService(mockDispatcher(blockFile, channelRef, 
recordOpen), IDLE_TIMEOUT, "test-");
+    StreamObserver<ContainerCommandResponseProto> responseObserver = 
mock(StreamObserver.class);
+    StreamObserver<ContainerCommandRequestProto> requestObserver = 
service.send(responseObserver);
+
+    requestObserver.onNext(readBlockRequest());
+    RandomAccessFileChannel channel = channelRef.get();
+    assertNotNull(channel);
+    assertTrue(openInFlight.get(), "block file should be open while a request 
is served");
+
+    GenericTestUtils.waitFor(() -> !channel.isOpen(), 20, 5000);
+    verify(responseObserver, never()).onError(any());
+    verify(responseObserver, never()).onCompleted();
+
+    openInFlight.set(null);
+    requestObserver.onNext(readBlockRequest());
+    assertTrue(openInFlight.get(), "next request should reopen the block 
file");
+    GenericTestUtils.waitFor(() -> !channel.isOpen(), 20, 5000);
+
+    requestObserver.onCompleted();
+    assertFalse(channel.isOpen());
+    verify(responseObserver).onCompleted();
+  }
+
+  @Test
+  void inFlightRequestKeepsBlockFileOpen() throws Exception {
+    File blockFile = Files.createFile(tempDir.resolve("block")).toFile();
+    AtomicReference<RandomAccessFileChannel> channelRef = new 
AtomicReference<>();
+    AtomicReference<Boolean> openDuringSlowRequest = new AtomicReference<>();
+    Runnable slowRequest = () -> {
+      // Stay in flight well past the idle timeout, then record whether the 
file survived.
+      try {
+        Thread.sleep(3 * IDLE_TIMEOUT.toMillis());
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+      }
+      openDuringSlowRequest.set(channelRef.get().isOpen());
+    };
+    AtomicReference<Runnable> inFlight = new AtomicReference<>(() -> { });
+    service = new GrpcXceiverService(mockDispatcher(blockFile, channelRef, () 
-> inFlight.get().run()),
+        IDLE_TIMEOUT, "test-");
+    StreamObserver<ContainerCommandRequestProto> requestObserver = 
service.send(mock(StreamObserver.class));
+
+    // First request arms the idle check; the second one is still running when 
it fires.
+    requestObserver.onNext(readBlockRequest());
+    inFlight.set(slowRequest);
+    requestObserver.onNext(readBlockRequest());
+    assertTrue(openDuringSlowRequest.get(), "an in-flight request must not 
lose its block file");
+
+    RandomAccessFileChannel channel = channelRef.get();
+    GenericTestUtils.waitFor(() -> !channel.isOpen(), 20, 5000);
+    requestObserver.onCompleted();
+  }
+
+  @Test
+  void errorClosesBlockFileImmediately() throws Exception {
+    File blockFile = Files.createFile(tempDir.resolve("block")).toFile();
+    AtomicReference<RandomAccessFileChannel> channelRef = new 
AtomicReference<>();
+    service = new GrpcXceiverService(mockDispatcher(blockFile, channelRef, () 
-> { }), Duration.ofHours(1), "test-");
+    StreamObserver<ContainerCommandRequestProto> requestObserver = 
service.send(mock(StreamObserver.class));
+
+    requestObserver.onNext(readBlockRequest());
+    assertTrue(channelRef.get().isOpen());
+    requestObserver.onError(new RuntimeException("client went away"));
+    assertFalse(channelRef.get().isOpen(), "stream teardown must close the 
block file");
+  }
+}


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

Reply via email to