This repository was archived by the owner on Nov 4, 2021. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 5
Optimize Kafka queue offset handling #123
Closed
Closed
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
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
This file contains hidden or 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 |
|---|---|---|
| @@ -1,13 +1,48 @@ | ||
| import * as Sentry from '@sentry/node' | ||
| import { Kafka, Consumer, Message, EachBatchPayload } from 'kafkajs' | ||
| import { PluginsServer, Queue, RawEventMessage } from 'types' | ||
| import { KAFKA_EVENTS_INGESTION_HANDOFF } from './topics' | ||
| import { KAFKA_EVENTS_WAL } from './topics' | ||
| import { PluginEvent } from '@posthog/plugin-scaffold' | ||
| import { status } from '../status' | ||
| import { killGracefully } from '../utils' | ||
|
|
||
| export type BatchCallback = (messages: Message[]) => Promise<void> | ||
|
|
||
| /** | ||
| * We use this to avoid a situation where the event was discarded and because of that its Kafka offset is not resolved, | ||
| * potentially causing unnecessary message retries. | ||
| * | ||
| * Works in the following way: for each event UUID the algorithm sets the Kafka offset to resolve | ||
| * based on the last event after it from the Kafka topic that WAS discarded. Example, we've got events: | ||
| * ```JS | ||
| * [{ uuid: 'a', offset: 1 }, { uuid: 's', offset: 2 }, { uuid: 'd', offset: 3 }, { uuid: 'f', offset: 4 }] | ||
| * ``` | ||
| * Now some plugin discards the last two! Returned map for use in resolveOffset() will in result look like this: | ||
| * ```JS | ||
| * { 'a': 1, 's': 4, 'd': 4, 'f': 4 } | ||
| * ``` | ||
| * Because 'd' and 'f' were discarded by a plugin, when we save 's' to the database, we'll know that at the same time | ||
| * we in fact also covered 'd' and 'f' and should resolve the last offset - belonging to 'f' – not an intermediary one. | ||
| * As for 'a', we simply resolve its offset outright, because its next event ('s') is not discarded. | ||
| */ | ||
| function aliasEventUuidForDiscardedKafkaOffsets( | ||
| rawEventMessages: RawEventMessage[], | ||
| processedEvents: PluginEvent[] | ||
| ): Map<string, string> { | ||
| rawEventMessages = [...rawEventMessages] // Shallow copy to avoid side effects | ||
| const eventUuidToKafkaOffset = new Map<string, string>() | ||
| const processedUuids: Set<string> = new Set(processedEvents.map((event) => event.uuid!)) | ||
| rawEventMessages.reverse() | ||
| // This initial value below is in fact the last message, due to the array being reversed | ||
| let currentNotDiscardedOffset: string = rawEventMessages.shift()!.kafka_offset | ||
| for (const rawEventMessage of rawEventMessages) { | ||
| if (processedUuids.has(rawEventMessage.uuid)) { | ||
| currentNotDiscardedOffset = rawEventMessage.kafka_offset | ||
| } | ||
| eventUuidToKafkaOffset.set(rawEventMessage.uuid, currentNotDiscardedOffset) | ||
| } | ||
| return eventUuidToKafkaOffset | ||
| } | ||
|
|
||
| export class KafkaQueue implements Queue { | ||
| private pluginsServer: PluginsServer | ||
| private kafka: Kafka | ||
|
|
@@ -39,27 +74,23 @@ export class KafkaQueue implements Queue { | |
| isStale, | ||
| }: EachBatchPayload): Promise<void> { | ||
| const batchProcessingTimer = new Date() | ||
| const rawEvents: RawEventMessage[] = batch.messages.map((message) => ({ | ||
| const rawEventMessages: RawEventMessage[] = batch.messages.map((message) => ({ | ||
| ...JSON.parse(message.value!.toString()), | ||
| kafka_offset: message.offset, | ||
| })) | ||
| const parsedEvents = rawEvents.map((rawEvent) => ({ | ||
| const parsedEventMessages = rawEventMessages.map((rawEvent) => ({ | ||
| ...rawEvent, | ||
| data: JSON.parse(rawEvent.data), | ||
| })) | ||
| const pluginEvents: PluginEvent[] = rawEvents.map((rawEvent) => { | ||
| const { data: dataStr, ...restOfRawEvent } = rawEvent | ||
| const event = { ...restOfRawEvent, ...JSON.parse(dataStr) } | ||
| return { | ||
| ...event, | ||
| kafka_offset: restOfRawEvent.kafka_offset, | ||
| site_url: event.site_url || null, | ||
| ip: event.ip || null, | ||
| } | ||
| }) | ||
| const pluginEvents: PluginEvent[] = parsedEventMessages.map((parsedEvent) => ({ | ||
| ...parsedEvent, | ||
| event: parsedEvent.data.event, | ||
| properties: parsedEvent.data.properties, | ||
| })) | ||
| const processedEvents: PluginEvent[] = ( | ||
| await this.processEventBatch(pluginEvents) | ||
| ).filter((event: PluginEvent[] | false | null | undefined) => Boolean(event)) | ||
| const eventUuidToKafkaOffset = aliasEventUuidForDiscardedKafkaOffsets(rawEventMessages, processedEvents) | ||
| for (const event of processedEvents) { | ||
| if (!isRunning()) { | ||
| status.info('😮', 'Consumer not running anymore, canceling batch processing!') | ||
|
|
@@ -71,7 +102,7 @@ export class KafkaQueue implements Queue { | |
| } | ||
| const singleIngestionTimer = new Date() | ||
| await this.saveEvent(event) | ||
| resolveOffset(event.kafka_offset!) | ||
| resolveOffset(eventUuidToKafkaOffset.get(event.uuid!)!) | ||
|
Member
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. TODO: don't run this if the event was not present in batch from Kafka (meaning it was inserted by a plugin's |
||
| await heartbeat() | ||
| await commitOffsetsIfNecessary() | ||
| this.pluginsServer.statsd?.timing('kafka_queue.single_ingestion', singleIngestionTimer) | ||
|
|
@@ -85,7 +116,7 @@ export class KafkaQueue implements Queue { | |
| this.consumer.on(this.consumer.events.CRASH, ({ payload: { error } }) => reject(error)) | ||
| status.info('⏬', `Connecting Kafka consumer to ${this.pluginsServer.KAFKA_HOSTS}...`) | ||
| this.wasConsumerRan = true | ||
| await this.consumer.subscribe({ topic: KAFKA_EVENTS_INGESTION_HANDOFF }) | ||
| await this.consumer.subscribe({ topic: KAFKA_EVENTS_WAL }) | ||
| // KafkaJS batching: https://kafka.js.org/docs/consuming#a-name-each-batch-a-eachbatch | ||
| await this.consumer.run({ | ||
| // TODO: eachBatchAutoResolve: false, // don't autoresolve whole batch in case we exit it early | ||
|
|
@@ -104,7 +135,7 @@ export class KafkaQueue implements Queue { | |
| return | ||
| } | ||
| status.info('⏳', 'Pausing Kafka consumer...') | ||
| await this.consumer.pause([{ topic: KAFKA_EVENTS_INGESTION_HANDOFF }]) | ||
| await this.consumer.pause([{ topic: KAFKA_EVENTS_WAL }]) | ||
| status.info('⏸', 'Kafka consumer paused!') | ||
| } | ||
|
|
||
|
|
@@ -113,12 +144,12 @@ export class KafkaQueue implements Queue { | |
| return | ||
| } | ||
| status.info('⏳', 'Resuming Kafka consumer...') | ||
| await this.consumer.resume([{ topic: KAFKA_EVENTS_INGESTION_HANDOFF }]) | ||
| await this.consumer.resume([{ topic: KAFKA_EVENTS_WAL }]) | ||
| status.info('▶️', 'Kafka consumer resumed!') | ||
| } | ||
|
|
||
| isPaused(): boolean { | ||
| return this.consumer.paused().some(({ topic }) => topic === KAFKA_EVENTS_INGESTION_HANDOFF) | ||
| return this.consumer.paused().some(({ topic }) => topic === KAFKA_EVENTS_WAL) | ||
| } | ||
|
|
||
| async stop(): Promise<void> { | ||
|
|
@@ -136,7 +167,7 @@ export class KafkaQueue implements Queue { | |
|
|
||
| private static buildConsumer(kafka: Kafka): Consumer { | ||
| const consumer = kafka.consumer({ | ||
| groupId: 'clickhouse-ingestion', | ||
| groupId: 'plugin-server', | ||
| readUncommitted: false, | ||
| }) | ||
| const { GROUP_JOIN, CRASH, CONNECT, DISCONNECT } = consumer.events | ||
|
|
@@ -146,7 +177,6 @@ export class KafkaQueue implements Queue { | |
| consumer.on(CRASH, ({ payload: { error, groupId } }) => { | ||
| status.error('⚠️', `Kafka consumer group ${groupId} crashed:\n`, error) | ||
| Sentry.captureException(error) | ||
| killGracefully() | ||
| }) | ||
| consumer.on(CONNECT, () => { | ||
| status.info('✅', 'Kafka consumer connected!') | ||
|
|
||
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.
Why the change from
KAFKA_EVENTS_INGESTION_HANDOFF?