21 KiB
项目实施方案:xcecli jobs peek
背景
用户需要在 XCE 集群管理系统中添加 xcecli jobs peek 子命令,用于实时查看运行中作业的标准输出和标准错误输出。
架构问题
输出文件由 worker 进程(xcecd)通过 dup2() 重定向写入,文件位于 计算节点的本地文件系统 上(默认 /tmp/ 或配置路径)。API server 运行在独立节点上,无法直接访问 远程 worker 节点的文件。
解决方案
采用 方案 B:通过 controller → worker 协议链路远程拉取文件内容。需要新增 OPCode 和 cbuf 超时机制。
当前实施状态
Phase 2(Go 层)已完成,Phase 1(C 层)尚未开始。
现有
controllers/output.go采用方案 A(本地读文件),直接os.Open()读取本地文件系统。这在 apiserver 与 worker 同节点时可用,但多节点部署下不可靠。Phase 1 完成后,Phase 2 需要重写 output.go,将底层数据源切换为 CGO → controller → worker 链路。
分阶段目标
- 第一阶段:cbuf 超时机制 + 新 OPCode(libxce/controller/worker)— 待完成
- 第二阶段:将 output.go 切换为 CGO/FetchFile 实现(Plan A → Plan B)— 待完成
- 第三阶段:测试验证 — Go 层单元测试已完成,C 层及集成测试待完成
完整架构
┌──────────────────────────────────────────────────────────────────┐
│ 用户终端 (xcecli) │
│ HTTP/REST │
└──────────────────────────────┬───────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ API Server (Go, Gin) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ controllers/ │ │ backend/ │ │
│ │ output.go │ │ clib.go │ │
│ │ │ │ │ ┌─────────────────┐ │
│ │ 1. 解析 jobid │───>│ 2. CGO 调用 │ │ 独立 ctx (peek) │ │
│ │ 2. 查 exec_hosts│ │ xce_fetchfile() │ │ 不阻塞主连接 │ │
│ │ 3. 找目标 worker│ │ (新函数) │ │ timeout=10s │ │
│ └─────────────────┘ └────────┬────────┘ └─────────────────┘ │
│ │ │
│ store.Jobs │ 主 ctx(submit/kill/heartbeat) │
│ JobInfo{ │ │
│ OutFile, ErrFile, │ │
│ ExecHosts, JobStatus │ │
│ } │ │
└─────────────────────────────────┼──────────────────────────────────┘
│ TCP/TLS (libxce)
▼
┌───────────────────────────────────────────────────────────────────┐
│ Controller (C, xcecd) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────┐ │
│ │ cluster.c │ │ job.c │ │ vtaskmgr (8 vCPU) │ │
│ │ │ │ │ │ │ │
│ │ 查找 worker │───>│ 转发到 │ │ 并发处理不同 client │ │
│ │ by hostname │ │ worker ep │ │ 的请求 │ │
│ │ │ │ │ │ │ │
│ │ OPCode │ │ OPCode │ │ │ │
│ │ fetchfile │ │ fetchfile │ │ │ │
│ │ (新增) │ │ (新增) │ │ │ │
│ └─────────────┘ └──────┬──────┘ └──────────────────────┘ │
└────────────────────────────┼──────────────────────────────────────┘
│ TCP/TLS (libxce)
▼
┌───────────────────────────────────────────────────────────────────┐
│ Worker Node (C, xcecd worker) │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ controller_connector.c │ │
│ │ myreq_handler() │ │
│ │ │ │
│ │ case OPCode_fetchfile: (新增) │ │
│ │ 1. 解析请求: jobid, filepath, offset, maxBytes │ │
│ │ 2. os.Open(filepath) │ │
│ │ 3. Seek(offset) │ │
│ │ 4. Read(maxBytes) │ │
│ │ 5. 回复: content, fileSize, error_code │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 输出文件: /tmp/job_XXX.out (本地文件系统) │
│ 由 process_manager.c / uv_proc.c 通过 dup2() 创建 │
└───────────────────────────────────────────────────────────────────┘
实施详情
Phase 1: C 层 — libxce + Controller + Worker
1.1 Cap'n Proto 协议定义
文件: src/schema/controller.capnp(注意:不是 src/include/xce/,没有独立的 opcodes.h,OPCode 枚举内联在 capnp 文件中)
修改一:OPCode 枚举末尾追加
enum OPCode {
# ... 现有枚举项 (nop @0 .. clrpreason @21) ...
fetchfile @22; # 新增
}
修改二:新增两个结构体
struct FetchFileMsg $C.mapname("xce_fetchfile_t") {
jobid @0: UInt64 $C.mapname("jobid");
filepath @1: Text $C.mapname("filepath");
offset @2: UInt64 $C.mapname("offset");
maxBytes @3: UInt32 $C.mapname("max_bytes");
}
struct FetchFileReply $C.mapname("xce_fetchfile_reply_t") {
error @0: Int32 $C.mapname("error"); # 0=成功, -1=不存在, -2=读取错误, -3=权限拒绝
content @1: Data $C.mapname("content");
fileSize @2: UInt64 $C.mapname("file_size");
bytesRead @3: UInt32 $C.mapname("bytes_read");
}
修改三:在 Request.body union 中追加新分支(⚠️ 关键,计划原版遗漏)
Request 结构体的 body 字段是一个 union(@3 到 @21),fetchfile 消息必须加入才能被框架正确编解码:
struct Request $C.mapname("xce_request_t") {
# ...
body : union {
# ... 现有分支 (nomsg @3 .. hostctrlmsg @21) ...
fetchfilemsg @22: FetchFileMsg; # 新增
}
}
RequestReply.body union 中同样需要追加 fetchfile 回复分支,供 worker 回复时使用:
struct RequestReply $C.mapname("xce_reply_t") {
body : union {
# ... 现有分支 ...
fetchfilereply @N: FetchFileReply; # 新增,N 为现有最大值+1
}
}
1.2 cbuf 超时机制 — cbuf_timedget()
文件: src/lib/mylock.c、src/include/mylock.h(注意:头文件路径是 src/include/,不是 src/lib/include/)
// 新增函数,保留 cbuf_get() 不变
int cbuf_timedget(cbuf_t *b, void *item, struct timespec *deadline) {
pthread_mutex_lock(&b->mutex);
while (b->len == 0) {
int rc = pthread_cond_timedwait(&b->condget, &b->mutex, deadline);
if (rc == ETIMEDOUT) {
pthread_mutex_unlock(&b->mutex);
return -ETIMEDOUT;
}
}
// ... 正常读取逻辑(同 cbuf_get)
pthread_mutex_unlock(&b->mutex);
return 0;
}
受影响场景分析:
| 调用方 | 文件 | 是否改用 timed | 原因 |
|---|---|---|---|
| xceapi.c(15处,非13处) | src/lib/xceapi.c |
是 | 客户端 RPC,超时防止 goroutine 泄漏 |
| scheduler 发布决策 | src/scheduler/src/scheduler.c |
是 | 通过 xceapi.c 路径,自动受益 |
| scheduler 内部协调 | src/scheduler/src/scheduler.c:133,233 |
否 | 内部线程,无限等待合理 |
| fcfs plugin | src/scheduler/src/plugins/fcfs.c:213 |
否 | 内部触发,无限等待合理 |
| controller 主循环 | src/controller/cluster.c:768 |
否 | 守护进程主循环,设计意图 |
| vtaskmgr (4处) | src/lib/vtaskmgr.c |
否 | 内部线程池,由 pthread_cancel 保护 |
超时值设置: XCE_RPC_TIMEOUT = 10s(定义在 src/include/xce/config.h)
- submit/kill 等操作:通常 <100ms,10s 足够
- fetchfile 操作:需要 worker 读文件,可能稍慢,但不超过 10s
- scheduler 发布决策:通常 <1s,10s 足够
- 如果超时,返回
-ETIMEDOUT,调用方可以区分超时和其他错误
1.3 libxce 客户端函数
文件: src/lib/request.c, src/lib/xceapi.c
// src/lib/xceapi.c — 新增
int xce_fetchfile(xcecli_ctx_t *ctx, fetchfile_req_t *req, fetchfile_rsp_t *rsp) {
// 1. 编码请求
// 2. endpoint_enq() 发送
// 3. cbuf_timedget() 等待回复(超时 10s)
// 4. 解码回复到 rsp
}
// 分块读取辅助函数
int xce_fetchfile_chunked(xcecli_ctx_t *ctx, const char *hostname,
const char *filepath, uint64_t offset,
uint32_t maxBytes, char **content, uint64_t *totalSize);
1.4 Controller 代理
文件: src/controller/cluster.c(主路由)+ src/controller/job.c(具体实现)
所有 OPCode 的 case 路由都在 cluster.c 的消息分发 switch 中。在 cluster.c 中添加分发,在 job.c 中实现代理逻辑:
// cluster.c — 分发
case OPCode_fetchfile:
on_fetchfile(c, ep, request);
break;
// job.c — 代理实现
void on_fetchfile(cluster_t *c, endpoint_t *ep, xce_request_t *req) {
xce_fetchfile_t *fetchmsg = &req->body.fetchfilemsg;
// 1. 根据 jobid 或 filepath 内嵌的 hostname 查找 worker endpoint
endpoint_t *worker_ep = cluster_find_worker_by_jobid(c, fetchmsg->jobid);
if (!worker_ep) {
send_error_reply(ep, OPCode_fetchfile, -ENOENT);
return;
}
// 2. 转发请求到 worker(保留原始 seq,便于追踪)
// 3. 等待 worker 回复(通过独立 cbuf 或直接透传)
// 4. 将 worker 的 FetchFileReply 回复给 apiserver
}
1.5 Worker 处理
文件: src/worker/src/controller_connector.c
在 myreq_handler() 中添加:
case OPCode_fetchfile: {
fetchfile_req_t req;
decode_fetchfile_req(msg, &req);
// 安全校验:只允许读取作业相关的输出文件
// 1. 通过 job_information.h 中的 job_id 字段验证 jobid 属于此 worker
// (worker 用 job_info_t 结构体维护本地运行作业列表,字段 job_info_t.job_id)
// 2. 验证 filepath 是作业的 out_file 或 err_file 路径
// 3. 防止路径穿越(检查路径中是否包含 "..")
fetchfile_rsp_t rsp = {0};
rsp.fileSize = get_file_size(req.filepath);
if (req.offset >= rsp.fileSize) {
rsp.error = 0; // 没有新内容
rsp.bytesRead = 0;
} else {
FILE *f = fopen(req.filepath, "r");
if (!f) {
rsp.error = -1; // 文件不存在
} else {
fseek(f, req.offset, SEEK_SET);
rsp.bytesRead = fread(rsp.content, 1, req.maxBytes, f);
fclose(f);
rsp.error = 0;
}
}
send_reply(ep, OPCode_reply, &rsp);
}
Phase 2: Go 层 — 将 output.go 切换为 Plan B 实现
注意:以下文件均已存在并有完整的 Plan A 实现(本地读文件)。 Phase 2 的任务是在 Phase 1 C 层完成后,将底层数据源替换为 CGO → controller → worker 链路。 CLI 端(peek.go)、路由注册、models、request.go 无需改动。
2.1 API Server CGO 绑定
文件: src/frontend/apiserver/backend/clib.go(已有,追加内容)
// peekCtx 是专用于 peek/fetchfile 的独立 controller 连接。
// 独立连接避免 fetchfile 的阻塞等待占用主连接的 vtaskmgr 槽位。
//
// HostRole 决策:复用 HostRole_apiserver(controller 的 join 处理已支持
// 同角色多连接)。若未来需区分,可在 capnp 中新增 apiserverPeek @5,
// 并同步修改 controller 的 join/leave 处理。
var peekCtx C.backend_ctx_t
func initPeekContext(config *BackendConfig) error {
// 创建第 2 个连接,专用于 peek;join 以 HostRole_apiserver 身份
}
// FetchFile 通过 controller→worker 链路读取作业输出文件的一段内容。
func FetchFile(jobID uint64, filePath string, offset uint64, maxBytes uint32) (content []byte, fileSize uint64, err error) {
// CGO 调用 xce_fetchfile()
}
文件: src/frontend/apiserver/backend/stub.go(已有,必须同步追加 stub)
// FetchFile stub — 供 go:build !cgo 的单元测试编译。
func FetchFile(jobID uint64, filePath string, offset uint64, maxBytes uint32) ([]byte, uint64, error) {
return nil, 0, fmt.Errorf("stub: FetchFile not implemented")
}
2.2 output.go 重写要点
文件: src/frontend/apiserver/controllers/output.go(已存在,需重写底层读取逻辑)
当前 Plan A 实现直接读本地文件(os.Open → readFileTail / readFromOffset)。
Plan B 重写后:
- 解析
job.ExecHosts(类型Jsonb=[]byte)获取 worker hostname:var hosts []models.JobExecHosts json.Unmarshal([]byte(job.ExecHosts), &hosts) hostname := hosts[0].HostName // JobExecHosts.HostName 字段 - 调用
backend.FetchFile(job.JobID, filePath, uint64(offset), 65536) - 返回 content、新 offset(= offset + bytesRead)、fileSize
当前 Bug:Plan A 的
readFileTail调用os.ReadFile(path)将整个文件读入内存,大文件会 OOM。Plan B 通过maxBytes参数天然规避,重写时不必保留readFileTail。
Phase 3: 测试验证
3.1 C 层测试
测试文件位于 src/lib/testsuite/(已有 test_request.c 等)。
- 新建
src/lib/testsuite/test_cbuf.c:cbuf_timedget正常读取测试cbuf_timedget超时返回-ETIMEDOUT测试- 与
cbuf_put的并发测试
OPCode_fetchfile端到端测试(含 controller testsuite)- Worker 安全测试:路径穿越防护(
..构造的恶意路径)
3.2 Go 层测试
output_test.go(已有 15 个用例,覆盖 Plan A):Plan B 重写后需更新为 mockbackend.FetchFilepeek_test.go(已有):无需大改,轮询逻辑不变stub.go中的FetchFilestub 必须能让现有单元测试通过编译
文件清单
需新建的文件
| 文件 | 说明 |
|---|---|
src/lib/testsuite/test_cbuf.c |
cbuf_timedget 单元测试 |
需修改的文件(C 层 — Phase 1)
| 文件 | 修改内容 |
|---|---|
src/schema/controller.capnp |
① OPCode 枚举加 fetchfile @22;② 新增 FetchFileMsg、FetchFileReply 结构体;③ Request.body union 加 fetchfilemsg @22;④ RequestReply.body union 加 fetchfile 回复分支 |
src/lib/mylock.c |
新增 cbuf_timedget() 实现 |
src/include/mylock.h |
声明 cbuf_timedget() |
src/lib/xceapi.c |
15处 cbuf_get 改用 cbuf_timedget();新增 xce_fetchfile() |
src/lib/request.c |
新增 fetchfile 请求编码/解码函数 |
src/controller/cluster.c |
在 OPCode switch 中添加 case OPCode_fetchfile 分发 |
src/controller/job.c |
实现 on_fetchfile() controller→worker 代理逻辑 |
src/worker/src/controller_connector.c |
在 myreq_handler() 中添加 case OPCode_fetchfile 处理,含安全校验 |
需修改的文件(Go 层 — Phase 2,已有文件重写/追加)
| 文件 | 当前状态 | 修改内容 |
|---|---|---|
src/frontend/apiserver/backend/clib.go |
已有 | 新增 peekCtx(独立连接)、FetchFile() CGO 绑定、initPeekContext() |
src/frontend/apiserver/backend/stub.go |
已有 | 新增 FetchFile() stub(⚠️ 否则单元测试编译失败) |
src/frontend/apiserver/controllers/output.go |
已有(Plan A) | 重写底层读取:os.Open → backend.FetchFile + ExecHosts 解析 |
src/frontend/apiserver/controllers/output_test.go |
已有(Plan A) | 更新 mock:本地文件 mock → FetchFile stub |
已完成、无需改动的文件
| 文件 | 说明 |
|---|---|
src/frontend/apiserver/router/routers.go |
✅ 路由 GET /:jobid/output 已注册 |
src/frontend/xcecli/cmd/jobs/peek.go |
✅ peek 命令完整实现 |
src/frontend/xcecli/cmd/jobs/peek_test.go |
✅ 单元测试已完成 |
src/frontend/xcecli/cmd/jobs/jobs.go |
✅ peek 子命令已注册 |
src/frontend/xcecli/models/jobs.go |
✅ JobOutputResponse 已定义 |
src/frontend/xcecli/common/request/request.go |
✅ GetJobOutput 已实现 |
性能影响总结
| 指标 | 现有系统 | 添加后 |
|---|---|---|
| 单次 peek 延迟 | N/A | ~10-50ms |
| 并发 peek (10 用户) | N/A | ~300ms 排队 |
| 并发 peek (100 用户) | N/A | ~3s 排队(建议加连接池) |
| controller vCPU 负载 | 不变 | 微小增加(转发请求) |
| worker 线程池 | 不变 | 微小增加(读文件操作) |
| 主连接影响 | 不变 | 独立 ctx,零影响 |
| 现有 xceapi.c 调用 | 无超时 | 10s 超时,防止永久阻塞 |
风险点
- cbuf_timedget 超时误判:如果 controller 处理慢(如大量作业同时调度),可能误超时。10s 是保守值,可通过配置调整。
- controller 代理性能:controller 需要额外转发请求到 worker,增加 controller 负载。但在 8 vCPU 线程池下,影响可控。
- 大文件传输:通过 maxBytes 限制单次传输大小,CLI 通过 offset 分块获取,不会 OOM。(当前 Plan A 实现的
readFileTail有整文件读入内存的 Bug,Plan B 天然修复) - Worker 安全性:必须校验 filepath 是作业的输出文件,防止任意文件读取攻击。通过
job_info_t.job_id和 out/err 文件路径双重验证。 - 独立 peekCtx 并发:单个 peekCtx 下,并发 peek 请求通过 cbuf 串行化(100 用户 ≈ 3s 排队)。若需更高并发,可维护一个小型 ctx 连接池(2~4个),但目前阶段不必实现。
- Plan A → Plan B 切换:重写 output.go 后,已有的 output_test.go(15 个用例)需同步更新为 mock
backend.FetchFile,否则测试会因找不到本地文件而失败。 - stub.go 漏改:若只改 clib.go 而忘记同步 stub.go,
make test(无 CGO)编译失败,CI 会挂。