Init commit of jobs output/error message peek project.
This commit is contained in:
parent
c772d7e601
commit
d2b47e3985
38 changed files with 2267 additions and 21 deletions
|
|
@ -201,6 +201,24 @@ static int myreq_handler(vtask_t* task)
|
||||||
case OPCode_clrpreason:
|
case OPCode_clrpreason:
|
||||||
rc = do_clr_preason(c, ep, &request);
|
rc = do_clr_preason(c, ep, &request);
|
||||||
break;
|
break;
|
||||||
|
case OPCode_fetchfile:
|
||||||
|
rc = on_fetchfile(c, ep, &request);
|
||||||
|
break;
|
||||||
|
case OPCode_reply: {
|
||||||
|
/* Reply from a worker (e.g., for fetchfile proxy) */
|
||||||
|
xce_reply_t* workreply = request.body.replymsg;
|
||||||
|
if (workreply != NULL && workreply->opcode == OPCode_fetchfile) {
|
||||||
|
xce_fetchfile_reply_t* fr =
|
||||||
|
(workreply->data_which == RequestReply_data_fetchfilereply)
|
||||||
|
? workreply->data.fetchfilereply
|
||||||
|
: NULL;
|
||||||
|
uint64_t jid = fr ? fr->jobid : 0;
|
||||||
|
fetchfile_signal(jid, workreply->status, fr);
|
||||||
|
/* Ownership of fr transfers to the pending waiter. */
|
||||||
|
workreply->data.fetchfilereply = NULL;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
rc = -E_XCE_BAD_OPCODE;
|
rc = -E_XCE_BAD_OPCODE;
|
||||||
ERROR("Unknown opcode: %d", request.opcode);
|
ERROR("Unknown opcode: %d", request.opcode);
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,8 @@ extern int release_resource_from_worker(char*, cluster_t*);
|
||||||
extern int collect_resval_with_idx(zarray_t*, xlist_t*);
|
extern int collect_resval_with_idx(zarray_t*, xlist_t*);
|
||||||
extern int do_set_preason(cluster_t*, endpoint_t*, xce_request_t*);
|
extern int do_set_preason(cluster_t*, endpoint_t*, xce_request_t*);
|
||||||
extern int do_clr_preason(cluster_t*, endpoint_t*, xce_request_t*);
|
extern int do_clr_preason(cluster_t*, endpoint_t*, xce_request_t*);
|
||||||
|
extern int on_fetchfile(cluster_t*, endpoint_t*, xce_request_t*);
|
||||||
|
extern void fetchfile_signal(uint64_t jobid, int status, xce_fetchfile_reply_t*);
|
||||||
extern int handle_apiserver_leave(cluster_t*, endpoint_t*);
|
extern int handle_apiserver_leave(cluster_t*, endpoint_t*);
|
||||||
extern int add_apiserver(cluster_t*, char*, endpoint_t*);
|
extern int add_apiserver(cluster_t*, char*, endpoint_t*);
|
||||||
extern int apiserver_init(cluster_t*);
|
extern int apiserver_init(cluster_t*);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
|
#include <pthread.h>
|
||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
|
|
||||||
#include "cluster.h"
|
#include "cluster.h"
|
||||||
#include "jobj.h"
|
#include "jobj.h"
|
||||||
#include "preason.h"
|
#include "preason.h"
|
||||||
|
#include "request.h"
|
||||||
#include "xce/cluster_def.h"
|
#include "xce/cluster_def.h"
|
||||||
#include "xce/errnox.h"
|
#include "xce/errnox.h"
|
||||||
#include "xce/types.h"
|
#include "xce/types.h"
|
||||||
|
|
@ -1047,3 +1049,188 @@ int do_clr_preason(cluster_t* c, endpoint_t* ep, xce_request_t* request)
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- fetchfile pending-request infrastructure ---- */
|
||||||
|
|
||||||
|
typedef struct fetchfile_pending {
|
||||||
|
uint64_t jobid;
|
||||||
|
pthread_mutex_t mu;
|
||||||
|
pthread_cond_t cv;
|
||||||
|
int done;
|
||||||
|
int status;
|
||||||
|
xce_fetchfile_reply_t* reply;
|
||||||
|
struct fetchfile_pending* next;
|
||||||
|
} fetchfile_pending_t;
|
||||||
|
|
||||||
|
static pthread_mutex_t g_fetchfile_mu = PTHREAD_MUTEX_INITIALIZER;
|
||||||
|
static fetchfile_pending_t* g_fetchfile_list = NULL;
|
||||||
|
|
||||||
|
static void fetchfile_register(fetchfile_pending_t* p)
|
||||||
|
{
|
||||||
|
pthread_mutex_lock(&g_fetchfile_mu);
|
||||||
|
p->next = g_fetchfile_list;
|
||||||
|
g_fetchfile_list = p;
|
||||||
|
pthread_mutex_unlock(&g_fetchfile_mu);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void fetchfile_unregister(fetchfile_pending_t* p)
|
||||||
|
{
|
||||||
|
fetchfile_pending_t** pp;
|
||||||
|
pthread_mutex_lock(&g_fetchfile_mu);
|
||||||
|
for (pp = &g_fetchfile_list; *pp != NULL; pp = &(*pp)->next) {
|
||||||
|
if (*pp == p) {
|
||||||
|
*pp = p->next;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pthread_mutex_unlock(&g_fetchfile_mu);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchfile_pending_t* fetchfile_find(uint64_t jobid)
|
||||||
|
{
|
||||||
|
fetchfile_pending_t* p;
|
||||||
|
pthread_mutex_lock(&g_fetchfile_mu);
|
||||||
|
for (p = g_fetchfile_list; p != NULL; p = p->next) {
|
||||||
|
if (p->jobid == jobid) break;
|
||||||
|
}
|
||||||
|
pthread_mutex_unlock(&g_fetchfile_mu);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signal a pending fetchfile request with the worker's reply. */
|
||||||
|
void fetchfile_signal(uint64_t jobid, int status, xce_fetchfile_reply_t* reply)
|
||||||
|
{
|
||||||
|
fetchfile_pending_t* p = fetchfile_find(jobid);
|
||||||
|
if (p == NULL) return;
|
||||||
|
|
||||||
|
pthread_mutex_lock(&p->mu);
|
||||||
|
p->status = status;
|
||||||
|
p->reply = reply;
|
||||||
|
p->done = 1;
|
||||||
|
pthread_cond_signal(&p->cv);
|
||||||
|
pthread_mutex_unlock(&p->mu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- on_fetchfile: proxy fetchfile from apiserver to worker ---- */
|
||||||
|
|
||||||
|
int on_fetchfile(cluster_t* c, endpoint_t* caller_ep, xce_request_t* request)
|
||||||
|
{
|
||||||
|
xce_fetchfile_t* msg = request->body.fetchfilemsg;
|
||||||
|
xce_reply_t reply;
|
||||||
|
job_t* j = NULL;
|
||||||
|
worker_t* w = NULL;
|
||||||
|
int rc = 0;
|
||||||
|
char* exechost = NULL;
|
||||||
|
struct timespec deadline;
|
||||||
|
|
||||||
|
fetchfile_pending_t pending = {
|
||||||
|
.mu = PTHREAD_MUTEX_INITIALIZER,
|
||||||
|
.cv = PTHREAD_COND_INITIALIZER,
|
||||||
|
.done = 0, .status = -ETIMEDOUT, .reply = NULL, .next = NULL
|
||||||
|
};
|
||||||
|
|
||||||
|
init_reply_by_req(&reply, request);
|
||||||
|
|
||||||
|
if (msg == NULL || msg->filepath == NULL || msg->filepath[0] == '\0') {
|
||||||
|
reply.status = -EINVAL;
|
||||||
|
reply.data_which = RequestReply_data_nodata;
|
||||||
|
xce_reply(caller_ep, c->myhost, &reply);
|
||||||
|
return -EINVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.jobid = msg->jobid;
|
||||||
|
|
||||||
|
/* Look up the job */
|
||||||
|
lock(c->jobmgr, RTTI_LOCK_READ);
|
||||||
|
key_find(c->jobmgr, (void*)msg->jobid, (void**)&j);
|
||||||
|
if (j != NULL) reference(j);
|
||||||
|
unlock(c->jobmgr);
|
||||||
|
|
||||||
|
if (j == NULL) {
|
||||||
|
reply.status = -ENOENT;
|
||||||
|
reply.data_which = RequestReply_data_nodata;
|
||||||
|
xce_reply(caller_ep, c->myhost, &reply);
|
||||||
|
return -ENOENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Get the first exec-host from job allocs */
|
||||||
|
lock(j, RTTI_LOCK_READ);
|
||||||
|
if (j->job.allocs != NULL && j->job.allocs->nhosts > 0
|
||||||
|
&& j->job.allocs->allocs != NULL && j->job.allocs->allocs[0] != NULL
|
||||||
|
&& j->job.allocs->allocs[0]->host != NULL) {
|
||||||
|
exechost = strdup(j->job.allocs->allocs[0]->host);
|
||||||
|
}
|
||||||
|
unlock(j);
|
||||||
|
dereference(j);
|
||||||
|
|
||||||
|
if (exechost == NULL) {
|
||||||
|
reply.status = -ENOENT;
|
||||||
|
reply.data_which = RequestReply_data_nodata;
|
||||||
|
xce_reply(caller_ep, c->myhost, &reply);
|
||||||
|
return -ENOENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Find the worker */
|
||||||
|
lock(c->workermgr, RTTI_LOCK_READ);
|
||||||
|
rc = key_find(c->workermgr, (void*)exechost, (void**)&w);
|
||||||
|
if (w != NULL) reference(w);
|
||||||
|
unlock(c->workermgr);
|
||||||
|
free(exechost);
|
||||||
|
|
||||||
|
if (w == NULL) {
|
||||||
|
reply.status = -ENOENT;
|
||||||
|
reply.data_which = RequestReply_data_nodata;
|
||||||
|
xce_reply(caller_ep, c->myhost, &reply);
|
||||||
|
return -ENOENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock(w, RTTI_LOCK_READ);
|
||||||
|
if (w->ep == NULL) {
|
||||||
|
unlock(w);
|
||||||
|
dereference(w);
|
||||||
|
reply.status = -ENOENT;
|
||||||
|
reply.data_which = RequestReply_data_nodata;
|
||||||
|
xce_reply(caller_ep, c->myhost, &reply);
|
||||||
|
return -ENOENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Register pending before sending to avoid a race with the reply */
|
||||||
|
fetchfile_register(&pending);
|
||||||
|
rc = xce_fetchfile_req(w->ep, msg);
|
||||||
|
unlock(w);
|
||||||
|
dereference(w);
|
||||||
|
|
||||||
|
if (rc < 0) {
|
||||||
|
fetchfile_unregister(&pending);
|
||||||
|
reply.status = rc;
|
||||||
|
reply.data_which = RequestReply_data_nodata;
|
||||||
|
xce_reply(caller_ep, c->myhost, &reply);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wait up to 10 seconds for the worker to respond */
|
||||||
|
clock_gettime(CLOCK_REALTIME, &deadline);
|
||||||
|
deadline.tv_sec += 10;
|
||||||
|
|
||||||
|
pthread_mutex_lock(&pending.mu);
|
||||||
|
while (!pending.done) {
|
||||||
|
rc = pthread_cond_timedwait(&pending.cv, &pending.mu, &deadline);
|
||||||
|
if (rc == ETIMEDOUT) break;
|
||||||
|
}
|
||||||
|
pthread_mutex_unlock(&pending.mu);
|
||||||
|
|
||||||
|
fetchfile_unregister(&pending);
|
||||||
|
|
||||||
|
/* Relay the reply back to the apiserver */
|
||||||
|
if (pending.done && pending.status >= 0 && pending.reply != NULL) {
|
||||||
|
reply.status = 0;
|
||||||
|
reply.data_which = RequestReply_data_fetchfilereply;
|
||||||
|
reply.data.fetchfilereply = pending.reply;
|
||||||
|
} else {
|
||||||
|
reply.status = pending.done ? pending.status : -ETIMEDOUT;
|
||||||
|
reply.data_which = RequestReply_data_nodata;
|
||||||
|
FREEUP(pending.reply);
|
||||||
|
}
|
||||||
|
xce_reply(caller_ep, c->myhost, &reply);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
408
src/docs/job_peek.md
Normal file
408
src/docs/job_peek.md
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
# 项目实施方案: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 链路。
|
||||||
|
|
||||||
|
### 分阶段目标
|
||||||
|
1. **第一阶段**:cbuf 超时机制 + 新 OPCode(libxce/controller/worker)— **待完成**
|
||||||
|
2. **第二阶段**:将 output.go 切换为 CGO/FetchFile 实现(Plan A → Plan B)— **待完成**
|
||||||
|
3. **第三阶段**:测试验证 — **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 枚举末尾追加**
|
||||||
|
```capnp
|
||||||
|
enum OPCode {
|
||||||
|
# ... 现有枚举项 (nop @0 .. clrpreason @21) ...
|
||||||
|
fetchfile @22; # 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改二:新增两个结构体**
|
||||||
|
```capnp
|
||||||
|
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 消息必须加入才能被框架正确编解码:
|
||||||
|
```capnp
|
||||||
|
struct Request $C.mapname("xce_request_t") {
|
||||||
|
# ...
|
||||||
|
body : union {
|
||||||
|
# ... 现有分支 (nomsg @3 .. hostctrlmsg @21) ...
|
||||||
|
fetchfilemsg @22: FetchFileMsg; # 新增
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`RequestReply.body` union 中同样需要追加 fetchfile 回复分支,供 worker 回复时使用:
|
||||||
|
```capnp
|
||||||
|
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/`)
|
||||||
|
|
||||||
|
```c
|
||||||
|
// 新增函数,保留 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`
|
||||||
|
|
||||||
|
```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 中实现代理逻辑:
|
||||||
|
|
||||||
|
```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()` 中添加:
|
||||||
|
```c
|
||||||
|
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`(已有,追加内容)
|
||||||
|
|
||||||
|
```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)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 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 重写后:
|
||||||
|
1. 解析 `job.ExecHosts`(类型 `Jsonb` = `[]byte`)获取 worker hostname:
|
||||||
|
```go
|
||||||
|
var hosts []models.JobExecHosts
|
||||||
|
json.Unmarshal([]byte(job.ExecHosts), &hosts)
|
||||||
|
hostname := hosts[0].HostName // JobExecHosts.HostName 字段
|
||||||
|
```
|
||||||
|
2. 调用 `backend.FetchFile(job.JobID, filePath, uint64(offset), 65536)`
|
||||||
|
3. 返回 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 重写后需更新为 mock `backend.FetchFile`
|
||||||
|
- **`peek_test.go`**(已有):无需大改,轮询逻辑不变
|
||||||
|
- `stub.go` 中的 `FetchFile` stub 必须能让现有单元测试通过编译
|
||||||
|
|
||||||
|
## 文件清单
|
||||||
|
|
||||||
|
### 需新建的文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `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 超时,防止永久阻塞 |
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
1. **cbuf_timedget 超时误判**:如果 controller 处理慢(如大量作业同时调度),可能误超时。10s 是保守值,可通过配置调整。
|
||||||
|
2. **controller 代理性能**:controller 需要额外转发请求到 worker,增加 controller 负载。但在 8 vCPU 线程池下,影响可控。
|
||||||
|
3. **大文件传输**:通过 maxBytes 限制单次传输大小,CLI 通过 offset 分块获取,不会 OOM。(当前 Plan A 实现的 `readFileTail` 有整文件读入内存的 Bug,Plan B 天然修复)
|
||||||
|
4. **Worker 安全性**:必须校验 filepath 是作业的输出文件,防止任意文件读取攻击。通过 `job_info_t.job_id` 和 out/err 文件路径双重验证。
|
||||||
|
5. **独立 peekCtx 并发**:单个 peekCtx 下,并发 peek 请求通过 cbuf 串行化(100 用户 ≈ 3s 排队)。若需更高并发,可维护一个小型 ctx 连接池(2~4个),但目前阶段不必实现。
|
||||||
|
6. **Plan A → Plan B 切换**:重写 output.go 后,已有的 output_test.go(15 个用例)需同步更新为 mock `backend.FetchFile`,否则测试会因找不到本地文件而失败。
|
||||||
|
7. **stub.go 漏改**:若只改 clib.go 而忘记同步 stub.go,`make test`(无 CGO)编译失败,CI 会挂。
|
||||||
|
|
@ -416,3 +416,41 @@ func signalNumber(signame string, action int) int {
|
||||||
}
|
}
|
||||||
return 15 // SIGTERM as safe default
|
return 15 // SIGTERM as safe default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchFile fetches up to maxBytes of a job output file from offset via the
|
||||||
|
// controller→worker chain. The filepath must be the fully resolved absolute
|
||||||
|
// path on the exec host.
|
||||||
|
func FetchFile(jobid uint64, filepath string, offset uint64, maxBytes uint32) (*FetchFileResult, error) {
|
||||||
|
cFilepath := C.CString(filepath)
|
||||||
|
defer C.free(unsafe.Pointer(cFilepath))
|
||||||
|
|
||||||
|
msg := C.xce_fetchfile_t{
|
||||||
|
jobid: C.uint64_t(jobid),
|
||||||
|
filepath: cFilepath,
|
||||||
|
offset: C.uint64_t(offset),
|
||||||
|
max_bytes: C.uint32_t(maxBytes),
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply *C.xce_fetchfile_reply_t
|
||||||
|
rc := C.xcecli_fetchfile(gCtx.ctx, &msg, &reply)
|
||||||
|
if rc < 0 {
|
||||||
|
return nil, fmt.Errorf("xcecli_fetchfile failed: %d", int(rc))
|
||||||
|
}
|
||||||
|
if reply == nil {
|
||||||
|
return nil, fmt.Errorf("xcecli_fetchfile returned nil reply")
|
||||||
|
}
|
||||||
|
defer C.free(unsafe.Pointer(reply))
|
||||||
|
|
||||||
|
if reply.error != 0 {
|
||||||
|
return nil, fmt.Errorf("fetchfile remote error: %d", int(reply.error))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &FetchFileResult{
|
||||||
|
FileSize: uint64(reply.file_size),
|
||||||
|
BytesRead: uint32(reply.bytes_read),
|
||||||
|
}
|
||||||
|
if reply.bytes_read > 0 && reply.content != nil {
|
||||||
|
result.Content = C.GoBytes(unsafe.Pointer(reply.content), C.int(reply.bytes_read))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,10 @@ type BackendConfig struct {
|
||||||
From string // identity sent to controller (default: "xce_apiserver")
|
From string // identity sent to controller (default: "xce_apiserver")
|
||||||
Key string // authentication key
|
Key string // authentication key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchFileResult holds the data returned by FetchFile.
|
||||||
|
type FetchFileResult struct {
|
||||||
|
Content []byte
|
||||||
|
FileSize uint64
|
||||||
|
BytesRead uint32
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package backend
|
||||||
import (
|
import (
|
||||||
"apiserver/models"
|
"apiserver/models"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -76,6 +77,33 @@ func IsArrayJob(id uint64) bool {
|
||||||
return extractIDTag(id) == tagJobArray
|
return extractIDTag(id) == tagJobArray
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatJobID formats a packed 64-bit job ID as a human-readable string,
|
||||||
|
// matching the C fmt_jobid() output in src/lib/utils.c.
|
||||||
|
// - Regular job: "{cluster}-{apiserver}-{timestamp}-{sequence}"
|
||||||
|
// - Array child: "{cluster}-{apiserver}-{timestamp}-{base}[{idx}]"
|
||||||
|
func FormatJobID(uniqueJobID int64) string {
|
||||||
|
id := uint64(uniqueJobID)
|
||||||
|
clusterID := extractBits(id, clusterIDShift, nClusterIDBits)
|
||||||
|
apiserverID := extractBits(id, apiserverIDShift, nApiserverIDBits)
|
||||||
|
timestamp := extractBits(id, timestampShift, nTimestampBits)
|
||||||
|
|
||||||
|
if extractIDTag(id) == tagJobArray {
|
||||||
|
arrayBase := extractBits(id, sequenceHighShift, nSequenceBits-nArrayIdxBits) << uint(sequenceHighShift)
|
||||||
|
arrayIdx := extractBits(id, 0, nArrayIdxBits)
|
||||||
|
return fmt.Sprintf("%d-%d-%d-%d[%d]",
|
||||||
|
clusterID, apiserverID, timestamp, arrayBase, arrayIdx)
|
||||||
|
}
|
||||||
|
sequence := extractBits(id, 0, nSequenceBits)
|
||||||
|
return fmt.Sprintf("%d-%d-%d-%d",
|
||||||
|
clusterID, apiserverID, timestamp, sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerDefaultOutputPath returns the output file path the worker uses by default
|
||||||
|
// for the given job ID and task index, matching uv_proc.c make_job_output_path().
|
||||||
|
func WorkerDefaultOutputPath(uniqueJobID int64, taskIdx int) string {
|
||||||
|
return fmt.Sprintf("/tmp/job_%s_task%d.out", FormatJobID(uniqueJobID), taskIdx)
|
||||||
|
}
|
||||||
|
|
||||||
// rawStartInfo mirrors the C job_startinfo_t fields used by the Go model.
|
// rawStartInfo mirrors the C job_startinfo_t fields used by the Go model.
|
||||||
type rawStartInfo struct {
|
type rawStartInfo struct {
|
||||||
cmdline string
|
cmdline string
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,8 @@ func EvalSelectForHost(_, _ string) error { return errNoCGO }
|
||||||
|
|
||||||
// Cleanup stub — real implementation in clib.go (requires CGO).
|
// Cleanup stub — real implementation in clib.go (requires CGO).
|
||||||
func Cleanup() {}
|
func Cleanup() {}
|
||||||
|
|
||||||
|
// FetchFile stub — real implementation in clib.go (requires CGO).
|
||||||
|
func FetchFile(_ uint64, _ string, _ uint64, _ uint32) (*FetchFileResult, error) {
|
||||||
|
return nil, errNoCGO
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1434,7 +1434,7 @@ func (j JobController) CreateJob(ctx *gin.Context) {
|
||||||
logger.Info("Generated job ID", zap.Int64("jobID", primaryJobID), zap.Int("arraySize", arraySize))
|
logger.Info("Generated job ID", zap.Int64("jobID", primaryJobID), zap.Int("arraySize", arraySize))
|
||||||
|
|
||||||
defaultValues := map[string]string{
|
defaultValues := map[string]string{
|
||||||
"job_id": fmt.Sprintf("%d", primaryJobID),
|
"job_id": backend.FormatJobID(primaryJobID),
|
||||||
"user_name": submitUser,
|
"user_name": submitUser,
|
||||||
"job_group": req.JobGroup,
|
"job_group": req.JobGroup,
|
||||||
"project_name": req.ProjectName,
|
"project_name": req.ProjectName,
|
||||||
|
|
|
||||||
245
src/frontend/apiserver/controllers/output.go
Normal file
245
src/frontend/apiserver/controllers/output.go
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"apiserver/backend"
|
||||||
|
"apiserver/common"
|
||||||
|
"apiserver/models"
|
||||||
|
"apiserver/store"
|
||||||
|
"apiserver/utils"
|
||||||
|
|
||||||
|
logger "apiserver/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fetchFile is the function used to fetch remote file content.
|
||||||
|
// Overrideable in tests.
|
||||||
|
var fetchFile = backend.FetchFile
|
||||||
|
|
||||||
|
const (
|
||||||
|
// tailChunkSize is the maximum bytes fetched per request in tail mode.
|
||||||
|
tailChunkSize = 4 * 1024 * 1024
|
||||||
|
// incrementalChunkSize is the maximum bytes fetched in incremental mode.
|
||||||
|
incrementalChunkSize = 4 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
// OutputData represents the response payload for job output.
|
||||||
|
type OutputData struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Offset int64 `json:"offset"`
|
||||||
|
JobStatus string `json:"job_status"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJobOutput returns the tail or incremental content of a job's output file.
|
||||||
|
// GET /xce/v1/jobs/:jobid/output?offset=0&lines=100&type=stdout
|
||||||
|
func (j JobController) GetJobOutput(ctx *gin.Context) {
|
||||||
|
logger.Debug("Enter GetJobOutput() function from ", zap.String("client_ip", ctx.ClientIP()))
|
||||||
|
|
||||||
|
token := ctx.GetHeader("Authorization")
|
||||||
|
submitUser, err := GetUsernameFromToken(token)
|
||||||
|
if err != nil {
|
||||||
|
ReturnError(ctx, common.ERROR_CODE, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.IsAborted() {
|
||||||
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobidStr := ctx.Param("jobids")
|
||||||
|
offsetStr := ctx.DefaultQuery("offset", "0")
|
||||||
|
linesStr := ctx.DefaultQuery("lines", "100")
|
||||||
|
streamType := ctx.DefaultQuery("type", "stdout")
|
||||||
|
|
||||||
|
offset, err := strconv.ParseInt(offsetStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, "invalid offset: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, err := strconv.Atoi(linesStr)
|
||||||
|
if err != nil || lines <= 0 {
|
||||||
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, "invalid lines: must be a positive integer", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if streamType != "stdout" && streamType != "stderr" {
|
||||||
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, "invalid type: must be 'stdout' or 'stderr'", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse job ID
|
||||||
|
jobInfo, err := parseJobIDParam(jobidStr)
|
||||||
|
if err != nil {
|
||||||
|
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up job in store
|
||||||
|
job, ok := store.Jobs.Get(uint64(jobInfo.Unique_JobID))
|
||||||
|
if !ok {
|
||||||
|
ReturnError(ctx, common.ERROR_NOT_FOUND_CODE, common.ERROR_JOB_NOT_FOUND_MSG, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership: users with domain_type=all can view any job
|
||||||
|
permission, perr := GetRequireResourcePermission(submitUser, "job:view")
|
||||||
|
if perr != nil || permission.DomainType != models.DomainTypeAll {
|
||||||
|
if job.SubmitUser != submitUser {
|
||||||
|
ReturnError(ctx, common.ERROR_AUTH_CODE, common.ERROR_ACCESS_FORBIDDEN_MSG, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve output file path
|
||||||
|
var filePath string
|
||||||
|
if streamType == "stderr" {
|
||||||
|
filePath = resolveOutputPath(job.ErrFile, job)
|
||||||
|
} else {
|
||||||
|
filePath = resolveOutputPath(job.OutFile, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filePath == "" {
|
||||||
|
ReturnError(ctx, common.ERROR_READ_FILE_CODE, fmt.Sprintf("job <%s>: no output file path could be determined for %s", backend.FormatJobID(job.Unique_JobID), streamType), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var content string
|
||||||
|
var newOffset int64
|
||||||
|
|
||||||
|
if offset == 0 {
|
||||||
|
content, newOffset, err = fetchFileTail(uint64(job.Unique_JobID), filePath, lines)
|
||||||
|
} else {
|
||||||
|
content, newOffset, err = fetchFileFromOffset(uint64(job.Unique_JobID), filePath, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
ReturnError(ctx, common.ERROR_READ_FILE_CODE, fmt.Sprintf("job <%s> (path=%s): failed to read output: %s", backend.FormatJobID(job.Unique_JobID), filePath, err.Error()), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobStatusStr := models.JobStateStr(job.JobStatus)
|
||||||
|
done := jobStatusStr != "RUN" && jobStatusStr != "PSUSP" && jobStatusStr != "USUSP" && jobStatusStr != "SSUSP"
|
||||||
|
|
||||||
|
ReturnSuccess(ctx, common.SUCCESS_CODE, common.SUCCESS_MSG, OutputData{
|
||||||
|
Content: content,
|
||||||
|
Offset: newOffset,
|
||||||
|
JobStatus: jobStatusStr,
|
||||||
|
Done: done,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchFileTail fetches the last n lines of a file via the backend.
|
||||||
|
// It uses a two-pass approach for files larger than tailChunkSize.
|
||||||
|
func fetchFileTail(jobid uint64, path string, n int) (string, int64, error) {
|
||||||
|
// First pass: fetch from offset 0 to get file size and content for small files.
|
||||||
|
result, err := fetchFile(jobid, path, 0, tailChunkSize)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fileSize := int64(result.FileSize)
|
||||||
|
if fileSize == 0 {
|
||||||
|
return "", 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data := result.Content
|
||||||
|
|
||||||
|
// If the file is larger than our chunk, re-fetch the tail portion.
|
||||||
|
if result.FileSize > tailChunkSize {
|
||||||
|
endOffset := result.FileSize - tailChunkSize
|
||||||
|
result, err = fetchFile(jobid, path, endOffset, tailChunkSize)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
data = result.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastNLines(data, n), fileSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchFileFromOffset fetches content from the given byte offset via the backend.
|
||||||
|
func fetchFileFromOffset(jobid uint64, path string, offset int64) (string, int64, error) {
|
||||||
|
result, err := fetchFile(jobid, path, uint64(offset), incrementalChunkSize)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fileSize := int64(result.FileSize)
|
||||||
|
if int64(offset) >= fileSize {
|
||||||
|
return "", fileSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(result.Content), fileSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastNLines returns the last n lines from data as a string.
|
||||||
|
func lastNLines(data []byte, n int) string {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
lineCount := 0
|
||||||
|
start := 0
|
||||||
|
end := len(data) - 1
|
||||||
|
|
||||||
|
for i := end; i >= 0; i-- {
|
||||||
|
if data[i] == '\n' {
|
||||||
|
lineCount++
|
||||||
|
if lineCount == n+1 {
|
||||||
|
start = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data[start:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOutputPath replaces placeholders in the output file path.
|
||||||
|
// When path is empty, returns the worker's default output path for task 0,
|
||||||
|
// matching uv_proc.c make_job_output_path() behavior.
|
||||||
|
func resolveOutputPath(path string, job models.JobInfo) string {
|
||||||
|
if path == "" {
|
||||||
|
return backend.WorkerDefaultOutputPath(job.Unique_JobID, 0)
|
||||||
|
}
|
||||||
|
formattedID := backend.FormatJobID(job.Unique_JobID)
|
||||||
|
path = strings.ReplaceAll(path, "%J", formattedID)
|
||||||
|
path = strings.ReplaceAll(path, "%I", strconv.Itoa(job.ArrayIdx))
|
||||||
|
path = strings.ReplaceAll(path, "%U", job.SubmitUser)
|
||||||
|
if job.CWD != "" {
|
||||||
|
path = strings.ReplaceAll(path, "%C", job.CWD)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJobIDParam parses a job ID string and returns the corresponding JobInfo.
|
||||||
|
func parseJobIDParam(jobidStr string) (models.JobInfo, error) {
|
||||||
|
// Use the same parsing logic as GetJobByIDs
|
||||||
|
jobArrInfo, err := parseArrayName(jobidStr)
|
||||||
|
if err != nil {
|
||||||
|
return models.JobInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !utils.IsNumeric(jobArrInfo.KeyID) {
|
||||||
|
return models.JobInfo{}, fmt.Errorf("bad jobid: %s", jobArrInfo.KeyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
uniqueJobID, err := strconv.ParseInt(jobArrInfo.KeyID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return models.JobInfo{}, fmt.Errorf("bad jobid: %s", jobArrInfo.KeyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.JobInfo{
|
||||||
|
Unique_JobID: uniqueJobID,
|
||||||
|
ArrayIdx: 0, // peek defaults to base job; array idx can be added later
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
515
src/frontend/apiserver/controllers/output_test.go
Normal file
515
src/frontend/apiserver/controllers/output_test.go
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"apiserver/backend"
|
||||||
|
"apiserver/common"
|
||||||
|
"apiserver/models"
|
||||||
|
"apiserver/store"
|
||||||
|
"apiserver/utils"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockFetchFile simulates FetchFile by reading from the local filesystem.
|
||||||
|
// It is installed by resetJobsState() so all output endpoint tests use it.
|
||||||
|
func mockFetchFile(jobid uint64, filepath string, offset uint64, maxBytes uint32) (*backend.FetchFileResult, error) {
|
||||||
|
data, err := os.ReadFile(filepath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fileSize := uint64(len(data))
|
||||||
|
if offset >= fileSize {
|
||||||
|
return &backend.FetchFileResult{FileSize: fileSize, BytesRead: 0}, nil
|
||||||
|
}
|
||||||
|
chunk := data[offset:]
|
||||||
|
if maxBytes > 0 && uint32(len(chunk)) > maxBytes {
|
||||||
|
chunk = chunk[:maxBytes]
|
||||||
|
}
|
||||||
|
return &backend.FetchFileResult{
|
||||||
|
Content: chunk,
|
||||||
|
FileSize: fileSize,
|
||||||
|
BytesRead: uint32(len(chunk)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetJobsState() {
|
||||||
|
store.InitStores()
|
||||||
|
utils.Log = zap.NewNop()
|
||||||
|
initTestAuthConfig() // ensure JWT parsing works in all output tests
|
||||||
|
fetchFile = mockFetchFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestJob(t *testing.T, tmpDir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// OutFile/ErrFile use %J placeholder; resolveOutputPath replaces %J with
|
||||||
|
// the formatted job ID (FormatJobID(1001) = "0-0-0-1001" for small test IDs).
|
||||||
|
outFile := filepath.Join(tmpDir, "job_%J.out")
|
||||||
|
errFile := filepath.Join(tmpDir, "job_%J.err")
|
||||||
|
|
||||||
|
job := models.JobInfo{
|
||||||
|
Unique_JobID: 1001,
|
||||||
|
JobID: 1001,
|
||||||
|
ArrayIdx: 0,
|
||||||
|
SubmitUser: "testuser",
|
||||||
|
JobStatus: 0x02, // RUN
|
||||||
|
OutFile: outFile,
|
||||||
|
ErrFile: errFile,
|
||||||
|
CWD: tmpDir,
|
||||||
|
}
|
||||||
|
store.Jobs.Put(1001, job)
|
||||||
|
|
||||||
|
// Write to the resolved paths (with %J replaced by formatted ID "0-0-0-1001").
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "job_0-0-0-1001.out"), []byte("line1\nline2\nline3\nline4\nline5\n"), 0644)
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, "job_0-0-0-1001.err"), []byte("err1\nerr2\n"), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestRouter() *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
handler := &JobController{}
|
||||||
|
r.GET("/xce/v1/jobs/:jobids/output", handler.GetJobOutput)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// testToken returns a signed JWT for "testuser" valid for 1 hour.
|
||||||
|
// initTestAuthConfig() must be called before using this token.
|
||||||
|
func testToken() string {
|
||||||
|
return "Bearer " + signTestJWT("testuser", "admin", time.Now().Add(time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeResponse(t *testing.T, w *httptest.ResponseRecorder, v interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
t.Logf("response body: %s", w.Body.String())
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), v); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobOutput_TailLines(t *testing.T) {
|
||||||
|
resetJobsState()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setupTestJob(t, tmpDir)
|
||||||
|
|
||||||
|
router := setupTestRouter()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/xce/v1/jobs/1001/output?lines=2", nil)
|
||||||
|
req.Header.Set("Authorization", testToken())
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data OutputData `json:"data"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, w, &resp)
|
||||||
|
|
||||||
|
if resp.Data.Content != "line4\nline5\n" {
|
||||||
|
t.Errorf("content = %q, want %q", resp.Data.Content, "line4\nline5\n")
|
||||||
|
}
|
||||||
|
if resp.Data.Offset != 30 {
|
||||||
|
t.Errorf("offset = %d, want 30", resp.Data.Offset)
|
||||||
|
}
|
||||||
|
if resp.Data.Done {
|
||||||
|
t.Error("done should be false for running job")
|
||||||
|
}
|
||||||
|
if resp.Data.JobStatus != "RUN" {
|
||||||
|
t.Errorf("job_status = %q, want RUN", resp.Data.JobStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobOutput_FromOffset(t *testing.T) {
|
||||||
|
resetJobsState()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
setupTestJob(t, tmpDir)
|
||||||
|
|
||||||
|
router := setupTestRouter()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/xce/v1/jobs/1001/output?offset=12", nil)
|
||||||
|
req.Header.Set("Authorization", testToken())
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data OutputData `json:"data"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, w, &resp)
|
||||||
|
|
||||||
|
if resp.Data.Content != "line3\nline4\nline5\n" {
|
||||||
|
t.Errorf("content = %q, want %q", resp.Data.Content, "line3\nline4\nline5\n")
|
||||||
|
}
|
||||||
|
if resp.Data.Offset != 30 {
|
||||||
|
t.Errorf("offset = %d, want 30", resp.Data.Offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobOutput_FileNotFound(t *testing.T) {
|
||||||
|
resetJobsState()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
outFile := filepath.Join(tmpDir, "job_%J.out")
|
||||||
|
|
||||||
|
job := models.JobInfo{
|
||||||
|
Unique_JobID: 2001,
|
||||||
|
JobID: 2001,
|
||||||
|
ArrayIdx: 0,
|
||||||
|
SubmitUser: "testuser",
|
||||||
|
JobStatus: 0x02,
|
||||||
|
OutFile: outFile,
|
||||||
|
}
|
||||||
|
store.Jobs.Put(2001, job)
|
||||||
|
|
||||||
|
router := setupTestRouter()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/xce/v1/jobs/2001/output?lines=10", nil)
|
||||||
|
req.Header.Set("Authorization", testToken())
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, w, &resp)
|
||||||
|
|
||||||
|
if resp.Code != common.ERROR_READ_FILE_CODE {
|
||||||
|
t.Errorf("code = %d, want %d", resp.Code, common.ERROR_READ_FILE_CODE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobOutput_NoOutputFileConfigured(t *testing.T) {
|
||||||
|
resetJobsState()
|
||||||
|
|
||||||
|
job := models.JobInfo{
|
||||||
|
Unique_JobID: 3001,
|
||||||
|
JobID: 3001,
|
||||||
|
ArrayIdx: 0,
|
||||||
|
SubmitUser: "testuser",
|
||||||
|
JobStatus: 0x02,
|
||||||
|
OutFile: "",
|
||||||
|
}
|
||||||
|
store.Jobs.Put(3001, job)
|
||||||
|
|
||||||
|
router := setupTestRouter()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/xce/v1/jobs/3001/output?lines=10", nil)
|
||||||
|
req.Header.Set("Authorization", testToken())
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
// resolveOutputPath uses the worker default (/tmp/job_0-0-0-3001_task0.out).
|
||||||
|
// That file does not exist, so we expect a read error (501).
|
||||||
|
var resp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, w, &resp)
|
||||||
|
|
||||||
|
if resp.Code != common.ERROR_READ_FILE_CODE {
|
||||||
|
t.Errorf("code = %d, want %d", resp.Code, common.ERROR_READ_FILE_CODE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetJobOutput_DefaultPathFallback verifies that when OutFile is empty,
|
||||||
|
// the worker's default output path is used and content is returned successfully.
|
||||||
|
func TestGetJobOutput_DefaultPathFallback(t *testing.T) {
|
||||||
|
resetJobsState()
|
||||||
|
|
||||||
|
// Use a real-ish packed job ID: cluster=1, apiserver=0, ts=1778311965, seq=0
|
||||||
|
// FormatJobID → "1-0-1778311965-0"
|
||||||
|
// WorkerDefaultOutputPath → "/tmp/job_1-0-1778311965-0_task0.out"
|
||||||
|
const uniqueID = int64(0x0100000006A04BDD<<0) // MakeJobID(1,0,1778311965,0,0)
|
||||||
|
// Use backend.MakeJobID directly for clarity; compute inline:
|
||||||
|
// clusterID=1 at bit 56, timestamp=1778311965 at bit 20
|
||||||
|
// packed = (1 << 56) | (1778311965 << 20)
|
||||||
|
const packedID = int64((uint64(1) << 56) | (uint64(1778311965) << 20))
|
||||||
|
|
||||||
|
// Install a fetchFile that reads from a tmpdir-based path instead of /tmp
|
||||||
|
// to avoid touching /tmp during tests.
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
workerDefault := backend.WorkerDefaultOutputPath(packedID, 0) // /tmp/job_1-0-1778311965-0_task0.out
|
||||||
|
formattedID := backend.FormatJobID(packedID) // "1-0-1778311965-0"
|
||||||
|
localFile := filepath.Join(tmpDir, "job_"+formattedID+"_task0.out")
|
||||||
|
os.WriteFile(localFile, []byte("worker output line\n"), 0644)
|
||||||
|
|
||||||
|
// Override fetchFile for this test to redirect /tmp paths to tmpDir.
|
||||||
|
origFetchFile := fetchFile
|
||||||
|
fetchFile = func(jobid uint64, path string, offset uint64, maxBytes uint32) (*backend.FetchFileResult, error) {
|
||||||
|
if path == workerDefault {
|
||||||
|
path = localFile
|
||||||
|
}
|
||||||
|
return mockFetchFile(jobid, path, offset, maxBytes)
|
||||||
|
}
|
||||||
|
defer func() { fetchFile = origFetchFile }()
|
||||||
|
|
||||||
|
job := models.JobInfo{
|
||||||
|
Unique_JobID: packedID,
|
||||||
|
JobID: packedID,
|
||||||
|
ArrayIdx: 0,
|
||||||
|
SubmitUser: "testuser",
|
||||||
|
JobStatus: 0x02, // RUN
|
||||||
|
OutFile: "", // no OutFile — should fall back to worker default
|
||||||
|
}
|
||||||
|
store.Jobs.Put(uint64(packedID), job)
|
||||||
|
|
||||||
|
router := setupTestRouter()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
jobIDStr := fmt.Sprintf("%d", packedID)
|
||||||
|
req, _ := http.NewRequest("GET", "/xce/v1/jobs/"+jobIDStr+"/output?lines=10", nil)
|
||||||
|
req.Header.Set("Authorization", testToken())
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Data OutputData `json:"data"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, w, &resp)
|
||||||
|
|
||||||
|
if resp.Data.Content != "worker output line\n" {
|
||||||
|
t.Errorf("content = %q, want %q", resp.Data.Content, "worker output line\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobOutput_PlaceholderResolution(t *testing.T) {
|
||||||
|
resetJobsState()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
// FormatJobID(4001) = "0-0-0-4001" for small test IDs
|
||||||
|
outFile := filepath.Join(tmpDir, "job_0-0-0-4001.out")
|
||||||
|
|
||||||
|
job := models.JobInfo{
|
||||||
|
Unique_JobID: 4001,
|
||||||
|
JobID: 4001,
|
||||||
|
ArrayIdx: 0,
|
||||||
|
SubmitUser: "testuser",
|
||||||
|
JobStatus: 0x02,
|
||||||
|
OutFile: filepath.Join(tmpDir, "job_%J.out"),
|
||||||
|
}
|
||||||
|
store.Jobs.Put(4001, job)
|
||||||
|
|
||||||
|
os.WriteFile(outFile, []byte("placeholder resolved\n"), 0644)
|
||||||
|
|
||||||
|
router := setupTestRouter()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/xce/v1/jobs/4001/output?lines=10", nil)
|
||||||
|
req.Header.Set("Authorization", testToken())
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Data OutputData `json:"data"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, w, &resp)
|
||||||
|
|
||||||
|
if resp.Data.Content != "placeholder resolved\n" {
|
||||||
|
t.Errorf("content = %q, want %q", resp.Data.Content, "placeholder resolved\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobOutput_JobNotFound(t *testing.T) {
|
||||||
|
resetJobsState()
|
||||||
|
|
||||||
|
router := setupTestRouter()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/xce/v1/jobs/99999/output?lines=10", nil)
|
||||||
|
req.Header.Set("Authorization", testToken())
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobOutput_InvalidParams(t *testing.T) {
|
||||||
|
resetJobsState()
|
||||||
|
|
||||||
|
router := setupTestRouter()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
expectCode int
|
||||||
|
}{
|
||||||
|
{"invalid offset", "/xce/v1/jobs/1001/output?offset=abc", http.StatusBadRequest},
|
||||||
|
{"invalid lines zero", "/xce/v1/jobs/1001/output?lines=0", http.StatusBadRequest},
|
||||||
|
{"invalid type", "/xce/v1/jobs/1001/output?type=combined", http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", tc.url, nil)
|
||||||
|
req.Header.Set("Authorization", testToken())
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != tc.expectCode {
|
||||||
|
t.Errorf("expected %d, got %d: %s", tc.expectCode, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLastNLines(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []byte
|
||||||
|
n int
|
||||||
|
expect string
|
||||||
|
}{
|
||||||
|
{"tail 2 of 5", []byte("a\nb\nc\nd\ne\n"), 2, "d\ne\n"},
|
||||||
|
{"tail all", []byte("a\nb\n"), 100, "a\nb\n"},
|
||||||
|
{"empty", []byte(""), 10, ""},
|
||||||
|
{"no trailing newline", []byte("no newline"), 5, "no newline"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := lastNLines(tc.input, tc.n)
|
||||||
|
if got != tc.expect {
|
||||||
|
t.Errorf("lastNLines(%q, %d) = %q, want %q", tc.input, tc.n, got, tc.expect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveOutputPath(t *testing.T) {
|
||||||
|
// FormatJobID(123) = "0-0-0-123" for small packed IDs (cluster=0, apiserver=0, ts=0, seq=123)
|
||||||
|
job := models.JobInfo{
|
||||||
|
Unique_JobID: 123,
|
||||||
|
JobID: 123,
|
||||||
|
ArrayIdx: 2,
|
||||||
|
SubmitUser: "alice",
|
||||||
|
CWD: "/home/alice/work",
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"/tmp/job_%J.out", "/tmp/job_0-0-0-123.out"},
|
||||||
|
{"/tmp/job_%J_%I.out", "/tmp/job_0-0-0-123_2.out"},
|
||||||
|
{"/tmp/job_%U.out", "/tmp/job_alice.out"},
|
||||||
|
{"/tmp/job_%C.out", "/tmp/job_/home/alice/work.out"},
|
||||||
|
// empty path → worker default for task 0
|
||||||
|
{"", "/tmp/job_0-0-0-123_task0.out"},
|
||||||
|
{"/tmp/fixed.out", "/tmp/fixed.out"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
result := resolveOutputPath(tc.input, job)
|
||||||
|
if result != tc.expected {
|
||||||
|
t.Errorf("resolveOutputPath(%q) = %q, want %q", tc.input, result, tc.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobOutput_Stderr(t *testing.T) {
|
||||||
|
resetJobsState()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
errFile := filepath.Join(tmpDir, "job_5001.err")
|
||||||
|
|
||||||
|
job := models.JobInfo{
|
||||||
|
Unique_JobID: 5001,
|
||||||
|
JobID: 5001,
|
||||||
|
ArrayIdx: 0,
|
||||||
|
SubmitUser: "testuser",
|
||||||
|
JobStatus: 0x02,
|
||||||
|
ErrFile: errFile,
|
||||||
|
}
|
||||||
|
store.Jobs.Put(5001, job)
|
||||||
|
|
||||||
|
os.WriteFile(errFile, []byte("stderr output\n"), 0644)
|
||||||
|
|
||||||
|
router := setupTestRouter()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/xce/v1/jobs/5001/output?type=stderr&lines=10", nil)
|
||||||
|
req.Header.Set("Authorization", testToken())
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Data OutputData `json:"data"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, w, &resp)
|
||||||
|
|
||||||
|
if resp.Data.Content != "stderr output\n" {
|
||||||
|
t.Errorf("content = %q, want %q", resp.Data.Content, "stderr output\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobOutput_DoneJob(t *testing.T) {
|
||||||
|
resetJobsState()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
outFile := filepath.Join(tmpDir, "job_6001.out")
|
||||||
|
|
||||||
|
job := models.JobInfo{
|
||||||
|
Unique_JobID: 6001,
|
||||||
|
JobID: 6001,
|
||||||
|
ArrayIdx: 0,
|
||||||
|
SubmitUser: "testuser",
|
||||||
|
JobStatus: 0x08, // DONE
|
||||||
|
OutFile: outFile,
|
||||||
|
}
|
||||||
|
store.Jobs.Put(6001, job)
|
||||||
|
|
||||||
|
os.WriteFile(outFile, []byte("finished output\n"), 0644)
|
||||||
|
|
||||||
|
router := setupTestRouter()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/xce/v1/jobs/6001/output?lines=10", nil)
|
||||||
|
req.Header.Set("Authorization", testToken())
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Data OutputData `json:"data"`
|
||||||
|
}
|
||||||
|
decodeResponse(t, w, &resp)
|
||||||
|
|
||||||
|
if !resp.Data.Done {
|
||||||
|
t.Error("done should be true for finished job")
|
||||||
|
}
|
||||||
|
if resp.Data.JobStatus != "DONE" {
|
||||||
|
t.Errorf("job_status = %q, want DONE", resp.Data.JobStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -125,6 +125,9 @@ func Router() *gin.Engine {
|
||||||
jobsApi := r.Group("/xce/v1/jobs")
|
jobsApi := r.Group("/xce/v1/jobs")
|
||||||
{
|
{
|
||||||
jobHandler := &controllers.JobController{}
|
jobHandler := &controllers.JobController{}
|
||||||
|
// Note: /:jobid/output must be registered before /:jobids to avoid
|
||||||
|
// Gin matching "output" as a literal job ID.
|
||||||
|
jobsApi.GET("/:jobids/output", controllers.RequirePermission("job:view"), jobHandler.GetJobOutput)
|
||||||
jobsApi.GET("", controllers.RequirePermission("job:view"), jobHandler.GetJobsInfo)
|
jobsApi.GET("", controllers.RequirePermission("job:view"), jobHandler.GetJobsInfo)
|
||||||
jobsApi.GET("/:jobids", controllers.RequirePermission("job:view"), jobHandler.GetJobByIDs)
|
jobsApi.GET("/:jobids", controllers.RequirePermission("job:view"), jobHandler.GetJobByIDs)
|
||||||
jobsApi.POST("/create", controllers.RequirePermission("queue:submit"), jobHandler.CreateJob)
|
jobsApi.POST("/create", controllers.RequirePermission("queue:submit"), jobHandler.CreateJob)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ Use 'xce jobs <action>' to submit/kill/stop/resume/checkpoint/restart jobs.`,
|
||||||
cmd.AddCommand(NewResumeJobsCmd(configManager))
|
cmd.AddCommand(NewResumeJobsCmd(configManager))
|
||||||
cmd.AddCommand(NewCheckPointJobsCmd(configManager))
|
cmd.AddCommand(NewCheckPointJobsCmd(configManager))
|
||||||
cmd.AddCommand(NewRestartJobsCmd(configManager))
|
cmd.AddCommand(NewRestartJobsCmd(configManager))
|
||||||
|
// Add peek command to view job output
|
||||||
|
cmd.AddCommand(NewPeekJobsCmd(configManager))
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
153
src/frontend/xcecli/cmd/jobs/peek.go
Normal file
153
src/frontend/xcecli/cmd/jobs/peek.go
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
package jobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"xcecli/common/format"
|
||||||
|
"xcecli/common/request"
|
||||||
|
"xcecli/config"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PeekQuerier implements the jobs peek command.
|
||||||
|
type PeekQuerier struct {
|
||||||
|
configManager *config.ConfigManager
|
||||||
|
lines int
|
||||||
|
stderr bool
|
||||||
|
noFollow bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPeekQuerier creates a new PeekQuerier.
|
||||||
|
func NewPeekQuerier(configManager *config.ConfigManager) *PeekQuerier {
|
||||||
|
return &PeekQuerier{
|
||||||
|
configManager: configManager,
|
||||||
|
lines: 100, // default show last 100 lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPeekJobsCmd creates the xce jobs peek command.
|
||||||
|
func NewPeekJobsCmd(configManager *config.ConfigManager) *cobra.Command {
|
||||||
|
querier := NewPeekQuerier(configManager)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "peek",
|
||||||
|
Short: "Peek at job output",
|
||||||
|
Long: `Peek at job output in real-time.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
xce jobs peek jobid # stream stdout output
|
||||||
|
xce jobs peek jobid -n 50 # initial show last 50 lines
|
||||||
|
xce jobs peek jobid --err # view stderr output
|
||||||
|
xce jobs peek jobid --no-follow # one-shot snapshot, no streaming`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
fmt.Println("error: job ID is required")
|
||||||
|
fmt.Println()
|
||||||
|
cmd.Help()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
jobID := args[0]
|
||||||
|
if err := querier.Run(jobID); err != nil {
|
||||||
|
fmt.Println(strings.ToLower(err.Error()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := cmd.Flags()
|
||||||
|
flags.IntVarP(&querier.lines, "lines", "n", 100, "number of initial lines to show")
|
||||||
|
flags.BoolVar(&querier.stderr, "err", false, "view stderr output instead of stdout")
|
||||||
|
flags.BoolVar(&querier.noFollow, "no-follow", false, "one-shot snapshot, do not follow new output")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the peek command.
|
||||||
|
func (q *PeekQuerier) Run(jobID string) error {
|
||||||
|
serverInfo, err := q.configManager.GetServerInfo()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert job ID to API format
|
||||||
|
apiJobID, err := format.ToAPIJobID(jobID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid job ID %q: %w", jobID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiClient := request.NewAPIClient(serverInfo.URL)
|
||||||
|
apiClient.SetRootCAs(serverInfo.CACert)
|
||||||
|
|
||||||
|
// Determine stream type
|
||||||
|
streamType := "stdout"
|
||||||
|
if q.stderr {
|
||||||
|
streamType = "stderr"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup signal handling for graceful exit
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
offset := int64(0)
|
||||||
|
lines := q.lines
|
||||||
|
|
||||||
|
for {
|
||||||
|
// Check for interrupt
|
||||||
|
select {
|
||||||
|
case <-sigChan:
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query params
|
||||||
|
params := map[string]string{
|
||||||
|
"type": streamType,
|
||||||
|
}
|
||||||
|
if offset == 0 {
|
||||||
|
params["lines"] = strconv.Itoa(lines)
|
||||||
|
} else {
|
||||||
|
params["offset"] = strconv.FormatInt(offset, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch output
|
||||||
|
resp, err := apiClient.GetJobOutput(serverInfo.Token, serverInfo.CACert, apiJobID, params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for error in response
|
||||||
|
if resp.Code != 200 {
|
||||||
|
return fmt.Errorf("job <%s>: %s", jobID, resp.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print new content
|
||||||
|
if resp.Data.Content != "" {
|
||||||
|
fmt.Print(resp.Data.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update offset for next poll
|
||||||
|
offset = resp.Data.Offset
|
||||||
|
|
||||||
|
// If done, exit
|
||||||
|
if resp.Data.Done {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If one-shot mode, exit after first fetch
|
||||||
|
if q.noFollow {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll interval
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/frontend/xcecli/cmd/jobs/peek_test.go
Normal file
80
src/frontend/xcecli/cmd/jobs/peek_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package jobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"xcecli/common/format"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPeekJobIDParsing(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
wantAPI string
|
||||||
|
}{
|
||||||
|
{"12345", "12345"},
|
||||||
|
{"12345[0]", "12345[0]"},
|
||||||
|
{"12345[2]", "12345[2]"},
|
||||||
|
{"plain_id", "plain_id"}, // ToAPIJobID passes through non-numeric strings
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
apiID, err := format.ToAPIJobID(tc.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if apiID != tc.wantAPI {
|
||||||
|
t.Errorf("apiID = %q, want %q", apiID, tc.wantAPI)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeekDefaults(t *testing.T) {
|
||||||
|
q := NewPeekQuerier(nil)
|
||||||
|
if q.lines != 100 {
|
||||||
|
t.Errorf("default lines = %d, want 100", q.lines)
|
||||||
|
}
|
||||||
|
if q.stderr {
|
||||||
|
t.Error("default stderr should be false")
|
||||||
|
}
|
||||||
|
if q.noFollow {
|
||||||
|
t.Error("default noFollow should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeekStreamType(t *testing.T) {
|
||||||
|
q := NewPeekQuerier(nil)
|
||||||
|
|
||||||
|
streamType := "stdout"
|
||||||
|
if q.stderr {
|
||||||
|
streamType = "stderr"
|
||||||
|
}
|
||||||
|
if streamType != "stdout" {
|
||||||
|
t.Errorf("default stream type = %q, want stdout", streamType)
|
||||||
|
}
|
||||||
|
|
||||||
|
q2 := NewPeekQuerier(nil)
|
||||||
|
q2.stderr = true
|
||||||
|
streamType = "stdout"
|
||||||
|
if q2.stderr {
|
||||||
|
streamType = "stderr"
|
||||||
|
}
|
||||||
|
if streamType != "stderr" {
|
||||||
|
t.Errorf("stderr stream type = %q, want stderr", streamType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeekCommand_NoJobID(t *testing.T) {
|
||||||
|
q := NewPeekQuerier(nil)
|
||||||
|
|
||||||
|
err := q.Run("")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for empty job ID")
|
||||||
|
}
|
||||||
|
// Should fail on job ID parsing, not on config
|
||||||
|
if !strings.Contains(strings.ToLower(err.Error()), "invalid") {
|
||||||
|
t.Logf("error message (may vary): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -170,6 +170,45 @@ func (c *APIClient) SubmitJob(token string, req *models.JobSubmitRequest) (*mode
|
||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetJobOutput retrieves job output from the API server.
|
||||||
|
func (c *APIClient) GetJobOutput(token, certPath, jobID string, params map[string]string) (*models.JobOutputResponse, error) {
|
||||||
|
var resp models.JobOutputResponse
|
||||||
|
baseURL := c.BaseURL
|
||||||
|
if strings.HasPrefix(baseURL, "https://") {
|
||||||
|
c.Client.SetTLSClientConfig(&tls.Config{
|
||||||
|
RootCAs: loadRootCAs(certPath),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if idx := strings.Index(baseURL, "/xce/v1"); idx != -1 {
|
||||||
|
baseURL = baseURL[:idx]
|
||||||
|
}
|
||||||
|
url := baseURL + "/xce/v1/jobs/" + jobID + "/output"
|
||||||
|
|
||||||
|
req := c.Client.R().
|
||||||
|
SetHeader("Authorization", token).
|
||||||
|
SetResult(&resp)
|
||||||
|
|
||||||
|
for k, v := range params {
|
||||||
|
req.SetQueryParam(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResp, err := req.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := ExtractErrorMessage(err)
|
||||||
|
return nil, fmt.Errorf("get job output request failed: [%s]", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if httpResp.StatusCode() != http.StatusOK {
|
||||||
|
logger.Info("get job output failed", zap.Any("resp", resp), zap.Int("status", httpResp.StatusCode()))
|
||||||
|
if resp.Msg != "" {
|
||||||
|
return &resp, fmt.Errorf("get job output failed: %s", resp.Msg)
|
||||||
|
}
|
||||||
|
return &resp, fmt.Errorf("get job output failed: status %d", httpResp.StatusCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
// extractErrorMessage extracts a user-friendly error message from various error types
|
// extractErrorMessage extracts a user-friendly error message from various error types
|
||||||
func ExtractErrorMessage(err error) string {
|
func ExtractErrorMessage(err error) string {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
|
|
@ -515,3 +515,18 @@ func (e *ExitStatusValue) UnmarshalJSON(data []byte) error {
|
||||||
*e = ExitStatusValue(s)
|
*e = ExitStatusValue(s)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JobOutputResponse represents the response from the job output API.
|
||||||
|
type JobOutputResponse struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data JobOutputData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobOutputData represents the payload in a job output response.
|
||||||
|
type JobOutputData struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Offset int64 `json:"offset"`
|
||||||
|
JobStatus string `json:"job_status"`
|
||||||
|
Done bool `json:"done"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,4 +75,7 @@ extern int xcecli_jobinfo(xcecli_ctx_t, uint64_t, xce_job_t**);
|
||||||
#define xcecli_free_groups(ngroups, groups) xce_free_groups(ngroups, groups)
|
#define xcecli_free_groups(ngroups, groups) xce_free_groups(ngroups, groups)
|
||||||
#define xcecli_free_clusterconf(conf) xce_free_clusterconf(conf)
|
#define xcecli_free_clusterconf(conf) xce_free_clusterconf(conf)
|
||||||
|
|
||||||
|
extern int xcecli_reply_fetchfile(xcecli_ctx_t, xce_request_t*, xce_fetchfile_reply_t*);
|
||||||
|
extern int xcecli_fetchfile(xcecli_ctx_t, xce_fetchfile_t*, xce_fetchfile_reply_t**);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,6 @@ extern void cbuf_fini(cbuf_t*);
|
||||||
extern int cbuf_put(cbuf_t*, void*);
|
extern int cbuf_put(cbuf_t*, void*);
|
||||||
extern int cbuf_tryput(cbuf_t*, void*);
|
extern int cbuf_tryput(cbuf_t*, void*);
|
||||||
extern int cbuf_get(cbuf_t*, void*);
|
extern int cbuf_get(cbuf_t*, void*);
|
||||||
|
extern int cbuf_tryget(cbuf_t*, void*);
|
||||||
|
extern int cbuf_timedget(cbuf_t*, void*, const struct timespec*);
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -58,5 +58,6 @@ extern int xce_hostctrl_req(endpoint_t*, xce_hostctrl_t*);
|
||||||
extern int xce_runtask_req(endpoint_t*, xce_taskrun_t*);
|
extern int xce_runtask_req(endpoint_t*, xce_taskrun_t*);
|
||||||
extern int xce_set_pend_reason_req(endpoint_t*, xce_preason_set_t*);
|
extern int xce_set_pend_reason_req(endpoint_t*, xce_preason_set_t*);
|
||||||
extern int xce_clear_reason_req(endpoint_t*, xce_clear_reason_t*);
|
extern int xce_clear_reason_req(endpoint_t*, xce_clear_reason_t*);
|
||||||
|
extern int xce_fetchfile_req(endpoint_t*, xce_fetchfile_t*);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,7 @@ typedef struct {
|
||||||
uint64_t jobid;
|
uint64_t jobid;
|
||||||
xce_data_t* getdata;
|
xce_data_t* getdata;
|
||||||
xce_clusterconf_t* clusterconf;
|
xce_clusterconf_t* clusterconf;
|
||||||
|
xce_fetchfile_reply_t* fetchfilereply;
|
||||||
} data;
|
} data;
|
||||||
} xce_reply_t;
|
} xce_reply_t;
|
||||||
|
|
||||||
|
|
@ -197,6 +198,7 @@ typedef struct {
|
||||||
xce_preason_set_t* preasonmsg;
|
xce_preason_set_t* preasonmsg;
|
||||||
xce_clear_reason_t* clrpreasonmsg;
|
xce_clear_reason_t* clrpreasonmsg;
|
||||||
xce_hostctrl_t* hostctrlmsg;
|
xce_hostctrl_t* hostctrlmsg;
|
||||||
|
xce_fetchfile_t* fetchfilemsg;
|
||||||
} body;
|
} body;
|
||||||
} xce_request_t;
|
} xce_request_t;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -773,4 +773,19 @@ typedef struct {
|
||||||
xce_limit_t* limit;
|
xce_limit_t* limit;
|
||||||
} xce_vres_t;
|
} xce_vres_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
uint64_t jobid;
|
||||||
|
char* filepath;
|
||||||
|
uint64_t offset;
|
||||||
|
uint32_t max_bytes;
|
||||||
|
} xce_fetchfile_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
int32_t error;
|
||||||
|
uint8_t* content;
|
||||||
|
uint64_t file_size;
|
||||||
|
uint32_t bytes_read;
|
||||||
|
uint64_t jobid; /* echoed from request for pending-request matching */
|
||||||
|
} xce_fetchfile_reply_t;
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -33,5 +33,6 @@ extern int xce_update_jusage(xce_ctx_t*, uint64_t, int, int*, int, int*, int,
|
||||||
extern int xce_set_pend_reason(xce_ctx_t*, xce_preason_set_t*);
|
extern int xce_set_pend_reason(xce_ctx_t*, xce_preason_set_t*);
|
||||||
extern int xce_clear_reason(xce_ctx_t*, xce_clear_reason_t*);
|
extern int xce_clear_reason(xce_ctx_t*, xce_clear_reason_t*);
|
||||||
extern int xce_jobinfo(xce_ctx_t*, uint64_t, xce_job_t**);
|
extern int xce_jobinfo(xce_ctx_t*, uint64_t, xce_job_t**);
|
||||||
|
extern int xce_fetchfile(xce_ctx_t*, xce_fetchfile_t*, xce_fetchfile_reply_t**);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,11 @@ typedef struct {
|
||||||
|
|
||||||
struct {
|
struct {
|
||||||
union {
|
union {
|
||||||
char* debugmsg;
|
char* debugmsg;
|
||||||
dataset_t* get;
|
dataset_t* get;
|
||||||
uint64_t jobid;
|
uint64_t jobid;
|
||||||
xce_clusterconf_t* clusterconf;
|
xce_clusterconf_t* clusterconf;
|
||||||
|
xce_fetchfile_reply_t* fetchfilereply;
|
||||||
} u;
|
} u;
|
||||||
} reply;
|
} reply;
|
||||||
} xce_ctx_t;
|
} xce_ctx_t;
|
||||||
|
|
|
||||||
|
|
@ -673,3 +673,33 @@ int xcecli_jobinfo(xcecli_ctx_t c, uint64_t jobid, xce_job_t** job)
|
||||||
rc = xce_jobinfo(&(ctx->ctx), jobid, job);
|
rc = xce_jobinfo(&(ctx->ctx), jobid, job);
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int xcecli_reply_fetchfile(xcecli_ctx_t c, xce_request_t* request,
|
||||||
|
xce_fetchfile_reply_t* freply)
|
||||||
|
{
|
||||||
|
api_ctx_t* ctx = (api_ctx_t*)c;
|
||||||
|
xce_reply_t reply;
|
||||||
|
|
||||||
|
if ((ctx == NULL) || (request == NULL) || (freply == NULL)) {
|
||||||
|
return -EINVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
init_reply_by_req(&reply, request);
|
||||||
|
reply.status = freply->error;
|
||||||
|
reply.data_which = RequestReply_data_fetchfilereply;
|
||||||
|
reply.data.fetchfilereply = freply;
|
||||||
|
|
||||||
|
return xce_reply(&(ctx->ep), &(ctx->ctx.myinfo), &reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
int xcecli_fetchfile(xcecli_ctx_t c, xce_fetchfile_t* msg,
|
||||||
|
xce_fetchfile_reply_t** reply)
|
||||||
|
{
|
||||||
|
api_ctx_t* ctx = (api_ctx_t*)c;
|
||||||
|
|
||||||
|
if ((ctx == NULL) || (msg == NULL) || (reply == NULL)) {
|
||||||
|
return -EINVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return xce_fetchfile(&(ctx->ctx), msg, reply);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,8 @@ char* xce_request_op_name(xce_request_t* req)
|
||||||
return "setpreason";
|
return "setpreason";
|
||||||
case OPCode_clrpreason:
|
case OPCode_clrpreason:
|
||||||
return "clrpreason";
|
return "clrpreason";
|
||||||
|
case OPCode_fetchfile:
|
||||||
|
return "fetchfile";
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,7 @@ libxce_test_src = ['testsuite/test.c',
|
||||||
'testsuite/test_wobj.c',
|
'testsuite/test_wobj.c',
|
||||||
'testsuite/test_qobj.c',
|
'testsuite/test_qobj.c',
|
||||||
'testsuite/test_request.c',
|
'testsuite/test_request.c',
|
||||||
|
'testsuite/test_cbuf.c',
|
||||||
]
|
]
|
||||||
|
|
||||||
libxce_test_deps = [libxce_deps, librtti_dep, unity_dep, libxce_dep]
|
libxce_test_deps = [libxce_deps, librtti_dep, unity_dep, libxce_dep]
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,32 @@ int cbuf_tryget(cbuf_t* b, void* it)
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int cbuf_timedget(cbuf_t* b, void* it, const struct timespec* deadline)
|
||||||
|
{
|
||||||
|
if ((b == NULL) || (it == NULL) || (deadline == NULL)) {
|
||||||
|
return -EINVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(it, (void*)((char*)b->data) + b->isize * b->head, b->isize);
|
||||||
|
|
||||||
|
b->head = (b->head + 1) % b->ncap;
|
||||||
|
b->len--;
|
||||||
|
|
||||||
|
pthread_cond_signal(&(b->condput));
|
||||||
|
|
||||||
|
pthread_mutex_unlock(&(b->mutex));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
int my_rwlock_init(my_rwlock_t* l)
|
int my_rwlock_init(my_rwlock_t* l)
|
||||||
{
|
{
|
||||||
if (l == NULL) {
|
if (l == NULL) {
|
||||||
|
|
|
||||||
|
|
@ -935,3 +935,26 @@ int xce_clear_reason_req(endpoint_t* ep, xce_clear_reason_t* reason)
|
||||||
|
|
||||||
return xce_request(ep, &request);
|
return xce_request(ep, &request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int xce_fetchfile_req(endpoint_t* ep, xce_fetchfile_t* msg)
|
||||||
|
{
|
||||||
|
/* clang-format off */
|
||||||
|
xce_request_t request = {
|
||||||
|
.opcode = OPCode_fetchfile,
|
||||||
|
.body_which = Request_body_fetchfilemsg,
|
||||||
|
.body = {
|
||||||
|
.fetchfilemsg = msg
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/* clang-format on */
|
||||||
|
|
||||||
|
if ((ep == NULL) || (msg == NULL)) {
|
||||||
|
return -EINVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg->filepath == NULL || msg->filepath[0] == '\0') {
|
||||||
|
return -EINVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return xce_request(ep, &request);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ extern void test_jobobj(void);
|
||||||
extern void test_qobj(void);
|
extern void test_qobj(void);
|
||||||
extern void test_wobj(void);
|
extern void test_wobj(void);
|
||||||
extern void test_request(void);
|
extern void test_request(void);
|
||||||
|
extern void test_cbuf(void);
|
||||||
|
|
||||||
void setUp(void) { return; }
|
void setUp(void) { return; }
|
||||||
|
|
||||||
|
|
@ -127,6 +128,7 @@ int main(int argc, char* argv[])
|
||||||
TEST(test_wobj);
|
TEST(test_wobj);
|
||||||
TEST(test_qobj);
|
TEST(test_qobj);
|
||||||
TEST(test_request);
|
TEST(test_request);
|
||||||
|
TEST(test_cbuf);
|
||||||
|
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
207
src/lib/testsuite/test_cbuf.c
Normal file
207
src/lib/testsuite/test_cbuf.c
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
#include <errno.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
#include <unity/unity.h>
|
||||||
|
|
||||||
|
#include "mylock.h"
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* helpers
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
static void make_deadline(struct timespec *ts, long ms)
|
||||||
|
{
|
||||||
|
clock_gettime(CLOCK_REALTIME, ts);
|
||||||
|
ts->tv_sec += ms / 1000;
|
||||||
|
ts->tv_nsec += (ms % 1000) * 1000000L;
|
||||||
|
if (ts->tv_nsec >= 1000000000L) {
|
||||||
|
ts->tv_sec++;
|
||||||
|
ts->tv_nsec -= 1000000000L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* cbuf_timedget: argument validation
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
static void test_cbuf_timedget_null_buf(void)
|
||||||
|
{
|
||||||
|
int val;
|
||||||
|
struct timespec ts;
|
||||||
|
make_deadline(&ts, 100);
|
||||||
|
TEST_ASSERT_EQUAL_INT(-EINVAL, cbuf_timedget(NULL, &val, &ts));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_cbuf_timedget_null_item(void)
|
||||||
|
{
|
||||||
|
cbuf_t b;
|
||||||
|
cbuf_init(&b, 4, sizeof(int));
|
||||||
|
struct timespec ts;
|
||||||
|
make_deadline(&ts, 100);
|
||||||
|
TEST_ASSERT_EQUAL_INT(-EINVAL, cbuf_timedget(&b, NULL, &ts));
|
||||||
|
cbuf_fini(&b);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_cbuf_timedget_null_deadline(void)
|
||||||
|
{
|
||||||
|
cbuf_t b;
|
||||||
|
cbuf_init(&b, 4, sizeof(int));
|
||||||
|
int val;
|
||||||
|
TEST_ASSERT_EQUAL_INT(-EINVAL, cbuf_timedget(&b, &val, NULL));
|
||||||
|
cbuf_fini(&b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* cbuf_timedget: timeout on empty buffer
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
static void test_cbuf_timedget_timeout_empty(void)
|
||||||
|
{
|
||||||
|
cbuf_t b;
|
||||||
|
cbuf_init(&b, 4, sizeof(int));
|
||||||
|
|
||||||
|
int val = 0;
|
||||||
|
struct timespec ts;
|
||||||
|
make_deadline(&ts, 50); /* 50 ms */
|
||||||
|
|
||||||
|
int rc = cbuf_timedget(&b, &val, &ts);
|
||||||
|
TEST_ASSERT_EQUAL_INT(-ETIMEDOUT, rc);
|
||||||
|
|
||||||
|
cbuf_fini(&b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* cbuf_timedget: normal read when item is already available
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
static void test_cbuf_timedget_success_immediate(void)
|
||||||
|
{
|
||||||
|
cbuf_t b;
|
||||||
|
cbuf_init(&b, 4, sizeof(int));
|
||||||
|
|
||||||
|
int put_val = 42;
|
||||||
|
cbuf_put(&b, &put_val);
|
||||||
|
|
||||||
|
int got_val = 0;
|
||||||
|
struct timespec ts;
|
||||||
|
make_deadline(&ts, 1000); /* generous 1 s */
|
||||||
|
|
||||||
|
int rc = cbuf_timedget(&b, &got_val, &ts);
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||||
|
TEST_ASSERT_EQUAL_INT(42, got_val);
|
||||||
|
|
||||||
|
cbuf_fini(&b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* cbuf_timedget: producer thread scenario
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
cbuf_t *buf;
|
||||||
|
int value;
|
||||||
|
long delay_ms;
|
||||||
|
} producer_arg_t;
|
||||||
|
|
||||||
|
static void *producer_thread(void *arg)
|
||||||
|
{
|
||||||
|
producer_arg_t *a = (producer_arg_t *)arg;
|
||||||
|
struct timespec ts = { .tv_sec = a->delay_ms / 1000,
|
||||||
|
.tv_nsec = (a->delay_ms % 1000) * 1000000L };
|
||||||
|
nanosleep(&ts, NULL);
|
||||||
|
cbuf_put(a->buf, &a->value);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_cbuf_timedget_success_after_producer(void)
|
||||||
|
{
|
||||||
|
cbuf_t b;
|
||||||
|
cbuf_init(&b, 4, sizeof(int));
|
||||||
|
|
||||||
|
producer_arg_t arg = { .buf = &b, .value = 99, .delay_ms = 30 };
|
||||||
|
pthread_t tid;
|
||||||
|
pthread_create(&tid, NULL, producer_thread, &arg);
|
||||||
|
|
||||||
|
int got_val = 0;
|
||||||
|
struct timespec ts;
|
||||||
|
make_deadline(&ts, 500); /* 500 ms — producer fires at 30 ms */
|
||||||
|
|
||||||
|
int rc = cbuf_timedget(&b, &got_val, &ts);
|
||||||
|
pthread_join(tid, NULL);
|
||||||
|
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, rc);
|
||||||
|
TEST_ASSERT_EQUAL_INT(99, got_val);
|
||||||
|
|
||||||
|
cbuf_fini(&b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* cbuf_timedget: timeout beats slow producer
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
static void test_cbuf_timedget_timeout_beats_slow_producer(void)
|
||||||
|
{
|
||||||
|
cbuf_t b;
|
||||||
|
cbuf_init(&b, 4, sizeof(int));
|
||||||
|
|
||||||
|
/* Producer fires at 200 ms, but we timeout at 50 ms */
|
||||||
|
producer_arg_t arg = { .buf = &b, .value = 7, .delay_ms = 200 };
|
||||||
|
pthread_t tid;
|
||||||
|
pthread_create(&tid, NULL, producer_thread, &arg);
|
||||||
|
|
||||||
|
int got_val = 0;
|
||||||
|
struct timespec ts;
|
||||||
|
make_deadline(&ts, 50);
|
||||||
|
|
||||||
|
int rc = cbuf_timedget(&b, &got_val, &ts);
|
||||||
|
TEST_ASSERT_EQUAL_INT(-ETIMEDOUT, rc);
|
||||||
|
|
||||||
|
pthread_join(tid, NULL);
|
||||||
|
cbuf_fini(&b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* cbuf_tryget: basic tests (also not previously declared in header)
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
static void test_cbuf_tryget_empty(void)
|
||||||
|
{
|
||||||
|
cbuf_t b;
|
||||||
|
cbuf_init(&b, 4, sizeof(int));
|
||||||
|
int val;
|
||||||
|
TEST_ASSERT_EQUAL_INT(-EAGAIN, cbuf_tryget(&b, &val));
|
||||||
|
cbuf_fini(&b);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_cbuf_tryget_success(void)
|
||||||
|
{
|
||||||
|
cbuf_t b;
|
||||||
|
cbuf_init(&b, 4, sizeof(int));
|
||||||
|
int put_val = 77;
|
||||||
|
cbuf_put(&b, &put_val);
|
||||||
|
int got_val = 0;
|
||||||
|
TEST_ASSERT_EQUAL_INT(0, cbuf_tryget(&b, &got_val));
|
||||||
|
TEST_ASSERT_EQUAL_INT(77, got_val);
|
||||||
|
cbuf_fini(&b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* suite entry point
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
void test_cbuf(void)
|
||||||
|
{
|
||||||
|
printf("\n>> cbuf_timedget / cbuf_tryget tests\n\n");
|
||||||
|
|
||||||
|
RUN_TEST(test_cbuf_timedget_null_buf);
|
||||||
|
RUN_TEST(test_cbuf_timedget_null_item);
|
||||||
|
RUN_TEST(test_cbuf_timedget_null_deadline);
|
||||||
|
RUN_TEST(test_cbuf_timedget_timeout_empty);
|
||||||
|
RUN_TEST(test_cbuf_timedget_success_immediate);
|
||||||
|
RUN_TEST(test_cbuf_timedget_success_after_producer);
|
||||||
|
RUN_TEST(test_cbuf_timedget_timeout_beats_slow_producer);
|
||||||
|
RUN_TEST(test_cbuf_tryget_empty);
|
||||||
|
RUN_TEST(test_cbuf_tryget_success);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
#include "mylock.h"
|
||||||
#include "utils.h"
|
#include "utils.h"
|
||||||
#include "xceapi.h"
|
#include "xceapi.h"
|
||||||
|
|
||||||
|
/* All synchronous API calls wait at most 10 seconds for a reply. */
|
||||||
|
static void cbuf_deadline_10s(struct timespec* ts)
|
||||||
|
{
|
||||||
|
clock_gettime(CLOCK_REALTIME, ts);
|
||||||
|
ts->tv_sec += 10;
|
||||||
|
}
|
||||||
|
|
||||||
int xce_hello(xce_ctx_t* ctx, char* msg)
|
int xce_hello(xce_ctx_t* ctx, char* msg)
|
||||||
{
|
{
|
||||||
int rc, status;
|
int rc, status;
|
||||||
|
|
@ -19,7 +28,8 @@ int xce_hello(xce_ctx_t* ctx, char* msg)
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +123,8 @@ int xce_get_with_condition(xce_ctx_t* ctx, xce_get_t* get, void*** data)
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +203,8 @@ int xce_resval(xce_ctx_t* ctx, char* host, char* res, xce_resval_t*** vals)
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -225,7 +237,8 @@ int xce_join(xce_ctx_t* ctx, int role, hostattrs_t* attrs)
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -259,7 +272,8 @@ int xce_jobdispatch(xce_ctx_t* ctx, xce_job_dispatch_t* info)
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -301,7 +315,8 @@ int xce_jobsubmit(xce_ctx_t* ctx, xce_job_submit_t* submit, uint64_t* jobid)
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -414,7 +429,8 @@ int xce_clusterconf(xce_ctx_t* ctx, xce_clusterconf_t** conf)
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -458,7 +474,8 @@ int xce_update_resvals(xce_ctx_t* ctx, int count, xce_resval_t** vals)
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -501,7 +518,8 @@ int xce_update_jusage(xce_ctx_t* ctx, uint64_t jobid, int npids, int* pids,
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -527,7 +545,8 @@ int xce_jobctrl(xce_ctx_t* ctx, xce_jobctrl_t* ctrl)
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -553,7 +572,8 @@ int xce_queuectrl(xce_ctx_t* ctx, xce_queuectrl_t* ctrl)
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -579,7 +599,8 @@ int xce_hostctrl(xce_ctx_t* ctx, xce_hostctrl_t* ctrl)
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -605,7 +626,8 @@ int xce_runtask(xce_ctx_t* ctx, xce_taskrun_t* runinfo)
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -631,7 +653,8 @@ int xce_set_pend_reason(xce_ctx_t* ctx, xce_preason_set_t* reasons)
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -657,7 +680,8 @@ int xce_clear_reason(xce_ctx_t* ctx, xce_clear_reason_t* reasons)
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = cbuf_get(&(ctx->wbuf), &status);
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
@ -697,3 +721,37 @@ int xce_jobinfo(xce_ctx_t* ctx, uint64_t jobid, xce_job_t** job)
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int xce_fetchfile(xce_ctx_t* ctx, xce_fetchfile_t* msg, xce_fetchfile_reply_t** reply)
|
||||||
|
{
|
||||||
|
int rc;
|
||||||
|
int status;
|
||||||
|
|
||||||
|
if ((ctx == NULL) || (msg == NULL) || (reply == NULL)) {
|
||||||
|
return -EINVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx->state != XCE_CTX_RUN) {
|
||||||
|
return -EINVAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx->reply.u.fetchfilereply = NULL;
|
||||||
|
|
||||||
|
rc = xce_fetchfile_req(ctx->ep, msg);
|
||||||
|
if (rc < 0) {
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct timespec _dl; cbuf_deadline_10s(&_dl);
|
||||||
|
rc = cbuf_timedget(&(ctx->wbuf), &status, &_dl);
|
||||||
|
if (rc < 0) {
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status < 0) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
MOVE_PTR(*reply, ctx->reply.u.fetchfilereply);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@ static int handle_reply(xce_ctx_t* ctx, xce_reply_t* reply)
|
||||||
rc = 1;
|
rc = 1;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case OPCode_fetchfile:
|
||||||
|
rc = -1;
|
||||||
|
if (reply->data_which == RequestReply_data_fetchfilereply) {
|
||||||
|
MOVE_PTR(ctx->reply.u.fetchfilereply, reply->data.fetchfilereply);
|
||||||
|
rc = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
rc = -ENOENT;
|
rc = -ENOENT;
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,10 @@ static int parjob_prepare_allocation(void* arg, xce_sched_ctx_t* ctx)
|
||||||
if (obj->tag == XCE_RESREQ_PRIMARY) {
|
if (obj->tag == XCE_RESREQ_PRIMARY) {
|
||||||
xce_primary_resreq_t* primary = obj->u.primary->resreq;
|
xce_primary_resreq_t* primary = obj->u.primary->resreq;
|
||||||
|
|
||||||
|
if (primary == NULL) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (primary->maxTasks == 1) {
|
if (primary->maxTasks == 1) {
|
||||||
/* do not care sequence job */
|
/* do not care sequence job */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,13 @@ xce_sched_job_t* make_sched_job(void* arg, xce_job_t* j)
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
rc = parse_resreq(job->resreq, j->submit->resreq, sched->resmgr, &errmsg);
|
{
|
||||||
|
char* resreq_str = j->submit->resreq;
|
||||||
|
if (NULLSTR(resreq_str)) {
|
||||||
|
resreq_str = "type == any";
|
||||||
|
}
|
||||||
|
rc = parse_resreq(job->resreq, resreq_str, sched->resmgr, &errmsg);
|
||||||
|
}
|
||||||
if (rc < 0) {
|
if (rc < 0) {
|
||||||
ERROR("%s parse_resreq failed, %s", __func__, errmsg);
|
ERROR("%s parse_resreq failed, %s", __func__, errmsg);
|
||||||
FREEUP(errmsg);
|
FREEUP(errmsg);
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,10 @@ static int sched_make_primary_allocation(xce_scheduler_t* sched,
|
||||||
xce_sched_ctx_t ctx;
|
xce_sched_ctx_t ctx;
|
||||||
int rc = 0;
|
int rc = 0;
|
||||||
|
|
||||||
|
if (primary == NULL) {
|
||||||
|
return -EINVAL;
|
||||||
|
}
|
||||||
|
|
||||||
sched_ctx_init(&ctx, sched, obj);
|
sched_ctx_init(&ctx, sched, obj);
|
||||||
sched_ctx_set_primary(&ctx, primary);
|
sched_ctx_set_primary(&ctx, primary);
|
||||||
rc = sched_make_primary_alloc_(sched, &ctx);
|
rc = sched_make_primary_alloc_(sched, &ctx);
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ enum OPCode {
|
||||||
runtasks @19;
|
runtasks @19;
|
||||||
setpreason @20;
|
setpreason @20;
|
||||||
clrpreason @21;
|
clrpreason @21;
|
||||||
|
fetchfile @22;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PeerInfo $C.mapname("xce_peerinfo_t") {
|
struct PeerInfo $C.mapname("xce_peerinfo_t") {
|
||||||
|
|
@ -536,6 +537,7 @@ struct RequestReply $C.mapname("xce_reply_t"){
|
||||||
getdata @5: Data;
|
getdata @5: Data;
|
||||||
jobid @6: UInt64;
|
jobid @6: UInt64;
|
||||||
clusterconf @7: ClusterConf;
|
clusterconf @7: ClusterConf;
|
||||||
|
fetchfilereply @8: FetchFileReply;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -569,6 +571,21 @@ struct ClearPReason $C.mapname("xce_clear_reason_t") {
|
||||||
jobs @0: List(UInt64) $C.mapname("jobs") $C.maplistcount("njobs");
|
jobs @0: List(UInt64) $C.mapname("jobs") $C.maplistcount("njobs");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
content @1: List(UInt8) $C.mapname("content") $C.maplistcount("bytes_read");
|
||||||
|
fileSize @2: UInt64 $C.mapname("file_size");
|
||||||
|
bytesRead @3: UInt32 $C.mapname("bytes_read");
|
||||||
|
jobid @4: UInt64 $C.mapname("jobid");
|
||||||
|
}
|
||||||
|
|
||||||
struct Request $C.mapname("xce_request_t"){
|
struct Request $C.mapname("xce_request_t"){
|
||||||
opcode @0: OPCode;
|
opcode @0: OPCode;
|
||||||
seq @1: UInt64;
|
seq @1: UInt64;
|
||||||
|
|
@ -593,6 +610,7 @@ struct Request $C.mapname("xce_request_t"){
|
||||||
preasonmsg @19: PendReasonSet;
|
preasonmsg @19: PendReasonSet;
|
||||||
clrpreasonmsg @20: ClearPReason;
|
clrpreasonmsg @20: ClearPReason;
|
||||||
hostctrlmsg @21: HostControl;
|
hostctrlmsg @21: HostControl;
|
||||||
|
fetchfilemsg @22: FetchFileMsg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
#include "client.h"
|
||||||
#include "controller_connector.h"
|
#include "controller_connector.h"
|
||||||
#include "netio.h"
|
#include "netio.h"
|
||||||
#include "uv_ipc.h"
|
#include "uv_ipc.h"
|
||||||
|
|
@ -11,6 +12,7 @@
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
|
|
@ -1097,6 +1099,89 @@ static int handle_runtasks(xce_request_t* request)
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Handles the fetchfile request from the controller.
|
||||||
|
*
|
||||||
|
* Reads up to msg->max_bytes bytes from msg->filepath starting at msg->offset
|
||||||
|
* and sends the data back to the controller as an OPCode_reply.
|
||||||
|
*
|
||||||
|
* @param request Pointer to the xce_request_t containing the fetchfile msg.
|
||||||
|
* @return int 0 on success, -1 on failure.
|
||||||
|
*/
|
||||||
|
static int handle_fetchfile(xce_request_t* request)
|
||||||
|
{
|
||||||
|
xce_fetchfile_t* msg = request->body.fetchfilemsg;
|
||||||
|
xce_fetchfile_reply_t freply;
|
||||||
|
struct stat st;
|
||||||
|
uint8_t* buf = NULL;
|
||||||
|
int fd;
|
||||||
|
ssize_t nread;
|
||||||
|
uint32_t maxbytes;
|
||||||
|
|
||||||
|
XCECD_LOG(XCE_LOG_DEBUG,
|
||||||
|
"Fetchfile request: jobid=%lu path=%s offset=%lu max=%u",
|
||||||
|
msg->jobid, msg->filepath, msg->offset, msg->max_bytes);
|
||||||
|
|
||||||
|
memset(&freply, 0, sizeof(freply));
|
||||||
|
freply.jobid = msg->jobid;
|
||||||
|
|
||||||
|
if (msg->filepath == NULL || msg->filepath[0] == '\0') {
|
||||||
|
XCECD_LOG(XCE_LOG_ERROR, "Fetchfile: empty filepath");
|
||||||
|
freply.error = -EINVAL;
|
||||||
|
goto send;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bound max_bytes to avoid huge allocations */
|
||||||
|
maxbytes = (msg->max_bytes == 0 || msg->max_bytes > 4 * 1024 * 1024)
|
||||||
|
? 4 * 1024 * 1024
|
||||||
|
: msg->max_bytes;
|
||||||
|
|
||||||
|
fd = open(msg->filepath, O_RDONLY);
|
||||||
|
if (fd < 0) {
|
||||||
|
XCECD_LOG(XCE_LOG_ERROR, "Fetchfile: open(%s) failed: %s",
|
||||||
|
msg->filepath, strerror(errno));
|
||||||
|
freply.error = -errno;
|
||||||
|
goto send;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fstat(fd, &st) < 0) {
|
||||||
|
freply.error = -errno;
|
||||||
|
close(fd);
|
||||||
|
goto send;
|
||||||
|
}
|
||||||
|
freply.file_size = (uint64_t)st.st_size;
|
||||||
|
|
||||||
|
buf = malloc(maxbytes);
|
||||||
|
if (buf == NULL) {
|
||||||
|
freply.error = -ENOMEM;
|
||||||
|
close(fd);
|
||||||
|
goto send;
|
||||||
|
}
|
||||||
|
|
||||||
|
nread = pread(fd, buf, maxbytes, (off_t)msg->offset);
|
||||||
|
close(fd);
|
||||||
|
|
||||||
|
if (nread < 0) {
|
||||||
|
XCECD_LOG(XCE_LOG_ERROR, "Fetchfile: pread failed: %s",
|
||||||
|
strerror(errno));
|
||||||
|
freply.error = -errno;
|
||||||
|
freply.bytes_read = 0;
|
||||||
|
free(buf);
|
||||||
|
buf = NULL;
|
||||||
|
} else {
|
||||||
|
freply.error = 0;
|
||||||
|
freply.bytes_read = (uint32_t)nread;
|
||||||
|
freply.content = buf;
|
||||||
|
buf = NULL; /* ownership passed to freply */
|
||||||
|
}
|
||||||
|
|
||||||
|
send:
|
||||||
|
xcecli_reply_fetchfile(xcecli_ctx, request, &freply);
|
||||||
|
free(freply.content);
|
||||||
|
free(buf);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Handles the runtasks request from the controller.
|
* @brief Handles the runtasks request from the controller.
|
||||||
*
|
*
|
||||||
|
|
@ -1234,6 +1319,9 @@ static int myreq_handler(xce_request_t* request, void* args)
|
||||||
case OPCode_jobcontrol:
|
case OPCode_jobcontrol:
|
||||||
ret = handle_jobcontrol(request);
|
ret = handle_jobcontrol(request);
|
||||||
break;
|
break;
|
||||||
|
case OPCode_fetchfile:
|
||||||
|
ret = handle_fetchfile(request);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue