package chunker_test
import (
"compress/gzip"
"io"
"os"
"path/filepath"
"strings"
"testing"
"workshop.perforce.com/p4lf/internal/chunker"
"workshop.perforce.com/p4lf/internal/config"
"workshop.perforce.com/p4lf/internal/tailer"
)
type nopLogger struct{}
func (nopLogger) Infof(string, ...interface{}) {}
func (nopLogger) Warnf(string, ...interface{}) {}
func (nopLogger) Debugf(string, ...interface{}) {}
func (nopLogger) Errorf(string, ...interface{}) {}
func testConfig(t *testing.T) config.Config {
t.Helper()
dir := t.TempDir()
cfg := config.Defaults()
cfg.LogChunksDir = dir
cfg.P4LogFile = "/dev/null"
cfg.StateFile = filepath.Join(dir, "p4lf.state")
return cfg
}
func TestWrite_CreatesGzipFile(t *testing.T) {
cfg := testConfig(t)
c, err := chunker.New(cfg, nopLogger{})
if err != nil {
t.Fatalf("New: %v", err)
}
chunk := &tailer.Chunk{
Data: []byte("line1\nline2\nline3\n"),
StartOffset: 0,
EndOffset: 18,
}
ok, err := c.Write(chunk)
if err != nil {
t.Fatalf("Write: %v", err)
}
if !ok {
t.Fatal("Write returned false (skipped), expected true")
}
// Filename now includes a date: log.<YYYY-MM-DD>.0.18.gz
matches, err := filepath.Glob(filepath.Join(cfg.LogChunksDir, "log.*.0.18.gz"))
if err != nil {
t.Fatalf("Glob error: %v", err)
}
if len(matches) == 0 {
t.Fatalf("expected chunk file log.*.0.18.gz not found in %s", cfg.LogChunksDir)
}
// Verify the gzip content.
f, err := os.Open(matches[0])
if err != nil {
t.Fatalf("opening chunk: %v", err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
t.Fatalf("gzip.NewReader: %v", err)
}
content, err := io.ReadAll(gz)
if err != nil {
t.Fatalf("reading gzip content: %v", err)
}
if string(content) != string(chunk.Data) {
t.Errorf("chunk content: got %q, want %q", content, chunk.Data)
}
}
func TestWrite_MaxLogChunks(t *testing.T) {
cfg := testConfig(t)
cfg.MaxLogChunks = 2
c, err := chunker.New(cfg, nopLogger{})
if err != nil {
t.Fatalf("New: %v", err)
}
// Write 3 chunks with MaxLogChunks=2; the third write should delete the
// oldest file to stay within the limit.
chunks := []*tailer.Chunk{
{Data: []byte("aaa"), StartOffset: 0, EndOffset: 3},
{Data: []byte("bbb"), StartOffset: 3, EndOffset: 6},
{Data: []byte("ccc"), StartOffset: 6, EndOffset: 9},
}
for i, chunk := range chunks {
ok, err := c.Write(chunk)
if err != nil {
t.Fatalf("Write[%d]: err=%v", i, err)
}
if !ok {
t.Fatalf("Write[%d]: expected write to succeed (delete-oldest, not skip)", i)
}
}
// After 3 writes with MaxLogChunks=2, we should have exactly 2 files.
matches, err := filepath.Glob(filepath.Join(cfg.LogChunksDir, "log.*.gz"))
if err != nil {
t.Fatalf("Glob: %v", err)
}
if len(matches) != 2 {
t.Errorf("expected 2 chunk files after enforcing MaxLogChunks=2, got %d", len(matches))
}
}
func TestWrite_NoPanicOnEmptyDir(t *testing.T) {
cfg := testConfig(t)
cfg.MaxLogChunks = 5
c, err := chunker.New(cfg, nopLogger{})
if err != nil {
t.Fatalf("New: %v", err)
}
chunk := &tailer.Chunk{Data: []byte("x"), StartOffset: 100, EndOffset: 101}
ok, err := c.Write(chunk)
if err != nil {
t.Fatalf("Write: %v", err)
}
if !ok {
t.Error("expected write to succeed")
}
}
func TestWrite_ContentIsCorrect(t *testing.T) {
cfg := testConfig(t)
c, err := chunker.New(cfg, nopLogger{})
if err != nil {
t.Fatalf("New: %v", err)
}
payload := strings.Repeat("Perforce log line\n", 100)
chunk := &tailer.Chunk{
Data: []byte(payload),
StartOffset: 1000,
EndOffset: 1000 + int64(len(payload)),
}
if _, err := c.Write(chunk); err != nil {
t.Fatalf("Write: %v", err)
}
matches, err := filepath.Glob(filepath.Join(cfg.LogChunksDir, "log.*.gz"))
if err != nil || len(matches) == 0 {
t.Fatalf("no chunk files found in %s", cfg.LogChunksDir)
}
f, _ := os.Open(matches[0])
defer f.Close()
gz, _ := gzip.NewReader(f)
got, _ := io.ReadAll(gz)
if string(got) != payload {
t.Errorf("content mismatch: got %d bytes, want %d bytes", len(got), len(payload))
}
}
| # | Change | User | Description | Committed | |
|---|---|---|---|---|---|
| #1 | 33231 | C. Thomas Tyler | Promoted to main for release. | ||
| //p4lf/dev/internal/chunker/chunker_test.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/ |
||