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

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


The following commit(s) were added to refs/heads/master by this push:
     new 868ac1713963 [SPARK-57664][CONNECT] Make dropped pipeline events 
observable in PipelineEventSender
868ac1713963 is described below

commit 868ac17139638e7c6a6ed04f74ea576651d21ad0
Author: YangJie <[email protected]>
AuthorDate: Tue Jul 7 12:20:12 2026 +0800

    [SPARK-57664][CONNECT] Make dropped pipeline events observable in 
PipelineEventSender
    
    ### What changes were proposed in this pull request?
    
    `PipelineEventSender` streams pipeline events to a Connect client through a 
single background thread backed by a bounded queue (sized by the event-queue 
capacity conf). When that queue is full it intentionally drops non-terminal 
`FlowProgress` and other non-`RunProgress` events to avoid blocking execution; 
terminal `FlowProgress` events and all `RunProgress` events are always 
enqueued. Until now those drops were completely silent.
    
    This PR makes them observable:
    
    - a running count of dropped events, exposed as `numDroppedEvents`;
    - a warning logged when an event is dropped, throttled to at most once per 
minute (the first drop always logs) so a persistently full queue does not flood 
the logs;
    - a summary warning at shutdown reporting the total, so drops that were 
suppressed by the throttle window are still surfaced.
    
    The throttling follows the approach `AsyncEventQueue` already uses for the 
same "queue full, drop events" situation.
    
    ### Why are the changes needed?
    
    A dropped event is lost progress reporting to the client. Previously a drop 
produced no log line and no counter, so an operator who noticed gaps in 
pipeline progress had no signal that events were being discarded, let alone how 
many.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. The counter is internal; the only outward change is the additional WARN 
log lines emitted when events are dropped.
    
    ### How was this patch tested?
    
    New unit tests in `PipelineEventSenderSuite`:
    
    - a sender that never overflows its queue reports zero dropped events;
    - events dropped at capacity are counted (`numDroppedEvents`);
    - the per-drop warning is throttled (logged once for two in-window drops) 
and the shutdown summary is emitted;
    - the warning is re-logged once the throttle interval has elapsed.
    
    The drop scenarios use a `CountDownLatch` handshake to park the worker 
deterministically rather than relying on timing. The existing capacity test 
continues to cover the enqueue/drop routing.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 4.8)
    
    Closes #56742 from LuciferYang/sdp-event-drop-observability.
    
    Authored-by: YangJie <[email protected]>
    Signed-off-by: yangjie01 <[email protected]>
---
 .../connect/pipelines/PipelineEventSender.scala    |  56 ++++++++++-
 .../pipelines/PipelineEventSenderSuite.scala       | 109 +++++++++++++++++++++
 2 files changed, 164 insertions(+), 1 deletion(-)

diff --git 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/pipelines/PipelineEventSender.scala
 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/pipelines/PipelineEventSender.scala
index 79dd07b6a658..e5e4a8ee6e77 100644
--- 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/pipelines/PipelineEventSender.scala
+++ 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/pipelines/PipelineEventSender.scala
@@ -18,7 +18,7 @@
 package org.apache.spark.sql.connect.pipelines
 
 import java.util.concurrent.ThreadPoolExecutor
-import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.{AtomicBoolean, AtomicLong}
 
 import scala.util.control.NonFatal
 
@@ -62,6 +62,17 @@ class PipelineEventSender(
    */
   private val isShutdown = new AtomicBoolean(false)
 
+  // Running total of non-terminal events dropped because the queue was full.
+  private val droppedEventCount = new AtomicLong(0L)
+
+  // Timestamp (ms) of the last dropped-event warning, used to throttle them. 
Only read/written in
+  // `recordDroppedEvent`, which runs inside the synchronized `sendEvent`, so 
a plain var suffices.
+  private var lastDropWarningTimestamp = 0L
+
+  // Overridable so tests can drive the throttle-reset path without waiting on 
the wall clock.
+  protected def droppedEventLogIntervalMs: Long =
+    PipelineEventSender.DROPPED_EVENT_LOG_INTERVAL_MS
+
   /**
    * Send an event async by submitting it to the executor, if the sender is 
not shut down.
    * Otherwise, throws an IllegalStateException, to raise awareness of the 
shutdown state.
@@ -85,12 +96,39 @@ class PipelineEventSender(
             }
           }
         })
+      } else {
+        recordDroppedEvent(event)
       }
     } else {
       throw 
IllegalStateErrors.eventSendAfterShutdown(sessionHolder.key.toString)
     }
   }
 
+  /**
+   * Record a non-terminal event that was dropped because the queue was at 
capacity. Always bumps
+   * the counter so the running total reported in the warnings stays accurate, 
and logs a warning
+   * at most once per [[droppedEventLogIntervalMs]] (the first drop always 
logs).
+   */
+  private def recordDroppedEvent(event: PipelineEvent): Unit = {
+    val totalDropped = droppedEventCount.incrementAndGet()
+    val now = System.currentTimeMillis()
+    if (now - lastDropWarningTimestamp >= droppedEventLogIntervalMs) {
+      lastDropWarningTimestamp = now
+      logWarning(
+        log"Dropped pipeline event for session " +
+          log"${MDC(LogKeys.SESSION_ID, sessionHolder.sessionId)} because the 
event queue is " +
+          log"full (capacity ${MDC(LogKeys.MAX_SIZE, queueCapacity)}); " +
+          log"${MDC(LogKeys.NUM_EVENTS, totalDropped)} non-terminal event(s) 
dropped so far. " +
+          log"Most recent dropped event: ${MDC(LogKeys.MESSAGE, 
event.message)}")
+    }
+  }
+
+  /**
+   * Total number of non-terminal events dropped because the queue was full. 
Exposed for tests;
+   * production observability is the warning logs emitted on drop and at 
shutdown.
+   */
+  private[connect] def numDroppedEvents: Long = droppedEventCount.get()
+
   private def shouldEnqueueEvent(event: PipelineEvent): Boolean = {
     event.details match {
       case _: RunProgress =>
@@ -127,6 +165,16 @@ class PipelineEventSender(
             log"${MDC(LogKeys.SESSION_ID, sessionHolder.sessionId)} failed to 
terminate")
         executor.shutdownNow()
       }
+      // Summarize total drops at shutdown so drops suppressed by the throttle 
window are still
+      // surfaced.
+      val totalDropped = droppedEventCount.get()
+      if (totalDropped > 0) {
+        logWarning(
+          log"Pipeline event sender for session " +
+            log"${MDC(LogKeys.SESSION_ID, sessionHolder.sessionId)} dropped a 
total of " +
+            log"${MDC(LogKeys.NUM_EVENTS, totalDropped)} non-terminal event(s) 
because the " +
+            log"event queue (capacity ${MDC(LogKeys.MAX_SIZE, queueCapacity)}) 
was full.")
+      }
       logInfo(
         log"Pipeline event sender shutdown completed for session " +
           log"${MDC(LogKeys.SESSION_ID, sessionHolder.sessionId)}")
@@ -174,3 +222,9 @@ class PipelineEventSender(
     protoEventBuilder.build()
   }
 }
+
+object PipelineEventSender {
+  // Minimum interval between dropped-event warnings, to avoid flooding the 
logs when the queue
+  // stays full. Mirrors AsyncEventQueue.LOGGING_INTERVAL.
+  private val DROPPED_EVENT_LOG_INTERVAL_MS = 60L * 1000
+}
diff --git 
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/pipelines/PipelineEventSenderSuite.scala
 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/pipelines/PipelineEventSenderSuite.scala
index d6942132b908..22f620d9d07a 100644
--- 
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/pipelines/PipelineEventSenderSuite.scala
+++ 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/pipelines/PipelineEventSenderSuite.scala
@@ -18,8 +18,10 @@
 package org.apache.spark.sql.connect.pipelines
 
 import java.sql.Timestamp
+import java.util.concurrent.{CountDownLatch, TimeUnit}
 
 import io.grpc.stub.StreamObserver
+import org.apache.logging.log4j.Level
 import org.mockito.{ArgumentCaptor, Mockito}
 import org.mockito.Mockito._
 import org.scalatestplus.mockito.MockitoSugar
@@ -89,6 +91,9 @@ class PipelineEventSenderSuite extends 
SparkDeclarativePipelinesServerTest with
 
       val pipelineEvent = response.getPipelineEventResult.getEvent
       assert(pipelineEvent.getMessage == "Test message")
+
+      // A healthy sender that never overflows its queue must report zero 
dropped events.
+      assert(eventSender.numDroppedEvents == 0)
     } finally {
       eventSender.shutdown()
     }
@@ -230,4 +235,108 @@ class PipelineEventSenderSuite extends 
SparkDeclarativePipelinesServerTest with
       eventSender.shutdown()
     }
   }
+
+  test("PipelineEventSender logs a warning when it drops events due to a full 
queue") {
+    val (mockObserver, mockSessionHolder) = createMockSetup(queueSize = "1")
+
+    // `workerStarted` fires once the background thread has dequeued and begun 
processing the
+    // first event; `release` keeps that thread parked so the queue fills 
deterministically
+    // (no timing assumptions) while we submit the events that must be dropped.
+    val workerStarted = new CountDownLatch(1)
+    val release = new CountDownLatch(1)
+    val eventSender = new PipelineEventSender(mockObserver, mockSessionHolder) 
{
+      override def sendEventToClient(event: PipelineEvent): Unit = {
+        workerStarted.countDown()
+        release.await()
+        super.sendEventToClient(event)
+      }
+    }
+
+    val logAppender = new LogAppender("dropped pipeline event warning")
+    try {
+      withLogAppender(logAppender, level = Some(Level.WARN)) {
+        // Occupy the single worker thread and wait until it is actually 
parked.
+        eventSender.sendEvent(
+          createTestEvent(id = "started", details = 
FlowProgress(FlowStatus.STARTING)))
+        assert(workerStarted.await(10, TimeUnit.SECONDS), "worker thread did 
not start")
+
+        // Fill the single queue slot.
+        eventSender.sendEvent(
+          createTestEvent(id = "queued", details = 
FlowProgress(FlowStatus.RUNNING)))
+
+        // These two non-terminal events have nowhere to go and must be 
dropped.
+        eventSender.sendEvent(
+          createTestEvent(id = "dropped-1", details = 
FlowProgress(FlowStatus.RUNNING)))
+        eventSender.sendEvent(
+          createTestEvent(id = "dropped-2", details = 
FlowProgress(FlowStatus.RUNNING)))
+
+        release.countDown()
+        eventSender.shutdown() // drains the started + queued events and logs 
the drop summary
+      }
+
+      assert(eventSender.numDroppedEvents == 2)
+
+      val warnings = logAppender.loggingEvents
+        .filter(_.getLevel == Level.WARN)
+        .map(_.getMessage.getFormattedMessage)
+      // The two drops are submitted back-to-back, far inside the default 60s 
throttle window, so
+      // the per-drop warning fires exactly once (first logs, second 
suppressed) and the running
+      // total is logged at shutdown. The assertions match on substrings so 
they stay robust to
+      // the exact WARN wording.
+      assert(warnings.count(_.contains("Dropped pipeline event for session")) 
== 1)
+      assert(warnings.exists(_.contains("dropped a total of")))
+    } finally {
+      release.countDown()
+      eventSender.shutdown()
+    }
+  }
+
+  test("PipelineEventSender re-logs a drop warning once the throttle interval 
has elapsed") {
+    val (mockObserver, mockSessionHolder) = createMockSetup(queueSize = "1")
+
+    val workerStarted = new CountDownLatch(1)
+    val release = new CountDownLatch(1)
+    // A negative interval makes the throttle treat every drop as past the 
window, so each drop
+    // re-logs. This exercises the re-log branch deterministically, 
independent of the wall clock
+    // (a zero interval would instead depend on currentTimeMillis() being 
monotonic across drops).
+    val eventSender = new PipelineEventSender(mockObserver, mockSessionHolder) 
{
+      override protected def droppedEventLogIntervalMs: Long = Long.MinValue
+      override def sendEventToClient(event: PipelineEvent): Unit = {
+        workerStarted.countDown()
+        release.await()
+        super.sendEventToClient(event)
+      }
+    }
+
+    val logAppender = new LogAppender("throttle reset warning")
+    try {
+      withLogAppender(logAppender, level = Some(Level.WARN)) {
+        eventSender.sendEvent(
+          createTestEvent(id = "started", details = 
FlowProgress(FlowStatus.STARTING)))
+        assert(workerStarted.await(10, TimeUnit.SECONDS), "worker thread did 
not start")
+
+        eventSender.sendEvent(
+          createTestEvent(id = "queued", details = 
FlowProgress(FlowStatus.RUNNING)))
+        eventSender.sendEvent(
+          createTestEvent(id = "dropped-1", details = 
FlowProgress(FlowStatus.RUNNING)))
+        eventSender.sendEvent(
+          createTestEvent(id = "dropped-2", details = 
FlowProgress(FlowStatus.RUNNING)))
+
+        release.countDown()
+        eventSender.shutdown()
+      }
+
+      assert(eventSender.numDroppedEvents == 2)
+
+      val warnings = logAppender.loggingEvents
+        .filter(_.getLevel == Level.WARN)
+        .map(_.getMessage.getFormattedMessage)
+      // With the throttle disabled, both drops re-arm the warning, so the 
per-drop message is
+      // logged once per drop rather than being suppressed.
+      assert(warnings.count(_.contains("Dropped pipeline event for session")) 
== 2)
+    } finally {
+      release.countDown()
+      eventSender.shutdown()
+    }
+  }
 }


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

Reply via email to