summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-21 16:13:40 +0200
committerPaul Buetow <paul@buetow.org>2026-02-21 16:13:40 +0200
commit2c2cbe07f5e10fdb996e2a039cde84be44866f18 (patch)
tree97654c2c9ba9fc91cb569ab0521c4c67247abc0b
parenteebc9cba272c1b20296ab998262298c5da99e047 (diff)
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 <amp@ampcode.com>
-rw-r--r--INTEGRATIONTESTS-PLAN.md146
-rw-r--r--TODO.md3
-rw-r--r--integrationtests/README.md30
-rw-r--r--integrationtests/cmd/ioworkload/main.go46
-rw-r--r--integrationtests/cmd/ioworkload/scenarios.go336
-rw-r--r--integrationtests/doc.go4
-rw-r--r--integrationtests/expectations.go83
-rw-r--r--integrationtests/parse.go25
-rw-r--r--internal/flamegraph/collapsed.go8
-rw-r--r--internal/flamegraph/iordata.go66
10 files changed, 714 insertions, 33 deletions
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=<name>` 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=<name>`
+2. Read PID from workload stdout (line 1)
+3. Start `ior -pid=<PID> -flamegraph -name=<scenario> -duration=<N>`
+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=<name>")
+ 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
}
}