Summary
AbstractBatchConsumer.restartConsumer() can trigger Kafka's static-membership fencing against itself, because it starts a replacement consumer with the same group.instance.id before confirming the previous consumer has actually left the group. When this race is lost, bulker treats the resulting fencing error as fatal and never retries -- the consumer for that topic goes permanently silent (no further log lines at all) until the process is manually restarted.
This caused a multi-hour to multi-day silent ingestion outage for us on two separate occasions, since nothing else in the system detects "consumer stopped producing any log output at all" as distinct from "consumer idle because there's nothing to consume."
Where
-
bulker/bulkerapp/app/abstract_consumer.go, GetInstanceId():
// range partitioner assigner distributes partitions between consumers in alphabetical order
// since bulker topics mostly have only 1 partition – instance with the lowest instanceId will be assigned for all topic.
// we use first letters of hash of 'topicId + instanceId' as a beginning of 'group.instance.id'
// so for each topic the first instance will be different
// while keeping consistency between restarts (if instanceId is the same)
firstByte := md5.Sum([]byte(ac.topicId + ac.config.InstanceId))[0]
return fmt.Sprintf("%x-%s", firstByte, ac.config.InstanceId)
Since config.InstanceId is fixed for the process lifetime, every consumer recreated for the same topic within the same process reuses the exact same group.instance.id by design.
-
bulker/bulkerapp/app/abstract_batch_consumer.go, restartConsumer():
bc.Infof("Restarting consumer")
go func(c *kafka.Consumer) {
e1 := c.Unsubscribe()
e2 := c.Close()
bc.Infof("Previous consumer closed: %v %v", e1, e2)
}(bc.consumer.Load())
ticker := time.NewTicker(time.Duration(bc.config.KafkaSessionTimeoutMs+15000) * time.Millisecond)
...
case <-ticker.C:
...
_, err := bc.initConsumer(true)
The old consumer's Close() runs in an unawaited goroutine. The code waits KafkaSessionTimeoutMs + 15s before creating the replacement (presumably to give the old member time to leave the group before the new one claims the same static ID), but there's no actual confirmation that the close completed -- just a fixed timer. Under sustained heavy load, Close() can apparently take longer than that window, and the new consumer joins with the same group.instance.id while the old one hasn't fully left, producing:
Fatal error: Fatal consumer error: Broker: Static consumer fenced by other consumer with same group.instance.id
This is logged via the normal error path but nothing in the code retries it -- the consumer just stops.
Impact
Once this fires, the affected topic's consumer produces zero further log output of any kind -- not even the periodic degradation/retry warnings -- until the process is externally restarted. For a high-volume topic, this is silent, total data-ingestion loss for that destination with no automatic recovery and no distinguishable signal from "topic is just idle."
Observed conditions
We saw this trigger repeatedly during sustained backlog catch-up (millions of queued messages after an unrelated infra restart), including:
- Once during a normal ECS rolling deployment (brief two-task overlap, each with the same static ID from a shared image/config -- expected collision)
- Twice with only one instance running, no deployment overlap at all -- the race triggered purely from bulker's own internal consumer-recreation cycle, roughly ~5-6 minutes into a long-running batch, right as the internal "no successful consume" watchdog fired and tried to restart the consumer
Sample log sequence (one instance, no overlapping tasks):
19:01:54 level=warning msg="[topic] Consumer degradation detected: no successful consume for 5m0s ... Recreating consumer."
19:01:54 level=info msg="[topic] Restarting consumer"
19:01:54 level=info msg="[topic] Previous consumer closed: <nil> <nil>"
19:02:54 level=info msg="[topic] Consumer created: bulkerapp#consumer-7"
19:02:54 level=info msg="[topic] Successfully altered consumer group offsets: ..."
19:02:54 level=info msg="[topic] Consumer created: bulkerapp#consumer-9"
19:02:54 level=error msg="[topic] Consume finished with error: ... Fatal error: Fatal consumer error: Broker: Static consumer fenced by other consumer with same group.instance.id stats: consumed: 1080000 processed: 1080000 ... time: 184.98 s."
(no further log lines for this topic for the next ~17 minutes, until the process was manually restarted)
Note two Consumer created lines within the same second (consumer-7, then consumer-9) -- suggests overlapping restart attempts even within a single process.
Suggested directions
- Make
restartConsumer actually wait for the old consumer's Unsubscribe()/Close() to complete (or for explicit leave-group confirmation) before calling initConsumer(true), rather than relying on a fixed timer with no completion signal.
- Treat the
"Static consumer fenced by other consumer with same group.instance.id" error as retryable with backoff rather than fatal-with-no-retry, since it's a transient self-inflicted race rather than a genuine unrecoverable state.
- Consider logging a distinct, loud signal when a consumer's goroutine exits after a fatal error (vs. going silent), so downstream monitoring can tell "stopped forever" apart from "just idle."
Happy to provide more log context or test against a patched build if useful.
Summary
AbstractBatchConsumer.restartConsumer()can trigger Kafka's static-membership fencing against itself, because it starts a replacement consumer with the samegroup.instance.idbefore confirming the previous consumer has actually left the group. When this race is lost, bulker treats the resulting fencing error as fatal and never retries -- the consumer for that topic goes permanently silent (no further log lines at all) until the process is manually restarted.This caused a multi-hour to multi-day silent ingestion outage for us on two separate occasions, since nothing else in the system detects "consumer stopped producing any log output at all" as distinct from "consumer idle because there's nothing to consume."
Where
bulker/bulkerapp/app/abstract_consumer.go,GetInstanceId():Since
config.InstanceIdis fixed for the process lifetime, every consumer recreated for the same topic within the same process reuses the exact samegroup.instance.idby design.bulker/bulkerapp/app/abstract_batch_consumer.go,restartConsumer():The old consumer's
Close()runs in an unawaited goroutine. The code waitsKafkaSessionTimeoutMs + 15sbefore creating the replacement (presumably to give the old member time to leave the group before the new one claims the same static ID), but there's no actual confirmation that the close completed -- just a fixed timer. Under sustained heavy load,Close()can apparently take longer than that window, and the new consumer joins with the samegroup.instance.idwhile the old one hasn't fully left, producing:This is logged via the normal error path but nothing in the code retries it -- the consumer just stops.
Impact
Once this fires, the affected topic's consumer produces zero further log output of any kind -- not even the periodic degradation/retry warnings -- until the process is externally restarted. For a high-volume topic, this is silent, total data-ingestion loss for that destination with no automatic recovery and no distinguishable signal from "topic is just idle."
Observed conditions
We saw this trigger repeatedly during sustained backlog catch-up (millions of queued messages after an unrelated infra restart), including:
Sample log sequence (one instance, no overlapping tasks):
Note two
Consumer createdlines within the same second (consumer-7, thenconsumer-9) -- suggests overlapping restart attempts even within a single process.Suggested directions
restartConsumeractually wait for the old consumer'sUnsubscribe()/Close()to complete (or for explicit leave-group confirmation) before callinginitConsumer(true), rather than relying on a fixed timer with no completion signal."Static consumer fenced by other consumer with same group.instance.id"error as retryable with backoff rather than fatal-with-no-retry, since it's a transient self-inflicted race rather than a genuine unrecoverable state.Happy to provide more log context or test against a patched build if useful.