-
-
Notifications
You must be signed in to change notification settings - Fork 532
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
Concurrent produce() sequence number fix #1050
Merged
Nevon
merged 11 commits into
tulios:master
from
t-d-d:1005_concurrent_produce_sequence_numbers
Feb 9, 2022
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
3ccf221
Concurrent produce() sequence number fix
t-d-d 03ea18d
1 Integration test
t-d-d 572083d
Merge branch 'master' into 1005_concurrent_produce_sequence_numbers
t-d-d ab3f96c
Merge branch 'master' into 1005_concurrent_produce_sequence_numbers
t-d-d 5fde09c
Merge branch 'master' into 1005_concurrent_produce_sequence_numbers
t-d-d e36f248
Revert sequenece numbers on produce() fail
t-d-d 5daca9b
Merge branch 'master' into 1005_concurrent_produce_sequence_numbers
t-d-d a686ebd
Remove sleep() from tests
t-d-d 2781376
waitFor the # of caals rather than # of results
t-d-d 0c87970
Merge branch 'master' into 1005_concurrent_produce_sequence_numbers
t-d-d 43e71bc
Merge branch 'master' into 1005_concurrent_produce_sequence_numbers
Nevon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
221 changes: 221 additions & 0 deletions
221
src/producer/__tests__/idempotentProduceMessages.spec.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,221 @@ | ||
jest.setTimeout(20000) | ||
|
||
const PromiseAllSettled = require('../../utils/promiseAllSettled') | ||
|
||
const { | ||
secureRandom, | ||
newLogger, | ||
createCluster, | ||
createTopic, | ||
waitForMessages, | ||
waitFor, | ||
} = require('testHelpers') | ||
const { KafkaJSError, KafkaJSProtocolError } = require('../../errors') | ||
|
||
const createProducer = require('../index') | ||
const createConsumer = require('../../consumer/index') | ||
const { describe } = require('jest-circus') | ||
|
||
const arrayUnique = a => [...new Set(a)] | ||
|
||
describe('Producer > Idempotent producer', () => { | ||
let producer, consumer, topicName, cluster, messages | ||
|
||
beforeAll(async () => { | ||
messages = Array(4) | ||
.fill() | ||
.map((_, i) => { | ||
const value = secureRandom() | ||
return { key: `key-${value}`, value: `${i}` } | ||
}) | ||
}) | ||
|
||
beforeEach(async () => { | ||
topicName = `test-topic-${secureRandom()}` | ||
cluster = createCluster() | ||
producer = createProducer({ | ||
cluster, | ||
logger: newLogger(), | ||
idempotent: true, | ||
}) | ||
consumer = createConsumer({ | ||
cluster, | ||
groupId: `consumer-group-id-${secureRandom()}`, | ||
maxWaitTimeInMs: 0, | ||
logger: newLogger(), | ||
}) | ||
await createTopic({ topic: topicName, partitions: 1 }) | ||
await Promise.all([producer.connect(), consumer.connect()]) | ||
await consumer.subscribe({ topic: topicName, fromBeginning: true }) | ||
}) | ||
|
||
afterEach( | ||
async () => | ||
await Promise.all([ | ||
producer && (await producer.disconnect()), | ||
consumer && (await consumer.disconnect()), | ||
]) | ||
) | ||
|
||
it('sequential produce() calls > all messages are written to the partition once, in order', async () => { | ||
const messagesConsumed = [] | ||
|
||
for (const m of messages) { | ||
await producer.send({ acks: -1, topic: topicName, messages: [m] }) | ||
} | ||
|
||
await consumer.run({ eachMessage: async message => messagesConsumed.push(message) }) | ||
|
||
await waitForMessages(messagesConsumed, { number: messages.length }) | ||
|
||
messagesConsumed.forEach(({ message: { value } }, i) => | ||
expect(value.toString()).toEqual(`${i}`) | ||
) | ||
}) | ||
|
||
it('sequential produce() calls > where produce() throws a retriable error, all messages are written to the partition once, in order', async () => { | ||
for (const nodeId of [0, 1, 2]) { | ||
const broker = await cluster.findBroker({ nodeId }) | ||
|
||
const brokerProduce = jest.spyOn(broker, 'produce') | ||
brokerProduce.mockImplementationOnce(() => { | ||
throw new KafkaJSError('retriable error') | ||
}) | ||
} | ||
|
||
const messagesConsumed = [] | ||
|
||
for (const m of messages) { | ||
await producer.send({ acks: -1, topic: topicName, messages: [m] }) | ||
} | ||
|
||
await consumer.run({ eachMessage: async message => messagesConsumed.push(message) }) | ||
|
||
await waitForMessages(messagesConsumed, { number: messages.length }) | ||
|
||
messagesConsumed.forEach(({ message: { value } }, i) => | ||
expect(value.toString()).toEqual(`${i}`) | ||
) | ||
}) | ||
|
||
it('sequential produce() calls > where produce() throws a retriable error after the message is written to the log, all messages are written to the partition once, in order', async () => { | ||
for (const nodeId of [0, 1, 2]) { | ||
const broker = await cluster.findBroker({ nodeId }) | ||
const originalCall = broker.produce.bind(broker) | ||
const brokerProduce = jest.spyOn(broker, 'produce') | ||
brokerProduce.mockImplementationOnce() | ||
brokerProduce.mockImplementationOnce() | ||
brokerProduce.mockImplementationOnce(async (...args) => { | ||
await originalCall(...args) | ||
throw new KafkaJSError('retriable error') | ||
}) | ||
} | ||
|
||
const messagesConsumed = [] | ||
|
||
for (const m of messages) { | ||
await producer.send({ acks: -1, topic: topicName, messages: [m] }) | ||
} | ||
|
||
await consumer.run({ eachMessage: async message => messagesConsumed.push(message) }) | ||
|
||
await waitForMessages(messagesConsumed, { number: messages.length }) | ||
|
||
messagesConsumed.forEach(({ message: { value } }, i) => | ||
expect(value.toString()).toEqual(`${i}`) | ||
) | ||
}) | ||
|
||
it('concurrent produce() calls > all messages are written to the partition once', async () => { | ||
const messagesConsumed = [] | ||
|
||
await Promise.all( | ||
messages.map(m => producer.send({ acks: -1, topic: topicName, messages: [m] })) | ||
) | ||
|
||
await consumer.run({ eachMessage: async message => messagesConsumed.push(message) }) | ||
|
||
await waitForMessages(messagesConsumed, { number: messages.length }) | ||
|
||
expect(arrayUnique(messagesConsumed.map(({ message: { value } }) => value))).toHaveLength( | ||
messages.length | ||
) | ||
}) | ||
|
||
it('concurrent produce() calls > where produce() throws a retriable error on the first call, all subsequent calls throw UNKNOWN_PRODUCER_ID', async () => { | ||
for (const nodeId of [0, 1, 2]) { | ||
const broker = await cluster.findBroker({ nodeId }) | ||
|
||
const brokerProduce = jest.spyOn(broker, 'produce') | ||
brokerProduce.mockImplementationOnce(async () => { | ||
await waitFor(() => brokerProduce.mock.calls.length >= messages.length) // for all the other concurrent calls to have completed | ||
throw new KafkaJSError('retriable error') | ||
}) | ||
} | ||
|
||
const settlements = await PromiseAllSettled( | ||
messages.map(m => producer.send({ acks: -1, topic: topicName, messages: [m] })) | ||
).catch(e => e) | ||
|
||
settlements | ||
.filter(({ status }) => status === 'rejected') | ||
.forEach(({ reason }) => { | ||
expect(reason).toBeInstanceOf(KafkaJSProtocolError) | ||
expect(reason.type).toBe('UNKNOWN_PRODUCER_ID') | ||
}) | ||
|
||
expect(settlements.filter(({ status }) => status === 'fulfilled')).toHaveLength(1) | ||
}) | ||
|
||
it('concurrent produce() calls > where produce() throws a retriable error on 2nd call, all subsequent calls throw OUT_OF_ORDER_SEQUENCE_NUMBER', async () => { | ||
for (const nodeId of [0, 1, 2]) { | ||
const broker = await cluster.findBroker({ nodeId }) | ||
|
||
const brokerProduce = jest.spyOn(broker, 'produce') | ||
brokerProduce.mockImplementationOnce() | ||
brokerProduce.mockImplementationOnce(async () => { | ||
await waitFor(() => brokerProduce.mock.calls.length >= messages.length) // for all the other concurrent calls to have completed | ||
throw new KafkaJSError('retriable error') | ||
}) | ||
} | ||
|
||
const settlements = await PromiseAllSettled( | ||
messages.map(m => producer.send({ acks: -1, topic: topicName, messages: [m] })) | ||
).catch(e => e) | ||
|
||
settlements | ||
.filter(({ status }) => status === 'rejected') | ||
.forEach(({ reason }) => { | ||
expect(reason).toBeInstanceOf(KafkaJSProtocolError) | ||
expect(reason.type).toBe('OUT_OF_ORDER_SEQUENCE_NUMBER') | ||
}) | ||
|
||
expect(settlements.filter(({ status }) => status === 'fulfilled')).toHaveLength(2) | ||
}) | ||
|
||
it('concurrent produce() calls > where produce() throws a retriable error after the message is written to the log, all messages are written to the partition once', async () => { | ||
for (const nodeId of [0, 1, 2]) { | ||
const broker = await cluster.findBroker({ nodeId }) | ||
const originalCall = broker.produce.bind(broker) | ||
const brokerProduce = jest.spyOn(broker, 'produce') | ||
brokerProduce.mockImplementationOnce(async (...args) => { | ||
await originalCall(...args) | ||
throw new KafkaJSError('retriable error') | ||
}) | ||
} | ||
|
||
const messagesConsumed = [] | ||
|
||
await Promise.all( | ||
messages.map(m => producer.send({ acks: -1, topic: topicName, messages: [m] })) | ||
) | ||
|
||
await consumer.run({ eachMessage: async message => messagesConsumed.push(message) }) | ||
|
||
await waitForMessages(messagesConsumed, { number: messages.length }) | ||
|
||
expect(arrayUnique(messagesConsumed.map(({ message: { value } }) => value))).toHaveLength( | ||
messages.length | ||
) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
/** | ||
* | ||
* @param { [Promise<T>] } promises | ||
* @returns {Promise<[{ status: "fulfilled", value: T} | { status: "rejected", reason: Error}]> } | ||
* @template T | ||
*/ | ||
|
||
function allSettled(promises) { | ||
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. We should actually bump the requirement on Node to 12 at this point, but that's outside the scope of this PR. |
||
const wrappedPromises = promises.map(p => | ||
Promise.resolve(p).then( | ||
val => ({ status: 'fulfilled', value: val }), | ||
err => ({ status: 'rejected', reason: err }) | ||
) | ||
) | ||
return Promise.all(wrappedPromises) | ||
} | ||
|
||
module.exports = allSettled |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Don't worry about changing this, but just for future reference, you can just wrap these test cases in a describe block to group the tests together, instead of repeating the prefix in each test case: