Remove file log store memory mirror
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -103,7 +104,11 @@ func (store *MemoryLogBodyStore) LatestSeq(streamID string) (uint64, error) {
|
|||||||
type FileLogBodyStore struct {
|
type FileLogBodyStore struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
rootDir string
|
rootDir string
|
||||||
memory *MemoryLogBodyStore
|
}
|
||||||
|
|
||||||
|
type logSegmentRef struct {
|
||||||
|
firstSeq uint64
|
||||||
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileLogBodyStore(rootDir string) (*FileLogBodyStore, error) {
|
func NewFileLogBodyStore(rootDir string) (*FileLogBodyStore, error) {
|
||||||
@@ -113,14 +118,10 @@ func NewFileLogBodyStore(rootDir string) (*FileLogBodyStore, error) {
|
|||||||
}
|
}
|
||||||
store := &FileLogBodyStore{
|
store := &FileLogBodyStore{
|
||||||
rootDir: rootDir,
|
rootDir: rootDir,
|
||||||
memory: NewMemoryLogBodyStore(),
|
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(rootDir, 0o700); err != nil {
|
if err := os.MkdirAll(rootDir, 0o700); err != nil {
|
||||||
return nil, fmt.Errorf("create log directory: %w", err)
|
return nil, fmt.Errorf("create log directory: %w", err)
|
||||||
}
|
}
|
||||||
if err := store.load(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return store, nil
|
return store, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,17 +133,19 @@ func (store *FileLogBodyStore) AppendBatch(streamID string, record domain.LogBat
|
|||||||
store.mu.Lock()
|
store.mu.Lock()
|
||||||
defer store.mu.Unlock()
|
defer store.mu.Unlock()
|
||||||
|
|
||||||
if _, exists, err := store.memory.GetBatch(streamID, record.FirstSeq); err != nil {
|
|
||||||
return err
|
|
||||||
} else if exists {
|
|
||||||
return store.memory.AppendBatch(streamID, record)
|
|
||||||
}
|
|
||||||
streamDir := store.streamDir(streamID)
|
streamDir := store.streamDir(streamID)
|
||||||
if err := os.MkdirAll(streamDir, 0o700); err != nil {
|
if err := os.MkdirAll(streamDir, 0o700); err != nil {
|
||||||
return fmt.Errorf("create log stream directory: %w", err)
|
return fmt.Errorf("create log stream directory: %w", err)
|
||||||
}
|
}
|
||||||
segmentPath := store.segmentPath(streamID, record.FirstSeq)
|
segmentPath := store.segmentPath(streamID, record.FirstSeq)
|
||||||
if _, err := os.Stat(segmentPath); err == nil {
|
if _, err := os.Stat(segmentPath); err == nil {
|
||||||
|
existing, err := readLogSegment(segmentPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sameLogBatchRecord(existing, record) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return validationError("log batch conflicts with acknowledged range")
|
return validationError("log batch conflicts with acknowledged range")
|
||||||
} else if !os.IsNotExist(err) {
|
} else if !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("stat log segment: %w", err)
|
return fmt.Errorf("stat log segment: %w", err)
|
||||||
@@ -157,64 +160,76 @@ func (store *FileLogBodyStore) AppendBatch(streamID string, record domain.LogBat
|
|||||||
if err := writeAtomicFile(segmentPath, []byte(body.String()), 0o600); err != nil {
|
if err := writeAtomicFile(segmentPath, []byte(body.String()), 0o600); err != nil {
|
||||||
return fmt.Errorf("persist log segment: %w", err)
|
return fmt.Errorf("persist log segment: %w", err)
|
||||||
}
|
}
|
||||||
return store.memory.AppendBatch(streamID, record)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *FileLogBodyStore) GetBatch(streamID string, firstSeq uint64) (domain.LogBatchRecord, bool, error) {
|
func (store *FileLogBodyStore) GetBatch(streamID string, firstSeq uint64) (domain.LogBatchRecord, bool, error) {
|
||||||
return store.memory.GetBatch(streamID, firstSeq)
|
store.mu.Lock()
|
||||||
|
defer store.mu.Unlock()
|
||||||
|
|
||||||
|
record, err := readLogSegment(store.segmentPath(streamID, firstSeq))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return domain.LogBatchRecord{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return domain.LogBatchRecord{}, false, err
|
||||||
|
}
|
||||||
|
if len(record.Entries) == 0 {
|
||||||
|
return domain.LogBatchRecord{}, false, nil
|
||||||
|
}
|
||||||
|
return record, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *FileLogBodyStore) Query(streamID string, afterSeq uint64, limit int) ([]domain.LogEntry, uint64, error) {
|
func (store *FileLogBodyStore) Query(streamID string, afterSeq uint64, limit int) ([]domain.LogEntry, uint64, error) {
|
||||||
return store.memory.Query(streamID, afterSeq, limit)
|
store.mu.Lock()
|
||||||
|
defer store.mu.Unlock()
|
||||||
|
|
||||||
|
if limit <= 0 {
|
||||||
|
return nil, afterSeq, nil
|
||||||
|
}
|
||||||
|
segments, err := store.listSegments(streamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
selected := make([]domain.LogEntry, 0, limit)
|
||||||
|
nextSeq := afterSeq
|
||||||
|
for index := firstLogSegmentIndex(segments, afterSeq); index < len(segments) && len(selected) < limit; index++ {
|
||||||
|
record, err := readLogSegment(segments[index].path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
for _, entry := range record.Entries {
|
||||||
|
if entry.Seq <= afterSeq {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(selected) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
selected = append(selected, domain.CopyLogEntry(entry))
|
||||||
|
nextSeq = entry.Seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected, nextSeq, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *FileLogBodyStore) LatestSeq(streamID string) (uint64, error) {
|
func (store *FileLogBodyStore) LatestSeq(streamID string) (uint64, error) {
|
||||||
return store.memory.LatestSeq(streamID)
|
store.mu.Lock()
|
||||||
}
|
defer store.mu.Unlock()
|
||||||
|
|
||||||
func (store *FileLogBodyStore) load() error {
|
segments, err := store.listSegments(streamID)
|
||||||
entries, err := os.ReadDir(store.rootDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read log root: %w", err)
|
return 0, err
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for index := len(segments) - 1; index >= 0; index-- {
|
||||||
if !entry.IsDir() {
|
record, err := readLogSegment(segments[index].path)
|
||||||
continue
|
|
||||||
}
|
|
||||||
streamID, err := url.PathUnescape(entry.Name())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("decode log stream directory: %w", err)
|
return 0, err
|
||||||
}
|
}
|
||||||
if err := store.loadStream(streamID, filepath.Join(store.rootDir, entry.Name())); err != nil {
|
if len(record.Entries) > 0 {
|
||||||
return err
|
return record.LastSeq, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return 0, nil
|
||||||
}
|
|
||||||
|
|
||||||
func (store *FileLogBodyStore) loadStream(streamID string, streamDir string) error {
|
|
||||||
segments, err := os.ReadDir(streamDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read log stream directory: %w", err)
|
|
||||||
}
|
|
||||||
sort.SliceStable(segments, func(i, j int) bool { return segments[i].Name() < segments[j].Name() })
|
|
||||||
for _, segment := range segments {
|
|
||||||
if segment.IsDir() || !strings.HasPrefix(segment.Name(), "segment-") || !strings.HasSuffix(segment.Name(), ".jsonl") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
segmentPath := filepath.Join(streamDir, segment.Name())
|
|
||||||
record, err := readLogSegment(segmentPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(record.Entries) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := store.memory.AppendBatch(streamID, record); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *FileLogBodyStore) streamDir(streamID string) string {
|
func (store *FileLogBodyStore) streamDir(streamID string) string {
|
||||||
@@ -225,6 +240,65 @@ func (store *FileLogBodyStore) segmentPath(streamID string, firstSeq uint64) str
|
|||||||
return filepath.Join(store.streamDir(streamID), fmt.Sprintf("segment-%020d.jsonl", firstSeq))
|
return filepath.Join(store.streamDir(streamID), fmt.Sprintf("segment-%020d.jsonl", firstSeq))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *FileLogBodyStore) listSegments(streamID string) ([]logSegmentRef, error) {
|
||||||
|
streamDir := store.streamDir(streamID)
|
||||||
|
entries, err := os.ReadDir(streamDir)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read log stream directory: %w", err)
|
||||||
|
}
|
||||||
|
segments := make([]logSegmentRef, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
firstSeq, ok, err := logSegmentFirstSeq(entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
segments = append(segments, logSegmentRef{firstSeq: firstSeq, path: filepath.Join(streamDir, entry.Name())})
|
||||||
|
}
|
||||||
|
sort.SliceStable(segments, func(i, j int) bool { return segments[i].firstSeq < segments[j].firstSeq })
|
||||||
|
return segments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func logSegmentFirstSeq(name string) (uint64, bool, error) {
|
||||||
|
if !strings.HasPrefix(name, "segment-") || !strings.HasSuffix(name, ".jsonl") {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
firstSeq, err := strconv.ParseUint(strings.TrimSuffix(strings.TrimPrefix(name, "segment-"), ".jsonl"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, true, fmt.Errorf("parse log segment %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return firstSeq, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLogSegmentIndex(segments []logSegmentRef, afterSeq uint64) int {
|
||||||
|
index := sort.Search(len(segments), func(i int) bool { return segments[i].firstSeq > afterSeq })
|
||||||
|
if index < len(segments) && afterSeq != ^uint64(0) && segments[index].firstSeq == afterSeq+1 {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
if index > 0 {
|
||||||
|
return index - 1
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameLogBatchRecord(left domain.LogBatchRecord, right domain.LogBatchRecord) bool {
|
||||||
|
if left.FirstSeq != right.FirstSeq || left.LastSeq != right.LastSeq {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if left.Checksum == right.Checksum {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return len(left.Entries) == 1 && right.Checksum == validator.LogLineChecksum(left.Entries[0].Line)
|
||||||
|
}
|
||||||
|
|
||||||
func readLogSegment(path string) (domain.LogBatchRecord, error) {
|
func readLogSegment(path string) (domain.LogBatchRecord, error) {
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -67,13 +67,12 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
|||||||
return domain.LogBatchIngestResult{}, validationError("log batch firstSeq must follow latest acknowledged sequence")
|
return domain.LogBatchIngestResult{}, validationError("log batch firstSeq must follow latest acknowledged sequence")
|
||||||
}
|
}
|
||||||
|
|
||||||
storedBatch := domain.CopyLogBatchIngest(batch)
|
record := domain.LogBatchRecord{
|
||||||
record := domain.CopyLogBatchRecord(domain.LogBatchRecord{
|
|
||||||
Checksum: batch.Checksum,
|
Checksum: batch.Checksum,
|
||||||
FirstSeq: batch.FirstSeq,
|
FirstSeq: batch.FirstSeq,
|
||||||
LastSeq: batch.LastSeq,
|
LastSeq: batch.LastSeq,
|
||||||
Entries: storedBatch.Entries,
|
Entries: batch.Entries,
|
||||||
})
|
}
|
||||||
if err := svc.logStore.AppendBatch(batch.LogStreamID, record); err != nil {
|
if err := svc.logStore.AppendBatch(batch.LogStreamID, record); err != nil {
|
||||||
return domain.LogBatchIngestResult{}, err
|
return domain.LogBatchIngestResult{}, err
|
||||||
}
|
}
|
||||||
@@ -84,7 +83,7 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
|||||||
}
|
}
|
||||||
locked = false
|
locked = false
|
||||||
lock.Unlock()
|
lock.Unlock()
|
||||||
svc.publishLogEvents(stream, storedBatch.Entries)
|
svc.publishLogEvents(stream, batch.Entries)
|
||||||
return domain.LogBatchIngestResult{
|
return domain.LogBatchIngestResult{
|
||||||
Accepted: true,
|
Accepted: true,
|
||||||
LogStreamID: batch.LogStreamID,
|
LogStreamID: batch.LogStreamID,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -554,6 +555,37 @@ func TestFileLogBodyStoreReloadsVerbatimLongLogLine(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileLogBodyStoreStartupSkipsBodyLoadAndQueriesFromCursorSegment(t *testing.T) {
|
||||||
|
rootDir := filepath.Join(t.TempDir(), "logs")
|
||||||
|
store, err := NewFileLogBodyStore(rootDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create file log store: %v", err)
|
||||||
|
}
|
||||||
|
first := []domain.LogEntry{{Seq: 1, Timestamp: time.Date(2026, 7, 3, 12, 0, 1, 0, time.UTC), Line: "old"}}
|
||||||
|
second := []domain.LogEntry{{Seq: 2, Timestamp: time.Date(2026, 7, 3, 12, 0, 2, 0, time.UTC), Line: "new"}}
|
||||||
|
if err := store.AppendBatch("log-1", domain.LogBatchRecord{Checksum: checksumForEntries(t, first), FirstSeq: 1, LastSeq: 1, Entries: first}); err != nil {
|
||||||
|
t.Fatalf("append first segment: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AppendBatch("log-1", domain.LogBatchRecord{Checksum: checksumForEntries(t, second), FirstSeq: 2, LastSeq: 2, Entries: second}); err != nil {
|
||||||
|
t.Fatalf("append second segment: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(store.segmentPath("log-1", 1), []byte("{not-json\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("corrupt old segment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded, err := NewFileLogBodyStore(rootDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload should not read all log bodies: %v", err)
|
||||||
|
}
|
||||||
|
selected, nextSeq, err := reloaded.Query("log-1", 1, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query from cursor segment: %v", err)
|
||||||
|
}
|
||||||
|
if len(selected) != 1 || selected[0].Seq != 2 || selected[0].Line != "new" || nextSeq != 2 {
|
||||||
|
t.Fatalf("unexpected cursor query: entries=%+v next=%d", selected, nextSeq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newRegisteredLogIngestService(t *testing.T) (*CoreService, string) {
|
func newRegisteredLogIngestService(t *testing.T) (*CoreService, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
svc := newTestCoreService()
|
svc := newTestCoreService()
|
||||||
|
|||||||
Reference in New Issue
Block a user