chunker.go #1

  • //
  • p4lf/
  • main/
  • internal/
  • chunker/
  • chunker.go
  • View
  • Commits
  • Open Download .zip Download (7 KB)
// Package chunker writes compressed log chunk files to LogChunksDir.
//
// Each chunk is written as a gzipped file named:
//
//	log.<YYYY-MM-DD>.<startOffset>.<endOffset>.gz
//
// The date component prevents naming collisions after a P4LOG rotation
// and makes files easy to sort and identify by date.
//
// External automation is expected to pick up files matching log.*.gz,
// ship them, and delete them.
//
// When MaxLogChunks > 0 and the file count reaches the limit, the oldest
// files (by modification time) are deleted to make room, and a warning is
// logged that log data has been lost. This keeps the service running even
// when the downstream consumer is not keeping up.
package chunker

import (
	"bytes"
	"compress/gzip"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"syscall"
	"time"

	"workshop.perforce.com/p4lf/internal/config"
	"workshop.perforce.com/p4lf/internal/tailer"
)

// Logger is the minimal interface Chunker needs.
type Logger interface {
	Infof(format string, args ...interface{})
	Warnf(format string, args ...interface{})
	Debugf(format string, args ...interface{})
	Errorf(format string, args ...interface{})
}

// Chunker writes log chunks to disk.
type Chunker struct {
	cfg config.Config
	log Logger
}

// New creates a Chunker.
func New(cfg config.Config, log Logger) (*Chunker, error) {
	if err := os.MkdirAll(cfg.LogChunksDir, 0755); err != nil {
		return nil, fmt.Errorf("creating LogChunksDir %q: %w", cfg.LogChunksDir, err)
	}
	return &Chunker{cfg: cfg, log: log}, nil
}

// Write compresses a Chunk and writes it to LogChunksDir.
// When MaxLogChunks > 0 and the current file count is at or above the limit,
// the oldest files are deleted to bring the count below the limit before
// writing the new chunk. A warning is logged for each deleted file because
// that log data will not be consumed by the downstream system.
// Returns (false, nil) if the write was skipped due to MinLogSpace, (true, nil) on success.
func (c *Chunker) Write(chunk *tailer.Chunk) (bool, error) {
	if err := c.enforceMaxLogChunks(); err != nil {
		return false, err
	}

	if ok, reason := c.checkSpaceGuard(); !ok {
		c.log.Warnf("Skipping chunk write: %s", reason)
		return false, nil
	}

	date := time.Now().Format("2006-01-02")
	finalName := fmt.Sprintf("log.%s.%d.%d.gz", date, chunk.StartOffset, chunk.EndOffset)
	finalPath := filepath.Join(c.cfg.LogChunksDir, finalName)

	gzTmp := filepath.Join(c.cfg.LogChunksDir,
		fmt.Sprintf("P4LOG_chunk.%d.%d.gz.tmp", chunk.StartOffset, chunk.EndOffset))

	// Write gzip directly to a temp file, then rename.
	if err := c.writeGzip(gzTmp, chunk.Data); err != nil {
		os.Remove(gzTmp)
		return false, fmt.Errorf("writing gzip chunk: %w", err)
	}

	if err := os.Rename(gzTmp, finalPath); err != nil {
		os.Remove(gzTmp)
		return false, fmt.Errorf("renaming chunk to %q: %w", finalPath, err)
	}

	c.log.Infof("Wrote chunk %s (%d bytes raw, offsets %d..%d)",
		finalName, len(chunk.Data), chunk.StartOffset, chunk.EndOffset)
	return true, nil
}

func (c *Chunker) writeGzip(path string, data []byte) error {
	f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0644)
	if err != nil {
		return err
	}
	gz := gzip.NewWriter(f)
	if _, err := io.Copy(gz, bytes.NewReader(data)); err != nil {
		gz.Close()
		f.Close()
		return err
	}
	if err := gz.Close(); err != nil {
		f.Close()
		return err
	}
	return f.Close()
}

// enforceMaxLogChunks deletes the oldest chunk files when the count is at or
// above MaxLogChunks, making room for the next write. A warning is emitted for
// each file deleted because that ingested log data will be lost. If MaxLogChunks
// is 0, no enforcement is done.
func (c *Chunker) enforceMaxLogChunks() error {
	if c.cfg.MaxLogChunks == 0 {
		return nil
	}

	files, err := chunkFilesSortedByAge(c.cfg.LogChunksDir)
	if err != nil {
		c.log.Warnf("Could not list chunk files for MaxLogChunks enforcement: %v", err)
		return nil
	}

	excess := len(files) - c.cfg.MaxLogChunks + 1 // +1 to make room for the new file
	if excess <= 0 {
		return nil
	}

	c.log.Warnf("MaxLogChunks=%d reached (%d files present); deleting %d oldest file(s) — log data will be lost",
		c.cfg.MaxLogChunks, len(files), excess)

	for i := 0; i < excess; i++ {
		path := filepath.Join(c.cfg.LogChunksDir, files[i].name)
		if err := os.Remove(path); err != nil {
			c.log.Errorf("Failed to delete old chunk file %q: %v", path, err)
		} else {
			c.log.Warnf("Deleted oldest chunk file %q to stay within MaxLogChunks limit", files[i].name)
		}
	}
	return nil
}

