Ctrl K
ComunidadGuías…

common/system_monitor_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package common

import (
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// newTestContainerUsage 在临时目录中构造 cgroup 文件,避免依赖运行环境的真实 cgroup。
func newTestContainerUsage(t *testing.T, schedulableCPUs float64, files map[string]string) *containerUsage {
	t.Helper()
	root := t.TempDir()
	for name, content := range files {
		writeTestCgroupFile(t, filepath.Join(root, name), content)
	}
	return &containerUsage{cgroupRoot: root, schedulableCPUs: schedulableCPUs}
}

func writeTestCgroupFile(t *testing.T, path string, content string) {
	t.Helper()
	require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
	require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
}

func TestContainerCPUSaturation(t *testing.T) {
	tests := []struct {
		name            string
		schedulableCPUs float64
		files           map[string]string
		usageFile       string
		usageAfter      string
		want            float64
	}{
		{
			name:            "cgroup v2 quota",
			schedulableCPUs: 4,
			files:           map[string]string{"cpu.max": "200000 100000\n", "cpu.stat": "usage_usec 1000000\nuser_usec 900000\n"},
			usageFile:       "cpu.stat",
			usageAfter:      "usage_usec 6000000\n", // 5 秒用 1 核,配额 2 核
			want:            50,
		},
		{
			name:            "fractional quota",
			schedulableCPUs: 4,
			files:           map[string]string{"cpu.max": "50000 100000\n", "cpu.stat": "usage_usec 1000000\n"},
			usageFile:       "cpu.stat",
			usageAfter:      "usage_usec 3500000\n", // 5 秒用 0.5 核,配额 0.5 核
			want:            100,
		},
		{
			name:            "quota above schedulable CPUs",
			schedulableCPUs: 1,
			files:           map[string]string{"cpu.max": "200000 100000\n", "cpu.stat": "usage_usec 1000000\n"},
			usageFile:       "cpu.stat",
			usageAfter:      "usage_usec 6000000\n", // cpuset 只有 1 核,已打满
			want:            100,
		},
		{
			name:            "cgroup v1 quota",
			schedulableCPUs: 4,
			files: map[string]string{
				"cpu/cpu.cfs_quota_us":  "200000\n",
				"cpu/cpu.cfs_period_us": "100000\n",
				"cpuacct/cpuacct.usage": "1000000000\n",
			},
			usageFile:  "cpuacct/cpuacct.usage",
			usageAfter: "11000000000\n", // 纳秒:5 秒用 2 核
			want:       100,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			usage := newTestContainerUsage(t, tt.schedulableCPUs, tt.files)
			base := time.Date(2026, 9, 11, 3, 0, 0, 0, time.UTC)
			_, ok := usage.cpuSaturation(base)
			assert.False(t, ok, "首次采样只建立基线")

			writeTestCgroupFile(t, filepath.Join(usage.cgroupRoot, tt.usageFile), tt.usageAfter)
			got, ok := usage.cpuSaturation(base.Add(5 * time.Second))
			require.True(t, ok)
			assert.InDelta(t, tt.want, got, 0.01)
		})
	}
}

func TestContainerCPUSaturationFallsBackWithoutQuota(t *testing.T) {
	tests := []struct {
		name  string
		files map[string]string
	}{
		{name: "cgroup v2 unlimited", files: map[string]string{"cpu.max": "max 100000\n", "cpu.stat": "usage_usec 1000000\n"}},
		{name: "cgroup v1 unlimited", files: map[string]string{"cpu/cpu.cfs_quota_us": "-1\n", "cpu/cpu.cfs_period_us": "100000\n", "cpuacct/cpuacct.usage": "1000\n"}},
		{name: "no cgroup files"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			usage := newTestContainerUsage(t, 4, tt.files)
			base := time.Date(2026, 9, 11, 3, 0, 0, 0, time.UTC)
			usage.cpuSaturation(base)
			_, ok := usage.cpuSaturation(base.Add(5 * time.Second))
			assert.False(t, ok, "没有配额时交由调用方回退宿主机口径")
		})
	}
}

func TestContainerMemoryUsageUsesWorkingSet(t *testing.T) {
	tests := []struct {
		name  string
		files map[string]string
		want  float64
	}{
		{
			name: "cgroup v2 excludes inactive file cache",
			files: map[string]string{
				"memory.current": "266338304\n",
				"memory.max":     "268435456\n",
				"memory.stat":    "anon 4435968\nfile 253362176\ninactive_file 251662336\n",
			},
			want: 5.4672,
		},
		{
			name: "cgroup v2 high working set keeps protection",
			files: map[string]string{
				"memory.current": "255852544\n",
				"memory.max":     "268435456\n",
				"memory.stat":    "anon 254803968\ninactive_file 1048576\n",
			},
			want: 94.9219,
		},
		{
			name: "cgroup v1 uses hierarchical inactive file cache",
			files: map[string]string{
				"memory/memory.usage_in_bytes": "805306368\n",
				"memory/memory.limit_in_bytes": "1610612736\n",
				"memory/memory.stat":           "inactive_file 999\ntotal_inactive_file 268435456\n",
			},
			want: 33.3333,
		},
		{
			name: "cache larger than usage does not underflow",
			files: map[string]string{
				"memory.current": "1000\n",
				"memory.max":     "268435456\n",
				"memory.stat":    "inactive_file 2000\n",
			},
			want: 0,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, ok := newTestContainerUsage(t, 4, tt.files).memoryUsage()
			require.True(t, ok)
			assert.InDelta(t, tt.want, got, 0.001)
		})
	}
}

