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:
477
weed/mq/kafka/integration/broker_client.go
Normal file
477
weed/mq/kafka/integration/broker_client.go
Normal file
@@ -0,0 +1,477 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer_client"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/mq"
|
||||
"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/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
const (
|
||||
brokerHealthCheckTimeout = 5 * time.Second
|
||||
brokerHealthCheckAttempts = 3
|
||||
brokerHealthCheckBackoff = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// NewBrokerClientWithFilerAccessor creates a client with a shared filer accessor
|
||||
func NewBrokerClientWithFilerAccessor(brokerAddress string, filerClientAccessor *filer_client.FilerClientAccessor) (*BrokerClient, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Use background context for gRPC connections to prevent them from being canceled
|
||||
// when BrokerClient.Close() is called. This allows subscriber streams to continue
|
||||
// operating even during client shutdown, which is important for testing scenarios.
|
||||
dialCtx := context.Background()
|
||||
|
||||
// CRITICAL FIX: Add timeout to dial context
|
||||
// gRPC dial will retry with exponential backoff. Without a timeout, it hangs indefinitely
|
||||
// if the broker is unreachable. Set a reasonable timeout for initial connection attempt.
|
||||
dialCtx, dialCancel := context.WithTimeout(dialCtx, 30*time.Second)
|
||||
defer dialCancel()
|
||||
|
||||
// Connect to broker
|
||||
// Load security configuration for broker connection
|
||||
util.LoadSecurityConfiguration()
|
||||
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.mq")
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, brokerAddress,
|
||||
grpcDialOption,
|
||||
)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("failed to connect to broker %s: %v", brokerAddress, err)
|
||||
}
|
||||
|
||||
client := mq_pb.NewSeaweedMessagingClient(conn)
|
||||
|
||||
return &BrokerClient{
|
||||
filerClientAccessor: filerClientAccessor,
|
||||
brokerAddress: brokerAddress,
|
||||
conn: conn,
|
||||
client: client,
|
||||
publishers: make(map[string]*BrokerPublisherSession),
|
||||
subscribers: make(map[string]*BrokerSubscriberSession),
|
||||
fetchRequests: make(map[string]*FetchRequest),
|
||||
partitionAssignmentCache: make(map[string]*partitionAssignmentCacheEntry),
|
||||
partitionAssignmentCacheTTL: 30 * time.Second, // Same as broker's cache TTL
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close shuts down the broker client and all streams
|
||||
func (bc *BrokerClient) Close() error {
|
||||
bc.cancel()
|
||||
|
||||
// Close all publisher streams
|
||||
bc.publishersLock.Lock()
|
||||
for key, session := range bc.publishers {
|
||||
if session.Stream != nil {
|
||||
_ = session.Stream.CloseSend()
|
||||
}
|
||||
delete(bc.publishers, key)
|
||||
}
|
||||
bc.publishersLock.Unlock()
|
||||
|
||||
// Close all subscriber streams
|
||||
bc.subscribersLock.Lock()
|
||||
for key, session := range bc.subscribers {
|
||||
if session.Stream != nil {
|
||||
_ = session.Stream.CloseSend()
|
||||
}
|
||||
if session.Cancel != nil {
|
||||
session.Cancel()
|
||||
}
|
||||
delete(bc.subscribers, key)
|
||||
}
|
||||
bc.subscribersLock.Unlock()
|
||||
|
||||
return bc.conn.Close()
|
||||
}
|
||||
|
||||
// HealthCheck verifies the broker connection is working
|
||||
func (bc *BrokerClient) HealthCheck() error {
|
||||
if bc.client == nil {
|
||||
return fmt.Errorf("broker client not connected")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= brokerHealthCheckAttempts; attempt++ {
|
||||
ctx, cancel := context.WithTimeout(bc.ctx, brokerHealthCheckTimeout)
|
||||
_, err := bc.client.FindBrokerLeader(ctx, &mq_pb.FindBrokerLeaderRequest{})
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if attempt == brokerHealthCheckAttempts {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-bc.ctx.Done():
|
||||
return bc.ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * brokerHealthCheckBackoff):
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("broker health check failed after %d attempts: %v", brokerHealthCheckAttempts, lastErr)
|
||||
}
|
||||
|
||||
// GetPartitionRangeInfo gets comprehensive range information from SeaweedMQ broker's native range manager
|
||||
func (bc *BrokerClient) GetPartitionRangeInfo(topic string, partition int32) (*PartitionRangeInfo, error) {
|
||||
|
||||
if bc.client == nil {
|
||||
return nil, fmt.Errorf("broker client not connected")
|
||||
}
|
||||
|
||||
// Get the actual partition assignment from the broker instead of hardcoding
|
||||
pbTopic := &schema_pb.Topic{
|
||||
Namespace: "kafka",
|
||||
Name: topic,
|
||||
}
|
||||
|
||||
// Get the actual partition assignment for this Kafka partition
|
||||
actualPartition, err := bc.getActualPartitionAssignment(topic, partition)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get actual partition assignment: %v", err)
|
||||
}
|
||||
|
||||
// Call the broker's gRPC method
|
||||
resp, err := bc.client.GetPartitionRangeInfo(context.Background(), &mq_pb.GetPartitionRangeInfoRequest{
|
||||
Topic: pbTopic,
|
||||
Partition: actualPartition,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get partition range info from broker: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
return nil, fmt.Errorf("broker error: %s", resp.Error)
|
||||
}
|
||||
|
||||
// Extract offset range information
|
||||
var earliestOffset, latestOffset, highWaterMark int64
|
||||
if resp.OffsetRange != nil {
|
||||
earliestOffset = resp.OffsetRange.EarliestOffset
|
||||
latestOffset = resp.OffsetRange.LatestOffset
|
||||
highWaterMark = resp.OffsetRange.HighWaterMark
|
||||
}
|
||||
|
||||
// Extract timestamp range information
|
||||
var earliestTimestampNs, latestTimestampNs int64
|
||||
if resp.TimestampRange != nil {
|
||||
earliestTimestampNs = resp.TimestampRange.EarliestTimestampNs
|
||||
latestTimestampNs = resp.TimestampRange.LatestTimestampNs
|
||||
}
|
||||
|
||||
info := &PartitionRangeInfo{
|
||||
EarliestOffset: earliestOffset,
|
||||
LatestOffset: latestOffset,
|
||||
HighWaterMark: highWaterMark,
|
||||
EarliestTimestampNs: earliestTimestampNs,
|
||||
LatestTimestampNs: latestTimestampNs,
|
||||
RecordCount: resp.RecordCount,
|
||||
ActiveSubscriptions: resp.ActiveSubscriptions,
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// GetHighWaterMark gets the high water mark for a topic partition
|
||||
func (bc *BrokerClient) GetHighWaterMark(topic string, partition int32) (int64, error) {
|
||||
|
||||
// Primary approach: Use SeaweedMQ's native range manager via gRPC
|
||||
info, err := bc.GetPartitionRangeInfo(topic, partition)
|
||||
if err != nil {
|
||||
// Fallback to chunk metadata approach
|
||||
highWaterMark, err := bc.getHighWaterMarkFromChunkMetadata(topic, partition)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return highWaterMark, nil
|
||||
}
|
||||
|
||||
return info.HighWaterMark, nil
|
||||
}
|
||||
|
||||
// GetEarliestOffset gets the earliest offset from SeaweedMQ broker's native offset manager
|
||||
func (bc *BrokerClient) GetEarliestOffset(topic string, partition int32) (int64, error) {
|
||||
|
||||
// Primary approach: Use SeaweedMQ's native range manager via gRPC
|
||||
info, err := bc.GetPartitionRangeInfo(topic, partition)
|
||||
if err != nil {
|
||||
// Fallback to chunk metadata approach
|
||||
earliestOffset, err := bc.getEarliestOffsetFromChunkMetadata(topic, partition)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return earliestOffset, nil
|
||||
}
|
||||
|
||||
return info.EarliestOffset, nil
|
||||
}
|
||||
|
||||
// getOffsetRangeFromChunkMetadata reads chunk metadata to find both earliest and latest offsets
|
||||
func (bc *BrokerClient) getOffsetRangeFromChunkMetadata(topic string, partition int32) (earliestOffset int64, highWaterMark int64, err error) {
|
||||
if bc.filerClientAccessor == nil {
|
||||
return 0, 0, fmt.Errorf("filer client not available")
|
||||
}
|
||||
|
||||
// Get the topic path and find the latest version
|
||||
topicPath := fmt.Sprintf("/topics/kafka/%s", topic)
|
||||
|
||||
// First, list the topic versions to find the latest
|
||||
var latestVersion string
|
||||
err = bc.filerClientAccessor.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 == "" {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
// Find the partition directory
|
||||
versionPath := fmt.Sprintf("%s/%s", topicPath, latestVersion)
|
||||
var partitionDir string
|
||||
err = bc.filerClientAccessor.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{
|
||||
Directory: versionPath,
|
||||
})
|
||||
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.Contains(resp.Entry.Name, "-") {
|
||||
// Parse partition range (format: NNNN-NNNN) and match requested partition
|
||||
var pStart, pStop int32
|
||||
if n, scanErr := fmt.Sscanf(resp.Entry.Name, "%04d-%04d", &pStart, &pStop); n == 2 && scanErr == nil {
|
||||
if partition >= pStart && partition < pStop {
|
||||
partitionDir = resp.Entry.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to list partition directories: %v", err)
|
||||
}
|
||||
|
||||
if partitionDir == "" {
|
||||
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 = bc.filerClientAccessor.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 {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
return earliestOffset, highWaterMark, nil
|
||||
}
|
||||
|
||||
// getHighWaterMarkFromChunkMetadata is a wrapper for backward compatibility
|
||||
func (bc *BrokerClient) getHighWaterMarkFromChunkMetadata(topic string, partition int32) (int64, error) {
|
||||
_, highWaterMark, err := bc.getOffsetRangeFromChunkMetadata(topic, partition)
|
||||
return highWaterMark, err
|
||||
}
|
||||
|
||||
// getEarliestOffsetFromChunkMetadata gets the earliest offset from chunk metadata (fallback)
|
||||
func (bc *BrokerClient) getEarliestOffsetFromChunkMetadata(topic string, partition int32) (int64, error) {
|
||||
earliestOffset, _, err := bc.getOffsetRangeFromChunkMetadata(topic, partition)
|
||||
return earliestOffset, err
|
||||
}
|
||||
|
||||
// GetFilerAddress returns the first filer address used by this broker client (for backward compatibility)
|
||||
func (bc *BrokerClient) GetFilerAddress() string {
|
||||
if bc.filerClientAccessor != nil && bc.filerClientAccessor.GetFilers != nil {
|
||||
filers := bc.filerClientAccessor.GetFilers()
|
||||
if len(filers) > 0 {
|
||||
return string(filers[0])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Delegate methods to the shared filer client accessor
|
||||
func (bc *BrokerClient) WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
|
||||
return bc.filerClientAccessor.WithFilerClient(streamingMode, fn)
|
||||
}
|
||||
|
||||
func (bc *BrokerClient) GetFilers() []pb.ServerAddress {
|
||||
return bc.filerClientAccessor.GetFilers()
|
||||
}
|
||||
|
||||
func (bc *BrokerClient) GetGrpcDialOption() grpc.DialOption {
|
||||
return bc.filerClientAccessor.GetGrpcDialOption()
|
||||
}
|
||||
|
||||
// ListTopics gets all topics from SeaweedMQ broker (includes in-memory topics)
|
||||
func (bc *BrokerClient) ListTopics() ([]string, error) {
|
||||
if bc.client == nil {
|
||||
return nil, fmt.Errorf("broker client not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(bc.ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := bc.client.ListTopics(ctx, &mq_pb.ListTopicsRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list topics from broker: %v", err)
|
||||
}
|
||||
|
||||
var topics []string
|
||||
for _, topic := range resp.Topics {
|
||||
// Filter for kafka namespace topics
|
||||
if topic.Namespace == "kafka" {
|
||||
topics = append(topics, topic.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return topics, nil
|
||||
}
|
||||
|
||||
// GetTopicConfiguration gets topic configuration including partition count from the broker
|
||||
func (bc *BrokerClient) GetTopicConfiguration(topicName string) (*mq_pb.GetTopicConfigurationResponse, error) {
|
||||
if bc.client == nil {
|
||||
return nil, fmt.Errorf("broker client not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(bc.ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := bc.client.GetTopicConfiguration(ctx, &mq_pb.GetTopicConfigurationRequest{
|
||||
Topic: &schema_pb.Topic{
|
||||
Namespace: "kafka",
|
||||
Name: topicName,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get topic configuration from broker: %v", err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// TopicExists checks if a topic exists in SeaweedMQ broker (includes in-memory topics)
|
||||
func (bc *BrokerClient) TopicExists(topicName string) (bool, error) {
|
||||
if bc.client == nil {
|
||||
return false, fmt.Errorf("broker client not connected")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(bc.ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
glog.V(2).Infof("[BrokerClient] TopicExists: Querying broker for topic %s", topicName)
|
||||
resp, err := bc.client.TopicExists(ctx, &mq_pb.TopicExistsRequest{
|
||||
Topic: &schema_pb.Topic{
|
||||
Namespace: "kafka",
|
||||
Name: topicName,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
glog.V(1).Infof("[BrokerClient] TopicExists: ERROR for topic %s: %v", topicName, err)
|
||||
return false, fmt.Errorf("failed to check topic existence: %v", err)
|
||||
}
|
||||
|
||||
glog.V(2).Infof("[BrokerClient] TopicExists: Topic %s exists=%v", topicName, resp.Exists)
|
||||
return resp.Exists, nil
|
||||
}
|
||||
188
weed/mq/kafka/integration/broker_client_fetch.go
Normal file
188
weed/mq/kafka/integration/broker_client_fetch.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
|
||||
)
|
||||
|
||||
// FetchMessagesStateless fetches messages using the Kafka-style stateless FetchMessage RPC
|
||||
// This is the long-term solution that eliminates all Subscribe loop complexity
|
||||
//
|
||||
// Benefits over SubscribeMessage:
|
||||
// 1. No broker-side session state
|
||||
// 2. No shared Subscribe loops
|
||||
// 3. No stream corruption from concurrent seeks
|
||||
// 4. Simple request/response pattern
|
||||
// 5. Natural support for concurrent reads
|
||||
//
|
||||
// This is how Kafka works - completely stateless per-fetch
|
||||
func (bc *BrokerClient) FetchMessagesStateless(ctx context.Context, topic string, partition int32, startOffset int64, maxRecords int, consumerGroup string, consumerID string) ([]*SeaweedRecord, error) {
|
||||
glog.V(4).Infof("[FETCH-STATELESS] Fetching from %s-%d at offset %d, maxRecords=%d",
|
||||
topic, partition, startOffset, maxRecords)
|
||||
|
||||
// Get actual partition assignment from broker
|
||||
actualPartition, err := bc.getActualPartitionAssignment(topic, partition)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get partition assignment: %v", err)
|
||||
}
|
||||
|
||||
// Create FetchMessage request
|
||||
req := &mq_pb.FetchMessageRequest{
|
||||
Topic: &schema_pb.Topic{
|
||||
Namespace: "kafka", // Kafka gateway always uses "kafka" namespace
|
||||
Name: topic,
|
||||
},
|
||||
Partition: actualPartition,
|
||||
StartOffset: startOffset,
|
||||
MaxMessages: int32(maxRecords),
|
||||
MaxBytes: 4 * 1024 * 1024, // 4MB default
|
||||
MaxWaitMs: 100, // 100ms wait for data (long poll)
|
||||
MinBytes: 0, // Return immediately if any data available
|
||||
ConsumerGroup: consumerGroup,
|
||||
ConsumerId: consumerID,
|
||||
}
|
||||
|
||||
// Get timeout from context (set by Kafka fetch request)
|
||||
// This respects the client's MaxWaitTime
|
||||
// Note: We use a default of 100ms above, but if context has shorter timeout, use that
|
||||
|
||||
// Call FetchMessage RPC (simple request/response)
|
||||
resp, err := bc.client.FetchMessage(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FetchMessage RPC failed: %v", err)
|
||||
}
|
||||
|
||||
// Check for errors in response
|
||||
if resp.Error != "" {
|
||||
// Check if this is an "offset out of range" error
|
||||
if resp.ErrorCode == 2 && resp.LogStartOffset > 0 && startOffset < resp.LogStartOffset {
|
||||
// Offset too old - broker suggests starting from LogStartOffset
|
||||
glog.V(3).Infof("[FETCH-STATELESS-CLIENT] Requested offset %d too old, adjusting to log start %d",
|
||||
startOffset, resp.LogStartOffset)
|
||||
|
||||
// Retry with adjusted offset
|
||||
req.StartOffset = resp.LogStartOffset
|
||||
resp, err = bc.client.FetchMessage(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FetchMessage RPC failed on retry: %v", err)
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return nil, fmt.Errorf("broker error on retry: %s (code=%d)", resp.Error, resp.ErrorCode)
|
||||
}
|
||||
// Continue with adjusted offset response
|
||||
startOffset = resp.LogStartOffset
|
||||
} else {
|
||||
return nil, fmt.Errorf("broker error: %s (code=%d)", resp.Error, resp.ErrorCode)
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: If broker returns 0 messages but hwm > startOffset, something is wrong
|
||||
if len(resp.Messages) == 0 && resp.HighWaterMark > startOffset {
|
||||
glog.Errorf("[FETCH-STATELESS-CLIENT] CRITICAL BUG: Broker returned 0 messages for %s[%d] offset %d, but HWM=%d (should have %d messages available)",
|
||||
topic, partition, startOffset, resp.HighWaterMark, resp.HighWaterMark-startOffset)
|
||||
glog.Errorf("[FETCH-STATELESS-CLIENT] This suggests broker's FetchMessage RPC is not returning data that exists!")
|
||||
glog.Errorf("[FETCH-STATELESS-CLIENT] Broker metadata: logStart=%d, nextOffset=%d, endOfPartition=%v",
|
||||
resp.LogStartOffset, resp.NextOffset, resp.EndOfPartition)
|
||||
}
|
||||
|
||||
// Convert protobuf messages to SeaweedRecord
|
||||
records := make([]*SeaweedRecord, 0, len(resp.Messages))
|
||||
for i, msg := range resp.Messages {
|
||||
record := &SeaweedRecord{
|
||||
Key: msg.Key,
|
||||
Value: msg.Value,
|
||||
Timestamp: msg.TsNs,
|
||||
Offset: startOffset + int64(i), // Sequential offset assignment
|
||||
}
|
||||
records = append(records, record)
|
||||
|
||||
// Log each message for debugging
|
||||
glog.V(4).Infof("[FETCH-STATELESS-CLIENT] Message %d: offset=%d, keyLen=%d, valueLen=%d",
|
||||
i, record.Offset, len(msg.Key), len(msg.Value))
|
||||
}
|
||||
|
||||
if len(records) > 0 {
|
||||
glog.V(3).Infof("[FETCH-STATELESS-CLIENT] Converted to %d SeaweedRecords, first offset=%d, last offset=%d",
|
||||
len(records), records[0].Offset, records[len(records)-1].Offset)
|
||||
} else {
|
||||
glog.V(3).Infof("[FETCH-STATELESS-CLIENT] Converted to 0 SeaweedRecords")
|
||||
}
|
||||
|
||||
glog.V(4).Infof("[FETCH-STATELESS] Fetched %d records, nextOffset=%d, highWaterMark=%d, endOfPartition=%v",
|
||||
len(records), resp.NextOffset, resp.HighWaterMark, resp.EndOfPartition)
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetPartitionHighWaterMark returns the highest offset available in a partition
|
||||
// This is useful for Kafka clients to track consumer lag
|
||||
func (bc *BrokerClient) GetPartitionHighWaterMark(ctx context.Context, topic string, partition int32) (int64, error) {
|
||||
// Use FetchMessage with 0 maxRecords to just get metadata
|
||||
actualPartition, err := bc.getActualPartitionAssignment(topic, partition)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get partition assignment: %v", err)
|
||||
}
|
||||
|
||||
req := &mq_pb.FetchMessageRequest{
|
||||
Topic: &schema_pb.Topic{
|
||||
Namespace: "kafka",
|
||||
Name: topic,
|
||||
},
|
||||
Partition: actualPartition,
|
||||
StartOffset: 0,
|
||||
MaxMessages: 0, // Just get metadata
|
||||
MaxBytes: 0,
|
||||
MaxWaitMs: 0, // Return immediately
|
||||
ConsumerGroup: "kafka-metadata",
|
||||
ConsumerId: "hwm-check",
|
||||
}
|
||||
|
||||
resp, err := bc.client.FetchMessage(ctx, req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("FetchMessage RPC failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
return 0, fmt.Errorf("broker error: %s", resp.Error)
|
||||
}
|
||||
|
||||
return resp.HighWaterMark, nil
|
||||
}
|
||||
|
||||
// GetPartitionLogStartOffset returns the earliest offset available in a partition
|
||||
// This is useful for Kafka clients to know the valid offset range
|
||||
func (bc *BrokerClient) GetPartitionLogStartOffset(ctx context.Context, topic string, partition int32) (int64, error) {
|
||||
actualPartition, err := bc.getActualPartitionAssignment(topic, partition)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get partition assignment: %v", err)
|
||||
}
|
||||
|
||||
req := &mq_pb.FetchMessageRequest{
|
||||
Topic: &schema_pb.Topic{
|
||||
Namespace: "kafka",
|
||||
Name: topic,
|
||||
},
|
||||
Partition: actualPartition,
|
||||
StartOffset: 0,
|
||||
MaxMessages: 0,
|
||||
MaxBytes: 0,
|
||||
MaxWaitMs: 0,
|
||||
ConsumerGroup: "kafka-metadata",
|
||||
ConsumerId: "lso-check",
|
||||
}
|
||||
|
||||
resp, err := bc.client.FetchMessage(ctx, req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("FetchMessage RPC failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.Error != "" {
|
||||
return 0, fmt.Errorf("broker error: %s", resp.Error)
|
||||
}
|
||||
|
||||
return resp.LogStartOffset, nil
|
||||
}
|
||||
399
weed/mq/kafka/integration/broker_client_publish.go
Normal file
399
weed/mq/kafka/integration/broker_client_publish.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/mq/pub_balancer"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
|
||||
)
|
||||
|
||||
// PublishRecord publishes a single record to SeaweedMQ broker
|
||||
// ctx controls the publish timeout - if client cancels, publish operation is cancelled
|
||||
func (bc *BrokerClient) PublishRecord(ctx context.Context, topic string, partition int32, key []byte, value []byte, timestamp int64) (int64, error) {
|
||||
// Check context before starting
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, fmt.Errorf("context cancelled before publish: %w", err)
|
||||
}
|
||||
|
||||
session, err := bc.getOrCreatePublisher(topic, partition)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if session.Stream == nil {
|
||||
return 0, fmt.Errorf("publisher session stream cannot be nil")
|
||||
}
|
||||
|
||||
// CRITICAL: Lock to prevent concurrent Send/Recv causing response mix-ups
|
||||
// Without this, two concurrent publishes can steal each other's offsets
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
|
||||
// Check context after acquiring lock
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, fmt.Errorf("context cancelled after lock: %w", err)
|
||||
}
|
||||
|
||||
// Send data message using broker API format
|
||||
dataMsg := &mq_pb.DataMessage{
|
||||
Key: key,
|
||||
Value: value,
|
||||
TsNs: timestamp,
|
||||
}
|
||||
|
||||
// DEBUG: Log message being published for GitHub Actions debugging
|
||||
valuePreview := ""
|
||||
if len(dataMsg.Value) > 0 {
|
||||
if len(dataMsg.Value) <= 50 {
|
||||
valuePreview = string(dataMsg.Value)
|
||||
} else {
|
||||
valuePreview = fmt.Sprintf("%s...(total %d bytes)", string(dataMsg.Value[:50]), len(dataMsg.Value))
|
||||
}
|
||||
} else {
|
||||
valuePreview = "<empty>"
|
||||
}
|
||||
glog.V(1).Infof("[PUBLISH] topic=%s partition=%d key=%s valueLen=%d valuePreview=%q timestamp=%d",
|
||||
topic, partition, string(key), len(value), valuePreview, timestamp)
|
||||
|
||||
// CRITICAL: Use a goroutine with context checking to enforce timeout
|
||||
// gRPC streams may not respect context deadlines automatically
|
||||
// We need to monitor the context and timeout the operation if needed
|
||||
sendErrChan := make(chan error, 1)
|
||||
go func() {
|
||||
sendErrChan <- session.Stream.Send(&mq_pb.PublishMessageRequest{
|
||||
Message: &mq_pb.PublishMessageRequest_Data{
|
||||
Data: dataMsg,
|
||||
},
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-sendErrChan:
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to send data: %v", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return 0, fmt.Errorf("context cancelled while sending: %w", ctx.Err())
|
||||
}
|
||||
|
||||
// Read acknowledgment with context timeout enforcement
|
||||
recvErrChan := make(chan interface{}, 1)
|
||||
go func() {
|
||||
resp, err := session.Stream.Recv()
|
||||
if err != nil {
|
||||
recvErrChan <- err
|
||||
} else {
|
||||
recvErrChan <- resp
|
||||
}
|
||||
}()
|
||||
|
||||
var resp *mq_pb.PublishMessageResponse
|
||||
select {
|
||||
case result := <-recvErrChan:
|
||||
if err, isErr := result.(error); isErr {
|
||||
return 0, fmt.Errorf("failed to receive ack: %v", err)
|
||||
}
|
||||
resp = result.(*mq_pb.PublishMessageResponse)
|
||||
case <-ctx.Done():
|
||||
return 0, fmt.Errorf("context cancelled while receiving: %w", ctx.Err())
|
||||
}
|
||||
|
||||
// Handle structured broker errors
|
||||
if kafkaErrorCode, errorMsg, handleErr := HandleBrokerResponse(resp); handleErr != nil {
|
||||
return 0, handleErr
|
||||
} else if kafkaErrorCode != 0 {
|
||||
// Return error with Kafka error code information for better debugging
|
||||
return 0, fmt.Errorf("broker error (Kafka code %d): %s", kafkaErrorCode, errorMsg)
|
||||
}
|
||||
|
||||
// Use the assigned offset from SMQ, not the timestamp
|
||||
glog.V(1).Infof("[PUBLISH_ACK] topic=%s partition=%d assignedOffset=%d", topic, partition, resp.AssignedOffset)
|
||||
return resp.AssignedOffset, nil
|
||||
}
|
||||
|
||||
// PublishRecordValue publishes a RecordValue message to SeaweedMQ via broker
|
||||
// ctx controls the publish timeout - if client cancels, publish operation is cancelled
|
||||
func (bc *BrokerClient) PublishRecordValue(ctx context.Context, topic string, partition int32, key []byte, recordValueBytes []byte, timestamp int64) (int64, error) {
|
||||
// Check context before starting
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, fmt.Errorf("context cancelled before publish: %w", err)
|
||||
}
|
||||
|
||||
session, err := bc.getOrCreatePublisher(topic, partition)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if session.Stream == nil {
|
||||
return 0, fmt.Errorf("publisher session stream cannot be nil")
|
||||
}
|
||||
|
||||
// CRITICAL: Lock to prevent concurrent Send/Recv causing response mix-ups
|
||||
session.mu.Lock()
|
||||
defer session.mu.Unlock()
|
||||
|
||||
// Check context after acquiring lock
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, fmt.Errorf("context cancelled after lock: %w", err)
|
||||
}
|
||||
|
||||
// Send data message with RecordValue in the Value field
|
||||
dataMsg := &mq_pb.DataMessage{
|
||||
Key: key,
|
||||
Value: recordValueBytes, // This contains the marshaled RecordValue
|
||||
TsNs: timestamp,
|
||||
}
|
||||
|
||||
if err := session.Stream.Send(&mq_pb.PublishMessageRequest{
|
||||
Message: &mq_pb.PublishMessageRequest_Data{
|
||||
Data: dataMsg,
|
||||
},
|
||||
}); err != nil {
|
||||
return 0, fmt.Errorf("failed to send RecordValue data: %v", err)
|
||||
}
|
||||
|
||||
// Read acknowledgment
|
||||
resp, err := session.Stream.Recv()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to receive RecordValue ack: %v", err)
|
||||
}
|
||||
|
||||
// Handle structured broker errors
|
||||
if kafkaErrorCode, errorMsg, handleErr := HandleBrokerResponse(resp); handleErr != nil {
|
||||
return 0, handleErr
|
||||
} else if kafkaErrorCode != 0 {
|
||||
// Return error with Kafka error code information for better debugging
|
||||
return 0, fmt.Errorf("RecordValue broker error (Kafka code %d): %s", kafkaErrorCode, errorMsg)
|
||||
}
|
||||
|
||||
// Use the assigned offset from SMQ, not the timestamp
|
||||
return resp.AssignedOffset, nil
|
||||
}
|
||||
|
||||
// getOrCreatePublisher gets or creates a publisher stream for a topic-partition
|
||||
func (bc *BrokerClient) getOrCreatePublisher(topic string, partition int32) (*BrokerPublisherSession, error) {
|
||||
key := fmt.Sprintf("%s-%d", topic, partition)
|
||||
|
||||
// Try to get existing publisher
|
||||
bc.publishersLock.RLock()
|
||||
if session, exists := bc.publishers[key]; exists {
|
||||
bc.publishersLock.RUnlock()
|
||||
return session, nil
|
||||
}
|
||||
bc.publishersLock.RUnlock()
|
||||
|
||||
// CRITICAL FIX: Prevent multiple concurrent attempts to create the same publisher
|
||||
// Use a creation lock that is specific to each topic-partition pair
|
||||
// This ensures only ONE goroutine tries to create/initialize for each publisher
|
||||
if bc.publisherCreationLocks == nil {
|
||||
bc.publishersLock.Lock()
|
||||
if bc.publisherCreationLocks == nil {
|
||||
bc.publisherCreationLocks = make(map[string]*sync.Mutex)
|
||||
}
|
||||
bc.publishersLock.Unlock()
|
||||
}
|
||||
|
||||
bc.publishersLock.RLock()
|
||||
creationLock, exists := bc.publisherCreationLocks[key]
|
||||
if !exists {
|
||||
// Need to create a creation lock for this topic-partition
|
||||
bc.publishersLock.RUnlock()
|
||||
bc.publishersLock.Lock()
|
||||
// Double-check if someone else created it
|
||||
if lock, exists := bc.publisherCreationLocks[key]; exists {
|
||||
creationLock = lock
|
||||
} else {
|
||||
creationLock = &sync.Mutex{}
|
||||
bc.publisherCreationLocks[key] = creationLock
|
||||
}
|
||||
bc.publishersLock.Unlock()
|
||||
} else {
|
||||
bc.publishersLock.RUnlock()
|
||||
}
|
||||
|
||||
// Acquire the creation lock - only ONE goroutine will proceed
|
||||
creationLock.Lock()
|
||||
defer creationLock.Unlock()
|
||||
|
||||
// Double-check if publisher was created while we were waiting for the lock
|
||||
bc.publishersLock.RLock()
|
||||
if session, exists := bc.publishers[key]; exists {
|
||||
bc.publishersLock.RUnlock()
|
||||
return session, nil
|
||||
}
|
||||
bc.publishersLock.RUnlock()
|
||||
|
||||
// Create the stream
|
||||
stream, err := bc.client.PublishMessage(bc.ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create publish stream: %v", err)
|
||||
}
|
||||
|
||||
// Get the actual partition assignment from the broker
|
||||
actualPartition, err := bc.getActualPartitionAssignment(topic, partition)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get actual partition assignment: %v", err)
|
||||
}
|
||||
|
||||
// Send init message
|
||||
if err := stream.Send(&mq_pb.PublishMessageRequest{
|
||||
Message: &mq_pb.PublishMessageRequest_Init{
|
||||
Init: &mq_pb.PublishMessageRequest_InitMessage{
|
||||
Topic: &schema_pb.Topic{
|
||||
Namespace: "kafka",
|
||||
Name: topic,
|
||||
},
|
||||
Partition: actualPartition,
|
||||
AckInterval: 1,
|
||||
PublisherName: "kafka-gateway",
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to send init message: %v", err)
|
||||
}
|
||||
|
||||
// Consume the "hello" message sent by broker after init
|
||||
helloResp, err := stream.Recv()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to receive hello message: %v", err)
|
||||
}
|
||||
if helloResp.ErrorCode != 0 {
|
||||
return nil, fmt.Errorf("broker init error (code %d): %s", helloResp.ErrorCode, helloResp.Error)
|
||||
}
|
||||
|
||||
session := &BrokerPublisherSession{
|
||||
Topic: topic,
|
||||
Partition: partition,
|
||||
Stream: stream,
|
||||
}
|
||||
|
||||
// Store in the map under the publishersLock
|
||||
bc.publishersLock.Lock()
|
||||
bc.publishers[key] = session
|
||||
bc.publishersLock.Unlock()
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// ClosePublisher closes a specific publisher session
|
||||
func (bc *BrokerClient) ClosePublisher(topic string, partition int32) error {
|
||||
key := fmt.Sprintf("%s-%d", topic, partition)
|
||||
|
||||
bc.publishersLock.Lock()
|
||||
defer bc.publishersLock.Unlock()
|
||||
|
||||
session, exists := bc.publishers[key]
|
||||
if !exists {
|
||||
return nil // Already closed or never existed
|
||||
}
|
||||
|
||||
if session.Stream != nil {
|
||||
session.Stream.CloseSend()
|
||||
}
|
||||
delete(bc.publishers, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getActualPartitionAssignment looks up the actual partition assignment from the broker configuration
|
||||
// Uses cache to avoid expensive LookupTopicBrokers calls on every fetch (13.5% CPU overhead!)
|
||||
func (bc *BrokerClient) getActualPartitionAssignment(topic string, kafkaPartition int32) (*schema_pb.Partition, error) {
|
||||
// Check cache first
|
||||
bc.partitionAssignmentCacheMu.RLock()
|
||||
if entry, found := bc.partitionAssignmentCache[topic]; found {
|
||||
if time.Now().Before(entry.expiresAt) {
|
||||
assignments := entry.assignments
|
||||
bc.partitionAssignmentCacheMu.RUnlock()
|
||||
glog.V(4).Infof("Partition assignment cache HIT for topic %s", topic)
|
||||
// Use cached assignments to find partition
|
||||
return bc.findPartitionInAssignments(topic, kafkaPartition, assignments)
|
||||
}
|
||||
}
|
||||
bc.partitionAssignmentCacheMu.RUnlock()
|
||||
|
||||
// Cache miss or expired - lookup from broker
|
||||
glog.V(4).Infof("Partition assignment cache MISS for topic %s, calling LookupTopicBrokers", topic)
|
||||
lookupResp, err := bc.client.LookupTopicBrokers(bc.ctx, &mq_pb.LookupTopicBrokersRequest{
|
||||
Topic: &schema_pb.Topic{
|
||||
Namespace: "kafka",
|
||||
Name: topic,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to lookup topic brokers: %v", err)
|
||||
}
|
||||
|
||||
if len(lookupResp.BrokerPartitionAssignments) == 0 {
|
||||
return nil, fmt.Errorf("no partition assignments found for topic %s", topic)
|
||||
}
|
||||
|
||||
// Cache the assignments
|
||||
bc.partitionAssignmentCacheMu.Lock()
|
||||
bc.partitionAssignmentCache[topic] = &partitionAssignmentCacheEntry{
|
||||
assignments: lookupResp.BrokerPartitionAssignments,
|
||||
expiresAt: time.Now().Add(bc.partitionAssignmentCacheTTL),
|
||||
}
|
||||
bc.partitionAssignmentCacheMu.Unlock()
|
||||
glog.V(4).Infof("Cached partition assignments for topic %s", topic)
|
||||
|
||||
// Use freshly fetched assignments to find partition
|
||||
return bc.findPartitionInAssignments(topic, kafkaPartition, lookupResp.BrokerPartitionAssignments)
|
||||
}
|
||||
|
||||
// findPartitionInAssignments finds the SeaweedFS partition for a given Kafka partition ID
|
||||
func (bc *BrokerClient) findPartitionInAssignments(topic string, kafkaPartition int32, assignments []*mq_pb.BrokerPartitionAssignment) (*schema_pb.Partition, error) {
|
||||
totalPartitions := int32(len(assignments))
|
||||
if kafkaPartition >= totalPartitions {
|
||||
return nil, fmt.Errorf("kafka partition %d out of range, topic %s has %d partitions",
|
||||
kafkaPartition, topic, totalPartitions)
|
||||
}
|
||||
|
||||
// Calculate expected range for this Kafka partition based on actual partition count
|
||||
// Ring is divided equally among partitions, with last partition getting any remainder
|
||||
rangeSize := int32(pub_balancer.MaxPartitionCount) / totalPartitions
|
||||
expectedRangeStart := kafkaPartition * rangeSize
|
||||
var expectedRangeStop int32
|
||||
|
||||
if kafkaPartition == totalPartitions-1 {
|
||||
// Last partition gets the remainder to fill the entire ring
|
||||
expectedRangeStop = int32(pub_balancer.MaxPartitionCount)
|
||||
} else {
|
||||
expectedRangeStop = (kafkaPartition + 1) * rangeSize
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Looking for Kafka partition %d in topic %s: expected range [%d, %d] out of %d partitions",
|
||||
kafkaPartition, topic, expectedRangeStart, expectedRangeStop, totalPartitions)
|
||||
|
||||
// Find the broker assignment that matches this range
|
||||
for _, assignment := range assignments {
|
||||
if assignment.Partition == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this assignment's range matches our expected range
|
||||
if assignment.Partition.RangeStart == expectedRangeStart && assignment.Partition.RangeStop == expectedRangeStop {
|
||||
glog.V(1).Infof("found matching partition assignment for %s[%d]: {RingSize: %d, RangeStart: %d, RangeStop: %d, UnixTimeNs: %d}",
|
||||
topic, kafkaPartition, assignment.Partition.RingSize, assignment.Partition.RangeStart,
|
||||
assignment.Partition.RangeStop, assignment.Partition.UnixTimeNs)
|
||||
return assignment.Partition, nil
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match found, log all available assignments for debugging
|
||||
glog.Warningf("no partition assignment found for Kafka partition %d in topic %s with expected range [%d, %d]",
|
||||
kafkaPartition, topic, expectedRangeStart, expectedRangeStop)
|
||||
glog.Warningf("Available assignments:")
|
||||
for i, assignment := range assignments {
|
||||
if assignment.Partition != nil {
|
||||
glog.Warningf(" Assignment[%d]: {RangeStart: %d, RangeStop: %d, RingSize: %d}",
|
||||
i, assignment.Partition.RangeStart, assignment.Partition.RangeStop, assignment.Partition.RingSize)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no broker assignment found for Kafka partition %d with expected range [%d, %d]",
|
||||
kafkaPartition, expectedRangeStart, expectedRangeStop)
|
||||
}
|
||||
340
weed/mq/kafka/integration/broker_client_restart_test.go
Normal file
340
weed/mq/kafka/integration/broker_client_restart_test.go
Normal file
@@ -0,0 +1,340 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// MockSubscribeStream implements mq_pb.SeaweedMessaging_SubscribeMessageClient for testing
|
||||
type MockSubscribeStream struct {
|
||||
sendCalls []interface{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (m *MockSubscribeStream) Send(req *mq_pb.SubscribeMessageRequest) error {
|
||||
m.sendCalls = append(m.sendCalls, req)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockSubscribeStream) Recv() (*mq_pb.SubscribeMessageResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockSubscribeStream) CloseSend() error {
|
||||
m.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockSubscribeStream) Header() (metadata.MD, error) { return nil, nil }
|
||||
func (m *MockSubscribeStream) Trailer() metadata.MD { return nil }
|
||||
func (m *MockSubscribeStream) Context() context.Context { return context.Background() }
|
||||
func (m *MockSubscribeStream) SendMsg(m2 interface{}) error { return nil }
|
||||
func (m *MockSubscribeStream) RecvMsg(m2 interface{}) error { return nil }
|
||||
|
||||
// TestNeedsRestart tests the NeedsRestart logic
|
||||
func TestNeedsRestart(t *testing.T) {
|
||||
bc := &BrokerClient{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
session *BrokerSubscriberSession
|
||||
requestedOffset int64
|
||||
want bool
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "Stream is nil - needs restart",
|
||||
session: &BrokerSubscriberSession{
|
||||
Topic: "test-topic",
|
||||
Partition: 0,
|
||||
StartOffset: 100,
|
||||
Stream: nil,
|
||||
},
|
||||
requestedOffset: 100,
|
||||
want: true,
|
||||
reason: "Stream is nil",
|
||||
},
|
||||
{
|
||||
name: "Offset in cache - no restart needed",
|
||||
session: &BrokerSubscriberSession{
|
||||
Topic: "test-topic",
|
||||
Partition: 0,
|
||||
StartOffset: 100,
|
||||
Stream: &MockSubscribeStream{},
|
||||
Ctx: context.Background(),
|
||||
consumedRecords: []*SeaweedRecord{
|
||||
{Offset: 95},
|
||||
{Offset: 96},
|
||||
{Offset: 97},
|
||||
{Offset: 98},
|
||||
{Offset: 99},
|
||||
},
|
||||
},
|
||||
requestedOffset: 97,
|
||||
want: false,
|
||||
reason: "Offset 97 is in cache [95-99]",
|
||||
},
|
||||
{
|
||||
name: "Offset before current - needs restart",
|
||||
session: &BrokerSubscriberSession{
|
||||
Topic: "test-topic",
|
||||
Partition: 0,
|
||||
StartOffset: 100,
|
||||
Stream: &MockSubscribeStream{},
|
||||
Ctx: context.Background(),
|
||||
},
|
||||
requestedOffset: 50,
|
||||
want: true,
|
||||
reason: "Requested offset 50 < current 100",
|
||||
},
|
||||
{
|
||||
name: "Large gap ahead - needs restart",
|
||||
session: &BrokerSubscriberSession{
|
||||
Topic: "test-topic",
|
||||
Partition: 0,
|
||||
StartOffset: 100,
|
||||
Stream: &MockSubscribeStream{},
|
||||
Ctx: context.Background(),
|
||||
},
|
||||
requestedOffset: 2000,
|
||||
want: true,
|
||||
reason: "Gap of 1900 is > 1000",
|
||||
},
|
||||
{
|
||||
name: "Small gap ahead - no restart needed",
|
||||
session: &BrokerSubscriberSession{
|
||||
Topic: "test-topic",
|
||||
Partition: 0,
|
||||
StartOffset: 100,
|
||||
Stream: &MockSubscribeStream{},
|
||||
Ctx: context.Background(),
|
||||
},
|
||||
requestedOffset: 150,
|
||||
want: false,
|
||||
reason: "Gap of 50 is < 1000",
|
||||
},
|
||||
{
|
||||
name: "Exact match - no restart needed",
|
||||
session: &BrokerSubscriberSession{
|
||||
Topic: "test-topic",
|
||||
Partition: 0,
|
||||
StartOffset: 100,
|
||||
Stream: &MockSubscribeStream{},
|
||||
Ctx: context.Background(),
|
||||
},
|
||||
requestedOffset: 100,
|
||||
want: false,
|
||||
reason: "Exact match with current offset",
|
||||
},
|
||||
{
|
||||
name: "Context is nil - needs restart",
|
||||
session: &BrokerSubscriberSession{
|
||||
Topic: "test-topic",
|
||||
Partition: 0,
|
||||
StartOffset: 100,
|
||||
Stream: &MockSubscribeStream{},
|
||||
Ctx: nil,
|
||||
},
|
||||
requestedOffset: 100,
|
||||
want: true,
|
||||
reason: "Context is nil",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := bc.NeedsRestart(tt.session, tt.requestedOffset)
|
||||
if got != tt.want {
|
||||
t.Errorf("NeedsRestart() = %v, want %v (reason: %s)", got, tt.want, tt.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNeedsRestart_CacheLogic tests cache-based restart decisions
|
||||
func TestNeedsRestart_CacheLogic(t *testing.T) {
|
||||
bc := &BrokerClient{}
|
||||
|
||||
// Create session with cache containing offsets 100-109
|
||||
session := &BrokerSubscriberSession{
|
||||
Topic: "test-topic",
|
||||
Partition: 0,
|
||||
StartOffset: 110,
|
||||
Stream: &MockSubscribeStream{},
|
||||
Ctx: context.Background(),
|
||||
consumedRecords: []*SeaweedRecord{
|
||||
{Offset: 100}, {Offset: 101}, {Offset: 102}, {Offset: 103}, {Offset: 104},
|
||||
{Offset: 105}, {Offset: 106}, {Offset: 107}, {Offset: 108}, {Offset: 109},
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
offset int64
|
||||
want bool
|
||||
desc string
|
||||
}{
|
||||
{100, false, "First offset in cache"},
|
||||
{105, false, "Middle offset in cache"},
|
||||
{109, false, "Last offset in cache"},
|
||||
{99, true, "Before cache start"},
|
||||
{110, false, "Current position"},
|
||||
{111, false, "One ahead"},
|
||||
{1200, true, "Large gap > 1000"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got := bc.NeedsRestart(session, tc.offset)
|
||||
if got != tc.want {
|
||||
t.Errorf("NeedsRestart(offset=%d) = %v, want %v (%s)", tc.offset, got, tc.want, tc.desc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNeedsRestart_EmptyCache tests behavior with empty cache
|
||||
func TestNeedsRestart_EmptyCache(t *testing.T) {
|
||||
bc := &BrokerClient{}
|
||||
|
||||
session := &BrokerSubscriberSession{
|
||||
Topic: "test-topic",
|
||||
Partition: 0,
|
||||
StartOffset: 100,
|
||||
Stream: &MockSubscribeStream{},
|
||||
Ctx: context.Background(),
|
||||
consumedRecords: nil, // Empty cache
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
offset int64
|
||||
want bool
|
||||
desc string
|
||||
}{
|
||||
{50, true, "Before current"},
|
||||
{100, false, "At current"},
|
||||
{150, false, "Small gap ahead"},
|
||||
{1200, true, "Large gap ahead"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
got := bc.NeedsRestart(session, tt.offset)
|
||||
if got != tt.want {
|
||||
t.Errorf("NeedsRestart(offset=%d) = %v, want %v (%s)", tt.offset, got, tt.want, tt.desc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNeedsRestart_ThreadSafety tests concurrent access
|
||||
func TestNeedsRestart_ThreadSafety(t *testing.T) {
|
||||
bc := &BrokerClient{}
|
||||
|
||||
session := &BrokerSubscriberSession{
|
||||
Topic: "test-topic",
|
||||
Partition: 0,
|
||||
StartOffset: 100,
|
||||
Stream: &MockSubscribeStream{},
|
||||
Ctx: context.Background(),
|
||||
}
|
||||
|
||||
// Run many concurrent checks
|
||||
done := make(chan bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
go func(offset int64) {
|
||||
bc.NeedsRestart(session, offset)
|
||||
done <- true
|
||||
}(int64(i))
|
||||
}
|
||||
|
||||
// Wait for all to complete
|
||||
for i := 0; i < 100; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Test passes if no panic/race condition
|
||||
}
|
||||
|
||||
// TestRestartSubscriber_StateManagement tests session state management
|
||||
func TestRestartSubscriber_StateManagement(t *testing.T) {
|
||||
oldStream := &MockSubscribeStream{}
|
||||
oldCtx, oldCancel := context.WithCancel(context.Background())
|
||||
|
||||
session := &BrokerSubscriberSession{
|
||||
Topic: "test-topic",
|
||||
Partition: 0,
|
||||
StartOffset: 100,
|
||||
Stream: oldStream,
|
||||
Ctx: oldCtx,
|
||||
Cancel: oldCancel,
|
||||
consumedRecords: []*SeaweedRecord{
|
||||
{Offset: 100, Key: []byte("key100"), Value: []byte("value100")},
|
||||
{Offset: 101, Key: []byte("key101"), Value: []byte("value101")},
|
||||
{Offset: 102, Key: []byte("key102"), Value: []byte("value102")},
|
||||
},
|
||||
nextOffsetToRead: 103,
|
||||
}
|
||||
|
||||
// Verify initial state
|
||||
if len(session.consumedRecords) != 3 {
|
||||
t.Errorf("Initial cache size = %d, want 3", len(session.consumedRecords))
|
||||
}
|
||||
if session.nextOffsetToRead != 103 {
|
||||
t.Errorf("Initial nextOffsetToRead = %d, want 103", session.nextOffsetToRead)
|
||||
}
|
||||
if session.StartOffset != 100 {
|
||||
t.Errorf("Initial StartOffset = %d, want 100", session.StartOffset)
|
||||
}
|
||||
|
||||
// Note: Full RestartSubscriber testing requires gRPC mocking
|
||||
// These tests verify the core state management and NeedsRestart logic
|
||||
}
|
||||
|
||||
// BenchmarkNeedsRestart_CacheHit benchmarks cache hit performance
|
||||
func BenchmarkNeedsRestart_CacheHit(b *testing.B) {
|
||||
bc := &BrokerClient{}
|
||||
|
||||
session := &BrokerSubscriberSession{
|
||||
Topic: "test-topic",
|
||||
Partition: 0,
|
||||
StartOffset: 1000,
|
||||
Stream: &MockSubscribeStream{},
|
||||
Ctx: context.Background(),
|
||||
consumedRecords: make([]*SeaweedRecord, 100),
|
||||
}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
session.consumedRecords[i] = &SeaweedRecord{Offset: int64(i)}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bc.NeedsRestart(session, 50) // Hit cache
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkNeedsRestart_CacheMiss benchmarks cache miss performance
|
||||
func BenchmarkNeedsRestart_CacheMiss(b *testing.B) {
|
||||
bc := &BrokerClient{}
|
||||
|
||||
session := &BrokerSubscriberSession{
|
||||
Topic: "test-topic",
|
||||
Partition: 0,
|
||||
StartOffset: 1000,
|
||||
Stream: &MockSubscribeStream{},
|
||||
Ctx: context.Background(),
|
||||
consumedRecords: make([]*SeaweedRecord, 100),
|
||||
}
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
session.consumedRecords[i] = &SeaweedRecord{Offset: int64(i)}
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
bc.NeedsRestart(session, 500) // Miss cache (within gap threshold)
|
||||
}
|
||||
}
|
||||
1252
weed/mq/kafka/integration/broker_client_subscribe.go
Normal file
1252
weed/mq/kafka/integration/broker_client_subscribe.go
Normal file
File diff suppressed because it is too large
Load Diff
124
weed/mq/kafka/integration/broker_error_mapping.go
Normal file
124
weed/mq/kafka/integration/broker_error_mapping.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
|
||||
)
|
||||
|
||||
// Kafka Protocol Error Codes (copied from protocol package to avoid import cycle)
|
||||
const (
|
||||
kafkaErrorCodeNone int16 = 0
|
||||
kafkaErrorCodeUnknownServerError int16 = 1
|
||||
kafkaErrorCodeUnknownTopicOrPartition int16 = 3
|
||||
kafkaErrorCodeNotLeaderOrFollower int16 = 6
|
||||
kafkaErrorCodeRequestTimedOut int16 = 7
|
||||
kafkaErrorCodeBrokerNotAvailable int16 = 8
|
||||
kafkaErrorCodeMessageTooLarge int16 = 10
|
||||
kafkaErrorCodeNetworkException int16 = 13
|
||||
kafkaErrorCodeOffsetLoadInProgress int16 = 14
|
||||
kafkaErrorCodeTopicAlreadyExists int16 = 36
|
||||
kafkaErrorCodeInvalidPartitions int16 = 37
|
||||
kafkaErrorCodeInvalidConfig int16 = 40
|
||||
kafkaErrorCodeInvalidRecord int16 = 42
|
||||
)
|
||||
|
||||
// MapBrokerErrorToKafka maps a broker error code to the corresponding Kafka protocol error code
|
||||
func MapBrokerErrorToKafka(brokerErrorCode int32) int16 {
|
||||
switch brokerErrorCode {
|
||||
case 0: // BrokerErrorNone
|
||||
return kafkaErrorCodeNone
|
||||
case 1: // BrokerErrorUnknownServerError
|
||||
return kafkaErrorCodeUnknownServerError
|
||||
case 2: // BrokerErrorTopicNotFound
|
||||
return kafkaErrorCodeUnknownTopicOrPartition
|
||||
case 3: // BrokerErrorPartitionNotFound
|
||||
return kafkaErrorCodeUnknownTopicOrPartition
|
||||
case 6: // BrokerErrorNotLeaderOrFollower
|
||||
return kafkaErrorCodeNotLeaderOrFollower
|
||||
case 7: // BrokerErrorRequestTimedOut
|
||||
return kafkaErrorCodeRequestTimedOut
|
||||
case 8: // BrokerErrorBrokerNotAvailable
|
||||
return kafkaErrorCodeBrokerNotAvailable
|
||||
case 10: // BrokerErrorMessageTooLarge
|
||||
return kafkaErrorCodeMessageTooLarge
|
||||
case 13: // BrokerErrorNetworkException
|
||||
return kafkaErrorCodeNetworkException
|
||||
case 14: // BrokerErrorOffsetLoadInProgress
|
||||
return kafkaErrorCodeOffsetLoadInProgress
|
||||
case 42: // BrokerErrorInvalidRecord
|
||||
return kafkaErrorCodeInvalidRecord
|
||||
case 36: // BrokerErrorTopicAlreadyExists
|
||||
return kafkaErrorCodeTopicAlreadyExists
|
||||
case 37: // BrokerErrorInvalidPartitions
|
||||
return kafkaErrorCodeInvalidPartitions
|
||||
case 40: // BrokerErrorInvalidConfig
|
||||
return kafkaErrorCodeInvalidConfig
|
||||
case 100: // BrokerErrorPublisherNotFound
|
||||
return kafkaErrorCodeUnknownServerError
|
||||
case 101: // BrokerErrorConnectionFailed
|
||||
return kafkaErrorCodeNetworkException
|
||||
case 102: // BrokerErrorFollowerConnectionFailed
|
||||
return kafkaErrorCodeNetworkException
|
||||
default:
|
||||
// Unknown broker error code, default to unknown server error
|
||||
return kafkaErrorCodeUnknownServerError
|
||||
}
|
||||
}
|
||||
|
||||
// HandleBrokerResponse processes a broker response and returns appropriate error information
|
||||
// Returns (kafkaErrorCode, errorMessage, error) where error is non-nil for system errors
|
||||
func HandleBrokerResponse(resp *mq_pb.PublishMessageResponse) (int16, string, error) {
|
||||
if resp.Error == "" && resp.ErrorCode == 0 {
|
||||
// No error
|
||||
return kafkaErrorCodeNone, "", nil
|
||||
}
|
||||
|
||||
// Use structured error code if available, otherwise fall back to string parsing
|
||||
if resp.ErrorCode != 0 {
|
||||
kafkaErrorCode := MapBrokerErrorToKafka(resp.ErrorCode)
|
||||
return kafkaErrorCode, resp.Error, nil
|
||||
}
|
||||
|
||||
// Fallback: parse string error for backward compatibility
|
||||
// This handles cases where older brokers might not set ErrorCode
|
||||
kafkaErrorCode := parseStringErrorToKafkaCode(resp.Error)
|
||||
return kafkaErrorCode, resp.Error, nil
|
||||
}
|
||||
|
||||
// parseStringErrorToKafkaCode provides backward compatibility for string-based error parsing
|
||||
// This is the old brittle approach that we're replacing with structured error codes
|
||||
func parseStringErrorToKafkaCode(errorMsg string) int16 {
|
||||
if errorMsg == "" {
|
||||
return kafkaErrorCodeNone
|
||||
}
|
||||
|
||||
// Check for common error patterns (brittle string matching)
|
||||
switch {
|
||||
case containsAny(errorMsg, "not the leader", "not leader"):
|
||||
return kafkaErrorCodeNotLeaderOrFollower
|
||||
case containsAny(errorMsg, "topic", "not found", "does not exist"):
|
||||
return kafkaErrorCodeUnknownTopicOrPartition
|
||||
case containsAny(errorMsg, "partition", "not found"):
|
||||
return kafkaErrorCodeUnknownTopicOrPartition
|
||||
case containsAny(errorMsg, "timeout", "timed out"):
|
||||
return kafkaErrorCodeRequestTimedOut
|
||||
case containsAny(errorMsg, "network", "connection"):
|
||||
return kafkaErrorCodeNetworkException
|
||||
case containsAny(errorMsg, "too large", "size"):
|
||||
return kafkaErrorCodeMessageTooLarge
|
||||
default:
|
||||
return kafkaErrorCodeUnknownServerError
|
||||
}
|
||||
}
|
||||
|
||||
// containsAny checks if the text contains any of the given substrings (case-insensitive)
|
||||
func containsAny(text string, substrings ...string) bool {
|
||||
textLower := strings.ToLower(text)
|
||||
for _, substr := range substrings {
|
||||
if strings.Contains(textLower, strings.ToLower(substr)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
169
weed/mq/kafka/integration/broker_error_mapping_test.go
Normal file
169
weed/mq/kafka/integration/broker_error_mapping_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
|
||||
)
|
||||
|
||||
func TestMapBrokerErrorToKafka(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
brokerErrorCode int32
|
||||
expectedKafka int16
|
||||
}{
|
||||
{"No error", 0, kafkaErrorCodeNone},
|
||||
{"Unknown server error", 1, kafkaErrorCodeUnknownServerError},
|
||||
{"Topic not found", 2, kafkaErrorCodeUnknownTopicOrPartition},
|
||||
{"Partition not found", 3, kafkaErrorCodeUnknownTopicOrPartition},
|
||||
{"Not leader or follower", 6, kafkaErrorCodeNotLeaderOrFollower},
|
||||
{"Request timed out", 7, kafkaErrorCodeRequestTimedOut},
|
||||
{"Broker not available", 8, kafkaErrorCodeBrokerNotAvailable},
|
||||
{"Message too large", 10, kafkaErrorCodeMessageTooLarge},
|
||||
{"Network exception", 13, kafkaErrorCodeNetworkException},
|
||||
{"Offset load in progress", 14, kafkaErrorCodeOffsetLoadInProgress},
|
||||
{"Invalid record", 42, kafkaErrorCodeInvalidRecord},
|
||||
{"Topic already exists", 36, kafkaErrorCodeTopicAlreadyExists},
|
||||
{"Invalid partitions", 37, kafkaErrorCodeInvalidPartitions},
|
||||
{"Invalid config", 40, kafkaErrorCodeInvalidConfig},
|
||||
{"Publisher not found", 100, kafkaErrorCodeUnknownServerError},
|
||||
{"Connection failed", 101, kafkaErrorCodeNetworkException},
|
||||
{"Follower connection failed", 102, kafkaErrorCodeNetworkException},
|
||||
{"Unknown error code", 999, kafkaErrorCodeUnknownServerError},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := MapBrokerErrorToKafka(tt.brokerErrorCode)
|
||||
if result != tt.expectedKafka {
|
||||
t.Errorf("MapBrokerErrorToKafka(%d) = %d, want %d", tt.brokerErrorCode, result, tt.expectedKafka)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBrokerResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response *mq_pb.PublishMessageResponse
|
||||
expectedKafkaCode int16
|
||||
expectedError string
|
||||
expectSystemError bool
|
||||
}{
|
||||
{
|
||||
name: "No error",
|
||||
response: &mq_pb.PublishMessageResponse{
|
||||
AckTsNs: 123,
|
||||
Error: "",
|
||||
ErrorCode: 0,
|
||||
},
|
||||
expectedKafkaCode: kafkaErrorCodeNone,
|
||||
expectedError: "",
|
||||
expectSystemError: false,
|
||||
},
|
||||
{
|
||||
name: "Structured error - Not leader",
|
||||
response: &mq_pb.PublishMessageResponse{
|
||||
AckTsNs: 0,
|
||||
Error: "not the leader for this partition, leader is: broker2:9092",
|
||||
ErrorCode: 6, // BrokerErrorNotLeaderOrFollower
|
||||
},
|
||||
expectedKafkaCode: kafkaErrorCodeNotLeaderOrFollower,
|
||||
expectedError: "not the leader for this partition, leader is: broker2:9092",
|
||||
expectSystemError: false,
|
||||
},
|
||||
{
|
||||
name: "Structured error - Topic not found",
|
||||
response: &mq_pb.PublishMessageResponse{
|
||||
AckTsNs: 0,
|
||||
Error: "topic test-topic not found",
|
||||
ErrorCode: 2, // BrokerErrorTopicNotFound
|
||||
},
|
||||
expectedKafkaCode: kafkaErrorCodeUnknownTopicOrPartition,
|
||||
expectedError: "topic test-topic not found",
|
||||
expectSystemError: false,
|
||||
},
|
||||
{
|
||||
name: "Fallback string parsing - Not leader",
|
||||
response: &mq_pb.PublishMessageResponse{
|
||||
AckTsNs: 0,
|
||||
Error: "not the leader for this partition",
|
||||
ErrorCode: 0, // No structured error code
|
||||
},
|
||||
expectedKafkaCode: kafkaErrorCodeNotLeaderOrFollower,
|
||||
expectedError: "not the leader for this partition",
|
||||
expectSystemError: false,
|
||||
},
|
||||
{
|
||||
name: "Fallback string parsing - Topic not found",
|
||||
response: &mq_pb.PublishMessageResponse{
|
||||
AckTsNs: 0,
|
||||
Error: "topic does not exist",
|
||||
ErrorCode: 0, // No structured error code
|
||||
},
|
||||
expectedKafkaCode: kafkaErrorCodeUnknownTopicOrPartition,
|
||||
expectedError: "topic does not exist",
|
||||
expectSystemError: false,
|
||||
},
|
||||
{
|
||||
name: "Fallback string parsing - Unknown error",
|
||||
response: &mq_pb.PublishMessageResponse{
|
||||
AckTsNs: 0,
|
||||
Error: "some unknown error occurred",
|
||||
ErrorCode: 0, // No structured error code
|
||||
},
|
||||
expectedKafkaCode: kafkaErrorCodeUnknownServerError,
|
||||
expectedError: "some unknown error occurred",
|
||||
expectSystemError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
kafkaCode, errorMsg, systemErr := HandleBrokerResponse(tt.response)
|
||||
|
||||
if kafkaCode != tt.expectedKafkaCode {
|
||||
t.Errorf("HandleBrokerResponse() kafkaCode = %d, want %d", kafkaCode, tt.expectedKafkaCode)
|
||||
}
|
||||
|
||||
if errorMsg != tt.expectedError {
|
||||
t.Errorf("HandleBrokerResponse() errorMsg = %q, want %q", errorMsg, tt.expectedError)
|
||||
}
|
||||
|
||||
if (systemErr != nil) != tt.expectSystemError {
|
||||
t.Errorf("HandleBrokerResponse() systemErr = %v, expectSystemError = %v", systemErr, tt.expectSystemError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStringErrorToKafkaCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errorMsg string
|
||||
expectedCode int16
|
||||
}{
|
||||
{"Empty error", "", kafkaErrorCodeNone},
|
||||
{"Not leader error", "not the leader for this partition", kafkaErrorCodeNotLeaderOrFollower},
|
||||
{"Not leader error variant", "not leader", kafkaErrorCodeNotLeaderOrFollower},
|
||||
{"Topic not found", "topic not found", kafkaErrorCodeUnknownTopicOrPartition},
|
||||
{"Topic does not exist", "topic does not exist", kafkaErrorCodeUnknownTopicOrPartition},
|
||||
{"Partition not found", "partition not found", kafkaErrorCodeUnknownTopicOrPartition},
|
||||
{"Timeout error", "request timed out", kafkaErrorCodeRequestTimedOut},
|
||||
{"Timeout error variant", "timeout occurred", kafkaErrorCodeRequestTimedOut},
|
||||
{"Network error", "network exception", kafkaErrorCodeNetworkException},
|
||||
{"Connection error", "connection failed", kafkaErrorCodeNetworkException},
|
||||
{"Message too large", "message too large", kafkaErrorCodeMessageTooLarge},
|
||||
{"Size error", "size exceeds limit", kafkaErrorCodeMessageTooLarge},
|
||||
{"Unknown error", "some random error", kafkaErrorCodeUnknownServerError},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := parseStringErrorToKafkaCode(tt.errorMsg)
|
||||
if result != tt.expectedCode {
|
||||
t.Errorf("parseStringErrorToKafkaCode(%q) = %d, want %d", tt.errorMsg, result, tt.expectedCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
155
weed/mq/kafka/integration/fetch_performance_test.go
Normal file
155
weed/mq/kafka/integration/fetch_performance_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestAdaptiveFetchTimeout verifies that the adaptive timeout strategy
|
||||
// allows reading multiple records from disk within a reasonable time
|
||||
func TestAdaptiveFetchTimeout(t *testing.T) {
|
||||
t.Log("Testing adaptive fetch timeout strategy...")
|
||||
|
||||
// Simulate the scenario where we need to read 4 records from disk
|
||||
// Each record takes 100-200ms to read (simulates disk I/O)
|
||||
recordReadTimes := []time.Duration{
|
||||
150 * time.Millisecond, // Record 1 (from disk)
|
||||
150 * time.Millisecond, // Record 2 (from disk)
|
||||
150 * time.Millisecond, // Record 3 (from disk)
|
||||
150 * time.Millisecond, // Record 4 (from disk)
|
||||
}
|
||||
|
||||
// Test 1: Old strategy (50ms timeout per record)
|
||||
t.Run("OldStrategy_50ms_Timeout", func(t *testing.T) {
|
||||
timeout := 50 * time.Millisecond
|
||||
recordsReceived := 0
|
||||
|
||||
start := time.Now()
|
||||
for i, readTime := range recordReadTimes {
|
||||
if readTime <= timeout {
|
||||
recordsReceived++
|
||||
} else {
|
||||
t.Logf("Record %d timed out (readTime=%v > timeout=%v)", i+1, readTime, timeout)
|
||||
break
|
||||
}
|
||||
}
|
||||
duration := time.Since(start)
|
||||
|
||||
t.Logf("Old strategy: received %d/%d records in %v", recordsReceived, len(recordReadTimes), duration)
|
||||
|
||||
if recordsReceived >= len(recordReadTimes) {
|
||||
t.Error("Old strategy should NOT receive all records (timeout too short)")
|
||||
} else {
|
||||
t.Logf("✓ Bug reproduced: old strategy times out too quickly")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: New adaptive strategy (1 second timeout for first 5 records)
|
||||
t.Run("NewStrategy_1s_Timeout", func(t *testing.T) {
|
||||
timeout := 1 * time.Second // Generous timeout for first batch
|
||||
recordsReceived := 0
|
||||
|
||||
start := time.Now()
|
||||
for i, readTime := range recordReadTimes {
|
||||
if readTime <= timeout {
|
||||
recordsReceived++
|
||||
t.Logf("Record %d received (readTime=%v)", i+1, readTime)
|
||||
} else {
|
||||
t.Logf("Record %d timed out (readTime=%v > timeout=%v)", i+1, readTime, timeout)
|
||||
break
|
||||
}
|
||||
}
|
||||
duration := time.Since(start)
|
||||
|
||||
t.Logf("New strategy: received %d/%d records in %v", recordsReceived, len(recordReadTimes), duration)
|
||||
|
||||
if recordsReceived < len(recordReadTimes) {
|
||||
t.Errorf("New strategy should receive all records (timeout=%v)", timeout)
|
||||
} else {
|
||||
t.Logf("✓ Fix verified: new strategy receives all records")
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: Schema Registry catch-up scenario
|
||||
t.Run("SchemaRegistry_CatchUp_Scenario", func(t *testing.T) {
|
||||
// Schema Registry has 500ms total timeout to catch up from offset 3 to 6
|
||||
schemaRegistryTimeout := 500 * time.Millisecond
|
||||
|
||||
// With old strategy (50ms per record after first):
|
||||
// - First record: 10s timeout ✓
|
||||
// - Records 2-4: 50ms each ✗ (times out after record 1)
|
||||
// Total time: > 500ms (only gets 1 record per fetch)
|
||||
|
||||
// With new strategy (1s per record for first 5):
|
||||
// - Records 1-4: 1s each ✓
|
||||
// - All 4 records received in ~600ms
|
||||
// Total time: ~600ms (gets all 4 records in one fetch)
|
||||
|
||||
recordsNeeded := 4
|
||||
perRecordReadTime := 150 * time.Millisecond
|
||||
|
||||
// Old strategy simulation
|
||||
oldStrategyTime := time.Duration(recordsNeeded) * 50 * time.Millisecond // Times out, need multiple fetches
|
||||
oldStrategyRoundTrips := recordsNeeded // One record per fetch
|
||||
|
||||
// New strategy simulation
|
||||
newStrategyTime := time.Duration(recordsNeeded) * perRecordReadTime // All in one fetch
|
||||
newStrategyRoundTrips := 1
|
||||
|
||||
t.Logf("Schema Registry catch-up simulation:")
|
||||
t.Logf(" Old strategy: %d round trips, ~%v total time", oldStrategyRoundTrips, oldStrategyTime*time.Duration(oldStrategyRoundTrips))
|
||||
t.Logf(" New strategy: %d round trip, ~%v total time", newStrategyRoundTrips, newStrategyTime)
|
||||
t.Logf(" Schema Registry timeout: %v", schemaRegistryTimeout)
|
||||
|
||||
oldStrategyTotalTime := oldStrategyTime * time.Duration(oldStrategyRoundTrips)
|
||||
newStrategyTotalTime := newStrategyTime * time.Duration(newStrategyRoundTrips)
|
||||
|
||||
if oldStrategyTotalTime > schemaRegistryTimeout {
|
||||
t.Logf("✓ Old strategy exceeds timeout: %v > %v", oldStrategyTotalTime, schemaRegistryTimeout)
|
||||
}
|
||||
|
||||
if newStrategyTotalTime <= schemaRegistryTimeout+200*time.Millisecond {
|
||||
t.Logf("✓ New strategy completes within timeout: %v <= %v", newStrategyTotalTime, schemaRegistryTimeout+200*time.Millisecond)
|
||||
} else {
|
||||
t.Errorf("New strategy too slow: %v > %v", newStrategyTotalTime, schemaRegistryTimeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFetchTimeoutProgression verifies the timeout progression logic
|
||||
func TestFetchTimeoutProgression(t *testing.T) {
|
||||
t.Log("Testing fetch timeout progression...")
|
||||
|
||||
// Adaptive timeout logic:
|
||||
// - First 5 records: 1 second (catch-up from disk)
|
||||
// - After 5 records: 100ms (streaming from memory)
|
||||
|
||||
getTimeout := func(recordNumber int) time.Duration {
|
||||
if recordNumber <= 5 {
|
||||
return 1 * time.Second
|
||||
}
|
||||
return 100 * time.Millisecond
|
||||
}
|
||||
|
||||
t.Logf("Timeout progression:")
|
||||
for i := 1; i <= 10; i++ {
|
||||
timeout := getTimeout(i)
|
||||
t.Logf(" Record %2d: timeout = %v", i, timeout)
|
||||
}
|
||||
|
||||
// Verify the progression
|
||||
if getTimeout(1) != 1*time.Second {
|
||||
t.Error("First record should have 1s timeout")
|
||||
}
|
||||
if getTimeout(5) != 1*time.Second {
|
||||
t.Error("Fifth record should have 1s timeout")
|
||||
}
|
||||
if getTimeout(6) != 100*time.Millisecond {
|
||||
t.Error("Sixth record should have 100ms timeout (fast path)")
|
||||
}
|
||||
if getTimeout(10) != 100*time.Millisecond {
|
||||
t.Error("Tenth record should have 100ms timeout (fast path)")
|
||||
}
|
||||
|
||||
t.Log("✓ Timeout progression is correct")
|
||||
}
|
||||
152
weed/mq/kafka/integration/record_retrieval_test.go
Normal file
152
weed/mq/kafka/integration/record_retrieval_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MockSeaweedClient provides a mock implementation for testing
|
||||
type MockSeaweedClient struct {
|
||||
records map[string]map[int32][]*SeaweedRecord // topic -> partition -> records
|
||||
}
|
||||
|
||||
func NewMockSeaweedClient() *MockSeaweedClient {
|
||||
return &MockSeaweedClient{
|
||||
records: make(map[string]map[int32][]*SeaweedRecord),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockSeaweedClient) AddRecord(topic string, partition int32, key []byte, value []byte, timestamp int64) {
|
||||
if m.records[topic] == nil {
|
||||
m.records[topic] = make(map[int32][]*SeaweedRecord)
|
||||
}
|
||||
if m.records[topic][partition] == nil {
|
||||
m.records[topic][partition] = make([]*SeaweedRecord, 0)
|
||||
}
|
||||
|
||||
record := &SeaweedRecord{
|
||||
Key: key,
|
||||
Value: value,
|
||||
Timestamp: timestamp,
|
||||
Offset: int64(len(m.records[topic][partition])), // Simple offset numbering
|
||||
}
|
||||
|
||||
m.records[topic][partition] = append(m.records[topic][partition], record)
|
||||
}
|
||||
|
||||
func (m *MockSeaweedClient) GetRecords(topic string, partition int32, fromOffset int64, maxRecords int) ([]*SeaweedRecord, error) {
|
||||
if m.records[topic] == nil || m.records[topic][partition] == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
allRecords := m.records[topic][partition]
|
||||
if fromOffset < 0 || fromOffset >= int64(len(allRecords)) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
endOffset := fromOffset + int64(maxRecords)
|
||||
if endOffset > int64(len(allRecords)) {
|
||||
endOffset = int64(len(allRecords))
|
||||
}
|
||||
|
||||
return allRecords[fromOffset:endOffset], nil
|
||||
}
|
||||
|
||||
func TestSeaweedSMQRecord_Interface(t *testing.T) {
|
||||
// Test that SeaweedSMQRecord properly implements SMQRecord interface
|
||||
key := []byte("test-key")
|
||||
value := []byte("test-value")
|
||||
timestamp := time.Now().UnixNano()
|
||||
kafkaOffset := int64(42)
|
||||
|
||||
record := &SeaweedSMQRecord{
|
||||
key: key,
|
||||
value: value,
|
||||
timestamp: timestamp,
|
||||
offset: kafkaOffset,
|
||||
}
|
||||
|
||||
// Test interface compliance
|
||||
var smqRecord SMQRecord = record
|
||||
|
||||
// Test GetKey
|
||||
if string(smqRecord.GetKey()) != string(key) {
|
||||
t.Errorf("Expected key %s, got %s", string(key), string(smqRecord.GetKey()))
|
||||
}
|
||||
|
||||
// Test GetValue
|
||||
if string(smqRecord.GetValue()) != string(value) {
|
||||
t.Errorf("Expected value %s, got %s", string(value), string(smqRecord.GetValue()))
|
||||
}
|
||||
|
||||
// Test GetTimestamp
|
||||
if smqRecord.GetTimestamp() != timestamp {
|
||||
t.Errorf("Expected timestamp %d, got %d", timestamp, smqRecord.GetTimestamp())
|
||||
}
|
||||
|
||||
// Test GetOffset
|
||||
if smqRecord.GetOffset() != kafkaOffset {
|
||||
t.Errorf("Expected offset %d, got %d", kafkaOffset, smqRecord.GetOffset())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeaweedMQHandler_GetStoredRecords_EmptyTopic(t *testing.T) {
|
||||
// Note: Ledgers have been removed - SMQ broker handles all offset management directly
|
||||
// This test is now obsolete as GetStoredRecords requires a real broker connection
|
||||
t.Skip("Test obsolete: ledgers removed, SMQ broker handles offset management")
|
||||
}
|
||||
|
||||
func TestSeaweedMQHandler_GetStoredRecords_EmptyPartition(t *testing.T) {
|
||||
// Note: Ledgers have been removed - SMQ broker handles all offset management directly
|
||||
// This test is now obsolete as GetStoredRecords requires a real broker connection
|
||||
t.Skip("Test obsolete: ledgers removed, SMQ broker handles offset management")
|
||||
}
|
||||
|
||||
func TestSeaweedMQHandler_GetStoredRecords_OffsetBeyondHighWaterMark(t *testing.T) {
|
||||
// Note: Ledgers have been removed - SMQ broker handles all offset management directly
|
||||
// This test is now obsolete as GetStoredRecords requires a real broker connection
|
||||
t.Skip("Test obsolete: ledgers removed, SMQ broker handles offset management")
|
||||
}
|
||||
|
||||
func TestSeaweedMQHandler_GetStoredRecords_MaxRecordsLimit(t *testing.T) {
|
||||
// Note: Ledgers have been removed - SMQ broker handles all offset management directly
|
||||
// This test is now obsolete as GetStoredRecords requires a real broker connection
|
||||
t.Skip("Test obsolete: ledgers removed, SMQ broker handles offset management")
|
||||
}
|
||||
|
||||
// Integration test helpers and benchmarks
|
||||
|
||||
func BenchmarkSeaweedSMQRecord_GetMethods(b *testing.B) {
|
||||
record := &SeaweedSMQRecord{
|
||||
key: []byte("benchmark-key"),
|
||||
value: []byte("benchmark-value-with-some-longer-content"),
|
||||
timestamp: time.Now().UnixNano(),
|
||||
offset: 12345,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
b.Run("GetKey", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = record.GetKey()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("GetValue", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = record.GetValue()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("GetTimestamp", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = record.GetTimestamp()
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("GetOffset", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = record.GetOffset()
|
||||
}
|
||||
})
|
||||
}
|
||||
513
weed/mq/kafka/integration/seaweedmq_handler.go
Normal file
513
weed/mq/kafka/integration/seaweedmq_handler.go
Normal file
@@ -0,0 +1,513 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
)
|
||||
|
||||
// GetStoredRecords retrieves records from SeaweedMQ using the proper subscriber API
|
||||
// ctx controls the fetch timeout (should match Kafka fetch request's MaxWaitTime)
|
||||
func (h *SeaweedMQHandler) GetStoredRecords(ctx context.Context, topic string, partition int32, fromOffset int64, maxRecords int) ([]SMQRecord, error) {
|
||||
glog.V(4).Infof("[FETCH] GetStoredRecords: topic=%s partition=%d fromOffset=%d maxRecords=%d", topic, partition, fromOffset, maxRecords)
|
||||
|
||||
// Verify topic exists
|
||||
if !h.TopicExists(topic) {
|
||||
return nil, fmt.Errorf("topic %s does not exist", topic)
|
||||
}
|
||||
|
||||
// CRITICAL: Use per-connection BrokerClient to prevent gRPC stream interference
|
||||
// Each Kafka connection has its own isolated BrokerClient instance
|
||||
var brokerClient *BrokerClient
|
||||
consumerGroup := "kafka-fetch-consumer" // default
|
||||
// CRITICAL FIX: Use stable consumer ID per topic-partition, NOT with timestamp
|
||||
// Including timestamp would create a new session on every fetch, causing subscriber churn
|
||||
consumerID := fmt.Sprintf("kafka-fetch-%s-%d", topic, partition) // default, stable per topic-partition
|
||||
|
||||
// Get the per-connection broker client from connection context
|
||||
if h.protocolHandler != nil {
|
||||
connCtx := h.protocolHandler.GetConnectionContext()
|
||||
if connCtx != nil {
|
||||
// Extract per-connection broker client
|
||||
if connCtx.BrokerClient != nil {
|
||||
if bc, ok := connCtx.BrokerClient.(*BrokerClient); ok {
|
||||
brokerClient = bc
|
||||
glog.V(4).Infof("[FETCH] Using per-connection BrokerClient for topic=%s partition=%d", topic, partition)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract consumer group and client ID
|
||||
if connCtx.ConsumerGroup != "" {
|
||||
consumerGroup = connCtx.ConsumerGroup
|
||||
glog.V(4).Infof("[FETCH] Using actual consumer group from context: %s", consumerGroup)
|
||||
}
|
||||
if connCtx.MemberID != "" {
|
||||
// Use member ID as base, but still include topic-partition for uniqueness
|
||||
consumerID = fmt.Sprintf("%s-%s-%d", connCtx.MemberID, topic, partition)
|
||||
glog.V(4).Infof("[FETCH] Using actual member ID from context: %s", consumerID)
|
||||
} else if connCtx.ClientID != "" {
|
||||
// Fallback to client ID if member ID not set (for clients not using consumer groups)
|
||||
// Include topic-partition to ensure each partition consumer is unique
|
||||
consumerID = fmt.Sprintf("%s-%s-%d", connCtx.ClientID, topic, partition)
|
||||
glog.V(4).Infof("[FETCH] Using client ID from context: %s", consumerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to shared broker client if per-connection client not available
|
||||
if brokerClient == nil {
|
||||
glog.Warningf("[FETCH] No per-connection BrokerClient, falling back to shared client")
|
||||
brokerClient = h.brokerClient
|
||||
if brokerClient == nil {
|
||||
return nil, fmt.Errorf("no broker client available")
|
||||
}
|
||||
}
|
||||
|
||||
// KAFKA-STYLE STATELESS FETCH (Long-term solution)
|
||||
// Uses FetchMessage RPC - completely stateless, no Subscribe loops
|
||||
//
|
||||
// Benefits:
|
||||
// 1. No session state on broker - each request is independent
|
||||
// 2. No shared Subscribe loops - no concurrent access issues
|
||||
// 3. No stream corruption - no cancel/restart complexity
|
||||
// 4. Safe concurrent reads - like Kafka's file-based reads
|
||||
// 5. Simple and maintainable - just request/response
|
||||
//
|
||||
// Architecture inspired by Kafka:
|
||||
// - Client manages offset tracking
|
||||
// - Each fetch is independent
|
||||
// - Broker reads from LogBuffer without maintaining state
|
||||
// - Natural support for concurrent requests
|
||||
glog.V(4).Infof("[FETCH-STATELESS] Fetching records for topic=%s partition=%d fromOffset=%d maxRecords=%d", topic, partition, fromOffset, maxRecords)
|
||||
|
||||
// Use the new FetchMessage RPC (Kafka-style stateless)
|
||||
seaweedRecords, err := brokerClient.FetchMessagesStateless(ctx, topic, partition, fromOffset, maxRecords, consumerGroup, consumerID)
|
||||
if err != nil {
|
||||
glog.Errorf("[FETCH-STATELESS] Failed to fetch records: %v", err)
|
||||
return nil, fmt.Errorf("failed to fetch records: %v", err)
|
||||
}
|
||||
|
||||
glog.V(4).Infof("[FETCH-STATELESS] Fetched %d records", len(seaweedRecords))
|
||||
//
|
||||
// STATELESS FETCH BENEFITS:
|
||||
// - No broker-side session state = no state synchronization bugs
|
||||
// - No Subscribe loops = no concurrent access to LogBuffer
|
||||
// - No stream corruption = no cancel/restart issues
|
||||
// - Natural concurrent access = like Kafka file reads
|
||||
// - Simple architecture = easier to maintain and debug
|
||||
//
|
||||
// EXPECTED RESULTS:
|
||||
// - <1% message loss (only from consumer rebalancing)
|
||||
// - No duplicates (no stream corruption)
|
||||
// - Low latency (direct LogBuffer reads)
|
||||
// - No context timeouts (no stream initialization overhead)
|
||||
|
||||
// Convert SeaweedMQ records to SMQRecord interface with proper Kafka offsets
|
||||
smqRecords := make([]SMQRecord, 0, len(seaweedRecords))
|
||||
for i, seaweedRecord := range seaweedRecords {
|
||||
// CRITICAL FIX: Use the actual offset from SeaweedMQ
|
||||
// The SeaweedRecord.Offset field now contains the correct offset from the subscriber
|
||||
kafkaOffset := seaweedRecord.Offset
|
||||
|
||||
// CRITICAL: Skip records before the requested offset
|
||||
// This can happen when the subscriber cache returns old data
|
||||
if kafkaOffset < fromOffset {
|
||||
glog.V(4).Infof("[FETCH] Skipping record %d with offset %d (requested fromOffset=%d)", i, kafkaOffset, fromOffset)
|
||||
continue
|
||||
}
|
||||
|
||||
smqRecord := &SeaweedSMQRecord{
|
||||
key: seaweedRecord.Key,
|
||||
value: seaweedRecord.Value,
|
||||
timestamp: seaweedRecord.Timestamp,
|
||||
offset: kafkaOffset,
|
||||
}
|
||||
smqRecords = append(smqRecords, smqRecord)
|
||||
|
||||
glog.V(4).Infof("[FETCH] Record %d: offset=%d, keyLen=%d, valueLen=%d", i, kafkaOffset, len(seaweedRecord.Key), len(seaweedRecord.Value))
|
||||
}
|
||||
|
||||
glog.V(4).Infof("[FETCH] Successfully read %d records from SMQ", len(smqRecords))
|
||||
return smqRecords, nil
|
||||
}
|
||||
|
||||
// GetEarliestOffset returns the earliest available offset for a topic partition
|
||||
// ALWAYS queries SMQ broker directly - no ledger involved
|
||||
func (h *SeaweedMQHandler) GetEarliestOffset(topic string, partition int32) (int64, error) {
|
||||
|
||||
// Check if topic exists
|
||||
if !h.TopicExists(topic) {
|
||||
return 0, nil // Empty topic starts at offset 0
|
||||
}
|
||||
|
||||
// ALWAYS query SMQ broker directly for earliest offset
|
||||
if h.brokerClient != nil {
|
||||
earliestOffset, err := h.brokerClient.GetEarliestOffset(topic, partition)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return earliestOffset, nil
|
||||
}
|
||||
|
||||
// No broker client - this shouldn't happen in production
|
||||
return 0, fmt.Errorf("broker client not available")
|
||||
}
|
||||
|
||||
// GetLatestOffset returns the latest available offset for a topic partition
|
||||
// ALWAYS queries SMQ broker directly - no ledger involved
|
||||
func (h *SeaweedMQHandler) GetLatestOffset(topic string, partition int32) (int64, error) {
|
||||
// Check if topic exists
|
||||
if !h.TopicExists(topic) {
|
||||
return 0, nil // Empty topic
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("%s:%d", topic, partition)
|
||||
h.hwmCacheMu.RLock()
|
||||
if entry, exists := h.hwmCache[cacheKey]; exists {
|
||||
if time.Now().Before(entry.expiresAt) {
|
||||
// Cache hit - return cached value
|
||||
h.hwmCacheMu.RUnlock()
|
||||
glog.V(2).Infof("[HWM] Cache HIT for %s: hwm=%d", cacheKey, entry.value)
|
||||
return entry.value, nil
|
||||
}
|
||||
}
|
||||
h.hwmCacheMu.RUnlock()
|
||||
|
||||
// Cache miss or expired - query SMQ broker
|
||||
if h.brokerClient != nil {
|
||||
glog.V(2).Infof("[HWM] Cache MISS for %s, querying broker...", cacheKey)
|
||||
latestOffset, err := h.brokerClient.GetHighWaterMark(topic, partition)
|
||||
if err != nil {
|
||||
glog.V(1).Infof("[HWM] ERROR querying broker for %s: %v", cacheKey, err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
glog.V(2).Infof("[HWM] Broker returned hwm=%d for %s", latestOffset, cacheKey)
|
||||
|
||||
// Update cache
|
||||
h.hwmCacheMu.Lock()
|
||||
h.hwmCache[cacheKey] = &hwmCacheEntry{
|
||||
value: latestOffset,
|
||||
expiresAt: time.Now().Add(h.hwmCacheTTL),
|
||||
}
|
||||
h.hwmCacheMu.Unlock()
|
||||
|
||||
return latestOffset, nil
|
||||
}
|
||||
|
||||
// No broker client - this shouldn't happen in production
|
||||
return 0, fmt.Errorf("broker client not available")
|
||||
}
|
||||
|
||||
// WithFilerClient executes a function with a filer client
|
||||
func (h *SeaweedMQHandler) WithFilerClient(streamingMode bool, fn func(client filer_pb.SeaweedFilerClient) error) error {
|
||||
if h.brokerClient == nil {
|
||||
return fmt.Errorf("no broker client available")
|
||||
}
|
||||
return h.brokerClient.WithFilerClient(streamingMode, fn)
|
||||
}
|
||||
|
||||
// GetFilerAddress returns the filer address used by this handler
|
||||
func (h *SeaweedMQHandler) GetFilerAddress() string {
|
||||
if h.brokerClient != nil {
|
||||
return h.brokerClient.GetFilerAddress()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ProduceRecord publishes a record to SeaweedMQ and lets SMQ generate the offset
|
||||
// ctx controls the publish timeout - if client cancels, broker operation is cancelled
|
||||
func (h *SeaweedMQHandler) ProduceRecord(ctx context.Context, topic string, partition int32, key []byte, value []byte) (int64, error) {
|
||||
if len(key) > 0 {
|
||||
}
|
||||
if len(value) > 0 {
|
||||
} else {
|
||||
}
|
||||
|
||||
// Verify topic exists
|
||||
if !h.TopicExists(topic) {
|
||||
return 0, fmt.Errorf("topic %s does not exist", topic)
|
||||
}
|
||||
|
||||
// Get current timestamp
|
||||
timestamp := time.Now().UnixNano()
|
||||
|
||||
// Publish to SeaweedMQ and let SMQ generate the offset
|
||||
var smqOffset int64
|
||||
var publishErr error
|
||||
if h.brokerClient == nil {
|
||||
publishErr = fmt.Errorf("no broker client available")
|
||||
} else {
|
||||
smqOffset, publishErr = h.brokerClient.PublishRecord(ctx, topic, partition, key, value, timestamp)
|
||||
}
|
||||
|
||||
if publishErr != nil {
|
||||
return 0, fmt.Errorf("failed to publish to SeaweedMQ: %v", publishErr)
|
||||
}
|
||||
|
||||
// SMQ should have generated and returned the offset - use it directly as the Kafka offset
|
||||
|
||||
// Invalidate HWM cache for this partition to ensure fresh reads
|
||||
// This is critical for read-your-own-write scenarios (e.g., Schema Registry)
|
||||
cacheKey := fmt.Sprintf("%s:%d", topic, partition)
|
||||
h.hwmCacheMu.Lock()
|
||||
delete(h.hwmCache, cacheKey)
|
||||
h.hwmCacheMu.Unlock()
|
||||
|
||||
return smqOffset, nil
|
||||
}
|
||||
|
||||
// ProduceRecordValue produces a record using RecordValue format to SeaweedMQ
|
||||
// ALWAYS uses broker's assigned offset - no ledger involved
|
||||
// ctx controls the publish timeout - if client cancels, broker operation is cancelled
|
||||
func (h *SeaweedMQHandler) ProduceRecordValue(ctx context.Context, topic string, partition int32, key []byte, recordValueBytes []byte) (int64, error) {
|
||||
// Verify topic exists
|
||||
if !h.TopicExists(topic) {
|
||||
return 0, fmt.Errorf("topic %s does not exist", topic)
|
||||
}
|
||||
|
||||
// Get current timestamp
|
||||
timestamp := time.Now().UnixNano()
|
||||
|
||||
// Publish RecordValue to SeaweedMQ and get the broker-assigned offset
|
||||
var smqOffset int64
|
||||
var publishErr error
|
||||
if h.brokerClient == nil {
|
||||
publishErr = fmt.Errorf("no broker client available")
|
||||
} else {
|
||||
smqOffset, publishErr = h.brokerClient.PublishRecordValue(ctx, topic, partition, key, recordValueBytes, timestamp)
|
||||
}
|
||||
|
||||
if publishErr != nil {
|
||||
return 0, fmt.Errorf("failed to publish RecordValue to SeaweedMQ: %v", publishErr)
|
||||
}
|
||||
|
||||
// SMQ broker has assigned the offset - use it directly as the Kafka offset
|
||||
|
||||
// Invalidate HWM cache for this partition to ensure fresh reads
|
||||
// This is critical for read-your-own-write scenarios (e.g., Schema Registry)
|
||||
cacheKey := fmt.Sprintf("%s:%d", topic, partition)
|
||||
h.hwmCacheMu.Lock()
|
||||
delete(h.hwmCache, cacheKey)
|
||||
h.hwmCacheMu.Unlock()
|
||||
|
||||
return smqOffset, nil
|
||||
}
|
||||
|
||||
// Ledger methods removed - SMQ broker handles all offset management directly
|
||||
|
||||
// FetchRecords DEPRECATED - only used in old tests
|
||||
func (h *SeaweedMQHandler) FetchRecords(topic string, partition int32, fetchOffset int64, maxBytes int32) ([]byte, error) {
|
||||
// Verify topic exists
|
||||
if !h.TopicExists(topic) {
|
||||
return nil, fmt.Errorf("topic %s does not exist", topic)
|
||||
}
|
||||
|
||||
// DEPRECATED: This function only used in old tests
|
||||
// Get HWM directly from broker
|
||||
highWaterMark, err := h.GetLatestOffset(topic, partition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If fetch offset is at or beyond high water mark, no records to return
|
||||
if fetchOffset >= highWaterMark {
|
||||
return []byte{}, nil
|
||||
}
|
||||
|
||||
// Get or create subscriber session for this topic/partition
|
||||
var seaweedRecords []*SeaweedRecord
|
||||
|
||||
// Calculate how many records to fetch
|
||||
recordsToFetch := int(highWaterMark - fetchOffset)
|
||||
if recordsToFetch > 100 {
|
||||
recordsToFetch = 100 // Limit batch size
|
||||
}
|
||||
|
||||
// Read records using broker client
|
||||
if h.brokerClient == nil {
|
||||
return nil, fmt.Errorf("no broker client available")
|
||||
}
|
||||
// Use default consumer group/ID since this is a deprecated function
|
||||
brokerSubscriber, subErr := h.brokerClient.GetOrCreateSubscriber(topic, partition, fetchOffset, "deprecated-consumer-group", "deprecated-consumer")
|
||||
if subErr != nil {
|
||||
return nil, fmt.Errorf("failed to get broker subscriber: %v", subErr)
|
||||
}
|
||||
// Use ReadRecordsFromOffset which handles caching and proper locking
|
||||
seaweedRecords, err = h.brokerClient.ReadRecordsFromOffset(context.Background(), brokerSubscriber, fetchOffset, recordsToFetch)
|
||||
|
||||
if err != nil {
|
||||
// If no records available, return empty batch instead of error
|
||||
return []byte{}, nil
|
||||
}
|
||||
|
||||
// Map SeaweedMQ records to Kafka offsets and update ledger
|
||||
kafkaRecords, err := h.mapSeaweedToKafkaOffsets(topic, partition, seaweedRecords, fetchOffset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to map offsets: %v", err)
|
||||
}
|
||||
|
||||
// Convert mapped records to Kafka record batch format
|
||||
return h.convertSeaweedToKafkaRecordBatch(kafkaRecords, fetchOffset, maxBytes)
|
||||
}
|
||||
|
||||
// mapSeaweedToKafkaOffsets maps SeaweedMQ records to proper Kafka offsets
|
||||
func (h *SeaweedMQHandler) mapSeaweedToKafkaOffsets(topic string, partition int32, seaweedRecords []*SeaweedRecord, startOffset int64) ([]*SeaweedRecord, error) {
|
||||
if len(seaweedRecords) == 0 {
|
||||
return seaweedRecords, nil
|
||||
}
|
||||
|
||||
// DEPRECATED: This function only used in old tests
|
||||
// Just map offsets sequentially
|
||||
mappedRecords := make([]*SeaweedRecord, 0, len(seaweedRecords))
|
||||
|
||||
for i, seaweedRecord := range seaweedRecords {
|
||||
currentKafkaOffset := startOffset + int64(i)
|
||||
|
||||
// Create a copy of the record with proper Kafka offset assignment
|
||||
mappedRecord := &SeaweedRecord{
|
||||
Key: seaweedRecord.Key,
|
||||
Value: seaweedRecord.Value,
|
||||
Timestamp: seaweedRecord.Timestamp,
|
||||
Offset: currentKafkaOffset,
|
||||
}
|
||||
|
||||
// Just skip any error handling since this is deprecated
|
||||
{
|
||||
// Log warning but continue processing
|
||||
}
|
||||
|
||||
mappedRecords = append(mappedRecords, mappedRecord)
|
||||
}
|
||||
|
||||
return mappedRecords, nil
|
||||
}
|
||||
|
||||
// convertSeaweedToKafkaRecordBatch converts SeaweedMQ records to Kafka record batch format
|
||||
func (h *SeaweedMQHandler) convertSeaweedToKafkaRecordBatch(seaweedRecords []*SeaweedRecord, fetchOffset int64, maxBytes int32) ([]byte, error) {
|
||||
if len(seaweedRecords) == 0 {
|
||||
return []byte{}, nil
|
||||
}
|
||||
|
||||
batch := make([]byte, 0, 512)
|
||||
|
||||
// Record batch header
|
||||
baseOffsetBytes := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(baseOffsetBytes, uint64(fetchOffset))
|
||||
batch = append(batch, baseOffsetBytes...) // base offset
|
||||
|
||||
// Batch length (placeholder, will be filled at end)
|
||||
batchLengthPos := len(batch)
|
||||
batch = append(batch, 0, 0, 0, 0)
|
||||
|
||||
batch = append(batch, 0, 0, 0, 0) // partition leader epoch
|
||||
batch = append(batch, 2) // magic byte (version 2)
|
||||
|
||||
// CRC placeholder
|
||||
batch = append(batch, 0, 0, 0, 0)
|
||||
|
||||
// Batch attributes
|
||||
batch = append(batch, 0, 0)
|
||||
|
||||
// Last offset delta
|
||||
lastOffsetDelta := uint32(len(seaweedRecords) - 1)
|
||||
lastOffsetDeltaBytes := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(lastOffsetDeltaBytes, lastOffsetDelta)
|
||||
batch = append(batch, lastOffsetDeltaBytes...)
|
||||
|
||||
// Timestamps - use actual timestamps from SeaweedMQ records
|
||||
var firstTimestamp, maxTimestamp int64
|
||||
if len(seaweedRecords) > 0 {
|
||||
firstTimestamp = seaweedRecords[0].Timestamp
|
||||
maxTimestamp = firstTimestamp
|
||||
for _, record := range seaweedRecords {
|
||||
if record.Timestamp > maxTimestamp {
|
||||
maxTimestamp = record.Timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
firstTimestampBytes := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(firstTimestampBytes, uint64(firstTimestamp))
|
||||
batch = append(batch, firstTimestampBytes...)
|
||||
|
||||
maxTimestampBytes := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(maxTimestampBytes, uint64(maxTimestamp))
|
||||
batch = append(batch, maxTimestampBytes...)
|
||||
|
||||
// Producer info (simplified)
|
||||
batch = append(batch, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF) // producer ID (-1)
|
||||
batch = append(batch, 0xFF, 0xFF) // producer epoch (-1)
|
||||
batch = append(batch, 0xFF, 0xFF, 0xFF, 0xFF) // base sequence (-1)
|
||||
|
||||
// Record count
|
||||
recordCountBytes := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(recordCountBytes, uint32(len(seaweedRecords)))
|
||||
batch = append(batch, recordCountBytes...)
|
||||
|
||||
// Add actual records from SeaweedMQ
|
||||
for i, seaweedRecord := range seaweedRecords {
|
||||
record := h.convertSingleSeaweedRecord(seaweedRecord, int64(i), fetchOffset)
|
||||
recordLength := byte(len(record))
|
||||
batch = append(batch, recordLength)
|
||||
batch = append(batch, record...)
|
||||
|
||||
// Check if we're approaching maxBytes limit
|
||||
if int32(len(batch)) > maxBytes*3/4 {
|
||||
// Leave room for remaining headers and stop adding records
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fill in the batch length
|
||||
batchLength := uint32(len(batch) - batchLengthPos - 4)
|
||||
binary.BigEndian.PutUint32(batch[batchLengthPos:batchLengthPos+4], batchLength)
|
||||
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
// convertSingleSeaweedRecord converts a single SeaweedMQ record to Kafka format
|
||||
func (h *SeaweedMQHandler) convertSingleSeaweedRecord(seaweedRecord *SeaweedRecord, index, baseOffset int64) []byte {
|
||||
record := make([]byte, 0, 64)
|
||||
|
||||
// Record attributes
|
||||
record = append(record, 0)
|
||||
|
||||
// Timestamp delta (varint - simplified)
|
||||
timestampDelta := seaweedRecord.Timestamp - baseOffset // Simple delta calculation
|
||||
if timestampDelta < 0 {
|
||||
timestampDelta = 0
|
||||
}
|
||||
record = append(record, byte(timestampDelta&0xFF)) // Simplified varint encoding
|
||||
|
||||
// Offset delta (varint - simplified)
|
||||
record = append(record, byte(index))
|
||||
|
||||
// Key length and key
|
||||
if len(seaweedRecord.Key) > 0 {
|
||||
record = append(record, byte(len(seaweedRecord.Key)))
|
||||
record = append(record, seaweedRecord.Key...)
|
||||
} else {
|
||||
// Null key
|
||||
record = append(record, 0xFF)
|
||||
}
|
||||
|
||||
// Value length and value
|
||||
if len(seaweedRecord.Value) > 0 {
|
||||
record = append(record, byte(len(seaweedRecord.Value)))
|
||||
record = append(record, seaweedRecord.Value...)
|
||||
} else {
|
||||
// Empty value
|
||||
record = append(record, 0)
|
||||
}
|
||||
|
||||
// Headers count (0)
|
||||
record = append(record, 0)
|
||||
|
||||
return record
|
||||
}
|
||||
512
weed/mq/kafka/integration/seaweedmq_handler_test.go
Normal file
512
weed/mq/kafka/integration/seaweedmq_handler_test.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Unit tests for new FetchRecords functionality
|
||||
|
||||
// TestSeaweedMQHandler_MapSeaweedToKafkaOffsets tests offset mapping logic
|
||||
func TestSeaweedMQHandler_MapSeaweedToKafkaOffsets(t *testing.T) {
|
||||
// Note: This test is now obsolete since the ledger system has been removed
|
||||
// SMQ now uses native offsets directly, so no mapping is needed
|
||||
t.Skip("Test obsolete: ledger system removed, SMQ uses native offsets")
|
||||
}
|
||||
|
||||
// TestSeaweedMQHandler_MapSeaweedToKafkaOffsets_EmptyRecords tests empty record handling
|
||||
func TestSeaweedMQHandler_MapSeaweedToKafkaOffsets_EmptyRecords(t *testing.T) {
|
||||
// Note: This test is now obsolete since the ledger system has been removed
|
||||
t.Skip("Test obsolete: ledger system removed, SMQ uses native offsets")
|
||||
}
|
||||
|
||||
// TestSeaweedMQHandler_ConvertSeaweedToKafkaRecordBatch tests record batch conversion
|
||||
func TestSeaweedMQHandler_ConvertSeaweedToKafkaRecordBatch(t *testing.T) {
|
||||
handler := &SeaweedMQHandler{}
|
||||
|
||||
// Create sample records
|
||||
seaweedRecords := []*SeaweedRecord{
|
||||
{
|
||||
Key: []byte("batch-key1"),
|
||||
Value: []byte("batch-value1"),
|
||||
Timestamp: 1000000000,
|
||||
Offset: 0,
|
||||
},
|
||||
{
|
||||
Key: []byte("batch-key2"),
|
||||
Value: []byte("batch-value2"),
|
||||
Timestamp: 1000000001,
|
||||
Offset: 1,
|
||||
},
|
||||
}
|
||||
|
||||
fetchOffset := int64(0)
|
||||
maxBytes := int32(1024)
|
||||
|
||||
// Test conversion
|
||||
batchData, err := handler.convertSeaweedToKafkaRecordBatch(seaweedRecords, fetchOffset, maxBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to convert to record batch: %v", err)
|
||||
}
|
||||
|
||||
if len(batchData) == 0 {
|
||||
t.Errorf("Record batch should not be empty")
|
||||
}
|
||||
|
||||
// Basic validation of record batch structure
|
||||
if len(batchData) < 61 { // Minimum Kafka record batch header size
|
||||
t.Errorf("Record batch too small: got %d bytes", len(batchData))
|
||||
}
|
||||
|
||||
// Verify magic byte (should be 2 for version 2)
|
||||
magicByte := batchData[16] // Magic byte is at offset 16
|
||||
if magicByte != 2 {
|
||||
t.Errorf("Invalid magic byte: got %d, want 2", magicByte)
|
||||
}
|
||||
|
||||
t.Logf("Successfully converted %d records to %d byte batch", len(seaweedRecords), len(batchData))
|
||||
}
|
||||
|
||||
// TestSeaweedMQHandler_ConvertSeaweedToKafkaRecordBatch_EmptyRecords tests empty batch handling
|
||||
func TestSeaweedMQHandler_ConvertSeaweedToKafkaRecordBatch_EmptyRecords(t *testing.T) {
|
||||
handler := &SeaweedMQHandler{}
|
||||
|
||||
batchData, err := handler.convertSeaweedToKafkaRecordBatch([]*SeaweedRecord{}, 0, 1024)
|
||||
if err != nil {
|
||||
t.Errorf("Converting empty records should not fail: %v", err)
|
||||
}
|
||||
|
||||
if len(batchData) != 0 {
|
||||
t.Errorf("Empty record batch should be empty, got %d bytes", len(batchData))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeaweedMQHandler_ConvertSingleSeaweedRecord tests individual record conversion
|
||||
func TestSeaweedMQHandler_ConvertSingleSeaweedRecord(t *testing.T) {
|
||||
handler := &SeaweedMQHandler{}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
record *SeaweedRecord
|
||||
index int64
|
||||
base int64
|
||||
}{
|
||||
{
|
||||
name: "Record with key and value",
|
||||
record: &SeaweedRecord{
|
||||
Key: []byte("test-key"),
|
||||
Value: []byte("test-value"),
|
||||
Timestamp: 1000000000,
|
||||
Offset: 5,
|
||||
},
|
||||
index: 0,
|
||||
base: 5,
|
||||
},
|
||||
{
|
||||
name: "Record with null key",
|
||||
record: &SeaweedRecord{
|
||||
Key: nil,
|
||||
Value: []byte("test-value-no-key"),
|
||||
Timestamp: 1000000001,
|
||||
Offset: 6,
|
||||
},
|
||||
index: 1,
|
||||
base: 5,
|
||||
},
|
||||
{
|
||||
name: "Record with empty value",
|
||||
record: &SeaweedRecord{
|
||||
Key: []byte("test-key-empty-value"),
|
||||
Value: []byte{},
|
||||
Timestamp: 1000000002,
|
||||
Offset: 7,
|
||||
},
|
||||
index: 2,
|
||||
base: 5,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
recordData := handler.convertSingleSeaweedRecord(tc.record, tc.index, tc.base)
|
||||
|
||||
if len(recordData) == 0 {
|
||||
t.Errorf("Record data should not be empty")
|
||||
}
|
||||
|
||||
// Basic validation - should have at least attributes, timestamp delta, offset delta, key length, value length, headers count
|
||||
if len(recordData) < 6 {
|
||||
t.Errorf("Record data too small: got %d bytes", len(recordData))
|
||||
}
|
||||
|
||||
// Verify record structure
|
||||
pos := 0
|
||||
|
||||
// Attributes (1 byte)
|
||||
if recordData[pos] != 0 {
|
||||
t.Errorf("Expected attributes to be 0, got %d", recordData[pos])
|
||||
}
|
||||
pos++
|
||||
|
||||
// Timestamp delta (1 byte simplified)
|
||||
pos++
|
||||
|
||||
// Offset delta (1 byte simplified)
|
||||
if recordData[pos] != byte(tc.index) {
|
||||
t.Errorf("Expected offset delta %d, got %d", tc.index, recordData[pos])
|
||||
}
|
||||
pos++
|
||||
|
||||
t.Logf("Successfully converted single record: %d bytes", len(recordData))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Integration tests
|
||||
|
||||
// TestSeaweedMQHandler_Creation tests handler creation and shutdown
|
||||
func TestSeaweedMQHandler_Creation(t *testing.T) {
|
||||
// Skip if no real broker available
|
||||
t.Skip("Integration test requires real SeaweedMQ Broker - run manually with broker available")
|
||||
|
||||
handler, err := NewSeaweedMQBrokerHandler("localhost:9333", "default", "localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SeaweedMQ handler: %v", err)
|
||||
}
|
||||
defer handler.Close()
|
||||
|
||||
// Test basic operations
|
||||
topics := handler.ListTopics()
|
||||
if topics == nil {
|
||||
t.Errorf("ListTopics returned nil")
|
||||
}
|
||||
|
||||
t.Logf("SeaweedMQ handler created successfully, found %d existing topics", len(topics))
|
||||
}
|
||||
|
||||
// TestSeaweedMQHandler_TopicLifecycle tests topic creation and deletion
|
||||
func TestSeaweedMQHandler_TopicLifecycle(t *testing.T) {
|
||||
t.Skip("Integration test requires real SeaweedMQ Broker - run manually with broker available")
|
||||
|
||||
handler, err := NewSeaweedMQBrokerHandler("localhost:9333", "default", "localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SeaweedMQ handler: %v", err)
|
||||
}
|
||||
defer handler.Close()
|
||||
|
||||
topicName := "lifecycle-test-topic"
|
||||
|
||||
// Initially should not exist
|
||||
if handler.TopicExists(topicName) {
|
||||
t.Errorf("Topic %s should not exist initially", topicName)
|
||||
}
|
||||
|
||||
// Create the topic
|
||||
err = handler.CreateTopic(topicName, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create topic: %v", err)
|
||||
}
|
||||
|
||||
// Now should exist
|
||||
if !handler.TopicExists(topicName) {
|
||||
t.Errorf("Topic %s should exist after creation", topicName)
|
||||
}
|
||||
|
||||
// Get topic info
|
||||
info, exists := handler.GetTopicInfo(topicName)
|
||||
if !exists {
|
||||
t.Errorf("Topic info should exist")
|
||||
}
|
||||
|
||||
if info.Name != topicName {
|
||||
t.Errorf("Topic name mismatch: got %s, want %s", info.Name, topicName)
|
||||
}
|
||||
|
||||
if info.Partitions != 1 {
|
||||
t.Errorf("Partition count mismatch: got %d, want 1", info.Partitions)
|
||||
}
|
||||
|
||||
// Try to create again (should fail)
|
||||
err = handler.CreateTopic(topicName, 1)
|
||||
if err == nil {
|
||||
t.Errorf("Creating existing topic should fail")
|
||||
}
|
||||
|
||||
// Delete the topic
|
||||
err = handler.DeleteTopic(topicName)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete topic: %v", err)
|
||||
}
|
||||
|
||||
// Should no longer exist
|
||||
if handler.TopicExists(topicName) {
|
||||
t.Errorf("Topic %s should not exist after deletion", topicName)
|
||||
}
|
||||
|
||||
t.Logf("Topic lifecycle test completed successfully")
|
||||
}
|
||||
|
||||
// TestSeaweedMQHandler_ProduceRecord tests message production
|
||||
func TestSeaweedMQHandler_ProduceRecord(t *testing.T) {
|
||||
t.Skip("Integration test requires real SeaweedMQ Broker - run manually with broker available")
|
||||
|
||||
handler, err := NewSeaweedMQBrokerHandler("localhost:9333", "default", "localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SeaweedMQ handler: %v", err)
|
||||
}
|
||||
defer handler.Close()
|
||||
|
||||
topicName := "produce-test-topic"
|
||||
|
||||
// Create topic
|
||||
err = handler.CreateTopic(topicName, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create topic: %v", err)
|
||||
}
|
||||
defer handler.DeleteTopic(topicName)
|
||||
|
||||
// Produce a record
|
||||
key := []byte("produce-key")
|
||||
value := []byte("produce-value")
|
||||
|
||||
offset, err := handler.ProduceRecord(context.Background(), topicName, 0, key, value)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to produce record: %v", err)
|
||||
}
|
||||
|
||||
if offset < 0 {
|
||||
t.Errorf("Invalid offset: %d", offset)
|
||||
}
|
||||
|
||||
// Check high water mark from broker (ledgers removed - broker handles offset management)
|
||||
hwm, err := handler.GetLatestOffset(topicName, 0)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get high water mark: %v", err)
|
||||
}
|
||||
|
||||
if hwm != offset+1 {
|
||||
t.Errorf("High water mark mismatch: got %d, want %d", hwm, offset+1)
|
||||
}
|
||||
|
||||
t.Logf("Produced record at offset %d, HWM: %d", offset, hwm)
|
||||
}
|
||||
|
||||
// TestSeaweedMQHandler_MultiplePartitions tests multiple partition handling
|
||||
func TestSeaweedMQHandler_MultiplePartitions(t *testing.T) {
|
||||
t.Skip("Integration test requires real SeaweedMQ Broker - run manually with broker available")
|
||||
|
||||
handler, err := NewSeaweedMQBrokerHandler("localhost:9333", "default", "localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SeaweedMQ handler: %v", err)
|
||||
}
|
||||
defer handler.Close()
|
||||
|
||||
topicName := "multi-partition-test-topic"
|
||||
numPartitions := int32(3)
|
||||
|
||||
// Create topic with multiple partitions
|
||||
err = handler.CreateTopic(topicName, numPartitions)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create topic: %v", err)
|
||||
}
|
||||
defer handler.DeleteTopic(topicName)
|
||||
|
||||
// Produce to different partitions
|
||||
for partitionID := int32(0); partitionID < numPartitions; partitionID++ {
|
||||
key := []byte("partition-key")
|
||||
value := []byte("partition-value")
|
||||
|
||||
offset, err := handler.ProduceRecord(context.Background(), topicName, partitionID, key, value)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to produce to partition %d: %v", partitionID, err)
|
||||
}
|
||||
|
||||
// Verify offset from broker (ledgers removed - broker handles offset management)
|
||||
hwm, err := handler.GetLatestOffset(topicName, partitionID)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get high water mark for partition %d: %v", partitionID, err)
|
||||
} else if hwm <= offset {
|
||||
t.Errorf("High water mark should be greater than produced offset for partition %d: hwm=%d, offset=%d", partitionID, hwm, offset)
|
||||
}
|
||||
|
||||
t.Logf("Partition %d: produced at offset %d", partitionID, offset)
|
||||
}
|
||||
|
||||
t.Logf("Multi-partition test completed successfully")
|
||||
}
|
||||
|
||||
// TestSeaweedMQHandler_FetchRecords tests record fetching with real SeaweedMQ data
|
||||
func TestSeaweedMQHandler_FetchRecords(t *testing.T) {
|
||||
t.Skip("Integration test requires real SeaweedMQ Broker - run manually with broker available")
|
||||
|
||||
handler, err := NewSeaweedMQBrokerHandler("localhost:9333", "default", "localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SeaweedMQ handler: %v", err)
|
||||
}
|
||||
defer handler.Close()
|
||||
|
||||
topicName := "fetch-test-topic"
|
||||
|
||||
// Create topic
|
||||
err = handler.CreateTopic(topicName, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create topic: %v", err)
|
||||
}
|
||||
defer handler.DeleteTopic(topicName)
|
||||
|
||||
// Produce some test records with known data
|
||||
testRecords := []struct {
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{"fetch-key-1", "fetch-value-1"},
|
||||
{"fetch-key-2", "fetch-value-2"},
|
||||
{"fetch-key-3", "fetch-value-3"},
|
||||
}
|
||||
|
||||
var producedOffsets []int64
|
||||
for i, record := range testRecords {
|
||||
offset, err := handler.ProduceRecord(context.Background(), topicName, 0, []byte(record.key), []byte(record.value))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to produce record %d: %v", i, err)
|
||||
}
|
||||
producedOffsets = append(producedOffsets, offset)
|
||||
t.Logf("Produced record %d at offset %d: key=%s, value=%s", i, offset, record.key, record.value)
|
||||
}
|
||||
|
||||
// Wait a bit for records to be available in SeaweedMQ
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Test fetching from beginning
|
||||
fetchedBatch, err := handler.FetchRecords(topicName, 0, 0, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch records: %v", err)
|
||||
}
|
||||
|
||||
if len(fetchedBatch) == 0 {
|
||||
t.Errorf("No record data fetched - this indicates the FetchRecords implementation is not working properly")
|
||||
} else {
|
||||
t.Logf("Successfully fetched %d bytes of real record batch data", len(fetchedBatch))
|
||||
|
||||
// Basic validation of Kafka record batch format
|
||||
if len(fetchedBatch) >= 61 { // Minimum Kafka record batch size
|
||||
// Check magic byte (at offset 16)
|
||||
magicByte := fetchedBatch[16]
|
||||
if magicByte == 2 {
|
||||
t.Logf("✓ Valid Kafka record batch format detected (magic byte = 2)")
|
||||
} else {
|
||||
t.Errorf("Invalid Kafka record batch magic byte: got %d, want 2", magicByte)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Fetched batch too small to be valid Kafka record batch: %d bytes", len(fetchedBatch))
|
||||
}
|
||||
}
|
||||
|
||||
// Test fetching from specific offset
|
||||
if len(producedOffsets) > 1 {
|
||||
partialBatch, err := handler.FetchRecords(topicName, 0, producedOffsets[1], 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch from specific offset: %v", err)
|
||||
}
|
||||
t.Logf("Fetched %d bytes starting from offset %d", len(partialBatch), producedOffsets[1])
|
||||
}
|
||||
|
||||
// Test fetching beyond high water mark (ledgers removed - use broker offset management)
|
||||
hwm, err := handler.GetLatestOffset(topicName, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get high water mark: %v", err)
|
||||
}
|
||||
|
||||
emptyBatch, err := handler.FetchRecords(topicName, 0, hwm, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to fetch from HWM: %v", err)
|
||||
}
|
||||
|
||||
if len(emptyBatch) != 0 {
|
||||
t.Errorf("Should get empty batch beyond HWM, got %d bytes", len(emptyBatch))
|
||||
}
|
||||
|
||||
t.Logf("✓ Real data fetch test completed successfully - FetchRecords is now working with actual SeaweedMQ data!")
|
||||
}
|
||||
|
||||
// TestSeaweedMQHandler_FetchRecords_ErrorHandling tests error cases for fetching
|
||||
func TestSeaweedMQHandler_FetchRecords_ErrorHandling(t *testing.T) {
|
||||
t.Skip("Integration test requires real SeaweedMQ Broker - run manually with broker available")
|
||||
|
||||
handler, err := NewSeaweedMQBrokerHandler("localhost:9333", "default", "localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SeaweedMQ handler: %v", err)
|
||||
}
|
||||
defer handler.Close()
|
||||
|
||||
// Test fetching from non-existent topic
|
||||
_, err = handler.FetchRecords("non-existent-topic", 0, 0, 1024)
|
||||
if err == nil {
|
||||
t.Errorf("Fetching from non-existent topic should fail")
|
||||
}
|
||||
|
||||
// Create topic for partition tests
|
||||
topicName := "fetch-error-test-topic"
|
||||
err = handler.CreateTopic(topicName, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create topic: %v", err)
|
||||
}
|
||||
defer handler.DeleteTopic(topicName)
|
||||
|
||||
// Test fetching from non-existent partition (partition 1 when only 0 exists)
|
||||
batch, err := handler.FetchRecords(topicName, 1, 0, 1024)
|
||||
// This may or may not fail depending on implementation, but should return empty batch
|
||||
if err != nil {
|
||||
t.Logf("Expected behavior: fetching from non-existent partition failed: %v", err)
|
||||
} else if len(batch) > 0 {
|
||||
t.Errorf("Fetching from non-existent partition should return empty batch, got %d bytes", len(batch))
|
||||
}
|
||||
|
||||
// Test with very small maxBytes
|
||||
_, err = handler.ProduceRecord(context.Background(), topicName, 0, []byte("key"), []byte("value"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to produce test record: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
smallBatch, err := handler.FetchRecords(topicName, 0, 0, 1) // Very small maxBytes
|
||||
if err != nil {
|
||||
t.Errorf("Fetching with small maxBytes should not fail: %v", err)
|
||||
}
|
||||
t.Logf("Fetch with maxBytes=1 returned %d bytes", len(smallBatch))
|
||||
|
||||
t.Logf("Error handling test completed successfully")
|
||||
}
|
||||
|
||||
// TestSeaweedMQHandler_ErrorHandling tests error conditions
|
||||
func TestSeaweedMQHandler_ErrorHandling(t *testing.T) {
|
||||
t.Skip("Integration test requires real SeaweedMQ Broker - run manually with broker available")
|
||||
|
||||
handler, err := NewSeaweedMQBrokerHandler("localhost:9333", "default", "localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create SeaweedMQ handler: %v", err)
|
||||
}
|
||||
defer handler.Close()
|
||||
|
||||
// Try to produce to non-existent topic
|
||||
_, err = handler.ProduceRecord(context.Background(), "non-existent-topic", 0, []byte("key"), []byte("value"))
|
||||
if err == nil {
|
||||
t.Errorf("Producing to non-existent topic should fail")
|
||||
}
|
||||
|
||||
// Try to fetch from non-existent topic
|
||||
_, err = handler.FetchRecords("non-existent-topic", 0, 0, 1024)
|
||||
if err == nil {
|
||||
t.Errorf("Fetching from non-existent topic should fail")
|
||||
}
|
||||
|
||||
// Try to delete non-existent topic
|
||||
err = handler.DeleteTopic("non-existent-topic")
|
||||
if err == nil {
|
||||
t.Errorf("Deleting non-existent topic should fail")
|
||||
}
|
||||
|
||||
t.Logf("Error handling test completed successfully")
|
||||
}
|
||||
286
weed/mq/kafka/integration/seaweedmq_handler_topics.go
Normal file
286
weed/mq/kafka/integration/seaweedmq_handler_topics.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/mq/schema"
|
||||
"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/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
// ErrTopicAlreadyExists is returned by the CreateTopic* methods when the topic
|
||||
// already exists in the filer. Callers performing auto-creation can treat it as
|
||||
// confirmation the topic exists rather than a hard failure.
|
||||
var ErrTopicAlreadyExists = errors.New("topic already exists")
|
||||
|
||||
// CreateTopic creates a new topic in both Kafka registry and SeaweedMQ
|
||||
func (h *SeaweedMQHandler) CreateTopic(name string, partitions int32) error {
|
||||
return h.CreateTopicWithSchema(name, partitions, nil)
|
||||
}
|
||||
|
||||
// CreateTopicWithSchema creates a topic with optional value schema
|
||||
func (h *SeaweedMQHandler) CreateTopicWithSchema(name string, partitions int32, recordType *schema_pb.RecordType) error {
|
||||
return h.CreateTopicWithSchemas(name, partitions, nil, recordType)
|
||||
}
|
||||
|
||||
// CreateTopicWithSchemas creates a topic with optional key and value schemas
|
||||
func (h *SeaweedMQHandler) CreateTopicWithSchemas(name string, partitions int32, keyRecordType *schema_pb.RecordType, valueRecordType *schema_pb.RecordType) error {
|
||||
// Check if topic already exists in filer
|
||||
if h.checkTopicInFiler(name) {
|
||||
return fmt.Errorf("%s: %w", name, ErrTopicAlreadyExists)
|
||||
}
|
||||
|
||||
// Create SeaweedMQ topic reference
|
||||
seaweedTopic := &schema_pb.Topic{
|
||||
Namespace: "kafka",
|
||||
Name: name,
|
||||
}
|
||||
|
||||
// Configure topic with SeaweedMQ broker via gRPC
|
||||
if len(h.brokerAddresses) > 0 {
|
||||
brokerAddress := h.brokerAddresses[0] // Use first available broker
|
||||
glog.V(1).Infof("Configuring topic %s with broker %s", name, brokerAddress)
|
||||
|
||||
// Load security configuration for broker connection
|
||||
util.LoadSecurityConfiguration()
|
||||
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.mq")
|
||||
|
||||
err := pb.WithBrokerGrpcClient(false, brokerAddress, grpcDialOption, func(client mq_pb.SeaweedMessagingClient) error {
|
||||
// Convert dual schemas to flat schema format
|
||||
var flatSchema *schema_pb.RecordType
|
||||
var keyColumns []string
|
||||
if keyRecordType != nil || valueRecordType != nil {
|
||||
flatSchema, keyColumns = schema.CombineFlatSchemaFromKeyValue(keyRecordType, valueRecordType)
|
||||
}
|
||||
|
||||
_, err := client.ConfigureTopic(context.Background(), &mq_pb.ConfigureTopicRequest{
|
||||
Topic: seaweedTopic,
|
||||
PartitionCount: partitions,
|
||||
MessageRecordType: flatSchema,
|
||||
KeyColumns: keyColumns,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure topic with broker: %w", err)
|
||||
}
|
||||
glog.V(1).Infof("successfully configured topic %s with broker", name)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to configure topic %s with broker %s: %w", name, brokerAddress, err)
|
||||
}
|
||||
} else {
|
||||
glog.Warningf("No brokers available - creating topic %s in gateway memory only (testing mode)", name)
|
||||
}
|
||||
|
||||
// Topic is now stored in filer only via SeaweedMQ broker
|
||||
// No need to create in-memory topic info structure
|
||||
|
||||
// Offset management now handled directly by SMQ broker - no initialization needed
|
||||
|
||||
// Invalidate cache after successful topic creation
|
||||
h.InvalidateTopicExistsCache(name)
|
||||
|
||||
glog.V(1).Infof("Topic %s created successfully with %d partitions", name, partitions)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateTopicWithRecordType creates a topic with flat schema and key columns
|
||||
func (h *SeaweedMQHandler) CreateTopicWithRecordType(name string, partitions int32, flatSchema *schema_pb.RecordType, keyColumns []string) error {
|
||||
// Check if topic already exists in filer
|
||||
if h.checkTopicInFiler(name) {
|
||||
return fmt.Errorf("%s: %w", name, ErrTopicAlreadyExists)
|
||||
}
|
||||
|
||||
// Create SeaweedMQ topic reference
|
||||
seaweedTopic := &schema_pb.Topic{
|
||||
Namespace: "kafka",
|
||||
Name: name,
|
||||
}
|
||||
|
||||
// Configure topic with SeaweedMQ broker via gRPC
|
||||
if len(h.brokerAddresses) > 0 {
|
||||
brokerAddress := h.brokerAddresses[0] // Use first available broker
|
||||
glog.V(1).Infof("Configuring topic %s with broker %s", name, brokerAddress)
|
||||
|
||||
// Load security configuration for broker connection
|
||||
util.LoadSecurityConfiguration()
|
||||
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.mq")
|
||||
|
||||
err := pb.WithBrokerGrpcClient(false, brokerAddress, grpcDialOption, func(client mq_pb.SeaweedMessagingClient) error {
|
||||
_, err := client.ConfigureTopic(context.Background(), &mq_pb.ConfigureTopicRequest{
|
||||
Topic: seaweedTopic,
|
||||
PartitionCount: partitions,
|
||||
MessageRecordType: flatSchema,
|
||||
KeyColumns: keyColumns,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to configure topic: %w", err)
|
||||
}
|
||||
|
||||
glog.V(1).Infof("successfully configured topic %s with broker", name)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
glog.Warningf("No broker addresses configured, topic %s not created in SeaweedMQ", name)
|
||||
}
|
||||
|
||||
// Topic is now stored in filer only via SeaweedMQ broker
|
||||
// No need to create in-memory topic info structure
|
||||
|
||||
glog.V(1).Infof("Topic %s created successfully with %d partitions using flat schema", name, partitions)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTopic removes a topic from both Kafka registry and SeaweedMQ
|
||||
func (h *SeaweedMQHandler) DeleteTopic(name string) error {
|
||||
// Check if topic exists in filer
|
||||
if !h.checkTopicInFiler(name) {
|
||||
return fmt.Errorf("topic %s does not exist", name)
|
||||
}
|
||||
|
||||
// Get topic info to determine partition count for cleanup
|
||||
topicInfo, exists := h.GetTopicInfo(name)
|
||||
if !exists {
|
||||
return fmt.Errorf("topic %s info not found", name)
|
||||
}
|
||||
|
||||
// Close all publisher sessions for this topic
|
||||
for partitionID := int32(0); partitionID < topicInfo.Partitions; partitionID++ {
|
||||
if h.brokerClient != nil {
|
||||
h.brokerClient.ClosePublisher(name, partitionID)
|
||||
}
|
||||
}
|
||||
|
||||
// Topic removal from filer would be handled by SeaweedMQ broker
|
||||
// No in-memory cache to clean up
|
||||
|
||||
// Offset management handled by SMQ broker - no cleanup needed
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TopicExists checks if a topic exists in SeaweedMQ broker (includes in-memory topics)
|
||||
// Uses a 5-second cache to reduce broker queries
|
||||
func (h *SeaweedMQHandler) TopicExists(name string) bool {
|
||||
// Check cache first
|
||||
h.topicExistsCacheMu.RLock()
|
||||
if entry, found := h.topicExistsCache[name]; found {
|
||||
if time.Now().Before(entry.expiresAt) {
|
||||
h.topicExistsCacheMu.RUnlock()
|
||||
return entry.exists
|
||||
}
|
||||
}
|
||||
h.topicExistsCacheMu.RUnlock()
|
||||
|
||||
// Cache miss or expired - query broker
|
||||
|
||||
var exists bool
|
||||
// Check via SeaweedMQ broker (includes in-memory topics)
|
||||
if h.brokerClient != nil {
|
||||
var err error
|
||||
exists, err = h.brokerClient.TopicExists(name)
|
||||
if err != nil {
|
||||
// Don't cache errors
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// Return false if broker is unavailable
|
||||
return false
|
||||
}
|
||||
|
||||
// Update cache
|
||||
h.topicExistsCacheMu.Lock()
|
||||
h.topicExistsCache[name] = &topicExistsCacheEntry{
|
||||
exists: exists,
|
||||
expiresAt: time.Now().Add(h.topicExistsCacheTTL),
|
||||
}
|
||||
h.topicExistsCacheMu.Unlock()
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
// InvalidateTopicExistsCache removes a topic from the existence cache
|
||||
// Should be called after creating or deleting a topic
|
||||
func (h *SeaweedMQHandler) InvalidateTopicExistsCache(name string) {
|
||||
h.topicExistsCacheMu.Lock()
|
||||
delete(h.topicExistsCache, name)
|
||||
h.topicExistsCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// GetTopicInfo returns information about a topic from broker
|
||||
func (h *SeaweedMQHandler) GetTopicInfo(name string) (*KafkaTopicInfo, bool) {
|
||||
// Get topic configuration from broker
|
||||
if h.brokerClient != nil {
|
||||
config, err := h.brokerClient.GetTopicConfiguration(name)
|
||||
if err == nil && config != nil {
|
||||
topicInfo := &KafkaTopicInfo{
|
||||
Name: name,
|
||||
Partitions: config.PartitionCount,
|
||||
CreatedAt: config.CreatedAtNs,
|
||||
}
|
||||
return topicInfo, true
|
||||
}
|
||||
glog.V(2).Infof("Failed to get topic configuration for %s from broker: %v", name, err)
|
||||
}
|
||||
|
||||
// Fallback: check if topic exists in filer (for backward compatibility)
|
||||
if !h.checkTopicInFiler(name) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Return default info if broker query failed but topic exists in filer
|
||||
topicInfo := &KafkaTopicInfo{
|
||||
Name: name,
|
||||
Partitions: 1, // Default to 1 partition if broker query failed
|
||||
CreatedAt: 0,
|
||||
}
|
||||
|
||||
return topicInfo, true
|
||||
}
|
||||
|
||||
// ListTopics returns all topic names from SeaweedMQ broker (includes in-memory topics)
|
||||
func (h *SeaweedMQHandler) ListTopics() []string {
|
||||
// Get topics from SeaweedMQ broker (includes in-memory topics)
|
||||
if h.brokerClient != nil {
|
||||
topics, err := h.brokerClient.ListTopics()
|
||||
if err == nil {
|
||||
return topics
|
||||
}
|
||||
}
|
||||
|
||||
// Return empty list if broker is unavailable
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// checkTopicInFiler checks if a topic exists in the filer
|
||||
func (h *SeaweedMQHandler) checkTopicInFiler(topicName string) bool {
|
||||
if h.filerClientAccessor == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var exists bool
|
||||
h.filerClientAccessor.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
|
||||
request := &filer_pb.LookupDirectoryEntryRequest{
|
||||
Directory: "/topics/kafka",
|
||||
Name: topicName,
|
||||
}
|
||||
|
||||
_, err := client.LookupDirectoryEntry(context.Background(), request)
|
||||
exists = (err == nil)
|
||||
return nil // Don't propagate error, just check existence
|
||||
})
|
||||
|
||||
return exists
|
||||
}
|
||||
217
weed/mq/kafka/integration/seaweedmq_handler_utils.go
Normal file
217
weed/mq/kafka/integration/seaweedmq_handler_utils.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/cluster"
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer_client"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/security"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
||||
)
|
||||
|
||||
// NewSeaweedMQBrokerHandler creates a new handler with SeaweedMQ broker integration
|
||||
func NewSeaweedMQBrokerHandler(masters string, filerGroup string, clientHost string) (*SeaweedMQHandler, error) {
|
||||
if masters == "" {
|
||||
return nil, fmt.Errorf("masters required - SeaweedMQ infrastructure must be configured")
|
||||
}
|
||||
|
||||
// Parse master addresses using SeaweedFS utilities
|
||||
masterServerAddresses := pb.ServerAddresses(masters).ToAddresses()
|
||||
if len(masterServerAddresses) == 0 {
|
||||
return nil, fmt.Errorf("no valid master addresses provided")
|
||||
}
|
||||
|
||||
// Load security configuration for gRPC connections
|
||||
util.LoadSecurityConfiguration()
|
||||
grpcDialOption := security.LoadClientTLS(util.GetViper(), "grpc.mq")
|
||||
masterDiscovery := pb.ServerAddresses(masters).ToServiceDiscovery()
|
||||
|
||||
// Use provided client host for proper gRPC connection
|
||||
// This is critical for MasterClient to establish streaming connections
|
||||
clientHostAddr := pb.ServerAddress(clientHost)
|
||||
|
||||
masterClient := wdclient.NewMasterClient(grpcDialOption, filerGroup, "kafka-gateway", clientHostAddr, "", "", *masterDiscovery)
|
||||
|
||||
glog.V(1).Infof("Created MasterClient with clientHost=%s, masters=%s", clientHost, masters)
|
||||
|
||||
// Start KeepConnectedToMaster in background to maintain connection
|
||||
glog.V(1).Infof("Starting KeepConnectedToMaster background goroutine...")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
defer cancel()
|
||||
masterClient.KeepConnectedToMaster(ctx)
|
||||
}()
|
||||
|
||||
// Give the connection a moment to establish
|
||||
time.Sleep(2 * time.Second)
|
||||
glog.V(1).Infof("Initial connection delay completed")
|
||||
|
||||
// Discover brokers from masters using master client
|
||||
glog.V(1).Infof("About to call discoverBrokersWithMasterClient...")
|
||||
brokerAddresses, err := discoverBrokersWithMasterClient(ctx, masterClient, filerGroup)
|
||||
if err != nil {
|
||||
glog.Errorf("Broker discovery failed: %v", err)
|
||||
return nil, fmt.Errorf("failed to discover brokers: %v", err)
|
||||
}
|
||||
glog.V(1).Infof("Broker discovery returned: %v", brokerAddresses)
|
||||
|
||||
if len(brokerAddresses) == 0 {
|
||||
return nil, fmt.Errorf("no brokers discovered from masters")
|
||||
}
|
||||
|
||||
// Discover filers from masters using master client
|
||||
filerAddresses, err := discoverFilersWithMasterClient(ctx, masterClient, filerGroup)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to discover filers: %v", err)
|
||||
}
|
||||
|
||||
// Create shared filer client accessor for all components
|
||||
sharedFilerAccessor := filer_client.NewFilerClientAccessor(
|
||||
filerAddresses,
|
||||
grpcDialOption,
|
||||
)
|
||||
|
||||
// For now, use the first broker (can be enhanced later for load balancing)
|
||||
brokerAddress := brokerAddresses[0]
|
||||
|
||||
// Create broker client with shared filer accessor
|
||||
brokerClient, err := NewBrokerClientWithFilerAccessor(brokerAddress, sharedFilerAccessor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create broker client: %v", err)
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
if err := brokerClient.HealthCheck(); err != nil {
|
||||
brokerClient.Close()
|
||||
return nil, fmt.Errorf("broker health check failed: %v", err)
|
||||
}
|
||||
|
||||
return &SeaweedMQHandler{
|
||||
filerClientAccessor: sharedFilerAccessor,
|
||||
brokerClient: brokerClient,
|
||||
masterClient: masterClient,
|
||||
// topics map removed - always read from filer directly
|
||||
// ledgers removed - SMQ broker handles all offset management
|
||||
brokerAddresses: brokerAddresses, // Store all discovered broker addresses
|
||||
hwmCache: make(map[string]*hwmCacheEntry),
|
||||
hwmCacheTTL: 100 * time.Millisecond, // 100ms cache TTL for fresh HWM reads (critical for Schema Registry)
|
||||
topicExistsCache: make(map[string]*topicExistsCacheEntry),
|
||||
topicExistsCacheTTL: 5 * time.Second, // 5 second cache TTL for topic existence
|
||||
}, nil
|
||||
}
|
||||
|
||||
// discoverBrokersWithMasterClient queries masters for available brokers using reusable master client
|
||||
func discoverBrokersWithMasterClient(ctx context.Context, masterClient *wdclient.MasterClient, filerGroup string) ([]string, error) {
|
||||
var brokers []string
|
||||
|
||||
err := masterClient.WithClient(ctx, false, func(client master_pb.SeaweedClient) error {
|
||||
glog.V(1).Infof("Inside MasterClient.WithClient callback - client obtained successfully")
|
||||
resp, err := client.ListClusterNodes(ctx, &master_pb.ListClusterNodesRequest{
|
||||
ClientType: cluster.BrokerType,
|
||||
FilerGroup: filerGroup,
|
||||
Limit: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
glog.V(1).Infof("list cluster nodes successful - found %d cluster nodes", len(resp.ClusterNodes))
|
||||
|
||||
// Extract broker addresses from response
|
||||
for _, node := range resp.ClusterNodes {
|
||||
if node.Address != "" {
|
||||
brokers = append(brokers, node.Address)
|
||||
glog.V(1).Infof("discovered broker: %s", node.Address)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
glog.Errorf("MasterClient.WithClient failed: %v", err)
|
||||
} else {
|
||||
glog.V(1).Infof("Broker discovery completed successfully - found %d brokers: %v", len(brokers), brokers)
|
||||
}
|
||||
|
||||
return brokers, err
|
||||
}
|
||||
|
||||
// discoverFilersWithMasterClient queries masters for available filers using reusable master client
|
||||
func discoverFilersWithMasterClient(ctx context.Context, masterClient *wdclient.MasterClient, filerGroup string) ([]pb.ServerAddress, error) {
|
||||
var filers []pb.ServerAddress
|
||||
|
||||
err := masterClient.WithClient(ctx, false, func(client master_pb.SeaweedClient) error {
|
||||
resp, err := client.ListClusterNodes(ctx, &master_pb.ListClusterNodesRequest{
|
||||
ClientType: cluster.FilerType,
|
||||
FilerGroup: filerGroup,
|
||||
Limit: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract filer addresses from response - return as HTTP addresses (pb.ServerAddress)
|
||||
for _, node := range resp.ClusterNodes {
|
||||
if node.Address != "" {
|
||||
// Return HTTP address as pb.ServerAddress (no pre-conversion to gRPC)
|
||||
httpAddr := pb.ServerAddress(node.Address)
|
||||
filers = append(filers, httpAddr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return filers, err
|
||||
}
|
||||
|
||||
// GetFilerClientAccessor returns the shared filer client accessor
|
||||
func (h *SeaweedMQHandler) GetFilerClientAccessor() *filer_client.FilerClientAccessor {
|
||||
return h.filerClientAccessor
|
||||
}
|
||||
|
||||
// SetProtocolHandler sets the protocol handler reference for accessing connection context
|
||||
func (h *SeaweedMQHandler) SetProtocolHandler(handler ProtocolHandler) {
|
||||
h.protocolHandler = handler
|
||||
}
|
||||
|
||||
// GetBrokerAddresses returns the discovered SMQ broker addresses
|
||||
func (h *SeaweedMQHandler) GetBrokerAddresses() []string {
|
||||
return h.brokerAddresses
|
||||
}
|
||||
|
||||
// Close shuts down the handler and all connections
|
||||
func (h *SeaweedMQHandler) Close() error {
|
||||
if h.brokerClient != nil {
|
||||
return h.brokerClient.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePerConnectionBrokerClient creates a new BrokerClient instance for a specific connection
|
||||
// CRITICAL: Each Kafka TCP connection gets its own BrokerClient to prevent gRPC stream interference
|
||||
// This fixes the deadlock where CreateFreshSubscriber would block all connections
|
||||
func (h *SeaweedMQHandler) CreatePerConnectionBrokerClient() (*BrokerClient, error) {
|
||||
// Use the same broker addresses as the shared client
|
||||
if len(h.brokerAddresses) == 0 {
|
||||
return nil, fmt.Errorf("no broker addresses available")
|
||||
}
|
||||
|
||||
// Use the first broker address (in production, could use load balancing)
|
||||
brokerAddress := h.brokerAddresses[0]
|
||||
|
||||
// Create a new client with the shared filer accessor
|
||||
client, err := NewBrokerClientWithFilerAccessor(brokerAddress, h.filerClientAccessor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create broker client: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
62
weed/mq/kafka/integration/test_helper.go
Normal file
62
weed/mq/kafka/integration/test_helper.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
|
||||
)
|
||||
|
||||
// TestSeaweedMQHandler wraps SeaweedMQHandler for testing
|
||||
type TestSeaweedMQHandler struct {
|
||||
handler *SeaweedMQHandler
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
// NewTestSeaweedMQHandler creates a new test handler with in-memory storage
|
||||
func NewTestSeaweedMQHandler(t *testing.T) *TestSeaweedMQHandler {
|
||||
// For now, return a stub implementation
|
||||
// Full implementation will be added when needed
|
||||
return &TestSeaweedMQHandler{
|
||||
handler: nil,
|
||||
t: t,
|
||||
}
|
||||
}
|
||||
|
||||
// ProduceMessage produces a message to a topic partition
|
||||
func (h *TestSeaweedMQHandler) ProduceMessage(ctx context.Context, topic, partition string, record *schema_pb.RecordValue, key []byte) error {
|
||||
// This will be implemented to use the handler's produce logic
|
||||
// For now, return a placeholder
|
||||
return fmt.Errorf("ProduceMessage not yet implemented")
|
||||
}
|
||||
|
||||
// CommitOffset commits an offset for a consumer group
|
||||
func (h *TestSeaweedMQHandler) CommitOffset(ctx context.Context, consumerGroup string, topic string, partition int32, offset int64, metadata string) error {
|
||||
// This will be implemented to use the handler's offset commit logic
|
||||
return fmt.Errorf("CommitOffset not yet implemented")
|
||||
}
|
||||
|
||||
// FetchOffset fetches the committed offset for a consumer group
|
||||
func (h *TestSeaweedMQHandler) FetchOffset(ctx context.Context, consumerGroup string, topic string, partition int32) (int64, string, error) {
|
||||
// This will be implemented to use the handler's offset fetch logic
|
||||
return -1, "", fmt.Errorf("FetchOffset not yet implemented")
|
||||
}
|
||||
|
||||
// FetchMessages fetches messages from a topic partition starting at an offset
|
||||
func (h *TestSeaweedMQHandler) FetchMessages(ctx context.Context, topic string, partition int32, startOffset int64, maxBytes int32) ([]*Message, error) {
|
||||
// This will be implemented to use the handler's fetch logic
|
||||
return nil, fmt.Errorf("FetchMessages not yet implemented")
|
||||
}
|
||||
|
||||
// Cleanup cleans up test resources
|
||||
func (h *TestSeaweedMQHandler) Cleanup() {
|
||||
// Cleanup resources when implemented
|
||||
}
|
||||
|
||||
// Message represents a fetched message
|
||||
type Message struct {
|
||||
Offset int64
|
||||
Key []byte
|
||||
Value []byte
|
||||
}
|
||||
240
weed/mq/kafka/integration/types.go
Normal file
240
weed/mq/kafka/integration/types.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/filer_client"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/mq_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/schema_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
||||
)
|
||||
|
||||
// SMQRecord interface for records from SeaweedMQ
|
||||
type SMQRecord interface {
|
||||
GetKey() []byte
|
||||
GetValue() []byte
|
||||
GetTimestamp() int64
|
||||
GetOffset() int64
|
||||
}
|
||||
|
||||
// hwmCacheEntry represents a cached high water mark value
|
||||
type hwmCacheEntry struct {
|
||||
value int64
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// topicExistsCacheEntry represents a cached topic existence check
|
||||
type topicExistsCacheEntry struct {
|
||||
exists bool
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// SeaweedMQHandler integrates Kafka protocol handlers with real SeaweedMQ storage
|
||||
type SeaweedMQHandler struct {
|
||||
// Shared filer client accessor for all components
|
||||
filerClientAccessor *filer_client.FilerClientAccessor
|
||||
|
||||
brokerClient *BrokerClient // For broker-based connections
|
||||
|
||||
// Master client for service discovery
|
||||
masterClient *wdclient.MasterClient
|
||||
|
||||
// Discovered broker addresses (for Metadata responses)
|
||||
brokerAddresses []string
|
||||
|
||||
// Reference to protocol handler for accessing connection context
|
||||
protocolHandler ProtocolHandler
|
||||
|
||||
// High water mark cache to reduce broker queries
|
||||
hwmCache map[string]*hwmCacheEntry // key: "topic:partition"
|
||||
hwmCacheMu sync.RWMutex
|
||||
hwmCacheTTL time.Duration
|
||||
|
||||
// Topic existence cache to reduce broker queries
|
||||
topicExistsCache map[string]*topicExistsCacheEntry // key: "topic"
|
||||
topicExistsCacheMu sync.RWMutex
|
||||
topicExistsCacheTTL time.Duration
|
||||
}
|
||||
|
||||
// ConnectionContext holds connection-specific information for requests
|
||||
// This is a local copy to avoid circular dependency with protocol package
|
||||
type ConnectionContext struct {
|
||||
ClientID string // Kafka client ID from request headers
|
||||
ConsumerGroup string // Consumer group (set by JoinGroup)
|
||||
MemberID string // Consumer group member ID (set by JoinGroup)
|
||||
BrokerClient interface{} // Per-connection broker client (*BrokerClient)
|
||||
}
|
||||
|
||||
// ProtocolHandler interface for accessing Handler's connection context
|
||||
type ProtocolHandler interface {
|
||||
GetConnectionContext() *ConnectionContext
|
||||
}
|
||||
|
||||
// KafkaTopicInfo holds Kafka-specific topic information
|
||||
type KafkaTopicInfo struct {
|
||||
Name string
|
||||
Partitions int32
|
||||
CreatedAt int64
|
||||
|
||||
// SeaweedMQ integration
|
||||
SeaweedTopic *schema_pb.Topic
|
||||
}
|
||||
|
||||
// TopicPartitionKey uniquely identifies a topic partition
|
||||
type TopicPartitionKey struct {
|
||||
Topic string
|
||||
Partition int32
|
||||
}
|
||||
|
||||
// SeaweedRecord represents a record received from SeaweedMQ
|
||||
type SeaweedRecord struct {
|
||||
Key []byte
|
||||
Value []byte
|
||||
Timestamp int64
|
||||
Offset int64
|
||||
}
|
||||
|
||||
// PartitionRangeInfo contains comprehensive range information for a partition
|
||||
type PartitionRangeInfo struct {
|
||||
// Offset range information
|
||||
EarliestOffset int64
|
||||
LatestOffset int64
|
||||
HighWaterMark int64
|
||||
|
||||
// Timestamp range information
|
||||
EarliestTimestampNs int64
|
||||
LatestTimestampNs int64
|
||||
|
||||
// Partition metadata
|
||||
RecordCount int64
|
||||
ActiveSubscriptions int64
|
||||
}
|
||||
|
||||
// SeaweedSMQRecord implements the SMQRecord interface for SeaweedMQ records
|
||||
type SeaweedSMQRecord struct {
|
||||
key []byte
|
||||
value []byte
|
||||
timestamp int64
|
||||
offset int64
|
||||
}
|
||||
|
||||
// GetKey returns the record key
|
||||
func (r *SeaweedSMQRecord) GetKey() []byte {
|
||||
return r.key
|
||||
}
|
||||
|
||||
// GetValue returns the record value
|
||||
func (r *SeaweedSMQRecord) GetValue() []byte {
|
||||
return r.value
|
||||
}
|
||||
|
||||
// GetTimestamp returns the record timestamp
|
||||
func (r *SeaweedSMQRecord) GetTimestamp() int64 {
|
||||
return r.timestamp
|
||||
}
|
||||
|
||||
// GetOffset returns the Kafka offset for this record
|
||||
func (r *SeaweedSMQRecord) GetOffset() int64 {
|
||||
return r.offset
|
||||
}
|
||||
|
||||
// BrokerClient wraps the SeaweedMQ Broker gRPC client for Kafka gateway integration
|
||||
// FetchRequest tracks an in-flight fetch request with multiple waiters
|
||||
type FetchRequest struct {
|
||||
topic string
|
||||
partition int32
|
||||
offset int64
|
||||
resultChan chan FetchResult // Single channel for the fetch result
|
||||
waiters []chan FetchResult // Multiple waiters can subscribe
|
||||
mu sync.Mutex
|
||||
inProgress bool
|
||||
}
|
||||
|
||||
// FetchResult contains the result of a fetch operation
|
||||
type FetchResult struct {
|
||||
records []*SeaweedRecord
|
||||
err error
|
||||
}
|
||||
|
||||
// partitionAssignmentCacheEntry caches LookupTopicBrokers results
|
||||
type partitionAssignmentCacheEntry struct {
|
||||
assignments []*mq_pb.BrokerPartitionAssignment
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type BrokerClient struct {
|
||||
// Reference to shared filer client accessor
|
||||
filerClientAccessor *filer_client.FilerClientAccessor
|
||||
|
||||
brokerAddress string
|
||||
conn *grpc.ClientConn
|
||||
client mq_pb.SeaweedMessagingClient
|
||||
|
||||
// Publisher streams: topic-partition -> stream info
|
||||
publishersLock sync.RWMutex
|
||||
publishers map[string]*BrokerPublisherSession
|
||||
|
||||
// Publisher creation locks to prevent concurrent creation attempts for the same topic-partition
|
||||
publisherCreationLocks map[string]*sync.Mutex
|
||||
|
||||
// Subscriber streams for offset tracking
|
||||
subscribersLock sync.RWMutex
|
||||
subscribers map[string]*BrokerSubscriberSession
|
||||
|
||||
// Request deduplication for stateless fetches
|
||||
fetchRequestsLock sync.Mutex
|
||||
fetchRequests map[string]*FetchRequest
|
||||
|
||||
// Partition assignment cache to reduce LookupTopicBrokers calls (13.5% CPU overhead!)
|
||||
partitionAssignmentCache map[string]*partitionAssignmentCacheEntry // Key: topic name
|
||||
partitionAssignmentCacheMu sync.RWMutex
|
||||
partitionAssignmentCacheTTL time.Duration
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// BrokerPublisherSession tracks a publishing stream to SeaweedMQ broker
|
||||
type BrokerPublisherSession struct {
|
||||
Topic string
|
||||
Partition int32
|
||||
Stream mq_pb.SeaweedMessaging_PublishMessageClient
|
||||
mu sync.Mutex // Protects Send/Recv pairs from concurrent access
|
||||
}
|
||||
|
||||
// BrokerSubscriberSession tracks a subscription stream for offset management
|
||||
type BrokerSubscriberSession struct {
|
||||
Topic string
|
||||
Partition int32
|
||||
Stream mq_pb.SeaweedMessaging_SubscribeMessageClient
|
||||
// Track the requested start offset used to initialize this stream
|
||||
StartOffset int64
|
||||
// Consumer group identity for this session
|
||||
ConsumerGroup string
|
||||
ConsumerID string
|
||||
// Context for canceling reads (used for timeout)
|
||||
Ctx context.Context
|
||||
Cancel context.CancelFunc
|
||||
// Mutex to serialize all operations on this session
|
||||
mu sync.Mutex
|
||||
// Cache of consumed records to avoid re-reading from broker
|
||||
consumedRecords []*SeaweedRecord
|
||||
nextOffsetToRead int64
|
||||
// Track what has actually been READ from the stream (not what was requested)
|
||||
// This is the HIGHEST offset that has been read from the stream
|
||||
// Used to determine if we need to seek or can continue reading
|
||||
lastReadOffset int64
|
||||
// Flag to indicate if this session has been initialized
|
||||
initialized bool
|
||||
}
|
||||
|
||||
// Key generates a unique key for this subscriber session
|
||||
// Includes consumer group and ID to prevent different consumers from sharing sessions
|
||||
func (s *BrokerSubscriberSession) Key() string {
|
||||
return fmt.Sprintf("%s-%d-%s-%s", s.Topic, s.Partition, s.ConsumerGroup, s.ConsumerID)
|
||||
}
|
||||
Reference in New Issue
Block a user