snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 03:23:42 +05:00
commit 89f5be005d
3997 changed files with 961970 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
package broker
import (
"context"
"fmt"
"io"
"math/rand/v2"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
)
// BrokerConnectToBalancer connects to the broker balancer and sends stats
func (b *MessageQueueBroker) BrokerConnectToBalancer(brokerBalancer string, stopCh chan struct{}) error {
self := string(b.option.BrokerAddress())
glog.V(0).Infof("broker %s connects to balancer %s", self, brokerBalancer)
if brokerBalancer == "" {
return fmt.Errorf("no balancer found")
}
// connect to the lock owner
return pb.WithBrokerGrpcClient(true, brokerBalancer, b.grpcDialOption, func(client mq_pb.SeaweedMessagingClient) error {
stream, err := client.PublisherToPubBalancer(context.Background())
if err != nil {
return fmt.Errorf("connect to balancer %v: %w", brokerBalancer, err)
}
defer stream.CloseSend()
err = stream.Send(&mq_pb.PublisherToPubBalancerRequest{
Message: &mq_pb.PublisherToPubBalancerRequest_Init{
Init: &mq_pb.PublisherToPubBalancerRequest_InitMessage{
Broker: self,
},
},
})
if err != nil {
return fmt.Errorf("send init message: %w", err)
}
for {
// check if the broker is stopping
select {
case <-stopCh:
return nil
default:
}
stats := b.localTopicManager.CollectStats(time.Second * 5)
err = stream.Send(&mq_pb.PublisherToPubBalancerRequest{
Message: &mq_pb.PublisherToPubBalancerRequest_Stats{
Stats: stats,
},
})
if err != nil {
if err == io.EOF {
// return err
}
return fmt.Errorf("send stats message to balancer %s: %v", brokerBalancer, err)
}
// glog.V(3).Infof("sent stats: %+v", stats)
time.Sleep(time.Millisecond*5000 + time.Duration(rand.IntN(1000))*time.Millisecond)
}
})
}
func (b *MessageQueueBroker) KeepConnectedToBrokerBalancer(newBrokerBalancerCh chan string) {
var stopPrevRunChan chan struct{}
for {
select {
case newBrokerBalancer := <-newBrokerBalancerCh:
if stopPrevRunChan != nil {
close(stopPrevRunChan)
stopPrevRunChan = nil
}
thisRunStopChan := make(chan struct{})
if newBrokerBalancer != "" {
stopPrevRunChan = thisRunStopChan
go func() {
for {
err := b.BrokerConnectToBalancer(newBrokerBalancer, thisRunStopChan)
if err != nil {
glog.V(0).Infof("connect to balancer %s: %v", newBrokerBalancer, err)
time.Sleep(time.Second)
} else {
break
}
select {
case <-thisRunStopChan:
return
default:
}
}
}()
}
}
}
}

View File

@@ -0,0 +1,127 @@
package broker
// Broker Error Codes
// These codes are used internally by the broker and can be mapped to Kafka protocol error codes
const (
// Success
BrokerErrorNone int32 = 0
// General broker errors
BrokerErrorUnknownServerError int32 = 1
BrokerErrorTopicNotFound int32 = 2
BrokerErrorPartitionNotFound int32 = 3
BrokerErrorNotLeaderOrFollower int32 = 6 // Maps to Kafka ErrorCodeNotLeaderOrFollower
BrokerErrorRequestTimedOut int32 = 7
BrokerErrorBrokerNotAvailable int32 = 8
BrokerErrorMessageTooLarge int32 = 10
BrokerErrorNetworkException int32 = 13
BrokerErrorOffsetLoadInProgress int32 = 14
BrokerErrorInvalidRecord int32 = 42
BrokerErrorTopicAlreadyExists int32 = 36
BrokerErrorInvalidPartitions int32 = 37
BrokerErrorInvalidConfig int32 = 40
// Publisher/connection errors
BrokerErrorPublisherNotFound int32 = 100
BrokerErrorConnectionFailed int32 = 101
BrokerErrorFollowerConnectionFailed int32 = 102
)
// BrokerErrorInfo contains metadata about a broker error
type BrokerErrorInfo struct {
Code int32
Name string
Description string
KafkaCode int16 // Corresponding Kafka protocol error code
}
// BrokerErrors maps broker error codes to their metadata and Kafka equivalents
var BrokerErrors = map[int32]BrokerErrorInfo{
BrokerErrorNone: {
Code: BrokerErrorNone, Name: "NONE",
Description: "No error", KafkaCode: 0,
},
BrokerErrorUnknownServerError: {
Code: BrokerErrorUnknownServerError, Name: "UNKNOWN_SERVER_ERROR",
Description: "Unknown server error", KafkaCode: 1,
},
BrokerErrorTopicNotFound: {
Code: BrokerErrorTopicNotFound, Name: "TOPIC_NOT_FOUND",
Description: "Topic not found", KafkaCode: 3, // UNKNOWN_TOPIC_OR_PARTITION
},
BrokerErrorPartitionNotFound: {
Code: BrokerErrorPartitionNotFound, Name: "PARTITION_NOT_FOUND",
Description: "Partition not found", KafkaCode: 3, // UNKNOWN_TOPIC_OR_PARTITION
},
BrokerErrorNotLeaderOrFollower: {
Code: BrokerErrorNotLeaderOrFollower, Name: "NOT_LEADER_OR_FOLLOWER",
Description: "Not leader or follower for this partition", KafkaCode: 6,
},
BrokerErrorRequestTimedOut: {
Code: BrokerErrorRequestTimedOut, Name: "REQUEST_TIMED_OUT",
Description: "Request timed out", KafkaCode: 7,
},
BrokerErrorBrokerNotAvailable: {
Code: BrokerErrorBrokerNotAvailable, Name: "BROKER_NOT_AVAILABLE",
Description: "Broker not available", KafkaCode: 8,
},
BrokerErrorMessageTooLarge: {
Code: BrokerErrorMessageTooLarge, Name: "MESSAGE_TOO_LARGE",
Description: "Message size exceeds limit", KafkaCode: 10,
},
BrokerErrorNetworkException: {
Code: BrokerErrorNetworkException, Name: "NETWORK_EXCEPTION",
Description: "Network error", KafkaCode: 13,
},
BrokerErrorOffsetLoadInProgress: {
Code: BrokerErrorOffsetLoadInProgress, Name: "OFFSET_LOAD_IN_PROGRESS",
Description: "Offset loading in progress", KafkaCode: 14,
},
BrokerErrorInvalidRecord: {
Code: BrokerErrorInvalidRecord, Name: "INVALID_RECORD",
Description: "Invalid record", KafkaCode: 42,
},
BrokerErrorTopicAlreadyExists: {
Code: BrokerErrorTopicAlreadyExists, Name: "TOPIC_ALREADY_EXISTS",
Description: "Topic already exists", KafkaCode: 36,
},
BrokerErrorInvalidPartitions: {
Code: BrokerErrorInvalidPartitions, Name: "INVALID_PARTITIONS",
Description: "Invalid partition count", KafkaCode: 37,
},
BrokerErrorInvalidConfig: {
Code: BrokerErrorInvalidConfig, Name: "INVALID_CONFIG",
Description: "Invalid configuration", KafkaCode: 40,
},
BrokerErrorPublisherNotFound: {
Code: BrokerErrorPublisherNotFound, Name: "PUBLISHER_NOT_FOUND",
Description: "Publisher not found", KafkaCode: 1, // UNKNOWN_SERVER_ERROR
},
BrokerErrorConnectionFailed: {
Code: BrokerErrorConnectionFailed, Name: "CONNECTION_FAILED",
Description: "Connection failed", KafkaCode: 13, // NETWORK_EXCEPTION
},
BrokerErrorFollowerConnectionFailed: {
Code: BrokerErrorFollowerConnectionFailed, Name: "FOLLOWER_CONNECTION_FAILED",
Description: "Failed to connect to follower brokers", KafkaCode: 13, // NETWORK_EXCEPTION
},
}
// GetBrokerErrorInfo returns error information for the given broker error code
func GetBrokerErrorInfo(code int32) BrokerErrorInfo {
if info, exists := BrokerErrors[code]; exists {
return info
}
return BrokerErrorInfo{
Code: code, Name: "UNKNOWN", Description: "Unknown broker error code", KafkaCode: 1,
}
}
// CreateBrokerError creates a structured broker error with both error code and message
func CreateBrokerError(code int32, message string) (int32, string) {
info := GetBrokerErrorInfo(code)
if message == "" {
message = info.Description
}
return code, message
}

View File

@@ -0,0 +1,24 @@
package broker
import (
"context"
"github.com/seaweedfs/seaweedfs/weed/mq/pub_balancer"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
)
func (b *MessageQueueBroker) FindBrokerLeader(c context.Context, request *mq_pb.FindBrokerLeaderRequest) (*mq_pb.FindBrokerLeaderResponse, error) {
ret := &mq_pb.FindBrokerLeaderResponse{}
err := b.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
resp, err := client.FindLockOwner(context.Background(), &filer_pb.FindLockOwnerRequest{
Name: pub_balancer.LockBrokerBalancer,
})
if err != nil {
return err
}
ret.Broker = resp.Owner
return nil
})
return ret, err
}

View File

@@ -0,0 +1,106 @@
package broker
import (
"context"
"fmt"
"sync"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq/logstore"
"github.com/seaweedfs/seaweedfs/weed/mq/pub_balancer"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
)
// AssignTopicPartitions Runs on the assigned broker, to execute the topic partition assignment
func (b *MessageQueueBroker) AssignTopicPartitions(c context.Context, request *mq_pb.AssignTopicPartitionsRequest) (*mq_pb.AssignTopicPartitionsResponse, error) {
ret := &mq_pb.AssignTopicPartitionsResponse{}
// drain existing topic partition subscriptions
for _, assignment := range request.BrokerPartitionAssignments {
t := topic.FromPbTopic(request.Topic)
partition := topic.FromPbPartition(assignment.Partition)
b.accessLock.Lock()
if request.IsDraining {
// TODO drain existing topic partition subscriptions
b.localTopicManager.RemoveLocalPartition(t, partition)
} else {
var localPartition *topic.LocalPartition
if localPartition = b.localTopicManager.GetLocalPartition(t, partition); localPartition == nil {
localPartition = topic.NewLocalPartition(partition, b.option.LogFlushInterval, b.genLogFlushFunc(t, partition), logstore.GenMergedReadFunc(b, t, partition))
// Initialize offset from existing data to ensure continuity on restart
b.initializePartitionOffsetFromExistingData(localPartition, t, partition)
b.localTopicManager.AddLocalPartition(t, localPartition)
} else {
}
}
b.accessLock.Unlock()
}
// if is leader, notify the followers to drain existing topic partition subscriptions
if request.IsLeader {
for _, brokerPartition := range request.BrokerPartitionAssignments {
if follower := brokerPartition.FollowerBroker; follower != "" {
err := pb.WithBrokerGrpcClient(false, follower, b.grpcDialOption, func(client mq_pb.SeaweedMessagingClient) error {
_, err := client.AssignTopicPartitions(context.Background(), request)
return err
})
if err != nil {
return ret, err
}
}
}
}
return ret, nil
}
// called by broker leader to drain existing partitions.
// new/updated partitions will be detected by broker from the filer
func (b *MessageQueueBroker) assignTopicPartitionsToBrokers(ctx context.Context, t *schema_pb.Topic, assignments []*mq_pb.BrokerPartitionAssignment, isAdd bool) error {
// notify the brokers to create the topic partitions in parallel
var wg sync.WaitGroup
for _, bpa := range assignments {
wg.Add(1)
go func(bpa *mq_pb.BrokerPartitionAssignment) {
defer wg.Done()
if doCreateErr := b.withBrokerClient(false, pb.ServerAddress(bpa.LeaderBroker), func(client mq_pb.SeaweedMessagingClient) error {
_, doCreateErr := client.AssignTopicPartitions(ctx, &mq_pb.AssignTopicPartitionsRequest{
Topic: t,
BrokerPartitionAssignments: []*mq_pb.BrokerPartitionAssignment{
{
Partition: bpa.Partition,
},
},
IsLeader: true,
IsDraining: !isAdd,
})
if doCreateErr != nil {
if !isAdd {
return fmt.Errorf("drain topic %s %v on %s: %v", t, bpa.LeaderBroker, bpa.Partition, doCreateErr)
} else {
return fmt.Errorf("create topic %s %v on %s: %v", t, bpa.LeaderBroker, bpa.Partition, doCreateErr)
}
}
brokerStats, found := b.PubBalancer.Brokers.Get(bpa.LeaderBroker)
if !found {
brokerStats = pub_balancer.NewBrokerStats()
if !b.PubBalancer.Brokers.SetIfAbsent(bpa.LeaderBroker, brokerStats) {
brokerStats, _ = b.PubBalancer.Brokers.Get(bpa.LeaderBroker)
}
}
brokerStats.RegisterAssignment(t, bpa.Partition, isAdd)
return nil
}); doCreateErr != nil {
glog.Errorf("create topic %s partition %+v on %s: %v", t, bpa.Partition, bpa.LeaderBroker, doCreateErr)
}
}(bpa)
}
wg.Wait()
return nil
}

View File

@@ -0,0 +1,28 @@
package broker
import (
"context"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
)
func (b *MessageQueueBroker) BalanceTopics(ctx context.Context, request *mq_pb.BalanceTopicsRequest) (resp *mq_pb.BalanceTopicsResponse, err error) {
if !b.isLockOwner() {
proxyErr := b.withBrokerClient(false, pb.ServerAddress(b.lockAsBalancer.LockOwner()), func(client mq_pb.SeaweedMessagingClient) error {
resp, err = client.BalanceTopics(ctx, request)
return nil
})
if proxyErr != nil {
return nil, proxyErr
}
return resp, err
}
ret := &mq_pb.BalanceTopicsResponse{}
actions := b.PubBalancer.BalancePublishers()
err = b.PubBalancer.ExecuteBalanceAction(actions, b.grpcDialOption)
return ret, err
}

View File

@@ -0,0 +1,145 @@
package broker
import (
"context"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq/pub_balancer"
"github.com/seaweedfs/seaweedfs/weed/mq/schema"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
// ConfigureTopic Runs on any broker, but proxied to the balancer if not the balancer
// It generates an assignments based on existing allocations,
// and then assign the partitions to the brokers.
func (b *MessageQueueBroker) ConfigureTopic(ctx context.Context, request *mq_pb.ConfigureTopicRequest) (resp *mq_pb.ConfigureTopicResponse, err error) {
if !b.isLockOwner() {
proxyErr := b.withBrokerClient(false, pb.ServerAddress(b.lockAsBalancer.LockOwner()), func(client mq_pb.SeaweedMessagingClient) error {
resp, err = client.ConfigureTopic(ctx, request)
return nil
})
if proxyErr != nil {
return nil, proxyErr
}
return resp, err
}
// Validate flat schema format
if request.MessageRecordType != nil && len(request.KeyColumns) > 0 {
if err := schema.ValidateKeyColumns(request.MessageRecordType, request.KeyColumns); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid key columns: %v", err)
}
}
t := topic.FromPbTopic(request.Topic)
var readErr, assignErr error
resp, readErr = b.fca.ReadTopicConfFromFiler(t)
if readErr != nil {
glog.V(0).Infof("read topic %s conf: %v", request.Topic, readErr)
}
if resp != nil {
assignErr = b.ensureTopicActiveAssignments(t, resp)
// no need to assign directly.
// The added or updated assignees will read from filer directly.
// The gone assignees will die by themselves.
}
if readErr == nil && assignErr == nil && len(resp.BrokerPartitionAssignments) == int(request.PartitionCount) {
// Check if schema needs to be updated
schemaChanged := false
if request.MessageRecordType != nil && resp.MessageRecordType != nil {
if !proto.Equal(request.MessageRecordType, resp.MessageRecordType) {
schemaChanged = true
}
} else if request.MessageRecordType != nil || resp.MessageRecordType != nil {
schemaChanged = true
}
// Check if retention needs to be updated. Callers that do not want to
// touch retention send Retention=nil; we only consider it a change
// when the request supplies a non-nil Retention that differs from
// the stored value.
retentionChanged := request.Retention != nil && !proto.Equal(request.Retention, resp.Retention)
if !schemaChanged && !retentionChanged {
glog.V(0).Infof("existing topic partitions %d: %+v", len(resp.BrokerPartitionAssignments), resp.BrokerPartitionAssignments)
return resp, nil
}
if schemaChanged {
resp.MessageRecordType = request.MessageRecordType
resp.KeyColumns = request.KeyColumns
resp.SchemaFormat = request.SchemaFormat
}
if retentionChanged {
resp.Retention = request.Retention
}
if err := b.fca.SaveTopicConfToFiler(t, resp); err != nil {
return nil, fmt.Errorf("update topic conf: %w", err)
}
// Invalidate topic cache since we just updated the topic
b.invalidateTopicCache(t)
glog.V(0).Infof("updated topic %s (schema=%v retention=%v)", request.Topic, schemaChanged, retentionChanged)
return resp, nil
}
// Capture the prior retention before we overwrite resp so partition-count
// changes (or first-time creates that hit this path after a partial
// readErr) don't accidentally clear an existing retention configuration
// when the request omits Retention.
var prevRetention *mq_pb.TopicRetention
if resp != nil {
prevRetention = resp.Retention
}
if resp != nil && len(resp.BrokerPartitionAssignments) > 0 {
if cancelErr := b.assignTopicPartitionsToBrokers(ctx, request.Topic, resp.BrokerPartitionAssignments, false); cancelErr != nil {
glog.V(1).Infof("cancel old topic %s partitions assignments %v : %v", request.Topic, resp.BrokerPartitionAssignments, cancelErr)
}
}
resp = &mq_pb.ConfigureTopicResponse{}
if b.PubBalancer.Brokers.IsEmpty() {
return nil, status.Errorf(codes.Unavailable, "no broker available: %v", pub_balancer.ErrNoBroker)
}
resp.BrokerPartitionAssignments = pub_balancer.AllocateTopicPartitions(b.PubBalancer.Brokers, request.PartitionCount)
// Set flat schema format
resp.MessageRecordType = request.MessageRecordType
resp.KeyColumns = request.KeyColumns
resp.SchemaFormat = request.SchemaFormat
if request.Retention != nil {
resp.Retention = request.Retention
} else {
resp.Retention = prevRetention
}
// save the topic configuration on filer
if err := b.fca.SaveTopicConfToFiler(t, resp); err != nil {
return nil, fmt.Errorf("configure topic: %w", err)
}
// Invalidate topic cache since we just created/updated the topic
b.invalidateTopicCache(t)
b.PubBalancer.OnPartitionChange(request.Topic, resp.BrokerPartitionAssignments)
// Actually assign the new partitions to brokers and add to localTopicManager
if assignErr := b.assignTopicPartitionsToBrokers(ctx, request.Topic, resp.BrokerPartitionAssignments, true); assignErr != nil {
glog.Errorf("assign topic %s partitions to brokers: %v", request.Topic, assignErr)
return nil, fmt.Errorf("assign topic partitions: %w", assignErr)
}
glog.V(0).Infof("ConfigureTopic: topic %s partition assignments: %v", request.Topic, resp.BrokerPartitionAssignments)
return resp, nil
}

View File

