|
| 1 | +# EnvHub & Dataset 开发设计文档 |
| 2 | + |
| 3 | +## 目录 |
| 4 | + |
| 5 | +- [EnvHub(现有)](#envhub现有) |
| 6 | +- [Dataset 功能设计](#dataset-功能设计) |
| 7 | + - [背景与目标](#背景与目标) |
| 8 | + - [OSS 路径约定](#oss-路径约定) |
| 9 | + - [模块结构](#模块结构) |
| 10 | + - [核心模型](#核心模型) |
| 11 | + - [Registry 抽象层](#registry-抽象层) |
| 12 | + - [OssDatasetRegistry](#ossdatasetregistry) |
| 13 | + - [DatasetClient](#datasetclient) |
| 14 | + - [CLI 命令](#cli-命令) |
| 15 | + - [配置文件扩展](#配置文件扩展) |
| 16 | + - [数据流](#数据流) |
| 17 | + - [错误处理](#错误处理) |
| 18 | + - [测试策略](#测试策略) |
| 19 | + |
| 20 | +--- |
| 21 | + |
| 22 | +## EnvHub(现有) |
| 23 | + |
| 24 | +EnvHub 是 ROCK 的环境管理服务,提供 Docker 环境的注册、查询、列举和删除功能。 |
| 25 | + |
| 26 | +| 入口 | 模块 | 说明 | |
| 27 | +|----------------|-----------------------------|------------------------------| |
| 28 | +| `envhub` 服务 | `rock.envhub.server` | FastAPI 服务,端口 8081 | |
| 29 | +| SDK Client | `rock.sdk.envhub.client` | `EnvHubClient`,HTTP 调用服务 | |
| 30 | + |
| 31 | +REST 端点:`POST /env/register`、`POST /env/get`、`POST /env/list`、`POST /env/delete`、`GET /health` |
| 32 | + |
| 33 | +--- |
| 34 | + |
| 35 | +## Dataset 功能设计 |
| 36 | + |
| 37 | +### 背景与目标 |
| 38 | + |
| 39 | +在 ROCK 中引入 dataset 管理能力,核心目标如下: |
| 40 | + |
| 41 | +**1. 约束 datasets** |
| 42 | + |
| 43 | +统一 dataset 的路径约定、命名规范和存储格式,避免各服务自行散落地写 OSS 路径。所有 dataset 必须遵循 `datasets/{organization}/{dataset_name}/{split}/{task_id}/` 结构,由本模块作为唯一入口强制执行。 |
| 44 | + |
| 45 | +**2. 提供 SDK 和 CLI 供其他服务集成** |
| 46 | + |
| 47 | +- **SDK**(`rock.sdk.envhub.datasets`):提供 `DatasetClient`,供 Python 代码直接调用 list / upload,适合 admin、job 等服务在流程中集成 dataset 操作。 |
| 48 | +- **CLI**(`rock datasets`):提供 `list` 和 `upload` 子命令,供运维、研究人员在终端操作 dataset,也适合脚本化批量处理。 |
| 49 | + |
| 50 | +**3. 为后续权限管理预留扩展点** |
| 51 | + |
| 52 | +当前阶段 CLI 直接对接 OSS,不经过 envhub server。后续可在 envhub server 增加 `/datasets/*` 端点,在 SDK/CLI 与 OSS 之间插入权限校验、审计日志等能力,Registry 抽象层的设计为此预留了扩展空间。 |
| 53 | + |
| 54 | +--- |
| 55 | + |
| 56 | +### OSS 路径约定 |
| 57 | + |
| 58 | +路径层级设计对齐 **HuggingFace Datasets** 的命名惯例(`{organization}/{dataset_name}/{split}`),在此基础上增加了 ROCK 特有的 `{task_id}` 层来组织结构化的 benchmark task 目录。 |
| 59 | + |
| 60 | +``` |
| 61 | +oss://{bucket}/datasets/{organization}/{dataset_name}/{split}/{task_id}/ |
| 62 | +``` |
| 63 | + |
| 64 | +| 层级 | 说明 | 类比 HuggingFace | |
| 65 | +|------|------|-----------------| |
| 66 | +| `organization` | 数据集所属组织,如 `qwen`、`alibaba` | HF namespace(`qwen/`) | |
| 67 | +| `dataset_name` | 数据集名称 | HF repo name(`my-bench`) | |
| 68 | +| `split` | 分片标识,如 `train`、`test`、`v1.0` | HF split(`train`/`test`) | |
| 69 | +| `task_id` | 单个 task 目录名(ROCK 特有) | HF 无此层,HF 直接存数据文件 | |
| 70 | + |
| 71 | +示例: |
| 72 | + |
| 73 | +``` |
| 74 | +oss://my-bucket/ |
| 75 | +└── datasets/ |
| 76 | + └── qwen/ # organization |
| 77 | + └── my-bench/ # dataset_name |
| 78 | + └── train/ # split |
| 79 | + ├── task-001/ # task_id(ROCK 特有) |
| 80 | + │ ├── task.toml |
| 81 | + │ └── tests/ |
| 82 | + └── task-002/ |
| 83 | + ├── task.toml |
| 84 | + └── tests/ |
| 85 | +``` |
| 86 | + |
| 87 | +task 目录内的文件结构原样保留(相对路径不变),上传和下载均以 `task_id/` 为单位。 |
| 88 | + |
| 89 | +--- |
| 90 | + |
| 91 | +### 模块结构 |
| 92 | + |
| 93 | +``` |
| 94 | +rock/ |
| 95 | +├── sdk/ |
| 96 | +│ └── envhub/ |
| 97 | +│ ├── client.py # 现有:EnvHubClient |
| 98 | +│ ├── config.py # 现有 |
| 99 | +│ ├── schema.py # 现有 |
| 100 | +│ └── datasets/ # 新增 |
| 101 | +│ ├── __init__.py # 对外入口:暴露 DatasetClient、DatasetSpec 等 |
| 102 | +│ ├── models.py # DatasetSpec, UploadResult(OssRegistryInfo 复用自 bench) |
| 103 | +│ ├── client.py # DatasetClient(对外统一入口) |
| 104 | +│ └── registry/ |
| 105 | +│ ├── __init__.py |
| 106 | +│ ├── base.py # BaseDatasetRegistry ABC |
| 107 | +│ └── oss.py # OssDatasetRegistry |
| 108 | +└── cli/ |
| 109 | + └── command/ |
| 110 | + └── datasets.py # DatasetsCommand(继承 Command ABC) |
| 111 | +``` |
| 112 | + |
| 113 | +--- |
| 114 | + |
| 115 | +### 核心模型 |
| 116 | + |
| 117 | +**复用模型(来自 `rock.sdk.bench.models.job.config`,不新增、不移动)** |
| 118 | + |
| 119 | +| 模型 | 关键字段 | 用途 | |
| 120 | +|------|----------|------| |
| 121 | +| `OssRegistryInfo` | `oss_bucket`, `oss_endpoint`, `oss_region`, `oss_access_key_id`, `oss_access_key_secret`, `oss_dataset_path` | OSS 连接凭证与路径前缀(`oss_dataset_path` 默认 `"datasets"`) | |
| 122 | +| `LocalDatasetConfig` | `path: Path` | 本地 task 目录(upload 数据源) | |
| 123 | +| `RegistryDatasetConfig` | `name="org/dataset_name"`, `version=split`, `overwrite`, `registry=OssRegistryInfo(...)` | 远端数据集引用(upload 目标) | |
| 124 | + |
| 125 | +`RegistryDatasetConfig.name` 遵循 HuggingFace 惯例,使用 `"{organization}/{dataset_name}"` 格式;`OssDatasetRegistry` 通过 `name.split("/", 1)` 拆分得到 org 和 name。`version` 对应 split(如 `"train"`、`"test"`)。`DatasetSpec.id` / `UploadResult.id` 同样使用此格式,对齐 HF `DatasetInfo.id`。 |
| 126 | + |
| 127 | +**新增模型(`rock/sdk/envhub/datasets/models.py`)** |
| 128 | + |
| 129 | +```python |
| 130 | +@dataclass |
| 131 | +class DatasetSpec: |
| 132 | + id: str # "{organization}/{dataset_name}",对齐 HF DatasetInfo.id,如 "princeton-nlp/SWE-bench_Verified" |
| 133 | + split: str |
| 134 | + task_ids: list[str] |
| 135 | + |
| 136 | +@dataclass |
| 137 | +class UploadResult: |
| 138 | + id: str # "{organization}/{dataset_name}" |
| 139 | + split: str |
| 140 | + uploaded: int # 成功上传的文件数 |
| 141 | + skipped: int # 已存在跳过的 task 数(overwrite=False) |
| 142 | + failed: int # 失败数 |
| 143 | +``` |
| 144 | + |
| 145 | +--- |
| 146 | + |
| 147 | +### Registry 抽象层 |
| 148 | + |
| 149 | +**`rock/sdk/envhub/datasets/registry/base.py`** |
| 150 | + |
| 151 | +```python |
| 152 | +class BaseDatasetRegistry(ABC): |
| 153 | + |
| 154 | + @abstractmethod |
| 155 | + def list_datasets(self, organization: str | None = None) -> list[DatasetSpec]: |
| 156 | + """枚举 registry 中的所有 datasets。 |
| 157 | + organization 不为 None 时只返回该 org 下的 datasets。 |
| 158 | + """ |
| 159 | + ... |
| 160 | + |
| 161 | + @abstractmethod |
| 162 | + def upload_dataset( |
| 163 | + self, |
| 164 | + source: LocalDatasetConfig, |
| 165 | + target: RegistryDatasetConfig, |
| 166 | + concurrency: int = 4, |
| 167 | + ) -> UploadResult: |
| 168 | + """将 source.path/{task_id}/ 批量上传到 target 指定的远端路径。 |
| 169 | + org/name/split/overwrite 均从 target 提取。 |
| 170 | + """ |
| 171 | + ... |
| 172 | +``` |
| 173 | + |
| 174 | +--- |
| 175 | + |
| 176 | +### OssDatasetRegistry |
| 177 | + |
| 178 | +**`rock/sdk/envhub/datasets/registry/oss.py`** |
| 179 | + |
| 180 | +```python |
| 181 | +class OssDatasetRegistry(BaseDatasetRegistry): |
| 182 | + def __init__(self, registry: OssRegistryInfo): ... |
| 183 | +``` |
| 184 | + |
| 185 | +**路径构建:** |
| 186 | + |
| 187 | +```python |
| 188 | +def _build_prefix(self, org: str, name: str, split: str | None = None) -> str: |
| 189 | + base = self._registry.oss_dataset_path or "datasets" |
| 190 | + parts = [base, org, name] |
| 191 | + if split: |
| 192 | + parts.append(split) |
| 193 | + return "/".join(parts) |
| 194 | +# → "datasets/qwen/my-bench/train" |
| 195 | +``` |
| 196 | + |
| 197 | +**list_datasets 逻辑:** |
| 198 | + |
| 199 | +1. 以 `datasets/` 为前缀列出三级目录(org → name → split) |
| 200 | +2. 对每个 `datasets/{org}/{name}/{split}/`,列出直接子目录作为 `task_ids` |
| 201 | +3. 返回 `list[DatasetSpec]` |
| 202 | + |
| 203 | +OSS 列举使用 `list_objects_v2` with `delimiter="/"` 逐层枚举目录,避免全量遍历。 |
| 204 | + |
| 205 | +**upload_dataset 逻辑:** |
| 206 | + |
| 207 | +1. 从 `target.name.split("/", 1)` 提取 `org` 和 `name`;`target.version` 为 `split`;`target.overwrite` 为覆盖标志 |
| 208 | +2. 遍历 `source.path` 下的一级子目录,每个子目录视为一个 task(`task_id = subdir.name`) |
| 209 | +3. 若 `target.overwrite=False` 且 OSS 上已存在该 task 目录,跳过 |
| 210 | +4. 并发上传(`ThreadPoolExecutor`,`concurrency` 控制并发数) |
| 211 | +5. 目标 key:`datasets/{org}/{name}/{split}/{task_id}/{relative_file_path}` |
| 212 | +6. 返回 `UploadResult` |
| 213 | + |
| 214 | +--- |
| 215 | + |
| 216 | +### DatasetClient |
| 217 | + |
| 218 | +**`rock/sdk/envhub/datasets/client.py`** |
| 219 | + |
| 220 | +薄封装层,负责从配置创建 registry 并提供面向业务的方法。 |
| 221 | + |
| 222 | +```python |
| 223 | +class DatasetClient: |
| 224 | + def __init__(self, registry: OssRegistryInfo): |
| 225 | + self._registry = OssDatasetRegistry(registry) |
| 226 | + |
| 227 | + def list_datasets(self, org: str | None = None) -> list[DatasetSpec]: |
| 228 | + return self._registry.list_datasets(org) |
| 229 | + |
| 230 | + def upload_dataset( |
| 231 | + self, |
| 232 | + source: LocalDatasetConfig, |
| 233 | + target: RegistryDatasetConfig, |
| 234 | + concurrency: int = 4, |
| 235 | + ) -> UploadResult: |
| 236 | + return self._registry.upload_dataset(source, target, concurrency) |
| 237 | +``` |
| 238 | + |
| 239 | +--- |
| 240 | + |
| 241 | +### CLI 命令 |
| 242 | + |
| 243 | +**`rock/cli/command/datasets.py`**,继承 `Command` ABC,`name = "datasets"`。 |
| 244 | + |
| 245 | +#### rock datasets list |
| 246 | + |
| 247 | +``` |
| 248 | +rock datasets list [OPTIONS] |
| 249 | +
|
| 250 | +Options: |
| 251 | + --org TEXT 只列出指定 organization 的 datasets |
| 252 | + --bucket TEXT OSS bucket 名称(覆盖 config.ini) |
| 253 | + --endpoint TEXT OSS endpoint(覆盖 config.ini) |
| 254 | + --access-key-id TEXT OSS access key ID(覆盖 config.ini) |
| 255 | + --access-key-secret TEXT OSS access key secret(覆盖 config.ini) |
| 256 | +``` |
| 257 | + |
| 258 | +输出示例: |
| 259 | + |
| 260 | +``` |
| 261 | +Dataset Split Tasks |
| 262 | +qwen/my-bench train 42 |
| 263 | +qwen/my-bench test 10 |
| 264 | +alibaba/code-eval train 100 |
| 265 | +``` |
| 266 | + |
| 267 | +#### rock datasets upload |
| 268 | + |
| 269 | +``` |
| 270 | +rock datasets upload [OPTIONS] |
| 271 | +
|
| 272 | +Required: |
| 273 | + --org TEXT Organization 名称 |
| 274 | + --dataset TEXT Dataset 名称 |
| 275 | + --split TEXT Split 名称(如 train、test、v1.0) |
| 276 | + --dir PATH 本地 task 目录(内含 {task_id}/ 子目录) |
| 277 | +
|
| 278 | +Options: |
| 279 | + --bucket TEXT OSS bucket(覆盖 config.ini) |
| 280 | + --endpoint TEXT OSS endpoint(覆盖 config.ini) |
| 281 | + --access-key-id TEXT OSS access key ID(覆盖 config.ini) |
| 282 | + --access-key-secret TEXT OSS access key secret(覆盖 config.ini) |
| 283 | + --concurrency INT 并发上传数(默认 4,范围 1-16) |
| 284 | + --overwrite 覆盖 OSS 上已存在的 task 目录(默认跳过) |
| 285 | +``` |
| 286 | + |
| 287 | +输出示例: |
| 288 | + |
| 289 | +``` |
| 290 | +Uploading to oss://my-bucket/datasets/qwen/my-bench/train/ |
| 291 | + ✓ task-001 (5 files) |
| 292 | + ✓ task-002 (5 files) |
| 293 | + - task-003 skipped (already exists) |
| 294 | +
|
| 295 | +Done: 2 uploaded, 1 skipped, 0 failed |
| 296 | +``` |
| 297 | + |
| 298 | +--- |
| 299 | + |
| 300 | +### 配置文件扩展 |
| 301 | + |
| 302 | +在 `.rock/config.ini` 新增 `[dataset]` section,用于存储 OSS 凭证默认值: |
| 303 | + |
| 304 | +```ini |
| 305 | +[rock] |
| 306 | +base_url = http://localhost:8080 |
| 307 | + |
| 308 | +[dataset] |
| 309 | +oss_bucket = my-bucket |
| 310 | +oss_endpoint = https://oss-cn-hangzhou.aliyuncs.com |
| 311 | +oss_access_key_id = LTAI5t... |
| 312 | +oss_access_key_secret = xxxxxxx |
| 313 | +``` |
| 314 | + |
| 315 | +**优先级(高→低)**:CLI 参数 > `config.ini [dataset]` section > 报错(必填项缺失) |
| 316 | + |
| 317 | +`ConfigManager` 扩展:在 `CLIConfig` 新增 `dataset_config: DatasetConfig` 字段(内部结构体),读取 `[dataset]` section 中的 OSS 凭证。`DatasetsCommand` 在初始化时合并 `DatasetConfig` + CLI args 构建 `OssRegistryInfo`(来自 `rock.sdk.bench.models.job.config`)。 |
| 318 | + |
| 319 | +--- |
| 320 | + |
| 321 | +### 数据流 |
| 322 | + |
| 323 | +**list:** |
| 324 | + |
| 325 | +``` |
| 326 | +rock datasets list --org qwen |
| 327 | + └─ DatasetCommand.list() |
| 328 | + ├─ ConfigManager.get_dataset_config() # 读 config.ini [dataset] |
| 329 | + ├─ 合并 CLI 参数 → OssRegistryInfo |
| 330 | + ├─ DatasetClient(config) |
| 331 | + └─ OssDatasetRegistry.list_datasets(org="qwen") |
| 332 | + └─ alibabacloud_oss_v2: list_objects_v2(prefix="datasets/qwen/", delimiter="/") |
| 333 | + → 枚举 name/split 层 |
| 334 | + → 构建 DatasetSpec 列表 |
| 335 | + → 打印表格 |
| 336 | +``` |
| 337 | + |
| 338 | +**upload:** |
| 339 | + |
| 340 | +``` |
| 341 | +rock datasets upload --org qwen --dataset my-bench --split train --dir ./tasks/ |
| 342 | + └─ DatasetsCommand.upload() |
| 343 | + ├─ ConfigManager.get_config().dataset_config # 读 config.ini [dataset] |
| 344 | + ├─ 合并 CLI 参数 → OssRegistryInfo |
| 345 | + ├─ source = LocalDatasetConfig(path=./tasks/) |
| 346 | + ├─ target = RegistryDatasetConfig( |
| 347 | + │ name="qwen/my-bench", version="train", |
| 348 | + │ overwrite=False, registry=OssRegistryInfo) |
| 349 | + ├─ DatasetClient(registry=OssRegistryInfo) |
| 350 | + └─ OssDatasetRegistry.upload_dataset(source, target, concurrency=4) |
| 351 | + ├─ target.name.split("/", 1) → org="qwen", name="my-bench" |
| 352 | + ├─ target.version → split="train" |
| 353 | + ├─ 遍历 source.path 下子目录:task-001/, task-002/, ... |
| 354 | + ├─ ThreadPoolExecutor(max_workers=concurrency) |
| 355 | + └─ 每个 task: |
| 356 | + ├─ 若 target.overwrite=False 且 OSS 存在 → skip |
| 357 | + └─ 遍历文件 → PutObject(key="datasets/qwen/my-bench/train/task-001/{file}") |
| 358 | +``` |
| 359 | + |
| 360 | +--- |
| 361 | + |
| 362 | +### 错误处理 |
| 363 | + |
| 364 | +| 场景 | 行为 | |
| 365 | +|------|------| |
| 366 | +| OSS 凭证缺失 | 启动时立即报错,提示配置 `[dataset]` section 或传 CLI 参数 | |
| 367 | +| OSS 权限错误(401/403) | 立即抛出,打印明确错误信息,不重试 | |
| 368 | +| OSS 网络错误(5xx/timeout) | 指数退避重试(最多 3 次),超限后报错 | |
| 369 | +| `--dir` 不存在或为空 | 命令入口检查,立即报错 | |
| 370 | +| 单个 task 上传失败 | 记录到 `UploadResult.failed`,继续上传其他 tasks,命令结束后汇总报告 | |
| 371 | +| `--org`/`--dataset`/`--split` 缺失 | argparse required 校验,自动报错 | |
| 372 | + |
| 373 | +--- |
| 374 | + |
| 375 | +### 测试策略 |
| 376 | + |
| 377 | +| 测试类型 | 覆盖范围 | 标记 | |
| 378 | +|----------|----------|------| |
| 379 | +| 单元测试 | `OssDatasetRegistry` 路径构建、`DatasetSpec` 模型、`ConfigManager` 解析 `[dataset]` | 无特殊标记 | |
| 380 | +| 集成测试(mock OSS) | `list_datasets`、`upload_dataset` 逻辑,使用 `unittest.mock` mock OSS SDK | `@pytest.mark.integration` | |
| 381 | +| 集成测试(真实 OSS) | 端到端 upload → list 验证 | `@pytest.mark.need_admin`(需要 OSS 凭证) | |
| 382 | + |
| 383 | +测试文件位置: |
| 384 | + |
| 385 | +``` |
| 386 | +tests/ |
| 387 | +├── unit/ |
| 388 | +│ └── datasets/ |
| 389 | +│ ├── test_models.py |
| 390 | +│ ├── test_oss_registry.py |
| 391 | +│ └── test_config.py |
| 392 | +└── integration/ |
| 393 | + └── datasets/ |
| 394 | + └── test_oss_e2e.py |
| 395 | +``` |
0 commit comments