Files
seaweedfs/weed/mq/topic/local_manager.go
T
Chris Lu 32df246a81 mq: fix idle-cleanup shard deadlock that permanently wedges the broker's topic map (#11051)
mq: remove emptied topics after the cleanup iteration, not inside it

cleanupIdlePartitions called manager.topics.Remove from inside
manager.topics.IterCb. IterCb holds the shard's read lock while running
the callback, and Remove takes the same shard's write lock, so removing
an emptied topic self-deadlocked the cleanup goroutine. The pending
writer then blocked every later reader of that shard, permanently
hanging ListTopicsInMemory and, for shard-mates, TopicExistsInMemory.

On the Kafka gateway this surfaced as flaky e2e consumer-group tests:
one minute after any earlier topic went idle, the broker's first
'Removing empty topic' wedged the map, every gateway
ListTopics/TopicExists RPC burned its full 5s timeout, Metadata could no
longer finish inside kafka-go's 5s coordinator deadline, and consumer
groups looped in PreparingRebalance until the test timed out.

Collect the emptied topic keys during the iteration and remove them
afterwards via RemoveCb, re-checking emptiness under the shard lock so a
topic that just gained a partition is kept.

Claude-Session: https://claude.ai/code/session_014yA6c8JQcY6MqPXCT13yYA
2026-08-31 09:44:34 -07:00

230 lines
7.2 KiB
Go

