Skip to content

数据库 Schema 参考

本文是控制面数据库 schema 的单一事实源——表、字段、关系、写入路径、迁移。

生产数据库是 PostgreSQL(主控制面经 Hyperdrive 访问),由两个控制面实现共享同一份 schema:

  • 主控制面(TS 面) apps/control-server-ts:生产 schema 的权威定义在 apps/control-server-ts/src/schema.ts(drizzle),迁移由 drizzle-kit 管理(见文末 TS 面 schema(drizzle))。
  • 温备控制面(Python 面) apps/control-serverapps/control-server/app/db/models/ 下的 SQLAlchemy ORM 与 drizzle schema 保持同构镜像(温备 + 本地开发用)。

两面新增表 / 列必须同步落到 drizzle schema 与 Python ORM。列名 / 类型 / 语义两面一致, 本文各表章节按 Python ORM 源码逐列描述。

Python 面所有表共享同一个 SQLAlchemy 声明性基类 Baseapps/control-server/app/db/base.py:20)。Base.metadata 注入了统一的命名约定 (NAMING_CONVENTIONbase.py:11),保证索引 / 外键 / 唯一键 / 主键名跨数据库稳定一致:

类型模板
索引 ixix_%(table_name)s_%(column_0_N_name)s
唯一键 uquq_%(table_name)s_%(column_0_N_name)s
检查 ckck_%(table_name)s_%(constraint_name)s
外键 fkfk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s
主键 pkpk_%(table_name)s

引擎装配在 apps/control-server/app/db/engine.pyDatabase 封装一个异步 AsyncEngine + async_sessionmakerexpire_on_commit=Falsepool_pre_ping=True)。 对 SQLite 有两处特殊处理:

  • check_same_thread=Falseengine.py:28)——异步多任务下放开 same-thread 限制。
  • 每个新连接执行 PRAGMA foreign_keys=ONengine.py:43)——SQLite 默认外键关闭,不开 则所有 ondelete 子句(如 Peering.remote_node_id / EnrollmentToken.node_idSET NULLAgentToken.node_idCASCADE)变成死字。

时间戳列统一用 DateTime(timezone=True),Python 侧默认 datetime.now(timezone.utc), DB 侧 server_default=func.now()updated_at 列额外带 onupdate


聚合根与模型概览

数据模型有三个核心组织点:

  • Node 是节点的聚合根。 一行 nodes 持有该节点的身份字段(node_id 主键、asnrouter_id、loopback、前缀)、base_template(DesiredState 中不来自子表的部分)以及 current_generation(已发布的最新世代号)。它的子表(agent_tokenspeeringswg_interfacesbgp_sessionsgenerations)全部 cascade="all, delete-orphan", 删节点连带删子行。WG 节点身份(公钥 + 私钥托管密文)也是节点级事实,落在 nodes 上。
  • Peering 是网络对等关系的聚合根。 一条 peering 对应 0..1 条 WgInterface + 0..N 条 BgpSession(均 cascade="all, delete-orphan")。local_node_id 指本端节点 (CASCADE),remote_node_id 可空、指向另一受管节点(SET NULL)——materializer 据此 现取现填对端 WG 公钥并区分内外部互联(见「Materialize 写入路径」)。
  • DnsGroup 是共享模型,节点通过 Node.dns_group_id 订阅。 DNS 不再按节点保存;多个 节点指向同一组即拿到同一份配置 = anycast / 任播。组被删时 Node.dns_group_id 置 NULL (节点停跑 DNS,不连带删节点)。

关系一览(→ 表示外键指向;括号内为 ondelete):

nodes (聚合根)
 ├─ agent_tokens.node_id            → nodes.node_id   (CASCADE)
 ├─ enrollment_tokens.node_id       → nodes.node_id   (SET NULL)
 ├─ generations.node_id             → nodes.node_id   (CASCADE)   UNIQUE(node_id, generation)
 ├─ peerings.local_node_id          → nodes.node_id   (CASCADE)   UNIQUE(local_node_id, name)
 ├─ peerings.remote_node_id         → nodes.node_id   (SET NULL)
 ├─ wg_interfaces.node_id           → nodes.node_id   (CASCADE)   UNIQUE(node_id, name)
 │   └─ wg_interfaces.peering_id    → peerings.id     (SET NULL)
 ├─ bgp_sessions.node_id            → nodes.node_id   (CASCADE)   UNIQUE(node_id, name)
 │   └─ bgp_sessions.peering_id     → peerings.id     (SET NULL)
 ├─ node_status.node_id             → nodes.node_id   (CASCADE)   (PK)
 ├─ node_status_events.node_id      → nodes.node_id   (CASCADE)
 ├─ node_routing.node_id            → nodes.node_id   (CASCADE)   (PK)
 ├─ node_route_entries.node_id      → nodes.node_id   (CASCADE)
 ├─ node_route_prefix_hashes.node_id→ nodes.node_id   (CASCADE)   (PK)
 ├─ node_routing_events.node_id     → nodes.node_id   (CASCADE)
 ├─ node_traffic_rollup.node_id     → nodes.node_id   (CASCADE)   (PK 复合)
 ├─ node_traffic_last_sample.node_id→ nodes.node_id   (CASCADE)   (PK)
 ├─ node_iface_traffic_rollup.node_id → nodes.node_id (CASCADE)   (PK 复合)
 ├─ node_iface_traffic_last.node_id → nodes.node_id   (CASCADE)   (PK 复合)
 ├─ node_metrics_rollup.node_id     → nodes.node_id   (CASCADE)   (PK 复合)
 ├─ node_metrics_last.node_id       → nodes.node_id   (CASCADE)   (PK)
 ├─ bgp_flap_state.node_id          → nodes.node_id   (CASCADE)   (PK 复合)
 ├─ bgp_flap_transitions.node_id    → nodes.node_id   (CASCADE)
 ├─ node_prefix_flaps.node_id       → nodes.node_id   (CASCADE)   (PK 复合)
 ├─ node_prefix_flap_meta.node_id   → nodes.node_id   (CASCADE)   (PK)
 ├─ node_prefix_flap_paths.node_id  → nodes.node_id   (CASCADE)   (PK 复合)
 ├─ prefix_flap_rate_rollup.node_id → nodes.node_id   (CASCADE)   (PK 复合)
 └─ nodes.dns_group_id              → dns_groups.id   (SET NULL)

dns_groups (共享聚合根)
 └─ dns_group_zones.dns_group_id    → dns_groups.id   (CASCADE)   UNIQUE(dns_group_id, zone)
     └─ dns_records.dns_group_zone_id → dns_group_zones.id (CASCADE)

admin_users
 └─ admin_sessions.user_id          → admin_users.id  (CASCADE)

flap_alerts   (独立;node_id 有意不设外键,告警不随节点删除蒸发)
flap_activity_rollup   (独立,无外键;fleet 级 60s 活跃度时间线)
admin_audit_log   (独立,无外键)
registry_asn / registry_meta   (独立,无外键;DN42 registry 只读副本)

WgInterface / BgpSession 采用「索引列 + spec JSON」双层结构:少量字段(name / node_id / peering_id / enabled / kind / remote_asn)做查询 / 约束,完整的 Pydantic schema dump 放 spec 列,后端不必随 schema 演进改表(peering.py:6)。索引列由 apply_spec() 从校验过的 spec 派生投影,杜绝列与 JSON 漂移。


节点与身份

nodes

节点聚合根。apps/control-server/app/db/models/node.py:31

类型约束/默认说明
node_idString(64)PK节点稳定字符串 ID(如 edge1)。
siteString(32)可空站点标识。
asnIntegerNOT NULL节点 ASN。
router_idString(64)NOT NULLBGP router-id。
loopback_ipv4String(64)可空身份 loopback v4。
loopback_ipv6String(64)可空身份 loopback v6。
link_localString(64)可空节点级 IPv6 link-local(fe80::/10,不带 %zone)。单一真相源:materializer 把 <link_local>/64 派生到所有外部 eBGP WG 接口的 addresses(见 desired-state.md)。迁移 a4b5c6d7e8f9 引入。
ipv4_prefixesJSONNOT NULL,默认 []宣告的 v4 前缀列表。
ipv6_prefixesJSONNOT NULL,默认 []宣告的 v6 前缀列表。
inventoryJSONNOT NULL,默认 {}agent 注册带来的可读元信息。
labelsJSONNOT NULL,默认 {}标签。
base_templateJSONNOT NULL,默认 {}DesiredState 去掉 generation/interfaces/bgp_sessions/dns 后的部分(runtime / bird / templates / schema_version 等)。
current_generationIntegerNOT NULL,默认 0已发布最新世代号;0 = 尚未发布。
lifecycleString(16)NOT NULL,默认 active,server_default activeactive / decommissioned。退役态 materialize 产空 interfaces/bgp/dns。
dns_group_idIntegerFK→dns_groups.id(SET NULL),可空,index订阅的共享 DNS 组;NULL = 不部署 DNS。
wireguard_public_keyString(64)可空节点 WG 公钥(agent 上报,注册一致性校验的权威事实,传播进对端 peer 配置)。
wireguard_private_key_escrowText可空节点 WG 私钥经恢复公钥 RSA-OAEP 封装的密文;控制面只存不解。
created_at / updated_atDateTime(tz)server_default now,updated_at 带 onupdate时间戳。

关系:agent_tokens / peeringsforeign_keys=Peering.local_node_id)/ wg_interfaces / bgp_sessions / generationscascade="all, delete-orphan"dns_group 为 joined 只读。

agent_tokens

长期 Bearer token,绑定具体节点。node.py:154

类型约束/默认说明
tokenString(128)PK非机密查找键 id(形如 agt_xxxx),明文 secret 永不落库。
token_hashString(128)NOT NULL,unique,index完整 Bearer 的 sha256;校验只走它。
node_idString(64)FK→nodes.node_id(CASCADE),NOT NULL,index绑定节点。
agent_idString(128)NOT NULLagent 标识。
issued_atDateTime(tz)server_default now签发时刻。
expires_atDateTime(tz)可空非空即会过期。
revoked_atDateTime(tz)可空吊销时刻。

enrollment_tokens

一次性注册 token。node.py:129。与 AgentToken 同安全模型。

类型约束/默认说明
tokenString(128)PK非机密查找键 id(ent_*)。
token_hashString(128)NOT NULL,unique,indexsha256;校验只走它。
node_idString(64)FK→nodes.node_id(SET NULL),可空空 = 尚未指定节点(管理员预生成)。
descriptionString(256)可空备注。
expires_atDateTime(tz)可空过期时刻。
used_atDateTime(tz)可空非空 = 已消费(一次性语义)。
created_atDateTime(tz)server_default now创建时刻。

pending_registrations

待审批的 agent 注册请求。node.py:179

类型约束/默认说明
idIntegerPK,autoincrement主键。
requested_node_idString(64)NOT NULL,index请求的节点 ID(重复注册刷新同一行)。
hostnameString(255)可空主机名。
inventoryJSONNOT NULL,默认 {}上报的硬件 / 系统信息。
statusString(16)NOT NULL,默认 pending,indexpending / approved / rejected
noteString(256)可空审批备注。
created_at / updated_atDateTime(tz)server_default now,updated_at 带 onupdate时间戳。

部分唯一索引(迁移 b5c6d7e8f9a0):UNIQUE(requested_node_id) WHERE status='pending'——同一节点同时只允许一条 pending 记录(重复注册刷新同一行),历史 approved/rejected 行不受限。


期望状态发布

generations

每节点的已发布 DesiredState 世代快照。apps/control-server/app/db/models/generation.py:28

类型约束/默认说明
idIntegerPK,autoincrement主键。
node_idString(64)FK→nodes.node_id(CASCADE),NOT NULL,index所属节点。
generationIntegerNOT NULL世代号,节点内严格单调递增。
snapshotJSONNOT NULL控制面渲染好的完整 DesiredState JSON,agent 直接读。
reasonString(256)可空发布原因备注。
published_atDateTime(tz)server_default now发布时刻。

唯一约束 UNIQUE(node_id, generation)uq_generations_node_id_generation)。当前世代由 Node.current_generation 指向。


网络配置

peerings

对等关系聚合根。apps/control-server/app/db/models/peering.py:38

类型约束/默认说明
idIntegerPK,autoincrement主键。
local_node_idString(64)FK→nodes.node_id(CASCADE),NOT NULL,index本端节点。
remote_node_idString(64)FK→nodes.node_id(SET NULL),可空,index对端受管节点(外部对等留空);materializer 据此取对端 WG 公钥。
nameString(64)NOT NULLpeering 名。
remote_asnIntegerNOT NULL对端 ASN。
remote_labelString(128)可空对端标签。
is_internalBooleanNOT NULL,默认 False内部互联(iBGP/OSPF)标志;影响 LLA 注入。
enabledBooleanNOT NULL,默认 True启用标志。
notesString(512)可空备注。
created_at / updated_atDateTime(tz)server_default now,updated_at 带 onupdate时间戳。

唯一约束 UNIQUE(local_node_id, name)uq_peerings_local_node_id_name)。关系: wg_interfaces / bgp_sessionscascade="all, delete-orphan"(selectin);local_noderemote_node 用各自的 foreign_keys 消歧。

wg_interfaces

节点接口资源(WireGuard / GRE / dummy 等)。peering.py:91

类型约束/默认说明
idIntegerPK,autoincrement主键。
node_idString(64)FK→nodes.node_id(CASCADE),NOT NULL,index所属节点。
peering_idIntegerFK→peerings.id(SET NULL),可空,index空 = 纯节点级接口(dummy lo、IGP 隧道等)。
nameString(64)NOT NULL接口名(apply_spec 从 spec 派生)。
kindString(32)NOT NULL接口种类(apply_specspec.kind 派生)。
enabledBooleanNOT NULL,默认 True控制面列;disabled 接口不进 snapshot。
specJSONNOT NULL完整 InterfaceSpec 的 Pydantic dump。
sort_orderIntegerNOT NULL,默认 0排序键(materializer 按 sort_order, id 取)。

