2026-09-27·by Sijie Wang#standmeet#architecture#design#events

concurrency-control

并发控制:有上限、有超时、能停下

上级:events

状态: 已在 v0.1.76 发布(2026-09-27)—— 设计与落地记录见 StandMeet 仓库的 docs/design/event-bus-outbox-webhooks.md。

所有后台工作都跑在有上限、有超时、能优雅停下的 worker 里;应用代码里的游离 go func 不再存在。

队列与并发上限

队列MaxWorkers任务超时跑什么
index430 秒(重建 10 分钟)corpus.index、corpus.reindex、构建完成的订阅方
notify230 秒owner.notify、批准邮件与确认邮件、supplier.invoke
webhook815 秒(扇出 30 秒)webhook.fanout、webhook.deliver;每端点同时 ≤ 1
maintenance110 分钟周期任务
fetch3按种类jobs.fetch_source(P4 加入)

数字是起始值,按实测调。

  • 分队列,各有上限:一个慢的外部端点只会占满 webhook 队列,不会拖慢索引和邮件。有一条 UT 把 webhook 队列堵死,断言索引任务照样按时完成。
  • 每端点同时 ≤ 1:一行租约。 webhook.deliver 执行 UPDATE webhook_endpoints SET busy_until = now() + 20 s WHERE id = \$1 AND enabled AND (busy_until IS NULL OR busy_until < now()) RETURNING …。拿不到行就 snooze 2 秒(不计次数)。租约比任务超时长,worker 崩了租约会自己过期。多进程也成立。
    • 没用 pg_try_advisory_xact_lock:事务级的锁意味着 POST 期间一直占着事务和它的连接。
  • relay 每轮 ≤ 200 行,每个进程都跑,不选主:一次 2000 篇的导入不会变成一个巨大的事务。见 relay-claims-rows-not-cursor。
  • 请求会等的队列每 100 毫秒轮询一次(jobs.QueueAwaited:index、notify)。其余队列保持 River 的 1 秒。River 每个队列每个 FetchCooldown(100 毫秒)只发一条插入通知。一次写入的第二个索引任务(wiki 那篇,比 raw 晚约 5 毫秒)发不出通知,worker 取完第一个后它要等 1 秒轮询,每次写入多花约 0.5 秒。现在回执的耗时是一次轮询(≤ 100 毫秒)加 River 批量完结器的周期:它每 250 毫秒记一次完结,不可配置。

连接池预算:网络调用绝不在事务里

pgxpool 的 MaxConns 是 40,一半留给请求。启动校验:各队列 MaxWorkers 之和 + relay(现在 4 + 2 + 8 + 1 + 3 + 1 = 19)超过 MaxConns 的一半(20)就拒绝启动。配置错误在启动时暴露,而不是线上排队时。

超时与取消

  • 每个任务种类有硬超时(上表),到点 ctx 被取消,按 retryable 处理。
  • webhook 的 HTTP 超时(10 秒)< 任务超时(15 秒),确保先得到明确的网络错误,而不是被整体杀掉。
  • worker 里的 panic 被任务层捕获,记为一次失败,不会带崩进程。

优雅停机(升级会重建容器)

请求内等待的背压

“写后等索引最多 2 秒”靠共享的 LISTEN 连接(pgstore.Listener:每个进程每个频道一条)分发通知,不是每个请求各开一个连接轮询。每个频道最多 64 个等待者,超了就直接返回 indexed: false,不排队。见 async-response-contract 和 completion-hooks。

不许再有游离 goroutine

  • 门禁 check-no-bare-goroutine.sh:go 语句只允许出现在 internal/infra/** 和 cmd/server/**(测试除外),域代码和 internal/routes 里一律禁止;要后台做事就入队一个任务,或声明一个 jobs.Periodic。没有排除清单。见 no-bypass-by-structure。
  • 必须自己拥有 goroutine 的进程管道放在 infra:detach.Go 拥有一个 goroutine 并吸收它的 panic(block 挂载预热用它);hostsocket.ListenWith 自己起 accept 循环。
  • 上门禁前清掉的违规点:routes/admin/obsidian.go(goroutine 删除)、plugin/adapters/invoke_background.go(删除)、routes/hostdesk/hostdesk.go 和 agentcore/hostops.go(accept 循环移进 hostsocket)、plugin/mount/mounted_warm.go(改用 detach.Go)。
  • 门禁上线前,在临时副本里植入一个 go func 证红过一次。

相关:retry-has-one-owner · saturation-degrades-gracefully

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →