360 lines
12 KiB
Go
360 lines
12 KiB
Go
package storage
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
log "github.com/CefBoud/monkafka/logging"
|
|
"github.com/CefBoud/monkafka/serde"
|
|
"github.com/CefBoud/monkafka/state"
|
|
"github.com/CefBoud/monkafka/types"
|
|
)
|
|
|
|
// LoadTopicsState loads the state of all topics from disk.
|
|
func LoadTopicsState() (types.TopicsState, error) {
|
|
logDir := state.Config.LogDir
|
|
log.Info("Starting with LogDir: %v\n", logDir)
|
|
err := os.MkdirAll(logDir, 0750)
|
|
if err != nil {
|
|
return state.TopicStateInstance, err
|
|
}
|
|
entries, err := os.ReadDir(logDir)
|
|
if err != nil {
|
|
return state.TopicStateInstance, err
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
lastIndex := strings.LastIndex(entry.Name(), "-")
|
|
if lastIndex == -1 {
|
|
continue
|
|
}
|
|
topicName := entry.Name()[:lastIndex]
|
|
index, err := strconv.Atoi(entry.Name()[lastIndex+1:])
|
|
if err != nil {
|
|
log.Error("Error partition index to int: %v", err)
|
|
return state.TopicStateInstance, err
|
|
}
|
|
if state.TopicStateInstance[types.TopicName(topicName)] == nil {
|
|
state.TopicStateInstance[types.TopicName(topicName)] = make(map[types.PartitionIndex]*types.Partition)
|
|
}
|
|
|
|
segments, err := LoadSegments(filepath.Join(logDir, entry.Name()))
|
|
log.Info("loaded %v segments for %v\n", len(segments), entry.Name())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load segments from %v. %v", filepath.Join(logDir, entry.Name()), err)
|
|
}
|
|
state.TopicStateInstance[types.TopicName(topicName)][types.PartitionIndex(index)] = &types.Partition{Segments: segments, Index: uint32(index), TopicName: topicName}
|
|
}
|
|
}
|
|
log.Info("loadTopicsState %v", state.TopicStateInstance)
|
|
return state.TopicStateInstance, err
|
|
}
|
|
|
|
// AppendRecord appends a record to the active segment of the specified partition.
|
|
func AppendRecord(topic string, partition uint32, recordBytes []byte) error {
|
|
partitionState := state.GetPartition(topic, partition)
|
|
partitionState.Lock()
|
|
defer partitionState.Unlock()
|
|
activeSegment := partitionState.ActiveSegment()
|
|
|
|
// Append to the end
|
|
activeSegment.LogFile.Seek(0, io.SeekEnd)
|
|
activeSegment.IndexFile.Seek(0, io.SeekEnd)
|
|
|
|
var newOffset = activeSegment.EndOffset
|
|
if len(activeSegment.IndexData) > 0 {
|
|
newOffset = activeSegment.EndOffset + 1
|
|
}
|
|
|
|
serde.Encoding.PutUint64(recordBytes, uint64(newOffset))
|
|
|
|
n, err := activeSegment.LogFile.Write(recordBytes)
|
|
if n != len(recordBytes) || err != nil {
|
|
log.Error("Error appending record to LogFile for topic %v\n", topic)
|
|
}
|
|
|
|
indexEntry := make([]byte, 8)
|
|
serde.Encoding.PutUint32(indexEntry, uint32(newOffset-activeSegment.StartOffset)) // relative to start
|
|
serde.Encoding.PutUint32(indexEntry[4:], activeSegment.LogFileSize)
|
|
activeSegment.IndexFile.Write(indexEntry)
|
|
|
|
// Update index
|
|
newIndexData := make([]byte, len(activeSegment.IndexData)+8)
|
|
copy(newIndexData, activeSegment.IndexData)
|
|
copy(newIndexData[len(activeSegment.IndexData):], indexEntry)
|
|
activeSegment.IndexData = newIndexData
|
|
recordBatch := ReadRecordBatch(recordBytes)
|
|
activeSegment.EndOffset = newOffset + uint64(recordBatch.LastOffsetDelta) // Update end offset
|
|
activeSegment.LogFileSize += uint32(len(recordBytes))
|
|
activeSegment.MaxTimestamp = recordBatch.MaxTimestamp
|
|
log.Debug(" newOffset: %v | NextRecordPosition: %v \n ", newOffset, activeSegment.LogFileSize)
|
|
return nil
|
|
}
|
|
|
|
// getClosestIndexEntryIndex finds the closest index entry for the given offset using binary search.
|
|
func getClosestIndexEntryIndex(offset uint32, indexData []byte) int {
|
|
lastEntry := len(indexData)/8 - 1
|
|
left, right := 0, lastEntry
|
|
var mid int
|
|
var closestOffset uint32
|
|
for left <= right {
|
|
mid = (left + right) / 2
|
|
closestOffset = serde.Encoding.Uint32(indexData[mid*8:])
|
|
if closestOffset == offset {
|
|
return mid
|
|
} else if closestOffset > offset {
|
|
right = mid - 1
|
|
} else {
|
|
left = mid + 1
|
|
}
|
|
}
|
|
// in case offset is greater than the latest one, left will exceed lastEntry
|
|
if left >= lastEntry {
|
|
return lastEntry
|
|
}
|
|
// Return the closest greater index
|
|
return left
|
|
}
|
|
|
|
// getOffsetSegment finds the segment containing the given offset.
|
|
func getOffsetSegment(offset uint64, partition *types.Partition) (*types.Segment, error) {
|
|
if partition.IsEmpty() {
|
|
return nil, fmt.Errorf("can't getOffsetSegment: empty partition")
|
|
}
|
|
if offset < partition.StartOffset() || offset > partition.EndOffset()+1 {
|
|
return nil, fmt.Errorf("out of range of offset")
|
|
}
|
|
if offset == partition.EndOffset()+1 { // Consumer caught up
|
|
if partition.ActiveSegment().LogFileSize > 0 {
|
|
return partition.ActiveSegment(), nil
|
|
}
|
|
// Return previous segment if active segment is empty
|
|
segmentBeforeActive := partition.Segments[len(partition.Segments)-2]
|
|
return segmentBeforeActive, nil
|
|
|
|
}
|
|
|
|
// Find the correct segment by offset range
|
|
for i, segment := range partition.Segments {
|
|
if offset >= segment.StartOffset && offset <= segment.EndOffset {
|
|
log.Debug("offset %v is within segment %v bounds [ %v, %v ]", offset, i, segment.StartOffset, segment.EndOffset)
|
|
return segment, nil
|
|
}
|
|
}
|
|
log.Error("mismatch between segments and partition offsets")
|
|
os.Exit(1)
|
|
return nil, nil
|
|
}
|
|
|
|
// GetRecordBatch retrieves the RecordBatch bytes corresponding to the specified offset in the partition.
|
|
func GetRecordBatch(offset uint64, topic string, partition uint32) ([]byte, error) {
|
|
log.Debug("GetRecord offset: %v | topic: %v \n\n", offset, topic)
|
|
partitionState := state.GetPartition(topic, partition)
|
|
partitionState.RLock()
|
|
defer partitionState.RUnlock()
|
|
|
|
if partitionState.IsEmpty() {
|
|
log.Debug("GetRecord offset: %v | topic: %v | Empty partition \n\n", offset, topic)
|
|
return nil, nil
|
|
}
|
|
targetSegment, err := getOffsetSegment(offset, partitionState)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error while getting offset segment: %v", err)
|
|
}
|
|
targetSegment.RLock()
|
|
defer targetSegment.RUnlock()
|
|
|
|
indexData := targetSegment.IndexData
|
|
var recordStartPosition, recordEndPosition uint32
|
|
// If offset is greater than latest, return latest record
|
|
if len(indexData)/8 > 0 && offset > targetSegment.EndOffset {
|
|
recordStartPosition = serde.Encoding.Uint32(indexData[len(indexData)-4:])
|
|
recordEndPosition = targetSegment.LogFileSize - 1
|
|
// Wait for consumer if no records are available
|
|
time.Sleep(300 * time.Millisecond) // TODO: Make this configurable
|
|
} else {
|
|
indexEntryIndex := getClosestIndexEntryIndex(uint32(offset-targetSegment.StartOffset), indexData)
|
|
log.Debug("GetRecord offset: %v | topic: %v | Closest indexEntryIndex: %v \n\n", offset, topic, indexEntryIndex)
|
|
|
|
recordStartPosition = serde.Encoding.Uint32(indexData[indexEntryIndex*8+4:])
|
|
|
|
if indexEntryIndex+1 < len(indexData)/8 {
|
|
recordEndPosition = serde.Encoding.Uint32(indexData[(indexEntryIndex+1)*8+4:]) - 1
|
|
} else {
|
|
recordEndPosition = targetSegment.LogFileSize - 1
|
|
}
|
|
}
|
|
log.Debug("GetRecord offset: %v | topic: %v | recordStartPosition: %v recordEndPosition: %v \n\n", offset, topic, recordStartPosition, recordEndPosition)
|
|
|
|
_, err = targetSegment.LogFile.Seek(int64(recordStartPosition), io.SeekStart)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
nbRecordByte := recordEndPosition - recordStartPosition + 1
|
|
recordBytes := make([]byte, nbRecordByte)
|
|
n, err := targetSegment.LogFile.Read(recordBytes)
|
|
if n != int(nbRecordByte) || err != nil {
|
|
return nil, fmt.Errorf("error while reading record offset %v, expected %v bytes and read %v bytes. %v", offset, nbRecordByte, n, err)
|
|
}
|
|
|
|
log.Debug("ReadRecordBatch %+v", ReadRecords(ReadRecordBatch(recordBytes)))
|
|
return recordBytes, nil
|
|
}
|
|
|
|
// CreateTopic creates a new topic with the given number of partitions and initializes them.
|
|
func CreateTopic(name string, numPartitions uint32) error {
|
|
if numPartitions <= 0 {
|
|
return fmt.Errorf("invalid number of partitions")
|
|
}
|
|
|
|
for i := 0; i < int(numPartitions); i++ {
|
|
err := os.MkdirAll(GetPartitionDir(name, uint32(i)), 0750)
|
|
if err != nil {
|
|
log.Error("Error creating topic directory: %v", err)
|
|
}
|
|
// Update state
|
|
if i == 0 {
|
|
state.TopicStateInstance[types.TopicName(name)] = make(map[types.PartitionIndex]*types.Partition)
|
|
}
|
|
partition := &types.Partition{
|
|
TopicName: name,
|
|
Index: uint32(i),
|
|
}
|
|
segment, err := NewSegment(partition)
|
|
if err != nil {
|
|
log.Error("Error creating segment: %v", err)
|
|
return err
|
|
}
|
|
partition.Segments = append(partition.Segments, segment)
|
|
state.TopicStateInstance[types.TopicName(name)][types.PartitionIndex(i)] = partition
|
|
}
|
|
log.Info("Topic %v created with %v partitions", name, numPartitions)
|
|
return nil
|
|
}
|
|
|
|
// EnsurePartition creates a partition if it doesn't exist
|
|
func EnsurePartition(topicName string, partitionIndex uint32) error {
|
|
partitionDir := GetPartitionDir(topicName, partitionIndex)
|
|
_, err := os.Stat(partitionDir)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
err := os.MkdirAll(partitionDir, 0750)
|
|
if err != nil {
|
|
log.Error("error creating partition directory: %v", err)
|
|
return err
|
|
}
|
|
if !state.TopicExists(topicName) {
|
|
state.TopicStateInstance[types.TopicName(topicName)] = make(map[types.PartitionIndex]*types.Partition)
|
|
}
|
|
partition := &types.Partition{
|
|
TopicName: topicName,
|
|
Index: partitionIndex,
|
|
}
|
|
segment, err := NewSegment(partition)
|
|
if err != nil {
|
|
log.Error("Error creating segment: %v", err)
|
|
return err
|
|
}
|
|
partition.Segments = append(partition.Segments, segment)
|
|
state.TopicStateInstance[types.TopicName(topicName)][types.PartitionIndex(partitionIndex)] = partition
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// GetPartitionDir returns the directory path for the log of a specific partition.
|
|
func GetPartitionDir(topic string, partition uint32) string {
|
|
return filepath.Join(state.Config.LogDir, topic+"-"+strconv.Itoa(int(partition)))
|
|
}
|
|
|
|
// SyncPartition syncs the log and index files of the active segment for the given partition.
|
|
func SyncPartition(partition *types.Partition) error {
|
|
activeSegment := partition.ActiveSegment()
|
|
if activeSegment == nil {
|
|
return fmt.Errorf("SyncPartition: partition %v-%v has no active segment", partition.TopicName, partition.Index)
|
|
}
|
|
err := activeSegment.LogFile.Sync()
|
|
if err != nil {
|
|
log.Error("Error syncing SegmentFile for %v: %v", partition, err)
|
|
return err
|
|
}
|
|
err = activeSegment.IndexFile.Sync()
|
|
if err != nil {
|
|
log.Error("Error syncing IndexFile for %v: %v", partition, err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FlushDataToDisk synchronously flushes all partition data to disk by syncing their log and index files.
|
|
func FlushDataToDisk() {
|
|
for topicName, partitionMap := range state.TopicStateInstance {
|
|
for i, partition := range partitionMap {
|
|
err := SyncPartition(partition)
|
|
if err != nil {
|
|
log.Error("error while flushing partition %v-%v to disk\n", topicName, i)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Startup initializes log management system
|
|
func Startup(Config *types.Configuration, shutdown chan bool) {
|
|
state.Config = Config
|
|
_, err := LoadTopicsState()
|
|
if err != nil {
|
|
log.Error("Error loading TopicsState: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
go func() {
|
|
flushTicker := time.NewTicker(time.Duration(Config.FlushIntervalMs) * time.Millisecond)
|
|
CleanupTicker := time.NewTicker(time.Duration(Config.LogRetentionCheckIntervalMs) * time.Millisecond)
|
|
|
|
defer flushTicker.Stop()
|
|
defer CleanupTicker.Stop()
|
|
for {
|
|
|
|
select {
|
|
case <-flushTicker.C:
|
|
FlushDataToDisk()
|
|
case <-CleanupTicker.C:
|
|
CleanupSegments()
|
|
case _, open := <-shutdown:
|
|
if !open {
|
|
log.Info("Stopping log management goroutines")
|
|
return
|
|
}
|
|
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Shutdown gracefully shuts down the log management system and closes all segment files.
|
|
func Shutdown() {
|
|
log.Info("Storage Shutdown...")
|
|
|
|
FlushDataToDisk()
|
|
for _, partitionMap := range state.TopicStateInstance {
|
|
for _, partition := range partitionMap {
|
|
err := partition.ActiveSegment().LogFile.Close()
|
|
if err != nil {
|
|
log.Error("Error syncing SegmentFile: %v", err)
|
|
}
|
|
partition.ActiveSegment().IndexFile.Close()
|
|
if err != nil {
|
|
log.Error("Error syncing IndexFile: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|