func TestContainerMemoryUsageFallsBackWhenUnavailable(t *testing.T) {
	limited := map[string]string{"memory.current": "266338304\n", "memory.max": "268435456\n"}
	tests := []struct {
		name  string
		files map[string]string
		stat  string
	}{
		{name: "cgroup v2 unlimited", files: map[string]string{"memory.current": "1000\n", "memory.max": "max\n"}, stat: "inactive_file 0\n"},
		{name: "cgroup v1 unlimited", files: map[string]string{
			"memory/memory.usage_in_bytes": "1000\n",
			"memory/memory.limit_in_bytes": "9223372036854771712\n",
			"memory/memory.stat":           "total_inactive_file 0\n",
		}},
		{name: "memory.stat missing", files: limited},
		{name: "inactive file field missing", files: limited, stat: "anon 4435968\n"},
		{name: "inactive file field corrupt", files: limited, stat: "inactive_file abc\n"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			usage := newTestContainerUsage(t, 4, tt.files)
			if tt.stat != "" {
				writeTestCgroupFile(t, filepath.Join(usage.cgroupRoot, "memory.stat"), tt.stat)
			}
			_, ok := usage.memoryUsage()
			assert.False(t, ok, "拿不到可信工作集时交由调用方回退宿主机口径")
		})
	}
}

func TestSystemOverloadRequiresConsecutiveSamples(t *testing.T) {
	config := PerformanceMonitorConfig{Enabled: true, CPUThreshold: 90, MemoryThreshold: 90, DiskThreshold: 95}
	now := time.Date(2026, 9, 11, 3, 0, 0, 0, time.UTC)
	cpuSamples := []struct {
		usage        float64
		wantOverload bool
	}{
		{usage: 95, wantOverload: false},
		{usage: 95, wantOverload: false},
		{usage: 95, wantOverload: true},
		{usage: 95, wantOverload: true},
		{usage: 50, wantOverload: false}, // 回落即恢复
		{usage: 95, wantOverload: false}, // 重新累计
		{usage: 95, wantOverload: false},
		{usage: 95, wantOverload: true},
	}

	var detector overloadDetector
	for i, sample := range cpuSamples {
		status := SystemStatus{CPUUsage: sample.usage, SampledAt: now}
		detector.mark(&status, config)
		overload := CheckSystemOverload(status, config, now)
		if !sample.wantOverload {
			assert.Nil(t, overload, "sample %d", i+1)
			continue
		}
		require.NotNil(t, overload, "sample %d", i+1)
		assert.Equal(t, "system_cpu_overloaded", overload.Code)
	}
}

func TestCheckSystemOverload(t *testing.T) {
	now := time.Date(2026, 9, 11, 3, 0, 0, 0, time.UTC)
	enabled := PerformanceMonitorConfig{Enabled: true, CPUThreshold: 90, MemoryThreshold: 90, DiskThreshold: 95}
	cpuOverloaded := SystemStatus{CPUUsage: 99.5, CPUOverloaded: true, SampledAt: now}
	tests := []struct {
		name        string
		status      SystemStatus
		config      PerformanceMonitorConfig
		wantCode    string
		wantMessage string
	}{
		{
			name:        "fresh cpu overload",
			status:      cpuOverloaded,
			config:      enabled,
			wantCode:    "system_cpu_overloaded",
			wantMessage: "system cpu overloaded (current: 99.5%, threshold: 90%)",
		},
		{
			name:        "fresh memory overload",
			status:      SystemStatus{MemoryUsage: 96.2, MemoryOverloaded: true, SampledAt: now},
			config:      enabled,
			wantCode:    "system_memory_overloaded",
			wantMessage: "system memory overloaded (current: 96.2%, threshold: 90%)",
		},
		{
			name:        "fresh disk overload",
			status:      SystemStatus{DiskUsage: 97, DiskOverloaded: true, SampledAt: now},
			config:      enabled,
			wantCode:    "system_disk_overloaded",
			wantMessage: "system disk overloaded (current: 97.0%, threshold: 95%)",
		},
		{
			name:        "lowered threshold reports the active threshold",
			status:      cpuOverloaded,
			config:      PerformanceMonitorConfig{Enabled: true, CPUThreshold: 80},
			wantCode:    "system_cpu_overloaded",
			wantMessage: "system cpu overloaded (current: 99.5%, threshold: 80%)",
		},
		{name: "monitor disabled", status: cpuOverloaded, config: PerformanceMonitorConfig{Enabled: false, CPUThreshold: 90}},
		{name: "cpu threshold disabled at runtime", status: cpuOverloaded, config: PerformanceMonitorConfig{Enabled: true, CPUThreshold: 0}},
		{name: "cpu threshold raised above usage", status: cpuOverloaded, config: PerformanceMonitorConfig{Enabled: true, CPUThreshold: 100}},
		{name: "stale sample", status: SystemStatus{CPUUsage: 99.5, CPUOverloaded: true, SampledAt: now.Add(-31 * time.Second)}, config: enabled},
		{name: "missing sample", status: SystemStatus{CPUUsage: 99.5, CPUOverloaded: true}, config: enabled},
		{name: "usage above threshold without consecutive samples", status: SystemStatus{CPUUsage: 99.5, SampledAt: now}, config: enabled},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			overload := CheckSystemOverload(tt.status, tt.config, now)
			if tt.wantCode == "" {
				assert.Nil(t, overload)
				return
			}
			require.NotNil(t, overload)
			assert.Equal(t, tt.wantCode, overload.Code)
			assert.Equal(t, tt.wantMessage, overload.Error())
		})
	}
}