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.
1061 lines
32 KiB
1061 lines
32 KiB
// Package logstorage implements the authoritative, bounded on-device event
|
|
// journal owned by tera-logd. Closed segment files are immutable; the index is
|
|
// derived metadata and may always be rebuilt from the checksummed records.
|
|
package logstorage
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"tera-clone/services/tera-api/internal/storage/atomicfile"
|
|
)
|
|
|
|
const (
|
|
ClassAudit = "audit"
|
|
ClassSystem = "system"
|
|
ClassPort = "port"
|
|
ClassLink = "link"
|
|
|
|
formatVersion = 1
|
|
defaultSegmentSize = int64(4 << 20)
|
|
minimumSegmentSize = int64(1 << 20)
|
|
maximumRecordBytes = int64(64 << 10)
|
|
pageCheckpointRecords = 256
|
|
)
|
|
|
|
var (
|
|
ErrCapacityExhausted = errors.New("log storage capacity exhausted")
|
|
ErrCorruptSegment = errors.New("log segment integrity check failed")
|
|
errPageComplete = errors.New("log page complete")
|
|
)
|
|
|
|
type Options struct {
|
|
Root string
|
|
SegmentBytes int64
|
|
AuditCapBytes int64
|
|
SystemCapBytes int64
|
|
PortCapBytes int64
|
|
IndexCapBytes int64
|
|
GlobalFreeFloor int64
|
|
Clock func() time.Time
|
|
FreeBytes func(string) (int64, error)
|
|
}
|
|
|
|
type Retention struct {
|
|
AuditDays int `json:"audit_days"`
|
|
SystemDays int `json:"system_days"`
|
|
PortDays int `json:"port_days"`
|
|
}
|
|
|
|
type Envelope struct {
|
|
FormatVersion int `json:"format_version"`
|
|
Sequence uint64 `json:"sequence"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Class string `json:"class"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
Checksum string `json:"checksum_sha256"`
|
|
}
|
|
|
|
type classState struct {
|
|
NextSequence uint64
|
|
ActiveFirst time.Time
|
|
ActiveLast time.Time
|
|
ActiveFirstSequence uint64
|
|
ActiveLastSequence uint64
|
|
ActiveBytes int64
|
|
ActiveRecords int
|
|
ActiveOffsets []recordOffset
|
|
}
|
|
|
|
type recordOffset struct {
|
|
Sequence uint64 `json:"sequence"`
|
|
Offset int64 `json:"offset"`
|
|
}
|
|
|
|
type segmentIndex struct {
|
|
FormatVersion int `json:"format_version"`
|
|
RebuiltAt time.Time `json:"rebuilt_at"`
|
|
Classes map[string]uint64 `json:"next_sequence"`
|
|
Segments []segmentMeta `json:"segments"`
|
|
}
|
|
|
|
type segmentMeta struct {
|
|
Class string `json:"class"`
|
|
Path string `json:"path"`
|
|
Closed bool `json:"closed"`
|
|
FirstSequence uint64 `json:"first_sequence"`
|
|
LastSequence uint64 `json:"last_sequence"`
|
|
Oldest time.Time `json:"oldest"`
|
|
Newest time.Time `json:"newest"`
|
|
Bytes int64 `json:"bytes"`
|
|
Records int `json:"records"`
|
|
PageOffsets []recordOffset `json:"page_offsets,omitempty"`
|
|
}
|
|
|
|
type Snapshot struct {
|
|
Root string `json:"root"`
|
|
FreeBytes int64 `json:"free_bytes"`
|
|
GlobalFreeFloor int64 `json:"global_free_floor_bytes"`
|
|
UsageBytes map[string]int64 `json:"usage_bytes"`
|
|
CapBytes map[string]int64 `json:"cap_bytes"`
|
|
NextSequence map[string]uint64 `json:"next_sequence"`
|
|
Oldest map[string]time.Time `json:"oldest"`
|
|
Newest map[string]time.Time `json:"newest"`
|
|
}
|
|
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
root string
|
|
segment int64
|
|
caps map[string]int64
|
|
indexCap int64
|
|
floor int64
|
|
clock func() time.Time
|
|
freeBytes func(string) (int64, error)
|
|
retention Retention
|
|
classes map[string]*classState
|
|
closed map[string][]segmentMeta
|
|
portClears portClearState
|
|
systemClear systemClearState
|
|
linkClear linkClearState
|
|
}
|
|
|
|
func Open(options Options) (*Store, error) {
|
|
root := filepath.Clean(strings.TrimSpace(options.Root))
|
|
if root == "." || !filepath.IsAbs(root) {
|
|
return nil, errors.New("log storage root must be an absolute path")
|
|
}
|
|
info, err := os.Lstat(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inspect log storage root: %w", err)
|
|
}
|
|
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return nil, errors.New("log storage root must be a real directory")
|
|
}
|
|
segmentBytes := options.SegmentBytes
|
|
if segmentBytes == 0 {
|
|
segmentBytes = defaultSegmentSize
|
|
}
|
|
if segmentBytes < minimumSegmentSize || segmentBytes > defaultSegmentSize {
|
|
return nil, fmt.Errorf("log segment size must be between %d and %d bytes", minimumSegmentSize, defaultSegmentSize)
|
|
}
|
|
clock := options.Clock
|
|
if clock == nil {
|
|
clock = time.Now
|
|
}
|
|
free := options.FreeBytes
|
|
if free == nil {
|
|
free = filesystemFreeBytes
|
|
total, err := filesystemTotalBytes(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Reserve at least 10% of the filesystem: log capacity is fixed at 90%.
|
|
if total/10 > options.GlobalFreeFloor {
|
|
options.GlobalFreeFloor = total / 10
|
|
}
|
|
}
|
|
store := &Store{
|
|
root: root, segment: segmentBytes, indexCap: options.IndexCapBytes,
|
|
floor: options.GlobalFreeFloor, clock: clock, freeBytes: free,
|
|
retention: Retention{AuditDays: 365, SystemDays: 365, PortDays: 365},
|
|
caps: map[string]int64{
|
|
"audit": options.AuditCapBytes, "system": options.SystemCapBytes, "port": options.PortCapBytes,
|
|
},
|
|
classes: map[string]*classState{},
|
|
closed: map[string][]segmentMeta{},
|
|
}
|
|
for _, directory := range []string{"audit", "system", "port", "index", "export"} {
|
|
path := filepath.Join(root, directory)
|
|
if err := ensureDirectory(path, directory == "export"); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
if err := store.loadPortClearsLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.loadLinkClearLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.loadSystemClearLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.rebuildLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(store.portClears.Cutoffs) > 0 {
|
|
if err := store.compactClearedPortsLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if store.systemClear.Revision > 0 {
|
|
if err := store.compactClearedClassLocked(ClassSystem, store.systemRecordCleared); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := store.compactClearedClassLocked(ClassAudit, store.systemRecordCleared); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if store.linkClear.Revision > 0 {
|
|
if err := store.compactClearedClassLocked(ClassLink, store.linkRecordCleared); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
func ensureDirectory(path string, private bool) error {
|
|
mode := fs.FileMode(0o750)
|
|
if private {
|
|
mode = 0o700
|
|
}
|
|
info, err := os.Lstat(path)
|
|
if os.IsNotExist(err) {
|
|
if err := os.Mkdir(path, mode); err != nil {
|
|
return fmt.Errorf("create log storage directory: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("inspect log storage directory: %w", err)
|
|
}
|
|
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("log storage path must be a real directory: %s", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) Append(class string, timestamp time.Time, value any) (Envelope, error) {
|
|
class, err := normalizeClass(class)
|
|
if err != nil {
|
|
return Envelope{}, err
|
|
}
|
|
if timestamp.IsZero() {
|
|
timestamp = s.clock().UTC()
|
|
} else {
|
|
timestamp = timestamp.UTC()
|
|
}
|
|
payload, err := json.Marshal(value)
|
|
if err != nil {
|
|
return Envelope{}, fmt.Errorf("encode %s log payload: %w", class, err)
|
|
}
|
|
if len(payload) == 0 || int64(len(payload)) > maximumRecordBytes {
|
|
return Envelope{}, fmt.Errorf("%s log payload must be between 1 and %d bytes", class, maximumRecordBytes)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
state := s.classes[class]
|
|
if state == nil {
|
|
state = &classState{NextSequence: 1}
|
|
s.classes[class] = state
|
|
}
|
|
envelope := Envelope{FormatVersion: formatVersion, Sequence: state.NextSequence, Timestamp: timestamp, Class: class, Payload: payload}
|
|
envelope.Checksum, err = envelopeChecksum(envelope)
|
|
if err != nil {
|
|
return Envelope{}, err
|
|
}
|
|
line, err := json.Marshal(envelope)
|
|
if err != nil {
|
|
return Envelope{}, fmt.Errorf("encode %s log envelope: %w", class, err)
|
|
}
|
|
line = append(line, '\n')
|
|
if int64(len(line)) > maximumRecordBytes+4096 {
|
|
return Envelope{}, fmt.Errorf("%s log record is too large", class)
|
|
}
|
|
if state.ActiveBytes > 0 && state.ActiveBytes+int64(len(line)) > s.segment {
|
|
if err := s.rotateLocked(class); err != nil {
|
|
return Envelope{}, err
|
|
}
|
|
}
|
|
if err := s.ensureCapacityLocked(class, int64(len(line))); err != nil {
|
|
return Envelope{}, err
|
|
}
|
|
path := s.activePath(class)
|
|
file, err := openAppendRegular(path)
|
|
if err != nil {
|
|
return Envelope{}, fmt.Errorf("open active %s log segment: %w", class, err)
|
|
}
|
|
_, writeErr := file.Write(line)
|
|
if writeErr == nil {
|
|
writeErr = file.Sync()
|
|
}
|
|
closeErr := file.Close()
|
|
if writeErr != nil {
|
|
return Envelope{}, fmt.Errorf("append active %s log segment: %w", class, writeErr)
|
|
}
|
|
if closeErr != nil {
|
|
return Envelope{}, fmt.Errorf("close active %s log segment: %w", class, closeErr)
|
|
}
|
|
if state.ActiveFirst.IsZero() {
|
|
state.ActiveFirst = timestamp
|
|
state.ActiveFirstSequence = envelope.Sequence
|
|
}
|
|
state.ActiveLast = timestamp
|
|
state.ActiveLastSequence = envelope.Sequence
|
|
if state.ActiveRecords%pageCheckpointRecords == 0 {
|
|
state.ActiveOffsets = append(state.ActiveOffsets, recordOffset{Sequence: envelope.Sequence, Offset: state.ActiveBytes})
|
|
}
|
|
state.ActiveRecords++
|
|
state.ActiveBytes += int64(len(line))
|
|
state.NextSequence++
|
|
return envelope, nil
|
|
}
|
|
|
|
func openAppendRegular(path string) (*os.File, error) {
|
|
if info, err := os.Lstat(path); err == nil {
|
|
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
|
return nil, errors.New("active log segment must be a regular file")
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
return os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
|
}
|
|
|
|
func (s *Store) Read(class string) ([]json.RawMessage, error) {
|
|
class, err := normalizeClass(class)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var payloads []json.RawMessage
|
|
err = s.walkClassLocked(class, false, func(envelope Envelope) error {
|
|
payloads = append(payloads, append(json.RawMessage(nil), envelope.Payload...))
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if payloads == nil {
|
|
payloads = []json.RawMessage{}
|
|
}
|
|
return payloads, nil
|
|
}
|
|
|
|
// Page returns verified authoritative records after the supplied sequence.
|
|
// The bounded page contract is used by the local tera-logd socket so API
|
|
// startup and queries never require an unbounded response frame.
|
|
func (s *Store) Page(class string, afterSequence uint64, limit int) ([]Envelope, error) {
|
|
class, err := normalizeClass(class)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if limit < 1 || limit > 500 {
|
|
return nil, errors.New("log page limit must be between 1 and 500")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
result := make([]Envelope, 0, limit)
|
|
visit := func(envelope Envelope) error {
|
|
if s.recordCleared(envelope) {
|
|
return nil
|
|
}
|
|
if envelope.Sequence > afterSequence && len(result) < limit {
|
|
envelope.Payload = append(json.RawMessage(nil), envelope.Payload...)
|
|
result = append(result, envelope)
|
|
if len(result) == limit {
|
|
return errPageComplete
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Closed segments are immutable and were fully verified while the store was
|
|
// opened. Use their indexed sequence ranges and sparse byte checkpoints to
|
|
// avoid rescanning every older record for each socket page. Every returned
|
|
// envelope is still decoded and checksum-verified below.
|
|
for _, meta := range s.closed[class] {
|
|
if meta.LastSequence <= afterSequence {
|
|
continue
|
|
}
|
|
path := filepath.Join(s.bucketPath(class), meta.Path)
|
|
offset := pageOffsetAfter(meta.PageOffsets, afterSequence)
|
|
if err := readSegmentPage(path, class, offset, visit); err != nil && !errors.Is(err, errPageComplete) {
|
|
return nil, err
|
|
}
|
|
if len(result) == limit {
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
state := s.classes[class]
|
|
if state == nil || state.ActiveLastSequence <= afterSequence {
|
|
return result, nil
|
|
}
|
|
active := s.activePath(class)
|
|
if _, err := os.Lstat(active); err == nil {
|
|
offset := pageOffsetAfter(state.ActiveOffsets, afterSequence)
|
|
if err := readSegmentPage(active, class, offset, visit); err != nil && !errors.Is(err, errPageComplete) {
|
|
return nil, err
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func pageOffsetAfter(offsets []recordOffset, afterSequence uint64) int64 {
|
|
var offset int64
|
|
for _, checkpoint := range offsets {
|
|
if checkpoint.Sequence > afterSequence {
|
|
break
|
|
}
|
|
offset = checkpoint.Offset
|
|
}
|
|
return offset
|
|
}
|
|
|
|
func readSegmentPage(path, class string, offset int64, visit func(Envelope) error) error {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("log segment must be a regular file: %s", path)
|
|
}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("read %s log segment: %w", class, err)
|
|
}
|
|
defer file.Close()
|
|
if offset > 0 {
|
|
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
|
return fmt.Errorf("seek %s log segment: %w", class, err)
|
|
}
|
|
}
|
|
reader := bufio.NewReaderSize(file, int(maximumRecordBytes+4096))
|
|
for {
|
|
line, readErr := reader.ReadBytes('\n')
|
|
if len(line) == 0 && errors.Is(readErr, io.EOF) {
|
|
return nil
|
|
}
|
|
if readErr != nil {
|
|
return fmt.Errorf("%w: %s: incomplete log record", ErrCorruptSegment, filepath.Base(path))
|
|
}
|
|
line = bytes.TrimSpace(line)
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var envelope Envelope
|
|
if err := json.Unmarshal(line, &envelope); err != nil {
|
|
return fmt.Errorf("%w: %s: %v", ErrCorruptSegment, filepath.Base(path), err)
|
|
}
|
|
if err := validateEnvelope(envelope, class); err != nil {
|
|
return fmt.Errorf("%w: %s: %v", ErrCorruptSegment, filepath.Base(path), err)
|
|
}
|
|
if err := visit(envelope); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Store) Export(class string, writer io.Writer) error {
|
|
class, err := normalizeClass(class)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if writer == nil {
|
|
return errors.New("log export writer is required")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
encoder := json.NewEncoder(writer)
|
|
return s.walkClassLocked(class, false, func(envelope Envelope) error { return encoder.Encode(envelope) })
|
|
}
|
|
|
|
func (s *Store) ApplyRetention(retention Retention) error {
|
|
if err := validateRetention(retention); err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.retention = retention
|
|
for _, class := range []string{ClassAudit, ClassSystem, ClassPort, ClassLink} {
|
|
if state := s.classes[class]; state != nil && state.ActiveBytes > 0 {
|
|
if err := s.rotateLocked(class); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
now := s.clock().UTC()
|
|
for _, class := range []string{ClassAudit, ClassSystem, ClassPort, ClassLink} {
|
|
cutoff := now.Add(-time.Duration(s.retentionDays(class)) * 24 * time.Hour)
|
|
segments, err := s.closedSegmentsLocked(class)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, segment := range segments {
|
|
meta, err := inspectSegment(segment, class, false, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !meta.Newest.IsZero() && meta.Newest.Before(cutoff) {
|
|
if err := os.Remove(segment); err != nil {
|
|
return fmt.Errorf("remove expired %s log segment: %w", class, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return s.rebuildLocked()
|
|
}
|
|
|
|
func validateRetention(retention Retention) error {
|
|
for name, days := range map[string]int{"audit_days": retention.AuditDays, "system_days": retention.SystemDays, "port_days": retention.PortDays} {
|
|
if days < 1 || days > 365 {
|
|
return fmt.Errorf("%s must be between 1 and 365", name)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) Snapshot() (Snapshot, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
free, err := s.freeBytes(s.root)
|
|
if err != nil {
|
|
return Snapshot{}, fmt.Errorf("measure log filesystem free bytes: %w", err)
|
|
}
|
|
snapshot := Snapshot{
|
|
Root: s.root, FreeBytes: free, GlobalFreeFloor: s.floor,
|
|
UsageBytes: make(map[string]int64), NextSequence: make(map[string]uint64),
|
|
CapBytes: make(map[string]int64),
|
|
Oldest: make(map[string]time.Time), Newest: make(map[string]time.Time),
|
|
}
|
|
for _, bucket := range []string{"audit", "system", "port", "index", "export"} {
|
|
usage, err := directoryUsage(filepath.Join(s.root, bucket))
|
|
if err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
snapshot.UsageBytes[bucket] = usage
|
|
if bucket == "index" {
|
|
snapshot.CapBytes[bucket] = s.indexCap
|
|
} else if capBytes, ok := s.caps[bucket]; ok {
|
|
snapshot.CapBytes[bucket] = capBytes
|
|
}
|
|
}
|
|
for _, class := range []string{ClassAudit, ClassSystem, ClassPort, ClassLink} {
|
|
state := s.classes[class]
|
|
if state != nil {
|
|
snapshot.NextSequence[class] = state.NextSequence
|
|
}
|
|
for _, meta := range s.closed[class] {
|
|
if snapshot.Oldest[class].IsZero() || meta.Oldest.Before(snapshot.Oldest[class]) {
|
|
snapshot.Oldest[class] = meta.Oldest
|
|
}
|
|
if meta.Newest.After(snapshot.Newest[class]) {
|
|
snapshot.Newest[class] = meta.Newest
|
|
}
|
|
}
|
|
if state != nil && state.ActiveBytes > 0 {
|
|
if snapshot.Oldest[class].IsZero() || state.ActiveFirst.Before(snapshot.Oldest[class]) {
|
|
snapshot.Oldest[class] = state.ActiveFirst
|
|
}
|
|
if state.ActiveLast.After(snapshot.Newest[class]) {
|
|
snapshot.Newest[class] = state.ActiveLast
|
|
}
|
|
}
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
func (s *Store) RebuildIndex() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.rebuildLocked()
|
|
}
|
|
|
|
func (s *Store) rebuildLocked() error {
|
|
s.classes = make(map[string]*classState)
|
|
s.closed = make(map[string][]segmentMeta)
|
|
for _, class := range []string{ClassAudit, ClassSystem, ClassPort, ClassLink} {
|
|
state := &classState{NextSequence: 1}
|
|
var lastSequence uint64
|
|
segments, err := s.closedSegmentsLocked(class)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, path := range segments {
|
|
meta, err := inspectSegment(path, class, false, func(envelope Envelope) error {
|
|
if envelope.Sequence <= lastSequence {
|
|
return fmt.Errorf("%w: %s sequence is not increasing", ErrCorruptSegment, class)
|
|
}
|
|
lastSequence = envelope.Sequence
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.closed[class] = append(s.closed[class], meta)
|
|
}
|
|
active := s.activePath(class)
|
|
if info, err := os.Lstat(active); err == nil {
|
|
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("active %s log segment must be a regular file", class)
|
|
}
|
|
meta, err := inspectSegment(active, class, true, func(envelope Envelope) error {
|
|
if envelope.Sequence <= lastSequence {
|
|
return fmt.Errorf("%w: %s sequence is not increasing", ErrCorruptSegment, class)
|
|
}
|
|
lastSequence = envelope.Sequence
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
state.ActiveBytes, state.ActiveFirst, state.ActiveLast = meta.Bytes, meta.Oldest, meta.Newest
|
|
state.ActiveFirstSequence, state.ActiveLastSequence = meta.FirstSequence, meta.LastSequence
|
|
state.ActiveRecords = meta.Records
|
|
state.ActiveOffsets = append([]recordOffset(nil), meta.PageOffsets...)
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
state.NextSequence = lastSequence + 1
|
|
if class == ClassPort && state.NextSequence < s.portClears.NextSequence {
|
|
state.NextSequence = s.portClears.NextSequence
|
|
}
|
|
if class == ClassLink && state.NextSequence < s.linkClear.NextSequence {
|
|
state.NextSequence = s.linkClear.NextSequence
|
|
}
|
|
if class == ClassAudit && state.NextSequence < s.systemClear.AuditNextSequence {
|
|
state.NextSequence = s.systemClear.AuditNextSequence
|
|
}
|
|
if class == ClassSystem && state.NextSequence < s.systemClear.NextSequence {
|
|
state.NextSequence = s.systemClear.NextSequence
|
|
}
|
|
s.classes[class] = state
|
|
}
|
|
return s.writeIndexLocked()
|
|
}
|
|
|
|
func (s *Store) walkClassLocked(class string, recoverActive bool, visit func(Envelope) error) error {
|
|
if visit != nil {
|
|
original := visit
|
|
visit = func(event Envelope) error {
|
|
if s.recordCleared(event) {
|
|
return nil
|
|
}
|
|
return original(event)
|
|
}
|
|
}
|
|
segments, err := s.closedSegmentsLocked(class)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, segment := range segments {
|
|
if _, err := inspectSegment(segment, class, false, visit); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
active := s.activePath(class)
|
|
if _, err := os.Lstat(active); err == nil {
|
|
_, err = inspectSegment(active, class, recoverActive, visit)
|
|
return err
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func inspectSegment(path, class string, recoverTail bool, visit func(Envelope) error) (segmentMeta, error) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return segmentMeta{}, err
|
|
}
|
|
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
|
return segmentMeta{}, fmt.Errorf("log segment must be a regular file: %s", path)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return segmentMeta{}, fmt.Errorf("read %s log segment: %w", class, err)
|
|
}
|
|
meta := segmentMeta{Class: class, Path: filepath.Base(path), Closed: !recoverTail, Bytes: int64(len(data))}
|
|
validBytes := 0
|
|
parts := bytes.SplitAfter(data, []byte{'\n'})
|
|
for index, part := range parts {
|
|
if len(part) == 0 {
|
|
continue
|
|
}
|
|
complete := part[len(part)-1] == '\n'
|
|
line := bytes.TrimSpace(part)
|
|
if len(line) == 0 {
|
|
validBytes += len(part)
|
|
continue
|
|
}
|
|
var envelope Envelope
|
|
decodeErr := json.Unmarshal(line, &envelope)
|
|
if decodeErr == nil {
|
|
decodeErr = validateEnvelope(envelope, class)
|
|
}
|
|
isTail := index == len(parts)-1 || index == len(parts)-2 && len(parts[len(parts)-1]) == 0
|
|
if decodeErr != nil || !complete {
|
|
if recoverTail && isTail {
|
|
if err := os.Truncate(path, int64(validBytes)); err != nil {
|
|
return segmentMeta{}, fmt.Errorf("recover truncated %s log tail: %w", class, err)
|
|
}
|
|
meta.Bytes = int64(validBytes)
|
|
break
|
|
}
|
|
return segmentMeta{}, fmt.Errorf("%w: %s: %v", ErrCorruptSegment, filepath.Base(path), decodeErr)
|
|
}
|
|
if meta.FirstSequence == 0 {
|
|
meta.FirstSequence, meta.Oldest = envelope.Sequence, envelope.Timestamp
|
|
}
|
|
meta.LastSequence, meta.Newest = envelope.Sequence, envelope.Timestamp
|
|
if meta.Records%pageCheckpointRecords == 0 {
|
|
meta.PageOffsets = append(meta.PageOffsets, recordOffset{Sequence: envelope.Sequence, Offset: int64(validBytes)})
|
|
}
|
|
meta.Records++
|
|
if visit != nil {
|
|
if err := visit(envelope); err != nil {
|
|
return segmentMeta{}, err
|
|
}
|
|
}
|
|
validBytes += len(part)
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
func validateEnvelope(envelope Envelope, class string) error {
|
|
if envelope.FormatVersion != formatVersion || envelope.Class != class || envelope.Sequence == 0 || envelope.Timestamp.IsZero() || len(envelope.Payload) == 0 {
|
|
return errors.New("invalid log envelope identity")
|
|
}
|
|
expected, err := envelopeChecksum(envelope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !strings.EqualFold(expected, envelope.Checksum) {
|
|
return errors.New("checksum mismatch")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func envelopeChecksum(envelope Envelope) (string, error) {
|
|
core := struct {
|
|
FormatVersion int `json:"format_version"`
|
|
Sequence uint64 `json:"sequence"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Class string `json:"class"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
}{envelope.FormatVersion, envelope.Sequence, envelope.Timestamp, envelope.Class, envelope.Payload}
|
|
data, err := json.Marshal(core)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func (s *Store) rotateLocked(class string) error {
|
|
state := s.classes[class]
|
|
if state == nil || state.ActiveBytes == 0 {
|
|
return nil
|
|
}
|
|
active := s.activePath(class)
|
|
meta, err := inspectSegment(active, class, true, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if meta.FirstSequence == 0 {
|
|
return nil
|
|
}
|
|
closedName := fmt.Sprintf("segment-%s-%020d-%020d.jsonl", class, meta.FirstSequence, meta.LastSequence)
|
|
closed := filepath.Join(s.bucketPath(class), closedName)
|
|
if _, err := os.Lstat(closed); err == nil {
|
|
return fmt.Errorf("closed log segment already exists: %s", closedName)
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
if err := os.Rename(active, closed); err != nil {
|
|
return fmt.Errorf("close %s log segment: %w", class, err)
|
|
}
|
|
if err := syncDirectory(s.bucketPath(class)); err != nil {
|
|
return err
|
|
}
|
|
meta.Path = closedName
|
|
meta.Closed = true
|
|
s.closed[class] = append(s.closed[class], meta)
|
|
state.ActiveBytes, state.ActiveFirst, state.ActiveLast = 0, time.Time{}, time.Time{}
|
|
state.ActiveFirstSequence, state.ActiveLastSequence = 0, 0
|
|
state.ActiveRecords, state.ActiveOffsets = 0, nil
|
|
return s.writeIndexLocked()
|
|
}
|
|
|
|
func (s *Store) ensureCapacityLocked(class string, incoming int64) error {
|
|
bucket := bucketForClass(class)
|
|
capBytes := s.caps[bucket]
|
|
if capBytes > 0 {
|
|
if incoming > capBytes {
|
|
return fmt.Errorf("%w: incoming record exceeds class capacity", ErrCapacityExhausted)
|
|
}
|
|
usage, err := directoryUsage(filepath.Join(s.root, bucket))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if usage+incoming > capBytes {
|
|
if err := s.sealBucketLocked(bucket); err != nil {
|
|
return err
|
|
}
|
|
if err := s.reclaimOldestLocked(bucket, usage+incoming-capBytes); err != nil {
|
|
return err
|
|
}
|
|
usage, err = directoryUsage(filepath.Join(s.root, bucket))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if usage+incoming > capBytes {
|
|
return fmt.Errorf("%w: class=%s used=%d incoming=%d cap=%d", ErrCapacityExhausted, class, usage, incoming, capBytes)
|
|
}
|
|
}
|
|
}
|
|
if s.floor > 0 {
|
|
free, err := s.freeBytes(s.root)
|
|
if err != nil {
|
|
return fmt.Errorf("measure log filesystem free bytes: %w", err)
|
|
}
|
|
if free-incoming < s.floor {
|
|
needed := s.floor - (free - incoming)
|
|
if err := s.reclaimGlobalOldestLocked(needed); err != nil {
|
|
return err
|
|
}
|
|
|
|
free, err = s.freeBytes(s.root)
|
|
if err != nil || free-incoming < s.floor {
|
|
return fmt.Errorf("%w: free=%d incoming=%d floor=%d", ErrCapacityExhausted, free, incoming, s.floor)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) sealBucketLocked(bucket string) error {
|
|
for _, class := range []string{ClassAudit, ClassSystem, ClassPort, ClassLink} {
|
|
if bucketForClass(class) == bucket && s.classes[class] != nil && s.classes[class].ActiveBytes > 0 {
|
|
if err := s.rotateLocked(class); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) reclaimGlobalOldestLocked(needed int64) error {
|
|
var candidates []segmentMeta
|
|
for _, bucket := range []string{"audit", "system", "port"} {
|
|
if err := s.sealBucketLocked(bucket); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, segments := range s.closed {
|
|
candidates = append(candidates, segments...)
|
|
}
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
if candidates[i].Oldest.Equal(candidates[j].Oldest) {
|
|
return candidates[i].Path < candidates[j].Path
|
|
}
|
|
return candidates[i].Oldest.Before(candidates[j].Oldest)
|
|
})
|
|
var reclaimed int64
|
|
for _, meta := range candidates {
|
|
if err := os.Remove(filepath.Join(s.bucketPath(meta.Class), meta.Path)); err != nil {
|
|
return err
|
|
}
|
|
s.removeClosedMetaLocked(meta.Class, meta.Path)
|
|
reclaimed += meta.Bytes
|
|
if reclaimed >= needed {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("%w: insufficient log segments to rotate", ErrCapacityExhausted)
|
|
}
|
|
|
|
func (s *Store) reclaimOldestLocked(bucket string, needed int64) error {
|
|
candidates, err := s.bucketSegmentsLocked(bucket)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var reclaimed int64
|
|
for _, path := range candidates {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
return err
|
|
}
|
|
reclaimed += info.Size()
|
|
s.removeClosedMetaLocked(classFromSegment(path), filepath.Base(path))
|
|
if reclaimed >= needed {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("%w: no reclaimable closed %s segments", ErrCapacityExhausted, bucket)
|
|
}
|
|
|
|
func (s *Store) bucketSegmentsLocked(bucket string) ([]string, error) {
|
|
entries, err := os.ReadDir(filepath.Join(s.root, bucket))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
paths := make([]string, 0)
|
|
for _, entry := range entries {
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
return nil, fmt.Errorf("symlink is not allowed in log storage: %s", entry.Name())
|
|
}
|
|
if entry.Type().IsRegular() && strings.HasPrefix(entry.Name(), "segment-") && strings.HasSuffix(entry.Name(), ".jsonl") {
|
|
paths = append(paths, filepath.Join(s.root, bucket, entry.Name()))
|
|
}
|
|
}
|
|
sort.Slice(paths, func(i, j int) bool {
|
|
iInfo, _ := os.Stat(paths[i])
|
|
jInfo, _ := os.Stat(paths[j])
|
|
if iInfo != nil && jInfo != nil && !iInfo.ModTime().Equal(jInfo.ModTime()) {
|
|
return iInfo.ModTime().Before(jInfo.ModTime())
|
|
}
|
|
return filepath.Base(paths[i]) < filepath.Base(paths[j])
|
|
})
|
|
return paths, nil
|
|
}
|
|
|
|
func (s *Store) closedSegmentsLocked(class string) ([]string, error) {
|
|
entries, err := os.ReadDir(s.bucketPath(class))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prefix := "segment-" + class + "-"
|
|
paths := make([]string, 0)
|
|
for _, entry := range entries {
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
return nil, fmt.Errorf("symlink is not allowed in log storage: %s", entry.Name())
|
|
}
|
|
if entry.Type().IsRegular() && strings.HasPrefix(entry.Name(), prefix) && strings.HasSuffix(entry.Name(), ".jsonl") {
|
|
paths = append(paths, filepath.Join(s.bucketPath(class), entry.Name()))
|
|
}
|
|
}
|
|
sort.Strings(paths)
|
|
return paths, nil
|
|
}
|
|
|
|
func (s *Store) writeIndexLocked() error {
|
|
index := segmentIndex{FormatVersion: formatVersion, RebuiltAt: s.clock().UTC(), Classes: make(map[string]uint64)}
|
|
for _, class := range []string{ClassAudit, ClassSystem, ClassPort, ClassLink} {
|
|
if state := s.classes[class]; state != nil {
|
|
index.Classes[class] = state.NextSequence
|
|
}
|
|
index.Segments = append(index.Segments, s.closed[class]...)
|
|
if state := s.classes[class]; state != nil && state.ActiveBytes > 0 {
|
|
index.Segments = append(index.Segments, segmentMeta{
|
|
Class: class, Path: filepath.Base(s.activePath(class)), Closed: false,
|
|
FirstSequence: state.ActiveFirstSequence, LastSequence: state.ActiveLastSequence,
|
|
Oldest: state.ActiveFirst, Newest: state.ActiveLast, Bytes: state.ActiveBytes,
|
|
Records: state.ActiveRecords, PageOffsets: state.ActiveOffsets,
|
|
})
|
|
}
|
|
}
|
|
data, err := json.MarshalIndent(index, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.indexCap > 0 && int64(len(data)) > s.indexCap {
|
|
return fmt.Errorf("%w: rebuilt index bytes=%d cap=%d", ErrCapacityExhausted, len(data), s.indexCap)
|
|
}
|
|
if err := atomicfile.Write(filepath.Join(s.root, "index", "log-index-v1.json"), append(data, '\n'), 0o600); err != nil {
|
|
return fmt.Errorf("write rebuildable log index: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) removeClosedMetaLocked(class, name string) {
|
|
if class == "" {
|
|
return
|
|
}
|
|
items := s.closed[class]
|
|
filtered := items[:0]
|
|
for _, item := range items {
|
|
if item.Path != name {
|
|
filtered = append(filtered, item)
|
|
}
|
|
}
|
|
s.closed[class] = filtered
|
|
}
|
|
|
|
func (s *Store) activePath(class string) string {
|
|
return filepath.Join(s.bucketPath(class), "active-"+class+".jsonl")
|
|
}
|
|
|
|
func (s *Store) bucketPath(class string) string { return filepath.Join(s.root, bucketForClass(class)) }
|
|
|
|
func bucketForClass(class string) string {
|
|
if class == ClassLink || class == ClassPort {
|
|
return "port"
|
|
}
|
|
return class
|
|
}
|
|
|
|
func classFromSegment(path string) string {
|
|
name := filepath.Base(path)
|
|
for _, class := range []string{ClassAudit, ClassSystem, ClassPort, ClassLink} {
|
|
if strings.HasPrefix(name, "segment-"+class+"-") {
|
|
return class
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeClass(class string) (string, error) {
|
|
class = strings.ToLower(strings.TrimSpace(class))
|
|
switch class {
|
|
case ClassAudit, ClassSystem, ClassPort, ClassLink:
|
|
return class, nil
|
|
default:
|
|
return "", errors.New("log class must be audit, system, port, or link")
|
|
}
|
|
}
|
|
|
|
func (s *Store) retentionDays(class string) int {
|
|
switch class {
|
|
case ClassAudit:
|
|
return s.retention.AuditDays
|
|
case ClassSystem:
|
|
return s.retention.SystemDays
|
|
default:
|
|
return s.retention.PortDays
|
|
}
|
|
}
|
|
|
|
func directoryUsage(root string) (int64, error) {
|
|
var total int64
|
|
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("symlink is not allowed in log storage: %s", path)
|
|
}
|
|
if entry.Type().IsRegular() {
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
total += info.Size()
|
|
}
|
|
return nil
|
|
})
|
|
return total, err
|
|
}
|
|
|