PostgreSQL 控制面持久化与启动装配¶
本页是 Issue #37 的实现契约。它复用已经合入的 Tenant、Agent App/Revision、Backend Profile 和 Channel Binding 设计,并记录 Model Profile 的持久化形状、统一 migration 顺序、 Repository 事务边界和进程启动装配。控制面 DDL 与受控 Repository 写入口分别从
0001、0002开始,migrations.Apply执行完整的嵌入式序列至0017_agent_chain.up.sql; Go Repository 和 bootstrap 实现在trpcservice/{tenant,app,model,backend,channels}/postgres;每个领域包拥有自己的 SQL Repository、行解码和领域 codec。trpcservice/storage/postgres只提供不依赖任何 控制面领域的连接池、事务、错误映射与 JSON 基础设施;trpcservice/bootstrap负责装配。
目标与边界¶
Issue #37 的目标是把当前的 InMemory 控制面推进为一条可持久化、可恢复、可测试的纵向链路:
PostgreSQL migration / roles
│
▼
Tenant + App/Revision + Model + Backend + Binding SQL Repositories
│
▼
explicit BootstrapConfig
│
├── Provider Catalogs
├── SecretResolver / ModelFactory
├── Session capability
├── PlanResolver
├── RunnerRegistry
└── HTTP Gateway + readiness
本 Issue 持久化当前控制面纵向链路的六类对象:tenant、agent_app、
agent_app_revision、model_profile、backend_profile 和 channel_binding。当前
gateway.PlanResolver 的直接 Repository 输入仍只有 Tenant、Agent App、Model Profile 和
Backend Profile 四类,外加两个 Provider Catalog;它不读取 Channel Binding。Channel Binding
由 Channel Adapter/Candidate Index 完成候选发现和可信路由,bootstrap 必须把它作为独立的
Repository/候选索引依赖装配,只有形成可信 Principal 后才进入 Dispatcher → PlanResolver。
Session、Event、Memory、Summary、Knowledge、Artifact、Redis、S3-compatible object、Vault、
Admin API 和 Outbox 消费均通过对应 runtime 与 bootstrap 模块接入;本页记录它们与 PostgreSQL
控制面的事务和启动边界。
既有表设计的复用关系¶
前置设计已经定义了领域字段和安全不变量;Issue #37 的 migration 不重新发明这些模型, 而是把它们整理成一套有明确依赖顺序的 PostgreSQL 变更:
| 对象 | 既有规范 | 本 Issue 的落地责任 |
|---|---|---|
tenant |
数据模型 的根表、生命周期函数、Outbox 和角色边界 | 建表、状态/配置受控函数和默认引用外键 |
agent_app / agent_app_revision |
Agent App 模型 的发布、回滚和不可变 Revision | 建表、复合外键、发布/回滚事务和 Outbox |
model_profile |
本页补齐的目标形状;领域契约见 Model Profile | 建表、无密钥配置列、生命周期和 Outbox |
backend_profile |
Backend Profile 模型 的 binding、延迟约束和状态函数 | 建表、复合外键、binding 完整性和 Outbox |
channel_binding |
Channel Binding 的候选索引和 active account 约束 | 建表、同租户 App 外键和候选查询索引 |
所有关联对象都显式携带 tenant_id。单列 tenant_id → tenant(tenant_id) 只表达根租户
存在;对象之间的引用必须使用 (tenant_id, object_id) 复合外键,不能让一个租户的 App、
Profile 或 Binding 被另一个租户引用。key 的唯一性也都限定在租户范围内,只有公开候选路由
和 active provider account 按领域规范使用全局索引。
Migration 组织与执行前提¶
迁移由 migrations 包按文件名和版本顺序执行,并通过历史摘要、连续版本和数据库锁保证可恢复;
控制面与运行时 migration 使用同一套启动装配入口。当前嵌入式有序 migration 序列包括:
0001_control_plane.up.sql
0002_control_plane_repository_functions.up.sql
0003_runtime_storage.up.sql
0004_runtime_session_delete_cascade.up.sql
0005_runtime_event_history.up.sql
0006_audit_usage.up.sql
0007_execution_audit_handoff.up.sql
0008_runtime_reply_target.up.sql
0009_runtime_reply_correlation.up.sql
0010_agent_app_canary.up.sql
0011_reply_trace_parent.up.sql
0012_runtime_capabilities.up.sql
0013_execution_queue.up.sql
0014_wecom_aibot_channel.up.sql
0015_runtime_attachments.up.sql
0016_runtime_reply_media.up.sql
0017_agent_chain.up.sql
执行约定如下:
- 在干净 PostgreSQL 实例上使用一个事务执行完整文件;失败时整个 schema 和权限变更回滚。
- 迁移开始固定
search_path为pg_catalog, public, pg_temp,所有函数体对业务表使用public.限定名;不依赖连接池或客户端会话的隐式 search path。 - 在干净实例中 migration 会创建缺失的
NOLOGIN受控角色;生产部署也可以在执行前预置 数据库 owner、migration owner、tenant_admin_writer和tenant_app_writer。这些角色不 属于普通请求连接池,migration 不把 owner 权限继承给运行时角色。 0002为 Tenant、Model、Backend、Agent App/Revision、Channel Binding 以及 Outbox 写入提供完整的SECURITY DEFINERentry point;函数先撤销PUBLIC的默认EXECUTE, 再只授予tenant_admin_writer。Worker 只能消费控制平面下发的固定快照,不能枚举根表 或草稿。- SQL 文件不包含 token、API key、DSN、密码、运行时客户端或测试 Secret。
secret_ref是 唯一允许进入控制面配置的凭据引用,且只按租户作用域解释。
Migration 的对象顺序为:
tenant
├── agent_app
│ ├── agent_app_revision
│ │ └── agent_app_revision_tool
│ ├── model_profile
│ ├── backend_profile
│ │ └── backend_profile_binding
│ └── channel_binding
└── tenant_status_change_outbox / profile change outboxes
创建对象时可以先建立不带默认指针的根表,再按上述顺序追加复合外键;默认 App、默认
Backend Profile 必须在最后通过同租户复合外键绑定。Backend Profile 的“至少一个 binding”
和 active 必须有 session binding 是延迟约束,发布/配置替换必须在一个事务内完成,不能
分别提交根行和 binding 集合。
Model Profile 持久化形状¶
Model Profile 的领域代码已经限制 provider、model、endpoint、option 和 generation 参数;
PostgreSQL 只需要保存规范化后的无密钥结果。目标根表如下,options 和 generation 不是
开放 JSON 扩展口,Repository 写入前必须用受信 Provider Catalog 重新校验:
CREATE TABLE model_profile (
tenant_id TEXT NOT NULL REFERENCES tenant(tenant_id),
profile_id TEXT NOT NULL
CHECK (profile_id ~ '^mp_[0-7][0-9A-HJKMNP-TV-Z]{25}$'),
profile_key TEXT NOT NULL
CHECK (profile_key ~ '^[a-z][a-z0-9-]{1,63}$'),
display_name TEXT NOT NULL
CHECK (length(btrim(display_name)) BETWEEN 1 AND 200),
description TEXT NOT NULL DEFAULT ''
CHECK (length(description) <= 2000),
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'disabled')),
schema_version INT NOT NULL DEFAULT 1 CHECK (schema_version = 1),
provider TEXT NOT NULL,
model TEXT NOT NULL,
endpoint TEXT NOT NULL DEFAULT '' CHECK (length(endpoint) <= 2048),
options JSONB NOT NULL DEFAULT '{}'::jsonb
CHECK (jsonb_typeof(options) = 'object'),
secret_ref TEXT NOT NULL DEFAULT '' CHECK (length(secret_ref) <= 256),
generation JSONB NOT NULL DEFAULT '{}'::jsonb
CHECK (jsonb_typeof(generation) = 'object'),
content_digest TEXT NOT NULL CHECK (content_digest ~ '^[0-9a-f]{64}$'),
version BIGINT NOT NULL DEFAULT 1 CHECK (version >= 1),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, profile_id),
UNIQUE (tenant_id, profile_key)
);
options 的 value 必须全部是字符串,generation 只能包含已支持的字段;SQL 约束无法
替代 Provider Catalog,所以两个 JSONB 列只能由受控 Repository/函数写入。Profile identity
不可变,完整配置替换递增一次 version、更新 updated_at、重新计算摘要,并和
model_profile_change_outbox 在同一事务提交。disabled 是终态;active/suspended 的状态
迁移使用固定 search_path 的受控函数。
Repository 事务契约¶
SQL Repository 是现有 InMemory Repository 的持久化实现,不向 Gateway 暴露 SQL 错误或底层
provider 细节。每个方法都必须把 context.Context 传给 QueryContext、ExecContext、
BeginTx 或等价的 pgx 方法;取消后不再等待连接、锁或查询结果。
| 领域操作 | 事务和锁边界 | 成功结果 |
|---|---|---|
| Tenant 配置/状态 | 先锁 Tenant;校验 expected version、状态迁移和默认对象同租户归属 | 新 Tenant 快照与状态 Outbox 原子提交 |
| App metadata/draft | 锁 (tenant_id, app_id);草稿另外校验 draft_version |
防御性 App/Revision 副本 |
| App publish/rollback | App → Revision → 同租户引用按固定顺序加锁;发布冻结 Revision 并移动 current pointer | 新 App、Revision 和 ChangeEvent 原子可见 |
| Model/Backend/Binding 配置 | 锁租户作用域的根行;完整替换只提交一次 version 增量 | 规范化对象和 ChangeEvent |
| 生命周期迁移 | 锁根行后先分类 disabled/conflict,再检查允许的状态迁移 | 状态变更与 Outbox 同一事务 |
| 候选查询 | 只用 (channel, public_route_key_digest) active 索引 |
不含 Tenant/App/Secret 的候选上下文 |
实现共同遵守:
- 每个查询显式带租户条件;任何按 key 的查询都不能省略
tenant_id。 - 跨对象引用使用复合外键和事务内状态检查,不能靠 key prefix 或调用方传入的 boolean。
sql.ErrNoRows和唯一冲突映射到领域的ErrNotFound、ErrDuplicateKey、ErrConflict等 可识别错误;其他数据库错误只返回稳定的存储错误类别。- 扫描出的 map、slice、pointer、JSON 和时间值在返回前复制;调用方修改返回值不能改变缓存 或 Repository 内部状态。
- 发布、回滚、状态迁移和相关审计/Outbox 不能“先提交主表、再尽力写事件”。
- 错误、日志、trace、ExecutionPlan、Factory cache key 和运行时客户端都不得包含 Secret 值。
Bootstrap、readiness 与 shutdown¶
启动装配使用显式 BootstrapConfig,配置来自进程配置文件、环境变量解析结果或测试构造
器;请求 body、header、Message 或未验证的 route 不能决定 Tenant/App/Profile。配置对象至少
包含:
- PostgreSQL DB/Pool 和连接关闭责任;
- Tenant、App、Model、Backend、Channel Binding 的 SQL Repositories;
- Model/Backend Provider Catalog;
- SecretResolver、ModelFactory 和 Session capability;
- API Authenticator、PlanResolver、RunnerRegistry、Dispatcher 和 HTTP 参数;
- readiness 所需的数据库 ping/迁移版本检查和可选的资源关闭超时。
当前 cmd/trpc-service 的生产入口由 bootstrap.NewFromEnvironment 装配真实图,而不是
启动一个永久不可用的空图。它要求 TRPC_POSTGRES_DSN、TRPC_API_TOKEN、
TRPC_TENANT_ID、TRPC_APP_ID 和 TRPC_MODEL_API_KEY;可选的
TRPC_MODEL_PROVIDER、TRPC_MODEL_NAMES、TRPC_MODEL_ENDPOINT_HOSTS、
TRPC_MODEL_SECRET_REF 用于建立受信 Model Catalog 和 SecretRef 映射。缺少必需配置时
进程在绑定 HTTP 端口前失败;NewUnavailable 只用于无外部依赖的测试装配。Session capability
根据显式配置使用 PostgreSQL、Redis 或 InMemory,并在 readiness 中报告实际后端。
装配顺序固定为:
validate explicit config
→ verify database + required catalog/capability
→ construct SQL repositories
→ construct PlanResolver
→ construct agent/runnerfactory.NewRuntimeRunnerRegistry
→ construct Dispatcher
→ construct HTTPHandler with real Dispatcher/Authenticator
→ expose readiness
/healthz 只表示进程存活;/readyz 必须在 DB、五类 Repository、Catalog、Secret/Model/Session
capability、Resolver、Registry、Dispatcher 和 Handler 都可用前返回 503。依赖失效或开始关闭
时 readiness 立即降为 false,不能让空的 HTTPConfig 误报 200。Bootstrap 测试使用 fake
SecretResolver、fake ModelFactory 和 InMemory Session,不需要真实模型供应商 Secret。
关闭顺序保持当前 Gateway 语义:先把 Handler 置为 draining,再调用 http.Server.Shutdown
等待 HTTP 摘流,然后 bounded close RunnerRegistry,最后关闭 Bootstrap 自己拥有的 DB/Pool
和其他资源。借用的 Session、Resolver、Factory 由其声明的 owner 关闭;超时返回稳定的关闭
错误并交给上层处理,不能无限等待 Runner lease 或数据库连接。
验证矩阵与交付证据¶
本页契约对应的验证入口如下;它们是 Issue #37 的纵向实现证据:
migrations/migration_test.go:干净 PostgreSQL migration、权限、跨租户 FK、published current pointer、延迟 binding 和嵌套凭据键检查;trpcservice/internal/testsupport/postgres/integration_test.go:五类 SQL Repository 的租户作用域、 生命周期、发布、候选消费、Outbox、Context 取消和深拷贝路径;CI 使用独立 PostgreSQL 服务执行;scripts/coverage.sh:使用单次原生 Go-coverpkgprofile 执行各包单测及上述跨领域集成测试, 让 Codecov 与本地go tool cover一致地统计五个 Repository 的跨包执行路径,且不会重复初始化 CI PostgreSQL schema;trpcservice/bootstrap/bootstrap_test.go:真实 Resolver/Registry/HTTPHandler 组装、 readiness 503→200 和 shutdown gate;- 生产代码中的
codec.go、受控 SQL 函数和稳定错误映射保证 Secret 不进入运行时对象或底层 数据库错误。
控制面迁移、Repository、bootstrap、readiness、Admin API、runtime storage、Outbox、Secret Resolver、Channel candidate routing 和重启恢复共同形成可运行的 PostgreSQL 纵向链路;其 验证由本页列出的单测、live smoke、fault-injection E2E 和部署 golden path 覆盖。