Files
zenfeed/pkg/storage/feed/block/chunk/chunk.go
glidea 8b33df8a05 init
2025-04-19 15:50:26 +08:00

742 lines
19 KiB
Go

// Copyright (C) 2025 wangyusong
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package chunk
import (
"bytes"
"context"
"encoding/binary"
"io"
"os"
"sync"
"sync/atomic"
"time"
"github.com/edsrzf/mmap-go"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/glidea/zenfeed/pkg/component"
"github.com/glidea/zenfeed/pkg/model"
"github.com/glidea/zenfeed/pkg/telemetry"
telemetrymodel "github.com/glidea/zenfeed/pkg/telemetry/model"
"github.com/glidea/zenfeed/pkg/util/buffer"
timeutil "github.com/glidea/zenfeed/pkg/util/time"
)
// --- Interface code block ---
// File is the interface for a chunk file.
// Concurrent safe.
type File interface {
component.Component
// EnsureReadonly ensures the file is readonly (can not Append).
// It should be fast when the file already is readonly.
// It will ensure the writeonly related resources are closed,
// and open the readonly related resources, such as mmap to save memory.
EnsureReadonly(ctx context.Context) (err error)
Count(ctx context.Context) (count uint32)
// Append appends feeds to the file.
// onSuccess is called when the feed is appended successfully (synchronously).
// The offset is the offset of the feed in the file.
// !!! It doesn't buffer the data between requests, so the caller should buffer the feeds to avoid high I/O.
Append(ctx context.Context, feeds []*Feed, onSuccess func(feed *Feed, offset uint64) error) (err error)
// Read reads a feed from the file.
Read(ctx context.Context, offset uint64) (feed *Feed, err error)
// Range ranges over all feeds in the file.
Range(ctx context.Context, iter func(feed *Feed, offset uint64) (err error)) (err error)
}
// Config for a chunk file.
type Config struct {
// Path is the path to the chunk file.
// If the file does not exist, it will be created.
// If the file exists, it will be reloaded.
Path string
// ReadonlyAtFirst indicates whether the file should be readonly at first.
// If file of path does not exist, it cannot be true.
ReadonlyAtFirst bool
}
func (c *Config) Validate() (fileExists bool, err error) {
if c.Path == "" {
return false, errors.New("path is required")
}
fi, err := os.Stat(c.Path)
switch {
case err == nil:
if fi.IsDir() {
return false, errors.New("path is a directory")
}
return true, nil
case os.IsNotExist(err):
if c.ReadonlyAtFirst {
return false, errors.New("path does not exist")
}
return false, nil
default:
return false, errors.Wrap(err, "stat path")
}
}
type Dependencies struct{}
// File struct.
var (
headerBytes = 64
headerMagicNumber = []byte{0x77, 0x79, 0x73, 0x20, 0x69, 0x73, 0x20,
0x61, 0x77, 0x65, 0x73, 0x6f, 0x6d, 0x65, 0x00, 0x00}
headerMagicNumberBytes = 16
headerVersionStart = headerMagicNumberBytes
headerVersion = uint32(1)
headerVersionBytes = 4
dataStart = headerBytes
header = func() []byte {
b := make([]byte, headerBytes)
copy(b[:headerMagicNumberBytes], headerMagicNumber)
binary.LittleEndian.PutUint32(b[headerVersionStart:headerVersionStart+headerVersionBytes], headerVersion)
return b
}()
)
// Metrics.
var (
modes = []string{"readwrite", "readonly"}
feedCount = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: model.AppName,
Subsystem: "chunk",
Name: "feed_count",
Help: "Number of feeds in the chunk file.",
},
[]string{telemetrymodel.KeyComponent, telemetrymodel.KeyComponentInstance, "mode"},
)
byteSize = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: model.AppName,
Subsystem: "chunk",
Name: "bytes",
Help: "Size of the chunk file.",
},
[]string{telemetrymodel.KeyComponent, telemetrymodel.KeyComponentInstance, "mode"},
)
)
// --- Factory code block ---
type Factory component.Factory[File, Config, Dependencies]
func NewFactory(mockOn ...component.MockOption) Factory {
if len(mockOn) > 0 {
return component.FactoryFunc[File, Config, Dependencies](
func(instance string, config *Config, dependencies Dependencies) (File, error) {
m := &mockFile{}
component.MockOptions(mockOn).Apply(&m.Mock)
return m, nil
},
)
}
return component.FactoryFunc[File, Config, Dependencies](new)
}
// new creates a new chunk file.
// It will create a new chunk file if the file that path points to does not exist.
// It will open the file if the file exists, and reload it.
// If readonlyAtFirst is true, it will open the file readonly.
func new(instance string, config *Config, dependencies Dependencies) (File, error) {
fileExists, err := config.Validate()
if err != nil {
return nil, errors.Wrap(err, "validate config")
}
osFile, readWriteBuf, appendOffset, readonlyMmap, count, err := init0(fileExists, config)
if err != nil {
return nil, err
}
var rn atomic.Bool
rn.Store(config.ReadonlyAtFirst)
var cnt atomic.Uint32
cnt.Store(count)
return &file{
Base: component.New(&component.BaseConfig[Config, Dependencies]{
Name: "FeedChunk",
Instance: instance,
Config: config,
Dependencies: dependencies,
}),
f: osFile,
readWriteBuf: readWriteBuf,
appendOffset: appendOffset,
readonlyMmap: readonlyMmap,
readonly: &rn,
count: &cnt,
}, nil
}
func init0(
fileExists bool,
config *Config,
) (
osFile *os.File,
readWriteBuf *buffer.Bytes,
appendOffset uint64,
readonlyMmap mmap.MMap,
count uint32,
err error,
) {
// Ensure file.
if fileExists {
osFile, err = loadFromExisting(config.Path, config.ReadonlyAtFirst)
if err != nil {
return nil, nil, 0, nil, 0, errors.Wrap(err, "load from existing")
}
} else { // Create new file.
if config.ReadonlyAtFirst {
return nil, nil, 0, nil, 0, errors.New("cannot create readonly file")
}
osFile, err = createNewOSFile(config.Path)
if err != nil {
return nil, nil, 0, nil, 0, errors.Wrap(err, "create new os file")
}
}
// Setup for Read.
readWriteBuf, count, err = validateOSFile(osFile)
if err != nil {
_ = osFile.Close()
return nil, nil, 0, nil, 0, errors.Wrap(err, "validate os file")
}
if config.ReadonlyAtFirst {
readWriteBuf = nil // Help GC.
m, err := mmap.Map(osFile, mmap.RDONLY, 0)
if err != nil {
_ = osFile.Close()
return nil, nil, 0, nil, 0, errors.Wrap(err, "mmap file")
}
readonlyMmap = m
} else {
appendOffset = uint64(readWriteBuf.Len())
}
return
}
func validateOSFile(f *os.File) (readWriteBuf *buffer.Bytes, count uint32, err error) {
header, err := validateHeader(f)
if err != nil {
return nil, 0, errors.Wrap(err, "validate header")
}
readWriteBuf = &buffer.Bytes{B: header} // len(header) == cap(header).
if _, err := f.Seek(int64(dataStart), io.SeekStart); err != nil {
return nil, 0, errors.Wrap(err, "seek to data start")
}
tr := &trackReader{Reader: f}
var lastSuccessReaded int
var p Feed
for {
err := p.validateFrom(tr, readWriteBuf)
switch {
case err == nil:
count++
lastSuccessReaded = tr.Readed()
continue
case (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) ||
errors.Is(err, errChecksumMismatch):
// Truncate uncompleted feed if any.
readWriteBuf.B = readWriteBuf.B[:lastSuccessReaded+len(header)]
return readWriteBuf, count, nil
default:
return nil, 0, errors.Wrap(err, "validate payload")
}
}
}
func validateHeader(f *os.File) (header []byte, err error) {
header = make([]byte, headerBytes)
if _, err := f.ReadAt(header, 0); err != nil {
return nil, errors.Wrap(err, "read header")
}
// Validate magic number.
if !bytes.Equal(header[:headerMagicNumberBytes], headerMagicNumber) {
return nil, errors.New("invalid magic number")
}
// Validate version.
version := binary.LittleEndian.Uint32(header[headerVersionStart : headerVersionStart+headerVersionBytes])
if version != headerVersion {
return nil, errors.New("invalid version")
}
return header, nil
}
func loadFromExisting(path string, readonlyAtFirst bool) (osFile *os.File, err error) {
flag := os.O_RDWR
if readonlyAtFirst {
flag = os.O_RDONLY
}
osFile, err = os.OpenFile(path, flag, 0600)
if err != nil {
return nil, errors.Wrap(err, "open file")
}
return osFile, nil
}
func createNewOSFile(path string) (osFile *os.File, err error) {
osFile, err = os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return nil, errors.Wrap(err, "create file")
}
if _, err = osFile.Write(header); err != nil {
_ = osFile.Close()
return nil, errors.Wrap(err, "write header")
}
if err = osFile.Sync(); err != nil {
_ = osFile.Close()
return nil, errors.Wrap(err, "sync file")
}
return osFile, nil
}
// --- Implementation code block ---
type file struct {
*component.Base[Config, Dependencies]
f *os.File
count *atomic.Uint32
readonly *atomic.Bool
mu sync.RWMutex
// Only readwrite.
readWriteBuf *buffer.Bytes
appendOffset uint64
// Only readonly.
readonlyMmap mmap.MMap
}
func (f *file) Run() error {
f.MarkReady()
return timeutil.Tick(f.Context(), 30*time.Second, func() error {
mode := "readwrite"
sizeValue := f.appendOffset
if f.readonly.Load() {
mode = "readonly"
sizeValue = uint64(len(f.readonlyMmap))
}
feedCount.WithLabelValues(append(f.TelemetryLabelsIDFields(), mode)...).Set(float64(f.Count(context.Background())))
byteSize.WithLabelValues(append(f.TelemetryLabelsIDFields(), mode)...).Set(float64(sizeValue))
for _, m := range modes {
if m == mode {
continue
}
feedCount.DeleteLabelValues(append(f.TelemetryLabelsIDFields(), m)...)
byteSize.DeleteLabelValues(append(f.TelemetryLabelsIDFields(), m)...)
}
return nil
})
}
func (f *file) Close() error {
// Close Run().
if err := f.Base.Close(); err != nil {
return errors.Wrap(err, "closing base")
}
// Clean metrics.
feedCount.DeletePartialMatch(f.TelemetryLabelsID())
byteSize.DeletePartialMatch(f.TelemetryLabelsID())
// Unmap if readonly.
f.mu.Lock()
defer f.mu.Unlock()
if f.readonlyMmap != nil {
if err := f.readonlyMmap.Unmap(); err != nil {
return errors.Wrap(err, "unmap file")
}
f.readonlyMmap = nil
}
// Close file.
if err := f.f.Close(); err != nil {
return errors.Wrap(err, "close file")
}
f.f = nil
f.appendOffset = 0
return nil
}
func (f *file) EnsureReadonly(ctx context.Context) (err error) {
ctx = telemetry.StartWith(ctx, append(f.TelemetryLabels(), telemetrymodel.KeyOperation, "EnsureReadonly")...)
defer func() { telemetry.End(ctx, err) }()
// Fast path - already readonly.
if f.readonly.Load() {
return nil
}
// Acquire write lock
f.mu.Lock()
defer f.mu.Unlock()
if f.readonly.Load() {
return nil
}
// Clear readwrite resources.
f.readWriteBuf = nil
// Open mmap.
m, err := mmap.Map(f.f, mmap.RDONLY, 0)
if err != nil {
return errors.Wrap(err, "mmap file")
}
// Update state.
f.readonlyMmap = m
f.readonly.Store(true)
return nil
}
func (f *file) Count(ctx context.Context) uint32 {
ctx = telemetry.StartWith(ctx, append(f.TelemetryLabels(), telemetrymodel.KeyOperation, "Count")...)
defer func() { telemetry.End(ctx, nil) }()
return f.count.Load()
}
func (f *file) Append(ctx context.Context, feeds []*Feed, onSuccess func(feed *Feed, offset uint64) error) (err error) {
ctx = telemetry.StartWith(ctx, append(f.TelemetryLabels(), telemetrymodel.KeyOperation, "Append")...)
defer func() { telemetry.End(ctx, err) }()
f.mu.Lock()
// Precheck.
if f.readonly.Load() {
f.mu.Unlock()
return errors.New("file is readonly")
}
// Encode feeds into buffer.
currentAppendOffset := f.appendOffset
relativeOffsets, encodedBytesCount, err := f.encodeFeeds(feeds)
if err != nil {
f.readWriteBuf.B = f.readWriteBuf.B[:currentAppendOffset]
f.mu.Unlock()
return errors.Wrap(err, "encode feeds")
}
// Prepare for commit.
encodedData := f.readWriteBuf.Bytes()[currentAppendOffset:]
newAppendOffset := currentAppendOffset + uint64(encodedBytesCount)
// Commit data and header to file.
if err = f.commitAppendToFile(encodedData, currentAppendOffset); err != nil {
f.readWriteBuf.B = f.readWriteBuf.B[:currentAppendOffset]
f.mu.Unlock()
return errors.Wrap(err, "commit append to file")
}
// Update internal state on successful commit.
f.appendOffset = newAppendOffset
f.count.Add(uint32(len(feeds)))
f.mu.Unlock()
// Call callbacks after releasing the lock.
absoluteOffsets := make([]uint64, len(relativeOffsets))
for i, relOff := range relativeOffsets {
absoluteOffsets[i] = currentAppendOffset + relOff // Calculate absolute offsets based on append position.
}
if err := f.notifySuccess(feeds, absoluteOffsets, onSuccess); err != nil {
return errors.Wrap(err, "notify success callbacks")
}
return nil
}
func (f *file) Read(ctx context.Context, offset uint64) (feed *Feed, err error) {
ctx = telemetry.StartWith(ctx, append(f.TelemetryLabels(), telemetrymodel.KeyOperation, "Read")...)
defer func() { telemetry.End(ctx, err) }()
// Validate offset.
if offset < uint64(dataStart) {
return nil, errors.New("offset too small")
}
// Handle readonly mode.
if f.readonly.Load() {
if offset >= uint64(len(f.readonlyMmap)) {
return nil, errors.New("offset too large")
}
feed, _, err = f.readFeed(ctx, f.readonlyMmap, offset)
if err != nil {
return nil, errors.Wrap(err, "read feed")
}
return feed, nil
}
// Handle readwrite mode.
f.mu.RLock()
defer f.mu.RUnlock()
if offset >= f.appendOffset {
return nil, errors.New("offset too large")
}
feed, _, err = f.readFeed(ctx, f.readWriteBuf.Bytes(), offset)
if err != nil {
return nil, errors.Wrap(err, "read feed")
}
return feed, nil
}
func (f *file) Range(ctx context.Context, iter func(feed *Feed, offset uint64) error) (err error) {
ctx = telemetry.StartWith(ctx, append(f.TelemetryLabels(), telemetrymodel.KeyOperation, "Range")...)
defer func() { telemetry.End(ctx, err) }()
// Handle readonly mode.
if f.readonly.Load() {
// Start from data section.
offset := uint64(dataStart)
for offset < uint64(len(f.readonlyMmap)) {
feed, n, err := f.readFeed(ctx, f.readonlyMmap, offset)
if err != nil {
return errors.Wrap(err, "read feed")
}
if err := iter(feed, offset); err != nil {
return errors.Wrap(err, "iterate feed")
}
// Move to next feed.
offset += uint64(n) // G115: Safe conversion as n is uint32
}
return nil
}
// Handle readwrite mode.
f.mu.RLock()
defer f.mu.RUnlock()
data := f.readWriteBuf.Bytes()
offset := uint64(dataStart)
for offset < f.appendOffset { // appendOffset is already checked/maintained correctly.
feed, n, err := f.readFeed(ctx, data, offset)
if err != nil {
return errors.Wrap(err, "read feed")
}
if err := iter(feed, offset); err != nil {
return errors.Wrap(err, "iterate feed")
}
// Move to next feed.
offset += uint64(n)
}
return nil
}
const estimatedFeedSize = 4 * 1024
// encodeFeeds encodes a slice of feeds into the internal readWriteBuf.
// It returns the relative offsets of each feed within the newly added data,
// the total number of bytes encoded, and any error encountered.
func (f *file) encodeFeeds(feeds []*Feed) (relativeOffsets []uint64, encodedBytesCount int, err error) {
relativeOffsets = make([]uint64, len(feeds))
startOffset := f.readWriteBuf.Len()
f.readWriteBuf.EnsureRemaining(estimatedFeedSize * len(feeds))
for i, feed := range feeds {
currentOffsetInBuf := f.readWriteBuf.Len()
relativeOffsets[i] = uint64(currentOffsetInBuf - startOffset)
if err := feed.encodeTo(f.readWriteBuf); err != nil {
return nil, 0, errors.Wrapf(err, "encode feed %d", i)
}
}
encodedBytesCount = f.readWriteBuf.Len() - startOffset
return relativeOffsets, encodedBytesCount, nil
}
// commitAppendToFile writes the encoded data and updated header to the file and syncs.
func (f *file) commitAppendToFile(data []byte, currentAppendOffset uint64) error {
// Append data.
if _, err := f.f.WriteAt(data, int64(currentAppendOffset)); err != nil {
// Data might be partially written.
// We will overwrite it in the next append.
return errors.Wrap(err, "write feeds")
}
// Sync file to persist changes.
if err := f.f.Sync(); err != nil {
return errors.Wrap(err, "sync file")
}
return nil
}
// notifySuccess calls the onSuccess callback for each successfully appended feed.
func (f *file) notifySuccess(
feeds []*Feed,
absoluteOffsets []uint64,
onSuccess func(feed *Feed, offset uint64) error,
) error {
if onSuccess == nil {
return nil
}
for i, feed := range feeds {
if err := onSuccess(feed, absoluteOffsets[i]); err != nil {
// Return the first error encountered during callbacks.
return errors.Wrapf(err, "on success callback for feed %d", i)
}
}
return nil
}
func (f *file) readFeed(ctx context.Context, data []byte, offset uint64) (feed *Feed, length int, err error) {
ctx = telemetry.StartWith(ctx, append(f.TelemetryLabels(), telemetrymodel.KeyOperation, "readFeed")...)
defer func() { telemetry.End(ctx, err) }()
// Prepare reader.
r := io.NewSectionReader(bytes.NewReader(data), int64(offset), int64(uint64(len(data))-offset))
tr := &trackReader{Reader: r}
// Decode feed.
feed = &Feed{Feed: &model.Feed{}}
if err = feed.decodeFrom(tr); err != nil {
return nil, 0, errors.Wrap(err, "decode feed")
}
return feed, tr.Readed(), nil
}
type trackReader struct {
io.Reader
length int
}
func (r *trackReader) Read(p []byte) (n int, err error) {
n, err = r.Reader.Read(p)
r.length += n
return
}
func (r *trackReader) Readed() int {
return r.length
}
type mockFile struct {
component.Mock
}
func (m *mockFile) Run() error {
args := m.Called()
return args.Error(0)
}
func (m *mockFile) Ready() <-chan struct{} {
args := m.Called()
return args.Get(0).(<-chan struct{})
}
func (m *mockFile) Close() error {
args := m.Called()
return args.Error(0)
}
func (m *mockFile) Append(ctx context.Context, feeds []*Feed, onSuccess func(feed *Feed, offset uint64) error) error {
args := m.Called(ctx, feeds, onSuccess)
return args.Error(0)
}
func (m *mockFile) Read(ctx context.Context, offset uint64) (*Feed, error) {
args := m.Called(ctx, offset)
return args.Get(0).(*Feed), args.Error(1)
}
func (m *mockFile) Range(ctx context.Context, iter func(feed *Feed, offset uint64) error) error {
args := m.Called(ctx, iter)
return args.Error(0)
}
func (m *mockFile) Count(ctx context.Context) uint32 {
args := m.Called(ctx)
return args.Get(0).(uint32)
}
func (m *mockFile) EnsureReadonly(ctx context.Context) error {
args := m.Called(ctx)
return args.Error(0)
}