Separate storage shard meshes and unify Endor backup topology
Verify and deploy Tabularium / verify-and-deploy (push) Has been cancelled

This commit is contained in:
2026-10-27 21:10:43 +08:00
parent 1c27f6bc1e
commit 36840ef1d4
11 changed files with 925 additions and 549 deletions
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 88 KiB

@@ -1,6 +1,7 @@
import { useRef, useState } from "react";
const source = "/figures/endor-architecture.svg";
const figureWidth = 2000;
export function ArchitectureFigure({ alt }: { alt: string }) {
const dialog = useRef<HTMLDialogElement>(null);
@@ -15,7 +16,7 @@ export function ArchitectureFigure({ alt }: { alt: string }) {
}
function changeZoom(delta: number) {
const current = fit
? ((pan.current?.clientWidth ?? 1776) - 16) / 1760
? ((pan.current?.clientWidth ?? figureWidth + 16) - 16) / figureWidth
: zoom;
setFit(false);
setZoom(
@@ -25,7 +26,7 @@ export function ArchitectureFigure({ alt }: { alt: string }) {
return (
<figure className="architecture-figure">
<span className="figure-toolbar">
<span>ENDOR / 概念架构草案</span>
<span>ENDOR / 架构拓扑</span>
<button ref={trigger} onClick={open}>
放大查看 ↗
</button>
@@ -39,7 +40,7 @@ export function ArchitectureFigure({ alt }: { alt: string }) {
<img src={source} alt={alt} />
</button>
<span className="figure-caption">
两区域拓扑 · 点击放大查看全连接与复制路径
多区域拓扑 · 独立分片群与统一备份 · 点击放大查看
</span>
<dialog
ref={dialog}
@@ -48,9 +49,7 @@ export function ArchitectureFigure({ alt }: { alt: string }) {
onClose={() => trigger.current?.focus()}
>
<div className="figure-viewer-toolbar">
<strong>
Endor 概念架构 <small>草案</small>
</strong>
<strong>Endor 架构拓扑</strong>
<div className="figure-controls">
<button
onClick={() => changeZoom(-0.25)}
@@ -91,12 +90,12 @@ export function ArchitectureFigure({ alt }: { alt: string }) {
<img
src={source}
alt={alt}
style={{ width: fit ? "100%" : 1760 * zoom }}
style={{ width: fit ? "100%" : figureWidth * zoom }}
/>
</div>
<p className="figure-viewer-hint">
实线:请求 /
依赖 · 紫色:身份 · 蓝色点线:回源 · 金色:跨区备份。放大后可横向、纵向滚动,Esc
依赖 · 紫色:身份 · 蓝色点线:回源 · 金色:统一备份。放大后可横向、纵向滚动,Esc
关闭。
</p>
</dialog>
+34 -23
View File
@@ -1,46 +1,50 @@
# 理解 Endor:从全球入口到对象数据
Endor 的概念模型把全球访问、区域运行时和对象数据连接起来:请求从统一入口进入,在合适的区域找到 Nexus Runtime,再由它承载的 Aster VM 执行对象操作。数据以分片组织,在区域内保持高可用,并为每个分片安排跨区域恢复目标。
Endor 的概念模型把全球访问、区域运行时和对象数据连接起来:请求从统一入口进入,在合适的区域找到 Nexus Runtime,再由它承载的 Aster VM 执行对象操作。PostgreSQL、Aerarium 和 S3 各自维护独立的分片群,在区域内保持高可用,并由统一备份系统追踪所有区域、所有存储类型的分片与恢复材料。
> **概念设计草案**:本文按当前讨论绘制目标架构,不表示这些组件和协议已经全部实现。图中的区域、节点、分片数量仅为示例;CDN 和身份服务的接入方式是建议方案。
## 架构总览
![Endor 概念拓扑:全球入口连接北美与亚洲,各区 Traefik 与 Nexus Runtime 全连接,每个 Nexus 配一个 Aster VM;数据分片包含 PostgreSQL、Aerarium 和 S3,并具有区域内副本和跨区备份](/figures/endor-architecture.svg)
![Endor 架构:全球入口连接北美、亚洲及其他区域;各区两个 Traefik 与五个 Nexus Runtime 全连接,每个 Nexus 侧下方配一个 Aster VM;Aerarium 分片群分别与 PostgreSQL、S3 分片群全连接,所有分片汇入统一备份系统](/figures/endor-architecture.svg)
图中绿色/蓝色实线表示请求路径或区域内数据依赖,紫色虚线表示身份控制与公钥分发,蓝色点线表示 CDN 回源,金色虚线表示跨区复制和备份。连线表示可达或依赖关系,**不表示请求向所有节点广播**。
图的编排由 Python 生成 SVG:节点图标、二分全连接和复制线路都能复现和编辑。Python 更适合这张静态架构图;页面查看、缩放与下载由 TypeScript/React 负责。
图中绿色/蓝色实线表示请求路径或区域内数据依赖,紫色虚线表示身份控制与公钥分发,蓝色点线表示 CDN 回源,金色虚线表示向统一备份系统采集数据。连线表示可达或依赖关系,**不表示请求向所有节点广播**。
## 全球入口与区域入口
客户端包括浏览器、Tessera、SDK 和 CLI。`endorphin API` 是逻辑上的全球流量入口,尚未在本文规定一个真实的域名。全球调度根据健康、延迟、项目归属和数据放置策略选择区域;不能只选择网络最近的区域而忽略对象在哪个分片。
示例使用北美和亚洲两个区域。每个区域有自己的负载均衡入口,下面是 Traefik 网关,负责按配置进行 TLS、主机名或路径路由。图中的第二个 Traefik 标记为 **HA 副本**:多副本是生产冗余选项,单实例概念部署不要求两个网关。
图中展开北美和亚洲两个区域,并用“ … / etc · 其他区域”表示可以继续扩展。每个区域有自己的负载均衡入口,下面是 Traefik 网关,负责按配置进行 TLS、主机名或路径路由。图中的第二个 Traefik 标记为 **HA 副本**:多副本是生产冗余选项,单实例概念部署不要求两个网关。
### 为什么画成二分全连接
每个区域示例中有两个 Traefik 和三个 Nexus Runtime,画出全部六条边,即二分图 **K₂,₃**。每个网关都能把请求路由到任意一个符合条件的健康 Runtime,避免把一个网关固定绑定到一个运行时。
每个区域示例中有两个 Traefik 和五个 Nexus Runtime,画出全部十条边,即二分图 **K₂,₅**。网关使用独立的路由分流图标,与 Nexus 的服务器图标区分。每个网关都能把请求路由到任意一个符合条件的健康 Runtime,避免把一个网关固定绑定到一个运行时。
这是逻辑可达关系。实际转发仍需考虑服务发现、健康检查、项目和对象归属,以及有状态连接的路由策略;Runtime 之间并没有因此形成全连接网状网络。
## Nexus Runtime 与 Aster VM
此处采用 **一个 Nexus Runtime 节点配一个 Aster VM** 的概念约定。Nexus 表示对外接入与运行时承载边界,Aster VM 执行对象相关逻辑。图中每台服务器下面都单独画出一个 VM 芯片,避免把多个 VM 误读成另一个共享池。
此处采用 **一个 Nexus Runtime 节点配一个 Aster VM** 的概念约定。Nexus 表示对外接入与运行时承载边界,Aster VM 执行对象相关逻辑。图中每台服务器侧下方单独画出一个 VM 芯片,用点线连接到对应 Nexus;节点与 VM 采用错位排布,保持一对一归属。
请求经过区域入口和 Traefik 到达 Nexus,再定位目标分片。图中运行时下方的连接汇入分片路由层,表示节点可以按归属访问区域分片,**不是**「Runtime 1 只能访问分片 1」。对象身份、远程引用、生命周期与调用语义仍需结合[节点与对象挂载草案](/docs/02-mounting-draft)继续定义。
## 三类存储如何组成一个分片
## 三类存储各自独立分片
示例中每个区域都有两个逻辑数据分片。每个分片展示三个协作部分,它们是逻辑数据边界,不要求一分片对应三台独占物理机器。
PostgreSQL、Aerarium Object data pool 和 S3 是三组独立的分片群。图中每组各展开三个分片,数量可以独立增长,分片边界也可以不同;不能将一个 PG 分片、一个 Aerarium 分片和一个 S3 分片打包成固定“三件套”。
| 部分 | 概念职责 | 依赖关系 |
| ------------------------- | ------------------------------------------------------------ | ------------------------- |
| PostgreSQL | 结构化元数据、对象索引及需要事务约束的状态;具体数据模型待定 | 提供关系数据与事务能力 |
| Aerarium Object data pool | 对象数据的逻辑组织与访问层;不是把 S3 换一个名字 | 同时依赖 PostgreSQL 与 S3 |
| S3 兼容存储 | Blob、文件、快照和适合对象存储的数据;具体格式待定 | 提供对象持久化能力 |
| 分片群 | 概念职责 | 分片标识示例 |
| ------------------------- | ------------------------------------------------------------ | ---------------------------- |
| PostgreSQL | 结构化元数据、对象索引及需要事务约束的状态;具体数据模型待定 | NA-PG-01、NA-PG-02、NA-PG-03 |
| Aerarium Object data pool | 对象数据的逻辑组织、对象池状态和访问层;同时依赖 PG 与 S3 | NA-AO-01、NA-AO-02、NA-AO-03 |
| S3 兼容存储 | Blob、文件、快照和适合对象存储的数据;具体格式待定 | NA-S3-01、NA-S3-02、NA-S3-03 |
图中 Aerarium 向左右各画一条依赖箭头,分别连接 PostgreSQL 和 S3。它们**不是**三个互不相关的平行数据池。
### 分片群之间的全连接
每个 Aerarium 分片都能按映射访问任意一个 PostgreSQL 分片,也能访问任意一个 S3 分片。图中左右各画一组 **K₃,₃**:Aerarium ↔ PostgreSQL 有九条边,Aerarium ↔ S3 也有九条边;每区合计十八条存储关系边。
这些线表示逻辑可达关系,实际读写仍根据对象归属、分片映射、授权与健康状态选择目标。它们不表示广播,也不表示所有分片保存相同数据。PostgreSQL 和 S3 之间没有凭空增加直接全连接;两种存储通过 Aerarium 的对象模型与版本映射协作。
运行时访问先进入对象池的分片定位层,再由 Aerarium 按映射访问 PG 和 S3。因此不同层可以采用不同的分片数量和容量规划,而无需一一绑定。
### 区域内高可用
@@ -48,16 +52,23 @@ Endor 的概念模型把全球访问、区域运行时和对象数据连接起
这是逻辑 HA 要求,各层的实现不同:PostgreSQL 可以使用主备复制,Aerarium 需要自己的状态协调和故障恢复设计,S3 的耐久性与副本由选定的存储系统提供。图不强制三者采用同一种同步复制机制,也不把单纯增加副本等同于已经实现故障自动切换。
## 每个分片都要有跨区恢复目标
## 一个统一的备份系统
区域内副本解决本地节点或故障域失效;跨区备份解决整个区域不可用。四个源分片均有独立的异地目标:
备份是覆盖所有区域的总体系统,不按“北美备份亚洲、亚洲备份北美”分成两个固定系统。它统一编排各区域、各存储类型的备份采集、持久化、校验、保留与恢复。物理副本应隔离源区域的故障风险,实际存放位置由备份策略决定;图中不将该系统固定放进任何业务区域。
| 源分片 | 异地备份位置 | 覆盖内容 |
| ------------ | ------------ | -------------------------------------------- |
| NA-01、NA-02 | 亚洲区域 | 各分片的 PostgreSQL、Aerarium 状态和 S3 数据 |
| AS-01、AS-02 | 北美区域 | 各分片的 PostgreSQL、Aerarium 状态和 S3 数据 |
图中金色线路将每个区域的三类分片群接入同一备份边界,代表采集组内全部分片。统一恢复目录按“区域 + 存储类型 + 分片 ID”追踪每个分片,避免遗漏或将不同层的编号误当作同一个分片。
每个目标应保留可校验的恢复清单,将关系数据恢复点、对象池元数据版本与 S3 对象版本关联起来;不能只复制其中一种存储就宣称整个分片可恢复。图中的金色线路明确跨越区域,备份集按源分片编号标识。
### 备份中的 PG、Aerarium、S3 分别是什么
| 备份内容 | 保存什么 | 恢复时的用途 |
| --------------- | -------------------------------------------------------------------- | ------------------------------------------ |
| PostgreSQL 备份 | 各 PG 分片的基础备份、WAL 与可恢复时间点等事务恢复材料 | 恢复结构化元数据与事务状态 |
| Aerarium 备份 | 各对象池分片的状态、索引、对象身份和版本映射等恢复材料,具体格式待定 | 重建对象池,并关联 PG 恢复点与 S3 对象版本 |
| S3 备份 | 各 S3 分片的实际对象版本、Blob、文件及快照 | 恢复被对象池引用的实际字节数据 |
这里展示的是**统一系统内三类不同的备份内容与采集能力**,不是每个备份槽里又放一套在线 PG + Aerarium + S3 服务。系统还要维护统一恢复清单、校验和、重试状态,以及各层的版本对应关系。
恢复某个对象或项目时,清单应能定位所需的 PG 分片恢复点、Aerarium 分片状态与 S3 对象版本。因为三类存储独立分片,恢复集合也可能涉及多个不同编号的分片;不能按固定三件套恢复,也不能只备份其中一种存储就宣称整个对象可恢复。
### “最终一定拿到数据”需要怎样的保证
@@ -0,0 +1,27 @@
# Endor 拓扑修订验收 · 2026-10-27
本轮根据用户反馈修正第一版概念图。上一轮截图保留于 `../endor-concepts`,仅作为历史记录;本目录及当前 SVG 才反映本轮模型。
## 变更核对
- Traefik 使用路由菱形与分流箭头,Nexus 使用机架服务器,VM 使用芯片,三者图形不同。
- 每区五个 Nexus、两个 Traefik,形成完整 K₂,₅;两个区域合计10个 VM、20条网关到 Runtime 边。VM 位于各自 Nexus 侧下方,通过点线连接,Runtime 按高低错位排布。
- PostgreSQL、Aerarium 和 S3 各有自己的三个分片,不再以“三件套”作为一个分片。每区 Aerarium ↔ PG、Aerarium ↔ S3 各为 K₃,₃,两个区域合计36条存储关系边、18个独立存储分片及其区域HA示意。
- 各分片群接入同一个统一备份系统。PG 备份解释为基础备份/WAL/恢复点;Aerarium 备份解释为对象池状态/索引/版本映射;S3 备份解释为对象版本/Blob/快照。统一目录按区域、存储类型和分片ID追踪所有分片。
- 全局路由加入“… / etc · 其他区域”分支;删除图中的“概念设计 · 草案”角标及“一个全球入口,多个区域,一组可恢复的对象。”大标题。
- 图形查看器删除草案小标签,画布原始宽度更新为2000px;正文仍明确概念与未确定的实现边界。
## 检查
- Python拓扑与可复现生成测试通过:覆盖节点数、K₂,₅和K₃,₃边数、独立分片标识、统一备份唯一性、其他区域入口及删除的标签。
- `pnpm lint`、`pnpm typecheck`、`pnpm figures` 通过。
- `pnpm --filter @endor/docs run build --webpack` 通过;使用已有本机验证方式,没有变更生产构建、部署、缓存或回退配置。
- `python3 scripts/smoke.py http://127.0.0.1:3016` 检查健康、全部文档、JS/CSS、SVG及404。
- 浏览器实际检查1280px与390px宽度。桌面放大至86%时图宽1720px、容器宽1227px,页面仍宽1280px;手机放大至43%时容器宽372px、内容宽876px,页面仍宽390px。
- Escape关闭正常;图形可适应宽度、放大并在容器中滚动。浏览器捕获的error/warn日志为空。
## 当前截图
- `architecture.png`:2000×1860的完整SVG浏览器渲染。
- `viewer-desktop.png`、`viewer-zoom.png`:桌面适应及放大。
- `viewer-mobile.png`、`viewer-mobile-zoom.png`:手机适应及放大。
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

+151 -132
View File
@@ -13,9 +13,9 @@ FONT = 'ui-sans-serif, system-ui, -apple-system, PingFang SC, Microsoft YaHei, s
def build_architecture(output: Path) -> None:
svg = d.Drawing(1760, 1640)
svg.append_title('Endor 概念架构草案:全球入口、区域网关与运行时、分片和跨区恢复')
svg.append(d.Rectangle(0, 0, 1760, 1640, fill='#fcfdfb'))
svg = d.Drawing(2000, 1860)
svg.append_title('Endor 架构:全球入口、区域网关、独立存储分片与统一备份')
svg.append(d.Rectangle(0, 0, 2000, 1860, fill='#fcfdfb'))
def rect(x, y, w, h, fill='white', stroke=LINE, radius=12, dash=None):
svg.append(d.Rectangle(x, y, w, h, rx=radius, fill=fill, stroke=stroke,
@@ -68,7 +68,14 @@ def build_architecture(output: Path) -> None:
elif kind == 'lb':
g.append(d.Circle(0,-17,7)); g.append(d.Circle(-22,19,7)); g.append(d.Circle(22,19,7))
g.append(d.Lines(0,-10,0,1,-22,1,-22,12)); g.append(d.Lines(0,1,22,1,22,12))
elif kind in ('gateway','runtime'):
elif kind == 'gateway':
# Routing diamond: deliberately distinct from a Runtime server.
g.append(d.Lines(0,-23,23,0,0,23,-23,0,0,-23))
g.append(d.Line(-34,0,-12,0)); g.append(d.Lines(-17,-5,-12,0,-17,5))
for yy in [-12,0,12]:
g.append(d.Lines(0,0,9,yy,34,yy))
g.append(d.Lines(29,yy-4,34,yy,29,yy+4))
elif kind == 'runtime':
for dy in [-14,2]:
g.append(d.Rectangle(-25,dy,50,12,rx=3))
g.append(d.Circle(-16,dy+6,1.3,fill=color)); g.append(d.Line(-8,dy+6,16,dy+6))
@@ -100,139 +107,151 @@ def build_architecture(output: Path) -> None:
rect(x, y-17, w, 25, bg, 'none', 5)
text(s, x+w/2, y, 12, color, '500', 'middle')
text('ENDOR / SYSTEM ATLAS', 45, 48, 13, GREEN, '600')
text('一个全球入口,多个区域,一组可恢复的对象。', 45, 92, 31, INK, '600')
badge('概念设计 · 草案', 1520, 57, AMBER, '#f8f2e6', 192)
text('01 全球接入', 45, 142, 13, MUTED, '600')
text('ENDOR / SYSTEM ATLAS', 40, 42, 14, GREEN, '600')
text('01 全球接入', 40, 83, 13, MUTED, '600')
path([(160,143),(550,143)], arrow=True)
text('动态 API / 对象访问',280,131,13,GREEN)
path([(110,113),(110,100),(1300,100),(1300,114)],BLUE,arrow=True)
text('静态资源 / 可缓存内容',1000,91,13,BLUE)
path([(110,179),(110,214),(1750,214),(1750,179)],PURPLE,'5 6',arrow=True)
text('登录 / 换取凭证',1470,204,12,PURPLE)
icon('client',110,143)
text('全球客户端',110,191,17,INK,'600','middle')
icon('globe',600,143)
text('endorphin API',654,138,24,INK,'600')
text('全球流量入口 · 按健康与策略选区',654,165,14,MUTED)
icon('edge',1300,143,BLUE)
text('CDN',1340,137,21,INK,'600')
text('命中直接返回',1340,162,13,MUTED)
icon('shield',1750,143,PURPLE)
text('身份服务',1790,138,18,INK,'600')
text('登录 / 签发凭证',1790,162,12,MUTED)
# Request routes first, icons and labels above them.
path([(170,214),(500,214)], arrow=True)
text('动态 API / 对象访问', 280, 201, 13, GREEN)
path([(120,185),(120,163),(1160,163),(1160,184)], BLUE, arrow=True)
text('静态资源 / 可缓存内容', 815, 155, 13, BLUE)
path([(110,250),(110,300),(1540,300),(1540,253)], PURPLE, '5 6', arrow=True)
text('登录 / 换取凭证', 1140, 290, 13, PURPLE)
icon('client',120,214)
text('全球客户端',120,268,17,INK,'600','middle')
icon('globe',550,214)
text('endorphin API',620,208,23,INK,'600')
text('全球流量入口 · 按健康与策略选区',620,237,14,MUTED)
icon('edge',1160,214,BLUE)
text('CDN',1200,208,21,INK,'600')
text('命中直接返回',1200,234,13,MUTED)
icon('shield',1540,214,PURPLE)
text('身份服务',1580,207,17,INK,'600')
text('登录 / 签发凭证',1580,232,12,MUTED)
text('独立控制服务 · 建议',1580,256,11,PURPLE)
# A real fan-out includes an open-ended third branch, not just two regions.
path([(835,180),(835,243),(1900,243),(1900,306)],GREEN,arrow=True)
rect(1855,315,110,132,'#f8faf7',LINE,12,'3 5')
text('… / etc',1910,354,23,GREEN,'500','middle')
text('其他区域',1910,388,15,INK,'600','middle')
text('按需扩展',1910,418,12,MUTED,'400','middle')
# Two equally detailed regions. Solid edges mean eligible request routes,
# not broadcast execution or a requirement for inter-runtime mesh links.
regions = [('北美', 'NA', 40, GREEN), ('亚洲', 'AS', 910, BLUE)]
for name, prefix, ox, accent in regions:
center = ox+390
rect(ox,337,780,815,'#ffffff',LINE,18)
text(f'{name}区域',ox+28,375,23,INK,'600')
text(f'{prefix} / 独立故障域',ox+28,401,12,MUTED)
badge('区域入口',ox+623,376,accent,'#f0f6f3',126)
# Region's own load balancer; global routing fans into these, not nodes.
path([(800,252),(800,318),(center,318),(center,419)], GREEN, arrow=True)
icon('lb',center,446,accent,.72)
text('区域负载均衡',center+42,442,16,INK,'600')
text('健康检查 / TLS 路由',center+42,464,12,MUTED)
gateways = [ox+235, ox+545]
runtime_x = [ox+135, ox+390, ox+645]
regions = [('北美','NA',40,GREEN),('亚洲','AS',960,BLUE)]
# Each storage family owns its own shard set. No PG/Aerarium/S3 triplets.
families = [('PostgreSQL','PG','db',150),
('Aerarium','AO','pool',435),
('S3','S3','bucket',720)]
backup_ports = []
for name,prefix,ox,accent in regions:
center = ox+435
rect(ox,275,870,1025,'#ffffff',LINE,18)
text(f'{name}区域',ox+28,314,24,INK,'600')
text(f'{prefix} / 独立故障域',ox+28,340,12,MUTED)
badge('区域入口',ox+708,316,accent,'#eff5f0',132)
path([(835,180),(835,243),(center,243),(center,352)],GREEN,arrow=True)
icon('lb',center,377,accent,.72)
text('区域负载均衡',center+42,372,16,INK,'600')
text('健康检查 / TLS 路由',center+42,396,12,MUTED)
gateways = [ox+265,ox+605]
runtime_x = [ox+105+i*165 for i in range(5)]
runtime_y = [605,619,605,619,605]
for gx in gateways:
path([(center,470),(center,488),(gx,488),(gx,514)],accent,arrow=True)
text('TRAEFIK',ox+27,528,12,MUTED,'600')
text('生产冗余 · 可选',ox+27,550,12,MUTED)
for gi,gx in enumerate(gateways):
icon('gateway',gx,536,accent,.9)
text(f'Traefik {gi+1}',gx,579,14,INK,'500','middle')
if gi == 1:
badge('HA 副本',gx+42,539,accent,'#edf5f0',78)
# Explicit K2,3 complete bipartite graph. Every edge is drawn.
for gx in gateways:
for nx in runtime_x:
path([(gx,592),(nx,665)],accent,width=1.35,opacity=.65)
dot(gx,592,accent)
for ni,nx in enumerate(runtime_x):
dot(nx,665,accent)
icon('runtime',nx,701,accent)
text(f'Nexus Runtime {ni+1}',nx,746,15,INK,'600','middle')
icon('vm',nx-49,779,accent,.65)
text('Aster VM',nx-21,785,14,INK)
text('1 Nexus : 1 VM',nx,813,11,MUTED,'400','middle')
text('K₂,₃ 全连接',ox+22,621,11,accent)
text('选择健康节点',ox+22,640,11,MUTED)
# Shared regional shard routing bus, not a fixed node-to-shard assignment.
for nx in runtime_x:
path([(nx,829),(nx,850)],accent,opacity=.8)
path([(runtime_x[0],850),(runtime_x[-1],850)],accent,opacity=.8)
text('分片定位 / 对象读写',center,874,12,MUTED,'400','middle')
for si,sx in enumerate([ox+24,ox+406]):
mid=sx+175
path([(mid,850),(mid,905)],accent,arrow=True)
rect(sx,912,350,208,'#f8faf7', '#e2eae1',10)
text(f'{prefix}-{si+1:02} / 数据分片',sx+15,939,14,INK,'600')
badge('主 + 区域 HA',sx+212,938,accent,'#edf3ec',124)
for kind,dx,label in [('db',62,'PostgreSQL'),('pool',175,'Aerarium'),('bucket',288,'S3')]:
icon(kind,sx+dx,989,accent,.8)
text(label,sx+dx,1033,13,INK,'500','middle')
# Every tier has an explicit local standby, linked by dots.
path([(sx+dx,1041),(sx+dx,1062)],accent,'2 4',width=1.1)
icon(kind,sx+dx,1080,accent,.38)
# Aerarium depends on both PostgreSQL and S3.
path([(sx+150,986),(sx+87,986)],accent,arrow=True)
path([(sx+200,986),(sx+263,986)],accent,arrow=True)
text('Object data pool',mid,1052,10,MUTED,'400','middle')
text('区域内副本 · 跨故障域放置',mid,1108,11,MUTED,'400','middle')
# Each shard gets its own, separately routed off-region copy.
dot(mid,1120,AMBER)
path([(center,403),(center,423),(gx,423),(gx,444)],accent,arrow=True)
text('TRAEFIK',ox+26,467,12,MUTED,'600')
text('生产冗余 · 可选',ox+26,491,12,MUTED)
for i,gx in enumerate(gateways):
icon('gateway',gx,467,accent,.85)
text(f'Traefik {i+1}',gx,510,14,INK,'500','middle')
if i == 1:
badge('HA 副本',gx+49,469,accent,'#edf5f0',85)
for nx,ny in zip(runtime_x,runtime_y):
path([(gx,523),(nx,ny-30)],accent,width=1.35,opacity=.65)
dot(gx,523,accent)
text('K₂,₅ 全连接',ox+23,556,11,accent)
for i,(nx,ny) in enumerate(zip(runtime_x,runtime_y)):
dot(nx,ny-30,accent)
icon('runtime',nx,ny,accent)
text(f'Nexus Runtime {i+1:02}',nx,ny+39,13,INK,'600','middle')
# VM deliberately offset to the lower right of its host.
path([(nx+26,ny+5),(nx+64,ny+5),(nx+64,ny+70),(nx+50,ny+70)],accent,'2 4',width=1)
icon('vm',nx+35,ny+70,accent,.55)
text('Aster VM',nx+35,ny+100,11,INK,'400','middle')
# Route storage traffic outside the label/VM, not through them.
path([(nx-26,ny),(nx-61,ny),(nx-61,747)],accent,width=1.1,opacity=.75)
path([(runtime_x[0]-61,747),(runtime_x[-1]-61,747)],accent,width=1.2)
path([(center,747),(center,793)],accent,arrow=True)
text('分片定位 / 对象访问',center+16,775,12,MUTED)
text('02 独立存储分片群',ox+26,775,13,INK,'600')
ys = [880,1020,1160]
for label,code,kind,dx in families:
cx=ox+dx
rect(cx-95,802,190,468,'#f8faf7','#e3ebe2',10)
text(label,cx,829,17,INK,'600','middle')
text('Object data pool' if code=='AO' else '独立分片群',cx,850,11,MUTED,'400','middle')
for i,sy in enumerate(ys):
icon(kind,cx,sy,accent,.8)
text(f'{prefix}-{code}-{i+1:02}',cx,sy+36,12,INK,'500','middle')
path([(cx,sy+43),(cx,sy+54)],accent,'2 4',width=1)
icon(kind,cx,sy+69,accent,.32)
text('区域 HA',cx+23,sy+73,10,MUTED)
dot(cx,1270,AMBER)
backup_ports.append((prefix,code,cx))
# K3,3 on each side: every Aerarium shard can address every PG/S3 shard.
# The two storage families do not gain a fictitious direct PG-to-S3 edge.
pg,ao,s3 = ox+150,ox+435,ox+720
for ay in ys:
path([(ao-25,ay),(ao-75,ay)],accent,width=1.1)
path([(ao+25,ay),(ao+75,ay)],accent,width=1.1)
for by in ys:
path([(ao-75,ay),(pg+75,by)],accent,width=1.15,opacity=.55)
path([(ao+75,ay),(s3-75,by)],accent,width=1.15,opacity=.55)
dot(ao-75,ay,accent,2.5); dot(ao+75,ay,accent,2.5)
for sy in ys:
path([(pg+75,sy),(pg+25,sy)],accent,width=1.1,arrow=True)
path([(s3-75,sy),(s3-25,sy)],accent,width=1.1,arrow=True)
dot(pg+75,sy,accent,2.5); dot(s3-75,sy,accent,2.5)
text('Aerarium ↔ PG / S3:各分片群全连接,按归属与映射定位数据',center,1290,12,MUTED,'400','middle')
# Cross-region origin fill. CDN is intentionally outside private dynamic API.
path([(1160,253),(1160,278),(1718,278),(1718,883),(1680,883),(1680,989),(1627,989)],BLUE,'2 6',arrow=True)
rect(1698,582,58,46,'#fcfdfb','none',4)
text('CDN',1727,600,12,BLUE,anchor='middle')
text('S3 回源',1727,621,11,MUTED,anchor='middle')
# Control-plane key retrieval, deliberately not a runtime hosting dependency.
path([(1540,245),(1540,309),(869,309),(869,705)],PURPLE,'5 6',arrow=True)
path([(869,705),(710,705)],PURPLE,'5 6',arrow=True)
path([(869,705),(1020,705)],PURPLE,'5 6',arrow=True)
rect(791,682,156,39,'#fcfdfb','none',4)
text('JWKS / 凭证验证',869,699,12,PURPLE,anchor='middle')
text('Nexus 本地验证',869,716,11,PURPLE,anchor='middle')
# Identity is a control-plane relation; representative endpoints are labeled.
path([(1750,180),(1750,227),(935,227),(935,653)],PURPLE,'5 6')
path([(935,653),(900,653),(900,605),(871,605)],PURPLE,'5 6',arrow=True)
path([(935,653),(985,653),(985,605),(1038,605)],PURPLE,'5 6',arrow=True)
rect(870,667,130,49,'#fcfdfb','none',4)
text('JWKS / 凭证验证',935,686,11,PURPLE,anchor='middle')
text('Nexus 本地验证',935,705,10,PURPLE,anchor='middle')
# CDN fills from an S3 shard, without crossing other storage labels.
path([(1300,180),(1300,207),(1975,207),(1975,885),(1808,885),(1808,880),(1705,880)],BLUE,'2 6',arrow=True)
rect(1948,559,49,51,'#fcfdfb','none',3)
text('CDN',1972,580,11,BLUE,anchor='middle')
text('回源',1972,601,11,BLUE,anchor='middle')
text('02 跨区域恢复层',45,1486,16,INK,'600')
text('每个源分片都有一个异地目标;正常读取优先本区域,故障时恢复或转至已追平的副本。',45,1514,14,MUTED)
# Source routes are independent, with a visible crossing gap at the center.
# Backups physically hosted in the opposite region are explicitly labeled.
backup_specs = [
('亚洲分片的异地备份 · 位于北美', 40, 'AS', [1109,1491], [239,621]),
('北美分片的异地备份 · 位于亚洲', 910, 'NA', [239,621], [1109,1491]),
]
for bi, (label, bx, source, sources, targets) in enumerate(backup_specs):
rect(bx,1280,780,174,'#fbf8f1','#e9dec8',14)
text(label,bx+25,1310,17,INK,'600')
for i,(origin,target) in enumerate(zip(sources,targets)):
lane=1180+bi*40+i*20
path([(origin,1120),(origin+12,1134),(origin+12,lane),(target-12,lane),(target-12,1280)],AMBER,'6 6',arrow=True)
icon('backup',target-77,1376,AMBER,.85)
text(f'{source}-{i+1:02} 备份集',target-38,1369,15,INK,'600')
text('PG + Aerarium + S3',target-38,1393,12,MUTED)
text('恢复点 / 清单校验 / 重试',target-38,1415,11,MUTED)
# A caption is painted over empty whitespace, no connectivity is implied.
rect(724,1148,312,25,'#fcfdfb','none',4)
text('异步复制 / 版本化备份',880,1166,13,AMBER,'500','middle')
path([(50,1555),(105,1555)],GREEN,arrow=True)
text('请求与数据依赖',118,1560,12,MUTED)
path([(355,1555),(410,1555)],PURPLE,'5 6')
text('身份控制 / 公钥',423,1560,12,MUTED)
path([(670,1555),(725,1555)],BLUE,'2 6')
text('缓存回源',738,1560,12,MUTED)
path([(950,1555),(1005,1555)],AMBER,'6 6')
text('跨区复制 / 备份',1018,1560,12,MUTED)
text('示意数量:2 区域 × 2 网关 × 3 Runtime × 2 分片;数量不构成部署要求。',45,1608,12,MUTED)
text('可恢复性以已持久化且已校验的恢复点为边界;异步 ≠ 零 RPO。',1715,1608,12,AMBER,anchor='end')
# One system, not a North America / Asia pair of backup silos.
for i,(prefix,code,cx) in enumerate(backup_ports):
dest={'PG':370,'AO':980,'S3':1580}[code]
lane=1325+i*18
path([(cx,1270),(cx,lane),(dest,lane),(dest,1460)],AMBER,'6 6',arrow=True)
rect(40,1460,1790,280,'#fbf8f1','#e9dec8',16)
text('03 统一备份系统',68,1502,23,INK,'600')
text('全区域 / 全部分片 · 统一编排、持久化、校验与恢复',68,1531,14,MUTED)
for kind,cx,label,line1,line2 in [
('db',370,'PostgreSQL 备份','基础备份 + WAL / 恢复点','各区域 PostgreSQL 分片的事务恢复材料'),
('pool',980,'Aerarium 备份','对象池状态 + 索引 / 版本映射','关联对象、PG 恢复点与 S3 对象版本'),
('bucket',1580,'S3 备份','对象版本 + Blob / 快照','各区域 S3 分片的实际对象与文件数据'),
]:
icon(kind,cx-145,1596,AMBER,1.05)
text(label,cx-95,1587,18,INK,'600')
text(line1,cx-95,1615,14,MUTED)
text(line2,cx-95,1641,12,MUTED)
path([(90,1670),(1780,1670)],'#e3d5b9',width=1)
text('统一恢复目录:区域 / 存储类型 / 分片 ID / 恢复清单 / 校验和 / 重试状态',935,1709,14,AMBER,'500','middle')
path([(50,1790),(105,1790)],GREEN,arrow=True)
text('请求 / 存储依赖',118,1795,12,MUTED)
path([(365,1790),(420,1790)],PURPLE,'5 6')
text('身份控制 / 公钥',433,1795,12,MUTED)
path([(680,1790),(735,1790)],BLUE,'2 6')
text('缓存回源',748,1795,12,MUTED)
path([(965,1790),(1020,1790)],AMBER,'6 6')
text('统一备份采集',1033,1795,12,MUTED)
text('实线为逻辑可达关系;全连接不表示广播,也不表示任意分片拥有相同数据。',40,1837,12,MUTED)
text('备份保证以已持久化且已校验的恢复点为边界。',1830,1837,12,AMBER,anchor='end')
output.parent.mkdir(parents=True, exist_ok=True)
svg.save_svg(str(output))
+23 -12
View File
@@ -1,4 +1,4 @@
"""Protect the relationships that make this a topology rather than a block chart."""
"""Protect the topology, independent shard sets and unified backup boundary."""
from pathlib import Path
import tempfile
import unittest
@@ -17,21 +17,32 @@ class ArchitectureTest(unittest.TestCase):
root = ET.parse(first).getroot()
ns = {'s': 'http://www.w3.org/2000/svg'}
texts = [''.join(e.itertext()) for e in root.findall('.//s:text', ns)]
self.assertEqual(texts.count('Aster VM'), 6)
for i in range(1, 4):
self.assertEqual(texts.count(f'Nexus Runtime {i}'), 2)
self.assertEqual(texts.count('Aster VM'), 10)
for i in range(1, 6):
self.assertEqual(texts.count(f'Nexus Runtime {i:02}'), 2)
for i in range(1, 3):
self.assertEqual(texts.count(f'Traefik {i}'), 2)
for prefix in ('NA', 'AS'):
for i in range(1, 3):
self.assertIn(f'{prefix}-{i:02} / 数据分片', texts)
self.assertIn(f'{prefix}-{i:02} 备份集', texts)
mesh_edges = [e for e in root.findall('.//s:path', ns)
if e.get('stroke-width') == '1.35' and e.get('opacity') == '0.65']
self.assertEqual(len(mesh_edges), 12, 'Each region must retain the full K2,3 mesh')
# The figure must remain self-contained and editable.
for family in ('PG', 'AO', 'S3'):
for i in range(1, 4):
self.assertIn(f'{prefix}-{family}-{i:02}', texts)
paths = root.findall('.//s:path', ns)
runtime_mesh = [e for e in paths if e.get('stroke-width') == '1.35'
and e.get('opacity') == '0.65']
self.assertEqual(len(runtime_mesh), 20, 'Two complete K2,5 gateway meshes')
storage_mesh = [e for e in paths if e.get('stroke-width') == '1.15'
and e.get('opacity') == '0.55']
self.assertEqual(len(storage_mesh), 36, 'Each region has two K3,3 storage meshes')
self.assertEqual(texts.count('03 统一备份系统'), 1)
for family in ('PostgreSQL', 'Aerarium', 'S3'):
self.assertIn(f'{family} 备份', texts)
self.assertIn('… / etc', texts)
self.assertIn('其他区域', texts)
self.assertNotIn('PG + Aerarium + S3', texts)
self.assertNotIn('概念设计 · 草案', texts)
self.assertNotIn('一个全球入口,多个区域,一组可恢复的对象。', texts)
self.assertFalse(any('位于北美' in t or '位于亚洲' in t for t in texts))
self.assertEqual(root.findall('.//s:image', ns), [])
self.assertIn('异步复制 / 版本化备份', texts)
if __name__ == '__main__':