From 4d8dc20647ec5b96a44f3e3aa3fd0b1c9b708591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BE=9A=E5=AE=87=E7=BF=94?= Date: Mon, 7 Sep 2026 19:24:00 +0800 Subject: [PATCH] feat(align): align 5h window refresh to cron targets Add an `align` subcommand to manage cron targets (each with a Docker-style short id and its own max-delay budget) and preview upcoming ping/refresh times. `ping --if-5h-full` now does best-effort phase alignment by default, pinging only once the window is full and the aligned time is reached; `--without-align` restores the plain "ping when full" behavior. Targets and per-target max-delay (default 90m) persist to ~/.config/limitping/config.json. Push planned ping timestamps as a new metric for observability. --- README.md | 66 ++++++++- go.mod | 5 +- go.sum | 2 + internal/align/align_test.go | 262 ++++++++++++++++++++++++++++++++++ internal/align/config.go | 247 ++++++++++++++++++++++++++++++++ internal/align/scheduler.go | 171 ++++++++++++++++++++++ internal/cli/align.go | 181 +++++++++++++++++++++++ internal/cli/align_test.go | 112 +++++++++++++++ internal/cli/cli.go | 75 ++++++++-- internal/cli/cli_test.go | 2 +- internal/metrics/push.go | 19 ++- internal/metrics/push_test.go | 18 ++- 12 files changed, 1142 insertions(+), 18 deletions(-) create mode 100644 internal/align/align_test.go create mode 100644 internal/align/config.go create mode 100644 internal/align/scheduler.go create mode 100644 internal/cli/align.go create mode 100644 internal/cli/align_test.go diff --git a/README.md b/README.md index 6b2c220..62a9e56 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,9 @@ CCLimitPing;如果只需要一个可嵌入现有 cron/plist/监控体系的 Co - `--json` 输出机器可读快照。 - 从 `codex debug models` 动态选择最低优先级的可见模型。 - `--dry-run` 在执行模型调用或推送指标前预览操作。 -- `--if-5h-full` 只在 5 小时额度恢复到 100% 时执行 ping;指标仍正常上报。 +- `--if-5h-full` 只在 5 小时额度恢复到 100% 时执行 ping;此时默认还会结合已配置的 cron 目标做相位对齐:仅在满额且到达对齐时间后才 ping(指标仍正常上报)。 +- `--without-align` 关闭相位对齐,只保留满额判断(满额即 ping);仅在配合 `--if-5h-full` 时有意义。不带任何 flag 时 `ping` 立即执行,与对齐无关。 +- `align` 子命令管理多条 cron 目标(每条带唯一 id 和自己的延时预算 max-delay,可按 id 单独删除),并预览未来的 ping/刷新时间。 - 将用量、重置券数量和最近成功 ping 时间推送到 Pushgateway。 - 成功 ping 时间持久化,后续上报不会被 `ping_completed=0` 覆盖。 @@ -104,6 +106,10 @@ limitping update limitping ping --dry-run limitping ping limitping ping --if-5h-full +limitping ping --if-5h-full --without-align +limitping align add "0 0 * * *" +limitping align list +limitping align preview --count 5 limitping ping --push-metric https://pushgateway.example.com limitping status --push-metric https://pushgateway.example.com ``` @@ -117,7 +123,62 @@ codex exec -m -c model_reasoning_effort=low ping `--if-5h-full` 要求用量响应包含 5 小时窗口,并且 `used_percent == 0`。不满足时命令正常 退出;如果同时设置了 `--push-metric`,仍会推送最新用量,并将本次 -`limitping_ping_completed` 记为 `0`。 +`limitping_ping_completed` 记为 `0`。满足满额条件后,默认还会做相位对齐(见下一节), +只有到达对齐时间才真正 ping;加 `--without-align` 可关闭对齐,恢复“满额即 ping”。 + +## 刷新时间对齐(`--if-5h-full` 默认开启) + +如果你希望 5h 窗口的刷新时刻尽量落在某个固定时间(例如每晚 00:00,好让 23:00 开始 +工作、用完一轮后 00:00 刚好刷新到),可以用 `align` 配置一组 cron 目标。之后 +`limitping ping --if-5h-full` 会默认做“尽力而为”的相位对齐(无需额外 flag)。 + +配合每分钟运行一次的 cron(或 LaunchAgent):平时满额即 ping、每 5h 正常链式;临近 +目标时间时,会在延时预算内延后 ping,使某次窗口的重置刚好落在目标时间附近。若只想保留 +满额判断而不对齐,加 `--without-align`。 + +```sh +# 声明目标:每晚 00:00(多条时,每轮取最近的一个目标) +limitping align add "0 0 * * *" +limitping align add "0 12 * * *" + +# 延时预算(max-delay)绑定在每个目标上:add 时用 --max-delay 设置该目标自己的预算; +# 缺省 90m。重复 add 同一个 cron 且带 --max-delay 时,只更新该目标的预算、不新增。 +limitping align add "0 0 * * *" --max-delay 90m + +# 查看当前配置(每个目标一行:id、cron、该目标的延时预算 max-delay) +limitping align list + +# 按 id(或唯一前缀)删除单个目标;clear 清空全部 +limitping align delete +limitping align clear + +# 预览未来 5 次计划 ping 与对应刷新时间 +limitping align preview --count 5 + +# 实际运行:--if-5h-full 默认对齐;--without-align 关闭对齐(满额即 ping) +limitping ping --if-5h-full +limitping ping --if-5h-full --without-align +``` + +工作原理与取舍: + +- 目标刷新时刻 `T` 取所有 cron 中最近的一次触发;理想 ping 锚点为 `A* = T − 5h`, + 在此锚定可让窗口在 `T` 重置。 +- 离锚点还远(超过该目标自己的延时预算)时,按原始行为立即 ping,保持窗口新鲜;临近锚点 + 时在预算内延后到贴近 `A*` 再 ping。判定只依赖当前时间与 cron,无额外状态。 +- 因为窗口是滚动 5h,而 `24 ÷ 5` 除不尽,纯 5h 链无法每天精确命中同一墙钟时间;且相位 + 只能“往后拖、不能往前提”。延时预算(每个目标各自设置,缺省 90 分钟)决定精度与窗口空转 + 之间的平衡。对齐是“大致落在目标时间附近”,通常有分钟级到 1~2 小时的偏差。 + +配置保存在 `${XDG_CONFIG_HOME:-$HOME/.config}/limitping/config.json`,延时预算随每个目标落盘: + +```json +{ + "targets": [ + { "id": "ca5ca66014b8", "cron": "0 0 * * *", "max_delay_minutes": 90 } + ] +} +``` ## 预触发 5h 窗口能少等多久? @@ -194,6 +255,7 @@ $T/2=2.5$ 小时,也就是约 **50%**。这也是“随机时刻到达”直 | `limitping_reset_credit_expiration_timestamp_seconds` | 最早到期的可用重置券时间戳 | | `limitping_ping_completed` | 本次是否完成 ping(0/1) | | `limitping_last_successful_ping_timestamp_seconds` | 最近一次成功 ping 的时间戳 | +| `limitping_planned_ping_timestamp_seconds` | 未来计划 ping 时间戳(`index="1..5"`,1 为最近,需配置对齐目标并以 `--if-5h-full` 运行) | | `metrics_pusher_collector_success` | 本次采集是否成功 | | `metrics_pusher_last_run_timestamp_seconds` | 最近采集开始时间 | diff --git a/go.mod b/go.mod index d6a3142..069aaa3 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/ShawnKung/limitping go 1.22 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/robfig/cron/v3 v3.0.1 + github.com/spf13/cobra v1.10.2 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index a6ee3e0..e50a0f3 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= diff --git a/internal/align/align_test.go b/internal/align/align_test.go new file mode 100644 index 0000000..2599c0e --- /dev/null +++ b/internal/align/align_test.go @@ -0,0 +1,262 @@ +package align + +import ( + "fmt" + "path/filepath" + "testing" + "time" +) + +func mustPlanner(t *testing.T, crons []string, maxDelayMinutes int) *Planner { + t.Helper() + planner, err := NewPlanner(&Config{Targets: targetsFromCrons(crons, maxDelayMinutes)}) + if err != nil { + t.Fatalf("NewPlanner: %v", err) + } + return planner +} + +func targetsFromCrons(crons []string, maxDelayMinutes int) []Target { + targets := make([]Target, 0, len(crons)) + for i, expr := range crons { + targets = append(targets, Target{ID: fmt.Sprintf("test%08d", i), Cron: expr, MaxDelayMinutes: maxDelayMinutes}) + } + return targets +} + +func TestDecideNoTargetsAlwaysPings(t *testing.T) { + planner := mustPlanner(t, nil, 0) + decision := planner.Decide(time.Now()) + if decision.Action != ActionPing || decision.HasTarget { + t.Fatalf("expected unconditional ping, got %+v", decision) + } +} + +func TestDecideFarFromTargetPingsImmediately(t *testing.T) { + // 目标每晚 00:00;现在 08:00,锚点应为 19:00,距今 11h,远超预算 → 立即 ping。 + planner := mustPlanner(t, []string{"0 0 * * *"}, DefaultMaxDelayMinutes) + now := time.Date(2026, 9, 7, 8, 0, 0, 0, time.Local) + decision := planner.Decide(now) + if decision.Action != ActionPing { + t.Fatalf("expected ping when far from anchor, got %+v", decision) + } + if !decision.HasTarget { + t.Fatalf("expected a resolved target") + } +} + +func TestDecideHoldsWithinBudget(t *testing.T) { + // 目标 00:00,锚点 19:00。现在 18:30,距锚点 30min,在 90min 预算内 → hold。 + planner := mustPlanner(t, []string{"0 0 * * *"}, DefaultMaxDelayMinutes) + now := time.Date(2026, 9, 7, 18, 30, 0, 0, time.Local) + decision := planner.Decide(now) + if decision.Action != ActionHold { + t.Fatalf("expected hold, got %+v", decision) + } + wantAnchor := time.Date(2026, 9, 7, 19, 0, 0, 0, time.Local) + if !decision.Anchor.Equal(wantAnchor) { + t.Fatalf("anchor = %s, want %s", decision.Anchor, wantAnchor) + } +} + +func TestDecidePingsAtAnchor(t *testing.T) { + // 现在正好 19:00,落在锚点容差内 → ping。 + planner := mustPlanner(t, []string{"0 0 * * *"}, DefaultMaxDelayMinutes) + now := time.Date(2026, 9, 7, 19, 0, 0, 0, time.Local) + decision := planner.Decide(now) + if decision.Action != ActionPing { + t.Fatalf("expected ping at anchor, got %+v", decision) + } + wantTarget := time.Date(2026, 9, 8, 0, 0, 0, 0, time.Local) + if !decision.Target.Equal(wantTarget) { + t.Fatalf("target = %s, want %s", decision.Target, wantTarget) + } +} + +func TestDecidePicksNearestOfMultipleCrons(t *testing.T) { + // 两个目标 00:00 与 12:00。现在 06:40,最近可达锚点是 12:00 的锚点 07:00。 + planner := mustPlanner(t, []string{"0 0 * * *", "0 12 * * *"}, DefaultMaxDelayMinutes) + now := time.Date(2026, 9, 7, 6, 40, 0, 0, time.Local) + decision := planner.Decide(now) + wantTarget := time.Date(2026, 9, 7, 12, 0, 0, 0, time.Local) + if !decision.Target.Equal(wantTarget) { + t.Fatalf("target = %s, want %s", decision.Target, wantTarget) + } + if decision.Action != ActionHold { + t.Fatalf("expected hold near noon anchor, got %+v", decision) + } +} + +func TestDecideUsesPerTargetMaxDelay(t *testing.T) { + // 目标 00:00 锚点 19:00。现在 18:30,距锚点 30min。 + // 若该目标预算只有 15min(<30),应立即 ping 而非 hold。 + planner, err := NewPlanner(&Config{Targets: []Target{ + {ID: "aaaa", Cron: "0 0 * * *", MaxDelayMinutes: 15}, + }}) + if err != nil { + t.Fatalf("NewPlanner: %v", err) + } + now := time.Date(2026, 9, 7, 18, 30, 0, 0, time.Local) + decision := planner.Decide(now) + if decision.Action != ActionPing { + t.Fatalf("expected ping when delay exceeds this target budget, got %+v", decision) + } + if decision.MaxDelay != 15*time.Minute { + t.Fatalf("expected decision to carry per-target max delay 15m, got %s", decision.MaxDelay) + } +} + +func TestNextPingTimesConvergeThenAlign(t *testing.T) { + // 从 08:00 起链式:前几次每 5h,临近后把某次拖到 19:00,使刷新落在 00:00。 + planner := mustPlanner(t, []string{"0 0 * * *"}, DefaultMaxDelayMinutes) + start := time.Date(2026, 9, 7, 8, 0, 0, 0, time.Local) + plans := planner.NextPingTimes(start, 5) + if len(plans) != 5 { + t.Fatalf("want 5 plans, got %d", len(plans)) + } + // 每次刷新必须等于对应 ping + 5h。 + for _, plan := range plans { + if !plan.RefreshAt.Equal(plan.PingAt.Add(WindowLength)) { + t.Fatalf("refresh != ping+5h: %+v", plan) + } + } + // 存在某次刷新命中目标 00:00(允许容差)。 + hitMidnight := false + for _, plan := range plans { + if plan.RefreshAt.Hour() == 0 && plan.RefreshAt.Minute() == 0 { + hitMidnight = true + } + } + if !hitMidnight { + t.Fatalf("expected a refresh aligned to 00:00, plans=%v", plans) + } +} + +func TestUpcomingUsesRunningWindow(t *testing.T) { + planner := mustPlanner(t, []string{"0 0 * * *"}, DefaultMaxDelayMinutes) + now := time.Date(2026, 9, 7, 8, 0, 0, 0, time.Local) + lastPing := now.Add(-2 * time.Hour) // 窗口还剩 3h + plans := planner.Upcoming(now, &lastPing, 3) + firstFull := lastPing.Add(WindowLength) + if plans[0].PingAt.Before(firstFull) { + t.Fatalf("first ping %s should not precede next full %s", plans[0].PingAt, firstFull) + } +} + +func TestParseCronRejectsInvalid(t *testing.T) { + if _, err := ParseCron("not a cron"); err == nil { + t.Fatal("expected error for invalid cron") + } + if _, err := ParseCron(""); err == nil { + t.Fatal("expected error for empty cron") + } +} + +func TestConfigRoundTrip(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfg := &Config{} + first, _, err := cfg.Add("0 0 * * *", 120) + if err != nil { + t.Fatalf("Add: %v", err) + } + if _, _, err := cfg.Add("30 12 * * 1", 0); err != nil { + t.Fatalf("Add: %v", err) + } + if err := Save(cfg); err != nil { + t.Fatalf("Save: %v", err) + } + loaded, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(loaded.Targets) != 2 { + t.Fatalf("round trip mismatch: %+v", loaded) + } + if loaded.Targets[0].ID != first.ID || loaded.Targets[0].Cron != "0 0 * * *" || loaded.Targets[0].MaxDelayMinutes != 120 { + t.Fatalf("id/cron/max-delay not persisted: %+v", loaded.Targets[0]) + } + // 第二个目标未指定预算,落盘为 0,读回时以默认值解释。 + if loaded.Targets[1].MaxDelayMinutes != 0 || loaded.Targets[1].MaxDelay() != DefaultMaxDelayMinutes*time.Minute { + t.Fatalf("default max-delay wrong: %+v -> %s", loaded.Targets[1], loaded.Targets[1].MaxDelay()) + } + if path, _ := ConfigPath(); path != filepath.Join(dir, "limitping", configFilename) { + t.Fatalf("unexpected config path %s", path) + } +} + +func TestLoadMissingReturnsDefault(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.Targets) != 0 { + t.Fatalf("expected no targets, got %v", cfg.Targets) + } + if (Target{}).MaxDelay() != DefaultMaxDelayMinutes*time.Minute { + t.Fatalf("expected default max delay, got %s", (Target{}).MaxDelay()) + } +} + +func TestSaveRejectsInvalidCron(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := Save(&Config{Targets: []Target{{ID: "abc", Cron: "bogus"}}}); err == nil { + t.Fatal("expected save to reject invalid cron") + } +} + +func TestAddAssignsUniqueIDsAndUpdatesDelayOnDup(t *testing.T) { + cfg := &Config{} + a, created, err := cfg.Add("0 0 * * *", 45) + if err != nil || !created { + t.Fatalf("Add: %v created=%v", err, created) + } + b, created, err := cfg.Add("0 12 * * *", 0) + if err != nil || !created { + t.Fatalf("Add: %v created=%v", err, created) + } + if a.ID == "" || b.ID == "" || a.ID == b.ID { + t.Fatalf("expected distinct non-empty ids, got %q and %q", a.ID, b.ID) + } + if len(a.ID) != idLength { + t.Fatalf("id length = %d, want %d", len(a.ID), idLength) + } + if a.MaxDelayMinutes != 45 { + t.Fatalf("expected per-target max delay 45, got %d", a.MaxDelayMinutes) + } + // 重复 cron 且给了新预算:不新增目标,只更新其预算。 + dup, created, err := cfg.Add("0 0 * * *", 30) + if err != nil || created { + t.Fatalf("Add dup: %v created=%v", err, created) + } + if dup.ID != a.ID || len(cfg.Targets) != 2 || cfg.Targets[0].MaxDelayMinutes != 30 { + t.Fatalf("duplicate cron should reuse id and update delay: dup=%+v targets=%d", dup, len(cfg.Targets)) + } +} + +func TestDeleteByIDAndPrefix(t *testing.T) { + cfg := &Config{} + a, _, _ := cfg.Add("0 0 * * *", 0) + b, _, _ := cfg.Add("0 12 * * *", 0) + + removed, err := cfg.Delete(a.ID) + if err != nil { + t.Fatalf("Delete by full id: %v", err) + } + if removed.ID != a.ID || len(cfg.Targets) != 1 { + t.Fatalf("full-id delete failed: removed=%+v remaining=%d", removed, len(cfg.Targets)) + } + + removed, err = cfg.Delete(b.ID[:4]) + if err != nil { + t.Fatalf("Delete by prefix: %v", err) + } + if removed.ID != b.ID || len(cfg.Targets) != 0 { + t.Fatalf("prefix delete failed: removed=%+v remaining=%d", removed, len(cfg.Targets)) + } + + if _, err := cfg.Delete("deadbeef"); err == nil { + t.Fatal("expected error deleting unknown id") + } +} diff --git a/internal/align/config.go b/internal/align/config.go new file mode 100644 index 0000000..25aa7e0 --- /dev/null +++ b/internal/align/config.go @@ -0,0 +1,247 @@ +// Package align 负责把 5h 滚动窗口的刷新时刻,尽量对齐到用户用 cron 表达式声明的 +// 目标时间(例如每晚 00:00)。它只做“尽力而为”的相位规划:平时每 5h 正常链式 +// ping,临近目标时间时在允许的延时预算内 hold,让某次窗口刚好在目标时间重置。 +package align + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/robfig/cron/v3" +) + +const configFilename = "config.json" + +// DefaultMaxDelayMinutes 是临近目标时间时,单个窗口允许 hold 的默认上限(分钟)。 +const DefaultMaxDelayMinutes = 90 + +// idLength 是对齐目标短 id 的 hex 字符数(docker 风格)。 +const idLength = 12 + +// Target 是一条对齐目标:一个短 id、一个标准 5 字段 cron 表达式,以及该目标临近时 +// 单个窗口允许 hold 的延时预算(分钟)。延时预算绑定在每个目标上,互不影响。 +type Target struct { + ID string `json:"id"` + Cron string `json:"cron"` + MaxDelayMinutes int `json:"max_delay_minutes"` +} + +// Config 是落盘在 ~/.config/limitping/config.json 的对齐配置。 +type Config struct { + // Targets 是一组对齐目标。多条时取最近的一次触发,并用该目标自己的延时预算。 + Targets []Target `json:"targets"` +} + +// MaxDelay 返回该目标的延时预算,缺省时使用默认值。 +func (t Target) MaxDelay() time.Duration { + minutes := t.MaxDelayMinutes + if minutes <= 0 { + minutes = DefaultMaxDelayMinutes + } + return time.Duration(minutes) * time.Minute +} + +// ConfigPath 返回配置文件路径,优先使用 XDG_CONFIG_HOME,否则回退到 ~/.config。 +func ConfigPath() (string, error) { + base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")) + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("读取用户目录: %w", err) + } + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "limitping", configFilename), nil +} + +// Load 读取配置。文件不存在时返回空配置,不视为错误。 +func Load() (*Config, error) { + path, err := ConfigPath() + if err != nil { + return nil, err + } + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return &Config{}, nil + } + if err != nil { + return nil, fmt.Errorf("读取对齐配置: %w", err) + } + var cfg Config + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("解析对齐配置: %w", err) + } + return &cfg, nil +} + +// Save 原子写入配置,权限 0600,必要时创建目录。 +func Save(cfg *Config) error { + if err := cfg.Validate(); err != nil { + return err + } + path, err := ConfigPath() + if err != nil { + return err + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("创建配置目录: %w", err) + } + blob, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("序列化对齐配置: %w", err) + } + blob = append(blob, '\n') + temporary, err := os.CreateTemp(dir, ".config-*") + if err != nil { + return fmt.Errorf("创建临时配置文件: %w", err) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if err := temporary.Chmod(0o600); err != nil { + temporary.Close() + return fmt.Errorf("设置配置文件权限: %w", err) + } + if _, err := temporary.Write(blob); err != nil { + temporary.Close() + return fmt.Errorf("写入配置文件: %w", err) + } + if err := temporary.Close(); err != nil { + return fmt.Errorf("关闭配置文件: %w", err) + } + if err := os.Rename(temporaryPath, path); err != nil { + return fmt.Errorf("保存配置文件: %w", err) + } + return nil +} + +// Validate 校验所有 cron 表达式可解析,且延时预算非负。 +func (c *Config) Validate() error { + for _, target := range c.Targets { + if target.MaxDelayMinutes < 0 { + return fmt.Errorf("max_delay_minutes 不能为负数") + } + if _, err := ParseCron(target.Cron); err != nil { + return err + } + } + return nil +} + +// Add 追加一个 cron 目标并分配唯一短 id,同时记录该目标的延时预算(分钟,<=0 表示用默认值)。 +// 若表达式已存在:当 maxDelayMinutes>0 时更新其延时预算并返回(created=false);否则原样返回。 +func (c *Config) Add(expr string, maxDelayMinutes int) (target Target, created bool, err error) { + trimmed := strings.TrimSpace(expr) + if _, err := ParseCron(trimmed); err != nil { + return Target{}, false, err + } + if maxDelayMinutes < 0 { + return Target{}, false, fmt.Errorf("max_delay_minutes 不能为负数") + } + for i, existing := range c.Targets { + if existing.Cron == trimmed { + if maxDelayMinutes > 0 { + c.Targets[i].MaxDelayMinutes = maxDelayMinutes + } + return c.Targets[i], false, nil + } + } + target = Target{ID: c.newID(), Cron: trimmed, MaxDelayMinutes: maxDelayMinutes} + c.Targets = append(c.Targets, target) + return target, true, nil +} + +// Delete 按 id 或其唯一前缀删除一个目标。 +func (c *Config) Delete(idPrefix string) (Target, error) { + prefix := strings.TrimSpace(idPrefix) + if prefix == "" { + return Target{}, fmt.Errorf("需要提供对齐目标 id") + } + index := -1 + for i, target := range c.Targets { + if target.ID == prefix { + index = i + break + } + } + if index == -1 { + matches := make([]int, 0, 2) + for i, target := range c.Targets { + if strings.HasPrefix(target.ID, prefix) { + matches = append(matches, i) + } + } + switch len(matches) { + case 0: + return Target{}, fmt.Errorf("未找到 id 为 %q 的对齐目标", idPrefix) + case 1: + index = matches[0] + default: + return Target{}, fmt.Errorf("id 前缀 %q 匹配到多个对齐目标,请提供更长的 id", idPrefix) + } + } + removed := c.Targets[index] + c.Targets = append(c.Targets[:index], c.Targets[index+1:]...) + return removed, nil +} + +// newID 生成一个在当前配置内唯一的 docker 风格短 id。 +func (c *Config) newID() string { + for { + buf := make([]byte, idLength/2) + if _, err := rand.Read(buf); err != nil { + // 退化到时间戳,极少发生。 + return hex.EncodeToString([]byte(fmt.Sprintf("%012x", time.Now().UnixNano())))[:idLength] + } + id := hex.EncodeToString(buf) + unique := true + for _, target := range c.Targets { + if target.ID == id { + unique = false + break + } + } + if unique { + return id + } + } +} + +// compiledTarget 是一条目标解析后的调度对象及其自己的延时预算。 +type compiledTarget struct { + schedule cron.Schedule + maxDelay time.Duration +} + +// Compile 把每个目标解析成调度对象,并附带该目标自己的延时预算。 +func (c *Config) Compile() ([]compiledTarget, error) { + compiled := make([]compiledTarget, 0, len(c.Targets)) + for _, target := range c.Targets { + schedule, err := ParseCron(target.Cron) + if err != nil { + return nil, err + } + compiled = append(compiled, compiledTarget{schedule: schedule, maxDelay: target.MaxDelay()}) + } + return compiled, nil +} + +// ParseCron 解析标准 5 字段 cron 表达式(分 时 日 月 周),按本地时区解释。 +func ParseCron(expr string) (cron.Schedule, error) { + trimmed := strings.TrimSpace(expr) + if trimmed == "" { + return nil, fmt.Errorf("cron 表达式不能为空") + } + schedule, err := cron.ParseStandard(trimmed) + if err != nil { + return nil, fmt.Errorf("解析 cron 表达式 %q: %w", expr, err) + } + return schedule, nil +} diff --git a/internal/align/scheduler.go b/internal/align/scheduler.go new file mode 100644 index 0000000..6107e12 --- /dev/null +++ b/internal/align/scheduler.go @@ -0,0 +1,171 @@ +package align + +import ( + "fmt" + "time" +) + +// WindowLength 是 Codex 5h 滚动窗口长度。 +const WindowLength = 5 * time.Hour + +// Tolerance 是判定“已到达锚点”的容差。外层 cron 通常每分钟触发一次, +// 取略大于一分钟可保证在锚点所在的那一分钟(或前一分钟)就 ping,避免因 +// 分钟粒度而错过、反被顶到下一分钟造成重置时刻超调。 +const Tolerance = 90 * time.Second + +// Action 表示某一刻窗口满额时的调度决策。 +type Action int + +const ( + // ActionPing 表示应立即执行 ping。 + ActionPing Action = iota + // ActionHold 表示应跳过本次(延后),等待更接近目标锚点时再 ping。 + ActionHold +) + +// Decision 描述一次调度判定的结果,用于实际执行与 dry-run 展示。 +type Decision struct { + Action Action + Now time.Time + HasTarget bool + // Target 是本次对齐所瞄准的目标刷新时刻 T。 + Target time.Time + // Anchor 是理想的 ping 锚点 A* = T - Window,在此锚定可使窗口于 T 重置。 + Anchor time.Time + // Delay 是从现在到锚点还需等待的时长(Hold 时为正,Ping 时约为 0 或不适用)。 + Delay time.Duration + // MaxDelay 是本次瞄准目标自己的延时预算。 + MaxDelay time.Duration + Reason string +} + +// PingPlan 是前向模拟得到的一次计划:PingAt 锚定,RefreshAt 重置(PingAt+Window)。 +type PingPlan struct { + PingAt time.Time + RefreshAt time.Time +} + +// Planner 依据一组 cron 目标,把满额窗口的重置时刻尽量对齐到目标时间。 +type Planner struct { + targets []compiledTarget + window time.Duration + tolerance time.Duration +} + +// NewPlanner 根据配置构造 Planner。无 cron 时 Planner 退化为“满额即 ping”的原始行为。 +func NewPlanner(cfg *Config) (*Planner, error) { + targets, err := cfg.Compile() + if err != nil { + return nil, err + } + return &Planner{ + targets: targets, + window: WindowLength, + tolerance: Tolerance, + }, nil +} + +// WithWindow 覆盖窗口长度(主要用于按后端实际窗口长度校准和测试)。 +func (p *Planner) WithWindow(window time.Duration) *Planner { + if window > 0 { + p.window = window + } + return p +} + +// HasTargets 表示是否配置了对齐目标。 +func (p *Planner) HasTargets() bool { + return len(p.targets) > 0 +} + +// Decide 在窗口满额的前提下,判断此刻应 ping 还是 hold 延后。判定是无状态的, +// 仅依赖当前时间与 cron 目标:延后中的每一分钟都会重新评估,随着时间逼近锚点, +// 待等时长单调减小,绝不会超过预算。每个目标使用它自己的延时预算。 +func (p *Planner) Decide(now time.Time) Decision { + if len(p.targets) == 0 { + return Decision{Action: ActionPing, Now: now, Reason: "未配置对齐目标,满额即 ping"} + } + // 只考虑锚点仍可达(A* = T - window >= now - tol)的目标,取锚点最近的一个。 + threshold := now.Add(p.window).Add(-p.tolerance) + target, maxDelay, ok := p.nextOnOrAfter(threshold) + if !ok { + return Decision{Action: ActionPing, Now: now, Reason: "无可用 cron 目标,满额即 ping"} + } + anchor := target.Add(-p.window) + delay := anchor.Sub(now) + base := Decision{Now: now, HasTarget: true, Target: target, Anchor: anchor, Delay: delay, MaxDelay: maxDelay} + switch { + case delay > maxDelay: + base.Action = ActionPing + base.Reason = fmt.Sprintf("距目标锚点还有 %s,超过延时预算 %s,正常链式 ping", + roundDuration(delay), roundDuration(maxDelay)) + case delay <= p.tolerance: + base.Action = ActionPing + base.Reason = fmt.Sprintf("已到目标锚点,ping 使窗口约在 %s 重置", + target.Format("01-02 15:04")) + default: + base.Action = ActionHold + base.Reason = fmt.Sprintf("在预算内延后:等到 %s 再 ping,使窗口约在 %s 重置", + anchor.Format("01-02 15:04"), target.Format("01-02 15:04")) + } + return base +} + +// Upcoming 推导下一个满额时刻,并前向模拟未来 n 次计划 ping,供预览与指标共用。 +// lastPing 为最近一次成功 ping:若当前窗口仍在运行(lastPing+window 晚于 now), +// 则从该窗口重置时刻起算;否则视为此刻已满额。 +func (p *Planner) Upcoming(now time.Time, lastPing *time.Time, n int) []PingPlan { + start := now + if lastPing != nil { + if nextFull := lastPing.Add(p.window); nextFull.After(now) { + start = nextFull + } + } + return p.NextPingTimes(start, n) +} + +// NextPingTimes 从 start 时刻(应为窗口满额时刻)出发,前向模拟未来 n 次计划 ping。 +// 模拟假设每次 ping 后窗口在 window 后再次满额(工具自身链式语义),不建模真实使用。 +func (p *Planner) NextPingTimes(start time.Time, n int) []PingPlan { + plans := make([]PingPlan, 0, n) + full := start + for i := 0; i < n; i++ { + decision := p.Decide(full) + pingAt := full + if decision.Action == ActionHold { + pingAt = decision.Anchor + } + if pingAt.Before(full) { + pingAt = full + } + plans = append(plans, PingPlan{PingAt: pingAt, RefreshAt: pingAt.Add(p.window)}) + full = pingAt.Add(p.window) + } + return plans +} + +// nextOnOrAfter 返回所有 cron 中不早于 t 的最近一次触发时刻,及该目标自己的延时预算。 +func (p *Planner) nextOnOrAfter(t time.Time) (time.Time, time.Duration, bool) { + // cron.Schedule.Next 返回严格晚于入参的时刻,减一纳秒以纳入 t 本身。 + probe := t.Add(-time.Nanosecond) + var earliest time.Time + var maxDelay time.Duration + found := false + for _, target := range p.targets { + next := target.schedule.Next(probe) + if next.IsZero() { + continue + } + if !found || next.Before(earliest) { + earliest, maxDelay, found = next, target.maxDelay, true + } + } + return earliest, maxDelay, found +} + +func roundDuration(d time.Duration) time.Duration { + if d < 0 { + d = 0 + } + return d.Round(time.Minute) +} diff --git a/internal/cli/align.go b/internal/cli/align.go new file mode 100644 index 0000000..441c46c --- /dev/null +++ b/internal/cli/align.go @@ -0,0 +1,181 @@ +package cli + +import ( + "fmt" + "io" + "time" + + "github.com/spf13/cobra" + + "github.com/ShawnKung/limitping/internal/align" + "github.com/ShawnKung/limitping/internal/pingstate" +) + +func newAlignCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "align", + Short: "管理刷新对齐目标(cron),并预览未来的 ping/刷新时间", + Long: "align 用一组 cron 表达式声明希望 5h 窗口刷新命中的目标时间。\n" + + "平时每 5h 正常链式 ping,临近目标时会在延时预算内延后,使窗口尽量在目标时间重置。\n" + + "配置保存在 ${XDG_CONFIG_HOME:-~/.config}/limitping/config.json。", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(newAlignListCmd(), newAlignAddCmd(), newAlignDeleteCmd(), newAlignClearCmd(), newAlignPreviewCmd()) + return cmd +} + +func newAlignListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "列出当前对齐目标(每行:id、cron、该目标的延时预算 max-delay)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := align.Load() + if err != nil { + return err + } + out := cmd.OutOrStdout() + if len(cfg.Targets) == 0 { + fmt.Fprintln(out, "未配置对齐目标。使用 limitping align add \"\" 添加。") + } else { + for _, target := range cfg.Targets { + fmt.Fprintf(out, "%s %-11s max-delay=%s\n", target.ID, target.Cron, target.MaxDelay().Round(time.Minute)) + } + } + if path, err := align.ConfigPath(); err == nil { + fmt.Fprintf(out, "配置文件: %s\n", path) + } + return nil + }, + } +} + +func newAlignAddCmd() *cobra.Command { + var maxDelay time.Duration + cmd := &cobra.Command{ + Use: "add \"\"", + Short: "追加一个 cron 目标(标准 5 字段),返回其 id;--max-delay 设置该目标自己的延时预算(默认 90m)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := align.Load() + if err != nil { + return err + } + maxDelayMinutes := 0 + if cmd.Flags().Changed("max-delay") { + maxDelayMinutes = int(maxDelay.Minutes()) + } + target, created, err := cfg.Add(args[0], maxDelayMinutes) + if err != nil { + return err + } + if err := align.Save(cfg); err != nil { + return err + } + verb := "已更新对齐目标" + if created { + verb = "已添加对齐目标" + } + fmt.Fprintf(cmd.OutOrStdout(), "%s: %s %s max-delay=%s\n", + verb, target.ID, target.Cron, target.MaxDelay().Round(time.Minute)) + return nil + }, + } + cmd.Flags().DurationVar(&maxDelay, "max-delay", 0, "该目标临近时单窗口最大延后时长(如 90m),缺省 90m") + return cmd +} + +func newAlignDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm"}, + Short: "按 id(或唯一前缀)删除一个对齐目标", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := align.Load() + if err != nil { + return err + } + removed, err := cfg.Delete(args[0]) + if err != nil { + return err + } + if err := align.Save(cfg); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "已删除对齐目标: %s %s\n", removed.ID, removed.Cron) + return nil + }, + } +} + +func newAlignClearCmd() *cobra.Command { + return &cobra.Command{ + Use: "clear", + Short: "清空所有对齐目标", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := align.Load() + if err != nil { + return err + } + cfg.Targets = nil + if err := align.Save(cfg); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "已清空对齐目标。") + return nil + }, + } +} + +func newAlignPreviewCmd() *cobra.Command { + var count int + cmd := &cobra.Command{ + Use: "preview", + Short: "预览未来若干次计划 ping 时间与对应的刷新时间", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if count <= 0 { + count = plannedPingCount + } + cfg, err := align.Load() + if err != nil { + return err + } + planner, err := align.NewPlanner(cfg) + if err != nil { + return err + } + lastPing, err := pingstate.LastSuccessfulPing() + if err != nil { + return err + } + printAlignPreview(cmd.OutOrStdout(), planner, lastPing, count) + return nil + }, + } + cmd.Flags().IntVar(&count, "count", plannedPingCount, "预览的计划 ping 数量") + return cmd +} + +func printAlignPreview(out io.Writer, planner *align.Planner, lastPing *time.Time, count int) { + now := time.Now() + if !planner.HasTargets() { + fmt.Fprintln(out, "未配置对齐目标:满额即 ping,重置时间即 ping 时间 + 5h。") + } + if lastPing != nil { + fmt.Fprintf(out, "最近成功 ping: %s\n", lastPing.Local().Format("2006-01-02 15:04")) + } + plans := planner.Upcoming(now, lastPing, count) + fmt.Fprintln(out, "未来计划:") + for i, plan := range plans { + fmt.Fprintf(out, " %d. ping %s → 刷新 %s\n", + i+1, + plan.PingAt.Local().Format("01-02 15:04"), + plan.RefreshAt.Local().Format("01-02 15:04")) + } +} diff --git a/internal/cli/align_test.go b/internal/cli/align_test.go new file mode 100644 index 0000000..72e14f1 --- /dev/null +++ b/internal/cli/align_test.go @@ -0,0 +1,112 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestAlignAddListAndClear(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + var out bytes.Buffer + if err := Run([]string{"align", "add", "0 0 * * *"}, strings.NewReader(""), &out, &out); err != nil { + t.Fatalf("align add: %v", err) + } + if !strings.Contains(out.String(), "已添加对齐目标:") || !strings.Contains(out.String(), "0 0 * * *") { + t.Fatalf("add output = %q", out.String()) + } + + out.Reset() + if err := Run([]string{"align", "list"}, strings.NewReader(""), &out, &out); err != nil { + t.Fatalf("align list: %v", err) + } + if !strings.Contains(out.String(), "0 0 * * *") || !strings.Contains(out.String(), "max-delay=") { + t.Fatalf("list output = %q", out.String()) + } + + out.Reset() + if err := Run([]string{"align", "clear"}, strings.NewReader(""), &out, &out); err != nil { + t.Fatalf("align clear: %v", err) + } + out.Reset() + if err := Run([]string{"align", "list"}, strings.NewReader(""), &out, &out); err != nil { + t.Fatalf("align list after clear: %v", err) + } + if !strings.Contains(out.String(), "未配置对齐目标") { + t.Fatalf("list-after-clear output = %q", out.String()) + } +} + +func TestAlignDeleteByID(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + var out bytes.Buffer + if err := Run([]string{"align", "add", "0 0 * * *"}, strings.NewReader(""), &out, &out); err != nil { + t.Fatalf("align add: %v", err) + } + fields := strings.Fields(out.String()) + if len(fields) < 2 { + t.Fatalf("cannot parse id from add output %q", out.String()) + } + id := fields[1] + + out.Reset() + if err := Run([]string{"align", "delete", id}, strings.NewReader(""), &out, &out); err != nil { + t.Fatalf("align delete: %v", err) + } + if !strings.Contains(out.String(), "已删除对齐目标:") || !strings.Contains(out.String(), id) { + t.Fatalf("delete output = %q", out.String()) + } + + out.Reset() + if err := Run([]string{"align", "list"}, strings.NewReader(""), &out, &out); err != nil { + t.Fatalf("align list: %v", err) + } + if !strings.Contains(out.String(), "未配置对齐目标") { + t.Fatalf("list-after-delete output = %q", out.String()) + } +} + +func TestAlignAddWithMaxDelay(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var out bytes.Buffer + if err := Run([]string{"align", "add", "0 0 * * *", "--max-delay", "45m"}, strings.NewReader(""), &out, &out); err != nil { + t.Fatalf("align add: %v", err) + } + got := out.String() + if !strings.Contains(got, "0 0 * * *") { + t.Fatalf("add output missing cron: %q", got) + } + if !strings.Contains(got, "45m0s") { + t.Fatalf("add output missing max delay: %q", got) + } +} + +func TestAlignAddRejectsInvalidCron(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + var out bytes.Buffer + err := Run([]string{"align", "add", "not-a-cron"}, strings.NewReader(""), &out, &out) + if err == nil { + t.Fatal("expected invalid cron to error") + } +} + +func TestAlignPreviewRendersPlans(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + if err := Run([]string{"align", "add", "0 0 * * *"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}); err != nil { + t.Fatalf("align add: %v", err) + } + var out bytes.Buffer + if err := Run([]string{"align", "preview", "--count", "3"}, strings.NewReader(""), &out, &out); err != nil { + t.Fatalf("align preview: %v", err) + } + got := out.String() + if !strings.Contains(got, "未来计划:") || !strings.Contains(got, "刷新") { + t.Fatalf("preview output = %q", got) + } + if strings.Count(got, "ping ") < 3 { + t.Fatalf("expected 3 planned pings, output = %q", got) + } +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 09e05a5..1928a99 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -15,6 +15,7 @@ import ( "github.com/spf13/cobra" + "github.com/ShawnKung/limitping/internal/align" "github.com/ShawnKung/limitping/internal/auth" "github.com/ShawnKung/limitping/internal/metrics" "github.com/ShawnKung/limitping/internal/models" @@ -83,7 +84,7 @@ func newRootCmd() *cobra.Command { root.PersistentFlags().StringVar(&options.pushMetric, "push-metric", "", "将用量和 ping 结果推送到指定的 Pushgateway endpoint") root.SetVersionTemplate("limitping {{.Version}}\n") root.SetUsageTemplate(zhUsageTemplate) - root.AddCommand(newStatusCmd(options), newPingCmd(options), newVersionCmd(), newUpdateCmd()) + root.AddCommand(newStatusCmd(options), newPingCmd(options), newAlignCmd(), newVersionCmd(), newUpdateCmd()) root.InitDefaultCompletionCmd() localizeCompletionCommand(root) root.SetHelpCommand(newHelpCommand()) @@ -166,7 +167,7 @@ func newStatusCmd(options *globalOptions) *cobra.Command { printStatus(cmd.OutOrStdout(), snapshot) } if options.pushMetric != "" { - return pushSnapshot(cmd.Context(), cmd.ErrOrStderr(), snapshot, collectionDuration, options.pushMetric, false, false) + return pushSnapshot(cmd.Context(), cmd.ErrOrStderr(), snapshot, collectionDuration, options.pushMetric, false, false, nil) } return nil }, @@ -178,17 +179,24 @@ func newStatusCmd(options *globalOptions) *cobra.Command { func newPingCmd(options *globalOptions) *cobra.Command { var dryRun bool var ifFull bool + var withoutAlign bool cmd := &cobra.Command{ Use: "ping", Aliases: []string{"p"}, Short: "用当前最弱的可见模型发送最小 ping", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return executePing(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), dryRun, ifFull, options.pushMetric) + return executePing(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), pingParams{ + dryRun: dryRun, + ifFull: ifFull, + withoutAlign: withoutAlign, + pushEndpoint: options.pushMetric, + }) }, } cmd.Flags().BoolVar(&dryRun, "dry-run", false, "只打印将执行的命令") - cmd.Flags().BoolVar(&ifFull, "if-5h-full", false, "仅在 5h 可用额度为 100% 时执行") + cmd.Flags().BoolVar(&ifFull, "if-5h-full", false, "仅在 5h 可用额度为 100% 时执行;默认会结合已配置的 cron 目标做相位对齐(见 align 子命令),仅在满额且到达对齐时间后才 ping") + cmd.Flags().BoolVar(&withoutAlign, "without-align", false, "关闭相位对齐,只保留满额判断(满额即 ping);仅在配合 --if-5h-full 时有意义") return cmd } @@ -263,7 +271,35 @@ func localizeHelpFlags(cmd *cobra.Command) { } } -func executePing(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, dryRun, ifFull bool, pushEndpoint string) error { +type pingParams struct { + dryRun bool + ifFull bool + withoutAlign bool + pushEndpoint string +} + +func executePing(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, params pingParams) error { + dryRun := params.dryRun + ifFull := params.ifFull + pushEndpoint := params.pushEndpoint + // 相位对齐是 --if-5h-full 的默认行为;--without-align 关闭它,退化为“满额即 ping”。 + useAlign := ifFull && !params.withoutAlign + + var planner *align.Planner + if useAlign { + cfg, err := align.Load() + if err != nil { + return err + } + planner, err = align.NewPlanner(cfg) + if err != nil { + return err + } + if !planner.HasTargets() { + fmt.Fprintln(stderr, "提示:未配置对齐目标(limitping align add \"\"),本次退化为满额即 ping。") + } + } + var snapshot *usage.Snapshot var collectionDuration time.Duration collect := func() error { @@ -281,11 +317,22 @@ func executePing(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, if !ok { fmt.Fprintf(stdout, "跳过 ping:%s\n", reason) if pushEndpoint != "" { - return pushSnapshot(ctx, stdout, snapshot, collectionDuration, pushEndpoint, dryRun, false) + return pushSnapshot(ctx, stdout, snapshot, collectionDuration, pushEndpoint, dryRun, false, planner) } return nil } fmt.Fprintln(stdout, "5h 可用额度为 100%,满足触发条件。") + if planner != nil && planner.HasTargets() { + decision := planner.Decide(time.Now()) + fmt.Fprintf(stdout, "对齐决策:%s\n", decision.Reason) + if decision.Action == align.ActionHold { + fmt.Fprintf(stdout, "本次延后 ping(目标 %s)。\n", decision.Target.Format("01-02 15:04")) + if pushEndpoint != "" { + return pushSnapshot(ctx, stdout, snapshot, collectionDuration, pushEndpoint, dryRun, false, planner) + } + return nil + } + } } model, err := models.Weakest(ctx) if err != nil { @@ -301,7 +348,7 @@ func executePing(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, return fmt.Errorf("采集推送指标: %w", err) } } - return pushSnapshot(ctx, stdout, snapshot, collectionDuration, pushEndpoint, true, false) + return pushSnapshot(ctx, stdout, snapshot, collectionDuration, pushEndpoint, true, false, planner) } return nil } @@ -321,7 +368,7 @@ func executePing(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, } return metricErr } - pushErr := pushSnapshot(ctx, stdout, snapshot, collectionDuration, pushEndpoint, false, pingErr == nil) + pushErr := pushSnapshot(ctx, stdout, snapshot, collectionDuration, pushEndpoint, false, pingErr == nil, planner) return errors.Join(wrapPingError(pingErr), wrapStateError(stateErr), pushErr) } if pingErr != nil { @@ -330,16 +377,23 @@ func executePing(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, return wrapStateError(stateErr) } -func pushSnapshot(ctx context.Context, out io.Writer, snapshot *usage.Snapshot, collectionDuration time.Duration, endpoint string, dryRun, pingCompleted bool) error { +func pushSnapshot(ctx context.Context, out io.Writer, snapshot *usage.Snapshot, collectionDuration time.Duration, endpoint string, dryRun, pingCompleted bool, planner *align.Planner) error { lastSuccessfulPing, err := pingstate.LastSuccessfulPing() if err != nil { return fmt.Errorf("读取最近成功 ping: %w", err) } + var plannedPings []time.Time + if planner != nil && planner.HasTargets() { + for _, plan := range planner.Upcoming(time.Now(), lastSuccessfulPing, plannedPingCount) { + plannedPings = append(plannedPings, plan.PingAt) + } + } result, err := metrics.Deliver(ctx, snapshot, collectionDuration, metrics.Options{ GatewayURL: endpoint, DryRun: dryRun, PingCompleted: pingCompleted, LastSuccessfulPing: lastSuccessfulPing, + PlannedPings: plannedPings, }) if err != nil { return err @@ -352,6 +406,9 @@ func pushSnapshot(ctx context.Context, out io.Writer, snapshot *usage.Snapshot, return nil } +// plannedPingCount 是上报及预览默认展示的未来计划 ping 数量。 +const plannedPingCount = 5 + func wrapPingError(err error) error { if err == nil { return nil diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 83eaf9a..dd14c42 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -59,7 +59,7 @@ func TestCobraHelp(t *testing.T) { t.Fatal(err) } got := out.String() - for _, want := range []string{"可用命令:", "status, s, stat", "ping, p", "update", "--help"} { + for _, want := range []string{"可用命令:", "status, s, stat", "ping, p", "align", "update", "--help"} { if !strings.Contains(got, want) { t.Fatalf("help missing %q:\n%s", want, got) } diff --git a/internal/metrics/push.go b/internal/metrics/push.go index 8b5a806..9c3f90c 100644 --- a/internal/metrics/push.go +++ b/internal/metrics/push.go @@ -22,6 +22,7 @@ type Options struct { DryRun bool PingCompleted bool LastSuccessfulPing *time.Time + PlannedPings []time.Time HTTPClient *http.Client } @@ -50,7 +51,7 @@ func Deliver(ctx context.Context, snapshot *usage.Snapshot, collectionDuration t if err != nil { return result, err } - result.Payload, result.MissingMetrics = Render(snapshot, collectionDuration, time.Now(), options.PingCompleted, options.LastSuccessfulPing) + result.Payload, result.MissingMetrics = Render(snapshot, collectionDuration, time.Now(), options.PingCompleted, options.LastSuccessfulPing, options.PlannedPings) if options.DryRun { return result, nil } @@ -103,7 +104,7 @@ func groupingURL(gatewayURL, instance string) (string, error) { return baseURL + "/" + strings.Join(parts, "/"), nil } -func Render(snapshot *usage.Snapshot, collectionDuration time.Duration, now time.Time, pingCompleted bool, lastSuccessfulPing *time.Time) (string, []string) { +func Render(snapshot *usage.Snapshot, collectionDuration time.Duration, now time.Time, pingCompleted bool, lastSuccessfulPing *time.Time, plannedPings []time.Time) (string, []string) { var output strings.Builder output.WriteString("# HELP metrics_pusher_collector_success Whether the collector completed successfully.\n") output.WriteString("# TYPE metrics_pusher_collector_success gauge\n") @@ -140,6 +141,8 @@ func Render(snapshot *usage.Snapshot, collectionDuration time.Duration, now time output.WriteString("# TYPE limitping_ping_completed gauge\n") output.WriteString("# HELP limitping_last_successful_ping_timestamp_seconds Unix timestamp of the latest completed ping operation.\n") output.WriteString("# TYPE limitping_last_successful_ping_timestamp_seconds gauge\n") + output.WriteString("# HELP limitping_planned_ping_timestamp_seconds Unix timestamp of an upcoming planned ping, indexed from 1 (soonest).\n") + output.WriteString("# TYPE limitping_planned_ping_timestamp_seconds gauge\n") provider := snapshot.Provider if provider == "" { @@ -172,6 +175,18 @@ func Render(snapshot *usage.Snapshot, collectionDuration time.Duration, now time }) fmt.Fprintf(&output, "limitping_last_successful_ping_timestamp_seconds%s %.3f\n", lastPingLabels, float64(lastSuccessfulPing.UnixNano())/1e9) } + if len(plannedPings) == 0 { + missing = append(missing, "limitping_planned_ping_timestamp_seconds") + } else { + for i, planned := range plannedPings { + plannedLabels := prometheusLabels(map[string]string{ + "provider": provider, + "plan": plan, + "index": strconv.Itoa(i + 1), + }) + fmt.Fprintf(&output, "limitping_planned_ping_timestamp_seconds%s %.3f\n", plannedLabels, float64(planned.UnixNano())/1e9) + } + } if snapshot.ResetCredits == nil { missing = append(missing, "limitping_reset_credits_available") missing = append(missing, "limitping_reset_credit_expiration_timestamp_seconds") diff --git a/internal/metrics/push_test.go b/internal/metrics/push_test.go index d98e268..5b2931e 100644 --- a/internal/metrics/push_test.go +++ b/internal/metrics/push_test.go @@ -35,7 +35,8 @@ func completeSnapshot() *usage.Snapshot { func TestRenderMatchesExistingCollectorMetrics(t *testing.T) { lastPing := time.Unix(900, 0) - payload, missing := Render(completeSnapshot(), 250*time.Millisecond, time.Unix(1000, 0), true, &lastPing) + plannedPings := []time.Time{time.Unix(20000, 0), time.Unix(38000, 0)} + payload, missing := Render(completeSnapshot(), 250*time.Millisecond, time.Unix(1000, 0), true, &lastPing, plannedPings) if len(missing) != 0 { t.Fatalf("missing metrics: %v", missing) } @@ -55,6 +56,7 @@ func TestRenderMatchesExistingCollectorMetrics(t *testing.T) { "limitping_usage_fetched_timestamp_seconds", "limitping_ping_completed", "limitping_last_successful_ping_timestamp_seconds", + "limitping_planned_ping_timestamp_seconds", "limitping_collector_generated_timestamp_seconds", } for _, metric := range want { @@ -74,6 +76,12 @@ func TestRenderMatchesExistingCollectorMetrics(t *testing.T) { if !strings.Contains(payload, `limitping_reset_credit_expiration_timestamp_seconds{plan="plus",provider="codex",remaining="`) { t.Fatalf("reset credit expiration metric missing:\n%s", payload) } + if !strings.Contains(payload, `limitping_planned_ping_timestamp_seconds{index="1",plan="plus",provider="codex"} 20000.000`) { + t.Fatalf("planned ping #1 metric missing:\n%s", payload) + } + if !strings.Contains(payload, `limitping_planned_ping_timestamp_seconds{index="2",plan="plus",provider="codex"} 38000.000`) { + t.Fatalf("planned ping #2 metric missing:\n%s", payload) + } } func TestEarliestAvailableCreditExpiration(t *testing.T) { @@ -118,10 +126,14 @@ func TestCompactHighestUnit(t *testing.T) { } func TestRenderReportsMissingLastSuccessfulPing(t *testing.T) { - _, missing := Render(completeSnapshot(), time.Second, time.Unix(1000, 0), false, nil) - if !strings.Contains(strings.Join(missing, ","), "limitping_last_successful_ping_timestamp_seconds") { + _, missing := Render(completeSnapshot(), time.Second, time.Unix(1000, 0), false, nil, nil) + joined := strings.Join(missing, ",") + if !strings.Contains(joined, "limitping_last_successful_ping_timestamp_seconds") { t.Fatalf("missing metrics = %v", missing) } + if !strings.Contains(joined, "limitping_planned_ping_timestamp_seconds") { + t.Fatalf("planned ping should be reported missing when absent: %v", missing) + } } func TestDeliverUsesExistingGroupingAndPut(t *testing.T) {