P2-1(行为缺陷):窗口准入此前只在 prepareInbound 里算 `solo`,而 prepare 发生在 **窗口等待之后** —— 平台帧(action / 系统通知 / 我们自己的 message_sent 回执)照样 进窗、照样把滑动计时重置:一条回执就能把这一批的静默期一直续住(群友说了三句话, 她因为「撤回事件」迟迟不开口)。这与任务书「窗口只收 chat/image/voice/file」相反。 - `laneWindowAdmits(msg)` 成为**唯一准入判据**,只看结构化事实(ContentType + isSystemNoticeMessage,绝不解析正文); - `splitWindowItems` 在窗口等待阶段把到达的条目分成「进窗口」与「不进窗口」两半; - 非准入帧**不建窗口、不重置计时**(首条即到达即发车),窗口中途到达的只收进 solos, 这一批发车后**原样回插队首**各自单跑(保序、不合并、不丢弃); - prepareInbound 里的 `solo` 保留为第二道保险(适配器将来把平台事件标成 text 时, 仍会在组批前被切出去)。 P2-2(硬约束):lane.go 682 行 → 按职责纯搬移成 5 个文件(最大 246 行): lane.go(车道类型与注册表)/ lane_queue.go(入队与取队列)/ lane_worker.go(单消费者与回合槽)/ lane_drop.go(丢弃与停机诚实)/ lane_owner.go(主人私聊判据与车道键)。语义零改动:去注释后的声明集合 (34 个顶层声明,含函数体/字段/常量值)逐字节全等;既有用例一条未改。 顺带:把两处「IsOwner() && !IsGroupChat()」的注释改成代码真正用的判据 (ownerPrivateLane = !msg.IsGroup() && actor.IsOwner()),并记下这处 P3 分叉。 验证:全树 build/vet/test 全绿(73 包 ok);三包 -race 全绿; 新增 4 条准入用例在「准入失效」变异下三条必红、对照组保持绿。
132 lines
5.7 KiB
Go
132 lines
5.7 KiB
Go
package channelbridge
|
||
|
||
// 本文件承载车道的**丢弃与停机诚实**:陈旧度闸、门前拒收、以及「桥停了,这些东西一条
|
||
// 都没跑」的登记。它是「丢弃是诚实且有界」的落点 —— 每一类丢弃都留
|
||
// WARN + 计数 + onRejected 审计,绝不静默消失(与 flowgate 的 rate_limited /
|
||
// review_pruned 同一条流水)。回什么属于回合语义,本文件一个字都不改。
|
||
|
||
import (
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
)
|
||
|
||
// LaneStats 是车道的计数快照:**被丢弃的入站有多少、为什么**(不变量 ③ 的可审计面),
|
||
// 加上主人私聊溢出区的当前积压。
|
||
type LaneStats struct {
|
||
// ActiveLanes 是此刻有排队或在跑的会话车道数。
|
||
ActiveLanes int
|
||
// DroppedStale 是因陈旧度闸被丢弃的条目累计数(超龄不补答)。
|
||
DroppedStale int64
|
||
// DroppedOverflow 是车道满时**在门前被拒收**的条目累计数。它只可能落在群聊 /
|
||
// 陌生人私聊上:主人私聊车道永不拒收(见 offer 的 neverDrop)。
|
||
DroppedOverflow int64
|
||
// Backlog 是主人私聊溢出区里此刻还排着的条目数(其余车道恒为 0)。
|
||
Backlog int
|
||
}
|
||
|
||
// LaneStats 返回车道计数的快照。它是「丢弃是诚实且有界」的进程内数字答案。
|
||
//
|
||
// 生产可见性的口径(复审 P2-3):**不新开 HTTP 端点**(那是契约变更,归塔)。真正
|
||
// 生产可见的是每一条丢弃 WARN 里带的 `dropped_stale_total` / `dropped_overflow_total`
|
||
// (累计计数随日志落地)以及 `onRejected` 审计轨;本方法只是给进程内调用方(管理面板
|
||
// 将来接线、运维探针、测试)读同一组数字的窄口,不构成对外契约。
|
||
func (b *Bridge) LaneStats() LaneStats {
|
||
if b.lanes == nil {
|
||
return LaneStats{}
|
||
}
|
||
b.lanes.mu.Lock()
|
||
defer b.lanes.mu.Unlock()
|
||
backlog := 0
|
||
for _, l := range b.lanes.lanes {
|
||
backlog += len(l.spill)
|
||
}
|
||
return LaneStats{
|
||
ActiveLanes: len(b.lanes.lanes),
|
||
DroppedStale: b.lanes.droppedStale,
|
||
DroppedOverflow: b.lanes.droppedOverflow,
|
||
Backlog: backlog,
|
||
}
|
||
}
|
||
|
||
// staleInbound 实现陈旧度闸:轮到时已超龄 ⇒ 丢弃不补答,并留 WARN + 计数 + 审计。
|
||
// 返回 true 表示这条已被丢弃。waited 由调用方算好(含等全局回合槽的时间)。
|
||
func (b *Bridge) staleInbound(item laneItem, waited time.Duration) bool {
|
||
if !laneStaleEligible(item) {
|
||
return false
|
||
}
|
||
if waited <= b.lanes.staleAfter {
|
||
return false
|
||
}
|
||
b.dropInbound(item, laneDropReasonStale, "waited past the staleness threshold")
|
||
return true
|
||
}
|
||
|
||
// laneStaleEligible 报告某条入站是否受陈旧度闸约束。两类**豁免**各有硬理由:
|
||
//
|
||
// - **非聊天事件**(notice / request / meta_event / 解析失败兜底,ContentType=action):
|
||
// 它们不产生「回答旧话」这种失真,却是渠道事件留存(design/26 §5)与审计的唯一来源。
|
||
// 按年龄丢掉它们只会让留存出现空洞。
|
||
// - **主人私聊**:「回主人私聊」不是对群聊话题的应答,迟到的回答仍然是回答;
|
||
// 丢它等于对主人静默,直接违反「该回的回」这条红线。
|
||
func laneStaleEligible(item laneItem) bool {
|
||
if item.msg.ContentType == adapter.ContentAction {
|
||
return false
|
||
}
|
||
if !item.msg.IsGroup() && item.actor.IsOwner() {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// dropInbound 给「被丢弃的入站」留下**三样痕迹**,缺一不可:
|
||
// - 一条带 source 的 WARN(谁、哪个平台/会话/发送者、等了多久、为什么),**并且带上
|
||
// 两类丢弃的累计计数** —— 计数因此不需要新端点就能在生产日志里被读到(复审 P2-3:
|
||
// 计数只活在进程内存里等于没有出口);
|
||
// - 按原因的累计计数(LaneStats,供进程内调用方/后续面板接线读,不新开端点);
|
||
// - onRejected 审计轨(与 flowgate 的 rate_limited / review_pruned 同一条)。
|
||
//
|
||
// 它刻意**不**回一条「系统繁忙」:回什么属于回合语义,本切片不动它。
|
||
func (b *Bridge) dropInbound(item laneItem, reason, detail string) {
|
||
stale, overflow := b.lanes.noteDrop(reason)
|
||
b.lanes.log.Warn("inbound dropped by session lane",
|
||
"reason", reason, "detail", detail,
|
||
"platform", item.msg.Platform, "channel", item.msg.ChannelID,
|
||
"sender", item.msg.SenderID, "message_id", item.msg.MessageID,
|
||
"waited", b.now().Sub(item.arrivedAt).String(),
|
||
"dropped_stale_total", stale, "dropped_overflow_total", overflow)
|
||
if b.onRejected != nil {
|
||
b.onRejected(item.msg, reason)
|
||
}
|
||
}
|
||
|
||
// noteDrop 累加某一类丢弃的计数,并返回累加后的两类计数快照(写进 WARN 用)。
|
||
func (s *laneSet) noteDrop(reason string) (stale, overflow int64) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
switch reason {
|
||
case laneDropReasonStale:
|
||
s.droppedStale++
|
||
case laneDropReasonOverflow:
|
||
s.droppedOverflow++
|
||
}
|
||
return s.droppedStale, s.droppedOverflow
|
||
}
|
||
|
||
// abandonLaneAtStop 登记「桥停止时车道里还排着、因此永远不会被回答的条目」。
|
||
//
|
||
// 它与 coalesce_lane.go 的 abandonAtStop 分工明确:那边管**已经取在手上、正在等窗口**
|
||
// 的那一批,这里管**还躺在队列里**的(front/ch/spill)。两处都带 source 落 WARN 并计入
|
||
// abandoned_at_stop_total —— 进程要停了没法「晚回」,唯一能做对的是**说出来**。
|
||
func (b *Bridge) abandonLaneAtStop(l *lane) {
|
||
b.lanes.mu.Lock()
|
||
remaining := len(l.front) + len(l.ch) + len(l.spill)
|
||
b.lanes.mu.Unlock()
|
||
if remaining == 0 {
|
||
return
|
||
}
|
||
total := b.coalesceStats.abandonedAtStop.Add(int64(remaining))
|
||
b.lanes.log.Warn("lane queue abandoned at bridge stop",
|
||
"lane", l.key, "count", remaining, "abandoned_at_stop_total", total)
|
||
}
|