@@ -0,0 +1,164 @@
package broker
import (
"context"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
)
// FetchMessage implements Kafka-style stateless message fetching
// This is the recommended API for Kafka gateway and other stateless clients
//
// Key differences from SubscribeMessage:
// 1. Request/Response pattern (not streaming)
// 2. No session state maintained on broker
// 3. Each request is completely independent
// 4. Safe for concurrent calls at different offsets
// 5. No Subscribe loop cancellation/restart complexity
//
// Design inspired by Kafka's Fetch API:
// - Client manages offset tracking
// - Each fetch is independent
// - No shared state between requests
// - Natural support for concurrent reads
func (b *MessageQueueBroker) FetchMessage(ctx context.Context, req *mq_pb.FetchMessageRequest) (*mq_pb.FetchMessageResponse, error) {
glog.V(3).Infof("[FetchMessage] CALLED!") // DEBUG: ensure this shows up
// Validate request
if req.Topic == nil {
return nil, fmt.Errorf("missing topic")
}
if req.Partition == nil {
return nil, fmt.Errorf("missing partition")
}
t := topic.FromPbTopic(req.Topic)
partition := topic.FromPbPartition(req.Partition)
glog.V(3).Infof("[FetchMessage] %s/%s partition=%v offset=%d maxMessages=%d maxBytes=%d consumer=%s/%s",
t.Namespace, t.Name, partition, req.StartOffset, req.MaxMessages, req.MaxBytes,
req.ConsumerGroup, req.ConsumerId)
// Get local partition
localPartition, err := b.GetOrGenerateLocalPartition(t, partition)
if err != nil {
glog.Errorf("[FetchMessage] Failed to get partition: %v", err)
return &mq_pb.FetchMessageResponse{
Error: fmt.Sprintf("partition not found: %v", err),
ErrorCode: 1,
}, nil
}
if localPartition == nil {
return &mq_pb.FetchMessageResponse{
Error: "partition not found",
ErrorCode: 1,
}, nil
}
// Set defaults for limits
maxMessages := int(req.MaxMessages)
if maxMessages <= 0 {
maxMessages = 100 // Reasonable default
}
if maxMessages > 10000 {
maxMessages = 10000 // Safety limit
}
maxBytes := int(req.MaxBytes)
if maxBytes <= 0 {
maxBytes = 4 * 1024 * 1024 // 4MB default
}
if maxBytes > 100*1024*1024 {
maxBytes = 100 * 1024 * 1024 // 100MB safety limit
}
// TODO: Long poll support disabled for now (causing timeouts)
// Check if we should wait for data (long poll support)
// shouldWait := req.MaxWaitMs > 0
// if shouldWait {
// // Wait for data to be available (with timeout)
// dataAvailable := localPartition.LogBuffer.WaitForDataWithTimeout(req.StartOffset, int(req.MaxWaitMs))
// if !dataAvailable {
// // Timeout - return empty response
// glog.V(3).Infof("[FetchMessage] Timeout waiting for data at offset %d", req.StartOffset)
// return &mq_pb.FetchMessageResponse{
// Messages: []*mq_pb.DataMessage{},
// HighWaterMark: localPartition.LogBuffer.GetHighWaterMark(),
// LogStartOffset: localPartition.LogBuffer.GetLogStartOffset(),
// EndOfPartition: false,
// NextOffset: req.StartOffset,
// }, nil
// }
// }
// Check if disk read function is configured
if localPartition.LogBuffer.ReadFromDiskFn == nil {
glog.Errorf("[FetchMessage] LogBuffer.ReadFromDiskFn is nil! This should not happen.")
} else {
glog.V(3).Infof("[FetchMessage] LogBuffer.ReadFromDiskFn is configured")
}
// Use requested offset directly - let ReadMessagesAtOffset handle disk reads
requestedOffset := req.StartOffset
// Read messages from LogBuffer (stateless read)
logEntries, nextOffset, highWaterMark, endOfPartition, err := localPartition.LogBuffer.ReadMessagesAtOffset(
requestedOffset,
maxMessages,
maxBytes,
)
// CRITICAL: Log the result with full details
if len(logEntries) == 0 && highWaterMark > requestedOffset && err == nil {
glog.Errorf("[FetchMessage] CRITICAL: ReadMessagesAtOffset returned 0 entries but HWM=%d > requestedOffset=%d (should return data!)",
highWaterMark, requestedOffset)
glog.Errorf("[FetchMessage] Details: nextOffset=%d, endOfPartition=%v, bufferStartOffset=%d",
nextOffset, endOfPartition, localPartition.LogBuffer.GetLogStartOffset())
}
if err != nil {
// Check if this is an "offset out of range" error
errMsg := err.Error()
if len(errMsg) > 0 && (len(errMsg) < 20 || errMsg[:20] != "offset") {
glog.Errorf("[FetchMessage] Read error: %v", err)
} else {
// Offset out of range - this is expected when consumer requests old data
glog.V(3).Infof("[FetchMessage] Offset out of range: %v", err)
}
// Return empty response with metadata - let client adjust offset
return &mq_pb.FetchMessageResponse{
Messages: []*mq_pb.DataMessage{},
HighWaterMark: highWaterMark,
LogStartOffset: localPartition.LogBuffer.GetLogStartOffset(),
EndOfPartition: false,
NextOffset: localPartition.LogBuffer.GetLogStartOffset(), // Suggest starting from earliest available
Error: errMsg,
ErrorCode: 2,
}, nil
}
// Convert to protobuf messages
messages := make([]*mq_pb.DataMessage, 0, len(logEntries))
for _, entry := range logEntries {
messages = append(messages, &mq_pb.DataMessage{
Key: entry.Key,
Value: entry.Data,
TsNs: entry.TsNs,
})
}
glog.V(4).Infof("[FetchMessage] Returning %d messages, nextOffset=%d, highWaterMark=%d, endOfPartition=%v",
len(messages), nextOffset, highWaterMark, endOfPartition)
return &mq_pb.FetchMessageResponse{
Messages: messages,
HighWaterMark: highWaterMark,
LogStartOffset: localPartition.LogBuffer.GetLogStartOffset(),
EndOfPartition: endOfPartition,
NextOffset: nextOffset,
}, nil
}

View File

@@ -0,0 +1,441 @@
package broker
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
)
// LookupTopicBrokers returns the brokers that are serving the topic
func (b *MessageQueueBroker) LookupTopicBrokers(ctx context.Context, request *mq_pb.LookupTopicBrokersRequest) (resp *mq_pb.LookupTopicBrokersResponse, err error) {
if !b.isLockOwner() {
proxyErr := b.withBrokerClient(false, pb.ServerAddress(b.lockAsBalancer.LockOwner()), func(client mq_pb.SeaweedMessagingClient) error {
resp, err = client.LookupTopicBrokers(ctx, request)
return nil
})
if proxyErr != nil {
return nil, proxyErr
}
return resp, err
}
t := topic.FromPbTopic(request.Topic)
ret := &mq_pb.LookupTopicBrokersResponse{}
ret.Topic = request.Topic
// Use cached topic config to avoid expensive filer reads (26% CPU overhead!)
// getTopicConfFromCache also validates broker assignments on cache miss (saves 14% CPU)
conf, err := b.getTopicConfFromCache(t)
if err != nil {
glog.V(0).Infof("lookup topic %s conf: %v", request.Topic, err)
return ret, err
}
// Note: Assignment validation is now done inside getTopicConfFromCache on cache misses
// This avoids 14% CPU overhead from validating on EVERY lookup
ret.BrokerPartitionAssignments = conf.BrokerPartitionAssignments
return ret, nil
}
func (b *MessageQueueBroker) ListTopics(ctx context.Context, request *mq_pb.ListTopicsRequest) (resp *mq_pb.ListTopicsResponse, err error) {
glog.V(4).Infof("📋 ListTopics called, isLockOwner=%v", b.isLockOwner())
if !b.isLockOwner() {
glog.V(4).Infof("📋 ListTopics proxying to lock owner: %s", b.lockAsBalancer.LockOwner())
proxyErr := b.withBrokerClient(false, pb.ServerAddress(b.lockAsBalancer.LockOwner()), func(client mq_pb.SeaweedMessagingClient) error {
resp, err = client.ListTopics(ctx, request)
return nil
})
if proxyErr != nil {
return nil, proxyErr
}
return resp, err
}
glog.V(4).Infof("📋 ListTopics starting - getting in-memory topics")
ret := &mq_pb.ListTopicsResponse{}
// First, get topics from in-memory state (includes unflushed topics)
inMemoryTopics := b.localTopicManager.ListTopicsInMemory()
glog.V(4).Infof("📋 ListTopics found %d in-memory topics", len(inMemoryTopics))
topicMap := make(map[string]*schema_pb.Topic)
// Add in-memory topics to the result
for _, topic := range inMemoryTopics {
topicMap[topic.String()] = &schema_pb.Topic{
Namespace: topic.Namespace,
Name: topic.Name,
}
}
// Then, scan the filer directory structure to find persisted topics (fallback for topics not in memory)
// Use a shorter timeout for filer scanning to ensure Metadata requests remain fast
filerCtx, filerCancel := context.WithTimeout(ctx, 2*time.Second)
defer filerCancel()
glog.V(4).Infof("📋 ListTopics scanning filer for persisted topics (2s timeout)")
err = b.fca.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
// List all namespaces under /topics
glog.V(4).Infof("📋 ListTopics calling ListEntries for %s", filer.TopicsDir)
stream, err := client.ListEntries(filerCtx, &filer_pb.ListEntriesRequest{
Directory: filer.TopicsDir,
Limit: 1000,
})
if err != nil {
glog.V(0).Infof("list namespaces in %s: %v", filer.TopicsDir, err)
return err
}
glog.V(4).Infof("📋 ListTopics got ListEntries stream, processing namespaces...")
// Process each namespace
for {
resp, err := stream.Recv()
if err != nil {
if err.Error() == "EOF" {
break
}
return err
}
if resp.Entry == nil || !resp.Entry.IsDirectory {
continue
}
namespaceName := resp.Entry.Name
namespacePath := fmt.Sprintf("%s/%s", filer.TopicsDir, namespaceName)
// List all topics in this namespace
topicStream, err := client.ListEntries(filerCtx, &filer_pb.ListEntriesRequest{
Directory: namespacePath,
Limit: 1000,
})
if err != nil {
glog.V(0).Infof("list topics in namespace %s: %v", namespacePath, err)
continue
}
// Process each topic in the namespace
for {
topicResp, err := topicStream.Recv()
if err != nil {
if err.Error() == "EOF" {
break
}
glog.V(0).Infof("error reading topic stream in namespace %s: %v", namespaceName, err)
break
}
if topicResp.Entry == nil || !topicResp.Entry.IsDirectory {
continue
}
topicName := topicResp.Entry.Name
// Check if topic.conf exists
topicPath := fmt.Sprintf("%s/%s", namespacePath, topicName)
confResp, err := client.LookupDirectoryEntry(filerCtx, &filer_pb.LookupDirectoryEntryRequest{
Directory: topicPath,
Name: filer.TopicConfFile,
})
if err != nil {
glog.V(0).Infof("lookup topic.conf in %s: %v", topicPath, err)
continue
}
if confResp.Entry != nil {
// This is a valid persisted topic - add to map if not already present
topicKey := fmt.Sprintf("%s.%s", namespaceName, topicName)
if _, exists := topicMap[topicKey]; !exists {
topicMap[topicKey] = &schema_pb.Topic{
Namespace: namespaceName,
Name: topicName,
}
}
}
}
}
return nil
})
// Convert map to slice for response (combines in-memory and persisted topics)
for _, topic := range topicMap {
ret.Topics = append(ret.Topics, topic)
}
if err != nil {
glog.V(0).Infof("ListTopics: filer scan failed: %v (returning %d in-memory topics)", err, len(inMemoryTopics))
// Still return in-memory topics even if filer fails
} else {
glog.V(4).Infof("📋 ListTopics completed successfully: %d total topics (in-memory + persisted)", len(ret.Topics))
}
return ret, nil
}
// TopicExists checks if a topic exists in memory or filer
// Uses unified cache (checks if config is non-nil) to reduce filer load
func (b *MessageQueueBroker) TopicExists(ctx context.Context, request *mq_pb.TopicExistsRequest) (*mq_pb.TopicExistsResponse, error) {
if !b.isLockOwner() {
var resp *mq_pb.TopicExistsResponse
var err error
proxyErr := b.withBrokerClient(false, pb.ServerAddress(b.lockAsBalancer.LockOwner()), func(client mq_pb.SeaweedMessagingClient) error {
resp, err = client.TopicExists(ctx, request)
return nil
})
if proxyErr != nil {
return nil, proxyErr
}
return resp, err
}
if request.Topic == nil {
return &mq_pb.TopicExistsResponse{Exists: false}, nil
}
// Convert schema_pb.Topic to topic.Topic
topicObj := topic.Topic{
Namespace: request.Topic.Namespace,
Name: request.Topic.Name,
}
topicKey := topicObj.String()
// First check in-memory state (includes unflushed topics)
if b.localTopicManager.TopicExistsInMemory(topicObj) {
return &mq_pb.TopicExistsResponse{Exists: true}, nil
}
// Check unified cache (if conf != nil, topic exists; if conf == nil, doesn't exist)
b.topicCacheMu.RLock()
if entry, found := b.topicCache[topicKey]; found {
if time.Now().Before(entry.expiresAt) {
exists := entry.conf != nil
b.topicCacheMu.RUnlock()
glog.V(4).Infof("Topic cache HIT for %s: exists=%v", topicKey, exists)
return &mq_pb.TopicExistsResponse{Exists: exists}, nil
}
}
b.topicCacheMu.RUnlock()
// Cache miss or expired - query filer for persisted topics (lightweight check)
glog.V(4).Infof("Topic cache MISS for %s, querying filer for existence", topicKey)
exists := false
err := b.fca.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
topicPath := fmt.Sprintf("%s/%s/%s", filer.TopicsDir, request.Topic.Namespace, request.Topic.Name)
_, lookupErr := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: topicPath,
Name: filer.TopicConfFile,
})
if lookupErr == nil {
exists = true
return nil
}
// A definitive "not found" means the topic does not exist; surface any
// other (transient) error so we never cache a false negative.
if errors.Is(lookupErr, filer_pb.ErrNotFound) {
return nil
}
return lookupErr
})
if err != nil {
glog.V(0).Infof("check topic existence in filer: %v", err)
// Don't cache errors - return false and let next check retry
return &mq_pb.TopicExistsResponse{Exists: false}, nil
}
// Update unified cache with lightweight result (don't read full config yet)
// Cache existence info: conf=nil for non-existent (we don't have full config yet for existent)
b.topicCacheMu.Lock()
if !exists {
// Negative cache: topic definitely doesn't exist
b.topicCache[topicKey] = &topicCacheEntry{
conf: nil,
expiresAt: time.Now().Add(b.topicCacheTTL),
}
glog.V(4).Infof("Topic cached as non-existent: %s", topicKey)
}
// Note: For positive existence, we don't cache here to avoid partial state
// The config will be cached when GetOrGenerateLocalPartition reads it
b.topicCacheMu.Unlock()
return &mq_pb.TopicExistsResponse{Exists: exists}, nil
}
// GetTopicConfiguration returns the complete configuration of a topic including schema and partition assignments
func (b *MessageQueueBroker) GetTopicConfiguration(ctx context.Context, request *mq_pb.GetTopicConfigurationRequest) (resp *mq_pb.GetTopicConfigurationResponse, err error) {
if !b.isLockOwner() {
proxyErr := b.withBrokerClient(false, pb.ServerAddress(b.lockAsBalancer.LockOwner()), func(client mq_pb.SeaweedMessagingClient) error {
resp, err = client.GetTopicConfiguration(ctx, request)
return nil
})
if proxyErr != nil {
return nil, proxyErr
}
return resp, err
}
t := topic.FromPbTopic(request.Topic)
var conf *mq_pb.ConfigureTopicResponse
var createdAtNs, modifiedAtNs int64
if conf, createdAtNs, modifiedAtNs, err = b.fca.ReadTopicConfFromFilerWithMetadata(t); err != nil {
glog.V(0).Infof("get topic configuration %s: %v", request.Topic, err)
return nil, fmt.Errorf("failed to read topic configuration: %w", err)
}
// Ensure topic assignments are active
err = b.ensureTopicActiveAssignments(t, conf)
if err != nil {
glog.V(0).Infof("ensure topic active assignments %s: %v", request.Topic, err)
return nil, fmt.Errorf("failed to ensure topic assignments: %w", err)
}
// Build the response with complete configuration including metadata
ret := &mq_pb.GetTopicConfigurationResponse{
Topic: request.Topic,
PartitionCount: int32(len(conf.BrokerPartitionAssignments)),
MessageRecordType: conf.MessageRecordType,
KeyColumns: conf.KeyColumns,
BrokerPartitionAssignments: conf.BrokerPartitionAssignments,
CreatedAtNs: createdAtNs,
LastUpdatedNs: modifiedAtNs,
Retention: conf.Retention,
}
return ret, nil
}
// GetTopicPublishers returns the active publishers for a topic
func (b *MessageQueueBroker) GetTopicPublishers(ctx context.Context, request *mq_pb.GetTopicPublishersRequest) (resp *mq_pb.GetTopicPublishersResponse, err error) {
if !b.isLockOwner() {
proxyErr := b.withBrokerClient(false, pb.ServerAddress(b.lockAsBalancer.LockOwner()), func(client mq_pb.SeaweedMessagingClient) error {
resp, err = client.GetTopicPublishers(ctx, request)
return nil
})
if proxyErr != nil {
return nil, proxyErr
}
return resp, err
}
t := topic.FromPbTopic(request.Topic)
var publishers []*mq_pb.TopicPublisher
// Get topic configuration to find partition assignments
var conf *mq_pb.ConfigureTopicResponse
if conf, _, _, err = b.fca.ReadTopicConfFromFilerWithMetadata(t); err != nil {
glog.V(0).Infof("get topic configuration for publishers %s: %v", request.Topic, err)
return nil, fmt.Errorf("failed to read topic configuration: %w", err)
}
// Collect publishers from each partition that is hosted on this broker
for _, assignment := range conf.BrokerPartitionAssignments {
// Only collect from partitions where this broker is the leader
if pb.ServerAddress(assignment.LeaderBroker).Equals(b.option.BrokerAddress()) {
partition := topic.FromPbPartition(assignment.Partition)
if localPartition := b.localTopicManager.GetLocalPartition(t, partition); localPartition != nil {
// Get publisher information from local partition
localPartition.Publishers.ForEachPublisher(func(clientName string, publisher *topic.LocalPublisher) {
connectTimeNs, lastSeenTimeNs := publisher.GetTimestamps()
lastPublishedOffset, lastAckedOffset := publisher.GetOffsets()
publishers = append(publishers, &mq_pb.TopicPublisher{
PublisherName: clientName,
ClientId: clientName, // For now, client name is used as client ID
Partition: assignment.Partition,
ConnectTimeNs: connectTimeNs,
LastSeenTimeNs: lastSeenTimeNs,
Broker: assignment.LeaderBroker,
IsActive: true,
LastPublishedOffset: lastPublishedOffset,
LastAckedOffset: lastAckedOffset,
})
})
}
}
}
return &mq_pb.GetTopicPublishersResponse{
Publishers: publishers,
}, nil
}
// GetTopicSubscribers returns the active subscribers for a topic
func (b *MessageQueueBroker) GetTopicSubscribers(ctx context.Context, request *mq_pb.GetTopicSubscribersRequest) (resp *mq_pb.GetTopicSubscribersResponse, err error) {
if !b.isLockOwner() {
proxyErr := b.withBrokerClient(false, pb.ServerAddress(b.lockAsBalancer.LockOwner()), func(client mq_pb.SeaweedMessagingClient) error {
resp, err = client.GetTopicSubscribers(ctx, request)
return nil
})
if proxyErr != nil {
return nil, proxyErr
}
return resp, err
}
t := topic.FromPbTopic(request.Topic)
var subscribers []*mq_pb.TopicSubscriber
// Get topic configuration to find partition assignments
var conf *mq_pb.ConfigureTopicResponse
if conf, _, _, err = b.fca.ReadTopicConfFromFilerWithMetadata(t); err != nil {
glog.V(0).Infof("get topic configuration for subscribers %s: %v", request.Topic, err)
return nil, fmt.Errorf("failed to read topic configuration: %w", err)
}
// Collect subscribers from each partition that is hosted on this broker
for _, assignment := range conf.BrokerPartitionAssignments {
// Only collect from partitions where this broker is the leader
if pb.ServerAddress(assignment.LeaderBroker).Equals(b.option.BrokerAddress()) {
partition := topic.FromPbPartition(assignment.Partition)
if localPartition := b.localTopicManager.GetLocalPartition(t, partition); localPartition != nil {
// Get subscriber information from local partition
localPartition.Subscribers.ForEachSubscriber(func(clientName string, subscriber *topic.LocalSubscriber) {
// Parse client name to extract consumer group and consumer ID
// Format is typically: "consumerGroup/consumerID"
consumerGroup := "default"
consumerID := clientName
if idx := strings.Index(clientName, "/"); idx != -1 {
consumerGroup = clientName[:idx]
consumerID = clientName[idx+1:]
}
connectTimeNs, lastSeenTimeNs := subscriber.GetTimestamps()
lastReceivedOffset, lastAckedOffset := subscriber.GetOffsets()
subscribers = append(subscribers, &mq_pb.TopicSubscriber{
ConsumerGroup: consumerGroup,
ConsumerId: consumerID,
ClientId: clientName, // Full client name as client ID
Partition: assignment.Partition,
ConnectTimeNs: connectTimeNs,
LastSeenTimeNs: lastSeenTimeNs,
Broker: assignment.LeaderBroker,
IsActive: true,
CurrentOffset: lastAckedOffset, // for compatibility
LastReceivedOffset: lastReceivedOffset,
})
})
}
}
}
return &mq_pb.GetTopicSubscribersResponse{
Subscribers: subscribers,
}, nil
}
func (b *MessageQueueBroker) isLockOwner() bool {
return pb.ServerAddress(b.lockAsBalancer.LockOwner()).Equals(b.option.BrokerAddress())
}

View File

@@ -0,0 +1,296 @@
package broker
import (
"context"
"fmt"
"io"
"math/rand/v2"
"net"
"sync/atomic"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
"google.golang.org/grpc/peer"
"google.golang.org/protobuf/proto"
)
// PUB
// 1. gRPC API to configure a topic
// 1.1 create a topic with existing partition count
// 1.2 assign partitions to brokers
// 2. gRPC API to lookup topic partitions
// 3. gRPC API to publish by topic partitions
// SUB
// 1. gRPC API to lookup a topic partitions
// Re-balance topic partitions for publishing
// 1. collect stats from all the brokers
// 2. Rebalance and configure new generation of partitions on brokers
// 3. Tell brokers to close current generation of publishing.
// Publishers needs to lookup again and publish to the new generation of partitions.
// Re-balance topic partitions for subscribing
// 1. collect stats from all the brokers
// Subscribers needs to listen for new partitions and connect to the brokers.
// Each subscription may not get data. It can act as a backup.
func (b *MessageQueueBroker) PublishMessage(stream mq_pb.SeaweedMessaging_PublishMessageServer) error {
req, err := stream.Recv()
if err != nil {
return err
}
response := &mq_pb.PublishMessageResponse{}
initMessage := req.GetInit()
if initMessage == nil {
response.ErrorCode, response.Error = CreateBrokerError(BrokerErrorInvalidRecord, "missing init message")
glog.Errorf("missing init message")
return stream.Send(response)
}
// Check whether current broker should be the leader for the topic partition
leaderBroker, err := b.findBrokerForTopicPartition(initMessage.Topic, initMessage.Partition)
if err != nil {
response.ErrorCode, response.Error = CreateBrokerError(BrokerErrorTopicNotFound, fmt.Sprintf("failed to find leader for topic partition: %v", err))
glog.Errorf("failed to find leader for topic partition: %v", err)
return stream.Send(response)
}
currentBrokerAddress := fmt.Sprintf("%s:%d", b.option.Ip, b.option.Port)
if leaderBroker != currentBrokerAddress {
response.ErrorCode, response.Error = CreateBrokerError(BrokerErrorNotLeaderOrFollower, fmt.Sprintf("not the leader for this partition, leader is: %s", leaderBroker))
glog.V(1).Infof("rejecting publish request: not the leader for partition, leader is: %s", leaderBroker)
return stream.Send(response)
}
// get or generate a local partition
t, p := topic.FromPbTopic(initMessage.Topic), topic.FromPbPartition(initMessage.Partition)
localTopicPartition, getOrGenErr := b.GetOrGenerateLocalPartition(t, p)
if getOrGenErr != nil {
response.ErrorCode, response.Error = CreateBrokerError(BrokerErrorTopicNotFound, fmt.Sprintf("topic %v not found: %v", t, getOrGenErr))
glog.Errorf("topic %v not found: %v", t, getOrGenErr)
return stream.Send(response)
}
// connect to follower brokers
if followerErr := localTopicPartition.MaybeConnectToFollowers(initMessage, b.grpcDialOption); followerErr != nil {
response.ErrorCode, response.Error = CreateBrokerError(BrokerErrorFollowerConnectionFailed, followerErr.Error())
glog.Errorf("MaybeConnectToFollowers: %v", followerErr)
return stream.Send(response)
}
// process each published messages
clientName := fmt.Sprintf("%v-%4d", findClientAddress(stream.Context()), rand.IntN(10000))
publisher := topic.NewLocalPublisher()
localTopicPartition.Publishers.AddPublisher(clientName, publisher)
// DISABLED: Periodic ack goroutine not needed with immediate per-message acks
// Immediate acks provide correct offset information for Kafka Gateway
var receivedSequence, acknowledgedSequence int64
var isClosed bool
if false {
ackInterval := int64(1)
if initMessage.AckInterval > 0 {
ackInterval = int64(initMessage.AckInterval)
}
go func() {
defer func() {
// println("stop sending ack to publisher", initMessage.PublisherName)
}()
lastAckTime := time.Now()
for !isClosed {
receivedSequence = atomic.LoadInt64(&localTopicPartition.AckTsNs)
if acknowledgedSequence < receivedSequence && (receivedSequence-acknowledgedSequence >= ackInterval || time.Since(lastAckTime) > 100*time.Millisecond) {
acknowledgedSequence = receivedSequence
response := &mq_pb.PublishMessageResponse{
AckTsNs: acknowledgedSequence,
}
if err := stream.Send(response); err != nil {
glog.Errorf("Error sending response %v: %v", response, err)
}
// Update acknowledged offset for this publisher
publisher.UpdateAckedOffset(acknowledgedSequence)
// println("sent ack", acknowledgedSequence, "=>", initMessage.PublisherName)
lastAckTime = time.Now()
} else {
time.Sleep(10 * time.Millisecond) // Reduced from 1s to 10ms for faster acknowledgments
}
}
}()
}
defer func() {
// remove the publisher
localTopicPartition.Publishers.RemovePublisher(clientName)
// Use topic-aware shutdown logic to prevent aggressive removal of system topics
if localTopicPartition.MaybeShutdownLocalPartitionForTopic(t.Name) {
b.localTopicManager.RemoveLocalPartition(t, p)
glog.V(0).Infof("Removed local topic %v partition %v", initMessage.Topic, initMessage.Partition)
}
}()
// send a hello message
stream.Send(&mq_pb.PublishMessageResponse{})
defer func() {
isClosed = true
}()
// process each published messages
for {
// receive a message
req, err := stream.Recv()
if err != nil {
if err == io.EOF {
break
}
glog.V(0).Infof("topic %v partition %v publish stream from %s error: %v", initMessage.Topic, initMessage.Partition, initMessage.PublisherName, err)
break
}
// Process the received message
dataMessage := req.GetData()
if dataMessage == nil {
continue
}
// Validate RecordValue structure only for schema-based messages
// Note: Only messages sent via ProduceRecordValue should be in RecordValue format
// Regular Kafka messages and offset management messages are stored as raw bytes
if dataMessage.Value != nil {
record := &schema_pb.RecordValue{}
if err := proto.Unmarshal(dataMessage.Value, record); err == nil {
// Successfully unmarshaled as RecordValue - validate structure
if err := b.validateRecordValue(record, initMessage.Topic); err != nil {
glog.V(1).Infof("RecordValue validation failed on topic %v partition %v: %v", initMessage.Topic, initMessage.Partition, err)
}
}
// Note: We don't log errors for non-RecordValue messages since most Kafka messages
// are raw bytes and should not be expected to be in RecordValue format
}
// The control message should still be sent to the follower
// to avoid timing issue when ack messages.
// Send to the local partition with offset assignment
t, p := topic.FromPbTopic(initMessage.Topic), topic.FromPbPartition(initMessage.Partition)
// Create offset assignment function for this partition
assignOffsetFn := func() (int64, error) {
return b.offsetManager.AssignOffset(t, p)
}
// Use offset-aware publishing
assignedOffset, err := localTopicPartition.PublishWithOffset(dataMessage, assignOffsetFn)
if err != nil {
return fmt.Errorf("topic %v partition %v publish error: %w", initMessage.Topic, initMessage.Partition, err)
}
// No ForceFlush - subscribers use per-subscriber notification channels for instant wake-up
// Data is served from in-memory LogBuffer with <1ms latency
glog.V(2).Infof("Published offset %d to %s", assignedOffset, initMessage.Topic.Name)
// Send immediate per-message ack WITH offset
// This is critical for Gateway to return correct offsets to Kafka clients
response := &mq_pb.PublishMessageResponse{
AckTsNs: dataMessage.TsNs,
AssignedOffset: assignedOffset,
}
if err := stream.Send(response); err != nil {
glog.Errorf("Error sending immediate ack %v: %v", response, err)
return fmt.Errorf("failed to send ack: %v", err)
}
// Update published offset and last seen time for this publisher
publisher.UpdatePublishedOffset(assignedOffset)
}
glog.V(0).Infof("topic %v partition %v publish stream from %s closed.", initMessage.Topic, initMessage.Partition, initMessage.PublisherName)
return nil
}
// validateRecordValue validates the structure and content of a RecordValue message
// Since RecordValue messages are created from successful protobuf unmarshaling,
// their structure is already guaranteed to be valid by the protobuf library.
// Schema validation (if applicable) already happened during Kafka gateway decoding.
func (b *MessageQueueBroker) validateRecordValue(record *schema_pb.RecordValue, topic *schema_pb.Topic) error {
// Check for nil RecordValue
if record == nil {
return fmt.Errorf("RecordValue is nil")
}
// Check for nil Fields map
if record.Fields == nil {
return fmt.Errorf("RecordValue.Fields is nil")
}
// Check for empty Fields map
if len(record.Fields) == 0 {
return fmt.Errorf("RecordValue has no fields")
}
// If protobuf unmarshaling succeeded, the RecordValue is structurally valid
return nil
}
// duplicated from master_grpc_server.go
func findClientAddress(ctx context.Context) string {
// fmt.Printf("FromContext %+v\n", ctx)
pr, ok := peer.FromContext(ctx)
if !ok {
glog.Error("failed to get peer from ctx")
return ""
}
if pr.Addr == net.Addr(nil) {
glog.Error("failed to get peer address")
return ""
}
return pr.Addr.String()
}
// GetPartitionRangeInfo returns comprehensive range information for a partition (offsets, timestamps, etc.)
func (b *MessageQueueBroker) GetPartitionRangeInfo(ctx context.Context, req *mq_pb.GetPartitionRangeInfoRequest) (*mq_pb.GetPartitionRangeInfoResponse, error) {
if req.Topic == nil || req.Partition == nil {
return &mq_pb.GetPartitionRangeInfoResponse{
Error: "topic and partition are required",
}, nil
}
t := topic.FromPbTopic(req.Topic)
p := topic.FromPbPartition(req.Partition)
// Get offset information from the broker's internal method
info, err := b.GetPartitionOffsetInfoInternal(t, p)
if err != nil {
return &mq_pb.GetPartitionRangeInfoResponse{
Error: fmt.Sprintf("failed to get partition range info: %v", err),
}, nil
}
// TODO: Get timestamp range information from chunk metadata or log buffer
// For now, we'll return zero values for timestamps - this can be enhanced later
// to read from Extended attributes (ts_min, ts_max) from filer metadata
timestampRange := &mq_pb.TimestampRangeInfo{
EarliestTimestampNs: 0, // TODO: Read from chunk metadata ts_min
LatestTimestampNs: 0, // TODO: Read from chunk metadata ts_max
}
return &mq_pb.GetPartitionRangeInfoResponse{
OffsetRange: &mq_pb.OffsetRangeInfo{
EarliestOffset: info.EarliestOffset,
LatestOffset: info.LatestOffset,
HighWaterMark: info.HighWaterMark,
},
TimestampRange: timestampRange,
RecordCount: info.RecordCount,
ActiveSubscriptions: info.ActiveSubscriptions,
}, nil
}

View File

@@ -0,0 +1,48 @@
package broker
import (
"fmt"
"github.com/seaweedfs/seaweedfs/weed/mq/pub_balancer"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// PublisherToPubBalancer receives connections from brokers and collects stats
func (b *MessageQueueBroker) PublisherToPubBalancer(stream mq_pb.SeaweedMessaging_PublisherToPubBalancerServer) error {
if !b.isLockOwner() {
return status.Errorf(codes.Unavailable, "not current broker balancer")
}
req, err := stream.Recv()
if err != nil {
return fmt.Errorf("receive init message: %w", err)
}
// process init message
initMessage := req.GetInit()
var brokerStats *pub_balancer.BrokerStats
if initMessage != nil {
brokerStats = b.PubBalancer.AddBroker(initMessage.Broker)
} else {
return status.Errorf(codes.InvalidArgument, "balancer init message is empty")
}
defer func() {
b.PubBalancer.RemoveBroker(initMessage.Broker, brokerStats)
}()
// process stats message
for {
req, err := stream.Recv()
if err != nil {
return fmt.Errorf("receive stats message from %s: %v", initMessage.Broker, err)
}
if !b.isLockOwner() {
return status.Errorf(codes.Unavailable, "not current broker balancer")
}
if receivedStats := req.GetStats(); receivedStats != nil {
b.PubBalancer.OnBrokerStatsUpdated(initMessage.Broker, brokerStats, receivedStats)
// glog.V(4).Infof("received from %v: %+v", initMessage.Broker, receivedStats)
}
}
}

View File

@@ -0,0 +1,152 @@
package broker
import (
"fmt"
"io"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"github.com/seaweedfs/seaweedfs/weed/util/buffered_queue"
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
)
type memBuffer struct {
buf []byte
startTime time.Time
stopTime time.Time
}
func (b *MessageQueueBroker) PublishFollowMe(stream mq_pb.SeaweedMessaging_PublishFollowMeServer) (err error) {
var req *mq_pb.PublishFollowMeRequest
req, err = stream.Recv()
if err != nil {
return err
}
initMessage := req.GetInit()
if initMessage == nil {
return fmt.Errorf("missing init message")
}
// create an in-memory queue of buffered messages
inMemoryBuffers := buffered_queue.NewBufferedQueue[memBuffer](4)
logBuffer := b.buildFollowerLogBuffer(inMemoryBuffers)
lastFlushTsNs := time.Now().UnixNano()
// follow each published messages
for {
// receive a message
req, err = stream.Recv()
if err != nil {
if err == io.EOF {
err = nil
break
}
glog.V(0).Infof("topic %v partition %v publish stream error: %v", initMessage.Topic, initMessage.Partition, err)
break
}
// Process the received message
if dataMessage := req.GetData(); dataMessage != nil {
// TODO: change this to DataMessage
// log the message
if addErr := logBuffer.AddToBuffer(dataMessage); addErr != nil {
err = fmt.Errorf("failed to add message to log buffer: %w", addErr)
glog.Errorf("Failed to add message to log buffer: %v", addErr)
break
}
// send back the ack
if err := stream.Send(&mq_pb.PublishFollowMeResponse{
AckTsNs: dataMessage.TsNs,
}); err != nil {
glog.Errorf("Error sending response %v: %v", dataMessage, err)
}
// println("ack", string(dataMessage.Key), dataMessage.TsNs)
} else if closeMessage := req.GetClose(); closeMessage != nil {
glog.V(0).Infof("topic %v partition %v publish stream closed: %v", initMessage.Topic, initMessage.Partition, closeMessage)
break
} else if flushMessage := req.GetFlush(); flushMessage != nil {
glog.V(0).Infof("topic %v partition %v publish stream flushed: %v", initMessage.Topic, initMessage.Partition, flushMessage)
lastFlushTsNs = flushMessage.TsNs
// drop already flushed messages
for mem, found := inMemoryBuffers.PeekHead(); found; mem, found = inMemoryBuffers.PeekHead() {
if mem.stopTime.UnixNano() <= flushMessage.TsNs {
inMemoryBuffers.Dequeue()
// println("dropping flushed messages: ", mem.startTime.UnixNano(), mem.stopTime.UnixNano(), len(mem.buf))
} else {
break
}
}
} else {
glog.Errorf("unknown message: %v", req)
}
}
t, p := topic.FromPbTopic(initMessage.Topic), topic.FromPbPartition(initMessage.Partition)
logBuffer.ShutdownLogBuffer()
// wait until all messages are sent to inMemoryBuffers
for !logBuffer.IsAllFlushed() {
time.Sleep(113 * time.Millisecond)
}
partitionDir := topic.PartitionDir(t, p)
// flush the remaining messages
inMemoryBuffers.CloseInput()
for mem, found := inMemoryBuffers.Dequeue(); found; mem, found = inMemoryBuffers.Dequeue() {
if len(mem.buf) == 0 {
continue
}
startTime, stopTime := mem.startTime.UTC(), mem.stopTime.UTC()
if stopTime.UnixNano() <= lastFlushTsNs {
glog.V(0).Infof("dropping remaining data at %v %v", t, p)
continue
}
// TODO trim data earlier than lastFlushTsNs
targetFile := fmt.Sprintf("%s/%s", partitionDir, startTime.Format(topic.TIME_FORMAT))
for {
if err := b.appendToFile(targetFile, mem.buf); err != nil {
glog.V(0).Infof("metadata log write failed %s: %v", targetFile, err)
time.Sleep(737 * time.Millisecond)
} else {
break
}
}
glog.V(0).Infof("flushed remaining data at %v to %s size %d", mem.stopTime.UnixNano(), targetFile, len(mem.buf))
}
glog.V(0).Infof("shut down follower for %v %v", t, p)
return err
}
func (b *MessageQueueBroker) buildFollowerLogBuffer(inMemoryBuffers *buffered_queue.BufferedQueue[memBuffer]) *log_buffer.LogBuffer {
lb := log_buffer.NewLogBuffer("follower",
5*time.Second, func(logBuffer *log_buffer.LogBuffer, startTime, stopTime time.Time, buf []byte, minOffset, maxOffset int64) {
if len(buf) == 0 {
return
}
inMemoryBuffers.Enqueue(memBuffer{
buf: buf,
startTime: startTime,
stopTime: stopTime,
})
glog.V(0).Infof("queue up %d~%d size %d", startTime.UnixNano(), stopTime.UnixNano(), len(buf))
}, nil, func() {
})
return lb
}

View File

@@ -0,0 +1,351 @@
package broker
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
)
// BufferRange represents a range of buffer offsets that have been flushed to disk
type BufferRange struct {
start int64
end int64
}
// ErrNoPartitionAssignment indicates no broker assignment found for the partition.
// This is a normal case that means there are no unflushed messages for this partition.
var ErrNoPartitionAssignment = errors.New("no broker assignment found for partition")
// GetUnflushedMessages returns messages from the broker's in-memory LogBuffer
// that haven't been flushed to disk yet, using buffer_start metadata for deduplication
// Now supports streaming responses and buffer offset filtering for better performance
// Includes broker routing to redirect requests to the correct broker hosting the topic/partition
func (b *MessageQueueBroker) GetUnflushedMessages(req *mq_pb.GetUnflushedMessagesRequest, stream mq_pb.SeaweedMessaging_GetUnflushedMessagesServer) error {
// Convert protobuf types to internal types
t := topic.FromPbTopic(req.Topic)
partition := topic.FromPbPartition(req.Partition)
// Get or generate the local partition for this topic/partition (similar to subscriber flow)
localPartition, getOrGenErr := b.GetOrGenerateLocalPartition(t, partition)
if getOrGenErr != nil {
// Fall back to the original logic for broker routing
b.accessLock.Lock()
localPartition = b.localTopicManager.GetLocalPartition(t, partition)
b.accessLock.Unlock()
} else {
}
if localPartition == nil {
// Topic/partition not found locally, attempt to find the correct broker and redirect
glog.V(1).Infof("Topic/partition %v %v not found locally, looking up broker", t, partition)
// Look up which broker hosts this topic/partition
brokerHost, err := b.findBrokerForTopicPartition(req.Topic, req.Partition)
if err != nil {
if errors.Is(err, ErrNoPartitionAssignment) {
// Normal case: no broker assignment means no unflushed messages
glog.V(2).Infof("No broker assignment for %v %v - no unflushed messages", t, partition)
return stream.Send(&mq_pb.GetUnflushedMessagesResponse{
EndOfStream: true,
})
}
return stream.Send(&mq_pb.GetUnflushedMessagesResponse{
Error: fmt.Sprintf("failed to find broker for %v %v: %v", t, partition, err),
EndOfStream: true,
})
}
if brokerHost == "" {
// This should not happen after ErrNoPartitionAssignment check, but keep for safety
glog.V(2).Infof("Empty broker host for %v %v - no unflushed messages", t, partition)
return stream.Send(&mq_pb.GetUnflushedMessagesResponse{
EndOfStream: true,
})
}
// Redirect to the correct broker
glog.V(1).Infof("Redirecting GetUnflushedMessages request for %v %v to broker %s", t, partition, brokerHost)
return b.redirectGetUnflushedMessages(brokerHost, req, stream)
}
// Build deduplication map from existing log files using buffer_start metadata
partitionDir := topic.PartitionDir(t, partition)
flushedBufferRanges, err := b.buildBufferStartDeduplicationMap(partitionDir)
if err != nil {
glog.Errorf("Failed to build deduplication map for %v %v: %v", t, partition, err)
// Continue with empty map - better to potentially duplicate than to miss data
flushedBufferRanges = make([]BufferRange, 0)
}
// Use buffer_start offset for precise deduplication
lastFlushTsNs := localPartition.LogBuffer.GetLastFlushTsNs()
startBufferOffset := req.StartBufferOffset
startTimeNs := lastFlushTsNs // Still respect last flush time for safety
// Stream messages from LogBuffer with filtering
messageCount := 0
startPosition := log_buffer.NewMessagePosition(startTimeNs, startBufferOffset)
// Use the new LoopProcessLogDataWithOffset method to avoid code duplication
_, _, err = localPartition.LogBuffer.LoopProcessLogDataWithOffset(
"GetUnflushedMessages",
startPosition,
0, // stopTsNs = 0 means process all available data
func() bool { return false }, // waitForDataFn = false means don't wait for new data
func(logEntry *filer_pb.LogEntry, offset int64) (isDone bool, err error) {
// Apply buffer offset filtering if specified
if startBufferOffset > 0 && offset < startBufferOffset {
return false, nil
}
// Check if this message is from a buffer range that's already been flushed
if b.isBufferOffsetFlushed(offset, flushedBufferRanges) {
return false, nil
}
// Stream this message
err = stream.Send(&mq_pb.GetUnflushedMessagesResponse{
Message: logEntry,
EndOfStream: false,
})
if err != nil {
glog.Errorf("Failed to stream message: %v", err)
return true, err // isDone = true to stop processing
}
messageCount++
return false, nil // Continue processing
},
)
// Handle collection errors
if err != nil && err != log_buffer.ResumeFromDiskError {
streamErr := stream.Send(&mq_pb.GetUnflushedMessagesResponse{
Error: fmt.Sprintf("failed to stream unflushed messages: %v", err),
EndOfStream: true,
})
if streamErr != nil {
glog.Errorf("Failed to send error response: %v", streamErr)
}
return err
}
// Send end-of-stream marker
err = stream.Send(&mq_pb.GetUnflushedMessagesResponse{
EndOfStream: true,
})
if err != nil {
glog.Errorf("Failed to send end-of-stream marker: %v", err)
return err
}
return nil
}
// buildBufferStartDeduplicationMap scans log files to build a map of buffer ranges
// that have been flushed to disk, using the buffer_start metadata
func (b *MessageQueueBroker) buildBufferStartDeduplicationMap(partitionDir string) ([]BufferRange, error) {
var flushedRanges []BufferRange
// List all files in the partition directory using filer client accessor
// Use pagination to handle directories with more than 1000 files
err := b.fca.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
var lastFileName string
var hasMore = true
for hasMore {
var currentBatchProcessed int
err := filer_pb.SeaweedList(context.Background(), client, partitionDir, "", func(entry *filer_pb.Entry, isLast bool) error {
currentBatchProcessed++
hasMore = !isLast // If this is the last entry of a full batch, there might be more
lastFileName = entry.Name
if entry.IsDirectory {
return nil
}
// Skip Parquet files - they don't represent buffer ranges
if strings.HasSuffix(entry.Name, ".parquet") {
return nil
}
// Skip offset files
if strings.HasSuffix(entry.Name, ".offset") {
return nil
}
// Get buffer start for this file
bufferStart, err := b.getLogBufferStartFromFile(entry)
if err != nil {
glog.V(2).Infof("Failed to get buffer start from file %s: %v", entry.Name, err)
return nil // Continue with other files
}
if bufferStart == nil {
// File has no buffer metadata - skip deduplication for this file
glog.V(2).Infof("File %s has no buffer_start metadata", entry.Name)
return nil
}
// Calculate the buffer range covered by this file
chunkCount := int64(len(entry.GetChunks()))
if chunkCount > 0 {
fileRange := BufferRange{
start: bufferStart.StartIndex,
end: bufferStart.StartIndex + chunkCount - 1,
}
flushedRanges = append(flushedRanges, fileRange)
glog.V(3).Infof("File %s covers buffer range [%d-%d]", entry.Name, fileRange.start, fileRange.end)
}
return nil
}, lastFileName, false, 1000) // Start from last processed file name for next batch
if err != nil {
return err
}
// If we processed fewer than 1000 entries, we've reached the end
if currentBatchProcessed < 1000 {
hasMore = false
}
}
return nil
})
if err != nil {
return flushedRanges, fmt.Errorf("failed to list partition directory %s: %v", partitionDir, err)
}
return flushedRanges, nil
}
// getLogBufferStartFromFile extracts LogBufferStart metadata from a log file
func (b *MessageQueueBroker) getLogBufferStartFromFile(entry *filer_pb.Entry) (*LogBufferStart, error) {
if entry.Extended == nil {
return nil, nil
}
// Only support binary buffer_start format
if startData, exists := entry.Extended["buffer_start"]; exists {
if len(startData) == 8 {
startIndex := int64(binary.BigEndian.Uint64(startData))
if startIndex > 0 {
return &LogBufferStart{StartIndex: startIndex}, nil
}
} else {
return nil, fmt.Errorf("invalid buffer_start format: expected 8 bytes, got %d", len(startData))
}
}
return nil, nil
}
// isBufferOffsetFlushed checks if a buffer offset is covered by any of the flushed ranges
func (b *MessageQueueBroker) isBufferOffsetFlushed(bufferOffset int64, flushedRanges []BufferRange) bool {
for _, flushedRange := range flushedRanges {
if bufferOffset >= flushedRange.start && bufferOffset <= flushedRange.end {
return true
}
}
return false
}
// findBrokerForTopicPartition finds which broker hosts the specified topic/partition
func (b *MessageQueueBroker) findBrokerForTopicPartition(topic *schema_pb.Topic, partition *schema_pb.Partition) (string, error) {
// Use LookupTopicBrokers to find which broker hosts this topic/partition
ctx := context.Background()
lookupReq := &mq_pb.LookupTopicBrokersRequest{
Topic: topic,
}
// If we're not the lock owner (balancer), we need to redirect to the balancer first
var lookupResp *mq_pb.LookupTopicBrokersResponse
var err error
if !b.isLockOwner() {
// Redirect to balancer to get topic broker assignments
balancerAddress := pb.ServerAddress(b.lockAsBalancer.LockOwner())
err = b.withBrokerClient(false, balancerAddress, func(client mq_pb.SeaweedMessagingClient) error {
lookupResp, err = client.LookupTopicBrokers(ctx, lookupReq)
return err
})
} else {
// We are the balancer, handle the lookup directly
lookupResp, err = b.LookupTopicBrokers(ctx, lookupReq)
}
if err != nil {
return "", fmt.Errorf("failed to lookup topic brokers: %v", err)
}
// Find the broker assignment that matches our partition
for _, assignment := range lookupResp.BrokerPartitionAssignments {
if b.partitionsMatch(partition, assignment.Partition) {
if assignment.LeaderBroker != "" {
return assignment.LeaderBroker, nil
}
}
}
return "", ErrNoPartitionAssignment
}
// partitionsMatch checks if two partitions represent the same partition
func (b *MessageQueueBroker) partitionsMatch(p1, p2 *schema_pb.Partition) bool {
return p1.RingSize == p2.RingSize &&
p1.RangeStart == p2.RangeStart &&
p1.RangeStop == p2.RangeStop &&
p1.UnixTimeNs == p2.UnixTimeNs
}
// redirectGetUnflushedMessages forwards the GetUnflushedMessages request to the correct broker
func (b *MessageQueueBroker) redirectGetUnflushedMessages(brokerHost string, req *mq_pb.GetUnflushedMessagesRequest, stream mq_pb.SeaweedMessaging_GetUnflushedMessagesServer) error {
ctx := stream.Context()
// Connect to the target broker and forward the request
return b.withBrokerClient(false, pb.ServerAddress(brokerHost), func(client mq_pb.SeaweedMessagingClient) error {
// Create a new stream to the target broker
targetStream, err := client.GetUnflushedMessages(ctx, req)
if err != nil {
return fmt.Errorf("failed to create stream to broker %s: %v", brokerHost, err)
}
// Forward all responses from the target broker to our client
for {
response, err := targetStream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
// Normal end of stream
return nil
}
return fmt.Errorf("error receiving from broker %s: %v", brokerHost, err)
}
// Forward the response to our client
if sendErr := stream.Send(response); sendErr != nil {
return fmt.Errorf("error forwarding response to client: %v", sendErr)
}
// Check if this is the end of stream
if response.EndOfStream {
return nil
}
}
})
}

