guwensheng created KAFKA-21070:
----------------------------------
Summary: KafkaStreams StateUpdater Task Handle Leak on Unclean
Shutdown
Key: KAFKA-21070
URL: https://issues.apache.org/jira/browse/KAFKA-21070
Project: Kafka
Issue Type: Bug
Reporter: guwensheng
# KafkaStreams StateUpdater Task Handle Leak on Unclean Shutdown
## Summary
KafkaStreams `TaskManager.shutdownStateUpdater()` fails to close tasks under
certain conditions during unclean shutdown, leading to permanent RocksDB file
descriptor and POSIX advisory lock leaks. This occurs through two distinct code
paths in the `DefaultStateUpdater` and `TaskManager` classes.
**Affected Version**: Apache Kafka 3.9.2 (streams module)
**Severity**: High — leaked RocksDB file handles accumulate across pipeline
restarts, eventually causing `lock hold by current process` errors and
requiring a full process restart.
---
## Bug 1: `shutdownStateUpdater()` has no exception guard — interrupt skips all
task closing logic
### Location
`streams/src/main/java/org/apache/kafka/streams/processor/internals/TaskManager.java`,
lines 1520-1543
### Description
`shutdownStateUpdater()` calls `addToTasksToClose()` which internally calls
`waitForFuture()` → `future.get()` to wait for the StateUpdater thread to
process REMOVE actions. If the calling thread's interrupt flag is set (which
can happen via `ClosedByInterruptException` from NIO operations — this
exception does **not** clear the interrupt flag, unlike
`InterruptedException`), `future.get()` immediately throws
`InterruptedException`.
`waitForFuture()` catches this and throws `IllegalStateException`, which
propagates uncaught through `shutdownStateUpdater()`. As a result:
- `stateUpdater.shutdown()` is **never called** — the StateUpdater thread keeps
running
- `closeTaskDirty()` for all tasks is **never called** — tasks' RocksDB handles
are never released
- `drainExceptionsAndFailedTasks()` is **never called**
### Problematic Code
```java
// TaskManager.java:1520-1543
private void shutdownStateUpdater() {
if (stateUpdater != null) {
final Map<TaskId, CompletableFuture<StateUpdater.RemovedTaskResult>>
futures = new LinkedHashMap<>();
for (final Task task : stateUpdater.getTasks()) {
final CompletableFuture<StateUpdater.RemovedTaskResult> future =
stateUpdater.remove(task.id());
futures.put(task.id(), future);
}
final Set<Task> tasksToCloseClean = new HashSet<>();
final Set<Task> tasksToCloseDirty = new HashSet<>();
addToTasksToClose(futures, tasksToCloseClean, tasksToCloseDirty); // ←
throws if interrupted
stateUpdater.shutdown(Duration.ofMillis(Long.MAX_VALUE)); //
← NEVER REACHED
for (final Task task : tasksToCloseClean) { tasks.addTask(task); }
for (final Task task : tasksToCloseDirty) {
closeTaskDirty(task, false); //
← NEVER REACHED
}
for (final StateUpdater.ExceptionAndTask exceptionAndTask :
stateUpdater.drainExceptionsAndFailedTasks()) {
closeTaskDirty(exceptionAndTask.task(), false); // ←
NEVER REACHED
}
}
}
```
```java
// TaskManager.java:700-719
private StateUpdater.RemovedTaskResult waitForFuture(final TaskId taskId,
final
CompletableFuture<StateUpdater.RemovedTaskResult> future) {
try {
removedTaskResult = future.get(); // ← throws InterruptedException if
flag set
// ...
} catch (final InterruptedException shouldNotHappen) {
Thread.currentThread().interrupt();
log.error(INTERRUPTED_ERROR_MESSAGE, shouldNotHappen);
throw new IllegalStateException(INTERRUPTED_ERROR_MESSAGE,
shouldNotHappen); // ← propagates, no guard in caller
}
}
```
### Why the Interrupt Flag Persists
When a thread is interrupted during NIO channel operations (e.g.,
`EPoll.wait()` → `Selector.select()`), the JVM throws
`ClosedByInterruptException` (a subclass of
`AsynchronousCloseException`/`IOException`). **Unlike `InterruptedException`,
`ClosedByInterruptException` does NOT clear the thread's interrupt flag.** This
means any subsequent blocking call that checks the interrupt flag (such as
`Future.get()`, `LockSupport.park()`, `Object.wait()`) will immediately throw.
The `StreamThread.completeShutdown()` method is called from the `finally` block
of `StreamThread.run()`:
```java
// StreamThread.java:726-730
} catch (final Throwable e) {
streamsUncaughtExceptionHandler.accept(e, false);
} finally {
completeShutdown(cleanRun); // ← called while interrupt flag may still be
set
}
```
`completeShutdown()` calls `taskManager.shutdown(false)` →
`shutdownStateUpdater()` → `waitForFuture()` → `future.get()`, which
immediately fails due to the persistent interrupt flag.
### Suggested Fix
Wrap the `addToTasksToClose()` call in a try-catch, and ensure
`stateUpdater.shutdown()` and `closeTaskDirty()` are always called:
```java
private void shutdownStateUpdater() {
if (stateUpdater != null) {
final Map<TaskId, CompletableFuture<StateUpdater.RemovedTaskResult>>
futures = new LinkedHashMap<>();
for (final Task task : stateUpdater.getTasks()) {
final CompletableFuture<StateUpdater.RemovedTaskResult> future =
stateUpdater.remove(task.id());
futures.put(task.id(), future);
}
final Set<Task> tasksToCloseClean = new HashSet<>();
final Set<Task> tasksToCloseDirty = new HashSet<>();
try {
addToTasksToClose(futures, tasksToCloseClean, tasksToCloseDirty);
} catch (final RuntimeException e) {
log.warn("Failed to wait for state updater futures, will close all
tasks dirty", e);
// Add all tasks from stateUpdater to dirty close set
for (final Task task : stateUpdater.getTasks()) {
tasksToCloseDirty.add(task);
}
}
stateUpdater.shutdown(Duration.ofMillis(Long.MAX_VALUE));
for (final Task task : tasksToCloseClean) { tasks.addTask(task); }
for (final Task task : tasksToCloseDirty) { closeTaskDirty(task,
false); }
for (final StateUpdater.ExceptionAndTask exceptionAndTask :
stateUpdater.drainExceptionsAndFailedTasks()) {
closeTaskDirty(exceptionAndTask.task(), false);
}
}
}
```
---
## Bug 2: `StateUpdaterThread.run()` finally block discards tasks without
closing them
### Location
`streams/src/main/java/org/apache/kafka/streams/processor/internals/DefaultStateUpdater.java`,
lines 144-160
### Description
When the StateUpdaterThread stops (either normally or due to an exception), its
`finally` block calls `clearInputQueue()` and `clearUpdatingAndPausedTasks()`.
Both methods simply clear their respective collections **without closing any
tasks**. Additionally, the `restoredActiveTasks` and `removedTasks` queues are
**not cleared at all** in the finally block.
This means any task that was in any of these queues when the thread stops will
have its RocksDB store left open, leaking file descriptors and POSIX locks.
### Problematic Code
```java
// DefaultStateUpdater.java:144-160
@Override
public void run() {
log.info("State updater thread started");
try {
while (isRunning.get()) {
runOnce();
}
} catch (final RuntimeException anyOtherException) {
handleRuntimeException(anyOtherException);
} finally {
clearInputQueue(); // discards pending ADD/REMOVE actions
clearUpdatingAndPausedTasks(); // clears maps WITHOUT closing tasks
// restoredActiveTasks: NOT CLEARED AT ALL
// removedTasks: NOT CLEARED AT ALL
updaterMetrics.clear();
shutdownGate.countDown();
log.info("State updater thread stopped");
}
}
private void clearUpdatingAndPausedTasks() {
updatingTasks.clear(); // ← Task objects are GC'd, but RocksDB native
handles/POSIX locks are NOT released
pausedTasks.clear();
changelogReader.clear();
}
private void clearInputQueue() {
tasksAndActionsLock.lock();
try {
tasksAndActions.clear(); // ← Pending REMOVE actions are lost;
futures never complete
} finally {
tasksAndActionsLock.unlock();
}
}
```
### Task Queues Not Cleaned in finally
The `getStreamOfTasks()` method (line 1027-1048) shows all the queues that hold
Task references:
```java
private Stream<Task> getStreamOfTasks() {
return Stream.concat(
getStreamOfNonPausedTasks(),
getPausedTasks().stream()
);
}
private Stream<Task> getStreamOfNonPausedTasks() {
return Stream.concat(
tasksAndActions.stream()
.filter(taskAndAction -> taskAndAction.action() == Action.ADD)
.map(TaskAndAction::task),
Stream.concat(
getUpdatingTasks().stream(),
Stream.concat(
restoredActiveTasks.stream(), // ← NOT cleared in finally
Stream.concat(
exceptionsAndFailedTasks.stream().map(ExceptionAndTask::task), // ← NOT cleared
removedTasks.stream())))); // ← NOT cleared
}
```
### Suggested Fix
The finally block should close all tasks in all queues before clearing them:
```java
@Override
public void run() {
log.info("State updater thread started");
try {
while (isRunning.get()) {
runOnce();
}
} catch (final RuntimeException anyOtherException) {
handleRuntimeException(anyOtherException);
} finally {
clearInputQueue();
closeAndClearAllTasks(); // ← new method: close all tasks before
clearing
updaterMetrics.clear();
shutdownGate.countDown();
log.info("State updater thread stopped");
}
}
private void closeAndClearAllTasks() {
// Close all tasks in all queues
final List<Task> allTasks = new ArrayList<>();
allTasks.addAll(getUpdatingTasks());
allTasks.addAll(getPausedTasks());
restoredActiveTasksLock.lock();
try {
allTasks.addAll(restoredActiveTasks);
} finally {
restoredActiveTasksLock.unlock();
}
exceptionsAndFailedTasksLock.lock();
try {
exceptionsAndFailedTasks.stream()
.map(ExceptionAndTask::task)
.forEach(allTasks::add);
} finally {
exceptionsAndFailedTasksLock.unlock();
}
allTasks.addAll(removedTasks);
for (final Task task : allTasks) {
try {
task.closeDirty(); // or appropriate close method
} catch (final RuntimeException e) {
log.warn("Failed to close task " + task.id() + " during state
updater shutdown", e);
}
}
// Now safe to clear
updatingTasks.clear();
pausedTasks.clear();
restoredActiveTasks.clear();
removedTasks.clear();
exceptionsAndFailedTasks.clear();
changelogReader.clear();
}
```
---
## Reproduction Scenario
### Environment
- Apache Kafka 3.9.2 (streams module)
- KafkaStreams with StateUpdater enabled (processing threads)
- Stateful tasks using RocksDB state stores
- 3 KafkaStreams instances in a consumer group
### Trigger Sequence
1. A StreamThread encounters an exception (e.g., `TimeoutException` on
repartition topic position query) and is replaced by a new StreamThread
2. The new StreamThread creates tasks and adds them to the StateUpdater
(RocksDB stores are opened, file descriptors acquired)
3. A missed rebalance causes some tasks to be closed, but tasks in the
StateUpdater (especially standby tasks) may not be closed
4. `KafkaStreams.close()` is called with a short timeout, which starts a
shutdown thread that calls `StreamThread.shutdown()` and waits for the thread
to join
5. If the close times out, the calling application may interrupt the
StreamThread (this is a legitimate use case — the `KafkaStreams` API doesn't
prevent this)
6. The interrupt hits the StreamThread during an NIO operation (e.g.,
`KafkaConsumer.poll()` → `EPoll.wait()`), causing `ClosedByInterruptException`
with the interrupt flag preserved
7. `StreamThread.run()`'s `finally` block calls `completeShutdown(false)`
(unclean)
8. `completeShutdown()` → `taskManager.shutdown(false)` →
`shutdownStateUpdater()` → `waitForFuture()` → `future.get()` immediately
throws `InterruptedException` due to the persistent interrupt flag
9. `stateUpdater.shutdown()` and `closeTaskDirty()` are never called — tasks in
the StateUpdater are never closed
10. RocksDB file descriptors and POSIX advisory locks are permanently leaked
### Observed Evidence
**Log output (abbreviated):**
```
Shutting down unclean
Thread got interrupted. This indicates a bug.
java.lang.IllegalStateException: Thread got interrupted. This indicates a bug.
at TaskManager.waitForFuture(TaskManager.java:718)
at TaskManager.addToTasksToClose(TaskManager.java:678)
at TaskManager.shutdownStateUpdater(TaskManager.java:1529)
at TaskManager.shutdown(TaskManager.java:1478)
at StreamThread.completeShutdown(StreamThread.java:1524)
at StreamThread.run(StreamThread.java:729)
Failed to close task manager due to the following error:
(same stack trace)
Some task directories still locked while closing state, this indicates unclean
shutdown: {1_0=..., 4_0=..., 1_2=...}
State transition from PENDING_SHUTDOWN to DEAD
```
**Process file descriptors:**
```
$ ls -la /proc/<PID>/fd | grep deleted | grep LOCK | wc -l
41 # (deleted) LOCK files still held with POSIX advisory write locks
```
The leaked file descriptors show as `(deleted)` because the state directories
are later deleted, but the POSIX locks on the file descriptors remain active,
preventing new KafkaStreams instances from opening the same RocksDB stores
(`lock hold by current process`).
---
## Impact
1. **RocksDB file descriptor leak**: Each unclean shutdown leaks all tasks
currently in the StateUpdater (updating, paused, restored, removed, and failed
queues). File descriptors are never reclaimed.
2. **POSIX advisory lock leak**: The leaked file descriptors hold POSIX
advisory write locks on RocksDB LOCK files. These locks persist until the
process exits.
3. **Pipeline restart failures**: New KafkaStreams instances cannot open the
same state stores because the POSIX locks are still held, resulting in
`RocksDBException: lock hold by current process`.
4. **Accumulation over time**: In production environments with frequent
pipeline restarts (e.g., due to rebalances or configuration changes), the
leaked handles accumulate and eventually cause the process to run out of file
descriptors.
---
## Comparison: Both Bugs Must Be Fixed
| Bug | Path | Root Cause |
|-----|------|------------|
| Bug 1 | `shutdownStateUpdater()` → `waitForFuture()` → `future.get()` | No
try-catch guard; interrupt flag from NIO `ClosedByInterruptException` causes
immediate failure, skipping all task closing logic |
| Bug 2 | `StateUpdaterThread.run()` finally block |
`clearUpdatingAndPausedTasks()` discards tasks without closing;
`restoredActiveTasks` and `removedTasks` not cleared at all |
**Bug 1** is the primary trigger in the interrupt scenario — even if Bug 2 were
fixed, the tasks would still leak because `stateUpdater.shutdown()` and
`closeTaskDirty()` are never called.
**Bug 2** is a defense-in-depth issue — even in a normal (non-interrupt)
shutdown where `stateUpdater.shutdown()` is called, if the StateUpdater
thread's `runOnce()` throws an unexpected `RuntimeException`, the finally block
would still discard tasks without closing them.
Both bugs need to be fixed to ensure RocksDB handles are always properly
released.
## Environment Details
- **Kafka Version**: 3.9.2
- **JDK**: OpenJDK 21
- **OS**: Linux (SUSE/Red Hat based)
- **State Store**: RocksDB (rocksdbjni 7.9.2)
- **Stream Configuration**: Single StreamThread per KafkaStreams instance,
standby tasks enabled, processing threads enabled
---
## Files Involved
| File | Key Lines | Role |
|------|-----------|------|
| `TaskManager.java` | 700-719 (`waitForFuture`), 1520-1543
(`shutdownStateUpdater`), 1477-1518 (`shutdown`) | No interrupt guard in
shutdown path |
| `DefaultStateUpdater.java` | 144-160 (`run` finally), 458-462
(`clearUpdatingAndPausedTasks`), 162-169 (`clearInputQueue`), 1027-1048
(`getStreamOfTasks`) | Tasks discarded without closing in finally block |
| `StreamThread.java` | 1513-1561 (`completeShutdown`), 720-731 (`run` finally)
| Calls `taskManager.shutdown()` while interrupt flag may be set |
--
This message was sent by Atlassian Jira
(v8.20.10#820010)