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
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:
103
weed/mq/broker/broker_connect.go
Normal file
103
weed/mq/broker/broker_connect.go
Normal 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:
|
||||
}
|
||||
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
127
weed/mq/broker/broker_errors.go
Normal file
127
weed/mq/broker/broker_errors.go
Normal 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
|
||||
}
|
||||
24
weed/mq/broker/broker_grpc_admin.go
Normal file
24
weed/mq/broker/broker_grpc_admin.go
Normal 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
|
||||
}
|
||||
106
weed/mq/broker/broker_grpc_assign.go
Normal file
106
weed/mq/broker/broker_grpc_assign.go
Normal 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
|
||||
}
|
||||
28
weed/mq/broker/broker_grpc_balance.go
Normal file
28
weed/mq/broker/broker_grpc_balance.go
Normal 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
|
||||
}
|
||||
145
weed/mq/broker/broker_grpc_configure.go
Normal file
145
weed/mq/broker/broker_grpc_configure.go
Normal 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
|
||||
}
|
||||
164
weed/mq/broker/broker_grpc_fetch.go
Normal file
164
weed/mq/broker/broker_grpc_fetch.go
Normal 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
|
||||
}
|
||||
441
weed/mq/broker/broker_grpc_lookup.go
Normal file
441
weed/mq/broker/broker_grpc_lookup.go
Normal 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())
|
||||
}
|
||||
296
weed/mq/broker/broker_grpc_pub.go
Normal file
296
weed/mq/broker/broker_grpc_pub.go
Normal 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
|
||||
}
|
||||
48
weed/mq/broker/broker_grpc_pub_balancer.go
Normal file
48
weed/mq/broker/broker_grpc_pub_balancer.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
152
weed/mq/broker/broker_grpc_pub_follow.go
Normal file
152
weed/mq/broker/broker_grpc_pub_follow.go
Normal 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
|
||||
}
|
||||
351
weed/mq/broker/broker_grpc_query.go
Normal file
351
weed/mq/broker/broker_grpc_query.go
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
422
weed/mq/broker/broker_grpc_sub.go
Normal file
422
weed/mq/broker/broker_grpc_sub.go
Normal 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
|
||||
}
|
||||
92
weed/mq/broker/broker_grpc_sub_coordinator.go
Normal file
92
weed/mq/broker/broker_grpc_sub_coordinator.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
73
weed/mq/broker/broker_grpc_sub_follow.go
Normal file
73
weed/mq/broker/broker_grpc_sub_follow.go
Normal 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)
|
||||
}
|
||||
253
weed/mq/broker/broker_grpc_sub_offset.go
Normal file
253
weed/mq/broker/broker_grpc_sub_offset.go
Normal 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
|
||||
}
|
||||
}
|
||||
707
weed/mq/broker/broker_grpc_sub_offset_test.go
Normal file
707
weed/mq/broker/broker_grpc_sub_offset_test.go
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
29
weed/mq/broker/broker_grpc_topic_partition_control.go
Normal file
29
weed/mq/broker/broker_grpc_topic_partition_control.go
Normal 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
|
||||
}
|
||||
152
weed/mq/broker/broker_log_buffer_offset.go
Normal file
152
weed/mq/broker/broker_log_buffer_offset.go
Normal 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,
|
||||
}
|
||||
}
|
||||
202
weed/mq/broker/broker_offset_manager.go
Normal file
202
weed/mq/broker/broker_offset_manager.go
Normal 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)
|
||||
}
|
||||
180
weed/mq/broker/broker_recordvalue_test.go
Normal file
180
weed/mq/broker/broker_recordvalue_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
212
weed/mq/broker/broker_server.go
Normal file
212
weed/mq/broker/broker_server.go
Normal 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)
|
||||
})
|
||||
|
||||
}
|
||||
383
weed/mq/broker/broker_topic_conf_read_write.go
Normal file
383
weed/mq/broker/broker_topic_conf_read_write.go
Normal 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
|
||||
}
|
||||
53
weed/mq/broker/broker_topic_partition_read_write.go
Normal file
53
weed/mq/broker/broker_topic_partition_read_write.go
Normal 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)
|
||||
}
|
||||
}
|
||||
188
weed/mq/broker/broker_write.go
Normal file
188
weed/mq/broker/broker_write.go
Normal 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
|
||||
}
|
||||
199
weed/mq/broker/memory_storage_test.go
Normal file
199
weed/mq/broker/memory_storage_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user