# ADR-0003: SessionStore 多副本边界 (sticky routing + 三层 pin) - **Status**: Accepted (L693 C1+C2 落地, C3-C4 部署 + 文档收尾) - **Date**: 2026-04-26 - **Deciders**: PM (产品经理) + Flyto Agent core team - **Related code**: `platform/common/internal/server/sessionstore/`, `platform/common/internal/db/`, `platform/common/internal/server/server.go`, `platform/common/cmd/common/main.go`, `deploy/docker-compose.yml`, `deploy/Caddyfile` - **Related TODO**: `core/TODO.md` L693 (登记 + 完成同日, 2026-04-26); 衍生 tracked debt L694 (cross-transport request-id) / L695 (SSE 带宽监控); 部分关闭 ADR-0002 § 4 标注的"in-memory sessions 单实例假设"限制 - **Commit chain**: `eec48fe` (C1 接口 + InMemoryStore drop-in) → `beaff60` (C2 Postgres + db.Pool + docker-compose pg) → C3 (本 ADR + Caddyfile sticky 注释) → C4 (TODO + CHANGELOG + CLAUDE.md 同步) - **Cross-reference**: ADR-0002 (REST 业务通道 + § 4 单副本限制点名); memory `feedback_dont_extrapolate_offhand_remarks` (PM 一句"做"≠ 全套 推断); memory `feedback_architecture_principle_over_rule_of_two` (产 品架构通路是铁律, rule-of-two 仅适用具体实现选择) --- ## 1. 背景 / Context `platform/common/internal/server/server.go` 在 ADR-0002 (2026-04-26) 落地 REST/SSE 业务通道时, 用 `sessions map[string]*sessionState` 持有会话状态, 被 5 个 HTTP handler 消费 (CreateSession / GetSession / DeleteSession / SendMessage / PermissionReply). ADR-0002 § 4 已显式标注此限制并登记 L693 追踪: "in-memory sessions 单实例假设, k8s 多副本立刻挂". PM 在 2026-04-26 提出做 L693, 意图是"让多副本部署不破". 我最初给 PM 的 比喻 ("把客户对话状态搬到共享白板") 不完整 -- 漏掉了一个比 sessions map 更狠的物理 pin: **server.go:103 `permCh map[string]chan permReply`** 是 SSE 流和权限回复 HTTP endpoint 之间的 Go channel 桥. **Go channel 不能 跨进程**, 这是语言层面的物理事实. 3-agent 并行 review (调研业界 prior art / 质疑必要性 / 设计接口与实现) reconcile 后, 真相清楚: 多副本部署的真阻塞不是 sessions map 抽接口, 而 是三层进程内 pin 同时存在. 仅做 SessionStore 让任意副本接得住 session **不够**, 必须配 LB 层 sticky routing. 反过来一旦有 sticky routing, SessionStore 的核心价值从"多副本必备"降到"replica 重启不丢元数据 + 滚动部署 drain + audit". --- ## 2. 决策 / Decision ### 2.1 SessionStore 接口三方法 (Create / Get / Delete) `platform/common/internal/server/sessionstore/SessionStore` 是 metadata 持久化契约. **三个方法**, 不要 Touch / List / owner_id 等投机表面 —— 当前 5 个 HTTP handler 真实操作只有 Create / Get / Delete, 任何投机方 法都会被 dead-field-scanner ratchet 标为死表面. 真消费者出现时 (admin UI 列活跃会话 / 多租户 gating / SLA watchdog), 方法跟消费者同 commit 落地. ```go type SessionStore interface { Create(ctx, id, createdAt) error // ErrSessionExists, atomic Get(ctx, id) (SessionMetadata, error) // ErrSessionNotFound Delete(ctx, id) error // idempotent } ``` 接口形状对照 LangGraph BaseCheckpointSaver / Express session.Store / Django SessionBase / ASP.NET IDistributedCache 三家业界实现, 共识是 ephemeral metadata 用 Get/Set/Delete (+ 可选 Touch) 形态, 不是 staging. Store 的 7 状态机 + verb 形态. staging 的 meta-pattern (InMemoryStore + SQL-later + tracked-debt) 复用; 接口 verbs 不复用. ### 2.2 后端两档 (InMemoryStore + PostgresStore) - **InMemoryStore** (`inmemory.go`): 默认, dev 和单副本 docker-compose 零配置. `sync.RWMutex + map[string]SessionMetadata`. 跨进程零协调, 跨重启零持久. - **PostgresStore** (`postgres.go`): 生产多副本必备. `INSERT ... ON CONFLICT (id) DO NOTHING` + RowsAffected==0 检查 = 单语句原子 (行锁, 无显式事务). pgx.ErrNoRows 映射 ErrSessionNotFound. 不做 Redis 档. 元数据 payload (id + created_at) 太薄, Redis TTL 优势 被业务"会话长期持有"模式抵消. staging / verdict-audit 后续接 Postgres 共享同一池更经济 (rule of two: 没有第二个真消费者驱动 Redis). ### 2.3 db.Pool 是 platform 共享 Postgres 权威 `platform/common/internal/db/pool.go` 持 `*pgxpool.Pool` + 中央 schema 权威 (`platformMigrations`, append-only `CREATE TABLE IF NOT EXISTS` list). 一次 `Migrate` 调用把所有 platform 表升到当前形态. 当前注册 sessions 表; staging / verdict-audit 等真消费者落地时追加. 不引正式 migration 工具 (golang-migrate / atlas / goose). 当前 1 张表, schema 在变, 用 plain `CREATE IF NOT EXISTS` 启动自检足够. 等表 ≥ 5 张 + schema 稳定再引. ### 2.4 三层进程内 pin 是物理事实, 平台层不能解 ``` A. server.permCh map[string]chan permReply (server.go:103) -- SSE / 权限回复桥, Go channel 不可序列化 B. Session.pendingPermissions map[string]chan bool (engine/session.go) -- 引擎内权限等待 channel, 同上 C. Engine.sessionState.sessions map[string]*Session (engine/engine.go) -- 引擎内 *Session 缓存, 含 mutex/goroutine ``` A 在 platform 层, B+C 在 core 引擎层. **平台层即使做完整 SessionStore + PermissionBus pub/sub, 也只能解 A 一层**. B+C 要解需要改 core 引擎 API (在 engine.Config 加 PermissionBus 接口 + Session 暴露 MarshalState/ RestoreState), 是 RFC 级别的 core change, 不在 L693 范围. 业界 LangGraph / Vercel resumable-stream / Temporal HITL 走的 SessionStore + PermissionBus 双件套, 因为它们的引擎是从头为多副本设计的. Flyto 引擎 不是, 现实是 sticky routing 是必经之路. ### 2.5 多副本部署必须配 LB sticky routing **Caddy (HK-133 phase 1+ 部署模型)**: `/api/v1/*` 当前 reverse_proxy 单 upstream `common:8080`. 多副本 (docker-compose scale=N 或多 host common replica) 时改: ``` handle /api/v1/* { reverse_proxy common-1:8080 common-2:8080 common-3:8080 { lb_policy ip_hash # 同 client IP 永远到同副本 flush_interval -1 transport http { response_header_timeout 0 } } } ``` **k8s**: Service.spec.sessionAffinity = ClientIP (内置). ```yaml spec: sessionAffinity: ClientIP sessionAffinityConfig: clientIP: timeoutSeconds: 10800 # 3 小时, 跟 Session TTL 对齐 ``` **phase 1 简化**: ip_hash / ClientIP 都是 client IP 级别, NAT 后多客户 端共享 IP 会聚集到同一副本, 单客户端跨网络切换会脱黏. **可接受** —— 飞驼 当前用户基数小, 物理 pin 真切断时再升级. **phase 2 升级 (未发, 触发条件: NAT 聚集 / 跨设备脱黏成投诉)**: API 客户端首次创建会话拿 session_id 后, 每个请求带 `X-Session-ID: ` header; Caddy 用 `lb_policy header X-Session-ID` 替代 ip_hash. 精准 按 session 粘, 客户端要改但不复杂. 这是 phase 2 的 RFC, 不在本 ADR. ### 2.6 cache miss 在多副本下的临时 fallback PostgresStore 把 metadata 持久化跨副本, 但 *engine.Session pointer 仍 是进程内 cache (sessionCache). 多副本 + sticky routing 失败 (例如客户端 IP 漂移) 时, 副本 B 收到一个本地 cache 没有的 session_id 请求: - **handleGetSession**: cache miss → `MessageCount: 0` (metadata 在 pg 里有, 但本副本不知道历史长度, 暂返 0; 不是"找不到 session") - **handleSendMessage**: cache miss → `503 Service Unavailable` + 错 误体明示 "session not loaded on this replica; sticky routing required (see ADR-0003)". 不静默假装能用. - **handleDeleteSession**: cache miss → 不 panic, 跳过 Close, 仅删 metadata + 返 200 deleted. session 在原副本的 *Session 会被原副本 GC 收掉 (Close 不被调到不会泄露 goroutine, 只是 lastActiveAt 刷新 机会丢失). **SnapshotStore 自动注入历史**: `core/pkg/engine/session_snapshot.go` 已 有 SnapshotStore 接口 (Save/Load/Delete) + WithMessages 注入路径. 接线 让 cache miss 时副本 B 自动 Load + WithMessages 重建 *engine.Session 是 **未来 commit** (L693 follow-up, 不在 C1-C4 范围). 接线后 cache miss 不再返 503, 而是延迟 ~一次 Postgres 读. 触发条件: 真出现 sticky 失败 导致的 503 投诉. ADR 不预判 SLA, 由真实流量决定. --- ## 3. 评估流程 / Evaluation Process 3 agent 并行 review (Anthropic Agent tool, 不是 MiniMax) 后 reconcile, PM 拍板. ### 3.1 调研 agent (业界 prior art) 5 个项目接口形状对照 (LangGraph BaseCheckpointSaver / Express session. Store / Django SessionBase / ASP.NET IDistributedCache / Vercel AI SDK 5): - ephemeral metadata 用 Get/Set/Delete + 可选 Touch - 业界 Postgres + Redis 双轨, Redis 主 hot session, Postgres 主 audit - 跨副本桥三模式: sticky / pub-sub / durable workflow (Temporal-like) 主要发现: SessionStore + PermissionBus pub/sub 是 LLM agent SaaS 业界 共识, 但要求引擎本来就为多副本设计. ### 3.2 质疑 agent (6 道硬质疑) 3 道击中: - Q2: permCh 物理不可移动, **任何 SessionStore 解不了** -- 击中 - Q4: YAGNI -- 5 个 touch site 一个文件, 3 个月后重构机械上相同 -- 击中, PM 之后接受了"C3 staging Postgres 跳过留消费者驱动" - Q6: dead-field-scanner ratchet 把投机 exported 当 debt -- 击中, 接口缩到 3 方法, 不预留 Touch/List 3 道未击中或 PM 拍板凌驾: - Q1 sticky-only 不做接口: 被 PM "整个 platform 都 Postgres" 凌驾 - Q3 staging.Store 模板不适用: 部分接受 (复用 meta-pattern, 不复用 verbs) - Q5 hardcode Redis 或 staging.Store 套用: 都拒绝 ### 3.3 设计 agent (3 方案 → 推荐 Alternative 2) 3 alternatives: - **Alt 1 字节级 Get/Set/Delete (Express 风)**: 拒绝, 调用方手写 codec, 不符项目 typed + 双语 godoc 风格 - **Alt 2 typed 3 方法 (推荐)**: 接受 - **Alt 3 typed metadata + opaque blob**: 拒绝, 与 engine.SnapshotStore 职责重叠 (后者已在 core/pkg/engine 落地) PostgresStore 用 ON CONFLICT 单语句原子, 不需 SELECT-then-INSERT 事务. 关键发现: engine.SnapshotStore 已在 core 存在 (SessionStore 之外的并行 接口), 不要在 SessionStore 重新发明 *engine.Session 序列化. ### 3.4 PM 拍板与 reconcile PM 三轮决策序列: 1. "Postgres 肯定用啊" -- 拍板后端不只 InMemory 2. "整个 platform 都 psql" -- 拍板平台化基础设施 (db.Pool 共享池) 3. "为啥要迁移工具? 为啥要 sqlite?" -- 拒绝过度工程, 用 plain SQL CREATE IF NOT EXISTS + testcontainers (真 pg 测试, 不 sqlite mock) 4. "C3 staging Postgres 跳过" -- 接受 YAGNI, 等 staging 真有消费者再做 PM 一致性强: 不要为没消费者的子系统加抽象 (rule of two), 但已有消费者 (sessions) 就把基础设施做透. --- ## 4. 后果 / Consequences ### 正面 - **元数据跨副本重启存活**: PostgresStore + db.Pool 让 sessions 表持久, v1.0 多副本 deploy 不丢 session 创建记录 - **滚动部署 drain**: 副本被 drain 时 metadata 在 pg, 客户端被路由到 新副本只丢 in-flight 消息, 不丢会话创建状态 - **TOCTOU race 折叠**: 原 RLock-then-Lock 两阶段竞争 → SessionStore .Create 单语句原子 (InMemory: 单 mu Lock-then-check-then-insert, Postgres: ON CONFLICT 行锁) - **db.Pool 复用**: 后续 staging / verdict-audit 接入直接共享池, cmd/common 单 dsn flag 管全部 - **业界对照 + ADR 留档**: 三层 pin 分析 + sticky routing 决策 + 替代 方案保留, 下次有人提"为啥不上 pub/sub"时 ADR § 2.4 指明 core engine RFC 路径 ### 负面 - **多副本要 sticky routing 是部署侧约束**: 没 LB / 不能配 affinity 的部署 (例如纯随机路由的 service mesh) 不能跑多副本 - **NAT IP 聚集 + 跨设备脱黏**: phase 1 ip_hash / ClientIP 在企业 NAT 后多客户端共享外网 IP 会聚到同副本; 客户端切换网络 IP 会脱黏到另 一副本 → 503 (sessionCache miss). phase 2 改 X-Session-ID header 解决, 触发条件由真投诉驱动. - **Postgres 是新单点**: 池挂 → 所有 SessionStore 操作 503. healthcheck + restart unless-stopped + 持久 volume 是基础保护, 但 HA Postgres (pgpool / patroni) 是 v1.0 SLA 课题, 不在本 ADR - **测试启动慢**: testcontainers 启 docker pg, 每包测试 +30s. CI 时间 线性上升; 可接受 (5 测试 / 包级别) - **Gitea CI secret 新增**: POSTGRES_PASSWORD secret 必须在 release.yml 跑前配好, 否则 deploy step :?missing 失败. PM 必做. ### 中性 - **dev 单副本零变化**: cmd/common 不传 --postgres-dsn 时, 默认走 InMemoryStore, 与 C1 之前行为完全等价 - **API 形状不变**: 5 个 handler URL / 状态码 / 请求体 / 响应体 都不变, Swagger 不需重生成 - **engine.Session 不动**: 不引入 MarshalState/RestoreState, 序列化路径 借现有 engine.SnapshotStore (由后续 commit 接线 cache miss 恢复) --- ## 5. 替代方案保留 / Alternatives Preserved (CLAUDE.md 原则 5) ### 5.1 Defer L693 + 只配 sticky routing (质疑 agent 推荐) **否决原因**: PM 明确"整个 platform 都 Postgres", SessionStore 接口骨架 + Postgres 后端是平台化奠基, 不是 speculative engineering. 单做 sticky routing 解了多副本路由问题, 不解 replica 重启 metadata 丢的问题. **保留触发**: 若 PM 明年回头看认为 Postgres 投入 ROI 不够, 退回 sticky- only 是可逆的 (server.Config.SessionStore=nil + docker-compose 删 pg). ### 5.2 PermissionBus pub/sub + SessionStore 双件套 (调研 agent 业界主流) **否决原因**: 三层 pin 中 B+C 在 core 引擎层, 单做 platform 层 pub/sub 解 A 一层后 B+C 仍把 session 钉死单副本, **配置复杂度上去了, 收益没出**. 要彻底解必须改 engine API (engine.Config.PermissionBus + Session. MarshalState/RestoreState) -- 单独 RFC. **保留触发**: 真出现"sticky routing 失败客户端流量明显损失"的 SLA 压 力时, engine RFC 启动. 那时本 ADR § 2.4 三层 pin 分析是起点. ### 5.3 Redis 后端 **否决原因**: 元数据 payload 太薄 (id + created_at = ~50 字节), Redis TTL / 内存优势对此 payload 不显著. 与 staging / verdict-audit 共享 Postgres 池更经济 (rule of two: 没有第二个真消费者驱动 Redis). **保留触发**: 真有热路径 SessionStore.Get 高频调用 (例如 admin UI 实 时活跃会话面板 ≥ 1k QPS) 时再引. ### 5.4 sqlite 测试 mock **否决原因**: PM 直接拒绝 -- "整个 platform 都 Postgres, 测试就该真 Postgres". sqlite SQL 方言与 Postgres 差别 (jsonb / FOR UPDATE / ON CONFLICT) 大, mock 会掩盖真 bug. **保留触发**: 不重启. shadowdb 子包用 modernc.org/sqlite 是测试 SQL 工 具链不需 docker 的特殊场景, 不打开"测试用 sqlite 替代 pg"的口子. ### 5.5 staging.Store Postgres 后端 (原 C3, PM 跳过) **否决原因**: staging 子包当前 platform/common 没 wire (cmd/common 不装), 没真消费者. 给没消费者的子系统加 Postgres 是 dead code. PM 接受 YAGNI 论点跳过. **保留触发**: staging 真接入 (验证器 + ML + 审批工作流被 logistics 或 其他行业 platform 实际跑) 时, 同模式 (db.Pool + sessions 共享池 + testcontainers 测) 加 staging.Store Postgres 后端 + staging 表 schema 追加到 db.platformMigrations. 工作量约 ~400-500 行 (7 状态机方法每个一 个 SELECT-then-UPDATE 事务). --- ## 6. 触发重新评估的条件 满足以下任一条时本 ADR 应被新 ADR 替代或修订: 1. **多副本生产部署上线**: HK-133 从 docker-compose 单副本升 k8s 多副 本, 真观测到 sticky routing 行为. 需要回头校准 phase 1 ip_hash 是否 够, 是否升 phase 2 X-Session-ID header. 2. **NAT 聚集投诉**: 企业 NAT 客户报"全公司只能在一台副本登录" 时, phase 2 升级触发, 写 ADR-00xx 记录 X-Session-ID header 协议. 3. **跨副本 live session 失败转移成需求**: 客户要求"任意副本失败 30s 内自动接管 in-flight 推理" 时, engine PermissionBus + MarshalState RFC 启动. 那时本 ADR § 2.4 是起点, § 5.2 是详细方案. 4. **Postgres HA 课题**: v1.0 SLA 提到"会话 metadata 不可丢" 时, 单点 pg 升 patroni / Aurora / 类似 HA, 写部署 ADR. 5. **engine.SnapshotStore 接线**: cmd/common 装 SnapshotStore 让 cache miss 自动恢复历史, 修改本 ADR § 2.6 fallback 行为. --- ## 7. 参考链接 ### 业界 SessionStore prior art - [LangGraph BaseCheckpointSaver](https://docs.langchain.com/oss/python/langgraph/persistence) - [langgraph-redis 0.1.0 设计变更](https://redis.io/blog/langgraph-redis-checkpoint-010/) - [Express session.Store](https://expressjs.com/en/resources/middleware/session.html) - [Django SessionBase](https://github.com/django/django/blob/main/django/contrib/sessions/backends/base.py) - [ASP.NET IDistributedCache](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/distributed) - [Vercel resumable-stream](https://github.com/vercel/resumable-stream) ### 跨副本桥三模式 - [Upstash Resumable LLM Streams](https://upstash.com/blog/resumable-llm-streams) - [Redis HITL for AI Agents](https://redis.io/blog/ai-human-in-the-loop/) - [Temporal HITL Tutorial](https://learn.temporal.io/tutorials/ai/building-durable-ai-applications/human-in-the-loop/) - [Caddy lb_policy directive](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#lb_policy) - [k8s Service.sessionAffinity](https://kubernetes.io/docs/concepts/services-networking/service/#session-affinity) ### 项目内部参考 - ADR-0001 反向思维 gate (评估流程对照) - ADR-0002 业务 = REST/SSE; gRPC = 观测面 (REST 通道立项, sessions map 限制点名) - core/pkg/engine/session_snapshot.go (SnapshotStore 接口, 未来 cache miss 恢复路径) - core/pkg/staging/store.go (meta-pattern 借鉴, verbs 不借鉴) - memory `feedback_dont_extrapolate_offhand_remarks` (PM "Postgres 肯 定用" ≠ 自动给所有子系统加 Postgres) - memory `feedback_pm_abstraction_means_replaceability` (产品可替换性 视角看接口) --- ## 8. 修订记录 | 日期 | 版本 | 变更 | |---|---|---| | 2026-04-26 | 1.0 | 初版, L693 C3 commit (`96e893a`) 落地 |