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

pjfanning pushed a commit to branch 1.2.x
in repository https://gitbox.apache.org/repos/asf/pekko-connectors-kafka.git


The following commit(s) were added to refs/heads/1.2.x by this push:
     new d108ba41 Clear lastRevoked after use to avoid wiping buffers under 
cooperative rebalancing (#632)
d108ba41 is described below

commit d108ba41a6340abbc69caf242bb38cd128b1695b
Author: PJ Fanning <[email protected]>
AuthorDate: Mon Aug 17 10:51:00 2026 +0100

    Clear lastRevoked after use to avoid wiping buffers under cooperative 
rebalancing (#632)
    
    * Clear lastRevoked after use to avoid wiping buffers under cooperative 
rebalancing (#617)
    
    * Clear lastRevoked after use to avoid wiping buffers under cooperative 
rebalancing
    
    * With the cooperative rebalance protocol onPartitionsRevoked is only 
invoked on
      members that actually revoke partitions, while onPartitionsAssigned is 
invoked
      on every member in every rebalance. A stale lastRevoked set from an 
earlier
      rebalance was re-applied on such assignment callbacks and filtered 
buffered
      records of partitions that remained assigned, losing records without them
      being processed or committed.
    * Reset lastRevoked after the flush in SingleSourceLogic and SubSourceLogic.
    * Run the RebalanceSpec buffer tests against both the eager and cooperative
      protocols using a cooperative variant of the test assignor.
    * Add CooperativeRebalanceSpec reproducing the record loss for plain and
      partitioned sources; both tests fail without the fix.
    
    Fixes #616
    
    Co-Authored-By: Claude Fable 5 <[email protected]>
    
    * Address review: standard ASF header for new file, drop final vals and 
test logging, apply scalafmt
    
    Co-Authored-By: Claude Fable 5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Fable 5 <[email protected]>
    
    * merge issue
    
    * upgrade CI actions
    
    * Remove pull_request trigger from link-validator workflow
    
    * Add case to handle unexpected messages in rebalance test
    
    ---------
    
    Co-authored-by: Arvind Raghavan <[email protected]>
    Co-authored-by: Claude Fable 5 <[email protected]>
---
 .github/workflows/check-build-test.yml             |  46 +--
 .github/workflows/format.yml                       |   4 +-
 .github/workflows/headers.yml                      |   8 +-
 .github/workflows/link-validator.yml               |  11 +-
 .github/workflows/nightly-pekko.yml                |  20 +-
 .github/workflows/publish-1.0-docs.yml             |   8 +-
 .github/workflows/publish-1.1-docs.yml             |   8 +-
 .github/workflows/publish-nightly.yml              |   8 +-
 .../pekko/kafka/internal/SingleSourceLogic.scala   |   4 +-
 .../pekko/kafka/internal/SubSourceLogic.scala      |   4 +-
 .../kafka/scaladsl/CooperativeRebalanceSpec.scala  | 175 ++++++++
 .../pekko/kafka/scaladsl/RebalanceSpec.scala       | 441 ++++++++++++---------
 12 files changed, 497 insertions(+), 240 deletions(-)

diff --git a/.github/workflows/check-build-test.yml 
b/.github/workflows/check-build-test.yml
index 4185ae2d..0f17f4b0 100644
--- a/.github/workflows/check-build-test.yml
+++ b/.github/workflows/check-build-test.yml
@@ -24,7 +24,7 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
         with:
           # we don't know what commit the last tag was it's safer to get 
entire repo so previousStableVersion resolves
           fetch-depth: 0
@@ -38,16 +38,16 @@ jobs:
 
 
       - name: Setup Java 11
-        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # 
v4.7.1
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
         with:
           distribution: temurin
           java-version: 11
 
       - name: Install sbt
-        uses: sbt/setup-sbt@6c68d2fe8dfbc0a0534d70101baa2e0420e1a506 # v1.1.9
+        uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6
 
       - name: Cache Coursier cache
-        uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # 
v6.4.7
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
8.1.1
 
       - name: Code style check and binary-compatibility check
         # Run locally with: sbt 'javafmtCheckAll; mimaReportBinaryIssues'
@@ -58,7 +58,7 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
         with:
           fetch-depth: 0
           fetch-tags: true
@@ -70,13 +70,13 @@ jobs:
           git checkout scratch
 
       - name: Setup Java 11
-        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # 
v4.7.1
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
         with:
           distribution: temurin
           java-version: 11
 
       - name: Cache Coursier cache
-        uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # 
v6.4.7
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
8.1.1
 
       - name: Compile all code with fatal warnings for Java 11 and Scala 
2.12/2.13/3
         # Run locally with: env CI=true sbt 'clean ; Test/compile'
@@ -87,7 +87,7 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
         with:
           fetch-depth: 0
           fetch-tags: true
@@ -99,16 +99,16 @@ jobs:
           git checkout scratch
 
       - name: Setup Java 11
-        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # 
v4.7.1
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
         with:
           distribution: temurin
           java-version: 11
 
       - name: Install sbt
-        uses: sbt/setup-sbt@6c68d2fe8dfbc0a0534d70101baa2e0420e1a506 # v1.1.9
+        uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6
 
       - name: Cache Coursier cache
-        uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # 
v6.4.7
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
8.1.1
 
       - name: Create all API docs for artifacts/website and all reference docs
         run: sbt "doc ; unidoc ; docs/paradoxBrowse"
@@ -129,7 +129,7 @@ jobs:
           - { java-version: 11, scala-version: 3.3, sbt-opts: 
'-J-XX:+UnlockExperimentalVMOptions -J-XX:+UseJVMCICompiler' }
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
         with:
           fetch-depth: 0
           fetch-tags: true
@@ -141,16 +141,16 @@ jobs:
           git checkout scratch
 
       - name: Setup Java ${{ matrix.java-version }}
-        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # 
v4.7.1
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
         with:
           distribution: temurin
           java-version: ${{ matrix.java-version }}
 
       - name: Install sbt
-        uses: sbt/setup-sbt@6c68d2fe8dfbc0a0534d70101baa2e0420e1a506 # v1.1.9
+        uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6
 
       - name: Cache Coursier cache
-        uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # 
v6.4.7
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
8.1.1
 
       - name: Run tests with Scala ${{ matrix.scala-version }} and Java ${{ 
matrix.java-version }}
         run: sbt "++${{ matrix.scala-version }} test" ${{ matrix.sbt-opts }}
@@ -165,7 +165,7 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
         with:
           fetch-depth: 0
           fetch-tags: true
@@ -177,16 +177,16 @@ jobs:
           git checkout scratch
 
       - name: Setup Java 8
-        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # 
v4.7.1
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
         with:
           distribution: temurin
           java-version: 8
 
       - name: Install sbt
-        uses: sbt/setup-sbt@6c68d2fe8dfbc0a0534d70101baa2e0420e1a506 # v1.1.9
+        uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6
 
       - name: Cache Coursier cache
-        uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # 
v6.4.7
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
8.1.1
 
       - name: Run multi-broker and long running integration tests
         run: sbt "int-tests/test"
@@ -200,7 +200,7 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
         with:
           fetch-depth: 0
           fetch-tags: true
@@ -212,16 +212,16 @@ jobs:
           git checkout scratch
 
       - name: Setup Java 8
-        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # 
v4.7.1
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
         with:
           distribution: temurin
           java-version: 8
 
       - name: Install sbt
-        uses: sbt/setup-sbt@6c68d2fe8dfbc0a0534d70101baa2e0420e1a506 # v1.1.9
+        uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6
 
       - name: Cache Coursier cache
-        uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # 
v6.4.7
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
8.1.1
 
       - name: "Compile benchmark tests"
         run: sbt "benchmarks/Test/compile"
diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml
index f31104de..64f08b27 100644
--- a/.github/workflows/format.yml
+++ b/.github/workflows/format.yml
@@ -12,12 +12,12 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - name: Checkout current branch (full)
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
         with:
           fetch-depth: 0
           persist-credentials: false
 
       - name: Check project is formatted
-        uses: jrouly/scalafmt-native-action@v4
+        uses: jrouly/scalafmt-native-action@v5
         with:
           arguments: '--list --mode diff-ref=origin/main'
diff --git a/.github/workflows/headers.yml b/.github/workflows/headers.yml
index 830d0f80..3c4ce202 100644
--- a/.github/workflows/headers.yml
+++ b/.github/workflows/headers.yml
@@ -11,19 +11,19 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
 
       - name: Setup Java 11
-        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # 
v4.7.1
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
         with:
           distribution: temurin
           java-version: 11
 
       - name: Install sbt
-        uses: sbt/setup-sbt@6c68d2fe8dfbc0a0534d70101baa2e0420e1a506 # v1.1.9
+        uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6
 
       - name: Cache Coursier cache
-        uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # 
v6.4.7
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
8.1.1
 
       - name: Check headers
         run: |-
diff --git a/.github/workflows/link-validator.yml 
b/.github/workflows/link-validator.yml
index 96479f71..aeba0f8f 100644
--- a/.github/workflows/link-validator.yml
+++ b/.github/workflows/link-validator.yml
@@ -3,7 +3,6 @@ name: Link Validator
 permissions: {}
 
 on:
-  pull_request:
   workflow_dispatch:
   schedule:
     - cron:  '0 6 * * 1'
@@ -13,25 +12,25 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
         with:
           fetch-depth: 0
           fetch-tags: true
 
       - name: Setup Java 8
-        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # 
v4.7.1
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
         with:
           distribution: temurin
           java-version: 8
 
       - name: Install sbt
-        uses: sbt/setup-sbt@6c68d2fe8dfbc0a0534d70101baa2e0420e1a506 # v1.1.9
+        uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6
 
       - name: Cache Coursier cache
-        uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # 
v6.4.7
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
8.1.1
 
       - name: Setup Coursier
-        uses: coursier/setup-action@039f736548afa5411c1382f40a5bd9c2d30e0383 # 
v1.3.9
+        uses: coursier/setup-action@fd1707a76b027efdfb66ca79318b4d29b72e5a02 # 
v3.0.0
 
       - name: Build Documentation
         run: |-
diff --git a/.github/workflows/nightly-pekko.yml 
b/.github/workflows/nightly-pekko.yml
index e57d94c2..48947201 100644
--- a/.github/workflows/nightly-pekko.yml
+++ b/.github/workflows/nightly-pekko.yml
@@ -27,25 +27,25 @@ jobs:
         PEKKO_VERSION: ['main', '1.0.x']
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
         with:
           fetch-depth: 0
           fetch-tags: true
 
       - name: Set up JDK ${{ matrix.JDK }}
-        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # 
v4.7.1
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
         with:
           distribution: temurin
           java-version: ${{ matrix.JDK }}
 
       - name: Install sbt
-        uses: sbt/setup-sbt@6c68d2fe8dfbc0a0534d70101baa2e0420e1a506 # v1.1.9
+        uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6
 
       - name: Cache Coursier cache
-        uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # 
v6.4.7
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
8.1.1
 
       - name: Cache Build Target
-        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
+        uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
         with:
           path: project/**/target
           key: build-target-${{ hashFiles('**/*.sbt', 
'project/build.properties', 'project/**/*.scala') }}
@@ -68,25 +68,25 @@ jobs:
         PEKKO_VERSION: ['main', '1.0.x']
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
         with:
           fetch-depth: 0
           fetch-tags: true
 
       - name: Setup Java 8
-        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # 
v4.7.1
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
         with:
           distribution: temurin
           java-version: 8
 
       - name: Install sbt
-        uses: sbt/setup-sbt@6c68d2fe8dfbc0a0534d70101baa2e0420e1a506 # v1.1.9
+        uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6
 
       - name: Cache Coursier cache
-        uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # 
v6.4.7
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
8.1.1
 
       - name: Cache Build Target
-        uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
+        uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
         with:
           path: project/**/target
           key: build-target-${{ hashFiles('**/*.sbt', 
'project/build.properties', 'project/**/*.scala') }}
diff --git a/.github/workflows/publish-1.0-docs.yml 
b/.github/workflows/publish-1.0-docs.yml
index 95d5bda9..8199c7e3 100644
--- a/.github/workflows/publish-1.0-docs.yml
+++ b/.github/workflows/publish-1.0-docs.yml
@@ -10,23 +10,23 @@ jobs:
     if: github.repository == 'apache/pekko-connectors-kafka'
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
         with:
           fetch-depth: 0
           fetch-tags: true
           ref: 1.0.x
 
       - name: Setup Java 8
-        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # 
v4.7.1
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
         with:
           distribution: temurin
           java-version: 8
 
       - name: Install sbt
-        uses: sbt/setup-sbt@6c68d2fe8dfbc0a0534d70101baa2e0420e1a506 # v1.1.9
+        uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6
 
       - name: Cache Coursier cache
-        uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # 
v6.4.7
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
8.1.1
 
       - name: Build Documentation
         run: |-
diff --git a/.github/workflows/publish-1.1-docs.yml 
b/.github/workflows/publish-1.1-docs.yml
index 3c542fe9..bed9e19a 100644
--- a/.github/workflows/publish-1.1-docs.yml
+++ b/.github/workflows/publish-1.1-docs.yml
@@ -10,22 +10,22 @@ jobs:
     if: github.repository == 'apache/pekko-connectors-kafka'
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
         with:
           fetch-depth: 0
           fetch-tags: true
 
       - name: Setup Java 8
-        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # 
v4.7.1
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
         with:
           distribution: temurin
           java-version: 8
 
       - name: Install sbt
-        uses: sbt/setup-sbt@6c68d2fe8dfbc0a0534d70101baa2e0420e1a506 # v1.1.9
+        uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6
 
       - name: Cache Coursier cache
-        uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # 
v6.4.7
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
8.1.1
 
       - name: Build Documentation
         run: |-
diff --git a/.github/workflows/publish-nightly.yml 
b/.github/workflows/publish-nightly.yml
index de7704d5..ec014d14 100644
--- a/.github/workflows/publish-nightly.yml
+++ b/.github/workflows/publish-nightly.yml
@@ -32,19 +32,19 @@ jobs:
     if: github.repository == 'apache/pekko-connectors-kafka'
     steps:
       - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 
v4.2.2
+        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 
v6.0.3
         with:
           fetch-depth: 0
           fetch-tags: true
 
       - name: Setup Java 8
-        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # 
v4.7.1
+        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # 
v5.2.0
         with:
           distribution: temurin
           java-version: 8
 
       - name: Install sbt
-        uses: sbt/setup-sbt@6c68d2fe8dfbc0a0534d70101baa2e0420e1a506 # v1.1.9
+        uses: sbt/setup-sbt@bfea3c5f48abd221b04a6df4798aa5eb8b6a2baf # v1.5.6
 
       - name: Publish to Apache Maven repo
         env:
@@ -53,7 +53,7 @@ jobs:
         run: sbt +publish
 
       - name: Cache Coursier cache
-        uses: coursier/cache-action@4e2615869d13561d626ed48655e1a39e5b192b3c # 
v6.4.7
+        uses: coursier/cache-action@95e5b1029b6b86e7bac033ee44a0697d8a527d2d # 
8.1.1
 
       - name: Build Documentation
         run: |-
diff --git 
a/core/src/main/scala/org/apache/pekko/kafka/internal/SingleSourceLogic.scala 
b/core/src/main/scala/org/apache/pekko/kafka/internal/SingleSourceLogic.scala
index c7726ca7..9a36b884 100644
--- 
a/core/src/main/scala/org/apache/pekko/kafka/internal/SingleSourceLogic.scala
+++ 
b/core/src/main/scala/org/apache/pekko/kafka/internal/SingleSourceLogic.scala
@@ -96,8 +96,10 @@ import scala.concurrent.{ Future, Promise }
       override def onRevoke(revokedTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit =
         lastRevoked = revokedTps
 
-      override def onAssign(assignedTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit =
+      override def onAssign(assignedTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit = {
         filterRevokedPartitionsCB.invoke(lastRevoked -- assignedTps)
+        lastRevoked = Set.empty
+      }
 
       override def onLost(lostTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit =
         filterRevokedPartitionsCB.invoke(lostTps)
diff --git 
a/core/src/main/scala/org/apache/pekko/kafka/internal/SubSourceLogic.scala 
b/core/src/main/scala/org/apache/pekko/kafka/internal/SubSourceLogic.scala
index dff06e84..8000a0d1 100644
--- a/core/src/main/scala/org/apache/pekko/kafka/internal/SubSourceLogic.scala
+++ b/core/src/main/scala/org/apache/pekko/kafka/internal/SubSourceLogic.scala
@@ -301,11 +301,13 @@ private class SubSourceLogic[K, V, Msg](
       override def onRevoke(revokedTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit =
         lastRevoked = revokedTps
 
-      override def onAssign(assignedTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit =
+      override def onAssign(assignedTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit = {
         for {
           tp <- lastRevoked -- assignedTps
           control <- subSources.get(tp)
         } control.filterRevokedPartitionsCB.invoke(Set(tp))
+        lastRevoked = Set.empty
+      }
 
       override def onLost(lostTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit =
         for {
diff --git 
a/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/CooperativeRebalanceSpec.scala
 
b/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/CooperativeRebalanceSpec.scala
new file mode 100644
index 00000000..d2a8995f
--- /dev/null
+++ 
b/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/CooperativeRebalanceSpec.scala
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.kafka.scaladsl
+
+import org.apache.pekko
+import pekko.Done
+import pekko.kafka._
+import pekko.kafka.scaladsl.Consumer.Control
+import pekko.kafka.testkit.scaladsl.TestcontainersKafkaLike
+import pekko.stream.scaladsl.{ Keep, Source }
+import pekko.stream.testkit.scaladsl.StreamTestKit.assertAllStagesStopped
+import pekko.stream.testkit.scaladsl.TestSink
+import pekko.testkit.TestProbe
+import org.apache.kafka.clients.consumer.{ ConsumerConfig, ConsumerRecord }
+import org.apache.kafka.common.TopicPartition
+import org.scalatest.Inside
+
+import scala.concurrent.duration._
+import scala.util.Random
+
+/**
+ * With the cooperative rebalance protocol `onPartitionsRevoked` is only 
invoked on members that
+ * actually revoke partitions, while `onPartitionsAssigned` is invoked on 
every member in every
+ * rebalance, possibly with an empty set. These tests reproduce rebalances 
where the revoke
+ * callback is skipped and assert that buffered records of partitions that 
remain assigned are
+ * still delivered (at-least-once).
+ *
+ * Reproduces the scenario of 
https://github.com/apache/pekko-connectors-kafka/issues/616:
+ *  1. a partition is revoked from consumer 1 (its `lastRevoked` state becomes 
non-empty),
+ *  2. the partition is later re-assigned to consumer 1,
+ *  3. an unrelated member joins, completing a rebalance in which consumer 1 
revokes nothing
+ *     (`onPartitionsRevoked` is not invoked) and gains nothing 
(`onPartitionsAssigned` with an
+ *     empty set) while records for the re-assigned partition sit in the 
source stage buffer.
+ *
+ * The test runs against `plainSource` and `plainPartitionedSource` (flattened 
with
+ * `flatMapMerge` so both shapes expose a single stream of records) as the 
underlying stages
+ * implement the revoked-buffer bookkeeping independently. The merge stage of 
the flattened
+ * variant buffers a few records of its own that survive a revoke as 
duplicates, so the tests
+ * assert complete delivery rather than an exact sequence.
+ */
+class CooperativeRebalanceSpec extends SpecBase with TestcontainersKafkaLike 
with Inside {
+
+  implicit override val patienceConfig: PatienceConfig = 
PatienceConfig(30.seconds, 500.millis)
+
+  val partition1 = 1
+  val consumerClientId1 = "consumer-1"
+  val consumerClientId2 = "consumer-2"
+  val consumerClientId3 = "consumer-3"
+
+  private def cooperativeSettings(group: String) =
+    consumerDefaults
+      .withProperty(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "500")
+      .withProperty(
+        ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
+        classOf[CooperativePekkoConnectorsAssignor].getName)
+      .withGroupId(group)
+
+  private def awaitAssigned(rebalanceActor: TestProbe,
+      subscription: AutoSubscription,
+      tps: Set[TopicPartition]): Unit =
+    rebalanceActor.fishForMessage(10.seconds) {
+      case TopicPartitionsAssigned(`subscription`, assigned) if assigned == 
tps  => true
+      case TopicPartitionsAssigned(`subscription`, assigned) if 
assigned.isEmpty => false
+      case _                                                                   
  => false
+    }
+
+  sealed trait SourceCase {
+    def label: String
+    def source(settings: ConsumerSettings[String, String],
+        subscription: AutoSubscription): Source[ConsumerRecord[String, 
String], Control]
+  }
+
+  case object PlainCase extends SourceCase {
+    override val label = "plain source"
+    override def source(settings: ConsumerSettings[String, String],
+        subscription: AutoSubscription): Source[ConsumerRecord[String, 
String], Control] =
+      Consumer.plainSource(settings, subscription)
+  }
+
+  case object PartitionedCase extends SourceCase {
+    override val label = "partitioned source"
+    override def source(settings: ConsumerSettings[String, String],
+        subscription: AutoSubscription): Source[ConsumerRecord[String, 
String], Control] =
+      Consumer
+        .plainPartitionedSource(settings, subscription)
+        .flatMapMerge(breadth = 8, { case (_, records) => records })
+  }
+
+  "Buffered records of partitions that stay assigned" must {
+
+    List(PlainCase, PartitionedCase).foreach { mode =>
+      s"be delivered after a rebalance without revocation (${mode.label})" in 
assertAllStagesStopped {
+        val count = 100L
+        val topicSuffix = Random.nextInt()
+        val topic1 = createTopic(topicSuffix, partitions = 2)
+        val group1 = createGroupId(1)
+        val tp0 = new TopicPartition(topic1, partition0)
+        val tp1 = new TopicPartition(topic1, partition1)
+        val consumerSettings = cooperativeSettings(group1)
+
+        def joinConsumer(clientId: String) = {
+          val rebalanceActor = TestProbe()
+          val subscription = 
Subscriptions.topics(topic1).withRebalanceListener(rebalanceActor.ref)
+          val (control, probe) = Consumer
+            .plainSource(consumerSettings.withClientId(clientId), subscription)
+            .toMat(TestSink())(Keep.both)
+            .run()
+          (control, probe, rebalanceActor, subscription)
+        }
+
+        awaitProduce(produce(topic1, 0 to count.toInt, partition1))
+
+        
PekkoConnectorsAssignor.clientIdToPartitionMap.set(Map(consumerClientId1 -> 
Set(tp0, tp1)))
+
+        val probe1rebalanceActor = TestProbe()
+        val probe1subscription = 
Subscriptions.topics(topic1).withRebalanceListener(probe1rebalanceActor.ref)
+        val (control1, probe1) = mode
+          .source(consumerSettings.withClientId(consumerClientId1), 
probe1subscription)
+          .toMat(TestSink())(Keep.both)
+          .run()
+
+        
probe1rebalanceActor.expectMsg(TopicPartitionsAssigned(probe1subscription, 
Set(tp0, tp1)))
+        probe1.requestNext()
+
+        PekkoConnectorsAssignor.clientIdToPartitionMap.set(
+          Map(consumerClientId1 -> Set(tp0), consumerClientId2 -> Set(tp1)))
+        val (control2, probe2, probe2rebalanceActor, probe2subscription) = 
joinConsumer(consumerClientId2)
+
+        
probe1rebalanceActor.expectMsg(TopicPartitionsRevoked(probe1subscription, 
Set(tp1)))
+        awaitAssigned(probe2rebalanceActor, probe2subscription, Set(tp1))
+
+        
PekkoConnectorsAssignor.clientIdToPartitionMap.set(Map(consumerClientId1 -> 
Set(tp0, tp1)))
+        probe2.cancel()
+        control2.isShutdown.futureValue shouldBe Done
+        awaitAssigned(probe1rebalanceActor, probe1subscription, Set(tp1))
+
+        probe1.requestNext()
+
+        PekkoConnectorsAssignor.clientIdToPartitionMap.set(
+          Map(consumerClientId1 -> Set(tp0, tp1), consumerClientId3 -> 
Set.empty[TopicPartition]))
+        val (control3, probe3, probe3rebalanceActor, probe3subscription) = 
joinConsumer(consumerClientId3)
+
+        
probe1rebalanceActor.expectMsg(TopicPartitionsAssigned(probe1subscription, 
Set.empty))
+        
probe3rebalanceActor.expectMsg(TopicPartitionsAssigned(probe3subscription, 
Set.empty))
+
+        // give the asynchronous buffer filter of the rebalance a chance to 
apply before demanding
+        probe1.expectNoMessage(500.millis)
+
+        probe1.request(count * 3)
+        val values = probe1.receiveWithin(5.seconds).map(_.value)
+        values should contain allElementsOf (1 to count.toInt).map(_.toString)
+
+        probe1.cancel()
+        probe3.cancel()
+        control1.isShutdown.futureValue shouldBe Done
+        control3.isShutdown.futureValue shouldBe Done
+      }
+    }
+  }
+}
diff --git 
a/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/RebalanceSpec.scala 
b/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/RebalanceSpec.scala
index a673428a..a8c8e2b9 100644
--- a/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/RebalanceSpec.scala
+++ b/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/RebalanceSpec.scala
@@ -20,13 +20,13 @@ import java.util.concurrent.atomic.AtomicReference
 import org.apache.pekko
 import pekko.kafka._
 import pekko.kafka.testkit.scaladsl.TestcontainersKafkaLike
+import pekko.util.ccompat.JavaConverters._
 import pekko.stream.scaladsl.{ Keep, Source }
 import pekko.stream.testkit.TestSubscriber
 import pekko.stream.testkit.scaladsl.StreamTestKit.assertAllStagesStopped
 import pekko.stream.testkit.scaladsl.TestSink
 import pekko.testkit.TestProbe
 import pekko.{ Done, NotUsed }
-import pekko.util.ccompat.JavaConverters._
 import org.apache.kafka.clients.consumer.{ ConsumerConfig, 
ConsumerPartitionAssignor, ConsumerRecord }
 import org.apache.kafka.clients.consumer.internals.AbstractPartitionAssignor
 import org.apache.kafka.common.TopicPartition
@@ -45,197 +45,241 @@ class RebalanceSpec extends SpecBase with 
TestcontainersKafkaLike with Inside {
   final val consumerClientId1 = "consumer-1"
   final val consumerClientId2 = "consumer-2"
 
+  sealed trait ProtocolCase {
+    def label: String
+    def assignor: Class[?]
+
+    /** Expected rebalance listener events after the second consumer joins and 
takes tp1. */
+    def expectSecondConsumerJoin(
+        probe1rebalanceActor: TestProbe,
+        probe1subscription: AutoSubscription,
+        probe2rebalanceActor: TestProbe,
+        probe2subscription: AutoSubscription,
+        tp0: TopicPartition,
+        tp1: TopicPartition): Unit
+  }
+
+  case object EagerCase extends ProtocolCase {
+    override val label = "eager"
+    override val assignor: Class[?] = classOf[PekkoConnectorsAssignor]
+
+    override def expectSecondConsumerJoin(
+        probe1rebalanceActor: TestProbe,
+        probe1subscription: AutoSubscription,
+        probe2rebalanceActor: TestProbe,
+        probe2subscription: AutoSubscription,
+        tp0: TopicPartition,
+        tp1: TopicPartition): Unit = {
+      
probe1rebalanceActor.expectMsg(TopicPartitionsRevoked(probe1subscription, 
Set(tp0, tp1)))
+      
probe1rebalanceActor.expectMsg(TopicPartitionsAssigned(probe1subscription, 
Set(tp0)))
+      
probe2rebalanceActor.expectMsg(TopicPartitionsAssigned(probe2subscription, 
Set(tp1)))
+    }
+  }
+
+  case object CooperativeCase extends ProtocolCase {
+    override val label = "cooperative"
+    override val assignor: Class[?] = 
classOf[CooperativePekkoConnectorsAssignor]
+
+    override def expectSecondConsumerJoin(
+        probe1rebalanceActor: TestProbe,
+        probe1subscription: AutoSubscription,
+        probe2rebalanceActor: TestProbe,
+        probe2subscription: AutoSubscription,
+        tp0: TopicPartition,
+        tp1: TopicPartition): Unit = {
+      // tp1 is revoked from consumer 1 and assigned to consumer 2 in a 
follow-up rebalance.
+      // Intermediate empty assignments are not asserted: members that fail 
the intermediate
+      // generation's sync with REBALANCE_IN_PROGRESS skip its assignment 
callback entirely.
+      
probe1rebalanceActor.expectMsg(TopicPartitionsRevoked(probe1subscription, 
Set(tp1)))
+      probe2rebalanceActor.fishForMessage(10.seconds) {
+        case TopicPartitionsAssigned(`probe2subscription`, assigned) if 
assigned == Set(tp1) => true
+        case TopicPartitionsAssigned(`probe2subscription`, assigned) if 
assigned.isEmpty     => false
+      }
+    }
+  }
+
   "Fetched records" must {
 
     // The `max.poll.records` controls how many records Kafka fetches 
internally during a poll.
     // issue explained in https://github.com/akka/alpakka-kafka/issues/872
     // this test added with https://github.com/akka/alpakka-kafka/pull/865
-    "be removed from the source stage buffer when a partition is revoked" in 
assertAllStagesStopped {
-      val count = 20L
-      // de-coupling consecutive test runs with crossScalaVersions on build
-      val topicSuffix = Random.nextInt()
-      val topic1 = createTopic(topicSuffix, partitions = 2)
-      val group1 = createGroupId(1)
-      val tp0 = new TopicPartition(topic1, partition0)
-      val tp1 = new TopicPartition(topic1, partition1)
-      val consumerSettings = consumerDefaults
-        .withProperty(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "500") // 500 is 
the default value
-        .withProperty(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG, 
classOf[PekkoConnectorsAssignor].getName)
-        .withGroupId(group1)
-
-      awaitProduce(produce(topic1, 0 to count.toInt, partition1))
-
-      PekkoConnectorsAssignor.clientIdToPartitionMap.set(
-        Map(
-          consumerClientId1 -> Set(tp0, tp1)))
-
-      log.debug("Subscribe to the topic (without demand)")
-      val probe1rebalanceActor = TestProbe()
-      val probe1subscription = 
Subscriptions.topics(topic1).withRebalanceListener(probe1rebalanceActor.ref)
-      val (control1, probe1) = Consumer
-        .plainSource(consumerSettings.withClientId(consumerClientId1), 
probe1subscription)
-        .toMat(TestSink.probe)(Keep.both)
-        .run()
-
-      log.debug("Await initial partition assignment")
-      probe1rebalanceActor.expectMsg(
-        TopicPartitionsAssigned(probe1subscription,
-          Set(new TopicPartition(topic1, partition0), new 
TopicPartition(topic1, partition1))))
-
-      log.debug("read one message from probe1 with partition 1")
-      probe1.requestNext()
-
-      PekkoConnectorsAssignor.clientIdToPartitionMap.set(
-        Map(
-          consumerClientId1 -> Set(tp0),
-          consumerClientId2 -> Set(tp1)))
-
-      log.debug("Subscribe to the topic (without demand)")
-      val probe2rebalanceActor = TestProbe()
-      val probe2subscription = 
Subscriptions.topics(topic1).withRebalanceListener(probe2rebalanceActor.ref)
-      val (control2, probe2) = Consumer
-        .plainSource(consumerSettings.withClientId(consumerClientId2), 
probe2subscription)
-        .toMat(TestSink.probe)(Keep.both)
-        .run()
-
-      log.debug("Await a revoke to consumer 1")
-      probe1rebalanceActor.expectMsg(
-        TopicPartitionsRevoked(probe1subscription,
-          Set(new TopicPartition(topic1, partition0), new 
TopicPartition(topic1, partition1))))
-
-      log.debug("the rebalance finishes")
-      probe1rebalanceActor.expectMsg(
-        TopicPartitionsAssigned(probe1subscription, Set(new 
TopicPartition(topic1, partition0))))
-      probe2rebalanceActor.expectMsg(
-        TopicPartitionsAssigned(probe2subscription, Set(new 
TopicPartition(topic1, partition1))))
-
-      log.debug("resume demand on both consumers")
-      probe1.request(count)
-      probe2.request(count)
-
-      val probe2messages = probe2.expectNextN(count)
-
-      log.debug("no further messages enqueued on probe1 as partition 1 is 
balanced away")
-      probe1.expectNoMessage(500.millis)
-
-      probe2messages should have size count
-
-      probe1.cancel()
-      probe2.cancel()
-
-      control1.isShutdown.futureValue shouldBe Done
-      control2.isShutdown.futureValue shouldBe Done
+    List(EagerCase, CooperativeCase).foreach { mode =>
+      s"be removed from the source stage buffer when a partition is revoked 
(${mode.label})" in assertAllStagesStopped {
+        val count = 20L
+        // de-coupling consecutive test runs with crossScalaVersions on build
+        val topicSuffix = Random.nextInt()
+        val topic1 = createTopic(topicSuffix, partitions = 2)
+        val group1 = createGroupId(1)
+        val tp0 = new TopicPartition(topic1, partition0)
+        val tp1 = new TopicPartition(topic1, partition1)
+        val consumerSettings = consumerDefaults
+          .withProperty(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "500") // 500 
is the default value
+          .withProperty(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG, 
mode.assignor.getName)
+          .withGroupId(group1)
+
+        awaitProduce(produce(topic1, 0 to count.toInt, partition1))
+
+        PekkoConnectorsAssignor.clientIdToPartitionMap.set(
+          Map(
+            consumerClientId1 -> Set(tp0, tp1)))
+
+        log.debug("Subscribe to the topic (without demand)")
+        val probe1rebalanceActor = TestProbe()
+        val probe1subscription = 
Subscriptions.topics(topic1).withRebalanceListener(probe1rebalanceActor.ref)
+        val (control1, probe1) = Consumer
+          .plainSource(consumerSettings.withClientId(consumerClientId1), 
probe1subscription)
+          .toMat(TestSink())(Keep.both)
+          .run()
+
+        log.debug("Await initial partition assignment")
+        probe1rebalanceActor.expectMsg(
+          TopicPartitionsAssigned(probe1subscription,
+            Set(new TopicPartition(topic1, partition0), new 
TopicPartition(topic1, partition1))))
+
+        log.debug("read one message from probe1 with partition 1")
+        probe1.requestNext()
+
+        PekkoConnectorsAssignor.clientIdToPartitionMap.set(
+          Map(
+            consumerClientId1 -> Set(tp0),
+            consumerClientId2 -> Set(tp1)))
+
+        log.debug("Subscribe to the topic (without demand)")
+        val probe2rebalanceActor = TestProbe()
+        val probe2subscription = 
Subscriptions.topics(topic1).withRebalanceListener(probe2rebalanceActor.ref)
+        val (control2, probe2) = Consumer
+          .plainSource(consumerSettings.withClientId(consumerClientId2), 
probe2subscription)
+          .toMat(TestSink())(Keep.both)
+          .run()
+
+        log.debug("Await the rebalance to complete")
+        mode.expectSecondConsumerJoin(probe1rebalanceActor, 
probe1subscription, probe2rebalanceActor,
+          probe2subscription, tp0, tp1)
+
+        log.debug("resume demand on both consumers")
+        probe1.request(count)
+        probe2.request(count)
+
+        val probe2messages = probe2.expectNextN(count)
+
+        log.debug("no further messages enqueued on probe1 as partition 1 is 
balanced away")
+        probe1.expectNoMessage(500.millis)
+
+        probe2messages should have size count
+
+        probe1.cancel()
+        probe2.cancel()
+
+        control1.isShutdown.futureValue shouldBe Done
+        control2.isShutdown.futureValue shouldBe Done
+      }
     }
 
-    "be removed from the partitioned source stage buffer when a partition is 
revoked" in assertAllStagesStopped {
-      def subSourcesWithProbes(
-          partitions: Int,
-          probe: TestSubscriber.Probe[(TopicPartition, 
Source[ConsumerRecord[String, String], NotUsed])])
-          : Seq[(TopicPartition, TestSubscriber.Probe[ConsumerRecord[String, 
String]])] =
-        probe
-          .expectNextN(partitions.toLong)
-          .map {
-            case (tp, subSource) =>
-              (tp, subSource.toMat(TestSink.probe)(Keep.right).run())
+    List(EagerCase, CooperativeCase).foreach { mode =>
+      s"be removed from the partitioned source stage buffer when a partition 
is revoked (${mode.label})" in
+      assertAllStagesStopped {
+        def subSourcesWithProbes(
+            partitions: Int,
+            probe: TestSubscriber.Probe[(TopicPartition, 
Source[ConsumerRecord[String, String], NotUsed])])
+            : Seq[(TopicPartition, TestSubscriber.Probe[ConsumerRecord[String, 
String]])] =
+          probe
+            .expectNextN(partitions.toLong)
+            .map {
+              case (tp, subSource) =>
+                (tp, subSource.toMat(TestSink())(Keep.right).run())
+            }
+
+        def runForSubSource(
+            partition: Int,
+            subSourcesWithProbes: Seq[(TopicPartition, 
TestSubscriber.Probe[ConsumerRecord[String, String]])])(
+            fun: TestSubscriber.Probe[ConsumerRecord[String, String]] => Unit) 
=
+          subSourcesWithProbes
+            .find { case (tp, _) => tp.partition() == partition }
+            .foreach { case (_, probe) => fun(probe) }
+
+        val count = 20L
+        // de-coupling consecutive test runs with crossScalaVersions on build
+        val topicSuffix = Random.nextInt()
+        val topic1 = createTopic(topicSuffix, partitions = 2)
+        val group1 = createGroupId(1)
+        val tp0 = new TopicPartition(topic1, partition0)
+        val tp1 = new TopicPartition(topic1, partition1)
+        val consumerSettings = consumerDefaults
+          .withProperty(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "500") // 500 
is the default value
+          .withProperty(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG, 
mode.assignor.getName)
+          .withGroupId(group1)
+
+        awaitProduce(produce(topic1, 0 to count.toInt, partition1))
+
+        PekkoConnectorsAssignor.clientIdToPartitionMap.set(
+          Map(
+            consumerClientId1 -> Set(tp0, tp1)))
+
+        log.debug("Subscribe to the topic (without demand)")
+        val probe1rebalanceActor = TestProbe()
+        val probe1subscription = 
Subscriptions.topics(topic1).withRebalanceListener(probe1rebalanceActor.ref)
+        val (control1, probe1) = Consumer
+          
.plainPartitionedSource(consumerSettings.withClientId(consumerClientId1), 
probe1subscription)
+          .toMat(TestSink())(Keep.both)
+          .run()
+
+        log.debug("Await initial partition assignment")
+        probe1rebalanceActor.expectMsg(
+          TopicPartitionsAssigned(probe1subscription,
+            Set(new TopicPartition(topic1, partition0), new 
TopicPartition(topic1, partition1))))
+
+        log.debug("read 2 sub sources returned by partitioned source")
+        probe1.request(2)
+        val probe1RunningSubSourceProbes = subSourcesWithProbes(partitions = 
2, probe1)
+
+        log.debug("read one message from probe1 sub source for partition 1")
+        probe1RunningSubSourceProbes
+          .find { case (tp, _) => tp.partition() == partition1 }
+          .foreach { case (_, probe) => probe.requestNext() }
+
+        PekkoConnectorsAssignor.clientIdToPartitionMap.set(
+          Map(
+            consumerClientId1 -> Set(tp0),
+            consumerClientId2 -> Set(tp1)))
+
+        log.debug("Subscribe to the topic (without demand)")
+        val probe2rebalanceActor = TestProbe()
+        val probe2subscription = 
Subscriptions.topics(topic1).withRebalanceListener(probe2rebalanceActor.ref)
+        val (control2, probe2) = Consumer
+          
.plainPartitionedSource(consumerSettings.withClientId(consumerClientId2), 
probe2subscription)
+          .toMat(TestSink())(Keep.both)
+          .run()
+
+        probe2.request(1)
+        val probe2RunningSubSourceProbes = subSourcesWithProbes(partitions = 
1, probe2)
+
+        log.debug("Await the rebalance to complete")
+        mode.expectSecondConsumerJoin(probe1rebalanceActor, 
probe1subscription, probe2rebalanceActor,
+          probe2subscription, tp0, tp1)
+
+        log.debug("resume demand on both consumers")
+        runForSubSource(partition = 1, 
probe1RunningSubSourceProbes)(_.request(count))
+        runForSubSource(partition = 1, 
probe2RunningSubSourceProbes)(_.request(count))
+
+        log.debug("no further messages enqueued on probe1 as partition 1 is 
balanced away")
+        runForSubSource(partition = 1, 
probe1RunningSubSourceProbes)(_.expectComplete())
+
+        val probe2messages = probe2RunningSubSourceProbes
+          .find { case (tp, _) => tp.partition() == partition1 }
+          .toList
+          .flatMap {
+            case (_, probe) =>
+              probe.expectNextN(count)
           }
 
-      def runForSubSource(
-          partition: Int,
-          subSourcesWithProbes: Seq[(TopicPartition, 
TestSubscriber.Probe[ConsumerRecord[String, String]])])(
-          fun: TestSubscriber.Probe[ConsumerRecord[String, String]] => Unit) =
-        subSourcesWithProbes
-          .find { case (tp, _) => tp.partition() == partition }
-          .foreach { case (_, probe) => fun(probe) }
-
-      val count = 20L
-      // de-coupling consecutive test runs with crossScalaVersions on build
-      val topicSuffix = Random.nextInt()
-      val topic1 = createTopic(topicSuffix, partitions = 2)
-      val group1 = createGroupId(1)
-      val tp0 = new TopicPartition(topic1, partition0)
-      val tp1 = new TopicPartition(topic1, partition1)
-      val consumerSettings = consumerDefaults
-        .withProperty(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "500") // 500 is 
the default value
-        .withProperty(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG, 
classOf[PekkoConnectorsAssignor].getName)
-        .withGroupId(group1)
-
-      awaitProduce(produce(topic1, 0 to count.toInt, partition1))
-
-      PekkoConnectorsAssignor.clientIdToPartitionMap.set(
-        Map(
-          consumerClientId1 -> Set(tp0, tp1)))
-
-      log.debug("Subscribe to the topic (without demand)")
-      val probe1rebalanceActor = TestProbe()
-      val probe1subscription = 
Subscriptions.topics(topic1).withRebalanceListener(probe1rebalanceActor.ref)
-      val (control1, probe1) = Consumer
-        
.plainPartitionedSource(consumerSettings.withClientId(consumerClientId1), 
probe1subscription)
-        .toMat(TestSink.probe)(Keep.both)
-        .run()
-
-      log.debug("Await initial partition assignment")
-      probe1rebalanceActor.expectMsg(
-        TopicPartitionsAssigned(probe1subscription,
-          Set(new TopicPartition(topic1, partition0), new 
TopicPartition(topic1, partition1))))
-
-      log.debug("read 2 sub sources returned by partitioned source")
-      probe1.request(2)
-      val probe1RunningSubSourceProbes = subSourcesWithProbes(partitions = 2, 
probe1)
-
-      log.debug("read one message from probe1 sub source for partition 1")
-      probe1RunningSubSourceProbes
-        .find { case (tp, _) => tp.partition() == partition1 }
-        .foreach { case (_, probe) => probe.requestNext() }
-
-      PekkoConnectorsAssignor.clientIdToPartitionMap.set(
-        Map(
-          consumerClientId1 -> Set(tp0),
-          consumerClientId2 -> Set(tp1)))
-
-      log.debug("Subscribe to the topic (without demand)")
-      val probe2rebalanceActor = TestProbe()
-      val probe2subscription = 
Subscriptions.topics(topic1).withRebalanceListener(probe2rebalanceActor.ref)
-      val (control2, probe2) = Consumer
-        
.plainPartitionedSource(consumerSettings.withClientId(consumerClientId2), 
probe2subscription)
-        .toMat(TestSink.probe)(Keep.both)
-        .run()
-
-      probe2.request(1)
-      val probe2RunningSubSourceProbes = subSourcesWithProbes(partitions = 1, 
probe2)
-
-      log.debug("Await a revoke to consumer 1")
-      probe1rebalanceActor.expectMsg(
-        TopicPartitionsRevoked(probe1subscription,
-          Set(new TopicPartition(topic1, partition0), new 
TopicPartition(topic1, partition1))))
-
-      log.debug("the rebalance finishes")
-      probe1rebalanceActor.expectMsg(
-        TopicPartitionsAssigned(probe1subscription, Set(new 
TopicPartition(topic1, partition0))))
-      probe2rebalanceActor.expectMsg(
-        TopicPartitionsAssigned(probe2subscription, Set(new 
TopicPartition(topic1, partition1))))
-
-      log.debug("resume demand on both consumers")
-      runForSubSource(partition = 1, 
probe1RunningSubSourceProbes)(_.request(count))
-      runForSubSource(partition = 1, 
probe2RunningSubSourceProbes)(_.request(count))
-
-      log.debug("no further messages enqueued on probe1 as partition 1 is 
balanced away")
-      runForSubSource(partition = 1, 
probe1RunningSubSourceProbes)(_.expectComplete())
-
-      val probe2messages = probe2RunningSubSourceProbes
-        .find { case (tp, _) => tp.partition() == partition1 }
-        .toList
-        .flatMap {
-          case (_, probe) =>
-            probe.expectNextN(count)
-        }
-
-      probe2messages should have size count
+        probe2messages should have size count
 
-      probe1.cancel()
-      probe2.cancel()
+        probe1.cancel()
+        probe2.cancel()
 
-      control1.isShutdown.futureValue shouldBe Done
-      control2.isShutdown.futureValue shouldBe Done
+        control1.isShutdown.futureValue shouldBe Done
+        control2.isShutdown.futureValue shouldBe Done
+      }
     }
   }
 }
@@ -290,3 +334,38 @@ class PekkoConnectorsAssignor extends 
AbstractPartitionAssignor {
     assignments.toMap.asJava
   }
 }
+
+/**
+ * Variant of [[PekkoConnectorsAssignor]] that uses the cooperative rebalance 
protocol.
+ *
+ * The cooperative protocol requires that a partition never moves directly 
from one member to
+ * another within a single rebalance: it must be absent from all assignments 
for one generation
+ * (revoking it from its previous owner, which triggers a follow-up rebalance) 
before it may be
+ * assigned to its new owner.
+ */
+class CooperativePekkoConnectorsAssignor extends PekkoConnectorsAssignor {
+
+  override def name(): String = "pekko-connector-kafka-test-cooperative"
+
+  override def supportedProtocols(): 
util.List[ConsumerPartitionAssignor.RebalanceProtocol] =
+    util.Arrays.asList(ConsumerPartitionAssignor.RebalanceProtocol.COOPERATIVE)
+
+  override def assign(
+      partitionsPerTopic: util.Map[String, Integer],
+      subscriptions: util.Map[String, ConsumerPartitionAssignor.Subscription])
+      : util.Map[String, util.List[TopicPartition]] = {
+    val desired = super.assign(partitionsPerTopic, subscriptions).asScala
+    val currentOwner: Map[TopicPartition, String] = (for {
+      (memberId, subscription) <- subscriptions.asScala.toSeq
+      tp <- subscription.ownedPartitions().asScala
+    } yield tp -> memberId).toMap
+
+    desired.map {
+      case (memberId, tps) =>
+        val withoutMovingPartitions = tps.asScala.filter { tp =>
+          currentOwner.get(tp).forall(_ == memberId)
+        }
+        memberId -> withoutMovingPartitions.asJava
+    }.asJava
+  }
+}


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

Reply via email to