package topic
import (
"context"
"time"
cmap "github.com/orcaman/concurrent-map/v2"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
"github.com/shirou/gopsutil/v4/cpu"
)
// LocalTopicManager manages topics on local broker
type LocalTopicManager struct {
topics cmap.ConcurrentMap[string, *LocalTopic]
cleanupDone chan struct{} // Signal cleanup goroutine to stop
cleanupTimer *time.Ticker
}
// NewLocalTopicManager creates a new LocalTopicManager
func NewLocalTopicManager() *LocalTopicManager {
return &LocalTopicManager{
topics: cmap.New[*LocalTopic](),
cleanupDone: make(chan struct{}),
}
}
// StartIdlePartitionCleanup starts a background goroutine that periodically
// cleans up idle partitions (partitions with no publishers and no subscribers)
func (manager *LocalTopicManager) StartIdlePartitionCleanup(ctx context.Context, checkInterval, idleTimeout time.Duration) {
manager.cleanupTimer = time.NewTicker(checkInterval)
go func() {
defer close(manager.cleanupDone)
defer manager.cleanupTimer.Stop()
glog.V(1).Infof("Idle partition cleanup started: check every %v, cleanup after %v idle", checkInterval, idleTimeout)
for {
select {
case <-ctx.Done():
glog.V(1).Info("Idle partition cleanup stopped")
return
case <-manager.cleanupTimer.C:
manager.cleanupIdlePartitions(idleTimeout)
}
}
}()
}
// cleanupIdlePartitions removes idle partitions from memory
func (manager *LocalTopicManager) cleanupIdlePartitions(idleTimeout time.Duration) {
cleanedCount := 0
var emptyTopics []string
// Iterate through all topics
manager.topics.IterCb(func(topicKey string, localTopic *LocalTopic) {
localTopic.partitionLock.Lock()
defer localTopic.partitionLock.Unlock()
// Check each partition
for i := len(localTopic.Partitions) - 1; i >= 0; i-- {
partition := localTopic.Partitions[i]
if partition.ShouldCleanup(idleTimeout) {
glog.V(1).Infof("Cleaning up idle partition %s (idle for %v, publishers=%d, subscribers=%d)",
partition.Partition.String(),
partition.GetIdleDuration(),
partition.Publishers.Size(),
partition.Subscribers.Size())
// Shutdown the partition (closes LogBuffer, etc.)
partition.Shutdown()
// Remove from slice
localTopic.Partitions = append(localTopic.Partitions[:i], localTopic.Partitions[i+1:]...)
cleanedCount++
}
}
if len(localTopic.Partitions) == 0 {
emptyTopics = append(emptyTopics, topicKey)
}
})
// Remove emptied topics only after the iteration: IterCb holds the shard's
// read lock while running the callback, so calling Remove (which takes the
// same shard's write lock) inside it self-deadlocks and permanently wedges
// the shard — every later reader of that shard blocks behind the stuck
// writer, hanging ListTopicsInMemory/TopicExistsInMemory forever.
for _, topicKey := range emptyTopics {
manager.topics.RemoveCb(topicKey, func(key string, localTopic *LocalTopic, exists bool) bool {
if !exists || localTopic == nil {
return false
}
// Re-check emptiness under the shard's write lock: a publisher or
// subscriber may have added a partition since the scan above.
localTopic.partitionLock.RLock()
defer localTopic.partitionLock.RUnlock()
if len(localTopic.Partitions) > 0 {
return false
}
glog.V(1).Infof("Removing empty topic %s", key)
return true
})
}
if cleanedCount > 0 {
glog.V(0).Infof("Cleaned up %d idle partition(s)", cleanedCount)
}
}
// WaitForCleanupShutdown waits for the cleanup goroutine to finish
func (manager *LocalTopicManager) WaitForCleanupShutdown() {
<-manager.cleanupDone
glog.V(1).Info("Idle partition cleanup shutdown complete")
}
// AddLocalPartition adds a topic to the local topic manager
func (manager *LocalTopicManager) AddLocalPartition(topic Topic, localPartition *LocalPartition) {
localTopic, ok := manager.topics.Get(topic.String())
if !ok {
localTopic = NewLocalTopic(topic)
}
if !manager.topics.SetIfAbsent(topic.String(), localTopic) {
localTopic, _ = manager.topics.Get(topic.String())
}
localTopic.addPartition(localPartition)
}
// GetLocalPartition gets a topic from the local topic manager
func (manager *LocalTopicManager) GetLocalPartition(topic Topic, partition Partition) *LocalPartition {
localTopic, ok := manager.topics.Get(topic.String())
if !ok {
return nil
}
result := localTopic.findPartition(partition)
return result
}
// RemoveTopic removes a topic from the local topic manager
func (manager *LocalTopicManager) RemoveTopic(topic Topic) {
manager.topics.Remove(topic.String())
}
func (manager *LocalTopicManager) RemoveLocalPartition(topic Topic, partition Partition) (removed bool) {
localTopic, ok := manager.topics.Get(topic.String())
if !ok {
return false
}
return localTopic.removePartition(partition)
}
func (manager *LocalTopicManager) ClosePublishers(topic Topic, unixTsNs int64) (removed bool) {
localTopic, ok := manager.topics.Get(topic.String())
if !ok {
return false
}
return localTopic.closePartitionPublishers(unixTsNs)
}
func (manager *LocalTopicManager) CloseSubscribers(topic Topic, unixTsNs int64) (removed bool) {
localTopic, ok := manager.topics.Get(topic.String())
if !ok {
return false
}
return localTopic.closePartitionSubscribers(unixTsNs)
}
// ListTopicsInMemory returns all topics currently tracked in memory
func (manager *LocalTopicManager) ListTopicsInMemory() []Topic {
var topics []Topic
for item := range manager.topics.IterBuffered() {
topics = append(topics, item.Val.Topic)
}
return topics
}
// TopicExistsInMemory checks if a topic exists in memory (not flushed data)
func (manager *LocalTopicManager) TopicExistsInMemory(topic Topic) bool {
_, exists := manager.topics.Get(topic.String())
return exists
}
func (manager *LocalTopicManager) CollectStats(duration time.Duration) *mq_pb.BrokerStats {
stats := &mq_pb.BrokerStats{
Stats: make(map[string]*mq_pb.TopicPartitionStats),
}
// collect current broker's cpu usage
// this needs to be in front, so the following stats can be more accurate
usages, err := cpu.Percent(duration, false)
if err == nil && len(usages) > 0 {
stats.CpuUsagePercent = int32(usages[0])
}
// collect current broker's topics and partitions
manager.topics.IterCb(func(topic string, localTopic *LocalTopic) {
for _, localPartition := range localTopic.Partitions {
topicPartition := &TopicPartition{
Topic: Topic{Namespace: localTopic.Namespace, Name: localTopic.Name},
Partition: localPartition.Partition,
}
stats.Stats[topicPartition.TopicPartitionId()] = &mq_pb.TopicPartitionStats{
Topic: &schema_pb.Topic{
Namespace: string(localTopic.Namespace),
Name: localTopic.Name,
},
Partition: localPartition.Partition.ToPbPartition(),
PublisherCount: int32(localPartition.Publishers.Size()),
SubscriberCount: int32(localPartition.Subscribers.Size()),
Follower: localPartition.Follower,
}
// fmt.Printf("collect topic %+v partition %+v\n", topicPartition, localPartition.Partition)
}
})
return stats
}
func (manager *LocalTopicManager) WaitUntilNoPublishers(topic Topic) {
localTopic, ok := manager.topics.Get(topic.String())
if !ok {
return
}
localTopic.WaitUntilNoPublishers()
}