chunker.go #2

  • //
  • p4lf/
  • dev/
  • internal/
  • chunker/
  • chunker.go
  • View
  • Commits
  • Open Download .zip Download (5 KB)
// Package chunker writes compressed log chunk files to LogChunksDir.
//
// Each chunk is written as:
//  1. P4LOG_chunk.<startOffset>.<endOffset>      (raw bytes)
//  2. P4LOG_chunk.<startOffset>.<endOffset>.gz   (gzipped)
//  3. log.<startOffset>.<endOffset>.gz            (final name, renamed from step 2)
//
// External automation is expected to pick up files matching log.*.gz,
// ship them, and delete them.
package chunker

import (
	"bytes"
	"compress/gzip"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"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, subject to
// MaxLogChunks and MinLogSpace guards.  Returns (false, nil) if the write
// was skipped due to a guard condition, (true, nil) on success.
func (c *Chunker) Write(chunk *tailer.Chunk) (bool, error) {
	if ok, reason := c.checkGuards(); !ok {
		c.log.Warnf("Skipping chunk write: %s", reason)
		return false, nil
	}

	finalName := fmt.Sprintf("log.%d.%d.gz", 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, %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()
}

// checkGuards returns (false, reason) if MaxLogChunks or MinLogSpace is
// violated, otherwise (true, "").
func (c *Chunker) checkGuards() (bool, string) {
	if c.cfg.MaxLogChunks > 0 {
		count, err := countChunkFiles(c.cfg.LogChunksDir)
		if err != nil {
			c.log.Warnf("Could not count chunk files: %v", err)
		} else if count >= c.cfg.MaxLogChunks {
			return false, fmt.Sprintf("MaxLogChunks %d reached (%d files present)",
				c.cfg.MaxLogChunks, count)
		}
	}

	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, ""
}

// countChunkFiles counts files matching log.*.gz in LogChunksDir.
func countChunkFiles(dir string) (int, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return 0, err
	}
	count := 0
	for _, e := range entries {
		if !e.IsDir() {
			m, _ := filepath.Match("log.*.gz", e.Name())
			if m {
				count++
			}
		}
	}
	return count, 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 MaxLogChunks and MinLogSpace guards are
// satisfied, checking every checkInterval. Returns when clear or when done is closed.
func (c *Chunker) WaitUntilGuardsPassed(checkInterval time.Duration, done <-chan struct{}) {
	for {
		ok, reason := c.checkGuards()
		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
#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/