// checkSpaceGuard returns (false, reason) if MinLogSpace is violated,
// otherwise (true, "").
func (c *Chunker) checkSpaceGuard() (bool, string) {
	sp := c.cfg.MinLogSpace
	if sp.IsPercent && sp.Percent > 0 {
		pct, err := freeSpacePercent(c.cfg.LogChunksDir)
		if err != nil {
			c.log.Warnf("Could not check disk space: %v", err)
		} else if pct < sp.Percent {
			return false, fmt.Sprintf("MinLogSpace %.1f%% not met (%.1f%% free)", sp.Percent, pct)
		}
	} else if !sp.IsPercent && sp.Bytes > 0 {
		free, err := freeSpaceBytes(c.cfg.LogChunksDir)
		if err != nil {
			c.log.Warnf("Could not check disk space: %v", err)
		} else if free < sp.Bytes {
			return false, fmt.Sprintf("MinLogSpace %d bytes not met (%d bytes free)", sp.Bytes, free)
		}
	}
	return true, ""
}

type chunkFileInfo struct {
	name    string
	modTime time.Time
}

// chunkFilesSortedByAge returns chunk files matching log.*.gz sorted oldest-first.
func chunkFilesSortedByAge(dir string) ([]chunkFileInfo, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, err
	}
	var files []chunkFileInfo
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		matched, _ := filepath.Match("log.*.gz", e.Name())
		if !matched {
			continue
		}
		info, err := e.Info()
		if err != nil {
			continue
		}
		files = append(files, chunkFileInfo{name: e.Name(), modTime: info.ModTime()})
	}
	sort.Slice(files, func(i, j int) bool {
		return files[i].modTime.Before(files[j].modTime)
	})
	return files, nil
}

func freeSpaceBytes(path string) (int64, error) {
	var stat syscall.Statfs_t
	if err := syscall.Statfs(path, &stat); err != nil {
		return 0, err
	}
	return int64(stat.Bavail) * int64(stat.Bsize), nil
}

func freeSpacePercent(path string) (float64, error) {
	var stat syscall.Statfs_t
	if err := syscall.Statfs(path, &stat); err != nil {
		return 0, err
	}
	if stat.Blocks == 0 {
		return 0, fmt.Errorf("filesystem reports 0 total blocks")
	}
	return float64(stat.Bavail) / float64(stat.Blocks) * 100.0, nil
}

// WaitUntilGuardsPassed blocks until the MinLogSpace guard is satisfied,
// checking every checkInterval. MaxLogChunks is handled by deleting old files
// rather than waiting, so it is not checked here. Returns when clear or when
// done is closed.
func (c *Chunker) WaitUntilGuardsPassed(checkInterval time.Duration, done <-chan struct{}) {
	for {
		ok, reason := c.checkSpaceGuard()
		if ok {
			return
		}
		c.log.Warnf("Waiting: %s (checking again in %s)", reason, checkInterval)
		select {
		case <-done:
			return
		case <-time.After(checkInterval):
		}
	}
}
# Change User Description Committed
#1 33231 C. Thomas Tyler Promoted to main for release.
//p4lf/dev/internal/chunker/chunker.go
#3 33125 C. Thomas Tyler Add date to chunk filenames; MaxLogChunks deletes oldest; update README

- Chunk files renamed: log.<YYYY-MM-DD>.<start>.<end>.gz
- MaxLogChunks now deletes oldest files instead of pausing; default 5000
- WaitUntilGuardsPassed checks MinLogSpace only (MaxLogChunks no longer blocks)
- p4lf.cfg.example: updated MaxLogChunks comment with delete-oldest semantics
- README: full rewrite removing p4 logtail language; describes Go inode-based impl
- Tests updated for new filename format and delete-oldest MaxLogChunks behavior

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
#2 32824 C. Thomas Tyler Rename Go module path from github.com/rcowham/p4lf to workshop.perforce.com/p4lf.

The rcowham prefix was a carryover from early research into go-libtail
(which is hosted at github.com/rcowham/go-libtail). p4lf is hosted on
the Perforce Public Depot / Workshop, not GitHub, so workshop.perforce.com
is the correct canonical module path.

Updated: go.mod, all *.go files with internal imports, Makefile (MODULE
and LDFLAGS). Session logs retain the old path as accurate historical
record.
#1 32818 C. Thomas Tyler Initial implementation of p4lf in Go.

Adds:
- Go module (github.com/rcowham/p4lf) with fsnotify dependency
- internal/config: KEY=VALUE config parser with all settings
- internal/tailer: file reader with inode-based rotation detection and
  state file checkpoint/resume (inode + byte offset, JSON, atomic write)
- internal/chunker: gzip chunk writer with MaxLogChunks/MinLogSpace guards
- internal/logger: rotating log writer with gzip of rotated files
- cmd/p4lf/main.go: service main loop, SIGHUP/SIGTERM/SIGINT handling,
  config hot-reload on modtime change
- Makefile: build/test/install/release targets for Linux amd64/arm64,
  macOS arm64/amd64; version injected via ldflags
- p4lf.cfg.example: fully documented example config
- p4lf.service: systemd unit file (User=perforce, Restart=on-failure)
- ai/session_log_2026-06-25.md: design session log
- ai/session_log_2026-06-25-2.md: implementation session log
- .p4ignore: added bin/ and dist/