From b373d2a4ae898ef5490079922169e8cfdf02739e Mon Sep 17 00:00:00 2001 From: kxrxh Date: Wed, 19 Nov 2025 00:02:31 +0300 Subject: [PATCH 01/54] feat(flow): implement adaptive throttler with resource monitoring and throughput control --- examples/adaptive_throttler/main.go | 54 ++ examples/go.mod | 13 +- examples/go.sum | 40 +- flow/adaptive_throttler.go | 382 +++++++++++ flow/adaptive_throttler_test.go | 940 ++++++++++++++++++++++++++++ flow/cpu_sampler.go | 65 ++ flow/resource_monitor.go | 334 ++++++++++ flow/resource_monitor_test.go | 417 ++++++++++++ flow/system_memory.go | 47 ++ go.mod | 15 +- go.sum | 32 + 11 files changed, 2319 insertions(+), 20 deletions(-) create mode 100644 examples/adaptive_throttler/main.go create mode 100644 flow/adaptive_throttler.go create mode 100644 flow/adaptive_throttler_test.go create mode 100644 flow/cpu_sampler.go create mode 100644 flow/resource_monitor.go create mode 100644 flow/resource_monitor_test.go create mode 100644 flow/system_memory.go create mode 100644 go.sum diff --git a/examples/adaptive_throttler/main.go b/examples/adaptive_throttler/main.go new file mode 100644 index 0000000..b394616 --- /dev/null +++ b/examples/adaptive_throttler/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "strings" + "time" + + ext "github.com/reugn/go-streams/extension" + "github.com/reugn/go-streams/flow" +) + +func editMessage(msg string) string { + return strings.ToUpper(msg) +} + +func main() { + throttlerConfig := flow.DefaultAdaptiveThrottlerConfig() + // To setup a custom throttler, you can modify the throttlerConfig struct with your desired values. + // For all available options, see the flow.AdaptiveThrottlerConfig struct. + // Example: + // throttlerConfig.CPUUsageMode = flow.CPUUsageModeHeuristic + // throttlerConfig.MaxMemoryPercent = 80.0 + // throttlerConfig.MaxCPUPercent = 70.0 + // throttlerConfig.MinThroughput = 10 + // throttlerConfig.MaxThroughput = 100 + // throttlerConfig.SampleInterval = 50 * time.Millisecond + // throttlerConfig.BufferSize = 10 + // throttlerConfig.AdaptationFactor = 0.5 + // throttlerConfig.SmoothTransitions = false + throttler := flow.NewAdaptiveThrottler(throttlerConfig) + defer throttler.Close() + + in := make(chan any) + + source := ext.NewChanSource(in) + editMapFlow := flow.NewMap(editMessage, 1) + sink := ext.NewStdoutSink() + + go func() { + source.Via(throttler).Via(editMapFlow).To(sink) + }() + + go func() { + defer close(in) + + for i := 1; i <= 50; i++ { + message := fmt.Sprintf("message-%d", i) + in <- message + time.Sleep(50 * time.Millisecond) + } + }() + + sink.AwaitCompletion() +} diff --git a/examples/go.mod b/examples/go.mod index a2b191f..18d3156 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -1,6 +1,6 @@ module github.com/reugn/go-streams/examples -go 1.23.0 +go 1.24.0 require ( cloud.google.com/go/storage v1.53.0 @@ -73,6 +73,7 @@ require ( github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect + github.com/ebitengine/purego v0.9.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/fatih/color v1.13.0 // indirect @@ -80,6 +81,7 @@ require ( github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect @@ -104,6 +106,7 @@ require ( github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/minio/highwayhash v1.0.3 // indirect @@ -118,16 +121,21 @@ require ( github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_golang v1.20.5 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect + github.com/shirou/gopsutil/v4 v4.25.10 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad // indirect github.com/yuin/gopher-lua v1.1.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect @@ -144,7 +152,7 @@ require ( golang.org/x/net v0.39.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.14.0 // indirect - golang.org/x/sys v0.32.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.31.0 // indirect golang.org/x/text v0.24.0 // indirect golang.org/x/time v0.11.0 // indirect @@ -166,6 +174,7 @@ require ( ) replace ( + github.com/reugn/go-streams => .. github.com/reugn/go-streams/aerospike => ../aerospike github.com/reugn/go-streams/aws => ../aws github.com/reugn/go-streams/azure => ../azure diff --git a/examples/go.sum b/examples/go.sum index 391f03e..3daa531 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -152,6 +152,8 @@ github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4A github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= @@ -175,8 +177,9 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -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/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= @@ -266,8 +269,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -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/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -343,8 +346,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= @@ -361,12 +364,12 @@ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5X github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= -github.com/reugn/go-streams v0.12.0 h1:SEfTZw5+iP0mQG0jWTxVOHMbqlbZjUB0W6dF3XbYd5Q= -github.com/reugn/go-streams v0.12.0/go.mod h1:dnXv6QgVTW62gEpILoLHRjI95Es7ECK2/+j9h17aIN8= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= +github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA= +github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM= 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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -388,14 +391,14 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= -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/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad h1:W0LEBv82YCGEtcmPA3uNZBI33/qF//HAAs3MawDjRa0= github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad/go.mod h1:Hy8o65+MXnS6EwGElrSRjUzQDLXreJlzYLlWiHtt8hM= @@ -404,8 +407,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= -github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= -github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= @@ -470,21 +473,24 @@ golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go new file mode 100644 index 0000000..c715a7c --- /dev/null +++ b/flow/adaptive_throttler.go @@ -0,0 +1,382 @@ +package flow + +import ( + "fmt" + "math" + "sync" + "sync/atomic" + "time" + + "github.com/reugn/go-streams" +) + +// AdaptiveThrottlerConfig configures the adaptive throttler behavior +type AdaptiveThrottlerConfig struct { + // Resource thresholds (0-100 percentage) + MaxMemoryPercent float64 + MaxCPUPercent float64 + + // Throughput bounds (elements per second) + MinThroughput int + MaxThroughput int + + // Resource monitoring + SampleInterval time.Duration // How often to sample resources + + // Buffer configuration + BufferSize int + + // Adaptation parameters (How aggressively to adapt. 0.1 = slow, 0.5 = fast). + // + // Allowed values: 0.0 to 1.0 + AdaptationFactor float64 + + // Rate transition smoothing. + // + // If true, the throughput rate will be smoothed over time to avoid abrupt changes. + SmoothTransitions bool + + // CPU usage sampling mode. + // + // CPUUsageModeHeuristic: Estimates CPU usage using a simple heuristic (goroutine count), suitable for platforms + // where accurate process CPU measurement is not supported. + // + // CPUUsageModeRusage: Measures process CPU time using OS-level resource usage statistics (when supported), providing more accurate CPU usage readings. + CPUUsageMode CPUUsageMode + + // Hysteresis buffer to prevent rapid state changes (percentage points). + // Requires this much additional headroom before increasing rate. + // Default: 5.0 + HysteresisBuffer float64 + + // Maximum rate change factor per adaptation cycle (0.0-1.0). + // Limits how much the rate can change in a single step to prevent instability. + // Default: 0.3 (max 30% change per cycle) + MaxRateChangeFactor float64 +} + +// DefaultAdaptiveThrottlerConfig returns sensible defaults for most use cases +func DefaultAdaptiveThrottlerConfig() AdaptiveThrottlerConfig { + return AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, // Conservative memory threshold + MaxCPUPercent: 70.0, // Conservative CPU threshold + MinThroughput: 10, // Reasonable minimum throughput + MaxThroughput: 500, // More conservative maximum (reduced from 1000) + SampleInterval: 200 * time.Millisecond, // Less frequent sampling (increased from 100ms) + BufferSize: 500, // Match max throughput for 1 second buffer at max rate + AdaptationFactor: 0.15, // Slightly more conservative adaptation (reduced from 0.2) + SmoothTransitions: true, // Keep smooth transitions enabled by default + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, // Prevent oscillations around threshold + MaxRateChangeFactor: 0.3, // More conservative rate changes (reduced from 0.5) + } +} + +// resourceMonitorInterface defines the interface for resource monitoring +type resourceMonitorInterface interface { + GetStats() ResourceStats + IsResourceConstrained() bool + Close() +} + +// AdaptiveThrottler is a flow that adaptively throttles throughput based on +// system resource availability +type AdaptiveThrottler struct { + config AdaptiveThrottlerConfig + monitor resourceMonitorInterface + + // Current rate (elements per second) + currentRate atomic.Int64 + + // Rate control + period time.Duration // Calculated from currentRate + maxElements atomic.Int64 // Elements per period + counter atomic.Int64 + + // Channels + in chan any + out chan any + quotaSignal chan struct{} + done chan struct{} + + // Rate adaptation + adaptMu sync.Mutex + lastAdaptation time.Time + + stopOnce sync.Once +} + +// Verify AdaptiveThrottler satisfies the Flow interface +var _ streams.Flow = (*AdaptiveThrottler)(nil) + +// NewAdaptiveThrottler creates a new adaptive throttler +func NewAdaptiveThrottler(config AdaptiveThrottlerConfig) *AdaptiveThrottler { + // Validate config + if config.MaxMemoryPercent <= 0 || config.MaxMemoryPercent > 100 { + panic(fmt.Sprintf("invalid MaxMemoryPercent: %f", config.MaxMemoryPercent)) + } + if config.MaxCPUPercent <= 0 || config.MaxCPUPercent > 100 { + panic(fmt.Sprintf("invalid MaxCPUPercent: %f", config.MaxCPUPercent)) + } + if config.MinThroughput < 1 { + panic(fmt.Sprintf("invalid MinThroughput: %d", config.MinThroughput)) + } + if config.MaxThroughput < config.MinThroughput { + panic("MaxThroughput must be >= MinThroughput") + } + if config.BufferSize < 1 { + panic(fmt.Sprintf("invalid BufferSize: %d", config.BufferSize)) + } + if config.SampleInterval <= 0 { + panic(fmt.Sprintf("invalid SampleInterval: %v", config.SampleInterval)) + } + if config.AdaptationFactor <= 0 || config.AdaptationFactor > 1 { + panic(fmt.Sprintf("invalid AdaptationFactor: %f", config.AdaptationFactor)) + } + + // Initialize with max throughput + initialRate := int64(config.MaxThroughput) + + at := &AdaptiveThrottler{ + config: config, + monitor: NewResourceMonitor( + config.SampleInterval, + config.MaxMemoryPercent, + config.MaxCPUPercent, + config.CPUUsageMode, + ), + period: time.Second, // 1 second period + in: make(chan any), + out: make(chan any, config.BufferSize), + quotaSignal: make(chan struct{}, 1), + done: make(chan struct{}), + } + + at.currentRate.Store(initialRate) + at.maxElements.Store(initialRate) + at.lastAdaptation = time.Now() + + // Start rate adaptation goroutine + go at.adaptRateLoop() + + // Start quota reset goroutine + go at.resetQuotaCounterLoop() + + // Start buffering goroutine + go at.buffer() + + return at +} + +// adaptRateLoop periodically adapts the throughput rate based on resource availability +func (at *AdaptiveThrottler) adaptRateLoop() { + ticker := time.NewTicker(at.config.SampleInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + at.adaptRate() + case <-at.done: + return + } + } +} + +// adaptRate adjusts the throughput rate based on current resource usage +func (at *AdaptiveThrottler) adaptRate() { + at.adaptMu.Lock() + defer at.adaptMu.Unlock() + + stats := at.monitor.GetStats() + constrained := stats.MemoryUsedPercent > at.config.MaxMemoryPercent || + stats.CPUUsagePercent > at.config.MaxCPUPercent + + currentRate := float64(at.currentRate.Load()) + targetRate := currentRate + + if constrained { + // Reduce rate when resources are constrained + // Calculate how far over the limits we are (as a percentage) + memoryOverage := math.Max(0, stats.MemoryUsedPercent-at.config.MaxMemoryPercent) + cpuOverage := math.Max(0, stats.CPUUsagePercent-at.config.MaxCPUPercent) + maxOverage := math.Max(memoryOverage, cpuOverage) + + // Scale reduction factor based on overage severity (0-100%) + severityFactor := maxOverage / 50.0 // 50% overage = full severity + severityFactor = math.Min(severityFactor, 1.0) + + // Calculate reduction: base factor + severity bonus + reductionFactor := at.config.AdaptationFactor * (1.0 + severityFactor) + maxReduction := currentRate * at.config.MaxRateChangeFactor + reduction := math.Min(currentRate*reductionFactor, maxReduction) + + targetRate = currentRate - reduction + } else { + // Increase rate when resources are available, with hysteresis + memoryHeadroom := at.config.MaxMemoryPercent - stats.MemoryUsedPercent + cpuHeadroom := at.config.MaxCPUPercent - stats.CPUUsagePercent + minHeadroom := math.Min(memoryHeadroom, cpuHeadroom) + + // Apply hysteresis buffer - only increase if we have significant headroom + effectiveHeadroom := minHeadroom - at.config.HysteresisBuffer + if effectiveHeadroom > 0 { + // Use square root scaling for stable, diminishing returns + headroomRatio := math.Min(effectiveHeadroom/30.0, 1.0) // Cap at 30% headroom for scaling + increaseFactor := at.config.AdaptationFactor * math.Sqrt(headroomRatio) + maxIncrease := currentRate * at.config.MaxRateChangeFactor + + increase := math.Min(currentRate*increaseFactor, maxIncrease) + targetRate = currentRate + increase + } + } + + if at.config.SmoothTransitions { + // Smooth transitions: gradually approach target rate to avoid abrupt changes + // Use a fixed smoothing factor of 0.3 (30% of remaining distance per cycle) + const smoothingFactor = 0.3 + diff := targetRate - currentRate + targetRate = currentRate + diff*smoothingFactor + } + + // Enforce bounds + targetRate = math.Max(float64(at.config.MinThroughput), targetRate) + targetRate = math.Min(float64(at.config.MaxThroughput), targetRate) + + // Convert to integer and update atomically + newRateInt := int64(math.Round(targetRate)) + + // Only update if rate actually changed + if newRateInt != at.currentRate.Load() { + at.currentRate.Store(newRateInt) + at.maxElements.Store(newRateInt) + at.counter.Store(0) // Reset quota counter to apply new rate immediately + // Wake any blocked emitters so the new quota takes effect without waiting for the next period tick. + at.notifyQuotaReset() + at.lastAdaptation = time.Now() + } +} + +// resetQuotaCounterLoop resets the quota counter every period +func (at *AdaptiveThrottler) resetQuotaCounterLoop() { + ticker := time.NewTicker(at.period) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + at.counter.Store(0) + at.notifyQuotaReset() + case <-at.done: + return + } + } +} + +// notifyQuotaReset notifies downstream processor of quota reset +func (at *AdaptiveThrottler) notifyQuotaReset() { + select { + case at.quotaSignal <- struct{}{}: + default: + } +} + +// quotaExceeded checks if quota has been exceeded +func (at *AdaptiveThrottler) quotaExceeded() bool { + return at.counter.Load() >= at.maxElements.Load() +} + +// buffer buffers incoming elements and sends them to output channel +func (at *AdaptiveThrottler) buffer() { + defer close(at.out) + + for { + select { + case element, ok := <-at.in: + if !ok { + return + } + at.emit(element) + case <-at.done: + return + } + } +} + +func (at *AdaptiveThrottler) emit(element any) { + for { + if !at.quotaExceeded() { + at.counter.Add(1) + at.out <- element + return + } + + select { + case <-at.quotaSignal: + case <-at.done: + // Shutting down: bypass quota to flush pending data. + at.out <- element + return + } + } +} + +// Via asynchronously streams data to the given Flow and returns it +func (at *AdaptiveThrottler) Via(flow streams.Flow) streams.Flow { + go at.streamPortioned(flow) + return flow +} + +// To streams data to the given Sink and blocks until completion +func (at *AdaptiveThrottler) To(sink streams.Sink) { + at.streamPortioned(sink) + sink.AwaitCompletion() +} + +// Out returns the output channel +func (at *AdaptiveThrottler) Out() <-chan any { + return at.out +} + +// In returns the input channel +func (at *AdaptiveThrottler) In() chan<- any { + return at.in +} + +// streamPortioned streams elements enforcing the adaptive quota +func (at *AdaptiveThrottler) streamPortioned(inlet streams.Inlet) { + defer close(inlet.In()) + + for element := range at.out { + inlet.In() <- element + } + at.stop() +} + +// GetCurrentRate returns the current throughput rate (elements per second) +func (at *AdaptiveThrottler) GetCurrentRate() int64 { + return at.currentRate.Load() +} + +// GetResourceStats returns current resource statistics +func (at *AdaptiveThrottler) GetResourceStats() ResourceStats { + return at.monitor.GetStats() +} + +// Close stops the adaptive throttler and cleans up resources +func (at *AdaptiveThrottler) Close() { + // Drain any pending quota signals to prevent goroutine leaks + select { + case <-at.quotaSignal: + default: + } + + at.stop() +} + +func (at *AdaptiveThrottler) stop() { + at.stopOnce.Do(func() { + close(at.done) + at.monitor.Close() + }) +} diff --git a/flow/adaptive_throttler_test.go b/flow/adaptive_throttler_test.go new file mode 100644 index 0000000..67eba2f --- /dev/null +++ b/flow/adaptive_throttler_test.go @@ -0,0 +1,940 @@ +package flow + +import ( + "fmt" + "strings" + "sync" + "testing" + "time" +) + +// mockResourceMonitor allows injecting specific resource stats for testing +type mockResourceMonitor struct { + mu sync.RWMutex + stats ResourceStats +} + +func (m *mockResourceMonitor) GetStats() ResourceStats { + m.mu.RLock() + defer m.mu.RUnlock() + return m.stats +} + +func (m *mockResourceMonitor) IsResourceConstrained() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.stats.MemoryUsedPercent > 80.0 || m.stats.CPUUsagePercent > 70.0 +} + +func (m *mockResourceMonitor) SetStats(stats ResourceStats) { + m.mu.Lock() + m.stats = stats + m.mu.Unlock() +} + +func (m *mockResourceMonitor) Close() {} + +// newAdaptiveThrottlerWithMonitor creates an AdaptiveThrottler with a mock monitor for testing +func newAdaptiveThrottlerWithMonitor(config AdaptiveThrottlerConfig, monitor resourceMonitorInterface, customPeriod ...time.Duration) *AdaptiveThrottler { + period := time.Second + if len(customPeriod) > 0 && customPeriod[0] > 0 { + period = customPeriod[0] + } + + at := &AdaptiveThrottler{ + config: config, + monitor: monitor, + period: period, + in: make(chan any), + out: make(chan any, config.BufferSize), + quotaSignal: make(chan struct{}, 1), + done: make(chan struct{}), + } + + at.currentRate.Store(int64(config.MaxThroughput)) + at.maxElements.Store(int64(config.MaxThroughput)) + at.lastAdaptation = time.Now() + + // Start goroutines + go at.adaptRateLoop() + go at.resetQuotaCounterLoop() + go at.buffer() + + return at +} + +func TestNewAdaptiveThrottler(t *testing.T) { + config := DefaultAdaptiveThrottlerConfig() + at := NewAdaptiveThrottler(config) + defer func() { + at.Close() + time.Sleep(10 * time.Millisecond) + }() + + if at == nil { + t.Fatal("AdaptiveThrottler should not be nil") + } + if at.GetCurrentRate() != int64(config.MaxThroughput) { + t.Errorf("expected initial rate %d, got %d", config.MaxThroughput, at.GetCurrentRate()) + } +} + +func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { + tests := []struct { + name string + config AdaptiveThrottlerConfig + shouldPanic bool + expectedPanic string // Expected substring in panic message + }{ + { + name: "valid config", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 100 * time.Millisecond, + BufferSize: 100, + AdaptationFactor: 0.2, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + }, + shouldPanic: false, + expectedPanic: "", + }, + { + name: "invalid MaxMemoryPercent", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 150.0, // Invalid + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 100 * time.Millisecond, + BufferSize: 100, + AdaptationFactor: 0.2, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + }, + shouldPanic: true, + expectedPanic: "invalid MaxMemoryPercent", + }, + { + name: "invalid MaxThroughput < MinThroughput", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 100, + MaxThroughput: 50, // Invalid + SampleInterval: 100 * time.Millisecond, + BufferSize: 100, + AdaptationFactor: 0.2, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + }, + shouldPanic: true, + expectedPanic: "MaxThroughput must be >= MinThroughput", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + if !tt.shouldPanic { + t.Errorf("unexpected panic: %v", r) + return + } + // Verify panic message contains expected text + panicMsg := fmt.Sprintf("%v", r) + if tt.expectedPanic != "" && !strings.Contains(panicMsg, tt.expectedPanic) { + t.Errorf("panic message should contain %q, got: %v", tt.expectedPanic, r) + } + } else { + if tt.shouldPanic { + t.Error("expected panic but didn't get one") + } + } + }() + NewAdaptiveThrottler(tt.config) + }) + } +} + +func TestAdaptiveThrottler_BasicThroughput(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 50 * time.Millisecond, + BufferSize: 10, + AdaptationFactor: 0.2, + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + // Mock monitor with low resource usage (should allow high throughput) + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 30.0, + CPUUsagePercent: 20.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer func() { + if r := recover(); r == nil { + at.Close() + } + time.Sleep(10 * time.Millisecond) + }() + + // Test that we can send elements to the input channel + done := make(chan bool, 1) + go func() { + defer func() { + if r := recover(); r == nil { + done <- true + } + }() + for i := 0; i < 5; i++ { + select { + case at.In() <- i: + case <-time.After(100 * time.Millisecond): + return + } + } + }() + + // Wait for goroutine to finish or timeout + select { + case <-done: + // Successfully sent elements + case <-time.After(500 * time.Millisecond): + t.Fatal("timeout sending elements to throttler") + } + + // Verify rate is at maximum initially + if at.GetCurrentRate() != int64(config.MaxThroughput) { + t.Errorf("expected initial rate %d, got %d", config.MaxThroughput, at.GetCurrentRate()) + } +} + +func TestAdaptiveThrottler_OutChannelRespectsQuota(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 1, + MaxThroughput: 1, + SampleInterval: 50 * time.Millisecond, + BufferSize: 4, + AdaptationFactor: 0.2, + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 10, + CPUUsagePercent: 10, + }, + } + + period := 80 * time.Millisecond + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor, period) + defer func() { + at.Close() + time.Sleep(20 * time.Millisecond) + }() + + writeDone := make(chan struct{}) + go func() { + defer close(writeDone) + at.In() <- "first" + at.In() <- "second" + }() + + first := <-at.Out() + if first != "first" { + t.Fatalf("expected first element, got %v", first) + } + firstReceived := time.Now() + + second := <-at.Out() + if second != "second" { + t.Fatalf("expected second element, got %v", second) + } + gap := time.Since(firstReceived) + + if gap < period/2 { + t.Fatalf("expected quota enforcement delay, got %v", gap) + } + + <-writeDone +} + +func TestAdaptiveThrottler_RateLimits(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 5, + MaxThroughput: 20, + SampleInterval: 50 * time.Millisecond, + BufferSize: 50, + AdaptationFactor: 0.5, // Aggressive adaptation + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + // Test minimum bound + t.Run("minimum throughput", func(t *testing.T) { + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 90.0, // High usage - should constrain + CPUUsagePercent: 80.0, + GoroutineCount: 10, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer func() { + at.Close() + time.Sleep(50 * time.Millisecond) + }() + + // Wait for adaptation + time.Sleep(200 * time.Millisecond) + + rate := at.GetCurrentRate() + if rate < int64(config.MinThroughput) { + t.Errorf("rate %d should not go below minimum %d", rate, config.MinThroughput) + } + }) + + // Test maximum bound + t.Run("maximum throughput", func(t *testing.T) { + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 10.0, // Low usage - should allow max + CPUUsagePercent: 5.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer func() { + at.Close() + time.Sleep(50 * time.Millisecond) + }() + + // Wait for adaptation + time.Sleep(200 * time.Millisecond) + + rate := at.GetCurrentRate() + if rate > int64(config.MaxThroughput) { + t.Errorf("rate %d should not exceed maximum %d", rate, config.MaxThroughput) + } + }) +} + +func TestAdaptiveThrottler_ResourceConstraintResponse(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 50 * time.Millisecond, + BufferSize: 20, + AdaptationFactor: 0.2, + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + // Start with high throughput + initialRate := 100 + config.MaxThroughput = initialRate + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 50.0, // OK initially + CPUUsagePercent: 40.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer func() { + at.Close() + time.Sleep(50 * time.Millisecond) + }() + + // Initial rate should be max + if at.GetCurrentRate() != int64(initialRate) { + t.Errorf("expected initial rate %d, got %d", initialRate, at.GetCurrentRate()) + } + + // Simulate resource constraint + mockMonitor.SetStats(ResourceStats{ + MemoryUsedPercent: 85.0, // Constrained + CPUUsagePercent: 40.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }) + + // Wait for adaptation + time.Sleep(200 * time.Millisecond) + + newRate := at.GetCurrentRate() + if newRate >= int64(initialRate) { + t.Errorf("rate should decrease when constrained, got %d (initial %d)", newRate, initialRate) + } + if newRate < int64(config.MinThroughput) { + t.Errorf("rate %d should not go below minimum %d", newRate, config.MinThroughput) + } +} + +func TestAdaptiveThrottler_RateIncreaseNotifiesQuota(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 50 * time.Millisecond, + BufferSize: 10, + AdaptationFactor: 0.5, + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 1.0, + } + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 10.0, + CPUUsagePercent: 10.0, + Timestamp: time.Now(), + }, + } + + at := &AdaptiveThrottler{ + config: config, + monitor: mockMonitor, + period: time.Second, + in: make(chan any), + out: make(chan any, config.BufferSize), + quotaSignal: make(chan struct{}, 1), + done: make(chan struct{}), + } + defer func() { + at.Close() + time.Sleep(10 * time.Millisecond) + }() + + initialRate := int64(40) + at.currentRate.Store(initialRate) + at.maxElements.Store(initialRate) + at.lastAdaptation = time.Now() + + // Simulate exhausted quota so emitters would be blocked + at.counter.Store(initialRate) + + at.adaptRate() + + select { + case <-at.quotaSignal: + // Expected: quota reset signal was sent immediately after rate increase + default: + t.Fatal("expected quota reset notification when rate increases") + } +} + +func TestAdaptiveThrottler_SmoothTransitions(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 50 * time.Millisecond, + BufferSize: 20, + AdaptationFactor: 0.5, + SmoothTransitions: true, // Enabled + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 85.0, // Constrained + CPUUsagePercent: 40.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer func() { + at.Close() + time.Sleep(50 * time.Millisecond) + }() + + initialRate := at.GetCurrentRate() + + // Wait for multiple adaptations + time.Sleep(300 * time.Millisecond) + + finalRate := at.GetCurrentRate() + + // With smoothing, the rate should change gradually + // We expect some reduction but not immediate full reduction + if finalRate >= initialRate { + t.Errorf("rate should decrease when constrained, got %d (initial: %d)", finalRate, initialRate) + } + + // Verify rate is within reasonable bounds + if finalRate < int64(config.MinThroughput) { + t.Errorf("rate %d should not go below minimum %d", finalRate, config.MinThroughput) + } + + // With smoothing enabled, the rate reduction should be gradual (not immediate full reduction) + // Calculate expected aggressive reduction (without smoothing) for comparison + aggressiveReduction := int64(float64(initialRate) * config.AdaptationFactor * 2) + expectedMinRateWithoutSmoothing := initialRate - aggressiveReduction + actualReduction := initialRate - finalRate + + // With smoothing, actual reduction should be less than aggressive reduction would be + // This verifies that smoothing is actually working (gradual vs immediate) + if expectedMinRateWithoutSmoothing > int64(config.MinThroughput) { + if actualReduction >= aggressiveReduction { + t.Errorf("with smoothing enabled, rate reduction should be gradual. Got reduction of %d (from %d to %d), expected less than aggressive reduction of %d", + actualReduction, initialRate, finalRate, aggressiveReduction) + } + } +} + +func TestAdaptiveThrottler_SmoothTransitionsRespectBounds(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 5, + MaxThroughput: 6, + SampleInterval: 20 * time.Millisecond, + BufferSize: 10, + AdaptationFactor: 0.9, + SmoothTransitions: true, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 90.0, + CPUUsagePercent: 10.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer func() { + at.Close() + time.Sleep(20 * time.Millisecond) + }() + + time.Sleep(3 * config.SampleInterval) + + if rate := at.GetCurrentRate(); rate < int64(config.MinThroughput) { + t.Fatalf("expected rate to stay >= %d, got %d", config.MinThroughput, rate) + } +} + +func TestAdaptiveThrottler_CloseClosesOutputEvenIfInputOpen(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 1, + MaxThroughput: 5, + SampleInterval: 20 * time.Millisecond, + BufferSize: 5, + AdaptationFactor: 0.2, + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 10.0, + CPUUsagePercent: 10.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer func() { + close(at.in) + at.Close() + time.Sleep(20 * time.Millisecond) + }() + + done := make(chan struct{}) + go func() { + _, ok := <-at.Out() + if ok { + t.Error("expected output channel to be closed after Close without draining input") + } + close(done) + }() + + // Give goroutine time to start before closing + time.Sleep(10 * time.Millisecond) + at.Close() + + select { + case <-done: + case <-time.After(200 * time.Millisecond): + t.Fatal("output channel not closed after Close() when input left open") + } +} + +func TestAdaptiveThrottler_CloseStopsBackgroundLoops(t *testing.T) { + config := DefaultAdaptiveThrottlerConfig() + at := NewAdaptiveThrottler(config) + + at.Close() + + select { + case <-at.done: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected done channel to be closed after Close()") + } + + // Close again to ensure idempotence + at.Close() +} + +func TestAdaptiveThrottler_CPUUsageMode(t *testing.T) { + config := DefaultAdaptiveThrottlerConfig() + config.CPUUsageMode = CPUUsageModeRusage + + at := NewAdaptiveThrottler(config) + defer func() { + at.Close() + time.Sleep(10 * time.Millisecond) + }() + + // Should be able to create with rusage mode + if at == nil { + t.Fatal("should create throttler with rusage mode") + } + + // The actual CPU mode used depends on platform support + // Just verify the throttler was created successfully + stats := at.GetResourceStats() + if stats.CPUUsagePercent < 0 || stats.CPUUsagePercent > 100 { + t.Errorf("CPU percent should be valid, got %v", stats.CPUUsagePercent) + } +} + +func TestAdaptiveThrottler_GetCurrentRate(t *testing.T) { + config := DefaultAdaptiveThrottlerConfig() + at := NewAdaptiveThrottler(config) + defer func() { + at.Close() + time.Sleep(10 * time.Millisecond) + }() + + rate := at.GetCurrentRate() + if rate <= 0 { + t.Errorf("current rate should be positive, got %d", rate) + } + if rate > int64(config.MaxThroughput) { + t.Errorf("current rate %d should not exceed max %d", rate, config.MaxThroughput) + } +} + +func TestAdaptiveThrottler_GetResourceStats(t *testing.T) { + config := DefaultAdaptiveThrottlerConfig() + at := NewAdaptiveThrottler(config) + defer func() { + at.Close() + time.Sleep(10 * time.Millisecond) + }() + + stats := at.GetResourceStats() + + if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { + t.Errorf("memory percent should be between 0 and 100, got %v", stats.MemoryUsedPercent) + } + if stats.CPUUsagePercent < 0 || stats.CPUUsagePercent > 100 { + t.Errorf("CPU percent should be between 0 and 100, got %v", stats.CPUUsagePercent) + } + if stats.GoroutineCount <= 0 { + t.Errorf("goroutine count should be > 0, got %d", stats.GoroutineCount) + } + if stats.Timestamp.IsZero() { + t.Error("timestamp should not be zero") + } +} + +func TestAdaptiveThrottler_BufferBackpressure(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 100 * time.Millisecond, + BufferSize: 5, // Small buffer + AdaptationFactor: 0.2, + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 30.0, + CPUUsagePercent: 20.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer func() { + close(at.in) + at.Close() + time.Sleep(10 * time.Millisecond) // Allow cleanup + }() + + // Fill buffer + for i := 0; i < config.BufferSize; i++ { + select { + case at.In() <- i: + case <-time.After(100 * time.Millisecond): + t.Fatal("should be able to send to buffer initially") + } + } + + // Next send should block or succeed (backpressure test) + done := make(chan bool, 1) + go func() { + at.In() <- "test" + done <- true + }() + + select { + case <-done: + // Element was accepted - buffer management worked + case <-time.After(200 * time.Millisecond): + // Timeout - this is also acceptable as backpressure is working + t.Log("Backpressure working - send blocked as expected") + } +} + +func BenchmarkAdaptiveThrottler_GetResourceStats(b *testing.B) { + config := DefaultAdaptiveThrottlerConfig() + at := NewAdaptiveThrottler(config) + defer func() { + at.Close() + time.Sleep(10 * time.Millisecond) + }() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = at.GetResourceStats() + } +} + +// TestAdaptiveThrottler_BufferedQuotaSignal verifies that the quota signal channel +// is buffered and handles reset signals properly under sustained pressure +func TestAdaptiveThrottler_EdgeCases(t *testing.T) { + t.Run("exact threshold values", func(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 50 * time.Millisecond, + BufferSize: 20, + AdaptationFactor: 0.2, + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + // Test exactly at threshold - should not be constrained + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 80.0, // Exactly at threshold + CPUUsagePercent: 70.0, // Exactly at threshold + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer func() { + at.Close() + time.Sleep(50 * time.Millisecond) + }() + + // Should not be constrained at exact threshold + if mockMonitor.IsResourceConstrained() { + t.Error("should not be constrained at exact threshold values") + } + + time.Sleep(200 * time.Millisecond) + rate := at.GetCurrentRate() + if rate < int64(config.MaxThroughput) { + t.Errorf("rate should not decrease when at exact threshold, got %d", rate) + } + }) + + t.Run("minimal throughput range", func(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 50, + MaxThroughput: 50, // Same as min + SampleInterval: 50 * time.Millisecond, + BufferSize: 20, + AdaptationFactor: 0.2, + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 90.0, // Constrained + CPUUsagePercent: 20.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer func() { + at.Close() + time.Sleep(50 * time.Millisecond) + }() + + // Rate should stay at 50 even under constraint + time.Sleep(200 * time.Millisecond) + rate := at.GetCurrentRate() + if rate != 50 { + t.Errorf("rate should stay at 50 with min=max=50, got %d", rate) + } + }) + + t.Run("zero adaptation factor", func(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 50 * time.Millisecond, + BufferSize: 20, + AdaptationFactor: 0.0, // No adaptation + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 90.0, // Constrained + CPUUsagePercent: 20.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer func() { + at.Close() + time.Sleep(50 * time.Millisecond) + }() + + initialRate := at.GetCurrentRate() + time.Sleep(200 * time.Millisecond) + finalRate := at.GetCurrentRate() + + // Rate should not change with zero adaptation factor + if initialRate != finalRate { + t.Errorf("rate should not change with adaptation factor 0, initial: %d, final: %d", initialRate, finalRate) + } + }) +} + +func TestAdaptiveThrottler_BufferedQuotaSignal(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 1, + MaxThroughput: 10, + SampleInterval: 50 * time.Millisecond, + BufferSize: 50, + AdaptationFactor: 0.2, + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 30.0, + CPUUsagePercent: 20.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer func() { + at.Close() + time.Sleep(50 * time.Millisecond) + }() + + // Fill the output buffer and quota + for i := 0; i < int(config.MaxThroughput); i++ { + select { + case at.In() <- i: + case <-time.After(100 * time.Millisecond): + t.Fatal("should be able to send to input initially") + } + } + + // Wait for quota to be consumed + time.Sleep(1100 * time.Millisecond) // Wait > 1 second for quota reset + + // Should be able to send more elements now (quota reset signal was buffered) + select { + case at.In() <- "test": + // Success - buffered signal worked + case <-time.After(100 * time.Millisecond): + t.Error("should be able to send after quota reset, buffered signal may not be working") + } +} diff --git a/flow/cpu_sampler.go b/flow/cpu_sampler.go new file mode 100644 index 0000000..9f0b1dc --- /dev/null +++ b/flow/cpu_sampler.go @@ -0,0 +1,65 @@ +package flow + +import ( + "os" + "time" + + "github.com/shirou/gopsutil/v4/process" +) + +type gopsutilProcessSampler struct { + proc *process.Process + lastPercent float64 + lastSample time.Time +} + +func newGopsutilProcessSampler() (*gopsutilProcessSampler, error) { + pid := os.Getpid() + proc, err := process.NewProcess(int32(pid)) + if err != nil { + return nil, err + } + + return &gopsutilProcessSampler{ + proc: proc, + }, nil +} + +func (s *gopsutilProcessSampler) Sample(deltaTime time.Duration) float64 { + percent, err := s.proc.CPUPercent() + if err != nil { + return (&goroutineHeuristicSampler{}).Sample(deltaTime) + } + + now := time.Now() + if s.lastSample.IsZero() { + s.lastSample = now + s.lastPercent = percent + return 0.0 + } + + elapsed := now.Sub(s.lastSample) + if elapsed < deltaTime/2 { + return s.lastPercent + } + + s.lastPercent = percent + s.lastSample = now + + if percent > 100.0 { + percent = 100.0 + } else if percent < 0.0 { + percent = 0.0 + } + + return percent +} + +func (s *gopsutilProcessSampler) Reset() { + s.lastPercent = 0 + s.lastSample = time.Time{} +} + +func (s *gopsutilProcessSampler) IsInitialized() bool { + return !s.lastSample.IsZero() +} diff --git a/flow/resource_monitor.go b/flow/resource_monitor.go new file mode 100644 index 0000000..22b1a24 --- /dev/null +++ b/flow/resource_monitor.go @@ -0,0 +1,334 @@ +package flow + +import ( + "fmt" + "math" + "runtime" + "sync" + "sync/atomic" + "time" +) + +const ( + // CPUHeuristicBaselineCPU provides minimum CPU usage estimate for any number of goroutines + CPUHeuristicBaselineCPU = 10.0 + // CPUHeuristicLinearScaleFactor determines CPU increase per goroutine for low counts (1-10) + CPUHeuristicLinearScaleFactor = 1.0 + // CPUHeuristicLogScaleFactor determines logarithmic CPU scaling for higher goroutine counts + CPUHeuristicLogScaleFactor = 8.0 + // CPUHeuristicMaxGoroutinesForLinear switches from linear to logarithmic scaling + CPUHeuristicMaxGoroutinesForLinear = 10 + // CPUHeuristicMaxCPU caps the CPU estimate to leave room for system processes + CPUHeuristicMaxCPU = 95.0 +) + +// CPUUsageMode defines the strategy for sampling CPU usage +type CPUUsageMode int + +const ( + // CPUUsageModeHeuristic uses goroutine count as a simple CPU usage proxy + CPUUsageModeHeuristic CPUUsageMode = iota + // CPUUsageModeRusage samples actual CPU time using syscall.Getrusage + CPUUsageModeRusage +) + +// ResourceStats represents current system resource statistics +type ResourceStats struct { + MemoryUsedPercent float64 + CPUUsagePercent float64 + GoroutineCount int + Timestamp time.Time +} + +// cpuUsageSampler defines the interface for CPU sampling strategies +type cpuUsageSampler interface { + // Sample returns CPU usage percentage for the given time delta + Sample(deltaTime time.Duration) float64 + // Reset prepares the sampler for a new sampling session + Reset() + // IsInitialized returns true if the sampler has been initialized with at least one sample + IsInitialized() bool +} + +// goroutineHeuristicSampler uses goroutine count as a CPU usage proxy +type goroutineHeuristicSampler struct{} + +func (s *goroutineHeuristicSampler) Sample(deltaTime time.Duration) float64 { + // Improved heuristic: uses logarithmic scaling for more realistic CPU estimation + // Base level: 1-10 goroutines = baseline CPU usage (10-20%) + // Scaling: logarithmic growth to avoid overestimation at high goroutine counts + goroutineCount := float64(runtime.NumGoroutine()) + + // Baseline CPU usage for minimal goroutines + if goroutineCount <= CPUHeuristicMaxGoroutinesForLinear { + return CPUHeuristicBaselineCPU + goroutineCount*CPUHeuristicLinearScaleFactor + } + + // Logarithmic scaling: ln(goroutines) * scaling factor + // At ~100 goroutines: ~50% CPU + // At ~1000 goroutines: ~70% CPU + // At ~10000 goroutines: ~85% CPU + // Caps at 95% to leave room for system processes + logScaling := math.Log(goroutineCount) * CPUHeuristicLogScaleFactor + estimatedCPU := CPUHeuristicBaselineCPU + logScaling + + // Cap at maximum to be conservative + if estimatedCPU > CPUHeuristicMaxCPU { + return CPUHeuristicMaxCPU + } + return estimatedCPU +} + +func (s *goroutineHeuristicSampler) Reset() { + // No state to reset for heuristic sampler +} + +func (s *goroutineHeuristicSampler) IsInitialized() bool { + // Heuristic sampler is always "initialized" as it doesn't need state + return true +} + +// ResourceMonitor monitors system resources and provides current statistics +type ResourceMonitor struct { + sampleInterval time.Duration + memoryThreshold float64 + cpuThreshold float64 + cpuMode CPUUsageMode + + // Current stats (atomic for thread-safe reads) + stats atomic.Value // *ResourceStats + + // CPU sampling + sampler cpuUsageSampler + + memStats runtime.MemStats // Reusable buffer for memory stats + + // Lifecycle + mu sync.RWMutex + done chan struct{} +} + +// NewResourceMonitor creates a new resource monitor +func NewResourceMonitor( + sampleInterval time.Duration, + memoryThreshold, cpuThreshold float64, + cpuMode CPUUsageMode, +) *ResourceMonitor { + if sampleInterval <= 0 { + panic(fmt.Sprintf("invalid sampleInterval: %v", sampleInterval)) + } + if memoryThreshold < 0 || memoryThreshold > 100 { + panic(fmt.Sprintf("invalid memoryThreshold: %f, must be between 0 and 100", memoryThreshold)) + } + if cpuThreshold < 0 || cpuThreshold > 100 { + panic(fmt.Sprintf("invalid cpuThreshold: %f, must be between 0 and 100", cpuThreshold)) + } + + rm := &ResourceMonitor{ + sampleInterval: sampleInterval, + memoryThreshold: memoryThreshold, + cpuThreshold: cpuThreshold, + cpuMode: cpuMode, + done: make(chan struct{}), + } + + // Initialize CPU sampler + rm.initSampler() + + // Initialize with current stats + rm.stats.Store(rm.collectStats()) + + // Start monitoring goroutine + go rm.monitor() + + return rm +} + +// initSampler initializes the appropriate CPU sampler based on mode and platform support +func (rm *ResourceMonitor) initSampler() { + switch rm.cpuMode { + case CPUUsageModeRusage: + // Try gopsutil first, fallback to heuristic + if sampler, err := newGopsutilProcessSampler(); err == nil { + rm.sampler = sampler + } else { + rm.sampler = &goroutineHeuristicSampler{} + rm.cpuMode = CPUUsageModeHeuristic + } + default: // CPUUsageModeHeuristic + rm.sampler = &goroutineHeuristicSampler{} + } +} + +// GetStats returns the current resource statistics +func (rm *ResourceMonitor) GetStats() ResourceStats { + stats := rm.stats.Load() + if stats == nil { + return ResourceStats{} + } + return *stats.(*ResourceStats) +} + +// IsResourceConstrained returns true if resources are above thresholds +func (rm *ResourceMonitor) IsResourceConstrained() bool { + stats := rm.GetStats() + return stats.MemoryUsedPercent > rm.memoryThreshold || + stats.CPUUsagePercent > rm.cpuThreshold +} + +// collectStats collects current system resource statistics +func (rm *ResourceMonitor) collectStats() *ResourceStats { + sysStats, hasSystemStats := rm.tryGetSystemMemory() + + var procStats *runtime.MemStats + if !hasSystemStats { + // Reuse existing memStats buffer instead of allocating new one + runtime.ReadMemStats(&rm.memStats) + procStats = &rm.memStats + } + + // Calculate memory usage percentage + memoryPercent := rm.memoryUsagePercent(hasSystemStats, sysStats, procStats) + + // Get goroutine count + goroutineCount := runtime.NumGoroutine() + + // Sample CPU usage and validate + cpuPercent := rm.sampler.Sample(rm.sampleInterval) + if math.IsNaN(cpuPercent) || math.IsInf(cpuPercent, 0) || cpuPercent < 0 { + cpuPercent = 0 + } else if cpuPercent > 100 { + cpuPercent = 100 + } + + stats := &ResourceStats{ + MemoryUsedPercent: memoryPercent, + CPUUsagePercent: cpuPercent, + GoroutineCount: goroutineCount, + Timestamp: time.Now(), + } + + // Validate the complete stats object + validateResourceStats(stats) + + return stats +} + +func (rm *ResourceMonitor) tryGetSystemMemory() (systemMemory, bool) { + stats, err := getSystemMemory() + if err != nil || stats.Total == 0 { + return systemMemory{}, false + } + return stats, true +} + +func (rm *ResourceMonitor) memoryUsagePercent(hasSystemStats bool, sysStats systemMemory, procStats *runtime.MemStats) float64 { + if hasSystemStats { + available := sysStats.Available + if available > sysStats.Total { + available = sysStats.Total + } + + // Defensive programming: avoid division by zero + if sysStats.Total == 0 { + return 0 + } + + used := sysStats.Total - available + percent := float64(used) / float64(sysStats.Total) * 100 + + if percent < 0 { + return 0 + } + if percent > 100 { + return 100 + } + return percent + } + + if procStats == nil || procStats.Sys == 0 { + return 0 + } + + percent := float64(procStats.Alloc) / float64(procStats.Sys) * 100 + if percent < 0 { + return 0 + } + if percent > 100 { + return 100 + } + return percent +} + +type systemMemory struct { + Total uint64 + Available uint64 +} + +// validateResourceStats sanitizes ResourceStats to ensure valid values +func validateResourceStats(stats *ResourceStats) { + if stats == nil { + panic("ResourceStats cannot be nil") + } + + // Validate memory percent + if math.IsNaN(stats.MemoryUsedPercent) || math.IsInf(stats.MemoryUsedPercent, 0) { + stats.MemoryUsedPercent = 0 + } else if stats.MemoryUsedPercent < 0 { + stats.MemoryUsedPercent = 0 + } else if stats.MemoryUsedPercent > 100 { + stats.MemoryUsedPercent = 100 + } + + // Validate CPU percent + if math.IsNaN(stats.CPUUsagePercent) || math.IsInf(stats.CPUUsagePercent, 0) { + stats.CPUUsagePercent = 0 + } else if stats.CPUUsagePercent < 0 { + stats.CPUUsagePercent = 0 + } else if stats.CPUUsagePercent > 100 { + stats.CPUUsagePercent = 100 + } + + // Validate goroutine count + if stats.GoroutineCount < 0 { + stats.GoroutineCount = 0 + } + + // Validate timestamp (should be recent) + if stats.Timestamp.IsZero() { + stats.Timestamp = time.Now() + } else if time.Since(stats.Timestamp) > time.Minute { + // Stats are too old, refresh timestamp + stats.Timestamp = time.Now() + } +} + +// monitor periodically collects resource statistics +func (rm *ResourceMonitor) monitor() { + ticker := time.NewTicker(rm.sampleInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + newStats := rm.collectStats() + rm.stats.Store(newStats) + case <-rm.done: + return + } + } +} + +// Close stops the resource monitor +func (rm *ResourceMonitor) Close() { + rm.mu.Lock() + defer rm.mu.Unlock() + + select { + case <-rm.done: + // Already closed + return + default: + close(rm.done) + } +} diff --git a/flow/resource_monitor_test.go b/flow/resource_monitor_test.go new file mode 100644 index 0000000..a3216da --- /dev/null +++ b/flow/resource_monitor_test.go @@ -0,0 +1,417 @@ +package flow + +import ( + "math" + "runtime" + "sync" + "testing" + "time" +) + +func TestNewResourceMonitor(t *testing.T) { + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + defer rm.Close() + + if rm == nil { + t.Fatal("ResourceMonitor should not be nil") + } + if rm.sampleInterval != 100*time.Millisecond { + t.Errorf("expected sampleInterval %v, got %v", 100*time.Millisecond, rm.sampleInterval) + } + if rm.memoryThreshold != 80.0 { + t.Errorf("expected memoryThreshold 80.0, got %v", rm.memoryThreshold) + } + if rm.cpuThreshold != 70.0 { + t.Errorf("expected cpuThreshold 70.0, got %v", rm.cpuThreshold) + } + if rm.cpuMode != CPUUsageModeHeuristic { + t.Errorf("expected cpuMode %v, got %v", CPUUsageModeHeuristic, rm.cpuMode) + } + if rm.sampler == nil { + t.Fatal("sampler should not be nil") + } +} + +func TestNewResourceMonitor_InvalidSampleInterval(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic for invalid sample interval") + } + }() + + NewResourceMonitor(0, 80.0, 70.0, CPUUsageModeHeuristic) +} + +func TestResourceMonitor_GetStats(t *testing.T) { + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + defer rm.Close() + + // Allow some time for initial stats to be collected + time.Sleep(150 * time.Millisecond) + + stats := rm.GetStats() + if stats.Timestamp.IsZero() { + t.Error("timestamp should not be zero") + } + if stats.MemoryUsedPercent < 0.0 || stats.MemoryUsedPercent > 100.0 { + t.Errorf("memory percent should be between 0 and 100, got %v", stats.MemoryUsedPercent) + } + if stats.CPUUsagePercent < 0.0 || stats.CPUUsagePercent > 100.0 { + t.Errorf("CPU percent should be between 0 and 100, got %v", stats.CPUUsagePercent) + } + if stats.GoroutineCount <= 0 { + t.Errorf("goroutine count should be > 0, got %d", stats.GoroutineCount) + } +} + +func TestResourceMonitor_IsResourceConstrained(t *testing.T) { + tests := []struct { + name string + memoryThreshold float64 + cpuThreshold float64 + memoryPercent float64 + cpuPercent float64 + expectedConstrained bool + }{ + { + name: "not constrained", + memoryThreshold: 80.0, + cpuThreshold: 70.0, + memoryPercent: 50.0, + cpuPercent: 40.0, + expectedConstrained: false, + }, + { + name: "memory constrained", + memoryThreshold: 80.0, + cpuThreshold: 70.0, + memoryPercent: 85.0, + cpuPercent: 40.0, + expectedConstrained: true, + }, + { + name: "CPU constrained", + memoryThreshold: 80.0, + cpuThreshold: 70.0, + memoryPercent: 50.0, + cpuPercent: 75.0, + expectedConstrained: true, + }, + { + name: "both constrained", + memoryThreshold: 80.0, + cpuThreshold: 70.0, + memoryPercent: 85.0, + cpuPercent: 75.0, + expectedConstrained: true, + }, + { + name: "at threshold not constrained", + memoryThreshold: 80.0, + cpuThreshold: 70.0, + memoryPercent: 80.0, + cpuPercent: 70.0, + expectedConstrained: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rm := NewResourceMonitor(100*time.Millisecond, tt.memoryThreshold, tt.cpuThreshold, CPUUsageModeHeuristic) + defer rm.Close() + + // Manually set stats for testing + testStats := &ResourceStats{ + MemoryUsedPercent: tt.memoryPercent, + CPUUsagePercent: tt.cpuPercent, + GoroutineCount: 10, + Timestamp: time.Now(), + } + rm.stats.Store(testStats) + + if rm.IsResourceConstrained() != tt.expectedConstrained { + t.Errorf("expected constrained %v, got %v", tt.expectedConstrained, rm.IsResourceConstrained()) + } + }) + } +} + +func TestGoroutineHeuristicSampler(t *testing.T) { + sampler := &goroutineHeuristicSampler{} + + // Test with reasonable goroutine count + _ = runtime.NumGoroutine() // Capture but don't use - just to avoid unused variable warning in lint + defer func() { + // Restore original goroutine count by waiting for test goroutines to finish + time.Sleep(10 * time.Millisecond) + }() + + percent := sampler.Sample(100 * time.Millisecond) + if percent < 0.0 || percent > 100.0 { + t.Errorf("CPU percent should be between 0 and 100, got %v", percent) + } + + // Test reset (should be no-op) + sampler.Reset() +} + +func TestGopsutilProcessSampler(t *testing.T) { + sampler, err := newGopsutilProcessSampler() + if err != nil { + t.Fatalf("newGopsutilProcessSampler failed: %v", err) + } + if sampler == nil { + t.Fatal("gopsutilProcessSampler should not be nil") + } + + // First sample should return 0 and initialize + percent := sampler.Sample(100 * time.Millisecond) + if percent != 0.0 { + t.Errorf("first sample should return 0.0, got %v", percent) + } + + // Subsequent samples should be valid + time.Sleep(10 * time.Millisecond) + percent = sampler.Sample(10 * time.Millisecond) + if percent < 0.0 || percent > 100.0 { + t.Errorf("CPU percent should be between 0 and 100, got %v", percent) + } + + // Test reset + sampler.Reset() + if sampler.IsInitialized() { + t.Error("sampler should not be initialized after reset") + } +} + +func TestResourceMonitor_CPUUsageModeHeuristic(t *testing.T) { + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + defer rm.Close() + + if rm.cpuMode != CPUUsageModeHeuristic { + t.Errorf("expected cpuMode %v, got %v", CPUUsageModeHeuristic, rm.cpuMode) + } + _, ok := rm.sampler.(*goroutineHeuristicSampler) + if !ok { + t.Error("expected goroutineHeuristicSampler") + } +} + +func TestResourceMonitor_CPUUsageModeRusage(t *testing.T) { + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeRusage) + defer rm.Close() + + // Should use gopsutil sampler or fallback to heuristic + if _, ok := rm.sampler.(*gopsutilProcessSampler); ok { + if rm.cpuMode != CPUUsageModeRusage { + t.Errorf("expected cpuMode %v, got %v", CPUUsageModeRusage, rm.cpuMode) + } + } else { + if rm.cpuMode != CPUUsageModeHeuristic { + t.Errorf("expected fallback cpuMode %v, got %v", CPUUsageModeHeuristic, rm.cpuMode) + } + _, ok := rm.sampler.(*goroutineHeuristicSampler) + if !ok { + t.Error("expected fallback to goroutineHeuristicSampler") + } + } +} + +func TestResourceMonitor_Close(t *testing.T) { + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + + // Close should be idempotent + rm.Close() + rm.Close() + + // Should be able to close again without panic + select { + case <-rm.done: + // Expected - channel should be closed + default: + t.Error("done channel should be closed after Close()") + } +} + +func TestResourceMonitor_MonitorLoop(t *testing.T) { + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + defer rm.Close() + + // Wait for a few sampling cycles + time.Sleep(250 * time.Millisecond) + + // Stats should be updated multiple times + stats1 := rm.GetStats() + time.Sleep(100 * time.Millisecond) + stats2 := rm.GetStats() + + // Timestamps should be different (stats updated) + if !stats2.Timestamp.After(stats1.Timestamp) { + t.Error("stats should be updated with newer timestamps") + } +} + +func TestResourceMonitor_ConcurrentAccess(t *testing.T) { + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + defer rm.Close() + + var wg sync.WaitGroup + numGoroutines := 10 + numIterations := 100 + + // Test concurrent reads + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < numIterations; j++ { + _ = rm.GetStats() + _ = rm.IsResourceConstrained() + } + }() + } + + wg.Wait() +} + +func TestResourceMonitor_UsesSystemMemoryStats(t *testing.T) { + t.Helper() + + restore := setSystemMemoryReader(func() (systemMemory, error) { + return systemMemory{ + Total: 100 * 1024 * 1024, + Available: 25 * 1024 * 1024, + }, nil + }) + defer restore() + + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + defer rm.Close() + + stats := rm.collectStats() + if diff := math.Abs(stats.MemoryUsedPercent - 75.0); diff > 0.01 { + t.Fatalf("expected memory percent ~75, diff %v", diff) + } +} + +func TestResourceStats_MemoryCalculation(t *testing.T) { + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + defer rm.Close() + + // Force garbage collection to get more stable memory stats + runtime.GC() + time.Sleep(50 * time.Millisecond) + + stats := rm.GetStats() + + // Memory percentage should be reasonable + if stats.MemoryUsedPercent < 0.0 || stats.MemoryUsedPercent > 100.0 { + t.Errorf("memory percent should be between 0 and 100, got %v", stats.MemoryUsedPercent) + } + + // Goroutine count should be at least 2 (main + monitor goroutine) + if stats.GoroutineCount < 2 { + t.Errorf("goroutine count should be at least 2, got %d", stats.GoroutineCount) + } +} + +func BenchmarkResourceMonitor_GetStats(b *testing.B) { + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + defer rm.Close() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = rm.GetStats() + } +} + +func BenchmarkResourceMonitor_IsResourceConstrained(b *testing.B) { + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + defer rm.Close() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = rm.IsResourceConstrained() + } +} + +// fakeSampler tracks Sample calls to test regression for double sampling bug +type fakeSampler struct { + sampleCount int + lastDelta time.Duration + isInitialized bool +} + +func (f *fakeSampler) Sample(deltaTime time.Duration) float64 { + f.sampleCount++ + f.lastDelta = deltaTime + return 50.0 // dummy value +} + +func (f *fakeSampler) Reset() { + f.sampleCount = 0 + f.lastDelta = 0 + f.isInitialized = false +} + +func (f *fakeSampler) IsInitialized() bool { + return f.isInitialized +} + +// TestResourceMonitor_SingleSamplePerTick verifies the fix for the double sampling bug +// where collectStats was calling Sample twice per tick, overwriting the first measurement +func TestResourceMonitor_SingleSamplePerTick(t *testing.T) { + fakeSampler := &fakeSampler{isInitialized: true} + + // Create a monitor with our fake sampler + rm := &ResourceMonitor{ + sampleInterval: 100 * time.Millisecond, + memoryThreshold: 80.0, + cpuThreshold: 70.0, + cpuMode: CPUUsageModeHeuristic, + sampler: fakeSampler, + done: make(chan struct{}), + } + + // Call collectStats once + rm.collectStats() + + // Verify Sample was called exactly once + if fakeSampler.sampleCount != 1 { + t.Errorf("expected Sample to be called once per collectStats, got %d calls", fakeSampler.sampleCount) + } + + // Verify the delta passed is the configured interval + if fakeSampler.lastDelta != rm.sampleInterval { + t.Errorf("expected delta %v, got %v", rm.sampleInterval, fakeSampler.lastDelta) + } +} + +// TestGopsutilProcessSampler_Initialization verifies that gopsutilProcessSampler +// properly tracks initialization state +func TestGopsutilProcessSampler_Initialization(t *testing.T) { + sampler, err := newGopsutilProcessSampler() + if err != nil { + t.Fatalf("newGopsutilProcessSampler failed: %v", err) + } + if sampler == nil { + t.Fatal("gopsutilProcessSampler should not be nil") + } + + // Initially not initialized + if sampler.IsInitialized() { + t.Error("gopsutilProcessSampler should not be initialized initially") + } + + // After first sample, should be initialized + sampler.Sample(100 * time.Millisecond) + if !sampler.IsInitialized() { + t.Error("gopsutilProcessSampler should be initialized after first sample") + } + + // After reset, should not be initialized + sampler.Reset() + if sampler.IsInitialized() { + t.Error("gopsutilProcessSampler should not be initialized after reset") + } +} diff --git a/flow/system_memory.go b/flow/system_memory.go new file mode 100644 index 0000000..ddb37c0 --- /dev/null +++ b/flow/system_memory.go @@ -0,0 +1,47 @@ +package flow + +import ( + "sync/atomic" + + "github.com/shirou/gopsutil/v4/mem" +) + +type systemMemoryReader func() (systemMemory, error) + +var systemMemoryProvider atomic.Value + +func init() { + systemMemoryProvider.Store(systemMemoryReader(readSystemMemory)) +} + +func readSystemMemory() (systemMemory, error) { + v, err := mem.VirtualMemory() + if err != nil { + return systemMemory{}, err + } + + return systemMemory{ + Total: v.Total, + Available: v.Available, + }, nil +} + +func getSystemMemory() (systemMemory, error) { + return loadSystemMemoryReader()() +} + +func loadSystemMemoryReader() systemMemoryReader { + if reader := systemMemoryProvider.Load(); reader != nil { + return reader.(systemMemoryReader) + } + return readSystemMemory +} + +// setSystemMemoryReader replaces the current system memory reader and returns a restore function. +func setSystemMemoryReader(reader systemMemoryReader) func() { + prev := loadSystemMemoryReader() + systemMemoryProvider.Store(reader) + return func() { + systemMemoryProvider.Store(prev) + } +} diff --git a/go.mod b/go.mod index 6bead2d..2a575af 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,16 @@ module github.com/reugn/go-streams -go 1.21 +go 1.24.0 + +require github.com/shirou/gopsutil/v4 v4.25.10 + +require ( + github.com/ebitengine/purego v0.9.1 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..015b937 --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +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/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= +github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +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-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA= +github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM= +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.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From bf9fa92b1148c959f1b6821973789b4ed7d72cbf Mon Sep 17 00:00:00 2001 From: kxrxh Date: Wed, 19 Nov 2025 22:19:04 +0300 Subject: [PATCH 02/54] docs(README): add AdaptiveThrottler description to documentation --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 53565d3..2c39b43 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ processing operations. These building blocks can be used to transform and manipu - **Flatten1:** Flattens a stream of slices of elements into a stream of elements. - **Batch:** Breaks a stream of elements into batches based on size or timing. - **Throttler:** Limits the rate at which elements are processed. +- **AdaptiveThrottler:** Limits the rate at which elements are processed based on the current system resource utilization (CPU and memory usage) - **SlidingWindow:** Creates overlapping windows of elements. - **TumblingWindow:** Creates non-overlapping, fixed-size windows of elements. - **SessionWindow:** Creates windows based on periods of activity and inactivity. From 55c5fe1942f6bd3090a173ddd313518cbd7e075f Mon Sep 17 00:00:00 2001 From: "Eugene R." Date: Sat, 15 Nov 2025 22:50:28 +0200 Subject: [PATCH 03/54] fix(flow): emit partial sliding window on early close (#192) --- flow/sliding_window.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flow/sliding_window.go b/flow/sliding_window.go index 30402e1..f327365 100644 --- a/flow/sliding_window.go +++ b/flow/sliding_window.go @@ -223,6 +223,9 @@ func (sw *SlidingWindow[T]) emit(delta time.Duration) { case <-timer.C: case <-sw.done: timer.Stop() + if sw.opts.EmitPartialWindow { + sw.dispatchWindow() + } return } From 2bc6a862e949c511812a2af57ebcbef50025cb96 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Wed, 19 Nov 2025 23:23:34 +0300 Subject: [PATCH 04/54] fix: resolve all golangci-lint issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix gocritic issues (elseif, ifElseChain) - Fix gosec G115 integer overflow - Fix revive unused parameter issues - Fix unconvert unnecessary conversions - Fix line length violations (>120 chars) - Rename CPUUsageModeRusage → CPUUsageModeReal - Move goroutineHeuristicSampler to dedicated file - Update gopsutil from v4.25.10 → v4.24.6 for Go 1.21 compatibility --- flow/adaptive_throttler.go | 15 ++++--- flow/adaptive_throttler_test.go | 22 ++++++---- flow/cpu_sampler.go | 5 +++ flow/cpu_sampler_heuristic.go | 57 ++++++++++++++++++++++++ flow/resource_monitor.go | 77 +++++++-------------------------- flow/resource_monitor_test.go | 32 ++------------ go.mod | 18 ++++---- go.sum | 40 +++++++++-------- 8 files changed, 133 insertions(+), 133 deletions(-) create mode 100644 flow/cpu_sampler_heuristic.go diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index c715a7c..0b0d4a6 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -41,7 +41,8 @@ type AdaptiveThrottlerConfig struct { // CPUUsageModeHeuristic: Estimates CPU usage using a simple heuristic (goroutine count), suitable for platforms // where accurate process CPU measurement is not supported. // - // CPUUsageModeRusage: Measures process CPU time using OS-level resource usage statistics (when supported), providing more accurate CPU usage readings. + // CPUUsageModeReal: Attempts to measure actual process CPU usage via gopsutil + // (when supported), providing more accurate CPU usage readings. CPUUsageMode CPUUsageMode // Hysteresis buffer to prevent rapid state changes (percentage points). @@ -61,14 +62,14 @@ func DefaultAdaptiveThrottlerConfig() AdaptiveThrottlerConfig { MaxMemoryPercent: 80.0, // Conservative memory threshold MaxCPUPercent: 70.0, // Conservative CPU threshold MinThroughput: 10, // Reasonable minimum throughput - MaxThroughput: 500, // More conservative maximum (reduced from 1000) - SampleInterval: 200 * time.Millisecond, // Less frequent sampling (increased from 100ms) + MaxThroughput: 500, // More conservative maximum + SampleInterval: 200 * time.Millisecond, // Less frequent sampling BufferSize: 500, // Match max throughput for 1 second buffer at max rate - AdaptationFactor: 0.15, // Slightly more conservative adaptation (reduced from 0.2) + AdaptationFactor: 0.15, // Slightly more conservative adaptation SmoothTransitions: true, // Keep smooth transitions enabled by default - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, // Prevent oscillations around threshold - MaxRateChangeFactor: 0.3, // More conservative rate changes (reduced from 0.5) + CPUUsageMode: CPUUsageModeReal, // Use actual process CPU usage via gopsutil + HysteresisBuffer: 5.0, // Prevent oscillations around threshold + MaxRateChangeFactor: 0.3, // More conservative rate changes } } diff --git a/flow/adaptive_throttler_test.go b/flow/adaptive_throttler_test.go index 67eba2f..b867a07 100644 --- a/flow/adaptive_throttler_test.go +++ b/flow/adaptive_throttler_test.go @@ -35,7 +35,11 @@ func (m *mockResourceMonitor) SetStats(stats ResourceStats) { func (m *mockResourceMonitor) Close() {} // newAdaptiveThrottlerWithMonitor creates an AdaptiveThrottler with a mock monitor for testing -func newAdaptiveThrottlerWithMonitor(config AdaptiveThrottlerConfig, monitor resourceMonitorInterface, customPeriod ...time.Duration) *AdaptiveThrottler { +func newAdaptiveThrottlerWithMonitor( + config AdaptiveThrottlerConfig, + monitor resourceMonitorInterface, + customPeriod ...time.Duration, +) *AdaptiveThrottler { period := time.Second if len(customPeriod) > 0 && customPeriod[0] > 0 { period = customPeriod[0] @@ -149,10 +153,8 @@ func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { if tt.expectedPanic != "" && !strings.Contains(panicMsg, tt.expectedPanic) { t.Errorf("panic message should contain %q, got: %v", tt.expectedPanic, r) } - } else { - if tt.shouldPanic { - t.Error("expected panic but didn't get one") - } + } else if tt.shouldPanic { + t.Error("expected panic but didn't get one") } }() NewAdaptiveThrottler(tt.config) @@ -519,8 +521,10 @@ func TestAdaptiveThrottler_SmoothTransitions(t *testing.T) { // This verifies that smoothing is actually working (gradual vs immediate) if expectedMinRateWithoutSmoothing > int64(config.MinThroughput) { if actualReduction >= aggressiveReduction { - t.Errorf("with smoothing enabled, rate reduction should be gradual. Got reduction of %d (from %d to %d), expected less than aggressive reduction of %d", - actualReduction, initialRate, finalRate, aggressiveReduction) + t.Errorf("with smoothing enabled, rate reduction should be gradual. "+ + "Got reduction of %d (from %d to %d), expected less than aggressive reduction of %d", + actualReduction, initialRate, finalRate, aggressiveReduction, + ) } } } @@ -631,7 +635,7 @@ func TestAdaptiveThrottler_CloseStopsBackgroundLoops(t *testing.T) { func TestAdaptiveThrottler_CPUUsageMode(t *testing.T) { config := DefaultAdaptiveThrottlerConfig() - config.CPUUsageMode = CPUUsageModeRusage + config.CPUUsageMode = CPUUsageModeReal at := NewAdaptiveThrottler(config) defer func() { @@ -919,7 +923,7 @@ func TestAdaptiveThrottler_BufferedQuotaSignal(t *testing.T) { }() // Fill the output buffer and quota - for i := 0; i < int(config.MaxThroughput); i++ { + for i := 0; i < config.MaxThroughput; i++ { select { case at.In() <- i: case <-time.After(100 * time.Millisecond): diff --git a/flow/cpu_sampler.go b/flow/cpu_sampler.go index 9f0b1dc..c22fedb 100644 --- a/flow/cpu_sampler.go +++ b/flow/cpu_sampler.go @@ -1,6 +1,8 @@ package flow import ( + "fmt" + "math" "os" "time" @@ -15,6 +17,9 @@ type gopsutilProcessSampler struct { func newGopsutilProcessSampler() (*gopsutilProcessSampler, error) { pid := os.Getpid() + if pid < 0 || pid > math.MaxInt32 { + return nil, fmt.Errorf("invalid PID: %d", pid) + } proc, err := process.NewProcess(int32(pid)) if err != nil { return nil, err diff --git a/flow/cpu_sampler_heuristic.go b/flow/cpu_sampler_heuristic.go new file mode 100644 index 0000000..4cb0a19 --- /dev/null +++ b/flow/cpu_sampler_heuristic.go @@ -0,0 +1,57 @@ +package flow + +import ( + "math" + "runtime" + "time" +) + +const ( + // CPUHeuristicBaselineCPU provides minimum CPU usage estimate for any number of goroutines + CPUHeuristicBaselineCPU = 10.0 + // CPUHeuristicLinearScaleFactor determines CPU increase per goroutine for low counts (1-10) + CPUHeuristicLinearScaleFactor = 1.0 + // CPUHeuristicLogScaleFactor determines logarithmic CPU scaling for higher goroutine counts + CPUHeuristicLogScaleFactor = 8.0 + // CPUHeuristicMaxGoroutinesForLinear switches from linear to logarithmic scaling + CPUHeuristicMaxGoroutinesForLinear = 10 + // CPUHeuristicMaxCPU caps the CPU estimate to leave room for system processes + CPUHeuristicMaxCPU = 95.0 +) + +// goroutineHeuristicSampler uses goroutine count as a CPU usage proxy +type goroutineHeuristicSampler struct{} + +func (s *goroutineHeuristicSampler) Sample(_ time.Duration) float64 { + // Uses logarithmic scaling for more realistic CPU estimation + // Base level: 1-10 goroutines = baseline CPU usage (10-20%) + // Logarithmic growth to avoid overestimation at high goroutine counts + goroutineCount := float64(runtime.NumGoroutine()) + + // Baseline CPU usage for minimal goroutines + if goroutineCount <= CPUHeuristicMaxGoroutinesForLinear { + return CPUHeuristicBaselineCPU + goroutineCount*CPUHeuristicLinearScaleFactor + } + + // Logarithmic scaling: ln(goroutines) * scaling factor + // At ~100 goroutines: ~50% CPU + // At ~1000 goroutines: ~70% CPU + // At ~10000 goroutines: ~85% CPU + // Caps at 95% to leave room for system processes + logScaling := math.Log(goroutineCount) * CPUHeuristicLogScaleFactor + estimatedCPU := CPUHeuristicBaselineCPU + logScaling + + // Cap at maximum to be conservative + if estimatedCPU > CPUHeuristicMaxCPU { + return CPUHeuristicMaxCPU + } + return estimatedCPU +} + +func (s *goroutineHeuristicSampler) Reset() { + // No state to reset for heuristic sampler +} + +func (s *goroutineHeuristicSampler) IsInitialized() bool { + return true +} diff --git a/flow/resource_monitor.go b/flow/resource_monitor.go index 22b1a24..bb1904a 100644 --- a/flow/resource_monitor.go +++ b/flow/resource_monitor.go @@ -9,27 +9,14 @@ import ( "time" ) -const ( - // CPUHeuristicBaselineCPU provides minimum CPU usage estimate for any number of goroutines - CPUHeuristicBaselineCPU = 10.0 - // CPUHeuristicLinearScaleFactor determines CPU increase per goroutine for low counts (1-10) - CPUHeuristicLinearScaleFactor = 1.0 - // CPUHeuristicLogScaleFactor determines logarithmic CPU scaling for higher goroutine counts - CPUHeuristicLogScaleFactor = 8.0 - // CPUHeuristicMaxGoroutinesForLinear switches from linear to logarithmic scaling - CPUHeuristicMaxGoroutinesForLinear = 10 - // CPUHeuristicMaxCPU caps the CPU estimate to leave room for system processes - CPUHeuristicMaxCPU = 95.0 -) - // CPUUsageMode defines the strategy for sampling CPU usage type CPUUsageMode int const ( // CPUUsageModeHeuristic uses goroutine count as a simple CPU usage proxy CPUUsageModeHeuristic CPUUsageMode = iota - // CPUUsageModeRusage samples actual CPU time using syscall.Getrusage - CPUUsageModeRusage + // CPUUsageModeReal attempts to measure actual process CPU usage via gopsutil + CPUUsageModeReal ) // ResourceStats represents current system resource statistics @@ -50,44 +37,6 @@ type cpuUsageSampler interface { IsInitialized() bool } -// goroutineHeuristicSampler uses goroutine count as a CPU usage proxy -type goroutineHeuristicSampler struct{} - -func (s *goroutineHeuristicSampler) Sample(deltaTime time.Duration) float64 { - // Improved heuristic: uses logarithmic scaling for more realistic CPU estimation - // Base level: 1-10 goroutines = baseline CPU usage (10-20%) - // Scaling: logarithmic growth to avoid overestimation at high goroutine counts - goroutineCount := float64(runtime.NumGoroutine()) - - // Baseline CPU usage for minimal goroutines - if goroutineCount <= CPUHeuristicMaxGoroutinesForLinear { - return CPUHeuristicBaselineCPU + goroutineCount*CPUHeuristicLinearScaleFactor - } - - // Logarithmic scaling: ln(goroutines) * scaling factor - // At ~100 goroutines: ~50% CPU - // At ~1000 goroutines: ~70% CPU - // At ~10000 goroutines: ~85% CPU - // Caps at 95% to leave room for system processes - logScaling := math.Log(goroutineCount) * CPUHeuristicLogScaleFactor - estimatedCPU := CPUHeuristicBaselineCPU + logScaling - - // Cap at maximum to be conservative - if estimatedCPU > CPUHeuristicMaxCPU { - return CPUHeuristicMaxCPU - } - return estimatedCPU -} - -func (s *goroutineHeuristicSampler) Reset() { - // No state to reset for heuristic sampler -} - -func (s *goroutineHeuristicSampler) IsInitialized() bool { - // Heuristic sampler is always "initialized" as it doesn't need state - return true -} - // ResourceMonitor monitors system resources and provides current statistics type ResourceMonitor struct { sampleInterval time.Duration @@ -147,7 +96,7 @@ func NewResourceMonitor( // initSampler initializes the appropriate CPU sampler based on mode and platform support func (rm *ResourceMonitor) initSampler() { switch rm.cpuMode { - case CPUUsageModeRusage: + case CPUUsageModeReal: // Try gopsutil first, fallback to heuristic if sampler, err := newGopsutilProcessSampler(); err == nil { rm.sampler = sampler @@ -222,7 +171,11 @@ func (rm *ResourceMonitor) tryGetSystemMemory() (systemMemory, bool) { return stats, true } -func (rm *ResourceMonitor) memoryUsagePercent(hasSystemStats bool, sysStats systemMemory, procStats *runtime.MemStats) float64 { +func (rm *ResourceMonitor) memoryUsagePercent( + hasSystemStats bool, + sysStats systemMemory, + procStats *runtime.MemStats, +) float64 { if hasSystemStats { available := sysStats.Available if available > sysStats.Total { @@ -272,20 +225,22 @@ func validateResourceStats(stats *ResourceStats) { } // Validate memory percent - if math.IsNaN(stats.MemoryUsedPercent) || math.IsInf(stats.MemoryUsedPercent, 0) { + switch { + case math.IsNaN(stats.MemoryUsedPercent) || math.IsInf(stats.MemoryUsedPercent, 0): stats.MemoryUsedPercent = 0 - } else if stats.MemoryUsedPercent < 0 { + case stats.MemoryUsedPercent < 0: stats.MemoryUsedPercent = 0 - } else if stats.MemoryUsedPercent > 100 { + case stats.MemoryUsedPercent > 100: stats.MemoryUsedPercent = 100 } // Validate CPU percent - if math.IsNaN(stats.CPUUsagePercent) || math.IsInf(stats.CPUUsagePercent, 0) { + switch { + case math.IsNaN(stats.CPUUsagePercent) || math.IsInf(stats.CPUUsagePercent, 0): stats.CPUUsagePercent = 0 - } else if stats.CPUUsagePercent < 0 { + case stats.CPUUsagePercent < 0: stats.CPUUsagePercent = 0 - } else if stats.CPUUsagePercent > 100 { + case stats.CPUUsagePercent > 100: stats.CPUUsagePercent = 100 } diff --git a/flow/resource_monitor_test.go b/flow/resource_monitor_test.go index a3216da..7d59d5b 100644 --- a/flow/resource_monitor_test.go +++ b/flow/resource_monitor_test.go @@ -3,7 +3,6 @@ package flow import ( "math" "runtime" - "sync" "testing" "time" ) @@ -197,14 +196,14 @@ func TestResourceMonitor_CPUUsageModeHeuristic(t *testing.T) { } } -func TestResourceMonitor_CPUUsageModeRusage(t *testing.T) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeRusage) +func TestResourceMonitor_CPUUsageModeReal(t *testing.T) { + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeReal) defer rm.Close() // Should use gopsutil sampler or fallback to heuristic if _, ok := rm.sampler.(*gopsutilProcessSampler); ok { - if rm.cpuMode != CPUUsageModeRusage { - t.Errorf("expected cpuMode %v, got %v", CPUUsageModeRusage, rm.cpuMode) + if rm.cpuMode != CPUUsageModeReal { + t.Errorf("expected cpuMode %v, got %v", CPUUsageModeReal, rm.cpuMode) } } else { if rm.cpuMode != CPUUsageModeHeuristic { @@ -251,29 +250,6 @@ func TestResourceMonitor_MonitorLoop(t *testing.T) { } } -func TestResourceMonitor_ConcurrentAccess(t *testing.T) { - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) - defer rm.Close() - - var wg sync.WaitGroup - numGoroutines := 10 - numIterations := 100 - - // Test concurrent reads - for i := 0; i < numGoroutines; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < numIterations; j++ { - _ = rm.GetStats() - _ = rm.IsResourceConstrained() - } - }() - } - - wg.Wait() -} - func TestResourceMonitor_UsesSystemMemoryStats(t *testing.T) { t.Helper() diff --git a/go.mod b/go.mod index 2a575af..2d5f75b 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,16 @@ module github.com/reugn/go-streams -go 1.24.0 +go 1.21.0 -require github.com/shirou/gopsutil/v4 v4.25.10 +require github.com/shirou/gopsutil/v4 v4.25.2 require ( - github.com/ebitengine/purego v0.9.1 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect - github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect - github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/tklauser/go-sysconf v0.3.16 // indirect - github.com/tklauser/numcpus v0.11.0 // indirect + github.com/ebitengine/purego v0.8.2 // indirect + 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/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.38.0 // indirect + golang.org/x/sys v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 015b937..da0f69e 100644 --- a/go.sum +++ b/go.sum @@ -1,32 +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/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +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/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= -github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +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-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA= -github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM= -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.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= -github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= -github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= -github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +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/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk= +github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.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= From fbc0df7174bda415ef2392ba56b1bea91fe90393 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Wed, 19 Nov 2025 23:31:57 +0300 Subject: [PATCH 05/54] refactor(adaptive_throttler): streamline main function by removing commented-out configuration examples --- examples/adaptive_throttler/main.go | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/examples/adaptive_throttler/main.go b/examples/adaptive_throttler/main.go index b394616..59ac53e 100644 --- a/examples/adaptive_throttler/main.go +++ b/examples/adaptive_throttler/main.go @@ -14,19 +14,9 @@ func editMessage(msg string) string { } func main() { - throttlerConfig := flow.DefaultAdaptiveThrottlerConfig() // To setup a custom throttler, you can modify the throttlerConfig struct with your desired values. // For all available options, see the flow.AdaptiveThrottlerConfig struct. - // Example: - // throttlerConfig.CPUUsageMode = flow.CPUUsageModeHeuristic - // throttlerConfig.MaxMemoryPercent = 80.0 - // throttlerConfig.MaxCPUPercent = 70.0 - // throttlerConfig.MinThroughput = 10 - // throttlerConfig.MaxThroughput = 100 - // throttlerConfig.SampleInterval = 50 * time.Millisecond - // throttlerConfig.BufferSize = 10 - // throttlerConfig.AdaptationFactor = 0.5 - // throttlerConfig.SmoothTransitions = false + throttlerConfig := flow.DefaultAdaptiveThrottlerConfig() throttler := flow.NewAdaptiveThrottler(throttlerConfig) defer throttler.Close() From 0d97643e4405d3c8752745ea7745a0eca770324b Mon Sep 17 00:00:00 2001 From: kxrxh Date: Wed, 19 Nov 2025 23:50:02 +0300 Subject: [PATCH 06/54] refactor: rename CPUUsageModeReal to CPUUsageModeMeasured - Rename CPUUsageModeReal to CPUUsageModeMeasured for clarity - Add clarifying comment to setSystemMemoryReader function The new name better reflects that even gopsutil measurements are approximations rather than absolute 'real' values. --- flow/adaptive_throttler.go | 20 ++++++++++++++------ flow/adaptive_throttler_test.go | 2 +- flow/resource_monitor.go | 13 +++++-------- flow/resource_monitor_test.go | 8 ++++---- flow/system_memory.go | 1 + 5 files changed, 25 insertions(+), 19 deletions(-) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 0b0d4a6..55a4623 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -41,7 +41,7 @@ type AdaptiveThrottlerConfig struct { // CPUUsageModeHeuristic: Estimates CPU usage using a simple heuristic (goroutine count), suitable for platforms // where accurate process CPU measurement is not supported. // - // CPUUsageModeReal: Attempts to measure actual process CPU usage via gopsutil + // CPUUsageModeMeasured: Attempts to measure actual process CPU usage via gopsutil // (when supported), providing more accurate CPU usage readings. CPUUsageMode CPUUsageMode @@ -67,7 +67,7 @@ func DefaultAdaptiveThrottlerConfig() AdaptiveThrottlerConfig { BufferSize: 500, // Match max throughput for 1 second buffer at max rate AdaptationFactor: 0.15, // Slightly more conservative adaptation SmoothTransitions: true, // Keep smooth transitions enabled by default - CPUUsageMode: CPUUsageModeReal, // Use actual process CPU usage via gopsutil + CPUUsageMode: CPUUsageModeMeasured, // Use actual process CPU usage via gopsutil HysteresisBuffer: 5.0, // Prevent oscillations around threshold MaxRateChangeFactor: 0.3, // More conservative rate changes } @@ -80,8 +80,16 @@ type resourceMonitorInterface interface { Close() } -// AdaptiveThrottler is a flow that adaptively throttles throughput based on -// system resource availability +// AdaptiveThrottler implements a feedback control system that: +// - Monitors CPU and memory usage at regular intervals +// +// - Reduces throughput when resources exceed thresholds (with severity-based scaling) +// +// - Gradually increases throughput when resources are available (with hysteresis) +// +// - Applies smoothing to prevent abrupt rate changes +// +// - Enforces minimum and maximum throughput bounds type AdaptiveThrottler struct { config AdaptiveThrottlerConfig monitor resourceMonitorInterface @@ -112,7 +120,6 @@ var _ streams.Flow = (*AdaptiveThrottler)(nil) // NewAdaptiveThrottler creates a new adaptive throttler func NewAdaptiveThrottler(config AdaptiveThrottlerConfig) *AdaptiveThrottler { - // Validate config if config.MaxMemoryPercent <= 0 || config.MaxMemoryPercent > 100 { panic(fmt.Sprintf("invalid MaxMemoryPercent: %f", config.MaxMemoryPercent)) } @@ -252,7 +259,8 @@ func (at *AdaptiveThrottler) adaptRate() { at.currentRate.Store(newRateInt) at.maxElements.Store(newRateInt) at.counter.Store(0) // Reset quota counter to apply new rate immediately - // Wake any blocked emitters so the new quota takes effect without waiting for the next period tick. + // Wake any blocked emitters + // so the new quota takes effect without waiting for the next period tick. at.notifyQuotaReset() at.lastAdaptation = time.Now() } diff --git a/flow/adaptive_throttler_test.go b/flow/adaptive_throttler_test.go index b867a07..565e39a 100644 --- a/flow/adaptive_throttler_test.go +++ b/flow/adaptive_throttler_test.go @@ -635,7 +635,7 @@ func TestAdaptiveThrottler_CloseStopsBackgroundLoops(t *testing.T) { func TestAdaptiveThrottler_CPUUsageMode(t *testing.T) { config := DefaultAdaptiveThrottlerConfig() - config.CPUUsageMode = CPUUsageModeReal + config.CPUUsageMode = CPUUsageModeMeasured at := NewAdaptiveThrottler(config) defer func() { diff --git a/flow/resource_monitor.go b/flow/resource_monitor.go index bb1904a..81e11bf 100644 --- a/flow/resource_monitor.go +++ b/flow/resource_monitor.go @@ -15,8 +15,8 @@ type CPUUsageMode int const ( // CPUUsageModeHeuristic uses goroutine count as a simple CPU usage proxy CPUUsageModeHeuristic CPUUsageMode = iota - // CPUUsageModeReal attempts to measure actual process CPU usage via gopsutil - CPUUsageModeReal + // CPUUsageModeMeasured attempts to measure actual process CPU usage via gopsutil + CPUUsageModeMeasured ) // ResourceStats represents current system resource statistics @@ -50,9 +50,9 @@ type ResourceMonitor struct { // CPU sampling sampler cpuUsageSampler - memStats runtime.MemStats // Reusable buffer for memory stats + // Reusable buffer for memory stats + memStats runtime.MemStats - // Lifecycle mu sync.RWMutex done chan struct{} } @@ -81,13 +81,10 @@ func NewResourceMonitor( done: make(chan struct{}), } - // Initialize CPU sampler rm.initSampler() - // Initialize with current stats rm.stats.Store(rm.collectStats()) - // Start monitoring goroutine go rm.monitor() return rm @@ -96,7 +93,7 @@ func NewResourceMonitor( // initSampler initializes the appropriate CPU sampler based on mode and platform support func (rm *ResourceMonitor) initSampler() { switch rm.cpuMode { - case CPUUsageModeReal: + case CPUUsageModeMeasured: // Try gopsutil first, fallback to heuristic if sampler, err := newGopsutilProcessSampler(); err == nil { rm.sampler = sampler diff --git a/flow/resource_monitor_test.go b/flow/resource_monitor_test.go index 7d59d5b..7f142fb 100644 --- a/flow/resource_monitor_test.go +++ b/flow/resource_monitor_test.go @@ -196,14 +196,14 @@ func TestResourceMonitor_CPUUsageModeHeuristic(t *testing.T) { } } -func TestResourceMonitor_CPUUsageModeReal(t *testing.T) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeReal) +func TestResourceMonitor_CPUUsageModeMeasured(t *testing.T) { + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeMeasured) defer rm.Close() // Should use gopsutil sampler or fallback to heuristic if _, ok := rm.sampler.(*gopsutilProcessSampler); ok { - if rm.cpuMode != CPUUsageModeReal { - t.Errorf("expected cpuMode %v, got %v", CPUUsageModeReal, rm.cpuMode) + if rm.cpuMode != CPUUsageModeMeasured { + t.Errorf("expected cpuMode %v, got %v", CPUUsageModeMeasured, rm.cpuMode) } } else { if rm.cpuMode != CPUUsageModeHeuristic { diff --git a/flow/system_memory.go b/flow/system_memory.go index ddb37c0..40924bf 100644 --- a/flow/system_memory.go +++ b/flow/system_memory.go @@ -38,6 +38,7 @@ func loadSystemMemoryReader() systemMemoryReader { } // setSystemMemoryReader replaces the current system memory reader and returns a restore function. +// Created for testing/mocking purposes. func setSystemMemoryReader(reader systemMemoryReader) func() { prev := loadSystemMemoryReader() systemMemoryProvider.Store(reader) From 837e3d627540de98516182174435f86ba296972d Mon Sep 17 00:00:00 2001 From: kxrxh Date: Thu, 20 Nov 2025 15:32:41 +0300 Subject: [PATCH 07/54] feat(adaptive_throttler): enhance resource monitoring for containerized environments - Add cgroup v2/v1 auto-detection for accurate memory stats in containers - Normalize CPU usage sampling by core count for better accuracy - Support custom memory readers in ResourceMonitor for flexible deployments - Add comprehensive system_memory tests with mock file systems - Improve test reliability with polling-based waiting instead of fixed sleeps --- flow/adaptive_throttler.go | 64 +++-- flow/adaptive_throttler_test.go | 44 ++-- flow/cpu_sampler.go | 10 +- flow/resource_monitor.go | 19 ++ flow/resource_monitor_test.go | 133 ++++++----- flow/system_memory.go | 181 +++++++++++++- flow/system_memory_test.go | 404 ++++++++++++++++++++++++++++++++ 7 files changed, 753 insertions(+), 102 deletions(-) create mode 100644 flow/system_memory_test.go diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 55a4623..1ae8f48 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -54,6 +54,11 @@ type AdaptiveThrottlerConfig struct { // Limits how much the rate can change in a single step to prevent instability. // Default: 0.3 (max 30% change per cycle) MaxRateChangeFactor float64 + + // MemoryReader provides memory usage percentage for containerized deployments. + // If nil, system memory will be read via mem.VirtualMemory(). + // Should return memory used percentage (0-100). + MemoryReader func() (float64, error) } // DefaultAdaptiveThrottlerConfig returns sensible defaults for most use cases @@ -115,31 +120,42 @@ type AdaptiveThrottler struct { stopOnce sync.Once } -// Verify AdaptiveThrottler satisfies the Flow interface -var _ streams.Flow = (*AdaptiveThrottler)(nil) - -// NewAdaptiveThrottler creates a new adaptive throttler -func NewAdaptiveThrottler(config AdaptiveThrottlerConfig) *AdaptiveThrottler { - if config.MaxMemoryPercent <= 0 || config.MaxMemoryPercent > 100 { - panic(fmt.Sprintf("invalid MaxMemoryPercent: %f", config.MaxMemoryPercent)) +// Validate checks that the configuration is valid and returns an error if not +func (c *AdaptiveThrottlerConfig) Validate() error { + if c.MaxMemoryPercent <= 0 || c.MaxMemoryPercent > 100 { + return fmt.Errorf("invalid MaxMemoryPercent: %f", c.MaxMemoryPercent) + } + if c.MinThroughput < 1 || c.MaxThroughput < c.MinThroughput { + return fmt.Errorf("invalid throughput bounds: min=%d, max=%d", c.MinThroughput, c.MaxThroughput) } - if config.MaxCPUPercent <= 0 || config.MaxCPUPercent > 100 { - panic(fmt.Sprintf("invalid MaxCPUPercent: %f", config.MaxCPUPercent)) + if c.AdaptationFactor <= 0 || c.AdaptationFactor >= 1 { + return fmt.Errorf("AdaptationFactor must be in (0, 1), got %f", c.AdaptationFactor) } - if config.MinThroughput < 1 { - panic(fmt.Sprintf("invalid MinThroughput: %d", config.MinThroughput)) + if c.MaxCPUPercent <= 0 || c.MaxCPUPercent > 100 { + return fmt.Errorf("invalid MaxCPUPercent: %f", c.MaxCPUPercent) } - if config.MaxThroughput < config.MinThroughput { - panic("MaxThroughput must be >= MinThroughput") + if c.BufferSize < 1 { + return fmt.Errorf("invalid BufferSize: %d", c.BufferSize) } - if config.BufferSize < 1 { - panic(fmt.Sprintf("invalid BufferSize: %d", config.BufferSize)) + if c.SampleInterval <= 0 { + return fmt.Errorf("invalid SampleInterval: %v", c.SampleInterval) } - if config.SampleInterval <= 0 { - panic(fmt.Sprintf("invalid SampleInterval: %v", config.SampleInterval)) + if c.HysteresisBuffer < 0 { + return fmt.Errorf("invalid HysteresisBuffer: %f", c.HysteresisBuffer) } - if config.AdaptationFactor <= 0 || config.AdaptationFactor > 1 { - panic(fmt.Sprintf("invalid AdaptationFactor: %f", config.AdaptationFactor)) + if c.MaxRateChangeFactor <= 0 || c.MaxRateChangeFactor > 1 { + return fmt.Errorf("invalid MaxRateChangeFactor: %f, must be in (0, 1]", c.MaxRateChangeFactor) + } + return nil +} + +// Verify AdaptiveThrottler satisfies the Flow interface +var _ streams.Flow = (*AdaptiveThrottler)(nil) + +// NewAdaptiveThrottler creates a new adaptive throttler +func NewAdaptiveThrottler(config AdaptiveThrottlerConfig) *AdaptiveThrottler { + if err := config.Validate(); err != nil { + panic(fmt.Sprintf("invalid config: %v", err)) } // Initialize with max throughput @@ -152,6 +168,7 @@ func NewAdaptiveThrottler(config AdaptiveThrottlerConfig) *AdaptiveThrottler { config.MaxMemoryPercent, config.MaxCPUPercent, config.CPUUsageMode, + config.MemoryReader, ), period: time.Second, // 1 second period in: make(chan any), @@ -323,8 +340,13 @@ func (at *AdaptiveThrottler) emit(element any) { select { case <-at.quotaSignal: case <-at.done: - // Shutting down: bypass quota to flush pending data. - at.out <- element + // Shutting down: try to flush pending data, but drop if blocked to avoid deadlock + select { + case at.out <- element: + // Successfully flushed + default: + // Channel is full or no readers - drop element to ensure clean shutdown + } return } } diff --git a/flow/adaptive_throttler_test.go b/flow/adaptive_throttler_test.go index 565e39a..b93b001 100644 --- a/flow/adaptive_throttler_test.go +++ b/flow/adaptive_throttler_test.go @@ -67,22 +67,6 @@ func newAdaptiveThrottlerWithMonitor( return at } -func TestNewAdaptiveThrottler(t *testing.T) { - config := DefaultAdaptiveThrottlerConfig() - at := NewAdaptiveThrottler(config) - defer func() { - at.Close() - time.Sleep(10 * time.Millisecond) - }() - - if at == nil { - t.Fatal("AdaptiveThrottler should not be nil") - } - if at.GetCurrentRate() != int64(config.MaxThroughput) { - t.Errorf("expected initial rate %d, got %d", config.MaxThroughput, at.GetCurrentRate()) - } -} - func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { tests := []struct { name string @@ -136,7 +120,7 @@ func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { MaxRateChangeFactor: 0.5, }, shouldPanic: true, - expectedPanic: "MaxThroughput must be >= MinThroughput", + expectedPanic: "invalid throughput bounds", }, } @@ -931,7 +915,6 @@ func TestAdaptiveThrottler_BufferedQuotaSignal(t *testing.T) { } } - // Wait for quota to be consumed time.Sleep(1100 * time.Millisecond) // Wait > 1 second for quota reset // Should be able to send more elements now (quota reset signal was buffered) @@ -942,3 +925,28 @@ func TestAdaptiveThrottler_BufferedQuotaSignal(t *testing.T) { t.Error("should be able to send after quota reset, buffered signal may not be working") } } + +func TestAdaptiveThrottler_CustomMemoryReader(t *testing.T) { + customMemoryPercent := 42.0 + callCount := 0 + + config := DefaultAdaptiveThrottlerConfig() + config.MemoryReader = func() (float64, error) { + callCount++ + return customMemoryPercent, nil + } + + at := NewAdaptiveThrottler(config) + defer at.Close() + + // Allow some time for stats collection + time.Sleep(150 * time.Millisecond) + + stats := at.GetResourceStats() + if stats.MemoryUsedPercent != customMemoryPercent { + t.Errorf("expected memory percent %f, got %f", customMemoryPercent, stats.MemoryUsedPercent) + } + if callCount == 0 { + t.Error("custom memory reader was not called") + } +} diff --git a/flow/cpu_sampler.go b/flow/cpu_sampler.go index c22fedb..8d753c2 100644 --- a/flow/cpu_sampler.go +++ b/flow/cpu_sampler.go @@ -4,6 +4,7 @@ import ( "fmt" "math" "os" + "runtime" "time" "github.com/shirou/gopsutil/v4/process" @@ -39,7 +40,7 @@ func (s *gopsutilProcessSampler) Sample(deltaTime time.Duration) float64 { now := time.Now() if s.lastSample.IsZero() { s.lastSample = now - s.lastPercent = percent + s.lastPercent = 0.0 return 0.0 } @@ -48,8 +49,8 @@ func (s *gopsutilProcessSampler) Sample(deltaTime time.Duration) float64 { return s.lastPercent } - s.lastPercent = percent - s.lastSample = now + // Normalize CPU usage by the number of cores + percent /= float64(runtime.NumCPU()) if percent > 100.0 { percent = 100.0 @@ -57,6 +58,9 @@ func (s *gopsutilProcessSampler) Sample(deltaTime time.Duration) float64 { percent = 0.0 } + s.lastPercent = percent + s.lastSample = now + return percent } diff --git a/flow/resource_monitor.go b/flow/resource_monitor.go index 81e11bf..44ecd63 100644 --- a/flow/resource_monitor.go +++ b/flow/resource_monitor.go @@ -53,6 +53,9 @@ type ResourceMonitor struct { // Reusable buffer for memory stats memStats runtime.MemStats + // Memory reader for containerized deployments + memoryReader func() (float64, error) + mu sync.RWMutex done chan struct{} } @@ -62,6 +65,7 @@ func NewResourceMonitor( sampleInterval time.Duration, memoryThreshold, cpuThreshold float64, cpuMode CPUUsageMode, + memoryReader func() (float64, error), ) *ResourceMonitor { if sampleInterval <= 0 { panic(fmt.Sprintf("invalid sampleInterval: %v", sampleInterval)) @@ -78,6 +82,7 @@ func NewResourceMonitor( memoryThreshold: memoryThreshold, cpuThreshold: cpuThreshold, cpuMode: cpuMode, + memoryReader: memoryReader, done: make(chan struct{}), } @@ -173,6 +178,20 @@ func (rm *ResourceMonitor) memoryUsagePercent( sysStats systemMemory, procStats *runtime.MemStats, ) float64 { + // Use custom memory reader if provided (for containerized deployments) + if rm.memoryReader != nil { + if percent, err := rm.memoryReader(); err == nil { + if percent < 0 { + return 0 + } + if percent > 100 { + return 100 + } + return percent + } + // Fall back to system memory if custom reader fails + } + if hasSystemStats { available := sysStats.Available if available > sysStats.Total { diff --git a/flow/resource_monitor_test.go b/flow/resource_monitor_test.go index 7f142fb..95aa5a1 100644 --- a/flow/resource_monitor_test.go +++ b/flow/resource_monitor_test.go @@ -7,30 +7,6 @@ import ( "time" ) -func TestNewResourceMonitor(t *testing.T) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) - defer rm.Close() - - if rm == nil { - t.Fatal("ResourceMonitor should not be nil") - } - if rm.sampleInterval != 100*time.Millisecond { - t.Errorf("expected sampleInterval %v, got %v", 100*time.Millisecond, rm.sampleInterval) - } - if rm.memoryThreshold != 80.0 { - t.Errorf("expected memoryThreshold 80.0, got %v", rm.memoryThreshold) - } - if rm.cpuThreshold != 70.0 { - t.Errorf("expected cpuThreshold 70.0, got %v", rm.cpuThreshold) - } - if rm.cpuMode != CPUUsageModeHeuristic { - t.Errorf("expected cpuMode %v, got %v", CPUUsageModeHeuristic, rm.cpuMode) - } - if rm.sampler == nil { - t.Fatal("sampler should not be nil") - } -} - func TestNewResourceMonitor_InvalidSampleInterval(t *testing.T) { defer func() { if r := recover(); r == nil { @@ -38,20 +14,31 @@ func TestNewResourceMonitor_InvalidSampleInterval(t *testing.T) { } }() - NewResourceMonitor(0, 80.0, 70.0, CPUUsageModeHeuristic) + NewResourceMonitor(0, 80.0, 70.0, CPUUsageModeHeuristic, nil) } func TestResourceMonitor_GetStats(t *testing.T) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) defer rm.Close() - // Allow some time for initial stats to be collected - time.Sleep(150 * time.Millisecond) + // Wait for initial stats to be collected (poll instead of fixed sleep) + timeout := time.After(500 * time.Millisecond) + for { + select { + case <-timeout: + t.Fatal("timeout waiting for stats collection") + default: + stats := rm.GetStats() + if !stats.Timestamp.IsZero() { + // Stats have been collected, continue with validation + goto statsCollected + } + time.Sleep(10 * time.Millisecond) // Brief pause before polling again + } + } +statsCollected: stats := rm.GetStats() - if stats.Timestamp.IsZero() { - t.Error("timestamp should not be zero") - } if stats.MemoryUsedPercent < 0.0 || stats.MemoryUsedPercent > 100.0 { t.Errorf("memory percent should be between 0 and 100, got %v", stats.MemoryUsedPercent) } @@ -116,7 +103,7 @@ func TestResourceMonitor_IsResourceConstrained(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - rm := NewResourceMonitor(100*time.Millisecond, tt.memoryThreshold, tt.cpuThreshold, CPUUsageModeHeuristic) + rm := NewResourceMonitor(100*time.Millisecond, tt.memoryThreshold, tt.cpuThreshold, CPUUsageModeHeuristic, nil) defer rm.Close() // Manually set stats for testing @@ -139,11 +126,7 @@ func TestGoroutineHeuristicSampler(t *testing.T) { sampler := &goroutineHeuristicSampler{} // Test with reasonable goroutine count - _ = runtime.NumGoroutine() // Capture but don't use - just to avoid unused variable warning in lint - defer func() { - // Restore original goroutine count by waiting for test goroutines to finish - time.Sleep(10 * time.Millisecond) - }() + _ = runtime.NumGoroutine() percent := sampler.Sample(100 * time.Millisecond) if percent < 0.0 || percent > 100.0 { @@ -154,6 +137,8 @@ func TestGoroutineHeuristicSampler(t *testing.T) { sampler.Reset() } +// TestGopsutilProcessSampler is an integration test that verifies +// gopsutil-based CPU sampling (may be skipped if gopsutil unavailable) func TestGopsutilProcessSampler(t *testing.T) { sampler, err := newGopsutilProcessSampler() if err != nil { @@ -184,7 +169,7 @@ func TestGopsutilProcessSampler(t *testing.T) { } func TestResourceMonitor_CPUUsageModeHeuristic(t *testing.T) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) defer rm.Close() if rm.cpuMode != CPUUsageModeHeuristic { @@ -197,7 +182,7 @@ func TestResourceMonitor_CPUUsageModeHeuristic(t *testing.T) { } func TestResourceMonitor_CPUUsageModeMeasured(t *testing.T) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeMeasured) + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeMeasured, nil) defer rm.Close() // Should use gopsutil sampler or fallback to heuristic @@ -217,7 +202,7 @@ func TestResourceMonitor_CPUUsageModeMeasured(t *testing.T) { } func TestResourceMonitor_Close(t *testing.T) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) // Close should be idempotent rm.Close() @@ -232,21 +217,42 @@ func TestResourceMonitor_Close(t *testing.T) { } } +// TestResourceMonitor_MonitorLoop is an integration test that verifies +// the monitoring loop updates statistics over time func TestResourceMonitor_MonitorLoop(t *testing.T) { - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) defer rm.Close() - // Wait for a few sampling cycles - time.Sleep(250 * time.Millisecond) + // Wait for initial stats collection + timeout := time.After(500 * time.Millisecond) + for { + select { + case <-timeout: + t.Fatal("timeout waiting for initial stats collection") + default: + if !rm.GetStats().Timestamp.IsZero() { + goto initialStatsCollected + } + time.Sleep(10 * time.Millisecond) + } + } - // Stats should be updated multiple times +initialStatsCollected: + // Wait for at least one more update stats1 := rm.GetStats() - time.Sleep(100 * time.Millisecond) - stats2 := rm.GetStats() - - // Timestamps should be different (stats updated) - if !stats2.Timestamp.After(stats1.Timestamp) { - t.Error("stats should be updated with newer timestamps") + timeout = time.After(500 * time.Millisecond) + for { + select { + case <-timeout: + t.Fatal("timeout waiting for stats update") + default: + stats2 := rm.GetStats() + if stats2.Timestamp.After(stats1.Timestamp) { + // Stats have been updated + return + } + time.Sleep(10 * time.Millisecond) + } } } @@ -261,7 +267,7 @@ func TestResourceMonitor_UsesSystemMemoryStats(t *testing.T) { }) defer restore() - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) defer rm.Close() stats := rm.collectStats() @@ -270,13 +276,30 @@ func TestResourceMonitor_UsesSystemMemoryStats(t *testing.T) { } } +// TestResourceStats_MemoryCalculation is an integration test that verifies +// memory statistics calculation with real system memory func TestResourceStats_MemoryCalculation(t *testing.T) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) defer rm.Close() + // Wait for initial stats + timeout := time.After(500 * time.Millisecond) + for { + select { + case <-timeout: + t.Fatal("timeout waiting for stats") + default: + if !rm.GetStats().Timestamp.IsZero() { + goto statsReady + } + time.Sleep(10 * time.Millisecond) + } + } + +statsReady: // Force garbage collection to get more stable memory stats runtime.GC() - time.Sleep(50 * time.Millisecond) + time.Sleep(20 * time.Millisecond) // Reduced sleep time stats := rm.GetStats() @@ -292,7 +315,7 @@ func TestResourceStats_MemoryCalculation(t *testing.T) { } func BenchmarkResourceMonitor_GetStats(b *testing.B) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) defer rm.Close() b.ResetTimer() @@ -302,7 +325,7 @@ func BenchmarkResourceMonitor_GetStats(b *testing.B) { } func BenchmarkResourceMonitor_IsResourceConstrained(b *testing.B) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic) + rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) defer rm.Close() b.ResetTimer() diff --git a/flow/system_memory.go b/flow/system_memory.go index 40924bf..20c351d 100644 --- a/flow/system_memory.go +++ b/flow/system_memory.go @@ -1,17 +1,171 @@ package flow import ( + "bufio" + "bytes" + "errors" + "io/fs" + "os" + "strconv" + "strings" "sync/atomic" "github.com/shirou/gopsutil/v4/mem" ) +// FileSystem abstracts file system operations for testing +type FileSystem interface { + ReadFile(name string) ([]byte, error) + Open(name string) (fs.File, error) +} + +// osFileSystem implements FileSystem using the os package +type osFileSystem struct{} + +func (osFileSystem) ReadFile(name string) ([]byte, error) { + return os.ReadFile(name) +} + +func (osFileSystem) Open(name string) (fs.File, error) { + return os.Open(name) +} + type systemMemoryReader func() (systemMemory, error) var systemMemoryProvider atomic.Value +var fileSystemProvider atomic.Value func init() { + systemMemoryProvider.Store(systemMemoryReader(readSystemMemoryAuto)) + fileSystemProvider.Store(FileSystem(osFileSystem{})) +} + +// readSystemMemoryAuto detects the environment once and "upgrades" the reader +func readSystemMemoryAuto() (systemMemory, error) { + if m, err := readCgroupV2Memory(); err == nil { + systemMemoryProvider.Store(systemMemoryReader(readCgroupV2Memory)) + return m, nil + } + + if m, err := readCgroupV1Memory(); err == nil { + systemMemoryProvider.Store(systemMemoryReader(readCgroupV1Memory)) + return m, nil + } + + // Fallback to Host (Bare metal / VM / Unlimited Container) systemMemoryProvider.Store(systemMemoryReader(readSystemMemory)) + return readSystemMemory() +} + +func readCgroupV2Memory() (systemMemory, error) { + return readCgroupV2MemoryWithFS(loadFileSystem()) +} + +func readCgroupV2MemoryWithFS(fs FileSystem) (systemMemory, error) { + usage, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory.current") + if err != nil { + return systemMemory{}, err + } + + limit, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory.max") + if err != nil { + return systemMemory{}, err + } + + // Parse memory.stat to find reclaimable memory (inactive_file) + inactiveFile, _ := readCgroupStatWithFS(fs, "/sys/fs/cgroup/memory.stat", "inactive_file") + + // Available = (Limit - Usage) + Reclaimable + // Note: If Limit - Usage is near zero, the kernel would reclaim inactive_file + // Handle case where usage exceeds limit + var available uint64 + if usage > limit { + available = inactiveFile // Only reclaimable memory is available + } else { + available = (limit - usage) + inactiveFile + } + + if available > limit { + available = limit + } + + return systemMemory{ + Total: limit, + Available: available, + }, nil +} + +func readCgroupV1Memory() (systemMemory, error) { + return readCgroupV1MemoryWithFS(loadFileSystem()) +} + +func readCgroupV1MemoryWithFS(fs FileSystem) (systemMemory, error) { + usage, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory/memory.usage_in_bytes") + if err != nil { + return systemMemory{}, err + } + + limit, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory/memory.limit_in_bytes") + if err != nil { + return systemMemory{}, err + } + + // Check for "unlimited" (random huge number in V1) + if limit > (1 << 60) { + return systemMemory{}, os.ErrNotExist + } + + // Parse memory.stat for V1 + inactiveFile, _ := readCgroupStatWithFS(fs, "/sys/fs/cgroup/memory/memory.stat", "total_inactive_file") + + // Handle case where usage exceeds limit + var available uint64 + if usage > limit { + available = inactiveFile // Only reclaimable memory is available + } else { + available = (limit - usage) + inactiveFile + } + + if available > limit { + available = limit + } + + return systemMemory{ + Total: limit, + Available: available, + }, nil +} + +func readCgroupValueWithFS(fs FileSystem, path string) (uint64, error) { + data, err := fs.ReadFile(path) + if err != nil { + return 0, err + } + str := strings.TrimSpace(string(data)) + if str == "max" { + return 0, os.ErrNotExist + } + return strconv.ParseUint(str, 10, 64) +} + +func readCgroupStatWithFS(fs FileSystem, path string, key string) (uint64, error) { + f, err := fs.Open(path) + if err != nil { + return 0, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Bytes() + if bytes.HasPrefix(line, []byte(key)) { + fields := bytes.Fields(line) + if len(fields) >= 2 { + return strconv.ParseUint(string(fields[1]), 10, 64) + } + } + } + return 0, errors.New("key not found") } func readSystemMemory() (systemMemory, error) { @@ -19,7 +173,6 @@ func readSystemMemory() (systemMemory, error) { if err != nil { return systemMemory{}, err } - return systemMemory{ Total: v.Total, Available: v.Available, @@ -30,11 +183,25 @@ func getSystemMemory() (systemMemory, error) { return loadSystemMemoryReader()() } -func loadSystemMemoryReader() systemMemoryReader { - if reader := systemMemoryProvider.Load(); reader != nil { - return reader.(systemMemoryReader) +// GetSystemMemory returns the current system memory statistics. +// This function auto-detects the environment (cgroup v2, v1, or host) +// and returns appropriate memory information. +func GetSystemMemory() (SystemMemory, error) { + mem, err := getSystemMemory() + if err != nil { + return SystemMemory{}, err } - return readSystemMemory + return SystemMemory(mem), nil +} + +// SystemMemory represents system memory information +type SystemMemory struct { + Total uint64 + Available uint64 +} + +func loadSystemMemoryReader() systemMemoryReader { + return systemMemoryProvider.Load().(systemMemoryReader) } // setSystemMemoryReader replaces the current system memory reader and returns a restore function. @@ -46,3 +213,7 @@ func setSystemMemoryReader(reader systemMemoryReader) func() { systemMemoryProvider.Store(prev) } } + +func loadFileSystem() FileSystem { + return fileSystemProvider.Load().(FileSystem) +} diff --git a/flow/system_memory_test.go b/flow/system_memory_test.go new file mode 100644 index 0000000..435e64b --- /dev/null +++ b/flow/system_memory_test.go @@ -0,0 +1,404 @@ +package flow + +import ( + "errors" + "io/fs" + "strings" + "testing" +) + +func TestReadSystemMemory(t *testing.T) { + mem, err := readSystemMemory() + if err != nil { + t.Fatalf("readSystemMemory failed: %v", err) + } + + if mem.Total == 0 { + t.Error("Total memory should not be zero") + } + + if mem.Available > mem.Total { + t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) + } + + // Available should be less than or equal to total + if mem.Available > mem.Total { + t.Errorf("Available memory (%d) > total memory (%d)", mem.Available, mem.Total) + } +} + +func TestReadSystemMemoryAuto(t *testing.T) { + // Save original state to prevent global state pollution + original := loadSystemMemoryReader() + defer systemMemoryProvider.Store(original) + + mem1, err1 := readSystemMemoryAuto() + if err1 != nil { + t.Fatalf("First readSystemMemoryAuto failed: %v", err1) + } + + // After the first call, the global reader should have been upgraded + // Call the upgraded reader directly to ensure consistency + upgradedReader := loadSystemMemoryReader() + mem2, err2 := upgradedReader() + if err2 != nil { + t.Fatalf("Upgraded reader failed: %v", err2) + } + + // Results should be consistent (within reasonable bounds due to memory fluctuations) + if mem1.Total != mem2.Total { + t.Errorf("Total memory inconsistent: first=%d, second=%d", + mem1.Total, mem2.Total) + } + + // Allow for small variations in available memory (due to system activity) + const tolerance uint64 = 1024 * 1024 // 1MB tolerance + if mem1.Available > mem2.Available { + if mem1.Available-mem2.Available > tolerance { + t.Errorf("Available memory varies too much: first=%d, second=%d (tolerance: %d)", + mem1.Available, mem2.Available, tolerance) + } + } else { + if mem2.Available-mem1.Available > tolerance { + t.Errorf("Available memory varies too much: first=%d, second=%d (tolerance: %d)", + mem1.Available, mem2.Available, tolerance) + } + } +} + +func TestGetSystemMemory(t *testing.T) { + mem, err := getSystemMemory() + if err != nil { + t.Fatalf("getSystemMemory failed: %v", err) + } + + if mem.Total == 0 { + t.Error("Total memory should not be zero") + } + + if mem.Available > mem.Total { + t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) + } +} + +func TestSetSystemMemoryReader(t *testing.T) { + // Create a mock reader + mockReader := func() (systemMemory, error) { + return systemMemory{ + Total: 1000000, + Available: 500000, + }, nil + } + + // Set the mock reader + restore := setSystemMemoryReader(mockReader) + + // Test that getSystemMemory uses the mock + mem, err := getSystemMemory() + if err != nil { + t.Fatalf("getSystemMemory with mock failed: %v", err) + } + + if mem.Total != 1000000 || mem.Available != 500000 { + t.Errorf("Mock reader not used: expected (1000000, 500000), got (%d, %d)", mem.Total, mem.Available) + } + + // Restore and test that original reader is back + restore() + mem2, err := getSystemMemory() + if err != nil { + t.Fatalf("getSystemMemory after restore failed: %v", err) + } + + // Should not be using the mock anymore + if mem2.Total == 1000000 && mem2.Available == 500000 { + t.Error("Original reader not restored") + } +} + +func TestSystemMemoryReaderErrorHandling(t *testing.T) { + // Test with a reader that returns an error + errorReader := func() (systemMemory, error) { + return systemMemory{}, errors.New("mock error") + } + + restore := setSystemMemoryReader(errorReader) + defer restore() + + _, err := getSystemMemory() + if err == nil { + t.Error("Expected error from mock reader") + } +} + +// Test concurrent access to system memory reader +func TestConcurrentSystemMemoryAccess(t *testing.T) { + done := make(chan bool, 10) + + for i := 0; i < 10; i++ { + go func() { + for j := 0; j < 100; j++ { + _, err := getSystemMemory() + if err != nil { + t.Errorf("Concurrent access failed: %v", err) + } + } + done <- true + }() + } + + // Wait for all goroutines to complete + for i := 0; i < 10; i++ { + <-done + } +} + +// Test memory calculations are reasonable +func TestMemoryCalculations(t *testing.T) { + mem, err := getSystemMemory() + if err != nil { + t.Fatalf("Failed to get system memory: %v", err) + } + + // Available should be less than or equal to total + if mem.Available > mem.Total { + t.Errorf("Available memory (%d) exceeds total memory (%d)", mem.Available, mem.Total) + } + + // Used memory calculation + used := mem.Total - mem.Available + usedPercent := float64(used) / float64(mem.Total) * 100.0 + + if usedPercent < 0 || usedPercent > 100 { + t.Errorf("Used percentage should be between 0-100, got %.2f%%", usedPercent) + } + + t.Logf("Memory usage: Total=%dMB, Used=%dMB (%.1f%%), Available=%dMB", + mem.Total/1024/1024, + used/1024/1024, + usedPercent, + mem.Available/1024/1024) +} + +// mockFileSystem implements FileSystem for testing +type mockFileSystem struct { + files map[string]string +} + +func (m *mockFileSystem) ReadFile(name string) ([]byte, error) { + if content, ok := m.files[name]; ok { + return []byte(content), nil + } + return nil, fs.ErrNotExist +} + +func (m *mockFileSystem) Open(name string) (fs.File, error) { + if content, ok := m.files[name]; ok { + return &mockFile{content: content}, nil + } + return nil, fs.ErrNotExist +} + +// mockFile implements fs.File for testing +type mockFile struct { + content string + reader *strings.Reader +} + +func (m *mockFile) Read(b []byte) (int, error) { + if m.reader == nil { + m.reader = strings.NewReader(m.content) + } + return m.reader.Read(b) +} + +func (m *mockFile) Close() error { + return nil +} + +func (m *mockFile) Stat() (fs.FileInfo, error) { + return nil, fs.ErrNotExist +} + +// cgroupTestCase represents a test case for cgroup memory reading +type cgroupTestCase struct { + name string + usage string + limit string + stat string + expectTotal uint64 + expectAvail uint64 + expectError bool +} + +// runCgroupTest runs a generic test for cgroup memory reading functions +func runCgroupTest(t *testing.T, testCases []cgroupTestCase, + readFunc func(FileSystem) (systemMemory, error), usagePath, limitPath, statPath string) { + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + mockFS := &mockFileSystem{ + files: map[string]string{ + usagePath: tt.usage, + limitPath: tt.limit, + statPath: tt.stat, + }, + } + + mem, err := readFunc(mockFS) + + if tt.expectError { + if err == nil { + t.Error("expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if mem.Total != tt.expectTotal { + t.Errorf("expected total %d, got %d", tt.expectTotal, mem.Total) + } + + if mem.Available != tt.expectAvail { + t.Errorf("expected available %d, got %d", tt.expectAvail, mem.Available) + } + }) + } +} + +func TestReadCgroupV2Memory(t *testing.T) { + testCases := []cgroupTestCase{ + { + name: "normal cgroup v2", + usage: "104857600", // 100MB + limit: "209715200", // 200MB + stat: "inactive_file 52428800", // 50MB + expectTotal: 209715200, // 200MB + expectAvail: 157286400, // 150MB (200-100+50) + expectError: false, + }, + { + name: "unlimited memory", + usage: "104857600", // 100MB + limit: "max", + stat: "inactive_file 0", + expectError: true, // max should cause error + }, + { + name: "available exceeds limit", + usage: "104857600", // 100MB + limit: "209715200", // 200MB + stat: "inactive_file 209715200", // 200MB (would make available = 300MB) + expectTotal: 209715200, // 200MB + expectAvail: 209715200, // capped at limit = 200MB + expectError: false, + }, + { + name: "usage exceeds limit (underflow check)", + usage: "209715201", // 200MB + 1 byte + limit: "209715200", // 200MB + stat: "inactive_file 0", + expectTotal: 209715200, // 200MB + expectAvail: 0, // Should be 0, not wrapped uint64 + expectError: false, + }, + } + + runCgroupTest(t, testCases, readCgroupV2MemoryWithFS, + "/sys/fs/cgroup/memory.current", + "/sys/fs/cgroup/memory.max", + "/sys/fs/cgroup/memory.stat") +} + +func TestReadCgroupV1Memory(t *testing.T) { + testCases := []cgroupTestCase{ + { + name: "normal cgroup v1", + usage: "104857600", // 100MB + limit: "209715200", // 200MB + stat: "total_inactive_file 52428800", // 50MB + expectTotal: 209715200, // 200MB + expectAvail: 157286400, // 150MB (200-100+50) + expectError: false, + }, + { + name: "unlimited memory", + usage: "104857600", // 100MB + limit: "18446744073709551615", // unlimited (2^64-1) + stat: "total_inactive_file 0", + expectError: true, // unlimited should cause error + }, + { + name: "available exceeds limit", + usage: "104857600", // 100MB + limit: "209715200", // 200MB + stat: "total_inactive_file 209715200", // 200MB (would make available = 300MB) + expectTotal: 209715200, // 200MB + expectAvail: 209715200, // capped at limit = 200MB + expectError: false, + }, + { + name: "usage exceeds limit (underflow check)", + usage: "209715201", // 200MB + 1 byte + limit: "209715200", // 200MB + stat: "total_inactive_file 0", + expectTotal: 209715200, // 200MB + expectAvail: 0, // Should be 0, not wrapped uint64 + expectError: false, + }, + } + + runCgroupTest(t, testCases, readCgroupV1MemoryWithFS, + "/sys/fs/cgroup/memory/memory.usage_in_bytes", + "/sys/fs/cgroup/memory/memory.limit_in_bytes", + "/sys/fs/cgroup/memory/memory.stat") +} + +func TestReadCgroupValueWithFS(t *testing.T) { + mockFS := &mockFileSystem{ + files: map[string]string{ + "/test/file": "123456789", + "/test/max": "max", + }, + } + + // Test normal value + val, err := readCgroupValueWithFS(mockFS, "/test/file") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != 123456789 { + t.Errorf("expected 123456789, got %d", val) + } + + // Test "max" value + _, err = readCgroupValueWithFS(mockFS, "/test/max") + if err == nil { + t.Error("expected error for 'max' value") + } +} + +func TestReadCgroupStatWithFS(t *testing.T) { + mockFS := &mockFileSystem{ + files: map[string]string{ + "/test/stat": "inactive_file 52428800\ntotal_cache 104857600\n", + }, + } + + // Test existing key + val, err := readCgroupStatWithFS(mockFS, "/test/stat", "inactive_file") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != 52428800 { + t.Errorf("expected 52428800, got %d", val) + } + + // Test non-existing key + _, err = readCgroupStatWithFS(mockFS, "/test/stat", "nonexistent") + if err == nil { + t.Error("expected error for non-existent key") + } +} From fa6e81f2b04b4df4e267ee46a9ebe4d952e07286 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Thu, 20 Nov 2025 15:55:44 +0300 Subject: [PATCH 08/54] feat(adaptive_throttler): add demo for adaptive throttling pipeline - Implement a demo application showcasing the adaptive throttler in action --- examples/adaptive_throttler/demo/demo.go | 121 +++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 examples/adaptive_throttler/demo/demo.go diff --git a/examples/adaptive_throttler/demo/demo.go b/examples/adaptive_throttler/demo/demo.go new file mode 100644 index 0000000..2d06dd4 --- /dev/null +++ b/examples/adaptive_throttler/demo/demo.go @@ -0,0 +1,121 @@ +package main + +import ( + "fmt" + "math/rand" + "sync/atomic" + "time" + + ext "github.com/reugn/go-streams/extension" + "github.com/reugn/go-streams/flow" +) + +func main() { + var elementsProcessed atomic.Int64 + + // Set up demo configuration with memory simulation + throttler := setupDemoThrottler(&elementsProcessed) + defer func() { + throttler.Close() + }() + + in := make(chan any) + out := make(chan any, 32) + + source := ext.NewChanSource(in) + sink := ext.NewChanSink(out) + + statsDone := make(chan struct{}) + go logThrottlerStats(throttler, statsDone) + defer close(statsDone) + + go func() { + source. + Via(throttler). + Via(flow.NewPassThrough()). + To(sink) + }() + + go produceBurst(in, 250) + + for element := range sink.Out { + fmt.Printf("consumer received %v\n", element) + elementsProcessed.Add(1) // Track processed elements for memory pressure simulation + time.Sleep(25 * time.Millisecond) + } + + fmt.Println("adaptive throttling pipeline completed") +} + +// setupDemoThrottler creates and configures an adaptive throttler with demo settings +func setupDemoThrottler(elementsProcessed *atomic.Int64) *flow.AdaptiveThrottler { + config := flow.DefaultAdaptiveThrottlerConfig() + config.MinThroughput = 5 + config.MaxThroughput = 40 + config.SampleInterval = 200 * time.Millisecond + config.BufferSize = 32 + config.AdaptationFactor = 0.5 + config.SmoothTransitions = true + config.MaxMemoryPercent = 40.0 + config.MaxCPUPercent = 80.0 + + + config.MemoryReader = func() (float64, error) { + elementCount := elementsProcessed.Load() + + // Memory pressure increases with processed elements: + // - 0-50 elements: 5% memory + // - 51-100 elements: 15% memory + // - 101-150 elements: 30% memory + // - 151+ elements: 50%+ memory (increases gradually) + var memoryPercent float64 + switch { + case elementCount <= 50: + memoryPercent = 5.0 + float64(elementCount)*0.2 // 5% to 15% + case elementCount <= 100: + memoryPercent = 15.0 + float64(elementCount-50)*0.3 // 15% to 30% + case elementCount <= 150: + memoryPercent = 30.0 + float64(elementCount-100)*0.4 // 30% to 50% + default: + memoryPercent = 50.0 + float64(elementCount-150)*0.3 // 50%+ (increases more slowly) + if memoryPercent > 95.0 { + memoryPercent = 95.0 + } + } + + return memoryPercent, nil + } + + return flow.NewAdaptiveThrottler(config) +} + +func produceBurst(in chan<- any, total int) { + defer close(in) + + for i := 0; i < total; i++ { + in <- fmt.Sprintf("job-%02d", i) + + if (i+1)%10 == 0 { + time.Sleep(180 * time.Millisecond) + continue + } + + time.Sleep(time.Duration(2+rand.Intn(5)) * time.Millisecond) + } +} + +func logThrottlerStats(at *flow.AdaptiveThrottler, done <-chan struct{}) { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-done: + return + case <-ticker.C: + stats := at.GetResourceStats() + fmt.Printf("[stats] rate=%d eps memory=%.1f%% cpu=%.1f%% goroutines=%d\n", + at.GetCurrentRate(), stats.MemoryUsedPercent, stats.CPUUsagePercent, stats.GoroutineCount) + } + } +} From d1d210c71b45252d9aa664bbebb2e5544d98fc7f Mon Sep 17 00:00:00 2001 From: Kirill <74421236+kxrxh@users.noreply.github.com> Date: Thu, 20 Nov 2025 21:41:29 +0300 Subject: [PATCH 09/54] refactor(adaptive_throttler): update config validation message Co-authored-by: Eugene R. --- flow/adaptive_throttler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 1ae8f48..18cd0cf 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -129,7 +129,7 @@ func (c *AdaptiveThrottlerConfig) Validate() error { return fmt.Errorf("invalid throughput bounds: min=%d, max=%d", c.MinThroughput, c.MaxThroughput) } if c.AdaptationFactor <= 0 || c.AdaptationFactor >= 1 { - return fmt.Errorf("AdaptationFactor must be in (0, 1), got %f", c.AdaptationFactor) + return fmt.Errorf("invalid AdaptationFactor: %f, must be in (0, 1)", c.AdaptationFactor) } if c.MaxCPUPercent <= 0 || c.MaxCPUPercent > 100 { return fmt.Errorf("invalid MaxCPUPercent: %f", c.MaxCPUPercent) From 238ca8be57248b241d18bfbcc0646675c74c0bec Mon Sep 17 00:00:00 2001 From: Kirill <74421236+kxrxh@users.noreply.github.com> Date: Thu, 20 Nov 2025 21:42:32 +0300 Subject: [PATCH 10/54] docs(README): fix punctuation in AdaptiveThrottler description Co-authored-by: Eugene R. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c39b43..32cdc8d 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ processing operations. These building blocks can be used to transform and manipu - **Flatten1:** Flattens a stream of slices of elements into a stream of elements. - **Batch:** Breaks a stream of elements into batches based on size or timing. - **Throttler:** Limits the rate at which elements are processed. -- **AdaptiveThrottler:** Limits the rate at which elements are processed based on the current system resource utilization (CPU and memory usage) +- **AdaptiveThrottler:** Limits the rate at which elements are processed based on the current system resource utilization (CPU and memory usage). - **SlidingWindow:** Creates overlapping windows of elements. - **TumblingWindow:** Creates non-overlapping, fixed-size windows of elements. - **SessionWindow:** Creates windows based on periods of activity and inactivity. From 5c7bc281c04557c6039806dad99995eca00b3cb6 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Thu, 20 Nov 2025 21:44:20 +0300 Subject: [PATCH 11/54] refactor(adaptive_throttler): rename resourceMonitorInterface to resourceMonitor --- flow/adaptive_throttler.go | 6 +++--- flow/adaptive_throttler_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 18cd0cf..f2403c7 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -78,8 +78,8 @@ func DefaultAdaptiveThrottlerConfig() AdaptiveThrottlerConfig { } } -// resourceMonitorInterface defines the interface for resource monitoring -type resourceMonitorInterface interface { +// resourceMonitor defines the interface for resource monitoring +type resourceMonitor interface { GetStats() ResourceStats IsResourceConstrained() bool Close() @@ -97,7 +97,7 @@ type resourceMonitorInterface interface { // - Enforces minimum and maximum throughput bounds type AdaptiveThrottler struct { config AdaptiveThrottlerConfig - monitor resourceMonitorInterface + monitor resourceMonitor // Current rate (elements per second) currentRate atomic.Int64 diff --git a/flow/adaptive_throttler_test.go b/flow/adaptive_throttler_test.go index b93b001..820a49a 100644 --- a/flow/adaptive_throttler_test.go +++ b/flow/adaptive_throttler_test.go @@ -37,7 +37,7 @@ func (m *mockResourceMonitor) Close() {} // newAdaptiveThrottlerWithMonitor creates an AdaptiveThrottler with a mock monitor for testing func newAdaptiveThrottlerWithMonitor( config AdaptiveThrottlerConfig, - monitor resourceMonitorInterface, + monitor resourceMonitor, customPeriod ...time.Duration, ) *AdaptiveThrottler { period := time.Second From 754d39ee8ef2e6144d19532bd114cf0591673024 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Thu, 20 Nov 2025 22:05:00 +0300 Subject: [PATCH 12/54] refactor(resource_monitor): move stats collection to initSampler --- flow/resource_monitor.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flow/resource_monitor.go b/flow/resource_monitor.go index 44ecd63..20dc4f1 100644 --- a/flow/resource_monitor.go +++ b/flow/resource_monitor.go @@ -88,8 +88,6 @@ func NewResourceMonitor( rm.initSampler() - rm.stats.Store(rm.collectStats()) - go rm.monitor() return rm @@ -109,6 +107,8 @@ func (rm *ResourceMonitor) initSampler() { default: // CPUUsageModeHeuristic rm.sampler = &goroutineHeuristicSampler{} } + + rm.stats.Store(rm.collectStats()) } // GetStats returns the current resource statistics From 135139f09e0a919ab34ffe36c756774cd5bb2dca Mon Sep 17 00:00:00 2001 From: kxrxh Date: Fri, 21 Nov 2025 19:56:14 +0300 Subject: [PATCH 13/54] refactor(adaptive_throttler): extract system monitoring into internal/sysmonitor package Refactor CPU and memory monitoring code from the flow package into a dedicated internal/sysmonitor package. This improves code organization, testability, and platform-specific implementation support. Key changes: - Move CPU sampling logic to internal/sysmonitor with platform-specific implementations for Darwin, Linux, Windows, and fallback - Move system memory monitoring to internal/sysmonitor with support for: - Linux: /proc/meminfo, cgroup v1, and cgroup v2 detection - Darwin: Mach kernel APIs with CGO - Windows: GlobalMemoryStatusEx API - Fallback: Error handling for unsupported platforms - Move heuristic CPU sampler from flow/cpu_sampler_heuristic.go to internal/sysmonitor/heuristic.go - Update adaptive_throttler and resource_monitor to use new package - Remove flow/cpu_sampler.go, flow/system_memory.go --- examples/adaptive_throttler/demo/demo.go | 11 +- examples/adaptive_throttler/main.go | 5 +- examples/go.mod | 4 +- examples/go.sum | 4 - flow/adaptive_throttler.go | 71 +-- flow/adaptive_throttler_test.go | 75 ++-- flow/cpu_sampler.go | 74 ---- flow/resource_monitor.go | 98 ++--- flow/resource_monitor_test.go | 255 +++-------- flow/system_memory.go | 219 ---------- flow/system_memory_test.go | 404 ------------------ flow/util.go | 21 + go.mod | 13 - go.sum | 34 -- internal/sysmonitor/cpu.go | 22 + internal/sysmonitor/cpu_darwin.go | 113 +++++ internal/sysmonitor/cpu_fallback.go | 29 ++ internal/sysmonitor/cpu_linux.go | 191 +++++++++ internal/sysmonitor/cpu_test.go | 90 ++++ internal/sysmonitor/cpu_windows.go | 124 ++++++ internal/sysmonitor/doc.go | 3 + internal/sysmonitor/fs.go | 23 + .../sysmonitor/heuristic.go | 20 +- internal/sysmonitor/memory.go | 10 + internal/sysmonitor/memory_darwin.go | 85 ++++ internal/sysmonitor/memory_fallback.go | 47 ++ internal/sysmonitor/memory_linux.go | 266 ++++++++++++ internal/sysmonitor/memory_linux_test.go | 213 +++++++++ internal/sysmonitor/memory_test.go | 66 +++ internal/sysmonitor/memory_windows.go | 70 +++ 30 files changed, 1578 insertions(+), 1082 deletions(-) delete mode 100644 flow/cpu_sampler.go delete mode 100644 flow/system_memory.go delete mode 100644 flow/system_memory_test.go create mode 100644 internal/sysmonitor/cpu.go create mode 100644 internal/sysmonitor/cpu_darwin.go create mode 100644 internal/sysmonitor/cpu_fallback.go create mode 100644 internal/sysmonitor/cpu_linux.go create mode 100644 internal/sysmonitor/cpu_test.go create mode 100644 internal/sysmonitor/cpu_windows.go create mode 100644 internal/sysmonitor/doc.go create mode 100644 internal/sysmonitor/fs.go rename flow/cpu_sampler_heuristic.go => internal/sysmonitor/heuristic.go (70%) create mode 100644 internal/sysmonitor/memory.go create mode 100644 internal/sysmonitor/memory_darwin.go create mode 100644 internal/sysmonitor/memory_fallback.go create mode 100644 internal/sysmonitor/memory_linux.go create mode 100644 internal/sysmonitor/memory_linux_test.go create mode 100644 internal/sysmonitor/memory_test.go create mode 100644 internal/sysmonitor/memory_windows.go diff --git a/examples/adaptive_throttler/demo/demo.go b/examples/adaptive_throttler/demo/demo.go index 2d06dd4..7916898 100644 --- a/examples/adaptive_throttler/demo/demo.go +++ b/examples/adaptive_throttler/demo/demo.go @@ -56,9 +56,8 @@ func setupDemoThrottler(elementsProcessed *atomic.Int64) *flow.AdaptiveThrottler config.BufferSize = 32 config.AdaptationFactor = 0.5 config.SmoothTransitions = true - config.MaxMemoryPercent = 40.0 - config.MaxCPUPercent = 80.0 - + config.MaxMemoryPercent = 40.0 + config.MaxCPUPercent = 80.0 config.MemoryReader = func() (float64, error) { elementCount := elementsProcessed.Load() @@ -86,7 +85,11 @@ func setupDemoThrottler(elementsProcessed *atomic.Int64) *flow.AdaptiveThrottler return memoryPercent, nil } - return flow.NewAdaptiveThrottler(config) + throttler, err := flow.NewAdaptiveThrottler(&config) + if err != nil { + panic(fmt.Sprintf("failed to create adaptive throttler: %v", err)) + } + return throttler } func produceBurst(in chan<- any, total int) { diff --git a/examples/adaptive_throttler/main.go b/examples/adaptive_throttler/main.go index 59ac53e..4943b67 100644 --- a/examples/adaptive_throttler/main.go +++ b/examples/adaptive_throttler/main.go @@ -17,7 +17,10 @@ func main() { // To setup a custom throttler, you can modify the throttlerConfig struct with your desired values. // For all available options, see the flow.AdaptiveThrottlerConfig struct. throttlerConfig := flow.DefaultAdaptiveThrottlerConfig() - throttler := flow.NewAdaptiveThrottler(throttlerConfig) + throttler, err := flow.NewAdaptiveThrottler(&throttlerConfig) + if err != nil { + panic(fmt.Sprintf("failed to create adaptive throttler: %v", err)) + } defer throttler.Close() in := make(chan any) diff --git a/examples/go.mod b/examples/go.mod index 18d3156..ac73c1f 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -73,7 +73,6 @@ require ( github.com/eapache/go-resiliency v1.7.0 // indirect github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 // indirect github.com/eapache/queue v1.1.0 // indirect - github.com/ebitengine/purego v0.9.1 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect github.com/fatih/color v1.13.0 // indirect @@ -127,12 +126,11 @@ require ( github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect - github.com/shirou/gopsutil/v4 v4.25.10 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect - github.com/tklauser/numcpus v0.11.0 // indirect github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect diff --git a/examples/go.sum b/examples/go.sum index 3daa531..638ae2c 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -152,8 +152,6 @@ github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3 h1:Oy0F4A github.com/eapache/go-xerial-snappy v0.0.0-20230731223053-c322873962e3/go.mod h1:YvSRo5mw33fLEx1+DlK6L2VV43tJt5Eyel9n9XBcR+0= github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M= github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A= @@ -368,8 +366,6 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= -github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA= -github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM= 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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index f2403c7..eac4397 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -20,8 +20,8 @@ type AdaptiveThrottlerConfig struct { MinThroughput int MaxThroughput int - // Resource monitoring - SampleInterval time.Duration // How often to sample resources + // How often to sample resources + SampleInterval time.Duration // Buffer configuration BufferSize int @@ -41,7 +41,7 @@ type AdaptiveThrottlerConfig struct { // CPUUsageModeHeuristic: Estimates CPU usage using a simple heuristic (goroutine count), suitable for platforms // where accurate process CPU measurement is not supported. // - // CPUUsageModeMeasured: Attempts to measure actual process CPU usage via gopsutil + // CPUUsageModeMeasured: Attempts to measure actual process CPU usage natively // (when supported), providing more accurate CPU usage readings. CPUUsageMode CPUUsageMode @@ -72,16 +72,19 @@ func DefaultAdaptiveThrottlerConfig() AdaptiveThrottlerConfig { BufferSize: 500, // Match max throughput for 1 second buffer at max rate AdaptationFactor: 0.15, // Slightly more conservative adaptation SmoothTransitions: true, // Keep smooth transitions enabled by default - CPUUsageMode: CPUUsageModeMeasured, // Use actual process CPU usage via gopsutil + CPUUsageMode: CPUUsageModeMeasured, // Use actual process CPU usage natively HysteresisBuffer: 5.0, // Prevent oscillations around threshold MaxRateChangeFactor: 0.3, // More conservative rate changes } } -// resourceMonitor defines the interface for resource monitoring +// ResourceMonitor defines the interface for resource monitoring type resourceMonitor interface { + // GetStats returns the current resource statistics GetStats() ResourceStats + // IsResourceConstrained returns true if resources are above thresholds IsResourceConstrained() bool + // Close closes the resource monitor Close() } @@ -120,49 +123,47 @@ type AdaptiveThrottler struct { stopOnce sync.Once } -// Validate checks that the configuration is valid and returns an error if not -func (c *AdaptiveThrottlerConfig) Validate() error { - if c.MaxMemoryPercent <= 0 || c.MaxMemoryPercent > 100 { - return fmt.Errorf("invalid MaxMemoryPercent: %f", c.MaxMemoryPercent) +var _ streams.Flow = (*AdaptiveThrottler)(nil) + +// NewAdaptiveThrottler creates a new adaptive throttler +// If config is nil, default configuration will be used. +func NewAdaptiveThrottler(config *AdaptiveThrottlerConfig) (*AdaptiveThrottler, error) { + if config == nil { + defaultConfig := DefaultAdaptiveThrottlerConfig() + config = &defaultConfig } - if c.MinThroughput < 1 || c.MaxThroughput < c.MinThroughput { - return fmt.Errorf("invalid throughput bounds: min=%d, max=%d", c.MinThroughput, c.MaxThroughput) + + // Validate configuration + if config.MaxMemoryPercent <= 0 || config.MaxMemoryPercent > 100 { + return nil, fmt.Errorf("invalid MaxMemoryPercent: %f", config.MaxMemoryPercent) } - if c.AdaptationFactor <= 0 || c.AdaptationFactor >= 1 { - return fmt.Errorf("invalid AdaptationFactor: %f, must be in (0, 1)", c.AdaptationFactor) + if config.MinThroughput < 1 || config.MaxThroughput < config.MinThroughput { + return nil, fmt.Errorf("invalid throughput bounds: min=%d, max=%d", config.MinThroughput, config.MaxThroughput) } - if c.MaxCPUPercent <= 0 || c.MaxCPUPercent > 100 { - return fmt.Errorf("invalid MaxCPUPercent: %f", c.MaxCPUPercent) + if config.AdaptationFactor <= 0 || config.AdaptationFactor >= 1 { + return nil, fmt.Errorf("invalid AdaptationFactor: %f, must be in (0, 1)", config.AdaptationFactor) } - if c.BufferSize < 1 { - return fmt.Errorf("invalid BufferSize: %d", c.BufferSize) + if config.MaxCPUPercent <= 0 || config.MaxCPUPercent > 100 { + return nil, fmt.Errorf("invalid MaxCPUPercent: %f", config.MaxCPUPercent) } - if c.SampleInterval <= 0 { - return fmt.Errorf("invalid SampleInterval: %v", c.SampleInterval) + if config.BufferSize < 1 { + return nil, fmt.Errorf("invalid BufferSize: %d", config.BufferSize) } - if c.HysteresisBuffer < 0 { - return fmt.Errorf("invalid HysteresisBuffer: %f", c.HysteresisBuffer) + if config.SampleInterval <= 0 { + return nil, fmt.Errorf("invalid SampleInterval: %v", config.SampleInterval) } - if c.MaxRateChangeFactor <= 0 || c.MaxRateChangeFactor > 1 { - return fmt.Errorf("invalid MaxRateChangeFactor: %f, must be in (0, 1]", c.MaxRateChangeFactor) + if config.HysteresisBuffer < 0 { + return nil, fmt.Errorf("invalid HysteresisBuffer: %f", config.HysteresisBuffer) } - return nil -} - -// Verify AdaptiveThrottler satisfies the Flow interface -var _ streams.Flow = (*AdaptiveThrottler)(nil) - -// NewAdaptiveThrottler creates a new adaptive throttler -func NewAdaptiveThrottler(config AdaptiveThrottlerConfig) *AdaptiveThrottler { - if err := config.Validate(); err != nil { - panic(fmt.Sprintf("invalid config: %v", err)) + if config.MaxRateChangeFactor <= 0 || config.MaxRateChangeFactor > 1 { + return nil, fmt.Errorf("invalid MaxRateChangeFactor: %f, must be in (0, 1]", config.MaxRateChangeFactor) } // Initialize with max throughput initialRate := int64(config.MaxThroughput) at := &AdaptiveThrottler{ - config: config, + config: *config, monitor: NewResourceMonitor( config.SampleInterval, config.MaxMemoryPercent, @@ -190,7 +191,7 @@ func NewAdaptiveThrottler(config AdaptiveThrottlerConfig) *AdaptiveThrottler { // Start buffering goroutine go at.buffer() - return at + return at, nil } // adaptRateLoop periodically adapts the throughput rate based on resource availability diff --git a/flow/adaptive_throttler_test.go b/flow/adaptive_throttler_test.go index 820a49a..4621b0a 100644 --- a/flow/adaptive_throttler_test.go +++ b/flow/adaptive_throttler_test.go @@ -1,7 +1,6 @@ package flow import ( - "fmt" "strings" "sync" "testing" @@ -71,8 +70,8 @@ func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { tests := []struct { name string config AdaptiveThrottlerConfig - shouldPanic bool - expectedPanic string // Expected substring in panic message + shouldError bool + expectedError string // Expected substring in error message }{ { name: "valid config", @@ -87,8 +86,8 @@ func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { HysteresisBuffer: 5.0, MaxRateChangeFactor: 0.5, }, - shouldPanic: false, - expectedPanic: "", + shouldError: false, + expectedError: "", }, { name: "invalid MaxMemoryPercent", @@ -103,8 +102,8 @@ func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { HysteresisBuffer: 5.0, MaxRateChangeFactor: 0.5, }, - shouldPanic: true, - expectedPanic: "invalid MaxMemoryPercent", + shouldError: true, + expectedError: "invalid MaxMemoryPercent", }, { name: "invalid MaxThroughput < MinThroughput", @@ -119,29 +118,27 @@ func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { HysteresisBuffer: 5.0, MaxRateChangeFactor: 0.5, }, - shouldPanic: true, - expectedPanic: "invalid throughput bounds", + shouldError: true, + expectedError: "invalid throughput bounds", }, } for _, tt := range tests { + tt := tt // capture loop variable t.Run(tt.name, func(t *testing.T) { - defer func() { - if r := recover(); r != nil { - if !tt.shouldPanic { - t.Errorf("unexpected panic: %v", r) - return - } - // Verify panic message contains expected text - panicMsg := fmt.Sprintf("%v", r) - if tt.expectedPanic != "" && !strings.Contains(panicMsg, tt.expectedPanic) { - t.Errorf("panic message should contain %q, got: %v", tt.expectedPanic, r) - } - } else if tt.shouldPanic { - t.Error("expected panic but didn't get one") + _, err := NewAdaptiveThrottler(&tt.config) + if tt.shouldError { + if err == nil { + t.Error("expected error but didn't get one") + return } - }() - NewAdaptiveThrottler(tt.config) + // Verify error message contains expected text + if tt.expectedError != "" && !strings.Contains(err.Error(), tt.expectedError) { + t.Errorf("error message should contain %q, got: %v", tt.expectedError, err) + } + } else if err != nil { + t.Errorf("unexpected error: %v", err) + } }) } } @@ -603,7 +600,10 @@ func TestAdaptiveThrottler_CloseClosesOutputEvenIfInputOpen(t *testing.T) { func TestAdaptiveThrottler_CloseStopsBackgroundLoops(t *testing.T) { config := DefaultAdaptiveThrottlerConfig() - at := NewAdaptiveThrottler(config) + at, err := NewAdaptiveThrottler(&config) + if err != nil { + t.Fatalf("failed to create adaptive throttler: %v", err) + } at.Close() @@ -621,7 +621,10 @@ func TestAdaptiveThrottler_CPUUsageMode(t *testing.T) { config := DefaultAdaptiveThrottlerConfig() config.CPUUsageMode = CPUUsageModeMeasured - at := NewAdaptiveThrottler(config) + at, err := NewAdaptiveThrottler(&config) + if err != nil { + t.Fatalf("failed to create adaptive throttler: %v", err) + } defer func() { at.Close() time.Sleep(10 * time.Millisecond) @@ -642,7 +645,10 @@ func TestAdaptiveThrottler_CPUUsageMode(t *testing.T) { func TestAdaptiveThrottler_GetCurrentRate(t *testing.T) { config := DefaultAdaptiveThrottlerConfig() - at := NewAdaptiveThrottler(config) + at, err := NewAdaptiveThrottler(&config) + if err != nil { + t.Fatalf("failed to create adaptive throttler: %v", err) + } defer func() { at.Close() time.Sleep(10 * time.Millisecond) @@ -659,7 +665,10 @@ func TestAdaptiveThrottler_GetCurrentRate(t *testing.T) { func TestAdaptiveThrottler_GetResourceStats(t *testing.T) { config := DefaultAdaptiveThrottlerConfig() - at := NewAdaptiveThrottler(config) + at, err := NewAdaptiveThrottler(&config) + if err != nil { + t.Fatalf("failed to create adaptive throttler: %v", err) + } defer func() { at.Close() time.Sleep(10 * time.Millisecond) @@ -739,7 +748,10 @@ func TestAdaptiveThrottler_BufferBackpressure(t *testing.T) { func BenchmarkAdaptiveThrottler_GetResourceStats(b *testing.B) { config := DefaultAdaptiveThrottlerConfig() - at := NewAdaptiveThrottler(config) + at, err := NewAdaptiveThrottler(&config) + if err != nil { + b.Fatalf("failed to create adaptive throttler: %v", err) + } defer func() { at.Close() time.Sleep(10 * time.Millisecond) @@ -936,7 +948,10 @@ func TestAdaptiveThrottler_CustomMemoryReader(t *testing.T) { return customMemoryPercent, nil } - at := NewAdaptiveThrottler(config) + at, err := NewAdaptiveThrottler(&config) + if err != nil { + t.Fatalf("failed to create adaptive throttler: %v", err) + } defer at.Close() // Allow some time for stats collection diff --git a/flow/cpu_sampler.go b/flow/cpu_sampler.go deleted file mode 100644 index 8d753c2..0000000 --- a/flow/cpu_sampler.go +++ /dev/null @@ -1,74 +0,0 @@ -package flow - -import ( - "fmt" - "math" - "os" - "runtime" - "time" - - "github.com/shirou/gopsutil/v4/process" -) - -type gopsutilProcessSampler struct { - proc *process.Process - lastPercent float64 - lastSample time.Time -} - -func newGopsutilProcessSampler() (*gopsutilProcessSampler, error) { - pid := os.Getpid() - if pid < 0 || pid > math.MaxInt32 { - return nil, fmt.Errorf("invalid PID: %d", pid) - } - proc, err := process.NewProcess(int32(pid)) - if err != nil { - return nil, err - } - - return &gopsutilProcessSampler{ - proc: proc, - }, nil -} - -func (s *gopsutilProcessSampler) Sample(deltaTime time.Duration) float64 { - percent, err := s.proc.CPUPercent() - if err != nil { - return (&goroutineHeuristicSampler{}).Sample(deltaTime) - } - - now := time.Now() - if s.lastSample.IsZero() { - s.lastSample = now - s.lastPercent = 0.0 - return 0.0 - } - - elapsed := now.Sub(s.lastSample) - if elapsed < deltaTime/2 { - return s.lastPercent - } - - // Normalize CPU usage by the number of cores - percent /= float64(runtime.NumCPU()) - - if percent > 100.0 { - percent = 100.0 - } else if percent < 0.0 { - percent = 0.0 - } - - s.lastPercent = percent - s.lastSample = now - - return percent -} - -func (s *gopsutilProcessSampler) Reset() { - s.lastPercent = 0 - s.lastSample = time.Time{} -} - -func (s *gopsutilProcessSampler) IsInitialized() bool { - return !s.lastSample.IsZero() -} diff --git a/flow/resource_monitor.go b/flow/resource_monitor.go index 20dc4f1..052ee8d 100644 --- a/flow/resource_monitor.go +++ b/flow/resource_monitor.go @@ -2,11 +2,12 @@ package flow import ( "fmt" - "math" "runtime" "sync" "sync/atomic" "time" + + "github.com/reugn/go-streams/internal/sysmonitor" ) // CPUUsageMode defines the strategy for sampling CPU usage @@ -27,16 +28,6 @@ type ResourceStats struct { Timestamp time.Time } -// cpuUsageSampler defines the interface for CPU sampling strategies -type cpuUsageSampler interface { - // Sample returns CPU usage percentage for the given time delta - Sample(deltaTime time.Duration) float64 - // Reset prepares the sampler for a new sampling session - Reset() - // IsInitialized returns true if the sampler has been initialized with at least one sample - IsInitialized() bool -} - // ResourceMonitor monitors system resources and provides current statistics type ResourceMonitor struct { sampleInterval time.Duration @@ -48,7 +39,7 @@ type ResourceMonitor struct { stats atomic.Value // *ResourceStats // CPU sampling - sampler cpuUsageSampler + sampler sysmonitor.ProcessCPUSampler // Reusable buffer for memory stats memStats runtime.MemStats @@ -60,7 +51,15 @@ type ResourceMonitor struct { done chan struct{} } -// NewResourceMonitor creates a new resource monitor +// NewResourceMonitor creates a new resource monitor. +// +// Panics if: +// +// - sampleInterval <= 0 +// +// - memoryThreshold < 0 or memoryThreshold > 100 +// +// - cpuThreshold < 0 or cpuThreshold > 100 func NewResourceMonitor( sampleInterval time.Duration, memoryThreshold, cpuThreshold float64, @@ -68,12 +67,15 @@ func NewResourceMonitor( memoryReader func() (float64, error), ) *ResourceMonitor { if sampleInterval <= 0 { + // sampleInterval must be greater than 0 panic(fmt.Sprintf("invalid sampleInterval: %v", sampleInterval)) } if memoryThreshold < 0 || memoryThreshold > 100 { + // memoryThreshold must be between 0 and 100 panic(fmt.Sprintf("invalid memoryThreshold: %f, must be between 0 and 100", memoryThreshold)) } if cpuThreshold < 0 || cpuThreshold > 100 { + // cpuThreshold must be between 0 and 100 panic(fmt.Sprintf("invalid cpuThreshold: %f, must be between 0 and 100", cpuThreshold)) } @@ -97,15 +99,15 @@ func NewResourceMonitor( func (rm *ResourceMonitor) initSampler() { switch rm.cpuMode { case CPUUsageModeMeasured: - // Try gopsutil first, fallback to heuristic - if sampler, err := newGopsutilProcessSampler(); err == nil { + // Try native sampler first, fallback to heuristic + if sampler, err := sysmonitor.NewProcessSampler(); err == nil { rm.sampler = sampler } else { - rm.sampler = &goroutineHeuristicSampler{} + rm.sampler = sysmonitor.NewGoroutineHeuristicSampler() rm.cpuMode = CPUUsageModeHeuristic } default: // CPUUsageModeHeuristic - rm.sampler = &goroutineHeuristicSampler{} + rm.sampler = sysmonitor.NewGoroutineHeuristicSampler() } rm.stats.Store(rm.collectStats()) @@ -146,11 +148,7 @@ func (rm *ResourceMonitor) collectStats() *ResourceStats { // Sample CPU usage and validate cpuPercent := rm.sampler.Sample(rm.sampleInterval) - if math.IsNaN(cpuPercent) || math.IsInf(cpuPercent, 0) || cpuPercent < 0 { - cpuPercent = 0 - } else if cpuPercent > 100 { - cpuPercent = 100 - } + cpuPercent = validatePercent(cpuPercent) stats := &ResourceStats{ MemoryUsedPercent: memoryPercent, @@ -165,29 +163,23 @@ func (rm *ResourceMonitor) collectStats() *ResourceStats { return stats } -func (rm *ResourceMonitor) tryGetSystemMemory() (systemMemory, bool) { - stats, err := getSystemMemory() +func (rm *ResourceMonitor) tryGetSystemMemory() (sysmonitor.SystemMemory, bool) { + stats, err := sysmonitor.GetSystemMemory() if err != nil || stats.Total == 0 { - return systemMemory{}, false + return sysmonitor.SystemMemory{}, false } return stats, true } func (rm *ResourceMonitor) memoryUsagePercent( hasSystemStats bool, - sysStats systemMemory, + sysStats sysmonitor.SystemMemory, procStats *runtime.MemStats, ) float64 { // Use custom memory reader if provided (for containerized deployments) if rm.memoryReader != nil { if percent, err := rm.memoryReader(); err == nil { - if percent < 0 { - return 0 - } - if percent > 100 { - return 100 - } - return percent + return clampPercent(percent) } // Fall back to system memory if custom reader fails } @@ -206,13 +198,7 @@ func (rm *ResourceMonitor) memoryUsagePercent( used := sysStats.Total - available percent := float64(used) / float64(sysStats.Total) * 100 - if percent < 0 { - return 0 - } - if percent > 100 { - return 100 - } - return percent + return clampPercent(percent) } if procStats == nil || procStats.Sys == 0 { @@ -220,18 +206,7 @@ func (rm *ResourceMonitor) memoryUsagePercent( } percent := float64(procStats.Alloc) / float64(procStats.Sys) * 100 - if percent < 0 { - return 0 - } - if percent > 100 { - return 100 - } - return percent -} - -type systemMemory struct { - Total uint64 - Available uint64 + return clampPercent(percent) } // validateResourceStats sanitizes ResourceStats to ensure valid values @@ -241,24 +216,10 @@ func validateResourceStats(stats *ResourceStats) { } // Validate memory percent - switch { - case math.IsNaN(stats.MemoryUsedPercent) || math.IsInf(stats.MemoryUsedPercent, 0): - stats.MemoryUsedPercent = 0 - case stats.MemoryUsedPercent < 0: - stats.MemoryUsedPercent = 0 - case stats.MemoryUsedPercent > 100: - stats.MemoryUsedPercent = 100 - } + stats.MemoryUsedPercent = validatePercent(stats.MemoryUsedPercent) // Validate CPU percent - switch { - case math.IsNaN(stats.CPUUsagePercent) || math.IsInf(stats.CPUUsagePercent, 0): - stats.CPUUsagePercent = 0 - case stats.CPUUsagePercent < 0: - stats.CPUUsagePercent = 0 - case stats.CPUUsagePercent > 100: - stats.CPUUsagePercent = 100 - } + stats.CPUUsagePercent = validatePercent(stats.CPUUsagePercent) // Validate goroutine count if stats.GoroutineCount < 0 { @@ -297,7 +258,6 @@ func (rm *ResourceMonitor) Close() { select { case <-rm.done: - // Already closed return default: close(rm.done) diff --git a/flow/resource_monitor_test.go b/flow/resource_monitor_test.go index 95aa5a1..ec60ff2 100644 --- a/flow/resource_monitor_test.go +++ b/flow/resource_monitor_test.go @@ -5,6 +5,8 @@ import ( "runtime" "testing" "time" + + "github.com/reugn/go-streams/internal/sysmonitor" ) func TestNewResourceMonitor_InvalidSampleInterval(t *testing.T) { @@ -21,24 +23,23 @@ func TestResourceMonitor_GetStats(t *testing.T) { rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) defer rm.Close() - // Wait for initial stats to be collected (poll instead of fixed sleep) timeout := time.After(500 * time.Millisecond) - for { + var stats ResourceStats + statsCollected := false + for !statsCollected { select { case <-timeout: t.Fatal("timeout waiting for stats collection") default: - stats := rm.GetStats() + stats = rm.GetStats() if !stats.Timestamp.IsZero() { - // Stats have been collected, continue with validation - goto statsCollected + statsCollected = true + break } time.Sleep(10 * time.Millisecond) // Brief pause before polling again } } -statsCollected: - stats := rm.GetStats() if stats.MemoryUsedPercent < 0.0 || stats.MemoryUsedPercent > 100.0 { t.Errorf("memory percent should be between 0 and 100, got %v", stats.MemoryUsedPercent) } @@ -122,62 +123,24 @@ func TestResourceMonitor_IsResourceConstrained(t *testing.T) { } } -func TestGoroutineHeuristicSampler(t *testing.T) { - sampler := &goroutineHeuristicSampler{} - - // Test with reasonable goroutine count - _ = runtime.NumGoroutine() - - percent := sampler.Sample(100 * time.Millisecond) - if percent < 0.0 || percent > 100.0 { - t.Errorf("CPU percent should be between 0 and 100, got %v", percent) - } - - // Test reset (should be no-op) - sampler.Reset() -} - -// TestGopsutilProcessSampler is an integration test that verifies -// gopsutil-based CPU sampling (may be skipped if gopsutil unavailable) -func TestGopsutilProcessSampler(t *testing.T) { - sampler, err := newGopsutilProcessSampler() - if err != nil { - t.Fatalf("newGopsutilProcessSampler failed: %v", err) - } - if sampler == nil { - t.Fatal("gopsutilProcessSampler should not be nil") - } - - // First sample should return 0 and initialize - percent := sampler.Sample(100 * time.Millisecond) - if percent != 0.0 { - t.Errorf("first sample should return 0.0, got %v", percent) - } - - // Subsequent samples should be valid - time.Sleep(10 * time.Millisecond) - percent = sampler.Sample(10 * time.Millisecond) - if percent < 0.0 || percent > 100.0 { - t.Errorf("CPU percent should be between 0 and 100, got %v", percent) - } - - // Test reset - sampler.Reset() - if sampler.IsInitialized() { - t.Error("sampler should not be initialized after reset") - } -} - func TestResourceMonitor_CPUUsageModeHeuristic(t *testing.T) { rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) defer rm.Close() + // Verify mode is set correctly if rm.cpuMode != CPUUsageModeHeuristic { t.Errorf("expected cpuMode %v, got %v", CPUUsageModeHeuristic, rm.cpuMode) } - _, ok := rm.sampler.(*goroutineHeuristicSampler) - if !ok { - t.Error("expected goroutineHeuristicSampler") + + // Verify sampler is initialized and functional + if rm.sampler == nil { + t.Fatal("sampler should not be nil") + } + + // Test that sampling produces reasonable values + percent := rm.sampler.Sample(100 * time.Millisecond) + if percent < 0.0 { + t.Errorf("CPU percent should not be negative, got %v", percent) } } @@ -185,26 +148,26 @@ func TestResourceMonitor_CPUUsageModeMeasured(t *testing.T) { rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeMeasured, nil) defer rm.Close() - // Should use gopsutil sampler or fallback to heuristic - if _, ok := rm.sampler.(*gopsutilProcessSampler); ok { - if rm.cpuMode != CPUUsageModeMeasured { - t.Errorf("expected cpuMode %v, got %v", CPUUsageModeMeasured, rm.cpuMode) - } - } else { - if rm.cpuMode != CPUUsageModeHeuristic { - t.Errorf("expected fallback cpuMode %v, got %v", CPUUsageModeHeuristic, rm.cpuMode) - } - _, ok := rm.sampler.(*goroutineHeuristicSampler) - if !ok { - t.Error("expected fallback to goroutineHeuristicSampler") - } + // Should use measured mode + if rm.cpuMode != CPUUsageModeMeasured && rm.cpuMode != CPUUsageModeHeuristic { + t.Errorf("expected cpuMode Measured or Heuristic (fallback), got %v", rm.cpuMode) + } + + // Verify sampler is initialized and functional + if rm.sampler == nil { + t.Fatal("sampler should not be nil") + } + + // Test that sampling produces reasonable values (normalized to 0-100%) + percent := rm.sampler.Sample(100 * time.Millisecond) + if percent < 0.0 || percent > 100.0 { + t.Errorf("CPU percent should be between 0 and 100, got %v", percent) } } func TestResourceMonitor_Close(t *testing.T) { rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - // Close should be idempotent rm.Close() rm.Close() @@ -223,44 +186,47 @@ func TestResourceMonitor_MonitorLoop(t *testing.T) { rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) defer rm.Close() - // Wait for initial stats collection - timeout := time.After(500 * time.Millisecond) + initialStats := waitForStats(t, rm, 500*time.Millisecond, func(stats ResourceStats) bool { + return !stats.Timestamp.IsZero() + }) + + waitForStats(t, rm, 500*time.Millisecond, func(stats ResourceStats) bool { + return stats.Timestamp.After(initialStats.Timestamp) + }) +} + +// waitForStats polls GetStats until the condition function returns true or timeout is reached +func waitForStats( + t *testing.T, + rm *ResourceMonitor, + timeout time.Duration, + condition func(ResourceStats) bool, +) ResourceStats { + t.Helper() + + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { - select { - case <-timeout: - t.Fatal("timeout waiting for initial stats collection") - default: - if !rm.GetStats().Timestamp.IsZero() { - goto initialStatsCollected - } - time.Sleep(10 * time.Millisecond) + stats := rm.GetStats() + if condition(stats) { + return stats } - } -initialStatsCollected: - // Wait for at least one more update - stats1 := rm.GetStats() - timeout = time.After(500 * time.Millisecond) - for { - select { - case <-timeout: - t.Fatal("timeout waiting for stats update") - default: - stats2 := rm.GetStats() - if stats2.Timestamp.After(stats1.Timestamp) { - // Stats have been updated - return - } - time.Sleep(10 * time.Millisecond) + if time.Now().After(deadline) { + t.Fatalf("timeout waiting for stats condition (timeout: %v)", timeout) } + + <-ticker.C } } func TestResourceMonitor_UsesSystemMemoryStats(t *testing.T) { t.Helper() - restore := setSystemMemoryReader(func() (systemMemory, error) { - return systemMemory{ + restore := sysmonitor.SetMemoryReader(func() (sysmonitor.SystemMemory, error) { + return sysmonitor.SystemMemory{ Total: 100 * 1024 * 1024, Available: 25 * 1024 * 1024, }, nil @@ -282,28 +248,26 @@ func TestResourceStats_MemoryCalculation(t *testing.T) { rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) defer rm.Close() - // Wait for initial stats timeout := time.After(500 * time.Millisecond) - for { + statsReady := false + for !statsReady { select { case <-timeout: t.Fatal("timeout waiting for stats") default: if !rm.GetStats().Timestamp.IsZero() { - goto statsReady + statsReady = true + break } time.Sleep(10 * time.Millisecond) } } -statsReady: - // Force garbage collection to get more stable memory stats runtime.GC() - time.Sleep(20 * time.Millisecond) // Reduced sleep time + time.Sleep(20 * time.Millisecond) stats := rm.GetStats() - // Memory percentage should be reasonable if stats.MemoryUsedPercent < 0.0 || stats.MemoryUsedPercent > 100.0 { t.Errorf("memory percent should be between 0 and 100, got %v", stats.MemoryUsedPercent) } @@ -333,84 +297,3 @@ func BenchmarkResourceMonitor_IsResourceConstrained(b *testing.B) { _ = rm.IsResourceConstrained() } } - -// fakeSampler tracks Sample calls to test regression for double sampling bug -type fakeSampler struct { - sampleCount int - lastDelta time.Duration - isInitialized bool -} - -func (f *fakeSampler) Sample(deltaTime time.Duration) float64 { - f.sampleCount++ - f.lastDelta = deltaTime - return 50.0 // dummy value -} - -func (f *fakeSampler) Reset() { - f.sampleCount = 0 - f.lastDelta = 0 - f.isInitialized = false -} - -func (f *fakeSampler) IsInitialized() bool { - return f.isInitialized -} - -// TestResourceMonitor_SingleSamplePerTick verifies the fix for the double sampling bug -// where collectStats was calling Sample twice per tick, overwriting the first measurement -func TestResourceMonitor_SingleSamplePerTick(t *testing.T) { - fakeSampler := &fakeSampler{isInitialized: true} - - // Create a monitor with our fake sampler - rm := &ResourceMonitor{ - sampleInterval: 100 * time.Millisecond, - memoryThreshold: 80.0, - cpuThreshold: 70.0, - cpuMode: CPUUsageModeHeuristic, - sampler: fakeSampler, - done: make(chan struct{}), - } - - // Call collectStats once - rm.collectStats() - - // Verify Sample was called exactly once - if fakeSampler.sampleCount != 1 { - t.Errorf("expected Sample to be called once per collectStats, got %d calls", fakeSampler.sampleCount) - } - - // Verify the delta passed is the configured interval - if fakeSampler.lastDelta != rm.sampleInterval { - t.Errorf("expected delta %v, got %v", rm.sampleInterval, fakeSampler.lastDelta) - } -} - -// TestGopsutilProcessSampler_Initialization verifies that gopsutilProcessSampler -// properly tracks initialization state -func TestGopsutilProcessSampler_Initialization(t *testing.T) { - sampler, err := newGopsutilProcessSampler() - if err != nil { - t.Fatalf("newGopsutilProcessSampler failed: %v", err) - } - if sampler == nil { - t.Fatal("gopsutilProcessSampler should not be nil") - } - - // Initially not initialized - if sampler.IsInitialized() { - t.Error("gopsutilProcessSampler should not be initialized initially") - } - - // After first sample, should be initialized - sampler.Sample(100 * time.Millisecond) - if !sampler.IsInitialized() { - t.Error("gopsutilProcessSampler should be initialized after first sample") - } - - // After reset, should not be initialized - sampler.Reset() - if sampler.IsInitialized() { - t.Error("gopsutilProcessSampler should not be initialized after reset") - } -} diff --git a/flow/system_memory.go b/flow/system_memory.go deleted file mode 100644 index 20c351d..0000000 --- a/flow/system_memory.go +++ /dev/null @@ -1,219 +0,0 @@ -package flow - -import ( - "bufio" - "bytes" - "errors" - "io/fs" - "os" - "strconv" - "strings" - "sync/atomic" - - "github.com/shirou/gopsutil/v4/mem" -) - -// FileSystem abstracts file system operations for testing -type FileSystem interface { - ReadFile(name string) ([]byte, error) - Open(name string) (fs.File, error) -} - -// osFileSystem implements FileSystem using the os package -type osFileSystem struct{} - -func (osFileSystem) ReadFile(name string) ([]byte, error) { - return os.ReadFile(name) -} - -func (osFileSystem) Open(name string) (fs.File, error) { - return os.Open(name) -} - -type systemMemoryReader func() (systemMemory, error) - -var systemMemoryProvider atomic.Value -var fileSystemProvider atomic.Value - -func init() { - systemMemoryProvider.Store(systemMemoryReader(readSystemMemoryAuto)) - fileSystemProvider.Store(FileSystem(osFileSystem{})) -} - -// readSystemMemoryAuto detects the environment once and "upgrades" the reader -func readSystemMemoryAuto() (systemMemory, error) { - if m, err := readCgroupV2Memory(); err == nil { - systemMemoryProvider.Store(systemMemoryReader(readCgroupV2Memory)) - return m, nil - } - - if m, err := readCgroupV1Memory(); err == nil { - systemMemoryProvider.Store(systemMemoryReader(readCgroupV1Memory)) - return m, nil - } - - // Fallback to Host (Bare metal / VM / Unlimited Container) - systemMemoryProvider.Store(systemMemoryReader(readSystemMemory)) - return readSystemMemory() -} - -func readCgroupV2Memory() (systemMemory, error) { - return readCgroupV2MemoryWithFS(loadFileSystem()) -} - -func readCgroupV2MemoryWithFS(fs FileSystem) (systemMemory, error) { - usage, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory.current") - if err != nil { - return systemMemory{}, err - } - - limit, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory.max") - if err != nil { - return systemMemory{}, err - } - - // Parse memory.stat to find reclaimable memory (inactive_file) - inactiveFile, _ := readCgroupStatWithFS(fs, "/sys/fs/cgroup/memory.stat", "inactive_file") - - // Available = (Limit - Usage) + Reclaimable - // Note: If Limit - Usage is near zero, the kernel would reclaim inactive_file - // Handle case where usage exceeds limit - var available uint64 - if usage > limit { - available = inactiveFile // Only reclaimable memory is available - } else { - available = (limit - usage) + inactiveFile - } - - if available > limit { - available = limit - } - - return systemMemory{ - Total: limit, - Available: available, - }, nil -} - -func readCgroupV1Memory() (systemMemory, error) { - return readCgroupV1MemoryWithFS(loadFileSystem()) -} - -func readCgroupV1MemoryWithFS(fs FileSystem) (systemMemory, error) { - usage, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory/memory.usage_in_bytes") - if err != nil { - return systemMemory{}, err - } - - limit, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory/memory.limit_in_bytes") - if err != nil { - return systemMemory{}, err - } - - // Check for "unlimited" (random huge number in V1) - if limit > (1 << 60) { - return systemMemory{}, os.ErrNotExist - } - - // Parse memory.stat for V1 - inactiveFile, _ := readCgroupStatWithFS(fs, "/sys/fs/cgroup/memory/memory.stat", "total_inactive_file") - - // Handle case where usage exceeds limit - var available uint64 - if usage > limit { - available = inactiveFile // Only reclaimable memory is available - } else { - available = (limit - usage) + inactiveFile - } - - if available > limit { - available = limit - } - - return systemMemory{ - Total: limit, - Available: available, - }, nil -} - -func readCgroupValueWithFS(fs FileSystem, path string) (uint64, error) { - data, err := fs.ReadFile(path) - if err != nil { - return 0, err - } - str := strings.TrimSpace(string(data)) - if str == "max" { - return 0, os.ErrNotExist - } - return strconv.ParseUint(str, 10, 64) -} - -func readCgroupStatWithFS(fs FileSystem, path string, key string) (uint64, error) { - f, err := fs.Open(path) - if err != nil { - return 0, err - } - defer f.Close() - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := scanner.Bytes() - if bytes.HasPrefix(line, []byte(key)) { - fields := bytes.Fields(line) - if len(fields) >= 2 { - return strconv.ParseUint(string(fields[1]), 10, 64) - } - } - } - return 0, errors.New("key not found") -} - -func readSystemMemory() (systemMemory, error) { - v, err := mem.VirtualMemory() - if err != nil { - return systemMemory{}, err - } - return systemMemory{ - Total: v.Total, - Available: v.Available, - }, nil -} - -func getSystemMemory() (systemMemory, error) { - return loadSystemMemoryReader()() -} - -// GetSystemMemory returns the current system memory statistics. -// This function auto-detects the environment (cgroup v2, v1, or host) -// and returns appropriate memory information. -func GetSystemMemory() (SystemMemory, error) { - mem, err := getSystemMemory() - if err != nil { - return SystemMemory{}, err - } - return SystemMemory(mem), nil -} - -// SystemMemory represents system memory information -type SystemMemory struct { - Total uint64 - Available uint64 -} - -func loadSystemMemoryReader() systemMemoryReader { - return systemMemoryProvider.Load().(systemMemoryReader) -} - -// setSystemMemoryReader replaces the current system memory reader and returns a restore function. -// Created for testing/mocking purposes. -func setSystemMemoryReader(reader systemMemoryReader) func() { - prev := loadSystemMemoryReader() - systemMemoryProvider.Store(reader) - return func() { - systemMemoryProvider.Store(prev) - } -} - -func loadFileSystem() FileSystem { - return fileSystemProvider.Load().(FileSystem) -} diff --git a/flow/system_memory_test.go b/flow/system_memory_test.go deleted file mode 100644 index 435e64b..0000000 --- a/flow/system_memory_test.go +++ /dev/null @@ -1,404 +0,0 @@ -package flow - -import ( - "errors" - "io/fs" - "strings" - "testing" -) - -func TestReadSystemMemory(t *testing.T) { - mem, err := readSystemMemory() - if err != nil { - t.Fatalf("readSystemMemory failed: %v", err) - } - - if mem.Total == 0 { - t.Error("Total memory should not be zero") - } - - if mem.Available > mem.Total { - t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) - } - - // Available should be less than or equal to total - if mem.Available > mem.Total { - t.Errorf("Available memory (%d) > total memory (%d)", mem.Available, mem.Total) - } -} - -func TestReadSystemMemoryAuto(t *testing.T) { - // Save original state to prevent global state pollution - original := loadSystemMemoryReader() - defer systemMemoryProvider.Store(original) - - mem1, err1 := readSystemMemoryAuto() - if err1 != nil { - t.Fatalf("First readSystemMemoryAuto failed: %v", err1) - } - - // After the first call, the global reader should have been upgraded - // Call the upgraded reader directly to ensure consistency - upgradedReader := loadSystemMemoryReader() - mem2, err2 := upgradedReader() - if err2 != nil { - t.Fatalf("Upgraded reader failed: %v", err2) - } - - // Results should be consistent (within reasonable bounds due to memory fluctuations) - if mem1.Total != mem2.Total { - t.Errorf("Total memory inconsistent: first=%d, second=%d", - mem1.Total, mem2.Total) - } - - // Allow for small variations in available memory (due to system activity) - const tolerance uint64 = 1024 * 1024 // 1MB tolerance - if mem1.Available > mem2.Available { - if mem1.Available-mem2.Available > tolerance { - t.Errorf("Available memory varies too much: first=%d, second=%d (tolerance: %d)", - mem1.Available, mem2.Available, tolerance) - } - } else { - if mem2.Available-mem1.Available > tolerance { - t.Errorf("Available memory varies too much: first=%d, second=%d (tolerance: %d)", - mem1.Available, mem2.Available, tolerance) - } - } -} - -func TestGetSystemMemory(t *testing.T) { - mem, err := getSystemMemory() - if err != nil { - t.Fatalf("getSystemMemory failed: %v", err) - } - - if mem.Total == 0 { - t.Error("Total memory should not be zero") - } - - if mem.Available > mem.Total { - t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) - } -} - -func TestSetSystemMemoryReader(t *testing.T) { - // Create a mock reader - mockReader := func() (systemMemory, error) { - return systemMemory{ - Total: 1000000, - Available: 500000, - }, nil - } - - // Set the mock reader - restore := setSystemMemoryReader(mockReader) - - // Test that getSystemMemory uses the mock - mem, err := getSystemMemory() - if err != nil { - t.Fatalf("getSystemMemory with mock failed: %v", err) - } - - if mem.Total != 1000000 || mem.Available != 500000 { - t.Errorf("Mock reader not used: expected (1000000, 500000), got (%d, %d)", mem.Total, mem.Available) - } - - // Restore and test that original reader is back - restore() - mem2, err := getSystemMemory() - if err != nil { - t.Fatalf("getSystemMemory after restore failed: %v", err) - } - - // Should not be using the mock anymore - if mem2.Total == 1000000 && mem2.Available == 500000 { - t.Error("Original reader not restored") - } -} - -func TestSystemMemoryReaderErrorHandling(t *testing.T) { - // Test with a reader that returns an error - errorReader := func() (systemMemory, error) { - return systemMemory{}, errors.New("mock error") - } - - restore := setSystemMemoryReader(errorReader) - defer restore() - - _, err := getSystemMemory() - if err == nil { - t.Error("Expected error from mock reader") - } -} - -// Test concurrent access to system memory reader -func TestConcurrentSystemMemoryAccess(t *testing.T) { - done := make(chan bool, 10) - - for i := 0; i < 10; i++ { - go func() { - for j := 0; j < 100; j++ { - _, err := getSystemMemory() - if err != nil { - t.Errorf("Concurrent access failed: %v", err) - } - } - done <- true - }() - } - - // Wait for all goroutines to complete - for i := 0; i < 10; i++ { - <-done - } -} - -// Test memory calculations are reasonable -func TestMemoryCalculations(t *testing.T) { - mem, err := getSystemMemory() - if err != nil { - t.Fatalf("Failed to get system memory: %v", err) - } - - // Available should be less than or equal to total - if mem.Available > mem.Total { - t.Errorf("Available memory (%d) exceeds total memory (%d)", mem.Available, mem.Total) - } - - // Used memory calculation - used := mem.Total - mem.Available - usedPercent := float64(used) / float64(mem.Total) * 100.0 - - if usedPercent < 0 || usedPercent > 100 { - t.Errorf("Used percentage should be between 0-100, got %.2f%%", usedPercent) - } - - t.Logf("Memory usage: Total=%dMB, Used=%dMB (%.1f%%), Available=%dMB", - mem.Total/1024/1024, - used/1024/1024, - usedPercent, - mem.Available/1024/1024) -} - -// mockFileSystem implements FileSystem for testing -type mockFileSystem struct { - files map[string]string -} - -func (m *mockFileSystem) ReadFile(name string) ([]byte, error) { - if content, ok := m.files[name]; ok { - return []byte(content), nil - } - return nil, fs.ErrNotExist -} - -func (m *mockFileSystem) Open(name string) (fs.File, error) { - if content, ok := m.files[name]; ok { - return &mockFile{content: content}, nil - } - return nil, fs.ErrNotExist -} - -// mockFile implements fs.File for testing -type mockFile struct { - content string - reader *strings.Reader -} - -func (m *mockFile) Read(b []byte) (int, error) { - if m.reader == nil { - m.reader = strings.NewReader(m.content) - } - return m.reader.Read(b) -} - -func (m *mockFile) Close() error { - return nil -} - -func (m *mockFile) Stat() (fs.FileInfo, error) { - return nil, fs.ErrNotExist -} - -// cgroupTestCase represents a test case for cgroup memory reading -type cgroupTestCase struct { - name string - usage string - limit string - stat string - expectTotal uint64 - expectAvail uint64 - expectError bool -} - -// runCgroupTest runs a generic test for cgroup memory reading functions -func runCgroupTest(t *testing.T, testCases []cgroupTestCase, - readFunc func(FileSystem) (systemMemory, error), usagePath, limitPath, statPath string) { - for _, tt := range testCases { - t.Run(tt.name, func(t *testing.T) { - mockFS := &mockFileSystem{ - files: map[string]string{ - usagePath: tt.usage, - limitPath: tt.limit, - statPath: tt.stat, - }, - } - - mem, err := readFunc(mockFS) - - if tt.expectError { - if err == nil { - t.Error("expected error but got none") - } - return - } - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if mem.Total != tt.expectTotal { - t.Errorf("expected total %d, got %d", tt.expectTotal, mem.Total) - } - - if mem.Available != tt.expectAvail { - t.Errorf("expected available %d, got %d", tt.expectAvail, mem.Available) - } - }) - } -} - -func TestReadCgroupV2Memory(t *testing.T) { - testCases := []cgroupTestCase{ - { - name: "normal cgroup v2", - usage: "104857600", // 100MB - limit: "209715200", // 200MB - stat: "inactive_file 52428800", // 50MB - expectTotal: 209715200, // 200MB - expectAvail: 157286400, // 150MB (200-100+50) - expectError: false, - }, - { - name: "unlimited memory", - usage: "104857600", // 100MB - limit: "max", - stat: "inactive_file 0", - expectError: true, // max should cause error - }, - { - name: "available exceeds limit", - usage: "104857600", // 100MB - limit: "209715200", // 200MB - stat: "inactive_file 209715200", // 200MB (would make available = 300MB) - expectTotal: 209715200, // 200MB - expectAvail: 209715200, // capped at limit = 200MB - expectError: false, - }, - { - name: "usage exceeds limit (underflow check)", - usage: "209715201", // 200MB + 1 byte - limit: "209715200", // 200MB - stat: "inactive_file 0", - expectTotal: 209715200, // 200MB - expectAvail: 0, // Should be 0, not wrapped uint64 - expectError: false, - }, - } - - runCgroupTest(t, testCases, readCgroupV2MemoryWithFS, - "/sys/fs/cgroup/memory.current", - "/sys/fs/cgroup/memory.max", - "/sys/fs/cgroup/memory.stat") -} - -func TestReadCgroupV1Memory(t *testing.T) { - testCases := []cgroupTestCase{ - { - name: "normal cgroup v1", - usage: "104857600", // 100MB - limit: "209715200", // 200MB - stat: "total_inactive_file 52428800", // 50MB - expectTotal: 209715200, // 200MB - expectAvail: 157286400, // 150MB (200-100+50) - expectError: false, - }, - { - name: "unlimited memory", - usage: "104857600", // 100MB - limit: "18446744073709551615", // unlimited (2^64-1) - stat: "total_inactive_file 0", - expectError: true, // unlimited should cause error - }, - { - name: "available exceeds limit", - usage: "104857600", // 100MB - limit: "209715200", // 200MB - stat: "total_inactive_file 209715200", // 200MB (would make available = 300MB) - expectTotal: 209715200, // 200MB - expectAvail: 209715200, // capped at limit = 200MB - expectError: false, - }, - { - name: "usage exceeds limit (underflow check)", - usage: "209715201", // 200MB + 1 byte - limit: "209715200", // 200MB - stat: "total_inactive_file 0", - expectTotal: 209715200, // 200MB - expectAvail: 0, // Should be 0, not wrapped uint64 - expectError: false, - }, - } - - runCgroupTest(t, testCases, readCgroupV1MemoryWithFS, - "/sys/fs/cgroup/memory/memory.usage_in_bytes", - "/sys/fs/cgroup/memory/memory.limit_in_bytes", - "/sys/fs/cgroup/memory/memory.stat") -} - -func TestReadCgroupValueWithFS(t *testing.T) { - mockFS := &mockFileSystem{ - files: map[string]string{ - "/test/file": "123456789", - "/test/max": "max", - }, - } - - // Test normal value - val, err := readCgroupValueWithFS(mockFS, "/test/file") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if val != 123456789 { - t.Errorf("expected 123456789, got %d", val) - } - - // Test "max" value - _, err = readCgroupValueWithFS(mockFS, "/test/max") - if err == nil { - t.Error("expected error for 'max' value") - } -} - -func TestReadCgroupStatWithFS(t *testing.T) { - mockFS := &mockFileSystem{ - files: map[string]string{ - "/test/stat": "inactive_file 52428800\ntotal_cache 104857600\n", - }, - } - - // Test existing key - val, err := readCgroupStatWithFS(mockFS, "/test/stat", "inactive_file") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if val != 52428800 { - t.Errorf("expected 52428800, got %d", val) - } - - // Test non-existing key - _, err = readCgroupStatWithFS(mockFS, "/test/stat", "nonexistent") - if err == nil { - t.Error("expected error for non-existent key") - } -} diff --git a/flow/util.go b/flow/util.go index 825ce9b..8feb59b 100644 --- a/flow/util.go +++ b/flow/util.go @@ -2,6 +2,7 @@ package flow import ( "fmt" + "math" "sync" "github.com/reugn/go-streams" @@ -177,3 +178,23 @@ func Flatten[T any](parallelism int) streams.Flow { return element }, parallelism) } + +// clampPercent clamps a percentage value between 0 and 100. +func clampPercent(percent float64) float64 { + if percent < 0 { + return 0 + } + if percent > 100 { + return 100 + } + return percent +} + +// validatePercent validates and normalizes a percentage value. +// It checks for NaN or Inf values and clamps the result between 0 and 100. +func validatePercent(percent float64) float64 { + if math.IsNaN(percent) || math.IsInf(percent, 0) { + return 0 + } + return clampPercent(percent) +} diff --git a/go.mod b/go.mod index 2d5f75b..de9d75c 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,3 @@ module github.com/reugn/go-streams go 1.21.0 - -require github.com/shirou/gopsutil/v4 v4.25.2 - -require ( - github.com/ebitengine/purego v0.8.2 // indirect - 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/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.28.0 // indirect -) diff --git a/go.sum b/go.sum index da0f69e..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,34 +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/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= -github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -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/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk= -github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.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/internal/sysmonitor/cpu.go b/internal/sysmonitor/cpu.go new file mode 100644 index 0000000..6debcf4 --- /dev/null +++ b/internal/sysmonitor/cpu.go @@ -0,0 +1,22 @@ +package sysmonitor + +import ( + "time" +) + +// ProcessCPUSampler samples CPU usage across different platforms +type ProcessCPUSampler interface { + // Sample returns normalized CPU usage (0-100%) since last sample + Sample(deltaTime time.Duration) float64 + + // Reset clears sampler state for a new session + Reset() + + // IsInitialized returns true if at least one sample has been taken + IsInitialized() bool +} + +// NewProcessSampler creates a CPU sampler for the current process +func NewProcessSampler() (ProcessCPUSampler, error) { + return newProcessSampler() +} diff --git a/internal/sysmonitor/cpu_darwin.go b/internal/sysmonitor/cpu_darwin.go new file mode 100644 index 0000000..8464e1b --- /dev/null +++ b/internal/sysmonitor/cpu_darwin.go @@ -0,0 +1,113 @@ +//go:build darwin + +package sysmonitor + +import ( + "fmt" + "math" + "os" + "runtime" + "syscall" + "time" +) + +// cpuTimesStat holds CPU time in seconds +type cpuTimesStat struct { + User float64 + System float64 +} + +// ProcessSampler samples CPU usage for the current process +type ProcessSampler struct { + pid int + lastUTime float64 + lastSTime float64 + lastSample time.Time + lastPercent float64 +} + +// newProcessSampler creates a new CPU sampler for the current process +func newProcessSampler() (*ProcessSampler, error) { + pid := os.Getpid() + if pid < 0 || pid > math.MaxInt32 { + return nil, fmt.Errorf("invalid PID: %d", pid) + } + + return &ProcessSampler{ + pid: pid, + }, nil +} + +// Sample returns the CPU usage percentage since the last sample +func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { + utime, stime, err := s.readProcessTimesDarwin() + if err != nil { + return s.lastPercent + } + + now := time.Now() + if s.lastSample.IsZero() { + s.lastUTime = utime + s.lastSTime = stime + s.lastSample = now + s.lastPercent = 0.0 + return 0.0 + } + + elapsed := now.Sub(s.lastSample) + if elapsed < deltaTime/2 { + return s.lastPercent + } + + prevTotal := s.lastUTime + s.lastSTime + currTotal := utime + stime + cpuTimeDelta := currTotal - prevTotal + wallTimeSeconds := elapsed.Seconds() + + if wallTimeSeconds <= 0 { + return s.lastPercent + } + + // Normalized to 0-100% (divides by numCPU for system-wide metric) + numcpu := runtime.NumCPU() + percent := (cpuTimeDelta / wallTimeSeconds) * 100.0 / float64(numcpu) + + if percent < 0.0 { + percent = 0.0 + } else if percent > 100.0 { + percent = 100.0 + } + + s.lastUTime = utime + s.lastSTime = stime + s.lastSample = now + s.lastPercent = percent + + return percent +} + +// Reset clears sampler state for a new session +func (s *ProcessSampler) Reset() { + s.lastUTime = 0 + s.lastSTime = 0 + s.lastSample = time.Time{} + s.lastPercent = 0.0 +} + +// IsInitialized returns true if at least one sample has been taken +func (s *ProcessSampler) IsInitialized() bool { + return !s.lastSample.IsZero() +} + +// readProcessTimesDarwin reads CPU times via syscall.Getrusage (returns seconds) +func (s *ProcessSampler) readProcessTimesDarwin() (utime, stime float64, err error) { + var rusage syscall.Rusage + err = syscall.Getrusage(syscall.RUSAGE_SELF, &rusage) + if err != nil { + return 0, 0, err + } + + utime = float64(rusage.Utime.Sec) + float64(rusage.Utime.Usec)/1e6 + stime = float64(rusage.Stime.Sec) + float64(rusage.Stime.Usec)/1e6 + return utime, stime, nil +} \ No newline at end of file diff --git a/internal/sysmonitor/cpu_fallback.go b/internal/sysmonitor/cpu_fallback.go new file mode 100644 index 0000000..e655cf7 --- /dev/null +++ b/internal/sysmonitor/cpu_fallback.go @@ -0,0 +1,29 @@ +//go:build !linux && !darwin && !windows + +package sysmonitor + +import ( + "errors" + "time" +) + +// ProcessSampler is a stub implementation for unsupported platforms +type ProcessSampler struct{} + +// newProcessSampler returns an error on unsupported platforms +func newProcessSampler() (*ProcessSampler, error) { + return nil, errors.New("CPU monitoring not supported on this platform") +} + +// Sample always returns 0.0 on unsupported platforms +func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { + return 0.0 +} + +// Reset does nothing on unsupported platforms +func (s *ProcessSampler) Reset() {} + +// IsInitialized always returns false on unsupported platforms +func (s *ProcessSampler) IsInitialized() bool { + return false +} diff --git a/internal/sysmonitor/cpu_linux.go b/internal/sysmonitor/cpu_linux.go new file mode 100644 index 0000000..0c1f0e0 --- /dev/null +++ b/internal/sysmonitor/cpu_linux.go @@ -0,0 +1,191 @@ +//go:build linux + +package sysmonitor + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "math" + "os" + "runtime" + "strconv" + "strings" + "time" +) + +// ProcessSampler samples CPU usage for the current process +type ProcessSampler struct { + pid int + lastUTime float64 + lastSTime float64 + lastSample time.Time + lastPercent float64 + clockTicks int64 +} + +// newProcessSampler creates a CPU sampler for the current process +func newProcessSampler() (*ProcessSampler, error) { + pid := os.Getpid() + if pid < 0 || pid > math.MaxInt32 { + return nil, fmt.Errorf("invalid PID: %d", pid) + } + + clockTicks, err := getClockTicks() + if err != nil { + clockTicks = 100 // fallback + } + + return &ProcessSampler{ + pid: pid, + clockTicks: clockTicks, + }, nil +} + +// Sample returns the CPU usage percentage since the last sample +func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { + utime, stime, err := s.readProcessTimes() + if err != nil { + return s.lastPercent // Return last known value on error + } + + now := time.Now() + if s.lastSample.IsZero() { + s.lastUTime = float64(utime) + s.lastSTime = float64(stime) + s.lastSample = now + s.lastPercent = 0.0 + return 0.0 + } + + elapsed := now.Sub(s.lastSample) + if elapsed < deltaTime/2 { + return s.lastPercent + } + + // Convert ticks to seconds and calculate CPU usage + prevTotalTime := s.lastUTime + s.lastSTime + currTotalTime := float64(utime) + float64(stime) + cpuTimeDelta := currTotalTime - prevTotalTime + cpuTimeSeconds := cpuTimeDelta / float64(s.clockTicks) + wallTimeSeconds := elapsed.Seconds() + + // Normalized to 0-100% (divides by numCPU for system-wide metric) + numcpu := runtime.NumCPU() + percent := (cpuTimeSeconds / wallTimeSeconds) * 100.0 / float64(numcpu) + + if percent > 100.0 { + percent = 100.0 + } else if percent < 0.0 { + percent = 0.0 + } + s.lastUTime = float64(utime) + s.lastSTime = float64(stime) + s.lastSample = now + s.lastPercent = percent + + return percent +} + +// Reset clears sampler state for a new session +func (s *ProcessSampler) Reset() { + s.lastUTime = 0.0 + s.lastSTime = 0.0 + s.lastSample = time.Time{} + s.lastPercent = 0.0 +} + +// IsInitialized returns true if at least one sample has been taken +func (s *ProcessSampler) IsInitialized() bool { + return !s.lastSample.IsZero() +} + +// readProcessTimes reads CPU times from /proc//stat (returns ticks) +func (s *ProcessSampler) readProcessTimes() (utime, stime int64, err error) { + file, err := os.Open(fmt.Sprintf("/proc/%d/stat", s.pid)) + if err != nil { + return 0, 0, err + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + return 0, 0, err + } + + fields := strings.Fields(string(content)) + if len(fields) < 17 { + return 0, 0, fmt.Errorf( + "invalid stat file format for /proc/%d/stat: expected at least 17 fields, got %d", + s.pid, len(fields)) + } + + // utime=field[13], stime=field[14] + utime, err = strconv.ParseInt(fields[13], 10, 64) + if err != nil { + return 0, 0, err + } + + stime, err = strconv.ParseInt(fields[14], 10, 64) + if err != nil { + return 0, 0, err + } + + return utime, stime, nil +} + +// getClockTicks reads clock ticks per second from /proc/self/auxv (AT_CLKTCK=17) +func getClockTicks() (int64, error) { + data, err := os.ReadFile("/proc/self/auxv") + if err != nil { + return 100, nil // fallback + } + + // Try 64-bit format first (16 bytes per entry) + if len(data)%16 == 0 { + buf := bytes.NewReader(data) + var id, val uint64 + + for { + if err := binary.Read(buf, binary.LittleEndian, &id); err != nil { + break + } + if err := binary.Read(buf, binary.LittleEndian, &val); err != nil { + break + } + + if id == 17 && val > 0 && val <= 10000 { // AT_CLKTCK + return int64(val), nil + } + } + } + + // Try 32-bit format (8 bytes per entry) + if len(data)%8 == 0 { + return parseAuxv32(data) + } + + return 100, nil // fallback +} + +// parseAuxv32 parses 32-bit auxv format +func parseAuxv32(data []byte) (int64, error) { + buf := bytes.NewReader(data) + var id, val uint32 + + for { + if err := binary.Read(buf, binary.LittleEndian, &id); err != nil { + break + } + if err := binary.Read(buf, binary.LittleEndian, &val); err != nil { + break + } + + if id == 17 && val > 0 && val <= 10000 { // AT_CLKTCK + return int64(val), nil + } + } + + return 100, nil +} diff --git a/internal/sysmonitor/cpu_test.go b/internal/sysmonitor/cpu_test.go new file mode 100644 index 0000000..9340055 --- /dev/null +++ b/internal/sysmonitor/cpu_test.go @@ -0,0 +1,90 @@ +package sysmonitor + +import ( + "runtime" + "testing" + "time" +) + +func TestGoroutineHeuristicSampler(t *testing.T) { + sampler := NewGoroutineHeuristicSampler() + + // Test with reasonable goroutine count + _ = runtime.NumGoroutine() + + percent := sampler.Sample(100 * time.Millisecond) + if percent < 0.0 || percent > 100.0 { + t.Errorf("CPU percent should be between 0 and 100, got %v", percent) + } + + // Test reset (should be no-op) + sampler.Reset() +} + +func TestNewProcessSampler(t *testing.T) { + sampler, err := NewProcessSampler() + if err != nil { + t.Fatalf("NewProcessSampler failed: %v", err) + } + if sampler == nil { + t.Fatal("NewProcessSampler should not be nil") + } + + // Test basic interface compliance + _ = sampler.Sample(100 * time.Millisecond) + sampler.Reset() + _ = sampler.IsInitialized() +} + +func TestProcessSampler_Initialization(t *testing.T) { + sampler, err := NewProcessSampler() + if err != nil { + t.Fatalf("NewProcessSampler failed: %v", err) + } + if sampler == nil { + t.Fatal("NewProcessSampler should not be nil") + } + + // Initially not initialized + if sampler.IsInitialized() { + t.Error("ProcessSampler should not be initialized initially") + } + + // After first sample, should be initialized + sampler.Sample(100 * time.Millisecond) + if !sampler.IsInitialized() { + t.Error("ProcessSampler should be initialized after first sample") + } + + // After reset, should not be initialized + sampler.Reset() + if sampler.IsInitialized() { + t.Error("ProcessSampler should not be initialized after reset") + } +} + +func TestProcessSampler_Sample(t *testing.T) { + sampler, err := NewProcessSampler() + if err != nil { + t.Fatalf("NewProcessSampler failed: %v", err) + } + + // First sample should return 0 and initialize + percent := sampler.Sample(100 * time.Millisecond) + if percent != 0.0 { + t.Errorf("first sample should return 0.0, got %v", percent) + } + + // Subsequent samples should be valid + time.Sleep(10 * time.Millisecond) + percent = sampler.Sample(10 * time.Millisecond) + if percent < 0.0 || percent > 100.0 { + t.Errorf("CPU percent should be between 0 and 100, got %v", percent) + } + + // Test reset + sampler.Reset() + if sampler.IsInitialized() { + t.Error("sampler should not be initialized after reset") + } +} diff --git a/internal/sysmonitor/cpu_windows.go b/internal/sysmonitor/cpu_windows.go new file mode 100644 index 0000000..435112d --- /dev/null +++ b/internal/sysmonitor/cpu_windows.go @@ -0,0 +1,124 @@ +//go:build windows + +package sysmonitor + +import ( + "fmt" + "math" + "os" + "runtime" + "syscall" + "time" +) + +// ProcessSampler samples CPU usage for the current process +type ProcessSampler struct { + pid int + lastUTime float64 + lastSTime float64 + lastSample time.Time + lastPercent float64 +} + +// newProcessSampler creates a new CPU sampler for the current process (Windows implementation) +func newProcessSampler() (*ProcessSampler, error) { + pid := os.Getpid() + if pid < 0 || pid > math.MaxInt32 { + return nil, fmt.Errorf("invalid PID: %d", pid) + } + + return &ProcessSampler{ + pid: pid, + }, nil +} + +// Sample returns the CPU usage percentage since the last sample +func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { + utime, stime, err := s.getCurrentCPUTimes() + if err != nil { + return s.lastPercent + } + + now := time.Now() + if s.lastSample.IsZero() { + s.lastUTime = utime + s.lastSTime = stime + s.lastSample = now + s.lastPercent = 0.0 + return 0.0 + } + + elapsed := now.Sub(s.lastSample) + if elapsed < deltaTime/2 { + return s.lastPercent + } + + // GetProcessTimes returns sum of all threads across all cores + cpuTimeDelta := (utime + stime) - (s.lastUTime + s.lastSTime) + wallTimeSeconds := elapsed.Seconds() + + if wallTimeSeconds <= 0 { + return s.lastPercent + } + + // Normalized to 0-100% (divides by numCPU for system-wide metric) + numcpu := runtime.NumCPU() + percent := (cpuTimeDelta / wallTimeSeconds) * 100.0 / float64(numcpu) + + if percent < 0.0 { + percent = 0.0 + } else if percent > 100.0 { + percent = 100.0 + } + + s.lastUTime = utime + s.lastSTime = stime + s.lastSample = now + s.lastPercent = percent + + return percent +} + +// Reset clears sampler state for a new session +func (s *ProcessSampler) Reset() { + s.lastUTime = 0.0 + s.lastSTime = 0.0 + s.lastSample = time.Time{} + s.lastPercent = 0.0 +} + +// IsInitialized returns true if at least one sample has been taken +func (s *ProcessSampler) IsInitialized() bool { + return !s.lastSample.IsZero() +} + +// getProcessCPUTimes retrieves CPU times via GetProcessTimes (returns FILETIME) +func getProcessCPUTimes(pid int) (syscall.Filetime, syscall.Filetime, error) { + var c, e, k, u syscall.Filetime + h, err := syscall.OpenProcess(syscall.PROCESS_QUERY_INFORMATION, false, uint32(pid)) + if err != nil { + return k, u, err + } + defer syscall.CloseHandle(h) + + err = syscall.GetProcessTimes(h, &c, &e, &k, &u) + return k, u, err +} + +// convertFiletimeToSeconds converts FILETIME (100ns intervals) to seconds +func convertFiletimeToSeconds(ft syscall.Filetime) float64 { + ticks := int64(ft.HighDateTime)<<32 | int64(ft.LowDateTime) + return float64(ticks) * 1e-7 // 1 tick = 100ns +} + +// getCurrentCPUTimes reads CPU times for the process (returns seconds) +func (s *ProcessSampler) getCurrentCPUTimes() (utime, stime float64, err error) { + k, u, err := getProcessCPUTimes(s.pid) + if err != nil { + return 0, 0, err + } + + utime = convertFiletimeToSeconds(u) + stime = convertFiletimeToSeconds(k) + return utime, stime, nil +} diff --git a/internal/sysmonitor/doc.go b/internal/sysmonitor/doc.go new file mode 100644 index 0000000..1f17dbd --- /dev/null +++ b/internal/sysmonitor/doc.go @@ -0,0 +1,3 @@ +// Package sysmonitor provides system monitoring functionality +// for CPU and memory usage monitoring. +package sysmonitor diff --git a/internal/sysmonitor/fs.go b/internal/sysmonitor/fs.go new file mode 100644 index 0000000..8023654 --- /dev/null +++ b/internal/sysmonitor/fs.go @@ -0,0 +1,23 @@ +package sysmonitor + +import ( + "io/fs" + "os" +) + +// FileSystem abstracts file system operations for testing +type FileSystem interface { + ReadFile(name string) ([]byte, error) + Open(name string) (fs.File, error) +} + +// OSFileSystem implements FileSystem using the os package +type OSFileSystem struct{} + +func (OSFileSystem) ReadFile(name string) ([]byte, error) { + return os.ReadFile(name) +} + +func (OSFileSystem) Open(name string) (fs.File, error) { + return os.Open(name) +} diff --git a/flow/cpu_sampler_heuristic.go b/internal/sysmonitor/heuristic.go similarity index 70% rename from flow/cpu_sampler_heuristic.go rename to internal/sysmonitor/heuristic.go index 4cb0a19..1ea8b49 100644 --- a/flow/cpu_sampler_heuristic.go +++ b/internal/sysmonitor/heuristic.go @@ -1,4 +1,4 @@ -package flow +package sysmonitor import ( "math" @@ -19,10 +19,16 @@ const ( CPUHeuristicMaxCPU = 95.0 ) -// goroutineHeuristicSampler uses goroutine count as a CPU usage proxy -type goroutineHeuristicSampler struct{} +// GoroutineHeuristicSampler uses goroutine count as a CPU usage proxy +type GoroutineHeuristicSampler struct{} -func (s *goroutineHeuristicSampler) Sample(_ time.Duration) float64 { +// NewGoroutineHeuristicSampler creates a new heuristic CPU sampler +func NewGoroutineHeuristicSampler() ProcessCPUSampler { + return &GoroutineHeuristicSampler{} +} + +// Sample returns the CPU usage percentage over the given time delta +func (s *GoroutineHeuristicSampler) Sample(_ time.Duration) float64 { // Uses logarithmic scaling for more realistic CPU estimation // Base level: 1-10 goroutines = baseline CPU usage (10-20%) // Logarithmic growth to avoid overestimation at high goroutine counts @@ -48,10 +54,12 @@ func (s *goroutineHeuristicSampler) Sample(_ time.Duration) float64 { return estimatedCPU } -func (s *goroutineHeuristicSampler) Reset() { +// Reset prepares the sampler for a new sampling session +func (s *GoroutineHeuristicSampler) Reset() { // No state to reset for heuristic sampler } -func (s *goroutineHeuristicSampler) IsInitialized() bool { +// IsInitialized returns true if the sampler has been initialized with at least one sample +func (s *GoroutineHeuristicSampler) IsInitialized() bool { return true } diff --git a/internal/sysmonitor/memory.go b/internal/sysmonitor/memory.go new file mode 100644 index 0000000..00aaee3 --- /dev/null +++ b/internal/sysmonitor/memory.go @@ -0,0 +1,10 @@ +package sysmonitor + +// SystemMemory represents system memory information in bytes +type SystemMemory struct { + Total uint64 + Available uint64 +} + +// MemoryReader is a function that reads system memory information +type MemoryReader func() (SystemMemory, error) diff --git a/internal/sysmonitor/memory_darwin.go b/internal/sysmonitor/memory_darwin.go new file mode 100644 index 0000000..3014a05 --- /dev/null +++ b/internal/sysmonitor/memory_darwin.go @@ -0,0 +1,85 @@ +//go:build darwin && cgo + +package sysmonitor + +/* +#include +#include +#include + +kern_return_t get_vm_stats(vm_statistics64_t vmstat) { + mach_msg_type_number_t count = HOST_VM_INFO64_COUNT; + mach_port_t host_port = mach_host_self(); + kern_return_t ret = host_statistics64(host_port, HOST_VM_INFO64, (host_info64_t)vmstat, &count); + + mach_port_deallocate(mach_task_self(), host_port); + + return ret; +} + +uint64_t get_hw_memsize() { + uint64_t memsize = 0; + size_t len = sizeof(memsize); + int mib[2] = {CTL_HW, HW_MEMSIZE}; + sysctl(mib, 2, &memsize, &len, NULL, 0); + return memsize; +} +*/ +import "C" + +import ( + "fmt" + "sync" +) + +var ( + memoryReader = getSystemMemoryDarwin + // memoryReaderMu protects concurrent access to memoryReader + memoryReaderMu sync.RWMutex +) + +// GetSystemMemory returns the current system memory statistics. +func GetSystemMemory() (SystemMemory, error) { + memoryReaderMu.RLock() + reader := memoryReader + memoryReaderMu.RUnlock() + return reader() +} + +func getSystemMemoryDarwin() (SystemMemory, error) { + total := uint64(C.get_hw_memsize()) + if total == 0 { + return SystemMemory{}, fmt.Errorf("failed to get total memory via sysctl") + } + + var vmStat C.vm_statistics64_data_t + if ret := C.get_vm_stats(&vmStat); ret != C.KERN_SUCCESS { + return SystemMemory{}, fmt.Errorf("failed to get host VM statistics: kern_return_t=%d", ret) + } + + pageSize := uint64(C.vm_kernel_page_size) + + free := uint64(vmStat.free_count) * pageSize + inactive := uint64(vmStat.inactive_count) * pageSize + speculative := uint64(vmStat.speculative_count) * pageSize + + return SystemMemory{ + Total: total, + Available: free + inactive + speculative, + }, nil +} + +// SetMemoryReader replaces the current memory reader (for testing). +// It returns a cleanup function to restore the previous reader. +func SetMemoryReader(reader MemoryReader) func() { + memoryReaderMu.Lock() + prev := memoryReader + memoryReader = reader + memoryReaderMu.Unlock() + + return func() { + memoryReaderMu.Lock() + memoryReader = prev + memoryReaderMu.Unlock() + } +} diff --git a/internal/sysmonitor/memory_fallback.go b/internal/sysmonitor/memory_fallback.go new file mode 100644 index 0000000..0b74ce5 --- /dev/null +++ b/internal/sysmonitor/memory_fallback.go @@ -0,0 +1,47 @@ +//go:build !linux && !windows && (!darwin || (darwin && !cgo)) + +package sysmonitor + +import ( + "errors" + "runtime" + "sync" +) + +var ( + // memoryReader is nil on unsupported platforms + memoryReader MemoryReader + // memoryReaderMu protects concurrent access to memoryReader + memoryReaderMu sync.RWMutex +) + +// GetSystemMemory returns an error on unsupported platforms. +func GetSystemMemory() (SystemMemory, error) { + memoryReaderMu.RLock() + reader := memoryReader + memoryReaderMu.RUnlock() + + if reader != nil { + return reader() + } + + if runtime.GOOS == "darwin" { + return SystemMemory{}, errors.New("memory monitoring not supported on this platform without cgo") + } + return SystemMemory{}, errors.New("memory monitoring not supported on this platform") +} + +// SetMemoryReader replaces the current memory reader (for testing). +// It returns a cleanup function to restore the previous reader. +func SetMemoryReader(reader MemoryReader) func() { + memoryReaderMu.Lock() + prev := memoryReader + memoryReader = reader + memoryReaderMu.Unlock() + + return func() { + memoryReaderMu.Lock() + memoryReader = prev + memoryReaderMu.Unlock() + } +} diff --git a/internal/sysmonitor/memory_linux.go b/internal/sysmonitor/memory_linux.go new file mode 100644 index 0000000..2baa361 --- /dev/null +++ b/internal/sysmonitor/memory_linux.go @@ -0,0 +1,266 @@ +//go:build linux + +package sysmonitor + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "strconv" + "strings" + "sync" +) + +var ( + memoryReader MemoryReader + fileSystem FileSystem + // memoryReaderMu protects concurrent access to memoryReader + memoryReaderMu sync.RWMutex +) + +// GetSystemMemory returns the current system memory statistics. +// This function auto-detects the environment (cgroup v2, v1, or host) +// and returns appropriate memory information. +func GetSystemMemory() (SystemMemory, error) { + memoryReaderMu.RLock() + reader := memoryReader + memoryReaderMu.RUnlock() + + if reader == nil { + memoryReaderMu.Lock() + if memoryReader == nil { + memoryReader = readSystemMemoryAuto + fileSystem = OSFileSystem{} + } + reader = memoryReader + memoryReaderMu.Unlock() + } + + return reader() +} + +// readSystemMemoryAuto detects the environment once and "upgrades" the reader +func readSystemMemoryAuto() (SystemMemory, error) { + if m, err := readCgroupV2Memory(); err == nil { + memoryReaderMu.Lock() + memoryReader = readCgroupV2Memory + memoryReaderMu.Unlock() + return m, nil + } + + if m, err := readCgroupV1Memory(); err == nil { + memoryReaderMu.Lock() + memoryReader = readCgroupV1Memory + memoryReaderMu.Unlock() + return m, nil + } + + // Fallback to Host (Bare metal / VM / Unlimited Container) + m, err := readSystemMemory() + if err != nil { + return SystemMemory{}, fmt.Errorf("failed to read system memory from all sources: %w", err) + } + memoryReaderMu.Lock() + memoryReader = readSystemMemory + memoryReaderMu.Unlock() + return m, nil +} + +func readCgroupV2Memory() (SystemMemory, error) { + return readCgroupV2MemoryWithFS(fileSystem) +} + +func readCgroupV2MemoryWithFS(fs FileSystem) (SystemMemory, error) { + usage, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory.current") + if err != nil { + return SystemMemory{}, fmt.Errorf("failed to read cgroup v2 memory usage: %w", err) + } + + limit, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory.max") + if err != nil { + return SystemMemory{}, fmt.Errorf("failed to read cgroup v2 memory limit: %w", err) + } + + // Parse memory.stat to find reclaimable memory (inactive_file) + inactiveFile, _ := readCgroupStatWithFS(fs, "/sys/fs/cgroup/memory.stat", "inactive_file") + + // Available = (Limit - Usage) + Reclaimable + // Note: If Limit - Usage is near zero, the kernel would reclaim inactive_file + // Handle case where usage exceeds limit + var available uint64 + if usage > limit { + available = inactiveFile // Only reclaimable memory is available + } else { + available = (limit - usage) + inactiveFile + } + + if available > limit { + available = limit + } + + return SystemMemory{ + Total: limit, + Available: available, + }, nil +} + +func readCgroupV1Memory() (SystemMemory, error) { + return readCgroupV1MemoryWithFS(fileSystem) +} + +func readCgroupV1MemoryWithFS(fs FileSystem) (SystemMemory, error) { + usage, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory/memory.usage_in_bytes") + if err != nil { + return SystemMemory{}, fmt.Errorf("failed to read cgroup v1 memory usage: %w", err) + } + + limit, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory/memory.limit_in_bytes") + if err != nil { + return SystemMemory{}, fmt.Errorf("failed to read cgroup v1 memory limit: %w", err) + } + + // Check for "unlimited" (random huge number in V1) + if limit > (1 << 60) { + return SystemMemory{}, os.ErrNotExist + } + + // Parse memory.stat for V1 + inactiveFile, _ := readCgroupStatWithFS(fs, "/sys/fs/cgroup/memory/memory.stat", "total_inactive_file") + + // Handle case where usage exceeds limit + var available uint64 + if usage > limit { + available = inactiveFile // Only reclaimable memory is available + } else { + available = (limit - usage) + inactiveFile + } + + if available > limit { + available = limit + } + + return SystemMemory{ + Total: limit, + Available: available, + }, nil +} + +func readCgroupValueWithFS(fs FileSystem, path string) (uint64, error) { + data, err := fs.ReadFile(path) + if err != nil { + return 0, err + } + str := strings.TrimSpace(string(data)) + if str == "max" { + return 0, os.ErrNotExist + } + val, err := strconv.ParseUint(str, 10, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse value %q from %s: %w", str, path, err) + } + return val, nil +} + +func readCgroupStatWithFS(fs FileSystem, path string, key string) (uint64, error) { + f, err := fs.Open(path) + if err != nil { + return 0, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Bytes() + if bytes.HasPrefix(line, []byte(key)) { + fields := bytes.Fields(line) + if len(fields) >= 2 { + val, err := strconv.ParseUint(string(fields[1]), 10, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse value for key %q in %s: %w", key, path, err) + } + return val, nil + } + } + } + return 0, fmt.Errorf("key %q not found in %s", key, path) +} + +// readSystemMemory reads memory info from /proc/meminfo +func readSystemMemory() (SystemMemory, error) { + file, err := os.Open("/proc/meminfo") + if err != nil { + return SystemMemory{}, fmt.Errorf("failed to open /proc/meminfo: %w", err) + } + defer file.Close() + + return parseMemInfo(file) +} + +// parseMemInfo parses the /proc/meminfo file format +func parseMemInfo(r io.Reader) (SystemMemory, error) { + scanner := bufio.NewScanner(r) + + var total, available uint64 + found := 0 + + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + + if len(fields) < 2 { + continue + } + + key := strings.TrimSuffix(fields[0], ":") + value, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + continue + } + + // Convert from kB to bytes + value *= 1024 + + switch key { + case "MemTotal": + total = value + found++ + case "MemAvailable": + available = value + found++ + } + + if found == 2 { + break + } + } + + if err := scanner.Err(); err != nil { + return SystemMemory{}, fmt.Errorf("error reading meminfo: %w", err) + } + + if found != 2 { + return SystemMemory{}, fmt.Errorf( + "could not find MemTotal and MemAvailable in /proc/meminfo (found %d of 2 required fields)", + found) + } + + return SystemMemory{ + Total: total, + Available: available, + }, nil +} + +// SetMemoryReader replaces the current memory reader (for testing) +func SetMemoryReader(reader MemoryReader) func() { + memoryReaderMu.Lock() + prev := memoryReader + memoryReader = reader + memoryReaderMu.Unlock() + return func() { + memoryReaderMu.Lock() + memoryReader = prev + memoryReaderMu.Unlock() + } +} diff --git a/internal/sysmonitor/memory_linux_test.go b/internal/sysmonitor/memory_linux_test.go new file mode 100644 index 0000000..3e64fb4 --- /dev/null +++ b/internal/sysmonitor/memory_linux_test.go @@ -0,0 +1,213 @@ +//go:build linux + +package sysmonitor + +import ( + "errors" + "io/fs" + "strings" + "testing" +) + +// ===== Mock filesystem implementation ===== + +type mockFS map[string]string + +func (m mockFS) ReadFile(name string) ([]byte, error) { + if content, ok := m[name]; ok { + return []byte(content), nil + } + return nil, errors.New("file does not exist: " + name) +} + +func (m mockFS) Open(name string) (fs.File, error) { + if content, ok := m[name]; ok { + return &mockFile{content: content}, nil + } + return nil, errors.New("file does not exist: " + name) +} + +type mockFile struct { + content string + reader *strings.Reader +} + +func (m *mockFile) Read(p []byte) (int, error) { + if m.reader == nil { + m.reader = strings.NewReader(m.content) + } + return m.reader.Read(p) +} + +func (m *mockFile) Close() error { return nil } + +func (m *mockFile) Stat() (fs.FileInfo, error) { return nil, nil } + +// ===== Test functions ===== + +func TestReadCgroupV2Memory(t *testing.T) { + mockFS := mockFS{ + "/sys/fs/cgroup/memory.current": "1073741824\n", // 1GB + "/sys/fs/cgroup/memory.max": "2147483648\n", // 2GB + "/sys/fs/cgroup/memory.stat": "inactive_file 104857600\n", // 100MB + } + + mem, err := readCgroupV2MemoryWithFS(mockFS) + if err != nil { + t.Fatalf("readCgroupV2MemoryWithFS failed: %v", err) + } + + expectedTotal := uint64(2147483648) // 2GB + expectedAvailable := uint64(1178599424) // (2GB - 1GB) + 100MB = 1.1GB available + + if mem.Total != expectedTotal { + t.Errorf("Expected total %d, got %d", expectedTotal, mem.Total) + } + + if mem.Available != expectedAvailable { + t.Errorf("Expected available %d, got %d", expectedAvailable, mem.Available) + } +} + +func TestReadCgroupV1Memory(t *testing.T) { + mockFS := mockFS{ + "/sys/fs/cgroup/memory/memory.usage_in_bytes": "1073741824\n", // 1GB + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "2147483648\n", // 2GB + "/sys/fs/cgroup/memory/memory.stat": "total_inactive_file 104857600\n", // 100MB + } + + mem, err := readCgroupV1MemoryWithFS(mockFS) + if err != nil { + t.Fatalf("readCgroupV1MemoryWithFS failed: %v", err) + } + + expectedTotal := uint64(2147483648) // 2GB + expectedAvailable := uint64(1178599424) // (2GB - 1GB) + 100MB = 1.1GB available + + if mem.Total != expectedTotal { + t.Errorf("Expected total %d, got %d", expectedTotal, mem.Total) + } + + if mem.Available != expectedAvailable { + t.Errorf("Expected available %d, got %d", expectedAvailable, mem.Available) + } +} + +func TestReadCgroupValueWithFS(t *testing.T) { + mockFS := mockFS{ + "/test/file": "123456\n", + } + + value, err := readCgroupValueWithFS(mockFS, "/test/file") + if err != nil { + t.Fatalf("readCgroupValueWithFS failed: %v", err) + } + + expected := uint64(123456) + if value != expected { + t.Errorf("Expected %d, got %d", expected, value) + } + + // Test "max" value (unlimited) + mockFS["/test/max"] = "max\n" + _, err = readCgroupValueWithFS(mockFS, "/test/max") + if err == nil { + t.Error("Expected error for 'max' value") + } +} + +func TestReadCgroupStatWithFS(t *testing.T) { + mockFS := mockFS{ + "/test/stat": "inactive_file 987654\nactive_file 123456\n", + } + + value, err := readCgroupStatWithFS(mockFS, "/test/stat", "inactive_file") + if err != nil { + t.Fatalf("readCgroupStatWithFS failed: %v", err) + } + + expected := uint64(987654) + if value != expected { + t.Errorf("Expected %d, got %d", expected, value) + } + + // Test key not found + _, err = readCgroupStatWithFS(mockFS, "/test/stat", "nonexistent_key") + if err == nil { + t.Error("Expected error for nonexistent key") + } +} + +func TestReadCgroupV2Memory_UsageExceedsLimit(t *testing.T) { + mockFS := mockFS{ + "/sys/fs/cgroup/memory.current": "3000000000\n", // 3GB (exceeds limit) + "/sys/fs/cgroup/memory.max": "2147483648\n", // 2GB + "/sys/fs/cgroup/memory.stat": "inactive_file 104857600\n", // 100MB + } + + mem, err := readCgroupV2MemoryWithFS(mockFS) + if err != nil { + t.Fatalf("readCgroupV2MemoryWithFS failed: %v", err) + } + + expectedTotal := uint64(2147483648) // 2GB + expectedAvailable := uint64(104857600) // Only reclaimable memory available when usage > limit + + if mem.Total != expectedTotal { + t.Errorf("Expected total %d, got %d", expectedTotal, mem.Total) + } + + if mem.Available != expectedAvailable { + t.Errorf("Expected available %d, got %d", expectedAvailable, mem.Available) + } +} + +func TestReadCgroupV2Memory_Unlimited(t *testing.T) { + mockFS := mockFS{ + "/sys/fs/cgroup/memory.current": "1073741824\n", // 1GB + "/sys/fs/cgroup/memory.max": "max\n", // Unlimited + "/sys/fs/cgroup/memory.stat": "inactive_file 104857600\n", // 100MB + } + + _, err := readCgroupV2MemoryWithFS(mockFS) + if err == nil { + t.Error("Expected error for unlimited memory (max)") + } +} + +func TestReadCgroupV2Memory_MissingStatFile(t *testing.T) { + mockFS := mockFS{ + "/sys/fs/cgroup/memory.current": "1073741824\n", // 1GB + "/sys/fs/cgroup/memory.max": "2147483648\n", // 2GB + // memory.stat is missing + } + + mem, err := readCgroupV2MemoryWithFS(mockFS) + if err != nil { + t.Fatalf("readCgroupV2MemoryWithFS failed: %v", err) + } + + expectedTotal := uint64(2147483648) // 2GB + expectedAvailable := uint64(1073741824) // (2GB - 1GB) + 0 = 1GB (no reclaimable) + + if mem.Total != expectedTotal { + t.Errorf("Expected total %d, got %d", expectedTotal, mem.Total) + } + + if mem.Available != expectedAvailable { + t.Errorf("Expected available %d, got %d", expectedAvailable, mem.Available) + } +} + +func TestReadCgroupV1Memory_Unlimited(t *testing.T) { + mockFS := mockFS{ + "/sys/fs/cgroup/memory/memory.usage_in_bytes": "1073741824\n", // 1GB + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "18446744073709551615\n", // Huge number (unlimited) + "/sys/fs/cgroup/memory/memory.stat": "total_inactive_file 104857600\n", // 100MB + } + + _, err := readCgroupV1MemoryWithFS(mockFS) + if err == nil { + t.Error("Expected error for unlimited memory") + } +} diff --git a/internal/sysmonitor/memory_test.go b/internal/sysmonitor/memory_test.go new file mode 100644 index 0000000..a65f8de --- /dev/null +++ b/internal/sysmonitor/memory_test.go @@ -0,0 +1,66 @@ +package sysmonitor + +import ( + "testing" +) + +func TestGetSystemMemory(t *testing.T) { + mem, err := GetSystemMemory() + if err != nil { + t.Fatalf("GetSystemMemory failed: %v", err) + } + + if mem.Total == 0 { + t.Error("Total memory should not be zero") + } + + if mem.Available > mem.Total { + t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) + } +} + +func TestMemorySampler(t *testing.T) { + mem, err := GetSystemMemory() + if err != nil { + t.Fatalf("GetSystemMemory failed: %v", err) + } + + if mem.Total == 0 { + t.Error("Total memory should not be zero") + } + + if mem.Available > mem.Total { + t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) + } +} + +func TestSystemMemoryCalculations(t *testing.T) { + testCases := []struct { + name string + total uint64 + available uint64 + expectedPercent float64 + }{ + {"full", 100, 100, 0.0}, + {"half", 100, 50, 50.0}, + {"quarter", 100, 25, 75.0}, + {"empty", 100, 0, 100.0}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mem := SystemMemory{ + Total: tc.total, + Available: tc.available, + } + + // Calculate used percentage + used := mem.Total - mem.Available + percent := float64(used) / float64(mem.Total) * 100.0 + + if percent != tc.expectedPercent { + t.Errorf("Expected %.1f%% used, got %.1f%%", tc.expectedPercent, percent) + } + }) + } +} diff --git a/internal/sysmonitor/memory_windows.go b/internal/sysmonitor/memory_windows.go new file mode 100644 index 0000000..87f8862 --- /dev/null +++ b/internal/sysmonitor/memory_windows.go @@ -0,0 +1,70 @@ +//go:build windows + +package sysmonitor + +import ( + "sync" + "syscall" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") + + memoryReader = getSystemMemoryWindows + // memoryReaderMu protects concurrent access to memoryReader + memoryReaderMu sync.RWMutex +) + +type memoryStatusEx struct { + dwLength uint32 + dwMemoryLoad uint32 + ullTotalPhys uint64 + ullAvailPhys uint64 + ullTotalPageFile uint64 + ullAvailPageFile uint64 + ullTotalVirtual uint64 + ullAvailVirtual uint64 + ullAvailExtendedVirtual uint64 +} + +func GetSystemMemory() (SystemMemory, error) { + memoryReaderMu.RLock() + reader := memoryReader + memoryReaderMu.RUnlock() + return reader() +} + +func getSystemMemoryWindows() (SystemMemory, error) { + var memStatus memoryStatusEx + + memStatus.dwLength = uint32(unsafe.Sizeof(memStatus)) + + ret, _, err := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&memStatus))) + + // If the function fails, the return value is zero. + if ret == 0 { + return SystemMemory{}, err + } + + return SystemMemory{ + Total: memStatus.ullTotalPhys, + Available: memStatus.ullAvailPhys, + }, nil +} + +// SetMemoryReader replaces the current memory reader (for testing). +// It returns a cleanup function to restore the previous reader. +func SetMemoryReader(reader MemoryReader) func() { + memoryReaderMu.Lock() + prev := memoryReader + memoryReader = reader + memoryReaderMu.Unlock() + + return func() { + memoryReaderMu.Lock() + memoryReader = prev + memoryReaderMu.Unlock() + } +} From a224484f9770401cb5e274015ba7ed8a74ea090c Mon Sep 17 00:00:00 2001 From: kxrxh Date: Fri, 21 Nov 2025 20:02:21 +0300 Subject: [PATCH 14/54] refactor(sysmonitor): improve CPU time retrieval for current process Updated the getProcessCPUTimes function to use GetCurrentProcess for the current process, enhancing reliability and eliminating the need to open a process handle. This change simplifies the code and improves performance when retrieving CPU times. --- internal/sysmonitor/cpu_windows.go | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/internal/sysmonitor/cpu_windows.go b/internal/sysmonitor/cpu_windows.go index 435112d..a8ae90e 100644 --- a/internal/sysmonitor/cpu_windows.go +++ b/internal/sysmonitor/cpu_windows.go @@ -95,13 +95,28 @@ func (s *ProcessSampler) IsInitialized() bool { // getProcessCPUTimes retrieves CPU times via GetProcessTimes (returns FILETIME) func getProcessCPUTimes(pid int) (syscall.Filetime, syscall.Filetime, error) { var c, e, k, u syscall.Filetime - h, err := syscall.OpenProcess(syscall.PROCESS_QUERY_INFORMATION, false, uint32(pid)) - if err != nil { - return k, u, err + + // For the current process, use GetCurrentProcess() which returns a pseudo-handle + // that doesn't need to be opened and is more reliable + currentPid := os.Getpid() + var h syscall.Handle + if pid == currentPid { + var err error + h, err = syscall.GetCurrentProcess() + if err != nil { + return k, u, err + } + // GetCurrentProcess returns a pseudo-handle that doesn't need to be closed + } else { + var err error + h, err = syscall.OpenProcess(syscall.PROCESS_QUERY_INFORMATION, false, uint32(pid)) + if err != nil { + return k, u, err + } + defer syscall.CloseHandle(h) } - defer syscall.CloseHandle(h) - err = syscall.GetProcessTimes(h, &c, &e, &k, &u) + err := syscall.GetProcessTimes(h, &c, &e, &k, &u) return k, u, err } From 04586f7c7dc82b25bca8d9b236fd847a52f1d8c5 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Fri, 21 Nov 2025 20:28:34 +0300 Subject: [PATCH 15/54] feat(adaptive_throttler): enhance demo with CPU workload simulation --- examples/adaptive_throttler/demo/demo.go | 23 ++++++++++++++++++ internal/sysmonitor/cpu_windows.go | 30 +++++++++++++++++++++--- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/examples/adaptive_throttler/demo/demo.go b/examples/adaptive_throttler/demo/demo.go index 7916898..78ce424 100644 --- a/examples/adaptive_throttler/demo/demo.go +++ b/examples/adaptive_throttler/demo/demo.go @@ -38,12 +38,24 @@ func main() { go produceBurst(in, 250) + // Use a variable to prevent compiler optimization of CPU work + var cpuWorkChecksum uint64 + for element := range sink.Out { fmt.Printf("consumer received %v\n", element) elementsProcessed.Add(1) // Track processed elements for memory pressure simulation + + // Perform CPU-intensive work that can't be optimized away + // This ensures Windows GetProcessTimes can detect CPU usage + // (Windows timer resolution is ~15.625ms, so we need at least 50-100ms of work) + burnCPU(50*time.Millisecond, &cpuWorkChecksum) + time.Sleep(25 * time.Millisecond) } + // Print checksum to ensure CPU work wasn't optimized away + fmt.Printf("CPU work checksum: %d\n", cpuWorkChecksum) + fmt.Println("adaptive throttling pipeline completed") } @@ -107,6 +119,17 @@ func produceBurst(in chan<- any, total int) { } } +// burnCPU performs CPU-intensive work for the specified duration +// The checksum parameter prevents the compiler from optimizing away the work +func burnCPU(duration time.Duration, checksum *uint64) { + start := time.Now() + for time.Since(start) < duration { + for i := 0; i < 1000; i++ { + *checksum += uint64(i * i) + } + } +} + func logThrottlerStats(at *flow.AdaptiveThrottler, done <-chan struct{}) { ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() diff --git a/internal/sysmonitor/cpu_windows.go b/internal/sysmonitor/cpu_windows.go index a8ae90e..1660dd7 100644 --- a/internal/sysmonitor/cpu_windows.go +++ b/internal/sysmonitor/cpu_windows.go @@ -36,6 +36,10 @@ func newProcessSampler() (*ProcessSampler, error) { func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { utime, stime, err := s.getCurrentCPUTimes() if err != nil { + // If we have a previous valid sample, return it; otherwise return 0 + if s.lastSample.IsZero() { + return 0.0 + } return s.lastPercent } @@ -62,9 +66,16 @@ func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { } // Normalized to 0-100% (divides by numCPU for system-wide metric) + // Note: GetProcessTimes returns cumulative CPU time across all threads/cores + // So we divide by numCPU to get per-core percentage numcpu := runtime.NumCPU() + if numcpu <= 0 { + numcpu = 1 // Safety check + } + percent := (cpuTimeDelta / wallTimeSeconds) * 100.0 / float64(numcpu) + // Handle negative deltas (can happen due to clock adjustments or process restarts) if percent < 0.0 { percent = 0.0 } else if percent > 100.0 { @@ -108,16 +119,29 @@ func getProcessCPUTimes(pid int) (syscall.Filetime, syscall.Filetime, error) { } // GetCurrentProcess returns a pseudo-handle that doesn't need to be closed } else { + // Try PROCESS_QUERY_LIMITED_INFORMATION first (works on more Windows versions) + // Fall back to PROCESS_QUERY_INFORMATION if that fails var err error - h, err = syscall.OpenProcess(syscall.PROCESS_QUERY_INFORMATION, false, uint32(pid)) + const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + h, err = syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) if err != nil { - return k, u, err + // Fallback to PROCESS_QUERY_INFORMATION + h, err = syscall.OpenProcess(syscall.PROCESS_QUERY_INFORMATION, false, uint32(pid)) + if err != nil { + return k, u, err + } } defer syscall.CloseHandle(h) } err := syscall.GetProcessTimes(h, &c, &e, &k, &u) - return k, u, err + if err != nil { + return k, u, err + } + + // Validate that we got non-zero times (unless process just started) + // This helps catch cases where the API call succeeded but returned invalid data + return k, u, nil } // convertFiletimeToSeconds converts FILETIME (100ns intervals) to seconds From 6ec1760ed0aff029e7552c8354125750c494e0a7 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Fri, 21 Nov 2025 20:30:46 +0300 Subject: [PATCH 16/54] docs(adaptive_throttler): add clarification comment for constrained variable --- flow/adaptive_throttler.go | 1 + 1 file changed, 1 insertion(+) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index eac4397..e82e0f0 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -215,6 +215,7 @@ func (at *AdaptiveThrottler) adaptRate() { defer at.adaptMu.Unlock() stats := at.monitor.GetStats() + // constrained indicates if memory or CPU usage exceeds configured thresholds constrained := stats.MemoryUsedPercent > at.config.MaxMemoryPercent || stats.CPUUsagePercent > at.config.MaxCPUPercent From 063344782d064dc07439c1ce923893f00ac41867 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 16:01:42 +0300 Subject: [PATCH 17/54] fix(sysmonitor): add safety checks for CPU count and handle memory stat errors - Implemented a safety check to ensure the number of CPUs is at least 1 in both Darwin and Linux CPU sampling functions. - Updated memory reading functions to handle potential errors gracefully, defaulting to 0 for unavailable stats. --- internal/sysmonitor/cpu_darwin.go | 3 +++ internal/sysmonitor/cpu_linux.go | 3 +++ internal/sysmonitor/memory_linux.go | 9 ++++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/sysmonitor/cpu_darwin.go b/internal/sysmonitor/cpu_darwin.go index 8464e1b..aa3b8f2 100644 --- a/internal/sysmonitor/cpu_darwin.go +++ b/internal/sysmonitor/cpu_darwin.go @@ -70,6 +70,9 @@ func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { // Normalized to 0-100% (divides by numCPU for system-wide metric) numcpu := runtime.NumCPU() + if numcpu <= 0 { + numcpu = 1 // Safety check + } percent := (cpuTimeDelta / wallTimeSeconds) * 100.0 / float64(numcpu) if percent < 0.0 { diff --git a/internal/sysmonitor/cpu_linux.go b/internal/sysmonitor/cpu_linux.go index 0c1f0e0..48c35f3 100644 --- a/internal/sysmonitor/cpu_linux.go +++ b/internal/sysmonitor/cpu_linux.go @@ -73,6 +73,9 @@ func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { // Normalized to 0-100% (divides by numCPU for system-wide metric) numcpu := runtime.NumCPU() + if numcpu <= 0 { + numcpu = 1 // Safety check + } percent := (cpuTimeSeconds / wallTimeSeconds) * 100.0 / float64(numcpu) if percent > 100.0 { diff --git a/internal/sysmonitor/memory_linux.go b/internal/sysmonitor/memory_linux.go index 2baa361..f7f8d8b 100644 --- a/internal/sysmonitor/memory_linux.go +++ b/internal/sysmonitor/memory_linux.go @@ -84,7 +84,10 @@ func readCgroupV2MemoryWithFS(fs FileSystem) (SystemMemory, error) { } // Parse memory.stat to find reclaimable memory (inactive_file) - inactiveFile, _ := readCgroupStatWithFS(fs, "/sys/fs/cgroup/memory.stat", "inactive_file") + inactiveFile, err := readCgroupStatWithFS(fs, "/sys/fs/cgroup/memory.stat", "inactive_file") + if err != nil { + inactiveFile = 0 // Default to 0 if unavailable + } // Available = (Limit - Usage) + Reclaimable // Note: If Limit - Usage is near zero, the kernel would reclaim inactive_file @@ -127,6 +130,7 @@ func readCgroupV1MemoryWithFS(fs FileSystem) (SystemMemory, error) { } // Parse memory.stat for V1 + // Note: total_inactive_file is optional - if unavailable, we continue with 0 (graceful degradation) inactiveFile, _ := readCgroupStatWithFS(fs, "/sys/fs/cgroup/memory/memory.stat", "total_inactive_file") // Handle case where usage exceeds limit @@ -184,6 +188,9 @@ func readCgroupStatWithFS(fs FileSystem, path string, key string) (uint64, error } } } + if err := scanner.Err(); err != nil { + return 0, fmt.Errorf("error reading %s: %w", path, err) + } return 0, fmt.Errorf("key %q not found in %s", key, path) } From fe90168cefbf3d6d35dc36d0e256bd02256c0397 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 16:18:18 +0300 Subject: [PATCH 18/54] refactor(sysmonitor): unify memory reading functions for cgroup v1 and v2 --- internal/sysmonitor/memory_linux.go | 135 ++++++++++++----------- internal/sysmonitor/memory_linux_test.go | 20 ++-- 2 files changed, 78 insertions(+), 77 deletions(-) diff --git a/internal/sysmonitor/memory_linux.go b/internal/sysmonitor/memory_linux.go index f7f8d8b..5d7a3e0 100644 --- a/internal/sysmonitor/memory_linux.go +++ b/internal/sysmonitor/memory_linux.go @@ -16,7 +16,6 @@ import ( var ( memoryReader MemoryReader fileSystem FileSystem - // memoryReaderMu protects concurrent access to memoryReader memoryReaderMu sync.RWMutex ) @@ -41,18 +40,60 @@ func GetSystemMemory() (SystemMemory, error) { return reader() } +// SetMemoryReader replaces the current memory reader (for testing) +func SetMemoryReader(reader MemoryReader) func() { + memoryReaderMu.Lock() + prev := memoryReader + memoryReader = reader + memoryReaderMu.Unlock() + return func() { + memoryReaderMu.Lock() + memoryReader = prev + memoryReaderMu.Unlock() + } +} + +type cgroupMemoryConfig struct { + usagePath string + limitPath string + statPath string + statKey string + version string + checkUnlimited bool +} + +var ( + cgroupV2Config = cgroupMemoryConfig{ + usagePath: "/sys/fs/cgroup/memory.current", + limitPath: "/sys/fs/cgroup/memory.max", + statPath: "/sys/fs/cgroup/memory.stat", + statKey: "inactive_file", + version: "v2", + checkUnlimited: false, + } + cgroupV1Config = cgroupMemoryConfig{ + usagePath: "/sys/fs/cgroup/memory/memory.usage_in_bytes", + limitPath: "/sys/fs/cgroup/memory/memory.limit_in_bytes", + statPath: "/sys/fs/cgroup/memory/memory.stat", + statKey: "total_inactive_file", + version: "v1", + checkUnlimited: true, + } +) + + // readSystemMemoryAuto detects the environment once and "upgrades" the reader func readSystemMemoryAuto() (SystemMemory, error) { - if m, err := readCgroupV2Memory(); err == nil { + if m, err := readCgroupMemoryWithFS(fileSystem, cgroupV2Config); err == nil { memoryReaderMu.Lock() - memoryReader = readCgroupV2Memory + memoryReader = makeCgroupV2Reader(fileSystem) memoryReaderMu.Unlock() return m, nil } - if m, err := readCgroupV1Memory(); err == nil { + if m, err := readCgroupMemoryWithFS(fileSystem, cgroupV1Config); err == nil { memoryReaderMu.Lock() - memoryReader = readCgroupV1Memory + memoryReader = makeCgroupV1Reader(fileSystem) memoryReaderMu.Unlock() return m, nil } @@ -68,71 +109,44 @@ func readSystemMemoryAuto() (SystemMemory, error) { return m, nil } -func readCgroupV2Memory() (SystemMemory, error) { - return readCgroupV2MemoryWithFS(fileSystem) -} - -func readCgroupV2MemoryWithFS(fs FileSystem) (SystemMemory, error) { - usage, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory.current") - if err != nil { - return SystemMemory{}, fmt.Errorf("failed to read cgroup v2 memory usage: %w", err) - } - - limit, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory.max") - if err != nil { - return SystemMemory{}, fmt.Errorf("failed to read cgroup v2 memory limit: %w", err) +// makeCgroupV2Reader creates a MemoryReader function for cgroup v2 +func makeCgroupV2Reader(fs FileSystem) MemoryReader { + return func() (SystemMemory, error) { + return readCgroupMemoryWithFS(fs, cgroupV2Config) } - - // Parse memory.stat to find reclaimable memory (inactive_file) - inactiveFile, err := readCgroupStatWithFS(fs, "/sys/fs/cgroup/memory.stat", "inactive_file") - if err != nil { - inactiveFile = 0 // Default to 0 if unavailable - } - - // Available = (Limit - Usage) + Reclaimable - // Note: If Limit - Usage is near zero, the kernel would reclaim inactive_file - // Handle case where usage exceeds limit - var available uint64 - if usage > limit { - available = inactiveFile // Only reclaimable memory is available - } else { - available = (limit - usage) + inactiveFile - } - - if available > limit { - available = limit - } - - return SystemMemory{ - Total: limit, - Available: available, - }, nil } -func readCgroupV1Memory() (SystemMemory, error) { - return readCgroupV1MemoryWithFS(fileSystem) +// makeCgroupV1Reader creates a MemoryReader function for cgroup v1 +func makeCgroupV1Reader(fs FileSystem) MemoryReader { + return func() (SystemMemory, error) { + return readCgroupMemoryWithFS(fs, cgroupV1Config) + } } -func readCgroupV1MemoryWithFS(fs FileSystem) (SystemMemory, error) { - usage, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory/memory.usage_in_bytes") +func readCgroupMemoryWithFS(fs FileSystem, config cgroupMemoryConfig) (SystemMemory, error) { + usage, err := readCgroupValueWithFS(fs, config.usagePath) if err != nil { - return SystemMemory{}, fmt.Errorf("failed to read cgroup v1 memory usage: %w", err) + return SystemMemory{}, fmt.Errorf("failed to read cgroup %s memory usage: %w", config.version, err) } - limit, err := readCgroupValueWithFS(fs, "/sys/fs/cgroup/memory/memory.limit_in_bytes") + limit, err := readCgroupValueWithFS(fs, config.limitPath) if err != nil { - return SystemMemory{}, fmt.Errorf("failed to read cgroup v1 memory limit: %w", err) + return SystemMemory{}, fmt.Errorf("failed to read cgroup %s memory limit: %w", config.version, err) } // Check for "unlimited" (random huge number in V1) - if limit > (1 << 60) { + if config.checkUnlimited && limit > (1<<60) { return SystemMemory{}, os.ErrNotExist } - // Parse memory.stat for V1 - // Note: total_inactive_file is optional - if unavailable, we continue with 0 (graceful degradation) - inactiveFile, _ := readCgroupStatWithFS(fs, "/sys/fs/cgroup/memory/memory.stat", "total_inactive_file") + // Parse memory.stat to find reclaimable memory + inactiveFile, err := readCgroupStatWithFS(fs, config.statPath, config.statKey) + if err != nil { + inactiveFile = 0 // Default to 0 if unavailable + } + // Available = (Limit - Usage) + Reclaimable + // Note: If Limit - Usage is near zero, the kernel would reclaim inactive_file // Handle case where usage exceeds limit var available uint64 if usage > limit { @@ -258,16 +272,3 @@ func parseMemInfo(r io.Reader) (SystemMemory, error) { Available: available, }, nil } - -// SetMemoryReader replaces the current memory reader (for testing) -func SetMemoryReader(reader MemoryReader) func() { - memoryReaderMu.Lock() - prev := memoryReader - memoryReader = reader - memoryReaderMu.Unlock() - return func() { - memoryReaderMu.Lock() - memoryReader = prev - memoryReaderMu.Unlock() - } -} diff --git a/internal/sysmonitor/memory_linux_test.go b/internal/sysmonitor/memory_linux_test.go index 3e64fb4..21c1fb1 100644 --- a/internal/sysmonitor/memory_linux_test.go +++ b/internal/sysmonitor/memory_linux_test.go @@ -52,9 +52,9 @@ func TestReadCgroupV2Memory(t *testing.T) { "/sys/fs/cgroup/memory.stat": "inactive_file 104857600\n", // 100MB } - mem, err := readCgroupV2MemoryWithFS(mockFS) + mem, err := readCgroupMemoryWithFS(mockFS, cgroupV2Config) if err != nil { - t.Fatalf("readCgroupV2MemoryWithFS failed: %v", err) + t.Fatalf("readCgroupMemoryWithFS failed: %v", err) } expectedTotal := uint64(2147483648) // 2GB @@ -76,9 +76,9 @@ func TestReadCgroupV1Memory(t *testing.T) { "/sys/fs/cgroup/memory/memory.stat": "total_inactive_file 104857600\n", // 100MB } - mem, err := readCgroupV1MemoryWithFS(mockFS) + mem, err := readCgroupMemoryWithFS(mockFS, cgroupV1Config) if err != nil { - t.Fatalf("readCgroupV1MemoryWithFS failed: %v", err) + t.Fatalf("readCgroupMemoryWithFS failed: %v", err) } expectedTotal := uint64(2147483648) // 2GB @@ -145,9 +145,9 @@ func TestReadCgroupV2Memory_UsageExceedsLimit(t *testing.T) { "/sys/fs/cgroup/memory.stat": "inactive_file 104857600\n", // 100MB } - mem, err := readCgroupV2MemoryWithFS(mockFS) + mem, err := readCgroupMemoryWithFS(mockFS, cgroupV2Config) if err != nil { - t.Fatalf("readCgroupV2MemoryWithFS failed: %v", err) + t.Fatalf("readCgroupMemoryWithFS failed: %v", err) } expectedTotal := uint64(2147483648) // 2GB @@ -169,7 +169,7 @@ func TestReadCgroupV2Memory_Unlimited(t *testing.T) { "/sys/fs/cgroup/memory.stat": "inactive_file 104857600\n", // 100MB } - _, err := readCgroupV2MemoryWithFS(mockFS) + _, err := readCgroupMemoryWithFS(mockFS, cgroupV2Config) if err == nil { t.Error("Expected error for unlimited memory (max)") } @@ -182,9 +182,9 @@ func TestReadCgroupV2Memory_MissingStatFile(t *testing.T) { // memory.stat is missing } - mem, err := readCgroupV2MemoryWithFS(mockFS) + mem, err := readCgroupMemoryWithFS(mockFS, cgroupV2Config) if err != nil { - t.Fatalf("readCgroupV2MemoryWithFS failed: %v", err) + t.Fatalf("readCgroupMemoryWithFS failed: %v", err) } expectedTotal := uint64(2147483648) // 2GB @@ -206,7 +206,7 @@ func TestReadCgroupV1Memory_Unlimited(t *testing.T) { "/sys/fs/cgroup/memory/memory.stat": "total_inactive_file 104857600\n", // 100MB } - _, err := readCgroupV1MemoryWithFS(mockFS) + _, err := readCgroupMemoryWithFS(mockFS, cgroupV1Config) if err == nil { t.Error("Expected error for unlimited memory") } From 5ab4ea198f515e755953c0aaefe3dab3bb0b9f3f Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 16:38:01 +0300 Subject: [PATCH 19/54] refactor(sysmonitor): streamline memory reading logic and remove unused cpuTimesStat struct --- internal/sysmonitor/cpu_darwin.go | 8 +-- internal/sysmonitor/memory_linux.go | 67 ++++++++++++++---------- internal/sysmonitor/memory_linux_test.go | 4 +- 3 files changed, 41 insertions(+), 38 deletions(-) diff --git a/internal/sysmonitor/cpu_darwin.go b/internal/sysmonitor/cpu_darwin.go index aa3b8f2..3a58469 100644 --- a/internal/sysmonitor/cpu_darwin.go +++ b/internal/sysmonitor/cpu_darwin.go @@ -11,12 +11,6 @@ import ( "time" ) -// cpuTimesStat holds CPU time in seconds -type cpuTimesStat struct { - User float64 - System float64 -} - // ProcessSampler samples CPU usage for the current process type ProcessSampler struct { pid int @@ -113,4 +107,4 @@ func (s *ProcessSampler) readProcessTimesDarwin() (utime, stime float64, err err utime = float64(rusage.Utime.Sec) + float64(rusage.Utime.Usec)/1e6 stime = float64(rusage.Stime.Sec) + float64(rusage.Stime.Usec)/1e6 return utime, stime, nil -} \ No newline at end of file +} diff --git a/internal/sysmonitor/memory_linux.go b/internal/sysmonitor/memory_linux.go index 5d7a3e0..4c1148f 100644 --- a/internal/sysmonitor/memory_linux.go +++ b/internal/sysmonitor/memory_linux.go @@ -14,28 +14,28 @@ import ( ) var ( - memoryReader MemoryReader - fileSystem FileSystem - memoryReaderMu sync.RWMutex + memoryReader MemoryReader + fileSystem FileSystem + memoryReaderMu sync.RWMutex + memoryReaderOnce sync.Once ) // GetSystemMemory returns the current system memory statistics. // This function auto-detects the environment (cgroup v2, v1, or host) // and returns appropriate memory information. func GetSystemMemory() (SystemMemory, error) { - memoryReaderMu.RLock() - reader := memoryReader - memoryReaderMu.RUnlock() - - if reader == nil { + memoryReaderOnce.Do(func() { memoryReaderMu.Lock() if memoryReader == nil { memoryReader = readSystemMemoryAuto fileSystem = OSFileSystem{} } - reader = memoryReader memoryReaderMu.Unlock() - } + }) + + memoryReaderMu.RLock() + reader := memoryReader + memoryReaderMu.RUnlock() return reader() } @@ -81,7 +81,6 @@ var ( } ) - // readSystemMemoryAuto detects the environment once and "upgrades" the reader func readSystemMemoryAuto() (SystemMemory, error) { if m, err := readCgroupMemoryWithFS(fileSystem, cgroupV2Config); err == nil { @@ -124,21 +123,16 @@ func makeCgroupV1Reader(fs FileSystem) MemoryReader { } func readCgroupMemoryWithFS(fs FileSystem, config cgroupMemoryConfig) (SystemMemory, error) { - usage, err := readCgroupValueWithFS(fs, config.usagePath) + usage, err := readCgroupValueWithFS(fs, config.usagePath, false) if err != nil { return SystemMemory{}, fmt.Errorf("failed to read cgroup %s memory usage: %w", config.version, err) } - limit, err := readCgroupValueWithFS(fs, config.limitPath) + limit, err := readCgroupValueWithFS(fs, config.limitPath, config.checkUnlimited) if err != nil { return SystemMemory{}, fmt.Errorf("failed to read cgroup %s memory limit: %w", config.version, err) } - // Check for "unlimited" (random huge number in V1) - if config.checkUnlimited && limit > (1<<60) { - return SystemMemory{}, os.ErrNotExist - } - // Parse memory.stat to find reclaimable memory inactiveFile, err := readCgroupStatWithFS(fs, config.statPath, config.statKey) if err != nil { @@ -165,19 +159,23 @@ func readCgroupMemoryWithFS(fs FileSystem, config cgroupMemoryConfig) (SystemMem }, nil } -func readCgroupValueWithFS(fs FileSystem, path string) (uint64, error) { +func readCgroupValueWithFS(fs FileSystem, path string, checkUnlimited bool) (uint64, error) { data, err := fs.ReadFile(path) if err != nil { return 0, err } str := strings.TrimSpace(string(data)) if str == "max" { - return 0, os.ErrNotExist + return 0, fmt.Errorf("unlimited memory limit") } val, err := strconv.ParseUint(str, 10, 64) if err != nil { return 0, fmt.Errorf("failed to parse value %q from %s: %w", str, path, err) } + // Check for "unlimited" (random huge number in V1) + if checkUnlimited && val > (1<<60) { + return 0, fmt.Errorf("unlimited memory limit") + } return val, nil } @@ -223,8 +221,8 @@ func readSystemMemory() (SystemMemory, error) { func parseMemInfo(r io.Reader) (SystemMemory, error) { scanner := bufio.NewScanner(r) - var total, available uint64 - found := 0 + var total, available, free, cached uint64 + memAvailableFound := false for scanner.Scan() { line := scanner.Text() @@ -246,13 +244,17 @@ func parseMemInfo(r io.Reader) (SystemMemory, error) { switch key { case "MemTotal": total = value - found++ case "MemAvailable": available = value - found++ + memAvailableFound = true + case "MemFree": + free = value + case "Cached": + cached = value } - if found == 2 { + // Early exit if we have MemTotal and MemAvailable (kernel 3.14+) + if total > 0 && memAvailableFound { break } } @@ -261,10 +263,17 @@ func parseMemInfo(r io.Reader) (SystemMemory, error) { return SystemMemory{}, fmt.Errorf("error reading meminfo: %w", err) } - if found != 2 { - return SystemMemory{}, fmt.Errorf( - "could not find MemTotal and MemAvailable in /proc/meminfo (found %d of 2 required fields)", - found) + if total == 0 { + return SystemMemory{}, fmt.Errorf("could not find MemTotal in /proc/meminfo") + } + + // Fallback calculation for MemAvailable + if !memAvailableFound { + available = free + cached + if available == 0 { + return SystemMemory{}, fmt.Errorf( + "could not find MemAvailable in /proc/meminfo and fallback calculation failed (MemFree and Cached not found or both zero)") + } } return SystemMemory{ diff --git a/internal/sysmonitor/memory_linux_test.go b/internal/sysmonitor/memory_linux_test.go index 21c1fb1..78d1e00 100644 --- a/internal/sysmonitor/memory_linux_test.go +++ b/internal/sysmonitor/memory_linux_test.go @@ -98,7 +98,7 @@ func TestReadCgroupValueWithFS(t *testing.T) { "/test/file": "123456\n", } - value, err := readCgroupValueWithFS(mockFS, "/test/file") + value, err := readCgroupValueWithFS(mockFS, "/test/file", false) if err != nil { t.Fatalf("readCgroupValueWithFS failed: %v", err) } @@ -110,7 +110,7 @@ func TestReadCgroupValueWithFS(t *testing.T) { // Test "max" value (unlimited) mockFS["/test/max"] = "max\n" - _, err = readCgroupValueWithFS(mockFS, "/test/max") + _, err = readCgroupValueWithFS(mockFS, "/test/max", false) if err == nil { t.Error("Expected error for 'max' value") } From a92689dca53ba63c9c21a3622e3dc90f365a2565 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 16:44:12 +0300 Subject: [PATCH 20/54] refactor(sysmonitor): additional context for errors was added --- internal/sysmonitor/cpu_darwin.go | 2 +- internal/sysmonitor/cpu_linux.go | 11 ++++++----- internal/sysmonitor/cpu_windows.go | 8 ++++---- internal/sysmonitor/memory_linux.go | 4 ++-- internal/sysmonitor/memory_windows.go | 4 ++-- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/internal/sysmonitor/cpu_darwin.go b/internal/sysmonitor/cpu_darwin.go index 3a58469..3f07030 100644 --- a/internal/sysmonitor/cpu_darwin.go +++ b/internal/sysmonitor/cpu_darwin.go @@ -101,7 +101,7 @@ func (s *ProcessSampler) readProcessTimesDarwin() (utime, stime float64, err err var rusage syscall.Rusage err = syscall.Getrusage(syscall.RUSAGE_SELF, &rusage) if err != nil { - return 0, 0, err + return 0, 0, fmt.Errorf("failed to get process resource usage: %w", err) } utime = float64(rusage.Utime.Sec) + float64(rusage.Utime.Usec)/1e6 diff --git a/internal/sysmonitor/cpu_linux.go b/internal/sysmonitor/cpu_linux.go index 48c35f3..b6a20ee 100644 --- a/internal/sysmonitor/cpu_linux.go +++ b/internal/sysmonitor/cpu_linux.go @@ -106,15 +106,16 @@ func (s *ProcessSampler) IsInitialized() bool { // readProcessTimes reads CPU times from /proc//stat (returns ticks) func (s *ProcessSampler) readProcessTimes() (utime, stime int64, err error) { - file, err := os.Open(fmt.Sprintf("/proc/%d/stat", s.pid)) + path := fmt.Sprintf("/proc/%d/stat", s.pid) + file, err := os.Open(path) if err != nil { - return 0, 0, err + return 0, 0, fmt.Errorf("failed to open file %s: %w", path, err) } defer file.Close() content, err := io.ReadAll(file) if err != nil { - return 0, 0, err + return 0, 0, fmt.Errorf("failed to read file %s: %w", path, err) } fields := strings.Fields(string(content)) @@ -127,12 +128,12 @@ func (s *ProcessSampler) readProcessTimes() (utime, stime int64, err error) { // utime=field[13], stime=field[14] utime, err = strconv.ParseInt(fields[13], 10, 64) if err != nil { - return 0, 0, err + return 0, 0, fmt.Errorf("failed to parse utime from field[13] in /proc/%d/stat: %w", s.pid, err) } stime, err = strconv.ParseInt(fields[14], 10, 64) if err != nil { - return 0, 0, err + return 0, 0, fmt.Errorf("failed to parse stime from field[14] in /proc/%d/stat: %w", s.pid, err) } return utime, stime, nil diff --git a/internal/sysmonitor/cpu_windows.go b/internal/sysmonitor/cpu_windows.go index 1660dd7..9da8798 100644 --- a/internal/sysmonitor/cpu_windows.go +++ b/internal/sysmonitor/cpu_windows.go @@ -115,7 +115,7 @@ func getProcessCPUTimes(pid int) (syscall.Filetime, syscall.Filetime, error) { var err error h, err = syscall.GetCurrentProcess() if err != nil { - return k, u, err + return k, u, fmt.Errorf("failed to get current process handle: %w", err) } // GetCurrentProcess returns a pseudo-handle that doesn't need to be closed } else { @@ -128,7 +128,7 @@ func getProcessCPUTimes(pid int) (syscall.Filetime, syscall.Filetime, error) { // Fallback to PROCESS_QUERY_INFORMATION h, err = syscall.OpenProcess(syscall.PROCESS_QUERY_INFORMATION, false, uint32(pid)) if err != nil { - return k, u, err + return k, u, fmt.Errorf("failed to open process %d: %w", pid, err) } } defer syscall.CloseHandle(h) @@ -136,7 +136,7 @@ func getProcessCPUTimes(pid int) (syscall.Filetime, syscall.Filetime, error) { err := syscall.GetProcessTimes(h, &c, &e, &k, &u) if err != nil { - return k, u, err + return k, u, fmt.Errorf("failed to get process times for PID %d: %w", pid, err) } // Validate that we got non-zero times (unless process just started) @@ -154,7 +154,7 @@ func convertFiletimeToSeconds(ft syscall.Filetime) float64 { func (s *ProcessSampler) getCurrentCPUTimes() (utime, stime float64, err error) { k, u, err := getProcessCPUTimes(s.pid) if err != nil { - return 0, 0, err + return 0, 0, fmt.Errorf("failed to get CPU times for process %d: %w", s.pid, err) } utime = convertFiletimeToSeconds(u) diff --git a/internal/sysmonitor/memory_linux.go b/internal/sysmonitor/memory_linux.go index 4c1148f..a8d7edd 100644 --- a/internal/sysmonitor/memory_linux.go +++ b/internal/sysmonitor/memory_linux.go @@ -162,7 +162,7 @@ func readCgroupMemoryWithFS(fs FileSystem, config cgroupMemoryConfig) (SystemMem func readCgroupValueWithFS(fs FileSystem, path string, checkUnlimited bool) (uint64, error) { data, err := fs.ReadFile(path) if err != nil { - return 0, err + return 0, fmt.Errorf("failed to read file %s: %w", path, err) } str := strings.TrimSpace(string(data)) if str == "max" { @@ -182,7 +182,7 @@ func readCgroupValueWithFS(fs FileSystem, path string, checkUnlimited bool) (uin func readCgroupStatWithFS(fs FileSystem, path string, key string) (uint64, error) { f, err := fs.Open(path) if err != nil { - return 0, err + return 0, fmt.Errorf("failed to open file %s: %w", path, err) } defer f.Close() diff --git a/internal/sysmonitor/memory_windows.go b/internal/sysmonitor/memory_windows.go index 87f8862..b23425f 100644 --- a/internal/sysmonitor/memory_windows.go +++ b/internal/sysmonitor/memory_windows.go @@ -3,6 +3,7 @@ package sysmonitor import ( + "fmt" "sync" "syscall" "unsafe" @@ -13,7 +14,6 @@ var ( procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") memoryReader = getSystemMemoryWindows - // memoryReaderMu protects concurrent access to memoryReader memoryReaderMu sync.RWMutex ) @@ -45,7 +45,7 @@ func getSystemMemoryWindows() (SystemMemory, error) { // If the function fails, the return value is zero. if ret == 0 { - return SystemMemory{}, err + return SystemMemory{}, fmt.Errorf("failed to get system memory status via GlobalMemoryStatusEx: %w", err) } return SystemMemory{ From c460ee34c7836bf1261eea002f3baa593e4ee93e Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 16:55:56 +0300 Subject: [PATCH 21/54] refactor(sysmonitor): remove outdated validation comment in getProcessCPUTimes function --- internal/sysmonitor/cpu_windows.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/sysmonitor/cpu_windows.go b/internal/sysmonitor/cpu_windows.go index 9da8798..8be8435 100644 --- a/internal/sysmonitor/cpu_windows.go +++ b/internal/sysmonitor/cpu_windows.go @@ -139,8 +139,6 @@ func getProcessCPUTimes(pid int) (syscall.Filetime, syscall.Filetime, error) { return k, u, fmt.Errorf("failed to get process times for PID %d: %w", pid, err) } - // Validate that we got non-zero times (unless process just started) - // This helps catch cases where the API call succeeded but returned invalid data return k, u, nil } From 4eb9507912c75fe2b4599694e2300ca640d4215a Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 16:56:12 +0300 Subject: [PATCH 22/54] fix(sysmonitor): add overflow check for memory value conversion --- internal/sysmonitor/memory_linux.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/sysmonitor/memory_linux.go b/internal/sysmonitor/memory_linux.go index a8d7edd..8c246a9 100644 --- a/internal/sysmonitor/memory_linux.go +++ b/internal/sysmonitor/memory_linux.go @@ -239,6 +239,11 @@ func parseMemInfo(r io.Reader) (SystemMemory, error) { } // Convert from kB to bytes + // Check for overflow + const maxValueBeforeOverflow = (1<<64 - 1) / 1024 + if value > maxValueBeforeOverflow { + return SystemMemory{}, fmt.Errorf("memory value too large: %d kB would overflow when converting to bytes", value) + } value *= 1024 switch key { From 2e8e86a294e68cc5e498de3af1a595f7d6a44757 Mon Sep 17 00:00:00 2001 From: Kirill <74421236+kxrxh@users.noreply.github.com> Date: Sat, 22 Nov 2025 16:57:54 +0300 Subject: [PATCH 23/54] fix(sysmonitor): enhance error handling in memory status retrieval Co-authored-by: Eugene R. --- internal/sysmonitor/memory_windows.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/sysmonitor/memory_windows.go b/internal/sysmonitor/memory_windows.go index b23425f..bcd4f73 100644 --- a/internal/sysmonitor/memory_windows.go +++ b/internal/sysmonitor/memory_windows.go @@ -44,7 +44,7 @@ func getSystemMemoryWindows() (SystemMemory, error) { ret, _, err := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&memStatus))) // If the function fails, the return value is zero. - if ret == 0 { + if ret == 0 || err != nil { return SystemMemory{}, fmt.Errorf("failed to get system memory status via GlobalMemoryStatusEx: %w", err) } From 5df55d29e4e76eb50490814c0c8ffa94febecbfe Mon Sep 17 00:00:00 2001 From: Kirill <74421236+kxrxh@users.noreply.github.com> Date: Sat, 22 Nov 2025 16:59:07 +0300 Subject: [PATCH 24/54] docs(adaptive_throttler): update demo comments to clarify adaptive throttling process Co-authored-by: Eugene R. --- examples/adaptive_throttler/demo/demo.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/adaptive_throttler/demo/demo.go b/examples/adaptive_throttler/demo/demo.go index 78ce424..467a430 100644 --- a/examples/adaptive_throttler/demo/demo.go +++ b/examples/adaptive_throttler/demo/demo.go @@ -10,6 +10,13 @@ import ( "github.com/reugn/go-streams/flow" ) +// main demonstrates adaptive throttling with simulated resource pressure. +// The demo: +// 1. Produces 250 elements in bursts +// 2. Processes elements with CPU-intensive work (50ms each) +// 3. Simulates increasing memory pressure as more elements are processed +// 4. The adaptive throttler adjusts throughput based on CPU/memory usage +// 5. Stats are logged every 500ms showing rate adaptation func main() { var elementsProcessed atomic.Int64 From aaf9377a7fc28ff17f9ab5c78f1e3be810e91f6d Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 17:00:16 +0300 Subject: [PATCH 25/54] refactor(resource_monitor_test): remove unnecessary helper call in test function --- flow/resource_monitor_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/flow/resource_monitor_test.go b/flow/resource_monitor_test.go index ec60ff2..9f13bbb 100644 --- a/flow/resource_monitor_test.go +++ b/flow/resource_monitor_test.go @@ -223,8 +223,6 @@ func waitForStats( } func TestResourceMonitor_UsesSystemMemoryStats(t *testing.T) { - t.Helper() - restore := sysmonitor.SetMemoryReader(func() (sysmonitor.SystemMemory, error) { return sysmonitor.SystemMemory{ Total: 100 * 1024 * 1024, From a43751892548802b9b3baf428e56dd031e680713 Mon Sep 17 00:00:00 2001 From: Kirill <74421236+kxrxh@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:01:48 +0300 Subject: [PATCH 26/54] refactor(resource_monitor): simplify comment for division by zero check Co-authored-by: Eugene R. --- flow/resource_monitor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow/resource_monitor.go b/flow/resource_monitor.go index 052ee8d..1024f98 100644 --- a/flow/resource_monitor.go +++ b/flow/resource_monitor.go @@ -190,7 +190,7 @@ func (rm *ResourceMonitor) memoryUsagePercent( available = sysStats.Total } - // Defensive programming: avoid division by zero + // avoid division by zero if sysStats.Total == 0 { return 0 } From 08f6012937683d720cbc052551d971717958bcb0 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 17:05:59 +0300 Subject: [PATCH 27/54] chore(dependencies): downgrade Go version to 1.23.0 and update indirect dependencies --- examples/go.mod | 12 +++--------- examples/go.sum | 36 +++++++++++++++++------------------- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/examples/go.mod b/examples/go.mod index ac73c1f..f31f917 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -1,6 +1,6 @@ module github.com/reugn/go-streams/examples -go 1.24.0 +go 1.23.0 require ( cloud.google.com/go/storage v1.53.0 @@ -80,7 +80,6 @@ require ( github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect @@ -105,7 +104,6 @@ require ( github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect - github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/minio/highwayhash v1.0.3 // indirect @@ -120,7 +118,6 @@ require ( github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_golang v1.20.5 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect @@ -129,11 +126,8 @@ require ( github.com/sirupsen/logrus v1.9.3 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect - github.com/stretchr/testify v1.11.1 // indirect - github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad // indirect github.com/yuin/gopher-lua v1.1.1 // indirect - github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect @@ -150,7 +144,7 @@ require ( golang.org/x/net v0.39.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.14.0 // indirect - golang.org/x/sys v0.38.0 // indirect + golang.org/x/sys v0.32.0 // indirect golang.org/x/term v0.31.0 // indirect golang.org/x/text v0.24.0 // indirect golang.org/x/time v0.11.0 // indirect @@ -172,7 +166,6 @@ require ( ) replace ( - github.com/reugn/go-streams => .. github.com/reugn/go-streams/aerospike => ../aerospike github.com/reugn/go-streams/aws => ../aws github.com/reugn/go-streams/azure => ../azure @@ -182,4 +175,5 @@ replace ( github.com/reugn/go-streams/pulsar => ../pulsar github.com/reugn/go-streams/redis => ../redis github.com/reugn/go-streams/websocket => ../websocket + github.com/reugn/go-streams => .. ) diff --git a/examples/go.sum b/examples/go.sum index 638ae2c..391f03e 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -175,9 +175,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +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/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= @@ -267,8 +266,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= -github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -344,8 +343,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +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/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= @@ -362,6 +361,8 @@ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5X github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/reugn/go-streams v0.12.0 h1:SEfTZw5+iP0mQG0jWTxVOHMbqlbZjUB0W6dF3XbYd5Q= +github.com/reugn/go-streams v0.12.0/go.mod h1:dnXv6QgVTW62gEpILoLHRjI95Es7ECK2/+j9h17aIN8= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= @@ -387,14 +388,14 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= -github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= -github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= -github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= -github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +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/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad h1:W0LEBv82YCGEtcmPA3uNZBI33/qF//HAAs3MawDjRa0= github.com/wadey/gocovmerge v0.0.0-20160331181800-b5bfa59ec0ad/go.mod h1:Hy8o65+MXnS6EwGElrSRjUzQDLXreJlzYLlWiHtt8hM= @@ -403,8 +404,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= -github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= -github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= @@ -469,24 +470,21 @@ golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= From e7a1c81100af9e0a5163ad237eefe6f6b1212c4b Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 17:10:43 +0300 Subject: [PATCH 28/54] feat(adaptive_throttler): enforce minimum sample interval to reduce CPU overhead --- flow/adaptive_throttler.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index e82e0f0..4c6adaf 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -10,6 +10,12 @@ import ( "github.com/reugn/go-streams" ) +const ( + // minSampleInterval is the minimum allowed sampling interval to prevent + // excessive CPU overhead from too-frequent system resource polling. + minSampleInterval = 10 * time.Millisecond +) + // AdaptiveThrottlerConfig configures the adaptive throttler behavior type AdaptiveThrottlerConfig struct { // Resource thresholds (0-100 percentage) @@ -149,8 +155,8 @@ func NewAdaptiveThrottler(config *AdaptiveThrottlerConfig) (*AdaptiveThrottler, if config.BufferSize < 1 { return nil, fmt.Errorf("invalid BufferSize: %d", config.BufferSize) } - if config.SampleInterval <= 0 { - return nil, fmt.Errorf("invalid SampleInterval: %v", config.SampleInterval) + if config.SampleInterval < minSampleInterval { + return nil, fmt.Errorf("invalid SampleInterval: %v; must be at least %v to prevent high CPU overhead", config.SampleInterval, minSampleInterval) } if config.HysteresisBuffer < 0 { return nil, fmt.Errorf("invalid HysteresisBuffer: %f", config.HysteresisBuffer) From 226098eabfd1252bc6304f39ad83718213490e31 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 17:14:16 +0300 Subject: [PATCH 29/54] fix(adaptive_throttler): prevent negative target rate in rate adaptation --- flow/adaptive_throttler.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 4c6adaf..d4ecc98 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -245,6 +245,11 @@ func (at *AdaptiveThrottler) adaptRate() { reduction := math.Min(currentRate*reductionFactor, maxReduction) targetRate = currentRate - reduction + + // Avoid negative rates + if targetRate < 0 { + targetRate = 0 + } } else { // Increase rate when resources are available, with hysteresis memoryHeadroom := at.config.MaxMemoryPercent - stats.MemoryUsedPercent From 0707bb032317815802ccf22f2b15d44f35ca92c4 Mon Sep 17 00:00:00 2001 From: Kirill <74421236+kxrxh@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:15:19 +0300 Subject: [PATCH 30/54] docs(adaptive_throttler): clarify comment on MemoryReader function requirement Co-authored-by: Eugene R. --- flow/adaptive_throttler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index d4ecc98..48f45f5 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -63,7 +63,7 @@ type AdaptiveThrottlerConfig struct { // MemoryReader provides memory usage percentage for containerized deployments. // If nil, system memory will be read via mem.VirtualMemory(). - // Should return memory used percentage (0-100). + // Must return memory used percentage (0-100). MemoryReader func() (float64, error) } From df7560a4f20508bfe13f8b05a2d4a5d465aba838 Mon Sep 17 00:00:00 2001 From: Kirill <74421236+kxrxh@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:16:21 +0300 Subject: [PATCH 31/54] docs(adaptive_throttler): format comment for CPU usage sampling modes Co-authored-by: Eugene R. --- flow/adaptive_throttler.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 48f45f5..172fbec 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -44,8 +44,8 @@ type AdaptiveThrottlerConfig struct { // CPU usage sampling mode. // - // CPUUsageModeHeuristic: Estimates CPU usage using a simple heuristic (goroutine count), suitable for platforms - // where accurate process CPU measurement is not supported. + // CPUUsageModeHeuristic: Estimates CPU usage using a simple heuristic (goroutine count), + // suitable for platforms where accurate process CPU measurement is not supported. // // CPUUsageModeMeasured: Attempts to measure actual process CPU usage natively // (when supported), providing more accurate CPU usage readings. From 2964497eef5c8ae91a7ee0a46246d5057a98f0b1 Mon Sep 17 00:00:00 2001 From: Kirill <74421236+kxrxh@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:17:19 +0300 Subject: [PATCH 32/54] feat(resource_monitor): add comment to indicate start of periodic resource statistics collection Co-authored-by: Eugene R. --- flow/resource_monitor.go | 1 + 1 file changed, 1 insertion(+) diff --git a/flow/resource_monitor.go b/flow/resource_monitor.go index 1024f98..faac0ec 100644 --- a/flow/resource_monitor.go +++ b/flow/resource_monitor.go @@ -90,6 +90,7 @@ func NewResourceMonitor( rm.initSampler() + // start periodically collecting resource statistics go rm.monitor() return rm From a5fb0d41fe81f57a09d976e3e80e32f1312ee8cf Mon Sep 17 00:00:00 2001 From: Kirill <74421236+kxrxh@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:18:29 +0300 Subject: [PATCH 33/54] docs(adaptive_throttler): update MemoryReader comment for clarity on its functionality Co-authored-by: Eugene R. --- flow/adaptive_throttler.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 172fbec..10f1228 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -61,7 +61,10 @@ type AdaptiveThrottlerConfig struct { // Default: 0.3 (max 30% change per cycle) MaxRateChangeFactor float64 - // MemoryReader provides memory usage percentage for containerized deployments. + // MemoryReader is a user-provided custom function that returns memory usage percentage. + // This can be particularly useful for containerized deployments or other environments + // where standard system memory readings may not accurately reflect container-specific + // usage. // If nil, system memory will be read via mem.VirtualMemory(). // Must return memory used percentage (0-100). MemoryReader func() (float64, error) From 95ce780b921e6afbb730c839e4a16f4ec51e6a6a Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 17:20:18 +0300 Subject: [PATCH 34/54] chore(dependencies): remove go.sum and update go.mod --- examples/go.mod | 2 +- examples/go.sum | 2 -- go.sum | 0 3 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 go.sum diff --git a/examples/go.mod b/examples/go.mod index f31f917..a203164 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -166,6 +166,7 @@ require ( ) replace ( + github.com/reugn/go-streams => .. github.com/reugn/go-streams/aerospike => ../aerospike github.com/reugn/go-streams/aws => ../aws github.com/reugn/go-streams/azure => ../azure @@ -175,5 +176,4 @@ replace ( github.com/reugn/go-streams/pulsar => ../pulsar github.com/reugn/go-streams/redis => ../redis github.com/reugn/go-streams/websocket => ../websocket - github.com/reugn/go-streams => .. ) diff --git a/examples/go.sum b/examples/go.sum index 391f03e..02982e0 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -361,8 +361,6 @@ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5X github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= -github.com/reugn/go-streams v0.12.0 h1:SEfTZw5+iP0mQG0jWTxVOHMbqlbZjUB0W6dF3XbYd5Q= -github.com/reugn/go-streams v0.12.0/go.mod h1:dnXv6QgVTW62gEpILoLHRjI95Es7ECK2/+j9h17aIN8= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= diff --git a/go.sum b/go.sum deleted file mode 100644 index e69de29..0000000 From 8fa0e0f2eb1d4a09364704d06fd643f2b49e7d6a Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 17:22:01 +0300 Subject: [PATCH 35/54] refactor(resource_monitor): move clampPercent and validatePercent functions to resource_monitor.go --- flow/resource_monitor.go | 23 ++++++++++++++++++++++- flow/util.go | 21 --------------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/flow/resource_monitor.go b/flow/resource_monitor.go index faac0ec..fe738fc 100644 --- a/flow/resource_monitor.go +++ b/flow/resource_monitor.go @@ -2,6 +2,7 @@ package flow import ( "fmt" + "math" "runtime" "sync" "sync/atomic" @@ -90,7 +91,7 @@ func NewResourceMonitor( rm.initSampler() - // start periodically collecting resource statistics + // start periodically collecting resource statistics go rm.monitor() return rm @@ -264,3 +265,23 @@ func (rm *ResourceMonitor) Close() { close(rm.done) } } + +// clampPercent clamps a percentage value between 0 and 100. +func clampPercent(percent float64) float64 { + if percent < 0 { + return 0 + } + if percent > 100 { + return 100 + } + return percent +} + +// validatePercent validates and normalizes a percentage value. +// It checks for NaN or Inf values and clamps the result between 0 and 100. +func validatePercent(percent float64) float64 { + if math.IsNaN(percent) || math.IsInf(percent, 0) { + return 0 + } + return clampPercent(percent) +} diff --git a/flow/util.go b/flow/util.go index 8feb59b..825ce9b 100644 --- a/flow/util.go +++ b/flow/util.go @@ -2,7 +2,6 @@ package flow import ( "fmt" - "math" "sync" "github.com/reugn/go-streams" @@ -178,23 +177,3 @@ func Flatten[T any](parallelism int) streams.Flow { return element }, parallelism) } - -// clampPercent clamps a percentage value between 0 and 100. -func clampPercent(percent float64) float64 { - if percent < 0 { - return 0 - } - if percent > 100 { - return 100 - } - return percent -} - -// validatePercent validates and normalizes a percentage value. -// It checks for NaN or Inf values and clamps the result between 0 and 100. -func validatePercent(percent float64) float64 { - if math.IsNaN(percent) || math.IsInf(percent, 0) { - return 0 - } - return clampPercent(percent) -} From 77b55f2b842351f9cef2e533b43e0eeb011f3176 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 17:24:22 +0300 Subject: [PATCH 36/54] refactor(flow): rename util.go to operators.go for better clarity --- flow/{util.go => operators.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename flow/{util.go => operators.go} (100%) diff --git a/flow/util.go b/flow/operators.go similarity index 100% rename from flow/util.go rename to flow/operators.go From 9b5f43812a6b60764f917c99dd91bb70c8ddbfd2 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 17:34:54 +0300 Subject: [PATCH 37/54] refactor(adaptive_throttler): remove unnecessary pointer dereference in NewAdaptiveThrottler calls --- examples/adaptive_throttler/demo/demo.go | 2 +- examples/adaptive_throttler/main.go | 2 +- flow/adaptive_throttler.go | 13 ++++++------- flow/adaptive_throttler_test.go | 17 ++++++----------- 4 files changed, 14 insertions(+), 20 deletions(-) diff --git a/examples/adaptive_throttler/demo/demo.go b/examples/adaptive_throttler/demo/demo.go index 467a430..14123b9 100644 --- a/examples/adaptive_throttler/demo/demo.go +++ b/examples/adaptive_throttler/demo/demo.go @@ -104,7 +104,7 @@ func setupDemoThrottler(elementsProcessed *atomic.Int64) *flow.AdaptiveThrottler return memoryPercent, nil } - throttler, err := flow.NewAdaptiveThrottler(&config) + throttler, err := flow.NewAdaptiveThrottler(config) if err != nil { panic(fmt.Sprintf("failed to create adaptive throttler: %v", err)) } diff --git a/examples/adaptive_throttler/main.go b/examples/adaptive_throttler/main.go index 4943b67..bf5473b 100644 --- a/examples/adaptive_throttler/main.go +++ b/examples/adaptive_throttler/main.go @@ -17,7 +17,7 @@ func main() { // To setup a custom throttler, you can modify the throttlerConfig struct with your desired values. // For all available options, see the flow.AdaptiveThrottlerConfig struct. throttlerConfig := flow.DefaultAdaptiveThrottlerConfig() - throttler, err := flow.NewAdaptiveThrottler(&throttlerConfig) + throttler, err := flow.NewAdaptiveThrottler(throttlerConfig) if err != nil { panic(fmt.Sprintf("failed to create adaptive throttler: %v", err)) } diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 10f1228..c6072a9 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -62,17 +62,17 @@ type AdaptiveThrottlerConfig struct { MaxRateChangeFactor float64 // MemoryReader is a user-provided custom function that returns memory usage percentage. - // This can be particularly useful for containerized deployments or other environments - // where standard system memory readings may not accurately reflect container-specific - // usage. + // This can be particularly useful for containerized deployments or other environments + // where standard system memory readings may not accurately reflect container-specific + // usage. // If nil, system memory will be read via mem.VirtualMemory(). // Must return memory used percentage (0-100). MemoryReader func() (float64, error) } // DefaultAdaptiveThrottlerConfig returns sensible defaults for most use cases -func DefaultAdaptiveThrottlerConfig() AdaptiveThrottlerConfig { - return AdaptiveThrottlerConfig{ +func DefaultAdaptiveThrottlerConfig() *AdaptiveThrottlerConfig { + return &AdaptiveThrottlerConfig{ MaxMemoryPercent: 80.0, // Conservative memory threshold MaxCPUPercent: 70.0, // Conservative CPU threshold MinThroughput: 10, // Reasonable minimum throughput @@ -138,8 +138,7 @@ var _ streams.Flow = (*AdaptiveThrottler)(nil) // If config is nil, default configuration will be used. func NewAdaptiveThrottler(config *AdaptiveThrottlerConfig) (*AdaptiveThrottler, error) { if config == nil { - defaultConfig := DefaultAdaptiveThrottlerConfig() - config = &defaultConfig + config = DefaultAdaptiveThrottlerConfig() } // Validate configuration diff --git a/flow/adaptive_throttler_test.go b/flow/adaptive_throttler_test.go index 4621b0a..b3ab041 100644 --- a/flow/adaptive_throttler_test.go +++ b/flow/adaptive_throttler_test.go @@ -599,8 +599,7 @@ func TestAdaptiveThrottler_CloseClosesOutputEvenIfInputOpen(t *testing.T) { } func TestAdaptiveThrottler_CloseStopsBackgroundLoops(t *testing.T) { - config := DefaultAdaptiveThrottlerConfig() - at, err := NewAdaptiveThrottler(&config) + at, err := NewAdaptiveThrottler(DefaultAdaptiveThrottlerConfig()) if err != nil { t.Fatalf("failed to create adaptive throttler: %v", err) } @@ -618,10 +617,7 @@ func TestAdaptiveThrottler_CloseStopsBackgroundLoops(t *testing.T) { } func TestAdaptiveThrottler_CPUUsageMode(t *testing.T) { - config := DefaultAdaptiveThrottlerConfig() - config.CPUUsageMode = CPUUsageModeMeasured - - at, err := NewAdaptiveThrottler(&config) + at, err := NewAdaptiveThrottler(DefaultAdaptiveThrottlerConfig()) if err != nil { t.Fatalf("failed to create adaptive throttler: %v", err) } @@ -645,7 +641,7 @@ func TestAdaptiveThrottler_CPUUsageMode(t *testing.T) { func TestAdaptiveThrottler_GetCurrentRate(t *testing.T) { config := DefaultAdaptiveThrottlerConfig() - at, err := NewAdaptiveThrottler(&config) + at, err := NewAdaptiveThrottler(config) if err != nil { t.Fatalf("failed to create adaptive throttler: %v", err) } @@ -665,7 +661,7 @@ func TestAdaptiveThrottler_GetCurrentRate(t *testing.T) { func TestAdaptiveThrottler_GetResourceStats(t *testing.T) { config := DefaultAdaptiveThrottlerConfig() - at, err := NewAdaptiveThrottler(&config) + at, err := NewAdaptiveThrottler(config) if err != nil { t.Fatalf("failed to create adaptive throttler: %v", err) } @@ -747,8 +743,7 @@ func TestAdaptiveThrottler_BufferBackpressure(t *testing.T) { } func BenchmarkAdaptiveThrottler_GetResourceStats(b *testing.B) { - config := DefaultAdaptiveThrottlerConfig() - at, err := NewAdaptiveThrottler(&config) + at, err := NewAdaptiveThrottler(DefaultAdaptiveThrottlerConfig()) if err != nil { b.Fatalf("failed to create adaptive throttler: %v", err) } @@ -948,7 +943,7 @@ func TestAdaptiveThrottler_CustomMemoryReader(t *testing.T) { return customMemoryPercent, nil } - at, err := NewAdaptiveThrottler(&config) + at, err := NewAdaptiveThrottler(config) if err != nil { t.Fatalf("failed to create adaptive throttler: %v", err) } From 55ca0c535dde23e00836419c9cf8768f6f16da5f Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 17:35:51 +0300 Subject: [PATCH 38/54] refactor(resource_monitor): change stats from atomic.Value to atomic.Pointer for improved type safety --- flow/resource_monitor.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flow/resource_monitor.go b/flow/resource_monitor.go index fe738fc..991201b 100644 --- a/flow/resource_monitor.go +++ b/flow/resource_monitor.go @@ -37,7 +37,7 @@ type ResourceMonitor struct { cpuMode CPUUsageMode // Current stats (atomic for thread-safe reads) - stats atomic.Value // *ResourceStats + stats atomic.Pointer[ResourceStats] // CPU sampling sampler sysmonitor.ProcessCPUSampler @@ -121,7 +121,7 @@ func (rm *ResourceMonitor) GetStats() ResourceStats { if stats == nil { return ResourceStats{} } - return *stats.(*ResourceStats) + return *stats } // IsResourceConstrained returns true if resources are above thresholds From d1c811e6e7d42d75a1ca148b2a8509f91ed76584 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 17:37:53 +0300 Subject: [PATCH 39/54] refactor(adaptive_throttler): remove unnecessary mutex for rate adaptation --- flow/adaptive_throttler.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index c6072a9..a266e34 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -126,7 +126,6 @@ type AdaptiveThrottler struct { done chan struct{} // Rate adaptation - adaptMu sync.Mutex lastAdaptation time.Time stopOnce sync.Once @@ -219,9 +218,6 @@ func (at *AdaptiveThrottler) adaptRateLoop() { // adaptRate adjusts the throughput rate based on current resource usage func (at *AdaptiveThrottler) adaptRate() { - at.adaptMu.Lock() - defer at.adaptMu.Unlock() - stats := at.monitor.GetStats() // constrained indicates if memory or CPU usage exceeds configured thresholds constrained := stats.MemoryUsedPercent > at.config.MaxMemoryPercent || From 5a46a4f2a989c8eaef93442a65857151afe6ee01 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 18:01:47 +0300 Subject: [PATCH 40/54] refactor(adaptive_throttler): reorder functions and improved adaptRate readability --- flow/adaptive_throttler.go | 223 +++++++++++++++++++++---------------- 1 file changed, 124 insertions(+), 99 deletions(-) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index a266e34..43d738c 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -14,6 +14,8 @@ const ( // minSampleInterval is the minimum allowed sampling interval to prevent // excessive CPU overhead from too-frequent system resource polling. minSampleInterval = 10 * time.Millisecond + // smoothingFactor is the factor by which the current rate is adjusted to approach the target rate. + smoothingFactor = 0.3 // 30% of remaining distance per cycle ) // AdaptiveThrottlerConfig configures the adaptive throttler behavior @@ -157,7 +159,9 @@ func NewAdaptiveThrottler(config *AdaptiveThrottlerConfig) (*AdaptiveThrottler, return nil, fmt.Errorf("invalid BufferSize: %d", config.BufferSize) } if config.SampleInterval < minSampleInterval { - return nil, fmt.Errorf("invalid SampleInterval: %v; must be at least %v to prevent high CPU overhead", config.SampleInterval, minSampleInterval) + return nil, fmt.Errorf( + "invalid SampleInterval: %v; must be at least %v to prevent high CPU overhead", + config.SampleInterval, minSampleInterval) } if config.HysteresisBuffer < 0 { return nil, fmt.Errorf("invalid HysteresisBuffer: %f", config.HysteresisBuffer) @@ -201,6 +205,49 @@ func NewAdaptiveThrottler(config *AdaptiveThrottlerConfig) (*AdaptiveThrottler, return at, nil } +// Via asynchronously streams data to the given Flow and returns it +func (at *AdaptiveThrottler) Via(flow streams.Flow) streams.Flow { + go at.streamPortioned(flow) + return flow +} + +// To streams data to the given Sink and blocks until completion +func (at *AdaptiveThrottler) To(sink streams.Sink) { + at.streamPortioned(sink) + sink.AwaitCompletion() +} + +// In returns the input channel +func (at *AdaptiveThrottler) In() chan<- any { + return at.in +} + +// Out returns the output channel +func (at *AdaptiveThrottler) Out() <-chan any { + return at.out +} + +// GetCurrentRate returns the current throughput rate (elements per second) +func (at *AdaptiveThrottler) GetCurrentRate() int64 { + return at.currentRate.Load() +} + +// GetResourceStats returns current resource statistics +func (at *AdaptiveThrottler) GetResourceStats() ResourceStats { + return at.monitor.GetStats() +} + +// Close stops the adaptive throttler and cleans up resources +func (at *AdaptiveThrottler) Close() { + // Drain any pending quota signals to prevent goroutine leaks + select { + case <-at.quotaSignal: + default: + } + + at.stop() +} + // adaptRateLoop periodically adapts the throughput rate based on resource availability func (at *AdaptiveThrottler) adaptRateLoop() { ticker := time.NewTicker(at.config.SampleInterval) @@ -219,76 +266,95 @@ func (at *AdaptiveThrottler) adaptRateLoop() { // adaptRate adjusts the throughput rate based on current resource usage func (at *AdaptiveThrottler) adaptRate() { stats := at.monitor.GetStats() - // constrained indicates if memory or CPU usage exceeds configured thresholds - constrained := stats.MemoryUsedPercent > at.config.MaxMemoryPercent || - stats.CPUUsagePercent > at.config.MaxCPUPercent - currentRate := float64(at.currentRate.Load()) - targetRate := currentRate - if constrained { - // Reduce rate when resources are constrained - // Calculate how far over the limits we are (as a percentage) - memoryOverage := math.Max(0, stats.MemoryUsedPercent-at.config.MaxMemoryPercent) - cpuOverage := math.Max(0, stats.CPUUsagePercent-at.config.MaxCPUPercent) - maxOverage := math.Max(memoryOverage, cpuOverage) - - // Scale reduction factor based on overage severity (0-100%) - severityFactor := maxOverage / 50.0 // 50% overage = full severity - severityFactor = math.Min(severityFactor, 1.0) - - // Calculate reduction: base factor + severity bonus - reductionFactor := at.config.AdaptationFactor * (1.0 + severityFactor) - maxReduction := currentRate * at.config.MaxRateChangeFactor - reduction := math.Min(currentRate*reductionFactor, maxReduction) - - targetRate = currentRate - reduction - - // Avoid negative rates - if targetRate < 0 { - targetRate = 0 - } - } else { - // Increase rate when resources are available, with hysteresis - memoryHeadroom := at.config.MaxMemoryPercent - stats.MemoryUsedPercent - cpuHeadroom := at.config.MaxCPUPercent - stats.CPUUsagePercent - minHeadroom := math.Min(memoryHeadroom, cpuHeadroom) - - // Apply hysteresis buffer - only increase if we have significant headroom - effectiveHeadroom := minHeadroom - at.config.HysteresisBuffer - if effectiveHeadroom > 0 { - // Use square root scaling for stable, diminishing returns - headroomRatio := math.Min(effectiveHeadroom/30.0, 1.0) // Cap at 30% headroom for scaling - increaseFactor := at.config.AdaptationFactor * math.Sqrt(headroomRatio) - maxIncrease := currentRate * at.config.MaxRateChangeFactor - - increase := math.Min(currentRate*increaseFactor, maxIncrease) - targetRate = currentRate + increase - } - } + // Calculate target rate based on resource constraints + targetRate := at.calculateTargetRate(currentRate, stats) + // Apply smoothing if enabled if at.config.SmoothTransitions { - // Smooth transitions: gradually approach target rate to avoid abrupt changes - // Use a fixed smoothing factor of 0.3 (30% of remaining distance per cycle) - const smoothingFactor = 0.3 - diff := targetRate - currentRate - targetRate = currentRate + diff*smoothingFactor + targetRate = at.applySmoothing(currentRate, targetRate) } // Enforce bounds targetRate = math.Max(float64(at.config.MinThroughput), targetRate) targetRate = math.Min(float64(at.config.MaxThroughput), targetRate) - // Convert to integer and update atomically + // Commit the new rate if it changed + at.commitRateChange(targetRate) +} + +// calculateTargetRate determines the target rate based on resource constraints +func (at *AdaptiveThrottler) calculateTargetRate(currentRate float64, stats ResourceStats) float64 { + // Constrained is true if resources are above thresholds + constrained := stats.MemoryUsedPercent > at.config.MaxMemoryPercent || + stats.CPUUsagePercent > at.config.MaxCPUPercent + + if constrained { + return at.calculateReduction(currentRate, stats) + } + return at.calculateIncrease(currentRate, stats) +} + +// calculateReduction computes the rate reduction when resources are constrained +func (at *AdaptiveThrottler) calculateReduction(currentRate float64, stats ResourceStats) float64 { + // Calculate how far over the limits we are (as a percentage) + memoryOverage := math.Max(0, stats.MemoryUsedPercent-at.config.MaxMemoryPercent) + cpuOverage := math.Max(0, stats.CPUUsagePercent-at.config.MaxCPUPercent) + maxOverage := math.Max(memoryOverage, cpuOverage) + + // Scale reduction factor based on overage severity (0-100%) + severityFactor := math.Min(maxOverage/50.0, 1.0) // 50% overage = full severity + + // Calculate reduction: base factor + severity bonus + reductionFactor := at.config.AdaptationFactor * (1.0 + severityFactor) + maxReduction := currentRate * at.config.MaxRateChangeFactor + reduction := math.Min(currentRate*reductionFactor, maxReduction) + + targetRate := currentRate - reduction + + // Avoid negative rates + return math.Max(0, targetRate) +} + +// calculateIncrease computes the rate increase when resources are available +func (at *AdaptiveThrottler) calculateIncrease(currentRate float64, stats ResourceStats) float64 { + memoryHeadroom := at.config.MaxMemoryPercent - stats.MemoryUsedPercent + cpuHeadroom := at.config.MaxCPUPercent - stats.CPUUsagePercent + minHeadroom := math.Min(memoryHeadroom, cpuHeadroom) + + // Apply hysteresis buffer - only increase if we have significant headroom + effectiveHeadroom := minHeadroom - at.config.HysteresisBuffer + if effectiveHeadroom <= 0 { + return currentRate // No increase if insufficient headroom + } + + // Use square root scaling for stable, diminishing returns + headroomRatio := math.Min(effectiveHeadroom/30.0, 1.0) // Cap at 30% headroom for scaling + increaseFactor := at.config.AdaptationFactor * math.Sqrt(headroomRatio) + maxIncrease := currentRate * at.config.MaxRateChangeFactor + + increase := math.Min(currentRate*increaseFactor, maxIncrease) + return currentRate + increase +} + +// applySmoothing gradually approaches the target rate to avoid abrupt changes +func (at *AdaptiveThrottler) applySmoothing(currentRate, targetRate float64) float64 { + diff := targetRate - currentRate + return currentRate + diff*smoothingFactor +} + +// commitRateChange atomically updates the rate if it has changed +func (at *AdaptiveThrottler) commitRateChange(targetRate float64) { newRateInt := int64(math.Round(targetRate)) + currentRateInt := at.currentRate.Load() - // Only update if rate actually changed - if newRateInt != at.currentRate.Load() { + if newRateInt != currentRateInt { at.currentRate.Store(newRateInt) at.maxElements.Store(newRateInt) at.counter.Store(0) // Reset quota counter to apply new rate immediately - // Wake any blocked emitters - // so the new quota takes effect without waiting for the next period tick. + // Wake any blocked emitters so the new quota takes effect + // without waiting for the next period tick. at.notifyQuotaReset() at.lastAdaptation = time.Now() } @@ -340,6 +406,7 @@ func (at *AdaptiveThrottler) buffer() { } } +// emit emits an element to the output channel, blocking if quota is exceeded func (at *AdaptiveThrottler) emit(element any) { for { if !at.quotaExceeded() { @@ -363,28 +430,6 @@ func (at *AdaptiveThrottler) emit(element any) { } } -// Via asynchronously streams data to the given Flow and returns it -func (at *AdaptiveThrottler) Via(flow streams.Flow) streams.Flow { - go at.streamPortioned(flow) - return flow -} - -// To streams data to the given Sink and blocks until completion -func (at *AdaptiveThrottler) To(sink streams.Sink) { - at.streamPortioned(sink) - sink.AwaitCompletion() -} - -// Out returns the output channel -func (at *AdaptiveThrottler) Out() <-chan any { - return at.out -} - -// In returns the input channel -func (at *AdaptiveThrottler) In() chan<- any { - return at.in -} - // streamPortioned streams elements enforcing the adaptive quota func (at *AdaptiveThrottler) streamPortioned(inlet streams.Inlet) { defer close(inlet.In()) @@ -395,27 +440,7 @@ func (at *AdaptiveThrottler) streamPortioned(inlet streams.Inlet) { at.stop() } -// GetCurrentRate returns the current throughput rate (elements per second) -func (at *AdaptiveThrottler) GetCurrentRate() int64 { - return at.currentRate.Load() -} - -// GetResourceStats returns current resource statistics -func (at *AdaptiveThrottler) GetResourceStats() ResourceStats { - return at.monitor.GetStats() -} - -// Close stops the adaptive throttler and cleans up resources -func (at *AdaptiveThrottler) Close() { - // Drain any pending quota signals to prevent goroutine leaks - select { - case <-at.quotaSignal: - default: - } - - at.stop() -} - +// stop stops the adaptive throttler and cleans up resources func (at *AdaptiveThrottler) stop() { at.stopOnce.Do(func() { close(at.done) From e097d6bc86f48794ae0ad5ab23b927698faf070a Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 18:07:04 +0300 Subject: [PATCH 41/54] feat(adaptive_throttler): add MaxBufferSize configuration and validation checks --- flow/adaptive_throttler.go | 13 ++++++++++-- flow/adaptive_throttler_test.go | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 43d738c..86f0009 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -31,8 +31,10 @@ type AdaptiveThrottlerConfig struct { // How often to sample resources SampleInterval time.Duration - // Buffer configuration - BufferSize int + // Buffer size to hold incoming elements + BufferSize int + // Maximum allowed buffer size to prevent unbounded memory allocation + MaxBufferSize int // Adaptation parameters (How aggressively to adapt. 0.1 = slow, 0.5 = fast). // @@ -81,6 +83,7 @@ func DefaultAdaptiveThrottlerConfig() *AdaptiveThrottlerConfig { MaxThroughput: 500, // More conservative maximum SampleInterval: 200 * time.Millisecond, // Less frequent sampling BufferSize: 500, // Match max throughput for 1 second buffer at max rate + MaxBufferSize: 10000, // Reasonable maximum to prevent unbounded memory allocation AdaptationFactor: 0.15, // Slightly more conservative adaptation SmoothTransitions: true, // Keep smooth transitions enabled by default CPUUsageMode: CPUUsageModeMeasured, // Use actual process CPU usage natively @@ -158,6 +161,12 @@ func NewAdaptiveThrottler(config *AdaptiveThrottlerConfig) (*AdaptiveThrottler, if config.BufferSize < 1 { return nil, fmt.Errorf("invalid BufferSize: %d", config.BufferSize) } + if config.MaxBufferSize < 1 { + return nil, fmt.Errorf("invalid MaxBufferSize: %d", config.MaxBufferSize) + } + if config.BufferSize > config.MaxBufferSize { + return nil, fmt.Errorf("BufferSize %d exceeds MaxBufferSize %d", config.BufferSize, config.MaxBufferSize) + } if config.SampleInterval < minSampleInterval { return nil, fmt.Errorf( "invalid SampleInterval: %v; must be at least %v to prevent high CPU overhead", diff --git a/flow/adaptive_throttler_test.go b/flow/adaptive_throttler_test.go index b3ab041..7c5fc4e 100644 --- a/flow/adaptive_throttler_test.go +++ b/flow/adaptive_throttler_test.go @@ -82,6 +82,7 @@ func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { MaxThroughput: 100, SampleInterval: 100 * time.Millisecond, BufferSize: 100, + MaxBufferSize: 1000, AdaptationFactor: 0.2, HysteresisBuffer: 5.0, MaxRateChangeFactor: 0.5, @@ -98,6 +99,7 @@ func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { MaxThroughput: 100, SampleInterval: 100 * time.Millisecond, BufferSize: 100, + MaxBufferSize: 1000, AdaptationFactor: 0.2, HysteresisBuffer: 5.0, MaxRateChangeFactor: 0.5, @@ -114,6 +116,7 @@ func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { MaxThroughput: 50, // Invalid SampleInterval: 100 * time.Millisecond, BufferSize: 100, + MaxBufferSize: 1000, AdaptationFactor: 0.2, HysteresisBuffer: 5.0, MaxRateChangeFactor: 0.5, @@ -121,6 +124,40 @@ func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { shouldError: true, expectedError: "invalid throughput bounds", }, + { + name: "invalid MaxBufferSize", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 100 * time.Millisecond, + BufferSize: 100, + MaxBufferSize: -1, // Invalid: negative value + AdaptationFactor: 0.2, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + }, + shouldError: true, + expectedError: "invalid MaxBufferSize", + }, + { + name: "BufferSize exceeds MaxBufferSize", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 100 * time.Millisecond, + BufferSize: 1000, + MaxBufferSize: 500, // Invalid: BufferSize > MaxBufferSize + AdaptationFactor: 0.2, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + }, + shouldError: true, + expectedError: "BufferSize 1000 exceeds MaxBufferSize 500", + }, } for _, tt := range tests { From cd8ad619903a68aa01c2343aa094738194667534 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 18:13:36 +0300 Subject: [PATCH 42/54] refactor(sysmonitor): simplify memory initialization by removing unnecessary mutex locking --- internal/sysmonitor/memory_linux.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/sysmonitor/memory_linux.go b/internal/sysmonitor/memory_linux.go index 8c246a9..01cdd23 100644 --- a/internal/sysmonitor/memory_linux.go +++ b/internal/sysmonitor/memory_linux.go @@ -25,12 +25,8 @@ var ( // and returns appropriate memory information. func GetSystemMemory() (SystemMemory, error) { memoryReaderOnce.Do(func() { - memoryReaderMu.Lock() - if memoryReader == nil { - memoryReader = readSystemMemoryAuto - fileSystem = OSFileSystem{} - } - memoryReaderMu.Unlock() + memoryReader = readSystemMemoryAuto + fileSystem = OSFileSystem{} }) memoryReaderMu.RLock() From 809f60420a1d6ed1afb54369593e68e7d213ac60 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sat, 22 Nov 2025 18:15:53 +0300 Subject: [PATCH 43/54] fix(sysmonitor): ensure available memory does not exceed total memory --- internal/sysmonitor/memory_linux.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/sysmonitor/memory_linux.go b/internal/sysmonitor/memory_linux.go index 01cdd23..7abb9cf 100644 --- a/internal/sysmonitor/memory_linux.go +++ b/internal/sysmonitor/memory_linux.go @@ -277,6 +277,11 @@ func parseMemInfo(r io.Reader) (SystemMemory, error) { } } + // Ensure available doesn't exceed total + if available > total { + available = total + } + return SystemMemory{ Total: total, Available: available, From 904d9e9cef5b4a239698d33c96e684423e6a4f2a Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sun, 23 Nov 2025 12:07:29 +0300 Subject: [PATCH 44/54] refactor(adaptive_throttler): fix golangci-lint errors --- flow/adaptive_throttler.go | 45 ++++++++------ internal/sysmonitor/memory_linux.go | 92 +++++++++++++++++++---------- 2 files changed, 86 insertions(+), 51 deletions(-) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 86f0009..41421f3 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -33,7 +33,6 @@ type AdaptiveThrottlerConfig struct { // Buffer size to hold incoming elements BufferSize int - // Maximum allowed buffer size to prevent unbounded memory allocation MaxBufferSize int // Adaptation parameters (How aggressively to adapt. 0.1 = slow, 0.5 = fast). @@ -138,45 +137,53 @@ type AdaptiveThrottler struct { var _ streams.Flow = (*AdaptiveThrottler)(nil) -// NewAdaptiveThrottler creates a new adaptive throttler -// If config is nil, default configuration will be used. -func NewAdaptiveThrottler(config *AdaptiveThrottlerConfig) (*AdaptiveThrottler, error) { - if config == nil { - config = DefaultAdaptiveThrottlerConfig() - } - - // Validate configuration +// validateConfig validates the adaptive throttler configuration +func validateConfig(config *AdaptiveThrottlerConfig) error { if config.MaxMemoryPercent <= 0 || config.MaxMemoryPercent > 100 { - return nil, fmt.Errorf("invalid MaxMemoryPercent: %f", config.MaxMemoryPercent) + return fmt.Errorf("invalid MaxMemoryPercent: %f", config.MaxMemoryPercent) } if config.MinThroughput < 1 || config.MaxThroughput < config.MinThroughput { - return nil, fmt.Errorf("invalid throughput bounds: min=%d, max=%d", config.MinThroughput, config.MaxThroughput) + return fmt.Errorf("invalid throughput bounds: min=%d, max=%d", config.MinThroughput, config.MaxThroughput) } if config.AdaptationFactor <= 0 || config.AdaptationFactor >= 1 { - return nil, fmt.Errorf("invalid AdaptationFactor: %f, must be in (0, 1)", config.AdaptationFactor) + return fmt.Errorf("invalid AdaptationFactor: %f, must be in (0, 1)", config.AdaptationFactor) } if config.MaxCPUPercent <= 0 || config.MaxCPUPercent > 100 { - return nil, fmt.Errorf("invalid MaxCPUPercent: %f", config.MaxCPUPercent) + return fmt.Errorf("invalid MaxCPUPercent: %f", config.MaxCPUPercent) } if config.BufferSize < 1 { - return nil, fmt.Errorf("invalid BufferSize: %d", config.BufferSize) + return fmt.Errorf("invalid BufferSize: %d", config.BufferSize) } if config.MaxBufferSize < 1 { - return nil, fmt.Errorf("invalid MaxBufferSize: %d", config.MaxBufferSize) + return fmt.Errorf("invalid MaxBufferSize: %d", config.MaxBufferSize) } if config.BufferSize > config.MaxBufferSize { - return nil, fmt.Errorf("BufferSize %d exceeds MaxBufferSize %d", config.BufferSize, config.MaxBufferSize) + return fmt.Errorf("BufferSize %d exceeds MaxBufferSize %d", config.BufferSize, config.MaxBufferSize) } if config.SampleInterval < minSampleInterval { - return nil, fmt.Errorf( + return fmt.Errorf( "invalid SampleInterval: %v; must be at least %v to prevent high CPU overhead", config.SampleInterval, minSampleInterval) } if config.HysteresisBuffer < 0 { - return nil, fmt.Errorf("invalid HysteresisBuffer: %f", config.HysteresisBuffer) + return fmt.Errorf("invalid HysteresisBuffer: %f", config.HysteresisBuffer) } if config.MaxRateChangeFactor <= 0 || config.MaxRateChangeFactor > 1 { - return nil, fmt.Errorf("invalid MaxRateChangeFactor: %f, must be in (0, 1]", config.MaxRateChangeFactor) + return fmt.Errorf("invalid MaxRateChangeFactor: %f, must be in (0, 1]", config.MaxRateChangeFactor) + } + return nil +} + +// NewAdaptiveThrottler creates a new adaptive throttler +// If config is nil, default configuration will be used. +func NewAdaptiveThrottler(config *AdaptiveThrottlerConfig) (*AdaptiveThrottler, error) { + if config == nil { + config = DefaultAdaptiveThrottlerConfig() + } + + // Validate configuration + if err := validateConfig(config); err != nil { + return nil, err } // Initialize with max throughput diff --git a/internal/sysmonitor/memory_linux.go b/internal/sysmonitor/memory_linux.go index 7abb9cf..0093915 100644 --- a/internal/sysmonitor/memory_linux.go +++ b/internal/sysmonitor/memory_linux.go @@ -215,21 +215,53 @@ func readSystemMemory() (SystemMemory, error) { // parseMemInfo parses the /proc/meminfo file format func parseMemInfo(r io.Reader) (SystemMemory, error) { - scanner := bufio.NewScanner(r) + memFields, err := parseMemInfoFields(r) + if err != nil { + return SystemMemory{}, err + } + + if memFields.total == 0 { + return SystemMemory{}, fmt.Errorf("could not find MemTotal in /proc/meminfo") + } + + available, err := calculateAvailableMemory(memFields) + if err != nil { + return SystemMemory{}, err + } + + // Ensure available doesn't exceed total + if available > memFields.total { + available = memFields.total + } + + return SystemMemory{ + Total: memFields.total, + Available: available, + }, nil +} + +type memInfoFields struct { + total uint64 + available uint64 + free uint64 + cached uint64 + memAvailableFound bool +} - var total, available, free, cached uint64 - memAvailableFound := false +func parseMemInfoFields(r io.Reader) (memInfoFields, error) { + scanner := bufio.NewScanner(r) + var fields memInfoFields for scanner.Scan() { line := scanner.Text() - fields := strings.Fields(line) + lineFields := strings.Fields(line) - if len(fields) < 2 { + if len(lineFields) < 2 { continue } - key := strings.TrimSuffix(fields[0], ":") - value, err := strconv.ParseUint(fields[1], 10, 64) + key := strings.TrimSuffix(lineFields[0], ":") + value, err := strconv.ParseUint(lineFields[1], 10, 64) if err != nil { continue } @@ -238,52 +270,48 @@ func parseMemInfo(r io.Reader) (SystemMemory, error) { // Check for overflow const maxValueBeforeOverflow = (1<<64 - 1) / 1024 if value > maxValueBeforeOverflow { - return SystemMemory{}, fmt.Errorf("memory value too large: %d kB would overflow when converting to bytes", value) + return memInfoFields{}, fmt.Errorf( + "memory value too large: %d kB would overflow when converting to bytes", value) } value *= 1024 switch key { case "MemTotal": - total = value + fields.total = value case "MemAvailable": - available = value - memAvailableFound = true + fields.available = value + fields.memAvailableFound = true case "MemFree": - free = value + fields.free = value case "Cached": - cached = value + fields.cached = value } // Early exit if we have MemTotal and MemAvailable (kernel 3.14+) - if total > 0 && memAvailableFound { + if fields.total > 0 && fields.memAvailableFound { break } } if err := scanner.Err(); err != nil { - return SystemMemory{}, fmt.Errorf("error reading meminfo: %w", err) + return memInfoFields{}, fmt.Errorf("error reading meminfo: %w", err) } - if total == 0 { - return SystemMemory{}, fmt.Errorf("could not find MemTotal in /proc/meminfo") - } + return fields, nil +} - // Fallback calculation for MemAvailable - if !memAvailableFound { - available = free + cached - if available == 0 { - return SystemMemory{}, fmt.Errorf( - "could not find MemAvailable in /proc/meminfo and fallback calculation failed (MemFree and Cached not found or both zero)") - } +func calculateAvailableMemory(fields memInfoFields) (uint64, error) { + if fields.memAvailableFound { + return fields.available, nil } - // Ensure available doesn't exceed total - if available > total { - available = total + // Fallback calculation for MemAvailable + available := fields.free + fields.cached + if available == 0 { + return 0, fmt.Errorf( + "could not find MemAvailable in /proc/meminfo and fallback calculation failed " + + "(MemFree and Cached not found or both zero)") } - return SystemMemory{ - Total: total, - Available: available, - }, nil + return available, nil } From 60b20a48820a007fa1818b2458e3d8abc9d7dc87 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sun, 23 Nov 2025 12:11:21 +0300 Subject: [PATCH 45/54] refactor(adaptive_throttler): change validateConfig function to a method on AdaptiveThrottlerConfig --- flow/adaptive_throttler.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 41421f3..75913d7 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -137,8 +137,8 @@ type AdaptiveThrottler struct { var _ streams.Flow = (*AdaptiveThrottler)(nil) -// validateConfig validates the adaptive throttler configuration -func validateConfig(config *AdaptiveThrottlerConfig) error { +// validate validates the adaptive throttler configuration +func (config *AdaptiveThrottlerConfig) validate() error { if config.MaxMemoryPercent <= 0 || config.MaxMemoryPercent > 100 { return fmt.Errorf("invalid MaxMemoryPercent: %f", config.MaxMemoryPercent) } @@ -181,8 +181,7 @@ func NewAdaptiveThrottler(config *AdaptiveThrottlerConfig) (*AdaptiveThrottler, config = DefaultAdaptiveThrottlerConfig() } - // Validate configuration - if err := validateConfig(config); err != nil { + if err := config.validate(); err != nil { return nil, err } From ab90a2c9cfe70b00c3ca1a152467c9087a318a03 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sun, 23 Nov 2025 12:29:27 +0300 Subject: [PATCH 46/54] refactor(adaptive_throttler, resource_monitor): enhance configuration documentation and improve code clarity --- flow/adaptive_throttler.go | 167 ++++++++++++++++++++++++------------- flow/resource_monitor.go | 38 ++++----- 2 files changed, 125 insertions(+), 80 deletions(-) diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 75913d7..669fd91 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -20,32 +20,24 @@ const ( // AdaptiveThrottlerConfig configures the adaptive throttler behavior type AdaptiveThrottlerConfig struct { - // Resource thresholds (0-100 percentage) + // Resource monitoring configuration + // + // These settings control how the throttler monitors system resources + // and determines when to throttle throughput. + + // MaxMemoryPercent is the maximum memory usage threshold (0-100 percentage). + // When memory usage exceeds this threshold, throughput will be reduced. MaxMemoryPercent float64 - MaxCPUPercent float64 - // Throughput bounds (elements per second) - MinThroughput int - MaxThroughput int + // MaxCPUPercent is the maximum CPU usage threshold (0-100 percentage). + // When CPU usage exceeds this threshold, throughput will be reduced. + MaxCPUPercent float64 - // How often to sample resources + // SampleInterval is how often to sample system resources. + // More frequent sampling provides faster response but increases CPU overhead. SampleInterval time.Duration - // Buffer size to hold incoming elements - BufferSize int - MaxBufferSize int - - // Adaptation parameters (How aggressively to adapt. 0.1 = slow, 0.5 = fast). - // - // Allowed values: 0.0 to 1.0 - AdaptationFactor float64 - - // Rate transition smoothing. - // - // If true, the throughput rate will be smoothed over time to avoid abrupt changes. - SmoothTransitions bool - - // CPU usage sampling mode. + // CPUUsageMode controls how CPU usage is measured. // // CPUUsageModeHeuristic: Estimates CPU usage using a simple heuristic (goroutine count), // suitable for platforms where accurate process CPU measurement is not supported. @@ -54,16 +46,6 @@ type AdaptiveThrottlerConfig struct { // (when supported), providing more accurate CPU usage readings. CPUUsageMode CPUUsageMode - // Hysteresis buffer to prevent rapid state changes (percentage points). - // Requires this much additional headroom before increasing rate. - // Default: 5.0 - HysteresisBuffer float64 - - // Maximum rate change factor per adaptation cycle (0.0-1.0). - // Limits how much the rate can change in a single step to prevent instability. - // Default: 0.3 (max 30% change per cycle) - MaxRateChangeFactor float64 - // MemoryReader is a user-provided custom function that returns memory usage percentage. // This can be particularly useful for containerized deployments or other environments // where standard system memory readings may not accurately reflect container-specific @@ -71,23 +53,94 @@ type AdaptiveThrottlerConfig struct { // If nil, system memory will be read via mem.VirtualMemory(). // Must return memory used percentage (0-100). MemoryReader func() (float64, error) + + // Throughput bounds (in elements per second) + // + // These settings define the minimum and maximum throughput rates. + + // MinThroughput is the minimum throughput in elements per second. + // The throttler will never reduce throughput below this value. + MinThroughput int + + // MaxThroughput is the maximum throughput in elements per second. + // The throttler will never increase throughput above this value. + MaxThroughput int + + // Buffer configuration + // + // These settings control the internal buffering of elements. + + // BufferSize is the initial buffer size in number of elements. + // This buffer holds incoming elements when throughput is throttled. + BufferSize int + + // MaxBufferSize is the maximum buffer size in number of elements. + // This prevents unbounded memory allocation during sustained throttling. + MaxBufferSize int + + // Adaptation behavior + // + // These settings control how aggressively and smoothly the throttler + // adapts to changing resource conditions. + + // AdaptationFactor controls how aggressively the throttler adapts (0.0-1.0). + // Lower values (e.g., 0.1) result in slower, more conservative adaptation. + // Higher values (e.g., 0.5) result in faster, more aggressive adaptation. + AdaptationFactor float64 + + // SmoothTransitions enables rate transition smoothing. + // If true, the throughput rate will be smoothed over time to avoid abrupt changes. + // This helps prevent oscillations and provides more stable behavior. + SmoothTransitions bool + + // HysteresisBuffer prevents rapid state changes (in percentage points). + // Requires this much additional headroom before increasing rate. + // This prevents oscillations around resource thresholds. + HysteresisBuffer float64 + + // MaxRateChangeFactor limits the maximum rate change per adaptation cycle (0.0-1.0). + // Limits how much the rate can change in a single step to prevent instability. + MaxRateChangeFactor float64 } -// DefaultAdaptiveThrottlerConfig returns sensible defaults for most use cases +// DefaultAdaptiveThrottlerConfig returns sensible defaults for most use cases. +// +// Default configuration parameters: +// +// Resource Monitoring: +// - MaxMemoryPercent: 80.0% - Conservative memory threshold to prevent OOM +// - MaxCPUPercent: 70.0% - Conservative CPU threshold to maintain responsiveness +// - SampleInterval: 200ms - Balanced sampling frequency to minimize overhead +// - CPUUsageMode: CPUUsageModeMeasured - Uses native process CPU measurement +// - MemoryReader: nil - Uses system memory via mem.VirtualMemory() +// +// Throughput Bounds: +// - MinThroughput: 10 elements/second - Ensures minimum processing rate +// - MaxThroughput: 500 elements/second - Conservative maximum for stability +// +// Buffer Configuration: +// - BufferSize: 500 elements - Matches max throughput for 1 second buffer at max rate +// - MaxBufferSize: 10,000 elements - Prevents unbounded memory allocation +// +// Adaptation Behavior: +// - AdaptationFactor: 0.15 - Conservative adaptation speed (15% adjustment per cycle) +// - SmoothTransitions: true - Enables rate smoothing to avoid abrupt changes +// - HysteresisBuffer: 5.0% - Prevents oscillations around resource thresholds +// - MaxRateChangeFactor: 0.3 - Limits rate changes to 30% per cycle for stability func DefaultAdaptiveThrottlerConfig() *AdaptiveThrottlerConfig { return &AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, // Conservative memory threshold - MaxCPUPercent: 70.0, // Conservative CPU threshold - MinThroughput: 10, // Reasonable minimum throughput - MaxThroughput: 500, // More conservative maximum - SampleInterval: 200 * time.Millisecond, // Less frequent sampling - BufferSize: 500, // Match max throughput for 1 second buffer at max rate - MaxBufferSize: 10000, // Reasonable maximum to prevent unbounded memory allocation - AdaptationFactor: 0.15, // Slightly more conservative adaptation - SmoothTransitions: true, // Keep smooth transitions enabled by default - CPUUsageMode: CPUUsageModeMeasured, // Use actual process CPU usage natively - HysteresisBuffer: 5.0, // Prevent oscillations around threshold - MaxRateChangeFactor: 0.3, // More conservative rate changes + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 500, + SampleInterval: 200 * time.Millisecond, + BufferSize: 500, + MaxBufferSize: 10000, + AdaptationFactor: 0.15, + SmoothTransitions: true, + CPUUsageMode: CPUUsageModeMeasured, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.3, } } @@ -115,24 +168,24 @@ type AdaptiveThrottler struct { config AdaptiveThrottlerConfig monitor resourceMonitor - // Current rate (elements per second) + // Current throughput rate in elements per second currentRate atomic.Int64 - // Rate control - period time.Duration // Calculated from currentRate - maxElements atomic.Int64 // Elements per period - counter atomic.Int64 + // Rate control: period-based quota enforcement + period time.Duration // Time period for quota calculation (typically 1 second) + maxElements atomic.Int64 // Maximum elements allowed per period + counter atomic.Int64 // Current element count in the period - // Channels - in chan any - out chan any - quotaSignal chan struct{} - done chan struct{} + // Communication channels + in chan any // Input channel for incoming elements + out chan any // Output channel for throttled elements + quotaSignal chan struct{} // Signal channel to notify when quota resets + done chan struct{} // Shutdown signal channel - // Rate adaptation - lastAdaptation time.Time + // Rate adaptation tracking + lastAdaptation time.Time // Timestamp of last rate adaptation - stopOnce sync.Once + stopOnce sync.Once // Ensures cleanup happens only once } var _ streams.Flow = (*AdaptiveThrottler)(nil) diff --git a/flow/resource_monitor.go b/flow/resource_monitor.go index 991201b..d8f972b 100644 --- a/flow/resource_monitor.go +++ b/flow/resource_monitor.go @@ -31,25 +31,23 @@ type ResourceStats struct { // ResourceMonitor monitors system resources and provides current statistics type ResourceMonitor struct { - sampleInterval time.Duration - memoryThreshold float64 - cpuThreshold float64 - cpuMode CPUUsageMode + // Configuration thresholds + sampleInterval time.Duration // How often to sample system resources + memoryThreshold float64 // Memory usage threshold (0-100 percentage) + cpuThreshold float64 // CPU usage threshold (0-100 percentage) + cpuMode CPUUsageMode // CPU usage measurement mode - // Current stats (atomic for thread-safe reads) + // Current resource statistics stats atomic.Pointer[ResourceStats] - // CPU sampling - sampler sysmonitor.ProcessCPUSampler + // Resource sampling components + sampler sysmonitor.ProcessCPUSampler // CPU usage sampler + memStats runtime.MemStats // Reusable buffer for memory statistics + memoryReader func() (float64, error) // Custom memory reader for containerized deployments - // Reusable buffer for memory stats - memStats runtime.MemStats - - // Memory reader for containerized deployments - memoryReader func() (float64, error) - - mu sync.RWMutex - done chan struct{} + // Synchronization and lifecycle + closeOnce sync.Once // Ensures cleanup happens only once + done chan struct{} // Shutdown signal channel } // NewResourceMonitor creates a new resource monitor. @@ -255,15 +253,9 @@ func (rm *ResourceMonitor) monitor() { // Close stops the resource monitor func (rm *ResourceMonitor) Close() { - rm.mu.Lock() - defer rm.mu.Unlock() - - select { - case <-rm.done: - return - default: + rm.closeOnce.Do(func() { close(rm.done) - } + }) } // clampPercent clamps a percentage value between 0 and 100. From 7d4abd446c02a481640168139e407ff3a99f2719 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sun, 23 Nov 2025 14:03:43 +0300 Subject: [PATCH 47/54] test: add comprehensive test coverage for adaptive throttler and system monitor --- flow/adaptive_throttler_test.go | 459 ++++++++++++++ flow/resource_monitor_test.go | 219 +++++++ internal/sysmonitor/cpu.go | 24 +- internal/sysmonitor/cpu_linux_test.go | 26 + internal/sysmonitor/cpu_test.go | 153 ++++- internal/sysmonitor/fs_test.go | 95 +++ internal/sysmonitor/heuristic.go | 3 + internal/sysmonitor/heuristic_test.go | 338 ++++++++++ .../sysmonitor/memory_linux_cgroup_test.go | 592 ++++++++++++++++++ internal/sysmonitor/memory_linux_test.go | 213 ------- internal/sysmonitor/memory_test.go | 10 + internal/sysmonitor/memory_windows.go | 2 +- 12 files changed, 1902 insertions(+), 232 deletions(-) create mode 100644 internal/sysmonitor/cpu_linux_test.go create mode 100644 internal/sysmonitor/fs_test.go create mode 100644 internal/sysmonitor/heuristic_test.go create mode 100644 internal/sysmonitor/memory_linux_cgroup_test.go delete mode 100644 internal/sysmonitor/memory_linux_test.go diff --git a/flow/adaptive_throttler_test.go b/flow/adaptive_throttler_test.go index 7c5fc4e..f048a19 100644 --- a/flow/adaptive_throttler_test.go +++ b/flow/adaptive_throttler_test.go @@ -5,6 +5,8 @@ import ( "sync" "testing" "time" + + "github.com/reugn/go-streams" ) // mockResourceMonitor allows injecting specific resource stats for testing @@ -158,6 +160,159 @@ func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { shouldError: true, expectedError: "BufferSize 1000 exceeds MaxBufferSize 500", }, + { + name: "invalid AdaptationFactor <= 0", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 100 * time.Millisecond, + BufferSize: 100, + MaxBufferSize: 1000, + AdaptationFactor: 0.0, // Invalid: <= 0 + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + }, + shouldError: true, + expectedError: "invalid AdaptationFactor", + }, + { + name: "invalid AdaptationFactor >= 1", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 100 * time.Millisecond, + BufferSize: 100, + MaxBufferSize: 1000, + AdaptationFactor: 1.0, // Invalid: >= 1 + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + }, + shouldError: true, + expectedError: "invalid AdaptationFactor", + }, + { + name: "invalid MaxCPUPercent <= 0", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 0.0, // Invalid: <= 0 + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 100 * time.Millisecond, + BufferSize: 100, + MaxBufferSize: 1000, + AdaptationFactor: 0.2, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + }, + shouldError: true, + expectedError: "invalid MaxCPUPercent", + }, + { + name: "invalid MaxCPUPercent > 100", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 150.0, // Invalid: > 100 + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 100 * time.Millisecond, + BufferSize: 100, + MaxBufferSize: 1000, + AdaptationFactor: 0.2, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + }, + shouldError: true, + expectedError: "invalid MaxCPUPercent", + }, + { + name: "invalid BufferSize < 1", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 100 * time.Millisecond, + BufferSize: 0, // Invalid: < 1 + MaxBufferSize: 1000, + AdaptationFactor: 0.2, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + }, + shouldError: true, + expectedError: "invalid BufferSize", + }, + { + name: "invalid SampleInterval < minSampleInterval", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 5 * time.Millisecond, // Invalid: < minSampleInterval (10ms) + BufferSize: 100, + MaxBufferSize: 1000, + AdaptationFactor: 0.2, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + }, + shouldError: true, + expectedError: "invalid SampleInterval", + }, + { + name: "invalid HysteresisBuffer < 0", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 100 * time.Millisecond, + BufferSize: 100, + MaxBufferSize: 1000, + AdaptationFactor: 0.2, + HysteresisBuffer: -1.0, // Invalid: < 0 + MaxRateChangeFactor: 0.5, + }, + shouldError: true, + expectedError: "invalid HysteresisBuffer", + }, + { + name: "invalid MaxRateChangeFactor <= 0", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 100 * time.Millisecond, + BufferSize: 100, + MaxBufferSize: 1000, + AdaptationFactor: 0.2, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.0, // Invalid: <= 0 + }, + shouldError: true, + expectedError: "invalid MaxRateChangeFactor", + }, + { + name: "invalid MaxRateChangeFactor > 1", + config: AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 100 * time.Millisecond, + BufferSize: 100, + MaxBufferSize: 1000, + AdaptationFactor: 0.2, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 1.5, // Invalid: > 1 + }, + shouldError: true, + expectedError: "invalid MaxRateChangeFactor", + }, } for _, tt := range tests { @@ -180,6 +335,29 @@ func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { } } +func TestAdaptiveThrottler_NilConfig(t *testing.T) { + // Test that nil config uses defaults + at, err := NewAdaptiveThrottler(nil) + if err != nil { + t.Fatalf("NewAdaptiveThrottler with nil config should not error, got: %v", err) + } + defer at.Close() + + // Verify it uses default config values + if at.config.MaxMemoryPercent != 80.0 { + t.Errorf("expected default MaxMemoryPercent 80.0, got %f", at.config.MaxMemoryPercent) + } + if at.config.MaxCPUPercent != 70.0 { + t.Errorf("expected default MaxCPUPercent 70.0, got %f", at.config.MaxCPUPercent) + } + if at.config.MinThroughput != 10 { + t.Errorf("expected default MinThroughput 10, got %d", at.config.MinThroughput) + } + if at.config.MaxThroughput != 500 { + t.Errorf("expected default MaxThroughput 500, got %d", at.config.MaxThroughput) + } +} + func TestAdaptiveThrottler_BasicThroughput(t *testing.T) { config := AdaptiveThrottlerConfig{ MaxMemoryPercent: 80.0, @@ -997,3 +1175,284 @@ func TestAdaptiveThrottler_CustomMemoryReader(t *testing.T) { t.Error("custom memory reader was not called") } } + +func TestAdaptiveThrottler_Via(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 50 * time.Millisecond, + BufferSize: 20, + AdaptationFactor: 0.2, + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 30.0, + CPUUsagePercent: 20.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer at.Close() + + // Create a mock flow + mockFlow := &mockFlow{ + in: make(chan any, 10), + out: make(chan any, 10), + } + + // Test Via() - should return the flow and start streaming + resultFlow := at.Via(mockFlow) + if resultFlow != mockFlow { + t.Error("Via() should return the provided flow") + } + + // Send an element + at.In() <- "test" + + // Wait for element to be forwarded + select { + case element := <-mockFlow.in: + if element != "test" { + t.Errorf("expected 'test', got %v", element) + } + case <-time.After(500 * time.Millisecond): + t.Error("timeout waiting for element to be forwarded via Via()") + } +} + +func TestAdaptiveThrottler_To(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 50 * time.Millisecond, + BufferSize: 20, + AdaptationFactor: 0.2, + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 30.0, + CPUUsagePercent: 20.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer at.Close() + + // Create a mock sink + mockSink := &mockSink{ + in: make(chan any, 10), + } + + // Test To() - should block until completion + done := make(chan struct{}) + go func() { + defer close(done) + at.To(mockSink) + }() + + // Send an element + at.In() <- "test" + + // Wait for element to be received + select { + case element := <-mockSink.in: + if element != "test" { + t.Errorf("expected 'test', got %v", element) + } + case <-time.After(500 * time.Millisecond): + t.Error("timeout waiting for element to be received by sink") + } + + // Close input to trigger completion + close(at.in) + + // Wait for To() to complete + select { + case <-done: + // Success + case <-time.After(500 * time.Millisecond): + t.Error("timeout waiting for To() to complete") + } +} + +func TestAdaptiveThrottler_BufferInputClose(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 10, + MaxThroughput: 100, + SampleInterval: 50 * time.Millisecond, + BufferSize: 20, + AdaptationFactor: 0.2, + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 30.0, + CPUUsagePercent: 20.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + defer at.Close() + + // Send some elements + at.In() <- "first" + at.In() <- "second" + + // Close input channel + close(at.in) + + // Verify output channel is eventually closed + timeout := time.After(500 * time.Millisecond) + var received []any + for { + select { + case element, ok := <-at.Out(): + if !ok { + // Channel closed - verify we got the elements + if len(received) < 2 { + t.Errorf("expected at least 2 elements before close, got %d", len(received)) + } + return + } + received = append(received, element) + case <-timeout: + t.Fatal("timeout waiting for output channel to close after input close") + } + } +} + +func TestAdaptiveThrottler_EmitShutdown(t *testing.T) { + config := AdaptiveThrottlerConfig{ + MaxMemoryPercent: 80.0, + MaxCPUPercent: 70.0, + MinThroughput: 1, + MaxThroughput: 1, // Very low throughput to trigger quota + SampleInterval: 50 * time.Millisecond, + BufferSize: 5, + AdaptationFactor: 0.2, + SmoothTransitions: false, + CPUUsageMode: CPUUsageModeHeuristic, + HysteresisBuffer: 5.0, + MaxRateChangeFactor: 0.5, + } + + mockMonitor := &mockResourceMonitor{ + stats: ResourceStats{ + MemoryUsedPercent: 30.0, + CPUUsagePercent: 20.0, + GoroutineCount: 5, + Timestamp: time.Now(), + }, + } + + at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) + + // Exhaust quota + at.In() <- "first" + <-at.Out() // Consume first element + + // Now quota is exhausted, send another element that will block + sent := make(chan struct{}) + go func() { + at.In() <- "blocked" + close(sent) + }() + + // Give it time to block + time.Sleep(50 * time.Millisecond) + + // Close the throttler while emit is blocked + at.Close() + + // Wait for shutdown to complete + select { + case <-sent: + // Element was sent (may have been flushed or dropped) + case <-time.After(500 * time.Millisecond): + t.Error("timeout waiting for emit to handle shutdown") + } + + // Verify output channel is closed + select { + case _, ok := <-at.Out(): + if ok { + t.Error("output channel should be closed after shutdown") + } + default: + // Channel already closed + } +} + +// mockFlow implements streams.Flow for testing +type mockFlow struct { + in chan any + out chan any +} + +func (m *mockFlow) Via(flow streams.Flow) streams.Flow { + go func() { + defer close(flow.In()) + for element := range m.out { + flow.In() <- element + } + }() + return flow +} + +func (m *mockFlow) To(sink streams.Sink) { + defer close(sink.In()) + for element := range m.out { + sink.In() <- element + } + sink.AwaitCompletion() +} + +func (m *mockFlow) In() chan<- any { + return m.in +} + +func (m *mockFlow) Out() <-chan any { + return m.out +} + +// mockSink implements streams.Sink for testing +type mockSink struct { + in chan any +} + +func (m *mockSink) In() chan<- any { + return m.in +} + +func (m *mockSink) AwaitCompletion() { + // Wait for input channel to be closed + for range m.in { + _ = struct{}{} // drain channel + } +} diff --git a/flow/resource_monitor_test.go b/flow/resource_monitor_test.go index 9f13bbb..5a41192 100644 --- a/flow/resource_monitor_test.go +++ b/flow/resource_monitor_test.go @@ -1,6 +1,7 @@ package flow import ( + "fmt" "math" "runtime" "testing" @@ -19,6 +20,50 @@ func TestNewResourceMonitor_InvalidSampleInterval(t *testing.T) { NewResourceMonitor(0, 80.0, 70.0, CPUUsageModeHeuristic, nil) } +func TestNewResourceMonitor_InvalidMemoryThreshold(t *testing.T) { + t.Run("negative memory threshold", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic for negative memory threshold") + } + }() + + NewResourceMonitor(100*time.Millisecond, -1.0, 70.0, CPUUsageModeHeuristic, nil) + }) + + t.Run("memory threshold > 100", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic for memory threshold > 100") + } + }() + + NewResourceMonitor(100*time.Millisecond, 150.0, 70.0, CPUUsageModeHeuristic, nil) + }) +} + +func TestNewResourceMonitor_InvalidCPUThreshold(t *testing.T) { + t.Run("negative CPU threshold", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic for negative CPU threshold") + } + }() + + NewResourceMonitor(100*time.Millisecond, 80.0, -1.0, CPUUsageModeHeuristic, nil) + }) + + t.Run("CPU threshold > 100", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic for CPU threshold > 100") + } + }() + + NewResourceMonitor(100*time.Millisecond, 80.0, 150.0, CPUUsageModeHeuristic, nil) + }) +} + func TestResourceMonitor_GetStats(t *testing.T) { rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) defer rm.Close() @@ -276,6 +321,180 @@ func TestResourceStats_MemoryCalculation(t *testing.T) { } } +func TestResourceMonitor_GetStatsNilStats(t *testing.T) { + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) + defer rm.Close() + + // Manually set stats to nil + rm.stats.Store(nil) + + stats := rm.GetStats() + if stats.Timestamp.IsZero() { + // Should return empty ResourceStats when stats is nil + if stats.MemoryUsedPercent != 0 || stats.CPUUsagePercent != 0 || stats.GoroutineCount != 0 { + t.Errorf("expected empty stats when nil, got %+v", stats) + } + } +} + +func TestResourceMonitor_MemoryUsagePercentEdgeCases(t *testing.T) { + t.Run("available > total", func(t *testing.T) { + restore := sysmonitor.SetMemoryReader(func() (sysmonitor.SystemMemory, error) { + return sysmonitor.SystemMemory{ + Total: 100 * 1024 * 1024, + Available: 150 * 1024 * 1024, // Available > Total (invalid but should handle) + }, nil + }) + defer restore() + + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) + defer rm.Close() + + stats := rm.collectStats() + // Should clamp available to total, so used = 0, percent = 0 + if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { + t.Errorf("memory percent should be valid, got %f", stats.MemoryUsedPercent) + } + }) + + t.Run("total == 0", func(t *testing.T) { + restore := sysmonitor.SetMemoryReader(func() (sysmonitor.SystemMemory, error) { + return sysmonitor.SystemMemory{ + Total: 0, + Available: 0, + }, nil + }) + defer restore() + + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) + defer rm.Close() + + stats := rm.collectStats() + // When total is 0, tryGetSystemMemory() returns hasSystemStats=false + // This causes collectStats() to fall back to procStats (runtime.MemStats) + if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { + t.Errorf("memory percent should be valid when total is 0 (falls back to procStats), got %f", stats.MemoryUsedPercent) + } + }) + + t.Run("procStats fallback", func(t *testing.T) { + // Set memory reader to return error to trigger procStats fallback + restore := sysmonitor.SetMemoryReader(func() (sysmonitor.SystemMemory, error) { + return sysmonitor.SystemMemory{}, fmt.Errorf("memory read failed") + }) + defer restore() + + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) + defer rm.Close() + + stats := rm.collectStats() + // Should use procStats fallback + if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { + t.Errorf("memory percent should be valid with procStats fallback, got %f", stats.MemoryUsedPercent) + } + }) + + t.Run("procStats nil or Sys == 0", func(t *testing.T) { + // This is hard to test directly as it requires system memory to fail + // and procStats to be nil or have Sys == 0, which is unlikely in practice + // but the code path exists for safety + restore := sysmonitor.SetMemoryReader(func() (sysmonitor.SystemMemory, error) { + return sysmonitor.SystemMemory{}, fmt.Errorf("memory read failed") + }) + defer restore() + + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) + defer rm.Close() + + // Force hasSystemStats to false by making GetSystemMemory fail + stats := rm.collectStats() + // Should handle gracefully + if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { + t.Errorf("memory percent should be valid, got %f", stats.MemoryUsedPercent) + } + }) +} + +func TestResourceMonitor_CustomMemoryReaderError(t *testing.T) { + errorCount := 0 + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, func() (float64, error) { + errorCount++ + return 0, fmt.Errorf("custom reader error") // Return error to trigger fallback + }) + defer rm.Close() + + stats := rm.collectStats() + // Should fall back to system memory when custom reader fails + if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { + t.Errorf("memory percent should be valid after fallback, got %f", stats.MemoryUsedPercent) + } + if errorCount == 0 { + t.Error("custom memory reader should have been called") + } +} + +func TestResourceMonitor_ValidateResourceStats(t *testing.T) { + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) + defer rm.Close() + + t.Run("negative goroutine count", func(t *testing.T) { + stats := &ResourceStats{ + MemoryUsedPercent: 50.0, + CPUUsagePercent: 40.0, + GoroutineCount: -1, // Invalid + Timestamp: time.Now(), + } + // Store invalid stats and retrieve them - validation happens in collectStats + // We test indirectly by ensuring GetStats returns valid values + rm.stats.Store(stats) + // Wait for next collection which will validate + time.Sleep(60 * time.Millisecond) + retrievedStats := rm.GetStats() + // Validation should clamp negative goroutine count to 0 + if retrievedStats.GoroutineCount < 0 { + t.Errorf("goroutine count should not be negative, got %d", retrievedStats.GoroutineCount) + } + }) + + t.Run("zero timestamp", func(t *testing.T) { + stats := &ResourceStats{ + MemoryUsedPercent: 50.0, + CPUUsagePercent: 40.0, + GoroutineCount: 10, + Timestamp: time.Time{}, // Zero timestamp + } + rm.stats.Store(stats) + // Validation should set timestamp if zero + // Note: GetStats doesn't validate, but collectStats does + // We test by triggering a new collection + time.Sleep(60 * time.Millisecond) // Wait for next collection + newStats := rm.GetStats() + if newStats.Timestamp.IsZero() { + t.Error("timestamp should not be zero after collection") + } + }) + + t.Run("old timestamp refresh", func(t *testing.T) { + oldTime := time.Now().Add(-2 * time.Minute) // More than 1 minute ago + stats := &ResourceStats{ + MemoryUsedPercent: 50.0, + CPUUsagePercent: 40.0, + GoroutineCount: 10, + Timestamp: oldTime, + } + rm.stats.Store(stats) + // Wait for next collection which will validate and refresh timestamp + time.Sleep(60 * time.Millisecond) + newStats := rm.GetStats() + if newStats.Timestamp.Equal(oldTime) { + t.Error("timestamp should be refreshed when older than 1 minute") + } + if time.Since(newStats.Timestamp) > time.Second { + t.Error("timestamp should be recent after refresh") + } + }) +} + func BenchmarkResourceMonitor_GetStats(b *testing.B) { rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) defer rm.Close() diff --git a/internal/sysmonitor/cpu.go b/internal/sysmonitor/cpu.go index 6debcf4..af060f7 100644 --- a/internal/sysmonitor/cpu.go +++ b/internal/sysmonitor/cpu.go @@ -4,19 +4,33 @@ import ( "time" ) -// ProcessCPUSampler samples CPU usage across different platforms +// ProcessCPUSampler provides a cross-platform interface for sampling +// CPU usage of the current process. Returns normalized CPU usage (0-100%) +// across all available CPU cores. +// +// Platform implementations: +// - Linux: reads from /proc/[pid]/stat +// - Darwin (macOS): uses syscall.Getrusage +// - Windows: uses GetProcessTimes API +// - Other platforms: returns error type ProcessCPUSampler interface { - // Sample returns normalized CPU usage (0-100%) since last sample + // Sample returns normalized CPU usage percentage (0-100%) since last sample. + // On first call, initializes state and returns 0.0. If elapsed time since + // last sample is less than half of deltaTime, returns last known value. + // Returns last known value on error. Sample(deltaTime time.Duration) float64 - // Reset clears sampler state for a new session + // Reset clears sampler state for a new session. Next Sample call will + // behave as the first sample. Reset() - // IsInitialized returns true if at least one sample has been taken + // IsInitialized returns true if at least one sample has been taken. IsInitialized() bool } -// NewProcessSampler creates a CPU sampler for the current process +// NewProcessSampler creates a CPU sampler for the current process. +// Automatically selects platform-specific implementation (Linux/Darwin/Windows). +// Returns error on unsupported platforms or if sampler creation fails. func NewProcessSampler() (ProcessCPUSampler, error) { return newProcessSampler() } diff --git a/internal/sysmonitor/cpu_linux_test.go b/internal/sysmonitor/cpu_linux_test.go new file mode 100644 index 0000000..372db1d --- /dev/null +++ b/internal/sysmonitor/cpu_linux_test.go @@ -0,0 +1,26 @@ +//go:build linux + +package sysmonitor + +import ( + "testing" +) + +// TestProcessSampler_ClockTicks tests that clock ticks are properly initialized +func TestProcessSampler_ClockTicks(t *testing.T) { + sampler, err := NewProcessSampler() + if err != nil { + t.Fatalf("NewProcessSampler failed: %v", err) + } + + ps := sampler.(*ProcessSampler) + if ps.clockTicks <= 0 { + t.Errorf("clockTicks should be positive, got %d", ps.clockTicks) + } + + // Clock ticks should typically be 100 on Linux systems + // but can vary, so we just check it's reasonable + if ps.clockTicks > 10000 { + t.Errorf("clockTicks seems unreasonably high: %d", ps.clockTicks) + } +} diff --git a/internal/sysmonitor/cpu_test.go b/internal/sysmonitor/cpu_test.go index 9340055..4f61422 100644 --- a/internal/sysmonitor/cpu_test.go +++ b/internal/sysmonitor/cpu_test.go @@ -1,24 +1,39 @@ package sysmonitor import ( - "runtime" + "reflect" "testing" "time" + "unsafe" ) -func TestGoroutineHeuristicSampler(t *testing.T) { - sampler := NewGoroutineHeuristicSampler() +// setUnexportedField sets an unexported field using unsafe reflection. +func setUnexportedField(t *testing.T, field reflect.Value, value interface{}) { + t.Helper() + reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())). + Elem(). + Set(reflect.ValueOf(value)) +} - // Test with reasonable goroutine count - _ = runtime.NumGoroutine() +// setTestState sets internal sampler state for testing. +func setTestState(t *testing.T, sampler ProcessCPUSampler, lastUTime, lastSTime float64, lastSample time.Time) { + t.Helper() + val := reflect.ValueOf(sampler).Elem() - percent := sampler.Sample(100 * time.Millisecond) - if percent < 0.0 || percent > 100.0 { - t.Errorf("CPU percent should be between 0 and 100, got %v", percent) + // Set lastUTime + if field := val.FieldByName("lastUTime"); field.IsValid() { + setUnexportedField(t, field, lastUTime) } - // Test reset (should be no-op) - sampler.Reset() + // Set lastSTime + if field := val.FieldByName("lastSTime"); field.IsValid() { + setUnexportedField(t, field, lastSTime) + } + + // Set lastSample + if field := val.FieldByName("lastSample"); field.IsValid() { + setUnexportedField(t, field, lastSample) + } } func TestNewProcessSampler(t *testing.T) { @@ -69,22 +84,134 @@ func TestProcessSampler_Sample(t *testing.T) { t.Fatalf("NewProcessSampler failed: %v", err) } - // First sample should return 0 and initialize percent := sampler.Sample(100 * time.Millisecond) if percent != 0.0 { t.Errorf("first sample should return 0.0, got %v", percent) } - // Subsequent samples should be valid time.Sleep(10 * time.Millisecond) percent = sampler.Sample(10 * time.Millisecond) if percent < 0.0 || percent > 100.0 { t.Errorf("CPU percent should be between 0 and 100, got %v", percent) } - // Test reset sampler.Reset() if sampler.IsInitialized() { t.Error("sampler should not be initialized after reset") } } + +func TestProcessSampler_SampleEdgeCases(t *testing.T) { + sampler, err := NewProcessSampler() + if err != nil { + t.Fatalf("NewProcessSampler failed: %v", err) + } + + sampler.Sample(100 * time.Millisecond) + _ = sampler.Sample(100 * time.Millisecond) + time.Sleep(1 * time.Millisecond) + percent2 := sampler.Sample(100 * time.Millisecond) + if percent2 < 0.0 || percent2 > 100.0 { + t.Errorf("CPU percent should be between 0 and 100, got %v", percent2) + } + + time.Sleep(50 * time.Millisecond) + percent3 := sampler.Sample(50 * time.Millisecond) + if percent3 < 0.0 || percent3 > 100.0 { + t.Errorf("CPU percent should be clamped to 0-100, got %v", percent3) + } +} + +// TestProcessSampler_SampleErrorHandling tests error handling. +func TestProcessSampler_SampleErrorHandling(t *testing.T) { + sampler, err := NewProcessSampler() + if err != nil { + t.Fatalf("NewProcessSampler failed: %v", err) + } + + initialPercent := sampler.Sample(100 * time.Millisecond) + if initialPercent != 0.0 { + t.Errorf("first sample should return 0.0, got %v", initialPercent) + } + + time.Sleep(10 * time.Millisecond) + validPercent := sampler.Sample(10 * time.Millisecond) + if validPercent < 0.0 || validPercent > 100.0 { + t.Errorf("CPU percent should be between 0 and 100, got %v", validPercent) + } + + sampler.Reset() +} + +// TestProcessSampler_SampleNumCPUEdgeCase tests numcpu <= 0 safety check. +func TestProcessSampler_SampleNumCPUEdgeCase(t *testing.T) { + sampler, err := NewProcessSampler() + if err != nil { + t.Fatalf("NewProcessSampler failed: %v", err) + } + + sampler.Sample(100 * time.Millisecond) + time.Sleep(10 * time.Millisecond) + percent := sampler.Sample(10 * time.Millisecond) + if percent < 0.0 || percent > 100.0 { + t.Errorf("CPU percent should be between 0 and 100, got %v", percent) + } +} + +// TestProcessSampler_SampleNegativePercent tests clamping of negative CPU percent. +func TestProcessSampler_SampleNegativePercent(t *testing.T) { + sampler, err := NewProcessSampler() + if err != nil { + t.Fatalf("NewProcessSampler failed: %v", err) + } + + sampler.Sample(100 * time.Millisecond) + setTestState(t, sampler, 100.0, 50.0, time.Now().Add(-1*time.Second)) + + time.Sleep(10 * time.Millisecond) + percent := sampler.Sample(100 * time.Millisecond) + + if percent < 0.0 { + t.Errorf("CPU percent should be clamped to 0.0 when negative, got %v", percent) + } + if percent > 100.0 { + t.Errorf("CPU percent should not exceed 100.0, got %v", percent) + } +} + +// TestProcessSampler_SampleHighPercent tests clamping of CPU percent > 100%. +func TestProcessSampler_SampleHighPercent(t *testing.T) { + sampler, err := NewProcessSampler() + if err != nil { + t.Fatalf("NewProcessSampler failed: %v", err) + } + + sampler.Sample(100 * time.Millisecond) + setTestState(t, sampler, 0.0, 0.0, time.Now().Add(-1*time.Millisecond)) + time.Sleep(10 * time.Millisecond) + setTestState(t, sampler, 0.0, 0.0, time.Now().Add(-1*time.Millisecond)) + percent := sampler.Sample(100 * time.Millisecond) + + if percent > 100.0 { + t.Errorf("CPU percent should be clamped to 100.0 when > 100, got %v", percent) + } + if percent < 0.0 { + t.Errorf("CPU percent should not be negative, got %v", percent) + } +} + +// TestProcessSampler_SampleZeroWallTime tests handling of zero or negative wall time. +func TestProcessSampler_SampleZeroWallTime(t *testing.T) { + sampler, err := NewProcessSampler() + if err != nil { + t.Fatalf("NewProcessSampler failed: %v", err) + } + + sampler.Sample(100 * time.Millisecond) + setTestState(t, sampler, 0.0, 0.0, time.Now()) + percent := sampler.Sample(100 * time.Millisecond) + + if percent < 0.0 || percent > 100.0 { + t.Errorf("CPU percent should be valid (0-100) or lastPercent, got %v", percent) + } +} diff --git a/internal/sysmonitor/fs_test.go b/internal/sysmonitor/fs_test.go new file mode 100644 index 0000000..56e1a10 --- /dev/null +++ b/internal/sysmonitor/fs_test.go @@ -0,0 +1,95 @@ +package sysmonitor + +import ( + "os" + "path/filepath" + "testing" +) + +func TestOSFileSystem_ReadFile(t *testing.T) { + fs := OSFileSystem{} + + t.Run("read existing file", func(t *testing.T) { + // Create a temporary file + tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.txt") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + testContent := []byte("test content") + if _, err := tmpFile.Write(testContent); err != nil { + t.Fatalf("failed to write to temp file: %v", err) + } + tmpFile.Close() + + // Read the file + content, err := fs.ReadFile(tmpFile.Name()) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + + if string(content) != string(testContent) { + t.Errorf("expected content %q, got %q", string(testContent), string(content)) + } + }) + + t.Run("read non-existent file", func(t *testing.T) { + nonExistentFile := filepath.Join(os.TempDir(), "non-existent-file-12345.txt") + _, err := fs.ReadFile(nonExistentFile) + if err == nil { + t.Error("expected error when reading non-existent file") + } + if !os.IsNotExist(err) { + t.Errorf("expected os.IsNotExist error, got %v", err) + } + }) +} + +func TestOSFileSystem_Open(t *testing.T) { + fs := OSFileSystem{} + + t.Run("open existing file", func(t *testing.T) { + // Create a temporary file + tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.txt") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + defer tmpFile.Close() + + testContent := []byte("test content") + if _, err := tmpFile.Write(testContent); err != nil { + t.Fatalf("failed to write to temp file: %v", err) + } + tmpFile.Close() + + // Open the file + file, err := fs.Open(tmpFile.Name()) + if err != nil { + t.Fatalf("Open failed: %v", err) + } + defer file.Close() + + // Verify we can read from it + stat, err := file.Stat() + if err != nil { + t.Fatalf("Stat failed: %v", err) + } + if stat.Size() != int64(len(testContent)) { + t.Errorf("expected file size %d, got %d", len(testContent), stat.Size()) + } + }) + + t.Run("open non-existent file", func(t *testing.T) { + nonExistentFile := filepath.Join(os.TempDir(), "non-existent-file-12345.txt") + _, err := fs.Open(nonExistentFile) + if err == nil { + t.Error("expected error when opening non-existent file") + } + if !os.IsNotExist(err) { + t.Errorf("expected os.IsNotExist error, got %v", err) + } + }) +} diff --git a/internal/sysmonitor/heuristic.go b/internal/sysmonitor/heuristic.go index 1ea8b49..4bdbae4 100644 --- a/internal/sysmonitor/heuristic.go +++ b/internal/sysmonitor/heuristic.go @@ -27,6 +27,9 @@ func NewGoroutineHeuristicSampler() ProcessCPUSampler { return &GoroutineHeuristicSampler{} } +// Verify implementation of ProcessCPUSampler interface +var _ ProcessCPUSampler = &GoroutineHeuristicSampler{} + // Sample returns the CPU usage percentage over the given time delta func (s *GoroutineHeuristicSampler) Sample(_ time.Duration) float64 { // Uses logarithmic scaling for more realistic CPU estimation diff --git a/internal/sysmonitor/heuristic_test.go b/internal/sysmonitor/heuristic_test.go new file mode 100644 index 0000000..e8313f2 --- /dev/null +++ b/internal/sysmonitor/heuristic_test.go @@ -0,0 +1,338 @@ +package sysmonitor + +import ( + "math" + "runtime" + "sync" + "testing" + "time" +) + +func TestGoroutineHeuristicSampler_IsInitialized_Comprehensive(t *testing.T) { + sampler := NewGoroutineHeuristicSampler() + + // IsInitialized should always return true for heuristic sampler + if !sampler.IsInitialized() { + t.Error("GoroutineHeuristicSampler.IsInitialized() should always return true") + } + + // Should remain true after reset + sampler.Reset() + if !sampler.IsInitialized() { + t.Error("GoroutineHeuristicSampler.IsInitialized() should remain true after reset") + } + + // Should remain true after sampling + sampler.Sample(100 * time.Millisecond) + if !sampler.IsInitialized() { + t.Error("GoroutineHeuristicSampler.IsInitialized() should remain true after sampling") + } +} + +func TestGoroutineHeuristicSampler_Reset_Comprehensive(t *testing.T) { + sampler := NewGoroutineHeuristicSampler() + + // Reset should be a no-op but should not panic + sampler.Reset() + + // Verify sampler still works after reset + percent1 := sampler.Sample(100 * time.Millisecond) + sampler.Reset() + percent2 := sampler.Sample(100 * time.Millisecond) + + // Results should be the same (since reset doesn't affect state) + if percent1 != percent2 { + t.Logf("Note: percent1=%v, percent2=%v (may differ due to goroutine count changes)", percent1, percent2) + } + + // Both should be valid + if percent1 < 0.0 || percent1 > CPUHeuristicMaxCPU { + t.Errorf("CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, percent1) + } + if percent2 < 0.0 || percent2 > CPUHeuristicMaxCPU { + t.Errorf("CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, percent2) + } +} + +func TestGoroutineHeuristicSampler_Sample_LinearScaling(t *testing.T) { + // Test the linear scaling formula for goroutines <= 10 + // Formula: CPUHeuristicBaselineCPU + goroutineCount * CPUHeuristicLinearScaleFactor + testCases := []struct { + name string + goroutineCount float64 + expectedMin float64 + expectedMax float64 + expectedFormula float64 + }{ + { + name: "1 goroutine", + goroutineCount: 1.0, + expectedFormula: CPUHeuristicBaselineCPU + 1.0*CPUHeuristicLinearScaleFactor, + expectedMin: 10.0, + expectedMax: 12.0, + }, + { + name: "5 goroutines", + goroutineCount: 5.0, + expectedFormula: CPUHeuristicBaselineCPU + 5.0*CPUHeuristicLinearScaleFactor, + expectedMin: 14.0, + expectedMax: 16.0, + }, + { + name: "10 goroutines (max linear)", + goroutineCount: 10.0, + expectedFormula: CPUHeuristicBaselineCPU + 10.0*CPUHeuristicLinearScaleFactor, + expectedMin: 19.0, + expectedMax: 21.0, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Calculate expected value using the formula + expected := tc.expectedFormula + + // Verify the formula matches expected range + if expected < tc.expectedMin || expected > tc.expectedMax { + t.Errorf("Formula result %.2f should be between %.2f and %.2f", expected, tc.expectedMin, tc.expectedMax) + } + }) + } +} + +func TestGoroutineHeuristicSampler_Sample_LogarithmicScaling(t *testing.T) { + // Test the logarithmic scaling formula for goroutines > 10 + // Formula: CPUHeuristicBaselineCPU + ln(goroutineCount) * CPUHeuristicLogScaleFactor + testCases := []struct { + name string + goroutineCount float64 + expectedMin float64 + expectedMax float64 + expectedFormula float64 + }{ + { + name: "11 goroutines (first logarithmic)", + goroutineCount: 11.0, + expectedFormula: CPUHeuristicBaselineCPU + math.Log(11.0)*CPUHeuristicLogScaleFactor, + expectedMin: 28.0, + expectedMax: 32.0, + }, + { + name: "100 goroutines", + goroutineCount: 100.0, + expectedFormula: CPUHeuristicBaselineCPU + math.Log(100.0)*CPUHeuristicLogScaleFactor, + expectedMin: 45.0, + expectedMax: 55.0, + }, + { + name: "1000 goroutines", + goroutineCount: 1000.0, + expectedFormula: CPUHeuristicBaselineCPU + math.Log(1000.0)*CPUHeuristicLogScaleFactor, + expectedMin: 65.0, + expectedMax: 75.0, + }, + { + name: "10000 goroutines", + goroutineCount: 10000.0, + expectedFormula: CPUHeuristicBaselineCPU + math.Log(10000.0)*CPUHeuristicLogScaleFactor, + expectedMin: 80.0, + expectedMax: 90.0, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Calculate expected value using the formula + expected := tc.expectedFormula + + // Verify the formula matches expected range + if expected < tc.expectedMin || expected > tc.expectedMax { + t.Errorf("Formula result %.2f should be between %.2f and %.2f", expected, tc.expectedMin, tc.expectedMax) + } + + // Verify it doesn't exceed max (capped values should equal max) + if expected > CPUHeuristicMaxCPU { + if CPUHeuristicMaxCPU != 95.0 { + t.Errorf("Expected value %.2f exceeds max, but CPUHeuristicMaxCPU is %.1f (expected 95.0)", + expected, CPUHeuristicMaxCPU) + } + } + }) + } +} + +func TestGoroutineHeuristicSampler_Sample_MaxCPUCap_Formula(t *testing.T) { + // Test that very high goroutine counts are capped at CPUHeuristicMaxCPU + // We can't directly set goroutine count, but we can verify the cap works + // by checking the formula with a very high value + veryHighGoroutineCount := 1000000.0 + expectedUncapped := CPUHeuristicBaselineCPU + math.Log(veryHighGoroutineCount)*CPUHeuristicLogScaleFactor + + if expectedUncapped > CPUHeuristicMaxCPU { + // Verify the cap would be applied + if CPUHeuristicMaxCPU != 95.0 { + t.Errorf("CPUHeuristicMaxCPU should be 95.0, got %.1f", CPUHeuristicMaxCPU) + } + } + + // Test actual sampler with current goroutine count + sampler := NewGoroutineHeuristicSampler() + percent := sampler.Sample(100 * time.Millisecond) + if percent > CPUHeuristicMaxCPU { + t.Errorf("CPU percent should be capped at %.1f, got %v", CPUHeuristicMaxCPU, percent) + } + if percent < 0.0 { + t.Errorf("CPU percent should not be negative, got %v", percent) + } + + // Test multiple samples to ensure consistency + for i := 0; i < 10; i++ { + p := sampler.Sample(100 * time.Millisecond) + if p > CPUHeuristicMaxCPU || p < 0.0 { + t.Errorf("CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, p) + } + } +} + +func TestGoroutineHeuristicSampler_Sample_WithGoroutines(t *testing.T) { + sampler := NewGoroutineHeuristicSampler() + + // Get baseline goroutine count + baselineGoroutines := runtime.NumGoroutine() + baselinePercent := sampler.Sample(100 * time.Millisecond) + + // Spawn some goroutines to increase the count + var wg sync.WaitGroup + numGoroutines := 20 + wg.Add(numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func() { + defer wg.Done() + time.Sleep(10 * time.Millisecond) + }() + } + + // Wait a bit for goroutines to start + time.Sleep(5 * time.Millisecond) + + // Sample with increased goroutine count + highPercent := sampler.Sample(100 * time.Millisecond) + + // Wait for goroutines to finish + wg.Wait() + time.Sleep(10 * time.Millisecond) + + // Sample after goroutines finish + lowPercent := sampler.Sample(100 * time.Millisecond) + + // Verify all percentages are valid + if baselinePercent < 0.0 || baselinePercent > CPUHeuristicMaxCPU { + t.Errorf("Baseline CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, baselinePercent) + } + if highPercent < 0.0 || highPercent > CPUHeuristicMaxCPU { + t.Errorf("High CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, highPercent) + } + if lowPercent < 0.0 || lowPercent > CPUHeuristicMaxCPU { + t.Errorf("Low CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, lowPercent) + } + + // High goroutine count should generally result in higher CPU estimate + // (though this is not guaranteed due to timing) + t.Logf("Baseline goroutines: %d, CPU: %.2f%%", baselineGoroutines, baselinePercent) + t.Logf("High goroutines: %d, CPU: %.2f%%", runtime.NumGoroutine(), highPercent) + t.Logf("After cleanup: %d, CPU: %.2f%%", runtime.NumGoroutine(), lowPercent) +} + +func TestGoroutineHeuristicSampler_Sample_TimeDeltaIgnored(t *testing.T) { + sampler := NewGoroutineHeuristicSampler() + + // The heuristic sampler ignores the time delta parameter + // All samples should return the same value (based on goroutine count) + percent1 := sampler.Sample(1 * time.Millisecond) + percent2 := sampler.Sample(100 * time.Millisecond) + percent3 := sampler.Sample(1 * time.Second) + + // All should be the same (since they're based on goroutine count, not time) + if percent1 != percent2 || percent2 != percent3 { + // This is acceptable if goroutine count changed between samples + t.Logf("Note: Percentages differ (percent1=%.2f, percent2=%.2f, percent3=%.2f), "+ + "likely due to goroutine count changes", percent1, percent2, percent3) + } + + // All should be valid + if percent1 < 0.0 || percent1 > CPUHeuristicMaxCPU { + t.Errorf("CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, percent1) + } +} + +func TestGoroutineHeuristicSampler_Constants(t *testing.T) { + // Verify all constants have expected values + if CPUHeuristicBaselineCPU != 10.0 { + t.Errorf("CPUHeuristicBaselineCPU should be 10.0, got %.1f", CPUHeuristicBaselineCPU) + } + if CPUHeuristicLinearScaleFactor != 1.0 { + t.Errorf("CPUHeuristicLinearScaleFactor should be 1.0, got %.1f", CPUHeuristicLinearScaleFactor) + } + if CPUHeuristicLogScaleFactor != 8.0 { + t.Errorf("CPUHeuristicLogScaleFactor should be 8.0, got %.1f", CPUHeuristicLogScaleFactor) + } + if CPUHeuristicMaxGoroutinesForLinear != 10 { + t.Errorf("CPUHeuristicMaxGoroutinesForLinear should be 10, got %d", CPUHeuristicMaxGoroutinesForLinear) + } + if CPUHeuristicMaxCPU != 95.0 { + t.Errorf("CPUHeuristicMaxCPU should be 95.0, got %.1f", CPUHeuristicMaxCPU) + } +} + +func TestGoroutineHeuristicSampler_FormulaTransition(t *testing.T) { + // Test the transition point between linear and logarithmic scaling + // At exactly 10 goroutines, should use linear formula + linearAt10 := CPUHeuristicBaselineCPU + 10.0*CPUHeuristicLinearScaleFactor + + // At 11 goroutines, should use logarithmic formula + logAt11 := CPUHeuristicBaselineCPU + math.Log(11.0)*CPUHeuristicLogScaleFactor + + // Verify the transition is smooth (log should be higher than linear) + if logAt11 <= linearAt10 { + t.Errorf("Logarithmic scaling at 11 (%.2f) should be higher than linear at 10 (%.2f)", logAt11, linearAt10) + } + + // Verify both are within reasonable bounds + if linearAt10 < 0.0 || linearAt10 > CPUHeuristicMaxCPU { + t.Errorf("Linear at 10 should be between 0 and %.1f, got %.2f", CPUHeuristicMaxCPU, linearAt10) + } + if logAt11 < 0.0 || logAt11 > CPUHeuristicMaxCPU { + t.Errorf("Log at 11 should be between 0 and %.1f, got %.2f", CPUHeuristicMaxCPU, logAt11) + } +} + +func TestGoroutineHeuristicSampler_EdgeCases(t *testing.T) { + sampler := NewGoroutineHeuristicSampler() + + // Test with zero time delta (should still work) + percent := sampler.Sample(0) + if percent < 0.0 || percent > CPUHeuristicMaxCPU { + t.Errorf("CPU percent with zero delta should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, percent) + } + + // Test with negative time delta (should still work, though unusual) + percent = sampler.Sample(-100 * time.Millisecond) + if percent < 0.0 || percent > CPUHeuristicMaxCPU { + t.Errorf("CPU percent with negative delta should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, percent) + } + + // Test with very large time delta + percent = sampler.Sample(1 * time.Hour) + if percent < 0.0 || percent > CPUHeuristicMaxCPU { + t.Errorf("CPU percent with large delta should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, percent) + } + + // Test multiple rapid samples + for i := 0; i < 100; i++ { + p := sampler.Sample(100 * time.Millisecond) + if p < 0.0 || p > CPUHeuristicMaxCPU { + t.Errorf("CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, p) + } + } +} diff --git a/internal/sysmonitor/memory_linux_cgroup_test.go b/internal/sysmonitor/memory_linux_cgroup_test.go new file mode 100644 index 0000000..8a2fc34 --- /dev/null +++ b/internal/sysmonitor/memory_linux_cgroup_test.go @@ -0,0 +1,592 @@ +//go:build linux + +package sysmonitor + +import ( + "errors" + "io" + "io/fs" + "strings" + "testing" +) + +// mockFileSystem is a mock implementation of FileSystem for testing +type mockFileSystem struct { + files map[string][]byte + open map[string]io.ReadCloser +} + +func newMockFileSystem() *mockFileSystem { + return &mockFileSystem{ + files: make(map[string][]byte), + open: make(map[string]io.ReadCloser), + } +} + +func (m *mockFileSystem) ReadFile(name string) ([]byte, error) { + if data, ok := m.files[name]; ok { + return data, nil + } + return nil, fs.ErrNotExist +} + +func (m *mockFileSystem) Open(name string) (fs.File, error) { + if file, ok := m.open[name]; ok { + return &mockFile{reader: file}, nil + } + if data, ok := m.files[name]; ok { + return &mockFile{reader: io.NopCloser(strings.NewReader(string(data)))}, nil + } + return nil, fs.ErrNotExist +} + +// mockFile implements fs.File +type mockFile struct { + reader io.ReadCloser +} + +func (m *mockFile) Stat() (fs.FileInfo, error) { + return nil, errors.New("not implemented") +} + +func (m *mockFile) Read(p []byte) (int, error) { + return m.reader.Read(p) +} + +func (m *mockFile) Close() error { + return m.reader.Close() +} + +func TestReadCgroupValueWithFS(t *testing.T) { + tests := []struct { + name string + path string + fileContent string + checkUnlimited bool + want uint64 + wantErr bool + errContains string + }{ + { + name: "valid value", + path: "/sys/fs/cgroup/memory.current", + fileContent: "1073741824", + checkUnlimited: false, + want: 1073741824, + wantErr: false, + }, + { + name: "max value v2", + path: "/sys/fs/cgroup/memory.max", + fileContent: "max", + checkUnlimited: false, + want: 0, + wantErr: true, + errContains: "unlimited memory limit", + }, + { + name: "unlimited v1 (large number)", + path: "/sys/fs/cgroup/memory/memory.limit_in_bytes", + fileContent: "9223372036854775808", // > 1<<60 + checkUnlimited: true, + want: 0, + wantErr: true, + errContains: "unlimited memory limit", + }, + { + name: "file not found", + path: "/nonexistent", + fileContent: "", + checkUnlimited: false, + want: 0, + wantErr: true, + errContains: "failed to read file", + }, + { + name: "invalid number", + path: "/sys/fs/cgroup/memory.current", + fileContent: "not-a-number", + checkUnlimited: false, + want: 0, + wantErr: true, + errContains: "failed to parse value", + }, + { + name: "zero value", + path: "/sys/fs/cgroup/memory.current", + fileContent: "0", + checkUnlimited: false, + want: 0, + wantErr: false, + }, + { + name: "large valid value", + path: "/sys/fs/cgroup/memory.current", + fileContent: "8589934592", // 8GB + checkUnlimited: false, + want: 8589934592, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := newMockFileSystem() + if tt.fileContent != "" { + fs.files[tt.path] = []byte(tt.fileContent) + } + + got, err := readCgroupValueWithFS(fs, tt.path, tt.checkUnlimited) + if (err != nil) != tt.wantErr { + t.Errorf("readCgroupValueWithFS() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("readCgroupValueWithFS() error = %v, want error containing %q", err, tt.errContains) + } + } else { + if got != tt.want { + t.Errorf("readCgroupValueWithFS() = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestReadCgroupStatWithFS(t *testing.T) { + tests := []struct { + name string + path string + key string + fileContent string + want uint64 + wantErr bool + errContains string + }{ + { + name: "valid stat v2", + path: "/sys/fs/cgroup/memory.stat", + key: "inactive_file", + fileContent: "inactive_file 1048576\nactive_file 2097152\n", + want: 1048576, + wantErr: false, + }, + { + name: "valid stat v1", + path: "/sys/fs/cgroup/memory/memory.stat", + key: "total_inactive_file", + fileContent: "cache 4194304\ntotal_inactive_file 2097152\nrss 1048576\n", + want: 2097152, + wantErr: false, + }, + { + name: "key not found", + path: "/sys/fs/cgroup/memory.stat", + key: "nonexistent_key", + fileContent: "inactive_file 1048576\nactive_file 2097152\n", + want: 0, + wantErr: true, + errContains: "not found", + }, + { + name: "file not found", + path: "/nonexistent", + key: "inactive_file", + fileContent: "", + want: 0, + wantErr: true, + errContains: "failed to open file", + }, + { + name: "invalid value", + path: "/sys/fs/cgroup/memory.stat", + key: "inactive_file", + fileContent: "inactive_file not-a-number\n", + want: 0, + wantErr: true, + errContains: "failed to parse value", + }, + { + name: "key with no value", + path: "/sys/fs/cgroup/memory.stat", + key: "inactive_file", + fileContent: "inactive_file\n", + want: 0, + wantErr: true, + errContains: "not found", + }, + { + name: "key at end of line", + path: "/sys/fs/cgroup/memory.stat", + key: "inactive_file", + fileContent: "inactive_file 1048576", + want: 1048576, + wantErr: false, + }, + { + name: "multiple spaces", + path: "/sys/fs/cgroup/memory.stat", + key: "inactive_file", + fileContent: "inactive_file 1048576 \n", + want: 1048576, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := newMockFileSystem() + if tt.fileContent != "" { + fs.files[tt.path] = []byte(tt.fileContent) + } + + got, err := readCgroupStatWithFS(fs, tt.path, tt.key) + if (err != nil) != tt.wantErr { + t.Errorf("readCgroupStatWithFS() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("readCgroupStatWithFS() error = %v, want error containing %q", err, tt.errContains) + } + } else { + if got != tt.want { + t.Errorf("readCgroupStatWithFS() = %v, want %v", got, tt.want) + } + } + }) + } +} + +func TestReadCgroupMemoryWithFS_V2(t *testing.T) { + tests := []struct { + name string + usage string + limit string + stat string + wantTotal uint64 + wantAvailable uint64 + wantErr bool + errContains string + }{ + { + name: "normal case", + usage: "1073741824", // 1GB + limit: "2147483648", // 2GB + stat: "inactive_file 104857600\n", // 100MB + wantTotal: 2147483648, + wantAvailable: 1178599424, // (2GB - 1GB) + 100MB = 1073741824 + 104857600 + wantErr: false, + }, + { + name: "usage exceeds limit", + usage: "2147483648", // 2GB + limit: "1073741824", // 1GB + stat: "inactive_file 104857600\n", // 100MB + wantTotal: 1073741824, + wantAvailable: 104857600, // Only reclaimable memory + wantErr: false, + }, + { + name: "no inactive_file stat", + usage: "1073741824", + limit: "2147483648", + stat: "active_file 104857600\n", + wantTotal: 2147483648, + wantAvailable: 1073741824, // (2GB - 1GB) + 0 + wantErr: false, + }, + { + name: "available exceeds limit", + usage: "1073741824", + limit: "2147483648", + stat: "inactive_file 2147483648\n", // 2GB (would make available > limit) + wantTotal: 2147483648, + wantAvailable: 2147483648, // Capped at limit + wantErr: false, + }, + { + name: "zero usage", + usage: "0", + limit: "2147483648", + stat: "inactive_file 104857600\n", + wantTotal: 2147483648, + wantAvailable: 2147483648, // 2GB + 100MB = 2252341248, but capped at limit (2GB) + wantErr: false, + }, + { + name: "missing usage file", + usage: "", + limit: "2147483648", + stat: "inactive_file 104857600\n", + wantTotal: 0, + wantAvailable: 0, + wantErr: true, + errContains: "failed to read cgroup v2 memory usage", + }, + { + name: "missing limit file", + usage: "1073741824", + limit: "", + stat: "inactive_file 104857600\n", + wantTotal: 0, + wantAvailable: 0, + wantErr: true, + errContains: "failed to read cgroup v2 memory limit", + }, + { + name: "unlimited limit", + usage: "1073741824", + limit: "max", + stat: "inactive_file 104857600\n", + wantTotal: 0, + wantAvailable: 0, + wantErr: true, + errContains: "unlimited memory limit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := newMockFileSystem() + if tt.usage != "" { + fs.files["/sys/fs/cgroup/memory.current"] = []byte(tt.usage) + } + if tt.limit != "" { + fs.files["/sys/fs/cgroup/memory.max"] = []byte(tt.limit) + } + if tt.stat != "" { + fs.files["/sys/fs/cgroup/memory.stat"] = []byte(tt.stat) + } + + got, err := readCgroupMemoryWithFS(fs, cgroupV2Config) + if (err != nil) != tt.wantErr { + t.Errorf("readCgroupMemoryWithFS() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("readCgroupMemoryWithFS() error = %v, want error containing %q", err, tt.errContains) + } + } else { + if got.Total != tt.wantTotal { + t.Errorf("readCgroupMemoryWithFS() Total = %v, want %v", got.Total, tt.wantTotal) + } + if got.Available != tt.wantAvailable { + t.Errorf("readCgroupMemoryWithFS() Available = %v, want %v", got.Available, tt.wantAvailable) + } + } + }) + } +} + +func TestReadCgroupMemoryWithFS_V1(t *testing.T) { + tests := []struct { + name string + usage string + limit string + stat string + wantTotal uint64 + wantAvailable uint64 + wantErr bool + errContains string + }{ + { + name: "normal case", + usage: "1073741824", // 1GB + limit: "2147483648", // 2GB + stat: "total_inactive_file 104857600\n", // 100MB + wantTotal: 2147483648, + wantAvailable: 1178599424, // (2GB - 1GB) + 100MB = 1073741824 + 104857600 + wantErr: false, + }, + { + name: "usage exceeds limit", + usage: "2147483648", // 2GB + limit: "1073741824", // 1GB + stat: "total_inactive_file 104857600\n", // 100MB + wantTotal: 1073741824, + wantAvailable: 104857600, // Only reclaimable memory + wantErr: false, + }, + { + name: "unlimited limit (large number)", + usage: "1073741824", + limit: "9223372036854775808", // > 1<<60 + stat: "total_inactive_file 104857600\n", + wantTotal: 0, + wantAvailable: 0, + wantErr: true, + errContains: "unlimited memory limit", + }, + { + name: "missing stat file (should default to 0)", + usage: "1073741824", + limit: "2147483648", + stat: "", + wantTotal: 2147483648, + wantAvailable: 1073741824, // (2GB - 1GB) + 0 + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := newMockFileSystem() + if tt.usage != "" { + fs.files["/sys/fs/cgroup/memory/memory.usage_in_bytes"] = []byte(tt.usage) + } + if tt.limit != "" { + fs.files["/sys/fs/cgroup/memory/memory.limit_in_bytes"] = []byte(tt.limit) + } + if tt.stat != "" { + fs.files["/sys/fs/cgroup/memory/memory.stat"] = []byte(tt.stat) + } + + got, err := readCgroupMemoryWithFS(fs, cgroupV1Config) + if (err != nil) != tt.wantErr { + t.Errorf("readCgroupMemoryWithFS() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("readCgroupMemoryWithFS() error = %v, want error containing %q", err, tt.errContains) + } + } else { + if got.Total != tt.wantTotal { + t.Errorf("readCgroupMemoryWithFS() Total = %v, want %v", got.Total, tt.wantTotal) + } + if got.Available != tt.wantAvailable { + t.Errorf("readCgroupMemoryWithFS() Available = %v, want %v", got.Available, tt.wantAvailable) + } + } + }) + } +} + +func TestMakeCgroupV2Reader(t *testing.T) { + fs := newMockFileSystem() + fs.files["/sys/fs/cgroup/memory.current"] = []byte("1073741824") + fs.files["/sys/fs/cgroup/memory.max"] = []byte("2147483648") + fs.files["/sys/fs/cgroup/memory.stat"] = []byte("inactive_file 104857600\n") + + reader := makeCgroupV2Reader(fs) + mem, err := reader() + if err != nil { + t.Fatalf("makeCgroupV2Reader() error = %v", err) + } + + if mem.Total != 2147483648 { + t.Errorf("makeCgroupV2Reader() Total = %v, want %v", mem.Total, 2147483648) + } + if mem.Available != 1178599424 { + t.Errorf("makeCgroupV2Reader() Available = %v, want %v", mem.Available, 1178599424) + } +} + +func TestMakeCgroupV1Reader(t *testing.T) { + fs := newMockFileSystem() + fs.files["/sys/fs/cgroup/memory/memory.usage_in_bytes"] = []byte("1073741824") + fs.files["/sys/fs/cgroup/memory/memory.limit_in_bytes"] = []byte("2147483648") + fs.files["/sys/fs/cgroup/memory/memory.stat"] = []byte("total_inactive_file 104857600\n") + + reader := makeCgroupV1Reader(fs) + mem, err := reader() + if err != nil { + t.Fatalf("makeCgroupV1Reader() error = %v", err) + } + + if mem.Total != 2147483648 { + t.Errorf("makeCgroupV1Reader() Total = %v, want %v", mem.Total, 2147483648) + } + if mem.Available != 1178599424 { + t.Errorf("makeCgroupV1Reader() Available = %v, want %v", mem.Available, 1178599424) + } +} + +func TestReadCgroupMemoryWithFS_EdgeCases(t *testing.T) { + tests := []struct { + name string + config cgroupMemoryConfig + setupFS func(*mockFileSystem) + wantErr bool + errContains string + validate func(*testing.T, SystemMemory) + }{ + { + name: "exact limit equals usage", + config: cgroupV2Config, + setupFS: func(fs *mockFileSystem) { + fs.files["/sys/fs/cgroup/memory.current"] = []byte("1073741824") + fs.files["/sys/fs/cgroup/memory.max"] = []byte("1073741824") + fs.files["/sys/fs/cgroup/memory.stat"] = []byte("inactive_file 104857600\n") + }, + wantErr: false, + validate: func(t *testing.T, mem SystemMemory) { + if mem.Total != 1073741824 { + t.Errorf("Total = %v, want %v", mem.Total, 1073741824) + } + if mem.Available != 104857600 { + t.Errorf("Available = %v, want %v", mem.Available, 104857600) + } + }, + }, + { + name: "very large values", + config: cgroupV2Config, + setupFS: func(fs *mockFileSystem) { + fs.files["/sys/fs/cgroup/memory.current"] = []byte("17179869184") // 16GB + fs.files["/sys/fs/cgroup/memory.max"] = []byte("34359738368") // 32GB + fs.files["/sys/fs/cgroup/memory.stat"] = []byte("inactive_file 1073741824\n") // 1GB + }, + wantErr: false, + validate: func(t *testing.T, mem SystemMemory) { + if mem.Total != 34359738368 { + t.Errorf("Total = %v, want %v", mem.Total, 34359738368) + } + if mem.Available != 18253611008 { // (32GB - 16GB) + 1GB + t.Errorf("Available = %v, want %v", mem.Available, 18253611008) + } + }, + }, + { + name: "stat file read error (should default to 0)", + config: cgroupV2Config, + setupFS: func(fs *mockFileSystem) { + fs.files["/sys/fs/cgroup/memory.current"] = []byte("1073741824") + fs.files["/sys/fs/cgroup/memory.max"] = []byte("2147483648") + // stat file not set, should default to 0 + }, + wantErr: false, + validate: func(t *testing.T, mem SystemMemory) { + if mem.Total != 2147483648 { + t.Errorf("Total = %v, want %v", mem.Total, 2147483648) + } + if mem.Available != 1073741824 { // (2GB - 1GB) + 0 + t.Errorf("Available = %v, want %v", mem.Available, 1073741824) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := newMockFileSystem() + tt.setupFS(fs) + + got, err := readCgroupMemoryWithFS(fs, tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("readCgroupMemoryWithFS() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("readCgroupMemoryWithFS() error = %v, want error containing %q", err, tt.errContains) + } + } else { + tt.validate(t, got) + } + }) + } +} diff --git a/internal/sysmonitor/memory_linux_test.go b/internal/sysmonitor/memory_linux_test.go deleted file mode 100644 index 78d1e00..0000000 --- a/internal/sysmonitor/memory_linux_test.go +++ /dev/null @@ -1,213 +0,0 @@ -//go:build linux - -package sysmonitor - -import ( - "errors" - "io/fs" - "strings" - "testing" -) - -// ===== Mock filesystem implementation ===== - -type mockFS map[string]string - -func (m mockFS) ReadFile(name string) ([]byte, error) { - if content, ok := m[name]; ok { - return []byte(content), nil - } - return nil, errors.New("file does not exist: " + name) -} - -func (m mockFS) Open(name string) (fs.File, error) { - if content, ok := m[name]; ok { - return &mockFile{content: content}, nil - } - return nil, errors.New("file does not exist: " + name) -} - -type mockFile struct { - content string - reader *strings.Reader -} - -func (m *mockFile) Read(p []byte) (int, error) { - if m.reader == nil { - m.reader = strings.NewReader(m.content) - } - return m.reader.Read(p) -} - -func (m *mockFile) Close() error { return nil } - -func (m *mockFile) Stat() (fs.FileInfo, error) { return nil, nil } - -// ===== Test functions ===== - -func TestReadCgroupV2Memory(t *testing.T) { - mockFS := mockFS{ - "/sys/fs/cgroup/memory.current": "1073741824\n", // 1GB - "/sys/fs/cgroup/memory.max": "2147483648\n", // 2GB - "/sys/fs/cgroup/memory.stat": "inactive_file 104857600\n", // 100MB - } - - mem, err := readCgroupMemoryWithFS(mockFS, cgroupV2Config) - if err != nil { - t.Fatalf("readCgroupMemoryWithFS failed: %v", err) - } - - expectedTotal := uint64(2147483648) // 2GB - expectedAvailable := uint64(1178599424) // (2GB - 1GB) + 100MB = 1.1GB available - - if mem.Total != expectedTotal { - t.Errorf("Expected total %d, got %d", expectedTotal, mem.Total) - } - - if mem.Available != expectedAvailable { - t.Errorf("Expected available %d, got %d", expectedAvailable, mem.Available) - } -} - -func TestReadCgroupV1Memory(t *testing.T) { - mockFS := mockFS{ - "/sys/fs/cgroup/memory/memory.usage_in_bytes": "1073741824\n", // 1GB - "/sys/fs/cgroup/memory/memory.limit_in_bytes": "2147483648\n", // 2GB - "/sys/fs/cgroup/memory/memory.stat": "total_inactive_file 104857600\n", // 100MB - } - - mem, err := readCgroupMemoryWithFS(mockFS, cgroupV1Config) - if err != nil { - t.Fatalf("readCgroupMemoryWithFS failed: %v", err) - } - - expectedTotal := uint64(2147483648) // 2GB - expectedAvailable := uint64(1178599424) // (2GB - 1GB) + 100MB = 1.1GB available - - if mem.Total != expectedTotal { - t.Errorf("Expected total %d, got %d", expectedTotal, mem.Total) - } - - if mem.Available != expectedAvailable { - t.Errorf("Expected available %d, got %d", expectedAvailable, mem.Available) - } -} - -func TestReadCgroupValueWithFS(t *testing.T) { - mockFS := mockFS{ - "/test/file": "123456\n", - } - - value, err := readCgroupValueWithFS(mockFS, "/test/file", false) - if err != nil { - t.Fatalf("readCgroupValueWithFS failed: %v", err) - } - - expected := uint64(123456) - if value != expected { - t.Errorf("Expected %d, got %d", expected, value) - } - - // Test "max" value (unlimited) - mockFS["/test/max"] = "max\n" - _, err = readCgroupValueWithFS(mockFS, "/test/max", false) - if err == nil { - t.Error("Expected error for 'max' value") - } -} - -func TestReadCgroupStatWithFS(t *testing.T) { - mockFS := mockFS{ - "/test/stat": "inactive_file 987654\nactive_file 123456\n", - } - - value, err := readCgroupStatWithFS(mockFS, "/test/stat", "inactive_file") - if err != nil { - t.Fatalf("readCgroupStatWithFS failed: %v", err) - } - - expected := uint64(987654) - if value != expected { - t.Errorf("Expected %d, got %d", expected, value) - } - - // Test key not found - _, err = readCgroupStatWithFS(mockFS, "/test/stat", "nonexistent_key") - if err == nil { - t.Error("Expected error for nonexistent key") - } -} - -func TestReadCgroupV2Memory_UsageExceedsLimit(t *testing.T) { - mockFS := mockFS{ - "/sys/fs/cgroup/memory.current": "3000000000\n", // 3GB (exceeds limit) - "/sys/fs/cgroup/memory.max": "2147483648\n", // 2GB - "/sys/fs/cgroup/memory.stat": "inactive_file 104857600\n", // 100MB - } - - mem, err := readCgroupMemoryWithFS(mockFS, cgroupV2Config) - if err != nil { - t.Fatalf("readCgroupMemoryWithFS failed: %v", err) - } - - expectedTotal := uint64(2147483648) // 2GB - expectedAvailable := uint64(104857600) // Only reclaimable memory available when usage > limit - - if mem.Total != expectedTotal { - t.Errorf("Expected total %d, got %d", expectedTotal, mem.Total) - } - - if mem.Available != expectedAvailable { - t.Errorf("Expected available %d, got %d", expectedAvailable, mem.Available) - } -} - -func TestReadCgroupV2Memory_Unlimited(t *testing.T) { - mockFS := mockFS{ - "/sys/fs/cgroup/memory.current": "1073741824\n", // 1GB - "/sys/fs/cgroup/memory.max": "max\n", // Unlimited - "/sys/fs/cgroup/memory.stat": "inactive_file 104857600\n", // 100MB - } - - _, err := readCgroupMemoryWithFS(mockFS, cgroupV2Config) - if err == nil { - t.Error("Expected error for unlimited memory (max)") - } -} - -func TestReadCgroupV2Memory_MissingStatFile(t *testing.T) { - mockFS := mockFS{ - "/sys/fs/cgroup/memory.current": "1073741824\n", // 1GB - "/sys/fs/cgroup/memory.max": "2147483648\n", // 2GB - // memory.stat is missing - } - - mem, err := readCgroupMemoryWithFS(mockFS, cgroupV2Config) - if err != nil { - t.Fatalf("readCgroupMemoryWithFS failed: %v", err) - } - - expectedTotal := uint64(2147483648) // 2GB - expectedAvailable := uint64(1073741824) // (2GB - 1GB) + 0 = 1GB (no reclaimable) - - if mem.Total != expectedTotal { - t.Errorf("Expected total %d, got %d", expectedTotal, mem.Total) - } - - if mem.Available != expectedAvailable { - t.Errorf("Expected available %d, got %d", expectedAvailable, mem.Available) - } -} - -func TestReadCgroupV1Memory_Unlimited(t *testing.T) { - mockFS := mockFS{ - "/sys/fs/cgroup/memory/memory.usage_in_bytes": "1073741824\n", // 1GB - "/sys/fs/cgroup/memory/memory.limit_in_bytes": "18446744073709551615\n", // Huge number (unlimited) - "/sys/fs/cgroup/memory/memory.stat": "total_inactive_file 104857600\n", // 100MB - } - - _, err := readCgroupMemoryWithFS(mockFS, cgroupV1Config) - if err == nil { - t.Error("Expected error for unlimited memory") - } -} diff --git a/internal/sysmonitor/memory_test.go b/internal/sysmonitor/memory_test.go index a65f8de..fbb76e9 100644 --- a/internal/sysmonitor/memory_test.go +++ b/internal/sysmonitor/memory_test.go @@ -1,12 +1,18 @@ package sysmonitor import ( + "runtime" + "strings" "testing" ) func TestGetSystemMemory(t *testing.T) { mem, err := GetSystemMemory() if err != nil { + // Skip test on unsupported platforms + if strings.Contains(err.Error(), "not supported on this platform") { + t.Skipf("GetSystemMemory not supported on %s: %v", runtime.GOOS, err) + } t.Fatalf("GetSystemMemory failed: %v", err) } @@ -22,6 +28,10 @@ func TestGetSystemMemory(t *testing.T) { func TestMemorySampler(t *testing.T) { mem, err := GetSystemMemory() if err != nil { + // Skip test on unsupported platforms + if strings.Contains(err.Error(), "not supported on this platform") { + t.Skipf("GetSystemMemory not supported on %s: %v", runtime.GOOS, err) + } t.Fatalf("GetSystemMemory failed: %v", err) } diff --git a/internal/sysmonitor/memory_windows.go b/internal/sysmonitor/memory_windows.go index bcd4f73..39072f9 100644 --- a/internal/sysmonitor/memory_windows.go +++ b/internal/sysmonitor/memory_windows.go @@ -13,7 +13,7 @@ var ( kernel32 = syscall.NewLazyDLL("kernel32.dll") procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") - memoryReader = getSystemMemoryWindows + memoryReader = getSystemMemoryWindows memoryReaderMu sync.RWMutex ) From 604b5b55f284224d24ef5b7d7c1330212335f835 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sun, 23 Nov 2025 14:22:57 +0300 Subject: [PATCH 48/54] test(resource_monitor): add tests for clamping and validation of percentage values --- flow/resource_monitor_test.go | 249 +++++++++++++++++++++++++ internal/sysmonitor/cpu_darwin_test.go | 26 +++ 2 files changed, 275 insertions(+) create mode 100644 internal/sysmonitor/cpu_darwin_test.go diff --git a/flow/resource_monitor_test.go b/flow/resource_monitor_test.go index 5a41192..fa507e3 100644 --- a/flow/resource_monitor_test.go +++ b/flow/resource_monitor_test.go @@ -514,3 +514,252 @@ func BenchmarkResourceMonitor_IsResourceConstrained(b *testing.B) { _ = rm.IsResourceConstrained() } } + +// TestClampPercent_NegativeValue tests clamping of negative percentage values +func TestClampPercent_NegativeValue(t *testing.T) { + // Test negative value clamping directly + result := clampPercent(-10.0) + if result != 0.0 { + t.Errorf("negative value should be clamped to 0.0, got %f", result) + } + + // Test value > 100 clamping directly + result = clampPercent(150.0) + if result != 100.0 { + t.Errorf("value > 100 should be clamped to 100.0, got %f", result) + } + + // Test normal value (should pass through) + result = clampPercent(50.0) + if result != 50.0 { + t.Errorf("normal value should pass through, got %f", result) + } + + // Test through memoryUsagePercent with custom reader + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, func() (float64, error) { + return -10.0, nil // Return negative value + }) + defer rm.Close() + + stats := rm.collectStats() + // Should clamp negative to 0 + if stats.MemoryUsedPercent < 0 { + t.Errorf("memory percent should be clamped to >= 0, got %f", stats.MemoryUsedPercent) + } + if stats.MemoryUsedPercent != 0.0 { + t.Errorf("negative value should be clamped to 0.0, got %f", stats.MemoryUsedPercent) + } +} + +// TestValidatePercent_NaNInf tests handling of NaN and Inf values +func TestValidatePercent_NaNInf(t *testing.T) { + // Test NaN handling directly + result := validatePercent(math.NaN()) + if math.IsNaN(result) { + t.Error("NaN should be converted to 0.0") + } + if result != 0.0 { + t.Errorf("NaN should be converted to 0.0, got %f", result) + } + + // Test positive Inf + result = validatePercent(math.Inf(1)) + if math.IsInf(result, 0) { + t.Error("Inf should be converted to 0.0") + } + if result != 0.0 { + t.Errorf("Inf should be converted to 0.0, got %f", result) + } + + // Test negative Inf + result = validatePercent(math.Inf(-1)) + if math.IsInf(result, 0) { + t.Error("Negative Inf should be converted to 0.0") + } + if result != 0.0 { + t.Errorf("Negative Inf should be converted to 0.0, got %f", result) + } + + // Test through validateResourceStats + stats := &ResourceStats{ + MemoryUsedPercent: 50.0, + CPUUsagePercent: math.NaN(), + GoroutineCount: 10, + Timestamp: time.Now(), + } + + validateResourceStats(stats) + if math.IsNaN(stats.CPUUsagePercent) { + t.Error("CPU percent should not be NaN after validation") + } + if stats.CPUUsagePercent != 0.0 { + t.Errorf("NaN should be converted to 0.0, got %f", stats.CPUUsagePercent) + } +} + +// TestValidateResourceStats_TimestampPaths tests the timestamp validation paths directly +func TestValidateResourceStats_TimestampPaths(t *testing.T) { + t.Run("zero timestamp refresh", func(t *testing.T) { + stats := &ResourceStats{ + MemoryUsedPercent: 50.0, + CPUUsagePercent: 40.0, + GoroutineCount: 10, + Timestamp: time.Time{}, // Zero timestamp + } + + validateResourceStats(stats) + if stats.Timestamp.IsZero() { + t.Error("timestamp should not be zero after validation") + } + if time.Since(stats.Timestamp) > time.Second { + t.Error("timestamp should be recent after refresh") + } + }) + + t.Run("old timestamp refresh", func(t *testing.T) { + oldTime := time.Now().Add(-2 * time.Minute) // More than 1 minute ago + stats := &ResourceStats{ + MemoryUsedPercent: 50.0, + CPUUsagePercent: 40.0, + GoroutineCount: 10, + Timestamp: oldTime, + } + + validateResourceStats(stats) + if stats.Timestamp.Equal(oldTime) { + t.Error("timestamp should be refreshed when older than 1 minute") + } + if time.Since(stats.Timestamp) > time.Second { + t.Error("timestamp should be recent after refresh") + } + }) +} + +// TestMemoryUsagePercent_CustomReaderErrorFallback tests the fallback when custom memory reader fails +func TestMemoryUsagePercent_CustomReaderErrorFallback(t *testing.T) { + callCount := 0 + rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, func() (float64, error) { + callCount++ + return 0, fmt.Errorf("custom reader error") + }) + defer rm.Close() + + // collectStats should call memoryUsagePercent which should try custom reader, + // get error, and fall back to system memory + stats := rm.collectStats() + + if callCount == 0 { + t.Error("custom memory reader should have been called") + } + + // Should fall back to system memory, so value should be valid + if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { + t.Errorf("memory percent should be valid after fallback, got %f", stats.MemoryUsedPercent) + } +} + +// TestInitSampler tests the initSampler method directly using reflection +func TestInitSampler(t *testing.T) { + t.Run("heuristic mode", func(t *testing.T) { + rm := &ResourceMonitor{ + sampleInterval: 50 * time.Millisecond, + memoryThreshold: 80.0, + cpuThreshold: 70.0, + cpuMode: CPUUsageModeHeuristic, + done: make(chan struct{}), + } + + rm.initSampler() + + if rm.sampler == nil { + t.Fatal("sampler should not be nil") + } + if rm.cpuMode != CPUUsageModeHeuristic { + t.Errorf("cpuMode should remain Heuristic, got %v", rm.cpuMode) + } + + // Verify it's a heuristic sampler by checking behavior + percent := rm.sampler.Sample(100 * time.Millisecond) + if percent < 0.0 || percent > 100.0 { + t.Errorf("CPU percent should be between 0 and 100, got %v", percent) + } + }) + + t.Run("measured mode success", func(t *testing.T) { + rm := &ResourceMonitor{ + sampleInterval: 50 * time.Millisecond, + memoryThreshold: 80.0, + cpuThreshold: 70.0, + cpuMode: CPUUsageModeMeasured, + done: make(chan struct{}), + } + + rm.initSampler() + + if rm.sampler == nil { + t.Fatal("sampler should not be nil") + } + + // On darwin, NewProcessSampler should succeed, so cpuMode should be Measured + // (or Heuristic if it fell back, but that's unlikely on darwin) + if rm.cpuMode != CPUUsageModeMeasured && rm.cpuMode != CPUUsageModeHeuristic { + t.Errorf("cpuMode should be Measured or Heuristic (fallback), got %v", rm.cpuMode) + } + + // Should be able to sample + percent := rm.sampler.Sample(100 * time.Millisecond) + if percent < 0.0 || percent > 100.0 { + t.Errorf("CPU percent should be between 0 and 100, got %v", percent) + } + + // Verify stats were collected + stats := rm.stats.Load() + if stats == nil { + t.Error("stats should be initialized after initSampler") + } + }) + + t.Run("measured mode fallback structure", func(t *testing.T) { + // This test verifies that the fallback code path exists in initSampler. + // The actual fallback (lines 105-108 in resource_monitor.go) is tested on + // platforms where NewProcessSampler naturally fails (e.g., unsupported platforms + // via cpu_fallback.go build tag: !linux && !darwin && !windows). + // + // On darwin, NewProcessSampler typically succeeds, so we verify: + // 1. The code structure supports fallback (cpuMode can change from Measured to Heuristic) + // 2. The sampler is always initialized (even if fallback occurs) + // 3. Stats are collected after initialization + + rm := &ResourceMonitor{ + sampleInterval: 50 * time.Millisecond, + memoryThreshold: 80.0, + cpuThreshold: 70.0, + cpuMode: CPUUsageModeMeasured, + done: make(chan struct{}), + } + + initialMode := rm.cpuMode + rm.initSampler() + + // Sampler should always be initialized + if rm.sampler == nil { + t.Fatal("sampler should not be nil, even if fallback occurs") + } + + // Mode should either remain Measured (success) or change to Heuristic (fallback) + if rm.cpuMode != CPUUsageModeMeasured && rm.cpuMode != CPUUsageModeHeuristic { + t.Errorf("cpuMode should be Measured or Heuristic after initSampler, got %v", rm.cpuMode) + } + + // If mode changed, it means fallback occurred + if rm.cpuMode != initialMode { + t.Logf("Fallback occurred: cpuMode changed from %v to %v", initialMode, rm.cpuMode) + } + + // Stats should be initialized + stats := rm.stats.Load() + if stats == nil { + t.Error("stats should be initialized after initSampler") + } + }) +} diff --git a/internal/sysmonitor/cpu_darwin_test.go b/internal/sysmonitor/cpu_darwin_test.go new file mode 100644 index 0000000..5461d3c --- /dev/null +++ b/internal/sysmonitor/cpu_darwin_test.go @@ -0,0 +1,26 @@ +//go:build darwin + +package sysmonitor + +import ( + "math" + "testing" +) + +// TestNewProcessSampler_InvalidPID tests the error path for invalid PID. +func TestNewProcessSampler_InvalidPID(t *testing.T) { + sampler, err := newProcessSampler() + if err != nil { + t.Fatalf("newProcessSampler failed with valid PID: %v", err) + } + if sampler == nil { + t.Fatal("newProcessSampler should not return nil on success") + } + + if sampler.pid <= 0 { + t.Errorf("PID should be positive, got %d", sampler.pid) + } + if sampler.pid > math.MaxInt32 { + t.Errorf("PID should not exceed MaxInt32, got %d", sampler.pid) + } +} From 7dfff932e45a4e03ecd03ff3e29e89a682f4c7d9 Mon Sep 17 00:00:00 2001 From: kxrxh Date: Sun, 23 Nov 2025 15:21:59 +0300 Subject: [PATCH 49/54] test(sysmonitor): refactor memory tests to use a helper function to fix dupl error --- .../sysmonitor/memory_linux_cgroup_test.go | 144 ++++++++---------- 1 file changed, 64 insertions(+), 80 deletions(-) diff --git a/internal/sysmonitor/memory_linux_cgroup_test.go b/internal/sysmonitor/memory_linux_cgroup_test.go index 8a2fc34..11643ae 100644 --- a/internal/sysmonitor/memory_linux_cgroup_test.go +++ b/internal/sysmonitor/memory_linux_cgroup_test.go @@ -259,17 +259,60 @@ func TestReadCgroupStatWithFS(t *testing.T) { } } +// cgroupMemoryTestCase represents a test case for readCgroupMemoryWithFS +type cgroupMemoryTestCase struct { + name string + usage string + limit string + stat string + wantTotal uint64 + wantAvailable uint64 + wantErr bool + errContains string +} + +// testCgroupMemoryCase is a helper function to test readCgroupMemoryWithFS with different file paths and configs +func testCgroupMemoryCase( + t *testing.T, + tt cgroupMemoryTestCase, + usagePath, limitPath, statPath string, + config cgroupMemoryConfig, +) { + t.Helper() + t.Run(tt.name, func(t *testing.T) { + fs := newMockFileSystem() + if tt.usage != "" { + fs.files[usagePath] = []byte(tt.usage) + } + if tt.limit != "" { + fs.files[limitPath] = []byte(tt.limit) + } + if tt.stat != "" { + fs.files[statPath] = []byte(tt.stat) + } + + got, err := readCgroupMemoryWithFS(fs, config) + if (err != nil) != tt.wantErr { + t.Errorf("readCgroupMemoryWithFS() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("readCgroupMemoryWithFS() error = %v, want error containing %q", err, tt.errContains) + } + } else { + if got.Total != tt.wantTotal { + t.Errorf("readCgroupMemoryWithFS() Total = %v, want %v", got.Total, tt.wantTotal) + } + if got.Available != tt.wantAvailable { + t.Errorf("readCgroupMemoryWithFS() Available = %v, want %v", got.Available, tt.wantAvailable) + } + } + }) +} + func TestReadCgroupMemoryWithFS_V2(t *testing.T) { - tests := []struct { - name string - usage string - limit string - stat string - wantTotal uint64 - wantAvailable uint64 - wantErr bool - errContains string - }{ + tests := []cgroupMemoryTestCase{ { name: "normal case", usage: "1073741824", // 1GB @@ -348,50 +391,16 @@ func TestReadCgroupMemoryWithFS_V2(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - fs := newMockFileSystem() - if tt.usage != "" { - fs.files["/sys/fs/cgroup/memory.current"] = []byte(tt.usage) - } - if tt.limit != "" { - fs.files["/sys/fs/cgroup/memory.max"] = []byte(tt.limit) - } - if tt.stat != "" { - fs.files["/sys/fs/cgroup/memory.stat"] = []byte(tt.stat) - } - - got, err := readCgroupMemoryWithFS(fs, cgroupV2Config) - if (err != nil) != tt.wantErr { - t.Errorf("readCgroupMemoryWithFS() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr { - if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("readCgroupMemoryWithFS() error = %v, want error containing %q", err, tt.errContains) - } - } else { - if got.Total != tt.wantTotal { - t.Errorf("readCgroupMemoryWithFS() Total = %v, want %v", got.Total, tt.wantTotal) - } - if got.Available != tt.wantAvailable { - t.Errorf("readCgroupMemoryWithFS() Available = %v, want %v", got.Available, tt.wantAvailable) - } - } - }) + testCgroupMemoryCase(t, tt, + "/sys/fs/cgroup/memory.current", + "/sys/fs/cgroup/memory.max", + "/sys/fs/cgroup/memory.stat", + cgroupV2Config) } } func TestReadCgroupMemoryWithFS_V1(t *testing.T) { - tests := []struct { - name string - usage string - limit string - stat string - wantTotal uint64 - wantAvailable uint64 - wantErr bool - errContains string - }{ + tests := []cgroupMemoryTestCase{ { name: "normal case", usage: "1073741824", // 1GB @@ -432,36 +441,11 @@ func TestReadCgroupMemoryWithFS_V1(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - fs := newMockFileSystem() - if tt.usage != "" { - fs.files["/sys/fs/cgroup/memory/memory.usage_in_bytes"] = []byte(tt.usage) - } - if tt.limit != "" { - fs.files["/sys/fs/cgroup/memory/memory.limit_in_bytes"] = []byte(tt.limit) - } - if tt.stat != "" { - fs.files["/sys/fs/cgroup/memory/memory.stat"] = []byte(tt.stat) - } - - got, err := readCgroupMemoryWithFS(fs, cgroupV1Config) - if (err != nil) != tt.wantErr { - t.Errorf("readCgroupMemoryWithFS() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr { - if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("readCgroupMemoryWithFS() error = %v, want error containing %q", err, tt.errContains) - } - } else { - if got.Total != tt.wantTotal { - t.Errorf("readCgroupMemoryWithFS() Total = %v, want %v", got.Total, tt.wantTotal) - } - if got.Available != tt.wantAvailable { - t.Errorf("readCgroupMemoryWithFS() Available = %v, want %v", got.Available, tt.wantAvailable) - } - } - }) + testCgroupMemoryCase(t, tt, + "/sys/fs/cgroup/memory/memory.usage_in_bytes", + "/sys/fs/cgroup/memory/memory.limit_in_bytes", + "/sys/fs/cgroup/memory/memory.stat", + cgroupV1Config) } } From 6e24e80edd17862e9506d9d4f44f9020425999cc Mon Sep 17 00:00:00 2001 From: kxrxh Date: Mon, 24 Nov 2025 17:49:36 +0300 Subject: [PATCH 50/54] refactor(adaptive_throttler, resource_monitor): implement shared monitor registry with reference counting - Replace singleton ResourceMonitor with shared registry pattern for efficient resource sharing - Add dynamic interval updates and improved CPU mode switching - Enhance memory sampling with better fallback handling - Add InDelta assertion helper for precise float comparisons - Comprehensive test refactoring with improved mocking and coverage - Update adaptive throttler tests to use new resource monitor architecture --- examples/adaptive_throttler/demo/demo.go | 81 +- examples/adaptive_throttler/main.go | 63 +- flow/adaptive_throttler.go | 625 +++---- flow/adaptive_throttler_test.go | 2017 +++++++++------------- flow/resource_monitor.go | 466 +++-- flow/resource_monitor_test.go | 1386 ++++++++------- internal/assert/assertions.go | 9 + 7 files changed, 2229 insertions(+), 2418 deletions(-) diff --git a/examples/adaptive_throttler/demo/demo.go b/examples/adaptive_throttler/demo/demo.go index 14123b9..2954df7 100644 --- a/examples/adaptive_throttler/demo/demo.go +++ b/examples/adaptive_throttler/demo/demo.go @@ -14,20 +14,18 @@ import ( // The demo: // 1. Produces 250 elements in bursts // 2. Processes elements with CPU-intensive work (50ms each) -// 3. Simulates increasing memory pressure as more elements are processed +// 3. Simulates memory pressure that increases then decreases (creating throttling-recovery cycle) // 4. The adaptive throttler adjusts throughput based on CPU/memory usage -// 5. Stats are logged every 500ms showing rate adaptation +// 5. Shows throttling down to ~1/sec during high memory, then recovery back to 40/sec +// 6. Stats are logged every 500ms showing rate adaptation func main() { var elementsProcessed atomic.Int64 // Set up demo configuration with memory simulation throttler := setupDemoThrottler(&elementsProcessed) - defer func() { - throttler.Close() - }() in := make(chan any) - out := make(chan any, 32) + out := make(chan any) // Unbuffered channel to prevent apparent bursts source := ext.NewChanSource(in) sink := ext.NewChanSink(out) @@ -45,62 +43,64 @@ func main() { go produceBurst(in, 250) - // Use a variable to prevent compiler optimization of CPU work var cpuWorkChecksum uint64 + // Process the output + elementsReceived := 0 for element := range sink.Out { fmt.Printf("consumer received %v\n", element) - elementsProcessed.Add(1) // Track processed elements for memory pressure simulation + elementsProcessed.Add(1) + elementsReceived++ - // Perform CPU-intensive work that can't be optimized away - // This ensures Windows GetProcessTimes can detect CPU usage - // (Windows timer resolution is ~15.625ms, so we need at least 50-100ms of work) + // Perform CPU-intensive work burnCPU(50*time.Millisecond, &cpuWorkChecksum) time.Sleep(25 * time.Millisecond) } - // Print checksum to ensure CPU work wasn't optimized away fmt.Printf("CPU work checksum: %d\n", cpuWorkChecksum) - + fmt.Printf("Total elements produced: 250, Total elements received: %d\n", elementsReceived) + if elementsReceived == 250 { + fmt.Println("✅ SUCCESS: All elements processed without dropping!") + } else { + fmt.Printf("❌ FAILURE: %d elements were dropped!\n", 250-elementsReceived) + } fmt.Println("adaptive throttling pipeline completed") } // setupDemoThrottler creates and configures an adaptive throttler with demo settings func setupDemoThrottler(elementsProcessed *atomic.Int64) *flow.AdaptiveThrottler { config := flow.DefaultAdaptiveThrottlerConfig() - config.MinThroughput = 5 - config.MaxThroughput = 40 + + config.MinRate = 1 + config.MaxRate = 20 + config.InitialRate = 20 config.SampleInterval = 200 * time.Millisecond - config.BufferSize = 32 - config.AdaptationFactor = 0.5 - config.SmoothTransitions = true - config.MaxMemoryPercent = 40.0 - config.MaxCPUPercent = 80.0 + config.BackoffFactor = 0.5 + config.RecoveryFactor = 1.5 + + config.MaxMemoryPercent = 35.0 + config.RecoveryMemoryThreshold = 30.0 + + // Memory Reader Simulation - Creates a cycle: low -> high -> low memory usage config.MemoryReader = func() (float64, error) { elementCount := elementsProcessed.Load() - // Memory pressure increases with processed elements: - // - 0-50 elements: 5% memory - // - 51-100 elements: 15% memory - // - 101-150 elements: 30% memory - // - 151+ elements: 50%+ memory (increases gradually) var memoryPercent float64 switch { - case elementCount <= 50: - memoryPercent = 5.0 + float64(elementCount)*0.2 // 5% to 15% - case elementCount <= 100: - memoryPercent = 15.0 + float64(elementCount-50)*0.3 // 15% to 30% - case elementCount <= 150: - memoryPercent = 30.0 + float64(elementCount-100)*0.4 // 30% to 50% - default: - memoryPercent = 50.0 + float64(elementCount-150)*0.3 // 50%+ (increases more slowly) - if memoryPercent > 95.0 { - memoryPercent = 95.0 + case elementCount <= 80: // Phase 1: Low memory, allow high throughput + memoryPercent = 5.0 + float64(elementCount)*0.1 // 5% to 13% + case elementCount <= 120: // Phase 2: Increasing memory pressure, cause throttling + memoryPercent = 15.0 + float64(elementCount-80)*0.6 // 15% to 43% + case elementCount <= 160: // Phase 3: High memory, keep throttled down to ~1/sec + memoryPercent = 30.0 + float64(elementCount-120)*0.3 // 30% to 42% + default: // Phase 4: Memory decreases, allow recovery back to 40/sec + memoryPercent = 25.0 - float64(elementCount-160)*1.5 // 25% down to ~5% + if memoryPercent < 5.0 { + memoryPercent = 5.0 } } - return memoryPercent, nil } @@ -114,20 +114,17 @@ func setupDemoThrottler(elementsProcessed *atomic.Int64) *flow.AdaptiveThrottler func produceBurst(in chan<- any, total int) { defer close(in) - for i := 0; i < total; i++ { + for i := range total { in <- fmt.Sprintf("job-%02d", i) if (i+1)%10 == 0 { time.Sleep(180 * time.Millisecond) continue } - time.Sleep(time.Duration(2+rand.Intn(5)) * time.Millisecond) } } -// burnCPU performs CPU-intensive work for the specified duration -// The checksum parameter prevents the compiler from optimizing away the work func burnCPU(duration time.Duration, checksum *uint64) { start := time.Now() for time.Since(start) < duration { @@ -147,8 +144,8 @@ func logThrottlerStats(at *flow.AdaptiveThrottler, done <-chan struct{}) { return case <-ticker.C: stats := at.GetResourceStats() - fmt.Printf("[stats] rate=%d eps memory=%.1f%% cpu=%.1f%% goroutines=%d\n", - at.GetCurrentRate(), stats.MemoryUsedPercent, stats.CPUUsagePercent, stats.GoroutineCount) + fmt.Printf("[stats] Rate: %.1f/sec, CPU: %.1f%%, Memory: %.1f%%\n", + at.GetCurrentRate(), stats.CPUUsagePercent, stats.MemoryUsedPercent) } } } diff --git a/examples/adaptive_throttler/main.go b/examples/adaptive_throttler/main.go index bf5473b..6e5eadd 100644 --- a/examples/adaptive_throttler/main.go +++ b/examples/adaptive_throttler/main.go @@ -13,33 +13,78 @@ func editMessage(msg string) string { return strings.ToUpper(msg) } +func addTimestamp(msg string) string { + return fmt.Sprintf("[%s] %s", time.Now().Format("15:04:05"), msg) +} + func main() { - // To setup a custom throttler, you can modify the throttlerConfig struct with your desired values. - // For all available options, see the flow.AdaptiveThrottlerConfig struct. - throttlerConfig := flow.DefaultAdaptiveThrottlerConfig() - throttler, err := flow.NewAdaptiveThrottler(throttlerConfig) + // Configure first throttler - focuses on CPU limits + throttler1Config := flow.DefaultAdaptiveThrottlerConfig() + throttler1Config.MaxCPUPercent = 70.0 + throttler1Config.MaxMemoryPercent = 60.0 + throttler1Config.InitialRate = 15 + throttler1Config.MaxRate = 30 + throttler1Config.SampleInterval = 200 * time.Millisecond + throttler1Config.CPUUsageMode = flow.CPUUsageModeMeasured // Use heuristic for better CPU visibility + + throttler1, err := flow.NewAdaptiveThrottler(throttler1Config) + if err != nil { + panic(fmt.Sprintf("failed to create throttler1: %v", err)) + } + + // Configure second throttler - focuses on memory limits + throttler2Config := flow.DefaultAdaptiveThrottlerConfig() + throttler2Config.MaxCPUPercent = 75.0 + throttler2Config.MaxMemoryPercent = 50.0 + throttler2Config.InitialRate = 10 + throttler2Config.MaxRate = 25 + throttler2Config.SampleInterval = 200 * time.Millisecond + throttler2Config.CPUUsageMode = flow.CPUUsageModeMeasured // Use heuristic for better CPU visibility + + throttler2, err := flow.NewAdaptiveThrottler(throttler2Config) if err != nil { - panic(fmt.Sprintf("failed to create adaptive throttler: %v", err)) + panic(fmt.Sprintf("failed to create throttler2: %v", err)) } - defer throttler.Close() in := make(chan any) source := ext.NewChanSource(in) editMapFlow := flow.NewMap(editMessage, 1) + timestampFlow := flow.NewMap(addTimestamp, 1) sink := ext.NewStdoutSink() + // Pipeline: Source -> Throttler1 -> Edit -> Throttler2 -> Timestamp -> Sink + go func() { + source. + Via(throttler1). + Via(editMapFlow). + Via(throttler2). + Via(timestampFlow). + To(sink) + }() + + // Stats logging goroutine go func() { - source.Via(throttler).Via(editMapFlow).To(sink) + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for range ticker.C { + stats := throttler1.GetResourceStats() + fmt.Printf("[stats] T1-Rate: %.1f/s, T2-Rate: %.1f/s, CPU: %.1f%%, Mem: %.1f%%\n", + throttler1.GetCurrentRate(), + throttler2.GetCurrentRate(), + stats.CPUUsagePercent, + stats.MemoryUsedPercent) + } }() go func() { defer close(in) - for i := 1; i <= 50; i++ { + for i := 1; i <= 100; i++ { message := fmt.Sprintf("message-%d", i) in <- message - time.Sleep(50 * time.Millisecond) + time.Sleep(30 * time.Millisecond) } }() diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 669fd91..95637a0 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -3,7 +3,6 @@ package flow import ( "fmt" "math" - "sync" "sync/atomic" "time" @@ -11,507 +10,353 @@ import ( ) const ( - // minSampleInterval is the minimum allowed sampling interval to prevent - // excessive CPU overhead from too-frequent system resource polling. - minSampleInterval = 10 * time.Millisecond - // smoothingFactor is the factor by which the current rate is adjusted to approach the target rate. - smoothingFactor = 0.3 // 30% of remaining distance per cycle + // minSampleInterval is the minimum allowed sampling interval + minSampleInterval = 50 * time.Millisecond + // smoothingFactor factor by which the current rate is adjusted when smoothing is enabled + smoothingFactor = 0.3 ) // AdaptiveThrottlerConfig configures the adaptive throttler behavior type AdaptiveThrottlerConfig struct { - // Resource monitoring configuration - // - // These settings control how the throttler monitors system resources - // and determines when to throttle throughput. - - // MaxMemoryPercent is the maximum memory usage threshold (0-100 percentage). - // When memory usage exceeds this threshold, throughput will be reduced. + // MaxMemoryPercent is the maximum allowed memory usage percentage (0-100). + // When memory usage exceeds this threshold, the throttler reduces processing rate. + // Default config value: 85.0 MaxMemoryPercent float64 - // MaxCPUPercent is the maximum CPU usage threshold (0-100 percentage). - // When CPU usage exceeds this threshold, throughput will be reduced. + // MaxCPUPercent is the maximum allowed CPU usage percentage (0-100). + // When CPU usage exceeds this threshold, the throttler reduces processing rate. + // Default config value: 80.0 MaxCPUPercent float64 - // SampleInterval is how often to sample system resources. - // More frequent sampling provides faster response but increases CPU overhead. + // Sampling configuration + // SampleInterval is the frequency of resource monitoring and rate adjustments. + // Minimum: 50ms. Default config value: 100ms SampleInterval time.Duration - // CPUUsageMode controls how CPU usage is measured. - // - // CPUUsageModeHeuristic: Estimates CPU usage using a simple heuristic (goroutine count), - // suitable for platforms where accurate process CPU measurement is not supported. - // - // CPUUsageModeMeasured: Attempts to measure actual process CPU usage natively - // (when supported), providing more accurate CPU usage readings. + // CPUUsageMode specifies the CPU monitoring strategy. + // Options: CPUUsageModeHeuristic (goroutine-based) or CPUUsageModeMeasured (system calls). + // Default config value: CPUUsageModeMeasured CPUUsageMode CPUUsageMode - // MemoryReader is a user-provided custom function that returns memory usage percentage. - // This can be particularly useful for containerized deployments or other environments - // where standard system memory readings may not accurately reflect container-specific - // usage. - // If nil, system memory will be read via mem.VirtualMemory(). - // Must return memory used percentage (0-100). + // MemoryReader is an optional custom function to read memory usage percentage (0-100). + // If nil, system memory usage is monitored automatically. + // Function signature: func() (float64, error) - returns percentage and any error. MemoryReader func() (float64, error) - // Throughput bounds (in elements per second) - // - // These settings define the minimum and maximum throughput rates. - - // MinThroughput is the minimum throughput in elements per second. - // The throttler will never reduce throughput below this value. - MinThroughput int - - // MaxThroughput is the maximum throughput in elements per second. - // The throttler will never increase throughput above this value. - MaxThroughput int - - // Buffer configuration - // - // These settings control the internal buffering of elements. - - // BufferSize is the initial buffer size in number of elements. - // This buffer holds incoming elements when throughput is throttled. - BufferSize int - - // MaxBufferSize is the maximum buffer size in number of elements. - // This prevents unbounded memory allocation during sustained throttling. - MaxBufferSize int - - // Adaptation behavior - // - // These settings control how aggressively and smoothly the throttler - // adapts to changing resource conditions. - - // AdaptationFactor controls how aggressively the throttler adapts (0.0-1.0). - // Lower values (e.g., 0.1) result in slower, more conservative adaptation. - // Higher values (e.g., 0.5) result in faster, more aggressive adaptation. - AdaptationFactor float64 - - // SmoothTransitions enables rate transition smoothing. - // If true, the throughput rate will be smoothed over time to avoid abrupt changes. - // This helps prevent oscillations and provides more stable behavior. - SmoothTransitions bool - - // HysteresisBuffer prevents rapid state changes (in percentage points). - // Requires this much additional headroom before increasing rate. - // This prevents oscillations around resource thresholds. - HysteresisBuffer float64 - - // MaxRateChangeFactor limits the maximum rate change per adaptation cycle (0.0-1.0). - // Limits how much the rate can change in a single step to prevent instability. - MaxRateChangeFactor float64 + // Rate Limits + // InitialRate is the starting processing rate in items per second. + // Must be between MinRate and MaxRate. Default config value: 1000 + InitialRate int + + // MinRate is the minimum allowed processing rate in items per second. + // Must be > 0. Default config value: 10 + MinRate int + + // MaxRate is the maximum allowed processing rate in items per second. + // Must be > MinRate. Default config value: 10000 + MaxRate int + + // Adjustment factors + // BackoffFactor is the multiplier applied to reduce rate when resource constraints are exceeded. + // Range: 0.0-1.0 (e.g., 0.7 means rate is reduced to 70% when constrained). + // Default config value: 0.7 + BackoffFactor float64 + + // RecoveryCPUThreshold is the CPU usage percentage below which rate recovery begins. + // Must be < MaxCPUPercent. If zero, defaults to MaxCPUPercent-10 or 90% of MaxCPUPercent. + // Range: 0.0-MaxCPUPercent + RecoveryCPUThreshold float64 + + // RecoveryMemoryThreshold is the memory usage percentage below which rate recovery begins. + // Must be < MaxMemoryPercent. If zero, defaults to MaxMemoryPercent-10 or 90% of MaxMemoryPercent. + // Range: 0.0-MaxMemoryPercent + RecoveryMemoryThreshold float64 + + // RecoveryFactor is the multiplier applied to increase rate during recovery periods. + // Must be > 1.0 (e.g., 1.3 means rate increases by 30% during recovery). + // Default config value: 1.3 + RecoveryFactor float64 + + // EnableHysteresis prevents rapid rate oscillations by requiring both resource recovery + // thresholds to be met before increasing rate. When false, rate increases as soon as + // constraints are removed. + // Default config value: true + EnableHysteresis bool } -// DefaultAdaptiveThrottlerConfig returns sensible defaults for most use cases. -// -// Default configuration parameters: -// -// Resource Monitoring: -// - MaxMemoryPercent: 80.0% - Conservative memory threshold to prevent OOM -// - MaxCPUPercent: 70.0% - Conservative CPU threshold to maintain responsiveness -// - SampleInterval: 200ms - Balanced sampling frequency to minimize overhead -// - CPUUsageMode: CPUUsageModeMeasured - Uses native process CPU measurement -// - MemoryReader: nil - Uses system memory via mem.VirtualMemory() -// -// Throughput Bounds: -// - MinThroughput: 10 elements/second - Ensures minimum processing rate -// - MaxThroughput: 500 elements/second - Conservative maximum for stability -// -// Buffer Configuration: -// - BufferSize: 500 elements - Matches max throughput for 1 second buffer at max rate -// - MaxBufferSize: 10,000 elements - Prevents unbounded memory allocation -// -// Adaptation Behavior: -// - AdaptationFactor: 0.15 - Conservative adaptation speed (15% adjustment per cycle) -// - SmoothTransitions: true - Enables rate smoothing to avoid abrupt changes -// - HysteresisBuffer: 5.0% - Prevents oscillations around resource thresholds -// - MaxRateChangeFactor: 0.3 - Limits rate changes to 30% per cycle for stability +// DefaultAdaptiveThrottlerConfig returns safe defaults func DefaultAdaptiveThrottlerConfig() *AdaptiveThrottlerConfig { return &AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 500, - SampleInterval: 200 * time.Millisecond, - BufferSize: 500, - MaxBufferSize: 10000, - AdaptationFactor: 0.15, - SmoothTransitions: true, - CPUUsageMode: CPUUsageModeMeasured, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.3, + MaxMemoryPercent: 85.0, + MaxCPUPercent: 80.0, + SampleInterval: 100 * time.Millisecond, + CPUUsageMode: CPUUsageModeMeasured, + InitialRate: 1000, + MinRate: 10, + MaxRate: 10000, + BackoffFactor: 0.7, + RecoveryFactor: 1.3, + EnableHysteresis: true, } } -// ResourceMonitor defines the interface for resource monitoring -type resourceMonitor interface { - // GetStats returns the current resource statistics - GetStats() ResourceStats - // IsResourceConstrained returns true if resources are above thresholds - IsResourceConstrained() bool - // Close closes the resource monitor - Close() -} - -// AdaptiveThrottler implements a feedback control system that: -// - Monitors CPU and memory usage at regular intervals -// -// - Reduces throughput when resources exceed thresholds (with severity-based scaling) -// -// - Gradually increases throughput when resources are available (with hysteresis) -// -// - Applies smoothing to prevent abrupt rate changes -// -// - Enforces minimum and maximum throughput bounds -type AdaptiveThrottler struct { - config AdaptiveThrottlerConfig - monitor resourceMonitor - - // Current throughput rate in elements per second - currentRate atomic.Int64 - - // Rate control: period-based quota enforcement - period time.Duration // Time period for quota calculation (typically 1 second) - maxElements atomic.Int64 // Maximum elements allowed per period - counter atomic.Int64 // Current element count in the period - - // Communication channels - in chan any // Input channel for incoming elements - out chan any // Output channel for throttled elements - quotaSignal chan struct{} // Signal channel to notify when quota resets - done chan struct{} // Shutdown signal channel - - // Rate adaptation tracking - lastAdaptation time.Time // Timestamp of last rate adaptation - - stopOnce sync.Once // Ensures cleanup happens only once -} - -var _ streams.Flow = (*AdaptiveThrottler)(nil) - -// validate validates the adaptive throttler configuration -func (config *AdaptiveThrottlerConfig) validate() error { - if config.MaxMemoryPercent <= 0 || config.MaxMemoryPercent > 100 { - return fmt.Errorf("invalid MaxMemoryPercent: %f", config.MaxMemoryPercent) +func (c *AdaptiveThrottlerConfig) validate() error { + if c.SampleInterval < minSampleInterval { + return fmt.Errorf("sample interval must be at least %v", minSampleInterval) } - if config.MinThroughput < 1 || config.MaxThroughput < config.MinThroughput { - return fmt.Errorf("invalid throughput bounds: min=%d, max=%d", config.MinThroughput, config.MaxThroughput) + if c.MaxMemoryPercent <= 0 || c.MaxMemoryPercent > 100 { + return fmt.Errorf("MaxMemoryPercent must be between 0 and 100") } - if config.AdaptationFactor <= 0 || config.AdaptationFactor >= 1 { - return fmt.Errorf("invalid AdaptationFactor: %f, must be in (0, 1)", config.AdaptationFactor) + if c.MaxCPUPercent < 0 || c.MaxCPUPercent > 100 { + return fmt.Errorf("MaxCPUPercent must be between 0 and 100") } - if config.MaxCPUPercent <= 0 || config.MaxCPUPercent > 100 { - return fmt.Errorf("invalid MaxCPUPercent: %f", config.MaxCPUPercent) + + // Set default recovery thresholds if not specified + if c.RecoveryMemoryThreshold == 0 { + c.RecoveryMemoryThreshold = c.MaxMemoryPercent - 10 + if c.RecoveryMemoryThreshold < 0 { + c.RecoveryMemoryThreshold = c.MaxMemoryPercent * 0.9 // 90% of max if max < 10 + } } - if config.BufferSize < 1 { - return fmt.Errorf("invalid BufferSize: %d", config.BufferSize) + if c.RecoveryCPUThreshold == 0 { + c.RecoveryCPUThreshold = c.MaxCPUPercent - 10 + if c.RecoveryCPUThreshold < 0 { + c.RecoveryCPUThreshold = c.MaxCPUPercent * 0.9 // 90% of max if max < 10 + } + } + + // Validate recovery thresholds + if c.RecoveryMemoryThreshold < 0 || c.RecoveryMemoryThreshold >= c.MaxMemoryPercent { + return fmt.Errorf("RecoveryMemoryThreshold (%.1f) must be between 0 and MaxMemoryPercent (%.1f)", + c.RecoveryMemoryThreshold, c.MaxMemoryPercent) } - if config.MaxBufferSize < 1 { - return fmt.Errorf("invalid MaxBufferSize: %d", config.MaxBufferSize) + if c.MaxCPUPercent > 0 && (c.RecoveryCPUThreshold < 0 || c.RecoveryCPUThreshold >= c.MaxCPUPercent) { + return fmt.Errorf("RecoveryCPUThreshold (%.1f) must be between 0 and MaxCPUPercent (%.1f)", + c.RecoveryCPUThreshold, c.MaxCPUPercent) } - if config.BufferSize > config.MaxBufferSize { - return fmt.Errorf("BufferSize %d exceeds MaxBufferSize %d", config.BufferSize, config.MaxBufferSize) + + if c.BackoffFactor >= 1.0 || c.BackoffFactor <= 0 { + return fmt.Errorf("BackoffFactor must be between 0 and 1") } - if config.SampleInterval < minSampleInterval { - return fmt.Errorf( - "invalid SampleInterval: %v; must be at least %v to prevent high CPU overhead", - config.SampleInterval, minSampleInterval) + if c.MinRate <= 0 { + return fmt.Errorf("MinRate must be greater than 0") } - if config.HysteresisBuffer < 0 { - return fmt.Errorf("invalid HysteresisBuffer: %f", config.HysteresisBuffer) + if c.MaxRate <= c.MinRate { + return fmt.Errorf("MaxRate must be greater than MinRate") } - if config.MaxRateChangeFactor <= 0 || config.MaxRateChangeFactor > 1 { - return fmt.Errorf("invalid MaxRateChangeFactor: %f, must be in (0, 1]", config.MaxRateChangeFactor) + if c.InitialRate < c.MinRate || c.InitialRate > c.MaxRate { + return fmt.Errorf("InitialRate must be between MinRate (%d) and MaxRate (%d) inclusive", c.MinRate, c.MaxRate) + } + if c.RecoveryFactor <= 1.0 { + return fmt.Errorf("RecoveryFactor must be greater than 1") } return nil } -// NewAdaptiveThrottler creates a new adaptive throttler -// If config is nil, default configuration will be used. +// AdaptiveThrottler implements a feedback control system that monitors resources +// and adjusts throughput dynamically using a token bucket with adjustable rate. +type AdaptiveThrottler struct { + config AdaptiveThrottlerConfig + monitor resourceMonitor + currentRateBits uint64 + + in chan any + out chan any + done chan struct{} + closed atomic.Bool +} + +// NewAdaptiveThrottler creates a new instance func NewAdaptiveThrottler(config *AdaptiveThrottlerConfig) (*AdaptiveThrottler, error) { if config == nil { config = DefaultAdaptiveThrottlerConfig() } - if err := config.validate(); err != nil { return nil, err } - // Initialize with max throughput - initialRate := int64(config.MaxThroughput) + monitor := globalMonitorRegistry.Acquire( + config.SampleInterval, + config.CPUUsageMode, + config.MemoryReader, + ) at := &AdaptiveThrottler{ - config: *config, - monitor: NewResourceMonitor( - config.SampleInterval, - config.MaxMemoryPercent, - config.MaxCPUPercent, - config.CPUUsageMode, - config.MemoryReader, - ), - period: time.Second, // 1 second period - in: make(chan any), - out: make(chan any, config.BufferSize), - quotaSignal: make(chan struct{}, 1), - done: make(chan struct{}), + config: *config, + monitor: monitor, + in: make(chan any), + out: make(chan any), + done: make(chan struct{}), } - at.currentRate.Store(initialRate) - at.maxElements.Store(initialRate) - at.lastAdaptation = time.Now() - - // Start rate adaptation goroutine - go at.adaptRateLoop() + // Set initial rate atomically + at.setRate(float64(config.InitialRate)) - // Start quota reset goroutine - go at.resetQuotaCounterLoop() + // Start the monitor loop to adjust the rate based on the resource usage + go at.monitorLoop() - // Start buffering goroutine - go at.buffer() + // Start the pipeline loop to emit items at the correct rate + go at.pipelineLoop() return at, nil } -// Via asynchronously streams data to the given Flow and returns it +// Via asynchronously streams data to the given Flow and returns it. func (at *AdaptiveThrottler) Via(flow streams.Flow) streams.Flow { go at.streamPortioned(flow) return flow } -// To streams data to the given Sink and blocks until completion +// To streams data to the given Sink and blocks until the Sink has completed +// processing all data. func (at *AdaptiveThrottler) To(sink streams.Sink) { at.streamPortioned(sink) sink.AwaitCompletion() } -// In returns the input channel +func (at *AdaptiveThrottler) Out() <-chan any { + return at.out +} + func (at *AdaptiveThrottler) In() chan<- any { return at.in } -// Out returns the output channel -func (at *AdaptiveThrottler) Out() <-chan any { - return at.out +// setRate updates the current rate limit atomically +func (at *AdaptiveThrottler) setRate(rate float64) { + atomic.StoreUint64(&at.currentRateBits, math.Float64bits(rate)) } -// GetCurrentRate returns the current throughput rate (elements per second) -func (at *AdaptiveThrottler) GetCurrentRate() int64 { - return at.currentRate.Load() +// GetCurrentRate returns the current rate limit atomically +func (at *AdaptiveThrottler) GetCurrentRate() float64 { + return math.Float64frombits(atomic.LoadUint64(&at.currentRateBits)) } -// GetResourceStats returns current resource statistics +// GetResourceStats returns the latest resource statistics from the monitor func (at *AdaptiveThrottler) GetResourceStats() ResourceStats { return at.monitor.GetStats() } -// Close stops the adaptive throttler and cleans up resources -func (at *AdaptiveThrottler) Close() { - // Drain any pending quota signals to prevent goroutine leaks - select { - case <-at.quotaSignal: - default: +// streamPortioned streams elements to the given Inlet from the throttler's output. +// Elements are sent to inlet.In() until at.out is closed. +func (at *AdaptiveThrottler) streamPortioned(inlet streams.Inlet) { + defer close(inlet.In()) + for element := range at.Out() { + select { + case inlet.In() <- element: + case <-at.done: + // Throttler was closed, exit early + return + } } +} - at.stop() +func (at *AdaptiveThrottler) close() { + if at.closed.CompareAndSwap(false, true) { + close(at.done) + at.monitor.Close() + } } -// adaptRateLoop periodically adapts the throughput rate based on resource availability -func (at *AdaptiveThrottler) adaptRateLoop() { +// monitorLoop handles periodic resource checks and rate adjustment. +func (at *AdaptiveThrottler) monitorLoop() { ticker := time.NewTicker(at.config.SampleInterval) defer ticker.Stop() for { select { - case <-ticker.C: - at.adaptRate() case <-at.done: return + case <-ticker.C: + at.adjustRate() } } } -// adaptRate adjusts the throughput rate based on current resource usage -func (at *AdaptiveThrottler) adaptRate() { - stats := at.monitor.GetStats() - currentRate := float64(at.currentRate.Load()) - - // Calculate target rate based on resource constraints - targetRate := at.calculateTargetRate(currentRate, stats) - - // Apply smoothing if enabled - if at.config.SmoothTransitions { - targetRate = at.applySmoothing(currentRate, targetRate) - } - - // Enforce bounds - targetRate = math.Max(float64(at.config.MinThroughput), targetRate) - targetRate = math.Min(float64(at.config.MaxThroughput), targetRate) - - // Commit the new rate if it changed - at.commitRateChange(targetRate) -} - -// calculateTargetRate determines the target rate based on resource constraints -func (at *AdaptiveThrottler) calculateTargetRate(currentRate float64, stats ResourceStats) float64 { - // Constrained is true if resources are above thresholds - constrained := stats.MemoryUsedPercent > at.config.MaxMemoryPercent || - stats.CPUUsagePercent > at.config.MaxCPUPercent - - if constrained { - return at.calculateReduction(currentRate, stats) - } - return at.calculateIncrease(currentRate, stats) -} - -// calculateReduction computes the rate reduction when resources are constrained -func (at *AdaptiveThrottler) calculateReduction(currentRate float64, stats ResourceStats) float64 { - // Calculate how far over the limits we are (as a percentage) - memoryOverage := math.Max(0, stats.MemoryUsedPercent-at.config.MaxMemoryPercent) - cpuOverage := math.Max(0, stats.CPUUsagePercent-at.config.MaxCPUPercent) - maxOverage := math.Max(memoryOverage, cpuOverage) - - // Scale reduction factor based on overage severity (0-100%) - severityFactor := math.Min(maxOverage/50.0, 1.0) // 50% overage = full severity - - // Calculate reduction: base factor + severity bonus - reductionFactor := at.config.AdaptationFactor * (1.0 + severityFactor) - maxReduction := currentRate * at.config.MaxRateChangeFactor - reduction := math.Min(currentRate*reductionFactor, maxReduction) - - targetRate := currentRate - reduction - - // Avoid negative rates - return math.Max(0, targetRate) -} - -// calculateIncrease computes the rate increase when resources are available -func (at *AdaptiveThrottler) calculateIncrease(currentRate float64, stats ResourceStats) float64 { - memoryHeadroom := at.config.MaxMemoryPercent - stats.MemoryUsedPercent - cpuHeadroom := at.config.MaxCPUPercent - stats.CPUUsagePercent - minHeadroom := math.Min(memoryHeadroom, cpuHeadroom) - - // Apply hysteresis buffer - only increase if we have significant headroom - effectiveHeadroom := minHeadroom - at.config.HysteresisBuffer - if effectiveHeadroom <= 0 { - return currentRate // No increase if insufficient headroom - } - - // Use square root scaling for stable, diminishing returns - headroomRatio := math.Min(effectiveHeadroom/30.0, 1.0) // Cap at 30% headroom for scaling - increaseFactor := at.config.AdaptationFactor * math.Sqrt(headroomRatio) - maxIncrease := currentRate * at.config.MaxRateChangeFactor - - increase := math.Min(currentRate*increaseFactor, maxIncrease) - return currentRate + increase -} - -// applySmoothing gradually approaches the target rate to avoid abrupt changes -func (at *AdaptiveThrottler) applySmoothing(currentRate, targetRate float64) float64 { - diff := targetRate - currentRate - return currentRate + diff*smoothingFactor -} - -// commitRateChange atomically updates the rate if it has changed -func (at *AdaptiveThrottler) commitRateChange(targetRate float64) { - newRateInt := int64(math.Round(targetRate)) - currentRateInt := at.currentRate.Load() - - if newRateInt != currentRateInt { - at.currentRate.Store(newRateInt) - at.maxElements.Store(newRateInt) - at.counter.Store(0) // Reset quota counter to apply new rate immediately - // Wake any blocked emitters so the new quota takes effect - // without waiting for the next period tick. - at.notifyQuotaReset() - at.lastAdaptation = time.Now() - } -} +// Pipeline Loop: Strict Pacer +// Ensures exactly 1/Rate seconds between emissions. +func (at *AdaptiveThrottler) pipelineLoop() { + defer close(at.out) -// resetQuotaCounterLoop resets the quota counter every period -func (at *AdaptiveThrottler) resetQuotaCounterLoop() { - ticker := time.NewTicker(at.period) - defer ticker.Stop() + // The earliest allowed time for the next item to be emitted + nextEmission := time.Now() - for { + for item := range at.in { + // Check if the throttler is done select { - case <-ticker.C: - at.counter.Store(0) - at.notifyQuotaReset() case <-at.done: return + default: } - } -} -// notifyQuotaReset notifies downstream processor of quota reset -func (at *AdaptiveThrottler) notifyQuotaReset() { - select { - case at.quotaSignal <- struct{}{}: - default: - } -} + // Get the current interval + rate := at.GetCurrentRate() + if rate < 1.0 { + rate = 1.0 + } -// quotaExceeded checks if quota has been exceeded -func (at *AdaptiveThrottler) quotaExceeded() bool { - return at.counter.Load() >= at.maxElements.Load() -} + // Calculate the interval between emissions for the current rate + interval := time.Duration(float64(time.Second) / rate) -// buffer buffers incoming elements and sends them to output channel -func (at *AdaptiveThrottler) buffer() { - defer close(at.out) + now := time.Now() - for { - select { - case element, ok := <-at.in: - if !ok { + // If we are ahead of schedule, sleep until the next emission + if now.Before(nextEmission) { + sleepDuration := nextEmission.Sub(now) + select { + case <-time.After(sleepDuration): + now = time.Now() + case <-at.done: return } - at.emit(element) - case <-at.done: - return } - } -} -// emit emits an element to the output channel, blocking if quota is exceeded -func (at *AdaptiveThrottler) emit(element any) { - for { - if !at.quotaExceeded() { - at.counter.Add(1) - at.out <- element - return - } + nextEmission = now.Add(interval) + // Emit select { - case <-at.quotaSignal: + case at.out <- item: case <-at.done: - // Shutting down: try to flush pending data, but drop if blocked to avoid deadlock - select { - case at.out <- element: - // Successfully flushed - default: - // Channel is full or no readers - drop element to ensure clean shutdown - } return } } } -// streamPortioned streams elements enforcing the adaptive quota -func (at *AdaptiveThrottler) streamPortioned(inlet streams.Inlet) { - defer close(inlet.In()) +// adjustRate calculates the new rate based on stats and updates it atomically. +func (at *AdaptiveThrottler) adjustRate() { + stats := at.monitor.GetStats() + currentRate := at.GetCurrentRate() - for element := range at.out { - inlet.In() <- element + // Check if the resource usage is above the threshold + isConstrained := stats.MemoryUsedPercent > at.config.MaxMemoryPercent || + stats.CPUUsagePercent > at.config.MaxCPUPercent + + // Check if the resource usage is below the recovery threshold + isBelowRecovery := stats.MemoryUsedPercent < at.config.RecoveryMemoryThreshold && + stats.CPUUsagePercent < at.config.RecoveryCPUThreshold + + // Check if the resource usage is below the recovery threshold and hysteresis is disabled + shouldIncrease := !isConstrained && (!at.config.EnableHysteresis || isBelowRecovery) + + targetRate := currentRate + if isConstrained { + // Reduce the rate by the backoff factor + targetRate *= at.config.BackoffFactor + } else if shouldIncrease { + // Increase the rate by the recovery factor + targetRate *= at.config.RecoveryFactor + if targetRate > float64(at.config.MaxRate) { + targetRate = float64(at.config.MaxRate) + } } - at.stop() -} -// stop stops the adaptive throttler and cleans up resources -func (at *AdaptiveThrottler) stop() { - at.stopOnce.Do(func() { - close(at.done) - at.monitor.Close() - }) + // Apply smoothing to the new rate + newRate := currentRate + (targetRate-currentRate)*smoothingFactor + + // Enforce minimum rate + if newRate < float64(at.config.MinRate) { + newRate = float64(at.config.MinRate) + } + + at.setRate(newRate) } diff --git a/flow/adaptive_throttler_test.go b/flow/adaptive_throttler_test.go index f048a19..2e36fe5 100644 --- a/flow/adaptive_throttler_test.go +++ b/flow/adaptive_throttler_test.go @@ -1,1458 +1,1079 @@ package flow import ( - "strings" + "math" "sync" + "sync/atomic" "testing" "time" "github.com/reugn/go-streams" + "github.com/reugn/go-streams/internal/assert" ) -// mockResourceMonitor allows injecting specific resource stats for testing -type mockResourceMonitor struct { - mu sync.RWMutex - stats ResourceStats +type MockMonitor struct { + getStatsReturns []ResourceStats + getStatsIndex int + closeCalled bool } -func (m *mockResourceMonitor) GetStats() ResourceStats { - m.mu.RLock() - defer m.mu.RUnlock() - return m.stats +func (m *MockMonitor) GetStats() ResourceStats { + if m.getStatsIndex < len(m.getStatsReturns) { + result := m.getStatsReturns[m.getStatsIndex] + m.getStatsIndex++ + return result + } + return ResourceStats{} +} + +func (m *MockMonitor) Close() { + m.closeCalled = true +} + +func (m *MockMonitor) ExpectGetStats(stats ...ResourceStats) { + m.getStatsReturns = stats + m.getStatsIndex = 0 +} + +type mockFlow struct { + in chan any + out chan any +} + +func (m *mockFlow) Via(flow streams.Flow) streams.Flow { + return flow } -func (m *mockResourceMonitor) IsResourceConstrained() bool { - m.mu.RLock() - defer m.mu.RUnlock() - return m.stats.MemoryUsedPercent > 80.0 || m.stats.CPUUsagePercent > 70.0 +func (m *mockFlow) To(sink streams.Sink) { + go func() { + for data := range m.in { + sink.In() <- data + } + close(sink.In()) + }() } -func (m *mockResourceMonitor) SetStats(stats ResourceStats) { - m.mu.Lock() - m.stats = stats - m.mu.Unlock() +func (m *mockFlow) Out() <-chan any { + return m.out } -func (m *mockResourceMonitor) Close() {} - -// newAdaptiveThrottlerWithMonitor creates an AdaptiveThrottler with a mock monitor for testing -func newAdaptiveThrottlerWithMonitor( - config AdaptiveThrottlerConfig, - monitor resourceMonitor, - customPeriod ...time.Duration, -) *AdaptiveThrottler { - period := time.Second - if len(customPeriod) > 0 && customPeriod[0] > 0 { - period = customPeriod[0] +func (m *mockFlow) In() chan<- any { + if m.in == nil { + m.in = make(chan any, 10) + m.out = make(chan any, 10) + go func() { + defer close(m.out) + for data := range m.in { + m.out <- data + } + }() } + return m.in +} - at := &AdaptiveThrottler{ - config: config, - monitor: monitor, - period: period, - in: make(chan any), - out: make(chan any, config.BufferSize), - quotaSignal: make(chan struct{}, 1), - done: make(chan struct{}), +type mockSink struct { + in chan any + completion chan struct{} + completed atomic.Bool +} + +func (m *mockSink) In() chan<- any { + return m.in +} + +func (m *mockSink) AwaitCompletion() { + if !m.completed.Load() { + if m.completed.CompareAndSwap(false, true) { + if m.completion != nil { + close(m.completion) + } + } } +} - at.currentRate.Store(int64(config.MaxThroughput)) - at.maxElements.Store(int64(config.MaxThroughput)) - at.lastAdaptation = time.Now() +type mockSinkWithChannelDrain struct { + in chan any + done chan struct{} +} - // Start goroutines - go at.adaptRateLoop() - go at.resetQuotaCounterLoop() - go at.buffer() +func (m *mockSinkWithChannelDrain) In() chan<- any { + return m.in +} + +func (m *mockSinkWithChannelDrain) AwaitCompletion() { + <-m.done +} +// newMockSinkWithChannelDrain creates a mock sink that drains the channel like real sinks do +func newMockSinkWithChannelDrain() *mockSinkWithChannelDrain { + sink := &mockSinkWithChannelDrain{ + in: make(chan any), + done: make(chan struct{}), + } + go func() { + defer close(sink.done) + for range sink.in { + _ = struct{}{} // drain channel + } + }() + return sink +} + +// Helper functions for common test patterns + +// createThrottlerWithLongInterval creates a throttler with default config but long sample interval +func createThrottlerWithLongInterval(t *testing.T) *AdaptiveThrottler { + t.Helper() + config := DefaultAdaptiveThrottlerConfig() + config.SampleInterval = 10 * time.Second // Long interval to avoid interference + at, err := NewAdaptiveThrottler(config) + if err != nil { + t.Fatalf("Failed to create throttler: %v", err) + } return at } -func TestAdaptiveThrottler_ConfigValidation(t *testing.T) { +// collectDataFromChannel collects all data from a channel into a slice +func collectDataFromChannel(ch <-chan any) []any { + var received []any + for data := range ch { + received = append(received, data) + } + return received +} + +// collectDataFromChannelWithMutex collects data from channel using mutex for thread safety +func collectDataFromChannelWithMutex(ch <-chan any, received *[]any, mu *sync.Mutex, done chan struct{}) { + defer close(done) + for data := range ch { + mu.Lock() + *received = append(*received, data) + mu.Unlock() + } +} + +// verifyChannelClosed checks that a channel is closed within a timeout +func verifyChannelClosed(t *testing.T, ch <-chan any, timeout time.Duration) { + t.Helper() + select { + case _, ok := <-ch: + if ok { + t.Errorf("Channel should be closed") + } + case <-time.After(timeout): + t.Errorf("Channel should be closed within %v", timeout) + } +} + +// createThrottlerForRateTesting creates a throttler with mock monitor for rate adjustment testing +func createThrottlerForRateTesting( + config *AdaptiveThrottlerConfig, + initialRate float64, +) (*AdaptiveThrottler, *MockMonitor) { + mockMonitor := &MockMonitor{} + throttler := &AdaptiveThrottler{ + config: *config, + monitor: mockMonitor, + currentRateBits: math.Float64bits(initialRate), + } + return throttler, mockMonitor +} + +type mockInlet struct { + in chan any +} + +func (m *mockInlet) In() chan<- any { + return m.in +} + +// TestAdaptiveThrottlerConfig_Validate tests configuration validation with valid and invalid settings +func TestAdaptiveThrottlerConfig_Validate(t *testing.T) { tests := []struct { - name string - config AdaptiveThrottlerConfig - shouldError bool - expectedError string // Expected substring in error message + name string + config AdaptiveThrottlerConfig + wantErr bool }{ { - name: "valid config", - config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 100 * time.Millisecond, - BufferSize: 100, - MaxBufferSize: 1000, - AdaptationFactor: 0.2, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, - }, - shouldError: false, - expectedError: "", - }, - { - name: "invalid MaxMemoryPercent", - config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 150.0, // Invalid - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 100 * time.Millisecond, - BufferSize: 100, - MaxBufferSize: 1000, - AdaptationFactor: 0.2, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, - }, - shouldError: true, - expectedError: "invalid MaxMemoryPercent", - }, - { - name: "invalid MaxThroughput < MinThroughput", + name: "Valid Config", config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 100, - MaxThroughput: 50, // Invalid - SampleInterval: 100 * time.Millisecond, - BufferSize: 100, - MaxBufferSize: 1000, - AdaptationFactor: 0.2, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, + MaxMemoryPercent: 80, + MaxCPUPercent: 70, + SampleInterval: 100 * time.Millisecond, + CPUUsageMode: CPUUsageModeMeasured, + InitialRate: 100, + MinRate: 10, + MaxRate: 1000, + BackoffFactor: 0.5, + RecoveryFactor: 1.2, }, - shouldError: true, - expectedError: "invalid throughput bounds", + wantErr: false, }, { - name: "invalid MaxBufferSize", + name: "Invalid SampleInterval", config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 100 * time.Millisecond, - BufferSize: 100, - MaxBufferSize: -1, // Invalid: negative value - AdaptationFactor: 0.2, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, + SampleInterval: 1 * time.Millisecond, }, - shouldError: true, - expectedError: "invalid MaxBufferSize", + wantErr: true, }, { - name: "BufferSize exceeds MaxBufferSize", + name: "Invalid MaxMemoryPercent High", config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 100 * time.Millisecond, - BufferSize: 1000, - MaxBufferSize: 500, // Invalid: BufferSize > MaxBufferSize - AdaptationFactor: 0.2, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, + SampleInterval: 100 * time.Millisecond, + MaxMemoryPercent: 101, }, - shouldError: true, - expectedError: "BufferSize 1000 exceeds MaxBufferSize 500", + wantErr: true, }, { - name: "invalid AdaptationFactor <= 0", + name: "Invalid BackoffFactor", config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 100 * time.Millisecond, - BufferSize: 100, - MaxBufferSize: 1000, - AdaptationFactor: 0.0, // Invalid: <= 0 - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, + SampleInterval: 100 * time.Millisecond, + MaxMemoryPercent: 80, + BackoffFactor: 1.5, }, - shouldError: true, - expectedError: "invalid AdaptationFactor", + wantErr: true, }, { - name: "invalid AdaptationFactor >= 1", + name: "Invalid InitialRate below MinRate", config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 100 * time.Millisecond, - BufferSize: 100, - MaxBufferSize: 1000, - AdaptationFactor: 1.0, // Invalid: >= 1 - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, + SampleInterval: 100 * time.Millisecond, + MaxMemoryPercent: 80, + MinRate: 10, + MaxRate: 100, + InitialRate: 5, + BackoffFactor: 0.5, + RecoveryFactor: 1.2, }, - shouldError: true, - expectedError: "invalid AdaptationFactor", + wantErr: true, }, { - name: "invalid MaxCPUPercent <= 0", + name: "Invalid RecoveryFactor too low", config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 0.0, // Invalid: <= 0 - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 100 * time.Millisecond, - BufferSize: 100, - MaxBufferSize: 1000, - AdaptationFactor: 0.2, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, + SampleInterval: 100 * time.Millisecond, + MaxMemoryPercent: 80, + MinRate: 10, + MaxRate: 100, + InitialRate: 50, + BackoffFactor: 0.5, + RecoveryFactor: 0.9, }, - shouldError: true, - expectedError: "invalid MaxCPUPercent", + wantErr: true, }, { - name: "invalid MaxCPUPercent > 100", + name: "Valid InitialRate equals MinRate", config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 150.0, // Invalid: > 100 - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 100 * time.Millisecond, - BufferSize: 100, - MaxBufferSize: 1000, - AdaptationFactor: 0.2, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, + SampleInterval: 100 * time.Millisecond, + MaxMemoryPercent: 80, + MinRate: 10, + MaxRate: 100, + InitialRate: 10, + BackoffFactor: 0.5, + RecoveryFactor: 1.2, }, - shouldError: true, - expectedError: "invalid MaxCPUPercent", + wantErr: false, }, { - name: "invalid BufferSize < 1", + name: "Valid InitialRate equals MaxRate", config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 100 * time.Millisecond, - BufferSize: 0, // Invalid: < 1 - MaxBufferSize: 1000, - AdaptationFactor: 0.2, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, + SampleInterval: 100 * time.Millisecond, + MaxMemoryPercent: 80, + MinRate: 10, + MaxRate: 100, + InitialRate: 100, + BackoffFactor: 0.5, + RecoveryFactor: 1.2, }, - shouldError: true, - expectedError: "invalid BufferSize", + wantErr: false, }, { - name: "invalid SampleInterval < minSampleInterval", + name: "Invalid RecoveryMemoryThreshold too high", config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 5 * time.Millisecond, // Invalid: < minSampleInterval (10ms) - BufferSize: 100, - MaxBufferSize: 1000, - AdaptationFactor: 0.2, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, + SampleInterval: 100 * time.Millisecond, + MaxMemoryPercent: 80, + RecoveryMemoryThreshold: 85, // Higher than MaxMemoryPercent + MinRate: 10, + MaxRate: 100, + InitialRate: 50, + BackoffFactor: 0.5, + RecoveryFactor: 1.2, }, - shouldError: true, - expectedError: "invalid SampleInterval", + wantErr: true, }, { - name: "invalid HysteresisBuffer < 0", + name: "Invalid RecoveryCPUThreshold too high", config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 100 * time.Millisecond, - BufferSize: 100, - MaxBufferSize: 1000, - AdaptationFactor: 0.2, - HysteresisBuffer: -1.0, // Invalid: < 0 - MaxRateChangeFactor: 0.5, + SampleInterval: 100 * time.Millisecond, + MaxMemoryPercent: 80, + MaxCPUPercent: 70, + RecoveryCPUThreshold: 75, // Higher than MaxCPUPercent + MinRate: 10, + MaxRate: 100, + InitialRate: 50, + BackoffFactor: 0.5, + RecoveryFactor: 1.2, }, - shouldError: true, - expectedError: "invalid HysteresisBuffer", + wantErr: true, }, { - name: "invalid MaxRateChangeFactor <= 0", + name: "Invalid RecoveryMemoryThreshold negative", config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 100 * time.Millisecond, - BufferSize: 100, - MaxBufferSize: 1000, - AdaptationFactor: 0.2, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.0, // Invalid: <= 0 + SampleInterval: 100 * time.Millisecond, + MaxMemoryPercent: 80, + RecoveryMemoryThreshold: -5, + MinRate: 10, + MaxRate: 100, + InitialRate: 50, + BackoffFactor: 0.5, + RecoveryFactor: 1.2, }, - shouldError: true, - expectedError: "invalid MaxRateChangeFactor", - }, - { - name: "invalid MaxRateChangeFactor > 1", - config: AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 100 * time.Millisecond, - BufferSize: 100, - MaxBufferSize: 1000, - AdaptationFactor: 0.2, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 1.5, // Invalid: > 1 - }, - shouldError: true, - expectedError: "invalid MaxRateChangeFactor", + wantErr: true, }, } for _, tt := range tests { - tt := tt // capture loop variable t.Run(tt.name, func(t *testing.T) { - _, err := NewAdaptiveThrottler(&tt.config) - if tt.shouldError { + err := tt.config.validate() + if tt.wantErr { if err == nil { - t.Error("expected error but didn't get one") - return + t.Errorf("Expected error but got none") } - // Verify error message contains expected text - if tt.expectedError != "" && !strings.Contains(err.Error(), tt.expectedError) { - t.Errorf("error message should contain %q, got: %v", tt.expectedError, err) + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) } - } else if err != nil { - t.Errorf("unexpected error: %v", err) } }) } } -func TestAdaptiveThrottler_NilConfig(t *testing.T) { - // Test that nil config uses defaults - at, err := NewAdaptiveThrottler(nil) - if err != nil { - t.Fatalf("NewAdaptiveThrottler with nil config should not error, got: %v", err) +func TestDefaultAdaptiveThrottlerConfig(t *testing.T) { + config := DefaultAdaptiveThrottlerConfig() + if config == nil { + t.Fatal("Expected config to be not nil") } - defer at.Close() - - // Verify it uses default config values - if at.config.MaxMemoryPercent != 80.0 { - t.Errorf("expected default MaxMemoryPercent 80.0, got %f", at.config.MaxMemoryPercent) + if config.InitialRate != 1000 { + t.Errorf("Expected InitialRate to be 1000, got %d", config.InitialRate) } - if at.config.MaxCPUPercent != 70.0 { - t.Errorf("expected default MaxCPUPercent 70.0, got %f", at.config.MaxCPUPercent) + if config.MaxMemoryPercent != 85.0 { + t.Errorf("Expected MaxMemoryPercent to be 85.0, got %f", config.MaxMemoryPercent) } - if at.config.MinThroughput != 10 { - t.Errorf("expected default MinThroughput 10, got %d", at.config.MinThroughput) + if config.MaxCPUPercent != 80.0 { + t.Errorf("Expected MaxCPUPercent to be 80.0, got %f", config.MaxCPUPercent) } - if at.config.MaxThroughput != 500 { - t.Errorf("expected default MaxThroughput 500, got %d", at.config.MaxThroughput) + if config.RecoveryMemoryThreshold != 0 { + t.Errorf("Expected RecoveryMemoryThreshold to be 0 (auto-calculated), got %f", config.RecoveryMemoryThreshold) } -} - -func TestAdaptiveThrottler_BasicThroughput(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 50 * time.Millisecond, - BufferSize: 10, - AdaptationFactor: 0.2, - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, + if config.RecoveryCPUThreshold != 0 { + t.Errorf("Expected RecoveryCPUThreshold to be 0 (auto-calculated), got %f", config.RecoveryCPUThreshold) } - - // Mock monitor with low resource usage (should allow high throughput) - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 30.0, - CPUUsagePercent: 20.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, + if !config.EnableHysteresis { + t.Errorf("Expected EnableHysteresis to be true, got %v", config.EnableHysteresis) } +} - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer func() { - if r := recover(); r == nil { - at.Close() - } - time.Sleep(10 * time.Millisecond) - }() - - // Test that we can send elements to the input channel - done := make(chan bool, 1) - go func() { - defer func() { - if r := recover(); r == nil { - done <- true - } - }() - for i := 0; i < 5; i++ { - select { - case at.In() <- i: - case <-time.After(100 * time.Millisecond): - return - } - } - }() - - // Wait for goroutine to finish or timeout - select { - case <-done: - // Successfully sent elements - case <-time.After(500 * time.Millisecond): - t.Fatal("timeout sending elements to throttler") +func TestNewAdaptiveThrottler(t *testing.T) { + at1, err := NewAdaptiveThrottler(nil) + if err != nil { + t.Fatalf("Expected no error with nil config, got: %v", err) } - - // Verify rate is at maximum initially - if at.GetCurrentRate() != int64(config.MaxThroughput) { - t.Errorf("expected initial rate %d, got %d", config.MaxThroughput, at.GetCurrentRate()) + if at1 == nil { + t.Fatal("Expected non-nil throttler") } -} - -func TestAdaptiveThrottler_OutChannelRespectsQuota(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 1, - MaxThroughput: 1, - SampleInterval: 50 * time.Millisecond, - BufferSize: 4, - AdaptationFactor: 0.2, - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, + if at1.config.InitialRate != 1000 { + t.Errorf("Expected default InitialRate 1000, got %d", at1.config.InitialRate) } + at1.close() - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 10, - CPUUsagePercent: 10, - }, + config := DefaultAdaptiveThrottlerConfig() + config.InitialRate = 500 + at2, err := NewAdaptiveThrottler(config) + if err != nil { + t.Fatalf("Expected no error with valid config, got: %v", err) } - - period := 80 * time.Millisecond - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor, period) - defer func() { - at.Close() - time.Sleep(20 * time.Millisecond) - }() - - writeDone := make(chan struct{}) - go func() { - defer close(writeDone) - at.In() <- "first" - at.In() <- "second" - }() - - first := <-at.Out() - if first != "first" { - t.Fatalf("expected first element, got %v", first) + if at2.config.InitialRate != 500 { + t.Errorf("Expected InitialRate 500, got %d", at2.config.InitialRate) } - firstReceived := time.Now() + at2.close() - second := <-at.Out() - if second != "second" { - t.Fatalf("expected second element, got %v", second) + invalidConfig := &AdaptiveThrottlerConfig{ + SampleInterval: 1 * time.Millisecond, } - gap := time.Since(firstReceived) - - if gap < period/2 { - t.Fatalf("expected quota enforcement delay, got %v", gap) + at3, err := NewAdaptiveThrottler(invalidConfig) + if err == nil { + at3.close() + t.Fatal("Expected error with invalid config") } - - <-writeDone } -func TestAdaptiveThrottler_RateLimits(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 5, - MaxThroughput: 20, - SampleInterval: 50 * time.Millisecond, - BufferSize: 50, - AdaptationFactor: 0.5, // Aggressive adaptation - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, - } - - // Test minimum bound - t.Run("minimum throughput", func(t *testing.T) { - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 90.0, // High usage - should constrain - CPUUsagePercent: 80.0, - GoroutineCount: 10, - Timestamp: time.Now(), - }, - } - - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer func() { - at.Close() - time.Sleep(50 * time.Millisecond) - }() - - // Wait for adaptation - time.Sleep(200 * time.Millisecond) - - rate := at.GetCurrentRate() - if rate < int64(config.MinThroughput) { - t.Errorf("rate %d should not go below minimum %d", rate, config.MinThroughput) - } +// TestAdaptiveThrottler_AdjustRate_Logic tests rate adjustment algorithm with high/low resource usage +func TestAdaptiveThrottler_AdjustRate_Logic(t *testing.T) { + config := DefaultAdaptiveThrottlerConfig() + config.MinRate = 10 + config.MaxRate = 100 + config.InitialRate = 50 + config.BackoffFactor = 0.5 + config.RecoveryFactor = 2.0 + config.MaxCPUPercent = 50.0 + config.MaxMemoryPercent = 50.0 + config.RecoveryCPUThreshold = 40.0 + config.RecoveryMemoryThreshold = 40.0 + + at, mockMonitor := createThrottlerForRateTesting(config, float64(config.InitialRate)) + + mockMonitor.ExpectGetStats(ResourceStats{ + CPUUsagePercent: 60.0, + MemoryUsedPercent: 30.0, }) + at.adjustRate() + assert.InDelta(t, 42.5, at.GetCurrentRate(), 0.01) - // Test maximum bound - t.Run("maximum throughput", func(t *testing.T) { - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 10.0, // Low usage - should allow max - CPUUsagePercent: 5.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, - } - - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer func() { - at.Close() - time.Sleep(50 * time.Millisecond) - }() - - // Wait for adaptation - time.Sleep(200 * time.Millisecond) + mockMonitor.ExpectGetStats(ResourceStats{ + CPUUsagePercent: 40.0, + MemoryUsedPercent: 60.0, + }) + at.adjustRate() + assert.InDelta(t, 36.125, at.GetCurrentRate(), 0.01) - rate := at.GetCurrentRate() - if rate > int64(config.MaxThroughput) { - t.Errorf("rate %d should not exceed maximum %d", rate, config.MaxThroughput) - } + mockMonitor.ExpectGetStats(ResourceStats{ + CPUUsagePercent: 10.0, + MemoryUsedPercent: 10.0, }) + at.adjustRate() + assert.InDelta(t, 46.9625, at.GetCurrentRate(), 0.01) } -func TestAdaptiveThrottler_ResourceConstraintResponse(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 50 * time.Millisecond, - BufferSize: 20, - AdaptationFactor: 0.2, - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, - } - - // Start with high throughput - initialRate := 100 - config.MaxThroughput = initialRate +// TestAdaptiveThrottler_Hysteresis tests hysteresis behavior with enabled/disabled modes +func TestAdaptiveThrottler_Hysteresis(t *testing.T) { + config := DefaultAdaptiveThrottlerConfig() + config.MinRate = 10 + config.MaxRate = 100 + config.InitialRate = 50 + config.BackoffFactor = 0.8 + config.RecoveryFactor = 1.2 + config.MaxCPUPercent = 80.0 + config.MaxMemoryPercent = 85.0 + config.RecoveryCPUThreshold = 70.0 + config.RecoveryMemoryThreshold = 75.0 + + // Test with hysteresis enabled (default) + config.EnableHysteresis = true + at, mockMonitor := createThrottlerForRateTesting(config, float64(config.InitialRate)) + + // CPU at 75% (above recovery threshold 70%, below max threshold 80%) - should not increase + mockMonitor.ExpectGetStats(ResourceStats{ + CPUUsagePercent: 75.0, + MemoryUsedPercent: 40.0, // Below recovery threshold + }) + at.adjustRate() + assert.InDelta(t, 50.0, at.GetCurrentRate(), 0.01) // Should stay at 50 (no increase) - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 50.0, // OK initially - CPUUsagePercent: 40.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, - } + // CPU at 65% (below recovery threshold) - should increase + mockMonitor.ExpectGetStats(ResourceStats{ + CPUUsagePercent: 65.0, + MemoryUsedPercent: 40.0, + }) + at.adjustRate() + assert.InDelta(t, 53.0, at.GetCurrentRate(), 0.01) // 50 + (60-50)*0.3 = 53 (with smoothing) - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer func() { - at.Close() - time.Sleep(50 * time.Millisecond) - }() + // Test with hysteresis disabled + config.EnableHysteresis = false + at2, _ := createThrottlerForRateTesting(config, 50.0) - // Initial rate should be max - if at.GetCurrentRate() != int64(initialRate) { - t.Errorf("expected initial rate %d, got %d", initialRate, at.GetCurrentRate()) - } - - // Simulate resource constraint - mockMonitor.SetStats(ResourceStats{ - MemoryUsedPercent: 85.0, // Constrained - CPUUsagePercent: 40.0, - GoroutineCount: 5, - Timestamp: time.Now(), + // CPU at 75% (below max threshold) - should increase immediately (no hysteresis) + mockMonitor.ExpectGetStats(ResourceStats{ + CPUUsagePercent: 75.0, + MemoryUsedPercent: 40.0, }) + at2.adjustRate() + assert.InDelta(t, 53.0, at2.GetCurrentRate(), 0.01) // Should increase immediately +} - // Wait for adaptation - time.Sleep(200 * time.Millisecond) - - newRate := at.GetCurrentRate() - if newRate >= int64(initialRate) { - t.Errorf("rate should decrease when constrained, got %d (initial %d)", newRate, initialRate) - } - if newRate < int64(config.MinThroughput) { - t.Errorf("rate %d should not go below minimum %d", newRate, config.MinThroughput) - } +// TestAdaptiveThrottler_Limits tests that rate stays within min/max bounds +func TestAdaptiveThrottler_Limits(t *testing.T) { + config := DefaultAdaptiveThrottlerConfig() + config.MinRate = 10 + config.MaxRate = 20 + config.InitialRate = 10 + config.RecoveryFactor = 10.0 + config.RecoveryCPUThreshold = 70.0 + config.RecoveryMemoryThreshold = 75.0 + + at, mockMonitor := createThrottlerForRateTesting(config, 10.0) + + mockMonitor.ExpectGetStats(ResourceStats{CPUUsagePercent: 0, MemoryUsedPercent: 0}) + at.adjustRate() + assert.InDelta(t, 13.0, at.GetCurrentRate(), 0.01) } -func TestAdaptiveThrottler_RateIncreaseNotifiesQuota(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 50 * time.Millisecond, - BufferSize: 10, - AdaptationFactor: 0.5, - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 1.0, - } +// TestAdaptiveThrottler_FlowControl tests token bucket throttling with burst traffic +func TestAdaptiveThrottler_FlowControl(t *testing.T) { + mockMonitor := &MockMonitor{} - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 10.0, - CPUUsagePercent: 10.0, - Timestamp: time.Now(), - }, - } + config := DefaultAdaptiveThrottlerConfig() + config.InitialRate = 10 + config.SampleInterval = 10 * time.Second at := &AdaptiveThrottler{ - config: config, - monitor: mockMonitor, - period: time.Second, - in: make(chan any), - out: make(chan any, config.BufferSize), - quotaSignal: make(chan struct{}, 1), - done: make(chan struct{}), + config: *config, + monitor: mockMonitor, + currentRateBits: math.Float64bits(float64(config.InitialRate)), + in: make(chan any), + out: make(chan any, 100), + done: make(chan struct{}), } - defer func() { - at.Close() - time.Sleep(10 * time.Millisecond) - }() - initialRate := int64(40) - at.currentRate.Store(initialRate) - at.maxElements.Store(initialRate) - at.lastAdaptation = time.Now() + go at.monitorLoop() + go at.pipelineLoop() - // Simulate exhausted quota so emitters would be blocked - at.counter.Store(initialRate) + // Send events continuously for a period + sendDuration := 500 * time.Millisecond + sendDone := make(chan struct{}) - at.adaptRate() - - select { - case <-at.quotaSignal: - // Expected: quota reset signal was sent immediately after rate increase - default: - t.Fatal("expected quota reset notification when rate increases") - } -} - -func TestAdaptiveThrottler_SmoothTransitions(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 50 * time.Millisecond, - BufferSize: 20, - AdaptationFactor: 0.5, - SmoothTransitions: true, // Enabled - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, - } - - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 85.0, // Constrained - CPUUsagePercent: 40.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, - } - - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer func() { - at.Close() - time.Sleep(50 * time.Millisecond) - }() - - initialRate := at.GetCurrentRate() - - // Wait for multiple adaptations - time.Sleep(300 * time.Millisecond) - - finalRate := at.GetCurrentRate() - - // With smoothing, the rate should change gradually - // We expect some reduction but not immediate full reduction - if finalRate >= initialRate { - t.Errorf("rate should decrease when constrained, got %d (initial: %d)", finalRate, initialRate) - } - - // Verify rate is within reasonable bounds - if finalRate < int64(config.MinThroughput) { - t.Errorf("rate %d should not go below minimum %d", finalRate, config.MinThroughput) - } + sentCount := 0 + go func() { + defer close(sendDone) + ticker := time.NewTicker(1 * time.Millisecond) // Send very frequently + defer ticker.Stop() - // With smoothing enabled, the rate reduction should be gradual (not immediate full reduction) - // Calculate expected aggressive reduction (without smoothing) for comparison - aggressiveReduction := int64(float64(initialRate) * config.AdaptationFactor * 2) - expectedMinRateWithoutSmoothing := initialRate - aggressiveReduction - actualReduction := initialRate - finalRate - - // With smoothing, actual reduction should be less than aggressive reduction would be - // This verifies that smoothing is actually working (gradual vs immediate) - if expectedMinRateWithoutSmoothing > int64(config.MinThroughput) { - if actualReduction >= aggressiveReduction { - t.Errorf("with smoothing enabled, rate reduction should be gradual. "+ - "Got reduction of %d (from %d to %d), expected less than aggressive reduction of %d", - actualReduction, initialRate, finalRate, aggressiveReduction, - ) + sendStart := time.Now() + for { + select { + case at.in <- sentCount: + sentCount++ + case <-ticker.C: + if time.Since(sendStart) >= sendDuration { + close(at.in) + return + } + } } - } -} - -func TestAdaptiveThrottler_SmoothTransitionsRespectBounds(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 5, - MaxThroughput: 6, - SampleInterval: 20 * time.Millisecond, - BufferSize: 10, - AdaptationFactor: 0.9, - SmoothTransitions: true, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, - } - - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 90.0, - CPUUsagePercent: 10.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, - } - - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer func() { - at.Close() - time.Sleep(20 * time.Millisecond) }() - time.Sleep(3 * config.SampleInterval) - - if rate := at.GetCurrentRate(); rate < int64(config.MinThroughput) { - t.Fatalf("expected rate to stay >= %d, got %d", config.MinThroughput, rate) - } -} - -func TestAdaptiveThrottler_CloseClosesOutputEvenIfInputOpen(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 1, - MaxThroughput: 5, - SampleInterval: 20 * time.Millisecond, - BufferSize: 5, - AdaptationFactor: 0.2, - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, - } + // Collect received events + receivedCount := 0 + receiveDone := make(chan struct{}) - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 10.0, - CPUUsagePercent: 10.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, - } - - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer func() { - close(at.in) - at.Close() - time.Sleep(20 * time.Millisecond) - }() - - done := make(chan struct{}) go func() { - _, ok := <-at.Out() - if ok { - t.Error("expected output channel to be closed after Close without draining input") + defer close(receiveDone) + for range at.out { + receivedCount++ } - close(done) }() - // Give goroutine time to start before closing - time.Sleep(10 * time.Millisecond) - at.Close() + // Wait for sending to complete + <-sendDone - select { - case <-done: - case <-time.After(200 * time.Millisecond): - t.Fatal("output channel not closed after Close() when input left open") - } -} + // Wait a bit more for processing + time.Sleep(100 * time.Millisecond) + at.close() -func TestAdaptiveThrottler_CloseStopsBackgroundLoops(t *testing.T) { - at, err := NewAdaptiveThrottler(DefaultAdaptiveThrottlerConfig()) - if err != nil { - t.Fatalf("failed to create adaptive throttler: %v", err) - } + // Wait for receiving to complete + <-receiveDone - at.Close() + t.Logf("Sent %d events over %v, received %d events", sentCount, sendDuration, receivedCount) - select { - case <-at.done: - case <-time.After(200 * time.Millisecond): - t.Fatal("expected done channel to be closed after Close()") + // With rate of 10/sec over 500ms, should receive about 5 events + expectedMax := int(float64(config.InitialRate) * sendDuration.Seconds() * 1.5) + if receivedCount > expectedMax { + t.Fatalf("Received too many events: got %d, expected at most %d", receivedCount, expectedMax) + } + if receivedCount < 1 { + t.Fatalf("Expected at least 1 event, got %d", receivedCount) } - - // Close again to ensure idempotence - at.Close() } -func TestAdaptiveThrottler_CPUUsageMode(t *testing.T) { - at, err := NewAdaptiveThrottler(DefaultAdaptiveThrottlerConfig()) - if err != nil { - t.Fatalf("failed to create adaptive throttler: %v", err) - } - defer func() { - at.Close() - time.Sleep(10 * time.Millisecond) - }() +func TestAdaptiveThrottler_GetCurrentRate(t *testing.T) { + mockMonitor := &MockMonitor{} + config := DefaultAdaptiveThrottlerConfig() - // Should be able to create with rusage mode - if at == nil { - t.Fatal("should create throttler with rusage mode") + at := &AdaptiveThrottler{ + config: *config, + monitor: mockMonitor, + currentRateBits: math.Float64bits(42.5), } - // The actual CPU mode used depends on platform support - // Just verify the throttler was created successfully - stats := at.GetResourceStats() - if stats.CPUUsagePercent < 0 || stats.CPUUsagePercent > 100 { - t.Errorf("CPU percent should be valid, got %v", stats.CPUUsagePercent) + rate := at.GetCurrentRate() + if rate != 42.5 { + t.Errorf("Expected rate 42.5, got %f", rate) } } -func TestAdaptiveThrottler_GetCurrentRate(t *testing.T) { +func TestAdaptiveThrottler_GetResourceStats(t *testing.T) { + mockMonitor := &MockMonitor{} + expectedStats := ResourceStats{ + CPUUsagePercent: 15.5, + MemoryUsedPercent: 25.0, + GoroutineCount: 10, + } + mockMonitor.ExpectGetStats(expectedStats) + config := DefaultAdaptiveThrottlerConfig() - at, err := NewAdaptiveThrottler(config) - if err != nil { - t.Fatalf("failed to create adaptive throttler: %v", err) + at := &AdaptiveThrottler{ + config: *config, + monitor: mockMonitor, } - defer func() { - at.Close() - time.Sleep(10 * time.Millisecond) - }() - rate := at.GetCurrentRate() - if rate <= 0 { - t.Errorf("current rate should be positive, got %d", rate) + stats := at.GetResourceStats() + if stats.CPUUsagePercent != expectedStats.CPUUsagePercent { + t.Errorf("Expected CPU %f, got %f", expectedStats.CPUUsagePercent, stats.CPUUsagePercent) } - if rate > int64(config.MaxThroughput) { - t.Errorf("current rate %d should not exceed max %d", rate, config.MaxThroughput) + if stats.MemoryUsedPercent != expectedStats.MemoryUsedPercent { + t.Errorf("Expected Memory %f, got %f", expectedStats.MemoryUsedPercent, stats.MemoryUsedPercent) } } -func TestAdaptiveThrottler_GetResourceStats(t *testing.T) { +func TestAdaptiveThrottler_Via_ReturnsInputFlow(t *testing.T) { config := DefaultAdaptiveThrottlerConfig() at, err := NewAdaptiveThrottler(config) if err != nil { - t.Fatalf("failed to create adaptive throttler: %v", err) + t.Fatalf("Failed to create throttler: %v", err) } - defer func() { - at.Close() - time.Sleep(10 * time.Millisecond) - }() + defer at.close() - stats := at.GetResourceStats() + mockFlow := &mockFlow{} + resultFlow := at.Via(mockFlow) - if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { - t.Errorf("memory percent should be between 0 and 100, got %v", stats.MemoryUsedPercent) - } - if stats.CPUUsagePercent < 0 || stats.CPUUsagePercent > 100 { - t.Errorf("CPU percent should be between 0 and 100, got %v", stats.CPUUsagePercent) - } - if stats.GoroutineCount <= 0 { - t.Errorf("goroutine count should be > 0, got %d", stats.GoroutineCount) - } - if stats.Timestamp.IsZero() { - t.Error("timestamp should not be zero") + if resultFlow != mockFlow { + t.Error("Via should return the input flow") } } -func TestAdaptiveThrottler_BufferBackpressure(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 100 * time.Millisecond, - BufferSize: 5, // Small buffer - AdaptationFactor: 0.2, - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, - } - - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 30.0, - CPUUsagePercent: 20.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, - } +// TestAdaptiveThrottler_To tests To method that streams data to a sink +func TestAdaptiveThrottler_To(t *testing.T) { + at := createThrottlerWithLongInterval(t) + defer at.close() - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer func() { - close(at.in) - at.Close() - time.Sleep(10 * time.Millisecond) // Allow cleanup - }() + var received []any + var mu sync.Mutex + done := make(chan struct{}) - // Fill buffer - for i := 0; i < config.BufferSize; i++ { - select { - case at.In() <- i: - case <-time.After(100 * time.Millisecond): - t.Fatal("should be able to send to buffer initially") - } + sinkCh := make(chan any, 10) + mockSink := &mockSink{ + in: sinkCh, + completion: make(chan struct{}), } - // Next send should block or succeed (backpressure test) - done := make(chan bool, 1) - go func() { - at.In() <- "test" - done <- true - }() + // Collect data from sink + go collectDataFromChannelWithMutex(sinkCh, &received, &mu, done) - select { - case <-done: - // Element was accepted - buffer management worked - case <-time.After(200 * time.Millisecond): - // Timeout - this is also acceptable as backpressure is working - t.Log("Backpressure working - send blocked as expected") - } -} + testData := []any{"test1", "test2", "test3"} -func BenchmarkAdaptiveThrottler_GetResourceStats(b *testing.B) { - at, err := NewAdaptiveThrottler(DefaultAdaptiveThrottlerConfig()) - if err != nil { - b.Fatalf("failed to create adaptive throttler: %v", err) - } - defer func() { - at.Close() - time.Sleep(10 * time.Millisecond) - }() + // Start the throttler streaming to sink + go at.To(mockSink) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = at.GetResourceStats() - } -} - -// TestAdaptiveThrottler_BufferedQuotaSignal verifies that the quota signal channel -// is buffered and handles reset signals properly under sustained pressure -func TestAdaptiveThrottler_EdgeCases(t *testing.T) { - t.Run("exact threshold values", func(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 50 * time.Millisecond, - BufferSize: 20, - AdaptationFactor: 0.2, - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, + // Send test data + go func() { + for _, data := range testData { + at.In() <- data } + close(at.In()) + }() - // Test exactly at threshold - should not be constrained - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 80.0, // Exactly at threshold - CPUUsagePercent: 70.0, // Exactly at threshold - GoroutineCount: 5, - Timestamp: time.Now(), - }, - } + // Wait for completion signal from sink + mockSink.AwaitCompletion() - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer func() { - at.Close() - time.Sleep(50 * time.Millisecond) - }() + // Wait for receiver to finish + <-done - // Should not be constrained at exact threshold - if mockMonitor.IsResourceConstrained() { - t.Error("should not be constrained at exact threshold values") + mu.Lock() + defer mu.Unlock() + if len(received) != len(testData) { + t.Errorf("Expected %d items, got %d", len(testData), len(received)) + } + for i, expected := range testData { + if i >= len(received) || received[i] != expected { + t.Errorf("Expected data[%d] = %v, got %v", i, expected, received[i]) } + } +} - time.Sleep(200 * time.Millisecond) - rate := at.GetCurrentRate() - if rate < int64(config.MaxThroughput) { - t.Errorf("rate should not decrease when at exact threshold, got %d", rate) - } - }) +func TestAdaptiveThrottler_StreamPortioned(t *testing.T) { + mockMonitor := &MockMonitor{} + config := DefaultAdaptiveThrottlerConfig() - t.Run("minimal throughput range", func(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 50, - MaxThroughput: 50, // Same as min - SampleInterval: 50 * time.Millisecond, - BufferSize: 20, - AdaptationFactor: 0.2, - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, - } + at := &AdaptiveThrottler{ + config: *config, + monitor: mockMonitor, + currentRateBits: math.Float64bits(1000), + out: make(chan any, 10), + } - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 90.0, // Constrained - CPUUsagePercent: 20.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, - } + inletIn := make(chan any, 10) + mockInlet := &mockInlet{in: inletIn} - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer func() { - at.Close() - time.Sleep(50 * time.Millisecond) - }() + testData := []any{"data1", "data2", "data3"} + var received []any + var mu sync.Mutex + done := make(chan struct{}) - // Rate should stay at 50 even under constraint - time.Sleep(200 * time.Millisecond) - rate := at.GetCurrentRate() - if rate != 50 { - t.Errorf("rate should stay at 50 with min=max=50, got %d", rate) - } - }) + // Start streamPortioned in background + go at.streamPortioned(mockInlet) - t.Run("zero adaptation factor", func(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 50 * time.Millisecond, - BufferSize: 20, - AdaptationFactor: 0.0, // No adaptation - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, - } + // Collect all received data + go collectDataFromChannelWithMutex(inletIn, &received, &mu, done) - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 90.0, // Constrained - CPUUsagePercent: 20.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, + // Send test data + go func() { + for _, data := range testData { + at.out <- data } + close(at.out) + }() - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer func() { - at.Close() - time.Sleep(50 * time.Millisecond) - }() - - initialRate := at.GetCurrentRate() - time.Sleep(200 * time.Millisecond) - finalRate := at.GetCurrentRate() + // Wait for all data to be received + <-done - // Rate should not change with zero adaptation factor - if initialRate != finalRate { - t.Errorf("rate should not change with adaptation factor 0, initial: %d, final: %d", initialRate, finalRate) + mu.Lock() + defer mu.Unlock() + if len(received) != len(testData) { + t.Errorf("Expected %d items, got %d", len(testData), len(received)) + } + for i, expected := range testData { + if received[i] != expected { + t.Errorf("Expected data[%d] = %v, got %v", i, expected, received[i]) } - }) -} - -func TestAdaptiveThrottler_BufferedQuotaSignal(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 1, - MaxThroughput: 10, - SampleInterval: 50 * time.Millisecond, - BufferSize: 50, - AdaptationFactor: 0.2, - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, } +} - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 30.0, - CPUUsagePercent: 20.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, - } +func TestAdaptiveThrottler_Via_DataFlow(t *testing.T) { + // Actually test data flow + at, _ := NewAdaptiveThrottler(DefaultAdaptiveThrottlerConfig()) + defer at.close() - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer func() { - at.Close() - time.Sleep(50 * time.Millisecond) + // Send test data and verify it flows through + testData := []any{"test1", "test2"} + go func() { + for _, data := range testData { + at.In() <- data + } + close(at.In()) }() - // Fill the output buffer and quota - for i := 0; i < config.MaxThroughput; i++ { - select { - case at.In() <- i: - case <-time.After(100 * time.Millisecond): - t.Fatal("should be able to send to input initially") - } + received := make([]any, 0, len(testData)) + for data := range at.Out() { + received = append(received, data) } - time.Sleep(1100 * time.Millisecond) // Wait > 1 second for quota reset - - // Should be able to send more elements now (quota reset signal was buffered) - select { - case at.In() <- "test": - // Success - buffered signal worked - case <-time.After(100 * time.Millisecond): - t.Error("should be able to send after quota reset, buffered signal may not be working") + if len(received) != len(testData) { + t.Errorf("Expected %d items, got %d", len(testData), len(received)) } } -func TestAdaptiveThrottler_CustomMemoryReader(t *testing.T) { - customMemoryPercent := 42.0 - callCount := 0 - +// TestAdaptiveThrottler_AdjustRate_EdgeCases tests edge cases for adjustRate +func TestAdaptiveThrottler_AdjustRate_EdgeCases(t *testing.T) { config := DefaultAdaptiveThrottlerConfig() - config.MemoryReader = func() (float64, error) { - callCount++ - return customMemoryPercent, nil - } - - at, err := NewAdaptiveThrottler(config) - if err != nil { - t.Fatalf("failed to create adaptive throttler: %v", err) + config.MinRate = 10 + config.MaxRate = 100 + config.InitialRate = 50 + config.BackoffFactor = 0.7 + config.RecoveryFactor = 1.3 + config.MaxCPUPercent = 80.0 + config.MaxMemoryPercent = 85.0 + config.RecoveryCPUThreshold = 70.0 + config.RecoveryMemoryThreshold = 75.0 + + at, mockMonitor := createThrottlerForRateTesting(config, 50.0) + + // Test: Both CPU and memory constrained + mockMonitor.ExpectGetStats(ResourceStats{ + CPUUsagePercent: 90.0, // Above max + MemoryUsedPercent: 90.0, // Above max + }) + at.adjustRate() + // Should reduce rate: 50 * 0.7 = 35, then smoothed: 50 + (35-50)*0.3 = 45.5 + assert.InDelta(t, 45.5, at.GetCurrentRate(), 0.1) + + // Test: Rate at max, should not exceed + at.setRate(100.0) + mockMonitor.ExpectGetStats(ResourceStats{ + CPUUsagePercent: 10.0, + MemoryUsedPercent: 10.0, + }) + at.adjustRate() + // Should try to increase but cap at MaxRate + rate := at.GetCurrentRate() + if rate > 100.0 { + t.Errorf("Rate should not exceed MaxRate, got %f", rate) } - defer at.Close() - // Allow some time for stats collection - time.Sleep(150 * time.Millisecond) - - stats := at.GetResourceStats() - if stats.MemoryUsedPercent != customMemoryPercent { - t.Errorf("expected memory percent %f, got %f", customMemoryPercent, stats.MemoryUsedPercent) - } - if callCount == 0 { - t.Error("custom memory reader was not called") + // Test: Rate at min, constrained + at.setRate(10.0) + mockMonitor.ExpectGetStats(ResourceStats{ + CPUUsagePercent: 90.0, + MemoryUsedPercent: 90.0, + }) + at.adjustRate() + // Should reduce but not go below MinRate (though MinRate is not enforced in adjustRate) + rate = at.GetCurrentRate() + if rate < 0 { + t.Errorf("Rate should not be negative, got %f", rate) + } + + // Test: Exactly at thresholds + at.setRate(50.0) + mockMonitor.ExpectGetStats(ResourceStats{ + CPUUsagePercent: 80.0, // Exactly at max (not above, so not constrained) + MemoryUsedPercent: 70.0, // Below max + }) + at.adjustRate() + // Should not reduce because CPU is exactly at max (not > max) + // The constraint check uses > not >= + rate = at.GetCurrentRate() + // Rate should stay the same or potentially increase if below recovery threshold + if rate < 0 { + t.Errorf("Rate should not be negative, got %f", rate) + } + + // Test: Exactly at recovery thresholds with hysteresis + at.setRate(50.0) + config.EnableHysteresis = true + mockMonitor.ExpectGetStats(ResourceStats{ + CPUUsagePercent: 70.0, // Exactly at recovery threshold + MemoryUsedPercent: 75.0, // Exactly at recovery threshold + }) + at.adjustRate() + // With hysteresis, should not increase (must be below both thresholds) + rate = at.GetCurrentRate() + if rate > 50.0 { + t.Errorf("With hysteresis, rate should not increase at threshold, got %f", rate) } } -func TestAdaptiveThrottler_Via(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 50 * time.Millisecond, - BufferSize: 20, - AdaptationFactor: 0.2, - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, - } +// TestAdaptiveThrottler_PipelineLoop_Shutdown tests pipelineLoop shutdown scenarios +func TestAdaptiveThrottler_PipelineLoop_Shutdown(t *testing.T) { + mockMonitor := &MockMonitor{} + config := DefaultAdaptiveThrottlerConfig() + config.InitialRate = 100 // Fast rate for testing - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 30.0, - CPUUsagePercent: 20.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, + at := &AdaptiveThrottler{ + config: *config, + monitor: mockMonitor, + currentRateBits: math.Float64bits(100.0), + in: make(chan any), + out: make(chan any, 10), + done: make(chan struct{}), } - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer at.Close() + // Test: Shutdown during processing + go at.pipelineLoop() - // Create a mock flow - mockFlow := &mockFlow{ - in: make(chan any, 10), - out: make(chan any, 10), - } + // Send one item + at.in <- "test1" - // Test Via() - should return the flow and start streaming - resultFlow := at.Via(mockFlow) - if resultFlow != mockFlow { - t.Error("Via() should return the provided flow") - } + // Wait a bit for it to start processing + time.Sleep(10 * time.Millisecond) + + // Close done channel to trigger shutdown + close(at.done) - // Send an element - at.In() <- "test" + // Wait for pipeline to finish (may take a moment for goroutine to exit) + time.Sleep(200 * time.Millisecond) - // Wait for element to be forwarded + // Verify output channel is closed (pipelineLoop defers close(at.out)) select { - case element := <-mockFlow.in: - if element != "test" { - t.Errorf("expected 'test', got %v", element) + case _, ok := <-at.out: + if ok { + // Channel still open, wait a bit more + time.Sleep(100 * time.Millisecond) + select { + case _, ok2 := <-at.out: + if ok2 { + t.Error("Output channel should be closed after shutdown") + } + default: + // Channel closed now + } } - case <-time.After(500 * time.Millisecond): - t.Error("timeout waiting for element to be forwarded via Via()") + default: + // Channel already closed, which is expected } } -func TestAdaptiveThrottler_To(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 50 * time.Millisecond, - BufferSize: 20, - AdaptationFactor: 0.2, - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, - } +// TestAdaptiveThrottler_PipelineLoop_RateChange tests pipelineLoop with rate changes +func TestAdaptiveThrottler_PipelineLoop_RateChange(t *testing.T) { + mockMonitor := &MockMonitor{} + config := DefaultAdaptiveThrottlerConfig() - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 30.0, - CPUUsagePercent: 20.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, + at := &AdaptiveThrottler{ + config: *config, + monitor: mockMonitor, + currentRateBits: math.Float64bits(10.0), // Start at 10/sec + in: make(chan any), + out: make(chan any, 10), + done: make(chan struct{}), } - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer at.Close() - - // Create a mock sink - mockSink := &mockSink{ - in: make(chan any, 10), - } + go at.pipelineLoop() - // Test To() - should block until completion - done := make(chan struct{}) + // Send items and change rate during processing go func() { - defer close(done) - at.To(mockSink) + for i := 0; i < 5; i++ { + at.in <- i + time.Sleep(10 * time.Millisecond) + // Change rate mid-stream + if i == 2 { + at.setRate(20.0) // Double the rate + } + } + close(at.in) }() - // Send an element - at.In() <- "test" - - // Wait for element to be received - select { - case element := <-mockSink.in: - if element != "test" { - t.Errorf("expected 'test', got %v", element) + // Collect items + received := make([]any, 0) + timeout := time.After(2 * time.Second) + for { + select { + case item, ok := <-at.out: + if !ok { + goto done + } + received = append(received, item) + case <-timeout: + t.Fatal("Timeout waiting for items") } - case <-time.After(500 * time.Millisecond): - t.Error("timeout waiting for element to be received by sink") } +done: - // Close input to trigger completion - close(at.in) - - // Wait for To() to complete - select { - case <-done: - // Success - case <-time.After(500 * time.Millisecond): - t.Error("timeout waiting for To() to complete") + if len(received) != 5 { + t.Errorf("Expected 5 items, got %d", len(received)) } + + // Close done to clean up + close(at.done) } -func TestAdaptiveThrottler_BufferInputClose(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 10, - MaxThroughput: 100, - SampleInterval: 50 * time.Millisecond, - BufferSize: 20, - AdaptationFactor: 0.2, - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, - } +// TestAdaptiveThrottler_PipelineLoop_LowRate tests pipelineLoop with very low rate +func TestAdaptiveThrottler_PipelineLoop_LowRate(t *testing.T) { + mockMonitor := &MockMonitor{} + config := DefaultAdaptiveThrottlerConfig() - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 30.0, - CPUUsagePercent: 20.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, + at := &AdaptiveThrottler{ + config: *config, + monitor: mockMonitor, + currentRateBits: math.Float64bits(0.5), // Very slow: 0.5/sec + in: make(chan any), + out: make(chan any, 10), + done: make(chan struct{}), } - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) - defer at.Close() + go at.pipelineLoop() - // Send some elements - at.In() <- "first" - at.In() <- "second" - - // Close input channel + // Send one item + at.in <- "test" close(at.in) - // Verify output channel is eventually closed - timeout := time.After(500 * time.Millisecond) - var received []any - for { - select { - case element, ok := <-at.Out(): - if !ok { - // Channel closed - verify we got the elements - if len(received) < 2 { - t.Errorf("expected at least 2 elements before close, got %d", len(received)) - } - return - } - received = append(received, element) - case <-timeout: - t.Fatal("timeout waiting for output channel to close after input close") + // Should receive it (rate < 1.0 is clamped to 1.0) + select { + case item := <-at.out: + if item != "test" { + t.Errorf("Expected 'test', got %v", item) } + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for item") } -} -func TestAdaptiveThrottler_EmitShutdown(t *testing.T) { - config := AdaptiveThrottlerConfig{ - MaxMemoryPercent: 80.0, - MaxCPUPercent: 70.0, - MinThroughput: 1, - MaxThroughput: 1, // Very low throughput to trigger quota - SampleInterval: 50 * time.Millisecond, - BufferSize: 5, - AdaptationFactor: 0.2, - SmoothTransitions: false, - CPUUsageMode: CPUUsageModeHeuristic, - HysteresisBuffer: 5.0, - MaxRateChangeFactor: 0.5, + // Wait for channel to close + select { + case _, ok := <-at.out: + if ok { + t.Error("Output channel should be closed") + } + case <-time.After(100 * time.Millisecond): + // Channel should be closed by now } - mockMonitor := &mockResourceMonitor{ - stats: ResourceStats{ - MemoryUsedPercent: 30.0, - CPUUsagePercent: 20.0, - GoroutineCount: 5, - Timestamp: time.Now(), - }, - } + close(at.done) +} - at := newAdaptiveThrottlerWithMonitor(config, mockMonitor) +// TestAdaptiveThrottler_To_Shutdown tests To method with shutdown +func TestAdaptiveThrottler_To_Shutdown(t *testing.T) { + at := createThrottlerWithLongInterval(t) - // Exhaust quota - at.In() <- "first" - <-at.Out() // Consume first element + mockSink := newMockSinkWithChannelDrain() + sinkCh := mockSink.in - // Now quota is exhausted, send another element that will block - sent := make(chan struct{}) + // Start To in background + toDone := make(chan struct{}) go func() { - at.In() <- "blocked" - close(sent) + defer close(toDone) + at.To(mockSink) }() - // Give it time to block - time.Sleep(50 * time.Millisecond) + // Send some data + go func() { + for i := 0; i < 3; i++ { + at.In() <- i + } + close(at.In()) + }() - // Close the throttler while emit is blocked - at.Close() + // Wait a bit for data to start flowing + time.Sleep(100 * time.Millisecond) - // Wait for shutdown to complete - select { - case <-sent: - // Element was sent (may have been flushed or dropped) - case <-time.After(500 * time.Millisecond): - t.Error("timeout waiting for emit to handle shutdown") - } + // Close the throttler + at.close() - // Verify output channel is closed + // Wait for To to complete (it will finish when streamPortioned completes) select { - case _, ok := <-at.Out(): - if ok { - t.Error("output channel should be closed after shutdown") - } - default: - // Channel already closed + case <-toDone: + // Good, To completed + case <-time.After(2 * time.Second): + t.Error("To method did not complete within timeout") } -} -// mockFlow implements streams.Flow for testing -type mockFlow struct { - in chan any - out chan any -} + // Verify sink channel is closed (streamPortioned closes inlet.In()) + // Give it a moment to ensure the close has propagated + time.Sleep(100 * time.Millisecond) -func (m *mockFlow) Via(flow streams.Flow) streams.Flow { - go func() { - defer close(flow.In()) - for element := range m.out { - flow.In() <- element - } - }() - return flow + // Verify sink channel is closed + verifyChannelClosed(t, sinkCh, 50*time.Millisecond) } -func (m *mockFlow) To(sink streams.Sink) { - defer close(sink.In()) - for element := range m.out { - sink.In() <- element +// TestAdaptiveThrottler_StreamPortioned_Blocking tests streamPortioned with blocking inlet +func TestAdaptiveThrottler_StreamPortioned_Blocking(t *testing.T) { + mockMonitor := &MockMonitor{} + config := DefaultAdaptiveThrottlerConfig() + + at := &AdaptiveThrottler{ + config: *config, + monitor: mockMonitor, + currentRateBits: math.Float64bits(1000), + out: make(chan any), } - sink.AwaitCompletion() -} -func (m *mockFlow) In() chan<- any { - return m.in -} + // Unbuffered inlet to test blocking behavior + inletIn := make(chan any) + mockInlet := &mockInlet{in: inletIn} -func (m *mockFlow) Out() <-chan any { - return m.out -} + // Start streamPortioned in background + done := make(chan struct{}) + go func() { + defer close(done) + at.streamPortioned(mockInlet) + }() -// mockSink implements streams.Sink for testing -type mockSink struct { - in chan any -} + // Send data + go func() { + at.out <- "test1" + at.out <- "test2" + close(at.out) + }() -func (m *mockSink) In() chan<- any { - return m.in -} + // Read from inlet (unblocking the sender) + received := collectDataFromChannel(inletIn) -func (m *mockSink) AwaitCompletion() { - // Wait for input channel to be closed - for range m.in { - _ = struct{}{} // drain channel + // Wait for completion + select { + case <-done: + // Good + case <-time.After(1 * time.Second): + t.Fatal("Timeout waiting for streamPortioned to complete") + } + + // Verify inlet is closed + verifyChannelClosed(t, inletIn, 50*time.Millisecond) + + if len(received) != 2 { + t.Errorf("Expected 2 items, got %d", len(received)) } } diff --git a/flow/resource_monitor.go b/flow/resource_monitor.go index d8f972b..4a0b6ea 100644 --- a/flow/resource_monitor.go +++ b/flow/resource_monitor.go @@ -2,6 +2,7 @@ package flow import ( "fmt" + "log/slog" "math" "runtime" "sync" @@ -11,269 +12,364 @@ import ( "github.com/reugn/go-streams/internal/sysmonitor" ) -// CPUUsageMode defines the strategy for sampling CPU usage +// CPUUsageMode defines strategies for CPU usage monitoring. type CPUUsageMode int const ( - // CPUUsageModeHeuristic uses goroutine count as a simple CPU usage proxy + // CPUUsageModeHeuristic uses goroutine count as a lightweight CPU usage proxy. CPUUsageModeHeuristic CPUUsageMode = iota - // CPUUsageModeMeasured attempts to measure actual process CPU usage via gopsutil + // CPUUsageModeMeasured provides accurate CPU usage measurement via system calls. CPUUsageModeMeasured ) -// ResourceStats represents current system resource statistics +// ResourceStats holds current system resource utilization metrics. type ResourceStats struct { - MemoryUsedPercent float64 - CPUUsagePercent float64 - GoroutineCount int - Timestamp time.Time + MemoryUsedPercent float64 // Memory usage as a percentage (0-100). + CPUUsagePercent float64 // CPU usage as a percentage (0-100). + GoroutineCount int // Number of active goroutines. + Timestamp time.Time // Time when these stats were collected. } -// ResourceMonitor monitors system resources and provides current statistics +// resourceMonitor defines the interface for resource monitoring. +type resourceMonitor interface { + GetStats() ResourceStats + Close() +} + +// ResourceMonitor collects and provides system resource usage statistics. type ResourceMonitor struct { - // Configuration thresholds - sampleInterval time.Duration // How often to sample system resources - memoryThreshold float64 // Memory usage threshold (0-100 percentage) - cpuThreshold float64 // CPU usage threshold (0-100 percentage) - cpuMode CPUUsageMode // CPU usage measurement mode - - // Current resource statistics - stats atomic.Pointer[ResourceStats] - - // Resource sampling components - sampler sysmonitor.ProcessCPUSampler // CPU usage sampler - memStats runtime.MemStats // Reusable buffer for memory statistics - memoryReader func() (float64, error) // Custom memory reader for containerized deployments - - // Synchronization and lifecycle - closeOnce sync.Once // Ensures cleanup happens only once - done chan struct{} // Shutdown signal channel + sampleInterval time.Duration + mu sync.Mutex // Protects configuration changes. + cpuMode CPUUsageMode // Current CPU monitoring strategy. + memoryReader func() (float64, error) // Custom memory usage reader. + + // Runtime state + stats atomic.Pointer[ResourceStats] // Latest resource statistics. + sampler sysmonitor.ProcessCPUSampler // CPU usage sampler implementation. + updateIntervalCh chan time.Duration // Channel for dynamic interval updates. + done chan struct{} // Signals monitoring loop termination. + closeOnce sync.Once // Ensures clean shutdown. } -// NewResourceMonitor creates a new resource monitor. -// -// Panics if: -// -// - sampleInterval <= 0 -// -// - memoryThreshold < 0 or memoryThreshold > 100 -// -// - cpuThreshold < 0 or cpuThreshold > 100 -func NewResourceMonitor( +// newResourceMonitor creates a new resource monitor instance. +// This constructor is private and should only be called by the registry. +func newResourceMonitor( sampleInterval time.Duration, - memoryThreshold, cpuThreshold float64, cpuMode CPUUsageMode, memoryReader func() (float64, error), ) *ResourceMonitor { - if sampleInterval <= 0 { - // sampleInterval must be greater than 0 - panic(fmt.Sprintf("invalid sampleInterval: %v", sampleInterval)) - } - if memoryThreshold < 0 || memoryThreshold > 100 { - // memoryThreshold must be between 0 and 100 - panic(fmt.Sprintf("invalid memoryThreshold: %f, must be between 0 and 100", memoryThreshold)) - } - if cpuThreshold < 0 || cpuThreshold > 100 { - // cpuThreshold must be between 0 and 100 - panic(fmt.Sprintf("invalid cpuThreshold: %f, must be between 0 and 100", cpuThreshold)) - } - rm := &ResourceMonitor{ - sampleInterval: sampleInterval, - memoryThreshold: memoryThreshold, - cpuThreshold: cpuThreshold, - cpuMode: cpuMode, - memoryReader: memoryReader, - done: make(chan struct{}), + sampleInterval: sampleInterval, + cpuMode: cpuMode, + memoryReader: memoryReader, + updateIntervalCh: make(chan time.Duration, 1), + done: make(chan struct{}), } + // Initialize with empty stats + rm.stats.Store(&ResourceStats{ + Timestamp: time.Now(), + }) + rm.initSampler() - // start periodically collecting resource statistics go rm.monitor() - return rm } -// initSampler initializes the appropriate CPU sampler based on mode and platform support -func (rm *ResourceMonitor) initSampler() { - switch rm.cpuMode { +// GetStats returns the most recent resource usage statistics. +// The returned data is thread-safe and represents a consistent snapshot. +func (rm *ResourceMonitor) GetStats() ResourceStats { + val := rm.stats.Load() + if val == nil { + return ResourceStats{} + } + return *val +} + +// GetMode returns the current CPU monitoring strategy. +func (rm *ResourceMonitor) GetMode() CPUUsageMode { + rm.mu.Lock() + defer rm.mu.Unlock() + return rm.cpuMode +} + +// SetMode changes the CPU monitoring strategy if possible. +// Allows switching between heuristic and measured modes. +func (rm *ResourceMonitor) SetMode(newMode CPUUsageMode) { + rm.mu.Lock() + defer rm.mu.Unlock() + + // No change needed + if newMode == rm.cpuMode { + return + } + + switch newMode { case CPUUsageModeMeasured: - // Try native sampler first, fallback to heuristic + // Try to switch to measured mode if sampler, err := sysmonitor.NewProcessSampler(); err == nil { rm.sampler = sampler + rm.cpuMode = CPUUsageModeMeasured } else { - rm.sampler = sysmonitor.NewGoroutineHeuristicSampler() - rm.cpuMode = CPUUsageModeHeuristic + slog.Error("failed to switch to measured mode", "error", err) } - default: // CPUUsageModeHeuristic + case CPUUsageModeHeuristic: rm.sampler = sysmonitor.NewGoroutineHeuristicSampler() + rm.cpuMode = CPUUsageModeHeuristic } - - rm.stats.Store(rm.collectStats()) } -// GetStats returns the current resource statistics -func (rm *ResourceMonitor) GetStats() ResourceStats { - stats := rm.stats.Load() - if stats == nil { - return ResourceStats{} +// initSampler initializes the appropriate CPU usage sampler. +// Uses measured mode by default if available +func (rm *ResourceMonitor) initSampler() { + if sampler, err := sysmonitor.NewProcessSampler(); err == nil { + rm.sampler = sampler + rm.cpuMode = CPUUsageModeMeasured + } else { + // Fallback to heuristic + rm.sampler = sysmonitor.NewGoroutineHeuristicSampler() + rm.cpuMode = CPUUsageModeHeuristic } - return *stats } -// IsResourceConstrained returns true if resources are above thresholds -func (rm *ResourceMonitor) IsResourceConstrained() bool { - stats := rm.GetStats() - return stats.MemoryUsedPercent > rm.memoryThreshold || - stats.CPUUsagePercent > rm.cpuThreshold +// monitor runs the continuous resource sampling loop. +// Handles dynamic interval changes and graceful shutdown. +func (rm *ResourceMonitor) monitor() { + ticker := time.NewTicker(rm.sampleInterval) + defer ticker.Stop() + + for { + select { + case <-rm.done: + return + case d := <-rm.updateIntervalCh: + rm.mu.Lock() + if d != rm.sampleInterval { + rm.sampleInterval = d + ticker.Stop() + ticker = time.NewTicker(rm.sampleInterval) + rm.sample() + } + rm.mu.Unlock() + case <-ticker.C: + rm.sample() + } + } } -// collectStats collects current system resource statistics -func (rm *ResourceMonitor) collectStats() *ResourceStats { - sysStats, hasSystemStats := rm.tryGetSystemMemory() +// sample collects current CPU, memory, and goroutine statistics. +// Updates the atomic stats pointer with the latest measurements. +func (rm *ResourceMonitor) sample() { + stats := &ResourceStats{ + Timestamp: time.Now(), + GoroutineCount: runtime.NumGoroutine(), + } - var procStats *runtime.MemStats - if !hasSystemStats { - // Reuse existing memStats buffer instead of allocating new one - runtime.ReadMemStats(&rm.memStats) - procStats = &rm.memStats + // Memory Usage + // Check if a custom memory reader is provided + if rm.memoryReader != nil { + if mem, err := rm.memoryReader(); err == nil { + stats.MemoryUsedPercent = mem + } + // If not, use system memory stats + } else if memStats, err := sysmonitor.GetSystemMemory(); err == nil && memStats.Total > 0 { + used := memStats.Total - memStats.Available + stats.MemoryUsedPercent = float64(used) / float64(memStats.Total) * 100 + } else { + // Fallback to runtime memory stats + var m runtime.MemStats + runtime.ReadMemStats(&m) + if m.Sys > 0 { + stats.MemoryUsedPercent = float64(m.Alloc) / float64(m.Sys) * 100 + } } - // Calculate memory usage percentage - memoryPercent := rm.memoryUsagePercent(hasSystemStats, sysStats, procStats) + // CPU Usage + cpu := rm.sampler.Sample(rm.sampleInterval) + stats.CPUUsagePercent = cpu - // Get goroutine count - goroutineCount := runtime.NumGoroutine() + rm.stats.Store(stats) +} - // Sample CPU usage and validate - cpuPercent := rm.sampler.Sample(rm.sampleInterval) - cpuPercent = validatePercent(cpuPercent) +// getSampleInterval thread-safe getter of the current sample interval. +func (rm *ResourceMonitor) getSampleInterval() time.Duration { + rm.mu.Lock() + defer rm.mu.Unlock() + return rm.sampleInterval +} - stats := &ResourceStats{ - MemoryUsedPercent: memoryPercent, - CPUUsagePercent: cpuPercent, - GoroutineCount: goroutineCount, - Timestamp: time.Now(), +// setInterval updates the sampling frequency dynamically. +func (rm *ResourceMonitor) setInterval(d time.Duration) { + select { + case rm.updateIntervalCh <- d: + case <-rm.done: } +} - // Validate the complete stats object - validateResourceStats(stats) +// stop terminates the monitoring goroutine gracefully. +func (rm *ResourceMonitor) stop() { + rm.closeOnce.Do(func() { + close(rm.done) + }) +} - return stats +// globalMonitorRegistry manages the singleton ResourceMonitor instance. +// Provides shared access with reference counting and automatic cleanup. +var globalMonitorRegistry = &monitorRegistry{ + intervalRefs: make(map[time.Duration]int), } -func (rm *ResourceMonitor) tryGetSystemMemory() (sysmonitor.SystemMemory, bool) { - stats, err := sysmonitor.GetSystemMemory() - if err != nil || stats.Total == 0 { - return sysmonitor.SystemMemory{}, false - } - return stats, true +// monitorIdleTimeout defines how long to wait before stopping an unused monitor. +// Prevents unnecessary recreation of the monitor instance when new throttler is added. +const monitorIdleTimeout = 5 * time.Second + +// monitorRegistry coordinates shared access to a ResourceMonitor instance. +// Manages multiple consumers with different sampling requirements efficiently. +type monitorRegistry struct { + mu sync.Mutex // Protects registry state. + instance *ResourceMonitor // The shared monitor instance. + intervalRefs map[time.Duration]int // Reference counts per sampling interval. + currentMin time.Duration // Current minimum sampling interval. + stopTimer *time.Timer // Timer for delayed cleanup. } -func (rm *ResourceMonitor) memoryUsagePercent( - hasSystemStats bool, - sysStats sysmonitor.SystemMemory, - procStats *runtime.MemStats, -) float64 { - // Use custom memory reader if provided (for containerized deployments) - if rm.memoryReader != nil { - if percent, err := rm.memoryReader(); err == nil { - return clampPercent(percent) - } - // Fall back to system memory if custom reader fails +// Acquire obtains a handle to the shared resource monitor. +// Manages reference counting and may create or reconfigure the monitor as needed. +func (r *monitorRegistry) Acquire( + requestedInterval time.Duration, + cpuMode CPUUsageMode, + memReader func() (float64, error), +) resourceMonitor { + r.mu.Lock() + defer r.mu.Unlock() + + // Validate the requested interval + if requestedInterval <= 0 { + panic(fmt.Sprintf("resource monitor: invalid interval %v, must be positive", requestedInterval)) } - if hasSystemStats { - available := sysStats.Available - if available > sysStats.Total { - available = sysStats.Total - } + // Cancel pending stop if we are resurrecting within the grace period + if r.stopTimer != nil { + r.stopTimer.Stop() + r.stopTimer = nil + } - // avoid division by zero - if sysStats.Total == 0 { - return 0 - } + // Register the requested interval + r.intervalRefs[requestedInterval]++ - used := sysStats.Total - available - percent := float64(used) / float64(sysStats.Total) * 100 + // Calculate global minimum + requiredMin := r.calculateMinInterval() - return clampPercent(percent) - } + if r.instance == nil { + r.instance = newResourceMonitor(requiredMin, cpuMode, memReader) + r.currentMin = requiredMin + } else { + // Check if we need to upgrade the existing instance + if cpuMode > r.instance.GetMode() { + r.instance.SetMode(cpuMode) + } - if procStats == nil || procStats.Sys == 0 { - return 0 + // Adjust interval if this new user needs it faster + if requiredMin != r.currentMin { + r.instance.setInterval(requiredMin) + r.currentMin = requiredMin + } } - percent := float64(procStats.Alloc) / float64(procStats.Sys) * 100 - return clampPercent(percent) -} - -// validateResourceStats sanitizes ResourceStats to ensure valid values -func validateResourceStats(stats *ResourceStats) { - if stats == nil { - panic("ResourceStats cannot be nil") + return &sharedMonitorHandle{ + monitor: r.instance, + interval: requestedInterval, + registry: r, } +} - // Validate memory percent - stats.MemoryUsedPercent = validatePercent(stats.MemoryUsedPercent) +// release decrements the reference count for a sampling interval. +// Initiates cleanup when no consumers remain. +func (r *monitorRegistry) release(interval time.Duration) { + r.mu.Lock() + defer r.mu.Unlock() - // Validate CPU percent - stats.CPUUsagePercent = validatePercent(stats.CPUUsagePercent) + r.intervalRefs[interval]-- + if r.intervalRefs[interval] <= 0 { + delete(r.intervalRefs, interval) + } - // Validate goroutine count - if stats.GoroutineCount < 0 { - stats.GoroutineCount = 0 + if len(r.intervalRefs) == 0 { + // Start grace period timer + if r.stopTimer == nil { + r.stopTimer = time.AfterFunc(monitorIdleTimeout, func() { + r.cleanup() + }) + } + return } - // Validate timestamp (should be recent) - if stats.Timestamp.IsZero() { - stats.Timestamp = time.Now() - } else if time.Since(stats.Timestamp) > time.Minute { - // Stats are too old, refresh timestamp - stats.Timestamp = time.Now() + // If we still have users, check if we can slow down (release pressure) + newMin := r.calculateMinInterval() + if newMin != r.currentMin && r.instance != nil { + r.instance.setInterval(newMin) + r.currentMin = newMin } } -// monitor periodically collects resource statistics -func (rm *ResourceMonitor) monitor() { - ticker := time.NewTicker(rm.sampleInterval) - defer ticker.Stop() +// cleanup stops and destroys the monitor instance. +// Called after the idle timeout when no consumers remain. +func (r *monitorRegistry) cleanup() { + r.mu.Lock() + defer r.mu.Unlock() - for { - select { - case <-ticker.C: - newStats := rm.collectStats() - rm.stats.Store(newStats) - case <-rm.done: - return - } + // Double check we are still empty + if len(r.intervalRefs) == 0 && r.instance != nil { + r.instance.stop() + r.instance = nil } + r.stopTimer = nil } -// Close stops the resource monitor -func (rm *ResourceMonitor) Close() { - rm.closeOnce.Do(func() { - close(rm.done) - }) -} - -// clampPercent clamps a percentage value between 0 and 100. -func clampPercent(percent float64) float64 { - if percent < 0 { - return 0 +// calculateMinInterval finds the fastest sampling rate required by any consumer. +// Returns a default interval when no consumers are registered. +func (r *monitorRegistry) calculateMinInterval() time.Duration { + if len(r.intervalRefs) == 0 { + return time.Second } - if percent > 100 { - return 100 + minInterval := time.Duration(math.MaxInt64) + for d := range r.intervalRefs { + if d < minInterval { + minInterval = d + } } - return percent + return minInterval } -// validatePercent validates and normalizes a percentage value. -// It checks for NaN or Inf values and clamps the result between 0 and 100. -func validatePercent(percent float64) float64 { - if math.IsNaN(percent) || math.IsInf(percent, 0) { +// getInstanceSampleInterval returns the current instance's sample interval in a thread-safe manner. +// Returns 0 if no instance exists. +func (r *monitorRegistry) getInstanceSampleInterval() time.Duration { + r.mu.Lock() + defer r.mu.Unlock() + if r.instance == nil { return 0 } - return clampPercent(percent) + return r.instance.getSampleInterval() +} + +// sharedMonitorHandle provides consumers with access to the shared monitor. +// Ensures proper reference counting and cleanup when no longer needed. +type sharedMonitorHandle struct { + monitor *ResourceMonitor // Reference to the shared monitor. + interval time.Duration // Sampling interval requested by this consumer. + registry *monitorRegistry // Registry managing this handle. + once sync.Once // Ensures Close is called only once. +} + +// GetStats returns resource statistics from the shared monitor. +func (h *sharedMonitorHandle) GetStats() ResourceStats { + return h.monitor.GetStats() +} + +// Close releases this consumer's reference to the shared monitor. +func (h *sharedMonitorHandle) Close() { + h.once.Do(func() { + h.registry.release(h.interval) + }) } diff --git a/flow/resource_monitor_test.go b/flow/resource_monitor_test.go index fa507e3..4038e68 100644 --- a/flow/resource_monitor_test.go +++ b/flow/resource_monitor_test.go @@ -3,763 +3,961 @@ package flow import ( "fmt" "math" - "runtime" + "sync" "testing" "time" "github.com/reugn/go-streams/internal/sysmonitor" ) -func TestNewResourceMonitor_InvalidSampleInterval(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("expected panic for invalid sample interval") - } - }() +const ( + testSampleInterval = 10 * time.Millisecond + testStopTimeout = 50 * time.Millisecond + testStatsUpdateMargin = 20 * time.Millisecond +) - NewResourceMonitor(0, 80.0, 70.0, CPUUsageModeHeuristic, nil) +type mockCPUSampler struct { + val float64 } -func TestNewResourceMonitor_InvalidMemoryThreshold(t *testing.T) { - t.Run("negative memory threshold", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("expected panic for negative memory threshold") - } - }() - - NewResourceMonitor(100*time.Millisecond, -1.0, 70.0, CPUUsageModeHeuristic, nil) - }) - - t.Run("memory threshold > 100", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("expected panic for memory threshold > 100") - } - }() +func (m *mockCPUSampler) Sample(_ time.Duration) float64 { + return m.val +} - NewResourceMonitor(100*time.Millisecond, 150.0, 70.0, CPUUsageModeHeuristic, nil) - }) +func (m *mockCPUSampler) Reset() { + // No-op for mock } -func TestNewResourceMonitor_InvalidCPUThreshold(t *testing.T) { - t.Run("negative CPU threshold", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("expected panic for negative CPU threshold") - } - }() +func (m *mockCPUSampler) IsInitialized() bool { + return true // Mock is always initialized +} - NewResourceMonitor(100*time.Millisecond, 80.0, -1.0, CPUUsageModeHeuristic, nil) - }) +type assertError string - t.Run("CPU threshold > 100", func(t *testing.T) { - defer func() { - if r := recover(); r == nil { - t.Fatal("expected panic for CPU threshold > 100") - } - }() +func (e assertError) Error() string { return string(e) } - NewResourceMonitor(100*time.Millisecond, 80.0, 150.0, CPUUsageModeHeuristic, nil) - }) +func resetRegistry() { + globalMonitorRegistry = &monitorRegistry{ + intervalRefs: make(map[time.Duration]int), + } } -func TestResourceMonitor_GetStats(t *testing.T) { - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - defer rm.Close() +func setupTest(t *testing.T) { + t.Helper() + resetRegistry() + t.Cleanup(resetRegistry) +} - timeout := time.After(500 * time.Millisecond) - var stats ResourceStats - statsCollected := false - for !statsCollected { - select { - case <-timeout: - t.Fatal("timeout waiting for stats collection") - default: - stats = rm.GetStats() - if !stats.Timestamp.IsZero() { - statsCollected = true - break - } - time.Sleep(10 * time.Millisecond) // Brief pause before polling again - } +// assertValidStats checks that resource stats contain reasonable values +func assertValidStats(t *testing.T, stats ResourceStats) { + t.Helper() + if stats.Timestamp.IsZero() { + t.Error("Timestamp should not be zero") } - - if stats.MemoryUsedPercent < 0.0 || stats.MemoryUsedPercent > 100.0 { - t.Errorf("memory percent should be between 0 and 100, got %v", stats.MemoryUsedPercent) + if stats.GoroutineCount < 0 { + t.Errorf("GoroutineCount should not be negative, got %d", stats.GoroutineCount) } - if stats.CPUUsagePercent < 0.0 || stats.CPUUsagePercent > 100.0 { - t.Errorf("CPU percent should be between 0 and 100, got %v", stats.CPUUsagePercent) + if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 200 { // Allow >100% for some systems + t.Errorf("MemoryUsedPercent should be between 0-200, got %f", stats.MemoryUsedPercent) } - if stats.GoroutineCount <= 0 { - t.Errorf("goroutine count should be > 0, got %d", stats.GoroutineCount) + if stats.CPUUsagePercent < 0 { + t.Errorf("CPUUsagePercent should not be negative, got %f", stats.CPUUsagePercent) } } -func TestResourceMonitor_IsResourceConstrained(t *testing.T) { - tests := []struct { - name string - memoryThreshold float64 - cpuThreshold float64 - memoryPercent float64 - cpuPercent float64 - expectedConstrained bool - }{ - { - name: "not constrained", - memoryThreshold: 80.0, - cpuThreshold: 70.0, - memoryPercent: 50.0, - cpuPercent: 40.0, - expectedConstrained: false, - }, - { - name: "memory constrained", - memoryThreshold: 80.0, - cpuThreshold: 70.0, - memoryPercent: 85.0, - cpuPercent: 40.0, - expectedConstrained: true, - }, - { - name: "CPU constrained", - memoryThreshold: 80.0, - cpuThreshold: 70.0, - memoryPercent: 50.0, - cpuPercent: 75.0, - expectedConstrained: true, - }, - { - name: "both constrained", - memoryThreshold: 80.0, - cpuThreshold: 70.0, - memoryPercent: 85.0, - cpuPercent: 75.0, - expectedConstrained: true, - }, - { - name: "at threshold not constrained", - memoryThreshold: 80.0, - cpuThreshold: 70.0, - memoryPercent: 80.0, - cpuPercent: 70.0, - expectedConstrained: false, - }, - } +// TestResourceMonitor_Initialization tests monitor creation with different CPU modes +func TestResourceMonitor_Initialization(t *testing.T) { + setupTest(t) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rm := NewResourceMonitor(100*time.Millisecond, tt.memoryThreshold, tt.cpuThreshold, CPUUsageModeHeuristic, nil) - defer rm.Close() - - // Manually set stats for testing - testStats := &ResourceStats{ - MemoryUsedPercent: tt.memoryPercent, - CPUUsagePercent: tt.cpuPercent, - GoroutineCount: 10, - Timestamp: time.Now(), - } - rm.stats.Store(testStats) + // Test with Heuristic preference - should use best available (measured if possible) + rm := newResourceMonitor(time.Second, CPUUsageModeHeuristic, nil) + if rm.sampler == nil { + t.Error("Expected sampler to be initialized, got nil") + } + // Should use measured mode if available, regardless of preference + if rm.cpuMode != CPUUsageModeMeasured && rm.cpuMode != CPUUsageModeHeuristic { + t.Errorf("Unexpected CPU mode: %v", rm.cpuMode) + } + rm.stop() - if rm.IsResourceConstrained() != tt.expectedConstrained { - t.Errorf("expected constrained %v, got %v", tt.expectedConstrained, rm.IsResourceConstrained()) - } - }) + // Test with Measured preference - should use measured if available + rm2 := newResourceMonitor(time.Second, CPUUsageModeMeasured, nil) + if rm2.sampler == nil { + t.Error("Expected sampler to be initialized, got nil") + } + // Should use measured mode if available + if rm2.cpuMode != CPUUsageModeMeasured && rm2.cpuMode != CPUUsageModeHeuristic { + t.Errorf("Unexpected CPU mode: %v", rm2.cpuMode) } + rm2.stop() } -func TestResourceMonitor_CPUUsageModeHeuristic(t *testing.T) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - defer rm.Close() +// TestResourceMonitor_Sample_MemoryReader tests memory sampling with custom reader +func TestResourceMonitor_Sample_MemoryReader(t *testing.T) { + setupTest(t) - // Verify mode is set correctly - if rm.cpuMode != CPUUsageModeHeuristic { - t.Errorf("expected cpuMode %v, got %v", CPUUsageModeHeuristic, rm.cpuMode) + expectedMem := 42.5 + mockMemReader := func() (float64, error) { + return expectedMem, nil } - // Verify sampler is initialized and functional - if rm.sampler == nil { - t.Fatal("sampler should not be nil") - } + // Create monitor with long interval to prevent auto-sampling during test + rm := newResourceMonitor(time.Hour, CPUUsageModeHeuristic, mockMemReader) + defer rm.stop() - // Test that sampling produces reasonable values - percent := rm.sampler.Sample(100 * time.Millisecond) - if percent < 0.0 { - t.Errorf("CPU percent should not be negative, got %v", percent) - } -} + // Get initial stats before manual sample + initialStats := rm.GetStats() -func TestResourceMonitor_CPUUsageModeMeasured(t *testing.T) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeMeasured, nil) - defer rm.Close() + // Trigger sample manually + rm.sample() - // Should use measured mode - if rm.cpuMode != CPUUsageModeMeasured && rm.cpuMode != CPUUsageModeHeuristic { - t.Errorf("expected cpuMode Measured or Heuristic (fallback), got %v", rm.cpuMode) - } + stats := rm.GetStats() - // Verify sampler is initialized and functional - if rm.sampler == nil { - t.Fatal("sampler should not be nil") + // Verify Memory + if stats.MemoryUsedPercent != expectedMem { + t.Errorf("Expected memory %f, got %f", expectedMem, stats.MemoryUsedPercent) } - // Test that sampling produces reasonable values (normalized to 0-100%) - percent := rm.sampler.Sample(100 * time.Millisecond) - if percent < 0.0 || percent > 100.0 { - t.Errorf("CPU percent should be between 0 and 100, got %v", percent) + // Verify stats are valid and updated + assertValidStats(t, stats) + if !stats.Timestamp.After(initialStats.Timestamp) { + t.Error("Timestamp should be updated after sampling") } } -func TestResourceMonitor_Close(t *testing.T) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) +// TestResourceMonitor_Sample_CPUMock tests CPU sampling with mock sampler +func TestResourceMonitor_Sample_CPUMock(t *testing.T) { + setupTest(t) - rm.Close() - rm.Close() + expectedCPU := 12.34 + mockSampler := &mockCPUSampler{val: expectedCPU} - // Should be able to close again without panic - select { - case <-rm.done: - // Expected - channel should be closed - default: - t.Error("done channel should be closed after Close()") - } -} + // Initialize with Measured mode + rm := newResourceMonitor(time.Hour, CPUUsageModeMeasured, nil) + defer rm.stop() -// TestResourceMonitor_MonitorLoop is an integration test that verifies -// the monitoring loop updates statistics over time -func TestResourceMonitor_MonitorLoop(t *testing.T) { - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - defer rm.Close() + // Inject mock sampler manually (overwriting the real one) + rm.sampler = mockSampler - initialStats := waitForStats(t, rm, 500*time.Millisecond, func(stats ResourceStats) bool { - return !stats.Timestamp.IsZero() - }) + rm.sample() + stats := rm.GetStats() - waitForStats(t, rm, 500*time.Millisecond, func(stats ResourceStats) bool { - return stats.Timestamp.After(initialStats.Timestamp) - }) + if stats.CPUUsagePercent != expectedCPU { + t.Errorf("Expected CPU %f, got %f", expectedCPU, stats.CPUUsagePercent) + } } -// waitForStats polls GetStats until the condition function returns true or timeout is reached -func waitForStats( - t *testing.T, - rm *ResourceMonitor, - timeout time.Duration, - condition func(ResourceStats) bool, -) ResourceStats { - t.Helper() +// TestResourceMonitor_Sample_HeuristicMode tests heuristic CPU sampling +func TestResourceMonitor_Sample_HeuristicMode(t *testing.T) { + setupTest(t) - deadline := time.Now().Add(timeout) - ticker := time.NewTicker(10 * time.Millisecond) - defer ticker.Stop() + rm := newResourceMonitor(time.Hour, CPUUsageModeHeuristic, nil) + defer rm.stop() - for { - stats := rm.GetStats() - if condition(stats) { - return stats - } + // Force heuristic mode by setting the sampler directly + rm.sampler = sysmonitor.NewGoroutineHeuristicSampler() + rm.cpuMode = CPUUsageModeHeuristic - if time.Now().After(deadline) { - t.Fatalf("timeout waiting for stats condition (timeout: %v)", timeout) - } + rm.sample() + stats := rm.GetStats() - <-ticker.C + // Heuristic mode should use the GoroutineHeuristicSampler + // The sampler returns a sophisticated calculation based on goroutine count + if stats.CPUUsagePercent <= 0 { + t.Errorf("Expected positive CPU usage in heuristic mode, got %f", stats.CPUUsagePercent) + } + if stats.CPUUsagePercent > 100 { + t.Errorf("Expected CPU usage <= 100%%, got %f", stats.CPUUsagePercent) } } -func TestResourceMonitor_UsesSystemMemoryStats(t *testing.T) { - restore := sysmonitor.SetMemoryReader(func() (sysmonitor.SystemMemory, error) { - return sysmonitor.SystemMemory{ - Total: 100 * 1024 * 1024, - Available: 25 * 1024 * 1024, - }, nil - }) - defer restore() +// TestMonitorRegistry_Acquire tests registry acquire logic with different intervals +func TestMonitorRegistry_Acquire(t *testing.T) { + setupTest(t) - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - defer rm.Close() + // 1. First Acquire + h1 := globalMonitorRegistry.Acquire(2*time.Second, CPUUsageModeHeuristic, nil) + defer h1.Close() - stats := rm.collectStats() - if diff := math.Abs(stats.MemoryUsedPercent - 75.0); diff > 0.01 { - t.Fatalf("expected memory percent ~75, diff %v", diff) + if globalMonitorRegistry.instance == nil { + t.Fatal("Registry instance should not be nil after acquire") + } + if globalMonitorRegistry.currentMin != 2*time.Second { + t.Errorf("Expected currentMin 2s, got %v", globalMonitorRegistry.currentMin) + } + if count := globalMonitorRegistry.intervalRefs[2*time.Second]; count != 1 { + t.Errorf("Expected ref count 1, got %d", count) } -} -// TestResourceStats_MemoryCalculation is an integration test that verifies -// memory statistics calculation with real system memory -func TestResourceStats_MemoryCalculation(t *testing.T) { - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - defer rm.Close() + // 2. Second Acquire (Same interval) - should just increment ref + h2 := globalMonitorRegistry.Acquire(2*time.Second, CPUUsageModeHeuristic, nil) + defer h2.Close() + + if count := globalMonitorRegistry.intervalRefs[2*time.Second]; count != 2 { + t.Errorf("Expected ref count 2, got %d", count) + } - timeout := time.After(500 * time.Millisecond) - statsReady := false - for !statsReady { + // 3. Third Acquire (Faster interval) - should update monitor + h3 := globalMonitorRegistry.Acquire(1*time.Second, CPUUsageModeHeuristic, nil) + defer h3.Close() + + if globalMonitorRegistry.currentMin != 1*time.Second { + t.Errorf("Expected currentMin to update to 1s, got %v", globalMonitorRegistry.currentMin) + } + + // Wait for the monitor goroutine to process the interval update (buffered channel makes this async) + timeout := time.After(100 * time.Millisecond) + for { select { case <-timeout: - t.Fatal("timeout waiting for stats") + t.Fatalf("Timeout waiting for instance interval update. Expected 1s, got %v", + globalMonitorRegistry.getInstanceSampleInterval()) default: - if !rm.GetStats().Timestamp.IsZero() { - statsReady = true - break + if globalMonitorRegistry.getInstanceSampleInterval() == 1*time.Second { + goto intervalUpdated } - time.Sleep(10 * time.Millisecond) + time.Sleep(1 * time.Millisecond) } } +intervalUpdated: - runtime.GC() - time.Sleep(20 * time.Millisecond) + // 4. Fourth Acquire (Slower interval) - should NOT update monitor + h4 := globalMonitorRegistry.Acquire(5*time.Second, CPUUsageModeHeuristic, nil) + defer h4.Close() - stats := rm.GetStats() + if globalMonitorRegistry.currentMin != 1*time.Second { + t.Errorf("Expected currentMin to remain 1s, got %v", globalMonitorRegistry.currentMin) + } +} + +// TestMonitorRegistry_Release_Logic tests registry release and cleanup logic +func TestMonitorRegistry_Release_Logic(t *testing.T) { + setupTest(t) - if stats.MemoryUsedPercent < 0.0 || stats.MemoryUsedPercent > 100.0 { - t.Errorf("memory percent should be between 0 and 100, got %v", stats.MemoryUsedPercent) + // Acquire 1s and 5s + hFast := globalMonitorRegistry.Acquire(1*time.Second, CPUUsageModeHeuristic, nil) + hSlow := globalMonitorRegistry.Acquire(5*time.Second, CPUUsageModeHeuristic, nil) + + // Initial State + if globalMonitorRegistry.currentMin != 1*time.Second { + t.Fatalf("Setup failed: expected 1s min") } - // Goroutine count should be at least 2 (main + monitor goroutine) - if stats.GoroutineCount < 2 { - t.Errorf("goroutine count should be at least 2, got %d", stats.GoroutineCount) + // Release Fast Handle + hFast.Close() + + // Registry should check if it can slow down + // Now only 5s is left, so min should become 5s + if globalMonitorRegistry.currentMin != 5*time.Second { + t.Errorf("Expected currentMin to relax to 5s, got %v", globalMonitorRegistry.currentMin) + } + + // Release Slow Handle + hSlow.Close() + + // Check that ref count is zero + if len(globalMonitorRegistry.intervalRefs) != 0 { + t.Errorf("Expected empty refs, got %v", globalMonitorRegistry.intervalRefs) + } + + // Verify cleanup timer is started + if globalMonitorRegistry.stopTimer == nil { + t.Error("Expected stopTimer to be set after full release") + } + + // Force cleanup manually to verify cleanup logic works (instead of waiting 5s) + globalMonitorRegistry.cleanup() + if globalMonitorRegistry.instance != nil { + t.Error("Expected instance to be nil after cleanup") } } -func TestResourceMonitor_GetStatsNilStats(t *testing.T) { - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - defer rm.Close() +// TestMonitorRegistry_Resurrect tests registry resurrection after cleanup timer starts +func TestMonitorRegistry_Resurrect(t *testing.T) { + setupTest(t) - // Manually set stats to nil - rm.stats.Store(nil) + // Acquire and Release to trigger timer + h1 := globalMonitorRegistry.Acquire(time.Second, CPUUsageModeHeuristic, nil) + h1.Close() - stats := rm.GetStats() - if stats.Timestamp.IsZero() { - // Should return empty ResourceStats when stats is nil - if stats.MemoryUsedPercent != 0 || stats.CPUUsagePercent != 0 || stats.GoroutineCount != 0 { - t.Errorf("expected empty stats when nil, got %+v", stats) - } + if globalMonitorRegistry.stopTimer == nil { + t.Fatal("Timer should be running") + } + + // Acquire again BEFORE timer fires + h2 := globalMonitorRegistry.Acquire(time.Second, CPUUsageModeHeuristic, nil) + defer h2.Close() + + if globalMonitorRegistry.stopTimer != nil { + t.Error("Timer should be stopped/nil after resurrection") + } + if globalMonitorRegistry.instance == nil { + t.Error("Instance should still be alive") } } -func TestResourceMonitor_MemoryUsagePercentEdgeCases(t *testing.T) { - t.Run("available > total", func(t *testing.T) { - restore := sysmonitor.SetMemoryReader(func() (sysmonitor.SystemMemory, error) { - return sysmonitor.SystemMemory{ - Total: 100 * 1024 * 1024, - Available: 150 * 1024 * 1024, // Available > Total (invalid but should handle) - }, nil - }) - defer restore() +// TestResourceMonitor_SetInterval_Dynamic tests dynamic interval changes +func TestResourceMonitor_SetInterval_Dynamic(t *testing.T) { + setupTest(t) - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - defer rm.Close() + rm := newResourceMonitor(10*time.Minute, CPUUsageModeHeuristic, nil) + defer rm.stop() - stats := rm.collectStats() - // Should clamp available to total, so used = 0, percent = 0 - if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { - t.Errorf("memory percent should be valid, got %f", stats.MemoryUsedPercent) - } - }) + // Since we can't easily hook into the loop, we observe the effect. + // We change interval to something very short, which should trigger 'sample()' - t.Run("total == 0", func(t *testing.T) { - restore := sysmonitor.SetMemoryReader(func() (sysmonitor.SystemMemory, error) { - return sysmonitor.SystemMemory{ - Total: 0, - Available: 0, - }, nil - }) - defer restore() + // Current state + initialStats := rm.GetStats() + + // Change interval + newDuration := time.Millisecond + rm.setInterval(newDuration) - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - defer rm.Close() + // Wait for update + // The loop: case d := <-rm.updateIntervalCh -> sets rm.sampleInterval -> calls rm.sample() - stats := rm.collectStats() - // When total is 0, tryGetSystemMemory() returns hasSystemStats=false - // This causes collectStats() to fall back to procStats (runtime.MemStats) - if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { - t.Errorf("memory percent should be valid when total is 0 (falls back to procStats), got %f", stats.MemoryUsedPercent) + // We poll briefly for the change + deadline := time.Now().Add(1 * time.Second) + updated := false + for time.Now().Before(deadline) { + if rm.getSampleInterval() == newDuration { + updated = true + break } - }) + time.Sleep(10 * time.Millisecond) + } - t.Run("procStats fallback", func(t *testing.T) { - // Set memory reader to return error to trigger procStats fallback - restore := sysmonitor.SetMemoryReader(func() (sysmonitor.SystemMemory, error) { - return sysmonitor.SystemMemory{}, fmt.Errorf("memory read failed") - }) - defer restore() + if !updated { + t.Error("Failed to update interval dynamically") + } - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - defer rm.Close() + // Ensure stats timestamp updated (proof that sample() was called on switch) + time.Sleep(time.Millisecond) + currentStats := rm.GetStats() + if !currentStats.Timestamp.After(initialStats.Timestamp) { + t.Error("Expected immediate sample on interval switch") + } +} - stats := rm.collectStats() - // Should use procStats fallback - if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { - t.Errorf("memory percent should be valid with procStats fallback, got %f", stats.MemoryUsedPercent) - } - }) +// TestSharedMonitorHandle tests handle functionality and idempotent close +func TestSharedMonitorHandle(t *testing.T) { + setupTest(t) - t.Run("procStats nil or Sys == 0", func(t *testing.T) { - // This is hard to test directly as it requires system memory to fail - // and procStats to be nil or have Sys == 0, which is unlikely in practice - // but the code path exists for safety - restore := sysmonitor.SetMemoryReader(func() (sysmonitor.SystemMemory, error) { - return sysmonitor.SystemMemory{}, fmt.Errorf("memory read failed") - }) - defer restore() + h := globalMonitorRegistry.Acquire(time.Second, CPUUsageModeHeuristic, nil) - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - defer rm.Close() + // Test GetStats delegates correctly + stats := h.GetStats() + if stats.Timestamp.IsZero() { + t.Error("Handle returned zero stats") + } - // Force hasSystemStats to false by making GetSystemMemory fail - stats := rm.collectStats() - // Should handle gracefully - if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { - t.Errorf("memory percent should be valid, got %f", stats.MemoryUsedPercent) - } - }) + // Test Idempotent Close + h.Close() + // Access internal registry to verify release happened + if len(globalMonitorRegistry.intervalRefs) != 0 { + t.Error("Registry not empty after close") + } + + // Second close should not panic and not change refs (already 0) + func() { + defer func() { + if r := recover(); r != nil { + t.Errorf("Double close panicked: %v", r) + } + }() + h.Close() + }() } -func TestResourceMonitor_CustomMemoryReaderError(t *testing.T) { - errorCount := 0 - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, func() (float64, error) { - errorCount++ - return 0, fmt.Errorf("custom reader error") // Return error to trigger fallback - }) - defer rm.Close() +// TestMemoryFallback tests fallback to runtime memory stats when reader is nil +func TestMemoryFallback(t *testing.T) { + setupTest(t) - stats := rm.collectStats() - // Should fall back to system memory when custom reader fails + // Test fallback when memoryReader is nil + rm := newResourceMonitor(time.Hour, CPUUsageModeHeuristic, nil) + defer rm.stop() + + rm.sample() + stats := rm.GetStats() + + // Memory percentage should be a valid value (0-100) if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { - t.Errorf("memory percent should be valid after fallback, got %f", stats.MemoryUsedPercent) + t.Errorf("Expected memory percentage in range [0,100], got %f", stats.MemoryUsedPercent) + } + + // Should have other stats populated + if stats.GoroutineCount <= 0 { + t.Errorf("Expected goroutine count > 0, got %d", stats.GoroutineCount) } - if errorCount == 0 { - t.Error("custom memory reader should have been called") + if stats.CPUUsagePercent < 0 { + t.Errorf("Expected CPU usage >= 0, got %f", stats.CPUUsagePercent) + } + if stats.Timestamp.IsZero() { + t.Error("Expected non-zero timestamp") } } -func TestResourceMonitor_ValidateResourceStats(t *testing.T) { - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - defer rm.Close() +// TestMemoryReaderError tests error handling in memory reader with fallback +func TestMemoryReaderError(t *testing.T) { + setupTest(t) - t.Run("negative goroutine count", func(t *testing.T) { - stats := &ResourceStats{ - MemoryUsedPercent: 50.0, - CPUUsagePercent: 40.0, - GoroutineCount: -1, // Invalid - Timestamp: time.Now(), - } - // Store invalid stats and retrieve them - validation happens in collectStats - // We test indirectly by ensuring GetStats returns valid values - rm.stats.Store(stats) - // Wait for next collection which will validate - time.Sleep(60 * time.Millisecond) - retrievedStats := rm.GetStats() - // Validation should clamp negative goroutine count to 0 - if retrievedStats.GoroutineCount < 0 { - t.Errorf("goroutine count should not be negative, got %d", retrievedStats.GoroutineCount) - } - }) + // Reader that returns error + errReader := func() (float64, error) { + return 0, assertError("fail") + } - t.Run("zero timestamp", func(t *testing.T) { - stats := &ResourceStats{ - MemoryUsedPercent: 50.0, - CPUUsagePercent: 40.0, - GoroutineCount: 10, - Timestamp: time.Time{}, // Zero timestamp - } - rm.stats.Store(stats) - // Validation should set timestamp if zero - // Note: GetStats doesn't validate, but collectStats does - // We test by triggering a new collection - time.Sleep(60 * time.Millisecond) // Wait for next collection - newStats := rm.GetStats() - if newStats.Timestamp.IsZero() { - t.Error("timestamp should not be zero after collection") - } - }) + rm := newResourceMonitor(time.Hour, CPUUsageModeHeuristic, errReader) + defer rm.stop() - t.Run("old timestamp refresh", func(t *testing.T) { - oldTime := time.Now().Add(-2 * time.Minute) // More than 1 minute ago - stats := &ResourceStats{ - MemoryUsedPercent: 50.0, - CPUUsagePercent: 40.0, - GoroutineCount: 10, - Timestamp: oldTime, - } - rm.stats.Store(stats) - // Wait for next collection which will validate and refresh timestamp - time.Sleep(60 * time.Millisecond) - newStats := rm.GetStats() - if newStats.Timestamp.Equal(oldTime) { - t.Error("timestamp should be refreshed when older than 1 minute") - } - if time.Since(newStats.Timestamp) > time.Second { - t.Error("timestamp should be recent after refresh") - } - }) + initialStats := rm.GetStats() + rm.sample() + stats := rm.GetStats() + + // Should fall back to runtime stats + if stats.MemoryUsedPercent < 0 { + t.Errorf("Expected memory percentage >= 0, got %f", stats.MemoryUsedPercent) + } + if stats.MemoryUsedPercent > 100 { + t.Errorf("Expected memory percentage <= 100, got %f", stats.MemoryUsedPercent) + } + + // Other stats should still be valid + if stats.GoroutineCount <= 0 { + t.Errorf("Expected goroutine count > 0, got %d", stats.GoroutineCount) + } + if stats.CPUUsagePercent < 0 { + t.Errorf("Expected CPU usage >= 0, got %f", stats.CPUUsagePercent) + } + if !stats.Timestamp.After(initialStats.Timestamp) { + t.Error("Expected timestamp to be updated") + } } -func BenchmarkResourceMonitor_GetStats(b *testing.B) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - defer rm.Close() +// TestResourceMonitor_CPU_SamplerError tests CPU sampler error handling +func TestResourceMonitor_CPU_SamplerError(t *testing.T) { + setupTest(t) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = rm.GetStats() + // Create monitor and inject a failing sampler + rm := newResourceMonitor(time.Hour, CPUUsageModeMeasured, nil) + defer rm.stop() + + // Create a mock sampler that returns negative values (error condition) + failingSampler := &mockCPUSampler{val: -1.0} + rm.sampler = failingSampler + + rm.sample() + stats := rm.GetStats() + + // CPU sampler can return negative values, so we just check it's a valid float + // The implementation doesn't clamp negative values, it passes them through + if math.IsNaN(stats.CPUUsagePercent) { + t.Error("Expected valid CPU usage value, got NaN") + } + + // Other stats should still be valid + if stats.GoroutineCount <= 0 { + t.Errorf("Expected goroutine count > 0, got %d", stats.GoroutineCount) + } + if stats.MemoryUsedPercent < 0 { + t.Errorf("Expected memory percentage >= 0, got %f", stats.MemoryUsedPercent) } } -func BenchmarkResourceMonitor_IsResourceConstrained(b *testing.B) { - rm := NewResourceMonitor(100*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, nil) - defer rm.Close() +// TestResourceMonitor_SetMode tests CPU monitoring mode switching +func TestResourceMonitor_SetMode(t *testing.T) { + setupTest(t) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _ = rm.IsResourceConstrained() + // Create monitor - should try measured mode first + rm := newResourceMonitor(time.Second, CPUUsageModeHeuristic, nil) + defer rm.stop() + + // Should use measured mode if available (new behavior) + initialMode := rm.GetMode() + if initialMode != CPUUsageModeMeasured && initialMode != CPUUsageModeHeuristic { + t.Errorf("Unexpected initial mode: %v", initialMode) + } + + // Test switching to Heuristic mode + rm.SetMode(CPUUsageModeHeuristic) + if rm.GetMode() != CPUUsageModeHeuristic { + t.Errorf("Expected mode Heuristic after switching, got %v", rm.GetMode()) + } + + // Test switching back to Measured mode + rm.SetMode(CPUUsageModeMeasured) + if rm.GetMode() != CPUUsageModeMeasured { + t.Errorf("Expected mode Measured after switching back, got %v", rm.GetMode()) + } + + // Test switching to the same mode (should be no-op) + rm.SetMode(CPUUsageModeMeasured) + if rm.GetMode() != CPUUsageModeMeasured { + t.Errorf("Expected mode to remain Measured, got %v", rm.GetMode()) } } -// TestClampPercent_NegativeValue tests clamping of negative percentage values -func TestClampPercent_NegativeValue(t *testing.T) { - // Test negative value clamping directly - result := clampPercent(-10.0) - if result != 0.0 { - t.Errorf("negative value should be clamped to 0.0, got %f", result) +// TestMonitorRegistry_BufferedChannel_DeadlockPrevention +func TestMonitorRegistry_BufferedChannel_DeadlockPrevention(t *testing.T) { + setupTest(t) + + // Create a blocking memory reader to simulate slow I/O + sampleStarted := make(chan bool, 1) + sampleContinue := make(chan bool, 1) + blockingMemoryReader := func() (float64, error) { + sampleStarted <- true + <-sampleContinue // Block until signaled to continue + return 50.0, nil } - // Test value > 100 clamping directly - result = clampPercent(150.0) - if result != 100.0 { - t.Errorf("value > 100 should be clamped to 100.0, got %f", result) + // Start first acquire with blocking memory reader + handle1 := globalMonitorRegistry.Acquire(200*time.Millisecond, CPUUsageModeHeuristic, blockingMemoryReader) + defer handle1.Close() + + // Wait for monitor to start sampling and block + select { + case <-sampleStarted: + // Monitor is now blocked in memoryReader + case <-time.After(1 * time.Second): + t.Fatal("Monitor didn't start sampling within timeout") } - // Test normal value (should pass through) - result = clampPercent(50.0) - if result != 50.0 { - t.Errorf("normal value should pass through, got %f", result) + // Now try to acquire with different interval - this triggers setInterval + // With unbuffered channel, this would deadlock holding registry lock + start := time.Now() + handle2 := globalMonitorRegistry.Acquire(50*time.Millisecond, CPUUsageModeHeuristic, nil) + defer handle2.Close() + + // The acquire should complete quickly with buffered channel + elapsed := time.Since(start) + if elapsed > 100*time.Millisecond { + t.Errorf("Acquire took too long (%v) - possible blocking", elapsed) } - // Test through memoryUsagePercent with custom reader - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, func() (float64, error) { - return -10.0, nil // Return negative value - }) - defer rm.Close() + // Unblock the memory reader so monitor can process the update + sampleContinue <- true - stats := rm.collectStats() - // Should clamp negative to 0 - if stats.MemoryUsedPercent < 0 { - t.Errorf("memory percent should be clamped to >= 0, got %f", stats.MemoryUsedPercent) + // Give monitor time to process the update + time.Sleep(50 * time.Millisecond) +} + +// TestMonitorRegistry_ConcurrentAccess tests concurrent access to the registry +func TestMonitorRegistry_ConcurrentAccess(t *testing.T) { + setupTest(t) + + const numGoroutines = 5 + const operationsPerGoroutine = 20 + + var wg sync.WaitGroup + errors := make(chan error, numGoroutines*operationsPerGoroutine) + + // Launch multiple goroutines performing concurrent operations + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < operationsPerGoroutine; j++ { + switch j % 3 { + case 0: // Acquire and release + handle := globalMonitorRegistry.Acquire(time.Duration(id+1)*time.Millisecond, CPUUsageModeHeuristic, nil) + handle.Close() + case 1: // Get stats if available + if globalMonitorRegistry.instance != nil { + _ = globalMonitorRegistry.instance.GetStats() + } + case 2: // Registry inspection + globalMonitorRegistry.mu.Lock() + _ = len(globalMonitorRegistry.intervalRefs) + globalMonitorRegistry.mu.Unlock() + } + } + }(i) } - if stats.MemoryUsedPercent != 0.0 { - t.Errorf("negative value should be clamped to 0.0, got %f", stats.MemoryUsedPercent) + + wg.Wait() + close(errors) + + // Check for any errors (panics would be caught here) + for err := range errors { + t.Errorf("Concurrent access error: %v", err) } } -// TestValidatePercent_NaNInf tests handling of NaN and Inf values -func TestValidatePercent_NaNInf(t *testing.T) { - // Test NaN handling directly - result := validatePercent(math.NaN()) - if math.IsNaN(result) { - t.Error("NaN should be converted to 0.0") +// TestResourceMonitor_BoundaryConditions tests edge cases and boundary conditions +func TestResourceMonitor_BoundaryConditions(t *testing.T) { + tests := []struct { + name string + interval time.Duration + expectedValid bool + }{ + {"very small interval", time.Nanosecond, true}, + {"very large interval", 24 * time.Hour, true}, + {"normal interval", time.Second, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupTest(t) + + // Test registry acquire with boundary intervals + handle := globalMonitorRegistry.Acquire(tt.interval, CPUUsageModeHeuristic, nil) + defer handle.Close() + + // Should not panic and should return valid stats + stats := handle.GetStats() + if stats.Timestamp.IsZero() { + t.Error("Expected valid timestamp") + } + + // For valid intervals, check that monitor was created + if tt.expectedValid { + if globalMonitorRegistry.instance == nil { + t.Error("Expected monitor instance to be created") + } + } + }) } - if result != 0.0 { - t.Errorf("NaN should be converted to 0.0, got %f", result) + + // Test invalid intervals cause validation panic + t.Run("invalid intervals", func(t *testing.T) { + testCases := []struct { + name string + interval time.Duration + wantMsg string + }{ + {"negative interval", -time.Second, "resource monitor: invalid interval -1s, must be positive"}, + {"zero interval", 0, "resource monitor: invalid interval 0s, must be positive"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + setupTest(t) + + defer func() { + if r := recover(); r == nil { + t.Errorf("Expected panic for %s", tc.name) + } else if msg := fmt.Sprintf("%v", r); msg != tc.wantMsg { + t.Errorf("Expected panic message %q, got %q", tc.wantMsg, msg) + } + }() + + // This should panic with validation error + globalMonitorRegistry.Acquire(tc.interval, CPUUsageModeHeuristic, nil) + }) + } + }) +} + +// TestResourceMonitor_ExtremeMemoryValues tests handling of extreme memory values +func TestResourceMonitor_ExtremeMemoryValues(t *testing.T) { + setupTest(t) + + testCases := []struct { + name string + memValue float64 + }{ + {"zero memory", 0.0}, + {"negative memory", -50.0}, + {"normal memory", 75.5}, + {"max memory", 100.0}, + {"over max memory", 150.0}, + {"very large memory", 1e6}, + {"NaN memory", math.NaN()}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockReader := func() (float64, error) { + return tc.memValue, nil + } + + rm := newResourceMonitor(time.Hour, CPUUsageModeHeuristic, mockReader) + defer rm.stop() + + rm.sample() + stats := rm.GetStats() + + // The implementation currently passes through any value from the custom reader + // without validation. This documents the current behavior. + if stats.MemoryUsedPercent != tc.memValue && !math.IsNaN(tc.memValue) { + t.Errorf("Expected memory %f, got %f", tc.memValue, stats.MemoryUsedPercent) + } + if math.IsNaN(tc.memValue) && !math.IsNaN(stats.MemoryUsedPercent) { + t.Errorf("Expected NaN memory, got %f", stats.MemoryUsedPercent) + } + + // Always check other stats are reasonable + if stats.GoroutineCount <= 0 { + t.Errorf("Expected goroutine count > 0, got %d", stats.GoroutineCount) + } + }) } +} + +// TestResourceMonitor_Stop tests the stop method for cleanup and shutdown +func TestResourceMonitor_Stop(t *testing.T) { + setupTest(t) + + rm := newResourceMonitor(testSampleInterval, CPUUsageModeHeuristic, nil) - // Test positive Inf - result = validatePercent(math.Inf(1)) - if math.IsInf(result, 0) { - t.Error("Inf should be converted to 0.0") + // Verify monitor is running + initialStats := rm.GetStats() + if initialStats.Timestamp.IsZero() { + t.Fatal("Monitor should be running and producing stats") } - if result != 0.0 { - t.Errorf("Inf should be converted to 0.0, got %f", result) + + // Wait for sampling to occur + time.Sleep(testStopTimeout) + statsBeforeStop := rm.GetStats() + if !statsBeforeStop.Timestamp.After(initialStats.Timestamp) { + t.Fatal("Monitor should have sampled at least once") } - // Test negative Inf - result = validatePercent(math.Inf(-1)) - if math.IsInf(result, 0) { - t.Error("Negative Inf should be converted to 0.0") + // Stop the monitor + rm.stop() + + // Verify monitor has stopped by checking no more updates occur + time.Sleep(testStopTimeout) + statsAfterStop := rm.GetStats() + + // Allow small timing variations but ensure no significant updates + if statsAfterStop.Timestamp.Sub(statsBeforeStop.Timestamp) > testStatsUpdateMargin { + t.Error("Monitor should have stopped, stats should not update significantly") } - if result != 0.0 { - t.Errorf("Negative Inf should be converted to 0.0, got %f", result) + + // Test idempotent stop + rm.stop() // Should not panic + rm.stop() // Should not panic +} + +// TestResourceMonitor_Stop_Concurrent tests concurrent stop calls +func TestResourceMonitor_Stop_Concurrent(t *testing.T) { + setupTest(t) + + rm := newResourceMonitor(100*time.Millisecond, CPUUsageModeHeuristic, nil) + + // Call stop concurrently + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + rm.stop() + }() } - // Test through validateResourceStats - stats := &ResourceStats{ - MemoryUsedPercent: 50.0, - CPUUsagePercent: math.NaN(), - GoroutineCount: 10, - Timestamp: time.Now(), + wg.Wait() + + // Should not panic and monitor should be stopped + time.Sleep(200 * time.Millisecond) + stats1 := rm.GetStats() + time.Sleep(200 * time.Millisecond) + stats2 := rm.GetStats() + + // Stats should not update after stop + if stats2.Timestamp.After(stats1.Timestamp.Add(50 * time.Millisecond)) { + t.Error("Monitor should be stopped, stats should not update") } +} - validateResourceStats(stats) - if math.IsNaN(stats.CPUUsagePercent) { - t.Error("CPU percent should not be NaN after validation") +// TestResourceMonitor_GetStats_Concurrent tests concurrent GetStats calls +func TestResourceMonitor_GetStats_Concurrent(t *testing.T) { + setupTest(t) + + rm := newResourceMonitor(100*time.Millisecond, CPUUsageModeHeuristic, nil) + defer rm.stop() + + // Call GetStats concurrently + var wg sync.WaitGroup + errors := make(chan error, 100) + + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + stats := rm.GetStats() + // Verify stats are valid + if stats.Timestamp.IsZero() { + errors <- fmt.Errorf("got zero timestamp") + } + if stats.GoroutineCount < 0 { + errors <- fmt.Errorf("got invalid goroutine count: %d", stats.GoroutineCount) + } + }() } - if stats.CPUUsagePercent != 0.0 { - t.Errorf("NaN should be converted to 0.0, got %f", stats.CPUUsagePercent) + + wg.Wait() + close(errors) + + // Check for errors + for err := range errors { + t.Error(err) } } -// TestValidateResourceStats_TimestampPaths tests the timestamp validation paths directly -func TestValidateResourceStats_TimestampPaths(t *testing.T) { - t.Run("zero timestamp refresh", func(t *testing.T) { - stats := &ResourceStats{ - MemoryUsedPercent: 50.0, - CPUUsagePercent: 40.0, - GoroutineCount: 10, - Timestamp: time.Time{}, // Zero timestamp - } +// TestResourceMonitor_GetStats_NilPointer tests GetStats with nil pointer +func TestResourceMonitor_GetStats_NilPointer(t *testing.T) { + setupTest(t) - validateResourceStats(stats) - if stats.Timestamp.IsZero() { - t.Error("timestamp should not be zero after validation") - } - if time.Since(stats.Timestamp) > time.Second { - t.Error("timestamp should be recent after refresh") - } - }) + rm := newResourceMonitor(time.Hour, CPUUsageModeHeuristic, nil) + defer rm.stop() - t.Run("old timestamp refresh", func(t *testing.T) { - oldTime := time.Now().Add(-2 * time.Minute) // More than 1 minute ago - stats := &ResourceStats{ - MemoryUsedPercent: 50.0, - CPUUsagePercent: 40.0, - GoroutineCount: 10, - Timestamp: oldTime, - } + // Manually set stats to nil (simulating edge case) + rm.stats.Store(nil) - validateResourceStats(stats) - if stats.Timestamp.Equal(oldTime) { - t.Error("timestamp should be refreshed when older than 1 minute") - } - if time.Since(stats.Timestamp) > time.Second { - t.Error("timestamp should be recent after refresh") - } - }) + // GetStats should return empty ResourceStats, not panic + stats := rm.GetStats() + if !stats.Timestamp.IsZero() { + t.Error("Expected zero timestamp for nil stats") + } } -// TestMemoryUsagePercent_CustomReaderErrorFallback tests the fallback when custom memory reader fails -func TestMemoryUsagePercent_CustomReaderErrorFallback(t *testing.T) { - callCount := 0 - rm := NewResourceMonitor(50*time.Millisecond, 80.0, 70.0, CPUUsageModeHeuristic, func() (float64, error) { - callCount++ - return 0, fmt.Errorf("custom reader error") - }) - defer rm.Close() +// TestSharedMonitorHandle_Close tests Close method for cleanup +func TestSharedMonitorHandle_Close(t *testing.T) { + setupTest(t) - // collectStats should call memoryUsagePercent which should try custom reader, - // get error, and fall back to system memory - stats := rm.collectStats() + // Acquire a handle + h1 := globalMonitorRegistry.Acquire(time.Second, CPUUsageModeHeuristic, nil) - if callCount == 0 { - t.Error("custom memory reader should have been called") + // Verify registry has reference + if len(globalMonitorRegistry.intervalRefs) == 0 { + t.Error("Registry should have reference after acquire") } - // Should fall back to system memory, so value should be valid - if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { - t.Errorf("memory percent should be valid after fallback, got %f", stats.MemoryUsedPercent) + // Close the handle + h1.Close() + + // Verify reference is released + if len(globalMonitorRegistry.intervalRefs) != 0 { + t.Error("Registry should have no references after close") + } + + // Test idempotent close + h1.Close() // Should not panic + h1.Close() // Should not panic + + // Verify still no references + if len(globalMonitorRegistry.intervalRefs) != 0 { + t.Error("Registry should still have no references after multiple closes") } } -// TestInitSampler tests the initSampler method directly using reflection -func TestInitSampler(t *testing.T) { - t.Run("heuristic mode", func(t *testing.T) { - rm := &ResourceMonitor{ - sampleInterval: 50 * time.Millisecond, - memoryThreshold: 80.0, - cpuThreshold: 70.0, - cpuMode: CPUUsageModeHeuristic, - done: make(chan struct{}), - } +// TestSharedMonitorHandle_Close_MultipleHandles tests Close with multiple handles +func TestSharedMonitorHandle_Close_MultipleHandles(t *testing.T) { + setupTest(t) - rm.initSampler() + // Acquire multiple handles with same interval + h1 := globalMonitorRegistry.Acquire(time.Second, CPUUsageModeHeuristic, nil) + h2 := globalMonitorRegistry.Acquire(time.Second, CPUUsageModeHeuristic, nil) + h3 := globalMonitorRegistry.Acquire(time.Second, CPUUsageModeHeuristic, nil) - if rm.sampler == nil { - t.Fatal("sampler should not be nil") - } - if rm.cpuMode != CPUUsageModeHeuristic { - t.Errorf("cpuMode should remain Heuristic, got %v", rm.cpuMode) - } + // Verify ref count is 3 + if count := globalMonitorRegistry.intervalRefs[time.Second]; count != 3 { + t.Errorf("Expected ref count 3, got %d", count) + } - // Verify it's a heuristic sampler by checking behavior - percent := rm.sampler.Sample(100 * time.Millisecond) - if percent < 0.0 || percent > 100.0 { - t.Errorf("CPU percent should be between 0 and 100, got %v", percent) - } - }) + // Close one handle + h1.Close() - t.Run("measured mode success", func(t *testing.T) { - rm := &ResourceMonitor{ - sampleInterval: 50 * time.Millisecond, - memoryThreshold: 80.0, - cpuThreshold: 70.0, - cpuMode: CPUUsageModeMeasured, - done: make(chan struct{}), - } + // Verify ref count is 2 + if count := globalMonitorRegistry.intervalRefs[time.Second]; count != 2 { + t.Errorf("Expected ref count 2, got %d", count) + } - rm.initSampler() + // Close remaining handles + h2.Close() + h3.Close() - if rm.sampler == nil { - t.Fatal("sampler should not be nil") - } + // Verify no references + if len(globalMonitorRegistry.intervalRefs) != 0 { + t.Error("Registry should have no references after all closes") + } +} - // On darwin, NewProcessSampler should succeed, so cpuMode should be Measured - // (or Heuristic if it fell back, but that's unlikely on darwin) - if rm.cpuMode != CPUUsageModeMeasured && rm.cpuMode != CPUUsageModeHeuristic { - t.Errorf("cpuMode should be Measured or Heuristic (fallback), got %v", rm.cpuMode) - } +// TestSharedMonitorHandle_Close_Concurrent tests concurrent Close calls +func TestSharedMonitorHandle_Close_Concurrent(t *testing.T) { + setupTest(t) - // Should be able to sample - percent := rm.sampler.Sample(100 * time.Millisecond) - if percent < 0.0 || percent > 100.0 { - t.Errorf("CPU percent should be between 0 and 100, got %v", percent) - } + // Acquire multiple handles + handles := make([]resourceMonitor, 10) + for i := 0; i < 10; i++ { + handles[i] = globalMonitorRegistry.Acquire(time.Second, CPUUsageModeHeuristic, nil) + } - // Verify stats were collected - stats := rm.stats.Load() - if stats == nil { - t.Error("stats should be initialized after initSampler") - } - }) + // Close all handles concurrently + var wg sync.WaitGroup + for _, h := range handles { + wg.Add(1) + go func(handle resourceMonitor) { + defer wg.Done() + handle.Close() + }(h) + } - t.Run("measured mode fallback structure", func(t *testing.T) { - // This test verifies that the fallback code path exists in initSampler. - // The actual fallback (lines 105-108 in resource_monitor.go) is tested on - // platforms where NewProcessSampler naturally fails (e.g., unsupported platforms - // via cpu_fallback.go build tag: !linux && !darwin && !windows). - // - // On darwin, NewProcessSampler typically succeeds, so we verify: - // 1. The code structure supports fallback (cpuMode can change from Measured to Heuristic) - // 2. The sampler is always initialized (even if fallback occurs) - // 3. Stats are collected after initialization - - rm := &ResourceMonitor{ - sampleInterval: 50 * time.Millisecond, - memoryThreshold: 80.0, - cpuThreshold: 70.0, - cpuMode: CPUUsageModeMeasured, - done: make(chan struct{}), - } + wg.Wait() - initialMode := rm.cpuMode - rm.initSampler() + // Verify all references are released + if len(globalMonitorRegistry.intervalRefs) != 0 { + t.Error("Registry should have no references after concurrent closes") + } +} - // Sampler should always be initialized - if rm.sampler == nil { - t.Fatal("sampler should not be nil, even if fallback occurs") - } +// TestMonitorRegistry_CalculateMinInterval tests calculateMinInterval function +func TestMonitorRegistry_CalculateMinInterval(t *testing.T) { + tests := []struct { + name string + intervals map[time.Duration]int + expected time.Duration + }{ + {"empty registry", nil, time.Second}, + {"single interval", map[time.Duration]int{2 * time.Second: 1}, 2 * time.Second}, + {"multiple intervals", map[time.Duration]int{ + 5 * time.Second: 1, + 1 * time.Second: 1, + 3 * time.Second: 1, + }, 1 * time.Second}, + {"very small interval", map[time.Duration]int{50 * time.Millisecond: 1}, 50 * time.Millisecond}, + {"very large interval", map[time.Duration]int{24 * time.Hour: 1}, 24 * time.Hour}, + } - // Mode should either remain Measured (success) or change to Heuristic (fallback) - if rm.cpuMode != CPUUsageModeMeasured && rm.cpuMode != CPUUsageModeHeuristic { - t.Errorf("cpuMode should be Measured or Heuristic after initSampler, got %v", rm.cpuMode) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupTest(t) // Ensure clean state - // If mode changed, it means fallback occurred - if rm.cpuMode != initialMode { - t.Logf("Fallback occurred: cpuMode changed from %v to %v", initialMode, rm.cpuMode) - } + // Set up test intervals + globalMonitorRegistry.intervalRefs = tt.intervals - // Stats should be initialized - stats := rm.stats.Load() - if stats == nil { - t.Error("stats should be initialized after initSampler") - } - }) + minInterval := globalMonitorRegistry.calculateMinInterval() + if minInterval != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, minInterval) + } + }) + } +} + +// TestMonitorRegistry_CalculateMinInterval_EdgeCases tests edge cases for calculateMinInterval +func TestMonitorRegistry_CalculateMinInterval_EdgeCases(t *testing.T) { + tests := []struct { + name string + intervals map[time.Duration]int + expected time.Duration + }{ + {"single nanosecond", map[time.Duration]int{time.Nanosecond: 1}, time.Nanosecond}, + {"multiple same intervals", map[time.Duration]int{time.Second: 5}, time.Second}, + {"mixed with duplicates", map[time.Duration]int{ + 100 * time.Millisecond: 2, + 200 * time.Millisecond: 1, + }, 100 * time.Millisecond}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupTest(t) + + globalMonitorRegistry.intervalRefs = tt.intervals + minInterval := globalMonitorRegistry.calculateMinInterval() + if minInterval != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, minInterval) + } + }) + } } diff --git a/internal/assert/assertions.go b/internal/assert/assertions.go index f656b0a..75494f8 100644 --- a/internal/assert/assertions.go +++ b/internal/assert/assertions.go @@ -1,6 +1,7 @@ package assert import ( + "math" "reflect" "strings" "testing" @@ -43,3 +44,11 @@ func Panics(t *testing.T, f func()) { }() f() } + +// InDelta checks whether two float64 values are within a given delta of each other. +func InDelta(t *testing.T, expected, actual, delta float64) { + if math.Abs(expected-actual) > delta { + t.Helper() + t.Fatalf("Expected %v to be within %v of %v", actual, delta, expected) + } +} From 0028afcd18363f9913cbd8c6518b277eefc16cda Mon Sep 17 00:00:00 2001 From: kxrxh Date: Thu, 27 Nov 2025 16:24:27 +0300 Subject: [PATCH 51/54] refactor(sysmonitor): remove SetMemoryReader function from memory platform files --- internal/sysmonitor/memory_darwin.go | 15 --------------- internal/sysmonitor/memory_fallback.go | 15 --------------- internal/sysmonitor/memory_linux.go | 13 ------------- internal/sysmonitor/memory_windows.go | 15 --------------- 4 files changed, 58 deletions(-) diff --git a/internal/sysmonitor/memory_darwin.go b/internal/sysmonitor/memory_darwin.go index 3014a05..13dcaa1 100644 --- a/internal/sysmonitor/memory_darwin.go +++ b/internal/sysmonitor/memory_darwin.go @@ -68,18 +68,3 @@ func getSystemMemoryDarwin() (SystemMemory, error) { Available: free + inactive + speculative, }, nil } - -// SetMemoryReader replaces the current memory reader (for testing). -// It returns a cleanup function to restore the previous reader. -func SetMemoryReader(reader MemoryReader) func() { - memoryReaderMu.Lock() - prev := memoryReader - memoryReader = reader - memoryReaderMu.Unlock() - - return func() { - memoryReaderMu.Lock() - memoryReader = prev - memoryReaderMu.Unlock() - } -} diff --git a/internal/sysmonitor/memory_fallback.go b/internal/sysmonitor/memory_fallback.go index 0b74ce5..7ce75a3 100644 --- a/internal/sysmonitor/memory_fallback.go +++ b/internal/sysmonitor/memory_fallback.go @@ -30,18 +30,3 @@ func GetSystemMemory() (SystemMemory, error) { } return SystemMemory{}, errors.New("memory monitoring not supported on this platform") } - -// SetMemoryReader replaces the current memory reader (for testing). -// It returns a cleanup function to restore the previous reader. -func SetMemoryReader(reader MemoryReader) func() { - memoryReaderMu.Lock() - prev := memoryReader - memoryReader = reader - memoryReaderMu.Unlock() - - return func() { - memoryReaderMu.Lock() - memoryReader = prev - memoryReaderMu.Unlock() - } -} diff --git a/internal/sysmonitor/memory_linux.go b/internal/sysmonitor/memory_linux.go index 0093915..b7d8578 100644 --- a/internal/sysmonitor/memory_linux.go +++ b/internal/sysmonitor/memory_linux.go @@ -36,19 +36,6 @@ func GetSystemMemory() (SystemMemory, error) { return reader() } -// SetMemoryReader replaces the current memory reader (for testing) -func SetMemoryReader(reader MemoryReader) func() { - memoryReaderMu.Lock() - prev := memoryReader - memoryReader = reader - memoryReaderMu.Unlock() - return func() { - memoryReaderMu.Lock() - memoryReader = prev - memoryReaderMu.Unlock() - } -} - type cgroupMemoryConfig struct { usagePath string limitPath string diff --git a/internal/sysmonitor/memory_windows.go b/internal/sysmonitor/memory_windows.go index 39072f9..aa295d6 100644 --- a/internal/sysmonitor/memory_windows.go +++ b/internal/sysmonitor/memory_windows.go @@ -53,18 +53,3 @@ func getSystemMemoryWindows() (SystemMemory, error) { Available: memStatus.ullAvailPhys, }, nil } - -// SetMemoryReader replaces the current memory reader (for testing). -// It returns a cleanup function to restore the previous reader. -func SetMemoryReader(reader MemoryReader) func() { - memoryReaderMu.Lock() - prev := memoryReader - memoryReader = reader - memoryReaderMu.Unlock() - - return func() { - memoryReaderMu.Lock() - memoryReader = prev - memoryReaderMu.Unlock() - } -} From 7f375ba4cdfd715dc3ec03ce1aeee591f7c9ad2e Mon Sep 17 00:00:00 2001 From: kxrxh Date: Wed, 3 Dec 2025 16:22:45 +0300 Subject: [PATCH 52/54] refactor: enhance system monitoring and adaptive throttling - Restructure sysmonitor package with platform-specific CPU/memory monitoring - Rename heuristic.go to cpu_heuristic.go for clarity - Add comprehensive test coverage for CPU and memory monitoring across platforms - Enhance ResourceMonitor with registry pattern for shared monitoring - Improve AdaptiveThrottler with hysteresis, recovery thresholds, and smoothing - Add test helpers and mock filesystem for better testing - Update adaptive throttler example with improved configuration --- examples/adaptive_throttler/main.go | 105 ++- flow/adaptive_throttler_test.go | 694 ++++++++++++++---- flow/resource_monitor.go | 43 +- flow/resource_monitor_test.go | 147 ++-- internal/sysmonitor/cpu.go | 4 +- internal/sysmonitor/cpu_darwin.go | 20 +- internal/sysmonitor/cpu_darwin_test.go | 71 +- internal/sysmonitor/cpu_fallback.go | 32 +- .../{heuristic.go => cpu_heuristic.go} | 18 +- internal/sysmonitor/cpu_heuristic_test.go | 96 +++ internal/sysmonitor/cpu_linux.go | 68 +- internal/sysmonitor/cpu_linux_test.go | 216 +++++- internal/sysmonitor/cpu_test.go | 275 +++---- internal/sysmonitor/cpu_windows.go | 28 +- internal/sysmonitor/cpu_windows_test.go | 48 ++ internal/sysmonitor/fs_test.go | 248 +++++-- internal/sysmonitor/heuristic_test.go | 338 --------- internal/sysmonitor/memory.go | 12 +- internal/sysmonitor/memory_darwin.go | 28 +- internal/sysmonitor/memory_darwin_test.go | 60 ++ internal/sysmonitor/memory_fallback.go | 29 +- internal/sysmonitor/memory_fallback_test.go | 74 ++ internal/sysmonitor/memory_linux.go | 170 +++-- .../sysmonitor/memory_linux_cgroup_test.go | 576 --------------- internal/sysmonitor/memory_linux_test.go | 578 +++++++++++++++ internal/sysmonitor/memory_test.go | 76 -- internal/sysmonitor/memory_windows.go | 23 +- internal/sysmonitor/memory_windows_test.go | 92 +++ internal/sysmonitor/test_helpers.go | 53 ++ 29 files changed, 2510 insertions(+), 1712 deletions(-) rename internal/sysmonitor/{heuristic.go => cpu_heuristic.go} (83%) create mode 100644 internal/sysmonitor/cpu_heuristic_test.go create mode 100644 internal/sysmonitor/cpu_windows_test.go delete mode 100644 internal/sysmonitor/heuristic_test.go create mode 100644 internal/sysmonitor/memory_darwin_test.go create mode 100644 internal/sysmonitor/memory_fallback_test.go delete mode 100644 internal/sysmonitor/memory_linux_cgroup_test.go create mode 100644 internal/sysmonitor/memory_linux_test.go delete mode 100644 internal/sysmonitor/memory_test.go create mode 100644 internal/sysmonitor/memory_windows_test.go create mode 100644 internal/sysmonitor/test_helpers.go diff --git a/examples/adaptive_throttler/main.go b/examples/adaptive_throttler/main.go index 6e5eadd..7b46696 100644 --- a/examples/adaptive_throttler/main.go +++ b/examples/adaptive_throttler/main.go @@ -2,13 +2,18 @@ package main import ( "fmt" + "math/rand" "strings" + "sync/atomic" "time" ext "github.com/reugn/go-streams/extension" "github.com/reugn/go-streams/flow" ) +// Demo of adaptive throttling with CPU-intensive work. +// T1 throttles on CPU > 0.01%, T2 throttles on Memory > 50%. + func editMessage(msg string) string { return strings.ToUpper(msg) } @@ -17,29 +22,48 @@ func addTimestamp(msg string) string { return fmt.Sprintf("[%s] %s", time.Now().Format("15:04:05"), msg) } +// cpuIntensiveWork simulates processing load that affects CPU usage +func cpuIntensiveWork(msg string) string { + // Simulate CPU-intensive work (hashing-like computation) + var checksum uint64 + for i := 0; i < 200000; i++ { // Increased from 50000 to 200000 for more CPU load + checksum += uint64(len(msg)) * uint64(i) //nolint:gosec + } + return msg +} + func main() { - // Configure first throttler - focuses on CPU limits + var messagesProcessed atomic.Int64 + + // Configure first throttler - CPU-focused with higher initial rate throttler1Config := flow.DefaultAdaptiveThrottlerConfig() - throttler1Config.MaxCPUPercent = 70.0 - throttler1Config.MaxMemoryPercent = 60.0 - throttler1Config.InitialRate = 15 - throttler1Config.MaxRate = 30 + throttler1Config.MaxCPUPercent = 0.01 // Throttle when CPU > 0.01% (extremely low threshold) + throttler1Config.MaxMemoryPercent = 80.0 // Less strict memory limit + throttler1Config.InitialRate = 50 // Start at 50/sec + throttler1Config.MaxRate = 100 // Can go up to 100/sec + throttler1Config.MinRate = 5 // Minimum 5/sec throttler1Config.SampleInterval = 200 * time.Millisecond - throttler1Config.CPUUsageMode = flow.CPUUsageModeMeasured // Use heuristic for better CPU visibility + throttler1Config.BackoffFactor = 0.6 // Reduce to 60% when constrained + throttler1Config.RecoveryFactor = 1.4 // Increase by 40% during recovery throttler1, err := flow.NewAdaptiveThrottler(throttler1Config) if err != nil { panic(fmt.Sprintf("failed to create throttler1: %v", err)) } - // Configure second throttler - focuses on memory limits + // Configure second throttler - Memory-focused with memory simulation throttler2Config := flow.DefaultAdaptiveThrottlerConfig() - throttler2Config.MaxCPUPercent = 75.0 - throttler2Config.MaxMemoryPercent = 50.0 - throttler2Config.InitialRate = 10 - throttler2Config.MaxRate = 25 + throttler2Config.MaxCPUPercent = 80.0 // Less strict CPU limit + throttler2Config.MaxMemoryPercent = 40.0 // Throttle when memory > 40% + throttler2Config.InitialRate = 30 // Start at 30/sec + throttler2Config.MaxRate = 80 // Can go up to 80/sec + throttler2Config.MinRate = 3 // Minimum 3/sec throttler2Config.SampleInterval = 200 * time.Millisecond - throttler2Config.CPUUsageMode = flow.CPUUsageModeMeasured // Use heuristic for better CPU visibility + throttler2Config.BackoffFactor = 0.5 // Reduce to 50% when constrained + throttler2Config.RecoveryFactor = 1.3 // Increase by 30% during recovery + + // Use system memory but with lower threshold to demonstrate throttling + throttler2Config.MaxMemoryPercent = 50.0 // Lower threshold than T1 throttler2, err := flow.NewAdaptiveThrottler(throttler2Config) if err != nil { @@ -50,43 +74,74 @@ func main() { source := ext.NewChanSource(in) editMapFlow := flow.NewMap(editMessage, 1) + cpuWorkFlow := flow.NewMap(cpuIntensiveWork, 1) // Add CPU-intensive work timestampFlow := flow.NewMap(addTimestamp, 1) sink := ext.NewStdoutSink() - // Pipeline: Source -> Throttler1 -> Edit -> Throttler2 -> Timestamp -> Sink + // Pipeline: Source -> Throttler1 (CPU) -> CPU Work -> Throttler2 (Memory) -> Edit -> Timestamp -> Sink go func() { source. - Via(throttler1). - Via(editMapFlow). - Via(throttler2). - Via(timestampFlow). + Via(throttler1). // First throttler monitors CPU + Via(cpuWorkFlow). // CPU-intensive processing + Via(throttler2). // Second throttler monitors memory + Via(editMapFlow). // Simple transformation + Via(timestampFlow). // Add timestamp To(sink) }() - // Stats logging goroutine + // Enhanced stats logging showing throttling behavior go func() { - ticker := time.NewTicker(1 * time.Second) + ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for range ticker.C { - stats := throttler1.GetResourceStats() - fmt.Printf("[stats] T1-Rate: %.1f/s, T2-Rate: %.1f/s, CPU: %.1f%%, Mem: %.1f%%\n", + stats1 := throttler1.GetResourceStats() + stats2 := throttler2.GetResourceStats() + + // Show current rates and resource usage + fmt.Printf("[stats] T1-CPU: %.1f/s (CPU:%.1f%%), T2-Mem: %.1f/s (Mem:%.1f%%), Total: %d msgs\n", throttler1.GetCurrentRate(), + stats1.CPUUsagePercent, throttler2.GetCurrentRate(), - stats.CPUUsagePercent, - stats.MemoryUsedPercent) + stats2.MemoryUsedPercent, + messagesProcessed.Load()) + + // Show throttling status + throttleReason := "" + if stats1.CPUUsagePercent > throttler1Config.MaxCPUPercent { + throttleReason += "T1:CPU-high " + } + if stats2.MemoryUsedPercent > throttler2Config.MaxMemoryPercent { + throttleReason += "T2:Mem-high " + } + if throttleReason == "" { + throttleReason = "No throttling" + } + fmt.Printf("[throttle] %s\n", throttleReason) } }() + // Producer with bursty traffic to test throttling go func() { defer close(in) for i := 1; i <= 100; i++ { - message := fmt.Sprintf("message-%d", i) + message := fmt.Sprintf("MESSAGE-%d", i) in <- message - time.Sleep(30 * time.Millisecond) + messagesProcessed.Add(1) + + // Variable delay to create bursts (some fast, some slow) + var delay time.Duration + if i%20 == 0 { + delay = 100 * time.Millisecond // Burst pause every 20 messages + } else { + delay = time.Duration(5+rand.Intn(15)) * time.Millisecond //nolint:gosec // 5-20ms between messages + } + time.Sleep(delay) } }() sink.AwaitCompletion() + + fmt.Printf("Demo completed! Processed %d messages\n", messagesProcessed.Load()) } diff --git a/flow/adaptive_throttler_test.go b/flow/adaptive_throttler_test.go index 2e36fe5..e6a4c21 100644 --- a/flow/adaptive_throttler_test.go +++ b/flow/adaptive_throttler_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/reugn/go-streams" "github.com/reugn/go-streams/internal/assert" ) @@ -35,42 +34,6 @@ func (m *MockMonitor) ExpectGetStats(stats ...ResourceStats) { m.getStatsIndex = 0 } -type mockFlow struct { - in chan any - out chan any -} - -func (m *mockFlow) Via(flow streams.Flow) streams.Flow { - return flow -} - -func (m *mockFlow) To(sink streams.Sink) { - go func() { - for data := range m.in { - sink.In() <- data - } - close(sink.In()) - }() -} - -func (m *mockFlow) Out() <-chan any { - return m.out -} - -func (m *mockFlow) In() chan<- any { - if m.in == nil { - m.in = make(chan any, 10) - m.out = make(chan any, 10) - go func() { - defer close(m.out) - for data := range m.in { - m.out <- data - } - }() - } - return m.in -} - type mockSink struct { in chan any completion chan struct{} @@ -113,19 +76,17 @@ func newMockSinkWithChannelDrain() *mockSinkWithChannelDrain { go func() { defer close(sink.done) for range sink.in { - _ = struct{}{} // drain channel + _ = struct{}{} } }() return sink } -// Helper functions for common test patterns - // createThrottlerWithLongInterval creates a throttler with default config but long sample interval func createThrottlerWithLongInterval(t *testing.T) *AdaptiveThrottler { t.Helper() config := DefaultAdaptiveThrottlerConfig() - config.SampleInterval = 10 * time.Second // Long interval to avoid interference + config.SampleInterval = 10 * time.Second at, err := NewAdaptiveThrottler(config) if err != nil { t.Fatalf("Failed to create throttler: %v", err) @@ -179,6 +140,100 @@ func createThrottlerForRateTesting( return throttler, mockMonitor } +// calculateNewRate calculates the new rate based on current rate and resource stats +// This implements the core adaptive throttling algorithm +func calculateNewRate(config *AdaptiveThrottlerConfig, currentRate float64, stats ResourceStats) float64 { + isConstrained := stats.MemoryUsedPercent > config.MaxMemoryPercent || + stats.CPUUsagePercent > config.MaxCPUPercent + + isBelowRecovery := stats.MemoryUsedPercent < config.RecoveryMemoryThreshold && + stats.CPUUsagePercent < config.RecoveryCPUThreshold + + shouldIncrease := !isConstrained && (!config.EnableHysteresis || isBelowRecovery) + + targetRate := currentRate + if isConstrained { + targetRate *= config.BackoffFactor + } else if shouldIncrease { + targetRate *= config.RecoveryFactor + if targetRate > float64(config.MaxRate) { + targetRate = float64(config.MaxRate) + } + } + + newRate := currentRate + (targetRate-currentRate)*smoothingFactor + + if newRate < float64(config.MinRate) { + newRate = float64(config.MinRate) + } + + return newRate +} + +// simulateRateAdjustments simulates a sequence of rate adjustments and returns the final rate +// This is used to calculate expected rate ranges algorithmically instead of hardcoding them +func simulateRateAdjustments( + config *AdaptiveThrottlerConfig, + initialRate float64, + statsSequence []ResourceStats, +) float64 { + currentRate := initialRate + + for _, stats := range statsSequence { + currentRate = calculateNewRate(config, currentRate, stats) + } + + return currentRate +} + +// calculateExpectedRateRange calculates the expected final rate range for a sustained load scenario +// Returns min and max expected rates based on the algorithm behavior +func calculateExpectedRateRange( + config *AdaptiveThrottlerConfig, + initialRate float64, + statsSequence []ResourceStats, +) (float64, float64) { + finalRate := simulateRateAdjustments(config, initialRate, statsSequence) + + tolerance := 0.1 // 10% tolerance + + minExpected := finalRate * (1 - tolerance) + maxExpected := finalRate * (1 + tolerance) + + if minExpected < float64(config.MinRate) { + minExpected = float64(config.MinRate) + } + if maxExpected > float64(config.MaxRate) { + maxExpected = float64(config.MaxRate) + } + + return minExpected, maxExpected +} + +// calculateExpectedRecoveryRate calculates the expected recovery rate after a high load followed by normal load +// Returns the expected rate after the specified number of recovery cycles +func calculateExpectedRecoveryRate( + config *AdaptiveThrottlerConfig, + initialRate float64, + highLoadStats, normalLoadStats ResourceStats, + recoveryCycles int, +) float64 { + currentRate := initialRate + + currentRate = simulateSingleAdjustment(config, currentRate, highLoadStats) + + for i := 0; i < recoveryCycles+1; i++ { // +1 for the first normal load adjustment + currentRate = simulateSingleAdjustment(config, currentRate, normalLoadStats) + } + + return currentRate +} + +// simulateSingleAdjustment simulates a single rate adjustment for given stats +func simulateSingleAdjustment(config *AdaptiveThrottlerConfig, currentRate float64, stats ResourceStats) float64 { + return calculateNewRate(config, currentRate, stats) +} + type mockInlet struct { in chan any } @@ -459,13 +514,13 @@ func TestAdaptiveThrottler_Hysteresis(t *testing.T) { config.EnableHysteresis = true at, mockMonitor := createThrottlerForRateTesting(config, float64(config.InitialRate)) - // CPU at 75% (above recovery threshold 70%, below max threshold 80%) - should not increase + // CPU at 75% (above recovery threshold) - should not increase with hysteresis mockMonitor.ExpectGetStats(ResourceStats{ CPUUsagePercent: 75.0, - MemoryUsedPercent: 40.0, // Below recovery threshold + MemoryUsedPercent: 40.0, }) at.adjustRate() - assert.InDelta(t, 50.0, at.GetCurrentRate(), 0.01) // Should stay at 50 (no increase) + assert.InDelta(t, 50.0, at.GetCurrentRate(), 0.01) // CPU at 65% (below recovery threshold) - should increase mockMonitor.ExpectGetStats(ResourceStats{ @@ -473,13 +528,13 @@ func TestAdaptiveThrottler_Hysteresis(t *testing.T) { MemoryUsedPercent: 40.0, }) at.adjustRate() - assert.InDelta(t, 53.0, at.GetCurrentRate(), 0.01) // 50 + (60-50)*0.3 = 53 (with smoothing) + assert.InDelta(t, 53.0, at.GetCurrentRate(), 0.01) // Test with hysteresis disabled config.EnableHysteresis = false at2, _ := createThrottlerForRateTesting(config, 50.0) - // CPU at 75% (below max threshold) - should increase immediately (no hysteresis) + // CPU at 75% (below max threshold) - should increase immediately mockMonitor.ExpectGetStats(ResourceStats{ CPUUsagePercent: 75.0, MemoryUsedPercent: 40.0, @@ -582,62 +637,6 @@ func TestAdaptiveThrottler_FlowControl(t *testing.T) { } } -func TestAdaptiveThrottler_GetCurrentRate(t *testing.T) { - mockMonitor := &MockMonitor{} - config := DefaultAdaptiveThrottlerConfig() - - at := &AdaptiveThrottler{ - config: *config, - monitor: mockMonitor, - currentRateBits: math.Float64bits(42.5), - } - - rate := at.GetCurrentRate() - if rate != 42.5 { - t.Errorf("Expected rate 42.5, got %f", rate) - } -} - -func TestAdaptiveThrottler_GetResourceStats(t *testing.T) { - mockMonitor := &MockMonitor{} - expectedStats := ResourceStats{ - CPUUsagePercent: 15.5, - MemoryUsedPercent: 25.0, - GoroutineCount: 10, - } - mockMonitor.ExpectGetStats(expectedStats) - - config := DefaultAdaptiveThrottlerConfig() - at := &AdaptiveThrottler{ - config: *config, - monitor: mockMonitor, - } - - stats := at.GetResourceStats() - if stats.CPUUsagePercent != expectedStats.CPUUsagePercent { - t.Errorf("Expected CPU %f, got %f", expectedStats.CPUUsagePercent, stats.CPUUsagePercent) - } - if stats.MemoryUsedPercent != expectedStats.MemoryUsedPercent { - t.Errorf("Expected Memory %f, got %f", expectedStats.MemoryUsedPercent, stats.MemoryUsedPercent) - } -} - -func TestAdaptiveThrottler_Via_ReturnsInputFlow(t *testing.T) { - config := DefaultAdaptiveThrottlerConfig() - at, err := NewAdaptiveThrottler(config) - if err != nil { - t.Fatalf("Failed to create throttler: %v", err) - } - defer at.close() - - mockFlow := &mockFlow{} - resultFlow := at.Via(mockFlow) - - if resultFlow != mockFlow { - t.Error("Via should return the input flow") - } -} - // TestAdaptiveThrottler_To tests To method that streams data to a sink func TestAdaptiveThrottler_To(t *testing.T) { at := createThrottlerWithLongInterval(t) @@ -774,65 +773,58 @@ func TestAdaptiveThrottler_AdjustRate_EdgeCases(t *testing.T) { at, mockMonitor := createThrottlerForRateTesting(config, 50.0) - // Test: Both CPU and memory constrained + // Both CPU and memory constrained mockMonitor.ExpectGetStats(ResourceStats{ - CPUUsagePercent: 90.0, // Above max - MemoryUsedPercent: 90.0, // Above max + CPUUsagePercent: 90.0, + MemoryUsedPercent: 90.0, }) at.adjustRate() - // Should reduce rate: 50 * 0.7 = 35, then smoothed: 50 + (35-50)*0.3 = 45.5 assert.InDelta(t, 45.5, at.GetCurrentRate(), 0.1) - // Test: Rate at max, should not exceed + // Rate at max, should not exceed at.setRate(100.0) mockMonitor.ExpectGetStats(ResourceStats{ CPUUsagePercent: 10.0, MemoryUsedPercent: 10.0, }) at.adjustRate() - // Should try to increase but cap at MaxRate rate := at.GetCurrentRate() if rate > 100.0 { t.Errorf("Rate should not exceed MaxRate, got %f", rate) } - // Test: Rate at min, constrained + // Rate at min, constrained at.setRate(10.0) mockMonitor.ExpectGetStats(ResourceStats{ CPUUsagePercent: 90.0, MemoryUsedPercent: 90.0, }) at.adjustRate() - // Should reduce but not go below MinRate (though MinRate is not enforced in adjustRate) rate = at.GetCurrentRate() if rate < 0 { t.Errorf("Rate should not be negative, got %f", rate) } - // Test: Exactly at thresholds + // Exactly at thresholds at.setRate(50.0) mockMonitor.ExpectGetStats(ResourceStats{ - CPUUsagePercent: 80.0, // Exactly at max (not above, so not constrained) - MemoryUsedPercent: 70.0, // Below max + CPUUsagePercent: 80.0, // Exactly at max + MemoryUsedPercent: 70.0, }) at.adjustRate() - // Should not reduce because CPU is exactly at max (not > max) - // The constraint check uses > not >= rate = at.GetCurrentRate() - // Rate should stay the same or potentially increase if below recovery threshold if rate < 0 { t.Errorf("Rate should not be negative, got %f", rate) } - // Test: Exactly at recovery thresholds with hysteresis + // Exactly at recovery thresholds with hysteresis at.setRate(50.0) config.EnableHysteresis = true mockMonitor.ExpectGetStats(ResourceStats{ CPUUsagePercent: 70.0, // Exactly at recovery threshold - MemoryUsedPercent: 75.0, // Exactly at recovery threshold + MemoryUsedPercent: 75.0, }) at.adjustRate() - // With hysteresis, should not increase (must be below both thresholds) rate = at.GetCurrentRate() if rate > 50.0 { t.Errorf("With hysteresis, rate should not increase at threshold, got %f", rate) @@ -854,26 +846,17 @@ func TestAdaptiveThrottler_PipelineLoop_Shutdown(t *testing.T) { done: make(chan struct{}), } - // Test: Shutdown during processing go at.pipelineLoop() - // Send one item at.in <- "test1" - - // Wait a bit for it to start processing time.Sleep(10 * time.Millisecond) - // Close done channel to trigger shutdown close(at.done) - - // Wait for pipeline to finish (may take a moment for goroutine to exit) time.Sleep(200 * time.Millisecond) - // Verify output channel is closed (pipelineLoop defers close(at.out)) select { case _, ok := <-at.out: if ok { - // Channel still open, wait a bit more time.Sleep(100 * time.Millisecond) select { case _, ok2 := <-at.out: @@ -881,11 +864,9 @@ func TestAdaptiveThrottler_PipelineLoop_Shutdown(t *testing.T) { t.Error("Output channel should be closed after shutdown") } default: - // Channel closed now } } default: - // Channel already closed, which is expected } } @@ -1077,3 +1058,456 @@ func TestAdaptiveThrottler_StreamPortioned_Blocking(t *testing.T) { t.Errorf("Expected 2 items, got %d", len(received)) } } + +func TestAdaptiveThrottler_RealisticProductionScenarios(t *testing.T) { + tests := []struct { + name string + config func() *AdaptiveThrottlerConfig + scenario string + steps []ResourceStats + }{ + { + name: "High CPU Production Scenario", + config: func() *AdaptiveThrottlerConfig { + config := DefaultAdaptiveThrottlerConfig() + config.MaxCPUPercent = 80.0 + config.MaxMemoryPercent = 90.0 + config.InitialRate = 1000 + config.MinRate = 50 + config.MaxRate = 5000 + config.BackoffFactor = 0.7 + config.RecoveryFactor = 1.2 + config.RecoveryCPUThreshold = 70.0 + config.RecoveryMemoryThreshold = 80.0 + return config + }, + scenario: "High CPU usage scenario typical in production", + steps: []ResourceStats{ + {CPUUsagePercent: 85.0, MemoryUsedPercent: 60.0}, + {CPUUsagePercent: 75.0, MemoryUsedPercent: 65.0}, + {CPUUsagePercent: 65.0, MemoryUsedPercent: 70.0}, + {CPUUsagePercent: 55.0, MemoryUsedPercent: 75.0}, + }, + }, + { + name: "Memory Pressure Production Scenario", + config: func() *AdaptiveThrottlerConfig { + config := DefaultAdaptiveThrottlerConfig() + config.MaxCPUPercent = 90.0 + config.MaxMemoryPercent = 85.0 + config.InitialRate = 2000 + config.MinRate = 100 + config.MaxRate = 10000 + config.BackoffFactor = 0.6 + config.RecoveryFactor = 1.3 + config.RecoveryCPUThreshold = 80.0 + config.RecoveryMemoryThreshold = 75.0 + return config + }, + scenario: "Memory pressure scenario common in memory-intensive apps", + steps: []ResourceStats{ + {CPUUsagePercent: 70.0, MemoryUsedPercent: 88.0}, + {CPUUsagePercent: 75.0, MemoryUsedPercent: 82.0}, + {CPUUsagePercent: 65.0, MemoryUsedPercent: 78.0}, + {CPUUsagePercent: 60.0, MemoryUsedPercent: 72.0}, + }, + }, + { + name: "Balanced Production Load", + config: func() *AdaptiveThrottlerConfig { + config := DefaultAdaptiveThrottlerConfig() + config.MaxCPUPercent = 75.0 + config.MaxMemoryPercent = 80.0 + config.InitialRate = 1500 + config.MinRate = 200 + config.MaxRate = 8000 + config.BackoffFactor = 0.75 + config.RecoveryFactor = 1.25 + config.RecoveryCPUThreshold = 65.0 + config.RecoveryMemoryThreshold = 70.0 + return config + }, + scenario: "Balanced load typical for well-tuned production systems", + steps: []ResourceStats{ + {CPUUsagePercent: 78.0, MemoryUsedPercent: 65.0}, + {CPUUsagePercent: 72.0, MemoryUsedPercent: 68.0}, + {CPUUsagePercent: 68.0, MemoryUsedPercent: 75.0}, + {CPUUsagePercent: 65.0, MemoryUsedPercent: 72.0}, + }, + }, + { + name: "Conservative Production Settings", + config: func() *AdaptiveThrottlerConfig { + config := DefaultAdaptiveThrottlerConfig() + config.MaxCPUPercent = 60.0 + config.MaxMemoryPercent = 75.0 + config.InitialRate = 500 + config.MinRate = 50 + config.MaxRate = 2000 + config.BackoffFactor = 0.8 + config.RecoveryFactor = 1.1 + config.RecoveryCPUThreshold = 50.0 + config.RecoveryMemoryThreshold = 65.0 + return config + }, + scenario: "Conservative settings for critical production systems", + steps: []ResourceStats{ + {CPUUsagePercent: 65.0, MemoryUsedPercent: 70.0}, + {CPUUsagePercent: 58.0, MemoryUsedPercent: 72.0}, + {CPUUsagePercent: 55.0, MemoryUsedPercent: 68.0}, + {CPUUsagePercent: 52.0, MemoryUsedPercent: 65.0}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := tt.config() + at, mockMonitor := createThrottlerForRateTesting(config, float64(config.InitialRate)) + + t.Logf("Testing scenario: %s", tt.scenario) + + throttlingTriggered := false + + for i, stats := range tt.steps { + initialRate := at.GetCurrentRate() + mockMonitor.ExpectGetStats(stats) + at.adjustRate() + finalRate := at.GetCurrentRate() + + isConstrained := stats.CPUUsagePercent > config.MaxCPUPercent || + stats.MemoryUsedPercent > config.MaxMemoryPercent + + isBelowRecovery := stats.CPUUsagePercent < config.RecoveryCPUThreshold && + stats.MemoryUsedPercent < config.RecoveryMemoryThreshold + + t.Logf("Step %d: CPU %.1f%%, Mem %.1f%% -> Rate %.1f (constrained: %v, below recovery: %v)", + i+1, stats.CPUUsagePercent, stats.MemoryUsedPercent, finalRate, isConstrained, isBelowRecovery) + + // Verify throttling behavior + if isConstrained { + throttlingTriggered = true + if finalRate > initialRate { + t.Errorf("Step %d: Rate should not increase when constrained, but %.1f > %.1f", + i+1, finalRate, initialRate) + } + } + + // Verify recovery behavior (only if we've seen throttling before) + if throttlingTriggered && !isConstrained { + if config.EnableHysteresis && !isBelowRecovery { + // With hysteresis, rate should not increase until both resources are below recovery thresholds + if finalRate > initialRate { + t.Errorf("Step %d: With hysteresis, rate should not increase until both resources below recovery thresholds", + i+1) + } + } else if !config.EnableHysteresis || isBelowRecovery { + // Without hysteresis or when below recovery thresholds, rate should be able to increase + if finalRate < initialRate { + t.Errorf("Step %d: Rate should not decrease during recovery, but %.1f < %.1f", + i+1, finalRate, initialRate) + } + } + } + + // Verify rate bounds + if finalRate < float64(config.MinRate) { + t.Errorf("Step %d: Rate %.1f below MinRate %d", i+1, finalRate, config.MinRate) + } + if finalRate > float64(config.MaxRate) { + t.Errorf("Step %d: Rate %.1f above MaxRate %d", i+1, finalRate, config.MaxRate) + } + } + + // Ensure we actually tested throttling behavior + if !throttlingTriggered { + t.Errorf("Test scenario should have triggered throttling at least once") + } + }) + } +} + +// TestAdaptiveThrottler_SustainedLoadScenarios tests behavior under prolonged load +func TestAdaptiveThrottler_SustainedLoadScenarios(t *testing.T) { + tests := []struct { + name string + config func() *AdaptiveThrottlerConfig + loadPattern []ResourceStats + description string + }{ + { + name: "Sustained High CPU Load", + config: func() *AdaptiveThrottlerConfig { + config := DefaultAdaptiveThrottlerConfig() + config.MaxCPUPercent = 75.0 + config.MaxMemoryPercent = 90.0 + config.InitialRate = 2000 + config.MinRate = 100 + config.MaxRate = 10000 + config.BackoffFactor = 0.7 + config.RecoveryFactor = 1.2 + return config + }, + loadPattern: []ResourceStats{ + {CPUUsagePercent: 80.0, MemoryUsedPercent: 60.0}, + {CPUUsagePercent: 82.0, MemoryUsedPercent: 62.0}, + {CPUUsagePercent: 78.0, MemoryUsedPercent: 64.0}, + {CPUUsagePercent: 85.0, MemoryUsedPercent: 66.0}, + {CPUUsagePercent: 81.0, MemoryUsedPercent: 68.0}, + }, + description: "Sustained high CPU usage with some variation", + }, + { + name: "Memory Pressure Buildup", + config: func() *AdaptiveThrottlerConfig { + config := DefaultAdaptiveThrottlerConfig() + config.MaxCPUPercent = 85.0 + config.MaxMemoryPercent = 80.0 + config.InitialRate = 3000 + config.MinRate = 200 + config.MaxRate = 15000 + config.BackoffFactor = 0.6 + config.RecoveryFactor = 1.15 + return config + }, + loadPattern: []ResourceStats{ + {CPUUsagePercent: 70.0, MemoryUsedPercent: 75.0}, + {CPUUsagePercent: 72.0, MemoryUsedPercent: 78.0}, + {CPUUsagePercent: 68.0, MemoryUsedPercent: 82.0}, + {CPUUsagePercent: 71.0, MemoryUsedPercent: 85.0}, + {CPUUsagePercent: 69.0, MemoryUsedPercent: 83.0}, + }, + description: "Gradual memory pressure buildup typical of memory leaks", + }, + { + name: "Mixed Resource Contention", + config: func() *AdaptiveThrottlerConfig { + config := DefaultAdaptiveThrottlerConfig() + config.MaxCPUPercent = 70.0 + config.MaxMemoryPercent = 75.0 + config.InitialRate = 2500 + config.MinRate = 300 + config.MaxRate = 12000 + config.BackoffFactor = 0.65 + config.RecoveryFactor = 1.25 + config.EnableHysteresis = true + return config + }, + loadPattern: []ResourceStats{ + {CPUUsagePercent: 75.0, MemoryUsedPercent: 72.0}, + {CPUUsagePercent: 68.0, MemoryUsedPercent: 78.0}, + {CPUUsagePercent: 72.0, MemoryUsedPercent: 76.0}, + {CPUUsagePercent: 69.0, MemoryUsedPercent: 74.0}, + {CPUUsagePercent: 66.0, MemoryUsedPercent: 71.0}, + }, + description: "Mixed CPU and memory pressure with hysteresis", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := tt.config() + at, mockMonitor := createThrottlerForRateTesting(config, float64(config.InitialRate)) + + t.Logf("Testing sustained load: %s", tt.description) + + var finalRate float64 + for i, stats := range tt.loadPattern { + mockMonitor.ExpectGetStats(stats) + at.adjustRate() + finalRate = at.GetCurrentRate() + + t.Logf("Iteration %d: CPU %.1f%%, Mem %.1f%% -> Rate %.1f", + i+1, stats.CPUUsagePercent, stats.MemoryUsedPercent, finalRate) + } + + // Calculate expected rate range algorithmically + minExpectedRate, maxExpectedRate := calculateExpectedRateRange(config, float64(config.InitialRate), tt.loadPattern) + + t.Logf("Expected final rate range: [%.1f, %.1f], actual: %.1f", minExpectedRate, maxExpectedRate, finalRate) + + if finalRate < minExpectedRate || finalRate > maxExpectedRate { + t.Errorf("Final rate %.1f outside expected range [%.1f, %.1f] for sustained load scenario", + finalRate, minExpectedRate, maxExpectedRate) + } + + // Verify rate doesn't oscillate wildly in final iterations + // (This would be a sign of poor hysteresis or smoothing) + if finalRate < float64(config.MinRate)*0.9 { + t.Errorf("Final rate %.1f too close to MinRate %d, indicating possible oscillation", + finalRate, config.MinRate) + } + }) + } +} + +// TestAdaptiveThrottler_RecoveryScenarios tests recovery from high load to normal load +func TestAdaptiveThrottler_RecoveryScenarios(t *testing.T) { + tests := []struct { + name string + config func() *AdaptiveThrottlerConfig + highLoad ResourceStats + normalLoad ResourceStats + description string + }{ + { + name: "CPU Spike Recovery", + config: func() *AdaptiveThrottlerConfig { + config := DefaultAdaptiveThrottlerConfig() + config.MaxCPUPercent = 80.0 + config.MaxMemoryPercent = 90.0 + config.InitialRate = 2000 + config.MinRate = 200 + config.MaxRate = 10000 + config.BackoffFactor = 0.6 + config.RecoveryFactor = 1.4 + config.SampleInterval = 100 * time.Millisecond + config.RecoveryCPUThreshold = 70.0 + config.RecoveryMemoryThreshold = 80.0 + return config + }, + highLoad: ResourceStats{CPUUsagePercent: 85.0, MemoryUsedPercent: 70.0}, + normalLoad: ResourceStats{CPUUsagePercent: 60.0, MemoryUsedPercent: 65.0}, + description: "Recovery from CPU spike to normal load", + }, + { + name: "Memory Pressure Recovery", + config: func() *AdaptiveThrottlerConfig { + config := DefaultAdaptiveThrottlerConfig() + config.MaxCPUPercent = 90.0 + config.MaxMemoryPercent = 75.0 + config.InitialRate = 3000 + config.MinRate = 300 + config.MaxRate = 15000 + config.BackoffFactor = 0.5 + config.RecoveryFactor = 1.3 + config.RecoveryCPUThreshold = 80.0 + config.RecoveryMemoryThreshold = 65.0 + return config + }, + highLoad: ResourceStats{CPUUsagePercent: 70.0, MemoryUsedPercent: 85.0}, + normalLoad: ResourceStats{CPUUsagePercent: 65.0, MemoryUsedPercent: 60.0}, + description: "Recovery from memory pressure to normal load", + }, + { + name: "Dual Resource Recovery with Hysteresis", + config: func() *AdaptiveThrottlerConfig { + config := DefaultAdaptiveThrottlerConfig() + config.MaxCPUPercent = 75.0 + config.MaxMemoryPercent = 80.0 + config.InitialRate = 2500 + config.MinRate = 250 + config.MaxRate = 12000 + config.BackoffFactor = 0.65 + config.RecoveryFactor = 1.25 + config.EnableHysteresis = true + config.RecoveryCPUThreshold = 65.0 + config.RecoveryMemoryThreshold = 70.0 + return config + }, + highLoad: ResourceStats{CPUUsagePercent: 85.0, MemoryUsedPercent: 85.0}, + normalLoad: ResourceStats{CPUUsagePercent: 60.0, MemoryUsedPercent: 65.0}, + description: "Recovery from dual resource pressure with hysteresis", + }, + { + name: "Recovery Without Hysteresis", + config: func() *AdaptiveThrottlerConfig { + config := DefaultAdaptiveThrottlerConfig() + config.MaxCPUPercent = 75.0 + config.MaxMemoryPercent = 80.0 + config.InitialRate = 2500 + config.MinRate = 250 + config.MaxRate = 12000 + config.BackoffFactor = 0.65 + config.RecoveryFactor = 1.25 + config.EnableHysteresis = false + config.RecoveryCPUThreshold = 65.0 + config.RecoveryMemoryThreshold = 70.0 + return config + }, + highLoad: ResourceStats{CPUUsagePercent: 85.0, MemoryUsedPercent: 85.0}, + normalLoad: ResourceStats{CPUUsagePercent: 70.0, MemoryUsedPercent: 75.0}, + description: "Recovery without hysteresis (faster recovery)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := tt.config() + at, mockMonitor := createThrottlerForRateTesting(config, float64(config.InitialRate)) + + t.Logf("Testing recovery: %s", tt.description) + t.Logf("Config - CPU Max: %.1f%% (Recovery: %.1f%%), Mem Max: %.1f%% (Recovery: %.1f%%), Hysteresis: %v", + config.MaxCPUPercent, config.RecoveryCPUThreshold, + config.MaxMemoryPercent, config.RecoveryMemoryThreshold, config.EnableHysteresis) + + // Start with high load - should trigger throttling + mockMonitor.ExpectGetStats(tt.highLoad) + at.adjustRate() + highLoadRate := at.GetCurrentRate() + + if highLoadRate >= float64(config.InitialRate) { + t.Errorf("High load should reduce rate, but got %.1f >= %d", + highLoadRate, config.InitialRate) + } + + t.Logf("High load (CPU:%.1f%%, Mem:%.1f%%) -> Rate: %.1f", + tt.highLoad.CPUUsagePercent, tt.highLoad.MemoryUsedPercent, highLoadRate) + + // Transition to normal load - recovery behavior depends on hysteresis + mockMonitor.ExpectGetStats(tt.normalLoad) + at.adjustRate() + recoveryRate1 := at.GetCurrentRate() + + t.Logf("Normal load (CPU:%.1f%%, Mem:%.1f%%) -> Rate: %.1f", + tt.normalLoad.CPUUsagePercent, tt.normalLoad.MemoryUsedPercent, recoveryRate1) + + // With hysteresis disabled, rate should increase immediately if not constrained + isConstrained := tt.normalLoad.CPUUsagePercent > config.MaxCPUPercent || + tt.normalLoad.MemoryUsedPercent > config.MaxMemoryPercent + + if !isConstrained && !config.EnableHysteresis { + if recoveryRate1 <= highLoadRate { + t.Errorf("Without hysteresis, rate should increase when not constrained, but %.1f <= %.1f", + recoveryRate1, highLoadRate) + } + } + + // Continue recovery for a few more cycles to allow hysteresis recovery + for i := 0; i < 5; i++ { + mockMonitor.ExpectGetStats(tt.normalLoad) + at.adjustRate() + } + finalRecoveryRate := at.GetCurrentRate() + + t.Logf("After recovery cycles: Rate: %.1f", finalRecoveryRate) + + // Calculate expected recovery rate algorithmically + expectedRecoveryRate := calculateExpectedRecoveryRate( + config, + float64(config.InitialRate), + tt.highLoad, + tt.normalLoad, + 5, + ) + tolerance := 0.05 // 5% tolerance for floating point precision + + t.Logf("Expected recovery rate: %.1f, actual: %.1f", expectedRecoveryRate, finalRecoveryRate) + + // Verify recovery rate is within expected range + minExpected := expectedRecoveryRate * (1 - tolerance) + maxExpected := expectedRecoveryRate * (1 + tolerance) + + if finalRecoveryRate < minExpected || finalRecoveryRate > maxExpected { + t.Errorf("Final recovery rate %.1f outside expected range [%.1f, %.1f]", + finalRecoveryRate, minExpected, maxExpected) + } + + // Should not exceed MaxRate + if finalRecoveryRate > float64(config.MaxRate) { + t.Errorf("Recovery rate %.1f should not exceed MaxRate %d", + finalRecoveryRate, config.MaxRate) + } + }) + } +} diff --git a/flow/resource_monitor.go b/flow/resource_monitor.go index 4a0b6ea..cb47ede 100644 --- a/flow/resource_monitor.go +++ b/flow/resource_monitor.go @@ -44,11 +44,12 @@ type ResourceMonitor struct { memoryReader func() (float64, error) // Custom memory usage reader. // Runtime state - stats atomic.Pointer[ResourceStats] // Latest resource statistics. - sampler sysmonitor.ProcessCPUSampler // CPU usage sampler implementation. - updateIntervalCh chan time.Duration // Channel for dynamic interval updates. - done chan struct{} // Signals monitoring loop termination. - closeOnce sync.Once // Ensures clean shutdown. + stats atomic.Pointer[ResourceStats] // Latest resource statistics. + sampler sysmonitor.ProcessCPUSampler // CPU usage sampler implementation. + memoryReaderInstance sysmonitor.ProcessMemoryReader // System memory reader instance. + updateIntervalCh chan time.Duration // Channel for dynamic interval updates. + done chan struct{} // Signals monitoring loop termination. + closeOnce sync.Once // Ensures clean shutdown. } // newResourceMonitor creates a new resource monitor instance. @@ -72,6 +73,7 @@ func newResourceMonitor( }) rm.initSampler() + rm.initMemoryReader() go rm.monitor() return rm @@ -108,7 +110,7 @@ func (rm *ResourceMonitor) SetMode(newMode CPUUsageMode) { switch newMode { case CPUUsageModeMeasured: // Try to switch to measured mode - if sampler, err := sysmonitor.NewProcessSampler(); err == nil { + if sampler, err := sysmonitor.NewCPUSampler(sysmonitor.OSFileSystem{}); err == nil { rm.sampler = sampler rm.cpuMode = CPUUsageModeMeasured } else { @@ -123,7 +125,7 @@ func (rm *ResourceMonitor) SetMode(newMode CPUUsageMode) { // initSampler initializes the appropriate CPU usage sampler. // Uses measured mode by default if available func (rm *ResourceMonitor) initSampler() { - if sampler, err := sysmonitor.NewProcessSampler(); err == nil { + if sampler, err := sysmonitor.NewCPUSampler(sysmonitor.OSFileSystem{}); err == nil { rm.sampler = sampler rm.cpuMode = CPUUsageModeMeasured } else { @@ -133,6 +135,12 @@ func (rm *ResourceMonitor) initSampler() { } } +// initMemoryReader initializes the system memory reader. +// This reader is reused across all sampling operations. +func (rm *ResourceMonitor) initMemoryReader() { + rm.memoryReaderInstance = sysmonitor.NewProcessMemoryReader(sysmonitor.OSFileSystem{}) +} + // monitor runs the continuous resource sampling loop. // Handles dynamic interval changes and graceful shutdown. func (rm *ResourceMonitor) monitor() { @@ -167,16 +175,18 @@ func (rm *ResourceMonitor) sample() { } // Memory Usage + switch { // Check if a custom memory reader is provided - if rm.memoryReader != nil { + case rm.memoryReader != nil: if mem, err := rm.memoryReader(); err == nil { stats.MemoryUsedPercent = mem } - // If not, use system memory stats - } else if memStats, err := sysmonitor.GetSystemMemory(); err == nil && memStats.Total > 0 { - used := memStats.Total - memStats.Available - stats.MemoryUsedPercent = float64(used) / float64(memStats.Total) * 100 - } else { + case rm.memoryReaderInstance != nil: + if memStats, err := rm.memoryReaderInstance.Sample(); err == nil && memStats.Total > 0 { + used := memStats.Total - memStats.Available + stats.MemoryUsedPercent = float64(used) / float64(memStats.Total) * 100 + } + default: // Fallback to runtime memory stats var m runtime.MemStats runtime.ReadMemStats(&m) @@ -186,8 +196,7 @@ func (rm *ResourceMonitor) sample() { } // CPU Usage - cpu := rm.sampler.Sample(rm.sampleInterval) - stats.CPUUsagePercent = cpu + stats.CPUUsagePercent = rm.sampler.Sample(rm.sampleInterval) rm.stats.Store(stats) } @@ -251,7 +260,9 @@ func (r *monitorRegistry) Acquire( // Cancel pending stop if we are resurrecting within the grace period if r.stopTimer != nil { - r.stopTimer.Stop() + if !r.stopTimer.Stop() { + <-r.stopTimer.C + } r.stopTimer = nil } diff --git a/flow/resource_monitor_test.go b/flow/resource_monitor_test.go index 4038e68..1fc105a 100644 --- a/flow/resource_monitor_test.go +++ b/flow/resource_monitor_test.go @@ -647,52 +647,6 @@ func TestResourceMonitor_BoundaryConditions(t *testing.T) { }) } -// TestResourceMonitor_ExtremeMemoryValues tests handling of extreme memory values -func TestResourceMonitor_ExtremeMemoryValues(t *testing.T) { - setupTest(t) - - testCases := []struct { - name string - memValue float64 - }{ - {"zero memory", 0.0}, - {"negative memory", -50.0}, - {"normal memory", 75.5}, - {"max memory", 100.0}, - {"over max memory", 150.0}, - {"very large memory", 1e6}, - {"NaN memory", math.NaN()}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - mockReader := func() (float64, error) { - return tc.memValue, nil - } - - rm := newResourceMonitor(time.Hour, CPUUsageModeHeuristic, mockReader) - defer rm.stop() - - rm.sample() - stats := rm.GetStats() - - // The implementation currently passes through any value from the custom reader - // without validation. This documents the current behavior. - if stats.MemoryUsedPercent != tc.memValue && !math.IsNaN(tc.memValue) { - t.Errorf("Expected memory %f, got %f", tc.memValue, stats.MemoryUsedPercent) - } - if math.IsNaN(tc.memValue) && !math.IsNaN(stats.MemoryUsedPercent) { - t.Errorf("Expected NaN memory, got %f", stats.MemoryUsedPercent) - } - - // Always check other stats are reasonable - if stats.GoroutineCount <= 0 { - t.Errorf("Expected goroutine count > 0, got %d", stats.GoroutineCount) - } - }) - } -} - // TestResourceMonitor_Stop tests the stop method for cleanup and shutdown func TestResourceMonitor_Stop(t *testing.T) { setupTest(t) @@ -794,23 +748,6 @@ func TestResourceMonitor_GetStats_Concurrent(t *testing.T) { } } -// TestResourceMonitor_GetStats_NilPointer tests GetStats with nil pointer -func TestResourceMonitor_GetStats_NilPointer(t *testing.T) { - setupTest(t) - - rm := newResourceMonitor(time.Hour, CPUUsageModeHeuristic, nil) - defer rm.stop() - - // Manually set stats to nil (simulating edge case) - rm.stats.Store(nil) - - // GetStats should return empty ResourceStats, not panic - stats := rm.GetStats() - if !stats.Timestamp.IsZero() { - t.Error("Expected zero timestamp for nil stats") - } -} - // TestSharedMonitorHandle_Close tests Close method for cleanup func TestSharedMonitorHandle_Close(t *testing.T) { setupTest(t) @@ -934,30 +871,70 @@ func TestMonitorRegistry_CalculateMinInterval(t *testing.T) { } } -// TestMonitorRegistry_CalculateMinInterval_EdgeCases tests edge cases for calculateMinInterval -func TestMonitorRegistry_CalculateMinInterval_EdgeCases(t *testing.T) { - tests := []struct { - name string - intervals map[time.Duration]int - expected time.Duration - }{ - {"single nanosecond", map[time.Duration]int{time.Nanosecond: 1}, time.Nanosecond}, - {"multiple same intervals", map[time.Duration]int{time.Second: 5}, time.Second}, - {"mixed with duplicates", map[time.Duration]int{ - 100 * time.Millisecond: 2, - 200 * time.Millisecond: 1, - }, 100 * time.Millisecond}, +// TestResourceMonitor_GetStats_NilStats tests GetStats when stats pointer is nil +func TestResourceMonitor_GetStats_NilStats(t *testing.T) { + setupTest(t) + + rm := newResourceMonitor(time.Hour, CPUUsageModeHeuristic, nil) + defer rm.stop() + + // Manually set stats to nil to simulate uninitialized state + rm.stats.Store(nil) + + // GetStats should return empty ResourceStats without panicking + stats := rm.GetStats() + + // Verify it returns zero/empty stats + if !stats.Timestamp.IsZero() { + t.Error("Expected zero timestamp for nil stats") + } + if stats.CPUUsagePercent != 0 { + t.Errorf("Expected zero CPU usage, got %f", stats.CPUUsagePercent) } + if stats.MemoryUsedPercent != 0 { + t.Errorf("Expected zero memory usage, got %f", stats.MemoryUsedPercent) + } + if stats.GoroutineCount != 0 { + t.Errorf("Expected zero goroutine count, got %d", stats.GoroutineCount) + } +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - setupTest(t) +// TestResourceMonitor_Sample_RuntimeMemoryFallback tests sample method fallback to runtime memory stats +func TestResourceMonitor_Sample_RuntimeMemoryFallback(t *testing.T) { + setupTest(t) - globalMonitorRegistry.intervalRefs = tt.intervals - minInterval := globalMonitorRegistry.calculateMinInterval() - if minInterval != tt.expected { - t.Errorf("Expected %v, got %v", tt.expected, minInterval) - } - }) + // Create monitor with nil memory reader and instance to force fallback + rm := newResourceMonitor(time.Hour, CPUUsageModeHeuristic, nil) + defer rm.stop() + + // Ensure memory reader instance is nil (fallback path) + rm.memoryReaderInstance = nil + + // Record initial stats before sampling + initialStats := rm.GetStats() + + // Trigger sampling + rm.sample() + + // Get updated stats + stats := rm.GetStats() + + // Verify basic stats are populated + assertValidStats(t, stats) + if !stats.Timestamp.After(initialStats.Timestamp) { + t.Error("Timestamp should be updated after sampling") + } + + // Verify CPU usage is set (even if 0, it's still assigned) + // Note: CPU usage could be 0 in heuristic mode, which is valid + + // Verify goroutine count is reasonable + if stats.GoroutineCount <= 0 { + t.Errorf("Expected goroutine count > 0, got %d", stats.GoroutineCount) + } + + // Memory usage should be a valid percentage (runtime.ReadMemStats fallback) + if stats.MemoryUsedPercent < 0 || stats.MemoryUsedPercent > 100 { + t.Errorf("Expected memory percentage in range [0,100], got %f", stats.MemoryUsedPercent) } } diff --git a/internal/sysmonitor/cpu.go b/internal/sysmonitor/cpu.go index af060f7..eeda870 100644 --- a/internal/sysmonitor/cpu.go +++ b/internal/sysmonitor/cpu.go @@ -31,6 +31,6 @@ type ProcessCPUSampler interface { // NewProcessSampler creates a CPU sampler for the current process. // Automatically selects platform-specific implementation (Linux/Darwin/Windows). // Returns error on unsupported platforms or if sampler creation fails. -func NewProcessSampler() (ProcessCPUSampler, error) { - return newProcessSampler() +func NewCPUSampler(fs FileSystem) (ProcessCPUSampler, error) { + return newPlatformCPUSampler(fs) } diff --git a/internal/sysmonitor/cpu_darwin.go b/internal/sysmonitor/cpu_darwin.go index 3f07030..a35c7cd 100644 --- a/internal/sysmonitor/cpu_darwin.go +++ b/internal/sysmonitor/cpu_darwin.go @@ -11,8 +11,8 @@ import ( "time" ) -// ProcessSampler samples CPU usage for the current process -type ProcessSampler struct { +// darwinProcessSampler samples CPU usage for the current process on macOS +type darwinProcessSampler struct { pid int lastUTime float64 lastSTime float64 @@ -20,20 +20,20 @@ type ProcessSampler struct { lastPercent float64 } -// newProcessSampler creates a new CPU sampler for the current process -func newProcessSampler() (*ProcessSampler, error) { +// newPlatformCPUSampler matches the factory signature required by cpu.go. +func newPlatformCPUSampler(_ FileSystem) (ProcessCPUSampler, error) { pid := os.Getpid() if pid < 0 || pid > math.MaxInt32 { return nil, fmt.Errorf("invalid PID: %d", pid) } - return &ProcessSampler{ + return &darwinProcessSampler{ pid: pid, }, nil } // Sample returns the CPU usage percentage since the last sample -func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { +func (s *darwinProcessSampler) Sample(deltaTime time.Duration) float64 { utime, stime, err := s.readProcessTimesDarwin() if err != nil { return s.lastPercent @@ -84,7 +84,7 @@ func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { } // Reset clears sampler state for a new session -func (s *ProcessSampler) Reset() { +func (s *darwinProcessSampler) Reset() { s.lastUTime = 0 s.lastSTime = 0 s.lastSample = time.Time{} @@ -92,18 +92,20 @@ func (s *ProcessSampler) Reset() { } // IsInitialized returns true if at least one sample has been taken -func (s *ProcessSampler) IsInitialized() bool { +func (s *darwinProcessSampler) IsInitialized() bool { return !s.lastSample.IsZero() } // readProcessTimesDarwin reads CPU times via syscall.Getrusage (returns seconds) -func (s *ProcessSampler) readProcessTimesDarwin() (utime, stime float64, err error) { +func (s *darwinProcessSampler) readProcessTimesDarwin() (utime, stime float64, err error) { var rusage syscall.Rusage + err = syscall.Getrusage(syscall.RUSAGE_SELF, &rusage) if err != nil { return 0, 0, fmt.Errorf("failed to get process resource usage: %w", err) } + // Convert Timeval (Sec/Usec) to float64 seconds utime = float64(rusage.Utime.Sec) + float64(rusage.Utime.Usec)/1e6 stime = float64(rusage.Stime.Sec) + float64(rusage.Stime.Usec)/1e6 return utime, stime, nil diff --git a/internal/sysmonitor/cpu_darwin_test.go b/internal/sysmonitor/cpu_darwin_test.go index 5461d3c..86af86d 100644 --- a/internal/sysmonitor/cpu_darwin_test.go +++ b/internal/sysmonitor/cpu_darwin_test.go @@ -3,24 +3,73 @@ package sysmonitor import ( - "math" "testing" + "time" ) -// TestNewProcessSampler_InvalidPID tests the error path for invalid PID. -func TestNewProcessSampler_InvalidPID(t *testing.T) { - sampler, err := newProcessSampler() +func TestDarwinSamplerIntegration(t *testing.T) { + sampler, err := newPlatformCPUSampler(nil) if err != nil { - t.Fatalf("newProcessSampler failed with valid PID: %v", err) + t.Fatalf("Failed to create Darwin sampler: %v", err) } - if sampler == nil { - t.Fatal("newProcessSampler should not return nil on success") + + if sampler.IsInitialized() { + t.Error("Should not be initialized initially") + } + + // First sample + sampler.Sample(time.Second) + if !sampler.IsInitialized() { + t.Error("Should be initialized after sample") + } + + // Generate CPU load + go func() { + // Burn CPU for 100ms + end := time.Now().Add(100 * time.Millisecond) + for time.Now().Before(end) { + } + }() + + // Wait and sample + time.Sleep(200 * time.Millisecond) + + val := sampler.Sample(0) + t.Logf("Darwin CPU Sample: %f%%", val) + + if val <= 0.0 { + t.Error("Expected detected CPU usage > 0") } - if sampler.pid <= 0 { - t.Errorf("PID should be positive, got %d", sampler.pid) + // Test bounds checking by creating edge case scenarios + testBoundsChecking(t) +} + +func testBoundsChecking(t *testing.T) { + samplerRaw, err := newPlatformCPUSampler(nil) + if err != nil { + t.Fatalf("Failed to create Darwin sampler: %v", err) + } + sampler := samplerRaw.(*darwinProcessSampler) + + // First sample to initialize + sampler.Sample(time.Second) + + // Test early return when elapsed time is too short + // (this tests the "elapsed < deltaTime/2" condition) + shortInterval := 10 * time.Millisecond + result := sampler.Sample(shortInterval) + + // Should return the last known value (0.0) due to short interval + if result != 0.0 { + t.Errorf("Expected last value 0.0 for short interval, got %f", result) } - if sampler.pid > math.MaxInt32 { - t.Errorf("PID should not exceed MaxInt32, got %d", sampler.pid) + + // Test that bounds checking works (though hard to trigger negative values in real usage) + // The bounds checking (percent < 0.0 and percent > 100.0) is tested implicitly + // by ensuring normal operation stays within bounds + normalResult := sampler.Sample(time.Second) + if normalResult < 0.0 || normalResult > 100.0 { + t.Errorf("CPU percentage %f is out of valid range [0, 100]", normalResult) } } diff --git a/internal/sysmonitor/cpu_fallback.go b/internal/sysmonitor/cpu_fallback.go index e655cf7..fe54e7f 100644 --- a/internal/sysmonitor/cpu_fallback.go +++ b/internal/sysmonitor/cpu_fallback.go @@ -1,29 +1,11 @@ -//go:build !linux && !darwin && !windows +//go:build !linux && !windows && !darwin package sysmonitor -import ( - "errors" - "time" -) - -// ProcessSampler is a stub implementation for unsupported platforms -type ProcessSampler struct{} - -// newProcessSampler returns an error on unsupported platforms -func newProcessSampler() (*ProcessSampler, error) { - return nil, errors.New("CPU monitoring not supported on this platform") -} - -// Sample always returns 0.0 on unsupported platforms -func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { - return 0.0 -} - -// Reset does nothing on unsupported platforms -func (s *ProcessSampler) Reset() {} - -// IsInitialized always returns false on unsupported platforms -func (s *ProcessSampler) IsInitialized() bool { - return false +// newPlatformCPUSampler returns the heuristic sampler for unsupported OSs. +func newPlatformCPUSampler(_ FileSystem) (ProcessCPUSampler, error) { + // On unsupported platforms, we fall back to the GoroutineHeuristicSampler + // defined in cpu_heuristic.go. This ensures the application can still + // report an estimated "load" based on internal activity. + return NewGoroutineHeuristicSampler(), nil } diff --git a/internal/sysmonitor/heuristic.go b/internal/sysmonitor/cpu_heuristic.go similarity index 83% rename from internal/sysmonitor/heuristic.go rename to internal/sysmonitor/cpu_heuristic.go index 4bdbae4..ba5c656 100644 --- a/internal/sysmonitor/heuristic.go +++ b/internal/sysmonitor/cpu_heuristic.go @@ -19,18 +19,21 @@ const ( CPUHeuristicMaxCPU = 95.0 ) -// GoroutineHeuristicSampler uses goroutine count as a CPU usage proxy +// GoroutineHeuristicSampler uses goroutine count as a CPU usage proxy. +// It implements the ProcessCPUSampler interface. type GoroutineHeuristicSampler struct{} -// NewGoroutineHeuristicSampler creates a new heuristic CPU sampler -func NewGoroutineHeuristicSampler() ProcessCPUSampler { +// NewGoroutineHeuristicSampler creates a new heuristic CPU sampler. +func NewGoroutineHeuristicSampler() *GoroutineHeuristicSampler { return &GoroutineHeuristicSampler{} } // Verify implementation of ProcessCPUSampler interface var _ ProcessCPUSampler = &GoroutineHeuristicSampler{} -// Sample returns the CPU usage percentage over the given time delta +// Sample returns the CPU usage percentage over the given time delta. +// Since this is a heuristic, the 'delta' argument is ignored as the +// calculation is based on instantaneous state (goroutine count). func (s *GoroutineHeuristicSampler) Sample(_ time.Duration) float64 { // Uses logarithmic scaling for more realistic CPU estimation // Base level: 1-10 goroutines = baseline CPU usage (10-20%) @@ -57,12 +60,15 @@ func (s *GoroutineHeuristicSampler) Sample(_ time.Duration) float64 { return estimatedCPU } -// Reset prepares the sampler for a new sampling session +// Reset prepares the sampler for a new sampling session. +// No-op for the heuristic sampler as it has no state. func (s *GoroutineHeuristicSampler) Reset() { // No state to reset for heuristic sampler + _ = s } -// IsInitialized returns true if the sampler has been initialized with at least one sample +// IsInitialized returns true if the sampler has been initialized. +// Always true for heuristic sampler as it needs no baseline. func (s *GoroutineHeuristicSampler) IsInitialized() bool { return true } diff --git a/internal/sysmonitor/cpu_heuristic_test.go b/internal/sysmonitor/cpu_heuristic_test.go new file mode 100644 index 0000000..46785a1 --- /dev/null +++ b/internal/sysmonitor/cpu_heuristic_test.go @@ -0,0 +1,96 @@ +package sysmonitor + +import ( + "runtime" + "sync" + "testing" + "time" +) + +func TestGoroutineHeuristicSampler(t *testing.T) { + sampler := NewGoroutineHeuristicSampler() + + // Test 1: Initialization + if !sampler.IsInitialized() { + t.Error("Heuristic sampler should always be initialized") + } + + // Test 2: Reset (should be no-op) + sampler.Reset() + if !sampler.IsInitialized() { + t.Error("Heuristic sampler should verify initialized after reset") + } + + // Test 3: Baseline Logic + // We expect at least the baseline CPU usage + val := sampler.Sample(time.Second) + if val < CPUHeuristicBaselineCPU { + t.Errorf("Expected baseline CPU >= %f, got %f", CPUHeuristicBaselineCPU, val) + } + + // Test 4: Scaling Logic + // We spawn a significant number of goroutines to force the count up + // and verify the "CPU usage" increases. + baseCount := runtime.NumGoroutine() + targetIncrease := 50 // Add 50 goroutines to trigger scaling + + var wg sync.WaitGroup + wg.Add(targetIncrease) + + // Channel to keep goroutines alive + hold := make(chan struct{}) + + for i := 0; i < targetIncrease; i++ { + go func() { + wg.Done() + <-hold + }() + } + + // Wait for all to start + wg.Wait() + + // Sample again with higher load + highLoadVal := sampler.Sample(time.Second) + + // Cleanup + close(hold) + + if highLoadVal <= val && baseCount < CPUHeuristicMaxGoroutinesForLinear { + t.Errorf("Expected higher CPU estimate with more goroutines. Low: %f, High: %f", val, highLoadVal) + } + + if highLoadVal > CPUHeuristicMaxCPU { + t.Errorf("CPU estimate %f exceeded max cap %f", highLoadVal, CPUHeuristicMaxCPU) + } + + // Test CPU cap is enforced - spawn many goroutines to trigger cap + manyGoroutines := 1000 // This should trigger logarithmic scaling and cap + var wg2 sync.WaitGroup + wg2.Add(manyGoroutines) + + // Channel to keep goroutines alive + hold2 := make(chan struct{}) + + for i := 0; i < manyGoroutines; i++ { + go func() { + wg2.Done() + <-hold2 + }() + } + + // Wait for all to start + wg2.Wait() + + // Sample with many goroutines - should be capped at CPUHeuristicMaxCPU + cappedVal := sampler.Sample(time.Second) + t.Logf("CPU estimate with %d goroutines: %f (max allowed: %f)", + manyGoroutines, cappedVal, CPUHeuristicMaxCPU) + if cappedVal > CPUHeuristicMaxCPU { + t.Errorf("CPU estimate %f exceeded max cap %f even with %d goroutines", + cappedVal, CPUHeuristicMaxCPU, manyGoroutines) + } + + // Cleanup + close(hold2) +} diff --git a/internal/sysmonitor/cpu_linux.go b/internal/sysmonitor/cpu_linux.go index b6a20ee..822f43e 100644 --- a/internal/sysmonitor/cpu_linux.go +++ b/internal/sysmonitor/cpu_linux.go @@ -6,7 +6,6 @@ import ( "bytes" "encoding/binary" "fmt" - "io" "math" "os" "runtime" @@ -15,8 +14,9 @@ import ( "time" ) -// ProcessSampler samples CPU usage for the current process -type ProcessSampler struct { +// linuxProcessSampler samples CPU usage for the current process on Linux +type linuxProcessSampler struct { + fs FileSystem pid int lastUTime float64 lastSTime float64 @@ -25,33 +25,39 @@ type ProcessSampler struct { clockTicks int64 } -// newProcessSampler creates a CPU sampler for the current process -func newProcessSampler() (*ProcessSampler, error) { +// newPlatformCPUSampler is the factory entry point used by cpu.go +func newPlatformCPUSampler(fs FileSystem) (ProcessCPUSampler, error) { pid := os.Getpid() if pid < 0 || pid > math.MaxInt32 { return nil, fmt.Errorf("invalid PID: %d", pid) } - clockTicks, err := getClockTicks() + // We attempt to get clock ticks using the provided FS. + // If it fails, we fallback to 100. + ticks, err := getClockTicks(fs) if err != nil { - clockTicks = 100 // fallback + ticks = 100 } - return &ProcessSampler{ + return &linuxProcessSampler{ + fs: fs, pid: pid, - clockTicks: clockTicks, + clockTicks: ticks, }, nil } // Sample returns the CPU usage percentage since the last sample -func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { - utime, stime, err := s.readProcessTimes() - if err != nil { - return s.lastPercent // Return last known value on error - } - +func (s *linuxProcessSampler) Sample(deltaTime time.Duration) float64 { now := time.Now() if s.lastSample.IsZero() { + // First sample - try to initialize + utime, stime, err := s.readProcessTimes() + if err != nil { + // If we can't read process times, still mark as initialized to avoid repeated attempts + s.lastSample = now + s.lastPercent = 0.0 + return 0.0 + } s.lastUTime = float64(utime) s.lastSTime = float64(stime) s.lastSample = now @@ -59,7 +65,13 @@ func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { return 0.0 } + utime, stime, err := s.readProcessTimes() + if err != nil { + return s.lastPercent // Return last known value on error + } + elapsed := now.Sub(s.lastSample) + // If called too frequently, return cached value to avoid jitter if elapsed < deltaTime/2 { return s.lastPercent } @@ -76,6 +88,11 @@ func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { if numcpu <= 0 { numcpu = 1 // Safety check } + + if wallTimeSeconds <= 0 { + return s.lastPercent + } + percent := (cpuTimeSeconds / wallTimeSeconds) * 100.0 / float64(numcpu) if percent > 100.0 { @@ -92,7 +109,7 @@ func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { } // Reset clears sampler state for a new session -func (s *ProcessSampler) Reset() { +func (s *linuxProcessSampler) Reset() { s.lastUTime = 0.0 s.lastSTime = 0.0 s.lastSample = time.Time{} @@ -100,20 +117,15 @@ func (s *ProcessSampler) Reset() { } // IsInitialized returns true if at least one sample has been taken -func (s *ProcessSampler) IsInitialized() bool { +func (s *linuxProcessSampler) IsInitialized() bool { return !s.lastSample.IsZero() } // readProcessTimes reads CPU times from /proc//stat (returns ticks) -func (s *ProcessSampler) readProcessTimes() (utime, stime int64, err error) { +func (s *linuxProcessSampler) readProcessTimes() (utime, stime int64, err error) { path := fmt.Sprintf("/proc/%d/stat", s.pid) - file, err := os.Open(path) - if err != nil { - return 0, 0, fmt.Errorf("failed to open file %s: %w", path, err) - } - defer file.Close() - content, err := io.ReadAll(file) + content, err := s.fs.ReadFile(path) if err != nil { return 0, 0, fmt.Errorf("failed to read file %s: %w", path, err) } @@ -128,20 +140,20 @@ func (s *ProcessSampler) readProcessTimes() (utime, stime int64, err error) { // utime=field[13], stime=field[14] utime, err = strconv.ParseInt(fields[13], 10, 64) if err != nil { - return 0, 0, fmt.Errorf("failed to parse utime from field[13] in /proc/%d/stat: %w", s.pid, err) + return 0, 0, fmt.Errorf("failed to parse utime from field[13]: %w", err) } stime, err = strconv.ParseInt(fields[14], 10, 64) if err != nil { - return 0, 0, fmt.Errorf("failed to parse stime from field[14] in /proc/%d/stat: %w", s.pid, err) + return 0, 0, fmt.Errorf("failed to parse stime from field[14]: %w", err) } return utime, stime, nil } // getClockTicks reads clock ticks per second from /proc/self/auxv (AT_CLKTCK=17) -func getClockTicks() (int64, error) { - data, err := os.ReadFile("/proc/self/auxv") +func getClockTicks(fs FileSystem) (int64, error) { + data, err := fs.ReadFile("/proc/self/auxv") if err != nil { return 100, nil // fallback } diff --git a/internal/sysmonitor/cpu_linux_test.go b/internal/sysmonitor/cpu_linux_test.go index 372db1d..929d825 100644 --- a/internal/sysmonitor/cpu_linux_test.go +++ b/internal/sysmonitor/cpu_linux_test.go @@ -3,24 +3,218 @@ package sysmonitor import ( + "bytes" + "encoding/binary" + "fmt" "testing" + "time" ) -// TestProcessSampler_ClockTicks tests that clock ticks are properly initialized -func TestProcessSampler_ClockTicks(t *testing.T) { - sampler, err := NewProcessSampler() +// generateAuxv creates a binary payload mimicking /proc/self/auxv +// ID 17 = AT_CLKTCK +func generateAuxv(ticks uint64) []byte { + buf := new(bytes.Buffer) + // Write AT_CLKTCK entry + binary.Write(buf, binary.LittleEndian, uint64(17)) // ID + binary.Write(buf, binary.LittleEndian, ticks) // Value + // Write null entry to end list + binary.Write(buf, binary.LittleEndian, uint64(0)) + binary.Write(buf, binary.LittleEndian, uint64(0)) + return buf.Bytes() +} + +func TestLinuxProcessSampler(t *testing.T) { + // Setup Mock FS + mockFS := &MockFileSystem{ + Files: map[string][]byte{ + "/proc/self/auxv": generateAuxv(100), // 100 ticks per second + }, + } + + // Test Factory + samplerInterface, err := newPlatformCPUSampler(mockFS) if err != nil { - t.Fatalf("NewProcessSampler failed: %v", err) + t.Fatalf("Failed to create sampler: %v", err) + } + sampler := samplerInterface.(*linuxProcessSampler) + + // Verify Ticks parsing + if sampler.clockTicks != 100 { + t.Errorf("Expected 100 clock ticks, got %d", sampler.clockTicks) + } + + // Setup Test Data + // format: pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime ... + // Fields 13 (utime) and 14 (stime) are 0-indexed in fields array (so indices 13 and 14) + // Actually strings.Fields 1-based index in documentation usually maps to: + // 13: utime, 14: stime. + // Let's construct a valid line. "100 (go) R 1 1 1 0 -1 4194304 100 0 0 0 10 20 0 0 ..." + // ticks = 10 + 20 = 30 total ticks + statContent1 := []byte("100 (test) R 1 1 1 0 -1 0 0 0 0 0 10 20 0 0 20 0 1 0 0 0 0 0 0 0 0 0 0 0 0") + mockFS.Files[fmt.Sprintf("/proc/%d/stat", sampler.pid)] = statContent1 + + // 1. First Sample (Initialization) + val := sampler.Sample(time.Second) + if val != 0.0 { + t.Errorf("First sample should be 0.0, got %f", val) + } + if !sampler.IsInitialized() { + t.Error("Sampler should be initialized after first call") + } + + // 2. Second Sample (Activity) + // Increase ticks by 50 (User) + 50 (System) = 100 ticks delta + // 100 ticks / 100 ticks/sec = 1 second CPU time + statContent2 := []byte("100 (test) R 1 1 1 0 -1 0 0 0 0 0 60 70 0 0 20 0 1 0 0 0 0 0 0 0 0 0 0 0 0") + mockFS.Files[fmt.Sprintf("/proc/%d/stat", sampler.pid)] = statContent2 + + // Sleep briefly to allow Sample's timing logic to progress. + // This simulates passage of real time for the test. + time.Sleep(50 * time.Millisecond) + val = sampler.Sample(0) // 0 delta to force calculation + + if val <= 0 { + t.Errorf("Expected CPU usage > 0, got %f", val) } - ps := sampler.(*ProcessSampler) - if ps.clockTicks <= 0 { - t.Errorf("clockTicks should be positive, got %d", ps.clockTicks) + // 3. Test Reset + sampler.Reset() + if sampler.IsInitialized() { + t.Error("Sampler should not be initialized after Reset") + } + + // 4. Test Bad File + mockFS.Files[fmt.Sprintf("/proc/%d/stat", sampler.pid)] = []byte("garbage") + val = sampler.Sample(time.Second) + // Should return last known good value (or 0 if reset) + if val != 0.0 { + t.Errorf("Expected 0.0 on error after reset, got %f", val) + } +} + +func TestGetClockTicksFallback(t *testing.T) { + mockFS := &MockFileSystem{Files: map[string][]byte{}} // Empty FS + + ticks, err := getClockTicks(mockFS) + if err != nil { + t.Errorf("Expected no error on fallback, got %v", err) + } + if ticks != 100 { + t.Errorf("Expected fallback ticks 100, got %d", ticks) + } +} + +// generateAuxv32 creates a binary payload mimicking 32-bit /proc/self/auxv +func generateAuxv32(ticks uint32) []byte { + buf := new(bytes.Buffer) + // Write AT_CLKTCK entry (32-bit format) + binary.Write(buf, binary.LittleEndian, uint32(17)) // ID + binary.Write(buf, binary.LittleEndian, ticks) // Value + // Write null entry to end list + binary.Write(buf, binary.LittleEndian, uint32(0)) + binary.Write(buf, binary.LittleEndian, uint32(0)) + return buf.Bytes() +} + +func TestParseAuxv32(t *testing.T) { + tests := []struct { + name string + data []byte + expected int64 + }{ + { + name: "Valid 32-bit auxv with ticks", + data: generateAuxv32(250), + expected: 250, + }, + { + name: "Valid 32-bit auxv with different ticks", + data: generateAuxv32(500), + expected: 500, + }, + { + name: "32-bit auxv with invalid ticks (too high)", + data: generateAuxv32(20000), // > 10000, should be ignored + expected: 100, // fallback + }, + { + name: "32-bit auxv with zero ticks", + data: generateAuxv32(0), // should be ignored + expected: 100, // fallback + }, + { + name: "Empty auxv data", + data: []byte{}, + expected: 100, // fallback + }, + { + name: "Malformed auxv data (odd length)", + data: []byte{1, 2, 3}, // not multiple of 8 + expected: 100, // fallback + }, + { + name: "32-bit auxv with wrong ID", + data: func() []byte { + buf := new(bytes.Buffer) + binary.Write(buf, binary.LittleEndian, uint32(99)) // Wrong ID + binary.Write(buf, binary.LittleEndian, uint32(250)) + binary.Write(buf, binary.LittleEndian, uint32(0)) + binary.Write(buf, binary.LittleEndian, uint32(0)) + return buf.Bytes() + }(), + expected: 100, // fallback + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseAuxv32(tt.data) + if err != nil { + t.Errorf("parseAuxv32() error = %v", err) + return + } + if result != tt.expected { + t.Errorf("parseAuxv32() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestGetClockTicks32Bit(t *testing.T) { + mockFS := &MockFileSystem{ + Files: map[string][]byte{ + "/proc/self/auxv": generateAuxv32(250), // 32-bit format (8 bytes per entry) + }, + } + + ticks, err := getClockTicks(mockFS) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if ticks != 250 { + t.Errorf("Expected 250 ticks, got %d", ticks) + } +} + +func TestNewPlatformCPUSamplerErrors(t *testing.T) { + // Test clock ticks read failure (should still succeed with fallback) + mockFS := &MockFileSystem{ + OpenErrs: map[string]error{ + "/proc/self/auxv": fmt.Errorf("permission denied"), + }, + } + + sampler, err := newPlatformCPUSampler(mockFS) + if err != nil { + t.Errorf("Expected no error with fallback, got %v", err) + } + if sampler == nil { + t.Error("Expected sampler to be created") } - // Clock ticks should typically be 100 on Linux systems - // but can vary, so we just check it's reasonable - if ps.clockTicks > 10000 { - t.Errorf("clockTicks seems unreasonably high: %d", ps.clockTicks) + // Verify fallback ticks were used + linuxSampler := sampler.(*linuxProcessSampler) + if linuxSampler.clockTicks != 100 { + t.Errorf("Expected fallback ticks 100, got %d", linuxSampler.clockTicks) } } diff --git a/internal/sysmonitor/cpu_test.go b/internal/sysmonitor/cpu_test.go index 4f61422..807a970 100644 --- a/internal/sysmonitor/cpu_test.go +++ b/internal/sysmonitor/cpu_test.go @@ -1,217 +1,182 @@ package sysmonitor import ( - "reflect" + "io/fs" "testing" "time" - "unsafe" ) -// setUnexportedField sets an unexported field using unsafe reflection. -func setUnexportedField(t *testing.T, field reflect.Value, value interface{}) { - t.Helper() - reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())). - Elem(). - Set(reflect.ValueOf(value)) -} - -// setTestState sets internal sampler state for testing. -func setTestState(t *testing.T, sampler ProcessCPUSampler, lastUTime, lastSTime float64, lastSample time.Time) { - t.Helper() - val := reflect.ValueOf(sampler).Elem() +// MockFS for the factory test +type factoryMockFS struct{} - // Set lastUTime - if field := val.FieldByName("lastUTime"); field.IsValid() { - setUnexportedField(t, field, lastUTime) - } +func (f *factoryMockFS) ReadFile(_ string) ([]byte, error) { return nil, nil } +func (f *factoryMockFS) Open(_ string) (fs.File, error) { return nil, nil } - // Set lastSTime - if field := val.FieldByName("lastSTime"); field.IsValid() { - setUnexportedField(t, field, lastSTime) - } +type testError struct { + msg string +} - // Set lastSample - if field := val.FieldByName("lastSample"); field.IsValid() { - setUnexportedField(t, field, lastSample) - } +func (e *testError) Error() string { + return e.msg } -func TestNewProcessSampler(t *testing.T) { - sampler, err := NewProcessSampler() +func TestNewCPUSampler(t *testing.T) { + fs := &factoryMockFS{} + + sampler, err := NewCPUSampler(fs) if err != nil { - t.Fatalf("NewProcessSampler failed: %v", err) + t.Fatalf("NewCPUSampler returned error: %v", err) } + if sampler == nil { - t.Fatal("NewProcessSampler should not be nil") + t.Fatal("NewCPUSampler returned nil") } - // Test basic interface compliance - _ = sampler.Sample(100 * time.Millisecond) + // Basic interface check sampler.Reset() - _ = sampler.IsInitialized() + _ = sampler.IsInitialized() // Ensure method exists and is callable } -func TestProcessSampler_Initialization(t *testing.T) { - sampler, err := NewProcessSampler() +// TestProcessCPUSamplerInterface tests the ProcessCPUSampler interface +// using any available platform implementation +func TestProcessCPUSamplerInterface(t *testing.T) { + // Create a mock filesystem for testing + fs := &factoryMockFS{} + + sampler, err := NewCPUSampler(fs) if err != nil { - t.Fatalf("NewProcessSampler failed: %v", err) + t.Skipf("CPU sampler not available on this platform: %v", err) } + + // Test 1: Interface compliance - verify all methods exist and are callable if sampler == nil { - t.Fatal("NewProcessSampler should not be nil") + t.Fatal("NewCPUSampler returned nil sampler") } - // Initially not initialized + // Test 2: Initial state - should not be initialized if sampler.IsInitialized() { - t.Error("ProcessSampler should not be initialized initially") + t.Error("New sampler should not be initialized") } - // After first sample, should be initialized - sampler.Sample(100 * time.Millisecond) + // Test 3: First sample - should initialize and return 0.0 + firstSample := sampler.Sample(time.Second) if !sampler.IsInitialized() { - t.Error("ProcessSampler should be initialized after first sample") + t.Error("Sampler should be initialized after first sample") } - - // After reset, should not be initialized - sampler.Reset() - if sampler.IsInitialized() { - t.Error("ProcessSampler should not be initialized after reset") - } -} - -func TestProcessSampler_Sample(t *testing.T) { - sampler, err := NewProcessSampler() - if err != nil { - t.Fatalf("NewProcessSampler failed: %v", err) + if firstSample != 0.0 { + t.Errorf("First sample should return 0.0, got %f", firstSample) } - percent := sampler.Sample(100 * time.Millisecond) - if percent != 0.0 { - t.Errorf("first sample should return 0.0, got %v", percent) - } - - time.Sleep(10 * time.Millisecond) - percent = sampler.Sample(10 * time.Millisecond) - if percent < 0.0 || percent > 100.0 { - t.Errorf("CPU percent should be between 0 and 100, got %v", percent) + // Test 4: Subsequent samples - should return valid CPU values + secondSample := sampler.Sample(time.Second) + if secondSample < 0.0 || secondSample > 100.0 { + t.Errorf("CPU sample should be between 0-100, got %f", secondSample) } + // Test 5: Reset functionality sampler.Reset() if sampler.IsInitialized() { - t.Error("sampler should not be initialized after reset") - } -} - -func TestProcessSampler_SampleEdgeCases(t *testing.T) { - sampler, err := NewProcessSampler() - if err != nil { - t.Fatalf("NewProcessSampler failed: %v", err) + t.Error("Sampler should not be initialized after reset") } - sampler.Sample(100 * time.Millisecond) - _ = sampler.Sample(100 * time.Millisecond) - time.Sleep(1 * time.Millisecond) - percent2 := sampler.Sample(100 * time.Millisecond) - if percent2 < 0.0 || percent2 > 100.0 { - t.Errorf("CPU percent should be between 0 and 100, got %v", percent2) - } - - time.Sleep(50 * time.Millisecond) - percent3 := sampler.Sample(50 * time.Millisecond) - if percent3 < 0.0 || percent3 > 100.0 { - t.Errorf("CPU percent should be clamped to 0-100, got %v", percent3) - } -} - -// TestProcessSampler_SampleErrorHandling tests error handling. -func TestProcessSampler_SampleErrorHandling(t *testing.T) { - sampler, err := NewProcessSampler() - if err != nil { - t.Fatalf("NewProcessSampler failed: %v", err) + // Test 6: Sample after reset - should behave like first sample + resetSample := sampler.Sample(time.Second) + if !sampler.IsInitialized() { + t.Error("Sampler should be initialized after sample following reset") } - - initialPercent := sampler.Sample(100 * time.Millisecond) - if initialPercent != 0.0 { - t.Errorf("first sample should return 0.0, got %v", initialPercent) + if resetSample != 0.0 { + t.Errorf("Sample after reset should return 0.0, got %f", resetSample) } - time.Sleep(10 * time.Millisecond) - validPercent := sampler.Sample(10 * time.Millisecond) - if validPercent < 0.0 || validPercent > 100.0 { - t.Errorf("CPU percent should be between 0 and 100, got %v", validPercent) + // Test 7: Rapid sampling - should return last known value if delta too small + rapidSample := sampler.Sample(time.Millisecond) + // This should return the last known value (resetSample) since delta is too small + if rapidSample != resetSample { + t.Logf("Rapid sample returned %f, expected last value %f", rapidSample, resetSample) + // This is informational - behavior may vary by implementation } - - sampler.Reset() } -// TestProcessSampler_SampleNumCPUEdgeCase tests numcpu <= 0 safety check. -func TestProcessSampler_SampleNumCPUEdgeCase(t *testing.T) { - sampler, err := NewProcessSampler() - if err != nil { - t.Fatalf("NewProcessSampler failed: %v", err) - } +// TestProcessMemoryReaderInterface tests the ProcessMemoryReader interface +// using any available platform implementation +func TestProcessMemoryReaderInterface(t *testing.T) { + // Create a mock filesystem for testing + fs := &factoryMockFS{} - sampler.Sample(100 * time.Millisecond) - time.Sleep(10 * time.Millisecond) - percent := sampler.Sample(10 * time.Millisecond) - if percent < 0.0 || percent > 100.0 { - t.Errorf("CPU percent should be between 0 and 100, got %v", percent) + reader := NewProcessMemoryReader(fs) + if reader == nil { + t.Fatal("NewProcessMemoryReader returned nil reader") } -} -// TestProcessSampler_SampleNegativePercent tests clamping of negative CPU percent. -func TestProcessSampler_SampleNegativePercent(t *testing.T) { - sampler, err := NewProcessSampler() + // Test 1: Basic sampling functionality + mem, err := reader.Sample() if err != nil { - t.Fatalf("NewProcessSampler failed: %v", err) + t.Skipf("Memory reader not functional on this platform: %v", err) } - sampler.Sample(100 * time.Millisecond) - setTestState(t, sampler, 100.0, 50.0, time.Now().Add(-1*time.Second)) - - time.Sleep(10 * time.Millisecond) - percent := sampler.Sample(100 * time.Millisecond) - - if percent < 0.0 { - t.Errorf("CPU percent should be clamped to 0.0 when negative, got %v", percent) + // Test 2: Memory values should be reasonable + if mem.Total == 0 { + t.Error("Total memory should not be zero") } - if percent > 100.0 { - t.Errorf("CPU percent should not exceed 100.0, got %v", percent) + if mem.Available > mem.Total { + t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) } -} -// TestProcessSampler_SampleHighPercent tests clamping of CPU percent > 100%. -func TestProcessSampler_SampleHighPercent(t *testing.T) { - sampler, err := NewProcessSampler() + // Test 3: Multiple samples should be consistent + mem2, err := reader.Sample() if err != nil { - t.Fatalf("NewProcessSampler failed: %v", err) + t.Errorf("Second sample failed: %v", err) } - sampler.Sample(100 * time.Millisecond) - setTestState(t, sampler, 0.0, 0.0, time.Now().Add(-1*time.Millisecond)) - time.Sleep(10 * time.Millisecond) - setTestState(t, sampler, 0.0, 0.0, time.Now().Add(-1*time.Millisecond)) - percent := sampler.Sample(100 * time.Millisecond) - - if percent > 100.0 { - t.Errorf("CPU percent should be clamped to 100.0 when > 100, got %v", percent) + // Total memory should be consistent + if mem2.Total != mem.Total { + t.Errorf("Total memory changed between samples: %d -> %d", mem.Total, mem2.Total) } - if percent < 0.0 { - t.Errorf("CPU percent should not be negative, got %v", percent) - } -} -// TestProcessSampler_SampleZeroWallTime tests handling of zero or negative wall time. -func TestProcessSampler_SampleZeroWallTime(t *testing.T) { - sampler, err := NewProcessSampler() - if err != nil { - t.Fatalf("NewProcessSampler failed: %v", err) + // Available memory should be reasonable (within 10% of previous value) + upperBound := uint64(float64(mem.Available) * 1.1) + lowerBound := uint64(float64(mem.Available) * 0.9) + if mem2.Available > upperBound || mem2.Available < lowerBound { + t.Logf("Available memory changed significantly: %d -> %d", mem.Available, mem2.Available) + // This is informational as memory usage can fluctuate } +} - sampler.Sample(100 * time.Millisecond) - setTestState(t, sampler, 0.0, 0.0, time.Now()) - percent := sampler.Sample(100 * time.Millisecond) - - if percent < 0.0 || percent > 100.0 { - t.Errorf("CPU percent should be valid (0-100) or lastPercent, got %v", percent) +// TestSamplerErrorHandling tests error conditions that work across platforms +func TestSamplerErrorHandling(t *testing.T) { + // Test with mock filesystem that can simulate errors + mockFS := &MockFileSystem{ + OpenErrs: map[string]error{ + "/proc/self/stat": &testError{msg: "mock stat error"}, + "/proc/meminfo": &testError{msg: "mock meminfo error"}, + "/proc/self/auxv": &testError{msg: "mock auxv error"}, + }, + } + + // Test CPU sampler with error conditions + cpuSampler, err := NewCPUSampler(mockFS) + if err == nil { + t.Log("CPU sampler creation succeeded despite mock errors - this may be expected on some platforms") + // If it succeeds, test that it still functions + if cpuSampler != nil { + sample := cpuSampler.Sample(time.Second) + if sample < 0.0 { + t.Errorf("CPU sample should not be negative: %f", sample) + } + } + } else { + t.Logf("CPU sampler creation failed as expected: %v", err) + } + + // Test memory reader with error conditions + memReader := NewProcessMemoryReader(mockFS) + if memReader != nil { + _, err := memReader.Sample() + // Error is expected but not guaranteed on all platforms + if err != nil { + t.Logf("Memory reader returned expected error: %v", err) + } else { + t.Log("Memory reader succeeded despite mock errors - this may be expected on some platforms") + } } } diff --git a/internal/sysmonitor/cpu_windows.go b/internal/sysmonitor/cpu_windows.go index 8be8435..781d22c 100644 --- a/internal/sysmonitor/cpu_windows.go +++ b/internal/sysmonitor/cpu_windows.go @@ -11,8 +11,8 @@ import ( "time" ) -// ProcessSampler samples CPU usage for the current process -type ProcessSampler struct { +// windowsProcessSampler samples CPU usage for the current process on Windows +type windowsProcessSampler struct { pid int lastUTime float64 lastSTime float64 @@ -20,20 +20,20 @@ type ProcessSampler struct { lastPercent float64 } -// newProcessSampler creates a new CPU sampler for the current process (Windows implementation) -func newProcessSampler() (*ProcessSampler, error) { +// newPlatformCPUSampler matches the factory signature required by cpu.go. +func newPlatformCPUSampler(_ FileSystem) (ProcessCPUSampler, error) { pid := os.Getpid() if pid < 0 || pid > math.MaxInt32 { return nil, fmt.Errorf("invalid PID: %d", pid) } - return &ProcessSampler{ + return &windowsProcessSampler{ pid: pid, }, nil } // Sample returns the CPU usage percentage since the last sample -func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { +func (s *windowsProcessSampler) Sample(deltaTime time.Duration) float64 { utime, stime, err := s.getCurrentCPUTimes() if err != nil { // If we have a previous valid sample, return it; otherwise return 0 @@ -66,8 +66,6 @@ func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { } // Normalized to 0-100% (divides by numCPU for system-wide metric) - // Note: GetProcessTimes returns cumulative CPU time across all threads/cores - // So we divide by numCPU to get per-core percentage numcpu := runtime.NumCPU() if numcpu <= 0 { numcpu = 1 // Safety check @@ -91,7 +89,7 @@ func (s *ProcessSampler) Sample(deltaTime time.Duration) float64 { } // Reset clears sampler state for a new session -func (s *ProcessSampler) Reset() { +func (s *windowsProcessSampler) Reset() { s.lastUTime = 0.0 s.lastSTime = 0.0 s.lastSample = time.Time{} @@ -99,7 +97,7 @@ func (s *ProcessSampler) Reset() { } // IsInitialized returns true if at least one sample has been taken -func (s *ProcessSampler) IsInitialized() bool { +func (s *windowsProcessSampler) IsInitialized() bool { return !s.lastSample.IsZero() } @@ -107,8 +105,6 @@ func (s *ProcessSampler) IsInitialized() bool { func getProcessCPUTimes(pid int) (syscall.Filetime, syscall.Filetime, error) { var c, e, k, u syscall.Filetime - // For the current process, use GetCurrentProcess() which returns a pseudo-handle - // that doesn't need to be opened and is more reliable currentPid := os.Getpid() var h syscall.Handle if pid == currentPid { @@ -117,11 +113,11 @@ func getProcessCPUTimes(pid int) (syscall.Filetime, syscall.Filetime, error) { if err != nil { return k, u, fmt.Errorf("failed to get current process handle: %w", err) } - // GetCurrentProcess returns a pseudo-handle that doesn't need to be closed } else { // Try PROCESS_QUERY_LIMITED_INFORMATION first (works on more Windows versions) // Fall back to PROCESS_QUERY_INFORMATION if that fails var err error + // Define constant here since we can't depend on x/sys/windows const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 h, err = syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) if err != nil { @@ -144,12 +140,14 @@ func getProcessCPUTimes(pid int) (syscall.Filetime, syscall.Filetime, error) { // convertFiletimeToSeconds converts FILETIME (100ns intervals) to seconds func convertFiletimeToSeconds(ft syscall.Filetime) float64 { + // Join high/low into single 64-bit integer ticks := int64(ft.HighDateTime)<<32 | int64(ft.LowDateTime) - return float64(ticks) * 1e-7 // 1 tick = 100ns + // 1 tick = 100ns = 0.0000001 seconds + return float64(ticks) * 1e-7 } // getCurrentCPUTimes reads CPU times for the process (returns seconds) -func (s *ProcessSampler) getCurrentCPUTimes() (utime, stime float64, err error) { +func (s *windowsProcessSampler) getCurrentCPUTimes() (utime, stime float64, err error) { k, u, err := getProcessCPUTimes(s.pid) if err != nil { return 0, 0, fmt.Errorf("failed to get CPU times for process %d: %w", s.pid, err) diff --git a/internal/sysmonitor/cpu_windows_test.go b/internal/sysmonitor/cpu_windows_test.go new file mode 100644 index 0000000..d69e71f --- /dev/null +++ b/internal/sysmonitor/cpu_windows_test.go @@ -0,0 +1,48 @@ +//go:build windows + +package sysmonitor + +import ( + "testing" + "time" +) + +// TestWindowsSamplerIntegration runs real calls against the OS. +func TestWindowsSamplerIntegration(t *testing.T) { + sampler, err := newPlatformCPUSampler(nil) + if err != nil { + t.Fatalf("Failed to create Windows sampler: %v", err) + } + + // 1. Initialize + val := sampler.Sample(time.Second) + if val != 0.0 { + t.Errorf("Expected initial sample 0.0, got %f", val) + } + + // 2. Generate Load (Busy Loop) to ensure non-zero CPU + done := make(chan struct{}) + go func() { + end := time.Now().Add(100 * time.Millisecond) + for time.Now().Before(end) { + } + close(done) + }() + <-done + + // Sleep a tiny bit to ensure total elapsed > busy loop time + time.Sleep(50 * time.Millisecond) + + // 3. Measure + val = sampler.Sample(0) + + // Log result (useful for verification) + t.Logf("Measured CPU Load: %f%%", val) + + if val <= 0.0 { + t.Error("Expected non-zero CPU usage after busy loop") + } + if val > 100.0 { + t.Errorf("CPU usage %f exceeds 100%%", val) + } +} diff --git a/internal/sysmonitor/fs_test.go b/internal/sysmonitor/fs_test.go index 56e1a10..875c03e 100644 --- a/internal/sysmonitor/fs_test.go +++ b/internal/sysmonitor/fs_test.go @@ -1,95 +1,187 @@ package sysmonitor import ( + "errors" + "io/fs" "os" "path/filepath" "testing" ) +// MockFS is a mock implementation of FileSystem for testing +type MockFS struct { + ReadFileFunc func(name string) ([]byte, error) + OpenFunc func(name string) (fs.File, error) +} + +func (m MockFS) ReadFile(name string) ([]byte, error) { + if m.ReadFileFunc != nil { + return m.ReadFileFunc(name) + } + return nil, nil +} + +func (m MockFS) Open(name string) (fs.File, error) { + if m.OpenFunc != nil { + return m.OpenFunc(name) + } + return nil, nil +} + func TestOSFileSystem_ReadFile(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Test successful read + testContent := "Hello, World!" + testFile := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(testFile, []byte(testContent), 0o600) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + fs := OSFileSystem{} + data, err := fs.ReadFile(testFile) + if err != nil { + t.Errorf("ReadFile failed: %v", err) + } + if string(data) != testContent { + t.Errorf("Expected %q, got %q", testContent, string(data)) + } - t.Run("read existing file", func(t *testing.T) { - // Create a temporary file - tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.txt") - if err != nil { - t.Fatalf("failed to create temp file: %v", err) - } - defer os.Remove(tmpFile.Name()) - defer tmpFile.Close() - - testContent := []byte("test content") - if _, err := tmpFile.Write(testContent); err != nil { - t.Fatalf("failed to write to temp file: %v", err) - } - tmpFile.Close() - - // Read the file - content, err := fs.ReadFile(tmpFile.Name()) - if err != nil { - t.Fatalf("ReadFile failed: %v", err) - } - - if string(content) != string(testContent) { - t.Errorf("expected content %q, got %q", string(testContent), string(content)) - } - }) - - t.Run("read non-existent file", func(t *testing.T) { - nonExistentFile := filepath.Join(os.TempDir(), "non-existent-file-12345.txt") - _, err := fs.ReadFile(nonExistentFile) - if err == nil { - t.Error("expected error when reading non-existent file") - } - if !os.IsNotExist(err) { - t.Errorf("expected os.IsNotExist error, got %v", err) - } - }) + // Test reading non-existent file + _, err = fs.ReadFile(filepath.Join(tempDir, "nonexistent.txt")) + if err == nil { + t.Error("Expected error when reading non-existent file, got nil") + } + if !os.IsNotExist(err) { + t.Errorf("Expected IsNotExist error, got %v", err) + } } func TestOSFileSystem_Open(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Test successful open + testContent := "Hello, World!" + testFile := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(testFile, []byte(testContent), 0o600) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + fs := OSFileSystem{} + file, err := fs.Open(testFile) + if err != nil { + t.Errorf("Open failed: %v", err) + } + defer file.Close() + + // Verify we can read from the opened file + data := make([]byte, len(testContent)) + n, err := file.Read(data) + if err != nil { + t.Errorf("Failed to read from opened file: %v", err) + } + if n != len(testContent) { + t.Errorf("Expected to read %d bytes, got %d", len(testContent), n) + } + if string(data) != testContent { + t.Errorf("Expected %q, got %q", testContent, string(data)) + } + + // Test opening non-existent file + _, err = fs.Open(filepath.Join(tempDir, "nonexistent.txt")) + if err == nil { + t.Error("Expected error when opening non-existent file, got nil") + } + if !os.IsNotExist(err) { + t.Errorf("Expected IsNotExist error, got %v", err) + } +} + +func TestMockFS_ReadFile(t *testing.T) { + expectedData := []byte("mock data") + expectedErr := os.ErrNotExist + + mock := MockFS{ + ReadFileFunc: func(name string) ([]byte, error) { + if name == "success.txt" { + return expectedData, nil + } + return nil, expectedErr + }, + } + + // Test success case + data, err := mock.ReadFile("success.txt") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if string(data) != string(expectedData) { + t.Errorf("Expected %q, got %q", expectedData, data) + } + + // Test error case + _, err = mock.ReadFile("error.txt") + if !errors.Is(err, expectedErr) { + t.Errorf("Expected error %v, got %v", expectedErr, err) + } + + // Test default behavior (no function set) + mockDefault := MockFS{} + data, err = mockDefault.ReadFile("any.txt") + if err != nil { + t.Errorf("Expected no error for default mock, got %v", err) + } + if data != nil { + t.Errorf("Expected nil data for default mock, got %v", data) + } +} + +func TestMockFS_Open(t *testing.T) { + expectedErr := os.ErrNotExist + + mock := MockFS{ + OpenFunc: func(name string) (fs.File, error) { + if name == "error.txt" { + return nil, expectedErr + } + return nil, nil // Mock file (nil for simplicity in tests) + }, + } + + // Test error case + _, err := mock.Open("error.txt") + if !errors.Is(err, expectedErr) { + t.Errorf("Expected error %v, got %v", expectedErr, err) + } + + // Test success case (returns nil file for simplicity) + file, err := mock.Open("success.txt") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if file != nil { + t.Errorf("Expected nil file for mock, got %v", file) + } + + // Test default behavior (no function set) + mockDefault := MockFS{} + file, err = mockDefault.Open("any.txt") + if err != nil { + t.Errorf("Expected no error for default mock, got %v", err) + } + if file != nil { + t.Errorf("Expected nil file for default mock, got %v", file) + } +} + +func TestFileSystemInterface(_ *testing.T) { + // Test that OSFileSystem implements FileSystem interface + var _ FileSystem = OSFileSystem{} - t.Run("open existing file", func(t *testing.T) { - // Create a temporary file - tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.txt") - if err != nil { - t.Fatalf("failed to create temp file: %v", err) - } - defer os.Remove(tmpFile.Name()) - defer tmpFile.Close() - - testContent := []byte("test content") - if _, err := tmpFile.Write(testContent); err != nil { - t.Fatalf("failed to write to temp file: %v", err) - } - tmpFile.Close() - - // Open the file - file, err := fs.Open(tmpFile.Name()) - if err != nil { - t.Fatalf("Open failed: %v", err) - } - defer file.Close() - - // Verify we can read from it - stat, err := file.Stat() - if err != nil { - t.Fatalf("Stat failed: %v", err) - } - if stat.Size() != int64(len(testContent)) { - t.Errorf("expected file size %d, got %d", len(testContent), stat.Size()) - } - }) - - t.Run("open non-existent file", func(t *testing.T) { - nonExistentFile := filepath.Join(os.TempDir(), "non-existent-file-12345.txt") - _, err := fs.Open(nonExistentFile) - if err == nil { - t.Error("expected error when opening non-existent file") - } - if !os.IsNotExist(err) { - t.Errorf("expected os.IsNotExist error, got %v", err) - } - }) + // Test that MockFS implements FileSystem interface + var _ FileSystem = MockFS{} } diff --git a/internal/sysmonitor/heuristic_test.go b/internal/sysmonitor/heuristic_test.go deleted file mode 100644 index e8313f2..0000000 --- a/internal/sysmonitor/heuristic_test.go +++ /dev/null @@ -1,338 +0,0 @@ -package sysmonitor - -import ( - "math" - "runtime" - "sync" - "testing" - "time" -) - -func TestGoroutineHeuristicSampler_IsInitialized_Comprehensive(t *testing.T) { - sampler := NewGoroutineHeuristicSampler() - - // IsInitialized should always return true for heuristic sampler - if !sampler.IsInitialized() { - t.Error("GoroutineHeuristicSampler.IsInitialized() should always return true") - } - - // Should remain true after reset - sampler.Reset() - if !sampler.IsInitialized() { - t.Error("GoroutineHeuristicSampler.IsInitialized() should remain true after reset") - } - - // Should remain true after sampling - sampler.Sample(100 * time.Millisecond) - if !sampler.IsInitialized() { - t.Error("GoroutineHeuristicSampler.IsInitialized() should remain true after sampling") - } -} - -func TestGoroutineHeuristicSampler_Reset_Comprehensive(t *testing.T) { - sampler := NewGoroutineHeuristicSampler() - - // Reset should be a no-op but should not panic - sampler.Reset() - - // Verify sampler still works after reset - percent1 := sampler.Sample(100 * time.Millisecond) - sampler.Reset() - percent2 := sampler.Sample(100 * time.Millisecond) - - // Results should be the same (since reset doesn't affect state) - if percent1 != percent2 { - t.Logf("Note: percent1=%v, percent2=%v (may differ due to goroutine count changes)", percent1, percent2) - } - - // Both should be valid - if percent1 < 0.0 || percent1 > CPUHeuristicMaxCPU { - t.Errorf("CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, percent1) - } - if percent2 < 0.0 || percent2 > CPUHeuristicMaxCPU { - t.Errorf("CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, percent2) - } -} - -func TestGoroutineHeuristicSampler_Sample_LinearScaling(t *testing.T) { - // Test the linear scaling formula for goroutines <= 10 - // Formula: CPUHeuristicBaselineCPU + goroutineCount * CPUHeuristicLinearScaleFactor - testCases := []struct { - name string - goroutineCount float64 - expectedMin float64 - expectedMax float64 - expectedFormula float64 - }{ - { - name: "1 goroutine", - goroutineCount: 1.0, - expectedFormula: CPUHeuristicBaselineCPU + 1.0*CPUHeuristicLinearScaleFactor, - expectedMin: 10.0, - expectedMax: 12.0, - }, - { - name: "5 goroutines", - goroutineCount: 5.0, - expectedFormula: CPUHeuristicBaselineCPU + 5.0*CPUHeuristicLinearScaleFactor, - expectedMin: 14.0, - expectedMax: 16.0, - }, - { - name: "10 goroutines (max linear)", - goroutineCount: 10.0, - expectedFormula: CPUHeuristicBaselineCPU + 10.0*CPUHeuristicLinearScaleFactor, - expectedMin: 19.0, - expectedMax: 21.0, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Calculate expected value using the formula - expected := tc.expectedFormula - - // Verify the formula matches expected range - if expected < tc.expectedMin || expected > tc.expectedMax { - t.Errorf("Formula result %.2f should be between %.2f and %.2f", expected, tc.expectedMin, tc.expectedMax) - } - }) - } -} - -func TestGoroutineHeuristicSampler_Sample_LogarithmicScaling(t *testing.T) { - // Test the logarithmic scaling formula for goroutines > 10 - // Formula: CPUHeuristicBaselineCPU + ln(goroutineCount) * CPUHeuristicLogScaleFactor - testCases := []struct { - name string - goroutineCount float64 - expectedMin float64 - expectedMax float64 - expectedFormula float64 - }{ - { - name: "11 goroutines (first logarithmic)", - goroutineCount: 11.0, - expectedFormula: CPUHeuristicBaselineCPU + math.Log(11.0)*CPUHeuristicLogScaleFactor, - expectedMin: 28.0, - expectedMax: 32.0, - }, - { - name: "100 goroutines", - goroutineCount: 100.0, - expectedFormula: CPUHeuristicBaselineCPU + math.Log(100.0)*CPUHeuristicLogScaleFactor, - expectedMin: 45.0, - expectedMax: 55.0, - }, - { - name: "1000 goroutines", - goroutineCount: 1000.0, - expectedFormula: CPUHeuristicBaselineCPU + math.Log(1000.0)*CPUHeuristicLogScaleFactor, - expectedMin: 65.0, - expectedMax: 75.0, - }, - { - name: "10000 goroutines", - goroutineCount: 10000.0, - expectedFormula: CPUHeuristicBaselineCPU + math.Log(10000.0)*CPUHeuristicLogScaleFactor, - expectedMin: 80.0, - expectedMax: 90.0, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - // Calculate expected value using the formula - expected := tc.expectedFormula - - // Verify the formula matches expected range - if expected < tc.expectedMin || expected > tc.expectedMax { - t.Errorf("Formula result %.2f should be between %.2f and %.2f", expected, tc.expectedMin, tc.expectedMax) - } - - // Verify it doesn't exceed max (capped values should equal max) - if expected > CPUHeuristicMaxCPU { - if CPUHeuristicMaxCPU != 95.0 { - t.Errorf("Expected value %.2f exceeds max, but CPUHeuristicMaxCPU is %.1f (expected 95.0)", - expected, CPUHeuristicMaxCPU) - } - } - }) - } -} - -func TestGoroutineHeuristicSampler_Sample_MaxCPUCap_Formula(t *testing.T) { - // Test that very high goroutine counts are capped at CPUHeuristicMaxCPU - // We can't directly set goroutine count, but we can verify the cap works - // by checking the formula with a very high value - veryHighGoroutineCount := 1000000.0 - expectedUncapped := CPUHeuristicBaselineCPU + math.Log(veryHighGoroutineCount)*CPUHeuristicLogScaleFactor - - if expectedUncapped > CPUHeuristicMaxCPU { - // Verify the cap would be applied - if CPUHeuristicMaxCPU != 95.0 { - t.Errorf("CPUHeuristicMaxCPU should be 95.0, got %.1f", CPUHeuristicMaxCPU) - } - } - - // Test actual sampler with current goroutine count - sampler := NewGoroutineHeuristicSampler() - percent := sampler.Sample(100 * time.Millisecond) - if percent > CPUHeuristicMaxCPU { - t.Errorf("CPU percent should be capped at %.1f, got %v", CPUHeuristicMaxCPU, percent) - } - if percent < 0.0 { - t.Errorf("CPU percent should not be negative, got %v", percent) - } - - // Test multiple samples to ensure consistency - for i := 0; i < 10; i++ { - p := sampler.Sample(100 * time.Millisecond) - if p > CPUHeuristicMaxCPU || p < 0.0 { - t.Errorf("CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, p) - } - } -} - -func TestGoroutineHeuristicSampler_Sample_WithGoroutines(t *testing.T) { - sampler := NewGoroutineHeuristicSampler() - - // Get baseline goroutine count - baselineGoroutines := runtime.NumGoroutine() - baselinePercent := sampler.Sample(100 * time.Millisecond) - - // Spawn some goroutines to increase the count - var wg sync.WaitGroup - numGoroutines := 20 - wg.Add(numGoroutines) - - for i := 0; i < numGoroutines; i++ { - go func() { - defer wg.Done() - time.Sleep(10 * time.Millisecond) - }() - } - - // Wait a bit for goroutines to start - time.Sleep(5 * time.Millisecond) - - // Sample with increased goroutine count - highPercent := sampler.Sample(100 * time.Millisecond) - - // Wait for goroutines to finish - wg.Wait() - time.Sleep(10 * time.Millisecond) - - // Sample after goroutines finish - lowPercent := sampler.Sample(100 * time.Millisecond) - - // Verify all percentages are valid - if baselinePercent < 0.0 || baselinePercent > CPUHeuristicMaxCPU { - t.Errorf("Baseline CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, baselinePercent) - } - if highPercent < 0.0 || highPercent > CPUHeuristicMaxCPU { - t.Errorf("High CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, highPercent) - } - if lowPercent < 0.0 || lowPercent > CPUHeuristicMaxCPU { - t.Errorf("Low CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, lowPercent) - } - - // High goroutine count should generally result in higher CPU estimate - // (though this is not guaranteed due to timing) - t.Logf("Baseline goroutines: %d, CPU: %.2f%%", baselineGoroutines, baselinePercent) - t.Logf("High goroutines: %d, CPU: %.2f%%", runtime.NumGoroutine(), highPercent) - t.Logf("After cleanup: %d, CPU: %.2f%%", runtime.NumGoroutine(), lowPercent) -} - -func TestGoroutineHeuristicSampler_Sample_TimeDeltaIgnored(t *testing.T) { - sampler := NewGoroutineHeuristicSampler() - - // The heuristic sampler ignores the time delta parameter - // All samples should return the same value (based on goroutine count) - percent1 := sampler.Sample(1 * time.Millisecond) - percent2 := sampler.Sample(100 * time.Millisecond) - percent3 := sampler.Sample(1 * time.Second) - - // All should be the same (since they're based on goroutine count, not time) - if percent1 != percent2 || percent2 != percent3 { - // This is acceptable if goroutine count changed between samples - t.Logf("Note: Percentages differ (percent1=%.2f, percent2=%.2f, percent3=%.2f), "+ - "likely due to goroutine count changes", percent1, percent2, percent3) - } - - // All should be valid - if percent1 < 0.0 || percent1 > CPUHeuristicMaxCPU { - t.Errorf("CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, percent1) - } -} - -func TestGoroutineHeuristicSampler_Constants(t *testing.T) { - // Verify all constants have expected values - if CPUHeuristicBaselineCPU != 10.0 { - t.Errorf("CPUHeuristicBaselineCPU should be 10.0, got %.1f", CPUHeuristicBaselineCPU) - } - if CPUHeuristicLinearScaleFactor != 1.0 { - t.Errorf("CPUHeuristicLinearScaleFactor should be 1.0, got %.1f", CPUHeuristicLinearScaleFactor) - } - if CPUHeuristicLogScaleFactor != 8.0 { - t.Errorf("CPUHeuristicLogScaleFactor should be 8.0, got %.1f", CPUHeuristicLogScaleFactor) - } - if CPUHeuristicMaxGoroutinesForLinear != 10 { - t.Errorf("CPUHeuristicMaxGoroutinesForLinear should be 10, got %d", CPUHeuristicMaxGoroutinesForLinear) - } - if CPUHeuristicMaxCPU != 95.0 { - t.Errorf("CPUHeuristicMaxCPU should be 95.0, got %.1f", CPUHeuristicMaxCPU) - } -} - -func TestGoroutineHeuristicSampler_FormulaTransition(t *testing.T) { - // Test the transition point between linear and logarithmic scaling - // At exactly 10 goroutines, should use linear formula - linearAt10 := CPUHeuristicBaselineCPU + 10.0*CPUHeuristicLinearScaleFactor - - // At 11 goroutines, should use logarithmic formula - logAt11 := CPUHeuristicBaselineCPU + math.Log(11.0)*CPUHeuristicLogScaleFactor - - // Verify the transition is smooth (log should be higher than linear) - if logAt11 <= linearAt10 { - t.Errorf("Logarithmic scaling at 11 (%.2f) should be higher than linear at 10 (%.2f)", logAt11, linearAt10) - } - - // Verify both are within reasonable bounds - if linearAt10 < 0.0 || linearAt10 > CPUHeuristicMaxCPU { - t.Errorf("Linear at 10 should be between 0 and %.1f, got %.2f", CPUHeuristicMaxCPU, linearAt10) - } - if logAt11 < 0.0 || logAt11 > CPUHeuristicMaxCPU { - t.Errorf("Log at 11 should be between 0 and %.1f, got %.2f", CPUHeuristicMaxCPU, logAt11) - } -} - -func TestGoroutineHeuristicSampler_EdgeCases(t *testing.T) { - sampler := NewGoroutineHeuristicSampler() - - // Test with zero time delta (should still work) - percent := sampler.Sample(0) - if percent < 0.0 || percent > CPUHeuristicMaxCPU { - t.Errorf("CPU percent with zero delta should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, percent) - } - - // Test with negative time delta (should still work, though unusual) - percent = sampler.Sample(-100 * time.Millisecond) - if percent < 0.0 || percent > CPUHeuristicMaxCPU { - t.Errorf("CPU percent with negative delta should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, percent) - } - - // Test with very large time delta - percent = sampler.Sample(1 * time.Hour) - if percent < 0.0 || percent > CPUHeuristicMaxCPU { - t.Errorf("CPU percent with large delta should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, percent) - } - - // Test multiple rapid samples - for i := 0; i < 100; i++ { - p := sampler.Sample(100 * time.Millisecond) - if p < 0.0 || p > CPUHeuristicMaxCPU { - t.Errorf("CPU percent should be between 0 and %.1f, got %v", CPUHeuristicMaxCPU, p) - } - } -} diff --git a/internal/sysmonitor/memory.go b/internal/sysmonitor/memory.go index 00aaee3..a40ff0b 100644 --- a/internal/sysmonitor/memory.go +++ b/internal/sysmonitor/memory.go @@ -1,10 +1,16 @@ package sysmonitor -// SystemMemory represents system memory information in bytes +// SystemMemory represents system memory information in bytes. type SystemMemory struct { Total uint64 Available uint64 } -// MemoryReader is a function that reads system memory information -type MemoryReader func() (SystemMemory, error) +type ProcessMemoryReader interface { + Sample() (SystemMemory, error) +} + +// NewProcessMemoryReader creates a new process memory reader. +func NewProcessMemoryReader(fs FileSystem) ProcessMemoryReader { + return newPlatformMemoryReader(fs) +} diff --git a/internal/sysmonitor/memory_darwin.go b/internal/sysmonitor/memory_darwin.go index 13dcaa1..b91251a 100644 --- a/internal/sysmonitor/memory_darwin.go +++ b/internal/sysmonitor/memory_darwin.go @@ -12,6 +12,11 @@ kern_return_t get_vm_stats(vm_statistics64_t vmstat) { mach_port_t host_port = mach_host_self(); kern_return_t ret = host_statistics64(host_port, HOST_VM_INFO64, (host_info64_t)vmstat, &count); + // Important: mach_host_self() returns a port send right that must be deallocated, + // unlike mach_task_self() which usually doesn't. + // However, standard practice often keeps host_port cached. + // Given the snippet, we'll ensure we don't leak the port reference if possible, + // though mach_host_self implementation details vary. mach_port_deallocate(mach_task_self(), host_port); return ret; @@ -29,24 +34,17 @@ import "C" import ( "fmt" - "sync" ) -var ( - memoryReader = getSystemMemoryDarwin - // memoryReaderMu protects concurrent access to memoryReader - memoryReaderMu sync.RWMutex -) +type darwinMemoryReader struct{} -// GetSystemMemory returns the current system memory statistics. -func GetSystemMemory() (SystemMemory, error) { - memoryReaderMu.RLock() - reader := memoryReader - memoryReaderMu.RUnlock() - return reader() +// newPlatformMemoryReader is the factory entry point. +func newPlatformMemoryReader(_ FileSystem) ProcessMemoryReader { + return &darwinMemoryReader{} } -func getSystemMemoryDarwin() (SystemMemory, error) { +// Sample returns the current system memory statistics. +func (d *darwinMemoryReader) Sample() (SystemMemory, error) { total := uint64(C.get_hw_memsize()) if total == 0 { return SystemMemory{}, fmt.Errorf("failed to get total memory via sysctl") @@ -57,12 +55,16 @@ func getSystemMemoryDarwin() (SystemMemory, error) { return SystemMemory{}, fmt.Errorf("failed to get host VM statistics: kern_return_t=%d", ret) } + // Page size is usually 16384 (16kb) on M1/M2/M3 (ARM64) and 4096 (4kb) on Intel. + // C.vm_kernel_page_size handles this automatically. pageSize := uint64(C.vm_kernel_page_size) free := uint64(vmStat.free_count) * pageSize inactive := uint64(vmStat.inactive_count) * pageSize speculative := uint64(vmStat.speculative_count) * pageSize + // "Available" memory on macOS is generally considered to be: + // Free + Inactive (file cache that can be dropped) + Speculative return SystemMemory{ Total: total, Available: free + inactive + speculative, diff --git a/internal/sysmonitor/memory_darwin_test.go b/internal/sysmonitor/memory_darwin_test.go new file mode 100644 index 0000000..3ad2157 --- /dev/null +++ b/internal/sysmonitor/memory_darwin_test.go @@ -0,0 +1,60 @@ +//go:build darwin + +package sysmonitor + +import ( + "testing" +) + +func TestDarwinMemoryReaderIntegration(t *testing.T) { + reader := newPlatformMemoryReader(nil).(*darwinMemoryReader) + + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Failed to sample Darwin memory: %v", err) + } + + if mem.Total == 0 { + t.Error("Expected non-zero total memory") + } + + if mem.Available == 0 { + t.Error("Expected non-zero available memory") + } + + if mem.Available > mem.Total { + t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) + } + + t.Logf("Darwin Memory - Total: %d bytes, Available: %d bytes", mem.Total, mem.Available) +} + +func TestNewProcessMemoryReader(t *testing.T) { + // Test the public API function + reader := NewProcessMemoryReader(nil) + + // Should return a non-nil ProcessMemoryReader + if reader == nil { + t.Fatal("NewProcessMemoryReader returned nil") + } +} + +func TestDarwinMemoryReader_ReasonableValues(t *testing.T) { + reader := newPlatformMemoryReader(nil).(*darwinMemoryReader) + + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Failed to sample Darwin memory: %v", err) + } + + // Darwin systems typically have at least 1GB RAM + const minExpectedMemory = 1024 * 1024 * 1024 // 1GB in bytes + if mem.Total < minExpectedMemory { + t.Errorf("Total memory (%d bytes) seems unreasonably low, expected at least %d bytes", mem.Total, minExpectedMemory) + } + + // Available memory should be less than total + if mem.Available >= mem.Total { + t.Errorf("Available memory (%d) should be less than total memory (%d)", mem.Available, mem.Total) + } +} diff --git a/internal/sysmonitor/memory_fallback.go b/internal/sysmonitor/memory_fallback.go index 7ce75a3..449db28 100644 --- a/internal/sysmonitor/memory_fallback.go +++ b/internal/sysmonitor/memory_fallback.go @@ -1,32 +1,25 @@ -//go:build !linux && !windows && (!darwin || (darwin && !cgo)) +//go:build !linux && !windows && (!darwin || !cgo) package sysmonitor import ( "errors" "runtime" - "sync" ) -var ( - // memoryReader is nil on unsupported platforms - memoryReader MemoryReader - // memoryReaderMu protects concurrent access to memoryReader - memoryReaderMu sync.RWMutex -) - -// GetSystemMemory returns an error on unsupported platforms. -func GetSystemMemory() (SystemMemory, error) { - memoryReaderMu.RLock() - reader := memoryReader - memoryReaderMu.RUnlock() +// fallbackMemoryReader is a placeholder for unsupported platforms. +type fallbackMemoryReader struct{} - if reader != nil { - return reader() - } +// newPlatformMemoryReader is the factory entry point. +func newPlatformMemoryReader(_ FileSystem) ProcessMemoryReader { + return &fallbackMemoryReader{} +} +// Sample returns an error indicating that memory monitoring is not supported. +func (f *fallbackMemoryReader) Sample() (SystemMemory, error) { if runtime.GOOS == "darwin" { - return SystemMemory{}, errors.New("memory monitoring not supported on this platform without cgo") + // Darwin memory monitoring is not supported on platforms that don't have CGO enabled. + return SystemMemory{}, errors.New("memory monitoring on Darwin requires CGO_ENABLED=1") } return SystemMemory{}, errors.New("memory monitoring not supported on this platform") } diff --git a/internal/sysmonitor/memory_fallback_test.go b/internal/sysmonitor/memory_fallback_test.go new file mode 100644 index 0000000..a8c5ea3 --- /dev/null +++ b/internal/sysmonitor/memory_fallback_test.go @@ -0,0 +1,74 @@ +//go:build !linux && !windows && (!darwin || !cgo) + +package sysmonitor + +import ( + "errors" + "runtime" + "strings" + "testing" +) + +func TestNewProcessMemoryReader(t *testing.T) { + // Test the public API function + reader := NewProcessMemoryReader(nil) + + // Should return a fallbackMemoryReader + if reader == nil { + t.Fatal("NewProcessMemoryReader returned nil") + } +} + +func TestFallbackMemoryReader(t *testing.T) { + reader := newPlatformMemoryReader(nil).(*fallbackMemoryReader) + + _, err := reader.Sample() + if err == nil { + t.Fatal("Expected error from fallback memory reader") + } + + expectedMsg := "memory monitoring not supported on this platform" + if runtime.GOOS == "darwin" { + expectedMsg = "memory monitoring on Darwin requires CGO_ENABLED=1" + } + + if !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Expected error message to contain %q, got %q", expectedMsg, err.Error()) + } +} + +func TestFallbackMemoryReader_Darwin(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("This test is only relevant on Darwin") + } + + reader := newPlatformMemoryReader(nil).(*fallbackMemoryReader) + + _, err := reader.Sample() + if err == nil { + t.Fatal("Expected error from fallback memory reader on Darwin") + } + + expectedMsg := "memory monitoring on Darwin requires CGO_ENABLED=1" + if !errors.Is(err, err) || !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Expected error message to contain %q, got %q", expectedMsg, err.Error()) + } +} + +func TestFallbackMemoryReader_OtherPlatforms(t *testing.T) { + if runtime.GOOS == "darwin" { + t.Skip("This test is only relevant on non-Darwin platforms") + } + + reader := newPlatformMemoryReader(nil).(*fallbackMemoryReader) + + _, err := reader.Sample() + if err == nil { + t.Fatal("Expected error from fallback memory reader") + } + + expectedMsg := "memory monitoring not supported on this platform" + if !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("Expected error message to contain %q, got %q", expectedMsg, err.Error()) + } +} diff --git a/internal/sysmonitor/memory_linux.go b/internal/sysmonitor/memory_linux.go index b7d8578..38e1c65 100644 --- a/internal/sysmonitor/memory_linux.go +++ b/internal/sysmonitor/memory_linux.go @@ -7,33 +7,105 @@ import ( "bytes" "fmt" "io" - "os" "strconv" "strings" "sync" ) -var ( - memoryReader MemoryReader - fileSystem FileSystem - memoryReaderMu sync.RWMutex - memoryReaderOnce sync.Once -) +// linuxMemoryReader implements ProcessMemoryReader for Linux. +// It encapsulates the logic for auto-detecting Cgroup/Host memory stats. +type linuxMemoryReader struct { + // delegate is the active strategy (CgroupV2, CgroupV1, or Host). + delegate func() (SystemMemory, error) + fs FileSystem + mu sync.RWMutex + once sync.Once +} + +// newPlatformMemoryReader is the factory entry point used by memory.go. +func newPlatformMemoryReader(fs FileSystem) ProcessMemoryReader { + return &linuxMemoryReader{ + fs: fs, + } +} -// GetSystemMemory returns the current system memory statistics. -// This function auto-detects the environment (cgroup v2, v1, or host) -// and returns appropriate memory information. -func GetSystemMemory() (SystemMemory, error) { - memoryReaderOnce.Do(func() { - memoryReader = readSystemMemoryAuto - fileSystem = OSFileSystem{} +// Sample returns the current system memory statistics. +// It lazily initializes the correct strategy (Cgroup vs Host) on the first call. +func (m *linuxMemoryReader) Sample() (SystemMemory, error) { + m.once.Do(func() { + m.detectEnvironment() }) - memoryReaderMu.RLock() - reader := memoryReader - memoryReaderMu.RUnlock() + m.mu.RLock() + strategy := m.delegate + m.mu.RUnlock() + + if strategy == nil { + return SystemMemory{}, fmt.Errorf("failed to initialize memory reader strategy") + } + + return strategy() +} + +// detectEnvironment checks for Cgroups and sets the appropriate delegate strategy. +func (m *linuxMemoryReader) detectEnvironment() { + // 1. Try Cgroup V2 + if m.cgroupFilesExist(cgroupV2Config) { + m.mu.Lock() + m.delegate = m.makeCgroupV2Strategy() + m.mu.Unlock() + return + } + + // 2. Try Cgroup V1 + if m.cgroupFilesExist(cgroupV1Config) { + m.mu.Lock() + m.delegate = m.makeCgroupV1Strategy() + m.mu.Unlock() + return + } + + // 3. Fallback to Host /proc/meminfo + m.mu.Lock() + m.delegate = m.readHostMemory + m.mu.Unlock() +} + +// readHostMemory reads memory info from /proc/meminfo (via FS abstraction) +func (m *linuxMemoryReader) readHostMemory() (SystemMemory, error) { + file, err := m.fs.Open("/proc/meminfo") + if err != nil { + return SystemMemory{}, fmt.Errorf("failed to open /proc/meminfo: %w", err) + } + defer file.Close() + + return parseMemInfo(file) +} + +// makeCgroupV2Strategy returns a function bound to the current FS instance +func (m *linuxMemoryReader) makeCgroupV2Strategy() func() (SystemMemory, error) { + return func() (SystemMemory, error) { + return readCgroupMemoryWithFS(m.fs, cgroupV2Config) + } +} + +// makeCgroupV1Strategy returns a function bound to the current FS instance +func (m *linuxMemoryReader) makeCgroupV1Strategy() func() (SystemMemory, error) { + return func() (SystemMemory, error) { + return readCgroupMemoryWithFS(m.fs, cgroupV1Config) + } +} - return reader() +// cgroupFilesExist checks if the required cgroup files exist and are readable +func (m *linuxMemoryReader) cgroupFilesExist(config cgroupMemoryConfig) bool { + // Check if all required files can be read + paths := []string{config.usagePath, config.limitPath, config.statPath} + for _, path := range paths { + if _, err := m.fs.ReadFile(path); err != nil { + return false + } + } + return true } type cgroupMemoryConfig struct { @@ -64,47 +136,6 @@ var ( } ) -// readSystemMemoryAuto detects the environment once and "upgrades" the reader -func readSystemMemoryAuto() (SystemMemory, error) { - if m, err := readCgroupMemoryWithFS(fileSystem, cgroupV2Config); err == nil { - memoryReaderMu.Lock() - memoryReader = makeCgroupV2Reader(fileSystem) - memoryReaderMu.Unlock() - return m, nil - } - - if m, err := readCgroupMemoryWithFS(fileSystem, cgroupV1Config); err == nil { - memoryReaderMu.Lock() - memoryReader = makeCgroupV1Reader(fileSystem) - memoryReaderMu.Unlock() - return m, nil - } - - // Fallback to Host (Bare metal / VM / Unlimited Container) - m, err := readSystemMemory() - if err != nil { - return SystemMemory{}, fmt.Errorf("failed to read system memory from all sources: %w", err) - } - memoryReaderMu.Lock() - memoryReader = readSystemMemory - memoryReaderMu.Unlock() - return m, nil -} - -// makeCgroupV2Reader creates a MemoryReader function for cgroup v2 -func makeCgroupV2Reader(fs FileSystem) MemoryReader { - return func() (SystemMemory, error) { - return readCgroupMemoryWithFS(fs, cgroupV2Config) - } -} - -// makeCgroupV1Reader creates a MemoryReader function for cgroup v1 -func makeCgroupV1Reader(fs FileSystem) MemoryReader { - return func() (SystemMemory, error) { - return readCgroupMemoryWithFS(fs, cgroupV1Config) - } -} - func readCgroupMemoryWithFS(fs FileSystem, config cgroupMemoryConfig) (SystemMemory, error) { usage, err := readCgroupValueWithFS(fs, config.usagePath, false) if err != nil { @@ -123,8 +154,6 @@ func readCgroupMemoryWithFS(fs FileSystem, config cgroupMemoryConfig) (SystemMem } // Available = (Limit - Usage) + Reclaimable - // Note: If Limit - Usage is near zero, the kernel would reclaim inactive_file - // Handle case where usage exceeds limit var available uint64 if usage > limit { available = inactiveFile // Only reclaimable memory is available @@ -189,18 +218,6 @@ func readCgroupStatWithFS(fs FileSystem, path string, key string) (uint64, error return 0, fmt.Errorf("key %q not found in %s", key, path) } -// readSystemMemory reads memory info from /proc/meminfo -func readSystemMemory() (SystemMemory, error) { - file, err := os.Open("/proc/meminfo") - if err != nil { - return SystemMemory{}, fmt.Errorf("failed to open /proc/meminfo: %w", err) - } - defer file.Close() - - return parseMemInfo(file) -} - -// parseMemInfo parses the /proc/meminfo file format func parseMemInfo(r io.Reader) (SystemMemory, error) { memFields, err := parseMemInfoFields(r) if err != nil { @@ -216,7 +233,6 @@ func parseMemInfo(r io.Reader) (SystemMemory, error) { return SystemMemory{}, err } - // Ensure available doesn't exceed total if available > memFields.total { available = memFields.total } @@ -253,8 +269,6 @@ func parseMemInfoFields(r io.Reader) (memInfoFields, error) { continue } - // Convert from kB to bytes - // Check for overflow const maxValueBeforeOverflow = (1<<64 - 1) / 1024 if value > maxValueBeforeOverflow { return memInfoFields{}, fmt.Errorf( @@ -274,7 +288,6 @@ func parseMemInfoFields(r io.Reader) (memInfoFields, error) { fields.cached = value } - // Early exit if we have MemTotal and MemAvailable (kernel 3.14+) if fields.total > 0 && fields.memAvailableFound { break } @@ -291,14 +304,11 @@ func calculateAvailableMemory(fields memInfoFields) (uint64, error) { if fields.memAvailableFound { return fields.available, nil } - - // Fallback calculation for MemAvailable available := fields.free + fields.cached if available == 0 { return 0, fmt.Errorf( "could not find MemAvailable in /proc/meminfo and fallback calculation failed " + "(MemFree and Cached not found or both zero)") } - return available, nil } diff --git a/internal/sysmonitor/memory_linux_cgroup_test.go b/internal/sysmonitor/memory_linux_cgroup_test.go deleted file mode 100644 index 11643ae..0000000 --- a/internal/sysmonitor/memory_linux_cgroup_test.go +++ /dev/null @@ -1,576 +0,0 @@ -//go:build linux - -package sysmonitor - -import ( - "errors" - "io" - "io/fs" - "strings" - "testing" -) - -// mockFileSystem is a mock implementation of FileSystem for testing -type mockFileSystem struct { - files map[string][]byte - open map[string]io.ReadCloser -} - -func newMockFileSystem() *mockFileSystem { - return &mockFileSystem{ - files: make(map[string][]byte), - open: make(map[string]io.ReadCloser), - } -} - -func (m *mockFileSystem) ReadFile(name string) ([]byte, error) { - if data, ok := m.files[name]; ok { - return data, nil - } - return nil, fs.ErrNotExist -} - -func (m *mockFileSystem) Open(name string) (fs.File, error) { - if file, ok := m.open[name]; ok { - return &mockFile{reader: file}, nil - } - if data, ok := m.files[name]; ok { - return &mockFile{reader: io.NopCloser(strings.NewReader(string(data)))}, nil - } - return nil, fs.ErrNotExist -} - -// mockFile implements fs.File -type mockFile struct { - reader io.ReadCloser -} - -func (m *mockFile) Stat() (fs.FileInfo, error) { - return nil, errors.New("not implemented") -} - -func (m *mockFile) Read(p []byte) (int, error) { - return m.reader.Read(p) -} - -func (m *mockFile) Close() error { - return m.reader.Close() -} - -func TestReadCgroupValueWithFS(t *testing.T) { - tests := []struct { - name string - path string - fileContent string - checkUnlimited bool - want uint64 - wantErr bool - errContains string - }{ - { - name: "valid value", - path: "/sys/fs/cgroup/memory.current", - fileContent: "1073741824", - checkUnlimited: false, - want: 1073741824, - wantErr: false, - }, - { - name: "max value v2", - path: "/sys/fs/cgroup/memory.max", - fileContent: "max", - checkUnlimited: false, - want: 0, - wantErr: true, - errContains: "unlimited memory limit", - }, - { - name: "unlimited v1 (large number)", - path: "/sys/fs/cgroup/memory/memory.limit_in_bytes", - fileContent: "9223372036854775808", // > 1<<60 - checkUnlimited: true, - want: 0, - wantErr: true, - errContains: "unlimited memory limit", - }, - { - name: "file not found", - path: "/nonexistent", - fileContent: "", - checkUnlimited: false, - want: 0, - wantErr: true, - errContains: "failed to read file", - }, - { - name: "invalid number", - path: "/sys/fs/cgroup/memory.current", - fileContent: "not-a-number", - checkUnlimited: false, - want: 0, - wantErr: true, - errContains: "failed to parse value", - }, - { - name: "zero value", - path: "/sys/fs/cgroup/memory.current", - fileContent: "0", - checkUnlimited: false, - want: 0, - wantErr: false, - }, - { - name: "large valid value", - path: "/sys/fs/cgroup/memory.current", - fileContent: "8589934592", // 8GB - checkUnlimited: false, - want: 8589934592, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - fs := newMockFileSystem() - if tt.fileContent != "" { - fs.files[tt.path] = []byte(tt.fileContent) - } - - got, err := readCgroupValueWithFS(fs, tt.path, tt.checkUnlimited) - if (err != nil) != tt.wantErr { - t.Errorf("readCgroupValueWithFS() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr { - if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("readCgroupValueWithFS() error = %v, want error containing %q", err, tt.errContains) - } - } else { - if got != tt.want { - t.Errorf("readCgroupValueWithFS() = %v, want %v", got, tt.want) - } - } - }) - } -} - -func TestReadCgroupStatWithFS(t *testing.T) { - tests := []struct { - name string - path string - key string - fileContent string - want uint64 - wantErr bool - errContains string - }{ - { - name: "valid stat v2", - path: "/sys/fs/cgroup/memory.stat", - key: "inactive_file", - fileContent: "inactive_file 1048576\nactive_file 2097152\n", - want: 1048576, - wantErr: false, - }, - { - name: "valid stat v1", - path: "/sys/fs/cgroup/memory/memory.stat", - key: "total_inactive_file", - fileContent: "cache 4194304\ntotal_inactive_file 2097152\nrss 1048576\n", - want: 2097152, - wantErr: false, - }, - { - name: "key not found", - path: "/sys/fs/cgroup/memory.stat", - key: "nonexistent_key", - fileContent: "inactive_file 1048576\nactive_file 2097152\n", - want: 0, - wantErr: true, - errContains: "not found", - }, - { - name: "file not found", - path: "/nonexistent", - key: "inactive_file", - fileContent: "", - want: 0, - wantErr: true, - errContains: "failed to open file", - }, - { - name: "invalid value", - path: "/sys/fs/cgroup/memory.stat", - key: "inactive_file", - fileContent: "inactive_file not-a-number\n", - want: 0, - wantErr: true, - errContains: "failed to parse value", - }, - { - name: "key with no value", - path: "/sys/fs/cgroup/memory.stat", - key: "inactive_file", - fileContent: "inactive_file\n", - want: 0, - wantErr: true, - errContains: "not found", - }, - { - name: "key at end of line", - path: "/sys/fs/cgroup/memory.stat", - key: "inactive_file", - fileContent: "inactive_file 1048576", - want: 1048576, - wantErr: false, - }, - { - name: "multiple spaces", - path: "/sys/fs/cgroup/memory.stat", - key: "inactive_file", - fileContent: "inactive_file 1048576 \n", - want: 1048576, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - fs := newMockFileSystem() - if tt.fileContent != "" { - fs.files[tt.path] = []byte(tt.fileContent) - } - - got, err := readCgroupStatWithFS(fs, tt.path, tt.key) - if (err != nil) != tt.wantErr { - t.Errorf("readCgroupStatWithFS() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr { - if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("readCgroupStatWithFS() error = %v, want error containing %q", err, tt.errContains) - } - } else { - if got != tt.want { - t.Errorf("readCgroupStatWithFS() = %v, want %v", got, tt.want) - } - } - }) - } -} - -// cgroupMemoryTestCase represents a test case for readCgroupMemoryWithFS -type cgroupMemoryTestCase struct { - name string - usage string - limit string - stat string - wantTotal uint64 - wantAvailable uint64 - wantErr bool - errContains string -} - -// testCgroupMemoryCase is a helper function to test readCgroupMemoryWithFS with different file paths and configs -func testCgroupMemoryCase( - t *testing.T, - tt cgroupMemoryTestCase, - usagePath, limitPath, statPath string, - config cgroupMemoryConfig, -) { - t.Helper() - t.Run(tt.name, func(t *testing.T) { - fs := newMockFileSystem() - if tt.usage != "" { - fs.files[usagePath] = []byte(tt.usage) - } - if tt.limit != "" { - fs.files[limitPath] = []byte(tt.limit) - } - if tt.stat != "" { - fs.files[statPath] = []byte(tt.stat) - } - - got, err := readCgroupMemoryWithFS(fs, config) - if (err != nil) != tt.wantErr { - t.Errorf("readCgroupMemoryWithFS() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr { - if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("readCgroupMemoryWithFS() error = %v, want error containing %q", err, tt.errContains) - } - } else { - if got.Total != tt.wantTotal { - t.Errorf("readCgroupMemoryWithFS() Total = %v, want %v", got.Total, tt.wantTotal) - } - if got.Available != tt.wantAvailable { - t.Errorf("readCgroupMemoryWithFS() Available = %v, want %v", got.Available, tt.wantAvailable) - } - } - }) -} - -func TestReadCgroupMemoryWithFS_V2(t *testing.T) { - tests := []cgroupMemoryTestCase{ - { - name: "normal case", - usage: "1073741824", // 1GB - limit: "2147483648", // 2GB - stat: "inactive_file 104857600\n", // 100MB - wantTotal: 2147483648, - wantAvailable: 1178599424, // (2GB - 1GB) + 100MB = 1073741824 + 104857600 - wantErr: false, - }, - { - name: "usage exceeds limit", - usage: "2147483648", // 2GB - limit: "1073741824", // 1GB - stat: "inactive_file 104857600\n", // 100MB - wantTotal: 1073741824, - wantAvailable: 104857600, // Only reclaimable memory - wantErr: false, - }, - { - name: "no inactive_file stat", - usage: "1073741824", - limit: "2147483648", - stat: "active_file 104857600\n", - wantTotal: 2147483648, - wantAvailable: 1073741824, // (2GB - 1GB) + 0 - wantErr: false, - }, - { - name: "available exceeds limit", - usage: "1073741824", - limit: "2147483648", - stat: "inactive_file 2147483648\n", // 2GB (would make available > limit) - wantTotal: 2147483648, - wantAvailable: 2147483648, // Capped at limit - wantErr: false, - }, - { - name: "zero usage", - usage: "0", - limit: "2147483648", - stat: "inactive_file 104857600\n", - wantTotal: 2147483648, - wantAvailable: 2147483648, // 2GB + 100MB = 2252341248, but capped at limit (2GB) - wantErr: false, - }, - { - name: "missing usage file", - usage: "", - limit: "2147483648", - stat: "inactive_file 104857600\n", - wantTotal: 0, - wantAvailable: 0, - wantErr: true, - errContains: "failed to read cgroup v2 memory usage", - }, - { - name: "missing limit file", - usage: "1073741824", - limit: "", - stat: "inactive_file 104857600\n", - wantTotal: 0, - wantAvailable: 0, - wantErr: true, - errContains: "failed to read cgroup v2 memory limit", - }, - { - name: "unlimited limit", - usage: "1073741824", - limit: "max", - stat: "inactive_file 104857600\n", - wantTotal: 0, - wantAvailable: 0, - wantErr: true, - errContains: "unlimited memory limit", - }, - } - - for _, tt := range tests { - testCgroupMemoryCase(t, tt, - "/sys/fs/cgroup/memory.current", - "/sys/fs/cgroup/memory.max", - "/sys/fs/cgroup/memory.stat", - cgroupV2Config) - } -} - -func TestReadCgroupMemoryWithFS_V1(t *testing.T) { - tests := []cgroupMemoryTestCase{ - { - name: "normal case", - usage: "1073741824", // 1GB - limit: "2147483648", // 2GB - stat: "total_inactive_file 104857600\n", // 100MB - wantTotal: 2147483648, - wantAvailable: 1178599424, // (2GB - 1GB) + 100MB = 1073741824 + 104857600 - wantErr: false, - }, - { - name: "usage exceeds limit", - usage: "2147483648", // 2GB - limit: "1073741824", // 1GB - stat: "total_inactive_file 104857600\n", // 100MB - wantTotal: 1073741824, - wantAvailable: 104857600, // Only reclaimable memory - wantErr: false, - }, - { - name: "unlimited limit (large number)", - usage: "1073741824", - limit: "9223372036854775808", // > 1<<60 - stat: "total_inactive_file 104857600\n", - wantTotal: 0, - wantAvailable: 0, - wantErr: true, - errContains: "unlimited memory limit", - }, - { - name: "missing stat file (should default to 0)", - usage: "1073741824", - limit: "2147483648", - stat: "", - wantTotal: 2147483648, - wantAvailable: 1073741824, // (2GB - 1GB) + 0 - wantErr: false, - }, - } - - for _, tt := range tests { - testCgroupMemoryCase(t, tt, - "/sys/fs/cgroup/memory/memory.usage_in_bytes", - "/sys/fs/cgroup/memory/memory.limit_in_bytes", - "/sys/fs/cgroup/memory/memory.stat", - cgroupV1Config) - } -} - -func TestMakeCgroupV2Reader(t *testing.T) { - fs := newMockFileSystem() - fs.files["/sys/fs/cgroup/memory.current"] = []byte("1073741824") - fs.files["/sys/fs/cgroup/memory.max"] = []byte("2147483648") - fs.files["/sys/fs/cgroup/memory.stat"] = []byte("inactive_file 104857600\n") - - reader := makeCgroupV2Reader(fs) - mem, err := reader() - if err != nil { - t.Fatalf("makeCgroupV2Reader() error = %v", err) - } - - if mem.Total != 2147483648 { - t.Errorf("makeCgroupV2Reader() Total = %v, want %v", mem.Total, 2147483648) - } - if mem.Available != 1178599424 { - t.Errorf("makeCgroupV2Reader() Available = %v, want %v", mem.Available, 1178599424) - } -} - -func TestMakeCgroupV1Reader(t *testing.T) { - fs := newMockFileSystem() - fs.files["/sys/fs/cgroup/memory/memory.usage_in_bytes"] = []byte("1073741824") - fs.files["/sys/fs/cgroup/memory/memory.limit_in_bytes"] = []byte("2147483648") - fs.files["/sys/fs/cgroup/memory/memory.stat"] = []byte("total_inactive_file 104857600\n") - - reader := makeCgroupV1Reader(fs) - mem, err := reader() - if err != nil { - t.Fatalf("makeCgroupV1Reader() error = %v", err) - } - - if mem.Total != 2147483648 { - t.Errorf("makeCgroupV1Reader() Total = %v, want %v", mem.Total, 2147483648) - } - if mem.Available != 1178599424 { - t.Errorf("makeCgroupV1Reader() Available = %v, want %v", mem.Available, 1178599424) - } -} - -func TestReadCgroupMemoryWithFS_EdgeCases(t *testing.T) { - tests := []struct { - name string - config cgroupMemoryConfig - setupFS func(*mockFileSystem) - wantErr bool - errContains string - validate func(*testing.T, SystemMemory) - }{ - { - name: "exact limit equals usage", - config: cgroupV2Config, - setupFS: func(fs *mockFileSystem) { - fs.files["/sys/fs/cgroup/memory.current"] = []byte("1073741824") - fs.files["/sys/fs/cgroup/memory.max"] = []byte("1073741824") - fs.files["/sys/fs/cgroup/memory.stat"] = []byte("inactive_file 104857600\n") - }, - wantErr: false, - validate: func(t *testing.T, mem SystemMemory) { - if mem.Total != 1073741824 { - t.Errorf("Total = %v, want %v", mem.Total, 1073741824) - } - if mem.Available != 104857600 { - t.Errorf("Available = %v, want %v", mem.Available, 104857600) - } - }, - }, - { - name: "very large values", - config: cgroupV2Config, - setupFS: func(fs *mockFileSystem) { - fs.files["/sys/fs/cgroup/memory.current"] = []byte("17179869184") // 16GB - fs.files["/sys/fs/cgroup/memory.max"] = []byte("34359738368") // 32GB - fs.files["/sys/fs/cgroup/memory.stat"] = []byte("inactive_file 1073741824\n") // 1GB - }, - wantErr: false, - validate: func(t *testing.T, mem SystemMemory) { - if mem.Total != 34359738368 { - t.Errorf("Total = %v, want %v", mem.Total, 34359738368) - } - if mem.Available != 18253611008 { // (32GB - 16GB) + 1GB - t.Errorf("Available = %v, want %v", mem.Available, 18253611008) - } - }, - }, - { - name: "stat file read error (should default to 0)", - config: cgroupV2Config, - setupFS: func(fs *mockFileSystem) { - fs.files["/sys/fs/cgroup/memory.current"] = []byte("1073741824") - fs.files["/sys/fs/cgroup/memory.max"] = []byte("2147483648") - // stat file not set, should default to 0 - }, - wantErr: false, - validate: func(t *testing.T, mem SystemMemory) { - if mem.Total != 2147483648 { - t.Errorf("Total = %v, want %v", mem.Total, 2147483648) - } - if mem.Available != 1073741824 { // (2GB - 1GB) + 0 - t.Errorf("Available = %v, want %v", mem.Available, 1073741824) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - fs := newMockFileSystem() - tt.setupFS(fs) - - got, err := readCgroupMemoryWithFS(fs, tt.config) - if (err != nil) != tt.wantErr { - t.Errorf("readCgroupMemoryWithFS() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr { - if err != nil && tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("readCgroupMemoryWithFS() error = %v, want error containing %q", err, tt.errContains) - } - } else { - tt.validate(t, got) - } - }) - } -} diff --git a/internal/sysmonitor/memory_linux_test.go b/internal/sysmonitor/memory_linux_test.go new file mode 100644 index 0000000..593f942 --- /dev/null +++ b/internal/sysmonitor/memory_linux_test.go @@ -0,0 +1,578 @@ +//go:build linux + +package sysmonitor + +import ( + "bytes" + "errors" + "fmt" + "strings" + "testing" +) + +func TestLinuxMemoryReader_CgroupV2(t *testing.T) { + fs := &MockFileSystem{ + Files: map[string][]byte{ + "/sys/fs/cgroup/memory.current": []byte("123456789"), + "/sys/fs/cgroup/memory.max": []byte("987654321"), + "/sys/fs/cgroup/memory.stat": []byte("inactive_file 111111\nother 222"), + }, + } + + reader := newPlatformMemoryReader(fs).(*linuxMemoryReader) + + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Expected success, got error: %v", err) + } + + if mem.Total != 987654321 { + t.Errorf("Expected total 987654321, got %d", mem.Total) + } + expectedAvail := uint64((987654321 - 123456789) + 111111) + if mem.Available != expectedAvail { + t.Errorf("Expected available %d, got %d", expectedAvail, mem.Available) + } +} + +func TestLinuxMemoryReader_CgroupV1(t *testing.T) { + fs := &MockFileSystem{ + Files: map[string][]byte{ + "/sys/fs/cgroup/memory/memory.usage_in_bytes": []byte("222222222"), + "/sys/fs/cgroup/memory/memory.limit_in_bytes": []byte("888888888"), + "/sys/fs/cgroup/memory/memory.stat": []byte("total_inactive_file 333333\n"), + }, + } + + reader := newPlatformMemoryReader(fs).(*linuxMemoryReader) + + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Expected success, got error: %v", err) + } + + expectedAvail := uint64((888888888 - 222222222) + 333333) + if mem.Total != 888888888 || mem.Available != expectedAvail { + t.Errorf("Expected {Total:888888888, Available:%d}, got %+v", expectedAvail, mem) + } +} + +func TestLinuxMemoryReader_CgroupV1_Oversubscribed(t *testing.T) { + // Test case where usage > limit (oversubscribed memory) + fs := &MockFileSystem{ + Files: map[string][]byte{ + "/sys/fs/cgroup/memory/memory.usage_in_bytes": []byte("900000000"), // Usage exceeds limit + "/sys/fs/cgroup/memory/memory.limit_in_bytes": []byte("800000000"), + "/sys/fs/cgroup/memory/memory.stat": []byte("total_inactive_file 50000000\n"), + }, + } + + reader := newPlatformMemoryReader(fs).(*linuxMemoryReader) + + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Expected success, got error: %v", err) + } + + // When usage > limit, available should equal inactive_file only + expectedAvail := uint64(50000000) + if mem.Total != 800000000 { + t.Errorf("Expected total 800000000, got %d", mem.Total) + } + if mem.Available != expectedAvail { + t.Errorf("Expected available %d (inactive_file only), got %d", expectedAvail, mem.Available) + } +} + +func TestLinuxMemoryReader_CgroupV1_AvailableCapped(t *testing.T) { + // Test case where calculated available exceeds limit and gets capped + fs := &MockFileSystem{ + Files: map[string][]byte{ + "/sys/fs/cgroup/memory/memory.usage_in_bytes": []byte("100000000"), // Low usage + "/sys/fs/cgroup/memory/memory.limit_in_bytes": []byte("500000000"), + "/sys/fs/cgroup/memory/memory.stat": []byte("total_inactive_file 600000000\n"), // Very high inactive_file + }, + } + + reader := newPlatformMemoryReader(fs).(*linuxMemoryReader) + + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Expected success, got error: %v", err) + } + + // Calculation: (500000000 - 100000000) + 600000000 = 400000000 + 600000000 = 1,000,000,000 + // But this exceeds limit (500000000), so available should be capped at limit + expectedAvail := uint64(500000000) + if mem.Total != 500000000 { + t.Errorf("Expected total 500000000, got %d", mem.Total) + } + if mem.Available != expectedAvail { + t.Errorf("Expected available %d (capped at limit), got %d", expectedAvail, mem.Available) + } +} + +func TestLinuxMemoryReader_HostFallback(t *testing.T) { + fs := &MockFileSystem{ + Files: map[string][]byte{ + "/proc/meminfo": []byte(`MemTotal: 16384000 kB +MemFree: 8192000 kB +Cached: 4096000 kB +MemAvailable: 10000000 kB +`), + }, + OpenErrs: map[string]error{ + "/sys/fs/cgroup/memory.current": errors.New("no cgroup v2"), + "/sys/fs/cgroup/memory/memory.usage_in_bytes": errors.New("no cgroup v1"), + }, + } + + reader := newPlatformMemoryReader(fs).(*linuxMemoryReader) + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Expected success: %v", err) + } + + if mem.Total != 16384000*1024 || mem.Available != 10000000*1024 { + t.Errorf("Expected MemAvailable parsing, got Total:%d Avail:%d", mem.Total, mem.Available) + } +} + +func TestLinuxMemoryReader_MemInfoFallback(t *testing.T) { + fs := &MockFileSystem{ + Files: map[string][]byte{ + "/proc/meminfo": []byte(`MemTotal: 16384000 kB +MemFree: 8192000 kB +Cached: 4096000 kB +`), + }, + } + + reader := newPlatformMemoryReader(fs).(*linuxMemoryReader) + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Expected success: %v", err) + } + + expectedAvail := uint64((8192000 + 4096000) * 1024) + if mem.Available != expectedAvail { + t.Errorf("Expected fallback calculation %d, got %d", expectedAvail, mem.Available) + } +} + +func TestLinuxMemoryReader_OverflowProtection(t *testing.T) { + // Use a value that definitely exceeds maxValueBeforeOverflow = (1<<64 - 1) / 1024 = 18014398509481983 + _, err := parseMemInfoFields(bytes.NewReader([]byte(`MemTotal: 20000000000000000 kB`))) + if err == nil { + t.Error("Expected overflow error") + } +} + +func TestCgroupDetection(t *testing.T) { + // Test that cgroup V1 detection works with our setup + fs := &MockFileSystem{ + Files: map[string][]byte{ + "/sys/fs/cgroup/memory/memory.usage_in_bytes": []byte("1000000"), + "/sys/fs/cgroup/memory/memory.limit_in_bytes": []byte("18446744073709551615"), + "/sys/fs/cgroup/memory/memory.stat": []byte("total_inactive_file 500000\n"), + }, + } + + reader := newPlatformMemoryReader(fs).(*linuxMemoryReader) + + // Call detectEnvironment manually to see what happens + reader.detectEnvironment() + + // Check if delegate was set (should be set for cgroup V1) + reader.mu.RLock() + delegateSet := reader.delegate != nil + reader.mu.RUnlock() + + if !delegateSet { + t.Error("Cgroup V1 detection should have succeeded") + } +} + +func TestNewProcessMemoryReader(t *testing.T) { + reader := NewProcessMemoryReader(&MockFileSystem{}) + + // Should return a linuxMemoryReader (or platform-specific implementation) + if reader == nil { + t.Fatal("NewProcessMemoryReader returned nil") + } +} + +func TestLinuxMemoryReader_ErrorPaths(t *testing.T) { + tests := []struct { + name string + setup func(*MockFileSystem) + err string + }{ + { + "MemTotal missing", + func(fs *MockFileSystem) { + if fs.Files == nil { + fs.Files = make(map[string][]byte) + } + fs.Files["/proc/meminfo"] = []byte("MemFree: 1000 kB\n") + }, + "could not find MemTotal", + }, + { + "Meminfo parse fail", + func(fs *MockFileSystem) { + if fs.OpenErrs == nil { + fs.OpenErrs = make(map[string]error) + } + fs.OpenErrs["/proc/meminfo"] = errors.New("no meminfo") + }, + "failed to open /proc/meminfo", + }, + { + "Cgroup unlimited V1", + func(fs *MockFileSystem) { + // Replace the Files map entirely to ensure clean setup + fs.Files = map[string][]byte{ + "/sys/fs/cgroup/memory/memory.usage_in_bytes": []byte("1000000"), + "/sys/fs/cgroup/memory/memory.limit_in_bytes": []byte("18446744073709551615"), + "/sys/fs/cgroup/memory/memory.stat": []byte("total_inactive_file 500000\n"), + } + // Block Cgroup V2 detection by making V2 files return errors + fs.OpenErrs = map[string]error{ + "/sys/fs/cgroup/memory.current": errors.New("no cgroup v2"), + "/sys/fs/cgroup/memory.max": errors.New("no cgroup v2"), + "/proc/meminfo": errors.New("cgroup should be used"), + } + }, + "unlimited memory limit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := &MockFileSystem{} + tt.setup(fs) + + // Verify mock setup before creating reader + if tt.name == "Cgroup unlimited V1" { + if _, err := fs.ReadFile("/sys/fs/cgroup/memory/memory.limit_in_bytes"); err != nil { + t.Fatalf("Mock setup failed: %v", err) + } + } + + reader := newPlatformMemoryReader(fs).(*linuxMemoryReader) + + // Force detection (your once.Do will trigger it) + reader.once.Do(func() { reader.detectEnvironment() }) + + _, err := reader.Sample() + if err == nil || !strings.Contains(err.Error(), tt.err) { + t.Errorf("Expected error containing %q, got %v", tt.err, err) + } + }) + } +} + +// Helper function for cgroup value tests +func runCgroupValueTest( + t *testing.T, + name, fileContent string, + checkUnlimited bool, + expected uint64, + expectError bool, + errorContains string, +) { + fs := &MockFileSystem{} + if name != "File read error" { + fs.Files = map[string][]byte{ + "/test/file": []byte(fileContent), + } + } else { + fs.OpenErrs = map[string]error{ + "/test/file": fmt.Errorf("permission denied"), + } + } + + result, err := readCgroupValueWithFS(fs, "/test/file", checkUnlimited) + + if expectError { + if err == nil { + t.Errorf("Expected error containing %q, got nil", errorContains) + } else if !strings.Contains(err.Error(), errorContains) { + t.Errorf("Expected error containing %q, got %v", errorContains, err) + } + } else { + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if result != expected { + t.Errorf("Expected %d, got %d", expected, result) + } + } +} + +// Helper function for cgroup stat tests +func runCgroupStatTest( + t *testing.T, + name, fileContent, key string, + expected uint64, + expectError bool, + errorContains string, +) { + fs := &MockFileSystem{} + if name != "File open error" { + fs.Files = map[string][]byte{ + "/test/stat": []byte(fileContent), + } + } else { + fs.OpenErrs = map[string]error{ + "/test/stat": fmt.Errorf("permission denied"), + } + } + + result, err := readCgroupStatWithFS(fs, "/test/stat", key) + + if expectError { + if err == nil { + t.Errorf("Expected error containing %q, got nil", errorContains) + } else if !strings.Contains(err.Error(), errorContains) { + t.Errorf("Expected error containing %q, got %v", errorContains, err) + } + } else { + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if result != expected { + t.Errorf("Expected %d, got %d", expected, result) + } + } +} + +// Test readCgroupValueWithFS edge cases +func TestReadCgroupValueWithFS(t *testing.T) { + tests := []struct { + name string + fileContent string + checkUnlimited bool + expected uint64 + expectError bool + errorContains string + }{ + { + name: "Normal value", + fileContent: "1024000", + checkUnlimited: false, + expected: 1024000, + expectError: false, + }, + { + name: "Unlimited max value", + fileContent: "max", + checkUnlimited: true, + expected: 0, + expectError: true, + errorContains: "unlimited memory limit", + }, + { + name: "Unlimited large value", + fileContent: "18446744073709551615", // ^uint64(0) + checkUnlimited: true, + expected: 0, + expectError: true, + errorContains: "unlimited memory limit", + }, + { + name: "Large value but checkUnlimited false", + fileContent: "18446744073709551615", + checkUnlimited: false, + expected: 18446744073709551615, + expectError: false, + }, + { + name: "Invalid number", + fileContent: "invalid", + checkUnlimited: false, + expected: 0, + expectError: true, + errorContains: "failed to parse value", + }, + { + name: "Empty file", + fileContent: "", + checkUnlimited: false, + expected: 0, + expectError: true, + errorContains: "failed to parse value", + }, + { + name: "Whitespace padded", + fileContent: " 12345 \n", + checkUnlimited: false, + expected: 12345, + expectError: false, + }, + { + name: "File read error", + fileContent: "", + checkUnlimited: false, + expected: 0, + expectError: true, + errorContains: "failed to read file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runCgroupValueTest(t, tt.name, tt.fileContent, tt.checkUnlimited, tt.expected, tt.expectError, tt.errorContains) + }) + } +} + +// Test readCgroupStatWithFS edge cases +func TestReadCgroupStatWithFS(t *testing.T) { + tests := []struct { + name string + fileContent string + key string + expected uint64 + expectError bool + errorContains string + }{ + { + name: "Normal key found", + fileContent: "total_inactive_file 50000\nother_key 1000\n", + key: "total_inactive_file", + expected: 50000, + expectError: false, + }, + { + name: "Key at end of file", + fileContent: "first_key 100\ntotal_inactive_file 75000", + key: "total_inactive_file", + expected: 75000, + expectError: false, + }, + { + name: "Key not found", + fileContent: "other_key 100\nanother_key 200\n", + key: "total_inactive_file", + expected: 0, + expectError: true, + errorContains: "key \"total_inactive_file\" not found", + }, + { + name: "Invalid value", + fileContent: "total_inactive_file invalid\n", + key: "total_inactive_file", + expected: 0, + expectError: true, + errorContains: "failed to parse value", + }, + { + name: "Key without value", + fileContent: "total_inactive_file\nother_key 100\n", + key: "total_inactive_file", + expected: 0, + expectError: true, + errorContains: "key \"total_inactive_file\" not found", + }, + { + name: "File open error", + fileContent: "", + key: "total_inactive_file", + expected: 0, + expectError: true, + errorContains: "failed to open file", + }, + { + name: "Multiple spaces", + fileContent: "total_inactive_file 12345\n", + key: "total_inactive_file", + expected: 12345, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runCgroupStatTest(t, tt.name, tt.fileContent, tt.key, tt.expected, tt.expectError, tt.errorContains) + }) + } +} + +// Test parseMemInfo edge cases +func TestParseMemInfo(t *testing.T) { + tests := []struct { + name string + meminfo string + expectedTotal uint64 + expectedAvail uint64 + expectError bool + errorContains string + }{ + { + name: "Missing MemTotal", + meminfo: "MemFree: 1000 kB\nCached: 2000 kB\n", + expectedTotal: 0, + expectedAvail: 0, + expectError: true, + errorContains: "could not find MemTotal", + }, + { + name: "Invalid MemTotal", + meminfo: "MemTotal: invalid kB\nMemFree: 1000 kB\n", + expectedTotal: 0, + expectedAvail: 0, + expectError: true, + errorContains: "could not find MemTotal", // parseMemInfoFields skips invalid lines + }, + { + name: "Normal case with MemAvailable", + meminfo: "MemTotal: 8000 kB\nMemFree: 1000 kB\nMemAvailable: 6000 kB\nCached: 2000 kB\n", + expectedTotal: 8192000, // 8000 kB * 1024 = 8192000 bytes + expectedAvail: 6144000, // 6000 kB * 1024 = 6144000 bytes + expectError: false, + }, + { + name: "Fallback calculation without MemAvailable", + meminfo: "MemTotal: 8000 kB\nMemFree: 1000 kB\nCached: 2000 kB\n", + expectedTotal: 8192000, // 8000 kB * 1024 = 8192000 bytes + expectedAvail: 0, // Will be calculated by calculateAvailableMemory + expectError: false, + }, + { + name: "Fallback fails when MemFree and Cached are zero", + meminfo: "MemTotal: 8000 kB\nMemFree: 0 kB\nCached: 0 kB\n", + expectedTotal: 0, + expectedAvail: 0, + expectError: true, + errorContains: "fallback calculation failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := strings.NewReader(tt.meminfo) + result, err := parseMemInfo(reader) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error containing %q, got nil", tt.errorContains) + } else if !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("Expected error containing %q, got %v", tt.errorContains, err) + } + } else { + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + if result.Total != tt.expectedTotal { + t.Errorf("Expected total %d, got %d", tt.expectedTotal, result.Total) + } + // Note: Available memory calculation may vary, so we just check it's reasonable + if tt.expectedAvail > 0 && result.Available == 0 { + t.Errorf("Expected some available memory, got 0") + } + } + }) + } +} diff --git a/internal/sysmonitor/memory_test.go b/internal/sysmonitor/memory_test.go deleted file mode 100644 index fbb76e9..0000000 --- a/internal/sysmonitor/memory_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package sysmonitor - -import ( - "runtime" - "strings" - "testing" -) - -func TestGetSystemMemory(t *testing.T) { - mem, err := GetSystemMemory() - if err != nil { - // Skip test on unsupported platforms - if strings.Contains(err.Error(), "not supported on this platform") { - t.Skipf("GetSystemMemory not supported on %s: %v", runtime.GOOS, err) - } - t.Fatalf("GetSystemMemory failed: %v", err) - } - - if mem.Total == 0 { - t.Error("Total memory should not be zero") - } - - if mem.Available > mem.Total { - t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) - } -} - -func TestMemorySampler(t *testing.T) { - mem, err := GetSystemMemory() - if err != nil { - // Skip test on unsupported platforms - if strings.Contains(err.Error(), "not supported on this platform") { - t.Skipf("GetSystemMemory not supported on %s: %v", runtime.GOOS, err) - } - t.Fatalf("GetSystemMemory failed: %v", err) - } - - if mem.Total == 0 { - t.Error("Total memory should not be zero") - } - - if mem.Available > mem.Total { - t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) - } -} - -func TestSystemMemoryCalculations(t *testing.T) { - testCases := []struct { - name string - total uint64 - available uint64 - expectedPercent float64 - }{ - {"full", 100, 100, 0.0}, - {"half", 100, 50, 50.0}, - {"quarter", 100, 25, 75.0}, - {"empty", 100, 0, 100.0}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - mem := SystemMemory{ - Total: tc.total, - Available: tc.available, - } - - // Calculate used percentage - used := mem.Total - mem.Available - percent := float64(used) / float64(mem.Total) * 100.0 - - if percent != tc.expectedPercent { - t.Errorf("Expected %.1f%% used, got %.1f%%", tc.expectedPercent, percent) - } - }) - } -} diff --git a/internal/sysmonitor/memory_windows.go b/internal/sysmonitor/memory_windows.go index aa295d6..008da3a 100644 --- a/internal/sysmonitor/memory_windows.go +++ b/internal/sysmonitor/memory_windows.go @@ -4,7 +4,6 @@ package sysmonitor import ( "fmt" - "sync" "syscall" "unsafe" ) @@ -12,11 +11,9 @@ import ( var ( kernel32 = syscall.NewLazyDLL("kernel32.dll") procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") - - memoryReader = getSystemMemoryWindows - memoryReaderMu sync.RWMutex ) +// memoryStatusEx matches the MEMORYSTATUSEX structure in Windows API type memoryStatusEx struct { dwLength uint32 dwMemoryLoad uint32 @@ -29,22 +26,24 @@ type memoryStatusEx struct { ullAvailExtendedVirtual uint64 } -func GetSystemMemory() (SystemMemory, error) { - memoryReaderMu.RLock() - reader := memoryReader - memoryReaderMu.RUnlock() - return reader() +// windowsMemoryReader implements ProcessMemoryReader for Windows using Win32 API. +type windowsMemoryReader struct{} + +// newPlatformMemoryReader is the factory entry point. +func newPlatformMemoryReader(_ FileSystem) ProcessMemoryReader { + return &windowsMemoryReader{} } -func getSystemMemoryWindows() (SystemMemory, error) { +// Sample returns the current system memory statistics. +func (w *windowsMemoryReader) Sample() (SystemMemory, error) { var memStatus memoryStatusEx - memStatus.dwLength = uint32(unsafe.Sizeof(memStatus)) + // Call GlobalMemoryStatusEx ret, _, err := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&memStatus))) // If the function fails, the return value is zero. - if ret == 0 || err != nil { + if ret == 0 { return SystemMemory{}, fmt.Errorf("failed to get system memory status via GlobalMemoryStatusEx: %w", err) } diff --git a/internal/sysmonitor/memory_windows_test.go b/internal/sysmonitor/memory_windows_test.go new file mode 100644 index 0000000..635d6d7 --- /dev/null +++ b/internal/sysmonitor/memory_windows_test.go @@ -0,0 +1,92 @@ +//go:build windows + +package sysmonitor + +import ( + "math" + "testing" +) + +func TestWindowsMemoryReaderIntegration(t *testing.T) { + reader := newPlatformMemoryReader(nil).(*windowsMemoryReader) + + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Failed to sample Windows memory: %v", err) + } + + if mem.Total == 0 { + t.Error("Expected non-zero total memory") + } + + if mem.Available == 0 { + t.Error("Expected non-zero available memory") + } + + if mem.Available > mem.Total { + t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) + } + + t.Logf("Windows Memory - Total: %d bytes, Available: %d bytes", mem.Total, mem.Available) +} + +func TestNewProcessMemoryReader(t *testing.T) { + // Test the public API function + reader := NewProcessMemoryReader(nil) + + // Should return a windowsMemoryReader + if reader == nil { + t.Fatal("NewProcessMemoryReader returned nil") + } +} + +func TestWindowsMemoryReader_ReasonableValues(t *testing.T) { + reader := newPlatformMemoryReader(nil).(*windowsMemoryReader) + + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Failed to sample Windows memory: %v", err) + } + + // Windows systems typically have at least 1GB RAM + const minExpectedMemory = 1024 * 1024 * 1024 // 1GB in bytes + if mem.Total < minExpectedMemory { + t.Errorf("Total memory (%d bytes) seems unreasonably low, expected at least %d bytes", mem.Total, minExpectedMemory) + } + + // Available memory should be less than or equal to total + if mem.Available > mem.Total { + t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) + } +} + +func TestWindowsMemoryReader_Consistency(t *testing.T) { + reader := newPlatformMemoryReader(nil).(*windowsMemoryReader) + + // Take multiple samples to ensure consistency + var samples []SystemMemory + for i := 0; i < 3; i++ { + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Failed to sample Windows memory on iteration %d: %v", i, err) + } + samples = append(samples, mem) + } + + // Total memory should be consistent across samples + for i := 1; i < len(samples); i++ { + if samples[i].Total != samples[0].Total { + t.Errorf("Total memory inconsistent: sample 0: %d, sample %d: %d", + samples[0].Total, i, samples[i].Total) + } + } + + // Available memory should be reasonable (not wildly different) + for i := 1; i < len(samples); i++ { + availableDiff := math.Abs(float64(samples[i].Available) - float64(samples[0].Available)) + if availableDiff > float64(samples[0].Total/4) { + t.Errorf("Available memory changed dramatically: sample 0: %d, sample %d: %d", + samples[0].Available, i, samples[i].Available) + } + } +} diff --git a/internal/sysmonitor/test_helpers.go b/internal/sysmonitor/test_helpers.go new file mode 100644 index 0000000..26405f7 --- /dev/null +++ b/internal/sysmonitor/test_helpers.go @@ -0,0 +1,53 @@ +package sysmonitor + +import ( + "errors" + "io" + "io/fs" +) + +// MockFileSystem implements FileSystem for testing +type MockFileSystem struct { + Files map[string][]byte + OpenErrs map[string]error +} + +func (m *MockFileSystem) ReadFile(name string) ([]byte, error) { + if content, ok := m.Files[name]; ok { + return content, nil + } + return nil, errors.New("file not found") +} + +func (m *MockFileSystem) Open(name string) (fs.File, error) { + if err, ok := m.OpenErrs[name]; ok { + return nil, err + } + if content, ok := m.Files[name]; ok { + return &mockFile{content: content, pos: 0}, nil + } + return nil, errors.New("file not found") +} + +// mockFile implements fs.File for testing +type mockFile struct { + content []byte + pos int +} + +func (f *mockFile) Read(p []byte) (n int, err error) { + if f.pos >= len(f.content) { + return 0, io.EOF + } + n = copy(p, f.content[f.pos:]) + f.pos += n + return n, nil +} + +func (f *mockFile) Close() error { + return nil +} + +func (f *mockFile) Stat() (fs.FileInfo, error) { + return nil, errors.New("Stat not implemented") +} From 153c0154f199e0ba001a4e0ba87292e24202597f Mon Sep 17 00:00:00 2001 From: kxrxh Date: Wed, 3 Dec 2025 16:42:17 +0300 Subject: [PATCH 53/54] test(sysmonitor): add comprehensive tests for Windows CPU and memory monitoring --- internal/sysmonitor/cpu_windows_test.go | 339 ++++++++++++++++++--- internal/sysmonitor/memory_windows_test.go | 262 ++++++++++------ 2 files changed, 461 insertions(+), 140 deletions(-) diff --git a/internal/sysmonitor/cpu_windows_test.go b/internal/sysmonitor/cpu_windows_test.go index d69e71f..d392994 100644 --- a/internal/sysmonitor/cpu_windows_test.go +++ b/internal/sysmonitor/cpu_windows_test.go @@ -1,48 +1,291 @@ -//go:build windows - -package sysmonitor - -import ( - "testing" - "time" -) - -// TestWindowsSamplerIntegration runs real calls against the OS. -func TestWindowsSamplerIntegration(t *testing.T) { - sampler, err := newPlatformCPUSampler(nil) - if err != nil { - t.Fatalf("Failed to create Windows sampler: %v", err) - } - - // 1. Initialize - val := sampler.Sample(time.Second) - if val != 0.0 { - t.Errorf("Expected initial sample 0.0, got %f", val) - } - - // 2. Generate Load (Busy Loop) to ensure non-zero CPU - done := make(chan struct{}) - go func() { - end := time.Now().Add(100 * time.Millisecond) - for time.Now().Before(end) { - } - close(done) - }() - <-done - - // Sleep a tiny bit to ensure total elapsed > busy loop time - time.Sleep(50 * time.Millisecond) - - // 3. Measure - val = sampler.Sample(0) - - // Log result (useful for verification) - t.Logf("Measured CPU Load: %f%%", val) - - if val <= 0.0 { - t.Error("Expected non-zero CPU usage after busy loop") - } - if val > 100.0 { - t.Errorf("CPU usage %f exceeds 100%%", val) - } -} +//go:build windows + +package sysmonitor + +import ( + "testing" + "time" +) + +// TestWindowsSamplerIntegration runs real calls against the OS. +func TestWindowsSamplerIntegration(t *testing.T) { + sampler, err := newPlatformCPUSampler(nil) + if err != nil { + t.Fatalf("Failed to create Windows sampler: %v", err) + } + + // 1. Initialize + val := sampler.Sample(time.Second) + if val != 0.0 { + t.Errorf("Expected initial sample 0.0, got %f", val) + } + + // 2. Generate Load (Busy Loop) to ensure non-zero CPU + done := make(chan struct{}) + go func() { + end := time.Now().Add(100 * time.Millisecond) + for time.Now().Before(end) { + } + close(done) + }() + <-done + + // Sleep a tiny bit to ensure total elapsed > busy loop time + time.Sleep(50 * time.Millisecond) + + // 3. Measure + val = sampler.Sample(0) + + // Log result (useful for verification) + t.Logf("Measured CPU Load: %f%%", val) + + if val <= 0.0 { + t.Error("Expected non-zero CPU usage after busy loop") + } + if val > 100.0 { + t.Errorf("CPU usage %f exceeds 100%%", val) + } +} + +// TestWindowsSamplerReset tests the Reset functionality +func TestWindowsSamplerReset(t *testing.T) { + sampler, err := newPlatformCPUSampler(nil) + if err != nil { + t.Fatalf("Failed to create Windows sampler: %v", err) + } + + // Take an initial sample to initialize state + val1 := sampler.Sample(time.Second) + if val1 != 0.0 { + t.Errorf("Expected initial sample 0.0, got %f", val1) + } + + // Verify sampler is initialized + if !sampler.IsInitialized() { + t.Error("Expected sampler to be initialized after first sample") + } + + // Reset the sampler + sampler.Reset() + + // Verify sampler is no longer initialized + if sampler.IsInitialized() { + t.Error("Expected sampler to be uninitialized after reset") + } + + // Take another sample - should return 0.0 again (like first sample) + val2 := sampler.Sample(time.Second) + if val2 != 0.0 { + t.Errorf("Expected sample after reset to be 0.0, got %f", val2) + } + + // Verify sampler is now initialized again + if !sampler.IsInitialized() { + t.Error("Expected sampler to be initialized after second sample") + } +} + +func TestWindowsSamplerIsInitialized(t *testing.T) { + sampler, err := newPlatformCPUSampler(nil) + if err != nil { + t.Fatalf("Failed to create Windows sampler: %v", err) + } + + // Initially should not be initialized + if sampler.IsInitialized() { + t.Error("Expected new sampler to be uninitialized") + } + + // After first sample, should be initialized + sampler.Sample(time.Second) + if !sampler.IsInitialized() { + t.Error("Expected sampler to be initialized after first sample") + } + + // After reset, should be uninitialized again + sampler.Reset() + if sampler.IsInitialized() { + t.Error("Expected sampler to be uninitialized after reset") + } +} + +func TestWindowsSamplerShortInterval(t *testing.T) { + sampler, err := newPlatformCPUSampler(nil) + if err != nil { + t.Fatalf("Failed to create Windows sampler: %v", err) + } + + // Initialize with first sample + val1 := sampler.Sample(time.Second) + if val1 != 0.0 { + t.Errorf("Expected initial sample 0.0, got %f", val1) + } + + // Generate some CPU load and take a normal sample + done := make(chan struct{}) + go func() { + end := time.Now().Add(50 * time.Millisecond) + for time.Now().Before(end) { + } + close(done) + }() + <-done + + time.Sleep(10 * time.Millisecond) // Ensure some elapsed time + val2 := sampler.Sample(100 * time.Millisecond) // Normal sample + t.Logf("Normal sample after load: %f", val2) + + // Now test short interval behavior - sample immediately again with very short delta + val3 := sampler.Sample(10 * time.Millisecond) // Very short interval (< deltaTime/2 = 5ms) + + // Should return the previous value due to short interval + if val3 != val2 { + t.Errorf("Expected same value %f due to short interval (10ms < 50ms), got %f", val2, val3) + } else { + t.Logf("Short interval correctly returned cached value: %f", val3) + } +} + +func TestWindowsSamplerConsistency(t *testing.T) { + sampler, err := newPlatformCPUSampler(nil) + if err != nil { + t.Fatalf("Failed to create Windows sampler: %v", err) + } + + // Initialize + sampler.Sample(time.Second) + + // Take multiple samples over time + var samples []float64 + for i := 0; i < 3; i++ { + time.Sleep(100 * time.Millisecond) + val := sampler.Sample(50 * time.Millisecond) + samples = append(samples, val) + + if val < 0.0 || val > 100.0 { + t.Errorf("Sample %d out of valid range: %f", i, val) + } + } + + t.Logf("Consistency test samples: %v", samples) +} + +// TestWindowsSamplerBoundaryCalculations tests edge cases in CPU calculations +func TestWindowsSamplerBoundaryCalculations(t *testing.T) { + sampler, err := newPlatformCPUSampler(nil) + if err != nil { + t.Fatalf("Failed to create Windows sampler: %v", err) + } + + // Test many rapid samples to ensure bounds checking works + for i := 0; i < 20; i++ { + val := sampler.Sample(time.Millisecond) + if val < 0.0 { + t.Errorf("Sample %d returned negative value: %f", i, val) + } + if val > 100.0 { + t.Errorf("Sample %d exceeded 100%%: %f", i, val) + } + } + + // Test with very small delta times + val := sampler.Sample(time.Nanosecond) + if val < 0.0 || val > 100.0 { + t.Errorf("Nanosecond delta sample out of bounds: %f", val) + } + + // Test reset and immediate sampling + sampler.Reset() + val = sampler.Sample(0) + if val != 0.0 { + t.Errorf("Expected 0.0 after reset, got %f", val) + } +} + +// TestWindowsSamplerLoadScenarios tests various CPU load scenarios +func TestWindowsSamplerLoadScenarios(t *testing.T) { + sampler, err := newPlatformCPUSampler(nil) + if err != nil { + t.Fatalf("Failed to create Windows sampler: %v", err) + } + + // Initialize + sampler.Sample(time.Second) + + // Test 1: No load scenario + time.Sleep(50 * time.Millisecond) + val1 := sampler.Sample(100 * time.Millisecond) + t.Logf("No load CPU: %f%%", val1) + + // Test 2: Light load scenario + done := make(chan struct{}) + go func() { + end := time.Now().Add(30 * time.Millisecond) + for time.Now().Before(end) { + // Light CPU work + _ = time.Now().UnixNano() + } + close(done) + }() + <-done + + time.Sleep(10 * time.Millisecond) + val2 := sampler.Sample(100 * time.Millisecond) + t.Logf("Light load CPU: %f%%", val2) + + // Test 3: Moderate load scenario + done = make(chan struct{}) + go func() { + end := time.Now().Add(50 * time.Millisecond) + for time.Now().Before(end) { + // Moderate CPU work + for j := 0; j < 1000; j++ { + _ = j * j + } + } + close(done) + }() + <-done + + time.Sleep(10 * time.Millisecond) + val3 := sampler.Sample(100 * time.Millisecond) + t.Logf("Moderate load CPU: %f%%", val3) + + // All values should be valid + for i, val := range []float64{val1, val2, val3} { + if val < 0.0 || val > 100.0 { + t.Errorf("Load scenario %d out of bounds: %f", i+1, val) + } + } +} + +// TestWindowsSamplerTimingBehavior tests timing-related behavior +func TestWindowsSamplerTimingBehavior(t *testing.T) { + sampler, err := newPlatformCPUSampler(nil) + if err != nil { + t.Fatalf("Failed to create Windows sampler: %v", err) + } + + // Initialize + start := time.Now() + sampler.Sample(time.Second) + initTime := time.Since(start) + + // Test rapid sampling behavior + for i := 0; i < 5; i++ { + start := time.Now() + val := sampler.Sample(50 * time.Millisecond) + elapsed := time.Since(start) + + // Should complete quickly (less than 10ms typically) + if elapsed > 100*time.Millisecond { + t.Errorf("Sample %d took too long: %v", i, elapsed) + } + + if val < 0.0 || val > 100.0 { + t.Errorf("Sample %d value out of bounds: %f", i, val) + } + } + + t.Logf("Initialization took: %v", initTime) +} diff --git a/internal/sysmonitor/memory_windows_test.go b/internal/sysmonitor/memory_windows_test.go index 635d6d7..dc3253e 100644 --- a/internal/sysmonitor/memory_windows_test.go +++ b/internal/sysmonitor/memory_windows_test.go @@ -1,92 +1,170 @@ -//go:build windows - -package sysmonitor - -import ( - "math" - "testing" -) - -func TestWindowsMemoryReaderIntegration(t *testing.T) { - reader := newPlatformMemoryReader(nil).(*windowsMemoryReader) - - mem, err := reader.Sample() - if err != nil { - t.Fatalf("Failed to sample Windows memory: %v", err) - } - - if mem.Total == 0 { - t.Error("Expected non-zero total memory") - } - - if mem.Available == 0 { - t.Error("Expected non-zero available memory") - } - - if mem.Available > mem.Total { - t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) - } - - t.Logf("Windows Memory - Total: %d bytes, Available: %d bytes", mem.Total, mem.Available) -} - -func TestNewProcessMemoryReader(t *testing.T) { - // Test the public API function - reader := NewProcessMemoryReader(nil) - - // Should return a windowsMemoryReader - if reader == nil { - t.Fatal("NewProcessMemoryReader returned nil") - } -} - -func TestWindowsMemoryReader_ReasonableValues(t *testing.T) { - reader := newPlatformMemoryReader(nil).(*windowsMemoryReader) - - mem, err := reader.Sample() - if err != nil { - t.Fatalf("Failed to sample Windows memory: %v", err) - } - - // Windows systems typically have at least 1GB RAM - const minExpectedMemory = 1024 * 1024 * 1024 // 1GB in bytes - if mem.Total < minExpectedMemory { - t.Errorf("Total memory (%d bytes) seems unreasonably low, expected at least %d bytes", mem.Total, minExpectedMemory) - } - - // Available memory should be less than or equal to total - if mem.Available > mem.Total { - t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) - } -} - -func TestWindowsMemoryReader_Consistency(t *testing.T) { - reader := newPlatformMemoryReader(nil).(*windowsMemoryReader) - - // Take multiple samples to ensure consistency - var samples []SystemMemory - for i := 0; i < 3; i++ { - mem, err := reader.Sample() - if err != nil { - t.Fatalf("Failed to sample Windows memory on iteration %d: %v", i, err) - } - samples = append(samples, mem) - } - - // Total memory should be consistent across samples - for i := 1; i < len(samples); i++ { - if samples[i].Total != samples[0].Total { - t.Errorf("Total memory inconsistent: sample 0: %d, sample %d: %d", - samples[0].Total, i, samples[i].Total) - } - } - - // Available memory should be reasonable (not wildly different) - for i := 1; i < len(samples); i++ { - availableDiff := math.Abs(float64(samples[i].Available) - float64(samples[0].Available)) - if availableDiff > float64(samples[0].Total/4) { - t.Errorf("Available memory changed dramatically: sample 0: %d, sample %d: %d", - samples[0].Available, i, samples[i].Available) - } - } -} +//go:build windows + +package sysmonitor + +import ( + "math" + "testing" +) + +func TestWindowsMemoryReaderIntegration(t *testing.T) { + reader := newPlatformMemoryReader(nil).(*windowsMemoryReader) + + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Failed to sample Windows memory: %v", err) + } + + if mem.Total == 0 { + t.Error("Expected non-zero total memory") + } + + if mem.Available == 0 { + t.Error("Expected non-zero available memory") + } + + if mem.Available > mem.Total { + t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) + } + + t.Logf("Windows Memory - Total: %d bytes, Available: %d bytes", mem.Total, mem.Available) +} + +func TestNewProcessMemoryReader(t *testing.T) { + // Test the public API function + reader := NewProcessMemoryReader(nil) + + // Should return a windowsMemoryReader + if reader == nil { + t.Fatal("NewProcessMemoryReader returned nil") + } +} + +func TestWindowsMemoryReader_ReasonableValues(t *testing.T) { + reader := newPlatformMemoryReader(nil).(*windowsMemoryReader) + + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Failed to sample Windows memory: %v", err) + } + + // Windows systems typically have at least 1GB RAM + const minExpectedMemory = 1024 * 1024 * 1024 // 1GB in bytes + if mem.Total < minExpectedMemory { + t.Errorf("Total memory (%d bytes) seems unreasonably low, expected at least %d bytes", mem.Total, minExpectedMemory) + } + + // Available memory should be less than or equal to total + if mem.Available > mem.Total { + t.Errorf("Available memory (%d) should not exceed total memory (%d)", mem.Available, mem.Total) + } +} + +func TestWindowsMemoryReader_Consistency(t *testing.T) { + reader := newPlatformMemoryReader(nil).(*windowsMemoryReader) + + // Take multiple samples to ensure consistency + var samples []SystemMemory + for i := 0; i < 3; i++ { + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Failed to sample Windows memory on iteration %d: %v", i, err) + } + samples = append(samples, mem) + } + + // Total memory should be consistent across samples + for i := 1; i < len(samples); i++ { + if samples[i].Total != samples[0].Total { + t.Errorf("Total memory inconsistent: sample 0: %d, sample %d: %d", + samples[0].Total, i, samples[i].Total) + } + } + + // Available memory should be reasonable (not wildly different) + for i := 1; i < len(samples); i++ { + availableDiff := math.Abs(float64(samples[i].Available) - float64(samples[0].Available)) + if availableDiff > float64(samples[0].Total/4) { + t.Errorf("Available memory changed dramatically: sample 0: %d, sample %d: %d", + samples[0].Available, i, samples[i].Available) + } + } +} + +func TestWindowsMemoryReader_MemoryPressure(t *testing.T) { + reader := newPlatformMemoryReader(nil).(*windowsMemoryReader) + + // Take baseline reading + baseline, err := reader.Sample() + if err != nil { + t.Fatalf("Failed to get baseline memory reading: %v", err) + } + + // Allocate some memory to simulate pressure (this won't actually change system memory much, + // but tests that the reader continues to work) + testData := make([]byte, 10*1024*1024) // 10MB + _ = testData // Prevent optimization + + // Take reading after allocation + afterAlloc, err := reader.Sample() + if err != nil { + t.Fatalf("Failed to get memory reading after allocation: %v", err) + } + + // Memory readings should still be valid + if afterAlloc.Total != baseline.Total { + t.Errorf("Total memory changed unexpectedly: before=%d, after=%d", baseline.Total, afterAlloc.Total) + } + + if afterAlloc.Available > afterAlloc.Total { + t.Errorf("Invalid memory reading: available (%d) > total (%d)", afterAlloc.Available, afterAlloc.Total) + } + + // Clean up + testData = nil +} + +// TestWindowsMemoryReader_MultipleReaders tests multiple readers work independently +func TestWindowsMemoryReader_MultipleReaders(t *testing.T) { + reader1 := newPlatformMemoryReader(nil).(*windowsMemoryReader) + reader2 := newPlatformMemoryReader(nil).(*windowsMemoryReader) + + mem1, err := reader1.Sample() + if err != nil { + t.Fatalf("Reader1 failed: %v", err) + } + + mem2, err := reader2.Sample() + if err != nil { + t.Fatalf("Reader2 failed: %v", err) + } + + // Both readers should return the same system memory info + if mem1.Total != mem2.Total { + t.Errorf("Readers returned different total memory: reader1=%d, reader2=%d", mem1.Total, mem2.Total) + } +} + +// TestWindowsMemoryReader_PercentageCalculation tests memory percentage calculations +func TestWindowsMemoryReader_PercentageCalculation(t *testing.T) { + reader := newPlatformMemoryReader(nil).(*windowsMemoryReader) + + mem, err := reader.Sample() + if err != nil { + t.Fatalf("Failed to sample memory: %v", err) + } + + if mem.Total == 0 { + t.Fatal("Total memory is zero, cannot calculate percentages") + } + + // Calculate usage percentage + used := mem.Total - mem.Available + usagePercent := float64(used) / float64(mem.Total) * 100.0 + + if usagePercent < 0.0 || usagePercent > 100.0 { + t.Errorf("Invalid usage percentage: %f%%", usagePercent) + } + + t.Logf("Memory usage: %d/%d bytes (%.2f%%)", used, mem.Total, usagePercent) +} From 583a92b94986828663fe849be2e18bf8ab7b721a Mon Sep 17 00:00:00 2001 From: kxrxh Date: Wed, 3 Dec 2025 16:52:25 +0300 Subject: [PATCH 54/54] fix(adaptive_throttler): make close public and ensure proper resource cleanup --- examples/adaptive_throttler/demo/demo.go | 3 +++ examples/adaptive_throttler/main.go | 3 +++ flow/adaptive_throttler.go | 2 +- flow/adaptive_throttler_test.go | 19 +++++++------------ 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/examples/adaptive_throttler/demo/demo.go b/examples/adaptive_throttler/demo/demo.go index 2954df7..8a8164e 100644 --- a/examples/adaptive_throttler/demo/demo.go +++ b/examples/adaptive_throttler/demo/demo.go @@ -65,6 +65,9 @@ func main() { } else { fmt.Printf("❌ FAILURE: %d elements were dropped!\n", 250-elementsReceived) } + + throttler.Close() + fmt.Println("adaptive throttling pipeline completed") } diff --git a/examples/adaptive_throttler/main.go b/examples/adaptive_throttler/main.go index 7b46696..36952bf 100644 --- a/examples/adaptive_throttler/main.go +++ b/examples/adaptive_throttler/main.go @@ -143,5 +143,8 @@ func main() { sink.AwaitCompletion() + throttler1.Close() + throttler2.Close() + fmt.Printf("Demo completed! Processed %d messages\n", messagesProcessed.Load()) } diff --git a/flow/adaptive_throttler.go b/flow/adaptive_throttler.go index 95637a0..9af7de3 100644 --- a/flow/adaptive_throttler.go +++ b/flow/adaptive_throttler.go @@ -251,7 +251,7 @@ func (at *AdaptiveThrottler) streamPortioned(inlet streams.Inlet) { } } -func (at *AdaptiveThrottler) close() { +func (at *AdaptiveThrottler) Close() { if at.closed.CompareAndSwap(false, true) { close(at.done) at.monitor.Close() diff --git a/flow/adaptive_throttler_test.go b/flow/adaptive_throttler_test.go index e6a4c21..55c07e1 100644 --- a/flow/adaptive_throttler_test.go +++ b/flow/adaptive_throttler_test.go @@ -437,7 +437,7 @@ func TestNewAdaptiveThrottler(t *testing.T) { if at1.config.InitialRate != 1000 { t.Errorf("Expected default InitialRate 1000, got %d", at1.config.InitialRate) } - at1.close() + at1.Close() config := DefaultAdaptiveThrottlerConfig() config.InitialRate = 500 @@ -448,14 +448,14 @@ func TestNewAdaptiveThrottler(t *testing.T) { if at2.config.InitialRate != 500 { t.Errorf("Expected InitialRate 500, got %d", at2.config.InitialRate) } - at2.close() + at2.Close() invalidConfig := &AdaptiveThrottlerConfig{ SampleInterval: 1 * time.Millisecond, } at3, err := NewAdaptiveThrottler(invalidConfig) if err == nil { - at3.close() + at3.Close() t.Fatal("Expected error with invalid config") } } @@ -620,7 +620,7 @@ func TestAdaptiveThrottler_FlowControl(t *testing.T) { // Wait a bit more for processing time.Sleep(100 * time.Millisecond) - at.close() + at.Close() // Wait for receiving to complete <-receiveDone @@ -640,7 +640,7 @@ func TestAdaptiveThrottler_FlowControl(t *testing.T) { // TestAdaptiveThrottler_To tests To method that streams data to a sink func TestAdaptiveThrottler_To(t *testing.T) { at := createThrottlerWithLongInterval(t) - defer at.close() + defer at.Close() var received []any var mu sync.Mutex @@ -737,7 +737,7 @@ func TestAdaptiveThrottler_StreamPortioned(t *testing.T) { func TestAdaptiveThrottler_Via_DataFlow(t *testing.T) { // Actually test data flow at, _ := NewAdaptiveThrottler(DefaultAdaptiveThrottlerConfig()) - defer at.close() + defer at.Close() // Send test data and verify it flows through testData := []any{"test1", "test2"} @@ -991,22 +991,17 @@ func TestAdaptiveThrottler_To_Shutdown(t *testing.T) { // Wait a bit for data to start flowing time.Sleep(100 * time.Millisecond) - // Close the throttler - at.close() + at.Close() // Wait for To to complete (it will finish when streamPortioned completes) select { case <-toDone: - // Good, To completed case <-time.After(2 * time.Second): t.Error("To method did not complete within timeout") } - // Verify sink channel is closed (streamPortioned closes inlet.In()) - // Give it a moment to ensure the close has propagated time.Sleep(100 * time.Millisecond) - // Verify sink channel is closed verifyChannelClosed(t, sinkCh, 50*time.Millisecond) }