-
Notifications
You must be signed in to change notification settings - Fork 15.4k
KAFKA-10080; Fix race condition on txn completion which can cause duplicate appends #8782
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,7 +16,10 @@ | |
| */ | ||
| package kafka.coordinator.transaction | ||
|
|
||
| import java.util | ||
| import java.util.Arrays.asList | ||
| import java.util.Collections | ||
| import java.util.concurrent.{Callable, Executors, Future} | ||
|
|
||
| import kafka.common.RequestAndCompletionHandler | ||
| import kafka.metrics.KafkaYammerMetrics | ||
|
|
@@ -34,11 +37,12 @@ import org.junit.Test | |
|
|
||
| import scala.jdk.CollectionConverters._ | ||
| import scala.collection.mutable | ||
| import scala.util.Try | ||
|
|
||
| class TransactionMarkerChannelManagerTest { | ||
| private val metadataCache: MetadataCache = EasyMock.createNiceMock(classOf[MetadataCache]) | ||
| private val networkClient: NetworkClient = EasyMock.createNiceMock(classOf[NetworkClient]) | ||
| private val txnStateManager: TransactionStateManager = EasyMock.createNiceMock(classOf[TransactionStateManager]) | ||
| private val txnStateManager: TransactionStateManager = EasyMock.mock(classOf[TransactionStateManager]) | ||
|
|
||
| private val partition1 = new TopicPartition("topic1", 0) | ||
| private val partition2 = new TopicPartition("topic1", 1) | ||
|
|
@@ -86,6 +90,70 @@ class TransactionMarkerChannelManagerTest { | |
| .anyTimes() | ||
| } | ||
|
|
||
| @Test | ||
| def shouldOnlyWriteTxnCompletionOnce(): Unit = { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this test cover concurrent calls to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It does. I was trying to setup this test to fit how we're likely hitting this in practice. In the call to
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Got it, so when the bug still exist this test would probably not fail consistently, but would be flaky, right?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It fails deterministically without the fix.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah yes, I missed the |
||
| mockCache() | ||
|
|
||
| val expectedTransition = txnMetadata2.prepareComplete(time.milliseconds()) | ||
|
|
||
| EasyMock.expect(metadataCache.getPartitionLeaderEndpoint( | ||
| EasyMock.eq(partition1.topic), | ||
| EasyMock.eq(partition1.partition), | ||
| EasyMock.anyObject()) | ||
| ).andReturn(Some(broker1)).anyTimes() | ||
|
|
||
| EasyMock.expect(txnStateManager.appendTransactionToLog( | ||
| EasyMock.eq(transactionalId2), | ||
| EasyMock.eq(coordinatorEpoch), | ||
| EasyMock.eq(expectedTransition), | ||
| EasyMock.capture(capturedErrorsCallback), | ||
| EasyMock.anyObject())) | ||
| .andAnswer(() => { | ||
| txnMetadata2.completeTransitionTo(expectedTransition) | ||
| capturedErrorsCallback.getValue.apply(Errors.NONE) | ||
| }).once() | ||
|
|
||
| EasyMock.replay(txnStateManager, metadataCache) | ||
|
|
||
| var addMarkerFuture: Future[Try[Unit]] = null | ||
| val executor = Executors.newFixedThreadPool(1) | ||
| txnMetadata2.lock.lock() | ||
| try { | ||
| addMarkerFuture = executor.submit((() => { | ||
| Try(channelManager.addTxnMarkersToSend(coordinatorEpoch, txnResult, | ||
| txnMetadata2, expectedTransition)) | ||
| }): Callable[Try[Unit]]) | ||
|
|
||
| val header = new RequestHeader(ApiKeys.WRITE_TXN_MARKERS, 0, "client", 1) | ||
| val response = new WriteTxnMarkersResponse( | ||
| Collections.singletonMap(producerId2: java.lang.Long, Collections.singletonMap(partition1, Errors.NONE))) | ||
| val clientResponse = new ClientResponse(header, null, null, | ||
| time.milliseconds(), time.milliseconds(), false, null, null, | ||
| response) | ||
|
|
||
| TestUtils.waitUntilTrue(() => { | ||
| val requests = channelManager.drainQueuedTransactionMarkers() | ||
| if (requests.nonEmpty) { | ||
| assertEquals(1, requests.size) | ||
| val request = requests.head | ||
| request.handler.onComplete(clientResponse) | ||
| true | ||
| } else { | ||
| false | ||
| } | ||
| }, "Timed out waiting for expected WriteTxnMarkers request") | ||
| } finally { | ||
| txnMetadata2.lock.unlock() | ||
| executor.shutdown() | ||
| } | ||
|
|
||
| assertNotNull(addMarkerFuture) | ||
| assertTrue("Add marker task failed with exception " + addMarkerFuture.get().get, | ||
| addMarkerFuture.get().isSuccess) | ||
|
|
||
| EasyMock.verify(txnStateManager) | ||
| } | ||
|
|
||
| @Test | ||
| def shouldGenerateEmptyMapWhenNoRequestsOutstanding(): Unit = { | ||
| assertTrue(channelManager.generateRequests().isEmpty) | ||
|
|
@@ -153,7 +221,6 @@ class TransactionMarkerChannelManagerTest { | |
| EasyMock.replay(metadataCache) | ||
|
|
||
| channelManager.addTxnMarkersToSend(coordinatorEpoch, txnResult, txnMetadata1, txnMetadata1.prepareComplete(time.milliseconds())) | ||
| channelManager.addTxnMarkersToSend(coordinatorEpoch, txnResult, txnMetadata2, txnMetadata2.prepareComplete(time.milliseconds())) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The change to use |
||
|
|
||
| assertEquals(1, channelManager.numTxnsWithPendingMarkers) | ||
| assertEquals(1, channelManager.queueForBroker(broker2.id).get.totalNumMarkers) | ||
|
|
@@ -291,7 +358,7 @@ class TransactionMarkerChannelManagerTest { | |
|
|
||
| val response = new WriteTxnMarkersResponse(createPidErrorMap(Errors.NONE)) | ||
| for (requestAndHandler <- requestAndHandlers) { | ||
| requestAndHandler.handler.onComplete(new ClientResponse(new RequestHeader(ApiKeys.PRODUCE, 0, "client", 1), | ||
| requestAndHandler.handler.onComplete(new ClientResponse(new RequestHeader(ApiKeys.WRITE_TXN_MARKERS, 0, "client", 1), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice catch. Not sure why they did not fail before :) |
||
| null, null, 0, 0, false, null, null, response)) | ||
| } | ||
|
|
||
|
|
@@ -338,7 +405,7 @@ class TransactionMarkerChannelManagerTest { | |
|
|
||
| val response = new WriteTxnMarkersResponse(createPidErrorMap(Errors.NONE)) | ||
| for (requestAndHandler <- requestAndHandlers) { | ||
| requestAndHandler.handler.onComplete(new ClientResponse(new RequestHeader(ApiKeys.PRODUCE, 0, "client", 1), | ||
| requestAndHandler.handler.onComplete(new ClientResponse(new RequestHeader(ApiKeys.WRITE_TXN_MARKERS, 0, "client", 1), | ||
| null, null, 0, 0, false, null, null, response)) | ||
| } | ||
|
|
||
|
|
@@ -387,7 +454,7 @@ class TransactionMarkerChannelManagerTest { | |
|
|
||
| val response = new WriteTxnMarkersResponse(createPidErrorMap(Errors.NONE)) | ||
| for (requestAndHandler <- requestAndHandlers) { | ||
| requestAndHandler.handler.onComplete(new ClientResponse(new RequestHeader(ApiKeys.PRODUCE, 0, "client", 1), | ||
| requestAndHandler.handler.onComplete(new ClientResponse(new RequestHeader(ApiKeys.WRITE_TXN_MARKERS, 0, "client", 1), | ||
| null, null, 0, 0, false, null, null, response)) | ||
| } | ||
|
|
||
|
|
@@ -402,7 +469,7 @@ class TransactionMarkerChannelManagerTest { | |
| assertEquals(CompleteCommit, txnMetadata2.state) | ||
| } | ||
|
|
||
| private def createPidErrorMap(errors: Errors) = { | ||
| private def createPidErrorMap(errors: Errors): util.HashMap[java.lang.Long, util.Map[TopicPartition, Errors]] = { | ||
| val pidMap = new java.util.HashMap[java.lang.Long, java.util.Map[TopicPartition, Errors]]() | ||
| val errorsMap = new java.util.HashMap[TopicPartition, Errors]() | ||
| errorsMap.put(partition1, errors) | ||
|
|
@@ -414,11 +481,11 @@ class TransactionMarkerChannelManagerTest { | |
| def shouldCreateMetricsOnStarting(): Unit = { | ||
| val metrics = KafkaYammerMetrics.defaultRegistry.allMetrics.asScala | ||
|
|
||
| assertEquals(1, metrics.filter { case (k, _) => | ||
| assertEquals(1, metrics.count { case (k, _) => | ||
| k.getMBeanName == "kafka.coordinator.transaction:type=TransactionMarkerChannelManager,name=UnknownDestinationQueueSize" | ||
| }.size) | ||
| assertEquals(1, metrics.filter { case (k, _) => | ||
| }) | ||
| assertEquals(1, metrics.count { case (k, _) => | ||
| k.getMBeanName == "kafka.coordinator.transaction:type=TransactionMarkerChannelManager,name=LogAppendRetryQueueSize" | ||
| }.size) | ||
| }) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Multiple threads may see the transaction still as pending and attempt completion.