View File

@@ -0,0 +1,422 @@
package broker
import (
"context"
"fmt"
"io"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq/sub_coordinator"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
)
func (b *MessageQueueBroker) SubscribeMessage(stream mq_pb.SeaweedMessaging_SubscribeMessageServer) error {
req, err := stream.Recv()
if err != nil {
return err
}
if req.GetInit() == nil {
glog.Errorf("missing init message")
return fmt.Errorf("missing init message")
}
// Create a cancellable context so we can properly clean up when the client disconnects
ctx, cancel := context.WithCancel(stream.Context())
defer cancel() // Ensure context is cancelled when function exits
clientName := fmt.Sprintf("%s/%s-%s", req.GetInit().ConsumerGroup, req.GetInit().ConsumerId, req.GetInit().ClientId)
t := topic.FromPbTopic(req.GetInit().Topic)
partition := topic.FromPbPartition(req.GetInit().GetPartitionOffset().GetPartition())
glog.V(0).Infof("Subscriber %s on %v %v connected", req.GetInit().ConsumerId, t, partition)
glog.V(4).Infof("Calling GetOrGenerateLocalPartition for %s %s", t, partition)
localTopicPartition, getOrGenErr := b.GetOrGenerateLocalPartition(t, partition)
if getOrGenErr != nil {
glog.V(4).Infof("GetOrGenerateLocalPartition failed: %v", getOrGenErr)
return getOrGenErr
}
glog.V(4).Infof("GetOrGenerateLocalPartition succeeded, localTopicPartition=%v", localTopicPartition != nil)
if localTopicPartition == nil {
return fmt.Errorf("failed to get or generate local partition for topic %v partition %v", t, partition)
}
subscriber := topic.NewLocalSubscriber()
localTopicPartition.Subscribers.AddSubscriber(clientName, subscriber)
glog.V(0).Infof("Subscriber %s connected on %v %v", clientName, t, partition)
isConnected := true
var counter int64
startPosition := b.getRequestPosition(req.GetInit())
imt := sub_coordinator.NewInflightMessageTracker(int(req.GetInit().SlidingWindowSize))
defer func() {
isConnected = false
// Clean up any in-flight messages to prevent them from blocking other subscribers
if cleanedCount := imt.Cleanup(); cleanedCount > 0 {
glog.V(0).Infof("Subscriber %s cleaned up %d in-flight messages on disconnect", clientName, cleanedCount)
}
localTopicPartition.Subscribers.RemoveSubscriber(clientName)
glog.V(0).Infof("Subscriber %s on %v %v disconnected, sent %d", clientName, t, partition, counter)
// Use topic-aware shutdown logic to prevent aggressive removal of system topics
if localTopicPartition.MaybeShutdownLocalPartitionForTopic(t.Name) {
b.localTopicManager.RemoveLocalPartition(t, partition)
}
}()
// connect to the follower
var subscribeFollowMeStream mq_pb.SeaweedMessaging_SubscribeFollowMeClient
glog.V(0).Infof("follower broker: %v", req.GetInit().FollowerBroker)
if req.GetInit().FollowerBroker != "" {
follower := req.GetInit().FollowerBroker
if followerGrpcConnection, err := pb.GrpcDial(ctx, follower, true, b.grpcDialOption); err != nil {
return fmt.Errorf("fail to dial %s: %v", follower, err)
} else {
defer func() {
println("closing SubscribeFollowMe connection", follower)
if subscribeFollowMeStream != nil {
subscribeFollowMeStream.CloseSend()
}
// followerGrpcConnection.Close()
}()
followerClient := mq_pb.NewSeaweedMessagingClient(followerGrpcConnection)
if subscribeFollowMeStream, err = followerClient.SubscribeFollowMe(ctx); err != nil {
return fmt.Errorf("fail to subscribe to %s: %v", follower, err)
} else {
if err := subscribeFollowMeStream.Send(&mq_pb.SubscribeFollowMeRequest{
Message: &mq_pb.SubscribeFollowMeRequest_Init{
Init: &mq_pb.SubscribeFollowMeRequest_InitMessage{
Topic: req.GetInit().Topic,
Partition: req.GetInit().GetPartitionOffset().Partition,
ConsumerGroup: req.GetInit().ConsumerGroup,
},
},
}); err != nil {
return fmt.Errorf("fail to send init to %s: %v", follower, err)
}
}
}
glog.V(0).Infof("follower %s connected", follower)
}
// Channel to handle seek requests - signals Subscribe loop to restart from new offset
seekChan := make(chan *mq_pb.SubscribeMessageRequest_SeekMessage, 1)
go func() {
defer cancel() // CRITICAL: Cancel context when Recv goroutine exits (client disconnect)
var lastOffset int64
for {
ack, err := stream.Recv()
if err != nil {
if err == io.EOF {
// the client has called CloseSend(). This is to ack the close.
stream.Send(&mq_pb.SubscribeMessageResponse{Message: &mq_pb.SubscribeMessageResponse_Ctrl{
Ctrl: &mq_pb.SubscribeMessageResponse_SubscribeCtrlMessage{
IsEndOfStream: true,
},
}})
break
}
glog.V(0).Infof("topic %v partition %v subscriber %s lastOffset %d error: %v", t, partition, clientName, lastOffset, err)
break
}
// Handle seek messages
if seekMsg := ack.GetSeek(); seekMsg != nil {
glog.V(0).Infof("Subscriber %s received seek request to offset %d (type %v)",
clientName, seekMsg.Offset, seekMsg.OffsetType)
// Send seek request to Subscribe loop
select {
case seekChan <- seekMsg:
glog.V(0).Infof("Subscriber %s seek request queued", clientName)
default:
glog.V(0).Infof("Subscriber %s seek request dropped (already pending)", clientName)
// Send error response if seek is already in progress
stream.Send(&mq_pb.SubscribeMessageResponse{Message: &mq_pb.SubscribeMessageResponse_Ctrl{
Ctrl: &mq_pb.SubscribeMessageResponse_SubscribeCtrlMessage{
Error: "Seek already in progress",
},
}})
}
continue
}
if ack.GetAck().Key == nil {
// skip ack for control messages
continue
}
imt.AcknowledgeMessage(ack.GetAck().Key, ack.GetAck().TsNs)
currentLastOffset := imt.GetOldestAckedTimestamp()
// Update acknowledged offset and last seen time for this subscriber when it sends an ack
subscriber.UpdateAckedOffset(currentLastOffset)
// fmt.Printf("%+v recv (%s,%d), oldest %d\n", partition, string(ack.GetAck().Key), ack.GetAck().TsNs, currentLastOffset)
if subscribeFollowMeStream != nil && currentLastOffset > lastOffset {
if err := subscribeFollowMeStream.Send(&mq_pb.SubscribeFollowMeRequest{
Message: &mq_pb.SubscribeFollowMeRequest_Ack{
Ack: &mq_pb.SubscribeFollowMeRequest_AckMessage{
TsNs: currentLastOffset,
},
},
}); err != nil {
glog.Errorf("Error sending ack to follower: %v", err)
break
}
lastOffset = currentLastOffset
// fmt.Printf("%+v forwarding ack %d\n", partition, lastOffset)
}
}
if lastOffset > 0 {
glog.V(0).Infof("saveConsumerGroupOffset %v %v %v %v", t, partition, req.GetInit().ConsumerGroup, lastOffset)
if err := b.saveConsumerGroupOffset(t, partition, req.GetInit().ConsumerGroup, lastOffset); err != nil {
glog.Errorf("saveConsumerGroupOffset partition %v lastOffset %d: %v", partition, lastOffset, err)
}
}
if subscribeFollowMeStream != nil {
if err := subscribeFollowMeStream.Send(&mq_pb.SubscribeFollowMeRequest{
Message: &mq_pb.SubscribeFollowMeRequest_Close{
Close: &mq_pb.SubscribeFollowMeRequest_CloseMessage{},
},
}); err != nil {
if err != io.EOF {
glog.Errorf("Error sending close to follower: %v", err)
}
}
}
}()
// Create a goroutine to handle context cancellation and wake up the condition variable
// This is created ONCE per subscriber, not per callback invocation
go func() {
<-ctx.Done()
// Wake up the condition variable when context is cancelled
localTopicPartition.ListenersLock.Lock()
localTopicPartition.ListenersCond.Broadcast()
localTopicPartition.ListenersLock.Unlock()
}()
// Subscribe loop - can be restarted when seek is requested
currentPosition := startPosition
subscribeLoop:
for {
// Context for this iteration of Subscribe (can be cancelled by seek)
subscribeCtx, subscribeCancel := context.WithCancel(ctx)
// Start Subscribe in a goroutine so we can interrupt it with seek
subscribeDone := make(chan error, 1)
go func() {
subscribeErr := localTopicPartition.Subscribe(clientName, currentPosition, func() bool {
// Check cancellation before waiting
if subscribeCtx.Err() != nil || !isConnected {
return false
}
// Wait for new data using condition variable (blocking, not polling)
localTopicPartition.ListenersLock.Lock()
localTopicPartition.ListenersCond.Wait()
localTopicPartition.ListenersLock.Unlock()
// After waking up, check if we should stop
return subscribeCtx.Err() == nil && isConnected
}, func(logEntry *filer_pb.LogEntry) (bool, error) {
// Wait for the message to be acknowledged with a timeout to prevent infinite loops
const maxWaitTime = 30 * time.Second
const checkInterval = 137 * time.Millisecond
startTime := time.Now()
for imt.IsInflight(logEntry.Key) {
// Check if we've exceeded the maximum wait time
if time.Since(startTime) > maxWaitTime {
glog.Warningf("Subscriber %s: message with key %s has been in-flight for more than %v, forcing acknowledgment",
clientName, string(logEntry.Key), maxWaitTime)
// Force remove the message from in-flight tracking to prevent infinite loop
imt.AcknowledgeMessage(logEntry.Key, logEntry.TsNs)
break
}
time.Sleep(checkInterval)
// Check if the client has disconnected by monitoring the context
select {
case <-subscribeCtx.Done():
err := subscribeCtx.Err()
if err == context.Canceled {
// Subscribe cancelled (seek or disconnect)
return false, nil
}
glog.V(0).Infof("Subscriber %s disconnected: %v", clientName, err)
return false, nil
default:
// Continue processing the request
}
}
if logEntry.Key != nil {
imt.EnflightMessage(logEntry.Key, logEntry.TsNs)
}
// Create the message to send
dataMsg := &mq_pb.DataMessage{
Key: logEntry.Key,
Value: logEntry.Data,
TsNs: logEntry.TsNs,
}
if err := stream.Send(&mq_pb.SubscribeMessageResponse{Message: &mq_pb.SubscribeMessageResponse_Data{
Data: dataMsg,
}}); err != nil {
glog.Errorf("Error sending data: %v", err)
return false, err
}
// Update received offset and last seen time for this subscriber
subscriber.UpdateReceivedOffset(logEntry.TsNs)
counter++
return false, nil
})
subscribeDone <- subscribeErr
}()
// Wait for either Subscribe to complete or a seek request
select {
case err = <-subscribeDone:
subscribeCancel()
if err != nil || ctx.Err() != nil {
// Subscribe finished with error or main context cancelled - exit loop
break subscribeLoop
}
// Subscribe completed normally (shouldn't happen in streaming mode)
break subscribeLoop
case seekMsg := <-seekChan:
// Seek requested - cancel current Subscribe and restart from new offset
glog.V(0).Infof("Subscriber %s seeking from offset %d to offset %d (type %v)",
clientName, currentPosition.GetOffset(), seekMsg.Offset, seekMsg.OffsetType)
// Cancel current Subscribe iteration
subscribeCancel()
// Wait for Subscribe to finish cancelling
<-subscribeDone
// Update position for next iteration
currentPosition = b.getRequestPositionFromSeek(seekMsg)
glog.V(0).Infof("Subscriber %s restarting Subscribe from new offset %d", clientName, seekMsg.Offset)
// Send acknowledgment that seek completed
stream.Send(&mq_pb.SubscribeMessageResponse{Message: &mq_pb.SubscribeMessageResponse_Ctrl{
Ctrl: &mq_pb.SubscribeMessageResponse_SubscribeCtrlMessage{
Error: "", // Empty error means success
},
}})
// Loop will restart with new position
}
}
return err
}
func (b *MessageQueueBroker) getRequestPosition(initMessage *mq_pb.SubscribeMessageRequest_InitMessage) (startPosition log_buffer.MessagePosition) {
if initMessage == nil {
return
}
offset := initMessage.GetPartitionOffset()
offsetType := initMessage.OffsetType
// reset to earliest or latest
if offsetType == schema_pb.OffsetType_RESET_TO_EARLIEST {
startPosition = log_buffer.NewMessagePosition(1, -3)
return
}
if offsetType == schema_pb.OffsetType_RESET_TO_LATEST {
startPosition = log_buffer.NewMessagePosition(time.Now().UnixNano(), -4)
return
}
// use the exact timestamp
if offsetType == schema_pb.OffsetType_EXACT_TS_NS {
startPosition = log_buffer.NewMessagePosition(offset.StartTsNs, -2)
return
}
// use exact offset (native offset-based positioning)
if offsetType == schema_pb.OffsetType_EXACT_OFFSET {
startPosition = log_buffer.NewMessagePositionFromOffset(offset.StartOffset)
return
}
// reset to specific offset
if offsetType == schema_pb.OffsetType_RESET_TO_OFFSET {
startPosition = log_buffer.NewMessagePositionFromOffset(offset.StartOffset)
return
}
// try to resume
if storedOffset, err := b.readConsumerGroupOffset(initMessage); err == nil {
glog.V(0).Infof("resume from saved offset %v %v %v: %v", initMessage.Topic, initMessage.PartitionOffset.Partition, initMessage.ConsumerGroup, storedOffset)
startPosition = log_buffer.NewMessagePosition(storedOffset, -2)
return
}
if offsetType == schema_pb.OffsetType_RESUME_OR_EARLIEST {
startPosition = log_buffer.NewMessagePosition(1, -5)
} else if offsetType == schema_pb.OffsetType_RESUME_OR_LATEST {
startPosition = log_buffer.NewMessagePosition(time.Now().UnixNano(), -6)
}
return
}
// getRequestPositionFromSeek converts a seek request to a MessagePosition
// This is used when implementing full seek support in Subscribe loop
func (b *MessageQueueBroker) getRequestPositionFromSeek(seekMsg *mq_pb.SubscribeMessageRequest_SeekMessage) (startPosition log_buffer.MessagePosition) {
if seekMsg == nil {
return
}
offsetType := seekMsg.OffsetType
offset := seekMsg.Offset
// reset to earliest or latest
if offsetType == schema_pb.OffsetType_RESET_TO_EARLIEST {
startPosition = log_buffer.NewMessagePosition(1, -3)
return
}
if offsetType == schema_pb.OffsetType_RESET_TO_LATEST {
startPosition = log_buffer.NewMessagePosition(time.Now().UnixNano(), -4)
return
}
// use the exact timestamp
if offsetType == schema_pb.OffsetType_EXACT_TS_NS {
startPosition = log_buffer.NewMessagePosition(offset, -2)
return
}
// use exact offset (native offset-based positioning)
if offsetType == schema_pb.OffsetType_EXACT_OFFSET {
startPosition = log_buffer.NewMessagePositionFromOffset(offset)
return
}
// reset to specific offset
if offsetType == schema_pb.OffsetType_RESET_TO_OFFSET {
startPosition = log_buffer.NewMessagePositionFromOffset(offset)
return
}
// default to exact offset
startPosition = log_buffer.NewMessagePositionFromOffset(offset)
return
}

View File

