Keep Run startup independent from log backlog

This commit is contained in:
npc0-hue
2026-08-31 18:13:38 +08:00
parent 4277791cb7
commit 40b704d60f
2 changed files with 339 additions and 52 deletions
+292 -52
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -23,7 +24,10 @@ type LogSpool struct {
inflight map[string]bool inflight map[string]bool
} }
const maxAggregatedLogEntries = 128 const (
maxAggregatedLogEntries = 128
maxDurableLogSegmentBytes = 8 * 1024 * 1024
)
type logStreamWatermark struct { type logStreamWatermark struct {
Allocated uint64 `json:"allocated"` Allocated uint64 `json:"allocated"`
@@ -50,7 +54,26 @@ type pendingLogSegment struct {
batch durableLogBatch batch durableLogBatch
} }
type pendingLogSegmentWatermark struct {
logStreamID string
lastSeq uint64
sourceCursor *LogSourceCursor
}
type pendingSegmentScanOptions struct {
diagnosticPhase string
}
type pendingSegmentScanStats struct {
candidates int
loaded int
skipped int
quarantined int
bytes int64
}
func NewLogSpool(dir string) (LogSpool, error) { func NewLogSpool(dir string) (LogSpool, error) {
startedAt := time.Now()
if strings.TrimSpace(dir) == "" { if strings.TrimSpace(dir) == "" {
return LogSpool{}, fmt.Errorf("spool directory is required") return LogSpool{}, fmt.Errorf("spool directory is required")
} }
@@ -62,39 +85,23 @@ func NewLogSpool(dir string) (LogSpool, error) {
if err := spool.loadWatermarks(); err != nil { if err := spool.loadWatermarks(); err != nil {
return LogSpool{}, err return LogSpool{}, err
} }
if err := spool.restorePendingWatermarks(); err != nil { log.Printf("RUN phase=log_spool.init status=watermarks_loaded streams=%d durationMs=%d", len(spool.watermarks), time.Since(startedAt).Milliseconds())
if err := spool.inspectPendingSegmentFiles(); err != nil {
return LogSpool{}, err return LogSpool{}, err
} }
log.Printf("RUN phase=log_spool.init status=complete streams=%d durationMs=%d", len(spool.watermarks), time.Since(startedAt).Milliseconds())
return spool, nil return spool, nil
} }
// restorePendingWatermarks supports upgrades from spools created before func (spool LogSpool) inspectPendingSegmentFiles() error {
// per-stream watermarks existed. Pending durable segments are still an
// allocation fact and must win over a remote progress lookup.
func (spool *LogSpool) restorePendingWatermarks() error {
spool.mu.Lock() spool.mu.Lock()
defer spool.mu.Unlock() defer spool.mu.Unlock()
pending, err := spool.pendingSegmentsLocked() entries, err := os.ReadDir(spool.dir)
if err != nil { if err != nil {
return err return fmt.Errorf("read log spool: %w", err)
}
changed := false
for _, segment := range pending {
batch := segment.batch
watermark := spool.watermarks[batch.LogStreamID]
if batch.LastSeq > watermark.Allocated {
watermark.Allocated = batch.LastSeq
changed = true
}
if batch.SourceCursor != nil && batch.SourceCursor.EndOffset > watermark.SourceOffset {
watermark.SourceOffset = batch.SourceCursor.EndOffset
changed = true
}
spool.watermarks[batch.LogStreamID] = watermark
}
if changed {
return spool.persistWatermarksLocked()
} }
paths, stats := spool.pendingSegmentPathsLocked(entries, pendingSegmentScanOptions{diagnosticPhase: "startup_inspect"})
log.Printf("RUN phase=log_spool.inspect status=complete candidates=%d accepted=%d skipped=%d quarantined=%d bytes=%d", stats.candidates, len(paths), stats.skipped, stats.quarantined, stats.bytes)
return nil return nil
} }
@@ -102,6 +109,16 @@ func (spool LogSpool) NextSequence(ctx context.Context, streamID string, recover
spool.mu.Lock() spool.mu.Lock()
defer spool.mu.Unlock() defer spool.mu.Unlock()
watermark, known := spool.watermarks[streamID] watermark, known := spool.watermarks[streamID]
if !known {
restored, restoredKnown, err := spool.restorePendingWatermarkLocked(streamID)
if err != nil {
return 0, err
}
if restoredKnown {
watermark = restored
known = true
}
}
if !known && recover != nil { if !known && recover != nil {
latest, err := recover(ctx, streamID) latest, err := recover(ctx, streamID)
if err != nil { if err != nil {
@@ -140,6 +157,16 @@ func (spool LogSpool) EnqueueNextAggregated(ctx context.Context, batch protocol.
spool.mu.Lock() spool.mu.Lock()
defer spool.mu.Unlock() defer spool.mu.Unlock()
watermark, known := spool.watermarks[batch.LogStreamID] watermark, known := spool.watermarks[batch.LogStreamID]
if !known {
restored, restoredKnown, err := spool.restorePendingWatermarkLocked(batch.LogStreamID)
if err != nil {
return 0, false, err
}
if restoredKnown {
watermark = restored
known = true
}
}
if !known && recover != nil { if !known && recover != nil {
latest, err := recover(ctx, batch.LogStreamID) latest, err := recover(ctx, batch.LogStreamID)
if err != nil { if err != nil {
@@ -283,6 +310,43 @@ func (spool LogSpool) writeBatchAt(path string, batch durableLogBatch) error {
return nil return nil
} }
// restorePendingWatermarkLocked lazily supports upgrades from spools created
// before per-stream watermarks existed without making worker startup decode the
// entire durable log backlog.
func (spool LogSpool) restorePendingWatermarkLocked(streamID string) (logStreamWatermark, bool, error) {
if strings.TrimSpace(streamID) == "" {
return logStreamWatermark{}, false, nil
}
startedAt := time.Now()
pending, stats, err := spool.pendingSegmentWatermarksLocked(pendingSegmentScanOptions{diagnosticPhase: "lazy_restore"})
if err != nil {
return logStreamWatermark{}, false, err
}
watermark := spool.watermarks[streamID]
changed := false
for _, segment := range pending {
if segment.logStreamID != streamID {
continue
}
if segment.lastSeq > watermark.Allocated {
watermark.Allocated = segment.lastSeq
changed = true
}
if segment.sourceCursor != nil && segment.sourceCursor.EndOffset > watermark.SourceOffset {
watermark.SourceOffset = segment.sourceCursor.EndOffset
changed = true
}
}
if changed {
spool.watermarks[streamID] = watermark
if err := spool.persistWatermarksLocked(); err != nil {
return logStreamWatermark{}, false, err
}
}
log.Printf("RUN phase=log_spool.restore status=lazy_complete stream=%s found=%t candidates=%d loaded=%d skipped=%d quarantined=%d durationMs=%d", safeSpoolStreamID(streamID), changed, stats.candidates, stats.loaded, stats.skipped, stats.quarantined, time.Since(startedAt).Milliseconds())
return watermark, changed, nil
}
func (spool LogSpool) Pending() ([]protocol.LogBatchIngestRequest, error) { func (spool LogSpool) Pending() ([]protocol.LogBatchIngestRequest, error) {
spool.mu.Lock() spool.mu.Lock()
defer spool.mu.Unlock() defer spool.mu.Unlock()
@@ -298,35 +362,135 @@ func (spool LogSpool) Pending() ([]protocol.LogBatchIngestRequest, error) {
} }
func (spool LogSpool) pendingSegmentsLocked() ([]pendingLogSegment, error) { func (spool LogSpool) pendingSegmentsLocked() ([]pendingLogSegment, error) {
segments, _, err := spool.pendingSegmentsLockedWithOptions(pendingSegmentScanOptions{})
return segments, err
}
func (spool LogSpool) pendingSegmentsLockedWithOptions(options pendingSegmentScanOptions) ([]pendingLogSegment, pendingSegmentScanStats, error) {
entries, err := os.ReadDir(spool.dir) entries, err := os.ReadDir(spool.dir)
if err != nil { if err != nil {
return nil, fmt.Errorf("read log spool: %w", err) return nil, pendingSegmentScanStats{}, fmt.Errorf("read log spool: %w", err)
} }
paths := make([]string, 0, len(entries)) paths, stats := spool.pendingSegmentPathsLocked(entries, options)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
paths = append(paths, filepath.Join(spool.dir, entry.Name()))
}
sort.Strings(paths)
segments := make([]pendingLogSegment, 0, len(paths)) segments := make([]pendingLogSegment, 0, len(paths))
for _, path := range paths { for _, path := range paths {
batch, err := readDurableLogBatch(path) batch, err := readDurableLogBatch(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
stats.skipped++
continue continue
} }
return nil, err if spool.quarantineLogSegmentLocked(path, "unreadable") == nil {
stats.quarantined++
log.Printf("RUN phase=log_spool.scan status=segment_quarantined reason=unreadable file=%s error=%s", safeSpoolFileName(path), logSpoolErrorKind(err))
continue
}
stats.skipped++
log.Printf("RUN phase=log_spool.scan status=segment_skipped reason=unreadable file=%s error=%s", safeSpoolFileName(path), logSpoolErrorKind(err))
continue
} }
var modTime time.Time var modTime time.Time
if info, statErr := os.Stat(path); statErr == nil { if info, statErr := os.Stat(path); statErr == nil {
modTime = info.ModTime() modTime = info.ModTime()
} }
segments = append(segments, pendingLogSegment{path: path, modTime: modTime, batch: batch}) segments = append(segments, pendingLogSegment{path: path, modTime: modTime, batch: batch})
stats.loaded++
} }
sort.SliceStable(segments, func(i, j int) bool { return pendingLogSegmentBefore(segments[i], segments[j]) }) sort.SliceStable(segments, func(i, j int) bool { return pendingLogSegmentBefore(segments[i], segments[j]) })
return segments, nil return segments, stats, nil
}
func (spool LogSpool) pendingSegmentWatermarksLocked(options pendingSegmentScanOptions) ([]pendingLogSegmentWatermark, pendingSegmentScanStats, error) {
entries, err := os.ReadDir(spool.dir)
if err != nil {
return nil, pendingSegmentScanStats{}, fmt.Errorf("read log spool: %w", err)
}
paths, stats := spool.pendingSegmentPathsLocked(entries, options)
segments := make([]pendingLogSegmentWatermark, 0, len(paths))
for _, path := range paths {
watermark, err := readDurableLogBatchWatermark(path)
if err != nil {
if os.IsNotExist(err) {
stats.skipped++
continue
}
if spool.quarantineLogSegmentLocked(path, "unreadable") == nil {
stats.quarantined++
log.Printf("RUN phase=log_spool.scan status=segment_quarantined phase=%s reason=unreadable file=%s error=%s", safeDiagnosticPhase(options.diagnosticPhase), safeSpoolFileName(path), logSpoolErrorKind(err))
continue
}
stats.skipped++
log.Printf("RUN phase=log_spool.scan status=segment_skipped phase=%s reason=unreadable file=%s error=%s", safeDiagnosticPhase(options.diagnosticPhase), safeSpoolFileName(path), logSpoolErrorKind(err))
continue
}
if strings.TrimSpace(watermark.LogStreamID) == "" {
if spool.quarantineLogSegmentLocked(path, "missing_stream") == nil {
stats.quarantined++
log.Printf("RUN phase=log_spool.scan status=segment_quarantined phase=%s reason=missing_stream file=%s", safeDiagnosticPhase(options.diagnosticPhase), safeSpoolFileName(path))
continue
}
stats.skipped++
continue
}
segments = append(segments, pendingLogSegmentWatermark{logStreamID: watermark.LogStreamID, lastSeq: watermark.LastSeq, sourceCursor: watermark.SourceCursor})
stats.loaded++
}
return segments, stats, nil
}
func (spool LogSpool) pendingSegmentPathsLocked(entries []os.DirEntry, options pendingSegmentScanOptions) ([]string, pendingSegmentScanStats) {
startedAt := time.Now()
paths := make([]string, 0, len(entries))
stats := pendingSegmentScanStats{}
if options.diagnosticPhase != "" {
log.Printf("RUN phase=log_spool.scan status=starting phase=%s entries=%d", safeDiagnosticPhase(options.diagnosticPhase), len(entries))
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
stats.candidates++
path := filepath.Join(spool.dir, entry.Name())
info, err := entry.Info()
if err != nil {
if spool.quarantineLogSegmentLocked(path, "stat_failed") == nil {
stats.quarantined++
log.Printf("RUN phase=log_spool.scan status=segment_quarantined phase=%s reason=stat_failed file=%s error=%s", safeDiagnosticPhase(options.diagnosticPhase), safeSpoolFileName(path), logSpoolErrorKind(err))
continue
}
stats.skipped++
log.Printf("RUN phase=log_spool.scan status=segment_skipped phase=%s reason=stat_failed file=%s error=%s", safeDiagnosticPhase(options.diagnosticPhase), safeSpoolFileName(path), logSpoolErrorKind(err))
continue
}
if !info.Mode().IsRegular() {
if spool.quarantineLogSegmentLocked(path, "non_regular") == nil {
stats.quarantined++
log.Printf("RUN phase=log_spool.scan status=segment_quarantined phase=%s reason=non_regular file=%s", safeDiagnosticPhase(options.diagnosticPhase), safeSpoolFileName(path))
continue
}
stats.skipped++
continue
}
if info.Size() > maxDurableLogSegmentBytes {
if spool.quarantineLogSegmentLocked(path, "oversized") == nil {
stats.quarantined++
log.Printf("RUN phase=log_spool.scan status=segment_quarantined phase=%s reason=oversized file=%s bytes=%d limit=%d", safeDiagnosticPhase(options.diagnosticPhase), safeSpoolFileName(path), info.Size(), maxDurableLogSegmentBytes)
continue
}
stats.skipped++
continue
}
stats.bytes += info.Size()
paths = append(paths, path)
if options.diagnosticPhase != "" && stats.candidates%100 == 0 {
log.Printf("RUN phase=log_spool.scan status=progress phase=%s candidates=%d accepted=%d quarantined=%d skipped=%d durationMs=%d", safeDiagnosticPhase(options.diagnosticPhase), stats.candidates, len(paths), stats.quarantined, stats.skipped, time.Since(startedAt).Milliseconds())
}
}
sort.Strings(paths)
if options.diagnosticPhase != "" {
log.Printf("RUN phase=log_spool.scan status=ready phase=%s candidates=%d accepted=%d quarantined=%d skipped=%d bytes=%d durationMs=%d", safeDiagnosticPhase(options.diagnosticPhase), stats.candidates, len(paths), stats.quarantined, stats.skipped, stats.bytes, time.Since(startedAt).Milliseconds())
}
return paths, stats
} }
func pendingLogSegmentBefore(left pendingLogSegment, right pendingLogSegment) bool { func pendingLogSegmentBefore(left pendingLogSegment, right pendingLogSegment) bool {
@@ -542,19 +706,14 @@ func (spool LogSpool) RejectStreamAfter(logStreamID string, latestSeq uint64, re
if err != nil { if err != nil {
return 0, err return 0, err
} }
rejectedDir := filepath.Join(filepath.Dir(spool.dir), "logs-rejected")
rejected := 0 rejected := 0
for _, segment := range segments { for _, segment := range segments {
batch := segment.batch.LogBatchIngestRequest batch := segment.batch.LogBatchIngestRequest
if batch.LogStreamID != logStreamID || batch.FirstSeq <= latestSeq { if batch.LogStreamID != logStreamID || batch.FirstSeq <= latestSeq {
continue continue
} }
if err := os.MkdirAll(rejectedDir, 0o755); err != nil { if err := spool.moveLogSegmentToRejectedLocked(segment.path, reason); err != nil {
return rejected, fmt.Errorf("create rejected log spool directory: %w", err) return rejected, err
}
rejectedPath := filepath.Join(rejectedDir, fmt.Sprintf("%s.%s", filepath.Base(segment.path), sanitizeSegmentName(reason)))
if err := os.Rename(segment.path, rejectedPath); err != nil {
return rejected, fmt.Errorf("move rejected log spool segment: %w", err)
} }
rejected++ rejected++
} }
@@ -579,18 +738,13 @@ func (spool LogSpool) Reject(batch protocol.LogBatchIngestRequest, reason string
if path == "" { if path == "" {
return nil return nil
} }
rejectedDir := filepath.Join(filepath.Dir(spool.dir), "logs-rejected") return spool.moveLogSegmentToRejectedLocked(path, reason)
if err := os.MkdirAll(rejectedDir, 0o755); err != nil {
return fmt.Errorf("create rejected log spool directory: %w", err)
}
rejectedPath := filepath.Join(rejectedDir, fmt.Sprintf("%s.%s", filepath.Base(path), sanitizeSegmentName(reason)))
if err := os.Rename(path, rejectedPath); err != nil {
return fmt.Errorf("move rejected log spool segment: %w", err)
}
return nil
} }
func readDurableLogBatch(path string) (durableLogBatch, error) { func readDurableLogBatch(path string) (durableLogBatch, error) {
if err := validateDurableLogSegment(path); err != nil {
return durableLogBatch{}, err
}
file, err := os.Open(path) file, err := os.Open(path)
if err != nil { if err != nil {
return durableLogBatch{}, err return durableLogBatch{}, err
@@ -603,6 +757,92 @@ func readDurableLogBatch(path string) (durableLogBatch, error) {
return batch, nil return batch, nil
} }
func readDurableLogBatchWatermark(path string) (struct {
LogStreamID string `json:"logStreamId"`
LastSeq uint64 `json:"lastSeq"`
SourceCursor *LogSourceCursor `json:"_sourceCursor,omitempty"`
}, error) {
var watermark struct {
LogStreamID string `json:"logStreamId"`
LastSeq uint64 `json:"lastSeq"`
SourceCursor *LogSourceCursor `json:"_sourceCursor,omitempty"`
}
if err := validateDurableLogSegment(path); err != nil {
return watermark, err
}
file, err := os.Open(path)
if err != nil {
return watermark, err
}
defer file.Close()
if err := json.NewDecoder(file).Decode(&watermark); err != nil {
return watermark, fmt.Errorf("decode log spool segment watermark: %w", err)
}
return watermark, nil
}
func validateDurableLogSegment(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("log spool segment is not a regular file")
}
if info.Size() > maxDurableLogSegmentBytes {
return fmt.Errorf("log spool segment exceeds %d bytes", maxDurableLogSegmentBytes)
}
return nil
}
func (spool LogSpool) quarantineLogSegmentLocked(path string, reason string) error {
return spool.moveLogSegmentToRejectedLocked(path, reason)
}
func (spool LogSpool) moveLogSegmentToRejectedLocked(path string, reason string) error {
rejectedDir := filepath.Join(filepath.Dir(spool.dir), "logs-rejected")
if err := os.MkdirAll(rejectedDir, 0o755); err != nil {
return fmt.Errorf("create rejected log spool directory: %w", err)
}
rejectedPath := filepath.Join(rejectedDir, fmt.Sprintf("%s.%s.%d", filepath.Base(path), sanitizeSegmentName(reason), time.Now().UTC().UnixNano()))
if err := os.Rename(path, rejectedPath); err != nil {
return fmt.Errorf("move rejected log spool segment: %w", err)
}
return nil
}
func safeSpoolFileName(path string) string {
return sanitizeSegmentName(filepath.Base(path))
}
func safeSpoolStreamID(streamID string) string {
return sanitizeSegmentName(streamID)
}
func safeDiagnosticPhase(value string) string {
if strings.TrimSpace(value) == "" {
return "-"
}
return sanitizeSegmentName(value)
}
func logSpoolErrorKind(err error) string {
if err == nil {
return "-"
}
if os.IsNotExist(err) {
return "not_found"
}
if os.IsPermission(err) {
return "permission"
}
var pathErr *os.PathError
if errors.As(err, &pathErr) {
return sanitizeSegmentName(pathErr.Op + "_" + pathErr.Err.Error())
}
return sanitizeSegmentName(err.Error())
}
func syncFile(path string) error { func syncFile(path string) error {
file, err := os.OpenFile(path, os.O_RDWR, 0) file, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil { if err != nil {
+47
View File
@@ -219,6 +219,53 @@ func TestLogSpoolRestoresPendingAllocationWithoutWatermark(t *testing.T) {
} }
} }
func TestLogSpoolStartupDoesNotDecodeCorruptPendingSegment(t *testing.T) {
root := t.TempDir()
logDir := filepath.Join(root, "logs")
if err := os.MkdirAll(logDir, 0o755); err != nil {
t.Fatalf("create log dir: %v", err)
}
if err := os.WriteFile(filepath.Join(logDir, "bad-00000000000000000001-00000000000000000001.json"), []byte("{"), 0o600); err != nil {
t.Fatalf("write corrupt segment: %v", err)
}
logSpool, err := NewLogSpool(root)
if err != nil {
t.Fatalf("startup should not decode corrupt segment: %v", err)
}
pending, err := logSpool.Pending()
if err != nil || len(pending) != 0 {
t.Fatalf("expected corrupt segment to be quarantined when read, pending=%+v err=%v", pending, err)
}
rejected, err := os.ReadDir(filepath.Join(root, "logs-rejected"))
if err != nil || len(rejected) != 1 {
t.Fatalf("expected one rejected corrupt segment, rejected=%+v err=%v", rejected, err)
}
}
func TestLogSpoolStartupQuarantinesOversizedPendingSegment(t *testing.T) {
root := t.TempDir()
logDir := filepath.Join(root, "logs")
if err := os.MkdirAll(logDir, 0o755); err != nil {
t.Fatalf("create log dir: %v", err)
}
oversized := make([]byte, maxDurableLogSegmentBytes+1)
if err := os.WriteFile(filepath.Join(logDir, "huge-00000000000000000001-00000000000000000001.json"), oversized, 0o600); err != nil {
t.Fatalf("write oversized segment: %v", err)
}
logSpool, err := NewLogSpool(root)
if err != nil {
t.Fatalf("startup should quarantine oversized segment: %v", err)
}
pending, err := logSpool.Pending()
if err != nil || len(pending) != 0 {
t.Fatalf("expected oversized segment to be quarantined, pending=%+v err=%v", pending, err)
}
rejected, err := os.ReadDir(filepath.Join(root, "logs-rejected"))
if err != nil || len(rejected) != 1 {
t.Fatalf("expected one rejected oversized segment, rejected=%+v err=%v", rejected, err)
}
}
func TestLogSpoolSourceCursorDeduplicatesAcknowledgedReplayAfterRestart(t *testing.T) { func TestLogSpoolSourceCursorDeduplicatesAcknowledgedReplayAfterRestart(t *testing.T) {
root := t.TempDir() root := t.TempDir()
checksum := func(entries []protocol.LogEntry) (string, error) { checksum := func(entries []protocol.LogEntry) (string, error) {