From 2c2cbe07f5e10fdb996e2a039cde84be44866f18 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 21 Feb 2026 16:13:40 +0200 Subject: Add integration test framework: plan, workload binary, harness scaffolding - INTEGRATIONTESTS-PLAN.md: full design for e2e integration tests - integrationtests/cmd/ioworkload: standalone binary with 13 I/O scenarios - integrationtests/expectations.go: ExpectedEvent type and assertion helpers - integrationtests/parse.go: .ior.zst parser producing TestResult - Export IterRecord and LoadFromFile in flamegraph package - Fix TraceId -> TraceID, StringByName returns error instead of panic Amp-Thread-ID: https://ampcode.com/threads/T-019c8031-c106-757a-95a0-7a5457163ce7 Co-authored-by: Amp --- INTEGRATIONTESTS-PLAN.md | 146 ++++++++++++ TODO.md | 3 - integrationtests/README.md | 30 +++ integrationtests/cmd/ioworkload/main.go | 46 ++++ integrationtests/cmd/ioworkload/scenarios.go | 336 +++++++++++++++++++++++++++ integrationtests/doc.go | 4 + integrationtests/expectations.go | 83 +++++++ integrationtests/parse.go | 25 ++ internal/flamegraph/collapsed.go | 8 +- internal/flamegraph/iordata.go | 66 +++--- 10 files changed, 714 insertions(+), 33 deletions(-) create mode 100644 INTEGRATIONTESTS-PLAN.md delete mode 100644 TODO.md create mode 100644 integrationtests/README.md create mode 100644 integrationtests/cmd/ioworkload/main.go create mode 100644 integrationtests/cmd/ioworkload/scenarios.go create mode 100644 integrationtests/doc.go create mode 100644 integrationtests/expectations.go create mode 100644 integrationtests/parse.go diff --git a/INTEGRATIONTESTS-PLAN.md b/INTEGRATIONTESTS-PLAN.md new file mode 100644 index 0000000..e9e9986 --- /dev/null +++ b/INTEGRATIONTESTS-PLAN.md @@ -0,0 +1,146 @@ +# Integration Tests Plan + +> Individual implementation tasks are tracked in **Taskwarrior** (`task project:ior +integrationtests`). + +## Overview + +End-to-end integration tests that verify ior correctly captures real I/O syscalls from a +known workload process via BPF tracepoints. A standalone Go binary performs deterministic +I/O operations, ior traces it by PID, and the test harness asserts the captured `.ior.zst` +output matches expectations. + +## Architecture + +``` + ┌─────────────────────────────────────────────────────────┐ + │ Go Test Harness (*_test.go) │ + │ │ + │ 1. Start ioworkload --scenario=X │ + │ 2. Read PID from workload's stdout (line 1) │ + │ 3. Start ior -pid=PID -flamegraph -duration=N │ + │ 4. Workload sleeps 2s, then performs I/O, exits │ + │ 5. ior finishes, produces .ior.zst │ + │ 6. Parse .ior.zst → assert expected events present │ + └─────────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ + ┌──────────────┐ ┌──────────────────┐ + │ ioworkload │ │ ior │ + │ (separate │ │ (BPF tracing │ + │ binary) │ │ -pid=WORKLOAD) │ + │ │ │ │ + │ prints PID │ │ writes .ior.zst │ + │ sleeps 2s │ └──────────────────┘ + │ does I/O │ + │ exits │ + └──────────────┘ +``` + +## Directory Layout + +``` +integrationtests/ +├── cmd/ +│ └── ioworkload/ +│ └── main.go # Standalone I/O workload binary +├── harness.go # Test orchestration (start ior + workload, collect output) +├── parse.go # Parse .ior.zst into assertable TestResult +├── expectations.go # ExpectedEvent type & assertion helpers +├── open_test.go # open, openat, creat, open_by_handle_at +├── readwrite_test.go # read, write, pread64, pwrite64, readv, writev +├── close_test.go # close, close_range +├── dup_test.go # dup, dup2, dup3 +├── fcntl_test.go # fcntl (F_DUPFD, F_SETFL, F_DUPFD_CLOEXEC) +├── rename_test.go # rename, renameat, renameat2 +├── link_test.go # link, linkat, symlink, symlinkat, readlink +├── unlink_test.go # unlink, unlinkat, rmdir +├── dir_test.go # mkdir, mkdirat, chdir, getdents +├── stat_test.go # stat, fstat, lstat, statx, access, faccessat +├── sync_test.go # fsync, fdatasync, sync, sync_file_range +├── truncate_test.go # truncate, ftruncate +├── iouring_test.go # io_uring_setup, io_uring_enter, io_uring_register +└── README.md +``` + +## Components + +### 1. I/O Workload Binary (`cmd/ioworkload/main.go`) + +A standalone Go binary that: +- Accepts `--scenario=` flag (e.g. `open-basic`, `dup-dup3-cloexec`) +- Prints its PID to stdout on line 1 +- Sleeps 2 seconds (gives harness time to start ior with PID filter) +- Performs deterministic, known I/O operations in a temp directory +- Cleans up and exits with code 0 + +### 2. Test Harness (`harness.go`) + +```go +type TestHarness struct { + IorBinary string // path to built ior binary + WorkloadBinary string // path to built ioworkload binary + BpfObject string // path to ior.bpf.o + OutputDir string // temp dir for .ior.zst output +} + +func (h *TestHarness) Run(scenario string, duration int) (*TestResult, error) +``` + +`Run()` sequence: +1. Start `ioworkload --scenario=` +2. Read PID from workload stdout (line 1) +3. Start `ior -pid= -flamegraph -name= -duration=` +4. Workload's 2s sleep expires, it performs I/O, then exits +5. ior finishes (duration expires or SIGTERM), writes `.ior.zst` +6. Parse `.ior.zst` into `TestResult` + +Requires root/CAP_BPF. Tests skip with `t.Skip("requires root for BPF")` if not root. + +### 3. Parser (`parse.go`) + +Reuses existing `flamegraph.newIorDataFromFile()` and `iorData.iter()` to deserialize +`.ior.zst` into an assertable `TestResult` struct containing all captured events. + +### 4. Assertions (`expectations.go`) + +```go +type ExpectedEvent struct { + PathContains string // substring match on file path + Tracepoint string // e.g. "sys_enter_openat" + Comm string // e.g. "ioworkload" + MinCount uint64 // minimum occurrences +} + +func AssertEventsPresent(t *testing.T, result *TestResult, expected []ExpectedEvent) +func AssertNoUnexpectedComm(t *testing.T, result *TestResult, expectedComm string) +``` + +### 5. Test Files (per syscall family) + +Each `*_test.go` defines scenarios and expectations for its syscall family. Example: + +```go +// open_test.go +func TestOpenBasic(t *testing.T) { runScenario(t, "open-basic", ...) } +func TestOpenCreat(t *testing.T) { runScenario(t, "open-creat", ...) } +func TestOpenByHandleAt(t *testing.T) { runScenario(t, "open-by-handle-at", ...) } +``` + +### 6. Mage Target (`Magefile.go`) + +```go +func IntegrationTest() error { + mg.SerialDeps(All) + // build ioworkload + // sudo go test ./integrationtests/... -v -failfast -count=1 +} +``` + +## Key Design Decisions + +- **Separate binary**: syscalls come from a real process with a distinct PID, matching production +- **`.ior.zst` as verification format**: reuses existing serialization, avoids parsing noisy stdout +- **`-pid` filter**: isolates workload I/O from system noise +- **2s sleep**: simple timing-based coordination, no synchronization pipes needed +- **One `*_test.go` per syscall family**: scales to many scenarios without monolithic test files +- **Standard `go test`**: no custom runner; `t.Skip` for non-root environments diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 22527d3..0000000 --- a/TODO.md +++ /dev/null @@ -1,3 +0,0 @@ -# TODO - I/O Riot NG Development Tasks - -All TODOs are tracked in taskwarrior under `project:ior`. diff --git a/integrationtests/README.md b/integrationtests/README.md new file mode 100644 index 0000000..741d14d --- /dev/null +++ b/integrationtests/README.md @@ -0,0 +1,30 @@ +# Integration Tests + +End-to-end integration tests for ior. A standalone I/O workload binary performs +deterministic syscalls, ior traces the workload by PID via BPF, and the test +harness asserts the captured `.ior.zst` output matches expectations. + +## Prerequisites + +- Built `ior` binary and `ior.bpf.o` (`mage all`) +- Root privileges or `CAP_BPF` (required for BPF tracepoint attachment) + +## Running + +```bash +mage integrationTest +``` + +This builds everything (ior, ioworkload) and runs the test suite with `sudo`. + +Tests automatically skip with `t.Skip` when not running as root. + +## Structure + +- `cmd/ioworkload/` — Standalone binary performing known I/O patterns +- `harness.go` — Test orchestration (start ior + workload, collect output) +- `parse.go` — Parse `.ior.zst` into assertable `TestResult` +- `expectations.go` — `ExpectedEvent` type and assertion helpers +- `*_test.go` — One file per syscall family + +See `../INTEGRATIONTESTS-PLAN.md` for the full design. diff --git a/integrationtests/cmd/ioworkload/main.go b/integrationtests/cmd/ioworkload/main.go new file mode 100644 index 0000000..3ed9cb2 --- /dev/null +++ b/integrationtests/cmd/ioworkload/main.go @@ -0,0 +1,46 @@ +// ioworkload is a standalone binary that performs deterministic I/O operations +// for integration testing of ior. It prints its PID to stdout, sleeps to allow +// ior to attach BPF tracepoints, then executes the requested I/O scenario. +package main + +import ( + "flag" + "fmt" + "os" + "slices" + "time" +) + +const startupDelay = 2 * time.Second + +func main() { + scenario := flag.String("scenario", "", "I/O scenario to execute") + flag.Parse() + + if *scenario == "" { + fmt.Fprintln(os.Stderr, "usage: ioworkload --scenario=") + os.Exit(2) + } + + run, ok := scenarios[*scenario] + if !ok { + fmt.Fprintf(os.Stderr, "unknown scenario: %s\navailable scenarios:\n", *scenario) + var names []string + for name := range scenarios { + names = append(names, name) + } + slices.Sort(names) + for _, name := range names { + fmt.Fprintf(os.Stderr, " %s\n", name) + } + os.Exit(2) + } + + fmt.Println(os.Getpid()) + time.Sleep(startupDelay) + + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "scenario %s failed: %v\n", *scenario, err) + os.Exit(1) + } +} diff --git a/integrationtests/cmd/ioworkload/scenarios.go b/integrationtests/cmd/ioworkload/scenarios.go new file mode 100644 index 0000000..41563d8 --- /dev/null +++ b/integrationtests/cmd/ioworkload/scenarios.go @@ -0,0 +1,336 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "syscall" +) + +// scenarios maps scenario names to their execution functions. +var scenarios = map[string]func() error{ + "open-basic": openBasic, + "open-creat": openCreat, + "readwrite-basic": readwriteBasic, + "close-basic": closeBasic, + "dup-basic": dupBasic, + "fcntl-dupfd": fcntlDupfd, + "rename-basic": renameBasic, + "link-basic": linkBasic, + "unlink-basic": unlinkBasic, + "dir-basic": dirBasic, + "stat-basic": statBasic, + "sync-basic": syncBasic, + "truncate-basic": truncateBasic, +} + +func makeTempDir(prefix string) (string, func(), error) { + dir, err := os.MkdirTemp("", fmt.Sprintf("ioworkload-%s-", prefix)) + if err != nil { + return "", nil, fmt.Errorf("create temp dir: %w", err) + } + cleanup := func() { os.RemoveAll(dir) } + return dir, cleanup, nil +} + +// openBasic opens a file with O_RDWR|O_CREAT, then closes it. +func openBasic() error { + dir, cleanup, err := makeTempDir("open-basic") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "testfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + return syscall.Close(fd) +} + +// openCreat uses the creat syscall to create a file. +func openCreat() error { + dir, cleanup, err := makeTempDir("open-creat") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "creatfile.txt") + fd, err := syscall.Creat(path, 0o644) + if err != nil { + return fmt.Errorf("creat: %w", err) + } + return syscall.Close(fd) +} + +// readwriteBasic opens a file, writes data, seeks to start, reads it back. +func readwriteBasic() error { + dir, cleanup, err := makeTempDir("readwrite-basic") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "rwfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + data := []byte("hello from ioworkload") + if _, err := syscall.Write(fd, data); err != nil { + return fmt.Errorf("write: %w", err) + } + if _, err := syscall.Seek(fd, 0, 0); err != nil { + return fmt.Errorf("seek: %w", err) + } + + buf := make([]byte, len(data)) + if _, err := syscall.Read(fd, buf); err != nil { + return fmt.Errorf("read: %w", err) + } + return nil +} + +// closeBasic opens multiple files and closes them. +func closeBasic() error { + dir, cleanup, err := makeTempDir("close-basic") + if err != nil { + return err + } + defer cleanup() + + var fds []int + for i := range 3 { + path := filepath.Join(dir, fmt.Sprintf("closefile-%d.txt", i)) + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open %d: %w", i, err) + } + fds = append(fds, fd) + } + for _, fd := range fds { + if err := syscall.Close(fd); err != nil { + return fmt.Errorf("close fd %d: %w", fd, err) + } + } + return nil +} + +// dupBasic opens a file, dups the fd, writes via the dup, closes both. +func dupBasic() error { + dir, cleanup, err := makeTempDir("dup-basic") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "dupfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + newFd, err := syscall.Dup(fd) + if err != nil { + return fmt.Errorf("dup: %w", err) + } + defer syscall.Close(newFd) + + if _, err := syscall.Write(newFd, []byte("via dup")); err != nil { + return fmt.Errorf("write via dup: %w", err) + } + return nil +} + +// fcntlDupfd uses fcntl F_DUPFD to duplicate a file descriptor. +func fcntlDupfd() error { + dir, cleanup, err := makeTempDir("fcntl-dupfd") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "fcntlfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + newFd, _, errno := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_DUPFD, 0) + if errno != 0 { + return fmt.Errorf("fcntl F_DUPFD: %w", errno) + } + defer syscall.Close(int(newFd)) + + if _, err := syscall.Write(int(newFd), []byte("via fcntl")); err != nil { + return fmt.Errorf("write via fcntl dup: %w", err) + } + return nil +} + +// renameBasic creates a file and renames it. +func renameBasic() error { + dir, cleanup, err := makeTempDir("rename-basic") + if err != nil { + return err + } + defer cleanup() + + oldPath := filepath.Join(dir, "oldname.txt") + newPath := filepath.Join(dir, "newname.txt") + + fd, err := syscall.Open(oldPath, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + if err := syscall.Close(fd); err != nil { + return fmt.Errorf("close: %w", err) + } + + return syscall.Rename(oldPath, newPath) +} + +// linkBasic creates a file, hard links it, and symlinks it. +func linkBasic() error { + dir, cleanup, err := makeTempDir("link-basic") + if err != nil { + return err + } + defer cleanup() + + origPath := filepath.Join(dir, "original.txt") + fd, err := syscall.Open(origPath, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + if err := syscall.Close(fd); err != nil { + return fmt.Errorf("close: %w", err) + } + + hardPath := filepath.Join(dir, "hardlink.txt") + if err := syscall.Link(origPath, hardPath); err != nil { + return fmt.Errorf("link: %w", err) + } + + symPath := filepath.Join(dir, "symlink.txt") + if err := syscall.Symlink(origPath, symPath); err != nil { + return fmt.Errorf("symlink: %w", err) + } + + buf := make([]byte, 256) + if _, err := syscall.Readlink(symPath, buf); err != nil { + return fmt.Errorf("readlink: %w", err) + } + return nil +} + +// unlinkBasic creates a file and unlinks it. +func unlinkBasic() error { + dir, cleanup, err := makeTempDir("unlink-basic") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "unlinkme.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + if err := syscall.Close(fd); err != nil { + return fmt.Errorf("close: %w", err) + } + + return syscall.Unlink(path) +} + +// dirBasic creates a directory, checks access, then removes it. +func dirBasic() error { + dir, cleanup, err := makeTempDir("dir-basic") + if err != nil { + return err + } + defer cleanup() + + subDir := filepath.Join(dir, "subdir") + if err := syscall.Mkdir(subDir, 0o755); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + if err := syscall.Access(subDir, syscall.F_OK); err != nil { + return fmt.Errorf("access: %w", err) + } + return syscall.Rmdir(subDir) +} + +// statBasic creates a file and stats it. +func statBasic() error { + dir, cleanup, err := makeTempDir("stat-basic") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "statfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + var stat syscall.Stat_t + if err := syscall.Fstat(fd, &stat); err != nil { + return fmt.Errorf("fstat: %w", err) + } + if err := syscall.Stat(path, &stat); err != nil { + return fmt.Errorf("stat: %w", err) + } + return nil +} + +// syncBasic opens a file, writes data, and fsyncs it. +func syncBasic() error { + dir, cleanup, err := makeTempDir("sync-basic") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "syncfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + if _, err := syscall.Write(fd, []byte("sync me")); err != nil { + return fmt.Errorf("write: %w", err) + } + return syscall.Fsync(fd) +} + +// truncateBasic opens a file, writes data, and truncates it. +func truncateBasic() error { + dir, cleanup, err := makeTempDir("truncate-basic") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "truncfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + if _, err := syscall.Write(fd, []byte("truncate this content")); err != nil { + return fmt.Errorf("write: %w", err) + } + return syscall.Ftruncate(fd, 5) +} diff --git a/integrationtests/doc.go b/integrationtests/doc.go new file mode 100644 index 0000000..027ba7f --- /dev/null +++ b/integrationtests/doc.go @@ -0,0 +1,4 @@ +// Package integrationtests provides end-to-end integration tests for ior. +// It verifies that ior correctly captures real I/O syscalls from a known +// workload process via BPF tracepoints. +package integrationtests diff --git a/integrationtests/expectations.go b/integrationtests/expectations.go new file mode 100644 index 0000000..ed155bc --- /dev/null +++ b/integrationtests/expectations.go @@ -0,0 +1,83 @@ +package integrationtests + +import ( + "ior/internal/flamegraph" + "strings" + "testing" +) + +// ExpectedEvent describes an I/O event that should appear in the test output. +type ExpectedEvent struct { + PathContains string // substring match on file path + Tracepoint string // tracepoint name substring, e.g. "openat" + Comm string // expected comm name, e.g. "ioworkload" + MinCount uint64 // minimum total occurrences across all matching records +} + +// AssertEventsPresent verifies that each expected event is found in the test result. +// Counts are summed across all matching records before comparing to MinCount. +func AssertEventsPresent(t *testing.T, result TestResult, expected []ExpectedEvent) { + t.Helper() + for _, exp := range expected { + var totalCount uint64 + var matched bool + for _, rec := range result.Records { + if matchesExpectation(rec, exp) { + matched = true + totalCount += rec.Cnt.Count + } + } + if !matched { + t.Errorf("expected event not found: %+v", exp) + continue + } + if exp.MinCount > 0 && totalCount < exp.MinCount { + t.Errorf("event matching %+v has total count %d, want >= %d", + exp, totalCount, exp.MinCount) + } + } +} + +// AssertNoUnexpectedComm verifies all records have the expected comm name. +// Fails fast on the first mismatch and reports the total count of unexpected records. +func AssertNoUnexpectedComm(t *testing.T, result TestResult, expectedComm string) { + t.Helper() + var count int + for _, rec := range result.Records { + if rec.Comm != expectedComm { + count++ + } + } + if count > 0 { + t.Fatalf("found %d records with unexpected comm (want %q)", count, expectedComm) + } +} + +// AssertNoUnexpectedPID verifies all records belong to the expected PID. +// Accepts int to match os.Getpid() return type. +func AssertNoUnexpectedPID(t *testing.T, result TestResult, expectedPID int) { + t.Helper() + pid := uint32(expectedPID) + var count int + for _, rec := range result.Records { + if rec.Pid != pid { + count++ + } + } + if count > 0 { + t.Fatalf("found %d records with unexpected PID (want %d)", count, expectedPID) + } +} + +func matchesExpectation(rec flamegraph.IterRecord, exp ExpectedEvent) bool { + if exp.PathContains != "" && !strings.Contains(rec.Path, exp.PathContains) { + return false + } + if exp.Tracepoint != "" && !strings.Contains(rec.TraceID.String(), exp.Tracepoint) { + return false + } + if exp.Comm != "" && rec.Comm != exp.Comm { + return false + } + return true +} diff --git a/integrationtests/parse.go b/integrationtests/parse.go new file mode 100644 index 0000000..cebb0ba --- /dev/null +++ b/integrationtests/parse.go @@ -0,0 +1,25 @@ +package integrationtests + +import ( + "fmt" + "ior/internal/flamegraph" +) + +// TestResult holds all captured I/O records from a single ior run. +type TestResult struct { + Records []flamegraph.IterRecord +} + +// LoadTestResult parses an .ior.zst file into a TestResult. +func LoadTestResult(iorZstFile string) (TestResult, error) { + iter, err := flamegraph.LoadFromFile(iorZstFile) + if err != nil { + return TestResult{}, fmt.Errorf("load test result: %w", err) + } + + var result TestResult + for record := range iter { + result.Records = append(result.Records, record) + } + return result, nil +} diff --git a/internal/flamegraph/collapsed.go b/internal/flamegraph/collapsed.go index aa0d81c..f04a38d 100644 --- a/internal/flamegraph/collapsed.go +++ b/internal/flamegraph/collapsed.go @@ -59,11 +59,15 @@ func (c Collapsed) Write(iorDataFile string) (string, error) { for record := range iod.iter() { var fieldValues []string for _, fieldName := range c.fields { - fieldValues = append(fieldValues, record.StringByName(fieldName)) + v, err := record.StringByName(fieldName) + if err != nil { + return outFile, fmt.Errorf("field %s: %w", fieldName, err) + } + fieldValues = append(fieldValues, v) } writer.Write([]byte(fmt.Sprintf("%s %d\n", strings.Join(fieldValues, ";"), - record.cnt.ValueByName(c.countField), + record.Cnt.ValueByName(c.countField), ))) } writer.Flush() diff --git a/internal/flamegraph/iordata.go b/internal/flamegraph/iordata.go index 463ed48..eec92fd 100644 --- a/internal/flamegraph/iordata.go +++ b/internal/flamegraph/iordata.go @@ -44,6 +44,15 @@ func newIorDataFromFile(filename string) (iorData, error) { return iod, nil } +// LoadFromFile loads an .ior.zst file and returns an iterator over all records. +func LoadFromFile(filename string) (iter.Seq[IterRecord], error) { + iod, err := newIorDataFromFile(filename) + if err != nil { + return nil, fmt.Errorf("load ior data from %s: %w", filename, err) + } + return iod.iter(), nil +} + func cloneString(s string) string { // Clone the string by creating a new string with the same content // This is a workaround to avoid using unsafe package @@ -186,55 +195,56 @@ func (iod *iorData) deserialize(buf *bytes.Buffer) error { return dec.Decode(&iod.paths) } -// Record returned by the iterator -type iterRecord struct { - path pathType - traceId traceIdType - comm commType - pid pidType - tid tidType - flags flagsType - cnt Counter +// IterRecord is a single record returned by the iterator. +type IterRecord struct { + Path string + TraceID types.TraceId + Comm string + Pid uint32 + Tid uint32 + Flags file.Flags + Cnt Counter } -func (ir iterRecord) StringByName(name string) string { +// StringByName returns the string representation of a field by name. +// Returns an error if the field name is not recognized. +func (ir IterRecord) StringByName(name string) (string, error) { switch name { case "path": - return strings.Join(strings.Split(ir.path, "/"), ";/") + return strings.Join(strings.Split(ir.Path, "/"), ";/"), nil case "comm": - return ir.comm + return ir.Comm, nil case "tracepoint": - return ir.traceId.String() + return ir.TraceID.String(), nil case "pid": - return fmt.Sprint(ir.pid) + return fmt.Sprint(ir.Pid), nil case "tid": - return fmt.Sprint(ir.tid) + return fmt.Sprint(ir.Tid), nil case "flags": - return ir.flags.String() + return ir.Flags.String(), nil default: - panic(fmt.Sprintln("No", name, "in record")) + return "", fmt.Errorf("unknown field %q in record", name) } } -func (iod iorData) iter() iter.Seq[iterRecord] { - return func(yield func(iterRecord) bool) { +func (iod iorData) iter() iter.Seq[IterRecord] { + return func(yield func(IterRecord) bool) { for path, traceIdMap := range iod.paths { for traceId, commMap := range traceIdMap { for comm, pidMap := range commMap { for pid, tidMap := range pidMap { for tid, flagsMap := range tidMap { for flags, cnt := range flagsMap { - record := iterRecord{ - path: path, - traceId: traceId, - comm: comm, - pid: pid, - tid: tid, - flags: flags, - cnt: cnt, + record := IterRecord{ + Path: path, + TraceID: traceId, + Comm: comm, + Pid: pid, + Tid: tid, + Flags: flags, + Cnt: cnt, } if !yield(record) { - // Stop iteration if yield returns false return } } -- cgit v1.2.3