sanghyeok An created KAFKA-21401:
------------------------------------

             Summary: Streams static member rejoin with a changed process ID 
loses task ownership tracking and permits premature assignment
                 Key: KAFKA-21401
                 URL: https://issues.apache.org/jira/browse/KAFKA-21401
             Project: Kafka
          Issue Type: Bug
          Components: group-coordinator, streams
            Reporter: sanghyeok An
            Assignee: sanghyeok An


When a static member rejoins a Streams group with a new process ID, inheriting 
its previous task assignment can cause it to hold the same standby task as 
another member on that process.

If one of those members subsequently acknowledges revocation, the coordinator 
removes the process from the task ownership index even though the other member 
still holds the task. A later assignment can then grant the active role to a 
new member on the same process without waiting for the remaining standby owner 
to release the task.

 

*Scenario*

The scenario assumes the following conditions:
 * The group uses the built-in Sticky assignor and the default 
NoOpAssignmentRefiner.
 * There are three stateful tasks, {{{}0_0{}}}, {{{}0_1{}}}, and {{{}0_2{}}}, 
with two standby replicas configured.
 * Process Pold contains static members A1 and A2. Process Q contains member B.
 * A1 and A2 temporarily leave, then rejoin sequentially with their original 
instance IDs, new member IDs, and a new process ID, Pnew.
 * When A2 rejoins, a tie between standby candidates is resolved in favor of 
A2. The resulting target assignment retains Standby {{0_2}} on A2 and removes 
it from A1.
 * Target assignments are recomputed for the relevant rejoins. Before A3 joins 
in the final step, A2 does not produce a metadata or current-assignment update 
that restores its ownership index entry.

In the table below, *A means active* and {*}S means standby{*}. A1 and A2 
denote the logical members across rejoins; their actual member IDs change when 
they rejoin.

The table shows the current assignments and pending revocations tracked by the 
coordinator at each step, rather than the final target assignment.
||Step||Pold||Pnew||Q||
|*1. Initial state*|*A1:* A={{{}0_0{}}} / *A2:* A={{{}0_1{}}}, 
S={{{}0_2{}}}|None|*B:* A={{{}0_2{}}}, S={{{}0_0, 0_1{}}}|
|*2. A1 and A2 temporarily leave (memberEpoch=-2)*|*Retained records only:* A1: 
A={{{}0_0{}}} / A2: A={{{}0_1{}}}, S={{{}0_2{}}}|None|*B:* A={{{}0_2{}}}, 
S={{{}0_0, 0_1{}}}|
|*3. A1 rejoins first on Pnew*|*Only A2's record remains:* A={{{}0_1{}}}, 
S={{{}0_2{}}}|*A1:* A={{{}0_0{}}}, S={{{}0_1, 0_2{}}}|*B:* A={{{}0_2{}}}, 
S={{{}0_0, 0_1{}}}|
|*4. A2 also rejoins on Pnew*|None|*A1:* A={{{}0_0{}}}, S={{{}0_1, 0_2{}}} / 
*A2:* A={{{}0_1{}}}, S={{{}0_2{}}}|*B:* A={{{}0_2{}}}, S={{{}0_0, 0_1{}}}|
|*5. A1 is instructed to revoke its standby tasks*|None|*A1:* A={{{}0_0{}}}, 
S={{{}0_1, 0_2{}}} pending revocation / *A2:* A={{{}0_1{}}}, S={{{}0_2{}}}|*B:* 
A={{{}0_2{}}}, S={{{}0_0, 0_1{}}}|
|*6. A1 acknowledges completed revocation*|None|*A1:* A={{{}0_0{}}} / *A2:* 
A={{{}0_1{}}}, S={{{}0_2{}}}|*B:* A={{{}0_2{}}}, S={{{}0_0, 0_1{}}}|
|*7. B permanently leaves (memberEpoch=-1)*|None|*A1:* A={{{}0_0{}}} / *A2:* 
A={{{}0_1{}}}, S={{{}0_2{}}}|None|
|*8. A new member A3 joins Pnew*|None|*A1:* A={{{}0_0{}}} / *A2:* 
A={{{}0_1{}}}, *S={{{}0_2{}}}* / *A3:* *A={{{}0_2{}}}*|None|

At step 3, the coordinator sees Pold, Pnew, and Q as distinct processes. It can 
therefore assign Standby {{0_2}} to A1 on Pnew while retaining A2's existing 
standby assignment on Pold.

At step 4, A2 moves to Pnew and inherits its previous assignment. Since Standby 
{{0_2}} appears in both its current and target assignments, reconciliation 
treats it as a retained task. It does not undergo the ownership check applied 
to newly assigned tasks. Consequently, A2 retains the standby without waiting 
for A1 to release it, creating duplicate ownership on the same process.

*Loss of ownership tracking*

The standby ownership index stores a {{Set<processId>}} for each task. It does 
not track individual member ownership or the number of owners within a process.
||Step||Standby owners of task 0_2 on Pnew||Processes recorded in the standby 
ownership index||
|*4. Immediately after duplicate assignment*|Both A1 and A2 hold the task|Pnew|
|*5. A1 is pending revocation*|A1 is pending revocation; A2 continues to hold 
the task|Pnew|
|*6. A1 acknowledges completed revocation*|*A2 still holds the task*|*Empty*|

At step 6, processing A1's revocation acknowledgement removes Pnew from the 
index without accounting for A2's continued ownership of Standby {{0_2}} on 
that process.

Subsequent ordinary heartbeats from A2 do not necessarily repair the index. If 
A2 is already stable and no assignment update is generated, the missing entry 
remains absent.

 

*Incorrect assignment*

After B leaves at step 7 and A3 joins at step 8, the Sticky assignor retains 
the existing active tasks on A1 and A2 and assigns the remaining Active {{0_2}} 
to A3's target assignment.

Because A2 still holds Standby {{0_2}} on the same process, the coordinator 
should withhold the active assignment from A3 until A2 releases the standby. 
However, Pnew is missing from the ownership index, so the coordinator does not 
detect the outstanding ownership and includes Active {{0_2}} in A3's heartbeat 
response.

As a result, {*}different members on the same process simultaneously hold the 
standby and active roles for the same task{*}. This violates process-level task 
exclusivity and the cooperative handoff requirement to confirm release by the 
existing owner before granting the task to another member.



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

Reply via email to