@@ -0,0 +1,92 @@
package broker
import (
"context"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq/sub_coordinator"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// SubscriberToSubCoordinator coordinates the subscribers
func (b *MessageQueueBroker) SubscriberToSubCoordinator(stream mq_pb.SeaweedMessaging_SubscriberToSubCoordinatorServer) error {
if !b.isLockOwner() {
return status.Errorf(codes.Unavailable, "not current broker balancer")
}
req, err := stream.Recv()
if err != nil {
return err
}
var cgi *sub_coordinator.ConsumerGroupInstance
var cg *sub_coordinator.ConsumerGroup
// process init message
initMessage := req.GetInit()
if initMessage != nil {
cg, cgi, err = b.SubCoordinator.AddSubscriber(initMessage)
if err != nil {
return status.Errorf(codes.InvalidArgument, "failed to add subscriber: %v", err)
}
glog.V(0).Infof("subscriber %s/%s/%s connected", initMessage.ConsumerGroup, initMessage.ConsumerGroupInstanceId, initMessage.Topic)
} else {
return status.Errorf(codes.InvalidArgument, "subscriber init message is empty")
}
defer func() {
b.SubCoordinator.RemoveSubscriber(initMessage)
glog.V(0).Infof("subscriber %s/%s/%s disconnected: %v", initMessage.ConsumerGroup, initMessage.ConsumerGroupInstanceId, initMessage.Topic, err)
}()
ctx := stream.Context()
go func() {
// process ack messages
for {
req, err := stream.Recv()
if err != nil {
glog.V(0).Infof("subscriber %s/%s/%s receive: %v", initMessage.ConsumerGroup, initMessage.ConsumerGroupInstanceId, initMessage.Topic, err)
}
if ackUnAssignment := req.GetAckUnAssignment(); ackUnAssignment != nil {
glog.V(0).Infof("subscriber %s/%s/%s ack close of %v", initMessage.ConsumerGroup, initMessage.ConsumerGroupInstanceId, initMessage.Topic, ackUnAssignment)
cg.AckUnAssignment(cgi, ackUnAssignment)
}
if ackAssignment := req.GetAckAssignment(); ackAssignment != nil {
glog.V(0).Infof("subscriber %s/%s/%s ack assignment %v", initMessage.ConsumerGroup, initMessage.ConsumerGroupInstanceId, initMessage.Topic, ackAssignment)
cg.AckAssignment(cgi, ackAssignment)
}
select {
case <-ctx.Done():
err := ctx.Err()
if err == context.Canceled {
// Client disconnected
return
}
return
default:
// Continue processing the request
}
}
}()
// send commands to subscriber
for {
select {
case <-ctx.Done():
err := ctx.Err()
if err == context.Canceled {
// Client disconnected
return err
}
glog.V(0).Infof("subscriber %s/%s/%s disconnected: %v", initMessage.ConsumerGroup, initMessage.ConsumerGroupInstanceId, initMessage.Topic, err)
return err
case message := <-cgi.ResponseChan:
glog.V(0).Infof("subscriber %s/%s/%s send: %v", initMessage.ConsumerGroup, initMessage.ConsumerGroupInstanceId, initMessage.Topic, message)
if err := stream.Send(message); err != nil {
glog.V(0).Infof("subscriber %s/%s/%s send: %v", initMessage.ConsumerGroup, initMessage.ConsumerGroupInstanceId, initMessage.Topic, err)
}
}
}
}

View File

@@ -0,0 +1,73 @@
package broker
import (
"fmt"
"io"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
)
func (b *MessageQueueBroker) SubscribeFollowMe(stream mq_pb.SeaweedMessaging_SubscribeFollowMeServer) (err error) {
var req *mq_pb.SubscribeFollowMeRequest
req, err = stream.Recv()
if err != nil {
return err
}
initMessage := req.GetInit()
if initMessage == nil {
return fmt.Errorf("missing init message")
}
// create an in-memory offset
var lastOffset int64
// follow each published messages
for {
// receive a message
req, err = stream.Recv()
if err != nil {
if err == io.EOF {
err = nil
break
}
glog.V(0).Infof("topic %v partition %v subscribe stream error: %v", initMessage.Topic, initMessage.Partition, err)
break
}
// Process the received message
if ackMessage := req.GetAck(); ackMessage != nil {
lastOffset = ackMessage.TsNs
// println("sub follower got offset", lastOffset)
} else if closeMessage := req.GetClose(); closeMessage != nil {
glog.V(0).Infof("topic %v partition %v subscribe stream closed: %v", initMessage.Topic, initMessage.Partition, closeMessage)
return nil
} else {
glog.Errorf("unknown message: %v", req)
}
}
t, p := topic.FromPbTopic(initMessage.Topic), topic.FromPbPartition(initMessage.Partition)
if lastOffset > 0 {
err = b.saveConsumerGroupOffset(t, p, initMessage.ConsumerGroup, lastOffset)
}
glog.V(0).Infof("shut down follower for %v offset %d", initMessage, lastOffset)
return err
}
func (b *MessageQueueBroker) readConsumerGroupOffset(initMessage *mq_pb.SubscribeMessageRequest_InitMessage) (offset int64, err error) {
t, p := topic.FromPbTopic(initMessage.Topic), topic.FromPbPartition(initMessage.PartitionOffset.Partition)
// Use the offset manager's consumer group storage
return b.offsetManager.LoadConsumerGroupOffset(t, p, initMessage.ConsumerGroup)
}
func (b *MessageQueueBroker) saveConsumerGroupOffset(t topic.Topic, p topic.Partition, consumerGroup string, offset int64) error {
// Use the offset manager's consumer group storage
glog.V(0).Infof("saving topic %s partition %v consumer group %s offset %d", t, p, consumerGroup, offset)
return b.offsetManager.SaveConsumerGroupOffset(t, p, consumerGroup, offset)
}

View File

@@ -0,0 +1,253 @@
package broker
import (
"context"
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq/offset"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
)
// SubscribeWithOffset handles subscription requests with offset-based positioning
// TODO: This extends the broker with offset-aware subscription support
// ASSUMPTION: This will eventually be integrated into the main SubscribeMessage method
func (b *MessageQueueBroker) SubscribeWithOffset(
ctx context.Context,
req *mq_pb.SubscribeMessageRequest,
stream mq_pb.SeaweedMessaging_SubscribeMessageServer,
offsetType schema_pb.OffsetType,
startOffset int64,
) error {
initMessage := req.GetInit()
if initMessage == nil {
return fmt.Errorf("missing init message")
}
// Extract partition information from the request
t := topic.FromPbTopic(initMessage.Topic)
// Get partition from the request's partition_offset field
if initMessage.PartitionOffset == nil || initMessage.PartitionOffset.Partition == nil {
return fmt.Errorf("missing partition information in request")
}
// Use the partition information from the request
p := topic.Partition{
RingSize: initMessage.PartitionOffset.Partition.RingSize,
RangeStart: initMessage.PartitionOffset.Partition.RangeStart,
RangeStop: initMessage.PartitionOffset.Partition.RangeStop,
UnixTimeNs: initMessage.PartitionOffset.Partition.UnixTimeNs,
}
// Create offset-based subscription
subscriptionID := fmt.Sprintf("%s-%s-%d", initMessage.ConsumerGroup, initMessage.ConsumerId, startOffset)
subscription, err := b.offsetManager.CreateSubscription(subscriptionID, t, p, offsetType, startOffset)
if err != nil {
return fmt.Errorf("failed to create offset subscription: %w", err)
}
defer func() {
if closeErr := b.offsetManager.CloseSubscription(subscriptionID); closeErr != nil {
glog.V(0).Infof("Failed to close subscription %s: %v", subscriptionID, closeErr)
}
}()
// Get local partition for reading
localTopicPartition, err := b.GetOrGenerateLocalPartition(t, p)
if err != nil {
return fmt.Errorf("topic %v partition %v not found: %v", t, p, err)
}
// Subscribe to messages using offset-based positioning
return b.subscribeWithOffsetSubscription(ctx, localTopicPartition, subscription, stream, initMessage)
}
// subscribeWithOffsetSubscription handles the actual message consumption with offset tracking
func (b *MessageQueueBroker) subscribeWithOffsetSubscription(
ctx context.Context,
localPartition *topic.LocalPartition,
subscription *offset.OffsetSubscription,
stream mq_pb.SeaweedMessaging_SubscribeMessageServer,
initMessage *mq_pb.SubscribeMessageRequest_InitMessage,
) error {
clientName := fmt.Sprintf("%s-%s", initMessage.ConsumerGroup, initMessage.ConsumerId)
// TODO: Implement offset-based message reading
// ASSUMPTION: For now, we'll use the existing subscription mechanism and track offsets separately
// This should be replaced with proper offset-based reading from storage
// Convert the subscription's current offset to a proper MessagePosition
startPosition, err := b.convertOffsetToMessagePosition(subscription)
if err != nil {
return fmt.Errorf("failed to convert offset to message position: %w", err)
}
glog.V(0).Infof("[%s] Starting Subscribe for topic %s partition %d-%d at offset %d",
clientName, subscription.TopicName, subscription.Partition.RangeStart, subscription.Partition.RangeStop, subscription.CurrentOffset)
return localPartition.Subscribe(clientName,
startPosition,
func() bool {
// Check if context is cancelled (client disconnected)
select {
case <-ctx.Done():
glog.V(0).Infof("[%s] Context cancelled, stopping", clientName)
return false
default:
}
// Check if subscription is still active and not at end
if !subscription.IsActive {
glog.V(0).Infof("[%s] Subscription not active, stopping", clientName)
return false
}
atEnd, err := subscription.IsAtEnd()
if err != nil {
glog.V(0).Infof("[%s] Error checking if subscription at end: %v", clientName, err)
return false
}
if atEnd {
glog.V(4).Infof("[%s] At end of subscription, stopping", clientName)
return false
}
// Add a small sleep to avoid CPU busy-wait when checking for new data
time.Sleep(10 * time.Millisecond)
return true
},
func(logEntry *filer_pb.LogEntry) (bool, error) {
// Check if this message matches our offset requirements
currentOffset := subscription.GetNextOffset()
if logEntry.Offset < currentOffset {
// Skip messages before our current offset
return false, nil
}
// Send message to client
if err := stream.Send(&mq_pb.SubscribeMessageResponse{
Message: &mq_pb.SubscribeMessageResponse_Data{
Data: &mq_pb.DataMessage{
Key: logEntry.Key,
Value: logEntry.Data,
TsNs: logEntry.TsNs,
},
},
}); err != nil {
glog.Errorf("Error sending data to %s: %v", clientName, err)
return false, err
}
// Advance subscription offset
subscription.AdvanceOffset()
// Check context for cancellation
select {
case <-ctx.Done():
return true, ctx.Err()
default:
return false, nil
}
})
}
// GetSubscriptionInfo returns information about an active subscription
func (b *MessageQueueBroker) GetSubscriptionInfo(subscriptionID string) (map[string]interface{}, error) {
subscription, err := b.offsetManager.GetSubscription(subscriptionID)
if err != nil {
return nil, err
}
lag, err := subscription.GetLag()
if err != nil {
return nil, err
}
atEnd, err := subscription.IsAtEnd()
if err != nil {
return nil, err
}
return map[string]interface{}{
"subscription_id": subscription.ID,
"start_offset": subscription.StartOffset,
"current_offset": subscription.CurrentOffset,
"offset_type": subscription.OffsetType.String(),
"is_active": subscription.IsActive,
"lag": lag,
"at_end": atEnd,
}, nil
}
// ListActiveSubscriptions returns information about all active subscriptions
func (b *MessageQueueBroker) ListActiveSubscriptions() ([]map[string]interface{}, error) {
subscriptions, err := b.offsetManager.ListActiveSubscriptions()
if err != nil {
return nil, err
}
result := make([]map[string]interface{}, len(subscriptions))
for i, subscription := range subscriptions {
lag, _ := subscription.GetLag()
atEnd, _ := subscription.IsAtEnd()
result[i] = map[string]interface{}{
"subscription_id": subscription.ID,
"start_offset": subscription.StartOffset,
"current_offset": subscription.CurrentOffset,
"offset_type": subscription.OffsetType.String(),
"is_active": subscription.IsActive,
"lag": lag,
"at_end": atEnd,
}
}
return result, nil
}
// SeekSubscription seeks an existing subscription to a specific offset
func (b *MessageQueueBroker) SeekSubscription(subscriptionID string, offset int64) error {
subscription, err := b.offsetManager.GetSubscription(subscriptionID)
if err != nil {
return err
}
return subscription.SeekToOffset(offset)
}
// convertOffsetToMessagePosition converts a subscription's current offset to a MessagePosition for log_buffer
func (b *MessageQueueBroker) convertOffsetToMessagePosition(subscription *offset.OffsetSubscription) (log_buffer.MessagePosition, error) {
currentOffset := subscription.GetNextOffset()
// Handle special offset cases
switch subscription.OffsetType {
case schema_pb.OffsetType_RESET_TO_EARLIEST:
return log_buffer.NewMessagePosition(1, -3), nil
case schema_pb.OffsetType_RESET_TO_LATEST:
return log_buffer.NewMessagePosition(time.Now().UnixNano(), -4), nil
case schema_pb.OffsetType_EXACT_OFFSET:
// Use proper offset-based positioning that provides consistent results
// This uses the same approach as the main subscription handler in broker_grpc_sub.go
return log_buffer.NewMessagePositionFromOffset(currentOffset), nil
case schema_pb.OffsetType_EXACT_TS_NS:
// For exact timestamps, use the timestamp directly
return log_buffer.NewMessagePosition(currentOffset, -2), nil
default:
// Default to starting from current time for unknown offset types
return log_buffer.NewMessagePosition(time.Now().UnixNano(), -2), nil
}
}

View File

@@ -0,0 +1,707 @@
package broker
import (
"fmt"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/mq/offset"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
)
func TestConvertOffsetToMessagePosition(t *testing.T) {
broker := &MessageQueueBroker{}
tests := []struct {
name string
offsetType schema_pb.OffsetType
currentOffset int64
expectedBatch int64
expectError bool
}{
{
name: "reset to earliest",
offsetType: schema_pb.OffsetType_RESET_TO_EARLIEST,
currentOffset: 0,
expectedBatch: -3,
expectError: false,
},
{
name: "reset to latest",
offsetType: schema_pb.OffsetType_RESET_TO_LATEST,
currentOffset: 0,
expectedBatch: -4,
expectError: false,
},
{
name: "exact offset zero",
offsetType: schema_pb.OffsetType_EXACT_OFFSET,
currentOffset: 0,
expectedBatch: 0, // NewMessagePositionFromOffset stores offset directly in Offset field
expectError: false,
},
{
name: "exact offset non-zero",
offsetType: schema_pb.OffsetType_EXACT_OFFSET,
currentOffset: 100,
expectedBatch: 100, // NewMessagePositionFromOffset stores offset directly in Offset field
expectError: false,
},
{
name: "exact timestamp",
offsetType: schema_pb.OffsetType_EXACT_TS_NS,
currentOffset: 50,
expectedBatch: -2,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a mock subscription
subscription := &offset.OffsetSubscription{
ID: "test-subscription",
CurrentOffset: tt.currentOffset,
OffsetType: tt.offsetType,
IsActive: true,
}
position, err := broker.convertOffsetToMessagePosition(subscription)
if tt.expectError && err == nil {
t.Error("Expected error but got none")
return
}
if !tt.expectError && err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if position.Offset != tt.expectedBatch {
t.Errorf("Expected batch index %d, got %d", tt.expectedBatch, position.Offset)
}
// Verify that the timestamp is reasonable (not zero for most cases)
// Note: EXACT_OFFSET uses epoch time (zero) with NewMessagePositionFromOffset
if tt.offsetType != schema_pb.OffsetType_RESET_TO_EARLIEST &&
tt.offsetType != schema_pb.OffsetType_EXACT_OFFSET &&
position.Time.IsZero() {
t.Error("Expected non-zero timestamp")
}
})
}
}
func TestConvertOffsetToMessagePosition_OffsetEncoding(t *testing.T) {
broker := &MessageQueueBroker{}
// Test that offset-based positions encode the offset correctly in Offset field
testCases := []struct {
offset int64
expectedBatch int64
expectedIsSentinel bool // Should timestamp be the offset sentinel value?
}{
{10, 10, true},
{100, 100, true},
{0, 0, true},
{42, 42, true},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("offset_%d", tc.offset), func(t *testing.T) {
subscription := &offset.OffsetSubscription{
ID: fmt.Sprintf("test-%d", tc.offset),
CurrentOffset: tc.offset,
OffsetType: schema_pb.OffsetType_EXACT_OFFSET,
IsActive: true,
}
pos, err := broker.convertOffsetToMessagePosition(subscription)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Check Offset encoding
if pos.Offset != tc.expectedBatch {
t.Errorf("Expected batch index %d, got %d", tc.expectedBatch, pos.Offset)
}
// Verify the offset can be extracted correctly using IsOffsetBased/GetOffset
if !pos.IsOffsetBased {
t.Error("Position should be detected as offset-based")
}
// Check that IsOffsetBased flag is set correctly
if tc.expectedIsSentinel && !pos.IsOffsetBased {
t.Error("Expected offset-based position but IsOffsetBased=false")
}
if extractedOffset := pos.GetOffset(); extractedOffset != tc.offset {
t.Errorf("Expected extracted offset %d, got %d", tc.offset, extractedOffset)
}
})
}
}
func TestConvertOffsetToMessagePosition_ConsistentResults(t *testing.T) {
broker := &MessageQueueBroker{}
subscription := &offset.OffsetSubscription{
ID: "consistent-test",
CurrentOffset: 42,
OffsetType: schema_pb.OffsetType_EXACT_OFFSET,
IsActive: true,
}
// Call multiple times within a short period
positions := make([]log_buffer.MessagePosition, 5)
for i := 0; i < 5; i++ {
pos, err := broker.convertOffsetToMessagePosition(subscription)
if err != nil {
t.Fatalf("Unexpected error on iteration %d: %v", i, err)
}
positions[i] = pos
time.Sleep(1 * time.Millisecond) // Small delay
}
// All positions should have the same Offset
for i := 1; i < len(positions); i++ {
if positions[i].Offset != positions[0].Offset {
t.Errorf("Inconsistent Offset: %d vs %d", positions[0].Offset, positions[i].Offset)
}
}
// With NewMessagePositionFromOffset, timestamps should be identical (zero time for offset-based)
expectedTime := time.Time{}
for i := 0; i < len(positions); i++ {
if !positions[i].Time.Equal(expectedTime) {
t.Errorf("Expected all timestamps to be sentinel time (%v), got %v at index %d",
expectedTime, positions[i].Time, i)
}
}
}
func TestConvertOffsetToMessagePosition_FixVerification(t *testing.T) {
// This test specifically verifies that the fix addresses the issue mentioned:
// "The calculated timestamp for a given offset will change every time the function is called"
broker := &MessageQueueBroker{}
subscription := &offset.OffsetSubscription{
ID: "fix-verification",
CurrentOffset: 123,
OffsetType: schema_pb.OffsetType_EXACT_OFFSET,
IsActive: true,
}
// Call the function multiple times with delays to simulate real-world usage
var positions []log_buffer.MessagePosition
var timestamps []int64
for i := 0; i < 10; i++ {
pos, err := broker.convertOffsetToMessagePosition(subscription)
if err != nil {
t.Fatalf("Unexpected error on iteration %d: %v", i, err)
}
positions = append(positions, pos)
timestamps = append(timestamps, pos.Time.UnixNano())
time.Sleep(2 * time.Millisecond) // Small delay to ensure time progression
}
// Verify ALL timestamps are identical (no time-based variance)
expectedTimestamp := timestamps[0]
for i, ts := range timestamps {
if ts != expectedTimestamp {
t.Errorf("Timestamp variance detected at call %d: expected %d, got %d", i, expectedTimestamp, ts)
}
}
// Verify ALL Offset values are identical
expectedBatch := positions[0].Offset
for i, pos := range positions {
if pos.Offset != expectedBatch {
t.Errorf("Offset variance detected at call %d: expected %d, got %d", i, expectedBatch, pos.Offset)
}
}
// Verify the offset can be consistently extracted
expectedOffset := subscription.CurrentOffset
for i, pos := range positions {
if extractedOffset := pos.GetOffset(); extractedOffset != expectedOffset {
t.Errorf("Extracted offset variance at call %d: expected %d, got %d", i, expectedOffset, extractedOffset)
}
}
}
func TestPartitionIdentityConsistency(t *testing.T) {
// Test that partition identity is preserved from request to avoid breaking offset manager keys
// Create a mock init message with specific partition info
partition := &schema_pb.Partition{
RingSize: 32,
RangeStart: 0,
RangeStop: 31,
UnixTimeNs: 1234567890123456789, // Fixed timestamp
}
initMessage := &mq_pb.SubscribeMessageRequest_InitMessage{
ConsumerGroup: "test-group",
ConsumerId: "test-consumer",
PartitionOffset: &schema_pb.PartitionOffset{
Partition: partition,
},
}
// Simulate the partition creation logic from SubscribeWithOffset
p := topic.Partition{
RingSize: initMessage.PartitionOffset.Partition.RingSize,
RangeStart: initMessage.PartitionOffset.Partition.RangeStart,
RangeStop: initMessage.PartitionOffset.Partition.RangeStop,
UnixTimeNs: initMessage.PartitionOffset.Partition.UnixTimeNs,
}
// Verify that the partition preserves the original UnixTimeNs
if p.UnixTimeNs != partition.UnixTimeNs {
t.Errorf("Partition UnixTimeNs not preserved: expected %d, got %d",
partition.UnixTimeNs, p.UnixTimeNs)
}
// Verify partition key consistency
expectedKey := fmt.Sprintf("ring:%d:range:%d-%d:time:%d",
partition.RingSize, partition.RangeStart, partition.RangeStop, partition.UnixTimeNs)
actualKey := fmt.Sprintf("ring:%d:range:%d-%d:time:%d",
p.RingSize, p.RangeStart, p.RangeStop, p.UnixTimeNs)
if actualKey != expectedKey {
t.Errorf("Partition key mismatch: expected %s, got %s", expectedKey, actualKey)
}
}
func TestBrokerOffsetManager_GetSubscription_Fixed(t *testing.T) {
// Test that GetSubscription now works correctly after the fix
storage := NewInMemoryOffsetStorageForTesting()
offsetManager := NewBrokerOffsetManagerWithStorage(storage)
// Create test topic and partition
testTopic := topic.Topic{Namespace: "test", Name: "topic1"}
testPartition := topic.Partition{
RingSize: 32,
RangeStart: 0,
RangeStop: 31,
UnixTimeNs: time.Now().UnixNano(),
}
// Test getting non-existent subscription
_, err := offsetManager.GetSubscription("non-existent")
if err == nil {
t.Error("Expected error for non-existent subscription")
}
// Create a subscription
subscriptionID := "test-subscription-fixed"
subscription, err := offsetManager.CreateSubscription(
subscriptionID,
testTopic,
testPartition,
schema_pb.OffsetType_RESET_TO_EARLIEST,
0,
)
if err != nil {
t.Fatalf("Failed to create subscription: %v", err)
}
// Test getting existing subscription (this should now work)
retrievedSub, err := offsetManager.GetSubscription(subscriptionID)
if err != nil {
t.Fatalf("GetSubscription failed after fix: %v", err)
}
if retrievedSub.ID != subscription.ID {
t.Errorf("Expected subscription ID %s, got %s", subscription.ID, retrievedSub.ID)
}
if retrievedSub.OffsetType != subscription.OffsetType {
t.Errorf("Expected offset type %v, got %v", subscription.OffsetType, retrievedSub.OffsetType)
}
}
func TestBrokerOffsetManager_ListActiveSubscriptions_Fixed(t *testing.T) {
// Test that ListActiveSubscriptions now works correctly after the fix
storage := NewInMemoryOffsetStorageForTesting()
offsetManager := NewBrokerOffsetManagerWithStorage(storage)
// Create test topic and partition
testTopic := topic.Topic{Namespace: "test", Name: "topic1"}
testPartition := topic.Partition{
RingSize: 32,
RangeStart: 0,
RangeStop: 31,
UnixTimeNs: time.Now().UnixNano(),
}
// Initially should have no subscriptions
subscriptions, err := offsetManager.ListActiveSubscriptions()
if err != nil {
t.Fatalf("ListActiveSubscriptions failed after fix: %v", err)
}
if len(subscriptions) != 0 {
t.Errorf("Expected 0 subscriptions, got %d", len(subscriptions))
}
// Create multiple subscriptions (use RESET types to avoid HWM validation issues)
subscriptionIDs := []string{"sub-fixed-1", "sub-fixed-2", "sub-fixed-3"}
offsetTypes := []schema_pb.OffsetType{
schema_pb.OffsetType_RESET_TO_EARLIEST,
schema_pb.OffsetType_RESET_TO_LATEST,
schema_pb.OffsetType_RESET_TO_EARLIEST, // Changed from EXACT_OFFSET
}
for i, subID := range subscriptionIDs {
_, err := offsetManager.CreateSubscription(
subID,
testTopic,
testPartition,
offsetTypes[i],
0, // Use 0 for all to avoid validation issues
)
if err != nil {
t.Fatalf("Failed to create subscription %s: %v", subID, err)
}
}
// List all subscriptions (this should now work)
subscriptions, err = offsetManager.ListActiveSubscriptions()
if err != nil {
t.Fatalf("ListActiveSubscriptions failed after fix: %v", err)
}
if len(subscriptions) != len(subscriptionIDs) {
t.Errorf("Expected %d subscriptions, got %d", len(subscriptionIDs), len(subscriptions))
}
// Verify all subscriptions are active
for _, sub := range subscriptions {
if !sub.IsActive {
t.Errorf("Subscription %s should be active", sub.ID)
}
}
}
func TestMessageQueueBroker_ListActiveSubscriptions_Fixed(t *testing.T) {
// Test that the broker-level ListActiveSubscriptions now works correctly
storage := NewInMemoryOffsetStorageForTesting()
offsetManager := NewBrokerOffsetManagerWithStorage(storage)
broker := &MessageQueueBroker{
offsetManager: offsetManager,
}
// Create test topic and partition
testTopic := topic.Topic{Namespace: "test", Name: "topic1"}
testPartition := topic.Partition{
RingSize: 32,
RangeStart: 0,
RangeStop: 31,
UnixTimeNs: time.Now().UnixNano(),
}
// Initially should have no subscriptions
subscriptionInfos, err := broker.ListActiveSubscriptions()
if err != nil {
t.Fatalf("Broker ListActiveSubscriptions failed after fix: %v", err)
}
if len(subscriptionInfos) != 0 {
t.Errorf("Expected 0 subscription infos, got %d", len(subscriptionInfos))
}
// Create subscriptions with different offset types (use RESET types to avoid HWM validation issues)
testCases := []struct {
id string
offsetType schema_pb.OffsetType
startOffset int64
}{
{"broker-earliest-sub", schema_pb.OffsetType_RESET_TO_EARLIEST, 0},
{"broker-latest-sub", schema_pb.OffsetType_RESET_TO_LATEST, 0},
{"broker-reset-sub", schema_pb.OffsetType_RESET_TO_EARLIEST, 0}, // Changed from EXACT_OFFSET
}
for _, tc := range testCases {
_, err := broker.offsetManager.CreateSubscription(
tc.id,
testTopic,
testPartition,
tc.offsetType,
tc.startOffset,
)
if err != nil {
t.Fatalf("Failed to create subscription %s: %v", tc.id, err)
}
}
// List subscription infos (this should now work)
subscriptionInfos, err = broker.ListActiveSubscriptions()
if err != nil {
t.Fatalf("Broker ListActiveSubscriptions failed after fix: %v", err)
}
if len(subscriptionInfos) != len(testCases) {
t.Errorf("Expected %d subscription infos, got %d", len(testCases), len(subscriptionInfos))
}
// Verify subscription info structure
for _, info := range subscriptionInfos {
// Check required fields
requiredFields := []string{
"subscription_id", "start_offset", "current_offset",
"offset_type", "is_active", "lag", "at_end",
}
for _, field := range requiredFields {
if _, ok := info[field]; !ok {
t.Errorf("Missing field %s in subscription info", field)
}
}
// Verify is_active is true
if isActive, ok := info["is_active"].(bool); !ok || !isActive {
t.Errorf("Expected is_active to be true, got %v", info["is_active"])
}
}
}
func TestSingleWriterPerPartitionCorrectness(t *testing.T) {
// Test that demonstrates correctness under single-writer-per-partition model
// Simulate two brokers with separate offset managers but same partition
storage1 := NewInMemoryOffsetStorageForTesting()
storage2 := NewInMemoryOffsetStorageForTesting()
offsetManager1 := NewBrokerOffsetManagerWithStorage(storage1)
offsetManager2 := NewBrokerOffsetManagerWithStorage(storage2)
broker1 := &MessageQueueBroker{offsetManager: offsetManager1}
broker2 := &MessageQueueBroker{offsetManager: offsetManager2}
// Same partition identity (this is key for correctness)
fixedTimestamp := time.Now().UnixNano()
testTopic := topic.Topic{Namespace: "test", Name: "shared-topic"}
testPartition := topic.Partition{
RingSize: 32,
RangeStart: 0,
RangeStop: 31,
UnixTimeNs: fixedTimestamp, // Same timestamp = same partition identity
}
// Broker 1 is the leader for this partition - assigns offsets
baseOffset, lastOffset, err := broker1.offsetManager.AssignBatchOffsets(testTopic, testPartition, 10)
if err != nil {
t.Fatalf("Failed to assign offsets on broker1: %v", err)
}
if baseOffset != 0 || lastOffset != 9 {
t.Errorf("Expected offsets 0-9, got %d-%d", baseOffset, lastOffset)
}
// Get HWM from leader
hwm1, err := broker1.offsetManager.GetHighWaterMark(testTopic, testPartition)
if err != nil {
t.Fatalf("Failed to get HWM from broker1: %v", err)
}
if hwm1 != 10 {
t.Errorf("Expected HWM 10 on leader, got %d", hwm1)
}
// Broker 2 is a follower - should have HWM 0 (no local assignments)
hwm2, err := broker2.offsetManager.GetHighWaterMark(testTopic, testPartition)
if err != nil {
t.Fatalf("Failed to get HWM from broker2: %v", err)
}
if hwm2 != 0 {
t.Errorf("Expected HWM 0 on follower, got %d", hwm2)
}
// Create subscription on leader (where offsets were assigned)
subscription1, err := broker1.offsetManager.CreateSubscription(
"leader-subscription",
testTopic,
testPartition,
schema_pb.OffsetType_RESET_TO_EARLIEST,
0,
)
if err != nil {
t.Fatalf("Failed to create subscription on leader: %v", err)
}
// Verify subscription can see the correct HWM
lag1, err := subscription1.GetLag()
if err != nil {
t.Fatalf("Failed to get lag on leader subscription: %v", err)
}
if lag1 != 10 {
t.Errorf("Expected lag 10 on leader subscription, got %d", lag1)
}
// Create subscription on follower (should have different lag due to local HWM)
subscription2, err := broker2.offsetManager.CreateSubscription(
"follower-subscription",
testTopic,
testPartition,
schema_pb.OffsetType_RESET_TO_EARLIEST,
0,
)
if err != nil {
t.Fatalf("Failed to create subscription on follower: %v", err)
}
lag2, err := subscription2.GetLag()
if err != nil {
t.Fatalf("Failed to get lag on follower subscription: %v", err)
}
if lag2 != 0 {
t.Errorf("Expected lag 0 on follower subscription (no local data), got %d", lag2)
}
}
func TestEndToEndWorkflowAfterFixes(t *testing.T) {
// Test the complete workflow with all fixes applied
storage := NewInMemoryOffsetStorageForTesting()
offsetManager := NewBrokerOffsetManagerWithStorage(storage)
broker := &MessageQueueBroker{
offsetManager: offsetManager,
}
// Create test topic and partition with fixed timestamp
fixedTimestamp := time.Now().UnixNano()
testTopic := topic.Topic{Namespace: "test", Name: "e2e-topic"}
testPartition := topic.Partition{
RingSize: 32,
RangeStart: 0,
RangeStop: 31,
UnixTimeNs: fixedTimestamp,
}
subscriptionID := "e2e-test-sub"
// 1. Create subscription (use RESET_TO_EARLIEST to avoid HWM validation issues)
subscription, err := broker.offsetManager.CreateSubscription(
subscriptionID,
testTopic,
testPartition,
schema_pb.OffsetType_RESET_TO_EARLIEST,
0,
)
if err != nil {
t.Fatalf("Failed to create subscription: %v", err)
}
// 2. Verify GetSubscription works
retrievedSub, err := broker.offsetManager.GetSubscription(subscriptionID)
if err != nil {
t.Fatalf("GetSubscription failed: %v", err)
}
if retrievedSub.ID != subscription.ID {
t.Errorf("GetSubscription returned wrong subscription: expected %s, got %s",
subscription.ID, retrievedSub.ID)
}
// 3. Verify it appears in active list
activeList, err := broker.ListActiveSubscriptions()
if err != nil {
t.Fatalf("Failed to list active subscriptions: %v", err)
}
found := false
for _, info := range activeList {
if info["subscription_id"] == subscriptionID {
found = true
break
}
}
if !found {
t.Error("New subscription not found in active list")
}
// 4. Get subscription info
info, err := broker.GetSubscriptionInfo(subscriptionID)
if err != nil {
t.Fatalf("Failed to get subscription info: %v", err)
}
if info["subscription_id"] != subscriptionID {
t.Errorf("Wrong subscription ID in info: expected %s, got %v", subscriptionID, info["subscription_id"])
}
// 5. Assign some offsets to create data for seeking
_, _, err = broker.offsetManager.AssignBatchOffsets(testTopic, testPartition, 50)
if err != nil {
t.Fatalf("Failed to assign offsets: %v", err)
}
// 6. Seek subscription
newOffset := int64(42)
err = broker.SeekSubscription(subscriptionID, newOffset)
if err != nil {
t.Fatalf("Failed to seek subscription: %v", err)
}
// 7. Verify seek worked
updatedInfo, err := broker.GetSubscriptionInfo(subscriptionID)
if err != nil {
t.Fatalf("Failed to get updated subscription info: %v", err)
}
if updatedInfo["current_offset"] != newOffset {
t.Errorf("Seek didn't work: expected offset %d, got %v", newOffset, updatedInfo["current_offset"])
}
// 8. Test offset to timestamp conversion with fixed partition identity
updatedSub, err := broker.offsetManager.GetSubscription(subscriptionID)
if err != nil {
t.Fatalf("Failed to get updated subscription: %v", err)
}
position, err := broker.convertOffsetToMessagePosition(updatedSub)
if err != nil {
t.Fatalf("Failed to convert offset to position: %v", err)
}
if position.Time.IsZero() {
t.Error("Expected non-zero timestamp from conversion")
}
// 9. Verify partition identity consistency throughout
partitionKey1 := fmt.Sprintf("ring:%d:range:%d-%d:time:%d",
testPartition.RingSize, testPartition.RangeStart, testPartition.RangeStop, testPartition.UnixTimeNs)
partitionKey2 := fmt.Sprintf("ring:%d:range:%d-%d:time:%d",
testPartition.RingSize, testPartition.RangeStart, testPartition.RangeStop, fixedTimestamp)
if partitionKey1 != partitionKey2 {
t.Errorf("Partition key inconsistency: %s != %s", partitionKey1, partitionKey2)
}
}

