外设配额判点(INV-QUOTA-05)

wesgine 五个外设 cap 的判据位置——在唯一增长点判定,与增长同临界区。


核心问题

五个外设 cap 都曾越界,方式只有两种,而两种都不报错、不打日志:

形状一:判在三个调用方之一

技能 cap 曾只写在 Skills().InstallPackage,InstallFromDir 与 boot 播种直接走过去。

形状二:判与写分成两步

两个写者各读到 cap-1 各加一,集合落在 cap+1,每一步算术都对。


症状分岔

子系统越界症状
cron / email多一行(不致命)
Channels / MCPs集合就是 CellSpec 且 Cell.Start 会校验它

Channels/MCPs 的分岔更严重:运行期越界写入持久化成功、当场毫无异常,下一次重启才拒绝启动。


五处共有形状

适配器不知道 cap 的数值
    ↓
只收 QuotaFn(current, delta) error 提问
    ↓
数值留在 internal/cell/quotas.go 的 CheckAdapterCount
    ↓
每子系统恰好一处能让集合变大
    ↓
那一处握着自己的锁跨越「问」和「长」

QuotaFn 模式

// 适配器侧——不知道 cap 数值
type QuotaFn func(current, delta int) error

// 使用示例
func (s *Scheduler) Add(ctx context.Context, actor string, spec JobSpec) (string, error) {
    // 在锁内:先问再长
    s.mu.Lock()
    defer s.mu.Unlock()
    
    current := s.count()
    if err := s.quotaFn(current, 1); err != nil {
        return "", err // 超限拒绝
    }
    // 插入新 job
}

五个 cap 的判点

Cap唯一增长点临界区
MaxIMChannelsUpdateSpec 的 Channels appendSpecPatch 写锁
MaxMCPServersUpdateSpec 的 MCPs appendSpecPatch 写锁
MaxCronJobscron.Scheduler.Adds.mu
MaxEmailAccountsemail.Store.PutAccounts.mu
MaxSkillsInstalledskills.Loader.installToDirl.mu

关键约束

「增长」不是「写入」

整体替换(UpdateSpec 的 Channels = append(nil, patch...))对 cap 幂等,收缩不需要 cap。

只有 append-to-self 会跨并发调用方累加。

覆盖已有名字 delta=0

upsert 是编辑手势。卡在上限的 Cell 必须还能轮换凭据。

current 取自权威处

进程内互斥量是充分的

INV-RESIL-04 让单进程独占 Cell 目录 flock。写者集合在构造上就是进程内的。


MaxPlugins 例外

MaxPlugins 不在其列——boot 期截断而非写点拒绝,没有运行期写门。


并发测试

闸门要求并发测试证明临界区跨越「问」和「长」:

// 拆开临界区后必须变红
func TestQuota_Concurrent(t *testing.T) {
    // 渠道那条跑 500 轮
    // 把「单次不太可能」变成「整体必然」
}

从未红过的并发测试测的是调度器,不是不变量。


闸门规则

66-quota-single-writepoint.sh 五条规则:

  1. 适配器不持有 cap 数值(只收 QuotaFn)
  2. 适配器不调 CheckAdapterCount
  3. 每子系统恰好一处写点(双向普查)
  4. 写点内有 quotaFn 调用
  5. 有并发测试且拆开能变红