linliu-code opened a new issue, #20061:
URL: https://github.com/apache/hudi/issues/20061

   ## Bug Description
   
   In a long-running Spark Thrift Server, `EmbeddedTimelineService` instances 
accumulate over the
   life of the driver JVM. Each retained instance keeps a live Javalin/Jetty 
server and its
   `TimelineService-JettyScheduler` thread pool, so threads and heap grow 
without bound over
   weeks of uptime.
   
   The great majority of timeline services are closed correctly. The problem is 
a small,
   persistent fraction that never are.
   
   **Measured over ~9 days on one driver:**
   
   | Log line | Count |
   | --- | --- |
   | `Starting Timeline server` | 4,664 |
   | `Closing Timeline server` (close attempted) | 4,637 |
   | `Closed Timeline server` (close completed) | 4,635 |
   
   So of 4,664 starts, **27 never had close attempted at all**, and 2 more 
began closing without
   finishing — about **0.6%**, roughly 3.2 leaked per day.
   
   (The driver is live, so counts in this report were sampled a few minutes 
apart and drift by a
   handful between sections. The gaps, which are what matter, are stable.)
   
   **Confirmed on the heap of that driver** (JVM uptime 29 days), via `jmap 
-histo`:
   
       171  io.javalin.Javalin
       171  io.javalin.jetty.JettyServer
       171  org.apache.hudi.timeline.service.TimelineService$Config
       342  
org.apache.hudi.org.apache.jetty.util.thread.ScheduledExecutorScheduler
         1  org.apache.hudi.timeline.service.TimelineService
         1  org.apache.hudi.client.embedded.EmbeddedTimelineService
   
   and 1,361 live threads named `TimelineService-JettyScheduler`.
   
   Two things worth noting there. Only **one** `TimelineService` / 
`EmbeddedTimelineService` object
   survives, so the wrappers are being dereferenced normally — it is the 
Javalin/Jetty servers they
   started that stay alive, rooted by their own running threads. And 1361/171 ≈ 
**7.96 threads per
   retained server**, matching the hardcoded `8` in
   `TimelineService.createApp()`:
   
       ScheduledExecutorScheduler scheduler =
           new ScheduledExecutorScheduler("TimelineService-JettyScheduler", 
true, 8);
   
   which confirms each retained `Javalin` corresponds to one `createApp()` call.
   
   The leak scales with write activity. Three drivers of identical age in the 
same deployment:
   
   | Driver | write activity | `io.javalin.Javalin` | scheduler threads |
   | --- | --- | --- | --- |
   | A | none (read-only) | 0 | 0 |
   | B | light | 4 | 32 |
   | C | heavy | 171 | 1361 |
   
   ## What I was able to rule out
   
   Sharing these so nobody re-walks them:
   
   - **Not the port-binding retry loop.** `startServiceOnPort` creates a fresh 
`Javalin` per attempt,
     which looked like an obvious candidate. Across all 4,664 starts there were 
**zero**
     `could not bind on port` and **zero** `Timeline server start failed` log 
lines.
   - **Not a missing close path.** `TimelineService.close()` does call 
`app.stop()`, and
     `EmbeddedTimelineService.stopForBasePath` does close the server once its 
last base path is
     removed — the wiring is there. (It is however unguarded, which causes a 
separate and much
     smaller failure; see "A second, narrower defect" below.)
   - **Not a missing `writeClient.close()` in the SQL write path.**
     `HoodieSparkSqlWriter` calls `handleWriteClientClosure` in a `finally`, 
plus in a
     `catch (HoodieException)`.
   - **Not the metadata-table writer.** `BaseActionExecutor.writeTableMetadata` 
uses
     try-with-resources; `HoodieBackedTableMetadataWriter.close()` closes its 
write client.
   - **Not an injected-and-unowned server.** `BaseHoodieClient` sets
     `shouldStopTimelineServer = !timelineServer.isPresent()`, so an injected 
server would not be
     stopped by the client — but no production construction site of 
`SparkRDDWriteClient` passes a
     non-empty `Option<EmbeddedTimelineService>`; they all pass 
`Option.empty()`.
   - **Not correlated with logged failures.** Over the same window: 0 
`SparkException`,
     0 `HoodieException`, 0 `Job aborted` in that driver's logs.
   
   ## The one lead I could not close out
   
   Over the same 9-day window on the same driver:
   
   | Log line | Count |
   | --- | --- |
   | `Starting Timeline server` | 4,664 |
   | `Closing write client` | 4,484 |
   
   a gap of **180**, which is much closer to the 171 retained servers than the 
29-line gap in the
   timeline-service close counts. That suggests the missing close is one level 
up — a write client
   that is never closed, rather than a timeline service specifically — and that 
it happens without
   throwing anything that gets logged.
   
   **I could not identify which caller skips the close.** I am reporting the 
observation and the
   eliminations rather than guessing at a mechanism; someone who knows the 
write-client lifecycle
   will likely see it quickly.
   
   ## How to reproduce / measure
   
   On any long-running driver that performs Hudi writes:
   
       # leak rate, from the driver's own logs
       grep -c "Starting Timeline server" <driver log>
       grep -c "Closing Timeline server" <driver log>
       grep -c "Closed Timeline server"  <driver log>
   
       # retained servers and their threads
       jmap -histo <pid> | grep -E 
"io\.javalin\.Javalin$|jetty\.JettyServer$|TimelineService"
       jcmd <pid> Thread.print | grep -c "TimelineService-JettyScheduler"
   
   A driver doing no writes shows zero of everything, which is a useful control.
   
   
   ## A second, narrower defect found in the same data
   
   Splitting the close counts by stage localises a distinct failure. 
`TimelineService.close()` logs
   `Closing Timeline Service` / `Closed Timeline Service` (capital S), while
   `EmbeddedTimelineService.stopForBasePath` logs `Closing Timeline server` / 
`Closed Timeline server`
   (lowercase). Over the same window on the same driver:
   
   | Log line | Count |
   | --- | --- |
   | `Closing Timeline Service` (enters `TimelineService.close()`) | 4,644 |
   | `Closed Timeline Service` (completes it) | 4,641 |
   | `Closing Timeline server` (enters the `stopForBasePath` close block) | 
4,642 |
   | `Closed Timeline server` (completes it) | 4,640 |
   
   So **3 calls entered `TimelineService.close()` and never came out**, 2 of 
which propagated up into
   `stopForBasePath`. (The count of 4,644 > 4,642 also shows `close()` has a 
second caller — the
   `RUNNING_SERVICES` sweep.)
   
   `TimelineService.close()` has no exception handling:
   
       public void close() {
         LOG.info("Closing Timeline Service");
         if (requestHandler != null) this.requestHandler.stop();
         if (this.app != null) { this.app.stop(); this.app = null; }
         this.fsViewsManager.close();
         LOG.info("Closed Timeline Service");
       }
   
   If any of `requestHandler.stop()`, `app.stop()` or `fsViewsManager.close()` 
throws:
   
   - `app = null` never executes, so the Javalin server stays both running and 
referenced
   - `fsViewsManager.close()` is skipped
   - in the caller, `this.server = null` never executes, so **that instance can 
never be closed
     again** — there is no retry path
   
   And it is silent: there were **zero** ERROR-level lines from 
`TimelineService` or
   `EmbeddedTimelineService` across the whole window (the 397 ERROR lines 
present all come from
   `SparkExecuteStatementOperation` and unrelated components). A close failure 
today leaves a
   permanently unclosable server and emits nothing.
   
   This accounts for only ~0.06% of starts, so it is not the main leak above — 
but it is narrow,
   independently fixable, and arguably worse per occurrence because the 
resulting instance is
   unrecoverable. A try/finally that nulls `app` and `server` regardless, plus 
a logged warning,
   would close it.
   
   ## Suggested direction
   
   No fix is proposed for the main leak, since the caller that skips the close 
is unidentified.
   Two adjacent things look worth doing regardless:
   
   1. `startServiceOnPort` leaves the final `app` unstopped if every retry 
fails: `createApp()`
      only stops the previous app at the *start* of the next attempt, and there 
is no cleanup after
      the loop before it throws `IOException`. This was not the cause here 
(zero bind failures) but
      it is a real leak on a path that can be hit.
   2. `NUM_SERVERS_RUNNING` is already maintained and registered as 
`numEmbeddedTimelineServers`.
      Surfacing it, or logging it periodically, would make this class of leak 
visible without a
      heap dump.
   
   ## Proposed fix for the narrow defect
   
   The close path is unobservable and non-idempotent. Over ~9 days on this 
driver there were
   **14,354 captured stack traces** and **zero** of them contained 
`TimelineService`,
   `EmbeddedTimelineService`, `javalin`, `jetty`, `stopForBasePath` or 
