You can not select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
					
					
						
							326 lines
						
					
					
						
							8.3 KiB
						
					
					
				
			
		
		
		
			
			
			
		
		
	
	
							326 lines
						
					
					
						
							8.3 KiB
						
					
					
				
								package consumer_offset
							 | 
						|
								
							 | 
						|
								import (
							 | 
						|
									"context"
							 | 
						|
									"encoding/json"
							 | 
						|
									"fmt"
							 | 
						|
									"io"
							 | 
						|
									"strings"
							 | 
						|
									"time"
							 | 
						|
								
							 | 
						|
									"github.com/seaweedfs/seaweedfs/weed/filer_client"
							 | 
						|
									"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
							 | 
						|
									"github.com/seaweedfs/seaweedfs/weed/util"
							 | 
						|
								)
							 | 
						|
								
							 | 
						|
								const (
							 | 
						|
									// ConsumerOffsetsBasePath is the base path for storing Kafka consumer offsets in SeaweedFS
							 | 
						|
									ConsumerOffsetsBasePath = "/topics/kafka/.meta/consumer_offsets"
							 | 
						|
								)
							 | 
						|
								
							 | 
						|
								// KafkaConsumerPosition represents a Kafka consumer's position
							 | 
						|
								// Can be either offset-based or timestamp-based
							 | 
						|
								type KafkaConsumerPosition struct {
							 | 
						|
									Type        string `json:"type"`         // "offset" or "timestamp"
							 | 
						|
									Value       int64  `json:"value"`        // The actual offset or timestamp value
							 | 
						|
									CommittedAt int64  `json:"committed_at"` // Unix timestamp in milliseconds when committed
							 | 
						|
									Metadata    string `json:"metadata"`     // Optional: application-specific metadata
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								// FilerStorage implements OffsetStorage using SeaweedFS filer
							 | 
						|
								// Offsets are stored in JSON format: {ConsumerOffsetsBasePath}/{group}/{topic}/{partition}/offset
							 | 
						|
								// Supports both offset and timestamp positioning
							 | 
						|
								type FilerStorage struct {
							 | 
						|
									fca    *filer_client.FilerClientAccessor
							 | 
						|
									closed bool
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								// NewFilerStorage creates a new filer-based offset storage
							 | 
						|
								func NewFilerStorage(fca *filer_client.FilerClientAccessor) *FilerStorage {
							 | 
						|
									return &FilerStorage{
							 | 
						|
										fca:    fca,
							 | 
						|
										closed: false,
							 | 
						|
									}
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								// CommitOffset commits an offset for a consumer group
							 | 
						|
								// Now stores as JSON to support both offset and timestamp positioning
							 | 
						|
								func (f *FilerStorage) CommitOffset(group, topic string, partition int32, offset int64, metadata string) error {
							 | 
						|
									if f.closed {
							 | 
						|
										return ErrStorageClosed
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// Validate inputs
							 | 
						|
									if offset < -1 {
							 | 
						|
										return ErrInvalidOffset
							 | 
						|
									}
							 | 
						|
									if partition < 0 {
							 | 
						|
										return ErrInvalidPartition
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									offsetPath := f.getOffsetPath(group, topic, partition)
							 | 
						|
								
							 | 
						|
									// Create position structure
							 | 
						|
									position := &KafkaConsumerPosition{
							 | 
						|
										Type:        "offset",
							 | 
						|
										Value:       offset,
							 | 
						|
										CommittedAt: time.Now().UnixMilli(),
							 | 
						|
										Metadata:    metadata,
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// Marshal to JSON
							 | 
						|
									jsonBytes, err := json.Marshal(position)
							 | 
						|
									if err != nil {
							 | 
						|
										return fmt.Errorf("failed to marshal offset to JSON: %w", err)
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// Store as single JSON file
							 | 
						|
									if err := f.writeFile(offsetPath, jsonBytes); err != nil {
							 | 
						|
										return fmt.Errorf("failed to write offset: %w", err)
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									return nil
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								// FetchOffset fetches the committed offset for a consumer group
							 | 
						|
								func (f *FilerStorage) FetchOffset(group, topic string, partition int32) (int64, string, error) {
							 | 
						|
									if f.closed {
							 | 
						|
										return -1, "", ErrStorageClosed
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									offsetPath := f.getOffsetPath(group, topic, partition)
							 | 
						|
								
							 | 
						|
									// Read offset file
							 | 
						|
									offsetData, err := f.readFile(offsetPath)
							 | 
						|
									if err != nil {
							 | 
						|
										// File doesn't exist, no offset committed
							 | 
						|
										return -1, "", nil
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// Parse JSON format
							 | 
						|
									var position KafkaConsumerPosition
							 | 
						|
									if err := json.Unmarshal(offsetData, &position); err != nil {
							 | 
						|
										return -1, "", fmt.Errorf("failed to parse offset JSON: %w", err)
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									return position.Value, position.Metadata, nil
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								// FetchAllOffsets fetches all committed offsets for a consumer group
							 | 
						|
								func (f *FilerStorage) FetchAllOffsets(group string) (map[TopicPartition]OffsetMetadata, error) {
							 | 
						|
									if f.closed {
							 | 
						|
										return nil, ErrStorageClosed
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									result := make(map[TopicPartition]OffsetMetadata)
							 | 
						|
									groupPath := f.getGroupPath(group)
							 | 
						|
								
							 | 
						|
									// List all topics for this group
							 | 
						|
									topics, err := f.listDirectory(groupPath)
							 | 
						|
									if err != nil {
							 | 
						|
										// Group doesn't exist, return empty map
							 | 
						|
										return result, nil
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									// For each topic, list all partitions
							 | 
						|
									for _, topicName := range topics {
							 | 
						|
										topicPath := fmt.Sprintf("%s/%s", groupPath, topicName)
							 | 
						|
										partitions, err := f.listDirectory(topicPath)
							 | 
						|
										if err != nil {
							 | 
						|
											continue
							 | 
						|
										}
							 | 
						|
								
							 | 
						|
										// For each partition, read the offset
							 | 
						|
										for _, partitionName := range partitions {
							 | 
						|
											var partition int32
							 | 
						|
											_, err := fmt.Sscanf(partitionName, "%d", &partition)
							 | 
						|
											if err != nil {
							 | 
						|
												continue
							 | 
						|
											}
							 | 
						|
								
							 | 
						|
											offset, metadata, err := f.FetchOffset(group, topicName, partition)
							 | 
						|
											if err == nil && offset >= 0 {
							 | 
						|
												tp := TopicPartition{Topic: topicName, Partition: partition}
							 | 
						|
												result[tp] = OffsetMetadata{Offset: offset, Metadata: metadata}
							 | 
						|
											}
							 | 
						|
										}
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									return result, nil
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								// DeleteGroup deletes all offset data for a consumer group
							 | 
						|
								func (f *FilerStorage) DeleteGroup(group string) error {
							 | 
						|
									if f.closed {
							 | 
						|
										return ErrStorageClosed
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									groupPath := f.getGroupPath(group)
							 | 
						|
									return f.deleteDirectory(groupPath)
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								// ListGroups returns all consumer group IDs
							 | 
						|
								func (f *FilerStorage) ListGroups() ([]string, error) {
							 | 
						|
									if f.closed {
							 | 
						|
										return nil, ErrStorageClosed
							 | 
						|
									}
							 | 
						|
								
							 | 
						|
									return f.listDirectory(ConsumerOffsetsBasePath)
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								// Close releases resources
							 | 
						|
								func (f *FilerStorage) Close() error {
							 | 
						|
									f.closed = true
							 | 
						|
									return nil
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								// Helper methods
							 | 
						|
								
							 | 
						|
								func (f *FilerStorage) getGroupPath(group string) string {
							 | 
						|
									return fmt.Sprintf("%s/%s", ConsumerOffsetsBasePath, group)
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (f *FilerStorage) getTopicPath(group, topic string) string {
							 | 
						|
									return fmt.Sprintf("%s/%s", f.getGroupPath(group), topic)
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (f *FilerStorage) getPartitionPath(group, topic string, partition int32) string {
							 | 
						|
									return fmt.Sprintf("%s/%d", f.getTopicPath(group, topic), partition)
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (f *FilerStorage) getOffsetPath(group, topic string, partition int32) string {
							 | 
						|
									return fmt.Sprintf("%s/offset", f.getPartitionPath(group, topic, partition))
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (f *FilerStorage) getMetadataPath(group, topic string, partition int32) string {
							 | 
						|
									return fmt.Sprintf("%s/metadata", f.getPartitionPath(group, topic, partition))
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (f *FilerStorage) writeFile(path string, data []byte) error {
							 | 
						|
									fullPath := util.FullPath(path)
							 | 
						|
									dir, name := fullPath.DirAndName()
							 | 
						|
								
							 | 
						|
									return f.fca.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
							 | 
						|
										// Create entry
							 | 
						|
										entry := &filer_pb.Entry{
							 | 
						|
											Name:        name,
							 | 
						|
											IsDirectory: false,
							 | 
						|
											Attributes: &filer_pb.FuseAttributes{
							 | 
						|
												Crtime:   time.Now().Unix(),
							 | 
						|
												Mtime:    time.Now().Unix(),
							 | 
						|
												FileMode: 0644,
							 | 
						|
												FileSize: uint64(len(data)),
							 | 
						|
											},
							 | 
						|
											Chunks: []*filer_pb.FileChunk{},
							 | 
						|
										}
							 | 
						|
								
							 | 
						|
										// For small files, store inline
							 | 
						|
										if len(data) > 0 {
							 | 
						|
											entry.Content = data
							 | 
						|
										}
							 | 
						|
								
							 | 
						|
										// Create or update the entry
							 | 
						|
										return filer_pb.CreateEntry(context.Background(), client, &filer_pb.CreateEntryRequest{
							 | 
						|
											Directory: dir,
							 | 
						|
											Entry:     entry,
							 | 
						|
										})
							 | 
						|
									})
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (f *FilerStorage) readFile(path string) ([]byte, error) {
							 | 
						|
									fullPath := util.FullPath(path)
							 | 
						|
									dir, name := fullPath.DirAndName()
							 | 
						|
								
							 | 
						|
									var data []byte
							 | 
						|
									err := f.fca.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
							 | 
						|
										// Get the entry
							 | 
						|
										resp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
							 | 
						|
											Directory: dir,
							 | 
						|
											Name:      name,
							 | 
						|
										})
							 | 
						|
										if err != nil {
							 | 
						|
											return err
							 | 
						|
										}
							 | 
						|
								
							 | 
						|
										entry := resp.Entry
							 | 
						|
										if entry.IsDirectory {
							 | 
						|
											return fmt.Errorf("path is a directory")
							 | 
						|
										}
							 | 
						|
								
							 | 
						|
										// Read inline content if available
							 | 
						|
										if len(entry.Content) > 0 {
							 | 
						|
											data = entry.Content
							 | 
						|
											return nil
							 | 
						|
										}
							 | 
						|
								
							 | 
						|
										// If no chunks, file is empty
							 | 
						|
										if len(entry.Chunks) == 0 {
							 | 
						|
											data = []byte{}
							 | 
						|
											return nil
							 | 
						|
										}
							 | 
						|
								
							 | 
						|
										return fmt.Errorf("chunked files not supported for offset storage")
							 | 
						|
									})
							 | 
						|
								
							 | 
						|
									return data, err
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (f *FilerStorage) listDirectory(path string) ([]string, error) {
							 | 
						|
									var entries []string
							 | 
						|
								
							 | 
						|
									err := f.fca.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
							 | 
						|
										stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{
							 | 
						|
											Directory: path,
							 | 
						|
										})
							 | 
						|
										if err != nil {
							 | 
						|
											return err
							 | 
						|
										}
							 | 
						|
								
							 | 
						|
										for {
							 | 
						|
											resp, err := stream.Recv()
							 | 
						|
											if err == io.EOF {
							 | 
						|
												break
							 | 
						|
											}
							 | 
						|
											if err != nil {
							 | 
						|
												return err
							 | 
						|
											}
							 | 
						|
								
							 | 
						|
											if resp.Entry.IsDirectory {
							 | 
						|
												entries = append(entries, resp.Entry.Name)
							 | 
						|
											}
							 | 
						|
										}
							 | 
						|
								
							 | 
						|
										return nil
							 | 
						|
									})
							 | 
						|
								
							 | 
						|
									return entries, err
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								func (f *FilerStorage) deleteDirectory(path string) error {
							 | 
						|
									fullPath := util.FullPath(path)
							 | 
						|
									dir, name := fullPath.DirAndName()
							 | 
						|
								
							 | 
						|
									return f.fca.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
							 | 
						|
										_, err := client.DeleteEntry(context.Background(), &filer_pb.DeleteEntryRequest{
							 | 
						|
											Directory:            dir,
							 | 
						|
											Name:                 name,
							 | 
						|
											IsDeleteData:         true,
							 | 
						|
											IsRecursive:          true,
							 | 
						|
											IgnoreRecursiveError: true,
							 | 
						|
										})
							 | 
						|
										return err
							 | 
						|
									})
							 | 
						|
								}
							 | 
						|
								
							 | 
						|
								// normalizePath removes leading/trailing slashes and collapses multiple slashes
							 | 
						|
								func normalizePath(path string) string {
							 | 
						|
									path = strings.Trim(path, "/")
							 | 
						|
									parts := strings.Split(path, "/")
							 | 
						|
									normalized := []string{}
							 | 
						|
									for _, part := range parts {
							 | 
						|
										if part != "" {
							 | 
						|
											normalized = append(normalized, part)
							 | 
						|
										}
							 | 
						|
									}
							 | 
						|
									return "/" + strings.Join(normalized, "/")
							 | 
						|
								}
							 |