From 35b050afd10539bd1eafe01945a3c4827c0d3fd9 Mon Sep 17 00:00:00 2001 From: Bury <18151161@qq.com> Date: Wed, 9 Sep 2026 19:55:51 +0800 Subject: [PATCH 1/5] feat(test): plugin-based test harness CLI with docs guide and bilingual README --- .gitignore | 3 +- docs/test-harness.md | 240 +++++++++++++++ go.mod | 9 + go.sum | 24 ++ test/README.md | 213 +++++++------- test/README_zh-CN.md | 195 ++++++------- test/cli.go | 107 +++++++ test/count.go | 98 ------- test/example.go | 104 +++++++ test/flag.go | 546 ----------------------------------- test/go.mod | 21 -- test/go.sum | 36 --- test/hook.go | 78 +++++ test/hooks.go | 83 ------ test/main.go | 208 +++++++------ test/metrics.go | 239 +++++++++++++++ test/option.go | 104 +++++++ test/plan.go | 89 ++++++ test/plot_hook_comparison.py | 240 --------------- test/plugin.go | 64 ++++ test/plugins.go | 18 ++ test/pool.go | 113 ++++++++ test/profile.go | 84 ++++++ test/registry.go | 55 ++++ test/run_test.bat | 71 ++--- test/run_test.sh | 71 +++-- test/store.go | 71 +++++ test/submit.go | 275 ++++++++++++++++++ test/task.go | 98 +++++++ 29 files changed, 2149 insertions(+), 1408 deletions(-) create mode 100644 docs/test-harness.md create mode 100644 test/cli.go delete mode 100644 test/count.go create mode 100644 test/example.go delete mode 100644 test/flag.go delete mode 100644 test/go.mod delete mode 100644 test/go.sum create mode 100644 test/hook.go delete mode 100644 test/hooks.go create mode 100644 test/metrics.go create mode 100644 test/option.go create mode 100644 test/plan.go delete mode 100644 test/plot_hook_comparison.py create mode 100644 test/plugin.go create mode 100644 test/plugins.go create mode 100644 test/pool.go create mode 100644 test/profile.go create mode 100644 test/registry.go create mode 100644 test/store.go create mode 100644 test/submit.go create mode 100644 test/task.go diff --git a/.gitignore b/.gitignore index 0adc409..04f07d7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,4 @@ .idea/ *.bak *.txt -*.log -test/ \ No newline at end of file +*.log \ No newline at end of file diff --git a/docs/test-harness.md b/docs/test-harness.md new file mode 100644 index 0000000..43d5fad --- /dev/null +++ b/docs/test-harness.md @@ -0,0 +1,240 @@ +# Test Harness: Plugin-Based CLI Design & Plugin Authoring Guide + +The benchmark/test tool under `test/` is a plugin-based, sub-command style +CLI. This document is the authoritative design reference for the harness +and, more importantly, a **guide for writing new plugins**. For a quick +overview and the scenario scripts see `test/README.md`. + +## 1. Why plugins + +The legacy tool (archived in `test/old/`) flattened pool options, task +duration models, submit strategies, instrumentation, sampling and profiling +into one `main` with short-flag prefixes (`-T`, `-U`, `-w`, ...). That made +options ambiguous and scenarios hard to compose. The harness now treats +**every capability as a plugin** and the command line as a composition of +plugin segments: + +``` +agilepool_test [--list | --help [name]] | (--pluginA args... --pluginB args...) +``` + +The host framework (~a few hundred lines) only splits segments, plans +dependencies and drives the lifecycle. It never knows what a plugin does. +**Adding a capability = one new file + one line in `plugins.go`. The +framework is untouched.** + +## 2. Segment syntax + +A **segment head** is any token whose text after `--` matches a registered +plugin name. It opens a new segment wherever it appears. + +### 2.1 Order contract + +Every non-head token belongs to the current segment and reaches the plugin +**verbatim, in command-line order**. The framework never reorders, merges, +deduplicates or groups tokens. Flag-style parsing (`-T fixed --task-base 500`, +flag/value pairs) relies on this; a plugin is free to parse `args` itself. +Order is only lost if the plugin itself chooses to (e.g. `ParseOptions` +turns `key=value` tokens into a map). + +- `key=value` — recommended, order-free, self-describing; parsed by helpers. +- bare words — optional, only where a plugin supports positional arguments. +- `-x` / `--unregistered` — allowed pass-through for plugin-owned flag parsing. +- An unregistered `--xxx` before any segment head is a usage error (typo guard). +- A registered name can never be passed as a bare argument (it always starts a + segment); embed it in a `key=value` value if ever needed. + +### 2.2 Parameter helpers + +`option.go` provides `ParseOptions`, `Positionals`, `GetString`, `GetInt`, +`GetFloat`, `GetDuration` and `GetBool`. All getters return the given default +when the key is absent and only error when a present value is malformed. + +## 3. Plugin contract + +Design principle: **minimal core + optional capability interfaces**, detected +by the host through type assertion. + +```go +// Required. +type Plugin interface { + Name() string // unique; used as segment head + Desc() string // shown by --list / --help + Run(ctx context.Context, rt *Runtime, args []string) error +} + +// Optional: option table for --help . Metadata only; no validation. +type Optioned interface { Options() []Option } // []Option{Name, Default, Help} + +// Optional: session-scoped lifecycle. +type Lifecycle interface { + Start(ctx context.Context, rt *Runtime, args []string) error + End(ctx context.Context, rt *Runtime) error +} + +// Optional: "must run after these plugins". +type Depender interface { Deps() []string } +``` + +Why capability interfaces instead of one big interface: a plugin file makes +its own role obvious (pool = lifecycle teardown, submit = dependencies only), +and the host never needs concrete plugin types. + +### 3.1 Lifecycle semantics + +- `Start` runs before **every** `Run`, in plan order. It receives the segment + args because envelope plugins (`metrics`, `profile`) must be in place + (sampler goroutine up, pprof started) before the session begins. +- `Run` runs per segment, in plan order. +- `End` runs after every `Run`, in **reverse** Start order — resources close + in the opposite order of their creation. +- Both `Start` and `End` must be provided when implementing `Lifecycle`; the + phase a plugin does not need returns `nil`. +- A failure aborts the session (exit code 1) but already-started lifecycle + plugins still get `End` called. + +### 3.2 Shared data: Store and the performance layering rule + +Plugins never reference each other. The only data path is the shared +`Store` (two-level table: plugin name → key → value), provided per session +via `Runtime`: + +```go +Provide(rt.Store, "pool", "pool", p) // writer: own namespace +p, err := Require[*agilepool.Pool](rt.Store, "pool", "pool") // reader: dependency namespace +``` + +Rules: + +- Write into **your own namespace** (`plugin.Name()`), immediately after + parsing. Re-providing the same key overwrites. +- Read only from namespaces of plugins you declared in `Deps()`; dependency + ordering guarantees they ran first. +- Generics carry the type (`Require[T]`); the type lives at the read site. +- **Performance layering**: the Store only carries *session-level objects* + (pool handle, configs, factories), touched a handful of times per session. + Per-task hot data (counters, samples) must stay in the plugin's own + `atomic` state and must never be written through the Store — that would add + a lock and a box to every increment. Readers sample the handle, not + per-tick Store writes. + +## 4. Host execution model + +``` +split argv → plan dependencies → Start (in order) → Run (segment by segment) + → End (reverse) → exit code +``` + +1. **Dependency auto-insertion**: when a dependency is not requested, the + planner inserts it (empty args = defaults) before its dependent, so + `--submit ...` alone also runs pool+task. Explicitly writing a dependency + *after* its dependent is an error (exit 2), never a silent reorder: the + user's segment order is intent. Duplicate segments and dependency cycles + are errors too. +2. **Start-before-Run** covers envelope needs; **End-in-reverse** tears down + symmetrically. +3. **Exit codes**: `0` success; `1` runtime error (plugin Start/Run/End); + `2` usage error (unknown segment, out-of-segment token, misordered + dependency, duplicate segment, cycle). Errors are printed with a + `[plugin]` prefix. +4. Ctrl+C cancels the shared `ctx`; plugins watch `ctx.Done()` and End still + runs. + +## 5. Writing a plugin (the guide) + +### 5.1 Steps + +1. Create `.go` in `test/` (one file per plugin). +2. Implement `Plugin`. Decide which capabilities apply: + + | Capability | Implement when... | + |---|---| + | `Options()` | the plugin takes `key=value` options and should show them in `--help ` | + | `Deps()` | it needs outputs of other plugins (pool handle, duration factory) | + | `Lifecycle` | it must envelope the session (profile), start background work (metrics), or own a resource to close (pool) | + +3. Register one line in `plugins.go` (`var allPlugins = []Plugin{...}`). +4. If the plugin produces something for downstream plugins, `Provide` it into + its own namespace during `Start` (if it must exist before Runs) or `Run`. +5. `--list` / `--help ` pick it up automatically. + +### 5.2 Template + +```go +package main + +import ( + "context" + "fmt" +) + +type myPlugin struct{} + +func (myPlugin) Name() string { return "my" } +func (myPlugin) Desc() string { return "one-line description for --list" } + +func (myPlugin) Options() []Option { + return []Option{{Name: "x", Default: "1", Help: "what it does"}} +} + +func (myPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + x, err := GetInt(opts, "x", 1) + if err != nil { + return err + } + // read dependencies: + // p, err := Require[*agilepool.Pool](rt.Store, "pool", "pool") + // share outputs: + // Provide(rt.Store, "my", "result", result) + _ = x + return nil +} +``` + +A complete runnable example pair (provider/consumer, demonstrating every +optional capability) is in `test/example.go`. Register it temporarily in +`plugins.go` to play with it. + +### 5.3 Checklist + +- [ ] `Name()` unique and stable — it is the CLI surface. +- [ ] Option parsing via the helpers; unknown keys are the plugin's business. +- [ ] Options parsed in `Run` (or in `Start` when the value must be ready + before other Runs, e.g. metrics interval, profile flags). +- [ ] Errors returned (host prints them with the `[name]` prefix and maps to + exit code 1); the plugin never calls `os.Exit`. +- [ ] No package-global mutable state; session state lives on the plugin + struct (register a pointer instance) or is passed via `Runtime`. +- [ ] Hot path stays allocation/atomic-light; Store untouched in loops. +- [ ] One line added to `plugins.go`. + +## 6. Existing plugins and legacy mapping + +| Plugin | Purpose | Legacy source | Deps | +|---|---|---|---| +| `pool` | create the pool, close on End | old `flag.go` pool flags + `newPool` | — | +| `task` | task duration model (fixed/uniform/normal), provides duration factory | old `flag.go` task flags + `buildDurationFn` | — | +| `submit` | strategies immediate/linear/constant/poisson/phased, blocks until drain | old `runSubmitter` family | `pool`, `task` | +| `hook` | event instrumentation: none / hook counters (trace pending upstream context wiring) | old `hooks.go` `setupHooks` | `pool` | +| `metrics` | periodic runtime/pool/GC/CPU sampling; End: final tick + wait-exit + summary | old `count.go` + main-level shutdown waits | `pool` | +| `profile` | pprof enveloping the session | old `--cpuprofile/--memprofile` wrapping | — | + +Legacy flag → new segment mapping used by the scenario scripts +(`run_test.bat`, `run_test.sh`): + +| Legacy | New | +|---|---| +| `-T X --task-base N --task-extra E --task-mean M --task-sigma S` | `--task type=X base=N extra=E mean=M sigma=S` | +| `-U S -t N --submit-interval I --submit-jitter J --submit-mean-interval PM --submit-shards SH -P "..."` | `--submit strategy=S num=N interval=I jitter=J mean-interval=PM shards=SH phases="..."` | +| `-w W -c C -m M` | `--pool workers=W container=C mode=M` | +| `-i T -f F -o FILE -e W` | `--metrics interval=T format=F file=FILE wait-exit=W` | +| `--cpuprofile --memprofile` | `--profile cpu=true mem=true` | + +## 7. Status and notes + +- Metrics CSV/JSON columns and formats are preserved from the legacy tool + column for column (the CSV header is written on the first tick). +- `--hook mode=trace` is intentionally rejected until the upstream + context-tracing API (`internal/context`) is wired. +- The old implementation is archived under `test/old/` (not compiled). diff --git a/go.mod b/go.mod index 2f73599..2f5d4a2 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,15 @@ require github.com/stretchr/testify v1.11.1 require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shirou/gopsutil/v3 v3.24.5 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.20.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b93a1ca..5ac0ce1 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,34 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/README.md b/test/README.md index 8cfd381..ee730bd 100644 --- a/test/README.md +++ b/test/README.md @@ -1,121 +1,104 @@ - -## Test Harness(test/) - -The `test/` directory provides a standalone `package main` entry point for benchmarking, metric collection, and performance analysis of agilePool under real workloads. - -### Why a separate test harness? - -- Captures system-level metrics over time: **memory, GC, goroutines, CPU** -- Easily compares pool configurations (container type, work mode, worker count) -- Outputs CSV/JSON for analysis with `plot_csv.py` or `go tool pprof` -- Edit `run_test.bat` / `run_test.sh` to change parameters quickly - -### Design - -Task duration generation (`--task-type`/`-T`) and submission control (`--submit-type`/`-U`) are decoupled and freely composable: - -| Task Type | Description | -|-----------|-------------| -| `fixed` | constant `--task-base` ms | -| `uniform` | `--task-base` + rand × `--task-extra` ms | -| `normal` | N(`--task-mean`, `--task-sigma`) ms | - -| Submit Type | Description | -|-------------|-------------| -| `immediate` | submit all N at once, wait for completion | -| `linear` | `--submit-interval` + rand × `--submit-jitter` ms | -| `constant` | fixed `--submit-interval` ms | -| `poisson` | mean interval `--submit-mean-interval` ms | -| `phased` | multi-phase burst: `-P "offset,dur,rate;..."` | - -### Usage - -```bash -cd test -go build -o agilepool_test.exe . -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -i 1 -f csv +# agilepool Test Harness (Plugin-Based) + +The performance test tool for go-agile-pool; the legacy tool is archived in +`old/`. The full design document and **plugin authoring guide** (English) +live in [`docs/test-harness.md`](../docs/test-harness.md), and a runnable +integration example is in [`example.go`](example.go). Chinese version: +[`README_zh-CN.md`](README_zh-CN.md). + +## What this is + +The legacy tool flattened pool options, task duration models, submit +strategies, instrumentation, sampling and profiling into a single `main` +with short-flag prefixes (`-T`/`-U`/`-w`...), which was ambiguous and hard to +compose. The harness is rebuilt as a **plugin-based, sub-command style CLI**: +every capability is an independent plugin and the host framework only splits +segments, plans dependencies and drives the lifecycle. **Adding a capability += one new file + one line in `plugins.go`; the framework stays untouched.** + +## Implemented plugins + +| Segment | Purpose | Key options (default) | Lifecycle | +|---|---|---|---| +| `--pool` | create and hold the pool | `workers`(20000) `queue`(10000) `container`(linkedlist) `mode`(block) `clean-period`(500ms) | Start creates / End `Close` | +| `--task` | task duration model | `type`(fixed) `base`(10) `extra`(0) `mean`(10) `sigma`(5) | — | +| `--submit` | submit per strategy, block until drain | `strategy`(immediate) `num`(1000000) `interval`(10) `jitter`(0) `mean-interval`(50) `phases` `shards`(1) `submitters`(1) | — | +| `--hook` | event instrumentation | `mode`(none; hook counters; trace pending upstream tracing) | — | +| `--metrics` | periodic sampling + summary | `interval`(1s) `format`(csv) `file`(metrics.csv) `wait-exit`(0) | Start sampler / End final tick + window + summary | +| `--profile` | pprof enveloping the session | `cpu`(false) `mem`(false) | Start CPU / End stop CPU + write heap | + +Submit strategies: immediate / linear / constant / poisson / phased. Task +duration types: fixed / uniform / normal. Dependencies: +`submit ← pool+task`, `hook ← pool`, `metrics ← pool`. Missing dependency +segments are auto-inserted with defaults (e.g. `--submit` alone also runs +pool/task); misordered dependencies, duplicate segments and cycles all exit +with code 2. + +## Quick start + +```text +# list plugins and get help +go run . --list +go run . --help submit + +# a full benchmark scenario (hook counters appear in the metrics rows) +go run . --pool workers=20000 queue=10000 \ + --task type=fixed base=500 \ + --hook mode=hook \ + --submit strategy=immediate num=200000 \ + --metrics interval=1 format=csv file=out.csv ``` -Or run `run_test.bat` / `run_test.sh` for a full test suite. - -### CLI Reference - -| Flag | Short | Default | Description | -|------|-------|---------|-------------| -| `--workers` | `-w` | `20000` | max concurrent workers | -| `--tasks` | `-t` | `1000000` | total tasks to submit | -| `--clean-period` | | `500ms` | idle worker cleanup interval | -| `--queue-size` | | `10000` | task queue size hint | -| `--container` | `-c` | `linkedlist` | idle container: linkedlist, minheap, slice, ringqueue | -| `--mode` | `-m` | `block` | work mode: block, nonblock | -| `--task-type` | `-T` | `fixed` | task type: fixed, uniform, normal | -| `--task-base` | | `10` | base task duration (ms) | -| `--task-extra` | | `0` | random extra range (ms, uniform) | -| `--task-mean` | | `10` | normal mean (ms) | -| `--task-sigma` | | `5` | normal stddev (ms) | -| `--submit-type` | `-U` | `immediate` | submit mode: immediate, linear, constant, poisson, phased | -| `--submit-interval` | | `10` | submit interval (ms) | -| `--submit-jitter` | | `0` | random jitter (ms, linear) | -| `--submit-mean-interval` | | `50` | poisson mean interval (ms) | -| `--submit-phases` | `-P` | `""` | phases: "offset,dur,rate;..." (phased) | -| `--submit-shards` | | `1` | parallel shards (phased) | -| `--take-time` | `-i` | `0` | metric sampling interval (seconds, 0=disabled) | -| `--log-file` | `-o` | auto | output file path | -| `--log-format` | `-f` | `csv` | output format: csv, json | -| `--cpuprofile` | | `false` | enable CPU profiling | -| `--memprofile` | | `false` | enable memory profiling | -| `--wait-exit` | `-e` | `0` | extra seconds to wait before exit | - -### Collected Metrics - -When `--take-time` (`-i`) > 0, a background goroutine samples these fields at the given interval: - -| CSV Column | Source | Description | -|------------|--------|-------------| -| `run_sec` | `time.Since(start).Seconds()` | seconds since program start | -| `goroutines` | `runtime.NumGoroutine()` | current goroutine count | -| `heap_alloc_mb` | `memStats.Alloc` | current heap allocation (MB) | -| `total_alloc_mb` | `memStats.TotalAlloc` | cumulative allocation (MB) | -| `sys_mb` | `memStats.Sys` | memory obtained from OS (MB) | -| `gc_total` | `memStats.NumGC` | total GC cycles | -| `gc_pause_total_ms` | `PauseTotalNs / 1e6` | total GC pause time (ms) | -| `gc_pause_avg_ms` | total / count | average pause per GC (ms) | -| `gc_cpu_pct` | `GCCPUFraction * 100` | GC CPU time fraction (%) | -| `last_gc_sec` | `(LastGC - start) / 1e9` | last GC's relative time (seconds) | -| `next_gc_mb` | `memStats.NextGC` | heap threshold for next GC (MB) | -| `workers_running` | `pool.GetRunningWorkersNum()` | currently executing workers | -| `workers_idle` | `pool.GetIdleWorkerCount()` | currently idle workers | -| `workers_created` | `pool.GetWorkerCreateCount()` | total workers created | -| `task_queue_len` | `pool.GetTaskQueueLen()` | pending tasks in queue | -| `cpu_pct` | `gopsutil cpu.Percent` | process CPU usage (%) | - -### Plotting - -```bash -python plot_csv.py -``` - -Scans all `metrics_*.csv` and generates PNG charts (memory / workers / GC / CPU subplots). - -### Three-Stage Shutdown - -After tasks complete: -1. Poll until all workers are idle (`GetRunningWorkersNum() == 0`) -2. If sampling is enabled, wait one interval for final metric flush -3. If `--wait-exit` is set, wait extra seconds for memory observation - -### Profiling +Regression scripts covering the whole scenario set (equivalent to the old +run_test, scenario for scenario, keeping the legacy auto-generated output +file names): -```bash -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 500000 -w 20000 --cpuprofile --memprofile -i 1 -f csv -go tool pprof -http=:8080 cpu_profile.prof -go tool pprof -http=:8080 mem_profile.prof +```text +run_test.bat # Windows +run_test.sh # Linux/macOS ``` -### One-Click Run - -```bash -cd test -run_test.bat # Windows -./run_test.sh # Linux/macOS +## Conventions at a glance + +- A **segment head** is a registered plugin name with a `--` prefix; it opens + a new segment anywhere. Segment arguments are passed through **verbatim, + in order** for the plugin to parse (`-T fixed --task-base 500` works), and + `key=value` is also supported (`ParseOptions`/`GetInt`/`GetDuration`/ + `GetBool`/`GetFloat`). +- Lifecycle: Start (before all Runs, in order, with segment args) → Run + (segment by segment) → End (reverse teardown). A failure aborts the run, + but started segments still get their End. +- Plugins only exchange **session-level objects** (pool handle/configs/ + factories) through the shared Store; per-task counters stay in plugin-owned + atomic state and metrics reads them through the handle — the shared table + never enters the hot path. +- Exit codes: 0 success / 1 runtime error / 2 usage error; errors carry a + `[plugin]` prefix. +- CLI analysis, execution model, and the column-by-column mapping to the + legacy data format are detailed in + [`docs/test-harness.md`](../docs/test-harness.md). + +## Layout + +```text +docs/test-harness.md design doc + plugin authoring guide (English, repo-root docs/) +test/ + README.md this file (English) + README_zh-CN.md Chinese version + main.go host: segment split / planning / lifecycle / help + plugin.go interfaces (Plugin/Optioned/Lifecycle/Depender) + Runtime + option.go key=value parsing and typed getters + store.go session-level shared Store (Provide/Get/Require) + registry.go registry (duplicate/dependency registration checks) + plan.go dependency planning (auto-insert/order/cycles) + cli.go segment splitting + --list/--help + plugins.go central registration table + example.go demo plugins provider/consumer (not registered by default) + pool.go ... the six real plugins (one file per plugin) + run_test.bat Windows regression script (new syntax) + run_test.sh Linux/macOS regression script (same scenarios as the bat) + old/ legacy tool archive (sources/plot scripts, not compiled) ``` + +Code comments are English, matching the rest of the library; the design doc +(`docs/test-harness.md`) is English, this README is bilingual. diff --git a/test/README_zh-CN.md b/test/README_zh-CN.md index 1cb66e2..54178c9 100644 --- a/test/README_zh-CN.md +++ b/test/README_zh-CN.md @@ -1,119 +1,88 @@ - -## 测试工具(test/) - -项目在 `test/` 目录下提供了一个独立测试入口包(`package main`),用于在实际运行场景下对 pool 进行基准测试、指标采集和性能分析。 - -### 为什么编写独立的测试工具? - -- 采集运行时的**内存、GC、goroutine、CPU**等系统级指标的时间变化 -- 对比不同池配置(容器类型、工作模式、worker 数量)对性能的影响 -- 输出 CSV/JSON,配合 `plot_csv.py` 或 `go tool pprof` 分析 -- 修改 `run_test.bat` / `run_test.sh` 即可快速更改参数 - -### 设计思路 - -任务时长生成(`--task-type`/`-T`)与提交控制(`--submit-type`/`-U`)解耦: - -| 任务类型 | 说明 | -|---------|------| -| `fixed` | 固定时长 `--task-base` ms | -| `uniform` | `--task-base` + rand × `--task-extra` ms | -| `normal` | N(`--task-mean`, `--task-sigma`) ms | - -| 提交类型 | 说明 | -|---------|------| -| `immediate` | 一次性全部提交 | -| `linear` | `--submit-interval` + rand × `--submit-jitter` ms | -| `constant` | 固定 `--submit-interval` ms | -| `poisson` | 平均间隔 `--submit-mean-interval` ms | -| `phased` | 多阶段突发:`-P "offset,dur,rate;..."` | - -### 使用方式 - -```bash -cd test -go build -o agilepool_test.exe . -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -i 1 -f csv +# agilepool 测试工具(插件式) + +go-agile-pool 的性能测试工具,旧版已归档到 `old/`。完整设计文档与插件编写 +指南(英文)见 [`docs/test-harness.md`](../../docs/test-harness.md);接入范例见 +[`example.go`](example.go)。English version: [`README.md`](README.md)。 + +## 这是什么 + +旧实现把所有能力(池参数、任务模型、提交策略、插桩、采样、profiling)平铺在 +一个 main 里,靠短 flag 前缀(`-T`/`-U`/`-w`…)区分,歧义多、难组合。重构为 +**插件式子命令式**:每个能力是一个独立插件,宿主框架只做"切段、排依赖、按 +生命周期调度"。**新增能力 = 新增一个文件 + `plugins.go` 登记一行,框架零改动。** + +## 已实现插件 + +| 段 | 作用 | 关键选项(默认) | 生命周期 | +|---|---|---|---| +| `--pool` | 创建池并持有 | `workers`(20000) `queue`(10000) `container`(linkedlist) `mode`(block) `clean-period`(500ms) | Start 建池 / End `Close` | +| `--task` | 任务时长模型 | `type`(fixed) `base`(10) `extra`(0) `mean`(10) `sigma`(5) | — | +| `--submit` | 提交策略并等排空 | `strategy`(immediate) `num`(1000000) `interval`(10) `jitter`(0) `mean-interval`(50) `phases` `shards`(1) `submitters`(1) | — | +| `--hook` | 事件插桩 | `mode`(none;hook 计数;trace 待上游追踪接线) | — | +| `--metrics` | 周期采样 + 汇总 | `interval`(1s) `format`(csv) `file`(metrics.csv) `wait-exit`(0) | Start 起采样 / End 收尾拍+观察窗+汇总 | +| `--profile` | pprof 包络会话 | `cpu`(false) `mem`(false) | Start 开 CPU / End 停 CPU+写 heap | + +submit 策略:immediate / linear / constant / poisson / phased;任务时长类型: +fixed / uniform / normal。依赖关系:`submit ← pool+task`,`hook ← pool`, +`metrics ← pool`,漏写依赖段时宿主按默认参数自动补全(如只写 `--submit` +会自动带出 pool/task),依赖乱序/重复段/成环都会以退出码 2 报错。 + +## 快速使用 + +```text +# 查看插件与帮助 +go run . --list +go run . --help submit + +# 一个完整基准场景(hook 计数会带进 metrics 采样行) +go run . --pool workers=20000 queue=10000 \ + --task type=fixed base=500 \ + --hook mode=hook \ + --submit strategy=immediate num=200000 \ + --metrics interval=1 format=csv file=out.csv ``` -也可直接运行 `run_test.bat` 或 `run_test.sh`。 - -### 命令行参数 - -| 参数 | 短形式 | 默认值 | 说明 | -|------|--------|--------|------| -| `--workers` | `-w` | `20000` | 最大 worker 数 | -| `--tasks` | `-t` | `1000000` | 提交任务总数 | -| `--clean-period` | | `500ms` | 空闲 worker 清理周期 | -| `--queue-size` | | `10000` | 任务队列大小提示 | -| `--container` | `-c` | `linkedlist` | 空闲容器类型 | -| `--mode` | `-m` | `block` | 工作模式 | -| `--task-type` | `-T` | `fixed` | 任务时长类型 | -| `--task-base` | | `10` | 基准时长(ms) | -| `--task-extra` | | `0` | 随机范围(ms, uniform) | -| `--task-mean` | | `10` | 正态均值(ms) | -| `--task-sigma` | | `5` | 正态标准差(ms) | -| `--submit-type` | `-U` | `immediate` | 提交模式 | -| `--submit-interval` | | `10` | 提交间隔(ms) | -| `--submit-jitter` | | `0` | 随机抖动(ms, linear) | -| `--submit-mean-interval` | | `50` | 泊松平均间隔(ms) | -| `--submit-phases` | `-P` | `""` | 多阶段编排(phased) | -| `--submit-shards` | | `1` | 并行分片数 | -| `--take-time` | `-i` | `0` | 采集间隔秒数(0=禁用) | -| `--log-file` | `-o` | 自动 | 输出路径 | -| `--log-format` | `-f` | `csv` | 输出格式 | -| `--cpuprofile` | | `false` | CPU profiling | -| `--memprofile` | | `false` | Memory profiling | -| `--wait-exit` | `-e` | `0` | 退出前额外等待秒数 | - -### 指标采集 - -当 `-i` > 0 时启动后台协程,按间隔采集以下字段: - -| CSV 列 | 来源 | 说明 | -|--------|------|------| -| `run_sec` | `time.Since(start).Seconds()` | 运行秒数 | -| `goroutines` | `runtime.NumGoroutine()` | goroutine 数 | -| `heap_alloc_mb` | `memStats.Alloc` | 当前堆分配(MB) | -| `total_alloc_mb` | `memStats.TotalAlloc` | 累计分配(MB) | -| `sys_mb` | `memStats.Sys` | 系统内存(MB) | -| `gc_total` | `memStats.NumGC` | GC 总次数 | -| `gc_pause_total_ms` | `PauseTotalNs / 1e6` | GC 暂停总耗时(ms) | -| `gc_pause_avg_ms` | 总耗时 / 次数 | GC 平均暂停(ms) | -| `gc_cpu_pct` | `GCCPUFraction * 100` | GC CPU 占比(%) | -| `last_gc_sec` | `(LastGC - start) / 1e9` | 上次 GC 时间(秒) | -| `next_gc_mb` | `memStats.NextGC` | 下次 GC 阈值(MB) | -| `workers_running` | `pool.GetRunningWorkersNum()` | 运行中 worker | -| `workers_idle` | `pool.GetIdleWorkerCount()` | 空闲 worker | -| `workers_created` | `pool.GetWorkerCreateCount()` | 累计创建 worker | -| `task_queue_len` | `pool.GetTaskQueueLen()` | 队列长度 | -| `cpu_pct` | `gopsutil cpu.Percent` | CPU 使用率(%) | - -### 绘图 - -```bash -python plot_csv.py -``` - -扫描 `metrics_*.csv` 生成 PNG 折线图。 - -### 三段式退出 - -1. 轮询至所有 worker 空闲 -2. 等待一个采集周期完成最终写入 -3. 若有 `--wait-exit`,等待额外秒数 - -### 性能分析 +全场景回归脚本(等价旧 run_test,场景逐行对应、输出文件名沿用旧自动命名): -```bash -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 500000 -w 20000 --cpuprofile --memprofile -i 1 -f csv -go tool pprof -http=:8080 cpu_profile.prof -go tool pprof -http=:8080 mem_profile.prof +```text +run_test.bat # Windows +run_test.sh # Linux/macOS ``` -### 一键运行 - -```bash -run_test.bat # Windows -./run_test.sh # Linux/macOS +## 约定速览 + +- 段头 = 命中注册插件名的 `--xxx`,任意位置开新段;段内参数**按原顺序原样透传** + 给插件自解析(`-T fixed --task-base 500` 这类 flag 写法可用),也支持 + `key=value`(框架提供 `ParseOptions`/`GetInt`/`GetDuration`/`GetBool`/`GetFloat`); +- 生命周期:Start(全会话前,按序,带本段参数)→ Run(逐段)→ End(逆序收尾); + 出错即中止但已 Start 的段仍逆序 End; +- 插件间只经共享 Store 交换**会话级对象**(池句柄/配置/工厂);每任务级计数留在 + 插件自身原子状态,metrics 采样时按句柄直接读数——共享表不进热路径; +- 退出码:0 成功 / 1 运行错误 / 2 用法错误;错误带 `[插件名]` 前缀; +- 命令行分析、执行模型、数据列与旧格式的对照等细节见 + [`docs/test-harness.md`](../../docs/test-harness.md)。 + +## 目录 + +```text +docs/test-harness.md 设计文档 + 插件编写指南(英文,仓库根 docs/) +test/ + README.md 本文档英文版 + README_zh-CN.md 本文档(中文) + main.go 宿主:切段/规划/生命周期调度/帮助 + plugin.go 接口(Plugin/Optioned/Lifecycle/Depender)+ Runtime + option.go key=value 解析与类型化取值 + store.go 会话级共享 Store(Provide/Get/Require) + registry.go 注册表(重名/依赖注册校验) + plan.go 依赖规划(自动补全/顺序/环检测) + cli.go 段切割 + --list/--help + plugins.go 集中注册表 + example.go 演示插件 provider/consumer(默认不注册) + pool.go ... 六个正式插件(一文件一插件) + run_test.bat Windows 回归脚本(新语法) + run_test.sh Linux/macOS 回归脚本(与 bat 同场景) + old/ 旧工具归档(源码/绘图脚本,不参与编译) ``` + +代码注释为英文,与库内其他包风格一致;设计文档 `docs/test-harness.md` 为 +英文,本 README 提供中英双语。 diff --git a/test/cli.go b/test/cli.go new file mode 100644 index 0000000..03d9d8f --- /dev/null +++ b/test/cli.go @@ -0,0 +1,107 @@ +package main + +import ( + "fmt" + "io" + "strings" +) + +// ParseSegments splits argv into ordered Segments. The rule in one sentence: +// "--" opens a new segment wherever it appears; every +// other token belongs to the current segment and is passed through as-is. +// +// Recognizing heads by registered names (instead of position) is what makes +// composable long command lines possible. Everything else — including +// flag-style "-x"/"--" tokens — is kept verbatim and in order: +// flag parsing like "-T fixed --task-base 500" relies on adjacent flag/value +// pairs, so the framework must not reorder, merge or drop anything (the +// order contract in docs/test-harness.md). The price is that a literal registered name +// cannot be expressed as a bare argument; embed it in a key=value if needed. +// +// Before any segment has opened, bare tokens and unregistered "--xxx" tokens +// are usage errors prompting for a segment head, so typos are not swallowed. +func ParseSegments(argv []string, reg *Registry) ([]Segment, error) { + var segs []Segment + var cur *Segment + // flush is a closure shared by the "new segment head" path and the tail + // of the loop to avoid duplicating the append logic. + flush := func() { + if cur != nil { + segs = append(segs, *cur) + cur = nil + } + } + for _, tok := range argv { + if strings.HasPrefix(tok, "--") { + name := tok[2:] + if _, ok := reg.Lookup(name); ok { + flush() + cur = &Segment{Name: name} + continue + } + // Unregistered name: pass through inside a segment, but treat + // it as an unknown head when no segment is open yet. + if cur == nil { + return nil, fmt.Errorf("usage: unknown segment --%s (see --list)", name) + } + cur.Args = append(cur.Args, tok) + continue + } + // A bare token before any segment means the user expected arguments + // to stand alone; point them at a segment head. + if cur == nil { + return nil, fmt.Errorf("usage: argument %q outside any segment (segment must start with --name)", tok) + } + cur.Args = append(cur.Args, tok) + } + flush() + return segs, nil +} + +// printUsage prints the global help (--help): the invocation forms followed +// by the plugin list, reusing printList so --help and --list cannot drift. +func printUsage(out io.Writer, reg *Registry) { + fmt.Fprintln(out, "usage: agilepool_test.exe [--list | --help [name]]") + fmt.Fprintln(out, " agilepool_test.exe --pluginA key=value ... --pluginB key=value ...") + fmt.Fprintln(out) + printList(out, reg) +} + +// printList prints one line per plugin. Deps and the lifecycle marker are +// shown because they are the information users need to compose command +// lines: auto-insertion visibility ("--submit alone also runs pool") and +// which segments take care of teardown. +func printList(out io.Writer, reg *Registry) { + for _, name := range reg.Names() { + p, _ := reg.Lookup(name) + var deps string + if d, ok := p.(Depender); ok && len(d.Deps()) > 0 { + deps = " (deps: --" + strings.Join(d.Deps(), ", --") + ")" + } + lc := "" + if _, ok := p.(Lifecycle); ok { + lc = " [lifecycle]" + } + fmt.Fprintf(out, " --%-10s %s%s%s\n", name, p.Desc(), deps, lc) + } +} + +// printPluginHelp prints one plugin's details (--help ). The name +// tolerates a leading "--" so output copied from --list pastes back cleanly. +// Errors are returned (not written) so run() decides the destination and +// exit code. +func printPluginHelp(out io.Writer, reg *Registry, name string) error { + name = strings.TrimPrefix(name, "--") + p, ok := reg.Lookup(name) + if !ok { + return fmt.Errorf("usage: unknown plugin %q (see --list)", name) + } + fmt.Fprintf(out, "--%s: %s\n", p.Name(), p.Desc()) + if o, ok := p.(Optioned); ok { + fmt.Fprintln(out, "options:") + for _, opt := range o.Options() { + fmt.Fprintf(out, " %-10s default: %-12s %s\n", opt.Name, opt.Default, opt.Help) + } + } + return nil +} diff --git a/test/count.go b/test/count.go deleted file mode 100644 index 7e6a4d8..0000000 --- a/test/count.go +++ /dev/null @@ -1,98 +0,0 @@ -package main - -import ( - "fmt" - "os" - "runtime" - "time" - - agilepool "github.com/Yiming1997/agilePool/v2" - "github.com/shirou/gopsutil/v3/cpu" -) - -// Couter collects memory, GC, CPU and pool metrics at a fixed interval. -func Couter(args FlagArgs, p *agilepool.Pool, f *os.File, start time.Time) { - var memStats runtime.MemStats - tick := time.NewTicker(time.Duration(args.TakeTime * float64(time.Second))) - defer tick.Stop() - - headerWritten := false - - for range tick.C { - runtime.ReadMemStats(&memStats) - - // Keep the collector responsive so the final sample can be written - // during the main program's observation window. - cpuPercent, _ := cpu.Percent(100*time.Millisecond, false) - var cpuUsage float64 - if len(cpuPercent) > 0 { - cpuUsage = cpuPercent[0] - } - - running := p.GetRunningWorkersNum() - idle := p.GetIdleWorkerCount() - created := p.GetWorkerCreateCount() - queueLen := p.GetTaskQueueLen() - - runSec := time.Since(start).Seconds() - - var lastGCRel float64 - if memStats.NumGC > 0 { - lastGCRel = float64(memStats.LastGC-uint64(start.UnixNano())) / 1e9 - } - - var avgPauseMs float64 - totalPauseMs := float64(memStats.PauseTotalNs) / 1e6 - if memStats.NumGC > 0 { - avgPauseMs = totalPauseMs / float64(memStats.NumGC) - } - - switch args.LogFormat { - case "csv": - if !headerWritten { - fmt.Fprintf(f, "run_sec,goroutines,heap_alloc_mb,total_alloc_mb,sys_mb,"+ - "gc_total,gc_pause_total_ms,gc_pause_avg_ms,gc_cpu_pct,"+ - "last_gc_sec,next_gc_mb,"+ - "workers_running,workers_idle,workers_created,task_queue_len,"+ - "cpu_pct,hook_mode,hook_submitted,hook_enqueued,hook_started,hook_completed\n") - headerWritten = true - } - - fmt.Fprintf(f, "%.3f,%d,%.2f,%.2f,%.2f,%d,%.2f,%.2f,%.4f,%.3f,%.2f,%d,%d,%d,%d,%.2f,%s,%d,%d,%d,%d\n", - runSec, - runtime.NumGoroutine(), - float64(memStats.Alloc)/1024/1024, - float64(memStats.TotalAlloc)/1024/1024, - float64(memStats.Sys)/1024/1024, - memStats.NumGC, - totalPauseMs, - avgPauseMs, - memStats.GCCPUFraction*100, - lastGCRel, - float64(memStats.NextGC)/1024/1024, - running, idle, created, queueLen, cpuUsage, - args.HookMode, hookSubmitted.Load(), hookEnqueued.Load(), hookStarted.Load(), hookCompleted.Load()) - case "json": - fmt.Fprintf(f, `{"run_sec":%.3f,"goroutines":%d,"heap_alloc_mb":%.2f,`+ - `"total_alloc_mb":%.2f,"sys_mb":%.2f,"gc_total":%d,`+ - `"gc_pause_total_ms":%.2f,"gc_pause_avg_ms":%.2f,`+ - `"gc_cpu_pct":%.4f,"last_gc_sec":%.3f,"next_gc_mb":%.2f,`+ - `"workers_running":%d,"workers_idle":%d,`+ - `"workers_created":%d,"task_queue_len":%d,`+ - `"cpu_pct":%.2f,"hook_mode":"%s","hook_submitted":%d,"hook_enqueued":%d,"hook_started":%d,"hook_completed":%d}`+"\n", - runSec, - runtime.NumGoroutine(), - float64(memStats.Alloc)/1024/1024, - float64(memStats.TotalAlloc)/1024/1024, - float64(memStats.Sys)/1024/1024, - memStats.NumGC, - totalPauseMs, - avgPauseMs, - memStats.GCCPUFraction*100, - lastGCRel, - float64(memStats.NextGC)/1024/1024, - running, idle, created, queueLen, - cpuUsage, args.HookMode, hookSubmitted.Load(), hookEnqueued.Load(), hookStarted.Load(), hookCompleted.Load()) - } - } -} diff --git a/test/example.go b/test/example.go new file mode 100644 index 0000000..bc823b4 --- /dev/null +++ b/test/example.go @@ -0,0 +1,104 @@ +package main + +// example.go holds two demonstration plugins that cover every optional +// capability of the framework in the smallest runnable form: +// +// - the minimum plugin contract Name/Desc/Run (both plugins) +// - Optioned.Options(): declares key=value options shown by --help +// (provider) +// - Lifecycle.Start/End: Start runs before every Run in order (and receives +// the segment args), End after every Run in reverse (both plugins; a +// plugin needing only one phase returns nil from the other) +// - Depender.Deps(): declares who must run first; the host auto-inserts a +// missing --provider before its dependent (consumer depends on provider) +// - shared data: the upstream Provides into its own namespace, the +// downstream Require-reads it (consumer reads the provider's integer) +// +// The examples are not registered by default (the real plugins live in +// plugins.go); to try them, temporarily add both types to allPlugins: +// +// agilepool_test.exe --provider seed=5 # provider alone +// agilepool_test.exe --consumer # provider auto-inserted first +// agilepool_test.exe --consumer --provider # misorder -> error +// agilepool_test.exe --provider fail=true # Run error -> exit 1, End still runs +// agilepool_test.exe --help provider # option table +// +// Expected order for --consumer: +// Start: provider → consumer; Run: provider → consumer; End: consumer → provider. + +import ( + "context" + "fmt" +) + +// --- provider: dependency-free upstream, demonstrates Options/Provide/Lifecycle --- + +type providerPlugin struct{} + +func (providerPlugin) Name() string { return "provider" } +func (providerPlugin) Desc() string { return "example: parse args and share an int via Store" } + +func (providerPlugin) Options() []Option { + return []Option{ + {Name: "seed", Default: "1", Help: "the integer to provide"}, + {Name: "fail", Default: "false", Help: "when true, Run returns an error (error path demo)"}, + } +} + +func (providerPlugin) Start(ctx context.Context, rt *Runtime, args []string) error { + fmt.Fprintln(rt.Out, "[provider] Start") + return nil +} + +func (providerPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + seed, err := GetInt(opts, "seed", 1) + if err != nil { + return err + } + fail, err := GetBool(opts, "fail", false) + if err != nil { + return err + } + fmt.Fprintf(rt.Out, "[provider] Run seed=%d\n", seed) + if fail { + return fmt.Errorf("injected failure (fail=true)") + } + // Writing one's own namespace; the key only has to be unique here. + Provide(rt.Store, "provider", "seed", seed) + return nil +} + +func (providerPlugin) End(ctx context.Context, rt *Runtime) error { + fmt.Fprintln(rt.Out, "[provider] End") + return nil +} + +// --- consumer: downstream depending on provider, demonstrates Deps/Require/Lifecycle --- + +type consumerPlugin struct{} + +func (consumerPlugin) Name() string { return "consumer" } +func (consumerPlugin) Desc() string { return "example: read the provider's Store value" } + +// Deps is metadata consumed by the planner; the actual read happens in Run. +func (consumerPlugin) Deps() []string { return []string{"provider"} } + +func (consumerPlugin) Start(ctx context.Context, rt *Runtime, args []string) error { + fmt.Fprintln(rt.Out, "[consumer] Start") + return nil +} + +func (consumerPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + seed, err := Require[int](rt.Store, "provider", "seed") + if err != nil { + return err + } + fmt.Fprintf(rt.Out, "[consumer] Run got provider.seed=%d\n", seed) + return nil +} + +func (consumerPlugin) End(ctx context.Context, rt *Runtime) error { + fmt.Fprintln(rt.Out, "[consumer] End") + return nil +} diff --git a/test/flag.go b/test/flag.go deleted file mode 100644 index 10509b0..0000000 --- a/test/flag.go +++ /dev/null @@ -1,546 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "math" - "math/rand/v2" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - agilepool "github.com/Yiming1997/agilePool/v2" -) - -// TaskType controls how simulated task durations are generated. -type TaskType int - -const ( - TaskFixed TaskType = iota // constant: BaseMs - TaskUniform // uniform: BaseMs + rand.Float64() * ExtraMs - TaskNormal // normal: max(0, NormFloat64()*SigmaMs + MeanMs) -) - -// TaskConfig holds parameters for task duration generation. -type TaskConfig struct { - Type TaskType - BaseMs int // fixed / uniform base (ms) - ExtraMs int // uniform random range (ms) - MeanMs int // normal mean (ms) - SigmaMs int // normal stddev (ms) -} - -// SubmitType controls how tasks are submitted to the pool. -type SubmitType int - -const ( - SubmitImmediate SubmitType = iota // submit all N at once - SubmitLinear // interval = IntervalMs + rand*JitterMs - SubmitConstant // fixed interval = IntervalMs - SubmitPoisson // poisson: -PoissonMeanMs * ln(rand) - SubmitPhased // multi-phase burst via token channels -) - -// Phase defines one stage of a multi-phase burst. -type Phase struct { - StartSec int // relative start time (seconds) - DurationSec int // duration (seconds) - RatePerSec float64 // submissions per second -} - -// SubmitConfig holds submission control parameters. -type SubmitConfig struct { - Type SubmitType - IntervalMs int // linear/constant interval (ms) - JitterMs int // linear jitter range (ms) - PoissonMeanMs int // poisson mean interval (ms) - Phases []Phase // multi-phase schedule - Shards int // parallel shards (phased) - Submitters int // submitters per shard (phased) -} - -// FlagArgs stores all parsed command-line arguments. -type FlagArgs struct { - // Pool config - WorkerCapacity int64 - CleanPeriod time.Duration - TaskQueueSize int64 - IdleContainerType agilepool.IdleContainerType - WorkMode agilepool.WorkMode - - // Task & submit - TaskCfg TaskConfig - SubmitCfg SubmitConfig - NumTasks int - - // Profiling - CPUProfile bool - MemProfile bool - - // Metric sampling - TakeTime float64 - LogFileName string - LogFormat string - - // Post-run wait - WaitExitTime int - - // Hook overhead test mode: none, hook, or trace. - HookMode string -} - -var taskTypeNames = map[string]TaskType{ - "fixed": TaskFixed, "uniform": TaskUniform, "normal": TaskNormal, -} - -var submitTypeNames = map[string]SubmitType{ - "immediate": SubmitImmediate, "linear": SubmitLinear, - "constant": SubmitConstant, "poisson": SubmitPoisson, "phased": SubmitPhased, -} - -func parseTaskType(s string) TaskType { - if t, ok := taskTypeNames[s]; ok { - return t - } - return TaskFixed -} - -func parseSubmitType(s string) SubmitType { - if t, ok := submitTypeNames[s]; ok { - return t - } - return SubmitImmediate -} - -// parsePhases parses a phase string "startSec,durSec,ratePerSec;...". -func parsePhases(s string) ([]Phase, error) { - if s == "" { - return nil, nil - } - segments := strings.Split(s, ";") - phases := make([]Phase, 0, len(segments)) - for i, seg := range segments { - seg = strings.TrimSpace(seg) - if seg == "" { - continue - } - parts := strings.Split(seg, ",") - if len(parts) != 3 { - return nil, fmt.Errorf("phase %d: expected 3 comma-separated values, got %d", i, len(parts)) - } - start, e1 := strconv.Atoi(strings.TrimSpace(parts[0])) - dur, e2 := strconv.Atoi(strings.TrimSpace(parts[1])) - rate, e3 := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64) - if e1 != nil || e2 != nil || e3 != nil { - return nil, fmt.Errorf("phase %d: invalid number", i) - } - phases = append(phases, Phase{StartSec: start, DurationSec: dur, RatePerSec: rate}) - } - return phases, nil -} - -func parseFlags() FlagArgs { - var ( - workers int64 - tasks int - cleanPeriod time.Duration - queueSize int64 - container, mode string - cpuProf, memProf bool - takeTime float64 - waitExit int - hookMode string - logFile, logFormat string - taskTypeStr string - taskBase, taskExtra, taskMean, taskSigma int - submitTypeStr string - submitInterval, submitJitter int - submitMeanIntvl, phasesStr string - submitShards, submitSubmitters int - ) - - flag.Int64Var(&workers, "workers", 20000, "max concurrent workers") - flag.Int64Var(&workers, "w", 20000, "max workers (short)") - flag.IntVar(&tasks, "tasks", 1000000, "total tasks to submit") - flag.IntVar(&tasks, "t", 1000000, "total tasks (short)") - flag.DurationVar(&cleanPeriod, "clean-period", 500*time.Millisecond, "idle worker cleanup interval") - flag.Int64Var(&queueSize, "queue-size", 10000, "task queue size hint") - flag.StringVar(&container, "container", "linkedlist", "idle container: linkedlist, minheap, slice, ringqueue") - flag.StringVar(&container, "c", "linkedlist", "idle container (short)") - flag.StringVar(&mode, "mode", "block", "work mode: block, nonblock") - flag.StringVar(&mode, "m", "block", "work mode (short)") - - flag.StringVar(&taskTypeStr, "task-type", "fixed", "task type: fixed, uniform, normal") - flag.StringVar(&taskTypeStr, "T", "fixed", "task type (short)") - flag.IntVar(&taskBase, "task-base", 10, "base task duration (ms)") - flag.IntVar(&taskExtra, "task-extra", 0, "random extra range (ms, uniform)") - flag.IntVar(&taskMean, "task-mean", 10, "normal mean (ms)") - flag.IntVar(&taskSigma, "task-sigma", 5, "normal stddev (ms)") - - flag.StringVar(&submitTypeStr, "submit-type", "immediate", "submit mode: immediate, linear, constant, poisson, phased") - flag.StringVar(&submitTypeStr, "U", "immediate", "submit mode (short)") - flag.IntVar(&submitInterval, "submit-interval", 10, "submit interval (ms)") - flag.IntVar(&submitJitter, "submit-jitter", 0, "random jitter (ms, linear)") - flag.StringVar(&submitMeanIntvl, "submit-mean-interval", "50", "poisson mean interval (ms)") - flag.StringVar(&phasesStr, "submit-phases", "", "phases: offset,dur,rate;... (phased)") - flag.StringVar(&phasesStr, "P", "", "phases (short)") - flag.IntVar(&submitShards, "submit-shards", 1, "parallel shards (phased)") - flag.IntVar(&submitSubmitters, "submit-submitters", 1, "submitters per shard (phased)") - - flag.BoolVar(&cpuProf, "cpuprofile", false, "enable CPU profiling") - flag.BoolVar(&memProf, "memprofile", false, "enable memory profiling") - flag.Float64Var(&takeTime, "take-time", 1, "metric sampling interval in seconds (0=disabled)") - flag.Float64Var(&takeTime, "i", 1, "sampling interval in seconds (short)") - flag.StringVar(&logFile, "log-file", "", "output file path (empty=auto)") - flag.StringVar(&logFile, "o", "", "output file (short)") - flag.StringVar(&logFormat, "log-format", "csv", "output format: csv, json") - flag.StringVar(&logFormat, "f", "csv", "output format (short)") - flag.IntVar(&waitExit, "wait-exit", 0, "extra seconds to wait before exit") - flag.IntVar(&waitExit, "e", 0, "extra wait (short)") - flag.StringVar(&hookMode, "hook-mode", "none", "hook overhead mode: none, hook, trace") - - flag.Parse() - - // Auto-generate log filename - if logFile == "" && takeTime > 0 { - logFile = fmt.Sprintf("metrics_%s_w%d_t%d_%s.%s", - strings.ToLower(taskTypeStr), workers, tasks, - strings.ToLower(strings.ReplaceAll(container, "_", "")), - strings.ToLower(logFormat)) - if dir := filepath.Dir(logFile); dir != "." { - os.MkdirAll(dir, 0755) - } - } - - // Map container type - var ctype agilepool.IdleContainerType - switch strings.ToLower(container) { - case "minheap": - ctype = agilepool.MinHeapType - case "slice": - ctype = agilepool.SliceType - case "ringqueue": - ctype = agilepool.RingQueueType - default: - ctype = agilepool.LinkedListType - } - - // Map work mode - var wm agilepool.WorkMode - if strings.ToLower(mode) == "nonblock" { - wm = agilepool.NONBLOCK - } else { - wm = agilepool.BLOCK - } - - // Parse phases - poissonMean, _ := strconv.Atoi(submitMeanIntvl) - phases, err := parsePhases(phasesStr) - if err != nil { - fmt.Fprintf(os.Stderr, "invalid --submit-phases: %v\n", err) - os.Exit(1) - } - - return FlagArgs{ - WorkerCapacity: workers, - NumTasks: tasks, - CleanPeriod: cleanPeriod, - TaskQueueSize: queueSize, - IdleContainerType: ctype, - WorkMode: wm, - TaskCfg: TaskConfig{ - Type: parseTaskType(strings.ToLower(taskTypeStr)), - BaseMs: taskBase, ExtraMs: taskExtra, MeanMs: taskMean, SigmaMs: taskSigma, - }, - SubmitCfg: SubmitConfig{ - Type: parseSubmitType(strings.ToLower(submitTypeStr)), - IntervalMs: submitInterval, - JitterMs: submitJitter, - PoissonMeanMs: poissonMean, - Phases: phases, - Shards: submitShards, - Submitters: submitSubmitters, - }, - CPUProfile: cpuProf, MemProfile: memProf, - TakeTime: takeTime, LogFileName: logFile, LogFormat: logFormat, - WaitExitTime: waitExit, - HookMode: normalizeHookMode(hookMode), - } -} - -func normalizeHookMode(mode string) string { - switch strings.ToLower(mode) { - case "hook", "trace": - return strings.ToLower(mode) - default: - return "none" - } -} - -func printConfig(args FlagArgs) { - fmt.Println("╔═══════════════════════════════════════╗") - fmt.Println("║ agilePool Test Harness ║") - fmt.Println("╚═══════════════════════════════════════╝") - fmt.Printf(" Workers: %d\n", args.WorkerCapacity) - fmt.Printf(" Tasks: %d\n", args.NumTasks) - fmt.Printf(" Container: %s\n", containerTypeName(args.IdleContainerType)) - fmt.Printf(" Mode: %s\n", workModeName(args.WorkMode)) - fmt.Printf(" Hook mode: %s\n", args.HookMode) - fmt.Printf(" Task type: %s", taskTypeName(args.TaskCfg.Type)) - switch args.TaskCfg.Type { - case TaskFixed: - fmt.Printf(" (%d ms)\n", args.TaskCfg.BaseMs) - case TaskUniform: - fmt.Printf(" (%d + rand×%d ms)\n", args.TaskCfg.BaseMs, args.TaskCfg.ExtraMs) - case TaskNormal: - fmt.Printf(" (N(%d,%d) ms)\n", args.TaskCfg.MeanMs, args.TaskCfg.SigmaMs) - } - fmt.Printf(" Submit type: %s\n", submitTypeName(args.SubmitCfg.Type)) - fmt.Printf(" Clean period: %s\n", args.CleanPeriod) - fmt.Printf(" Queue size: %d\n", args.TaskQueueSize) - if args.CPUProfile { - fmt.Println(" CPU profile: enabled") - } - if args.MemProfile { - fmt.Println(" Mem profile: enabled") - } - if args.TakeTime > 0 { - fmt.Printf(" Sampling: %.3fs → %s (%s)\n", args.TakeTime, args.LogFileName, args.LogFormat) - } - fmt.Println() -} - -func taskTypeName(t TaskType) string { - switch t { - case TaskFixed: - return "fixed" - case TaskUniform: - return "uniform" - case TaskNormal: - return "normal" - } - return "fixed" -} - -func submitTypeName(s SubmitType) string { - switch s { - case SubmitImmediate: - return "immediate" - case SubmitLinear: - return "linear" - case SubmitConstant: - return "constant" - case SubmitPoisson: - return "poisson" - case SubmitPhased: - return "phased" - } - return "immediate" -} - -func containerTypeName(t agilepool.IdleContainerType) string { - switch t { - case agilepool.MinHeapType: - return "MinHeap" - case agilepool.SliceType: - return "Slice" - case agilepool.RingQueueType: - return "RingQueue" - } - return "LinkedList" -} - -func workModeName(m agilepool.WorkMode) string { - if m == agilepool.NONBLOCK { - return "NONBLOCK" - } - return "BLOCK" -} - -// buildDurationFn returns a closure that produces random task durations. -// Safe for concurrent use. -func buildDurationFn(cfg TaskConfig) func() time.Duration { - switch cfg.Type { - case TaskFixed: - d := time.Duration(cfg.BaseMs) * time.Millisecond - return func() time.Duration { return d } - - case TaskUniform: - base := float64(cfg.BaseMs) - extra := float64(cfg.ExtraMs) - return func() time.Duration { - return time.Duration(base+rand.Float64()*extra) * time.Millisecond - } - - case TaskNormal: - mean := float64(cfg.MeanMs) - sigma := float64(cfg.SigmaMs) - return func() time.Duration { - v := math.Max(0, rand.NormFloat64()*sigma+mean) - return time.Duration(v) * time.Millisecond - } - } - d := time.Duration(cfg.BaseMs) * time.Millisecond - return func() time.Duration { return d } -} - -// newTask wraps durFn into an agilepool.Task. -func newTask(durFn func() time.Duration) agilepool.Task { - return agilepool.TaskFunc(func() error { - time.Sleep(durFn()) - return nil - }) -} - -// newTaskWithWG wraps durFn into a task that calls wg.Done on completion. -func newTaskWithWG(durFn func() time.Duration, wg *sync.WaitGroup) agilepool.Task { - return agilepool.TaskFunc(func() error { - defer wg.Done() - time.Sleep(durFn()) - return nil - }) -} - -// runSubmitter dispatches task submission according to SubmitConfig. -// Blocks until all tasks are submitted and processed. -func runSubmitter(pool *agilepool.Pool, cfg SubmitConfig, numTasks int, durFn func() time.Duration, hookMode string) { - switch cfg.Type { - case SubmitImmediate: - runImmediate(pool, numTasks, durFn, hookMode) - case SubmitLinear: - runTimedSubmit(pool, numTasks, durFn, time.Duration(cfg.IntervalMs)*time.Millisecond, time.Duration(cfg.JitterMs)*time.Millisecond, false, hookMode) - case SubmitConstant: - runTimedSubmit(pool, numTasks, durFn, time.Duration(cfg.IntervalMs)*time.Millisecond, 0, true, hookMode) - case SubmitPoisson: - runTimedSubmit(pool, numTasks, durFn, time.Duration(cfg.PoissonMeanMs)*time.Millisecond, 0, false, hookMode) - case SubmitPhased: - runPhased(pool, cfg, durFn, hookMode) - } -} - -func runImmediate(pool *agilepool.Pool, n int, durFn func() time.Duration, hookMode string) { - for i := 0; i < n; i++ { - submitTask(pool, durFn, hookMode) - } - pool.Wait() -} - -func runTimedSubmit(pool *agilepool.Pool, n int, durFn func() time.Duration, baseInterval, jitter time.Duration, isConstant bool, hookMode string) { - var wg sync.WaitGroup - wg.Add(n) - - go func() { - baseNs := float64(baseInterval) - jitterNs := float64(jitter) - for i := 0; i < n; i++ { - if !submitTaskWithWG(pool, durFn, &wg, hookMode) { - wg.Done() - } - - var delay time.Duration - switch { - case isConstant: - delay = baseInterval - case baseInterval == 0: - delay = 0 - case jitter == 0: - delay = time.Duration(-baseNs * math.Log(rand.Float64())) // poisson - default: - delay = time.Duration(baseNs + rand.Float64()*jitterNs) // linear - } - if delay > 0 { - time.Sleep(delay) - } - } - }() - - wg.Wait() - pool.Wait() -} - -// runPhased executes a multi-phase burst submission schedule. -// Each shard has its own token channel, a dispenser goroutine, and -// multiple submitters. Tokens control the submission rate; the pool's -// worker capacity caps actual concurrency. -func runPhased(pool *agilepool.Pool, cfg SubmitConfig, durFn func() time.Duration, hookMode string) { - if len(cfg.Phases) == 0 { - return - } - - shards := cfg.Shards - if shards < 1 { - shards = 1 - } - - submittersPerShard := cfg.Submitters - if submittersPerShard < 1 { - submittersPerShard = 1 - } - - var totalSubmitted int64 - var submitWG sync.WaitGroup - - for s := 0; s < shards; s++ { - tokenCh := make(chan struct{}, 10000) - go dispenseTokens(tokenCh, cfg.Phases, shards, s) - submitPhasedTasks(pool, tokenCh, submittersPerShard, &submitWG, &totalSubmitted, durFn, hookMode) - } - - submitWG.Wait() - pool.Wait() -} - -// dispenseTokens produces tokens using a float accumulator for precise -// sub-millisecond rate control. Even 0.5 tokens/s per shard will fire -// 1 token every 2 seconds. Drops tokens when the channel is full. -// -// Phase.StartSec is respected: if there is a gap between phases, -// the dispenser sleeps until the next phase's start time. -func dispenseTokens(tokenCh chan<- struct{}, phases []Phase, shards, shardID int) { - defer close(tokenCh) - - var elapsedMs int - for _, p := range phases { - // Wait until this phase's start time - startMs := p.StartSec * 1000 - if startMs > elapsedMs { - time.Sleep(time.Duration(startMs-elapsedMs) * time.Millisecond) - elapsedMs = startMs - } - - ratePerShard := p.RatePerSec / float64(shards) - var acc float64 - totalMs := p.DurationSec * 1000 - for ms := 0; ms < totalMs; ms++ { - acc += ratePerShard / 1000 - for acc >= 1 { - select { - case tokenCh <- struct{}{}: - default: - } - acc-- - } - time.Sleep(time.Millisecond) - } - elapsedMs += totalMs - } -} - -// submitPhasedTasks launches submitters that read tokens and submit real -// tasks (with durFn) to the pool. Stops when the token channel is closed. -func submitPhasedTasks(pool *agilepool.Pool, tokenCh <-chan struct{}, submitters int, wg *sync.WaitGroup, total *int64, durFn func() time.Duration, hookMode string) { - for g := 0; g < submitters; g++ { - wg.Add(1) - go func() { - defer wg.Done() - for range tokenCh { - atomic.AddInt64(total, 1) - submitTask(pool, durFn, hookMode) - } - }() - } -} diff --git a/test/go.mod b/test/go.mod deleted file mode 100644 index 76f5a71..0000000 --- a/test/go.mod +++ /dev/null @@ -1,21 +0,0 @@ -module agilepool-test - -go 1.23.4 - -require ( - github.com/Yiming1997/agilePool/v2 v2.0.0 - github.com/shirou/gopsutil/v3 v3.24.5 -) - -require ( - github.com/go-ole/go-ole v1.2.6 // indirect - github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect - github.com/tklauser/go-sysconf v0.3.12 // indirect - github.com/tklauser/numcpus v0.6.1 // indirect - github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/sys v0.20.0 // indirect -) - -replace github.com/Yiming1997/agilePool/v2 => ../ diff --git a/test/go.sum b/test/go.sum deleted file mode 100644 index ed902db..0000000 --- a/test/go.sum +++ /dev/null @@ -1,36 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= -github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= -github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= -github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/hook.go b/test/hook.go new file mode 100644 index 0000000..e7243ec --- /dev/null +++ b/test/hook.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/Yiming1997/agilePool/v2/internal/hook" +) + +// hookStats holds the hook mode and the four lifecycle counters. It is the +// plugin's own state; only the handle is shared via Store for metrics to +// read optionally. Per the Store layering rule the counters themselves are +// never written through the shared table. +type hookStats struct { + Mode string + Submitted atomic.Int64 + Enqueued atomic.Int64 + Started atomic.Int64 + Completed atomic.Int64 +} + +// hook instruments the pool (old setupHooks). Modes: +// +// - none: no callbacks registered (raw pool baseline) +// - hook: one atomic counter per lifecycle event, measuring callback +// dispatch overhead +// - trace: the old trace mode depended on context-tracing public APIs +// (OnTaskSubmitted/TimingHook/NewContext) that were removed upstream; +// internal/context is not wired up yet, so trace is explicitly rejected +// +// Counters use atomics because all four events sit on the per-task hot path; +// any heavier synchronization would pollute the overhead being measured. +type hookPlugin struct{} + +func (hookPlugin) Name() string { return "hook" } +func (hookPlugin) Desc() string { return "event instrumentation (none/hook; trace pending upstream)" } + +func (hookPlugin) Deps() []string { return []string{"pool"} } + +func (hookPlugin) Options() []Option { + return []Option{ + {Name: "mode", Default: "none", Help: "instrumentation mode: none/hook (trace pending)"}, + } +} + +func (hookPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + mode := strings.ToLower(GetString(ParseOptions(args), "mode", "none")) + switch mode { + case "none", "hook": + case "trace": + return fmt.Errorf("mode=trace is not supported yet: depends on upstream context tracing wiring (internal/context)") + default: + return fmt.Errorf("option %q: unsupported value %q", "mode", mode) + } + + stats := &hookStats{Mode: mode} + if mode == "hook" { + p, err := Require[*agilepool.Pool](rt.Store, "pool", "pool") + if err != nil { + return err + } + h := hook.NewHooks() + h.AddTaskSubmitted(func(context.Context, agilepool.Task) { stats.Submitted.Add(1) }) + h.AddTaskEnqueued(func(context.Context, agilepool.Task) { stats.Enqueued.Add(1) }) + h.AddTaskStarted(func(context.Context, agilepool.Task) { stats.Started.Add(1) }) + h.AddTaskCompleted(func(context.Context, agilepool.Task, any) { stats.Completed.Add(1) }) + if err := p.SetHook(h); err != nil { + return err + } + } + // Provide the handle in every mode so metrics does not need to know + // whether hook ran: it reads with Get and treats absence as none/zero. + Provide(rt.Store, "hook", "stats", stats) + return nil +} diff --git a/test/hooks.go b/test/hooks.go deleted file mode 100644 index cfc4144..0000000 --- a/test/hooks.go +++ /dev/null @@ -1,83 +0,0 @@ -package main - -import ( - "context" - "sync" - "sync/atomic" - "time" - - agilepool "github.com/Yiming1997/agilePool/v2" -) - -var ( - hookSubmitted atomic.Int64 - hookEnqueued atomic.Int64 - hookStarted atomic.Int64 - hookCompleted atomic.Int64 -) - -// setupHooks installs the selected instrumentation. none is the raw pool -// baseline; hook measures callback dispatch; trace also records timing and -// obtains a TraceID at every lifecycle point. -func setupHooks(pool *agilepool.Pool, mode string) { - hookSubmitted.Store(0) - hookEnqueued.Store(0) - hookStarted.Store(0) - hookCompleted.Store(0) - if mode == "none" { - return - } - pool.OnTaskSubmitted(func(context.Context, agilepool.Task) { hookSubmitted.Add(1) }) - pool.OnTaskEnqueued(func(context.Context, agilepool.Task) { hookEnqueued.Add(1) }) - pool.OnTaskStarted(func(context.Context, agilepool.Task) { hookStarted.Add(1) }) - pool.OnTaskCompleted(func(context.Context, agilepool.Task, any) { hookCompleted.Add(1) }) - if mode != "trace" { - return - } - submitted, enqueued, started, completed := agilepool.TimingHook() - pool.OnTaskSubmitted(withTraceID(submitted)) - pool.OnTaskEnqueued(withTraceID(enqueued)) - pool.OnTaskStarted(withTraceID(started)) - pool.OnTaskCompleted(func(ctx context.Context, task agilepool.Task, recovered any) { - if pc := agilepool.PoolContextFrom(ctx); pc != nil { - _, _ = pc.TraceID() - } - completed(ctx, task, recovered) - if pc := agilepool.PoolContextFrom(ctx); pc != nil { - pc.Timing() - pc.Release() - } - }) -} - -func withTraceID(next agilepool.TaskHook) agilepool.TaskHook { - return func(ctx context.Context, task agilepool.Task) { - if pc := agilepool.PoolContextFrom(ctx); pc != nil { - _, _ = pc.TraceID() - } - next(ctx, task) - } -} - -func submitTask(pool *agilepool.Pool, durFn func() time.Duration, mode string) { - if mode == "trace" { - ctx := agilepool.NewContext(context.Background()) - ctx.EnableTiming() - pool.Submit(agilepool.UpdateTask(ctx, newTask(durFn))) - return - } - pool.Submit(newTask(durFn)) -} - -func submitTaskWithWG(pool *agilepool.Pool, durFn func() time.Duration, wg *sync.WaitGroup, mode string) bool { - if mode == "trace" { - ctx := agilepool.NewContext(context.Background()) - ctx.EnableTiming() - accepted := pool.TrySubmit(agilepool.UpdateTask(ctx, newTaskWithWG(durFn, wg))) - if !accepted { - ctx.Release() - } - return accepted - } - return pool.TrySubmit(newTaskWithWG(durFn, wg)) -} diff --git a/test/main.go b/test/main.go index 2a9a443..fa3d04c 100644 --- a/test/main.go +++ b/test/main.go @@ -1,114 +1,144 @@ package main import ( + "context" "fmt" - "log" + "io" "os" - "runtime" - "runtime/pprof" - "time" - - agilepool "github.com/Yiming1997/agilePool/v2" + "os/signal" + "syscall" ) func main() { - args := parseFlags() - printConfig(args) + // main only translates run's return value into the process exit code. + // run keeps the whole flow on return values so main stays a thin shell. + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} - var logFileOut *os.File - if args.TakeTime > 0 { - var err error - logFileOut, err = os.OpenFile(args.LogFileName, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to create log file %s: %v\n", args.LogFileName, err) - os.Exit(1) - } - defer logFileOut.Close() - log.SetOutput(logFileOut) +// run is the host entry point and returns the process exit code: +// 0 = success, 1 = runtime error (a plugin Start/Run/End failed), +// 2 = usage error (bad command line). The split lets scripts distinguish +// "I mistyped the command" from "the run actually failed". +func run(argv []string, out, errOut io.Writer) int { + // The plugin set is fixed at compile time (plugins.go), so a registry + // error is a development error: fail immediately. + reg, err := NewRegistry(allPlugins) + if err != nil { + fmt.Fprintf(errOut, "%v\n", err) + return 1 } - // CPU profiling - if args.CPUProfile { - f, err := os.Create("cpu_profile.prof") - if err != nil { - fmt.Fprintf(os.Stderr, "failed to create CPU profile: %v", err) - os.Exit(1) - } - if err := pprof.StartCPUProfile(f); err != nil { - f.Close() - fmt.Fprintf(os.Stderr, "failed to start CPU profile: %v", err) - os.Exit(1) - } - defer func() { - pprof.StopCPUProfile() - f.Close() - fmt.Println(" CPU profile written to cpu_profile.prof") - }() - } - - // Memory profiling - if args.MemProfile { - defer func() { - runtime.GC() - f, err := os.Create("mem_profile.prof") - if err != nil { - fmt.Fprintf(os.Stderr, "failed to create mem profile: %v", err) - os.Exit(1) + // --list and --help are host built-ins, not plugins, so they bypass + // ParseSegments. They are only recognized as the first token; anywhere + // else they hit the "unknown segment" path instead of hijacking the run. + if len(argv) > 0 { + switch argv[0] { + case "--list": + if len(argv) > 1 { + fmt.Fprintln(errOut, "usage: --list does not accept arguments") + return 2 } - defer f.Close() - if err := pprof.WriteHeapProfile(f); err != nil { - fmt.Fprintf(os.Stderr, "failed to write heap profile: %v", err) - os.Exit(1) + printList(out, reg) + return 0 + case "--help": + switch len(argv) { + case 1: + printUsage(out, reg) + case 2: + if err := printPluginHelp(out, reg, argv[1]); err != nil { + fmt.Fprintf(errOut, "%v\n", err) + return 2 + } + default: + fmt.Fprintln(errOut, "usage: --help [name]") + return 2 } - fmt.Println(" Mem profile written to mem_profile.prof") - }() + return 0 + } } - start := time.Now() - - pool := newPool(args) - defer pool.Close() - setupHooks(pool, args.HookMode) + if len(argv) == 0 { + fmt.Fprintln(errOut, "nothing to run; see --help") + return 2 + } - // Start metrics collector - if args.TakeTime > 0 && logFileOut != nil { - go Couter(args, pool, logFileOut, start) + // Segment splitting and dependency planning are pure computation with + // no side effects; doing them first guarantees no plugin starts when + // the command line is invalid. + segs, err := ParseSegments(argv, reg) + if err != nil { + fmt.Fprintf(errOut, "%v\n", err) + return 2 + } + plan, err := Plan(segs, reg) + if err != nil { + fmt.Fprintf(errOut, "%v\n", err) + return 2 } - // Submit tasks according to task-type + submit-type - durFn := buildDurationFn(args.TaskCfg) - runSubmitter(pool, args.SubmitCfg, args.NumTasks, durFn, args.HookMode) + // The signal context only cancels ctx; cleanup is handled by the End + // phase of execSession. defer stop() releases the NotifyContext handler. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + // A fresh Runtime/Store per session: no mutable state is shared + // between successive runs. + rt := &Runtime{Out: out, Err: errOut, Store: NewStore()} + return execSession(ctx, rt, plan, reg) +} - // Three-stage shutdown - // Wait for all workers to finish - for pool.GetRunningWorkersNum() > 0 { - time.Sleep(10 * time.Millisecond) +// execSession runs a planned session: Start (in order), then Run (in order), +// then End (in reverse). Any failure aborts the remaining steps with exit +// code 1, but already-started Lifecycle plugins still get their End called. +// +// End must still run after a failure: Start may have created resources +// (sampler goroutines, pprof, the pool) that would leak otherwise. A plugin +// whose Start itself failed is not in started and owes no End. +// +// On error we stop instead of running the rest: the error usually means a +// precondition is gone (e.g. pool creation failed, so submit could not read +// it from the Store), and continuing would only stack meaningless errors on +// top of the first one. +func execSession(ctx context.Context, rt *Runtime, plan []Segment, reg *Registry) int { + code := 0 + var started []string + for _, s := range plan { + p, _ := reg.Lookup(s.Name) + lc, ok := p.(Lifecycle) + if !ok { + continue + } + if err := lc.Start(ctx, rt, s.Args); err != nil { + fmt.Fprintf(rt.Err, "[%s] %v\n", s.Name, err) + code = 1 + break + } + started = append(started, s.Name) } - // Wait one tick for final metrics collection - if args.TakeTime > 0 { - fmt.Printf(" Waiting %.3fs for final metrics collection...\n", args.TakeTime) - time.Sleep(time.Duration(args.TakeTime * float64(time.Second))) + // Skip all Runs when a Start failed: the session never opened properly, + // so Runs would see an incomplete Store. + if code == 0 { + for _, s := range plan { + p, _ := reg.Lookup(s.Name) + if err := p.Run(ctx, rt, s.Args); err != nil { + fmt.Fprintf(rt.Err, "[%s] %v\n", s.Name, err) + code = 1 + break + } + } } - // Extra observation window - if args.WaitExitTime > 0 { - fmt.Printf(" Waiting %ds to observe memory changes...\n", args.WaitExitTime) - time.Sleep(time.Duration(args.WaitExitTime) * time.Second) + // Tear down in reverse Start order so resources close in the opposite + // order of their creation. End errors are reported but only promoted to + // the exit code when nothing failed before, keeping the first error visible. + for i := len(started) - 1; i >= 0; i-- { + p, _ := reg.Lookup(started[i]) + if err := p.(Lifecycle).End(ctx, rt); err != nil { + fmt.Fprintf(rt.Err, "[%s] %v\n", started[i], err) + if code == 0 { + code = 1 + } + } } - - elapsed := time.Since(start) - fmt.Printf(" Elapsed: %s\n", elapsed) - fmt.Println(" Done.") -} - -// newPool creates a pool from args. -func newPool(args FlagArgs) *agilepool.Pool { - return agilepool.NewPool(agilepool.NewConfig( - agilepool.WithCleanPeriod(args.CleanPeriod), - agilepool.WithTaskQueueSize(args.TaskQueueSize), - agilepool.WithWorkerNumCapacity(args.WorkerCapacity), - agilepool.WithIdleContainerType(args.IdleContainerType), - agilepool.WithBlockMode(args.WorkMode), - )) + return code } diff --git a/test/metrics.go b/test/metrics.go new file mode 100644 index 0000000..77fc5f3 --- /dev/null +++ b/test/metrics.go @@ -0,0 +1,239 @@ +package main + +import ( + "context" + "fmt" + "os" + "runtime" + "sync" + "time" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/shirou/gopsutil/v3/cpu" +) + +// metrics replaces the old Couter plus the three-phase shutdown pieces that +// were main-level (final sample tick, wait-exit observation window, and the +// Elapsed/Done summary). Phase split: +// +// - Start: parse options, open the file, launch the sampler goroutine. +// It must happen before submit runs so the whole submission period is +// covered — hence args on Start (see Lifecycle). +// - Run: nothing to do. +// - End: sleep one interval so the sampler records the drained state, run +// the wait-exit window (the sampler keeps ticking, capturing idle memory +// changes), then stop the sampler, close the file and print the summary. +// +// The sampler reads the pool handle from Store on every tick instead of +// requiring it up front: the pool is provided during pool.Start so it is +// normally in place before the first tick, but reading lazily avoids a hard +// ordering dependency (e.g. --pool written after --metrics). +type metricsPlugin struct { + interval float64 // sampling interval (seconds) + format string + file string + waitExit int + + f *os.File + stop chan struct{} + wg sync.WaitGroup + start time.Time + rt *Runtime + store *Store +} + +func (m *metricsPlugin) Name() string { return "metrics" } +func (m *metricsPlugin) Desc() string { + return "sample runtime/pool/GC metrics periodically, summary on End" +} + +func (m *metricsPlugin) Deps() []string { return []string{"pool"} } + +func (m *metricsPlugin) Options() []Option { + return []Option{ + {Name: "interval", Default: "1", Help: "sampling interval (seconds); omit the segment to disable"}, + {Name: "format", Default: "csv", Help: "output format: csv/json"}, + {Name: "file", Default: "metrics.csv", Help: "output file path"}, + {Name: "wait-exit", Default: "0", Help: "extra observation seconds after draining (idle memory)"}, + } +} + +func (m *metricsPlugin) Start(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + + interval, err := GetFloat(opts, "interval", 1) + if err != nil { + return err + } + if interval <= 0 { + return fmt.Errorf("option %q: must be > 0 (omit --metrics to disable sampling)", "interval") + } + format := GetString(opts, "format", "csv") + if format != "csv" && format != "json" { + return fmt.Errorf("option %q: unsupported value %q", "format", format) + } + file := GetString(opts, "file", "metrics.csv") + waitExit, err := GetInt(opts, "wait-exit", 0) + if err != nil { + return err + } + + f, err := os.OpenFile(file, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("create %s: %w", file, err) + } + + m.interval = interval + m.format = format + m.file = file + m.waitExit = waitExit + m.f = f + m.stop = make(chan struct{}) + m.start = time.Now() + m.rt = rt + m.store = rt.Store + + m.wg.Add(1) + go m.sampleLoop() + return nil +} + +func (m *metricsPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + return nil +} + +func (m *metricsPlugin) End(ctx context.Context, rt *Runtime) error { + defer m.f.Close() + // Final sample tick: tasks are drained and workers are still exiting; + // sleeping one interval lets the sampler record the end state. With + // wait-exit set, the sampler keeps running through the observation + // window so memory changes stay visible, matching the old tool. + if m.waitExit > 0 { + fmt.Fprintf(rt.Out, " Sampling for %.3fs...\n", m.interval) + time.Sleep(time.Duration(m.interval * float64(time.Second))) + fmt.Fprintf(rt.Out, " Waiting %ds to observe memory changes...\n", m.waitExit) + time.Sleep(time.Duration(m.waitExit) * time.Second) + } else { + fmt.Fprintf(rt.Out, " Waiting %.3fs for final metrics collection...\n", m.interval) + time.Sleep(time.Duration(m.interval * float64(time.Second))) + } + + close(m.stop) + m.wg.Wait() + + elapsed := time.Since(m.start) + fmt.Fprintf(rt.Out, " Elapsed: %s\n", elapsed) + fmt.Fprintln(rt.Out, " Done.") + return nil +} + +// sampleLoop ticks every interval and writes a runtime/pool/GC/CPU/hook row. +// The CSV header is written on the first tick (same behavior as the old +// Couter) and the field set matches the old tool column for column. +func (m *metricsPlugin) sampleLoop() { + defer m.wg.Done() + tick := time.NewTicker(time.Duration(m.interval * float64(time.Second))) + defer tick.Stop() + + csvHeader := "run_sec,goroutines,heap_alloc_mb,total_alloc_mb,sys_mb," + + "gc_total,gc_pause_total_ms,gc_pause_avg_ms,gc_cpu_pct," + + "last_gc_sec,next_gc_mb," + + "workers_running,workers_idle,workers_created,task_queue_len," + + "cpu_pct,hook_mode,hook_submitted,hook_enqueued,hook_started,hook_completed\n" + headerWritten := false + + for { + select { + case <-tick.C: + if m.format == "csv" && !headerWritten { + fmt.Fprint(m.f, csvHeader) + headerWritten = true + } + m.sample() + case <-m.stop: + return + } + } +} + +// sample reads one row of state. Pool and hook data are optional reads: if +// the pool is not up yet (or the hook segment did not run), zeroes are used. +func (m *metricsPlugin) sample() { + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + + cpuPct, _ := cpu.Percent(100*time.Millisecond, false) + var cpuUsage float64 + if len(cpuPct) > 0 { + cpuUsage = cpuPct[0] + } + + var running, idle, created int64 + var queueLen int + if p, ok := Get[*agilepool.Pool](m.store, "pool", "pool"); ok { + running = p.GetRunningWorkersNum() + idle = p.GetIdleWorkerCount() + created = p.GetWorkerCreateCount() + queueLen = p.GetTaskQueueLen() + } + hookMode := "none" + var hookSubmitted, hookEnqueued, hookStarted, hookCompleted int64 + if stats, ok := Get[*hookStats](m.store, "hook", "stats"); ok { + hookMode = stats.Mode + hookSubmitted = stats.Submitted.Load() + hookEnqueued = stats.Enqueued.Load() + hookStarted = stats.Started.Load() + hookCompleted = stats.Completed.Load() + } + + runSec := time.Since(m.start).Seconds() + + var lastGCRel float64 + if memStats.NumGC > 0 { + lastGCRel = float64(memStats.LastGC-uint64(m.start.UnixNano())) / 1e9 + } + totalPauseMs := float64(memStats.PauseTotalNs) / 1e6 + var avgPauseMs float64 + if memStats.NumGC > 0 { + avgPauseMs = totalPauseMs / float64(memStats.NumGC) + } + + switch m.format { + case "csv": + fmt.Fprintf(m.f, "%.3f,%d,%.2f,%.2f,%.2f,%d,%.2f,%.2f,%.4f,%.3f,%.2f,%d,%d,%d,%d,%.2f,%s,%d,%d,%d,%d\n", + runSec, + runtime.NumGoroutine(), + float64(memStats.Alloc)/1024/1024, + float64(memStats.TotalAlloc)/1024/1024, + float64(memStats.Sys)/1024/1024, + memStats.NumGC, + totalPauseMs, + avgPauseMs, + memStats.GCCPUFraction*100, + lastGCRel, + float64(memStats.NextGC)/1024/1024, + running, idle, created, queueLen, cpuUsage, + hookMode, hookSubmitted, hookEnqueued, hookStarted, hookCompleted) + case "json": + fmt.Fprintf(m.f, `{"run_sec":%.3f,"goroutines":%d,"heap_alloc_mb":%.2f,`+ + `"total_alloc_mb":%.2f,"sys_mb":%.2f,"gc_total":%d,`+ + `"gc_pause_total_ms":%.2f,"gc_pause_avg_ms":%.2f,`+ + `"gc_cpu_pct":%.4f,"last_gc_sec":%.3f,"next_gc_mb":%.2f,`+ + `"workers_running":%d,"workers_idle":%d,`+ + `"workers_created":%d,"task_queue_len":%d,`+ + `"cpu_pct":%.2f,"hook_mode":"%s","hook_submitted":%d,"hook_enqueued":%d,"hook_started":%d,"hook_completed":%d}`+"\n", + runSec, + runtime.NumGoroutine(), + float64(memStats.Alloc)/1024/1024, + float64(memStats.TotalAlloc)/1024/1024, + float64(memStats.Sys)/1024/1024, + memStats.NumGC, + totalPauseMs, + avgPauseMs, + memStats.GCCPUFraction*100, + lastGCRel, + float64(memStats.NextGC)/1024/1024, + running, idle, created, queueLen, + cpuUsage, hookMode, hookSubmitted, hookEnqueued, hookStarted, hookCompleted) + } +} diff --git a/test/option.go b/test/option.go new file mode 100644 index 0000000..a61dafa --- /dev/null +++ b/test/option.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// These helpers form the shared syntax layer for segment arguments. +// The framework only splits key=value tokens and converts scalar types; it +// does not validate keys because only the plugin that implements an option +// knows its meaning. Accordingly every getter returns the default when the +// key is absent and errors only when a present value is malformed. + +// ParseOptions converts key=value arguments into a map. Tokens without "=" +// are positional words and are kept out of the map (see Positionals). +// A repeated key keeps the last value: segment options are order-free and +// duplicates usually come from script concatenation. +func ParseOptions(args []string) map[string]string { + opts := make(map[string]string, len(args)) + for _, arg := range args { + if k, v, ok := strings.Cut(arg, "="); ok { + opts[k] = v + } + } + return opts +} + +// Positionals returns the bare tokens (no "=") of a segment, in command-line +// order, for plugins that support positional arguments. +func Positionals(args []string) []string { + var pos []string + for _, arg := range args { + if _, _, ok := strings.Cut(arg, "="); !ok { + pos = append(pos, arg) + } + } + return pos +} + +// GetString returns the option value or def when the key is absent. +func GetString(opts map[string]string, key, def string) string { + if v, ok := opts[key]; ok { + return v + } + return def +} + +// GetInt parses the option as int, falling back to def when absent. +func GetInt(opts map[string]string, key string, def int) (int, error) { + v, ok := opts[key] + if !ok { + return def, nil + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("option %q: invalid int %q", key, v) + } + return n, nil +} + +// GetFloat parses the option as float64 (e.g. --metrics interval=0.5), +// falling back to def when absent. +func GetFloat(opts map[string]string, key string, def float64) (float64, error) { + v, ok := opts[key] + if !ok { + return def, nil + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return 0, fmt.Errorf("option %q: invalid float %q", key, v) + } + return f, nil +} + +// GetDuration parses the option as a time.Duration, falling back to def. +func GetDuration(opts map[string]string, key string, def time.Duration) (time.Duration, error) { + v, ok := opts[key] + if !ok { + return def, nil + } + d, err := time.ParseDuration(v) + if err != nil { + return 0, fmt.Errorf("option %q: invalid duration %q", key, v) + } + return d, nil +} + +// GetBool parses the option as bool, accepting true/1/on/yes and +// false/0/off/no, and falls back to def when absent. +func GetBool(opts map[string]string, key string, def bool) (bool, error) { + v, ok := opts[key] + if !ok { + return def, nil + } + switch strings.ToLower(v) { + case "true", "1", "on", "yes": + return true, nil + case "false", "0", "off", "no": + return false, nil + } + return false, fmt.Errorf("option %q: invalid bool %q", key, v) +} diff --git a/test/plan.go b/test/plan.go new file mode 100644 index 0000000..aa0cf4a --- /dev/null +++ b/test/plan.go @@ -0,0 +1,89 @@ +package main + +import "fmt" + +// Segment is one split slice: Name is the plugin name, Args are the raw +// segment arguments (still unparsed; parsing happens inside the plugin's +// Run, since some plugins want positional words or custom syntax). +type Segment struct { + Name string + Args []string +} + +// Plan expands the requested segments into the final execution order. +// +// Two deliberate asymmetries: +// +// 1. A missing dependency is auto-inserted (with empty args, i.e. defaults) +// before its dependent, so "--submit ..." alone runs pool+task. But an +// explicitly written dependency placed after its dependent is an error, +// never silently reordered: the user's segment order is intent (log +// ordering, profile range), and breaking it is worse than an error. +// 2. Planning is a DFS over the user order inserting deps in front, not a +// global topological sort, which would reorder everything the user wrote. +// +// placed tracks settled segments, visiting the recursion stack for cycle +// detection (a dependency cycle has no legal order whether the segments +// were explicit or auto-inserted). Duplicate segment names are rejected up +// front: one plugin has a single Store namespace and a single lifecycle, so +// duplicates have no defined semantics. +func Plan(segs []Segment, reg *Registry) ([]Segment, error) { + requested := make(map[string]bool, len(segs)) + argsOf := make(map[string][]string, len(segs)) + for _, s := range segs { + if requested[s.Name] { + return nil, fmt.Errorf("plan: duplicate segment --%s", s.Name) + } + requested[s.Name] = true + // Remember each segment's own args; auto-inserted segments are not + // in the map and get the zero value (empty args = defaults). + argsOf[s.Name] = s.Args + } + + placed := make(map[string]bool, len(segs)) + visiting := make(map[string]bool) + var order []Segment + var visit func(name string) error + visit = func(name string) error { + if placed[name] { + return nil + } + if visiting[name] { + return fmt.Errorf("plan: dependency cycle detected at --%s", name) + } + visiting[name] = true + + p, ok := reg.Lookup(name) + if !ok { + return fmt.Errorf("plan: unregistered plugin %q", name) + } + if d, ok := p.(Depender); ok { + for _, dep := range d.Deps() { + if placed[dep] { + continue + } + // Requested but not yet placed: the user wrote it after this + // segment — a misorder, not a miss, so report it. + if requested[dep] { + return fmt.Errorf("plan: dependency --%s of --%s must be ordered before it", dep, name) + } + // Not requested: auto-insert the dependency (recursing so + // its own deps are inserted first). + if err := visit(dep); err != nil { + return err + } + } + } + + visiting[name] = false + order = append(order, Segment{Name: name, Args: argsOf[name]}) + placed[name] = true + return nil + } + for _, s := range segs { + if err := visit(s.Name); err != nil { + return nil, err + } + } + return order, nil +} diff --git a/test/plot_hook_comparison.py b/test/plot_hook_comparison.py deleted file mode 100644 index 84c9c17..0000000 --- a/test/plot_hook_comparison.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Generate comparison charts for the strict Hook benchmark runs.""" - -import csv -import re -from pathlib import Path - -ROOT = Path(__file__).resolve().parent -RESULTS = ROOT / "results-strict" -BASELINE = ROOT / "main基准" -OUTPUT = ROOT / "hook-comparison" - - -def read_samples(path): - rows = [] - with path.open(encoding="utf-8-sig") as stream: - for row in csv.DictReader(stream): - if not row.get("run_sec"): - continue - rows.append({key: float(value) for key, value in row.items() - if key not in {"hook_mode"}}) - return rows - - -def max_value(rows, key): - return max((row[key] for row in rows), default=0.0) - - -def last_value(rows, key): - return rows[-1][key] if rows else 0.0 - - -def parse_elapsed(log_path): - current_mode = None - current_name = None - values = {} - pattern = re.compile(r"^Elapsed:\s+(?:(\d+)m\s*)?([0-9]+(?:\.[0-9]+)?)s$") - with log_path.open(encoding="utf-8", errors="replace") as stream: - for line in stream: - marker = re.match(r"^\[(none|hook|trace)\] (\S+)", line) - if marker: - current_mode, current_name = marker.groups() - continue - elapsed = pattern.match(line.strip()) - if elapsed and current_mode and current_name: - minutes = float(elapsed.group(1) or 0) - values[(current_mode, current_name)] = minutes * 60 + float(elapsed.group(2)) - return values - - -def baseline_file(name): - match = { - "fixed_100w_20k": "metrics_fixed_w100_t20000_linkedlist.csv", - "fixed_500w_50k": "metrics_fixed_w500_t50000_linkedlist.csv", - "fixed_2kw_100k": "metrics_fixed_w2000_t100000_linkedlist.csv", - "fixed_10kw_200k": "metrics_fixed_w10000_t200000_linkedlist.csv", - "fixed_immediate_500k": "metrics_fixed_w20000_t500000_linkedlist.csv", - "normal_immediate_500k": "metrics_normal_w20000_t500000_linkedlist.csv", - "uniform_immediate_500k": "metrics_uniform_w20000_t500000_linkedlist.csv", - } - return BASELINE / match[name] if name in match else None - - -def main(): - OUTPUT.mkdir(exist_ok=True) - elapsed = parse_elapsed(ROOT / "run_test_hook_all_strict.log") - names = sorted({path.stem for mode in ("none", "hook", "trace") - for path in (RESULTS / mode).glob("*.csv")}) - records = [] - - for name in names: - paths = {mode: RESULTS / mode / f"{name}.csv" for mode in ("none", "hook", "trace")} - if not all(path.exists() for path in paths.values()): - continue - samples = {mode: read_samples(path) for mode, path in paths.items()} - record = {"name": name} - for mode in ("none", "hook", "trace"): - record[f"{mode}_elapsed_s"] = elapsed.get((mode, name), 0.0) - record[f"{mode}_max_heap_mb"] = max_value(samples[mode], "heap_alloc_mb") - record[f"{mode}_last_heap_mb"] = last_value(samples[mode], "heap_alloc_mb") - record[f"{mode}_max_sys_mb"] = max_value(samples[mode], "sys_mb") - record[f"{mode}_last_total_alloc_mb"] = last_value(samples[mode], "total_alloc_mb") - record[f"{mode}_max_gc"] = max_value(samples[mode], "gc_total") - records.append(record) - - baseline_rows = [] - for row in records: - path = baseline_file(row["name"]) - if not path or not path.exists(): - continue - base = read_samples(path) - if not base: - continue - base_heap = max_value(base, "heap_alloc_mb") - base_sys = max_value(base, "sys_mb") - base_total = last_value(base, "total_alloc_mb") - baseline_rows.append({ - "name": row["name"], - "baseline_max_heap_mb": base_heap, - "none_max_heap_mb": row["none_max_heap_mb"], - "none_heap_change_pct": 100 * (row["none_max_heap_mb"] - base_heap) / base_heap, - "baseline_max_sys_mb": base_sys, - "none_max_sys_mb": row["none_max_sys_mb"], - "none_sys_change_pct": 100 * (row["none_max_sys_mb"] - base_sys) / base_sys, - "baseline_total_alloc_mb": base_total, - "none_total_alloc_mb": row["none_last_total_alloc_mb"], - "none_total_change_pct": 100 * (row["none_last_total_alloc_mb"] - base_total) / base_total, - }) - - baseline_by_name = {row["name"]: row for row in baseline_rows} - summary = [] - for row in records: - baseline = baseline_by_name.get(row["name"], {}) - summary_row = {"name": row["name"]} - for mode in ("none", "hook", "trace"): - summary_row[f"{mode}_elapsed_s"] = row[f"{mode}_elapsed_s"] - summary_row[f"{mode}_max_heap_mb"] = row[f"{mode}_max_heap_mb"] - summary_row[f"{mode}_max_sys_mb"] = row[f"{mode}_max_sys_mb"] - summary_row[f"{mode}_total_alloc_mb"] = row[f"{mode}_last_total_alloc_mb"] - summary_row[f"{mode}_max_gc"] = row[f"{mode}_max_gc"] - - def change(left, right): - return round(100 * (right - left) / left, 4) if left else "" - - summary_row["hook_elapsed_change_vs_none_pct"] = change( - row["none_elapsed_s"], row["hook_elapsed_s"]) - summary_row["trace_elapsed_change_vs_none_pct"] = change( - row["none_elapsed_s"], row["trace_elapsed_s"]) - summary_row["hook_heap_change_vs_none_pct"] = change( - row["none_max_heap_mb"], row["hook_max_heap_mb"]) - summary_row["trace_heap_change_vs_none_pct"] = change( - row["none_max_heap_mb"], row["trace_max_heap_mb"]) - summary_row["hook_sys_change_vs_none_pct"] = change( - row["none_max_sys_mb"], row["hook_max_sys_mb"]) - summary_row["trace_sys_change_vs_none_pct"] = change( - row["none_max_sys_mb"], row["trace_max_sys_mb"]) - summary_row["hook_total_alloc_change_vs_none_pct"] = change( - row["none_last_total_alloc_mb"], row["hook_last_total_alloc_mb"]) - summary_row["trace_total_alloc_change_vs_none_pct"] = change( - row["none_last_total_alloc_mb"], row["trace_last_total_alloc_mb"]) - - summary_row.update(baseline) - summary_row["none_elapsed_change_vs_baseline_pct"] = "" - if baseline.get("baseline_max_heap_mb"): - summary_row["none_heap_change_vs_baseline_pct"] = round( - 100 * (row["none_max_heap_mb"] - baseline["baseline_max_heap_mb"]) - / baseline["baseline_max_heap_mb"], 4) - summary_row["none_sys_change_vs_baseline_pct"] = round( - 100 * (row["none_max_sys_mb"] - baseline["baseline_max_sys_mb"]) - / baseline["baseline_max_sys_mb"], 4) - summary_row["none_total_alloc_change_vs_baseline_pct"] = round( - 100 * (row["none_last_total_alloc_mb"] - baseline["baseline_total_alloc_mb"]) - / baseline["baseline_total_alloc_mb"], 4) - else: - summary_row["none_heap_change_vs_baseline_pct"] = "" - summary_row["none_sys_change_vs_baseline_pct"] = "" - summary_row["none_total_alloc_change_vs_baseline_pct"] = "" - summary.append(summary_row) - - fieldnames = list(summary[0].keys()) - with (OUTPUT / "comparison_summary.csv").open("w", newline="", encoding="utf-8") as stream: - writer = csv.DictWriter(stream, fieldnames=fieldnames) - writer.writeheader() - writer.writerows(summary) - - groups = { - "all": lambda name: True, - "fixed": lambda name: name.startswith("fixed_"), - "uniform": lambda name: name.startswith("uniform_") or name == "uniform_json", - "normal": lambda name: name.startswith("normal_"), - "immediate": lambda name: "immediate" in name, - "phased": lambda name: name.startswith("phased_"), - "nonblock": lambda name: "nonblock" in name, - "short_task": lambda name: name in {"fixed_1ms_1m", "fixed_10kw_100ms"}, - "long_task": lambda name: name in {"fixed_10kw_2s", "fixed_3s_5k"}, - } - - def average(rows, key): - values = [float(row[key]) for row in rows if row[key] not in ("", None)] - return sum(values) / len(values) if values else "" - - def median(rows, key): - values = sorted(float(row[key]) for row in rows if row[key] not in ("", None)) - if not values: - return "" - middle = len(values) // 2 - return values[middle] if len(values) % 2 else (values[middle - 1] + values[middle]) / 2 - - def pct(left, right): - return 100 * (right - left) / left if left else "" - - aggregate = [] - for group, selector in groups.items(): - selected = [row for row in records if selector(row["name"])] - if not selected: - continue - row = {"group": group, "case_count": len(selected)} - for mode in ("none", "hook", "trace"): - for metric in ("elapsed_s", "max_heap_mb", "max_sys_mb", "last_total_alloc_mb"): - key = f"{mode}_{metric}" - row[f"{mode}_{metric}_avg"] = average(selected, key) - row[f"{mode}_{metric}_median"] = median(selected, key) - for metric in ("elapsed_s", "max_heap_mb", "max_sys_mb", "last_total_alloc_mb"): - for mode in ("hook", "trace"): - row[f"{mode}_{metric}_change_vs_none_pct"] = pct( - row[f"none_{metric}_avg"], row[f"{mode}_{metric}_avg"]) - aggregate.append(row) - - with (OUTPUT / "aggregate_summary.csv").open("w", newline="", encoding="utf-8") as stream: - writer = csv.DictWriter(stream, fieldnames=aggregate[0].keys()) - writer.writeheader() - writer.writerows(aggregate) - - outliers = [] - for row in records: - hook_change = pct(row["none_elapsed_s"], row["hook_elapsed_s"]) - trace_change = pct(row["none_elapsed_s"], row["trace_elapsed_s"]) - if abs(hook_change) >= 2 or abs(trace_change) >= 5: - outliers.append({ - "name": row["name"], - "none_elapsed_s": row["none_elapsed_s"], - "hook_elapsed_s": row["hook_elapsed_s"], - "hook_change_pct": hook_change, - "trace_elapsed_s": row["trace_elapsed_s"], - "trace_change_pct": trace_change, - "none_max_heap_mb": row["none_max_heap_mb"], - "trace_max_heap_mb": row["trace_max_heap_mb"], - "none_total_alloc_mb": row["none_last_total_alloc_mb"], - "trace_total_alloc_mb": row["trace_last_total_alloc_mb"], - }) - with (OUTPUT / "outliers.csv").open("w", newline="", encoding="utf-8") as stream: - writer = csv.DictWriter(stream, fieldnames=outliers[0].keys()) - writer.writeheader() - writer.writerows(outliers) - - print(f"wrote {len(summary)} rows to {OUTPUT / 'comparison_summary.csv'}") - print(f"wrote {len(aggregate)} aggregate rows and {len(outliers)} outlier rows") - - -if __name__ == "__main__": - main() diff --git a/test/plugin.go b/test/plugin.go new file mode 100644 index 0000000..b6493b6 --- /dev/null +++ b/test/plugin.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "io" +) + +// Plugin interfaces follow a "minimal core + optional capabilities" design +// instead of one big interface: a simple plugin only needs Name/Desc/Run. +// Help output, lifecycle management and dependency declarations are separate +// optional interfaces the host detects by type assertion (see execSession), +// so a plugin file makes its own role obvious and the host never needs to +// know concrete plugin types. + +// Runtime is the only environment handle given to plugins. Out/Err are the +// global streams and Store carries session-level shared data. Everything is +// passed in (never package globals) so sessions stay isolated and testable. +type Runtime struct { + Out io.Writer + Err io.Writer + Store *Store +} + +// Plugin is the minimum contract. Name must be unique: the CLI uses it as +// the segment header ("--") and the registry keys on it; a duplicate +// is rejected by NewRegistry. +type Plugin interface { + Name() string + Desc() string + Run(ctx context.Context, rt *Runtime, args []string) error +} + +// Option describes one key=value segment option for --help output. +// It is metadata only and is not validated at runtime. +type Option struct { + Name string + Default string + Help string +} + +// Optioned declares the segment option table. The host probes it when +// printing help and omits the options block if it is not implemented. +type Optioned interface { + Options() []Option +} + +// Lifecycle is the session-scoped lifecycle. Start runs before every Run, +// in plan order, and End runs after every Run, in reverse order. +// +// Start takes args because envelope plugins (metrics, profile) must be in +// place before the session begins (sampler goroutine up, pprof started), +// and args only arrive with the segment. End takes no args: teardown never +// needs them since Start/Run already parsed what it requires. +type Lifecycle interface { + Start(ctx context.Context, rt *Runtime, args []string) error + End(ctx context.Context, rt *Runtime) error +} + +// Depender declares which plugins must have run before this one. It is pure +// metadata consumed by the planner (auto-insertion, ordering checks, cycle +// detection); the Run code itself only reads dependency outputs from Store. +type Depender interface { + Deps() []string +} diff --git a/test/plugins.go b/test/plugins.go new file mode 100644 index 0000000..6a4799f --- /dev/null +++ b/test/plugins.go @@ -0,0 +1,18 @@ +package main + +// allPlugins is the central registration table, consumed by run() through +// NewRegistry. A single registration point is preferred over per-plugin +// init() self-registration: init ordering is compiler-determined and +// invisible, while one list makes the whole plugin set obvious. Adding a +// plugin means adding one line here. +// +// metrics/profile keep state across phases (Start parses and stores, End +// reads), so pointer instances are registered. +var allPlugins = []Plugin{ + poolPlugin{}, + taskPlugin{}, + submitPlugin{}, + hookPlugin{}, + &metricsPlugin{}, + &profilePlugin{}, +} diff --git a/test/pool.go b/test/pool.go new file mode 100644 index 0000000..a02cb9b --- /dev/null +++ b/test/pool.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + agilepool "github.com/Yiming1997/agilePool/v2" +) + +// poolParams is the parsed pool configuration (session-level, kept in Store +// for readers like metrics). +type poolParams struct { + Workers int64 + Queue int64 + CleanPeriod time.Duration + Container string + Mode string +} + +// pool creates the pool from its options and holds it until End closes it. +// The pool is created in Start rather than Run: hook/metrics need the pool +// to exist before any task is submitted, and the Start phase precedes all +// Runs — which is also why Start takes the segment args (see Lifecycle). +// Run has nothing left to do. +type poolPlugin struct{} + +func (poolPlugin) Name() string { return "pool" } +func (poolPlugin) Desc() string { return "create the pool from options, Close on End" } + +func (poolPlugin) Options() []Option { + return []Option{ + {Name: "workers", Default: "20000", Help: "max worker count"}, + {Name: "queue", Default: "10000", Help: "task queue size"}, + {Name: "container", Default: "linkedlist", Help: "idle container: linkedlist/minheap/slice/ringqueue/treap"}, + {Name: "mode", Default: "block", Help: "work mode: block/nonblock"}, + {Name: "clean-period", Default: "500ms", Help: "idle worker cleanup period"}, + } +} + +func (poolPlugin) Start(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + + workers, err := GetInt(opts, "workers", 20000) + if err != nil { + return err + } + queue, err := GetInt(opts, "queue", 10000) + if err != nil { + return err + } + clean, err := GetDuration(opts, "clean-period", 500*time.Millisecond) + if err != nil { + return err + } + + container := strings.ToLower(GetString(opts, "container", "linkedlist")) + var ct agilepool.IdleContainerType + switch container { + case "linkedlist": + ct = agilepool.LinkedListType + case "minheap": + ct = agilepool.MinHeapType + case "slice": + ct = agilepool.SliceType + case "ringqueue": + ct = agilepool.RingQueueType + case "treap": + ct = agilepool.TreapType + default: + return fmt.Errorf("option %q: unsupported value %q", "container", container) + } + + mode := strings.ToLower(GetString(opts, "mode", "block")) + var wm agilepool.WorkMode + switch mode { + case "block": + wm = agilepool.BLOCK + case "nonblock": + wm = agilepool.NONBLOCK + default: + return fmt.Errorf("option %q: unsupported value %q", "mode", mode) + } + + cfg := agilepool.NewConfig( + agilepool.WithCleanPeriod(clean), + agilepool.WithWorkerNumCapacity(int64(workers)), + agilepool.WithTaskQueueSize(int64(queue)), + agilepool.WithIdleContainerType(ct), + agilepool.WithBlockMode(wm), + ) + p := agilepool.NewPool(cfg) + Provide(rt.Store, "pool", "pool", p) + Provide(rt.Store, "pool", "params", poolParams{ + Workers: int64(workers), Queue: int64(queue), CleanPeriod: clean, + Container: container, Mode: mode, + }) + return nil +} + +func (poolPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + return nil +} + +func (poolPlugin) End(ctx context.Context, rt *Runtime) error { + p, err := Require[*agilepool.Pool](rt.Store, "pool", "pool") + if err != nil { + return err + } + p.Close() + return nil +} diff --git a/test/profile.go b/test/profile.go new file mode 100644 index 0000000..f3cdf7e --- /dev/null +++ b/test/profile.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "fmt" + "os" + "runtime" + "runtime/pprof" +) + +// profile replaces the old --cpuprofile/--memprofile wrapping. Start opens +// the CPU profile, End stops it and writes the heap profile. It must be a +// lifecycle plugin because profiling has to envelop the whole session +// (submission and sampling included); writing the heap profile in End also +// captures the final memory state after the wait-exit window. +type profilePlugin struct { + cpuFile *os.File + mem bool +} + +func (profilePlugin) Name() string { return "profile" } +func (profilePlugin) Desc() string { return "pprof profiling enveloping the whole session" } + +func (profilePlugin) Options() []Option { + return []Option{ + {Name: "cpu", Default: "false", Help: "enable CPU profile (cpu_profile.prof)"}, + {Name: "mem", Default: "false", Help: "enable memory profile (mem_profile.prof)"}, + } +} + +func (p *profilePlugin) Start(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + cpu, err := GetBool(opts, "cpu", false) + if err != nil { + return err + } + mem, err := GetBool(opts, "mem", false) + if err != nil { + return err + } + if !cpu && !mem { + return fmt.Errorf("nothing to profile: specify cpu=true or mem=true") + } + p.mem = mem + + if cpu { + f, err := os.Create("cpu_profile.prof") + if err != nil { + return fmt.Errorf("create cpu_profile.prof: %w", err) + } + if err := pprof.StartCPUProfile(f); err != nil { + f.Close() + return fmt.Errorf("start CPU profile: %w", err) + } + p.cpuFile = f + fmt.Fprintln(rt.Out, " CPU profile started -> cpu_profile.prof") + } + return nil +} + +func (p *profilePlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + return nil +} + +func (p *profilePlugin) End(ctx context.Context, rt *Runtime) error { + if p.cpuFile != nil { + pprof.StopCPUProfile() + p.cpuFile.Close() + fmt.Fprintln(rt.Out, " CPU profile written to cpu_profile.prof") + } + if p.mem { + runtime.GC() + f, err := os.Create("mem_profile.prof") + if err != nil { + return fmt.Errorf("create mem_profile.prof: %w", err) + } + defer f.Close() + if err := pprof.WriteHeapProfile(f); err != nil { + return fmt.Errorf("write heap profile: %w", err) + } + fmt.Fprintln(rt.Out, " Mem profile written to mem_profile.prof") + } + return nil +} diff --git a/test/registry.go b/test/registry.go new file mode 100644 index 0000000..a961fa2 --- /dev/null +++ b/test/registry.go @@ -0,0 +1,55 @@ +package main + +import ( + "fmt" + "sort" +) + +// Registry indexes plugins by name. A registry is needed because two parts +// of the host resolve by name: the segment splitter has to know whether a +// "--xxx" token is a valid segment head, and the planner has to read the +// dependency metadata of plugins. Both then work with names only. +type Registry struct { + byName map[string]Plugin +} + +// NewRegistry builds the index and validates two registration-time +// invariants: plugin names must be unique (a duplicate would make segment +// headers ambiguous), and every declared dependency must be a registered +// plugin. Ordering and cycles are not checked here — they depend on the +// requested segment sequence and are left to Plan. +func NewRegistry(plugins []Plugin) (*Registry, error) { + r := &Registry{byName: make(map[string]Plugin, len(plugins))} + for _, p := range plugins { + if _, dup := r.byName[p.Name()]; dup { + return nil, fmt.Errorf("registry: duplicate plugin %q", p.Name()) + } + r.byName[p.Name()] = p + } + for name, p := range r.byName { + if d, ok := p.(Depender); ok { + for _, dep := range d.Deps() { + if _, ok := r.byName[dep]; !ok { + return nil, fmt.Errorf("registry: plugin %q depends on unregistered plugin %q", name, dep) + } + } + } + } + return r, nil +} + +// Lookup finds a plugin by name; callers phrase their own errors. +func (r *Registry) Lookup(name string) (Plugin, bool) { + p, ok := r.byName[name] + return p, ok +} + +// Names returns plugin names sorted for stable --list output. +func (r *Registry) Names() []string { + names := make([]string, 0, len(r.byName)) + for n := range r.byName { + names = append(names, n) + } + sort.Strings(names) + return names +} diff --git a/test/run_test.bat b/test/run_test.bat index 21d72e5..b705ce1 100644 --- a/test/run_test.bat +++ b/test/run_test.bat @@ -1,37 +1,42 @@ -@REM Build and run all benchmark tests +@REM Build and run all benchmark tests (v3 plugin syntax, scenario-for-scenario equivalent) +@REM Mapping: 旧 flag → 新段(见 docs/test-harness.md §6) +@REM -T --task-base/-task-extra/-task-mean/-task-sigma → --task type/base/extra/mean/sigma +@REM -U -t --submit-interval/-jitter/-mean-interval/-shards -P → --submit strategy/num/interval/jitter/mean-interval/shards/phases +@REM -w -c -m → --pool workers/container/mode +@REM -i -f -e → --metrics interval/format/wait-exit;旧工具自动文件名在此显式给出(file=),重复场景互相覆盖的行为与旧版一致 +@REM --cpuprofile --memprofile → --profile cpu=true mem=true go build -o agilepool_test.exe . -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 500000 -w 20000 -i 1 -f csv -agilepool_test.exe -T uniform --task-base 500 --task-extra 50 -U immediate -t 500000 -w 20000 -i 1 -f csv -agilepool_test.exe -T normal --task-mean 500 --task-sigma 50 -U immediate -t 500000 -w 20000 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U linear --submit-interval 5 --submit-jitter 3 -t 20000 -w 20000 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U poisson --submit-mean-interval 10 -t 10000 -w 20000 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U phased -P "0,10,500;10,10,5000;20,10,500" --submit-shards 10 -w 20000 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -c linkedlist -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -c minheap -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -c slice -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -c ringqueue -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -m block -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -m nonblock -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 20000 -w 100 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 50000 -w 500 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 100000 -w 2000 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 100 -U immediate -t 200000 -w 10000 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 2000 -U immediate -t 50000 -w 10000 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 500000 -w 20000 --cpuprofile --memprofile -i 1 -f csv +agilepool_test.exe --pool workers=20000 --task type=fixed base=500 --submit strategy=immediate num=500000 --metrics interval=1 format=csv file=metrics_fixed_w20000_t500000_linkedlist.csv +agilepool_test.exe --pool workers=20000 --task type=uniform base=500 extra=50 --submit strategy=immediate num=500000 --metrics interval=1 format=csv file=metrics_uniform_w20000_t500000_linkedlist.csv +agilepool_test.exe --pool workers=20000 --task type=normal mean=500 sigma=50 --submit strategy=immediate num=500000 --metrics interval=1 format=csv file=metrics_normal_w20000_t500000_linkedlist.csv +agilepool_test.exe --pool workers=20000 --task type=fixed base=500 --submit strategy=linear num=20000 interval=5 jitter=3 --metrics interval=1 format=csv file=metrics_fixed_w20000_t20000_linkedlist.csv +agilepool_test.exe --pool workers=20000 --task type=fixed base=500 --submit strategy=poisson num=10000 mean-interval=10 --metrics interval=1 format=csv file=metrics_fixed_w20000_t10000_linkedlist.csv +agilepool_test.exe --pool workers=20000 --task type=fixed base=500 --submit strategy=phased phases="0,10,500;10,10,5000;20,10,500" shards=10 --metrics interval=1 format=csv file=metrics_fixed_w20000_t1000000_linkedlist.csv +agilepool_test.exe --pool workers=10000 container=linkedlist --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv +agilepool_test.exe --pool workers=10000 container=minheap --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_minheap.csv +agilepool_test.exe --pool workers=10000 container=slice --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_slice.csv +agilepool_test.exe --pool workers=10000 container=ringqueue --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_ringqueue.csv +agilepool_test.exe --pool workers=10000 mode=block --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv +agilepool_test.exe --pool workers=10000 mode=nonblock --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv +agilepool_test.exe --pool workers=100 --task type=fixed base=500 --submit strategy=immediate num=20000 --metrics interval=1 format=csv file=metrics_fixed_w100_t20000_linkedlist.csv +agilepool_test.exe --pool workers=500 --task type=fixed base=500 --submit strategy=immediate num=50000 --metrics interval=1 format=csv file=metrics_fixed_w500_t50000_linkedlist.csv +agilepool_test.exe --pool workers=2000 --task type=fixed base=500 --submit strategy=immediate num=100000 --metrics interval=1 format=csv file=metrics_fixed_w2000_t100000_linkedlist.csv +agilepool_test.exe --pool workers=10000 --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv +agilepool_test.exe --pool workers=10000 --task type=fixed base=100 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv +agilepool_test.exe --pool workers=10000 --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv +agilepool_test.exe --pool workers=10000 --task type=fixed base=2000 --submit strategy=immediate num=50000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t50000_linkedlist.csv +agilepool_test.exe --profile cpu=true mem=true --pool workers=20000 --task type=fixed base=500 --submit strategy=immediate num=500000 --metrics interval=1 format=csv file=metrics_fixed_w20000_t500000_linkedlist.csv @REM Complex orchestration tests -agilepool_test.exe -T fixed --task-base 500 -U phased -P "0,30,1000" --submit-shards 5 -w 10000 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U phased -P "0,3,200;3,3,5000;6,3,200;9,3,5000" --submit-shards 8 -w 15000 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U phased -P "0,10,2000;10,10,500" --submit-shards 5 -w 10000 -m nonblock -i 1 -f csv -agilepool_test.exe -T normal --task-mean 500 --task-sigma 100 -U poisson --submit-mean-interval 8 -t 15000 -w 20000 -i 1 -f csv -agilepool_test.exe -T uniform --task-base 500 --task-extra 100 -U linear --submit-interval 10 --submit-jitter 5 -t 10000 -w 10000 -m nonblock -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U constant --submit-interval 8 -t 15000 -w 20000 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 1 -U immediate -t 1000000 -w 5000 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 3000 -U immediate -t 5000 -w 500 -i 1 -f csv -agilepool_test.exe -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -i 1 -f csv -e 3 -agilepool_test.exe -T uniform --task-base 500 --task-extra 100 -U linear --submit-interval 15 --submit-jitter 5 -t 8000 -w 10000 -c minheap -m nonblock -i 1 -f json -e 5 +agilepool_test.exe --pool workers=10000 --task type=fixed base=500 --submit strategy=phased phases="0,30,1000" shards=5 --metrics interval=1 format=csv file=metrics_fixed_w10000_t1000000_linkedlist.csv +agilepool_test.exe --pool workers=15000 --task type=fixed base=500 --submit strategy=phased phases="0,3,200;3,3,5000;6,3,200;9,3,5000" shards=8 --metrics interval=1 format=csv file=metrics_fixed_w15000_t1000000_linkedlist.csv +agilepool_test.exe --pool workers=10000 mode=nonblock --task type=fixed base=500 --submit strategy=phased phases="0,10,2000;10,10,500" shards=5 --metrics interval=1 format=csv file=metrics_fixed_w10000_t1000000_linkedlist.csv +agilepool_test.exe --pool workers=20000 --task type=normal mean=500 sigma=100 --submit strategy=poisson num=15000 mean-interval=8 --metrics interval=1 format=csv file=metrics_normal_w20000_t15000_linkedlist.csv +agilepool_test.exe --pool workers=10000 mode=nonblock --task type=uniform base=500 extra=100 --submit strategy=linear num=10000 interval=10 jitter=5 --metrics interval=1 format=csv file=metrics_uniform_w10000_t10000_linkedlist.csv +agilepool_test.exe --pool workers=20000 --task type=fixed base=500 --submit strategy=constant num=15000 interval=8 --metrics interval=1 format=csv file=metrics_fixed_w20000_t15000_linkedlist.csv +agilepool_test.exe --pool workers=5000 --task type=fixed base=1 --submit strategy=immediate num=1000000 --metrics interval=1 format=csv file=metrics_fixed_w5000_t1000000_linkedlist.csv +agilepool_test.exe --pool workers=500 --task type=fixed base=3000 --submit strategy=immediate num=5000 --metrics interval=1 format=csv file=metrics_fixed_w500_t5000_linkedlist.csv +agilepool_test.exe --pool workers=10000 --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv wait-exit=3 +agilepool_test.exe --pool workers=10000 container=minheap mode=nonblock --task type=uniform base=500 extra=100 --submit strategy=linear num=8000 interval=15 jitter=5 --metrics interval=1 format=json file=metrics_uniform_w10000_t8000_minheap.json wait-exit=5 - -python plot_csv.py \ No newline at end of file +python old\plot_csv.py diff --git a/test/run_test.sh b/test/run_test.sh index a0e6857..8fcd226 100644 --- a/test/run_test.sh +++ b/test/run_test.sh @@ -1,37 +1,44 @@ #!/bin/bash -# Build and run all benchmark tests +# Build and run all benchmark tests (v3 plugin syntax, scenario-for-scenario +# equivalent of the old run_test.sh; identical to run_test.bat) +# Mapping: old flags -> new segments (see docs/test-harness.md §6) +# -T --task-base/-task-extra/-task-mean/-task-sigma -> --task type/base/extra/mean/sigma +# -U -t --submit-interval/-jitter/-mean-interval/-shards -P -> --submit strategy/num/interval/jitter/mean-interval/shards/phases +# -w -c -m -> --pool workers/container/mode +# -i -f -e -> --metrics interval/format/wait-exit (old auto file names are given explicitly) +# --cpuprofile --memprofile -> --profile cpu=true mem=true go build -o agilepool_test . -./agilepool_test -T fixed --task-base 500 -U immediate -t 500000 -w 20000 -i 1 -f csv -./agilepool_test -T uniform --task-base 500 --task-extra 50 -U immediate -t 500000 -w 20000 -i 1 -f csv -./agilepool_test -T normal --task-mean 500 --task-sigma 50 -U immediate -t 500000 -w 20000 -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U linear --submit-interval 5 --submit-jitter 3 -t 20000 -w 20000 -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U poisson --submit-mean-interval 10 -t 10000 -w 20000 -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U phased -P "0,10,500;10,10,5000;20,10,500" --submit-shards 10 -w 20000 -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -c linkedlist -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -c minheap -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -c slice -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -c ringqueue -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -m block -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -m nonblock -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U immediate -t 20000 -w 100 -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U immediate -t 50000 -w 500 -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U immediate -t 100000 -w 2000 -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -i 1 -f csv -./agilepool_test -T fixed --task-base 100 -U immediate -t 200000 -w 10000 -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -i 1 -f csv -./agilepool_test -T fixed --task-base 2000 -U immediate -t 50000 -w 10000 -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U immediate -t 500000 -w 20000 --cpuprofile --memprofile -i 1 -f csv +./agilepool_test --pool workers=20000 --task type=fixed base=500 --submit strategy=immediate num=500000 --metrics interval=1 format=csv file=metrics_fixed_w20000_t500000_linkedlist.csv +./agilepool_test --pool workers=20000 --task type=uniform base=500 extra=50 --submit strategy=immediate num=500000 --metrics interval=1 format=csv file=metrics_uniform_w20000_t500000_linkedlist.csv +./agilepool_test --pool workers=20000 --task type=normal mean=500 sigma=50 --submit strategy=immediate num=500000 --metrics interval=1 format=csv file=metrics_normal_w20000_t500000_linkedlist.csv +./agilepool_test --pool workers=20000 --task type=fixed base=500 --submit strategy=linear num=20000 interval=5 jitter=3 --metrics interval=1 format=csv file=metrics_fixed_w20000_t20000_linkedlist.csv +./agilepool_test --pool workers=20000 --task type=fixed base=500 --submit strategy=poisson num=10000 mean-interval=10 --metrics interval=1 format=csv file=metrics_fixed_w20000_t10000_linkedlist.csv +./agilepool_test --pool workers=20000 --task type=fixed base=500 --submit strategy=phased phases="0,10,500;10,10,5000;20,10,500" shards=10 --metrics interval=1 format=csv file=metrics_fixed_w20000_t1000000_linkedlist.csv +./agilepool_test --pool workers=10000 container=linkedlist --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv +./agilepool_test --pool workers=10000 container=minheap --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_minheap.csv +./agilepool_test --pool workers=10000 container=slice --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_slice.csv +./agilepool_test --pool workers=10000 container=ringqueue --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_ringqueue.csv +./agilepool_test --pool workers=10000 mode=block --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv +./agilepool_test --pool workers=10000 mode=nonblock --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv +./agilepool_test --pool workers=100 --task type=fixed base=500 --submit strategy=immediate num=20000 --metrics interval=1 format=csv file=metrics_fixed_w100_t20000_linkedlist.csv +./agilepool_test --pool workers=500 --task type=fixed base=500 --submit strategy=immediate num=50000 --metrics interval=1 format=csv file=metrics_fixed_w500_t50000_linkedlist.csv +./agilepool_test --pool workers=2000 --task type=fixed base=500 --submit strategy=immediate num=100000 --metrics interval=1 format=csv file=metrics_fixed_w2000_t100000_linkedlist.csv +./agilepool_test --pool workers=10000 --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv +./agilepool_test --pool workers=10000 --task type=fixed base=100 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv +./agilepool_test --pool workers=10000 --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv +./agilepool_test --pool workers=10000 --task type=fixed base=2000 --submit strategy=immediate num=50000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t50000_linkedlist.csv +./agilepool_test --profile cpu=true mem=true --pool workers=20000 --task type=fixed base=500 --submit strategy=immediate num=500000 --metrics interval=1 format=csv file=metrics_fixed_w20000_t500000_linkedlist.csv # Complex orchestration tests -./agilepool_test -T fixed --task-base 500 -U phased -P "0,30,1000" --submit-shards 5 -w 10000 -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U phased -P "0,3,200;3,3,5000;6,3,200;9,3,5000" --submit-shards 8 -w 15000 -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U phased -P "0,10,2000;10,10,500" --submit-shards 5 -w 10000 -m nonblock -i 1 -f csv -./agilepool_test -T normal --task-mean 500 --task-sigma 100 -U poisson --submit-mean-interval 8 -t 15000 -w 20000 -i 1 -f csv -./agilepool_test -T uniform --task-base 500 --task-extra 100 -U linear --submit-interval 10 --submit-jitter 5 -t 10000 -w 10000 -m nonblock -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U constant --submit-interval 8 -t 15000 -w 20000 -i 1 -f csv -./agilepool_test -T fixed --task-base 1 -U immediate -t 1000000 -w 5000 -i 1 -f csv -./agilepool_test -T fixed --task-base 3000 -U immediate -t 5000 -w 500 -i 1 -f csv -./agilepool_test -T fixed --task-base 500 -U immediate -t 200000 -w 10000 -i 1 -f csv -e 3 -./agilepool_test -T uniform --task-base 500 --task-extra 100 -U linear --submit-interval 15 --submit-jitter 5 -t 8000 -w 10000 -c minheap -m nonblock -i 1 -f json -e 5 +./agilepool_test --pool workers=10000 --task type=fixed base=500 --submit strategy=phased phases="0,30,1000" shards=5 --metrics interval=1 format=csv file=metrics_fixed_w10000_t1000000_linkedlist.csv +./agilepool_test --pool workers=15000 --task type=fixed base=500 --submit strategy=phased phases="0,3,200;3,3,5000;6,3,200;9,3,5000" shards=8 --metrics interval=1 format=csv file=metrics_fixed_w15000_t1000000_linkedlist.csv +./agilepool_test --pool workers=10000 mode=nonblock --task type=fixed base=500 --submit strategy=phased phases="0,10,2000;10,10,500" shards=5 --metrics interval=1 format=csv file=metrics_fixed_w10000_t1000000_linkedlist.csv +./agilepool_test --pool workers=20000 --task type=normal mean=500 sigma=100 --submit strategy=poisson num=15000 mean-interval=8 --metrics interval=1 format=csv file=metrics_normal_w20000_t15000_linkedlist.csv +./agilepool_test --pool workers=10000 mode=nonblock --task type=uniform base=500 extra=100 --submit strategy=linear num=10000 interval=10 jitter=5 --metrics interval=1 format=csv file=metrics_uniform_w10000_t10000_linkedlist.csv +./agilepool_test --pool workers=20000 --task type=fixed base=500 --submit strategy=constant num=15000 interval=8 --metrics interval=1 format=csv file=metrics_fixed_w20000_t15000_linkedlist.csv +./agilepool_test --pool workers=5000 --task type=fixed base=1 --submit strategy=immediate num=1000000 --metrics interval=1 format=csv file=metrics_fixed_w5000_t1000000_linkedlist.csv +./agilepool_test --pool workers=500 --task type=fixed base=3000 --submit strategy=immediate num=5000 --metrics interval=1 format=csv file=metrics_fixed_w500_t5000_linkedlist.csv +./agilepool_test --pool workers=10000 --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv wait-exit=3 +./agilepool_test --pool workers=10000 container=minheap mode=nonblock --task type=uniform base=500 extra=100 --submit strategy=linear num=8000 interval=15 jitter=5 --metrics interval=1 format=json file=metrics_uniform_w10000_t8000_minheap.json wait-exit=5 -python plot_csv.py \ No newline at end of file +python old/plot_csv.py diff --git a/test/store.go b/test/store.go new file mode 100644 index 0000000..03191f3 --- /dev/null +++ b/test/store.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "sync" +) + +// Store is the only data path between plugins: a two-level table of +// plugin name -> key -> value. Plugins do not know each other; a downstream +// plugin declares its Deps and reads what it needs here, and the plugin +// name in every call makes ownership explicit. +// +// Layering constraint (performance): Store only carries session-level +// objects (pool handle, configs, factories) touched a handful of times per +// session, so the map/any boxing overhead is negligible. Per-task hot data +// (counters, samples) must stay in plugin-owned atomic state and never be +// written here — that would add a lock and a box to every increment. +type Store struct { + mu sync.RWMutex + data map[string]map[string]any +} + +// NewStore builds an empty Store; the host creates one per session. +func NewStore() *Store { + return &Store{data: make(map[string]map[string]any)} +} + +// Go does not allow type parameters on methods, so the generic accessors +// below are free functions. + +// Provide stores v under plugin's namespace. Re-providing the same key +// overwrites: the latest parsed value wins. +func Provide[T any](s *Store, plugin, key string, v T) { + s.mu.Lock() + defer s.mu.Unlock() + if s.data[plugin] == nil { + s.data[plugin] = make(map[string]any) + } + s.data[plugin][key] = v +} + +// Get reads a value from an explicit namespace. A type mismatch (the stored +// type differs from T) reports ok=false instead of panicking: it is a broken +// plugin contract, better reported than crashing the process. +func Get[T any](s *Store, plugin, key string) (T, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + var zero T + if s.data[plugin] == nil { + return zero, false + } + v, ok := s.data[plugin][key] + if !ok { + return zero, false + } + t, ok := v.(T) + return t, ok +} + +// Require reads a value from a dependency's namespace and errors when it is +// missing. Errors instead of a silent zero value matter for End phases: +// after an aborted Run an upstream may not have provided yet, and the +// "not provided" error is a readable signal of that timing issue. +func Require[T any](s *Store, dep, key string) (T, error) { + v, ok := Get[T](s, dep, key) + if !ok { + var zero T + return zero, fmt.Errorf("dependency %q: value %q not provided", dep, key) + } + return v, nil +} diff --git a/test/submit.go b/test/submit.go new file mode 100644 index 0000000..1b4797e --- /dev/null +++ b/test/submit.go @@ -0,0 +1,275 @@ +package main + +import ( + "context" + "fmt" + "math" + "math/rand/v2" + "strconv" + "strings" + "sync" + "time" + + agilepool "github.com/Yiming1997/agilePool/v2" +) + +// submitParams is the parsed submit configuration (session-level, kept in +// Store for readers like metrics). +type submitParams struct { + Strategy string + Num int +} + +// phase is one stage of a phased burst: relative start (s), duration (s), +// rate (submissions per second). +type phase struct { + startSec int + durationSec int + ratePerSec float64 +} + +// submit implements the old -U/--submit-* strategies (immediate/linear/ +// constant/poisson/phased), logic ported as-is. Run blocks until every task +// is submitted and the pool drains, so the plugin needs no End phase. +type submitPlugin struct{} + +func (submitPlugin) Name() string { return "submit" } +func (submitPlugin) Desc() string { return "submit tasks per strategy and wait for drain" } + +func (submitPlugin) Deps() []string { return []string{"pool", "task"} } + +func (submitPlugin) Options() []Option { + return []Option{ + {Name: "strategy", Default: "immediate", Help: "strategy: immediate/linear/constant/poisson/phased"}, + {Name: "num", Default: "1000000", Help: "total tasks to submit"}, + {Name: "interval", Default: "10", Help: "linear/constant submit interval (ms)"}, + {Name: "jitter", Default: "0", Help: "linear random jitter (ms)"}, + {Name: "mean-interval", Default: "50", Help: "poisson mean interval (ms)"}, + {Name: "phases", Default: "", Help: "phased schedule: startSec,durSec,ratePerSec;... (semicolon separated)"}, + {Name: "shards", Default: "1", Help: "phased parallel shards"}, + {Name: "submitters", Default: "1", Help: "phased submitters per shard"}, + } +} + +func (submitPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + + strategy := strings.ToLower(GetString(opts, "strategy", "immediate")) + switch strategy { + case "immediate", "linear", "constant", "poisson", "phased": + default: + return fmt.Errorf("option %q: unsupported value %q", "strategy", strategy) + } + + num, err := GetInt(opts, "num", 1000000) + if err != nil { + return err + } + interval, err := GetInt(opts, "interval", 10) + if err != nil { + return err + } + jitter, err := GetInt(opts, "jitter", 0) + if err != nil { + return err + } + meanInterval, err := GetInt(opts, "mean-interval", 50) + if err != nil { + return err + } + shards, err := GetInt(opts, "shards", 1) + if err != nil { + return err + } + submitters, err := GetInt(opts, "submitters", 1) + if err != nil { + return err + } + phases, err := parsePhases(GetString(opts, "phases", "")) + if err != nil { + return err + } + if strategy == "phased" && len(phases) == 0 { + return fmt.Errorf("option %q: phased requires a non-empty phases schedule", "phases") + } + + p, err := Require[*agilepool.Pool](rt.Store, "pool", "pool") + if err != nil { + return err + } + durFn, err := Require[func() time.Duration](rt.Store, "task", "durfn") + if err != nil { + return err + } + + Provide(rt.Store, "submit", "params", submitParams{Strategy: strategy, Num: num}) + + switch strategy { + case "immediate": + runImmediate(p, num, durFn) + case "linear": + runTimedSubmit(p, num, durFn, time.Duration(interval)*time.Millisecond, + time.Duration(jitter)*time.Millisecond, false) + case "constant": + runTimedSubmit(p, num, durFn, time.Duration(interval)*time.Millisecond, 0, true) + case "poisson": + runTimedSubmit(p, num, durFn, time.Duration(meanInterval)*time.Millisecond, 0, false) + case "phased": + runPhased(p, phases, shards, submitters, durFn) + } + return nil +} + +// parsePhases parses the "startSec,durSec,ratePerSec;..." schedule. +func parsePhases(s string) ([]phase, error) { + if s == "" { + return nil, nil + } + var phases []phase + for i, seg := range strings.Split(s, ";") { + seg = strings.TrimSpace(seg) + if seg == "" { + continue + } + parts := strings.Split(seg, ",") + if len(parts) != 3 { + return nil, fmt.Errorf("phase %d: expected 3 comma-separated values, got %d", i, len(parts)) + } + start, e1 := strconv.Atoi(strings.TrimSpace(parts[0])) + dur, e2 := strconv.Atoi(strings.TrimSpace(parts[1])) + rate, e3 := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64) + if e1 != nil || e2 != nil || e3 != nil { + return nil, fmt.Errorf("phase %d: invalid number", i) + } + phases = append(phases, phase{startSec: start, durationSec: dur, ratePerSec: rate}) + } + return phases, nil +} + +// newTask wraps a duration factory into a task that sleeps one random +// duration and returns. +func newTask(durFn func() time.Duration) agilepool.Task { + return agilepool.TaskFunc(func() error { + time.Sleep(durFn()) + return nil + }) +} + +// runImmediate submits all tasks back to back, then blocks until the pool +// drains (Wait covers all submitted tasks). +func runImmediate(p *agilepool.Pool, n int, durFn func() time.Duration) { + for i := 0; i < n; i++ { + p.Submit(newTask(durFn)) + } + p.Wait() +} + +// runTimedSubmit submits one task per interval. isConstant pins the delay to +// baseInterval; otherwise jitter>0 yields a linear random delay and +// jitter==0 a poisson delay (matching the old tool). The WaitGroup tracks +// submission attempts: a TrySubmit rejection never queues, so its wg.Done +// is issued immediately; pool.Wait afterwards covers drain. +func runTimedSubmit(p *agilepool.Pool, n int, durFn func() time.Duration, baseInterval, jitter time.Duration, isConstant bool) { + var wg sync.WaitGroup + wg.Add(n) + + go func() { + baseNs := float64(baseInterval) + jitterNs := float64(jitter) + for i := 0; i < n; i++ { + if !p.TrySubmit(newTaskDone(durFn, &wg)) { + wg.Done() + } + + var delay time.Duration + switch { + case isConstant: + delay = baseInterval + case baseInterval == 0: + delay = 0 + case jitter == 0: + delay = time.Duration(-baseNs * math.Log(rand.Float64())) // poisson + default: + delay = time.Duration(baseNs + rand.Float64()*jitterNs) // linear + } + if delay > 0 { + time.Sleep(delay) + } + } + }() + + wg.Wait() + p.Wait() +} + +// newTaskDone wraps a task that signals wg.Done on completion (timed paths). +func newTaskDone(durFn func() time.Duration, wg *sync.WaitGroup) agilepool.Task { + return agilepool.TaskFunc(func() error { + defer wg.Done() + time.Sleep(durFn()) + return nil + }) +} + +// runPhased drives a multi-phase burst through token channels (ported from +// the old tool): one token dispenser per shard feeds the shard's submitters, +// and the dispenser controls the rate. +func runPhased(p *agilepool.Pool, phases []phase, shards, submitters int, durFn func() time.Duration) { + if shards < 1 { + shards = 1 + } + if submitters < 1 { + submitters = 1 + } + + var submitWG sync.WaitGroup + for s := 0; s < shards; s++ { + tokenCh := make(chan struct{}, 10000) + go dispenseTokens(tokenCh, phases, shards) + for g := 0; g < submitters; g++ { + submitWG.Add(1) + go func() { + defer submitWG.Done() + for range tokenCh { + p.Submit(newTask(durFn)) + } + }() + } + } + + submitWG.Wait() + p.Wait() +} + +// dispenseTokens emits one token per millisecond step using a float +// accumulator, which supports sub-millisecond rates (e.g. 0.5 tokens/s per +// shard fires every 2 s). Tokens are dropped when the channel is full, as in +// the old tool. Gaps between phases are slept through until the next start. +func dispenseTokens(tokenCh chan<- struct{}, phases []phase, shards int) { + defer close(tokenCh) + + var elapsedMs int + for _, p := range phases { + startMs := p.startSec * 1000 + if startMs > elapsedMs { + time.Sleep(time.Duration(startMs-elapsedMs) * time.Millisecond) + elapsedMs = startMs + } + + ratePerShard := p.ratePerSec / float64(shards) + var acc float64 + totalMs := p.durationSec * 1000 + for ms := 0; ms < totalMs; ms++ { + acc += ratePerShard / 1000 + for acc >= 1 { + select { + case tokenCh <- struct{}{}: + default: + } + acc-- + } + time.Sleep(time.Millisecond) + } + elapsedMs += totalMs + } +} diff --git a/test/task.go b/test/task.go new file mode 100644 index 0000000..4973b97 --- /dev/null +++ b/test/task.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "fmt" + "math" + "math/rand/v2" + "strings" + "time" +) + +// taskParams is the parsed task duration model (session-level, kept in Store). +type taskParams struct { + Type string + BaseMs int // fixed/uniform base + ExtraMs int // uniform random range + MeanMs int // normal mean + SigmaMs int // normal stddev +} + +// task builds the task duration model (old -T/--task-* flags) and provides +// two outputs: a duration factory (durfn) that submit calls once per task on +// the hot path, and the params table (session metadata). Types match the old +// tool: fixed/uniform/normal. +type taskPlugin struct{} + +func (taskPlugin) Name() string { return "task" } +func (taskPlugin) Desc() string { return "task duration model, provides a duration factory" } + +func (taskPlugin) Options() []Option { + return []Option{ + {Name: "type", Default: "fixed", Help: "duration type: fixed/uniform/normal"}, + {Name: "base", Default: "10", Help: "fixed/uniform base duration (ms)"}, + {Name: "extra", Default: "0", Help: "uniform random range (ms)"}, + {Name: "mean", Default: "10", Help: "normal mean (ms)"}, + {Name: "sigma", Default: "5", Help: "normal stddev (ms)"}, + } +} + +func (taskPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + + kind := strings.ToLower(GetString(opts, "type", "fixed")) + switch kind { + case "fixed", "uniform", "normal": + default: + return fmt.Errorf("option %q: unsupported value %q", "type", kind) + } + + base, err := GetInt(opts, "base", 10) + if err != nil { + return err + } + extra, err := GetInt(opts, "extra", 0) + if err != nil { + return err + } + mean, err := GetInt(opts, "mean", 10) + if err != nil { + return err + } + sigma, err := GetInt(opts, "sigma", 5) + if err != nil { + return err + } + + Provide(rt.Store, "task", "durfn", buildDurationFn(taskParams{ + Type: kind, BaseMs: base, ExtraMs: extra, MeanMs: mean, SigmaMs: sigma, + })) + Provide(rt.Store, "task", "params", taskParams{ + Type: kind, BaseMs: base, ExtraMs: extra, MeanMs: mean, SigmaMs: sigma, + }) + return nil +} + +// buildDurationFn returns a concurrency-safe duration generator. The +// math/rand/v2 top-level functions are goroutine-safe, so submit shards can +// share one factory. +func buildDurationFn(cfg taskParams) func() time.Duration { + switch cfg.Type { + case "uniform": + base := float64(cfg.BaseMs) + extra := float64(cfg.ExtraMs) + return func() time.Duration { + return time.Duration(base+rand.Float64()*extra) * time.Millisecond + } + case "normal": + mean := float64(cfg.MeanMs) + sigma := float64(cfg.SigmaMs) + return func() time.Duration { + v := math.Max(0, rand.NormFloat64()*sigma+mean) + return time.Duration(v) * time.Millisecond + } + } + // fixed (and the default fallback): capture the constant once. + d := time.Duration(cfg.BaseMs) * time.Millisecond + return func() time.Duration { return d } +} From c572720f24ae5b26977d272b446d6a7052db6097 Mon Sep 17 00:00:00 2001 From: Bury <18151161@qq.com> Date: Wed, 9 Sep 2026 23:02:11 +0800 Subject: [PATCH 2/5] feat(test): hook-stability plugin family, local-module tooling and test report Split test/ into a nested module (replace => ../) so the harness always builds against the local library, never the network copy. Add eight h* hook-stability plugins (hcount/hpanic/horder/hctx/hblock/hchurn/hreenter/hclose) on shared machinery in hookcheck.go. Each scenario builds private pools and asserts PASS/FAIL invariants within the registration contract (hooks are added before dispatch and frozen once tasks run): exact event accounting, panic immunity at callback and dispatch level, per-task event order and ctx payload fidelity, cancellation semantics, slow/reentrant hooks, concurrent registration bursts and OnPoolClosed exactly-once. Add run_hook_stress.bat/.sh (optional race arg) and update docs/test-harness.md plus both test READMEs. Report: docs/hook-stability-test-report.md --- docs/hook-stability-test-report.md | 78 ++++++++++ docs/test-harness.md | 67 +++++++- go.mod | 9 -- go.sum | 26 +--- test/README.md | 37 ++++- test/README_zh-CN.md | 31 +++- test/go.mod | 27 ++++ test/go.sum | 36 +++++ test/hblock.go | 100 ++++++++++++ test/hchurn.go | 172 +++++++++++++++++++++ test/hclose.go | 179 ++++++++++++++++++++++ test/hcount.go | 102 +++++++++++++ test/hctx.go | 184 ++++++++++++++++++++++ test/hookcheck.go | 184 ++++++++++++++++++++++ test/horder.go | 237 +++++++++++++++++++++++++++++ test/hpanic.go | 210 +++++++++++++++++++++++++ test/hreenter.go | 141 +++++++++++++++++ test/plot_csv.py | 214 ++++++++++++++------------ test/plugins.go | 8 + test/run_hook_stress.bat | 33 ++++ test/run_hook_stress.sh | 25 +++ test/run_test.bat | 2 +- test/run_test.sh | 2 +- 23 files changed, 1955 insertions(+), 149 deletions(-) create mode 100644 docs/hook-stability-test-report.md create mode 100644 test/go.mod create mode 100644 test/go.sum create mode 100644 test/hblock.go create mode 100644 test/hchurn.go create mode 100644 test/hclose.go create mode 100644 test/hcount.go create mode 100644 test/hctx.go create mode 100644 test/hookcheck.go create mode 100644 test/horder.go create mode 100644 test/hpanic.go create mode 100644 test/hreenter.go create mode 100644 test/run_hook_stress.bat create mode 100644 test/run_hook_stress.sh diff --git a/docs/hook-stability-test-report.md b/docs/hook-stability-test-report.md new file mode 100644 index 0000000..95cd7ba --- /dev/null +++ b/docs/hook-stability-test-report.md @@ -0,0 +1,78 @@ +# 钩子系统稳定性测试报告 + +日期:2026-09-09 +范围:test/ 插件式测试工具的钩子稳定性(h*)插件家族 + test 模块本地化引用 +分支:`feature-test-plugin`(提交后基于其新建 `feature-hook-stability`) + +## 1. 背景与目标 + +go-agile-pool 提供 5 类生命周期钩子(OnTaskSubmitted / OnTaskEnqueued / +OnTaskStarted / OnTaskCompleted / OnPoolClosed)。本次为 test 工具新增一批 +**钩子稳定性压测插件**,目标是验证钩子系统在对抗性条件下的稳定性契约: + +- 事件不丢、不重(精确记账); +- 钩子 panic 不击穿提交/执行/关闭路径; +- 单任务事件序与上下文载荷保真; +- 慢钩子、重入钩子、并发注册、Close 竞态下的行为; +- 全程无 goroutine 泄漏、无数据竞争(-race)。 + +测试只验证**受支持的注册契约**:钩子仅在启动期注册(可多协程并发),任务 +开始后回调列表即冻结;运行中 `Add*` 属于越界用法,不在测试范围内。 + +## 2. 方法 + +- `test/` 拆分为独立 Go 模块 `github.com/Yiming1997/agilePool/v2/test`, + `go.mod` 用 `replace => ../` 把库引用指向仓库根目录(离线、严格本地库); + 模块路径保留库前缀,使 `internal/hook` 可被导入。 +- 每个 h* 插件自建私有池(`queue == 任务数`,走直通 channel 路径, + 保证 Enqueued 每任务恰触发一次),断言 PASS/FAIL,失败退出码 1。 +- 公共机制集中在 `test/hookcheck.go`(场景池、原子计数器、drain/goroutine + 泄漏等待、PASS/FAIL 报告)。 +- 回归脚本:`run_hook_stress.bat` / `run_hook_stress.sh`(参数 `race` 时 + 以 `-race` 构建)。 + +## 3. 测试插件与覆盖点 + +| 插件 | 施压方式 | 核心断言(均通过) | +|---|---|---| +| `hcount` | N 钩子 × M 任务满并发 | 每事件计数 == N·M;执行数 == M;无泄漏 | +| `hpanic` | 每阶段:callback 层 panic / 整层 dispatch panic | 任务照常执行、可排空、Close 正常;摘钩后补发任务正常;close 后提交静默 | +| `horder` | 2 万任务(含 2 千 panic 任务)带 ctx id | Submitted 首事件、Started 先于 Completed、无重复;ctx 载荷全事件一致;panic 值原样到 OnTaskCompleted | +| `hctx` | 存活标记波 / 预取消波 / 排队中取消波 | ctx 贯穿全事件;预取消零事件;取消后每出队任务 Started==Completed 且正常排空 | +| `hblock` | 每钩子 sleep(默认 200µs) | 不死锁、四计数精确 | +| `hchurn` | 4 goroutine 启动期注册风暴 100 钩子 | 首发钩子每事件 == num;风暴钩子每事件 == num×100;换新 Hooks 后仍精确 | +| `hreenter` | 钩子内再提交任务(budget/depth 受限) | 原任务+重入任务全周期计数精确;排空无死锁 | +| `hclose` | 串行/4 路并发 Close、panic 的 OnPoolClosed、close 后提交 | OnPoolClosed 恰好一次、池指针一致;竞态 Close 不重复;关闭后零事件 | + +## 4. 执行结果 + +- 普通构建(`go build`):8 场景全 PASS,`run_hook_stress.bat` 端到端 + 退出码 0(汇总行:`hook-stability suite: all PASS`)。 +- 竞态检测(`go build -race`):8 场景全部干净(无 DATA RACE)。 +- 根模块回归:`go test ./...`(38.5s + 22.9s benchmark 包)全部通过; + 钩子相关单测(`TestHook*Panic*` 系列)通过。 + +## 5. 关键结论 + +1. **注册契约**:`internal/hook.Hooks` 的 `Add*` 仅用于启动期(多协程并发 + 注册由互斥锁保证);分发读取已冻结的列表。任务运行中注册不属于契约, + 库未做任何改动。 +2. **panic 双保险成立**:callback 层(库内 `invoke` 逐个 recover)与 + dispatch 层(`Pool.dispatchHook` 整体 recover)均能保证 worker 与 + 提交路径的簿记(wg.Done)不被跳过。 +3. **跨 goroutine 顺序边界**:Enqueued 与 Started 之间无跨 goroutine 的 + happens-before 保证(worker 可能先于提交方 enqueue 回调取走任务), + h* 断言只依赖真实的程序序保证(Submitted 首个、Startednul`/`2>/dev/null` 静默。 +- 未覆盖 `internal/context` 追踪接线(`--hook mode=trace` 仍被显式拒绝)。 +- 本批插件是正确性/稳定性验证,非性能基准;分发开销测量仍由 + `--hook mode=hook` 承担。 +- 测试间互不依赖、可单独运行;给插件的 `-race` 支持已在脚本内留好入口。 diff --git a/docs/test-harness.md b/docs/test-harness.md index 43d5fad..9901a99 100644 --- a/docs/test-harness.md +++ b/docs/test-harness.md @@ -7,9 +7,9 @@ overview and the scenario scripts see `test/README.md`. ## 1. Why plugins -The legacy tool (archived in `test/old/`) flattened pool options, task -duration models, submit strategies, instrumentation, sampling and profiling -into one `main` with short-flag prefixes (`-T`, `-U`, `-w`, ...). That made +The legacy tool flattened pool options, task duration models, submit +strategies, instrumentation, sampling and profiling into one `main` with +short-flag prefixes (`-T`, `-U`, `-w`, ...). That made options ambiguous and scenarios hard to compose. The harness now treats **every capability as a plugin** and the command line as a composition of plugin segments: @@ -231,10 +231,69 @@ Legacy flag → new segment mapping used by the scenario scripts | `-i T -f F -o FILE -e W` | `--metrics interval=T format=F file=FILE wait-exit=W` | | `--cpuprofile --memprofile` | `--profile cpu=true mem=true` | +### 6.1 Hook-stability family (`hcount`/`hpanic`/`horder`/`hctx`/`hblock`/`hchurn`/`hreenter`/`hclose`) + +The `h*` plugins stress-test the stability of the hook system rather than +benchmarking it. They share three design rules: + +1. **Self-contained**: every scenario builds its own private pools with a + known capacity and `queue == task count`, so invariants are + deterministic (the direct channel path fires Enqueued exactly once per + task; the overflow-buffer path may skip it by design) and Close can be + exercised without touching the session `--pool`. +2. **Adversarial hooks within the registration contract**: callbacks are + registered up front (before the pool starts processing — the contract's + registration window) but are otherwise hostile: they panic, sleep, or + resubmit tasks; registration itself is stressed from many goroutines at + once. Registering *during* live dispatch is outside the contract and is + never done (see §7). +3. **Explicit invariants**: each scenario prints `PASS`/`FAIL` lines and + returns an error on the first failure; the host maps it to exit code 1. + The shared machinery (scenario pool builder, atomic counters, drain and + goroutine-leak waiters, reporting) lives in `hookcheck.go` so each + scenario file is just its scenario. + +| Plugin | Invariants asserted (per task unless noted) | +|---|---| +| `hcount` | with N callbacks × M tasks: every event counter lands on N·M, no loss/duplication; tasks executed == M; no goroutine leak | +| `hpanic` | callbacks (bundled `Hooks`) or whole dispatch (hostile impl) panicking at each stage still let the pool execute, drain, and Close; follow-up burst after detaching hooks also runs; post-close submissions stay silent | +| `horder` | per id (carried via `SubmitCtx`): Submitted is the first event, Started precedes Completed, no duplicates; ctx payload reaches all events; Completed delivers the exact panic value (nil otherwise) | +| `hctx` | ctx payloads reach all events; already-canceled contexts fire nothing; cancel-while-queued still yields Started == Completed per dequeued task and a clean drain | +| `hblock` | callbacks sleeping `delay-us` never deadlock the pool and every event counter is exact | +| `hchurn` | registration burst from many goroutines happens strictly before any dispatch; pre-registered and burst hooks then all see every event exactly (no loss/duplication through the contended window); a fresh `Hooks` instance afterwards accounts exactly | +| `hreenter` | hooks that submit new tasks (bounded by `budget`/`depth`) drain with exact counters for originals + reentries | +| `hclose` | OnPoolClosed fires exactly once (sequential and racing Close), with pool identity; panicking PoolClosed callbacks/dispatch don't abort Close; post-close submissions fire no events | + +`run_hook_stress.bat` / `run_hook_stress.sh` run the whole family; +`run_hook_stress.* race` builds with `-race` first, so the entire family — +including the contended registration burst of `hchurn` — is proven +race-free in CI. + +Notes for interpretation: + +- `hpanic level=callback` logs each recovered panic to stderr by design + (that is the bundled `Hooks` logger working); the scripts silence it. +- A scenario pool only asserts what is a real happens-before guarantee. + Enqueued may legitimately land after Started when a worker dequeues the + task before the submitter's enqueue callback runs, so no `h*` assertion + depends on Enqueued-vs-Started ordering. + ## 7. Status and notes +- `test/` is a nested Go module (`github.com/Yiming1997/agilePool/v2/test`, + own `go.mod`) whose `replace` points the library import at the repo root, + so the harness builds against the local tree, never the network copy. The + module path keeps the library prefix so `internal/hook` stays importable. +- Registration contract of `internal/hook.Hooks`: callbacks are added + before the pool starts processing (typically from setup code, possibly + concurrently — `Add*` is mutex-guarded). Dispatch reads the frozen lists + and never synchronizes with registration; adding a callback while events + are being dispatched is **outside the contract** and would race on the + callback slices. Scenarios follow the contract: they register before the + first task (`hchurn` stresses exactly that window) and never call `Add*` + from a running pool. - Metrics CSV/JSON columns and formats are preserved from the legacy tool column for column (the CSV header is written on the first tick). - `--hook mode=trace` is intentionally rejected until the upstream context-tracing API (`internal/context`) is wired. -- The old implementation is archived under `test/old/` (not compiled). +- The superseded legacy implementation remains available in the git history. diff --git a/go.mod b/go.mod index 2f5d4a2..2f73599 100644 --- a/go.mod +++ b/go.mod @@ -6,15 +6,6 @@ require github.com/stretchr/testify v1.11.1 require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect - github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/shirou/gopsutil/v3 v3.24.5 // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect - github.com/tklauser/go-sysconf v0.3.12 // indirect - github.com/tklauser/numcpus v0.6.1 // indirect - github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/sys v0.20.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5ac0ce1..c4c1710 100644 --- a/go.sum +++ b/go.sum @@ -1,34 +1,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= -github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= -github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= -github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/README.md b/test/README.md index ee730bd..b088b45 100644 --- a/test/README.md +++ b/test/README.md @@ -1,9 +1,9 @@ # agilepool Test Harness (Plugin-Based) -The performance test tool for go-agile-pool; the legacy tool is archived in -`old/`. The full design document and **plugin authoring guide** (English) -live in [`docs/test-harness.md`](../docs/test-harness.md), and a runnable -integration example is in [`example.go`](example.go). Chinese version: +The performance test tool for go-agile-pool. The full design document and +**plugin authoring guide** (English) live in +[`docs/test-harness.md`](../docs/test-harness.md), and a runnable integration +example is in [`example.go`](example.go). Chinese version: [`README_zh-CN.md`](README_zh-CN.md). ## What this is @@ -27,6 +27,23 @@ segments, plans dependencies and drives the lifecycle. **Adding a capability | `--metrics` | periodic sampling + summary | `interval`(1s) `format`(csv) `file`(metrics.csv) `wait-exit`(0) | Start sampler / End final tick + window + summary | | `--profile` | pprof enveloping the session | `cpu`(false) `mem`(false) | Start CPU / End stop CPU + write heap | +### Hook-stability stress plugins (`h*` family) + +Each is a standalone scenario that builds its own private pools, installs +adversarial hooks and asserts PASS/FAIL invariants (exit 1 on the first +FAIL). They are deliberately dependency-free so `--hcount` alone works: + +| Segment | Scenario under stress | Key options (default) | +|---|---|---| +| `--hcount` | exact per-event accounting: N callbacks × M tasks, no lost/duplicated event | `num`(20000) `hooks`(8) `workers`(1000) | +| `--hpanic` | panicking hooks must not kill submit/worker/Close paths | `num`(3000) `level`(dispatch/callback) `stage`(all/…) | +| `--horder` | per-task order (Submitted first, Started before Completed) + ctx payload + panic-value fidelity | `num`(20000) `panics`(2000) `workers`(1000) | +| `--hctx` | ctx payload through all events; pre-canceled and cancel-while-queued semantics | `num`(3000) `queued`(150) | +| `--hblock` | slow/blocking hooks must not deadlock or lose events | `num`(5000) `delay-us`(200) `workers`(200) | +| `--hchurn` | concurrent registration burst before dispatch (contract window), exact accounting | `num`(10000) `num2`(5000) `churners`(4) `adds`(25) | +| `--hreenter` | reentrant dispatch: hooks that submit new tasks | `num`(2000) `budget`(2000) `depth`(32) `stage`(submitted/completed) | +| `--hclose` | OnPoolClosed exactly-once (incl. racing Close) and post-close silence | `num`(2000) `closers`(4) | + Submit strategies: immediate / linear / constant / poisson / phased. Task duration types: fixed / uniform / normal. Dependencies: `submit ← pool+task`, `hook ← pool`, `metrics ← pool`. Missing dependency @@ -56,8 +73,15 @@ file names): ```text run_test.bat # Windows run_test.sh # Linux/macOS +run_hook_stress.bat # Windows: all h* hook-stability scenarios +run_hook_stress.sh # Linux/macOS: same; run_hook_stress.* race -> -race build ``` +`test/` is its own Go module (`github.com/Yiming1997/agilePool/v2/test`); a +`replace` directive points the `github.com/Yiming1997/agilePool/v2` import +at the repo root directory, so the harness always builds against the local +library and never downloads it. + ## Conventions at a glance - A **segment head** is a registered plugin name with a `--` prefix; it opens @@ -95,9 +119,12 @@ test/ plugins.go central registration table example.go demo plugins provider/consumer (not registered by default) pool.go ... the six real plugins (one file per plugin) + hookcheck.go shared machinery for the h* stability family + hcount.go ... the eight h* hook-stability plugins (h*.go, one per file) run_test.bat Windows regression script (new syntax) run_test.sh Linux/macOS regression script (same scenarios as the bat) - old/ legacy tool archive (sources/plot scripts, not compiled) + run_hook_stress.bat/.sh hook-stability scenario scripts (optional `race` arg) + plot_csv.py plots metrics_*.csv into per-file SVGs (run from the results dir) ``` Code comments are English, matching the rest of the library; the design doc diff --git a/test/README_zh-CN.md b/test/README_zh-CN.md index 54178c9..5ddaf77 100644 --- a/test/README_zh-CN.md +++ b/test/README_zh-CN.md @@ -1,7 +1,7 @@ # agilepool 测试工具(插件式) -go-agile-pool 的性能测试工具,旧版已归档到 `old/`。完整设计文档与插件编写 -指南(英文)见 [`docs/test-harness.md`](../../docs/test-harness.md);接入范例见 +go-agile-pool 的性能测试工具。完整设计文档与插件编写指南(英文)见 +[`docs/test-harness.md`](../../docs/test-harness.md);接入范例见 [`example.go`](example.go)。English version: [`README.md`](README.md)。 ## 这是什么 @@ -22,6 +22,22 @@ go-agile-pool 的性能测试工具,旧版已归档到 `old/`。完整设计文 | `--metrics` | 周期采样 + 汇总 | `interval`(1s) `format`(csv) `file`(metrics.csv) `wait-exit`(0) | Start 起采样 / End 收尾拍+观察窗+汇总 | | `--profile` | pprof 包络会话 | `cpu`(false) `mem`(false) | Start 开 CPU / End 停 CPU+写 heap | +### 钩子稳定性压测插件(`h*` 家族) + +每个插件都是独立场景:自建私有池、装上对抗性钩子、断言 PASS/FAIL 不变量 +(首个 FAIL 即退出码 1)。刻意不声明依赖,`--hcount` 可单独运行: + +| 段 | 施压场景 | 关键选项(默认) | +|---|---|---| +| `--hcount` | 精确计数:N 个钩子 × M 个任务,事件不丢不重 | `num`(20000) `hooks`(8) `workers`(1000) | +| `--hpanic` | 钩子 panic 不得击穿提交/执行/Close 路径 | `num`(3000) `level`(dispatch/callback) `stage`(all/…) | +| `--horder` | 单任务事件序(Submitted 首、Started 先于 Completed)+ ctx 载荷 + panic 值透传 | `num`(20000) `panics`(2000) `workers`(1000) | +| `--hctx` | ctx 载荷贯穿全事件;预取消与排队中取消语义 | `num`(3000) `queued`(150) | +| `--hblock` | 慢/阻塞钩子不得死锁或丢事件 | `num`(5000) `delay-us`(200) `workers`(200) | +| `--hchurn` | 分发前多协程并发注册风暴(契约窗口),精确记账 | `num`(10000) `num2`(5000) `churners`(4) `adds`(25) | +| `--hreenter` | 重入分发:钩子内再提交新任务 | `num`(2000) `budget`(2000) `depth`(32) `stage`(submitted/completed) | +| `--hclose` | OnPoolClosed 恰好一次(含并发 Close)+ 关闭后静默 | `num`(2000) `closers`(4) | + submit 策略:immediate / linear / constant / poisson / phased;任务时长类型: fixed / uniform / normal。依赖关系:`submit ← pool+task`,`hook ← pool`, `metrics ← pool`,漏写依赖段时宿主按默认参数自动补全(如只写 `--submit` @@ -47,8 +63,14 @@ go run . --pool workers=20000 queue=10000 \ ```text run_test.bat # Windows run_test.sh # Linux/macOS +run_hook_stress.bat # Windows:跑全部 h* 钩子稳定性场景 +run_hook_stress.sh # Linux/macOS:同;run_hook_stress.* race -> -race 构建 ``` +`test/` 是独立 Go 模块(`github.com/Yiming1997/agilePool/v2/test`), +`replace` 把 `github.com/Yiming1997/agilePool/v2` 指到仓库根目录,工具始终 +编译本地库、绝不下发网络版本。 + ## 约定速览 - 段头 = 命中注册插件名的 `--xxx`,任意位置开新段;段内参数**按原顺序原样透传** @@ -79,9 +101,12 @@ test/ plugins.go 集中注册表 example.go 演示插件 provider/consumer(默认不注册) pool.go ... 六个正式插件(一文件一插件) + hookcheck.go h* 稳定性家族共用机制 + hcount.go ... 八个 h* 钩子稳定性插件(h*.go,一文件一插件) run_test.bat Windows 回归脚本(新语法) run_test.sh Linux/macOS 回归脚本(与 bat 同场景) - old/ 旧工具归档(源码/绘图脚本,不参与编译) + run_hook_stress.bat/.sh 钩子稳定性场景脚本(可带 `race` 参数) + plot_csv.py 将 metrics_*.csv 画成每个文件一份 SVG(在结果目录运行) ``` 代码注释为英文,与库内其他包风格一致;设计文档 `docs/test-harness.md` 为 diff --git a/test/go.mod b/test/go.mod new file mode 100644 index 0000000..fe98427 --- /dev/null +++ b/test/go.mod @@ -0,0 +1,27 @@ +// test/ is a separate module so the harness and its heavy deps +// (gopsutil) never pollute the root library module. The module path keeps +// the github.com/Yiming1997/agilePool/v2 prefix so internal packages +// (internal/hook) stay importable, and the replace below forces every +// agilePool import to resolve to the root directory on disk: building the +// harness never downloads or uses the network copy of the library. +module github.com/Yiming1997/agilePool/v2/test + +go 1.26.4 + +require ( + github.com/Yiming1997/agilePool/v2 v2.1.0 + github.com/shirou/gopsutil/v3 v3.24.5 +) + +replace github.com/Yiming1997/agilePool/v2 => ../ + +require ( + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.20.0 // indirect +) diff --git a/test/go.sum b/test/go.sum new file mode 100644 index 0000000..ed902db --- /dev/null +++ b/test/go.sum @@ -0,0 +1,36 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/hblock.go b/test/hblock.go new file mode 100644 index 0000000..5e29ed1 --- /dev/null +++ b/test/hblock.go @@ -0,0 +1,100 @@ +package main + +// hblock stress-tests the hook system under slow callbacks: every lifecycle +// callback sleeps, so at any instant a large share of workers and the +// submitting goroutine are inside hook dispatch. The pool must not deadlock +// (dispatch holds no pool locks) and every task must still see exactly one +// of each event. The reported per-task hook time is informational — the +// invariant is completion, not speed. + +import ( + "context" + "fmt" + "time" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/Yiming1997/agilePool/v2/internal/hook" +) + +type hblockPlugin struct{} + +func (hblockPlugin) Name() string { return "hblock" } +func (hblockPlugin) Desc() string { + return "hook stability: slow/blocking hooks must not deadlock the pool" +} + +func (hblockPlugin) Options() []Option { + return []Option{ + {Name: "num", Default: "5000", Help: "tasks to submit"}, + {Name: "delay-us", Default: "200", Help: "sleep per hook invocation (microseconds)"}, + {Name: "workers", Default: "200", Help: "worker capacity of the scenario pool"}, + } +} + +func (hblockPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + num, err := GetInt(opts, "num", 5000) + if err != nil { + return err + } + delayUs, err := GetInt(opts, "delay-us", 200) + if err != nil { + return err + } + workers, err := GetInt(opts, "workers", 200) + if err != nil { + return err + } + if num < 1 || workers < 1 { + return fmt.Errorf("num/workers must be >= 1") + } + if delayUs < 0 { + return fmt.Errorf("option %q: must be >= 0", "delay-us") + } + + baseline := goroutineBaseline() + p := newScenarioPool(int64(workers), int64(num)) + defer p.Close() + + h := hook.NewHooks() + var cnt counters + slow := func() { time.Sleep(time.Duration(delayUs) * time.Microsecond) } + h.AddTaskSubmitted(func(context.Context, agilepool.Task) { slow(); cnt.Submitted.Add(1) }) + h.AddTaskEnqueued(func(context.Context, agilepool.Task) { slow(); cnt.Enqueued.Add(1) }) + h.AddTaskStarted(func(context.Context, agilepool.Task) { slow(); cnt.Started.Add(1) }) + h.AddTaskCompleted(func(context.Context, agilepool.Task, any) { slow(); cnt.Completed.Add(1) }) + if err := p.SetHook(h); err != nil { + return err + } + + start := time.Now() + for i := 0; i < num; i++ { + p.Submit(agilepool.TaskFunc(func() error { return nil })) + } + if !drain(p, 60*time.Second) { + return fmt.Errorf("pool deadlocked with %dus blocking hooks (did not drain in 60s)", delayUs) + } + elapsed := time.Since(start) + + s, e, st, c := cnt.snapshot() + if err := report("hblock", rt, + s == int64(num) && e == int64(num) && st == int64(num) && c == int64(num), + "counters submitted=%d enqueued=%d started=%d completed=%d, want all == %d", + s, e, st, c, num); err != nil { + return err + } + perTask := elapsed.Seconds() * 1e6 / float64(num) + if err := report("hblock", rt, true, + "%d tasks drained in %s (%.1f us/task incl. 4x %dus hook sleeps)", + num, elapsed.Round(time.Millisecond), perTask, delayUs); err != nil { + return err + } + + p.SetHook(nil) + p.Close() + if !settleGoroutines(baseline, 4, 5*time.Second) { + return report("hblock", rt, false, + "goroutine leak: %d goroutines after close, baseline %d", goroutineBaseline(), baseline) + } + return report("hblock", rt, true, "slow hooks did not deadlock or lose events") +} diff --git a/test/hchurn.go b/test/hchurn.go new file mode 100644 index 0000000..3fea85a --- /dev/null +++ b/test/hchurn.go @@ -0,0 +1,172 @@ +package main + +// hchurn stress-tests the supported registration window of the hook +// system: hooks are registered up front (here from many goroutines at +// once, exercising Add under contention) and must be frozen before the +// first task starts — dispatching already-running tasks with a changing +// callback list is outside the contract and is never done here. +// +// Two accounting checks: +// +// 1. the pre-registered counting hook sees every event of wave one +// exactly num times; +// 2. every hook added during the registration burst also sees every +// event (their shared counters must land on num * churners * adds), +// proving the burst registered everyone with no loss or duplication; +// 3. after wave one a fresh Hooks instance on the same pool accounts +// exactly, i.e. swapping the hook set between runs stays intact. + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/Yiming1997/agilePool/v2/internal/hook" +) + +type hchurnPlugin struct{} + +func (hchurnPlugin) Name() string { return "hchurn" } +func (hchurnPlugin) Desc() string { + return "hook stability: concurrent registration burst, then exact accounting" +} + +func (hchurnPlugin) Options() []Option { + return []Option{ + {Name: "num", Default: "10000", Help: "tasks in the first wave"}, + {Name: "num2", Default: "5000", Help: "tasks in the verification wave"}, + {Name: "churners", Default: "4", Help: "goroutines registering hooks before dispatch"}, + {Name: "adds", Default: "25", Help: "hooks each churner registers"}, + {Name: "workers", Default: "500", Help: "worker capacity of the scenario pool"}, + } +} + +func (hchurnPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + num, err := GetInt(opts, "num", 10000) + if err != nil { + return err + } + num2, err := GetInt(opts, "num2", 5000) + if err != nil { + return err + } + churners, err := GetInt(opts, "churners", 4) + if err != nil { + return err + } + adds, err := GetInt(opts, "adds", 25) + if err != nil { + return err + } + workers, err := GetInt(opts, "workers", 500) + if err != nil { + return err + } + if num < 1 || num2 < 1 || workers < 1 { + return fmt.Errorf("num/num2/workers must be >= 1") + } + if churners < 0 || adds < 0 { + return fmt.Errorf("churners/adds must be >= 0") + } + + baseline := goroutineBaseline() + p := newScenarioPool(int64(workers), int64(num)+int64(num2)) + defer p.Close() + + var executed atomic.Int64 + task := agilepool.TaskFunc(func() error { executed.Add(1); return nil }) + + // Startup phase: a counting hook is registered first, then churners + // register `adds` hooks each from their own goroutines. All of this + // happens before the pool starts running tasks (SetHook and the first + // Submit come after the burst), which is the contract's registration + // window. Every churn-added hook increments the shared `burst` + // counters, so after wave one they must show num * churners * adds. + churn := hook.NewHooks() + var first counters // the pre-registered hook: must see num per event + var burst counters // the burst hooks: must see num * churners * adds per event + first.addTo(churn, 1) + + var churnWG sync.WaitGroup + for g := 0; g < churners; g++ { + churnWG.Add(1) + go func() { + defer churnWG.Done() + for i := 0; i < adds; i++ { + // One hook per event type per iteration, so every event + // list deterministically receives churners*adds burst hooks. + churn.AddTaskSubmitted(func(context.Context, agilepool.Task) { burst.Submitted.Add(1) }) + churn.AddTaskEnqueued(func(context.Context, agilepool.Task) { burst.Enqueued.Add(1) }) + churn.AddTaskStarted(func(context.Context, agilepool.Task) { burst.Started.Add(1) }) + churn.AddTaskCompleted(func(context.Context, agilepool.Task, any) { burst.Completed.Add(1) }) + } + }() + } + churnWG.Wait() // registration window closed before any dispatch + + if err := p.SetHook(churn); err != nil { + return err + } + for i := 0; i < num; i++ { + p.Submit(task) + } + if !drain(p, 30*time.Second) { + return fmt.Errorf("wave one did not drain within 30s") + } + + perBurst := int64(num * churners * adds) + s, e, st, c := first.snapshot() + if err := report("hchurn", rt, + s == int64(num) && e == int64(num) && st == int64(num) && c == int64(num), + "pre-registered hook saw submitted=%d enqueued=%d started=%d completed=%d, want all == %d", + s, e, st, c, num); err != nil { + return err + } + bs, be, bst, bc := burst.snapshot() + if err := report("hchurn", rt, + bs == perBurst && be == perBurst && bst == perBurst && bc == perBurst, + "%d burst hooks each saw every event: submitted=%d enqueued=%d started=%d completed=%d, want all == %d", + churners*adds, bs, be, bst, bc, perBurst); err != nil { + return err + } + + // Verification wave: fresh hooks on the same pool must account exactly, + // proving the swap between runs did not corrupt dispatch state. + verify := hook.NewHooks() + var cnt2 counters + cnt2.addTo(verify, 1) + if err := p.SetHook(verify); err != nil { + return err + } + for i := 0; i < num2; i++ { + p.Submit(task) + } + if !drain(p, 30*time.Second) { + return fmt.Errorf("verification wave did not drain within 30s") + } + + s2, e2, st2, c2 := cnt2.snapshot() + if err := report("hchurn", rt, + s2 == int64(num2) && e2 == int64(num2) && st2 == int64(num2) && c2 == int64(num2), + "post-burst hook set: submitted=%d enqueued=%d started=%d completed=%d, want all == %d", + s2, e2, st2, c2, num2); err != nil { + return err + } + if err := report("hchurn", rt, executed.Load() == int64(num+num2), + "tasks executed %d == %d across both waves", executed.Load(), num+num2); err != nil { + return err + } + + p.SetHook(nil) + p.Close() + if !settleGoroutines(baseline, 4, 5*time.Second) { + return report("hchurn", rt, false, + "goroutine leak: %d goroutines after close, baseline %d", goroutineBaseline(), baseline) + } + return report("hchurn", rt, true, + "survived a %d-goroutine registration burst of %d hooks, accounting exact", churners, churners*adds) +} diff --git a/test/hclose.go b/test/hclose.go new file mode 100644 index 0000000..561c6ce --- /dev/null +++ b/test/hclose.go @@ -0,0 +1,179 @@ +package main + +// hclose stress-tests OnPoolClosed and the closed-pool behavior around it: +// +// 1. exactly-once: Close fires OnPoolClosed once; a second Close is a no-op. +// 2. racing Close: several goroutines closing simultaneously still fire the +// hook exactly once (the closed flag CAS wins exactly one). +// 3. panicking OnPoolClosed: both a panicking callback and a hostile hooks +// implementation panicking at DispatchPoolClosed must not abort Close. +// 4. post-close silence: submissions after Close are dropped and must not +// generate any hook event; Wait still balances. + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/Yiming1997/agilePool/v2/internal/hook" +) + +type hclosePlugin struct{} + +func (hclosePlugin) Name() string { return "hclose" } +func (hclosePlugin) Desc() string { + return "hook stability: OnPoolClosed exactly-once and closed-pool silence" +} + +func (hclosePlugin) Options() []Option { + return []Option{ + {Name: "num", Default: "2000", Help: "tasks submitted before/around Close"}, + {Name: "closers", Default: "4", Help: "goroutines racing to Close in scenario 2"}, + } +} + +func (hclosePlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + num, err := GetInt(opts, "num", 2000) + if err != nil { + return err + } + closers, err := GetInt(opts, "closers", 4) + if err != nil { + return err + } + if num < 1 || closers < 2 { + return fmt.Errorf("num must be >= 1, closers >= 2") + } + + baseline := goroutineBaseline() + + // Scenario 1: sequential double Close, exactly one hook. + { + p := newScenarioPool(500, int64(num)) + var closed atomic.Int64 + var closedPool atomic.Pointer[agilepool.Pool] + h := hook.NewHooks() + h.AddPoolClosed(func(pool *agilepool.Pool) { + closed.Add(1) + closedPool.Store(pool) + }) + if err := p.SetHook(h); err != nil { + return err + } + for i := 0; i < num; i++ { + p.Submit(agilepool.TaskFunc(func() error { return nil })) + } + if !drain(p, 30*time.Second) { + return fmt.Errorf("[exactly-once] pool did not drain") + } + p.Close() + p.Close() // second Close must be a no-op for the hook + if err := report("hclose", rt, closed.Load() == 1 && closedPool.Load() == p, + "[exactly-once] OnPoolClosed fired %d time(s), pool pointer identity held", closed.Load()); err != nil { + return err + } + } + + // Scenario 2: racing Closes. + { + p := newScenarioPool(500, int64(num)) + var closed atomic.Int64 + var executed atomic.Int64 + h := hook.NewHooks() + h.AddPoolClosed(func(*agilepool.Pool) { closed.Add(1) }) + if err := p.SetHook(h); err != nil { + return err + } + for i := 0; i < num; i++ { + p.Submit(agilepool.TaskFunc(func() error { + executed.Add(1) + return nil + })) + } + var wg sync.WaitGroup + for i := 0; i < closers; i++ { + wg.Add(1) + go func() { defer wg.Done(); p.Close() }() + } + wg.Wait() + // In-flight tasks must still complete after Close (graceful drain). + if !drain(p, 30*time.Second) { + return fmt.Errorf("[racing] pool did not drain after racing Close") + } + if err := report("hclose", rt, closed.Load() == 1, + "[racing] %d concurrent Close calls fired OnPoolClosed %d time(s)", closers, closed.Load()); err != nil { + return err + } + if err := report("hclose", rt, executed.Load() == int64(num), + "[racing] all %d tasks completed despite racing Close", executed.Load()); err != nil { + return err + } + } + + // Scenario 3a: panicking OnPoolClosed callback (bundled Hooks recovers). + { + p := newScenarioPool(100, 100) + h := hook.NewHooks() + h.AddPoolClosed(func(*agilepool.Pool) { panic(panicMsg) }) + if err := p.SetHook(h); err != nil { + return err + } + p.Close() // must return normally + if err := report("hclose", rt, true, + "[panicking-callback] Close returned despite panicking OnPoolClosed callback"); err != nil { + return err + } + } + + // Scenario 3b: hostile hooks implementation panicking at dispatch. + { + p := newScenarioPool(100, 100) + ph := &panicHooks{panicWhen: func(d string) bool { return d == "closed" }, panicValue: panicMsg} + if err := p.SetHook(ph); err != nil { + return err + } + p.Close() // must return normally + if err := report("hclose", rt, true, + "[panicking-dispatch] Close returned despite DispatchPoolClosed panicking"); err != nil { + return err + } + } + + // Scenario 4: post-close silence. + { + p := newScenarioPool(100, 100) + var cnt counters + var closed atomic.Int64 + h := hook.NewHooks() + cnt.addTo(h, 1) + h.AddPoolClosed(func(*agilepool.Pool) { closed.Add(1) }) + if err := p.SetHook(h); err != nil { + return err + } + p.Close() + for i := 0; i < 200; i++ { + p.Submit(agilepool.TaskFunc(func() error { return nil })) + p.TrySubmit(agilepool.TaskFunc(func() error { return nil })) + } + if !drain(p, 10*time.Second) { + return fmt.Errorf("[silence] Wait did not return after post-close submissions") + } + s, e, st, c := cnt.snapshot() + if err := report("hclose", rt, + s == 0 && e == 0 && st == 0 && c == 0 && closed.Load() == 1, + "[silence] 400 post-close submissions fired no events (S=%d E=%d St=%d C=%d, closed=%d)", + s, e, st, c, closed.Load()); err != nil { + return err + } + } + + if !settleGoroutines(baseline, 4, 5*time.Second) { + return report("hclose", rt, false, + "goroutine leak: %d goroutines after scenario, baseline %d", goroutineBaseline(), baseline) + } + return report("hclose", rt, true, "OnPoolClosed and closed-pool semantics stable") +} diff --git a/test/hcount.go b/test/hcount.go new file mode 100644 index 0000000..d45a419 --- /dev/null +++ b/test/hcount.go @@ -0,0 +1,102 @@ +package main + +// hcount stress-tests hook dispatch at scale: many callbacks per lifecycle +// event across many tasks, asserting that no event is lost or duplicated +// under real worker concurrency. The scenario pool uses queue == task count +// so every accepted task takes the direct channel path (Enqueued fires +// exactly once per task) and the four counters must all land on num. +// +// This is the accounting baseline of the family: hpanic/hblock/hchurn keep +// the same four-counter contract wherever their scenario allows it. + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/Yiming1997/agilePool/v2/internal/hook" +) + +type hcountPlugin struct{} + +func (hcountPlugin) Name() string { return "hcount" } +func (hcountPlugin) Desc() string { + return "hook stability: N callbacks x M tasks, exact per-event accounting" +} + +func (hcountPlugin) Options() []Option { + return []Option{ + {Name: "num", Default: "20000", Help: "tasks to submit"}, + {Name: "hooks", Default: "8", Help: "callbacks per lifecycle event"}, + {Name: "workers", Default: "1000", Help: "worker capacity of the scenario pool"}, + } +} + +func (hcountPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + num, err := GetInt(opts, "num", 20000) + if err != nil { + return err + } + nHooks, err := GetInt(opts, "hooks", 8) + if err != nil { + return err + } + workers, err := GetInt(opts, "workers", 1000) + if err != nil { + return err + } + if num < 1 || nHooks < 1 || workers < 1 { + return fmt.Errorf("num/hooks/workers must all be >= 1") + } + + baseline := goroutineBaseline() + p := newScenarioPool(int64(workers), int64(num)) + // Error paths leave the pool for defer; the success path closes it + // explicitly so the goroutine-settling check runs after Close. + defer p.Close() + + h := hook.NewHooks() + var cnt counters + cnt.addTo(h, nHooks) + if err := p.SetHook(h); err != nil { + return err + } + + var executed atomic.Int64 + for i := 0; i < num; i++ { + p.Submit(agilepool.TaskFunc(func() error { + executed.Add(1) + return nil + })) + } + + start := time.Now() + if !drain(p, 30*time.Second) { + return fmt.Errorf("pool did not drain within 30s: hook dispatch lost a wg.Done") + } + elapsed := time.Since(start) + + s, e, st, c := cnt.snapshot() + want := int64(num) * int64(nHooks) + if err := report("hcount", rt, + s == want && e == want && st == want && c == want, + "counters submitted=%d enqueued=%d started=%d completed=%d, want all == %d (%d hooks x %d tasks in %s)", + s, e, st, c, want, nHooks, num, elapsed.Round(time.Millisecond)); err != nil { + return err + } + if err := report("hcount", rt, executed.Load() == int64(num), + "tasks executed %d == num %d", executed.Load(), num); err != nil { + return err + } + + p.SetHook(nil) + p.Close() + if !settleGoroutines(baseline, 4, 5*time.Second) { + return report("hcount", rt, false, + "goroutine leak: %d goroutines after close, baseline %d", goroutineBaseline(), baseline) + } + return report("hcount", rt, true, "pool closed cleanly, goroutines settled") +} diff --git a/test/hctx.go b/test/hctx.go new file mode 100644 index 0000000..ced7d06 --- /dev/null +++ b/test/hctx.go @@ -0,0 +1,184 @@ +package main + +// hctx stress-tests context handling across the hook boundary: +// +// 1. live markers: SubmitCtx payloads must reach all four events unchanged +// (the pool unwraps contextTask for hooks on the submit and worker +// paths; a lost/unwrapped ctx would drop the marker). +// 2. already-canceled ctx: SubmitCtx rejects it before any hook fires — +// the counters must not move at all. +// 3. cancel while queued: tasks still get Started/Completed exactly once +// each (the worker always starts and finishes what it dequeues, even +// when the body is skipped via the canceled context), the pool drains, +// and no submitter or worker goroutine is stranded. + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/Yiming1997/agilePool/v2/internal/hook" +) + +type hctxPlugin struct{} + +func (hctxPlugin) Name() string { return "hctx" } +func (hctxPlugin) Desc() string { return "hook stability: context payload and cancellation semantics" } + +func (hctxPlugin) Options() []Option { + return []Option{ + {Name: "num", Default: "3000", Help: "tasks in the live-marker wave"}, + {Name: "queued", Default: "150", Help: "tasks in the cancel-while-queued wave"}, + } +} + +// ctxMarkerKey carries the wave marker through SubmitCtx. +type ctxMarkerKey struct{} + +func (hctxPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + num, err := GetInt(opts, "num", 3000) + if err != nil { + return err + } + queued, err := GetInt(opts, "queued", 150) + if err != nil { + return err + } + if num < 1 || queued < 1 { + return fmt.Errorf("num/queued must be >= 1") + } + + baseline := goroutineBaseline() + var badCtx atomic.Int64 + var executed atomic.Int64 + var cnt counters + + install := func(p *agilepool.Pool) error { + h := hook.NewHooks() + h.AddTaskSubmitted(func(ctx context.Context, _ agilepool.Task) { + if _, ok := ctx.Value(ctxMarkerKey{}).(int); !ok { + badCtx.Add(1) + } + }) + h.AddTaskEnqueued(func(ctx context.Context, _ agilepool.Task) { + if _, ok := ctx.Value(ctxMarkerKey{}).(int); !ok { + badCtx.Add(1) + } + }) + h.AddTaskStarted(func(ctx context.Context, _ agilepool.Task) { + if _, ok := ctx.Value(ctxMarkerKey{}).(int); !ok { + badCtx.Add(1) + } + }) + h.AddTaskCompleted(func(ctx context.Context, _ agilepool.Task, _ any) { + if _, ok := ctx.Value(ctxMarkerKey{}).(int); !ok { + badCtx.Add(1) + } + }) + cnt.addTo(h, 1) + return p.SetHook(h) + } + + // Wave 1: live marker propagation with all events firing. + p1 := newScenarioPool(500, int64(num)) + if err := install(p1); err != nil { + return err + } + for i := 0; i < num; i++ { + ctx, cancel := context.WithCancel(context.WithValue(context.Background(), ctxMarkerKey{}, i)) + task := agilepool.TaskFunc(func() error { + executed.Add(1) + time.Sleep(2 * time.Millisecond) + cancel() // exercised end-to-end, harmless for hooks + return nil + }) + p1.SubmitCtx(ctx, task) + } + if !drain(p1, 30*time.Second) { + return fmt.Errorf("[live markers] pool did not drain within 30s") + } + s1, e1, st1, c1 := cnt.snapshot() + if err := report("hctx", rt, + s1 == int64(num) && e1 == int64(num) && st1 == int64(num) && c1 == int64(num), + "[live markers] totals submitted=%d enqueued=%d started=%d completed=%d for num=%d", + s1, e1, st1, c1, num); err != nil { + return err + } + if err := report("hctx", rt, executed.Load() == int64(num), + "[live markers] %d tasks executed", executed.Load()); err != nil { + return err + } + p1.SetHook(nil) + p1.Close() + + // Wave 2: already-canceled contexts must be rejected before any hook. + p2 := newScenarioPool(8, 64) + if err := install(p2); err != nil { + return err + } + canceled, cancelFn := context.WithCancel(context.Background()) + cancelFn() + for i := 0; i < 100; i++ { + p2.SubmitCtx(canceled, agilepool.TaskFunc(func() error { return nil })) + } + s2, e2, st2, c2 := cnt.snapshot() + if err := report("hctx", rt, + s2 == s1 && e2 == e1 && st2 == st1 && c2 == c1, + "[pre-canceled] rejected 100 submits, counters unchanged (S=%d E=%d St=%d C=%d)", + s2, e2, st2, c2); err != nil { + return err + } + p2.SetHook(nil) + p2.Close() + + // Wave 3: cancel while tasks are queued. Two slow workers and a small + // queue guarantee a backlog; cancel lands mid-drain. + p3 := newScenarioPool(2, 16) + if err := install(p3); err != nil { + return err + } + live, stop := context.WithCancel(context.Background()) + go func() { + time.Sleep(15 * time.Millisecond) + stop() + }() + executed.Store(0) + for i := 0; i < queued; i++ { + ctx := context.WithValue(live, ctxMarkerKey{}, i) + p3.SubmitCtx(ctx, agilepool.TaskFunc(func() error { + executed.Add(1) + time.Sleep(15 * time.Millisecond) + return nil + })) + } + if !drain(p3, 30*time.Second) { + return fmt.Errorf("[cancel-queued] pool did not drain: a canceled context stranded a submitter or worker") + } + s3, e3, st3, c3 := cnt.snapshot() + ds, de, dst, dc := s3-s1, e3-e1, st3-st1, c3-c1 + execDone := executed.Load() + if err := report("hctx", rt, dst == dc && dst == int64(queued), + "[cancel-queued] started=%d completed=%d of %d queued: every dequeued task completes exactly once", + dst, dc, queued); err != nil { + return err + } + if err := report("hctx", rt, execDone >= 0 && execDone <= int64(queued) && de <= ds && dst <= ds, + "[cancel-queued] %d/%d bodies ran before cancellation, hooks stayed symmetric", execDone, queued); err != nil { + return err + } + p3.SetHook(nil) + p3.Close() + + if err := report("hctx", rt, badCtx.Load() == 0, + "ctx payload lost across %d hook invocations", badCtx.Load()); err != nil { + return err + } + if !settleGoroutines(baseline, 4, 5*time.Second) { + return report("hctx", rt, false, + "goroutine leak: %d goroutines after scenario, baseline %d", goroutineBaseline(), baseline) + } + return report("hctx", rt, true, "context semantics held across all three waves") +} diff --git a/test/hookcheck.go b/test/hookcheck.go new file mode 100644 index 0000000..91a4b39 --- /dev/null +++ b/test/hookcheck.go @@ -0,0 +1,184 @@ +package main + +// hookcheck.go holds the shared machinery for the hook-stability plugin +// family (hcount/hpanic/horder/hctx/hblock/hchurn/hreenter/hclose). The +// plugins themselves only define a scenario: build a private pool, install +// adversarial hooks, run tasks, then assert invariants. Everything common — +// scenario pool construction, counting callbacks, drain/leak waiting and +// PASS/FAIL reporting — lives here so each scenario file stays readable. +// +// Every scenario is self-contained on purpose: it creates its own pools +// with a known capacity/queue (instead of borrowing the session --pool) so +// invariants are deterministic (e.g. queue >= task count rules out the +// overflow-buffer path where Enqueued is intentionally skipped) and Close +// semantics can be exercised without disturbing other segments. + +import ( + "context" + "fmt" + "runtime" + "sync/atomic" + "time" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/Yiming1997/agilePool/v2/internal/hook" +) + +// discardLogger swallows pool log lines. Hook-panic scenarios log every +// recovered panic through the pool logger by design; pointing it at this +// keeps the PASS/FAIL output readable while the recovery paths are still +// exercised. +type discardLogger struct{} + +func (discardLogger) Printf(string, ...any) {} +func (discardLogger) Println(...any) {} + +// counters is one atomic counter per lifecycle event. Scenario callbacks +// only increment these (the Store-layering rule of the host applies here +// too: hot-path data stays in atomic state owned by the plugin). +type counters struct { + Submitted atomic.Int64 + Enqueued atomic.Int64 + Started atomic.Int64 + Completed atomic.Int64 +} + +// addTo registers n callbacks per lifecycle event on h, each incrementing +// its own counter, so a scenario can install "many hooks" cheaply. +func (c *counters) addTo(h *hook.Hooks, n int) { + for i := 0; i < n; i++ { + h.AddTaskSubmitted(func(context.Context, agilepool.Task) { c.Submitted.Add(1) }) + h.AddTaskEnqueued(func(context.Context, agilepool.Task) { c.Enqueued.Add(1) }) + h.AddTaskStarted(func(context.Context, agilepool.Task) { c.Started.Add(1) }) + h.AddTaskCompleted(func(context.Context, agilepool.Task, any) { c.Completed.Add(1) }) + } +} + +func (c *counters) snapshot() (submitted, enqueued, started, completed int64) { + return c.Submitted.Load(), c.Enqueued.Load(), c.Started.Load(), c.Completed.Load() +} + +// panicHooks is a hostile hooks implementation: every dispatch method +// panics before delegating, mimicking a custom hooks implementation that +// does not recover its own callbacks. The pool must absorb these panics in +// dispatchHook and keep the submission/worker/close paths intact. +type panicHooks struct { + h *hook.Hooks + panicWhen func(dispatch string) bool + panicValue any +} + +func (p *panicHooks) DispatchTaskSubmitted(ctx context.Context, t agilepool.Task) { + if p.panicWhen("submitted") { + panic(p.panicValue) + } + if p.h != nil { + p.h.DispatchTaskSubmitted(ctx, t) + } +} + +func (p *panicHooks) DispatchTaskEnqueued(ctx context.Context, t agilepool.Task) { + if p.panicWhen("enqueued") { + panic(p.panicValue) + } + if p.h != nil { + p.h.DispatchTaskEnqueued(ctx, t) + } +} + +func (p *panicHooks) DispatchTaskStarted(ctx context.Context, t agilepool.Task) { + if p.panicWhen("started") { + panic(p.panicValue) + } + if p.h != nil { + p.h.DispatchTaskStarted(ctx, t) + } +} + +func (p *panicHooks) DispatchTaskCompleted(ctx context.Context, t agilepool.Task, rec any) { + if p.panicWhen("completed") { + panic(p.panicValue) + } + if p.h != nil { + p.h.DispatchTaskCompleted(ctx, t, rec) + } +} + +func (p *panicHooks) DispatchPoolClosed(pool *agilepool.Pool) { + if p.panicWhen("closed") { + panic(p.panicValue) + } + if p.h != nil { + p.h.DispatchPoolClosed(pool) + } +} + +// newScenarioPool builds a small, block-mode pool with a channel queue big +// enough to hold every task of the scenario, so accepted tasks always take +// the direct channel path (Enqueued fires exactly once per task) and the +// assertion surface is deterministic. Noise-free logger keeps adversarial +// scenarios quiet. +func newScenarioPool(capacity, queue int64) *agilepool.Pool { + cfg := agilepool.NewConfig( + agilepool.WithWorkerNumCapacity(capacity), + agilepool.WithTaskQueueSize(queue), + agilepool.WithBlockMode(agilepool.BLOCK), + agilepool.WithIdleContainerType(agilepool.LinkedListType), + agilepool.WithCleanPeriod(100*time.Millisecond), + ) + p := agilepool.NewPool(cfg) + p.SetLogger(discardLogger{}) + return p +} + +// drain waits for the pool to finish every in-flight task, reporting +// whether Wait returned inside the timeout. A regression that leaks wg.Done +// (e.g. a hook panic skipping pool bookkeeping) blocks here instead of +// hanging the scenario forever. +func drain(p *agilepool.Pool, timeout time.Duration) bool { + done := make(chan struct{}) + go func() { + p.Wait() + close(done) + }() + select { + case <-done: + return true + case <-time.After(timeout): + return false + } +} + +// settleGoroutines polls until the goroutine count returns to near the +// session baseline (pool Close is asynchronous: cleaner/scaler/sampler +// notice closePoolCn on their next tick). grace is the tolerated slack for +// runtime goroutines not owned by the scenario. +func settleGoroutines(baseline int, grace int, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if runtime.NumGoroutine() <= baseline+grace { + return true + } + time.Sleep(20 * time.Millisecond) + } + return false +} + +// goroutineBaseline snapshots the current goroutine count. Scenarios call +// it right before creating their pools. +func goroutineBaseline() int { + return runtime.NumGoroutine() +} + +// report prints a PASS/FAIL line and converts a failure into the error the +// host reports with the [plugin] prefix. name is the plugin name so the +// PASS lines and the error prefix agree. +func report(name string, rt *Runtime, ok bool, format string, args ...any) error { + msg := fmt.Sprintf(format, args...) + if ok { + fmt.Fprintf(rt.Out, "[%s] PASS %s\n", name, msg) + return nil + } + fmt.Fprintf(rt.Out, "[%s] FAIL %s\n", name, msg) + return fmt.Errorf("%s", msg) +} diff --git a/test/horder.go b/test/horder.go new file mode 100644 index 0000000..5562311 --- /dev/null +++ b/test/horder.go @@ -0,0 +1,237 @@ +package main + +// horder verifies the per-task lifecycle contract under full concurrency: +// for every task the four events fire exactly once, Submitted strictly +// precedes Enqueued/Started (submission-path program order), Started +// strictly precedes Completed (worker-path program order), the context +// payload reaches all four events unchanged, and Completed delivers the +// exact value a panicking task panicked with (nil otherwise). +// +// Events of one task are guarded by a per-id state machine. Only the +// guarantees that are real happen-before edges are enforced — Enqueued may +// legitimately land after Started if a worker picks the task up before the +// submitter's enqueue callback runs, so no assertion depends on that race. + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/Yiming1997/agilePool/v2/internal/hook" +) + +// orderCtxKey carries a task's id through SubmitCtx into the hooks. +type orderCtxKey struct{} + +const ( + orderEvSubmitted = iota + orderEvEnqueued + orderEvStarted + orderEvCompleted +) + +// panicSentinel is the value a panicking task panics with: ids below +// panicsTotal are expected to surface it in OnTaskCompleted. +func panicSentinel(id int) int { return 900000 + id } + +// orderRec is one task's event state machine. Transitions: +// +// submitted: state 0 -> 1 (only legal first event) +// enqueued / started: any time after submitted (two writers may race) +// completed: only after started (worker goroutine program order) +// +// Everything else (duplicates, out-of-order, missing) is an anomaly. +type orderRec struct { + mu sync.Mutex + state int + flags [4]bool + anomaly bool + recovered any +} + +func (r *orderRec) fire(ev int) { + r.mu.Lock() + defer r.mu.Unlock() + if r.flags[ev] { + r.anomaly = true // duplicate event + return + } + switch ev { + case orderEvSubmitted: + if r.state != 0 { + r.anomaly = true + return + } + r.state = 1 + case orderEvEnqueued, orderEvStarted: + if r.state == 0 { // before submitted: impossible by program order + r.anomaly = true + return + } + case orderEvCompleted: + if !r.flags[orderEvStarted] { + r.anomaly = true + return + } + } + r.flags[ev] = true +} + +func (r *orderRec) setRecovered(v any) { + r.mu.Lock() + defer r.mu.Unlock() + r.recovered = v +} + +type horderPlugin struct{} + +func (horderPlugin) Name() string { return "horder" } +func (horderPlugin) Desc() string { return "hook stability: per-task event order and payload fidelity" } + +func (horderPlugin) Options() []Option { + return []Option{ + {Name: "num", Default: "20000", Help: "tasks to submit"}, + {Name: "panics", Default: "2000", Help: "how many leading tasks panic (recovered-value check)"}, + {Name: "workers", Default: "1000", Help: "worker capacity of the scenario pool"}, + } +} + +func (horderPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + num, err := GetInt(opts, "num", 20000) + if err != nil { + return err + } + panics, err := GetInt(opts, "panics", 2000) + if err != nil { + return err + } + workers, err := GetInt(opts, "workers", 1000) + if err != nil { + return err + } + if num < 1 || workers < 1 { + return fmt.Errorf("num/workers must be >= 1") + } + if panics < 0 || panics > num { + return fmt.Errorf("option %q: must be within [0, num]", "panics") + } + + baseline := goroutineBaseline() + p := newScenarioPool(int64(workers), int64(num)) + defer p.Close() + + recs := make([]orderRec, num) + var badCtx atomic.Int64 + var cnt counters + var submitted, enqueued, started, completed atomic.Int64 + + expectPanic := func(id int) bool { return id < panics } + + h := hook.NewHooks() + h.AddTaskSubmitted(func(ctx context.Context, _ agilepool.Task) { + id, ok := ctx.Value(orderCtxKey{}).(int) + if !ok { + badCtx.Add(1) + return + } + submitted.Add(1) + recs[id].fire(orderEvSubmitted) + }) + h.AddTaskEnqueued(func(ctx context.Context, _ agilepool.Task) { + id, ok := ctx.Value(orderCtxKey{}).(int) + if !ok { + badCtx.Add(1) + return + } + enqueued.Add(1) + recs[id].fire(orderEvEnqueued) + }) + h.AddTaskStarted(func(ctx context.Context, _ agilepool.Task) { + id, ok := ctx.Value(orderCtxKey{}).(int) + if !ok { + badCtx.Add(1) + return + } + started.Add(1) + recs[id].fire(orderEvStarted) + }) + h.AddTaskCompleted(func(ctx context.Context, _ agilepool.Task, recovered any) { + id, ok := ctx.Value(orderCtxKey{}).(int) + if !ok { + badCtx.Add(1) + return + } + completed.Add(1) + recs[id].fire(orderEvCompleted) + recs[id].setRecovered(recovered) + }) + cnt.addTo(h, 1) + if err := p.SetHook(h); err != nil { + return err + } + + for id := 0; id < num; id++ { + id := id + var task agilepool.Task = agilepool.TaskFunc(func() error { + if expectPanic(id) { + panic(panicSentinel(id)) + } + return nil + }) + p.SubmitCtx(context.WithValue(context.Background(), orderCtxKey{}, id), task) + } + + if !drain(p, 30*time.Second) { + return fmt.Errorf("pool did not drain within 30s") + } + + s, e, st, c := cnt.snapshot() + msg := fmt.Sprintf("submitted=%d enqueued=%d started=%d completed=%d", s, e, st, c) + if err := report("horder", rt, + s == int64(num) && e == int64(num) && st == int64(num) && c == int64(num), + "event totals for %d tasks: %s", num, msg); err != nil { + return err + } + + var anomalies int64 + var badRecovered int64 + for id := 0; id < num; id++ { + r := &recs[id] + if r.anomaly { + anomalies++ + continue + } + got := r.recovered + if expectPanic(id) { + if v, ok := got.(int); !ok || v != panicSentinel(id) { + badRecovered++ + } + } else if got != nil { + badRecovered++ + } + } + if err := report("horder", rt, badCtx.Load() == 0, + "context payload missing/mismatched in %d hook invocations", badCtx.Load()); err != nil { + return err + } + if err := report("horder", rt, anomalies == 0, + "per-task order anomalies: %d tasks had duplicate/out-of-order events", anomalies); err != nil { + return err + } + if err := report("horder", rt, badRecovered == 0, + "OnTaskCompleted recovered payload wrong for %d tasks (%d panic tasks)", badRecovered, panics); err != nil { + return err + } + + p.SetHook(nil) + p.Close() + if !settleGoroutines(baseline, 4, 5*time.Second) { + return report("horder", rt, false, + "goroutine leak: %d goroutines after close, baseline %d", goroutineBaseline(), baseline) + } + return report("horder", rt, true, "per-task order/payload contract held for all %d tasks", num) +} diff --git a/test/hpanic.go b/test/hpanic.go new file mode 100644 index 0000000..c6e1f4f --- /dev/null +++ b/test/hpanic.go @@ -0,0 +1,210 @@ +package main + +// hpanic stress-tests panic immunity of the hook system. Two levels: +// +// - level=callback: every registered callback of the stage under test +// panics; internal/hook.Hooks must recover each one (invoke) while the +// counting callbacks registered before them keep receiving events, and +// the pool keeps executing tasks. +// - level=dispatch: the whole hooks implementation panics at the dispatch +// entry of the stage under test; pool.dispatchHook must recover it and +// keep the submission/worker/Close paths and their bookkeeping intact. +// +// stage=all walks the five lifecycle points one by one (each on a fresh +// pool), and after each burst the pool must still execute a plain follow-up +// task — a worker goroutine killed by a panic would fail that burst. + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/Yiming1997/agilePool/v2/internal/hook" +) + +const panicMsg = "hook stability panic" + +type hpanicPlugin struct{} + +func (hpanicPlugin) Name() string { return "hpanic" } +func (hpanicPlugin) Desc() string { return "hook stability: panicking hooks must not kill pool paths" } + +func (hpanicPlugin) Options() []Option { + return []Option{ + {Name: "num", Default: "3000", Help: "tasks per stage burst"}, + {Name: "level", Default: "dispatch", Help: "panic level: dispatch (whole hooks impl) / callback (per callback)"}, + {Name: "stage", Default: "all", Help: "stage under panic: all/submitted/enqueued/started/completed/closed"}, + } +} + +func (hpanicPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + num, err := GetInt(opts, "num", 3000) + if err != nil { + return err + } + level := GetString(opts, "level", "dispatch") + switch level { + case "dispatch", "callback": + default: + return fmt.Errorf("option %q: unsupported value %q", "level", level) + } + stage := GetString(opts, "stage", "all") + if num < 1 { + return fmt.Errorf("option %q: must be >= 1", "num") + } + + var stages []string + switch stage { + case "all": + stages = []string{"submitted", "enqueued", "started", "completed", "closed"} + case "submitted", "enqueued", "started", "completed", "closed": + stages = []string{stage} + default: + return fmt.Errorf("option %q: unsupported value %q", "stage", stage) + } + + baseline := goroutineBaseline() + for _, s := range stages { + if err := runPanicStage(rt, s, level, num); err != nil { + return err + } + } + if !settleGoroutines(baseline, 4, 5*time.Second) { + return report("hpanic", rt, false, + "goroutine leak: %d goroutines after scenario, baseline %d", goroutineBaseline(), baseline) + } + return report("hpanic", rt, true, "survived panics at %s level across %d stage(s)", level, len(stages)) +} + +// runPanicStage runs one stage burst on a private pool and closes it. The +// pool returns closed after the "closed" stage. +func runPanicStage(rt *Runtime, stage, level string, num int) error { + p := newScenarioPool(500, int64(num)) + closedPool := stage == "closed" + if !closedPool { + defer p.Close() + } + + var executed atomic.Int64 + panicFn := func() { panic(panicMsg) } + countStage := map[string]func(*counters) *atomic.Int64{ + "submitted": func(c *counters) *atomic.Int64 { return &c.Submitted }, + "enqueued": func(c *counters) *atomic.Int64 { return &c.Enqueued }, + "started": func(c *counters) *atomic.Int64 { return &c.Started }, + "completed": func(c *counters) *atomic.Int64 { return &c.Completed }, + } + + var want int64 = int64(num) + if level == "callback" { + h := hook.NewHooks() + var cnt counters + cnt.addTo(h, 1) + // The panicking callback sits after the counters: the counters must + // still see every event even though a sibling callback panics. + switch stage { + case "submitted": + h.AddTaskSubmitted(func(context.Context, agilepool.Task) { panicFn() }) + case "enqueued": + h.AddTaskEnqueued(func(context.Context, agilepool.Task) { panicFn() }) + case "started": + h.AddTaskStarted(func(context.Context, agilepool.Task) { panicFn() }) + case "completed": + h.AddTaskCompleted(func(context.Context, agilepool.Task, any) { panicFn() }) + case "closed": + h.AddPoolClosed(func(*agilepool.Pool) { panicFn() }) + want = 0 // PoolClosed carries no per-task counter + } + if err := p.SetHook(h); err != nil { + return err + } + submitBurst(p, &executed, num) + if !drain(p, 30*time.Second) { + return fmt.Errorf("[%s] callback panic blocked drain", stage) + } + if stage == "closed" { + p.Close() + } + if stage != "closed" { + s, e, st, c := cnt.snapshot() + got := countStage[stage](&cnt).Load() + if got != want { + return report("hpanic", rt, false, + "[%s/callback] counter %s = %d, want %d (S=%d E=%d St=%d C=%d)", + stage, stage, got, want, s, e, st, c) + } + } + if err := report("hpanic", rt, executed.Load() == int64(num), + "[%s/callback] %d tasks executed despite panicking %s hook", stage, executed.Load(), stage); err != nil { + return err + } + if stage == "closed" { + return report("hpanic", rt, true, + "[%s/callback] Close returned and panicking OnPoolClosed was contained", stage) + } + return nil + } + + // level=dispatch: hostile hooks implementation panicking at dispatch. + ph := &panicHooks{ + h: hook.NewHooks(), + panicWhen: func(d string) bool { return d == stage }, + panicValue: panicMsg, + } + if err := p.SetHook(ph); err != nil { + return err + } + submitBurst(p, &executed, num) + if !drain(p, 30*time.Second) { + return fmt.Errorf("[%s] dispatch panic blocked drain", stage) + } + if stage == "closed" { + p.Close() + } + if err := report("hpanic", rt, executed.Load() == int64(num), + "[%s/dispatch] %d tasks executed with %s dispatch panicking", stage, executed.Load(), stage); err != nil { + return err + } + if stage == "closed" { + // The pool is gone: follow-up submissions must be dropped silently + // (no crash, no hook dispatch), not executed. + for i := 0; i < num; i++ { + p.Submit(agilepool.TaskFunc(func() error { + executed.Add(1) + return nil + })) + } + if !drain(p, 30*time.Second) { + return fmt.Errorf("[%s] post-close submissions blocked drain", stage) + } + if err := report("hpanic", rt, executed.Load() == int64(num), + "[%s/dispatch] %d post-close submissions dropped without events", stage, num); err != nil { + return err + } + return report("hpanic", rt, true, + "[%s/dispatch] Close survived a panicking PoolClosed dispatch", stage) + } + // The worker goroutines must have survived: a plain task without hooks + // still runs after the hostile hooks are detached. + p.SetHook(nil) + submitBurst(p, &executed, num) + if !drain(p, 30*time.Second) { + return fmt.Errorf("[%s] post-panic follow-up burst blocked drain", stage) + } + if err := report("hpanic", rt, executed.Load() == 2*int64(num), + "[%s/dispatch] follow-up burst of %d also executed after hooks detached", stage, num); err != nil { + return err + } + return nil +} + +func submitBurst(p *agilepool.Pool, executed *atomic.Int64, n int) { + for i := 0; i < n; i++ { + p.Submit(agilepool.TaskFunc(func() error { + executed.Add(1) + return nil + })) + } +} diff --git a/test/hreenter.go b/test/hreenter.go new file mode 100644 index 0000000..8f6ad25 --- /dev/null +++ b/test/hreenter.go @@ -0,0 +1,141 @@ +package main + +// hreenter stress-tests reentrant hook dispatch: a hook itself submits new +// tasks to the pool while the pool is inside a dispatch on the same +// goroutine. Submission from a Submitted callback recurses through +// DispatchTaskSubmitted on the submitter's stack; submission from a +// Completed callback recurses from worker goroutines. Both must be safe +// (dispatch holds no pool locks) and every reentered task must flow through +// the full event cycle once. +// +// A global budget bounds the total reentries and a depth limit bounds each +// recursion chain, so the scenario cannot fan out forever. + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/Yiming1997/agilePool/v2/internal/hook" +) + +type hreenterPlugin struct{} + +func (hreenterPlugin) Name() string { return "hreenter" } +func (hreenterPlugin) Desc() string { + return "hook stability: hook-triggered resubmission (reentrant dispatch)" +} + +func (hreenterPlugin) Options() []Option { + return []Option{ + {Name: "num", Default: "2000", Help: "tasks to submit"}, + {Name: "budget", Default: "2000", Help: "maximum reentries across the whole run"}, + {Name: "depth", Default: "32", Help: "maximum recursion depth of one reentry chain"}, + {Name: "stage", Default: "submitted", Help: "hook that reenters: submitted/completed"}, + } +} + +func (hreenterPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + num, err := GetInt(opts, "num", 2000) + if err != nil { + return err + } + budget, err := GetInt(opts, "budget", 2000) + if err != nil { + return err + } + depth, err := GetInt(opts, "depth", 32) + if err != nil { + return err + } + stage := GetString(opts, "stage", "submitted") + switch stage { + case "submitted", "completed": + default: + return fmt.Errorf("option %q: unsupported value %q", "stage", stage) + } + if num < 1 || budget < 0 || depth < 1 { + return fmt.Errorf("num must be >= 1, budget >= 0, depth >= 1") + } + + baseline := goroutineBaseline() + // Queue sized for the worst case: num originals plus every reentry. + p := newScenarioPool(1000, int64(num+budget)) + defer p.Close() + + var remaining atomic.Int64 + remaining.Store(int64(budget)) + var curDepth atomic.Int64 + var reentered atomic.Int64 + var executed atomic.Int64 + + task := func() agilepool.Task { + return agilepool.TaskFunc(func() error { + executed.Add(1) + return nil + }) + } + + h := hook.NewHooks() + var cnt counters + cnt.addTo(h, 1) + reenter := func() { + // Depth is incremented for the duration of the nested Submit only, + // so chains recurse to at most `depth` while sibling chains on + // other goroutines are unaffected. + if curDepth.Load() >= int64(depth) { + return + } + if remaining.Add(-1) < 0 { + remaining.Add(1) + return + } + reentered.Add(1) + curDepth.Add(1) + defer curDepth.Add(-1) + p.Submit(task()) + } + if stage == "submitted" { + h.AddTaskSubmitted(func(context.Context, agilepool.Task) { reenter() }) + } else { + h.AddTaskCompleted(func(context.Context, agilepool.Task, any) { reenter() }) + } + if err := p.SetHook(h); err != nil { + return err + } + + for i := 0; i < num; i++ { + p.Submit(task()) + } + if !drain(p, 60*time.Second) { + return fmt.Errorf("pool did not drain: reentrant dispatch deadlocked (num=%d budget=%d)", num, budget) + } + + total := int64(num) + reentered.Load() + s, e, st, c := cnt.snapshot() + if err := report("hreenter", rt, + s == total && e == total && st == total && c == total, + "counters submitted=%d enqueued=%d started=%d completed=%d for %d originals + %d reentries", + s, e, st, c, num, reentered.Load()); err != nil { + return err + } + if err := report("hreenter", rt, executed.Load() == total, + "executed %d == %d originals + %d reentries", executed.Load(), num, reentered.Load()); err != nil { + return err + } + if err := report("hreenter", rt, reentered.Load() > 0 && reentered.Load() <= int64(budget), + "reentered %d tasks within budget %d (depth limit %d)", reentered.Load(), budget, depth); err != nil { + return err + } + + p.SetHook(nil) + p.Close() + if !settleGoroutines(baseline, 4, 5*time.Second) { + return report("hreenter", rt, false, + "goroutine leak: %d goroutines after close, baseline %d", goroutineBaseline(), baseline) + } + return report("hreenter", rt, true, "reentrant dispatch from %s hooks stayed stable", stage) +} diff --git a/test/plot_csv.py b/test/plot_csv.py index 4187ff4..1b351fd 100644 --- a/test/plot_csv.py +++ b/test/plot_csv.py @@ -1,104 +1,116 @@ -import pandas as pd -import matplotlib.pyplot as plt -import matplotlib.font_manager as fm +""" +plot_csv.py — plot harness metrics CSVs produced by the plugin-based test +harness (see docs/test-harness.md and the run_test scripts). + +Reads every `metrics_*.csv` in the current directory (column layout of the +current `--metrics` plugin: run_sec, goroutines, memory/GC/CPU, worker +counters, task queue, hook counters), draws one subplot per metric against +run_sec, and saves `metrics_.svg` next to each file. + +Usage: + python plot_csv.py # from the directory holding the CSVs +""" + import glob import os -# Try to find a CJK font for Chinese CSV headers -_cjk_fonts = ["Microsoft YaHei", "SimHei", "WenQuanYi Micro Hei", "Noto Sans CJK SC"] -for _f in _cjk_fonts: - try: - fm.findfont(_f, fallback_to_default=False) - plt.rcParams["font.sans-serif"] = [_f] - plt.rcParams["axes.unicode_minus"] = False - break - except Exception: - continue - -csv_files = sorted(glob.glob("metrics_*.csv")) -if not csv_files: - print("no metrics_*.csv files found") - exit() - -for f in csv_files: - name = os.path.splitext(f)[0] - - try: - df = pd.read_csv(f) - except Exception as e: - print(f" -> skip {f}: {e}") - continue - - if df.empty: - print(f" -> skip {f}: empty file") - continue - - print(f"\n=== {name} === ({len(df)} rows)") - - # Define metrics to plot (each in its own subplot) - metrics = [ - ('heap_alloc_mb', 'Heap Alloc (MB)'), - ('total_alloc_mb', 'Total Alloc (MB)'), - ('sys_mb', 'System Memory (MB)'), - ('workers_running', 'Workers Running'), - ('workers_idle', 'Workers Idle'), - ('workers_created', 'Workers Created'), - ('gc_pause_total_ms', 'GC Pause Total (ms)'), - ('gc_pause_avg_ms', 'GC Pause Avg (ms)'), - ('gc_cpu_pct', 'GC CPU (%)'), - ('cpu_pct', 'CPU (%)'), - ('gc_total', 'GC Total Count'), - ('goroutines', 'Goroutines'), - ('task_queue_len', 'Task Queue Length') - ] - - # Filter existing metrics - existing_metrics = [(col, title) for col, title in metrics if col in df.columns] - - if not existing_metrics: - print(f" -> skip {f}: no valid metric columns") - continue - - # Calculate subplot layout (max 3 columns) - n = len(existing_metrics) - ncols = min(3, n) - nrows = (n + ncols - 1) // ncols - - fig, axes = plt.subplots(nrows, ncols, figsize=(5*ncols, 4*nrows)) - fig.suptitle(name, fontsize=16, fontweight='bold') - - if n == 1: - axes = [axes] - else: - axes = axes.flatten() - - # Plot each metric - for idx, (col, title) in enumerate(existing_metrics): - ax = axes[idx] - df.plot(x="run_sec", y=[col], ax=ax, marker=".", linewidth=1.5, legend=False) - ax.set_title(title, fontsize=12) - ax.set_xlabel("Time (seconds)") - ax.set_ylabel(title) - ax.grid(True, alpha=0.3) - - # Annotate last value - last_val = df[col].iloc[-1] - last_time = df['run_sec'].iloc[-1] - ax.annotate(f'{last_val:.2f}', - xy=(last_time, last_val), - xytext=(5, 5), - textcoords='offset points', - fontsize=8, - bbox=dict(boxstyle='round,pad=0.3', facecolor='yellow', alpha=0.3)) - - # Hide unused subplots - for idx in range(len(existing_metrics), len(axes)): - axes[idx].axis('off') - - plt.tight_layout() - - # Save as SVG (vector format for infinite zoom) - svg_file = f"{name}.svg" - plt.savefig(svg_file, format='svg') - plt.close() - print(f" -> {svg_file}") \ No newline at end of file +import matplotlib.pyplot as plt +import pandas as pd + +# Metrics common to every run (columns exist in the current CSV header). +METRICS = [ + ("heap_alloc_mb", "Heap Alloc (MB)"), + ("total_alloc_mb", "Total Alloc (MB)"), + ("sys_mb", "System Memory (MB)"), + ("workers_running", "Workers Running"), + ("workers_idle", "Workers Idle"), + ("workers_created", "Workers Created"), + ("gc_pause_total_ms", "GC Pause Total (ms)"), + ("gc_pause_avg_ms", "GC Pause Avg (ms)"), + ("gc_cpu_pct", "GC CPU (%)"), + ("cpu_pct", "CPU (%)"), + ("gc_total", "GC Total Count"), + ("goroutines", "Goroutines"), + ("task_queue_len", "Task Queue Length"), +] + +# Hook counters only exist when the run had a hook segment (mode != none); +# plot them when any row is non-zero, otherwise they would be flat zero lines. +HOOK_METRICS = [ + ("hook_submitted", "Hook Submitted"), + ("hook_enqueued", "Hook Enqueued"), + ("hook_started", "Hook Started"), + ("hook_completed", "Hook Completed"), +] + + +def main(): + csv_files = sorted(glob.glob("metrics_*.csv")) + if not csv_files: + print("no metrics_*.csv files found") + return + + for f in csv_files: + name = os.path.splitext(f)[0] + + try: + df = pd.read_csv(f) + except Exception as e: # malformed file + print(f" -> skip {f}: {e}") + continue + + if df.empty: + # A run that finished before the first sampling tick produces no + # rows (the header is only written on the first tick). + print(f" -> skip {f}: empty file (run shorter than the sampling interval?)") + continue + + metrics = list(METRICS) + if df["hook_submitted"].sum() > 0: + metrics += HOOK_METRICS + metrics = [(col, title) for col, title in metrics if col in df.columns] + if not metrics: + print(f" -> skip {f}: no valid metric columns") + continue + + print(f"\n=== {name} === ({len(df)} rows)") + + n = len(metrics) + ncols = min(3, n) + nrows = (n + ncols - 1) // ncols + fig, axes = plt.subplots(nrows, ncols, figsize=(5 * ncols, 4 * nrows)) + fig.suptitle(name, fontsize=16, fontweight="bold") + axes = [axes] if n == 1 else axes.flatten() + + for idx, (col, title) in enumerate(metrics): + ax = axes[idx] + df.plot(x="run_sec", y=[col], ax=ax, marker=".", linewidth=1.5, legend=False) + ax.set_title(title, fontsize=12) + ax.set_xlabel("Time (seconds)") + ax.set_ylabel(title) + ax.grid(True, alpha=0.3) + + # Annotate the last sampled value. + last_time = df["run_sec"].iloc[-1] + last_val = df[col].iloc[-1] + ax.annotate( + f"{last_val:.2f}", + xy=(last_time, last_val), + xytext=(5, 5), + textcoords="offset points", + fontsize=8, + bbox=dict(boxstyle="round,pad=0.3", facecolor="yellow", alpha=0.3), + ) + + for idx in range(len(metrics), len(axes)): + axes[idx].axis("off") + + plt.tight_layout() + svg_file = f"{name}.svg" + plt.savefig(svg_file, format="svg") + plt.close() + print(f" -> {svg_file}") + + +if __name__ == "__main__": + main() diff --git a/test/plugins.go b/test/plugins.go index 6a4799f..49edb96 100644 --- a/test/plugins.go +++ b/test/plugins.go @@ -13,6 +13,14 @@ var allPlugins = []Plugin{ taskPlugin{}, submitPlugin{}, hookPlugin{}, + hcountPlugin{}, + hpanicPlugin{}, + horderPlugin{}, + hctxPlugin{}, + hblockPlugin{}, + hchurnPlugin{}, + hreenterPlugin{}, + hclosePlugin{}, &metricsPlugin{}, &profilePlugin{}, } diff --git a/test/run_hook_stress.bat b/test/run_hook_stress.bat new file mode 100644 index 0000000..02698fa --- /dev/null +++ b/test/run_hook_stress.bat @@ -0,0 +1,33 @@ +@REM Hook-system stability scenarios (h* plugin family). Each scenario is a +@REM standalone plugin that builds its own private pools and asserts PASS/FAIL +@REM invariants; any FAIL makes the scenario exit 1 and the script aborts. +@REM Run with the race detector: run_hook_stress.bat race +@REM (race builds a -race binary; slower, use it in CI to catch hook races) +@REM Per-plugin knobs: see `agilepool_test.exe --help hcount` etc. +if "%1"=="race" ( + go build -race -o agilepool_test.exe . +) else ( + go build -o agilepool_test.exe . +) +if errorlevel 1 exit /b 1 + +.\agilepool_test.exe --hcount +if errorlevel 1 exit /b 1 +.\agilepool_test.exe --hpanic num=3000 stage=all +if errorlevel 1 exit /b 1 +.\agilepool_test.exe --hpanic num=200 level=callback stage=all 2>nul +if errorlevel 1 exit /b 1 +.\agilepool_test.exe --horder +if errorlevel 1 exit /b 1 +.\agilepool_test.exe --hctx +if errorlevel 1 exit /b 1 +.\agilepool_test.exe --hblock num=2000 delay-us=100 +if errorlevel 1 exit /b 1 +.\agilepool_test.exe --hchurn +if errorlevel 1 exit /b 1 +.\agilepool_test.exe --hreenter +if errorlevel 1 exit /b 1 +.\agilepool_test.exe --hclose +if errorlevel 1 exit /b 1 + +@echo hook-stability suite: all PASS diff --git a/test/run_hook_stress.sh b/test/run_hook_stress.sh new file mode 100644 index 0000000..172c42e --- /dev/null +++ b/test/run_hook_stress.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Hook-system stability scenarios (h* plugin family). Each scenario is a +# standalone plugin that builds its own private pools and asserts PASS/FAIL +# invariants; any FAIL makes the scenario exit 1 and the script aborts. +# Run with the race detector: run_hook_stress.sh race +# (race builds a -race binary; slower, use it in CI to catch hook races) +# Per-plugin knobs: see `./agilepool_test --help hcount` etc. +set -e +if [ "$1" = "race" ]; then + go build -race -o agilepool_test . +else + go build -o agilepool_test . +fi + +./agilepool_test --hcount +./agilepool_test --hpanic num=3000 stage=all +./agilepool_test --hpanic num=200 level=callback stage=all 2>/dev/null +./agilepool_test --horder +./agilepool_test --hctx +./agilepool_test --hblock num=2000 delay-us=100 +./agilepool_test --hchurn +./agilepool_test --hreenter +./agilepool_test --hclose + +echo "hook-stability suite: all PASS" diff --git a/test/run_test.bat b/test/run_test.bat index b705ce1..8a9686c 100644 --- a/test/run_test.bat +++ b/test/run_test.bat @@ -39,4 +39,4 @@ agilepool_test.exe --pool workers=500 --task type=fixed base=3000 --submit strat agilepool_test.exe --pool workers=10000 --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv wait-exit=3 agilepool_test.exe --pool workers=10000 container=minheap mode=nonblock --task type=uniform base=500 extra=100 --submit strategy=linear num=8000 interval=15 jitter=5 --metrics interval=1 format=json file=metrics_uniform_w10000_t8000_minheap.json wait-exit=5 -python old\plot_csv.py +python plot_csv.py diff --git a/test/run_test.sh b/test/run_test.sh index 8fcd226..0ca9b46 100644 --- a/test/run_test.sh +++ b/test/run_test.sh @@ -41,4 +41,4 @@ go build -o agilepool_test . ./agilepool_test --pool workers=10000 --task type=fixed base=500 --submit strategy=immediate num=200000 --metrics interval=1 format=csv file=metrics_fixed_w10000_t200000_linkedlist.csv wait-exit=3 ./agilepool_test --pool workers=10000 container=minheap mode=nonblock --task type=uniform base=500 extra=100 --submit strategy=linear num=8000 interval=15 jitter=5 --metrics interval=1 format=json file=metrics_uniform_w10000_t8000_minheap.json wait-exit=5 -python old/plot_csv.py +python plot_csv.py From e374de5fb3de7b106615294987e762c1f54fa220 Mon Sep 17 00:00:00 2001 From: Bury <18151161@qq.com> Date: Thu, 10 Sep 2026 16:17:24 +0800 Subject: [PATCH 3/5] feat(hook): drop Task from callbacks and fire Enqueued exactly once - Hook callbacks now receive only ctx; per-task data travels via SubmitCtx. - Enqueued is dispatched exactly once per accepted task, after the overflow-buffer lock is released, so a slow or reentrant callback can no longer stall buffer access or self-deadlock on taskMu. - test: add henqueue (overflow accounting + reentrant watchdog), sandwich the panicking callback between two counter sets in hpanic, and keep running every scenario after a failure. --- .gitignore | 8 +- docs/test-harness.md | 52 ++++-- example/gin_example/main.go | 12 +- hook.go | 19 ++- hook_test.go | 8 +- internal/hook/hook.go | 24 +-- pool.go | 16 +- test/README.md | 3 +- test/README_zh-CN.md | 3 +- test/hblock.go | 8 +- test/hchurn.go | 8 +- test/hctx.go | 10 +- test/henqueue.go | 309 ++++++++++++++++++++++++++++++++++++ test/hook.go | 8 +- test/hookcheck.go | 24 +-- test/horder.go | 8 +- test/hpanic.go | 59 ++++--- test/hreenter.go | 4 +- test/plugins.go | 1 + test/run_hook_stress.bat | 42 ++--- test/run_hook_stress.sh | 32 ++-- worker.go | 6 +- 22 files changed, 520 insertions(+), 144 deletions(-) create mode 100644 test/henqueue.go diff --git a/.gitignore b/.gitignore index 04f07d7..9e4b13e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,10 @@ .idea/ *.bak *.txt -*.log \ No newline at end of file +*.log + +test/*.csv +test/*.svg +test/*.json +test/*.prof +test/*.exe diff --git a/docs/test-harness.md b/docs/test-harness.md index 9901a99..d7a2a97 100644 --- a/docs/test-harness.md +++ b/docs/test-harness.md @@ -231,16 +231,20 @@ Legacy flag → new segment mapping used by the scenario scripts | `-i T -f F -o FILE -e W` | `--metrics interval=T format=F file=FILE wait-exit=W` | | `--cpuprofile --memprofile` | `--profile cpu=true mem=true` | -### 6.1 Hook-stability family (`hcount`/`hpanic`/`horder`/`hctx`/`hblock`/`hchurn`/`hreenter`/`hclose`) +### 6.1 Hook-stability family (`hcount`/`hpanic`/`horder`/`hctx`/`hblock`/`hchurn`/`hreenter`/`hclose`/`henqueue`) The `h*` plugins stress-test the stability of the hook system rather than benchmarking it. They share three design rules: 1. **Self-contained**: every scenario builds its own private pools with a - known capacity and `queue == task count`, so invariants are + known capacity and mostly `queue == task count`, so invariants are deterministic (the direct channel path fires Enqueued exactly once per - task; the overflow-buffer path may skip it by design) and Close can be - exercised without touching the session `--pool`. + task) and Close can be exercised without touching the session `--pool`. + `henqueue` is the exception on purpose: it uses a queue far smaller than + the task count to force the overflow-buffer path and asserts that + Enqueued still fires exactly once per accepted task there, that a slow + Enqueued callback does not stall the pool, and that a reentrant Enqueued + callback does not deadlock. 2. **Adversarial hooks within the registration contract**: callbacks are registered up front (before the pool starts processing — the contract's registration window) but are otherwise hostile: they panic, sleep, or @@ -249,20 +253,23 @@ benchmarking it. They share three design rules: never done (see §7). 3. **Explicit invariants**: each scenario prints `PASS`/`FAIL` lines and returns an error on the first failure; the host maps it to exit code 1. - The shared machinery (scenario pool builder, atomic counters, drain and - goroutine-leak waiters, reporting) lives in `hookcheck.go` so each - scenario file is just its scenario. + `hpanic` keeps running every stage after a failure, and the runner + scripts keep running every scenario after a failure, so one broken area + never hides another. The shared machinery (scenario pool builder, atomic + counters, drain and goroutine-leak waiters, reporting) lives in + `hookcheck.go` so each scenario file is just its scenario. | Plugin | Invariants asserted (per task unless noted) | |---|---| | `hcount` | with N callbacks × M tasks: every event counter lands on N·M, no loss/duplication; tasks executed == M; no goroutine leak | -| `hpanic` | callbacks (bundled `Hooks`) or whole dispatch (hostile impl) panicking at each stage still let the pool execute, drain, and Close; follow-up burst after detaching hooks also runs; post-close submissions stay silent | +| `hpanic` | callbacks (bundled `Hooks`) or whole dispatch (hostile impl) panicking at each stage still let the pool execute, drain, and Close; at callback level the panicking callback is sandwiched between two counter sets, so both siblings must see every event (a recover path that panics itself starves the second set); follow-up burst after detaching hooks also runs; post-close submissions stay silent | | `horder` | per id (carried via `SubmitCtx`): Submitted is the first event, Started precedes Completed, no duplicates; ctx payload reaches all events; Completed delivers the exact panic value (nil otherwise) | | `hctx` | ctx payloads reach all events; already-canceled contexts fire nothing; cancel-while-queued still yields Started == Completed per dequeued task and a clean drain | | `hblock` | callbacks sleeping `delay-us` never deadlock the pool and every event counter is exact | | `hchurn` | registration burst from many goroutines happens strictly before any dispatch; pre-registered and burst hooks then all see every event exactly (no loss/duplication through the contended window); a fresh `Hooks` instance afterwards accounts exactly | | `hreenter` | hooks that submit new tasks (bounded by `budget`/`depth`) drain with exact counters for originals + reentries | | `hclose` | OnPoolClosed fires exactly once (sequential and racing Close), with pool identity; panicking PoolClosed callbacks/dispatch don't abort Close; post-close submissions fire no events | +| `henqueue` | with `queue << tasks` (overflow-buffer path): all four counters land on num, i.e. Enqueued fires exactly once per accepted task; a slow Enqueued callback does not stall the pool; a reentrant Enqueued callback (bounded by `reenter`) drains with exact counters for originals + reentries. The reentrancy wave runs `attempts` independent attempts on fresh pools because the self-deadlock window is narrow; each attempt's submit loop runs on a watchdog goroutine, so a wedged submitter reports FAIL instead of hanging, and the first deadlock stops the wave | `run_hook_stress.bat` / `run_hook_stress.sh` run the whole family; `run_hook_stress.* race` builds with `-race` first, so the entire family — @@ -271,12 +278,23 @@ race-free in CI. Notes for interpretation: -- `hpanic level=callback` logs each recovered panic to stderr by design - (that is the bundled `Hooks` logger working); the scripts silence it. +- `hpanic level=callback` guards the per-callback recovery of the bundled + `internal/hook.Hooks`: the panicking callback is sandwiched so a recover + path that panics itself (a zero-value `log.Logger` used to) starves the + second counter set and fails the scenario. stderr is silenced by the + scripts because the recovery path logs noisily by design. +- `henqueue` guards the Enqueued contract: every accepted task fires exactly + once, including tasks consumed from the overflow buffer, and the dispatch + happens outside `taskBuf`'s lock so slow/reentrant callbacks are safe. The + reentrancy wave repeats its scenario on fresh pools (`attempts`, default + 8) because a regression there can be a narrow race; each submit loop runs + on a watchdog goroutine, so a self-deadlock is reported as FAIL, not a + hang. - A scenario pool only asserts what is a real happens-before guarantee. Enqueued may legitimately land after Started when a worker dequeues the - task before the submitter's enqueue callback runs, so no `h*` assertion - depends on Enqueued-vs-Started ordering. + task before the submitter's enqueue callback runs (on the buffer path it + may even land after Completed); nothing here asserts Enqueued ordering, + only its count. ## 7. Status and notes @@ -292,6 +310,16 @@ Notes for interpretation: callback slices. Scenarios follow the contract: they register before the first task (`hchurn` stresses exactly that window) and never call `Add*` from a running pool. +- Defects this suite found and that are now fixed (kept as regression + coverage by `hpanic level=callback` and `henqueue`): + 1. `internal/hook.Hooks.logger` was a zero-value `log.Logger`, so + `invoke`'s recover path panicked while logging a recovered callback + panic and skipped the remaining callbacks of that event. + 2. Tasks consumed from the overflow buffer via `PopBatch` never fired + Enqueued, and when a forward succeeded Enqueued was dispatched while + `taskBuf`'s lock was held, stalling the buffer and risking a reentrant + self-deadlock. Enqueued is now dispatched exactly once per accepted + task, outside the buffer lock. - Metrics CSV/JSON columns and formats are preserved from the legacy tool column for column (the CSV header is written on the first tick). - `--hook mode=trace` is intentionally rejected until the upstream diff --git a/example/gin_example/main.go b/example/gin_example/main.go index 098afc3..f0aeb24 100644 --- a/example/gin_example/main.go +++ b/example/gin_example/main.go @@ -45,15 +45,15 @@ func main() { _ = r.Run("127.0.0.1:8080") } -func onSubmitted(ctx context.Context, task agilepool.Task) { - log.Printf("task submitted task=%T", task) +func onSubmitted(ctx context.Context) { + log.Printf("task submitted") } -func onEnqueued(ctx context.Context, task agilepool.Task) { - log.Printf("task enqueued task=%T", task) +func onEnqueued(ctx context.Context) { + log.Printf("task enqueued") } -func onStarted(ctx context.Context, task agilepool.Task) { +func onStarted(ctx context.Context) { if c, ok := ctx.(*gin.Context); ok { traceID, _ := c.Get("traceID") taskStartTimes.Store(traceID, time.Now()) @@ -61,7 +61,7 @@ func onStarted(ctx context.Context, task agilepool.Task) { } } -func onCompleted(ctx context.Context, task agilepool.Task, recovered any) { +func onCompleted(ctx context.Context, recovered any) { if c, ok := ctx.(*gin.Context); ok { traceID, _ := c.Get("traceID") if started, exists := taskStartTimes.LoadAndDelete(traceID); exists { diff --git a/hook.go b/hook.go index f120aa6..c1f2b5b 100644 --- a/hook.go +++ b/hook.go @@ -5,26 +5,31 @@ import ( ) // TaskHook is the callback signature for task lifecycle events. -type TaskHook func(ctx context.Context, task Task) +type TaskHook func(ctx context.Context) // TaskCompleteHook is the callback signature for task completion. // recovered is nil on normal exit, otherwise the value passed to panic. -type TaskCompleteHook func(ctx context.Context, task Task, recovered any) +type TaskCompleteHook func(ctx context.Context, recovered any) // PoolHook is the callback signature for pool-level events. type PoolHook func(p *Pool) type hooks interface { // DispatchTaskSubmitted must only be called by the pool submission path. - DispatchTaskSubmitted(context.Context, Task) - // DispatchTaskEnqueued must only be called by the pool enqueue path. - DispatchTaskEnqueued(context.Context, Task) + DispatchTaskSubmitted(context.Context) + // DispatchTaskEnqueued must only be called by the pool enqueue path. It + // fires exactly once per accepted task, once the task has been handed to + // the handoff channel or to the overflow buffer. Buffer-accepted tasks + // are dispatched after the buffer lock is released, so the callback may + // observe Enqueued after Started (or even after Completed) for the same + // task; ordering between these events is not guaranteed. + DispatchTaskEnqueued(context.Context) // DispatchTaskStarted must only be called by the worker execution path. - DispatchTaskStarted(context.Context, Task) + DispatchTaskStarted(context.Context) // DispatchTaskCompleted must only be called by the worker completion path. - DispatchTaskCompleted(context.Context, Task, any) + DispatchTaskCompleted(context.Context, any) // DispatchPoolClosed must only be called by Pool.Close. DispatchPoolClosed(*Pool) } diff --git a/hook_test.go b/hook_test.go index 5bc0b3f..0bacba8 100644 --- a/hook_test.go +++ b/hook_test.go @@ -18,25 +18,25 @@ type panicHooks struct { panicPoolClosed bool } -func (h *panicHooks) DispatchTaskSubmitted(context.Context, Task) { +func (h *panicHooks) DispatchTaskSubmitted(context.Context) { if h.panicSubmitted { panic("submitted hook panic") } } -func (h *panicHooks) DispatchTaskEnqueued(context.Context, Task) { +func (h *panicHooks) DispatchTaskEnqueued(context.Context) { if h.panicEnqueued { panic("enqueued hook panic") } } -func (h *panicHooks) DispatchTaskStarted(context.Context, Task) { +func (h *panicHooks) DispatchTaskStarted(context.Context) { if h.panicStarted { panic("started hook panic") } } -func (h *panicHooks) DispatchTaskCompleted(context.Context, Task, any) { +func (h *panicHooks) DispatchTaskCompleted(context.Context, any) { if h.panicCompleted { panic("completed hook panic") } diff --git a/internal/hook/hook.go b/internal/hook/hook.go index e441c72..3ab3632 100644 --- a/internal/hook/hook.go +++ b/internal/hook/hook.go @@ -9,8 +9,8 @@ import ( agilepool "github.com/Yiming1997/agilePool/v2" ) -type TaskHook func(ctx context.Context, task agilepool.Task) -type TaskCompleteHook func(ctx context.Context, task agilepool.Task, recovered any) +type TaskHook func(ctx context.Context) +type TaskCompleteHook func(ctx context.Context, recovered any) type PoolHook func(pool *agilepool.Pool) // Hooks stores and dispatches lifecycle callbacks without depending on the @@ -26,7 +26,9 @@ type Hooks struct { } func NewHooks() *Hooks { - return &Hooks{} + return &Hooks{ + logger: *log.Default(), + } } func (h *Hooks) AddTaskSubmitted(fn TaskHook) { @@ -61,33 +63,33 @@ func (h *Hooks) AddPoolClosed(fn PoolHook) { // DispatchTaskSubmitted dispatches submission callbacks. It must only be // called by the pool submission path. -func (h *Hooks) DispatchTaskSubmitted(ctx context.Context, task agilepool.Task) { +func (h *Hooks) DispatchTaskSubmitted(ctx context.Context) { for _, fn := range h.taskSubmitted { - h.invoke(func() { fn(ctx, task) }, "OnTaskSubmitted") + h.invoke(func() { fn(ctx) }, "OnTaskSubmitted") } } // DispatchTaskEnqueued dispatches enqueue callbacks. It must only be called // by the pool enqueue path. -func (h *Hooks) DispatchTaskEnqueued(ctx context.Context, task agilepool.Task) { +func (h *Hooks) DispatchTaskEnqueued(ctx context.Context) { for _, fn := range h.taskEnqueued { - h.invoke(func() { fn(ctx, task) }, "OnTaskEnqueued") + h.invoke(func() { fn(ctx) }, "OnTaskEnqueued") } } // DispatchTaskStarted dispatches start callbacks. It must only be called by // the worker execution path. -func (h *Hooks) DispatchTaskStarted(ctx context.Context, task agilepool.Task) { +func (h *Hooks) DispatchTaskStarted(ctx context.Context) { for _, fn := range h.taskStarted { - h.invoke(func() { fn(ctx, task) }, "OnTaskStarted") + h.invoke(func() { fn(ctx) }, "OnTaskStarted") } } // DispatchTaskCompleted dispatches completion callbacks. It must only be // called by the worker completion path. -func (h *Hooks) DispatchTaskCompleted(ctx context.Context, task agilepool.Task, recovered any) { +func (h *Hooks) DispatchTaskCompleted(ctx context.Context, recovered any) { for _, fn := range h.taskCompleted { - h.invoke(func() { fn(ctx, task, recovered) }, "OnTaskCompleted") + h.invoke(func() { fn(ctx, recovered) }, "OnTaskCompleted") } } diff --git a/pool.go b/pool.go index 172a601..2191240 100644 --- a/pool.go +++ b/pool.go @@ -217,13 +217,11 @@ func (p *Pool) submit(ctx context.Context, task Task) bool { } } hookCtx := context.Background() - hookTask := task if wrapped, ok := task.(*contextTask); ok { hookCtx = wrapped.ctx - hookTask = wrapped.task } p.dispatchHook(func(h hooks) { - h.DispatchTaskSubmitted(hookCtx, hookTask) + h.DispatchTaskSubmitted(hookCtx) }) if p.config.workMode == NONBLOCK { select { @@ -246,7 +244,6 @@ func (p *Pool) submit(ctx context.Context, task Task) bool { result := p.taskBuf.PushAndForward(task, func(t Task) bool { select { case p.taskQueue <- t: - p.dispatchTaskEnqueuedFor(t) return true default: return false @@ -274,6 +271,14 @@ func (p *Pool) submit(ctx context.Context, task Task) bool { return false } default: + // taskBufferAccepted: the task now lives in the overflow buffer, so + // it is enqueued in the pool's sense and must fire Enqueued exactly + // once. Dispatch after PushAndForward returned, i.e. outside the + // buffer lock, so a slow or reentrant callback cannot stall buffer + // access or self-deadlock on taskMu. The trade off is ordering: a + // worker may already have consumed the task, so Enqueued can be + // observed after Started (and, rarely, after Completed). + p.dispatchTaskEnqueuedFor(task) return true } } @@ -282,10 +287,9 @@ func (p *Pool) dispatchTaskEnqueuedFor(task Task) { ctx := context.Background() if wrapped, ok := task.(*contextTask); ok { ctx = wrapped.ctx - task = wrapped.task } p.dispatchHook(func(h hooks) { - h.DispatchTaskEnqueued(ctx, task) + h.DispatchTaskEnqueued(ctx) }) } diff --git a/test/README.md b/test/README.md index b088b45..16c0df7 100644 --- a/test/README.md +++ b/test/README.md @@ -43,6 +43,7 @@ FAIL). They are deliberately dependency-free so `--hcount` alone works: | `--hchurn` | concurrent registration burst before dispatch (contract window), exact accounting | `num`(10000) `num2`(5000) `churners`(4) `adds`(25) | | `--hreenter` | reentrant dispatch: hooks that submit new tasks | `num`(2000) `budget`(2000) `depth`(32) `stage`(submitted/completed) | | `--hclose` | OnPoolClosed exactly-once (incl. racing Close) and post-close silence | `num`(2000) `closers`(4) | +| `--henqueue` | Enqueued accounting on the overflow buffer (queue << num), slow and reentrant Enqueued callbacks | `num`(2000) `reenter`(500) `workers`(1) `queue`(2) `delay-us`(50) `submitters`(1) `rtask-us`(100) `attempts`(8) | Submit strategies: immediate / linear / constant / poisson / phased. Task duration types: fixed / uniform / normal. Dependencies: @@ -120,7 +121,7 @@ test/ example.go demo plugins provider/consumer (not registered by default) pool.go ... the six real plugins (one file per plugin) hookcheck.go shared machinery for the h* stability family - hcount.go ... the eight h* hook-stability plugins (h*.go, one per file) + hcount.go ... the nine h* hook-stability plugins (h*.go, one per file) run_test.bat Windows regression script (new syntax) run_test.sh Linux/macOS regression script (same scenarios as the bat) run_hook_stress.bat/.sh hook-stability scenario scripts (optional `race` arg) diff --git a/test/README_zh-CN.md b/test/README_zh-CN.md index 5ddaf77..2ed939a 100644 --- a/test/README_zh-CN.md +++ b/test/README_zh-CN.md @@ -37,6 +37,7 @@ go-agile-pool 的性能测试工具。完整设计文档与插件编写指南( | `--hchurn` | 分发前多协程并发注册风暴(契约窗口),精确记账 | `num`(10000) `num2`(5000) `churners`(4) `adds`(25) | | `--hreenter` | 重入分发:钩子内再提交新任务 | `num`(2000) `budget`(2000) `depth`(32) `stage`(submitted/completed) | | `--hclose` | OnPoolClosed 恰好一次(含并发 Close)+ 关闭后静默 | `num`(2000) `closers`(4) | +| `--henqueue` | 溢出缓冲路径上的 Enqueued 精确记账(queue << num)、慢钩子与重入钩子 | `num`(2000) `reenter`(500) `workers`(1) `queue`(2) `delay-us`(50) `submitters`(1) `rtask-us`(100) `attempts`(8) | submit 策略:immediate / linear / constant / poisson / phased;任务时长类型: fixed / uniform / normal。依赖关系:`submit ← pool+task`,`hook ← pool`, @@ -102,7 +103,7 @@ test/ example.go 演示插件 provider/consumer(默认不注册) pool.go ... 六个正式插件(一文件一插件) hookcheck.go h* 稳定性家族共用机制 - hcount.go ... 八个 h* 钩子稳定性插件(h*.go,一文件一插件) + hcount.go ... 九个 h* 钩子稳定性插件(h*.go,一文件一插件) run_test.bat Windows 回归脚本(新语法) run_test.sh Linux/macOS 回归脚本(与 bat 同场景) run_hook_stress.bat/.sh 钩子稳定性场景脚本(可带 `race` 参数) diff --git a/test/hblock.go b/test/hblock.go index 5e29ed1..c09c9fa 100644 --- a/test/hblock.go +++ b/test/hblock.go @@ -59,10 +59,10 @@ func (hblockPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { h := hook.NewHooks() var cnt counters slow := func() { time.Sleep(time.Duration(delayUs) * time.Microsecond) } - h.AddTaskSubmitted(func(context.Context, agilepool.Task) { slow(); cnt.Submitted.Add(1) }) - h.AddTaskEnqueued(func(context.Context, agilepool.Task) { slow(); cnt.Enqueued.Add(1) }) - h.AddTaskStarted(func(context.Context, agilepool.Task) { slow(); cnt.Started.Add(1) }) - h.AddTaskCompleted(func(context.Context, agilepool.Task, any) { slow(); cnt.Completed.Add(1) }) + h.AddTaskSubmitted(func(context.Context) { slow(); cnt.Submitted.Add(1) }) + h.AddTaskEnqueued(func(context.Context) { slow(); cnt.Enqueued.Add(1) }) + h.AddTaskStarted(func(context.Context) { slow(); cnt.Started.Add(1) }) + h.AddTaskCompleted(func(context.Context, any) { slow(); cnt.Completed.Add(1) }) if err := p.SetHook(h); err != nil { return err } diff --git a/test/hchurn.go b/test/hchurn.go index 3fea85a..d917823 100644 --- a/test/hchurn.go +++ b/test/hchurn.go @@ -99,10 +99,10 @@ func (hchurnPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { for i := 0; i < adds; i++ { // One hook per event type per iteration, so every event // list deterministically receives churners*adds burst hooks. - churn.AddTaskSubmitted(func(context.Context, agilepool.Task) { burst.Submitted.Add(1) }) - churn.AddTaskEnqueued(func(context.Context, agilepool.Task) { burst.Enqueued.Add(1) }) - churn.AddTaskStarted(func(context.Context, agilepool.Task) { burst.Started.Add(1) }) - churn.AddTaskCompleted(func(context.Context, agilepool.Task, any) { burst.Completed.Add(1) }) + churn.AddTaskSubmitted(func(context.Context) { burst.Submitted.Add(1) }) + churn.AddTaskEnqueued(func(context.Context) { burst.Enqueued.Add(1) }) + churn.AddTaskStarted(func(context.Context) { burst.Started.Add(1) }) + churn.AddTaskCompleted(func(context.Context, any) { burst.Completed.Add(1) }) } }() } diff --git a/test/hctx.go b/test/hctx.go index ced7d06..84065a5 100644 --- a/test/hctx.go +++ b/test/hctx.go @@ -58,22 +58,22 @@ func (hctxPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { install := func(p *agilepool.Pool) error { h := hook.NewHooks() - h.AddTaskSubmitted(func(ctx context.Context, _ agilepool.Task) { + h.AddTaskSubmitted(func(ctx context.Context) { if _, ok := ctx.Value(ctxMarkerKey{}).(int); !ok { badCtx.Add(1) } }) - h.AddTaskEnqueued(func(ctx context.Context, _ agilepool.Task) { + h.AddTaskEnqueued(func(ctx context.Context) { if _, ok := ctx.Value(ctxMarkerKey{}).(int); !ok { badCtx.Add(1) } }) - h.AddTaskStarted(func(ctx context.Context, _ agilepool.Task) { + h.AddTaskStarted(func(ctx context.Context) { if _, ok := ctx.Value(ctxMarkerKey{}).(int); !ok { badCtx.Add(1) } }) - h.AddTaskCompleted(func(ctx context.Context, _ agilepool.Task, _ any) { + h.AddTaskCompleted(func(ctx context.Context, _ any) { if _, ok := ctx.Value(ctxMarkerKey{}).(int); !ok { badCtx.Add(1) } @@ -165,7 +165,7 @@ func (hctxPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { dst, dc, queued); err != nil { return err } - if err := report("hctx", rt, execDone >= 0 && execDone <= int64(queued) && de <= ds && dst <= ds, + if err := report("hctx", rt, execDone <= int64(queued) && de <= ds && dst <= ds, "[cancel-queued] %d/%d bodies ran before cancellation, hooks stayed symmetric", execDone, queued); err != nil { return err } diff --git a/test/henqueue.go b/test/henqueue.go new file mode 100644 index 0000000..1af0633 --- /dev/null +++ b/test/henqueue.go @@ -0,0 +1,309 @@ +package main + +// henqueue stress-tests OnTaskEnqueued on the overflow-buffer path, which the +// rest of the family deliberately avoids by sizing queue == task count. With +// a small queue and slow tasks most submissions end up in the chunked buffer, +// where the current library has two known problems: +// +// - tasks a worker pulls out of the buffer via PopBatch never fire Enqueued +// (the event is skipped), so Enqueued accounting is wrong under backlog; +// - when a buffered task is forwarded to the channel, Enqueued is dispatched +// while taskBuf's lock is held, so a slow callback stalls the buffer and a +// reentrant callback that submits again can self-deadlock on the +// non-reentrant mutex. +// +// The scenario asserts the contract the event is supposed to honour — exactly +// one Enqueued per accepted task, callbacks free to block or resubmit — so it +// fails (rather than masks) while the library violates it. Both waves run +// even when the first one fails: +// +// 1. accounting: all four counters land on num even though the queue is far +// smaller than the submission count; a slow Enqueued callback must not +// stall the pool. +// 2. reentrancy: the Enqueued callback submits new tasks while the pool is +// dispatching. The submission loop runs on a watchdog goroutine so a +// self-deadlock reports FAIL instead of hanging the harness. +// +// Ordering note: Enqueued may be observed after Started (and, on the buffer +// path, after Completed). Nothing here asserts Enqueued ordering, only its +// exactly-once count. + +import ( + "context" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/Yiming1997/agilePool/v2/internal/hook" +) + +type henqueuePlugin struct{} + +func (henqueuePlugin) Name() string { return "henqueue" } +func (henqueuePlugin) Desc() string { + return "hook stability: Enqueued accounting and reentrancy on the overflow buffer" +} + +func (henqueuePlugin) Options() []Option { + return []Option{ + {Name: "num", Default: "2000", Help: "tasks in the accounting wave"}, + {Name: "reenter", Default: "500", Help: "reentrant submissions per reentrant attempt"}, + {Name: "workers", Default: "1", Help: "worker capacity (keep well below num)"}, + {Name: "queue", Default: "2", Help: "channel capacity (keep well below num)"}, + {Name: "delay-us", Default: "50", Help: "sleep per slow Enqueued callback (microseconds)"}, + {Name: "submitters", Default: "1", Help: "concurrent submitters per reentrant attempt (1 maximizes the race window)"}, + {Name: "rtask-us", Default: "100", Help: "task duration in the reentrant wave (microseconds)"}, + {Name: "attempts", Default: "8", Help: "independent reentrant attempts on fresh pools; the first deadlock is reported"}, + } +} + +func (henqueuePlugin) Run(ctx context.Context, rt *Runtime, args []string) error { + opts := ParseOptions(args) + num, err := GetInt(opts, "num", 2000) + if err != nil { + return err + } + reenter, err := GetInt(opts, "reenter", 500) + if err != nil { + return err + } + workers, err := GetInt(opts, "workers", 1) + if err != nil { + return err + } + queue, err := GetInt(opts, "queue", 2) + if err != nil { + return err + } + delayUs, err := GetInt(opts, "delay-us", 50) + if err != nil { + return err + } + submitters, err := GetInt(opts, "submitters", 1) + if err != nil { + return err + } + rtaskUs, err := GetInt(opts, "rtask-us", 100) + if err != nil { + return err + } + attempts, err := GetInt(opts, "attempts", 8) + if err != nil { + return err + } + if num < 1 || workers < 1 || queue < 1 { + return fmt.Errorf("num/workers/queue must be >= 1") + } + if reenter < 1 { + return fmt.Errorf("option %q: must be >= 1", "reenter") + } + if delayUs < 0 || rtaskUs < 0 { + return fmt.Errorf("delay-us/rtask-us must be >= 0") + } + if submitters < 1 { + return fmt.Errorf("option %q: must be >= 1", "submitters") + } + if attempts < 1 { + return fmt.Errorf("option %q: must be >= 1", "attempts") + } + + baseline := goroutineBaseline() + + // Both waves run even when one fails; the process must not settle + // goroutines after a wave failure because a reentrant deadlock leaves a + // submitter wedged on the buffer lock until the process exits. + var failures []string + if err := overflowWave(rt, num, workers, queue, delayUs); err != nil { + failures = append(failures, err.Error()) + } + if err := reentrantWave(rt, num, reenter, workers, queue, submitters, rtaskUs, attempts); err != nil { + failures = append(failures, err.Error()) + } + if len(failures) > 0 { + return fmt.Errorf("%d/2 wave(s) failed: %s", len(failures), strings.Join(failures, " | ")) + } + + if !settleGoroutines(baseline, 4, 5*time.Second) { + return report("henqueue", rt, false, + "goroutine leak: %d goroutines after scenario, baseline %d", goroutineBaseline(), baseline) + } + return report("henqueue", rt, true, "overflow-buffer Enqueued semantics stable") +} + +// overflowWave asserts exact accounting through the overflow buffer. One slow +// worker and a tiny queue guarantee a backlog, so most tasks travel through +// the buffer; every accepted task must still fire Enqueued exactly once. +func overflowWave(rt *Runtime, num, workers, queue, delayUs int) error { + p := newScenarioPool(int64(workers), int64(queue)) + h := hook.NewHooks() + var cnt counters + cnt.addTo(h, 1) + var slowEnqueued atomic.Int64 + h.AddTaskEnqueued(func(context.Context) { + time.Sleep(time.Duration(delayUs) * time.Microsecond) + slowEnqueued.Add(1) + }) + if err := p.SetHook(h); err != nil { + return err + } + + var executed atomic.Int64 + // Track the peak channel occupancy during submission: one slow worker and + // a tiny queue guarantee the channel hits capacity, which is the + // observable proof that the backlog overflowed into the buffer. Checking + // only after the loop would race with the worker draining a slot. + maxQueueLen := 0 + for i := 0; i < num; i++ { + if l := p.GetTaskQueueLen(); l > maxQueueLen { + maxQueueLen = l + } + p.Submit(agilepool.TaskFunc(func() error { + executed.Add(1) + time.Sleep(time.Millisecond) + return nil + })) + } + if err := report("henqueue", rt, + maxQueueLen == p.GetTaskQueueCapacity(), + "[overflow] channel hit capacity during submission (max len=%d cap=%d): overflow buffer exercised", + maxQueueLen, p.GetTaskQueueCapacity()); err != nil { + return err + } + if !drain(p, 60*time.Second) { + return fmt.Errorf("[overflow] pool did not drain within 60s") + } + + s, e, st, c := cnt.snapshot() + if err := report("henqueue", rt, + s == int64(num) && e == int64(num) && st == int64(num) && c == int64(num), + "[overflow] queue=%d num=%d counters submitted=%d enqueued=%d started=%d completed=%d, want all == %d", + queue, num, s, e, st, c, num); err != nil { + return err + } + if err := report("henqueue", rt, slowEnqueued.Load() == int64(num), + "[overflow] slow Enqueued callback saw %d/%d events without stalling the pool", slowEnqueued.Load(), num); err != nil { + return err + } + if err := report("henqueue", rt, executed.Load() == int64(num), + "[overflow] tasks executed %d == num %d", executed.Load(), num); err != nil { + return err + } + p.SetHook(nil) + p.Close() + return nil +} + +// reentrantWave submits from inside an Enqueued callback while the overflow +// buffer is hot. The self-deadlock needs a narrow interleaving — the direct +// channel send fails, a worker frees a slot, the buffer-lock forward then +// succeeds and its Enqueued dispatch re-enters Submit — so a single attempt +// only hits it intermittently. The wave therefore runs `attempts` independent +// attempts on fresh pools: the first deadlock is reported immediately, while +// attempts that merely mis-account (Enqueued skipped) are aggregated. +func reentrantWave(rt *Runtime, num, reenter, workers, queue, submitters, rtaskUs, attempts int) error { + var failures []string + for a := 1; a <= attempts; a++ { + deadlocked, err := reentrantAttempt(rt, a, num, reenter, workers, queue, submitters, rtaskUs) + if deadlocked { + return err + } + if err != nil { + failures = append(failures, fmt.Sprintf("attempt %d: %v", a, err)) + } + } + if len(failures) > 0 { + return fmt.Errorf("%d/%d reentrant attempt(s) failed: %s", len(failures), attempts, strings.Join(failures, " | ")) + } + return nil +} + +// reentrantAttempt is one fresh-pool reentrancy attempt. It reports a true +// deadlock separately from ordinary assertion failures: a wedged attempt must +// stop the wave at once, because its submitter still holds the old pool's +// buffer lock. Each attempt's submit loop runs on a watchdog goroutine, so a +// wedged Submit reports FAIL instead of hanging the process. +func reentrantAttempt(rt *Runtime, attempt, num, reenter, workers, queue, submitters, rtaskUs int) (bool, error) { + p := newScenarioPool(int64(workers), int64(queue)) + h := hook.NewHooks() + var cnt counters + cnt.addTo(h, 1) + var remaining atomic.Int64 + remaining.Store(int64(reenter)) + var reentered atomic.Int64 + var executed atomic.Int64 + h.AddTaskEnqueued(func(context.Context) { + if remaining.Add(-1) < 0 { + return + } + reentered.Add(1) + p.Submit(agilepool.TaskFunc(func() error { + executed.Add(1) + return nil + })) + }) + if err := p.SetHook(h); err != nil { + return false, err + } + + per := num / submitters + if per < 1 { + per = 1 + } + originals := int64(per * submitters) + + var submitWG sync.WaitGroup + submitDone := make(chan struct{}) + for s := 0; s < submitters; s++ { + submitWG.Add(1) + go func() { + defer submitWG.Done() + for i := 0; i < per; i++ { + p.Submit(agilepool.TaskFunc(func() error { + executed.Add(1) + time.Sleep(time.Duration(rtaskUs) * time.Microsecond) + return nil + })) + } + }() + } + go func() { + submitWG.Wait() + close(submitDone) + }() + + select { + case <-submitDone: + case <-time.After(10 * time.Second): + // Do NOT Close here: Close takes the same buffer lock the wedged + // submitter holds. The harness exits the process on the error. + return true, report("henqueue", rt, false, + "[reentrant] attempt %d: submitter deadlocked after %d submissions with %d submitters: reentrant Enqueued dispatch re-entered the buffer lock (reentered=%d)", + attempt, cnt.Submitted.Load(), submitters, reentered.Load()) + } + if !drain(p, 60*time.Second) { + return false, fmt.Errorf("[reentrant] attempt %d: pool did not drain: reentrant Enqueued callback deadlocked (%d originals, reenter=%d)", attempt, originals, reenter) + } + p.SetHook(nil) + defer p.Close() + + total := originals + reentered.Load() + s, e, st, c := cnt.snapshot() + if err := report("henqueue", rt, + s == total && e == total && st == total && c == total, + "[reentrant] attempt %d counters submitted=%d enqueued=%d started=%d completed=%d for %d originals + %d reentries", + attempt, s, e, st, c, originals, reentered.Load()); err != nil { + return false, err + } + if err := report("henqueue", rt, executed.Load() == total, + "[reentrant] attempt %d executed %d == %d originals + %d reentries", attempt, executed.Load(), originals, reentered.Load()); err != nil { + return false, err + } + if err := report("henqueue", rt, reentered.Load() == int64(reenter), + "[reentrant] attempt %d reentered %d tasks, want exactly budget %d", attempt, reentered.Load(), reenter); err != nil { + return false, err + } + return false, nil +} diff --git a/test/hook.go b/test/hook.go index e7243ec..7bd21c5 100644 --- a/test/hook.go +++ b/test/hook.go @@ -63,10 +63,10 @@ func (hookPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { return err } h := hook.NewHooks() - h.AddTaskSubmitted(func(context.Context, agilepool.Task) { stats.Submitted.Add(1) }) - h.AddTaskEnqueued(func(context.Context, agilepool.Task) { stats.Enqueued.Add(1) }) - h.AddTaskStarted(func(context.Context, agilepool.Task) { stats.Started.Add(1) }) - h.AddTaskCompleted(func(context.Context, agilepool.Task, any) { stats.Completed.Add(1) }) + h.AddTaskSubmitted(func(context.Context) { stats.Submitted.Add(1) }) + h.AddTaskEnqueued(func(context.Context) { stats.Enqueued.Add(1) }) + h.AddTaskStarted(func(context.Context) { stats.Started.Add(1) }) + h.AddTaskCompleted(func(context.Context, any) { stats.Completed.Add(1) }) if err := p.SetHook(h); err != nil { return err } diff --git a/test/hookcheck.go b/test/hookcheck.go index 91a4b39..60595c4 100644 --- a/test/hookcheck.go +++ b/test/hookcheck.go @@ -47,10 +47,10 @@ type counters struct { // its own counter, so a scenario can install "many hooks" cheaply. func (c *counters) addTo(h *hook.Hooks, n int) { for i := 0; i < n; i++ { - h.AddTaskSubmitted(func(context.Context, agilepool.Task) { c.Submitted.Add(1) }) - h.AddTaskEnqueued(func(context.Context, agilepool.Task) { c.Enqueued.Add(1) }) - h.AddTaskStarted(func(context.Context, agilepool.Task) { c.Started.Add(1) }) - h.AddTaskCompleted(func(context.Context, agilepool.Task, any) { c.Completed.Add(1) }) + h.AddTaskSubmitted(func(context.Context) { c.Submitted.Add(1) }) + h.AddTaskEnqueued(func(context.Context) { c.Enqueued.Add(1) }) + h.AddTaskStarted(func(context.Context) { c.Started.Add(1) }) + h.AddTaskCompleted(func(context.Context, any) { c.Completed.Add(1) }) } } @@ -68,39 +68,39 @@ type panicHooks struct { panicValue any } -func (p *panicHooks) DispatchTaskSubmitted(ctx context.Context, t agilepool.Task) { +func (p *panicHooks) DispatchTaskSubmitted(ctx context.Context) { if p.panicWhen("submitted") { panic(p.panicValue) } if p.h != nil { - p.h.DispatchTaskSubmitted(ctx, t) + p.h.DispatchTaskSubmitted(ctx) } } -func (p *panicHooks) DispatchTaskEnqueued(ctx context.Context, t agilepool.Task) { +func (p *panicHooks) DispatchTaskEnqueued(ctx context.Context) { if p.panicWhen("enqueued") { panic(p.panicValue) } if p.h != nil { - p.h.DispatchTaskEnqueued(ctx, t) + p.h.DispatchTaskEnqueued(ctx) } } -func (p *panicHooks) DispatchTaskStarted(ctx context.Context, t agilepool.Task) { +func (p *panicHooks) DispatchTaskStarted(ctx context.Context) { if p.panicWhen("started") { panic(p.panicValue) } if p.h != nil { - p.h.DispatchTaskStarted(ctx, t) + p.h.DispatchTaskStarted(ctx) } } -func (p *panicHooks) DispatchTaskCompleted(ctx context.Context, t agilepool.Task, rec any) { +func (p *panicHooks) DispatchTaskCompleted(ctx context.Context, rec any) { if p.panicWhen("completed") { panic(p.panicValue) } if p.h != nil { - p.h.DispatchTaskCompleted(ctx, t, rec) + p.h.DispatchTaskCompleted(ctx, rec) } } diff --git a/test/horder.go b/test/horder.go index 5562311..1714a60 100644 --- a/test/horder.go +++ b/test/horder.go @@ -132,7 +132,7 @@ func (horderPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { expectPanic := func(id int) bool { return id < panics } h := hook.NewHooks() - h.AddTaskSubmitted(func(ctx context.Context, _ agilepool.Task) { + h.AddTaskSubmitted(func(ctx context.Context) { id, ok := ctx.Value(orderCtxKey{}).(int) if !ok { badCtx.Add(1) @@ -141,7 +141,7 @@ func (horderPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { submitted.Add(1) recs[id].fire(orderEvSubmitted) }) - h.AddTaskEnqueued(func(ctx context.Context, _ agilepool.Task) { + h.AddTaskEnqueued(func(ctx context.Context) { id, ok := ctx.Value(orderCtxKey{}).(int) if !ok { badCtx.Add(1) @@ -150,7 +150,7 @@ func (horderPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { enqueued.Add(1) recs[id].fire(orderEvEnqueued) }) - h.AddTaskStarted(func(ctx context.Context, _ agilepool.Task) { + h.AddTaskStarted(func(ctx context.Context) { id, ok := ctx.Value(orderCtxKey{}).(int) if !ok { badCtx.Add(1) @@ -159,7 +159,7 @@ func (horderPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { started.Add(1) recs[id].fire(orderEvStarted) }) - h.AddTaskCompleted(func(ctx context.Context, _ agilepool.Task, recovered any) { + h.AddTaskCompleted(func(ctx context.Context, recovered any) { id, ok := ctx.Value(orderCtxKey{}).(int) if !ok { badCtx.Add(1) diff --git a/test/hpanic.go b/test/hpanic.go index c6e1f4f..c53f5ec 100644 --- a/test/hpanic.go +++ b/test/hpanic.go @@ -4,8 +4,11 @@ package main // // - level=callback: every registered callback of the stage under test // panics; internal/hook.Hooks must recover each one (invoke) while the -// counting callbacks registered before them keep receiving events, and -// the pool keeps executing tasks. +// counting callbacks registered before AND after them keep receiving +// events, and the pool keeps executing tasks. The panicking callback is +// deliberately sandwiched: a recover path that panics itself (e.g. a nil +// logger) aborts the rest of the callback list and starves the "after" +// counters, which must fail this scenario. // - level=dispatch: the whole hooks implementation panics at the dispatch // entry of the stage under test; pool.dispatchHook must recover it and // keep the submission/worker/Close paths and their bookkeeping intact. @@ -17,6 +20,7 @@ package main import ( "context" "fmt" + "strings" "sync/atomic" "time" @@ -67,11 +71,17 @@ func (hpanicPlugin) Run(ctx context.Context, rt *Runtime, args []string) error { } baseline := goroutineBaseline() + // Run every stage even after a failure, so the output lists all broken + // lifecycle points instead of only the first one. + var failures []string for _, s := range stages { if err := runPanicStage(rt, s, level, num); err != nil { - return err + failures = append(failures, fmt.Sprintf("%s: %v", s, err)) } } + if len(failures) > 0 { + return fmt.Errorf("%d/%d stage(s) failed: %s", len(failures), len(stages), strings.Join(failures, " | ")) + } if !settleGoroutines(baseline, 4, 5*time.Second) { return report("hpanic", rt, false, "goroutine leak: %d goroutines after scenario, baseline %d", goroutineBaseline(), baseline) @@ -90,33 +100,30 @@ func runPanicStage(rt *Runtime, stage, level string, num int) error { var executed atomic.Int64 panicFn := func() { panic(panicMsg) } - countStage := map[string]func(*counters) *atomic.Int64{ - "submitted": func(c *counters) *atomic.Int64 { return &c.Submitted }, - "enqueued": func(c *counters) *atomic.Int64 { return &c.Enqueued }, - "started": func(c *counters) *atomic.Int64 { return &c.Started }, - "completed": func(c *counters) *atomic.Int64 { return &c.Completed }, - } var want int64 = int64(num) if level == "callback" { h := hook.NewHooks() - var cnt counters - cnt.addTo(h, 1) - // The panicking callback sits after the counters: the counters must - // still see every event even though a sibling callback panics. + // The panicking callback sits BETWEEN two counter sets: recovery is + // per callback, so both siblings must still see every event. This is + // the assertion that catches a recovery path which panics itself + // (e.g. the zero-value Hooks logger): the remainder of the callback + // list is aborted and the "after" set goes to zero. + var before, after counters + before.addTo(h, 1) switch stage { case "submitted": - h.AddTaskSubmitted(func(context.Context, agilepool.Task) { panicFn() }) + h.AddTaskSubmitted(func(context.Context) { panicFn() }) case "enqueued": - h.AddTaskEnqueued(func(context.Context, agilepool.Task) { panicFn() }) + h.AddTaskEnqueued(func(context.Context) { panicFn() }) case "started": - h.AddTaskStarted(func(context.Context, agilepool.Task) { panicFn() }) + h.AddTaskStarted(func(context.Context) { panicFn() }) case "completed": - h.AddTaskCompleted(func(context.Context, agilepool.Task, any) { panicFn() }) + h.AddTaskCompleted(func(context.Context, any) { panicFn() }) case "closed": h.AddPoolClosed(func(*agilepool.Pool) { panicFn() }) - want = 0 // PoolClosed carries no per-task counter } + after.addTo(h, 1) if err := p.SetHook(h); err != nil { return err } @@ -127,14 +134,14 @@ func runPanicStage(rt *Runtime, stage, level string, num int) error { if stage == "closed" { p.Close() } - if stage != "closed" { - s, e, st, c := cnt.snapshot() - got := countStage[stage](&cnt).Load() - if got != want { - return report("hpanic", rt, false, - "[%s/callback] counter %s = %d, want %d (S=%d E=%d St=%d C=%d)", - stage, stage, got, want, s, e, st, c) - } + bs, be, bst, bc := before.snapshot() + as, ae, ast, ac := after.snapshot() + if err := report("hpanic", rt, + bs == want && be == want && bst == want && bc == want && + as == want && ae == want && ast == want && ac == want, + "[%s/callback] sibling counter sets saw every event: before(S=%d E=%d St=%d C=%d) after(S=%d E=%d St=%d C=%d), want all == %d", + stage, bs, be, bst, bc, as, ae, ast, ac, want); err != nil { + return err } if err := report("hpanic", rt, executed.Load() == int64(num), "[%s/callback] %d tasks executed despite panicking %s hook", stage, executed.Load(), stage); err != nil { diff --git a/test/hreenter.go b/test/hreenter.go index 8f6ad25..22e24ce 100644 --- a/test/hreenter.go +++ b/test/hreenter.go @@ -99,9 +99,9 @@ func (hreenterPlugin) Run(ctx context.Context, rt *Runtime, args []string) error p.Submit(task()) } if stage == "submitted" { - h.AddTaskSubmitted(func(context.Context, agilepool.Task) { reenter() }) + h.AddTaskSubmitted(func(context.Context) { reenter() }) } else { - h.AddTaskCompleted(func(context.Context, agilepool.Task, any) { reenter() }) + h.AddTaskCompleted(func(context.Context, any) { reenter() }) } if err := p.SetHook(h); err != nil { return err diff --git a/test/plugins.go b/test/plugins.go index 49edb96..b8073a3 100644 --- a/test/plugins.go +++ b/test/plugins.go @@ -21,6 +21,7 @@ var allPlugins = []Plugin{ hchurnPlugin{}, hreenterPlugin{}, hclosePlugin{}, + henqueuePlugin{}, &metricsPlugin{}, &profilePlugin{}, } diff --git a/test/run_hook_stress.bat b/test/run_hook_stress.bat index 02698fa..fbfff22 100644 --- a/test/run_hook_stress.bat +++ b/test/run_hook_stress.bat @@ -1,6 +1,7 @@ @REM Hook-system stability scenarios (h* plugin family). Each scenario is a @REM standalone plugin that builds its own private pools and asserts PASS/FAIL -@REM invariants; any FAIL makes the scenario exit 1 and the script aborts. +@REM invariants. Every scenario runs even when an earlier one fails, so one +@REM broken area never hides another; the script exits 1 if any scenario FAILed. @REM Run with the race detector: run_hook_stress.bat race @REM (race builds a -race binary; slower, use it in CI to catch hook races) @REM Per-plugin knobs: see `agilepool_test.exe --help hcount` etc. @@ -11,23 +12,26 @@ if "%1"=="race" ( ) if errorlevel 1 exit /b 1 -.\agilepool_test.exe --hcount -if errorlevel 1 exit /b 1 -.\agilepool_test.exe --hpanic num=3000 stage=all -if errorlevel 1 exit /b 1 -.\agilepool_test.exe --hpanic num=200 level=callback stage=all 2>nul -if errorlevel 1 exit /b 1 -.\agilepool_test.exe --horder -if errorlevel 1 exit /b 1 -.\agilepool_test.exe --hctx -if errorlevel 1 exit /b 1 -.\agilepool_test.exe --hblock num=2000 delay-us=100 -if errorlevel 1 exit /b 1 -.\agilepool_test.exe --hchurn -if errorlevel 1 exit /b 1 -.\agilepool_test.exe --hreenter -if errorlevel 1 exit /b 1 -.\agilepool_test.exe --hclose -if errorlevel 1 exit /b 1 +set FAILED=0 +call :scenario --hcount +call :scenario --hpanic num=3000 stage=all +call :scenario --hpanic num=200 level=callback stage=all 2>nul +call :scenario --horder +call :scenario --hctx +call :scenario --hblock num=2000 delay-us=100 +call :scenario --hchurn +call :scenario --hreenter +call :scenario --hclose +call :scenario --henqueue +if not "%FAILED%"=="0" ( + @echo hook-stability suite: FAILURES + exit /b 1 +) @echo hook-stability suite: all PASS +exit /b 0 + +:scenario +.\agilepool_test.exe %* +if errorlevel 1 set FAILED=1 +exit /b 0 diff --git a/test/run_hook_stress.sh b/test/run_hook_stress.sh index 172c42e..1353be8 100644 --- a/test/run_hook_stress.sh +++ b/test/run_hook_stress.sh @@ -1,25 +1,35 @@ #!/bin/bash # Hook-system stability scenarios (h* plugin family). Each scenario is a # standalone plugin that builds its own private pools and asserts PASS/FAIL -# invariants; any FAIL makes the scenario exit 1 and the script aborts. +# invariants. Every scenario runs even when an earlier one fails, so one +# broken area never hides another; the script exits 1 if any scenario FAILed. # Run with the race detector: run_hook_stress.sh race # (race builds a -race binary; slower, use it in CI to catch hook races) # Per-plugin knobs: see `./agilepool_test --help hcount` etc. -set -e if [ "$1" = "race" ]; then go build -race -o agilepool_test . else go build -o agilepool_test . fi -./agilepool_test --hcount -./agilepool_test --hpanic num=3000 stage=all -./agilepool_test --hpanic num=200 level=callback stage=all 2>/dev/null -./agilepool_test --horder -./agilepool_test --hctx -./agilepool_test --hblock num=2000 delay-us=100 -./agilepool_test --hchurn -./agilepool_test --hreenter -./agilepool_test --hclose +FAILED=0 +run() { + "$@" || FAILED=1 +} +run ./agilepool_test --hcount +run ./agilepool_test --hpanic num=3000 stage=all +run ./agilepool_test --hpanic num=200 level=callback stage=all 2>/dev/null +run ./agilepool_test --horder +run ./agilepool_test --hctx +run ./agilepool_test --hblock num=2000 delay-us=100 +run ./agilepool_test --hchurn +run ./agilepool_test --hreenter +run ./agilepool_test --hclose +run ./agilepool_test --henqueue + +if [ "$FAILED" -ne 0 ]; then + echo "hook-stability suite: FAILURES" + exit 1 +fi echo "hook-stability suite: all PASS" diff --git a/worker.go b/worker.go index 8fb48cd..7de0583 100644 --- a/worker.go +++ b/worker.go @@ -119,10 +119,8 @@ func (w *worker) runTask(task Task) { defer w.pool.done() hookCtx := context.Background() - hookTask := task if wrapped, ok := task.(*contextTask); ok { hookCtx = wrapped.ctx - hookTask = wrapped.task } // Capture task panics so the Completed hook can observe the recovered @@ -135,12 +133,12 @@ func (w *worker) runTask(task Task) { w.pool.logger.Printf("worker exits from panic: %v\n%s\n", p, Stack(1)) } w.pool.dispatchHook(func(h hooks) { - h.DispatchTaskCompleted(hookCtx, hookTask, recovered) + h.DispatchTaskCompleted(hookCtx, recovered) }) }() w.pool.dispatchHook(func(h hooks) { - h.DispatchTaskStarted(hookCtx, hookTask) + h.DispatchTaskStarted(hookCtx) }) task.process() } From 4b86a1078a7c041197c632efa24348a0b0ed3bd9 Mon Sep 17 00:00:00 2001 From: Bury <18151161@qq.com> Date: Thu, 10 Sep 2026 16:17:39 +0800 Subject: [PATCH 4/5] feat(hook): expose the bundled dispatcher as a public package Move internal/hook to the importable hook package and export the agilepool.Hooks interface accepted by Pool.SetHook, so third-party modules can use the bundled dispatcher. Update the gin example and the test harness to the public import, document lifecycle hooks in both READMEs, and drop the internal/hook package. --- README_zh-CN.md | 30 ++++++++++++++++++ docs/hook-stability-test-report.md | 4 +-- docs/test-harness.md | 8 ++--- example/gin_example/main.go | 29 +++++++++--------- hook.go | 9 ++++-- {internal/hook => hook}/hook.go | 49 ++++++++++++++++++++---------- hook_test.go | 2 +- pool.go | 24 +++++++++------ readme.md | 30 ++++++++++++++++++ test/hblock.go | 2 +- test/hchurn.go | 2 +- test/hclose.go | 2 +- test/hcount.go | 2 +- test/hctx.go | 2 +- test/henqueue.go | 2 +- test/hook.go | 2 +- test/hookcheck.go | 2 +- test/horder.go | 2 +- test/hpanic.go | 4 +-- test/hreenter.go | 2 +- worker.go | 4 +-- 21 files changed, 150 insertions(+), 63 deletions(-) rename {internal/hook => hook}/hook.go (57%) diff --git a/README_zh-CN.md b/README_zh-CN.md index 9aef867..1155ec0 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -30,6 +30,7 @@ - **带超时的提交** — `SubmitBefore` 设定执行截止时间。 - **优雅关闭** — `Wait` 等待所有运行中任务完成;`Close` 停止接收新任务。 - **自定义 Logger** — 可接入任何实现了 `Printf`/`Println` 的日志库(如 `zap.SugaredLogger`)。 +- **生命周期钩子** — `hook.NewHooks()` 分发 Submitted / Enqueued / Started / Completed / PoolClosed 回调;回调 panic 会被隔离并记录日志。 ## 安装 @@ -248,6 +249,35 @@ pool.SetLogger(log.Default()) // 标准库 pool.SetLogger(zapLogger.Sugar()) // zap ``` +## 生命周期钩子 + +`hook` 包提供五类生命周期事件的默认分发器。请在提交任务前注册回调,然后通过 `SetHook` 安装: + +```go +import ( + "context" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/Yiming1997/agilePool/v2/hook" +) + +h := hook.NewHooks() +h.AddTaskSubmitted(func(ctx context.Context) { /* 已提交 */ }) +h.AddTaskEnqueued(func(ctx context.Context) { /* 已入队 */ }) +h.AddTaskStarted(func(ctx context.Context) { /* 已开始 */ }) +h.AddTaskCompleted(func(ctx context.Context, recovered any) { + // 正常结束为 nil,否则为 panic 传入的值。 +}) +h.AddPoolClosed(func(p *agilepool.Pool) { /* 池已关闭 */ }) +pool.SetHook(h) +``` + +- 回调在触发事件的 goroutine 中执行:`Submitted`/`Enqueued` 在提交方,`Started`/`Completed` 在 worker,`PoolClosed` 在 `Close` 调用方。 +- 回调 panic 会被 recover 并记录日志,同一事件的其余回调不受影响。 +- `Enqueued` 对每个被接受的任务恰好触发一次。溢出缓冲路径下它在缓冲锁释放后派发,因此可能晚于 `Started`(甚至 `Completed`)被观察到;生命周期事件之间的顺序不做保证。 +- 回调不再接收 Task 本身,按任务携带数据请用 `SubmitCtx` 写入 ctx。 +- 必须在池开始处理任务前注册:每次分发都会无同步地读取钩子集合。自定义实现只需满足 `agilepool.Hooks` 的五个方法。 + ## 基准测试 完整的基准测试套件对比了 agilePool 与其他主流 Go goroutine 池的性能表现,详见 [agilePool-benchmark](https://github.com/Yiming1997/agilePool-benchmark)。 diff --git a/docs/hook-stability-test-report.md b/docs/hook-stability-test-report.md index 95cd7ba..b9e5089 100644 --- a/docs/hook-stability-test-report.md +++ b/docs/hook-stability-test-report.md @@ -23,7 +23,7 @@ OnTaskStarted / OnTaskCompleted / OnPoolClosed)。本次为 test 工具新增一 - `test/` 拆分为独立 Go 模块 `github.com/Yiming1997/agilePool/v2/test`, `go.mod` 用 `replace => ../` 把库引用指向仓库根目录(离线、严格本地库); - 模块路径保留库前缀,使 `internal/hook` 可被导入。 + 测试使用公开的 `hook` 包(与外部使用者同一入口)。 - 每个 h* 插件自建私有池(`queue == 任务数`,走直通 channel 路径, 保证 Enqueued 每任务恰触发一次),断言 PASS/FAIL,失败退出码 1。 - 公共机制集中在 `test/hookcheck.go`(场景池、原子计数器、drain/goroutine @@ -54,7 +54,7 @@ OnTaskStarted / OnTaskCompleted / OnPoolClosed)。本次为 test 工具新增一 ## 5. 关键结论 -1. **注册契约**:`internal/hook.Hooks` 的 `Add*` 仅用于启动期(多协程并发 +1. **注册契约**:`hook.Hooks` 的 `Add*` 仅用于启动期(多协程并发 注册由互斥锁保证);分发读取已冻结的列表。任务运行中注册不属于契约, 库未做任何改动。 2. **panic 双保险成立**:callback 层(库内 `invoke` 逐个 recover)与 diff --git a/docs/test-harness.md b/docs/test-harness.md index d7a2a97..8ce865c 100644 --- a/docs/test-harness.md +++ b/docs/test-harness.md @@ -279,7 +279,7 @@ race-free in CI. Notes for interpretation: - `hpanic level=callback` guards the per-callback recovery of the bundled - `internal/hook.Hooks`: the panicking callback is sandwiched so a recover + `hook.Hooks`: the panicking callback is sandwiched so a recover path that panics itself (a zero-value `log.Logger` used to) starves the second counter set and fails the scenario. stderr is silenced by the scripts because the recovery path logs noisily by design. @@ -301,8 +301,8 @@ Notes for interpretation: - `test/` is a nested Go module (`github.com/Yiming1997/agilePool/v2/test`, own `go.mod`) whose `replace` points the library import at the repo root, so the harness builds against the local tree, never the network copy. The - module path keeps the library prefix so `internal/hook` stays importable. -- Registration contract of `internal/hook.Hooks`: callbacks are added + harness uses the public `hook` package, the same one external users get. +- Registration contract of `hook.Hooks`: callbacks are added before the pool starts processing (typically from setup code, possibly concurrently — `Add*` is mutex-guarded). Dispatch reads the frozen lists and never synchronizes with registration; adding a callback while events @@ -312,7 +312,7 @@ Notes for interpretation: from a running pool. - Defects this suite found and that are now fixed (kept as regression coverage by `hpanic level=callback` and `henqueue`): - 1. `internal/hook.Hooks.logger` was a zero-value `log.Logger`, so + 1. `hook.Hooks.logger` was a zero-value `log.Logger`, so `invoke`'s recover path panicked while logging a recovered callback panic and skipped the remaining callbacks of that event. 2. Tasks consumed from the overflow buffer via `PopBatch` never fired diff --git a/example/gin_example/main.go b/example/gin_example/main.go index f0aeb24..132e45f 100644 --- a/example/gin_example/main.go +++ b/example/gin_example/main.go @@ -8,7 +8,7 @@ import ( "time" agilepool "github.com/Yiming1997/agilePool/v2" - hook "github.com/Yiming1997/agilePool/v2/internal/hook" + hook "github.com/Yiming1997/agilePool/v2/hook" "github.com/gin-gonic/gin" ) @@ -20,24 +20,25 @@ var pool = agilepool.NewPool(agilepool.NewConfig( agilepool.WithWorkerNumCapacity(8), )) -// A newly-created Pool has hooks == nil, so hooks are disabled. Registering -// any non-nil callback enables hook dispatch for that Pool. +// A newly-created Pool has no hook dispatcher, so hooks are disabled. +// SetHook enables them. var taskStartTimes sync.Map func main() { defer pool.Close() - // Register callbacks before submitting tasks. Hook callbacks run in the - // submitting goroutine, worker goroutine, or Close caller as appropriate. - // The registration calls are shown above because this version does not yet - // expose them on agilepool.Pool. - var hook = hook.NewHooks() - hook.AddTaskSubmitted(onSubmitted) - hook.AddTaskEnqueued(onEnqueued) - hook.AddTaskStarted(onStarted) - hook.AddTaskCompleted(onCompleted) - hook.AddPoolClosed(onPoolClosed) - pool.SetHook(hook) + // Register callbacks before submitting tasks: the hook set is read + // without synchronization by every dispatch path. Hook callbacks run in + // the submitting goroutine, a worker goroutine, or the Close caller as + // appropriate. hook.NewHooks is the bundled dispatcher; a custom + // implementation only needs agilepool.Hooks' five dispatch methods. + h := hook.NewHooks() + h.AddTaskSubmitted(onSubmitted) + h.AddTaskEnqueued(onEnqueued) + h.AddTaskStarted(onStarted) + h.AddTaskCompleted(onCompleted) + h.AddPoolClosed(onPoolClosed) + pool.SetHook(h) r := gin.Default() r.Use(PoolMiddleware) // This example shows how to limit concurrency for incoming requests. diff --git a/hook.go b/hook.go index c1f2b5b..302c38f 100644 --- a/hook.go +++ b/hook.go @@ -4,7 +4,8 @@ import ( "context" ) -// TaskHook is the callback signature for task lifecycle events. +// TaskHook is the callback signature for task lifecycle events. The task +// itself is not passed; use SubmitCtx to carry per-task data through ctx. type TaskHook func(ctx context.Context) // TaskCompleteHook is the callback signature for task completion. @@ -14,7 +15,11 @@ type TaskCompleteHook func(ctx context.Context, recovered any) // PoolHook is the callback signature for pool-level events. type PoolHook func(p *Pool) -type hooks interface { +// Hooks is the lifecycle callback dispatcher accepted by Pool.SetHook. The +// bundled implementation lives in +// github.com/Yiming1997/agilePool/v2/hook; custom implementations only need +// the five dispatch methods below. +type Hooks interface { // DispatchTaskSubmitted must only be called by the pool submission path. DispatchTaskSubmitted(context.Context) // DispatchTaskEnqueued must only be called by the pool enqueue path. It diff --git a/internal/hook/hook.go b/hook/hook.go similarity index 57% rename from internal/hook/hook.go rename to hook/hook.go index 3ab3632..4d1529b 100644 --- a/internal/hook/hook.go +++ b/hook/hook.go @@ -1,3 +1,16 @@ +// Package hook provides the bundled lifecycle callback dispatcher for +// agilePool. Create one with NewHooks, register callbacks, and hand it to +// Pool.SetHook before submitting tasks: +// +// h := hook.NewHooks() +// h.AddTaskStarted(func(ctx context.Context) { ... }) +// pool.SetHook(h) +// +// Every callback runs in the goroutine that triggers the event: the +// submitting goroutine for Submitted/Enqueued, a worker for Started/ +// Completed, and the Close caller for PoolClosed. A panicking callback is +// recovered and logged, and does not affect the remaining callbacks of the +// same event. package hook import ( @@ -9,53 +22,57 @@ import ( agilepool "github.com/Yiming1997/agilePool/v2" ) -type TaskHook func(ctx context.Context) -type TaskCompleteHook func(ctx context.Context, recovered any) -type PoolHook func(pool *agilepool.Pool) - -// Hooks stores and dispatches lifecycle callbacks without depending on the -// public pool package. +// Hooks stores and dispatches lifecycle callbacks. It implements the +// agilepool.Hooks interface. type Hooks struct { mu sync.RWMutex - taskSubmitted []TaskHook - taskEnqueued []TaskHook - taskStarted []TaskHook - taskCompleted []TaskCompleteHook - poolClosed []PoolHook + taskSubmitted []agilepool.TaskHook + taskEnqueued []agilepool.TaskHook + taskStarted []agilepool.TaskHook + taskCompleted []agilepool.TaskCompleteHook + poolClosed []agilepool.PoolHook logger log.Logger } +// NewHooks returns an empty dispatcher that logs recovered callback panics +// through the standard logger. func NewHooks() *Hooks { return &Hooks{ logger: *log.Default(), } } -func (h *Hooks) AddTaskSubmitted(fn TaskHook) { +// AddTaskSubmitted registers a callback for task submission. +func (h *Hooks) AddTaskSubmitted(fn agilepool.TaskHook) { h.mu.Lock() h.taskSubmitted = append(h.taskSubmitted, fn) h.mu.Unlock() } -func (h *Hooks) AddTaskEnqueued(fn TaskHook) { +// AddTaskEnqueued registers a callback for task enqueue. +func (h *Hooks) AddTaskEnqueued(fn agilepool.TaskHook) { h.mu.Lock() h.taskEnqueued = append(h.taskEnqueued, fn) h.mu.Unlock() } -func (h *Hooks) AddTaskStarted(fn TaskHook) { +// AddTaskStarted registers a callback for task start. +func (h *Hooks) AddTaskStarted(fn agilepool.TaskHook) { h.mu.Lock() h.taskStarted = append(h.taskStarted, fn) h.mu.Unlock() } -func (h *Hooks) AddTaskCompleted(fn TaskCompleteHook) { +// AddTaskCompleted registers a callback for task completion. The callback +// receives the value a panicking task panicked with, or nil on normal exit. +func (h *Hooks) AddTaskCompleted(fn agilepool.TaskCompleteHook) { h.mu.Lock() h.taskCompleted = append(h.taskCompleted, fn) h.mu.Unlock() } -func (h *Hooks) AddPoolClosed(fn PoolHook) { +// AddPoolClosed registers a callback for pool close. +func (h *Hooks) AddPoolClosed(fn agilepool.PoolHook) { h.mu.Lock() h.poolClosed = append(h.poolClosed, fn) h.mu.Unlock() diff --git a/hook_test.go b/hook_test.go index 0bacba8..9e6fd4f 100644 --- a/hook_test.go +++ b/hook_test.go @@ -9,7 +9,7 @@ import ( // panicHooks is a hooks implementation that deliberately panics at selected // lifecycle points. It mimics a custom hooks implementation that does not -// recover its own callbacks (the bundled internal/hook.Hooks does). +// recover its own callbacks (the bundled hook.Hooks does). type panicHooks struct { panicSubmitted bool panicEnqueued bool diff --git a/pool.go b/pool.go index 2191240..56fd2e8 100644 --- a/pool.go +++ b/pool.go @@ -100,7 +100,7 @@ type Pool struct { consumeHist *histogram // consume count distribution per window exitHist *histogram // exit count distribution per window - hooks hooks // lifecycle callbacks registered via OnTaskSubmitted etc. + hooks Hooks // lifecycle callbacks registered via SetHook } func NewPool(c *Config) *Pool { @@ -220,7 +220,7 @@ func (p *Pool) submit(ctx context.Context, task Task) bool { if wrapped, ok := task.(*contextTask); ok { hookCtx = wrapped.ctx } - p.dispatchHook(func(h hooks) { + p.dispatchHook(func(h Hooks) { h.DispatchTaskSubmitted(hookCtx) }) if p.config.workMode == NONBLOCK { @@ -288,7 +288,7 @@ func (p *Pool) dispatchTaskEnqueuedFor(task Task) { if wrapped, ok := task.(*contextTask); ok { ctx = wrapped.ctx } - p.dispatchHook(func(h hooks) { + p.dispatchHook(func(h Hooks) { h.DispatchTaskEnqueued(ctx) }) } @@ -540,7 +540,7 @@ func (p *Pool) Close() { p.taskBuf.Close() close(p.closePoolCn) - p.dispatchHook(func(h hooks) { + p.dispatchHook(func(h Hooks) { h.DispatchPoolClosed(p) // fire OnPoolClosed hooks }) } @@ -595,17 +595,21 @@ func (p *Pool) GetCapacity() int64 { return p.capacity } -func (p *Pool) SetHook(hooks hooks) error { +// SetHook installs the lifecycle callback dispatcher. Register callbacks +// before the pool starts processing tasks: the hook set is read without +// synchronization by every dispatch path, so replacing it while tasks are in +// flight is a data race. A nil set disables hook dispatch. +func (p *Pool) SetHook(hooks Hooks) error { p.hooks = hooks return nil } // dispatchHook invokes fn with the pool's hook set and absorbs any panic a -// hooks implementation raises. internal/hook.Hooks already recovers each -// registered callback; this guard additionally keeps a custom implementation -// that panics mid-dispatch from crashing the submitting goroutine, a worker, -// or Close, and from skipping pool bookkeeping such as wg.Done. -func (p *Pool) dispatchHook(fn func(h hooks)) { +// hooks implementation raises. hook.Hooks already recovers each registered +// callback; this guard additionally keeps a custom implementation that panics +// mid-dispatch from crashing the submitting goroutine, a worker, or Close, +// and from skipping pool bookkeeping such as wg.Done. +func (p *Pool) dispatchHook(fn func(h Hooks)) { if p.hooks == nil { return } diff --git a/readme.md b/readme.md index 874388c..4cc3659 100644 --- a/readme.md +++ b/readme.md @@ -30,6 +30,7 @@ - **Time-bounded submission** — `SubmitBefore` with a deadline window. - **Graceful shutdown** — `Wait` blocks until all in-flight tasks complete; `Close` stops new submissions. - **Custom logger** — plug any `Printf`/`Println` implementation (e.g. `zap.SugaredLogger`). +- **Lifecycle hooks** — `hook.NewHooks()` dispatches Submitted / Enqueued / Started / Completed / PoolClosed callbacks; panicking callbacks are isolated and logged. ## Installation @@ -248,6 +249,35 @@ pool.SetLogger(log.Default()) // stdlib pool.SetLogger(zapLogger.Sugar()) // zap ``` +## Lifecycle Hooks + +The `hook` package ships the default dispatcher for the five lifecycle events. Register callbacks before submitting tasks, then install it with `SetHook`: + +```go +import ( + "context" + + agilepool "github.com/Yiming1997/agilePool/v2" + "github.com/Yiming1997/agilePool/v2/hook" +) + +h := hook.NewHooks() +h.AddTaskSubmitted(func(ctx context.Context) { /* submitted */ }) +h.AddTaskEnqueued(func(ctx context.Context) { /* enqueued */ }) +h.AddTaskStarted(func(ctx context.Context) { /* started */ }) +h.AddTaskCompleted(func(ctx context.Context, recovered any) { + // recovered is nil on normal exit, otherwise the value passed to panic. +}) +h.AddPoolClosed(func(p *agilepool.Pool) { /* closed */ }) +pool.SetHook(h) +``` + +- Callbacks run in the goroutine that triggers the event: the submitting goroutine for `Submitted`/`Enqueued`, a worker for `Started`/`Completed`, and the `Close` caller for `PoolClosed`. +- A panicking callback is recovered and logged; the remaining callbacks of that event still run. +- `Enqueued` fires exactly once per accepted task. On the overflow-buffer path it is dispatched after the buffer lock is released, so it may be observed after `Started` (or even `Completed`); ordering between lifecycle events is not guaranteed. +- The task itself is not passed to callbacks; carry per-task data through `ctx` via `SubmitCtx`. +- Register before the pool starts processing tasks: the hook set is read without synchronization on every dispatch. A custom implementation only needs the five methods of `agilepool.Hooks`. + ## Benchmark A comprehensive benchmark suite comparing agilePool against other popular Go goroutine pools is available at [agilePool-benchmark](https://github.com/Yiming1997/agilePool-benchmark). diff --git a/test/hblock.go b/test/hblock.go index c09c9fa..16e55fe 100644 --- a/test/hblock.go +++ b/test/hblock.go @@ -13,7 +13,7 @@ import ( "time" agilepool "github.com/Yiming1997/agilePool/v2" - "github.com/Yiming1997/agilePool/v2/internal/hook" + "github.com/Yiming1997/agilePool/v2/hook" ) type hblockPlugin struct{} diff --git a/test/hchurn.go b/test/hchurn.go index d917823..de0618a 100644 --- a/test/hchurn.go +++ b/test/hchurn.go @@ -24,7 +24,7 @@ import ( "time" agilepool "github.com/Yiming1997/agilePool/v2" - "github.com/Yiming1997/agilePool/v2/internal/hook" + "github.com/Yiming1997/agilePool/v2/hook" ) type hchurnPlugin struct{} diff --git a/test/hclose.go b/test/hclose.go index 561c6ce..0b85abb 100644 --- a/test/hclose.go +++ b/test/hclose.go @@ -18,7 +18,7 @@ import ( "time" agilepool "github.com/Yiming1997/agilePool/v2" - "github.com/Yiming1997/agilePool/v2/internal/hook" + "github.com/Yiming1997/agilePool/v2/hook" ) type hclosePlugin struct{} diff --git a/test/hcount.go b/test/hcount.go index d45a419..df14e6a 100644 --- a/test/hcount.go +++ b/test/hcount.go @@ -16,7 +16,7 @@ import ( "time" agilepool "github.com/Yiming1997/agilePool/v2" - "github.com/Yiming1997/agilePool/v2/internal/hook" + "github.com/Yiming1997/agilePool/v2/hook" ) type hcountPlugin struct{} diff --git a/test/hctx.go b/test/hctx.go index 84065a5..3fc3c32 100644 --- a/test/hctx.go +++ b/test/hctx.go @@ -19,7 +19,7 @@ import ( "time" agilepool "github.com/Yiming1997/agilePool/v2" - "github.com/Yiming1997/agilePool/v2/internal/hook" + "github.com/Yiming1997/agilePool/v2/hook" ) type hctxPlugin struct{} diff --git a/test/henqueue.go b/test/henqueue.go index 1af0633..02311ef 100644 --- a/test/henqueue.go +++ b/test/henqueue.go @@ -37,7 +37,7 @@ import ( "time" agilepool "github.com/Yiming1997/agilePool/v2" - "github.com/Yiming1997/agilePool/v2/internal/hook" + "github.com/Yiming1997/agilePool/v2/hook" ) type henqueuePlugin struct{} diff --git a/test/hook.go b/test/hook.go index 7bd21c5..9f9c466 100644 --- a/test/hook.go +++ b/test/hook.go @@ -7,7 +7,7 @@ import ( "sync/atomic" agilepool "github.com/Yiming1997/agilePool/v2" - "github.com/Yiming1997/agilePool/v2/internal/hook" + "github.com/Yiming1997/agilePool/v2/hook" ) // hookStats holds the hook mode and the four lifecycle counters. It is the diff --git a/test/hookcheck.go b/test/hookcheck.go index 60595c4..f4faa3f 100644 --- a/test/hookcheck.go +++ b/test/hookcheck.go @@ -21,7 +21,7 @@ import ( "time" agilepool "github.com/Yiming1997/agilePool/v2" - "github.com/Yiming1997/agilePool/v2/internal/hook" + "github.com/Yiming1997/agilePool/v2/hook" ) // discardLogger swallows pool log lines. Hook-panic scenarios log every diff --git a/test/horder.go b/test/horder.go index 1714a60..f3bb62a 100644 --- a/test/horder.go +++ b/test/horder.go @@ -20,7 +20,7 @@ import ( "time" agilepool "github.com/Yiming1997/agilePool/v2" - "github.com/Yiming1997/agilePool/v2/internal/hook" + "github.com/Yiming1997/agilePool/v2/hook" ) // orderCtxKey carries a task's id through SubmitCtx into the hooks. diff --git a/test/hpanic.go b/test/hpanic.go index c53f5ec..5a16363 100644 --- a/test/hpanic.go +++ b/test/hpanic.go @@ -3,7 +3,7 @@ package main // hpanic stress-tests panic immunity of the hook system. Two levels: // // - level=callback: every registered callback of the stage under test -// panics; internal/hook.Hooks must recover each one (invoke) while the +// panics; hook.Hooks must recover each one (invoke) while the // counting callbacks registered before AND after them keep receiving // events, and the pool keeps executing tasks. The panicking callback is // deliberately sandwiched: a recover path that panics itself (e.g. a nil @@ -25,7 +25,7 @@ import ( "time" agilepool "github.com/Yiming1997/agilePool/v2" - "github.com/Yiming1997/agilePool/v2/internal/hook" + "github.com/Yiming1997/agilePool/v2/hook" ) const panicMsg = "hook stability panic" diff --git a/test/hreenter.go b/test/hreenter.go index 22e24ce..eaf9202 100644 --- a/test/hreenter.go +++ b/test/hreenter.go @@ -18,7 +18,7 @@ import ( "time" agilepool "github.com/Yiming1997/agilePool/v2" - "github.com/Yiming1997/agilePool/v2/internal/hook" + "github.com/Yiming1997/agilePool/v2/hook" ) type hreenterPlugin struct{} diff --git a/worker.go b/worker.go index 7de0583..2b269ee 100644 --- a/worker.go +++ b/worker.go @@ -132,12 +132,12 @@ func (w *worker) runTask(task Task) { recovered = p w.pool.logger.Printf("worker exits from panic: %v\n%s\n", p, Stack(1)) } - w.pool.dispatchHook(func(h hooks) { + w.pool.dispatchHook(func(h Hooks) { h.DispatchTaskCompleted(hookCtx, recovered) }) }() - w.pool.dispatchHook(func(h hooks) { + w.pool.dispatchHook(func(h Hooks) { h.DispatchTaskStarted(hookCtx) }) task.process() From 6a9f15f3cf305f3284349c4ad34cb0028d130394 Mon Sep 17 00:00:00 2001 From: Bury <18151161@qq.com> Date: Thu, 10 Sep 2026 16:24:53 +0800 Subject: [PATCH 5/5] fix(test): build the harness with the library's minimum Go test/go.mod required go 1.26.4, so the nested module failed to compile on Go 1.23.4 (the root module's minimum and the version CI installs): 'go.mod requires go >= 1.26.4'. Align the directive with the library and refresh the stale comment (the harness uses the public hook package now). Verified: root, test and example modules build and vet on both Go 1.23.4 and the default toolchain. --- test/go.mod | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/go.mod b/test/go.mod index fe98427..dc6bbe4 100644 --- a/test/go.mod +++ b/test/go.mod @@ -1,12 +1,12 @@ // test/ is a separate module so the harness and its heavy deps -// (gopsutil) never pollute the root library module. The module path keeps -// the github.com/Yiming1997/agilePool/v2 prefix so internal packages -// (internal/hook) stay importable, and the replace below forces every -// agilePool import to resolve to the root directory on disk: building the -// harness never downloads or uses the network copy of the library. +// (gopsutil) never pollute the root library module. The replace below forces +// every agilePool import to resolve to the root directory on disk: building +// the harness never downloads or uses the network copy of the library. The +// go directive tracks the library's minimum so the harness builds with the +// same toolchain as the root module. module github.com/Yiming1997/agilePool/v2/test -go 1.26.4 +go 1.23.4 require ( github.com/Yiming1997/agilePool/v2 v2.1.0