Skip to content
This repository was archived by the owner on Nov 4, 2021. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 25 additions & 23 deletions src/ingestion/kafka-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,12 @@ import { PluginEvent } from '@posthog/plugin-scaffold'
import { status } from '../status'
import { killGracefully } from '../utils'

export type BatchCallback = (messages: Message[]) => Promise<void>

export class KafkaQueue implements Queue {
private pluginsServer: PluginsServer
private kafka: Kafka
private consumer: Consumer
private wasConsumerRan: boolean
private processEventBatch: (batch: PluginEvent[]) => Promise<any>
private processEventBatch: (batch: PluginEvent[]) => Promise<PluginEvent[]>
private saveEvent: (event: PluginEvent) => Promise<void>

constructor(
Expand All @@ -34,32 +32,33 @@ export class KafkaQueue implements Queue {
resolveOffset,
heartbeat,
commitOffsetsIfNecessary,
uncommittedOffsets,
isRunning,
isStale,
}: EachBatchPayload): Promise<void> {
const batchProcessingTimer = new Date()
const rawEvents: RawEventMessage[] = batch.messages.map((message) => ({
...JSON.parse(message.value!.toString()),
kafka_offset: message.offset,
}))
const parsedEvents = rawEvents.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) }

const uuidOrder = new Map<string, number>()
const uuidOffset = new Map<string, string>()
const pluginEvents: PluginEvent[] = batch.messages.map((message, index) => {
const { data: dataStr, ...rawEvent } = JSON.parse(message.value!.toString())
const event = { ...rawEvent, ...JSON.parse(dataStr) }
uuidOrder.set(event.uuid, index)
uuidOffset.set(event.uuid, message.offset)
Comment on lines +45 to +46

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, so this still doesn't address cases like: we've got events a, b, c; b and c gets discarded; we never resolve b's and c's offsets; in case of restart b and c, get processed again. But then also it's true that plugins can return whatever the hell they want in any order, so no method can be completely trusted here (though in most cases there won't be shenanigans). Even sorting by UUIDs (which, the ones generated by us, are time-sortable with UUIDT), the uuid property may be randomized, so… Alright, whatever. In any case it's better to process an event at-least-once than at-most-once.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, this can go deep...

return {
...event,
kafka_offset: restOfRawEvent.kafka_offset,
site_url: event.site_url || null,
ip: event.ip || null,
}
})
const processedEvents: PluginEvent[] = (
await this.processEventBatch(pluginEvents)
).filter((event: PluginEvent[] | false | null | undefined) => Boolean(event))

const processedEvents = await this.processEventBatch(pluginEvents)

// Sort in the original order that the events came in, putting any randomly added events to the end.
// This is so we would resolve the correct kafka offsets in order.
processedEvents.sort(
(a, b) => (uuidOrder.get(a.uuid!) || pluginEvents.length) - (uuidOrder.get(b.uuid!) || pluginEvents.length)
)
Comment on lines +54 to +60

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice that the tacked on events are last!
Though as for the non-null assertion, as far as I understand, processEventBatch can return an array of practically anything, including non-objects falsy or truthy. 🤔

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should always return this or crash: https://github.com/PostHog/plugin-server/pull/124/files#diff-5d37292cf21755714262793e7553ee317a12b157d0601e27a0ca26e096a60c00L267

Though, yeah, there's no real sanitization. The plugins could return just { "n00b": "lololololol" } as an event and it would make its way here...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose the get is going to return undefined in any such case, so this should be fine even if the assertion is too optimistic.
Side note, I think we make this assumption in some more dangerous places too, so probably will have to take a look at some event structure validation in another PR.


for (const event of processedEvents) {
if (!isRunning()) {
status.info('😮', 'Consumer not running anymore, canceling batch processing!')
Expand All @@ -71,12 +70,17 @@ export class KafkaQueue implements Queue {
}
const singleIngestionTimer = new Date()
await this.saveEvent(event)
resolveOffset(event.kafka_offset!)
const offset = uuidOffset.get(event.uuid!)
if (offset) {
resolveOffset(offset)
}
await heartbeat()
await commitOffsetsIfNecessary()
this.pluginsServer.statsd?.timing('kafka_queue.single_ingestion', singleIngestionTimer)
}
this.pluginsServer.statsd?.timing('kafka_queue.each_batch', batchProcessingTimer)
resolveOffset(batch.lastOffset())
await commitOffsetsIfNecessary()
}

async start(): Promise<void> {
Expand All @@ -88,9 +92,7 @@ export class KafkaQueue implements Queue {
await this.consumer.subscribe({ topic: KAFKA_EVENTS_INGESTION_HANDOFF })
// 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
// The issue is right now we'd miss some messages and not resolve them as processEventBatch COMPETELY
// discards some events, leaving us with no kafka_offset to resolve when in fact it should be resolved.
Comment on lines -91 to -93

@Twixes Twixes Feb 3, 2021

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment was a bit more cryptic than it should have been, but the concern was with potentially resolving offsets for unsaved events in two cases:

  • if we return early because of the isRunning check - however now my understanding is that if the consumer is not running, then it won't be possible to commit offsets, not completely certain though
  • if we return early because of the isStale check - may happen if consumer.seek() or something like that is used to move partition offset somewhere, this is a bit more concerning, but also I'm unsure about the specifics of such a situation

In the end though, just to control this behavior without any doubt (and we should always prefer at-least-once over at-most-once), I'd rather uncomment this (setting eachBatchAutoResolve to false) and instead just resolve the offset deliberately.

Here's the relevant comment from KafkaJS source:

kafkajs/src/consumer/runner.js:240

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

eachBatchAutoResolve: false, // we are resolving the last offset of the batch more deliberately
autoCommitInterval: 500, // autocommit every 500 ms…
autoCommitThreshold: 1000, // …or every 1000 messages, whichever is sooner
eachBatch: this.eachBatch.bind(this),
Expand Down
2 changes: 1 addition & 1 deletion src/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ export async function runPluginsOnBatch(server: PluginsServer, batch: PluginEven
allReturnedEvents = allReturnedEvents.concat(returnedEvents)
}

return allReturnedEvents
return allReturnedEvents.filter(Boolean)
}

export async function runPluginTask(server: PluginsServer, taskName: string, pluginConfigId: number): Promise<any> {
Expand Down