View File

@@ -0,0 +1,29 @@
package broker
import (
"context"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
)
func (b *MessageQueueBroker) ClosePublishers(ctx context.Context, request *mq_pb.ClosePublishersRequest) (resp *mq_pb.ClosePublishersResponse, err error) {
resp = &mq_pb.ClosePublishersResponse{}
t := topic.FromPbTopic(request.Topic)
b.localTopicManager.ClosePublishers(t, request.UnixTimeNs)
// wait until all publishers are closed
b.localTopicManager.WaitUntilNoPublishers(t)
return
}
func (b *MessageQueueBroker) CloseSubscribers(ctx context.Context, request *mq_pb.CloseSubscribersRequest) (resp *mq_pb.CloseSubscribersResponse, err error) {
resp = &mq_pb.CloseSubscribersResponse{}
b.localTopicManager.CloseSubscribers(topic.FromPbTopic(request.Topic), request.UnixTimeNs)
return
}

View File

@@ -0,0 +1,152 @@
package broker
import (
"time"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
)
// OffsetAssignmentFunc is a function type for assigning offsets to messages
type OffsetAssignmentFunc func() (int64, error)
// AddToBufferWithOffset adds a message to the log buffer with offset assignment
// TODO: This is a temporary solution until LogBuffer can be modified to accept offset assignment
// ASSUMPTION: This function will be integrated into LogBuffer.AddToBuffer in the future
func (b *MessageQueueBroker) AddToBufferWithOffset(
logBuffer *log_buffer.LogBuffer,
message *mq_pb.DataMessage,
t topic.Topic,
p topic.Partition,
) error {
// Assign offset for this message
offset, err := b.offsetManager.AssignOffset(t, p)
if err != nil {
return err
}
// PERFORMANCE OPTIMIZATION: Pre-process expensive operations OUTSIDE the lock
processingTsNs := message.TsNs
if processingTsNs == 0 {
processingTsNs = time.Now().UnixNano()
}
// Create LogEntry with assigned offset
logEntry := &filer_pb.LogEntry{
TsNs: processingTsNs,
PartitionKeyHash: util.HashToInt32(message.Key),
Data: message.Value,
Key: message.Key,
Offset: offset, // Add the assigned offset
}
// Use the existing LogBuffer infrastructure for the rest
// TODO: This is a workaround - ideally LogBuffer should handle offset assignment
// For now, we'll add the message with the pre-assigned offset
return b.addLogEntryToBuffer(logBuffer, logEntry)
}
// addLogEntryToBuffer adds a pre-constructed LogEntry to the buffer
// This is a helper function that directly uses LogBuffer.AddLogEntryToBuffer
func (b *MessageQueueBroker) addLogEntryToBuffer(
logBuffer *log_buffer.LogBuffer,
logEntry *filer_pb.LogEntry,
) error {
// Use the AddLogEntryToBuffer method to preserve offset information
// This ensures the offset is maintained throughout the entire data flow
return logBuffer.AddLogEntryToBuffer(logEntry)
}
// GetPartitionOffsetInfoInternal returns offset information for a partition (internal method)
func (b *MessageQueueBroker) GetPartitionOffsetInfoInternal(t topic.Topic, p topic.Partition) (*PartitionOffsetInfo, error) {
info, err := b.offsetManager.GetPartitionOffsetInfo(t, p)
if err != nil {
return nil, err
}
// CRITICAL FIX: Also check LogBuffer for in-memory messages
// The offset manager only tracks assigned offsets from persistent storage
// But the LogBuffer contains recently written messages that haven't been flushed yet
localPartition := b.localTopicManager.GetLocalPartition(t, p)
logBufferHWM := int64(-1)
if localPartition != nil && localPartition.LogBuffer != nil {
logBufferHWM = localPartition.LogBuffer.GetOffset()
} else {
}
// Use the MAX of offset manager HWM and LogBuffer HWM
// This ensures we report the correct HWM even if data hasn't been flushed to disk yet
// IMPORTANT: Use >= not > because when they're equal, we still want the correct value
highWaterMark := info.HighWaterMark
if logBufferHWM >= 0 && logBufferHWM > highWaterMark {
highWaterMark = logBufferHWM
} else if logBufferHWM >= 0 && logBufferHWM == highWaterMark && highWaterMark > 0 {
} else if logBufferHWM >= 0 {
}
// Latest offset is HWM - 1 (last assigned offset)
latestOffset := highWaterMark - 1
if highWaterMark == 0 {
latestOffset = -1 // No records
}
// Convert to broker-specific format
return &PartitionOffsetInfo{
Topic: t,
Partition: p,
EarliestOffset: info.EarliestOffset,
LatestOffset: latestOffset,
HighWaterMark: highWaterMark,
RecordCount: highWaterMark, // HWM equals record count (offsets 0 to HWM-1)
ActiveSubscriptions: info.ActiveSubscriptions,
}, nil
}
// PartitionOffsetInfo provides offset information for a partition (broker-specific)
type PartitionOffsetInfo struct {
Topic topic.Topic
Partition topic.Partition
EarliestOffset int64
LatestOffset int64
HighWaterMark int64
RecordCount int64
ActiveSubscriptions int64
}
// CreateOffsetSubscription creates an offset-based subscription through the broker
func (b *MessageQueueBroker) CreateOffsetSubscription(
subscriptionID string,
t topic.Topic,
p topic.Partition,
offsetType string, // Will be converted to schema_pb.OffsetType
startOffset int64,
) error {
// TODO: Convert string offsetType to schema_pb.OffsetType
// ASSUMPTION: For now using RESET_TO_EARLIEST as default
// This should be properly mapped based on the offsetType parameter
_, err := b.offsetManager.CreateSubscription(
subscriptionID,
t,
p,
0, // schema_pb.OffsetType_RESET_TO_EARLIEST
startOffset,
)
return err
}
// GetOffsetMetrics returns offset metrics for monitoring
func (b *MessageQueueBroker) GetOffsetMetrics() map[string]interface{} {
metrics := b.offsetManager.GetOffsetMetrics()
return map[string]interface{}{
"partition_count": metrics.PartitionCount,
"total_offsets": metrics.TotalOffsets,
"active_subscriptions": metrics.ActiveSubscriptions,
"average_latency": metrics.AverageLatency,
}
}

View File

@@ -0,0 +1,202 @@
package broker
import (
"fmt"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer_client"
"github.com/seaweedfs/seaweedfs/weed/mq/offset"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
)
// BrokerOffsetManager manages offset assignment for all partitions in a broker
type BrokerOffsetManager struct {
mu sync.RWMutex
offsetIntegration *offset.SMQOffsetIntegration
storage offset.OffsetStorage
consumerGroupStorage offset.ConsumerGroupOffsetStorage
}
// NewBrokerOffsetManagerWithFilerAccessor creates a new broker offset manager using existing filer client accessor
func NewBrokerOffsetManagerWithFilerAccessor(filerAccessor *filer_client.FilerClientAccessor) *BrokerOffsetManager {
// Create filer storage using the accessor directly - no duplicate connection management
filerStorage := offset.NewFilerOffsetStorageWithAccessor(filerAccessor)
// Create consumer group storage using the accessor directly
consumerGroupStorage := offset.NewFilerConsumerGroupOffsetStorageWithAccessor(filerAccessor)
return &BrokerOffsetManager{
offsetIntegration: offset.NewSMQOffsetIntegration(filerStorage),
storage: filerStorage,
consumerGroupStorage: consumerGroupStorage,
}
}
// AssignOffset assigns the next offset for a partition
func (bom *BrokerOffsetManager) AssignOffset(t topic.Topic, p topic.Partition) (int64, error) {
partition := topicPartitionToSchemaPartition(t, p)
// Use the integration layer's offset assigner to ensure consistency with subscriptions
result := bom.offsetIntegration.AssignSingleOffset(t.Namespace, t.Name, partition)
if result.Error != nil {
return 0, result.Error
}
return result.Assignment.Offset, nil
}
// AssignBatchOffsets assigns a batch of offsets for a partition
func (bom *BrokerOffsetManager) AssignBatchOffsets(t topic.Topic, p topic.Partition, count int64) (baseOffset, lastOffset int64, err error) {
partition := topicPartitionToSchemaPartition(t, p)
// Use the integration layer's offset assigner to ensure consistency with subscriptions
result := bom.offsetIntegration.AssignBatchOffsets(t.Namespace, t.Name, partition, count)
if result.Error != nil {
return 0, 0, result.Error
}
return result.Batch.BaseOffset, result.Batch.LastOffset, nil
}
// GetHighWaterMark returns the high water mark for a partition
func (bom *BrokerOffsetManager) GetHighWaterMark(t topic.Topic, p topic.Partition) (int64, error) {
partition := topicPartitionToSchemaPartition(t, p)
// Use the integration layer's offset assigner to ensure consistency with subscriptions
return bom.offsetIntegration.GetHighWaterMark(t.Namespace, t.Name, partition)
}
// CreateSubscription creates an offset-based subscription
func (bom *BrokerOffsetManager) CreateSubscription(
subscriptionID string,
t topic.Topic,
p topic.Partition,
offsetType schema_pb.OffsetType,
startOffset int64,
) (*offset.OffsetSubscription, error) {
partition := topicPartitionToSchemaPartition(t, p)
return bom.offsetIntegration.CreateSubscription(subscriptionID, t.Namespace, t.Name, partition, offsetType, startOffset)
}
// GetSubscription retrieves an existing subscription
func (bom *BrokerOffsetManager) GetSubscription(subscriptionID string) (*offset.OffsetSubscription, error) {
return bom.offsetIntegration.GetSubscription(subscriptionID)
}
// CloseSubscription closes a subscription
func (bom *BrokerOffsetManager) CloseSubscription(subscriptionID string) error {
return bom.offsetIntegration.CloseSubscription(subscriptionID)
}
// ListActiveSubscriptions returns all active subscriptions
func (bom *BrokerOffsetManager) ListActiveSubscriptions() ([]*offset.OffsetSubscription, error) {
return bom.offsetIntegration.ListActiveSubscriptions()
}
// GetPartitionOffsetInfo returns comprehensive offset information for a partition
func (bom *BrokerOffsetManager) GetPartitionOffsetInfo(t topic.Topic, p topic.Partition) (*offset.PartitionOffsetInfo, error) {
partition := topicPartitionToSchemaPartition(t, p)
// Use the integration layer to ensure consistency with subscriptions
return bom.offsetIntegration.GetPartitionOffsetInfo(t.Namespace, t.Name, partition)
}
// topicPartitionToSchemaPartition converts topic.Topic and topic.Partition to schema_pb.Partition
func topicPartitionToSchemaPartition(t topic.Topic, p topic.Partition) *schema_pb.Partition {
return &schema_pb.Partition{
RingSize: int32(p.RingSize),
RangeStart: int32(p.RangeStart),
RangeStop: int32(p.RangeStop),
UnixTimeNs: p.UnixTimeNs,
}
}
// OffsetAssignmentResult contains the result of offset assignment for logging/metrics
type OffsetAssignmentResult struct {
Topic topic.Topic
Partition topic.Partition
BaseOffset int64
LastOffset int64
Count int64
Timestamp int64
Error error
}
// AssignOffsetsWithResult assigns offsets and returns detailed result for logging/metrics
func (bom *BrokerOffsetManager) AssignOffsetsWithResult(t topic.Topic, p topic.Partition, count int64) *OffsetAssignmentResult {
baseOffset, lastOffset, err := bom.AssignBatchOffsets(t, p, count)
result := &OffsetAssignmentResult{
Topic: t,
Partition: p,
Count: count,
Error: err,
}
if err == nil {
result.BaseOffset = baseOffset
result.LastOffset = lastOffset
result.Timestamp = time.Now().UnixNano()
}
return result
}
// GetOffsetMetrics returns metrics about offset usage across all partitions
func (bom *BrokerOffsetManager) GetOffsetMetrics() *offset.OffsetMetrics {
// Use the integration layer to ensure consistency with subscriptions
return bom.offsetIntegration.GetOffsetMetrics()
}
// Shutdown gracefully shuts down the offset manager
func (bom *BrokerOffsetManager) Shutdown() {
bom.mu.Lock()
defer bom.mu.Unlock()
// Reset the underlying storage to ensure clean restart behavior
// This is important for testing where we want offsets to start from 0 after shutdown
if bom.storage != nil {
if resettable, ok := bom.storage.(interface{ Reset() error }); ok {
resettable.Reset()
}
}
// Reset the integration layer to ensure clean restart behavior
bom.offsetIntegration.Reset()
}
// Consumer Group Offset Management
// SaveConsumerGroupOffset saves the committed offset for a consumer group
func (bom *BrokerOffsetManager) SaveConsumerGroupOffset(t topic.Topic, p topic.Partition, consumerGroup string, offset int64) error {
if bom.consumerGroupStorage == nil {
return fmt.Errorf("consumer group storage not configured")
}
return bom.consumerGroupStorage.SaveConsumerGroupOffset(t, p, consumerGroup, offset)
}
// LoadConsumerGroupOffset loads the committed offset for a consumer group
func (bom *BrokerOffsetManager) LoadConsumerGroupOffset(t topic.Topic, p topic.Partition, consumerGroup string) (int64, error) {
if bom.consumerGroupStorage == nil {
return -1, fmt.Errorf("consumer group storage not configured")
}
return bom.consumerGroupStorage.LoadConsumerGroupOffset(t, p, consumerGroup)
}
// ListConsumerGroups returns all consumer groups for a topic partition
func (bom *BrokerOffsetManager) ListConsumerGroups(t topic.Topic, p topic.Partition) ([]string, error) {
if bom.consumerGroupStorage == nil {
return nil, fmt.Errorf("consumer group storage not configured")
}
return bom.consumerGroupStorage.ListConsumerGroups(t, p)
}
// DeleteConsumerGroupOffset removes the offset file for a consumer group
func (bom *BrokerOffsetManager) DeleteConsumerGroupOffset(t topic.Topic, p topic.Partition, consumerGroup string) error {
if bom.consumerGroupStorage == nil {
return fmt.Errorf("consumer group storage not configured")
}
return bom.consumerGroupStorage.DeleteConsumerGroupOffset(t, p, consumerGroup)
}

