docs/dev-guides/9-workspace-storage-architecture.md
Scope: 磁盘目录结构、每个持久化文件的职责、WorkspaceManager 与 Workspace 的分工、 三种后端模式(local / azure_blob / ephemeral)的差异。
DATA_FORMULATOR_HOME/ # 默认 ~/.data_formulator,可通过 --data-dir 覆盖
├── credentials.db # SQLite — 加密的 Data Connector 凭据
├── .vault_key # AES 密钥,用于加解密 credentials.db
├── connectors.yaml # 管理员级 Data Connector 配置(全局)
│
└── users/
└── <identity_id>/ # 每个用户一个目录
├── connectors/ # 用户级 Data Connector 配置(每连接器一个 JSON)
│ ├── postgresql_prod-db.json
│ └── mysql_analytics.json
├── catalog_cache/ # 数据源 catalog 元数据快照
│ ├── postgresql_prod-db.json
│ └── mysql_analytics.json
└── workspaces/
└── <workspace_id>/ # 每个 workspace 一个目录
├── workspace_meta.json # 轻量索引 — 用于列表页快速展示
├── workspace.yaml # 表元数据 — 后端数据层的核心索引
├── session_state.json # 前端 Redux 状态快照
├── .workspace.lock # 并发写锁(运行时产生)
└── data/
├── gapminder.parquet
├── sales_data.parquet
└── query_1.xlsx # 上传的原始文件
| 来源 | 格式 | 示例 |
|---|---|---|
| 匿名浏览器 | browser:<uuid> | browser:101fdf9b-6213-456d-8fa9-6b9adeb32b0a |
| OIDC 登录 | user:<sub> | user:7 |
| 本地开发 | local:<os_user> | local:admin |
目录名经过 secure_filename() 清洗,例如 browser:101fdf9b-... → browser101fdf9b-...。
workspace_meta.json — 列表页索引Owner: WorkspaceManager(Python)
大小: ~150 bytes
写入时机: create_workspace(), save_session_state(), update_display_name()
update_display_name() 采用 write-through 策略:同时写 workspace_meta.json 和
session_state.json(patch activeWorkspace.displayName),确保两个文件的 displayName
始终一致——即使被重命名的 workspace 不是当前前端打开的那个。
Azure Blob 后端因 session blob 可能好几 MB,下载+重上传开销大,不做 write-through;
如果出现不一致,用户再改一次名字即可,下次 auto-save 会同步。
{
"id": "session_20260426_212411_1503",
"displayName": "全球发展洞察台",
"updatedAt": "2026-04-26T14:48:43.037489+00:00",
"tableCount": 6,
"chartCount": 4
}
用途: list_workspaces() 只读这个文件,不读 session_state.json(几 MB),
实现 O(n × 150B) 的列表扫描。
workspace.yaml — 后端表元数据Owner: Workspace 类(Python)
大小: 几 KB ~ 几十 KB(只存 schema,不存数据行)
写入时机: Workspace.__init__(首次创建)、write_parquet()、add_table_metadata() 等表操作
version: '1.1'
created_at: '2026-04-26T13:24:16+00:00'
updated_at: '2026-04-26T14:37:57+00:00'
tables:
gapminder:
source_type: data_loader # upload | data_loader
filename: gapminder.parquet # data/ 下的物理文件名
file_type: parquet
content_hash: f4cca39e... # 内容指纹,用于刷新去重
file_size: 16211
row_count: 682
description: "源系统提供的表描述" # 可选,只读 system description
columns:
- {name: year, dtype: int64, description: "年份"}
- {name: country, dtype: object}
loader_type: superset # Data Loader 来源信息(可选)
source_table: gapminder # 远程表名(可选)
用途:
TableMetadata.description、ColumnInfo.description)不存: 数据行、图表配置、UI 状态、用户编辑的 attachedMetadata。
源系统描述与用户描述是两个独立字段:
| 字段 | Owner | 来源 | 权限 | 写入时机 |
|---|---|---|---|---|
TableMetadata.description | 后端 Workspace | 源系统表/数据集/报表描述 | 只读展示 | Data Loader import / refresh |
ColumnInfo.description | 后端 Workspace | 源系统字段注释 | 只读展示 | Data Loader import / refresh |
DictTable.attachedMetadata | 前端 Redux | 用户手写业务说明 | 用户可编辑 | 元数据弹窗保存 |
规则:
attachedMetadata。attachedMetadata 不写入 workspace.yaml,随前端状态进入 session_state.json。description 表示源端已清空描述;缺少 description key 表示保留已有描述。workspace.yaml 没有列描述时必须继续正常加载。session_state.json — 前端状态快照Owner: 前端 Redux store → WorkspaceManager.save_session_state()
大小: 几百 KB ~ 几 MB(含表的全量行数据)
写入时机: 前端自动保存(定时 + 切换 workspace)
{
"tables": [
{
"id": "gapminder",
"names": ["year", "country", ...],
"rows": [/* 682 行全量数据 */],
"source": {"type": "example", "url": "..."}
}
],
"charts": [/* 图表配置 */],
"draftNodes": [/* 编码面板 */],
"conceptShelfItems": [/* 概念架 */],
"messages": [/* 聊天记录 */],
"config":,
"activeWorkspace": {"displayName": "..."}
}
用途: 加载 workspace 时恢复完整前端状态(表数据 + 图表 + 对话 + 布局)。
敏感字段自动剥离(不持久化):
models, selectedModelId, testedModels, dataLoaderConnectParams,
identity, agentRules, serverConfig。
data/ 目录 — 物理数据文件存放 parquet(Agent 衍生表、Data Loader 导入)和用户上传的原始文件(csv, xlsx 等)。
文件名由 workspace.yaml 中的 filename 字段索引。
.workspace.lock — 并发写锁WorkspaceLock 上下文管理器使用的锁文件。Windows 用 LockFileEx,Unix 用 fcntl.flock。
保护 workspace.yaml 的读-改-写原子性。运行时产生,无需手动管理。
connectors/ — Data Connector 配置(用户级)位置: users/<identity>/connectors/<source_id>.json(不在 workspace 内)
用途: 记录用户创建的 Data Connector 实例(类型、连接参数,不含密码)。
每个连接器一个 JSON 文件,支持原子化增删。凭据存储在全局 credentials.db 中。
{
"source_id": "postgresql:prod-db",
"loader_type": "postgresql",
"display_name": "Production DB",
"default_params": {"host": "db.corp", "database": "analytics"},
"icon": "postgresql"
}
Breaking change: 旧版 connectors.yaml 格式已不再支持。升级后需重新创建连接器。
catalog_cache/ — 数据源 Catalog 元数据快照(用户级)位置: users/<identity>/catalog_cache/<source_id>.json(不在 workspace 内)
用途: 缓存数据源的轻量 catalog 元数据(表名、描述、列名、列类型),
供 Agent 搜索工具在无活跃连接时也能发现数据。
文件形状:
{
"source_id": "postgresql:prod-db",
"tables": [
{
"name": "public.orders",
"metadata": {
"_source_name": "public.orders",
"description": "订单事实表",
"columns": [
{"name": "order_id", "type": "INTEGER", "description": "订单唯一标识"},
{"name": "created_at", "type": "TIMESTAMP"}
]
}
}
]
}
写入时机: 连接数据源成功后,best-effort 调用 list_tables() 并持久化。
点击刷新 catalog 时,也会重新调用 list_tables() 并覆盖同名缓存文件。
删除时机: 断开连接或删除连接器时同步清理。
不常驻内存: 搜索时按需加载,用完释放。
安全边界: cache 位于当前 identity 的用户目录,只保存 catalog 轻量信息,不能包含 loader_params、凭据、连接串或内部文件路径。
Agent 的 search_data_tables 使用两层只读搜索:
WorkspaceMetadata.search_tables() 搜索当前 workspace 已导入表。catalog_cache/*.json 搜索当前用户已连接数据源的轻量 catalog。缓存不存在或损坏时跳过,不触发远程连接或实时拉取。
WorkspaceManager Workspace
(管理 workspace 生命周期) (管理单个 workspace 内的数据)
┌──────────────────────┐ ┌──────────────────────┐
│ create_workspace() │ │ write_parquet() │
│ list_workspaces() │ open ──────► │ read_data_as_df() │
│ delete_workspace() │ │ add_table_metadata() │
│ save_session_state() │ │ get_metadata() │
│ load_session_state() │ │ export_session_zip() │
│ workspace_exists() │ │ run_parquet_sql() │
└──────────────────────┘ └──────────────────────┘
操作 workspace_meta.json 操作 workspace.yaml
操作 session_state.json 操作 data/ 下的文件
核心约定:
WorkspaceManagerget_workspace() → Workspaceget_workspace() 包含懒创建逻辑:frontend 生成 ID,backend 首次使用时创建Workspace 是否存在由 目录是否存在 唯一决定。
如果目录存在但缺少 workspace_meta.json(老版本遗留),自动补写修复。
# workspace_manager.py
def workspace_exists(self, workspace_id: str) -> bool:
"""目录存在 = workspace 存在。"""
return (self._root / self._safe_id(workspace_id)).is_dir()
list_workspaces() 遍历子目录时,对缺少 workspace_meta.json 的目录
调用 _ensure_meta() 自动补写,使其在列表中可见:
def _ensure_meta(self, workspace_id: str) -> dict:
"""缺少 workspace_meta.json 时,从已有信息推断并补写。"""
meta_file = self._root / self._safe_id(workspace_id) / WORKSPACE_META_FILENAME
if meta_file.exists():
return json.loads(meta_file.read_text(encoding="utf-8"))
# 从 session_state.json 推断 displayName;缺失则用 ID
self._write_meta(workspace_id, workspace_id)
return json.loads(meta_file.read_text(encoding="utf-8"))
create_workspace() 的防重复检查也统一为目录检查(与 workspace_exists 语义一致)。
旧代码中三个方法用不同标准判断 workspace 是否存在:
| 方法 | 旧判断依据 | 问题 |
|---|---|---|
list_workspaces | 只看 workspace_meta.json | 老 workspace 不可见 |
workspace_exists | meta.json OR yaml OR state.json | 和 list 不一致 |
create_workspace | 目录是否存在 | 和 exists 不一致 |
导致"幽灵 workspace":存在但在列表中看不见,也无法用同 ID 创建新的。
通过 --workspace-backend 或 WORKSPACE_BACKEND 环境变量选择。
DATA_FORMULATOR_HOME/users/<id>/workspaces/<ws_id>/WorkspaceManager → Workspaceusers/<id>/workspaces/<ws_id>/ 组织AzureBlobWorkspaceManager → AzureBlobWorkspace(自带下载缓存)_workspace_tables 发送全量表数据atexit 清理临时目录--disable-database 模式(无服务端持久化)DEFAULT_ROW_LIMIT_EPHEMERAL),以兼顾浏览器性能行数限制: 两种模式的数据导入行数由统一的
frontendRowLimit(前端)和MAX_IMPORT_ROWS(后端硬上限 200 万)控制。详见docs/dev-guides/13-unified-row-limits.md。
用户拖入 Excel
→ POST /api/upload-data
→ get_workspace() → Workspace
→ save_uploaded_file() → data/sales.xlsx
→ 转 parquet → data/sales_xlsx_sheet1.parquet
→ workspace.yaml 新增 table entry
→ 前端收到 rows/schema → Redux → 自动保存
→ POST /api/sessions/save
→ WorkspaceManager.save_session_state()
→ session_state.json(含 rows)
→ workspace_meta.json(tableCount++)
用户提交 prompt
→ POST /api/data-agent-streaming
→ get_workspace() → Workspace
→ Agent 生成 Python 代码
→ sandbox 执行 → 产出 DataFrame
→ write_parquet() → data/d_result.parquet
→ workspace.yaml 新增 table entry
用户点击 workspace 列表项
→ POST /api/sessions/load {id: "session_xxx"}
→ WorkspaceManager.load_session_state()
→ 读 session_state.json → 返回完整前端状态
→ 前端 Redux hydrate → 恢复表/图表/对话
| 维度 | workspace.yaml | session_state.json |
|---|---|---|
| Owner | 后端 Workspace | 前端 Redux → WorkspaceManager |
| 存什么 | 表的 schema + 物理文件索引 | 完整 UI 状态(含全量行数据) |
| 表数据行 | 不存 | 存(rows[]) |
| 图表/对话 | 不存 | 存 |
| 谁读 | Agent, DuckDB, 上传逻辑 | 前端加载 workspace 时 |
| 典型大小 | 几 KB | 几百 KB ~ 几 MB |
| 并发保护 | .workspace.lock 文件锁 | 无(单次完整覆写) |
两者在 schema 信息上有有意冗余:后端独立于前端状态就能知道表结构。
当修改 workspace 相关代码时:
data/ 子目录内(不在 workspace 根目录写数据文件)_atomic_update_metadata() 而非直接 save_metadata()workspace_exists 语义 = 目录存在,不要引入新的文件检查条件TableMetadata.to_dict() 和 from_dict()ColumnInfo.to_dict() 和 from_dict(),并验证旧 metadata 兼容TableMetadata.description / ColumnInfo.description 不覆盖前端 attachedMetadatacatalog_cache/<source_id>.json 的写入、刷新、断开和删除语义一致_SENSITIVE_FIELDS 集合,禁止持久化到 session_state.jsonAzureBlobWorkspaceManager 需同步修改