Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions cmd/forgemark/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,16 @@ import (

func main() {
if err := run(); err != nil {
if errors.Is(err, errThresholdBreached) {
os.Exit(2)
}
fmt.Fprintln(os.Stderr, "forgemark: "+err.Error())
os.Exit(1)
}
}

var errThresholdBreached = errors.New("threshold breached")

type runConfig struct {
remote string
tokenFile string // path to the credential secret ("-" = stdin); preferred over $ACCESS_TOKEN
Expand All @@ -69,6 +74,10 @@ type runConfig struct {
out string
runID string

minPushRate float64
maxP95 time.Duration
maxErrors int

// session strategy
sessionCommits int
cloneDepth int
Expand Down Expand Up @@ -116,7 +125,17 @@ func run() error {
printRow(res)
}

return writeResults(cfg, ep, results)
if err := writeResults(cfg, ep, results); err != nil {
return err
}
breaches := checkThresholds(results, cfg)
for _, breach := range breaches {
fmt.Fprintln(os.Stderr, "forgemark: threshold breached: "+breach)
}
if len(breaches) > 0 {
return errThresholdBreached
}
return nil
}

// setupTarget builds the credential provider and resolved endpoint, inferring
Expand Down Expand Up @@ -307,7 +326,7 @@ func (c *runConfig) commitDesc() string {
}

func parseFlags() (*runConfig, error) {
cfg := &runConfig{}
cfg := &runConfig{maxErrors: -1}
var reposCSV, pattern, concCSV string
var repoCount int

Expand All @@ -328,6 +347,9 @@ func parseFlags() (*runConfig, error) {
flag.StringVar(&cfg.objectFmt, "object-format", "auto", "auto | sha1 | sha256 (auto probes the entiredb advertisement; generic/github default sha1)")
flag.BoolVar(&cfg.insecure, "insecure", false, "skip TLS verification (dev/self-signed hosts)")
flag.StringVar(&cfg.out, "out", "", "write JSON results here (default: results/forgemark-<id>.json)")
flag.Float64Var(&cfg.minPushRate, "min-push-rate", 0, "minimum successful pushes/sec per concurrency level (disabled when unset)")
flag.DurationVar(&cfg.maxP95, "max-p95", 0, "maximum p95 push latency per concurrency level (disabled when unset)")
flag.IntVar(&cfg.maxErrors, "max-errors", -1, "maximum non-CAS errors per concurrency level (disabled when unset)")
flag.IntVar(&cfg.sessionCommits, "session-commits", 5, "session strategy: commit+push checkpoints per cloned session")
flag.IntVar(&cfg.cloneDepth, "clone-depth", 1, "session strategy: shallow clone depth (1=tip; 0=full history)")
flag.StringVar(&cfg.baseRef, "base-ref", "", "session strategy: branch to clone — bare name (main) or full ref (refs/heads/main); default: remote default branch")
Expand Down Expand Up @@ -371,6 +393,15 @@ func parseFlags() (*runConfig, error) {
if cfg.commit.fileSize < 1 {
return nil, errors.New("-file-size must be >= 1 (empty blobs reproduce → ErrEmptyCommit)")
}
if cfg.minPushRate < 0 {
return nil, errors.New("-min-push-rate must be >= 0")
}
if cfg.maxP95 < 0 {
return nil, errors.New("-max-p95 must be >= 0")
}
if cfg.maxErrors < -1 {
return nil, errors.New("-max-errors must be >= 0 when set")
}
cfg.runID = "fm" + strconv.FormatInt(time.Now().Unix(), 36)
// Validate the assembled ref, not the prefix alone: validity is context-dependent
// (a trailing "/" or bare word is fine mid-ref, invalid standalone). c/a are arbitrary —
Expand Down Expand Up @@ -446,6 +477,26 @@ func writeResults(cfg *runConfig, ep *endpoint, results []levelResult) error {
"commit": cfg.commitDesc(),
"levels": results,
}
if thresholdsConfigured(cfg) {
breaches := checkThresholds(results, cfg)
if breaches == nil {
breaches = []string{}
}
doc["thresholds_ok"] = len(breaches) == 0
thresholds := map[string]any{
"breaches": breaches,
}
if cfg.minPushRate > 0 {
thresholds["min_push_rate"] = cfg.minPushRate
}
if cfg.maxP95 > 0 {
thresholds["max_p95"] = cfg.maxP95.String()
}
if cfg.maxErrors >= 0 {
thresholds["max_errors"] = cfg.maxErrors
}
doc["thresholds"] = thresholds
}
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return fmt.Errorf("marshal results: %w", err)
Expand All @@ -457,6 +508,10 @@ func writeResults(cfg *runConfig, ep *endpoint, results []levelResult) error {
return nil
}

func thresholdsConfigured(cfg *runConfig) bool {
return cfg.minPushRate > 0 || cfg.maxP95 > 0 || cfg.maxErrors >= 0
}

func maxInt(xs []int) int {
m := 0
for _, x := range xs {
Expand Down
148 changes: 148 additions & 0 deletions cmd/forgemark/main_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package main

import (
"encoding/json"
"flag"
"io"
"os"
"path/filepath"
"reflect"
"testing"
"time"

"github.com/go-git/go-git/v6/plumbing"
)
Expand Down Expand Up @@ -77,3 +83,145 @@ func TestDestRef(t *testing.T) {
})
}
}

func TestParseFlagsThresholds(t *testing.T) {
tests := []struct {
name string
args []string
wantMinRate float64
wantMaxP95 time.Duration
wantMaxErrors int
}{
{
name: "unset",
args: []string{"-repos", "org/repo"},
wantMaxErrors: -1,
},
{
name: "configured",
args: []string{"-repos", "org/repo", "-min-push-rate", "5.5", "-max-p95", "1500ms", "-max-errors", "0"},
wantMinRate: 5.5,
wantMaxP95: 1500 * time.Millisecond,
wantMaxErrors: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := parseFlagsForTest(t, tt.args...)
if err != nil {
t.Fatalf("parseFlags() error = %v", err)
}
if cfg.minPushRate != tt.wantMinRate {
t.Errorf("minPushRate = %v, want %v", cfg.minPushRate, tt.wantMinRate)
}
if cfg.maxP95 != tt.wantMaxP95 {
t.Errorf("maxP95 = %v, want %v", cfg.maxP95, tt.wantMaxP95)
}
if cfg.maxErrors != tt.wantMaxErrors {
t.Errorf("maxErrors = %v, want %v", cfg.maxErrors, tt.wantMaxErrors)
}
})
}
}

func TestWriteResultsIncludesThresholdsWhenConfigured(t *testing.T) {
out := filepath.Join(t.TempDir(), "results.json")
cfg := &runConfig{
out: out,
runID: "fmtest",
strategy: "branch",
duration: 30 * time.Second,
warmup: 5 * time.Second,
repos: []string{"org/repo"},
minPushRate: 5,
maxP95: 2 * time.Second,
maxErrors: 0,
}
results := []levelResult{{
Concurrency: 4,
OpsPerSec: 3.1,
P95ms: 2200,
OtherErrors: 2,
}}

if err := writeResults(cfg, &endpoint{label: "test"}, results); err != nil {
t.Fatalf("writeResults() error = %v", err)
}
b, err := os.ReadFile(out)
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", out, err)
}
var doc map[string]any
if err := json.Unmarshal(b, &doc); err != nil {
t.Fatalf("Unmarshal results error = %v", err)
}
ok, exists := doc["thresholds_ok"].(bool)
if !exists || ok {
t.Fatalf("thresholds_ok = %v (exists %v), want false", doc["thresholds_ok"], exists)
}
thresholds, exists := doc["thresholds"].(map[string]any)
if !exists {
t.Fatalf("thresholds missing from result: %v", doc)
}
if got := thresholds["min_push_rate"]; got != float64(5) {
t.Errorf("min_push_rate = %v, want 5", got)
}
if got := thresholds["max_p95"]; got != "2s" {
t.Errorf("max_p95 = %v, want 2s", got)
}
if got := thresholds["max_errors"]; got != float64(0) {
t.Errorf("max_errors = %v, want 0", got)
}
breaches, exists := thresholds["breaches"].([]any)
if !exists || len(breaches) != 3 {
t.Fatalf("breaches = %v (exists %v), want 3 entries", thresholds["breaches"], exists)
}
if breaches[0] != "c=4 push/s=3.1 < min 5.0" {
t.Errorf("first breach = %v, want push-rate breach", breaches[0])
}
}

func TestWriteResultsOmitsThresholdsWhenUnset(t *testing.T) {
out := filepath.Join(t.TempDir(), "results.json")
cfg := &runConfig{
out: out,
runID: "fmtest",
strategy: "branch",
repos: []string{"org/repo"},
maxErrors: -1,
}

if err := writeResults(cfg, &endpoint{label: "test"}, []levelResult{{Concurrency: 1}}); err != nil {
t.Fatalf("writeResults() error = %v", err)
}
b, err := os.ReadFile(out)
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", out, err)
}
var doc map[string]any
if err := json.Unmarshal(b, &doc); err != nil {
t.Fatalf("Unmarshal results error = %v", err)
}
if _, exists := doc["thresholds"]; exists {
t.Fatalf("thresholds present when unset: %v", doc["thresholds"])
}
if _, exists := doc["thresholds_ok"]; exists {
t.Fatalf("thresholds_ok present when unset: %v", doc["thresholds_ok"])
}
}

func parseFlagsForTest(t *testing.T, args ...string) (*runConfig, error) {
t.Helper()
oldArgs := os.Args
oldCommandLine := flag.CommandLine
t.Cleanup(func() {
os.Args = oldArgs
flag.CommandLine = oldCommandLine
})

fs := flag.NewFlagSet("forgemark", flag.ContinueOnError)
fs.SetOutput(io.Discard)
flag.CommandLine = fs
os.Args = append([]string{"forgemark"}, args...)
return parseFlags()
}
24 changes: 24 additions & 0 deletions cmd/forgemark/stats.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"fmt"
"math"
"sort"
"time"
Expand Down Expand Up @@ -190,6 +191,29 @@ func summarize(samples []sample, concurrency int, strategy string, repos, nodes
return r
}

// checkThresholds reports every configured threshold breach in sweep order.
func checkThresholds(results []levelResult, cfg *runConfig) []string {
if cfg == nil {
return nil
}
var breaches []string
for _, r := range results {
if cfg.minPushRate > 0 && r.OpsPerSec < cfg.minPushRate {
breaches = append(breaches, fmt.Sprintf("c=%d push/s=%.1f < min %.1f", r.Concurrency, r.OpsPerSec, cfg.minPushRate))
}
if cfg.maxP95 > 0 {
maxP95ms := float64(cfg.maxP95) / float64(time.Millisecond)
if r.P95ms > maxP95ms {
breaches = append(breaches, fmt.Sprintf("c=%d p95=%.1fms > max %.1fms", r.Concurrency, r.P95ms, maxP95ms))
}
}
if cfg.maxErrors >= 0 && r.OtherErrors > cfg.maxErrors {
breaches = append(breaches, fmt.Sprintf("c=%d errors=%d > max %d", r.Concurrency, r.OtherErrors, cfg.maxErrors))
}
}
return breaches
}

// percentile returns the nearest-rank percentile of an already-sorted slice.
func percentile(sorted []float64, p float64) float64 {
if len(sorted) == 0 {
Expand Down
39 changes: 39 additions & 0 deletions cmd/forgemark/stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"errors"
"fmt"
"reflect"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -62,6 +63,44 @@ func TestSummarizeDropsWarmupAndCountsOutcomes(t *testing.T) {
}
}

func TestCheckThresholds(t *testing.T) {
results := []levelResult{
{Concurrency: 1, OpsPerSec: 6.2, P95ms: 900, OtherErrors: 0},
{Concurrency: 4, OpsPerSec: 3.1, P95ms: 2200, OtherErrors: 2},
}
tests := []struct {
name string
cfg *runConfig
want []string
}{
{
name: "breach",
cfg: &runConfig{minPushRate: 5, maxP95: 2 * time.Second, maxErrors: 0},
want: []string{
"c=4 push/s=3.1 < min 5.0",
"c=4 p95=2200.0ms > max 2000.0ms",
"c=4 errors=2 > max 0",
},
},
{
name: "no breach",
cfg: &runConfig{minPushRate: 3, maxP95: 3 * time.Second, maxErrors: 2},
},
{
name: "unset flags",
cfg: &runConfig{maxErrors: -1},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := checkThresholds(results, tt.cfg)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("checkThresholds() = %#v, want %#v", got, tt.want)
}
})
}
}

func TestClassify(t *testing.T) {
cases := []struct {
name string
Expand Down