View File

@@ -0,0 +1,180 @@
package broker
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
"google.golang.org/protobuf/proto"
)
func TestValidateRecordValue(t *testing.T) {
broker := &MessageQueueBroker{}
// Test valid schema-based RecordValue
validRecord := &schema_pb.RecordValue{
Fields: map[string]*schema_pb.Value{
"user_name": {
Kind: &schema_pb.Value_StringValue{StringValue: "john_doe"},
},
"user_age": {
Kind: &schema_pb.Value_Int32Value{Int32Value: 30},
},
"is_active": {
Kind: &schema_pb.Value_BoolValue{BoolValue: true},
},
},
}
kafkaTopic := &schema_pb.Topic{
Namespace: "kafka",
Name: "test-topic",
}
err := broker.validateRecordValue(validRecord, kafkaTopic)
if err != nil {
t.Errorf("Valid schema-based RecordValue should pass validation: %v", err)
}
}
func TestValidateRecordValueEmptyFields(t *testing.T) {
broker := &MessageQueueBroker{}
kafkaTopic := &schema_pb.Topic{
Namespace: "kafka",
Name: "test-topic",
}
// Test empty fields
recordEmptyFields := &schema_pb.RecordValue{
Fields: map[string]*schema_pb.Value{},
}
err := broker.validateRecordValue(recordEmptyFields, kafkaTopic)
if err == nil {
t.Error("RecordValue with empty fields should fail validation")
}
if err.Error() != "RecordValue has no fields" {
t.Errorf("Expected specific error message, got: %v", err)
}
}
func TestValidateRecordValueNonKafkaTopic(t *testing.T) {
broker := &MessageQueueBroker{}
// For non-Kafka topics, validation should be more lenient
nonKafkaTopic := &schema_pb.Topic{
Namespace: "custom",
Name: "test-topic",
}
recordWithoutKafkaFields := &schema_pb.RecordValue{
Fields: map[string]*schema_pb.Value{
"custom_field": {
Kind: &schema_pb.Value_StringValue{StringValue: "custom-value"},
},
},
}
err := broker.validateRecordValue(recordWithoutKafkaFields, nonKafkaTopic)
if err != nil {
t.Errorf("Non-Kafka topic should allow flexible RecordValue structure: %v", err)
}
}
func TestValidateRecordValueNilInputs(t *testing.T) {
broker := &MessageQueueBroker{}
kafkaTopic := &schema_pb.Topic{
Namespace: "kafka",
Name: "test-topic",
}
// Test nil RecordValue
err := broker.validateRecordValue(nil, kafkaTopic)
if err == nil {
t.Error("Nil RecordValue should fail validation")
}
if err.Error() != "RecordValue is nil" {
t.Errorf("Expected specific error message, got: %v", err)
}
// Test RecordValue with nil Fields
recordWithNilFields := &schema_pb.RecordValue{
Fields: nil,
}
err = broker.validateRecordValue(recordWithNilFields, kafkaTopic)
if err == nil {
t.Error("RecordValue with nil Fields should fail validation")
}
if err.Error() != "RecordValue.Fields is nil" {
t.Errorf("Expected specific error message, got: %v", err)
}
}
func TestRecordValueMarshalUnmarshalIntegration(t *testing.T) {
broker := &MessageQueueBroker{}
// Create a valid RecordValue
originalRecord := &schema_pb.RecordValue{
Fields: map[string]*schema_pb.Value{
"key": {
Kind: &schema_pb.Value_BytesValue{BytesValue: []byte("integration-key")},
},
"value": {
Kind: &schema_pb.Value_StringValue{StringValue: "integration-value"},
},
"timestamp": {
Kind: &schema_pb.Value_TimestampValue{
TimestampValue: &schema_pb.TimestampValue{
TimestampMicros: 1234567890,
IsUtc: true,
},
},
},
},
}
// Marshal to bytes
recordBytes, err := proto.Marshal(originalRecord)
if err != nil {
t.Fatalf("Failed to marshal RecordValue: %v", err)
}
// Unmarshal back
unmarshaledRecord := &schema_pb.RecordValue{}
err = proto.Unmarshal(recordBytes, unmarshaledRecord)
if err != nil {
t.Fatalf("Failed to unmarshal RecordValue: %v", err)
}
// Validate the unmarshaled record
kafkaTopic := &schema_pb.Topic{
Namespace: "kafka",
Name: "integration-topic",
}
err = broker.validateRecordValue(unmarshaledRecord, kafkaTopic)
if err != nil {
t.Errorf("Unmarshaled RecordValue should pass validation: %v", err)
}
// Verify field values
keyField := unmarshaledRecord.Fields["key"]
if keyValue, ok := keyField.Kind.(*schema_pb.Value_BytesValue); ok {
if string(keyValue.BytesValue) != "integration-key" {
t.Errorf("Key field mismatch: expected 'integration-key', got '%s'", string(keyValue.BytesValue))
}
} else {
t.Errorf("Key field is not BytesValue: %T", keyField.Kind)
}
valueField := unmarshaledRecord.Fields["value"]
if valueValue, ok := valueField.Kind.(*schema_pb.Value_StringValue); ok {
if valueValue.StringValue != "integration-value" {
t.Errorf("Value field mismatch: expected 'integration-value', got '%s'", valueValue.StringValue)
}
} else {
t.Errorf("Value field is not StringValue: %T", valueField.Kind)
}
}

View File

@@ -0,0 +1,212 @@
package broker
import (
"context"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer_client"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq/pub_balancer"
"github.com/seaweedfs/seaweedfs/weed/mq/sub_coordinator"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/cluster"
"github.com/seaweedfs/seaweedfs/weed/cluster/lock_manager"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"google.golang.org/grpc"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
)
type MessageQueueBrokerOption struct {
Masters map[string]pb.ServerAddress
FilerGroup string
DataCenter string
Rack string
DefaultReplication string
MaxMB int
Ip string
Port int
Cipher bool
VolumeServerAccess string // how to access volume servers
LogFlushInterval int // log buffer flush interval in seconds
}
func (option *MessageQueueBrokerOption) BrokerAddress() pb.ServerAddress {
return pb.NewServerAddress(option.Ip, option.Port, 0)
}
// topicCacheEntry caches both topic existence and configuration
// If conf is nil, topic doesn't exist (negative cache)
// If conf is non-nil, topic exists with this configuration (positive cache)
type topicCacheEntry struct {
conf *mq_pb.ConfigureTopicResponse // nil = topic doesn't exist
expiresAt time.Time
}
type MessageQueueBroker struct {
mq_pb.UnimplementedSeaweedMessagingServer
option *MessageQueueBrokerOption
grpcDialOption grpc.DialOption
MasterClient *wdclient.MasterClient
filers map[pb.ServerAddress]struct{}
currentFiler pb.ServerAddress
localTopicManager *topic.LocalTopicManager
PubBalancer *pub_balancer.PubBalancer
lockAsBalancer *cluster.LiveLock
// TODO: Add native offset management to broker
// ASSUMPTION: BrokerOffsetManager handles all partition offset assignment
offsetManager *BrokerOffsetManager
SubCoordinator *sub_coordinator.SubCoordinator
// Removed gatewayRegistry - no longer needed
accessLock sync.Mutex
fca *filer_client.FilerClientAccessor
// Unified topic cache for both existence and configuration
// Caches topic config (positive: conf != nil) and non-existence (negative: conf == nil)
// Eliminates 60% CPU overhead from repeated filer reads and JSON unmarshaling
topicCache map[string]*topicCacheEntry
topicCacheMu sync.RWMutex
topicCacheTTL time.Duration
}
func NewMessageBroker(option *MessageQueueBrokerOption, grpcDialOption grpc.DialOption) (mqBroker *MessageQueueBroker, err error) {
pubBalancer := pub_balancer.NewPubBalancer()
subCoordinator := sub_coordinator.NewSubCoordinator()
mqBroker = &MessageQueueBroker{
option: option,
grpcDialOption: grpcDialOption,
MasterClient: wdclient.NewMasterClient(grpcDialOption, option.FilerGroup, cluster.BrokerType, option.BrokerAddress(), option.DataCenter, option.Rack, *pb.NewServiceDiscoveryFromMap(option.Masters)),
filers: make(map[pb.ServerAddress]struct{}),
localTopicManager: topic.NewLocalTopicManager(),
PubBalancer: pubBalancer,
SubCoordinator: subCoordinator,
offsetManager: nil, // Will be initialized below
topicCache: make(map[string]*topicCacheEntry),
topicCacheTTL: 30 * time.Second, // Unified cache for existence + config (eliminates 60% CPU overhead)
}
// Create FilerClientAccessor that adapts broker's single filer to the new multi-filer interface
fca := &filer_client.FilerClientAccessor{
GetGrpcDialOption: mqBroker.GetGrpcDialOption,
GetFilers: func() []pb.ServerAddress {
filer := mqBroker.GetFiler()
if filer != "" {
return []pb.ServerAddress{filer}
}
return []pb.ServerAddress{}
},
}
mqBroker.fca = fca
subCoordinator.FilerClientAccessor = fca
mqBroker.MasterClient.SetOnPeerUpdateFn(mqBroker.OnBrokerUpdate)
pubBalancer.OnPartitionChange = mqBroker.SubCoordinator.OnPartitionChange
go mqBroker.MasterClient.KeepConnectedToMaster(context.Background())
// Initialize offset manager using the filer accessor
// The filer accessor will automatically use the current filer address as it gets discovered
// No hardcoded namespace/topic - offset storage now derives paths from actual topic information
mqBroker.offsetManager = NewBrokerOffsetManagerWithFilerAccessor(fca)
glog.V(0).Infof("broker initialized offset manager with filer accessor (current filer: %s)", mqBroker.GetFiler())
// Start idle partition cleanup task
// Cleans up partitions with no publishers/subscribers after 5 minutes of idle time
// Checks every 1 minute to avoid memory bloat from short-lived topics
mqBroker.localTopicManager.StartIdlePartitionCleanup(
context.Background(),
1*time.Minute, // Check interval
5*time.Minute, // Idle timeout - clean up after 5 minutes of no activity
)
glog.V(0).Info("Started idle partition cleanup task (check: 1m, timeout: 5m)")
existingNodes := cluster.ListExistingPeerUpdates(mqBroker.MasterClient.GetMaster(context.Background()), grpcDialOption, option.FilerGroup, cluster.FilerType)
for _, newNode := range existingNodes {
mqBroker.OnBrokerUpdate(newNode, time.Now())
}
// keep connecting to balancer
go func() {
for mqBroker.currentFiler == "" {
time.Sleep(time.Millisecond * 237)
}
self := option.BrokerAddress()
glog.V(0).Infof("broker %s found filer %s", self, mqBroker.currentFiler)
newBrokerBalancerCh := make(chan string, 1)
lockClient := cluster.NewLockClient(grpcDialOption, mqBroker.currentFiler)
mqBroker.lockAsBalancer = lockClient.StartLongLivedLock(pub_balancer.LockBrokerBalancer, string(self), func(newLockOwner string) {
glog.V(0).Infof("broker %s found balanacer %s", self, newLockOwner)
newBrokerBalancerCh <- newLockOwner
}, lock_manager.LiveLockTTL)
mqBroker.KeepConnectedToBrokerBalancer(newBrokerBalancerCh)
}()
return mqBroker, nil
}
func (b *MessageQueueBroker) OnBrokerUpdate(update *master_pb.ClusterNodeUpdate, startFrom time.Time) {
if update.NodeType != cluster.FilerType {
return
}
address := pb.ServerAddress(update.Address)
if update.IsAdd {
b.filers[address] = struct{}{}
if b.currentFiler == "" {
b.currentFiler = address
// The offset manager will automatically use the updated filer through the filer accessor
glog.V(0).Infof("broker discovered filer %s (offset manager will automatically use it via filer accessor)", address)
}
} else {
delete(b.filers, address)
if b.currentFiler == address {
for filer := range b.filers {
b.currentFiler = filer
// The offset manager will automatically use the new filer through the filer accessor
glog.V(0).Infof("broker switched to filer %s (offset manager will automatically use it)", filer)
break
}
}
}
}
func (b *MessageQueueBroker) GetGrpcDialOption() grpc.DialOption {
return b.grpcDialOption
}
func (b *MessageQueueBroker) GetFiler() pb.ServerAddress {
return b.currentFiler
}
func (b *MessageQueueBroker) WithFilerClient(streamingMode bool, fn func(filer_pb.SeaweedFilerClient) error) error {
return pb.WithFilerClient(streamingMode, 0, b.GetFiler(), b.grpcDialOption, fn)
}
func (b *MessageQueueBroker) AdjustedUrl(location *filer_pb.Location) string {
return location.Url
}
func (b *MessageQueueBroker) GetDataCenter() string {
return ""
}
func (b *MessageQueueBroker) withBrokerClient(streamingMode bool, server pb.ServerAddress, fn func(client mq_pb.SeaweedMessagingClient) error) error {
return pb.WithBrokerGrpcClient(streamingMode, server.String(), b.grpcDialOption, func(client mq_pb.SeaweedMessagingClient) error {
return fn(client)
})
}

View File

