HIVE-29780: Fix LLAP plugin rendezvous race under concurrent DAGs (NullPointerException in LlapTaskCommunicator.setResponse) - #6659
Conversation
8b20674 to
df2cc85
Compare
|
I'll fix related failures in |
There was a problem hiding this comment.
Pull request overview
This PR addresses a Tez LLAP plugin initialization race where concurrent DAGs in the same JVM could cross-wire LlapTaskCommunicator and LlapTaskSchedulerService, leading to scheduler == null and a NullPointerException in LlapTaskCommunicator.setResponse. It replaces the previous single static-slot rendezvous with a per-ApplicationAttemptId handshake so each DAG’s plugin pair is matched only with its counterpart.
Changes:
- Replace the class-static single “instance” rendezvous with
ConcurrentMap<ApplicationAttemptId, ...>parking/removal on both plugins. - Key the handshake on
TaskCommunicatorContext.getApplicationAttemptId()/TaskSchedulerContext.getApplicationAttemptId()while keeping the existing init lock for atomic pairing.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| llap-tez/src/java/org/apache/hadoop/hive/llap/tezplugins/LlapTaskSchedulerService.java | Parks/removes schedulers by ApplicationAttemptId to avoid cross-DAG plugin pairing. |
| llap-tez/src/java/org/apache/hadoop/hive/llap/tezplugins/LlapTaskCommunicator.java | Parks/removes communicators by ApplicationAttemptId to avoid cross-DAG plugin pairing and documents the race. |
Comments suppressed due to low confidence (1)
llap-tez/src/java/org/apache/hadoop/hive/llap/tezplugins/LlapTaskCommunicator.java:176
- pendingCommunicators entries are never removed if the communicator parks itself (put) and then gets shut down before the matching scheduler is constructed. That can leak the communicator instance and also lets a later scheduler init for the same ApplicationAttemptId pair against a stopped communicator (reintroducing null/incorrect wiring). Consider storing the appAttemptId on the instance and removing the entry in shutdown (e.g., synchronized on pluginInitLock, pendingCommunicators.remove(appAttemptId, this)).
ApplicationAttemptId appAttemptId = getContext().getApplicationAttemptId();
synchronized (pluginInitLock) {
LlapTaskSchedulerService peer = LlapTaskSchedulerService.pendingSchedulers.remove(appAttemptId);
if (peer != null) {
// We are the last to initialize for this DAG.
peer.setTaskCommunicator(this);
this.setScheduler(peer);
} else {
pendingCommunicators.put(appAttemptId, this);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -158,15 +164,15 @@ public LlapTaskCommunicator( | |||
|
|
|||
| credentialMap = new ConcurrentHashMap<>(); | |||
| sourceStateTracker = new SourceStateTracker(getContext(), this); | |||
| ApplicationAttemptId appAttemptId = getContext().getApplicationAttemptId(); | |||
| synchronized (pluginInitLock) { | |||
| LlapTaskSchedulerService peer = LlapTaskSchedulerService.instance; | |||
| LlapTaskSchedulerService peer = LlapTaskSchedulerService.pendingSchedulers.remove(appAttemptId); | |||
| if (peer != null) { | |||
| // We are the last to initialize. | |||
| // We are the last to initialize for this DAG. | |||
| peer.setTaskCommunicator(this); | |||
| this.setScheduler(peer); | |||
| LlapTaskSchedulerService.instance = null; | |||
| } else { | |||
| instance = this; | |||
| pendingCommunicators.put(appAttemptId, this); | |||
| } | |||
There was a problem hiding this comment.
makes sense, let me try to add a unit test
There was a problem hiding this comment.
added TestLlapPluginRendezvous, maybe it's an overkill, considering that this whole patch is a single JVM multiple DAGs test scenario, and manual testing already proved it
| ApplicationAttemptId appAttemptId = getContext().getApplicationAttemptId(); | ||
| synchronized (LlapTaskCommunicator.pluginInitLock) { | ||
| LlapTaskCommunicator peer = LlapTaskCommunicator.instance; | ||
| LlapTaskCommunicator peer = LlapTaskCommunicator.pendingCommunicators.remove(appAttemptId); | ||
| if (peer != null) { | ||
| // We are the last to initialize. | ||
| // We are the last to initialize for this DAG. | ||
| this.setTaskCommunicator(peer); | ||
| peer.setScheduler(this); | ||
| LlapTaskCommunicator.instance = null; | ||
| } else { | ||
| instance = this; | ||
| pendingSchedulers.put(appAttemptId, this); | ||
| } |
There was a problem hiding this comment.
this makes sense to me
need to be aware that in production, we'll always have a single instance per AM, but taking care of removing the instances properly in the shutdown() method makes sense to me
however, this is not true: "a later communicator init to pair with a stopped scheduler" <- this cannot happen, as both the schedulers and communicators are keyed with ApplicationAttemptId, and every new JVM-local DAGAppMaster gets a new application id...so the risk is not accidental re-pairing, just a simple leak, which has to be avoided
|
…llPointerException in LlapTaskCommunicator.setResponse)
LlapTaskCommunicator and LlapTaskSchedulerService are constructed
independently by the Tez app-master and have to find each other to
wire the callback that reports task starts back to the scheduler
(scheduler.notifyStarted(taskAttemptID) in the SubmitWork response
callback). They used a class-static handshake:
static final Object pluginInitLock = new Object();
static LlapTaskCommunicator instance = null; // one slot
static LlapTaskSchedulerService instance = null;
// in each constructor, under pluginInitLock:
if (peer.instance != null) { pair(); peer.instance = null; }
else { this.instance = this; }
This single-slot handshake is race-prone when multiple DAGs come up
concurrently in the same JVM. The communicator from DAG-A could end up
paired with the scheduler from DAG-B (whichever happened to park itself
in "instance" first), and DAG-A's real communicator was then left with
scheduler == null. The first task submission for that DAG then hit
LlapTaskCommunicator$3.setResponse:540 → scheduler.notifyStarted(...)
→ NPE, and the vertex died with OWN_TASK_FAILURE / return code 2.
A production Tez DAGAppMaster only runs one DAG at a time, so this race
is not reachable there. It shows up in JVMs that host many concurrent
DAGs — MiniHS2, MiniLlapCluster, tests that fan out concurrent inserts.
In a 30-way concurrent insert-only INSERT reproduction on MiniHS2 this
took out ~5 of 30 sessions.
Fix: key the rendezvous on ApplicationAttemptId (both TaskCommunicator
Context and TaskSchedulerContext expose it, and the two plugins for the
same DAG share it). Replace the class-static "instance" slot with a
ConcurrentMap<ApplicationAttemptId, ThisSide>. The pluginInitLock stays
so the pair-or-park section remains atomic per DAG. shutdown() on each
side reaps its own leftover entry via ConcurrentMap.remove(key, this),
so a failed DAG init that only constructed one plugin doesn't leak.
Production impact: none. The map holds at most one entry per DAG init;
in a production AM (one DAG per AM lifetime) that's at most one entry
total, put and removed at plugin construction / shutdown. Nothing on
the task submission or scheduling hot path changes.
Verification (30-way concurrent INSERT into an insert-only managed
partitioned Parquet table on s3a://):
Before: 25 of 30 sessions succeeded, 5 hit the NPE (hive.log had
120 stack traces from Tez task-attempt retries).
After: 30 of 30 sessions succeeded, hive.log has 0 notifyStarted
NPEs. All 30 writers landed in their own
delta_<writeId>_<writeId>_0000 subdirectory as expected for
insert-only ACID.



What changes were proposed in this pull request?
Replace the class-static single-slot rendezvous between
LlapTaskCommunicatorandLlapTaskSchedulerServicewith a per-ApplicationAttemptIdhandshake. Each side now parks itself in aConcurrentMap<ApplicationAttemptId, ThisSide>keyed by the appAttemptId both plugins for the same DAG share (viaTaskCommunicatorContext.getApplicationAttemptId()/TaskSchedulerContext.getApplicationAttemptId()). The existingpluginInitLockstays so the pair-or-park section is atomic per DAG.shutdown()on each side reaps its own leftover entry withConcurrentMap.remove(key, this)so a failed DAG init doesn't leak.Why are the changes needed?
The single-slot handshake pairs plugins across DAGs when multiple DAGs come up concurrently in the same JVM: the communicator from DAG-A ends up bound to the scheduler from DAG-B, DAG-A's real communicator is left with
scheduler == null, and the first task submission hitswhich surfaces as
Vertex Map 1 killed/failed due to:OWN_TASK_FAILURE→MoveTask return code 2. In a 30-way concurrent-INSERT reproduction on MiniHS2 (LLAP) 5 of 30 sessions failed with this NPE.Does this PR introduce any user-facing change?
No. A production Tez
DAGAppMasterruns one DAG at a time so the race is not reachable there, and the map holds at most one entry at any moment; the hot paths (task submission, scheduling) are unchanged.How was this patch tested?
30-way concurrent
INSERTburst on MiniHS2 (clusterType=llap,-DminiHS2.isMetastoreRemote=true) against an insert-only managed partitioned table. Before: 25/30 sessions succeeded, 5 hit the NPE andhive.loghad 120 stack traces from Tez task-attempt retries. After: 30/30 sessions succeeded, zeronotifyStartedNPEs inhive.log, all 30 writers landed in their own subdirectory as expected for insert-only ACID.