Skip to content

Commit b1c679c

Browse files
authored
fix(queue): clamp kafka lag to low watermark to prevent phantom backlog (#4129)
Signed-off-by: Uroš Marolt <uros@marolt.me>
1 parent 900db32 commit b1c679c

3 files changed

Lines changed: 18 additions & 66 deletions

File tree

services/apps/cron_service/src/jobs/queueMonitoring.job.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,9 @@ async function getTopicMessageCount(
189189
try {
190190
const topicOffsets = await admin.fetchTopicOffsets(topic)
191191

192-
// Sum up all partition offsets to get total messages
192+
// Sum (high - low) per partition — actual messages currently in the topic.
193193
const totalMessages = topicOffsets.reduce((sum, partition) => {
194-
return sum + Number(partition.offset)
194+
return sum + Math.max(0, Number(partition.offset) - Number(partition.low))
195195
}, 0)
196196

197197
return totalMessages

services/libs/queue/src/vendors/kafka/client.ts

Lines changed: 3 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818

1919
import { configMap } from './config'
2020
import { IKafkaChannelConfig, IKafkaQueueStartOptions } from './types'
21+
import { getKafkaMessageCounts } from './utils'
2122

2223
export class KafkaQueueService extends LoggerBase implements IQueue {
2324
private readonly MAX_RECONNECT_ATTEMPTS = 5
@@ -56,65 +57,14 @@ export class KafkaQueueService extends LoggerBase implements IQueue {
5657

5758
async getQueueMessageCount(conf: IKafkaChannelConfig): Promise<number> {
5859
const topic = conf.name
59-
// The consumer group ID is the same as the topic name
6060
const groupId = topic
6161

6262
const admin = this.client.admin()
6363
await admin.connect()
6464

6565
try {
66-
this.log.debug({ topic, groupId }, 'Fetching message count for topic and consumer group')
67-
68-
const topicOffsets = await admin.fetchTopicOffsets(topic)
69-
this.log.debug({ topic, groupId, topicOffsets }, 'Topic offsets fetched')
70-
71-
const offsetsResponse = await admin.fetchOffsets({
72-
groupId: groupId,
73-
topics: [topic],
74-
})
75-
this.log.debug({ topic, groupId, offsetsResponse }, 'Consumer group offsets fetched')
76-
77-
const offsets = offsetsResponse[0].partitions
78-
this.log.debug({ topic, groupId, offsets }, 'Consumer group offsets')
79-
80-
let totalLeft = 0
81-
for (const offset of offsets) {
82-
const topicOffset = topicOffsets.find((p) => p.partition === offset.partition)
83-
if (topicOffset) {
84-
// If consumer offset is -1, it means no committed offset, so lag is the total messages in partition
85-
if (offset.offset === '-1') {
86-
const lag = Number(topicOffset.offset) - Number(topicOffset.low)
87-
totalLeft += lag
88-
this.log.debug(
89-
{
90-
partition: offset.partition,
91-
topicOffset: topicOffset.offset,
92-
topicLow: topicOffset.low,
93-
consumerOffset: offset.offset,
94-
lag,
95-
},
96-
'Partition lag calculated (no committed offset)',
97-
)
98-
} else {
99-
const lag = Number(topicOffset.offset) - Number(offset.offset)
100-
totalLeft += lag
101-
this.log.debug(
102-
{
103-
partition: offset.partition,
104-
topicOffset: topicOffset.offset,
105-
consumerOffset: offset.offset,
106-
lag,
107-
},
108-
'Partition lag calculated',
109-
)
110-
}
111-
} else {
112-
this.log.debug({ partition: offset.partition }, 'No topic offset found for partition')
113-
}
114-
}
115-
116-
this.log.debug({ topic, groupId, totalLeft }, 'Total messages left calculated')
117-
return totalLeft
66+
const counts = await getKafkaMessageCounts(this.log, admin, topic, groupId)
67+
return counts.unconsumed
11868
} catch (err) {
11969
this.log.error({ topic, groupId, err }, 'Failed to get message count!')
12070
throw err

services/libs/queue/src/vendors/kafka/utils.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import { Logger } from '@crowd/logging'
55
/**
66
* Returns the total, consumed, and unconsumed message counts for a Kafka topic/consumer-group pair.
77
*
8-
* Handles the `-1` offset edge case: when a consumer group has never committed an offset,
9-
* Kafka returns `-1`. In that case all messages from the low watermark onward are unconsumed.
8+
* Uses the low watermark as a floor when computing lag so that stale committed offsets
9+
* (e.g. after a topic is recreated or truncated) do not produce phantom lag.
10+
* A committed offset of -1 (no commit yet) is treated as if the consumer is at the low watermark.
1011
*/
1112
export async function getKafkaMessageCounts(
1213
log: Logger,
@@ -30,16 +31,17 @@ export async function getKafkaMessageCounts(
3031
for (const offset of offsets) {
3132
const topicOffset = topicOffsets.find((p) => p.partition === offset.partition)
3233
if (topicOffset) {
33-
totalMessages += Number(topicOffset.offset)
34+
const high = Number(topicOffset.offset)
35+
const low = Number(topicOffset.low)
36+
// Clamp committed to [low, high]: stale commits below the floor are treated as "at floor";
37+
// commits above the high watermark (topic recreated at lower offsets) are treated as "at high".
38+
// -1 means no committed offset; treat as at low watermark.
39+
const committedRaw = offset.offset === '-1' ? low : Number(offset.offset)
40+
const committed = Math.min(Math.max(committedRaw, low), high)
3441

35-
if (offset.offset === '-1') {
36-
// No committed offset yet — treat all messages from the low watermark as unconsumed.
37-
consumedMessages += 0
38-
totalLeft += Number(topicOffset.offset) - Number(topicOffset.low)
39-
} else {
40-
consumedMessages += Number(offset.offset)
41-
totalLeft += Number(topicOffset.offset) - Number(offset.offset)
42-
}
42+
totalMessages += Math.max(0, high - low)
43+
consumedMessages += Math.max(0, committed - low)
44+
totalLeft += Math.max(0, high - committed)
4345
}
4446
}
4547

0 commit comments

Comments
 (0)