@@ -0,0 +1,383 @@
package broker
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq"
"github.com/seaweedfs/seaweedfs/weed/mq/logstore"
"github.com/seaweedfs/seaweedfs/weed/mq/pub_balancer"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
)
func (b *MessageQueueBroker) GetOrGenerateLocalPartition(t topic.Topic, partition topic.Partition) (localTopicPartition *topic.LocalPartition, getOrGenError error) {
// get or generate a local partition using cached topic config
conf, err := b.getTopicConfFromCache(t)
if err != nil {
glog.Errorf("topic %v not found: %v", t, err)
return nil, fmt.Errorf("topic %v not found: %w", t, err)
}
localTopicPartition, _, getOrGenError = b.doGetOrGenLocalPartition(t, partition, conf)
if getOrGenError != nil {
glog.Errorf("topic %v partition %v not setup: %v", t, partition, getOrGenError)
return nil, fmt.Errorf("topic %v partition %v not setup: %w", t, partition, getOrGenError)
}
return localTopicPartition, nil
}
// invalidateTopicCache removes a topic from the unified cache
// Should be called when a topic is created, deleted, or config is updated
func (b *MessageQueueBroker) invalidateTopicCache(t topic.Topic) {
topicKey := t.String()
b.topicCacheMu.Lock()
delete(b.topicCache, topicKey)
b.topicCacheMu.Unlock()
glog.V(4).Infof("Invalidated topic cache for %s", topicKey)
}
// getTopicConfFromCache reads topic configuration with caching
// Returns the config or error if not found. Uses unified cache to avoid expensive filer reads.
// On cache miss, validates broker assignments to ensure they're still active (14% CPU overhead).
// This is the public API for reading topic config - always use this instead of direct filer reads.
func (b *MessageQueueBroker) getTopicConfFromCache(t topic.Topic) (*mq_pb.ConfigureTopicResponse, error) {
topicKey := t.String()
// Check unified cache first
b.topicCacheMu.RLock()
if entry, found := b.topicCache[topicKey]; found {
if time.Now().Before(entry.expiresAt) {
conf := entry.conf
b.topicCacheMu.RUnlock()
// If conf is nil, topic was cached as non-existent
if conf == nil {
glog.V(4).Infof("Topic cache HIT for %s: topic doesn't exist", topicKey)
return nil, fmt.Errorf("topic %v not found (cached)", t)
}
glog.V(4).Infof("Topic cache HIT for %s (skipping assignment validation)", topicKey)
// Cache hit - return immediately without validating assignments
// Assignments were validated when we first cached this config
return conf, nil
}
}
b.topicCacheMu.RUnlock()
// Cache miss or expired - read from filer
glog.V(4).Infof("Topic cache MISS for %s, reading from filer", topicKey)
conf, readConfErr := b.fca.ReadTopicConfFromFiler(t)
if readConfErr != nil {
// Only negative-cache when the filer definitively reports topic.conf is
// missing. Transient errors (timeouts, connection blips) must not poison
// the cache, otherwise the topic appears to vanish for the full TTL even
// though it still exists on disk.
if errors.Is(readConfErr, filer_pb.ErrNotFound) {
b.topicCacheMu.Lock()
b.topicCache[topicKey] = &topicCacheEntry{
conf: nil,
expiresAt: time.Now().Add(b.topicCacheTTL),
}
b.topicCacheMu.Unlock()
glog.V(4).Infof("Topic cached as non-existent: %s", topicKey)
}
return nil, fmt.Errorf("topic %v not found: %w", t, readConfErr)
}
// Validate broker assignments before caching (NOT holding cache lock)
// This ensures cached configs always have valid broker assignments
// Only done on cache miss (not on every lookup), saving 14% CPU
glog.V(4).Infof("Validating broker assignments for %s", topicKey)
hasChanges := b.ensureTopicActiveAssignmentsUnsafe(t, conf)
if hasChanges {
glog.V(0).Infof("topic %v partition assignments updated due to broker changes", t)
// Save updated assignments to filer immediately to ensure persistence
if err := b.fca.SaveTopicConfToFiler(t, conf); err != nil {
glog.Errorf("failed to save updated topic config for %s: %v", topicKey, err)
// Don't cache on error - let next request retry
return conf, err
}
// CRITICAL FIX: Invalidate cache while holding lock to prevent race condition
// Before the fix, between checking the cache and invalidating it, another goroutine
// could read stale data. Now we hold the lock throughout.
b.topicCacheMu.Lock()
delete(b.topicCache, topicKey)
// Cache the updated config with validated assignments
b.topicCache[topicKey] = &topicCacheEntry{
conf: conf,
expiresAt: time.Now().Add(b.topicCacheTTL),
}
b.topicCacheMu.Unlock()
glog.V(4).Infof("Updated cache for %s after assignment update", topicKey)
return conf, nil
}
// Positive cache: topic exists with validated assignments
b.topicCacheMu.Lock()
b.topicCache[topicKey] = &topicCacheEntry{
conf: conf,
expiresAt: time.Now().Add(b.topicCacheTTL),
}
b.topicCacheMu.Unlock()
glog.V(4).Infof("Topic config cached for %s", topicKey)
return conf, nil
}
func (b *MessageQueueBroker) doGetOrGenLocalPartition(t topic.Topic, partition topic.Partition, conf *mq_pb.ConfigureTopicResponse) (localPartition *topic.LocalPartition, isGenerated bool, err error) {
b.accessLock.Lock()
defer b.accessLock.Unlock()
if localPartition = b.localTopicManager.GetLocalPartition(t, partition); localPartition == nil {
localPartition, isGenerated, err = b.genLocalPartitionFromFiler(t, partition, conf)
if err != nil {
return nil, false, err
}
}
return localPartition, isGenerated, nil
}
func (b *MessageQueueBroker) genLocalPartitionFromFiler(t topic.Topic, partition topic.Partition, conf *mq_pb.ConfigureTopicResponse) (localPartition *topic.LocalPartition, isGenerated bool, err error) {
self := b.option.BrokerAddress()
glog.V(4).Infof("genLocalPartitionFromFiler for %s %s, self=%s", t, partition, self)
glog.V(4).Infof("conf.BrokerPartitionAssignments: %v", conf.BrokerPartitionAssignments)
for _, assignment := range conf.BrokerPartitionAssignments {
assignmentPartition := topic.FromPbPartition(assignment.Partition)
glog.V(4).Infof("checking assignment: LeaderBroker=%s, Partition=%s", assignment.LeaderBroker, assignmentPartition)
glog.V(4).Infof("comparing self=%s with LeaderBroker=%s: %v", self, assignment.LeaderBroker, pb.ServerAddress(assignment.LeaderBroker).Equals(self))
glog.V(4).Infof("comparing partition=%s with assignmentPartition=%s: %v", partition.String(), assignmentPartition.String(), partition.Equals(assignmentPartition))
glog.V(4).Infof("logical comparison (RangeStart, RangeStop only): %v", partition.LogicalEquals(assignmentPartition))
glog.V(4).Infof("partition details: RangeStart=%d, RangeStop=%d, RingSize=%d, UnixTimeNs=%d", partition.RangeStart, partition.RangeStop, partition.RingSize, partition.UnixTimeNs)
glog.V(4).Infof("assignmentPartition details: RangeStart=%d, RangeStop=%d, RingSize=%d, UnixTimeNs=%d", assignmentPartition.RangeStart, assignmentPartition.RangeStop, assignmentPartition.RingSize, assignmentPartition.UnixTimeNs)
if pb.ServerAddress(assignment.LeaderBroker).Equals(self) && partition.LogicalEquals(assignmentPartition) {
glog.V(4).Infof("Creating local partition for %s %s", t, partition)
localPartition = topic.NewLocalPartition(partition, b.option.LogFlushInterval, b.genLogFlushFunc(t, partition), logstore.GenMergedReadFunc(b, t, partition))
// Initialize offset from existing data to ensure continuity on restart
b.initializePartitionOffsetFromExistingData(localPartition, t, partition)
b.localTopicManager.AddLocalPartition(t, localPartition)
isGenerated = true
glog.V(4).Infof("Successfully added local partition %s %s to localTopicManager", t, partition)
break
}
}
if !isGenerated {
glog.V(4).Infof("No matching assignment found for %s %s", t, partition)
}
return localPartition, isGenerated, nil
}
// ensureTopicActiveAssignmentsUnsafe validates that partition assignments reference active brokers
// Returns true if assignments were changed. Caller must save config to filer if hasChanges=true.
// Note: Assumes caller holds topicCacheMu lock or is OK with concurrent access to conf
func (b *MessageQueueBroker) ensureTopicActiveAssignmentsUnsafe(t topic.Topic, conf *mq_pb.ConfigureTopicResponse) (hasChanges bool) {
// also fix assignee broker if invalid
hasChanges = pub_balancer.EnsureAssignmentsToActiveBrokers(b.PubBalancer.Brokers, 1, conf.BrokerPartitionAssignments)
return hasChanges
}
func (b *MessageQueueBroker) ensureTopicActiveAssignments(t topic.Topic, conf *mq_pb.ConfigureTopicResponse) (err error) {
// Validate and save if needed
hasChanges := b.ensureTopicActiveAssignmentsUnsafe(t, conf)
if hasChanges {
glog.V(0).Infof("topic %v partition updated assignments: %v", t, conf.BrokerPartitionAssignments)
if err = b.fca.SaveTopicConfToFiler(t, conf); err != nil {
return err
}
}
return err
}
// initializePartitionOffsetFromExistingData initializes the LogBuffer offset from existing data on filer
// This ensures offset continuity when SMQ restarts
func (b *MessageQueueBroker) initializePartitionOffsetFromExistingData(localPartition *topic.LocalPartition, t topic.Topic, partition topic.Partition) {
// Create a function to get the highest existing offset from chunk metadata
getHighestOffsetFn := func() (int64, error) {
// Use the existing chunk metadata approach to find the highest offset
if b.fca == nil {
return -1, fmt.Errorf("no filer client accessor available")
}
// Use the same logic as getOffsetRangeFromChunkMetadata but only get the highest offset
_, highWaterMark, err := b.getOffsetRangeFromChunkMetadata(t, partition)
if err != nil {
return -1, err
}
// The high water mark is the next offset to be assigned, so the highest existing offset is hwm - 1
if highWaterMark > 0 {
return highWaterMark - 1, nil
}
return -1, nil // No existing data
}
// Initialize the LogBuffer offset from existing data
if err := localPartition.LogBuffer.InitializeOffsetFromExistingData(getHighestOffsetFn); err != nil {
glog.V(0).Infof("Failed to initialize offset for partition %s %s: %v", t, partition, err)
}
}
// getOffsetRangeFromChunkMetadata reads chunk metadata to find both earliest and latest offsets
func (b *MessageQueueBroker) getOffsetRangeFromChunkMetadata(t topic.Topic, partition topic.Partition) (earliestOffset int64, highWaterMark int64, err error) {
if b.fca == nil {
return 0, 0, fmt.Errorf("filer client accessor not available")
}
// Get the topic path and find the latest version
topicPath := fmt.Sprintf("/topics/%s/%s", t.Namespace, t.Name)
// First, list the topic versions to find the latest
var latestVersion string
err = b.fca.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{
Directory: topicPath,
})
if err != nil {
return err
}
for {
resp, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
if resp.Entry != nil && resp.Entry.IsDirectory && strings.HasPrefix(resp.Entry.Name, "v") {
if latestVersion == "" || resp.Entry.Name > latestVersion {
latestVersion = resp.Entry.Name
}
}
}
return nil
})
if err != nil {
return 0, 0, fmt.Errorf("failed to list topic versions: %v", err)
}
if latestVersion == "" {
glog.V(0).Infof("No version directory found for topic %s", t)
return 0, 0, nil
}
// Find the partition directory
versionPath := fmt.Sprintf("%s/%s", topicPath, latestVersion)
var partitionDir string
err = b.fca.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{
Directory: versionPath,
})
if err != nil {
return err
}
// Look for the partition directory that matches our partition range
targetPartitionName := fmt.Sprintf("%04d-%04d", partition.RangeStart, partition.RangeStop)
for {
resp, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
if resp.Entry != nil && resp.Entry.IsDirectory && resp.Entry.Name == targetPartitionName {
partitionDir = resp.Entry.Name
break
}
}
return nil
})
if err != nil {
return 0, 0, fmt.Errorf("failed to list partition directories: %v", err)
}
if partitionDir == "" {
glog.V(0).Infof("No partition directory found for topic %s partition %s", t, partition)
return 0, 0, nil
}
// Scan all message files to find the highest offset_max and lowest offset_min
partitionPath := fmt.Sprintf("%s/%s", versionPath, partitionDir)
highWaterMark = 0
earliestOffset = -1 // -1 indicates no data found yet
err = b.fca.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{
Directory: partitionPath,
})
if err != nil {
return err
}
for {
resp, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
if resp.Entry != nil && !resp.Entry.IsDirectory && resp.Entry.Name != "checkpoint.offset" {
// Check for offset ranges in Extended attributes (both log files and parquet files)
if resp.Entry.Extended != nil {
fileType := "log"
if strings.HasSuffix(resp.Entry.Name, ".parquet") {
fileType = "parquet"
}
// Track maximum offset for high water mark
if maxOffsetBytes, exists := resp.Entry.Extended[mq.ExtendedAttrOffsetMax]; exists && len(maxOffsetBytes) == 8 {
maxOffset := int64(binary.BigEndian.Uint64(maxOffsetBytes))
if maxOffset > highWaterMark {
highWaterMark = maxOffset
}
glog.V(2).Infof("%s file %s has offset_max=%d", fileType, resp.Entry.Name, maxOffset)
}
// Track minimum offset for earliest offset
if minOffsetBytes, exists := resp.Entry.Extended[mq.ExtendedAttrOffsetMin]; exists && len(minOffsetBytes) == 8 {
minOffset := int64(binary.BigEndian.Uint64(minOffsetBytes))
if earliestOffset == -1 || minOffset < earliestOffset {
earliestOffset = minOffset
}
glog.V(2).Infof("%s file %s has offset_min=%d", fileType, resp.Entry.Name, minOffset)
}
}
}
}
return nil
})
if err != nil {
return 0, 0, fmt.Errorf("failed to scan message files: %v", err)
}
// High water mark is the next offset after the highest written offset
if highWaterMark > 0 {
highWaterMark++
}
// If no data found, set earliest offset to 0
if earliestOffset == -1 {
earliestOffset = 0
}
glog.V(0).Infof("Offset range for topic %s partition %s: earliest=%d, highWaterMark=%d", t, partition, earliestOffset, highWaterMark)
return earliestOffset, highWaterMark, nil
}

View File

@@ -0,0 +1,53 @@
package broker
import (
"fmt"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq/topic"
"github.com/seaweedfs/seaweedfs/weed/util/log_buffer"
)
// LogBufferStart tracks the starting buffer offset for a live log file
// Buffer offsets are monotonically increasing, count = number of chunks
// Now stored in binary format for efficiency
type LogBufferStart struct {
StartIndex int64 // Starting buffer offset (count = len(chunks))
}
func (b *MessageQueueBroker) genLogFlushFunc(t topic.Topic, p topic.Partition) log_buffer.LogFlushFuncType {
partitionDir := topic.PartitionDir(t, p)
return func(logBuffer *log_buffer.LogBuffer, startTime, stopTime time.Time, buf []byte, minOffset, maxOffset int64) {
if len(buf) == 0 {
return
}
startTime, stopTime = startTime.UTC(), stopTime.UTC()
targetFile := fmt.Sprintf("%s/%s", partitionDir, startTime.Format(topic.TIME_FORMAT))
// Get buffer offset (sequential: 0, 1, 2, 3...)
bufferOffset := logBuffer.GetOffset()
for {
if err := b.appendToFileWithBufferIndex(targetFile, buf, bufferOffset, minOffset, maxOffset); err != nil {
glog.V(0).Infof("metadata log write failed %s: %v", targetFile, err)
time.Sleep(737 * time.Millisecond)
} else {
break
}
}
logBuffer.SetLastFlushTsNs(stopTime.UnixNano())
b.accessLock.Lock()
defer b.accessLock.Unlock()
if localPartition := b.localTopicManager.GetLocalPartition(t, p); localPartition != nil {
localPartition.NotifyLogFlushed(logBuffer.GetLastFlushTsNs())
}
glog.V(0).Infof("flushing at %d to %s size %d from buffer %s (offset %d)", logBuffer.GetLastFlushTsNs(), targetFile, len(buf), logBuffer.GetName(), bufferOffset)
}
}

View File

@@ -0,0 +1,188 @@
package broker
import (
"context"
"encoding/binary"
"fmt"
"os"
"time"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/mq"
"github.com/seaweedfs/seaweedfs/weed/operation"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/util"
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
)
func (b *MessageQueueBroker) appendToFile(targetFile string, data []byte) error {
return b.appendToFileWithBufferIndex(targetFile, data, 0)
}
func (b *MessageQueueBroker) appendToFileWithBufferIndex(targetFile string, data []byte, bufferOffset int64, offsetArgs ...int64) error {
// Extract optional offset parameters (minOffset, maxOffset)
var minOffset, maxOffset int64
if len(offsetArgs) >= 2 {
minOffset = offsetArgs[0]
maxOffset = offsetArgs[1]
}
fileId, uploadResult, err2 := b.assignAndUpload(targetFile, data)
if err2 != nil {
return err2
}
// find out existing entry
fullpath := util.FullPath(targetFile)
dir, name := fullpath.DirAndName()
entry, _, _, err := filer_pb.GetEntry(context.Background(), b, fullpath)
var offset int64 = 0
if err == filer_pb.ErrNotFound {
entry = &filer_pb.Entry{
Name: name,
IsDirectory: false,
Attributes: &filer_pb.FuseAttributes{
Crtime: time.Now().Unix(),
Mtime: time.Now().Unix(),
FileMode: uint32(os.FileMode(0644)),
Uid: uint32(os.Getuid()),
Gid: uint32(os.Getgid()),
},
}
// Add buffer start offset for deduplication tracking (binary format)
if bufferOffset != 0 {
entry.Extended = make(map[string][]byte)
bufferStartBytes := make([]byte, 8)
binary.BigEndian.PutUint64(bufferStartBytes, uint64(bufferOffset))
entry.Extended[mq.ExtendedAttrBufferStart] = bufferStartBytes
}
// Add offset range metadata for Kafka integration
if minOffset > 0 && maxOffset >= minOffset {
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
minOffsetBytes := make([]byte, 8)
binary.BigEndian.PutUint64(minOffsetBytes, uint64(minOffset))
entry.Extended[mq.ExtendedAttrOffsetMin] = minOffsetBytes
maxOffsetBytes := make([]byte, 8)
binary.BigEndian.PutUint64(maxOffsetBytes, uint64(maxOffset))
entry.Extended[mq.ExtendedAttrOffsetMax] = maxOffsetBytes
}
} else if err != nil {
return fmt.Errorf("find %s: %v", fullpath, err)
} else {
offset = int64(filer.TotalSize(entry.GetChunks()))
// Verify buffer offset continuity for existing files (append operations)
if bufferOffset != 0 {
if entry.Extended == nil {
entry.Extended = make(map[string][]byte)
}
// Check for existing buffer start (binary format)
if existingData, exists := entry.Extended[mq.ExtendedAttrBufferStart]; exists {
if len(existingData) == 8 {
existingStartIndex := int64(binary.BigEndian.Uint64(existingData))
// Verify that the new buffer offset is consecutive
// Expected offset = start + number of existing chunks
expectedOffset := existingStartIndex + int64(len(entry.GetChunks()))
if bufferOffset != expectedOffset {
// This shouldn't happen in normal operation
// Log warning but continue (don't crash the system)
glog.Warningf("non-consecutive buffer offset for %s. Expected %d, got %d",
fullpath, expectedOffset, bufferOffset)
}
// Note: We don't update the start offset - it stays the same
}
} else {
// No existing buffer start, create new one (shouldn't happen for existing files)
bufferStartBytes := make([]byte, 8)
binary.BigEndian.PutUint64(bufferStartBytes, uint64(bufferOffset))
entry.Extended[mq.ExtendedAttrBufferStart] = bufferStartBytes
}
}
// Update offset range metadata for existing files
if minOffset > 0 && maxOffset >= minOffset {
// Update minimum offset if this chunk has a lower minimum
if existingMinData, exists := entry.Extended[mq.ExtendedAttrOffsetMin]; exists && len(existingMinData) == 8 {
existingMin := int64(binary.BigEndian.Uint64(existingMinData))
if minOffset < existingMin {
minOffsetBytes := make([]byte, 8)
binary.BigEndian.PutUint64(minOffsetBytes, uint64(minOffset))
entry.Extended[mq.ExtendedAttrOffsetMin] = minOffsetBytes
}
} else {
// No existing minimum, set it
minOffsetBytes := make([]byte, 8)
binary.BigEndian.PutUint64(minOffsetBytes, uint64(minOffset))
entry.Extended[mq.ExtendedAttrOffsetMin] = minOffsetBytes
}
// Update maximum offset if this chunk has a higher maximum
if existingMaxData, exists := entry.Extended[mq.ExtendedAttrOffsetMax]; exists && len(existingMaxData) == 8 {
existingMax := int64(binary.BigEndian.Uint64(existingMaxData))
if maxOffset > existingMax {
maxOffsetBytes := make([]byte, 8)
binary.BigEndian.PutUint64(maxOffsetBytes, uint64(maxOffset))
entry.Extended[mq.ExtendedAttrOffsetMax] = maxOffsetBytes
}
} else {
// No existing maximum, set it
maxOffsetBytes := make([]byte, 8)
binary.BigEndian.PutUint64(maxOffsetBytes, uint64(maxOffset))
entry.Extended[mq.ExtendedAttrOffsetMax] = maxOffsetBytes
}
}
}
// append to existing chunks
entry.Chunks = append(entry.GetChunks(), uploadResult.ToPbFileChunk(fileId, offset, time.Now().UnixNano()))
// update the entry
return b.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return filer_pb.CreateEntry(context.Background(), client, &filer_pb.CreateEntryRequest{
Directory: dir,
Entry: entry,
})
})
}
func (b *MessageQueueBroker) assignAndUpload(targetFile string, data []byte) (fileId string, uploadResult *operation.UploadResult, err error) {
reader := util.NewBytesReader(data)
uploader, err := operation.NewUploader()
if err != nil {
return
}
uploadOption := &operation.UploadOption{
Cipher: b.option.Cipher,
}
if b.option.VolumeServerAccess == "filerProxy" {
// b.currentFiler can change on failover, so read it per attempt.
uploadOption.GenUploadUrl = func(host, fileId string) string {
return util_http.ProxyChunkUrl(string(b.currentFiler), fileId)
}
}
fileId, uploadResult, err, _ = uploader.UploadWithRetry(
b,
&filer_pb.AssignVolumeRequest{
Count: 1,
Replication: b.option.DefaultReplication,
Collection: "topics",
DataCenter: b.option.DataCenter,
Path: targetFile,
},
uploadOption,
reader,
)
return
}

View File

@@ -0,0 +1,199 @@
package broker
import (
"fmt"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/weed/mq/offset"
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
)
// recordEntry holds a record with timestamp for TTL cleanup
type recordEntry struct {
exists bool
timestamp time.Time
}
// InMemoryOffsetStorage provides an in-memory implementation of OffsetStorage for testing ONLY
// This is a copy of the implementation in weed/mq/offset/memory_storage_test.go
type InMemoryOffsetStorage struct {
mu sync.RWMutex
checkpoints map[string]int64 // partition key -> offset
records map[string]map[int64]*recordEntry // partition key -> offset -> entry with timestamp
// Memory leak protection
maxRecordsPerPartition int // Maximum records to keep per partition
recordTTL time.Duration // TTL for record entries
lastCleanup time.Time // Last cleanup time
cleanupInterval time.Duration // How often to run cleanup
}
// NewInMemoryOffsetStorage creates a new in-memory storage with memory leak protection
// FOR TESTING ONLY - do not use in production
func NewInMemoryOffsetStorage() *InMemoryOffsetStorage {
return &InMemoryOffsetStorage{
checkpoints: make(map[string]int64),
records: make(map[string]map[int64]*recordEntry),
maxRecordsPerPartition: 10000, // Limit to 10K records per partition
recordTTL: 1 * time.Hour, // Records expire after 1 hour
cleanupInterval: 5 * time.Minute, // Cleanup every 5 minutes
lastCleanup: time.Now(),
}
}
// SaveCheckpoint saves the checkpoint for a partition
func (s *InMemoryOffsetStorage) SaveCheckpoint(namespace, topicName string, partition *schema_pb.Partition, off int64) error {
s.mu.Lock()
defer s.mu.Unlock()
key := offset.PartitionKey(partition)
s.checkpoints[key] = off
return nil
}
// LoadCheckpoint loads the checkpoint for a partition
func (s *InMemoryOffsetStorage) LoadCheckpoint(namespace, topicName string, partition *schema_pb.Partition) (int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
key := offset.PartitionKey(partition)
off, exists := s.checkpoints[key]
if !exists {
return -1, fmt.Errorf("no checkpoint found")
}
return off, nil
}
// GetHighestOffset finds the highest offset in storage for a partition
func (s *InMemoryOffsetStorage) GetHighestOffset(namespace, topicName string, partition *schema_pb.Partition) (int64, error) {
s.mu.RLock()
defer s.mu.RUnlock()
key := offset.PartitionKey(partition)
offsets, exists := s.records[key]
if !exists || len(offsets) == 0 {
return -1, fmt.Errorf("no records found")
}
var highest int64 = -1
for off, entry := range offsets {
if entry.exists && off > highest {
highest = off
}
}
return highest, nil
}
// AddRecord simulates storing a record with an offset (for testing)
func (s *InMemoryOffsetStorage) AddRecord(partition *schema_pb.Partition, off int64) {
s.mu.Lock()
defer s.mu.Unlock()
key := offset.PartitionKey(partition)
if s.records[key] == nil {
s.records[key] = make(map[int64]*recordEntry)
}
// Add record with current timestamp
s.records[key][off] = &recordEntry{
exists: true,
timestamp: time.Now(),
}
// Trigger cleanup if needed (memory leak protection)
s.cleanupIfNeeded()
}
// Reset removes all data (implements resettable interface for shutdown)
func (s *InMemoryOffsetStorage) Reset() error {
s.mu.Lock()
defer s.mu.Unlock()
s.checkpoints = make(map[string]int64)
s.records = make(map[string]map[int64]*recordEntry)
s.lastCleanup = time.Now()
return nil
}
// cleanupIfNeeded performs memory leak protection cleanup
// This method assumes the caller already holds the write lock
func (s *InMemoryOffsetStorage) cleanupIfNeeded() {
now := time.Now()
// Only cleanup if enough time has passed
if now.Sub(s.lastCleanup) < s.cleanupInterval {
return
}
s.lastCleanup = now
cutoff := now.Add(-s.recordTTL)
// Clean up expired records and enforce size limits
for partitionKey, offsets := range s.records {
// Remove expired records
for offset, entry := range offsets {
if entry.timestamp.Before(cutoff) {
delete(offsets, offset)
}
}
// Enforce size limit per partition
if len(offsets) > s.maxRecordsPerPartition {
// Keep only the most recent records
type offsetTime struct {
offset int64
time time.Time
}
var entries []offsetTime
for offset, entry := range offsets {
entries = append(entries, offsetTime{offset: offset, time: entry.timestamp})
}
// Sort by timestamp (newest first)
for i := 0; i < len(entries)-1; i++ {
for j := i + 1; j < len(entries); j++ {
if entries[i].time.Before(entries[j].time) {
entries[i], entries[j] = entries[j], entries[i]
}
}
}
// Keep only the newest maxRecordsPerPartition entries
newOffsets := make(map[int64]*recordEntry)
for i := 0; i < s.maxRecordsPerPartition && i < len(entries); i++ {
offset := entries[i].offset
newOffsets[offset] = offsets[offset]
}
s.records[partitionKey] = newOffsets
}
// Remove empty partition maps
if len(offsets) == 0 {
delete(s.records, partitionKey)
}
}
}
// NewInMemoryOffsetStorageForTesting creates an InMemoryOffsetStorage for testing purposes
func NewInMemoryOffsetStorageForTesting() offset.OffsetStorage {
return NewInMemoryOffsetStorage()
}
// NewBrokerOffsetManagerWithStorage creates a new broker offset manager with custom storage
// FOR TESTING ONLY - moved from production code since it's only used in tests
func NewBrokerOffsetManagerWithStorage(storage offset.OffsetStorage) *BrokerOffsetManager {
if storage == nil {
panic("BrokerOffsetManager requires a storage implementation. Use NewBrokerOffsetManagerWithFiler() or provide FilerOffsetStorage/SQLOffsetStorage. InMemoryOffsetStorage is only for testing.")
}
return &BrokerOffsetManager{
offsetIntegration: offset.NewSMQOffsetIntegration(storage),
storage: storage,
consumerGroupStorage: nil, // Will be set separately if needed
}
}