唯一约束 UNIQUE(node_id, name)uq_wg_interfaces_node_id_name)。apply_spec(InterfaceSpec)peering.py:119)投影 name / kind / specenabled 由调用方单独维护。

bgp_sessions

节点上的一条 BGP 会话。peering.py:132

类型约束/默认说明
idIntegerPK,autoincrement主键。
node_idString(64)FK→nodes.node_id(CASCADE),NOT NULL,index所属节点。
peering_idIntegerFK→peerings.id(SET NULL),可空,index关联 peering。
nameString(64)NOT NULL会话名(从 spec 派生)。
remote_asnIntegerNOT NULL对端 ASN(从 spec 派生)。
enabledBooleanNOT NULL,默认 True从 spec 派生;disabled 会话仍进 snapshot 但 enabled=False
specJSONNOT NULL完整 BgpSessionSpec 的 Pydantic dump。
sort_orderIntegerNOT NULL,默认 0排序键。

唯一约束 UNIQUE(node_id, name)uq_bgp_sessions_node_id_name)。apply_spec(BgpSessionSpec)peering.py:156)投影 name / remote_asn / enabled / spec;materializer 读取时 _bgp_payload 把列 enabled 投影回 spec。


共享 DNS

记录为中心的共享 DNS 三级模型(apps/control-server/app/db/models/dns.py)。节点经 Node.dns_group_id 订阅;materializer 把 组→zone→记录 组装成 DnsSpec

dns_groups

共享 DNS 组:一份可被多节点订阅的配置。dns.py:36

类型约束/默认说明
idIntegerPK,autoincrement主键。
nameString(255)NOT NULL,unique组名。
bind_addressesJSONNOT NULL,默认 []提供 DNS 服务的 IP(anycast 监听地址)。
cache_ttl_secondsIntegerNOT NULL,默认 300缓存 TTL。
forwardsJSONNOT NULL,默认 []转发(递归 resolver)配置;纯权威留空。
enabledBooleanNOT NULL,默认 True禁用则订阅节点不部署 DNS。
created_at / updated_atDateTime(tz)server_default now,updated_at 带 onupdate时间戳。

关系:zones cascade="all, delete-orphan"

dns_group_zones

组声明的权威 zone + 可选 SOA 覆盖。dns.py:67

类型约束/默认说明
idIntegerPK,autoincrement主键。
dns_group_idIntegerFK→dns_groups.id(CASCADE),NOT NULL,index所属组。
zoneString(255)NOT NULL权威 zone(正向 example.dn42 / 反向 20.172.in-addr.arpa)。
primary_nsString(255)可空SOA 主 NS(留空自动 ns.<zone>)。
admin_emailString(255)可空SOA 管理邮箱(留空自动 hostmaster.<zone>)。
soa_refreshInteger可空SOA refresh(留空默认)。
soa_retryInteger可空SOA retry。
soa_expireInteger可空SOA expire。
soa_minimumInteger可空SOA minimum。
default_ttlInteger可空默认 TTL。
enabledBooleanNOT NULL,默认 True禁用 zone 不输出。
created_at / updated_atDateTime(tz)server_default now,updated_at 带 onupdate时间戳。

唯一约束 UNIQUE(dns_group_id, zone)uq_dns_group_zones_group_id_zone)。关系:recordscascade="all, delete-orphan"

dns_records

扁平 DNS 资源记录。dns.py:114

类型约束/默认说明
idIntegerPK,autoincrement主键。
dns_group_zone_idIntegerFK→dns_group_zones.id(CASCADE),NOT NULL,index所属 zone。
nameString(255)NOT NULLzone 内主机名(相对 / @ / FQDN)。
typeString(16)NOT NULL记录类型(rDNS 即反向 zone 下 PTR)。
contentTextNOT NULL记录值。
ttlInteger可空记录 TTL。
commentText可空仅供 WebUI,不进 zone 文件。
enabledBooleanNOT NULL,默认 Truedisabled 记录不输出。
sort_orderIntegerNOT NULL,默认 0排序键。
created_at / updated_atDateTime(tz)server_default now,updated_at 带 onupdate时间戳。

运行状态

node_status

每节点最新运行时健康,随上报 upsert。apps/control-server/app/db/models/node_status.py:30

类型约束/默认说明
node_idString(64)PK,FK→nodes.node_id(CASCADE)主键即外键。
desired_generationInteger可空控制面已发布的期望世代。
observed_generationInteger可空agent 实际观测到的世代。
last_report_statusString(32)可空最近对账状态(succeeded/degraded/failed/skipped)。
last_apply_statusString(32)可空最近 apply 状态。
drift_countIntegerNOT NULL,默认 0漂移计数。
healthString(16)NOT NULL,默认 unknown派生健康。库里只落 4 态ok/degraded/stale/unknown;第五态 down 是读侧按时间阈值叠加的覆盖(不改库,见 internals/control-server.md)。
last_snapshot / last_report / last_applyJSON可空最近一次完整 payload(面板下钻)。
bgp_summaryJSON可空快照摄入时预提取的 payload.bgp_protocols 原样副本(P2.7,与 TS 面同构)——fleet 聚合读侧不再解析 last_snapshot 大列。node_status.py:60,TS 面 schema.ts:260
peer_ratesJSON可空与上一份快照差分得出的 per-peer 速率(compute_peer_rates,两面同算法同形态;P2.7 预提取列)。node_status.py:61,TS 面 schema.ts:261
last_snapshot_at / last_report_at / last_apply_atDateTime(tz)可空对应时刻。
updated_atDateTime(tz)server_default now,带 onupdate更新时刻。

node_status_events

append-only 上报历史。node_status.py:75。写入按节点+种类裁剪到最近 _HISTORY_KEEP 条。

类型约束/默认说明
idIntegerPK,autoincrement主键。
node_idString(64)FK→nodes.node_id(CASCADE),NOT NULL,index所属节点。
kindString(16)NOT NULL,indexsnapshot / report / apply
generationInteger可空关联世代。
statusString(32)可空状态。
payloadJSONNOT NULL完整上报 payload。
created_atDateTime(tz)server_default now,index写入时刻。

路由

agent 周期上报的 RoutingTableSnapshot 持久化在这里,与 reconcile 健康分开 (apps/control-server/app/db/models/routing.py)。路由明细的写路径为差分写: per-prefix 哈希基准表(node_route_prefix_hashes)门控,只重写实际变化的前缀 (演进脉络见 ROADMAP)。

node_routing

每节点最新路由全表 + 预聚合,随上报 upsert。routing.py:27

类型约束/默认说明
node_idString(64)PK,FK→nodes.node_id(CASCADE)主键即外键。
observationString(16)NOT NULL,默认 not-observed采集状态(observed/unavailable/not-observed)。
captured_atDateTime(tz)可空采集时刻。
route_countIntegerNOT NULL,默认 0唯一前缀数(按最优路径去重)。
route_count_v4 / route_count_v6IntegerNOT NULL,默认 0分族计数。
rpki_valid / rpki_invalid / rpki_not_foundIntegerNOT NULL,默认 0RPKI 分布计数(rpki_unknown 已被迁移 a3b4c5d6e7f8 删除)。
aggregatesJSON可空预聚合(origins / prefix_lengths / as_path_lengths / peers / routes_hash)。
updated_atDateTime(tz)server_default now,带 onupdate更新时刻。

历史的 routes 单 JSON 列已由迁移 f2a3b4c5d6e7 删除,明细改存 node_route_entries

node_route_entries

逐路由明细,每路由一行。routing.py:61。前缀检索走 SQL WHERE + 索引 + LIMIT;写入 两级门控:全表内容哈希未变整份跳过;变化时对比 node_route_prefix_hashes 基准做差分写 (只 delete+insert 哈希变化 / 消失的前缀,见下)。

类型约束/默认说明
idIntegerPK,autoincrement主键。
node_idString(64)FK→nodes.node_id(CASCADE),NOT NULL所属节点(不单独建 index,由复合索引最左前缀覆盖)。
prefixString(64)NOT NULL路由前缀。
is_v6BooleanNOT NULL,默认 False,server_default 0地址族(按 prefix 是否含冒号预判)。
localBooleanNOT NULL,默认 False,server_default 0本地起源(static/direct/device),供 scope 过滤。
primaryBooleanNOT NULL,默认 False,server_default 0是否最优路径。
origin_asnInteger可空起源 AS。
protocolString(128)可空协议来源。
rpkiString(16)可空RPKI 状态。
next_hopString(128)可空下一跳。
as_path / communities / large_communitiesJSON可空路径与团体属性。

复合索引(均以 node_id 打头):ix_node_route_entries_node_v6 (node_id, is_v6)ix_node_route_entries_node_local (node_id, local)ix_node_route_entries_node_prefix (node_id, prefix)

node_route_prefix_hashes

每节点 per-prefix 规范化哈希基准(差分写门控,P1)。routing.py:98record_snapshot 全表哈希变化时不再整表重写明细,而是对比这份基准:只 delete+insert 哈希变化 / 消失的前缀 (实测每轮实际变化通常几十行,对比整表 ~12.8k 行)。哈希算法与 TS 面逐字节一致 (FNV-1a64 over 定序数组 JSON);跨面不一致的最坏后果 = 一次无害的整表重写(基准缺失分支)。

类型约束/默认说明
node_idString(64)PK,FK→nodes.node_id(CASCADE)主键即外键。
hashesJSONNOT NULL,默认 {}prefix → 规范化哈希 映射(整节点一行一份基准)。
updated_atDateTime(tz)server_default now,带 onupdate更新时刻。

node_routing_events

append-only 路由表时间序列(每次全表快照一条 + churn)。routing.py:121。按节点裁剪到最近 _HISTORY_KEEP 条。

类型约束/默认说明
idIntegerPK,autoincrement主键。
node_idString(64)FK→nodes.node_id(CASCADE),NOT NULL,index所属节点。
captured_atDateTime(tz)可空采集时刻。
route_count / route_count_v4 / route_count_v6IntegerNOT NULL,默认 0表规模计数。
rpki_valid / rpki_invalid / rpki_not_foundIntegerNOT NULL,默认 0RPKI 计数(rpki_unknown 已被迁移 a3b4c5d6e7f8 删除)。
announced / withdrawnIntegerNOT NULL,默认 0相对上次快照的 churn(新增 / 撤销前缀数)。
created_atDateTime(tz)server_default now,index写入时刻。

流量存档

agent 30s 轻量循环上报的 WG 流量采样主要落 Redis 热窗口(2h、30s 分辨率,供实时吞吐视图),Redis 是旁路、可丢;这组小表是它的持久化存档 + 差分 prev 回退apps/control-server/app/db/models/traffic.py),保证 Redis 不可用时 5min 粒度历史与速率差分不断档。

node_traffic_rollup

每节点每 5min 桶的 WG 吞吐速率降采样,随采样 upsert(累加进桶)。迁移 c0d1e2f3a4b5

类型约束/默认说明
node_idString(64)PK(复合),FK→nodes.node_id(CASCADE)所属节点。
bucket_startBigIntegerPK(复合)桶起点(epoch 秒,对齐 5min)。
rx_rate_sum / tx_rate_sumFloatNOT NULL,默认 0.0落进该桶的各次瞬时速率(字节/秒)之和;读时 sum/count 得均值。
sample_countIntegerNOT NULL,默认 0采样次数。
updated_atDateTime(tz)server_default now,带 onupdate更新时刻。

node_traffic_last_sample

每节点最近一次累计采样(差分的持久 prev),随采样 upsert。迁移 d1e2f3a4b5c6

类型约束/默认说明
node_idString(64)PK,FK→nodes.node_id(CASCADE)主键即外键。
captured_atString(64)NOT NULL采样时刻(ISO8601 字符串,与 Redis 热窗口元素同形)。
rx_bytes / tx_bytesBigIntegerNOT NULL,默认 0全 peer 累计收 / 发字节。
updated_atDateTime(tz)server_default now,带 onupdate更新时刻。

node_iface_traffic_rollup / node_iface_traffic_last

按 (节点, WG 接口) 粒度的 5min 速率存档与差分 prev(「按对端 AS 划分流量」的数据层)。数据源是 runtime snapshot 里的 per-peer 累计字节;读侧经 接口 → peering → remote_asn 归并出按 AS 的序列/份额。迁移 f3a4b5c6d7e8