`BaseHoodieClient.close` —
   so the exception aborting these closes leaves no trace anywhere, because 
none of the four frames
   in the call chain catches or logs it. (`HoodieSparkSqlWriter` calls 
`handleWriteClientClosure`
   from a `finally`, so the throw also replaces any in-flight exception on its 
way out.)
   
   Two changes make the failure visible and the instance releasable.
   
   `TimelineService.close()` — never skip the field nulling, and say when a 
stage failed:
   
       public void close() {
         LOG.info("Closing Timeline Service");
         try {
           if (requestHandler != null) {
             this.requestHandler.stop();
           }
         } catch (Exception e) {
           LOG.warn("Failed to stop the timeline request handler; continuing 
shutdown", e);
         }
         try {
           if (this.app != null) {
             this.app.stop();
           }
         } catch (Exception e) {
           LOG.warn("Failed to stop the Javalin app; continuing shutdown", e);
         } finally {
           this.app = null;
         }
         try {
           this.fsViewsManager.close();
         } catch (Exception e) {
           LOG.warn("Failed to close the file system view manager", e);
         }
         LOG.info("Closed Timeline Service");
       }
   
   `EmbeddedTimelineService.stopForBasePath()` — release the reference even 
when the close throws,
   so the instance cannot end up permanently unclosable:
   
       if (basePaths.isEmpty() && null != server) {
         LOG.info("Closing Timeline server");
         try {
           this.server.close();
         } catch (Exception e) {
           LOG.warn("Timeline server did not close cleanly; releasing the 
reference anyway", e);
         } finally {
           METRICS_REGISTRY.set(NUM_EMBEDDED_TIMELINE_SERVERS, 
NUM_SERVERS_RUNNING.decrementAndGet());
           this.server = null;
           this.viewManager = null;
         }
         LOG.info("Closed Timeline server");
       }
   
   **What this does and does not achieve.** It removes the unrecoverable state 
— today, if
   `app.stop()` throws, `this.app` and `this.server` stay set and that instance 
can never be closed
   again — and it makes the failure visible for the first time. It does **not** 
guarantee the
   underlying Jetty threads are reclaimed: nulling a reference does not stop a 
server that failed to
   stop. So this converts a silent, unrecoverable leak into a visible one, 
which is what is needed
   to diagnose the remainder. A forced-stop or retry path would be the 
follow-up, and is better
   designed once the WARN above shows what actually throws.
   
   ## Affected lines
   
   The unguarded close is present, unchanged, on every line checked:
   
   | Line | Version | `TimelineService.close()` | `stopForBasePath` |
   | --- | --- | --- | --- |
   | apache/hudi `master` | 1.3.0-SNAPSHOT | no try/catch | no try/catch |
   | 0.x line | 0.x | no try/catch | no try/catch |
   | 1.x line | 1.1.0-SNAPSHOT | no try/catch | no try/catch |
   | 1.2 line | 1.2.0 | no try/catch | no try/catch |
   | 1.3-pre line | 1.3 pre-release | no try/catch | no try/catch |
   
   Only the log formatting differs: newer lines use `log.info("Closing Timeline 
Service with port {}", serverPort)`
   and parameterised SLF4J, older ones use `LOG.info("Closing Timeline 
Service")` or string concatenation.
   Anyone grepping for these lines should match on `Closing Timeline Service` / 
`Closed Timeline Service`
   without assuming the trailing text.
   
   Note also that `EmbeddedTimelineService` has a **second** call site for 
`server.close()` — the
   `RUNNING_SERVICES` sweep — which is equally unguarded on every line. In the 
data above this shows
   up as `Closing Timeline Service` (4,644) exceeding `Closing Timeline server` 
(4,642).
   
   ## Environment
   
   - Hudi: observed on a 0.x-line build; the same code is present on every line 
checked (see Affected lines)
   - Spark 3.5, Scala 2.12, JDK 17
   - Deployment: Spark Thrift Server on Kubernetes, driver uptime 29 days
   - Table type: COW and MOR, metadata table enabled
   - `hoodie.embed.timeline.server.reuse.enabled`: default (`false`)
   
   ## Logs and Stack Trace
   
   No exception — this is a retention issue rather than a failure. The relevant 
lines are the
   INFO-level `Starting Timeline server on port: {}` 
(`TimelineService.startService`) and
   `Closing Timeline server` / `Closed Timeline server`
   (`EmbeddedTimelineService.stopForBasePath`), counted above.
   


-- 
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]

Reply via email to