类型约束/默认说明
node_idString(64)PK(复合),FK→nodes.node_id(CASCADE)所属节点。
interfaceString(64)PK(复合)WG 接口名。
bucket_startBigIntegerPK(复合,仅 rollup)桶起点(epoch 秒)。
其余列同上两表(rollup 含 *_rate_sum/sample_count;last 含 captured_at/*_bytes)。

node_metrics_rollup / node_metrics_last

agent 自观测(self_metrics)的 5min 存档与失败数差分 prev(apps/control-server/app/db/models/metrics.py),GET /ui/nodes/metrics 机群指标矩阵的数据层——快照事件历史只留 100 条(≈8h),7d 观测窗口靠这张存档(保留 60 天,时间截断)。快照入库时顺手累加,旧 agent 快照不带 self_metrics 则 no-op。迁移 a5b6c7d8e9f0

类型约束/默认说明
node_idString(64)PK(复合),FK→nodes.node_id(CASCADE)所属节点。
bucket_startBigIntegerPK(复合,仅 rollup)桶起点(epoch 秒,对齐 5min)。
cpu_sum / cpu_countFloat / IntegerNOT NULL,默认 0CPU 百分比的和与计数(分开计数:AgentSelfMetrics 字段全部可选),均值 = 和/计数。
rss_sum / rss_countFloat / IntegerNOT NULL,默认 0RSS(MB)的和与计数。
reconcile_failuresIntegerNOT NULL,默认 0桶内 reconcile 失败次数(total_failures 累计计数与 prev 差分后累加;agent 重启归零时按当前值计)。
sample_countIntegerNOT NULL,默认 0落桶快照数(>0 即「该桶有上报」)。
captured_at / total_failuresString(64) / BigInteger(仅 last 表)NOT NULL / 可空最近一次采样时刻与 total_failures 累计值(差分的持久 prev,仅向前推进)。
updated_atDateTime(tz)server_default now,带 onupdate更新时刻。

Flap 检测

会话级(P0)与前缀级(P1)两级 flap 检测、告警事件(P2)与时序存档的数据层 (apps/control-server/app/db/models/flap.pyprefix_flap.py)。前缀级打分热态存 KV(TS 面)/ Redis(Python 面)blob(pflap:<node>,见下文「前缀级热态」),SQL 只留 meta 与速率存档。检测机制全貌见 flap 检测内部, 演进脉络见 ROADMAP

bgp_flap_state

每 (节点, bird 协议名) 一行的会话级 flap 打分状态,随快照 upsert(乱序快照跳过)。 flap.py:27。数据源是 runtime snapshot 里的 bgp_protocols(~5min 节奏)——快照入库时与 上一份逐会话比对,发现状态转移(或 state 同为 Established 但 since 变化的「隐性重连」) 即记转移并给该会话的指数衰减 flap 分加分;零 agent 变更,纯控制面推导。会话从快照消失 (配置删除)时行同步删除。

类型约束/默认说明
node_idString(64)PK(复合),FK→nodes.node_id(CASCADE)所属节点。
nameString(128)PK(复合)bird protocol 名(快照内唯一)。
sessionString(128)可空反查到的 schema 会话名(eBGP 有,iBGP 合成会话为 NULL)。
last_stateString(32)NOT NULL上次观测的 state(比对基准)。
last_sinceString(64)可空上次观测的 since(since 变而 state 不变 = 隐性重连)。
captured_atString(64)NOT NULL分数锚点 = 上次纳入比对的快照 captured_at(ISO8601);读取时从锚点按半衰期续衰到 now。
scoreFloatNOT NULL,默认 0.0指数衰减 flap 分(每次 flap 事件 +1 后随时间衰减,半衰期见 services/flaps.py)。
transitions_totalIntegerNOT NULL,默认 0累计状态转移数(不衰减,供展示)。
flaps_totalIntegerNOT NULL,默认 0其中计分的 flap 事件数。
last_transition_atString(64)可空最近一次转移时刻。
updated_atDateTime(tz)server_default now,带 onupdate更新时刻。

bgp_flap_transitions

append-only 会话状态转移历史,供 Web 下钻「这条会话何时抖过」。flap.py:62。只落涉及 Established 的转移(断连 / 隐性重连 / 恢复;非 Established 间换挡只计数不落行,防挤占), 按节点裁剪到最近 _TRANSITIONS_KEEP 条。

类型约束/默认说明
idIntegerPK,autoincrement主键。
node_idString(64)FK→nodes.node_id(CASCADE),NOT NULL,index所属节点。
nameString(128)NOT NULL,indexbird protocol 名。
sessionString(128)可空schema 会话名。
kindString(16)NOT NULLstate(state 实变,如 Established→Active)/ restart(隐性重连:两份快照 state 均 Established 但 since 变化)。
from_state / to_stateString(32)NOT NULL转移前后状态。
captured_atString(64)NOT NULL观测到转移的快照时刻(ISO8601,非转移实际发生时刻——分辨率即快照节奏)。
created_atDateTime(tz)server_default now写入时刻。

flap_alerts

flap 告警事件(边沿触发的持久层,P2)。flap.py:87。衰减分是「当下」视图,消退后历史即 不可见;本表由控制面背景评估循环(services/flap_alerts.py,60s)边沿触发地维护:分数 上穿阈值一条,下穿消警线(阈值一半,迟滞防乒乓)该条(resolved_at)。 kind=session 定位 (node_id, name=会话名);kind=prefix 定位 (name=前缀, peer_asn=归因 对端),nodes 记录波及节点集合。node_id 有意不设外键:告警是历史事实,不随节点删除 级联蒸发。

类型约束/默认说明
idIntegerPK,autoincrement主键。
kindString(16)NOT NULLsession / prefix
node_idString(64)可空,index(无 FK)会话告警的所属节点;前缀告警为 NULL。
nameString(128)NOT NULL会话名或前缀。
peer_asnBigInteger可空前缀告警的归因对端 AS。
nodesJSON可空波及节点集合(session=单元素;prefix=看到该抖动的节点,持续期间并集)。
peak_score / last_scoreFloatNOT NULL持续期间峰值分 / 最近评估分。
started_atDateTime(tz)NOT NULL,server_default now开单时刻。
resolved_atDateTime(tz)可空,index消警时刻;NULL = 在开。
updated_atDateTime(tz)server_default now,带 onupdate更新时刻。

前缀级热态:主存 Redis / KV,SQL 是回退

前缀级 flap 的「当下打分热态」(打分条目 + 路径证据,每 60s 整份替换、读时续衰)不落 SQL——落 SQL 每月产生 1.5 亿+ 行写。Python 面存 Redis 键 pflap:<node> 单 blob(每报 1 次 SET,TTL 7 天,app/services/prefix_flaps.py:68-74);TS 面存 KV 同名键 (src/services/prefixFlaps.ts,blob 结构逐字段一致)。SQL 只存 meta(喂送健康度)与 60s 速率存档;Redis / KV 未配置或故障时自动回退下述 node_prefix_flaps / node_prefix_flap_paths 整表路径(测试与降级共用)。

node_prefix_flaps

一条 (节点, 前缀, 对端 AS) 的当前前缀级 flap 打分,随上报按节点整表替换(条目 ≤500, delete + bulk insert)。prefix_flap.py:33。数据源是 agent 旁路 flapfeed 采集管线的周期 上报(PrefixFlapReport,~60s):agent 侧已按 (prefix, 归因对端 AS) 算好指数衰减分并截断 Top-N。热态主存 Redis / KV(见上),本表为回退路径。

类型约束/默认说明
node_idString(64)PK(复合),FK→nodes.node_id(CASCADE)所属节点。
prefixString(64)PK(复合)前缀。
peer_asnBigIntegerPK(复合),默认 0归因对端 AS;0 = 未归因 / 本节点自起源(主键列不可空,读出归一回 None)。⚠️ ASN 列必须 BigInteger(32-bit ASN 溢出 int32)。
origin_asnBigInteger可空前缀起源 AS(AS_PATH 最后一跳);本网自起源 / 无从归因 / 旧 agent 未上报为 NULL。
scoreFloatNOT NULLagent 侧算好的指数衰减分(锚定 captured_at,读取时可续衰)。
changesBigIntegerNOT NULL,默认 0该 (prefix, peer) 自 flapfeed 启动以来的累计变化数(不衰减)。
recent_changesBigInteger可空上一报告间隔内的变化数(收报时对同键 changes 差分;上一报告无该键 = 无基准 → NULL,宁可漏检不虚报)。
rate_per_sFloat可空折算的每秒速率(榜单 Rate 列口径)。
last_change_atString(64)可空最近一次变化时刻。
captured_atString(64)NOT NULL所属上报批次的生成时刻(sink 侧,ISO8601)。
updated_atDateTime(tz)server_default now,带 onupdate更新时刻。

node_prefix_flap_meta

每节点最近一次前缀级 flap 上报的元信息(喂送健康度),区分「没有抖动」与「喂送断了看 不见」。prefix_flap.py:69。写率低,始终走 SQL。

类型约束/默认说明
node_idString(64)PK,FK→nodes.node_id(CASCADE)主键即外键。
captured_atString(64)NOT NULL最近上报生成时刻。
feed_stateString(32)NOT NULL,默认 unknownflapfeed BGP 会话状态(established / down / unknown)。
total_changesBigIntegerNOT NULL,默认 0sink 启动以来的累计路径变化总数。
updated_atDateTime(tz)server_default now,带 onupdate更新时刻。

node_prefix_flap_paths

一条 (节点, 前缀) 的 V2 路径证据,随上报按节点整表替换。prefix_flap.py:91。只对上榜 抖动前缀存(agent 侧已过滤,量小;JSON 路径列表 ≤32 条)。控制面根因区定位时跨节点读出、 按 AS_PATH 求并喂 app.services.flap_localize.localize(判「源头 AS 传播过来」还是「中段 某链路在翻」)。同属热态,主存 Redis / KV,本表为回退路径。

类型约束/默认说明
node_idString(64)PK(复合),FK→nodes.node_id(CASCADE)所属节点。
prefixString(64)PK(复合)前缀。
pathsJSONNOT NULL,默认 [][{"as_path": [int...], "churn": float, "present": bool}]——外部 AS_PATH 证据(已剥本网前缀)。
changesFloatNOT NULL,默认 0.0前缀级衰减计数:总变化事件(源头信号分母)。
withdrawnFloatNOT NULL,默认 0.0彻底消失次数(分子)。
captured_atString(64)NOT NULL上报批次生成时刻。
updated_atDateTime(tz)server_default now,带 onupdate更新时刻。

prefix_flap_rate_rollup

每 (节点, 60s 桶) 的路径变化速率存档,收报时差分累加,保留 60 天。prefix_flap.py:120changes 来自喂送级累计计数 total_changes 的差分(该节点看到的全部计分事件,含未 上榜前缀);listed_changes 是榜内 (prefix, peer) 条目 changes 的差分之和(只算两份报告 都在榜的键——新上榜键无基准不计,宁可漏检)。fleet 级「路由变化速率」时序与 gauge 都从 这张表读时聚合。

类型约束/默认说明
node_idString(64)PK(复合),FK→nodes.node_id(CASCADE)所属节点。
bucket_startBigIntegerPK(复合)桶起点(epoch 秒,对齐 60s)。
changesBigIntegerNOT NULL,默认 0喂送级差分(含未上榜前缀)。
listed_changesBigIntegerNOT NULL,默认 0榜内条目差分之和。
samplesIntegerNOT NULL,默认 0落桶报告数(>0 即「该桶有上报」,读侧据此区分 0 与无数据)。

额外索引:ix_prefix_flap_rate_rollup_bucket (bucket_start)

flap_activity_rollup

fleet 级 flap 活跃度时间线(每 60s 桶一行,无 node 维度),告警评估循环顺手落点,保留 60 天。prefix_flap.py:144(ORM 类名 FlapActivitySample)。与速率存档互补:速率答 「变化多快」,这里答「多少主体在抖」。

类型约束/默认说明
bucket_startBigIntegerPK桶起点(epoch 秒,对齐 60s)。
tracked_prefixesIntegerNOT NULL,默认 0在跟踪的前缀数(有任何存活分数)。
flapping_prefixesIntegerNOT NULL,默认 0过阈前缀数。
flapping_sessionsIntegerNOT NULL,默认 0过阈会话数。
open_alertsIntegerNOT NULL,默认 0在开告警数。

DN42 registry 副本

控制面周期从官方 registry(git.dn42.dev)同步 tarball 并解析 RPSL 对象(apps/control-server/app/db/models/registry.py;需配置 DN42_CONTROL_REGISTRY_TOKEN,见 configuration.md)。第一消费者是 UI 起源 AS 榜与 peering 向导的「名称」列。迁移 e2f3a4b5c6d7

registry_asn

每 ASN 一行(aut-num 摘要),整表随每次成功同步全量替换(registry 是外部权威,本地不做增量合并)。

类型约束/默认说明
asnBigIntegerPK(非自增)ASN(32 位 ASN 溢出 int32,必须 BigInteger)。
as_nameString(128)可空aut-num 的 as-name。
mnt_byString(128)可空首个 mnt-by(维护者标识)。
updated_atDateTime(tz)server_default now,带 onupdate更新时刻。

registry_meta

同步位置单行表(id 恒为 1):commit(master sha,变更门控用)、asn_countsynced_at


管理员账号

账号登录(用户名 + 密码)签发的不透明会话令牌与静态 DN42_CONTROL_ADMIN_TOKENdeps.require_admin 同一入口无差别接受(apps/control-server/app/db/models/auth.py)。 安全模型与 agent token 同纪律:明文令牌永不落库,只存 sha256 摘要;密码只存 argon2id 单向散列。迁移 b2c3d4e5f6a7

admin_users

一个管理员账号。auth.py:20

类型约束/默认说明
idIntegerPK,autoincrement主键。
usernameString(64)NOT NULL,unique用户名。
password_hashString(255)NOT NULLargon2id 编码串(含参数与盐,形如 $argon2id$v=19$m=...)。
roleString(32)NOT NULL,默认 admin,server_default adminv1 预留字段(当前恒为 admin,无权限分级)。
created_at / updated_atDateTime(tz)NOT NULL,server_default now,updated_at 带 onupdate时间戳。

admin_sessions

账号登录签发的一条会话。auth.py:44

类型约束/默认说明
idIntegerPK,autoincrement主键。
user_idIntegerFK→admin_users.id(CASCADE),NOT NULL,index所属账号。
token_hashString(64)NOT NULL,unique完整 Bearer 的 sha256 十六进制摘要(唯一查找键)。
created_atDateTime(tz)NOT NULL,server_default now签发时刻。
expires_atDateTime(tz)NOT NULL过期时刻。
revoked_atDateTime(tz)可空非空即已吊销(改密时批量吊销该用户全部会话)。

审计

admin_audit_log

append-only Admin 写操作记录。apps/control-server/app/db/models/audit.py:14。无外键。每条 对应一次到达 /api/v1/admin 的变更类请求(POST/PUT/PATCH/DELETE),无论鉴权是否通过。

类型约束/默认说明
idIntegerPK,autoincrement主键。
actorString(64)可空,index鉴权通过的主体:静态 admin token 为 admin,账号会话为用户名(见「管理员账号」);失败时空。
methodString(8)NOT NULLHTTP 方法。
pathString(512)NOT NULL,index请求路径。
status_codeIntegerNOT NULL响应状态码。
detailJSONNOT NULL,默认 {}详情。
created_atDateTime(tz)server_default now,index写入时刻。

Materialize 写入路径

控制面采用「normalized 表 → DesiredState 快照」的物化模型。入口有二:

  • Provisionprovision_node_from_stateapps/control-server/app/db/provision.py:74) 把整份 DesiredState 落库。_split_base_template 切出 Node.base_template(剥掉 interfaces / bgp_sessions / generationdns 整段置 None——DNS 改共享组模型;并剥掉 node 块里的身份字段 node_id/asn/router_id/loopback/prefixes,因为它们是 nodes 列的 权威值,materialize 时由 _node_payload 无条件覆盖,避免存陈旧副本)。子表逐行 apply_spec 写入。该函数幂等:节点已存在时覆盖 base_template、删并重建子表,再 materialize。
  • Admin 写:管理员对 peerings / wg / bgp / dns 等表的增改删,提交前调用 materialize 发布新世代。

materialize(session, node_id, ...)apps/control-server/app/services/materializer.py:33) 流程:

  1. session.get(Node, node_id, with_for_update=True) 行级锁住节点,让并发 admin 写在此处 串行化,保证 generation 严格单调、不撞 UNIQUE(node_id, generation)(Postgres/MySQL 走 SELECT ... FOR UPDATE;SQLite 忽略该子句,靠单写者 + UNIQUE 兜底)。
  2. new_generation = (current_generation or 0) + 1
  3. _assemble_snapshotbase_template 叠加子表内容组装成完整 DesiredState dict:
    • _node_payload:DB 节点身份字段优先覆盖,base_template.node 兜底(提供 region 等非 DB 字段)。
    • _load_interfaces / _load_sessions:按 sort_order, id 取子行。
    • enabled 语义InterfaceSpecenabled 字段,disabled 接口直接不进 snapshot; BgpSessionSpecenabled,disabled 会话仍在 snapshot 但 enabled=False
    • 对端 WG 公钥派生_load_peer_public_keys + _interface_payload):对端是另一受管 节点的接口(peering.remote_node_id 非空),取对端 Node.wireguard_public_key 现取现填 进 wireguard_peer.public_key——真相源是对端节点的公钥,不再依赖 spec 副本。
    • LLA 派生:节点级 NodeSpec.link_local 注入到外部 eBGP WG 接口的 addressesfe80::X/64),条件为接口是 WIREGUARD、peering 存在且 is_internal=False。内部互联 用各自接口的 LL。
    • DNS 派生_load_dns_group):节点未分配组 / 组不存在 / 组禁用 ⇒ dns=None;否则按 组→enabled zone→enabled 记录 组装 DnsSpec(每 zone 内联 records,SOA 留空自动生成)。空 zone 不输出;无任何 zone 且无 forwards ⇒ None。多节点订阅同组拿到同一份 = anycast。
    • 退役处理Node.lifecycle == "decommissioned" 时,snapshot 的 interfaces / bgp_sessions 置空、dns 置 None——agent 收敛即拆所有隧道、撤所有 BGP、停止宣告路由;核心 runtime 服务(router-netns/wg-gateway/bird-router)按 schema 要求保留为惰性空转。
  4. DesiredState.model_validate(snapshot) 再走一遍 schema 校验(拒绝任何漂移,不通过即抛、由 上层回滚事务),保证写入 generations.snapshot 的内容总是合法。
  5. 写一条新 Generation(含 snapshot + reason),并把 Node.current_generation 指过去。
  6. 世代保留裁剪keep_generations(默认 DEFAULT_GENERATION_RETENTION = 100materializer.py:30)> 0 时,同一事务内删除 generation <= new_generation - keep 的旧代, 防止 generations 表无界增长。当前代必然落在保留窗口内。

materialize 触发 EventBus;由调用方在事务提交后决定是否广播事件,避免「事件先发、事务 后回滚」让 agent 拉到旧数据。

详见 控制服务器内部DesiredState 参考


迁移(Alembic)

迁移脚本在 migrations/versions/,配置见 alembic.iniscript_location = migrationsfile_template = %(rev)s_%(slug)s)。migrations/env.py 从环境变量 DN42_CONTROL_DATABASE_URL 取 DSN(与运行时共享同一变量),并把 async driver 统一替换为同步 驱动跑迁移(+aiosqlite→空、+asyncpg+psycopg2+asyncmy+pymysql);target_metadata = Base.metadata

后端库是 PostgreSQL(ASN 等列用 BigInteger——DN42 ASN 4242420000+ 超 int32)。schema 初始化由启动期 _init_schemaapps/control-server/app/main.py:79)按配置项 db_auto_migrate(TOML [database].auto_migrate / env DN42_CONTROL_DB_AUTO_MIGRATE, 默认 False)分流:置 Truealembic upgrade head(迁移失败即启动中止,fail-fast); 默认走 Base.metadata.create_all(dev/CI;只建缺失的表、绝不应用 ALTER)。

两者当前不等价:ORM 含 node_route_prefix_hashes 表与 node_status.bgp_summary / peer_rates 列,而迁移链(head b2c3d4e5f6a7)没有对应迁移(生产 Python 面历史上经 create_all 建库,这两处随写路径 P1 / P2.7 只补了 TS 面 drizzle 迁移 0002 / 0003), 从空库 alembic upgrade head 产出会缺这三处,待补迁移。既有由 create_all 建起、未被 alembic 纳管的库首次切 db_auto_migrate=True 前还需手动 alembic stamp(见 config.py:211 的字段说明)。

历史坑(早期 SQLite + create_all 起库期):create_all 只建「最新模型」、不跑迁移里的数据剥离 / 列删除,故破坏性变更(删列)在 create_all 库需手动 ALTER TABLE——如迁移 a3b4c5d6e7f8DROP COLUMN rpki_unknown。现已迁到 PostgreSQL,新部署 create_all/alembic 任一即可。

迁移历史(按链表顺序):

revisiondown_revisionslug内容
a0043f410bda(无,根)initial_schema初始 schema:nodes 及核心表。
b1f2c3d4e5a6a0043f410bdanode_runtime_status新增 node_status / node_status_events 运行时健康表。
c2a3b4c5d6e7b1f2c3d4e5a6token_hash_pending_registrationsagent_tokenstoken_hash/expires_at+唯一索引;新增 pending_registrations
d4e5f6a7b8c9c2a3b4c5d6e7admin_audit_enrollment_hash新增 admin_audit_logenrollment_tokens 改哈希存储(回填 + 收紧非空唯一)。
e5f6a7b8c9d0d4e5f6a7b8c9wireguard_key_escrownodeswireguard_public_key + wireguard_private_key_escrow(节点级 WG 身份/私钥托管)。
f6a7b8c9d0e1e5f6a7b8c9d0remove_compose_runtime_fields去 docker-compose:从存量 base_template/snapshot JSON 剥 runtime.adapter/templates.compose/templates.systemd(strict schema 否则校验失败)。
a7b8c9d0e1f2f6a7b8c9d0e1slim_build_spec从存量 DesiredState JSON 剥 build.context/build.dockerfile(router 镜像改 agent 内存生成)。
b8c9d0e1f2a3a7b8c9d0e1f2node_lifecyclenodeslifecycle(active/decommissioned,server_default active)。
c9d0e1f2a3b4b8c9d0e1f2a3node_route_entries新增 node_route_entries 索引表,逐路由明细从 node_routing.routes 单 JSON 列拆出;旧列暂保留写 NULL。
d0e1f2a3b4c5c9d0e1f2a3b4dns_groupsDNS 从节点级 dns_zones 改共享组:新增 dns_groups/dns_group_zones/dns_recordsnodesdns_group_id
e1f2a3b4c5d6d0e1f2a3b4c5drop_legacy_compat_shims剥 DB 残留以删运行时垫片:去 lookglass/looking-glass-* 服务、归一化字符串端口、删 aggregates 里 prefilter 的 unknown 键(downgrade no-op)。
f2a3b4c5d6e7e1f2a3b4c5d6drop_node_routing_routes删死列 node_routing.routes(明细已迁 node_route_entries)。
a3b4c5d6e7f8f2a3b4c5d6e7drop_rpki_unknown删死列 node_routing.rpki_unknown / node_routing_events.rpki_unknown(RPKI「未知」三态已移除)。
a4b5c6d7e8f9a3b4c5d6e7f8node_link_localnodeslink_local(节点级 IPv6 LLA 单一真相源,materialize 派生注入外部 eBGP WG 接口)。
b5c6d7e8f9a0a4b5c6d7e8f9pending_reg_unique_pendingpending_registrations 加部分唯一索引 UNIQUE(requested_node_id) WHERE status='pending'
c0d1e2f3a4b5b5c6d7e8f9a0node_traffic_rollup新增 node_traffic_rollup(WG 吞吐 5min 降采样持久存档)。
d1e2f3a4b5c6c0d1e2f3a4b5node_traffic_last_sample新增 node_traffic_last_sample(速率差分的持久 prev,Redis 不可用时兜底)。
e2f3a4b5c6d7d1e2f3a4b5c6registry_mirror新增 registry_asn / registry_meta(DN42 registry 本地只读副本)。
f3a4b5c6d7e8e2f3a4b5c6d7iface_traffic_rollup新增 node_iface_traffic_rollup / node_iface_traffic_last(按接口→对端 AS 划分流量的数据层)。
a5b6c7d8e9f0f3a4b5c6d7e8node_metrics_rollup新增 node_metrics_rollup / node_metrics_last(agent 自观测 5min 存档 + 失败数差分 prev,/ui/nodes/metrics 数据层)。
b6c7d8e9f0a1a5b6c7d8e9f0bgp_session_flaps新增 bgp_flap_state / bgp_flap_transitions(会话级 flap 检测 P0:比对基准 + 衰减分 + 累计计数 / append-only 转移历史;数据源为快照 bgp_protocols,零 agent 变更)。
c7d8e9f0a1b2b6c7d8e9f0a1prefix_flaps新增 node_prefix_flaps / node_prefix_flap_meta(前缀级 flap 检测 P1:agent flapfeed 上报的当前衰减打分(按节点整表替换)+ 喂送健康度元信息;ASN 列 BigInteger)。
d8e9f0a1b2c3c7d8e9f0a1b2flap_alerts新增 flap_alerts(P2 告警事件:背景评估循环边沿触发维护,上穿开单 / 下穿消警,让「昨晚谁抖过」可回答)。
e9f0a1b2c3d4d8e9f0a1b2c3prefix_flap_origin_asnnode_prefix_flapsorigin_asn(起源 AS 归因列,与 peer_asn 第一跳对称;可空 BigInteger)。
f0a1b2c3d4e5e9f0a1b2c3d4prefix_flap_paths新增 node_prefix_flap_paths(V2 根因区定位:每 (节点, 前缀) 的 AS_PATH 证据 JSON + 前缀级衰减计数,只存上榜前缀)。
a1b2c3d4e5f6f0a1b2c3d4e5flap_stats_rollup新增 prefix_flap_rate_rollup / flap_activity_rollup(60s 桶速率 / fleet 活跃度时序存档,保留 60 天);node_prefix_flapsrecent_changes / rate_per_s(榜单 Rate 列)。
b2c3d4e5f6a7a1b2c3d4e5f6admin_accounts新增 admin_users / admin_sessions(账号登录:argon2id 密码散列 + sha256 会话令牌摘要;静态 admin token 并行可用)。

链表当前的 head 为 b2c3d4e5f6a7。多处迁移用 op.batch_alter_table 以兼容 SQLite(无原生 DROP COLUMN)与 Postgres。

升级与迁移操作指引见 升级与迁移


TS 面 schema(drizzle)

主控制面(TS 面)的 schema 权威定义在 apps/control-server-ts/src/schema.ts——单文件 36 张 pgTable,与 Python 面 ORM 同构(列名 / 类型 / FK / 索引逐项对应),列语义见 前文各表章节,此处不逐列重复。类型映射约定(schema.ts 头注):

SQLAlchemydrizzle
BigIntegerbigint({ mode: 'number' })(DN42 ASN 超 int32,< 2^53 故 number 安全)
JSONjsonb().$type<...>()
DateTime(timezone=True)timestamp({ withTimezone: true })
String(n) / Textvarchar({ length: n }) / text

表分组(schema.ts 定义顺序):

  • 节点身份与凭证nodesenrollment_tokensagent_tokenspending_registrations
  • 网络配置与世代peeringswg_interfacesbgp_sessionsgenerations
  • 共享 DNS 组dns_groupsdns_group_zonesdns_records
  • 运行健康node_statusnode_status_events
  • 路由node_routingnode_route_entriesnode_route_prefix_hashesnode_routing_events
  • 流量与指标 rollupnode_traffic_rollupnode_traffic_last_samplenode_metrics_rollupnode_metrics_lastnode_iface_traffic_rollupnode_iface_traffic_last
  • flap 检测bgp_flap_statebgp_flap_transitionsflap_alertsnode_prefix_flapsnode_prefix_flap_metanode_prefix_flap_pathsprefix_flap_rate_rollupflap_activity_rollup
  • registry 副本registry_asnregistry_meta
  • 账号与审计admin_usersadmin_sessionsadmin_audit_log

迁移在 apps/control-server-ts/drizzle/(条目清单见 drizzle/meta/_journal.json):

迁移内容
0000_init.sql初始 19 张核心表(节点 / 网络 / DNS / 世代 / 健康 / 路由 / 流量存档 / 审计)。
0001_brainy_deathstrike.sql新增 16 张表:flap 两级检测全家桶、registry 副本、账号会话、指标与 per-interface 流量存档。
0002_amusing_squadron_sinister.sqlnode_statusbgp_summary / peer_rates(P2.7 预提取列)。
0003_route_prefix_hashes.sql新增 node_route_prefix_hashes(路由差分写基准,P1)。

应用方式:drizzle-kit 在 Worker 外部直连源 PostgreSQL(不经 Hyperdrive)—— npm run db:generate 从 schema 生成迁移 SQL,DATABASE_URL=postgres://... npm run db:migrate 应用;实际生产因源库自签 TLS 采用 psql 管道法,见 apps/control-server-ts/README.md