Merge branch 'working' into job_peek_lchen

job_peek_lchen: Add fetchfile feature (job output/error peek proxy)
working: HA/Raft infrastructure (consensus, replication, config mgmt)

Conflict resolution summary:
- controller.capnp: fetchfile @41, fetchfilereply @18, fetchfilemsg @30
- cluster.c: bifurcated OPCode_reply for fetchfile + config-push ACK
- job.c: kept full fetchfile pending-request subsystem (~183 lines)
- request.c: kept both xce_fetchfile_req() and XCE_RETRY_DEFAULTS
- helper.c: added missing preVoteRequest/preVoteReply mappings
- All 15 new test registrations (test_cbuf.c + 14 HA tests)
This commit is contained in:
Lei Chen 2026-07-22 13:57:25 +08:00
commit 3a2094a461
529 changed files with 345174 additions and 5048 deletions

7
src/docs/FUTURE_WORK.md Normal file
View file

@ -0,0 +1,7 @@
# XCE Controller: Future Work Items
> **Moved (2026-06-10)**: All items merged into the single consolidated future-work document at
> [`.kiro/steering/roadmap.md`](../../.kiro/steering/roadmap.md).
>
> Section mapping: §1+§8 → admin-config-tool (backlog); §2 → superseded by ha-consistency-invariants Req 5;
> §3, §4, §5, §6, §7 → Hardening / Direct-Implementation Items.

File diff suppressed because it is too large Load diff

View file

@ -1,225 +0,0 @@
# API Server Backend Gap Analysis
Source: `src/controller/testsuite/test.c` (C test tool) vs `src/frontend/apiserver/` (Go API server)
## Overview
The C test tool (`test.c`) directly calls `xcecli_*` C library functions to interact
with the controller. The Go API server currently operates in a **push-based, read-only-from-store**
model: the controller pushes data (jobs, workers, queues, resources) via Cap'n Proto, and
the API server reads from its in-memory stores. Write/control operations are forwarded
via the CGO backend layer, but only a subset of the C API is wired up.
---
## Currently Implemented (Backend Forwards to C Controller)
| C Test Command | C API Call | Go Backend Function | REST Endpoint |
|----------------|------------------------|-------------------------|------------------------------------|
| `jobsub` | `xcecli_jobsubmit()` | `backend.SubmitJob()` | `POST /xce/v1/jobs/create` |
| `jobkill/jobckpt` | `xcecli_jobctrl()` | `backend.ControlJob()` | `POST /xce/v1/jobs/:jobids/control` |
| `qctrl` | `xcecli_queuectrl()` | `backend.ControlQueue()`| `POST /xce/v1/queues/control` |
| *(partial)* | `xcecli_hostctrl()` (N/A) | `backend.ControlHost()` **stub only** | `POST /xce/v1/hosts/control` |
Push notifications (read path, already working via `handlePush`):
- Job updates via `OnJobUpdated` -> `store.Jobs`
- Worker/host updates via `OnHostUpdated` -> `store.Hosts`
- Queue updates via `OnQueueUpdated` -> `store.Queues`
- Resource definitions via `OnResourceUpdated` -> `store.Resources`
- Resource locations via `OnResourceLocationUpdated` -> `store.ResourceLocations`
---
## NOT Implemented -- Backend Request Gaps
### 1. Resource Value Query (`cmd_resval` -> `xcecli_resval()`)
- **What it does**: Queries current numeric/string values of a named resource on a
specific host (or cluster-wide). Returns typed values (float, string, bool).
- **REST impact**: No `GET /xce/v1/resources/values` endpoint exists. The resource
controller only serves definitions and distributions, not live values.
- **Priority**: **High** -- Essential for monitoring dashboards to show real-time
`mem`, `r1m`, `slots` etc.
[feedback] api server will not pull resources from controller, it just waits controller to push
updated resource information, so api server just handle resource information update will be OK.
### 2. Resource Value Update (`cmd_resupdate` -> `xcecli_update_resvals()`)
- **What it does**: Worker pushes updated resource values to the controller.
- **REST impact**: No equivalent. Currently workers call the C API directly. If the
API server should support an admin endpoint to update resource values (e.g., for
testing or override), this is missing.
- **Priority**: **Low-Medium** -- Workers typically call this directly; rarely needed
via REST. Could be useful for admin overrides.
[feedback] This item will not be applied to api server, resource update function need to be
used to update resources information of worker node(such as cpu/memory/..., and api
server will not talke workload, so it do not need this)
### 3. Job Dispatch (`cmd_jobrun` -> `xcecli_jobdispatch()`)
- **What it does**: Dispatches a job to specific hosts with allocation info
(host:ntasks pairs). This is how the scheduler tells the controller to start
running a job on specific workers.
- **REST impact**: Not exposed. The `POST /xce/v1/jobs/create` handles submission,
but **dispatch** (assignment to hosts) is a separate operation currently done by
the scheduler directly.
- **Priority**: **Low** -- Dispatch is an internal scheduler->controller operation.
Not typically user-facing.
[feedback] treat this as low priority, IBM LSF has a brun command which issued by user to
force a job to run in given host, we may need this. also cmd_jobrun() has
been used to test execution flow/protocol between controller and work daemon
### 4. Job Info / Single Job Detail (`cmd_jobinfo` -> `xcecli_jobinfo()`)
- **What it does**: Queries detailed info for a single job by ID, including pend
reasons.
- **REST impact**: The `GET /xce/v1/jobs/:jobids` endpoint reads from the in-memory
store, which is populated by push notifications. This works for already-known jobs
but **cannot query a job that hasn't been pushed yet**. The C `xcecli_jobinfo()` is
a synchronous request-response to the controller -- fundamentally different from
the store-read pattern.
- **Priority**: **Medium** -- Store-based reads are sufficient for most cases. The
sync query path would only matter if pushes are delayed or the store is stale.
[feedback] no need this, as job information will be pushed to api server
### 5. Cluster Configuration (`cmd_getconf` -> `xcecli_clusterconf()`)
- **What it does**: Synchronously fetches the full cluster config (resources, queues,
workers, host groups, user groups, global configs).
- **REST impact**: Currently the API server relies entirely on push notifications to
populate stores. There's no way to do a one-shot **full sync** with the controller.
On startup, it joins via `xcecli_join(HostRole_apiserver)` but doesn't explicitly
request the current state.
- **Priority**: **High** -- Without an initial sync, if the API server restarts
mid-session, it loses all state until the next push. The store is stale until a
new push arrives.
[feedback] api server need handle pushed cluster configuration
### 6. Host Control (`backend.ControlHost()` -> `xcecli_hostctrl()`)
- **What it does**: The C library doesn't expose `xcecli_hostctrl()` yet (the Go
stub returns an error).
- **REST impact**: `POST /xce/v1/hosts/control` exists but will always fail:
`"host control not yet supported via C library"`.
- **Priority**: **High** -- Users cannot open/close hosts via the API.
[feedback] we need this
### 7. Set Pending Reason (`cmd_setpend` -> `xcecli_set_pend_reason()`)
- **What it does**: Scheduler sets pend reasons on a job (host-based or
resource-based).
- **REST impact**: No equivalent endpoint. Job pend reasons are currently populated
only via push data.
- **Priority**: **Low** -- This is a scheduler->controller internal operation.
[feedback] set pending reason is for scheduler to set why job will be pend, this is
used for test scheduler to controller job pending api
### 8. Run Tasks (`cmd_runtasks` -> `xcecli_runtask()`)
- **What it does**: Launches individual tasks on specific hosts (not a full job, but
sub-task execution).
- **REST impact**: No endpoint exists.
- **Priority**: **Low-Medium** -- Used for advanced task launch scenarios. Could be
needed for MPI job management.
[feedback] this function is used by worker to run non rank 0 tasks(this is for MPI jobs) and api server
need not handle this
### 9. Shutdown (`cmd_shutdown` -> `xcecli_shutdown()`)
- **What it does**: Sends shutdown command to the cluster controller.
- **REST impact**: No endpoint. Currently cluster management operations (shutdown,
restart) are not available via REST.
- **Priority**: **Medium** -- Operations teams need remote cluster control capability.
[feedback] we may need to admin tool, which talk to controller directly for this, as normal
user do not need this at all
### 10. Resource Requirement Select (`FilterHostByResReqSelect`)
- **What it does**: The hosts `POST /select` endpoint exists but returns **empty
results** -- it's a stub: `processHostData([]utils.OrderMap{}, "")`.
- **REST impact**: `POST /xce/v1/hosts/select` is defined but non-functional.
- **Priority**: **Medium** -- This is the XCE equivalent of `lsload -R` / host
matching by resource requirements. Important for job submission workflows to
find eligible hosts.
[feedback] we may need this
---
## Enhancement Proposal (Prioritized)
| Priority | Enhancement | Effort | Description |
|----------|---------------------------------------------|------------|-------------|
| **P0** | Initial state sync via `xcecli_clusterconf()` | Medium | On startup (after `RegisterToBackend`), call `xcecli_clusterconf()` to do a full state sync into stores. Prevents stale stores after API server restart. |
| **P0** | Implement `ControlHost()` in C library + Go wrapper | Medium | Expose `xcecli_hostctrl()` in `client.h`, then replace the Go stub in `clib.go` with a real CGO call. |
| **P1** | Resource value query endpoint | Medium | Add `GET /xce/v1/resources/values?host=X&name=Y` -> `backend.QueryResourceValues()` -> `xcecli_resval()`. This is a synchronous request-response pattern (different from push). |
| **P1** | Implement `FilterHostByResReqSelect` | Medium-High| Wire `POST /xce/v1/hosts/select` to evaluate resource requirement expressions against current host state from the store. |
| **P2** | Cluster shutdown endpoint | Low | Add `POST /xce/v1/clusters/control` -> `backend.ShutdownCluster()` -> `xcecli_shutdown()`. Should be admin-only with RBAC. |
| **P2** | Resource value update endpoint | Low | Add `POST /xce/v1/resources/values/update` -> `backend.UpdateResourceValues()` -> `xcecli_update_resvals()`. Admin/testing use. |
| **P3** | Task run endpoint | Low-Medium | Add `POST /xce/v1/tasks/run` -> `backend.RunTask()` -> `xcecli_runtask()`. For advanced MPI task management. |
| **P3** | Set pending reason endpoint | Low | Add `POST /xce/v1/jobs/:jobid/pending-reason` -> `backend.SetPendReason()` -> `xcecli_set_pend_reason()`. Internal/scheduler-facing only. |
---
## Architecture Note: Synchronous vs Push-Based
The biggest design decision lurking here: several of these C APIs (`xcecli_resval()`,
`xcecli_clusterconf()`, `xcecli_jobinfo()`) are **synchronous request-response** calls
-- the client sends a request and blocks until the controller replies. This is
fundamentally different from the current **push-based** pattern where the controller
asynchronously pushes updates.
To implement these, we'd need to either:
1. **Direct CGO call** (simpler but blocks the Go goroutine -- acceptable if calls
are fast and we set timeouts)
2. **Request-response via push channel** (more complex -- send request, register a
wait, receive push reply)
Recommendation: option 1 for initial implementation (it's what `SubmitJob` and
`ControlJob` already do), with timeouts and goroutine isolation to prevent a slow
controller from blocking the API server.
---
## Additional C API Calls Not Used Anywhere (Reference)
These exist in `client.h` but are not called by `test.c` either:
| C API Call | Purpose |
|--------------------------|---------|
| `xcecli_hello()` | Health check / greeting |
| `xcecli_nop()` | No-op heartbeat |
| `xcecli_heartbeat()` | Periodic heartbeat |
| `xcecli_notify_jobstart()`| Worker notifies job started (with PID info) |
| `xcecli_notify_jobfail()` | Worker notifies job failed |
| `xcecli_notify_jobstop()` | Worker notifies job stopped |
| `xcecli_notify_jobresume()`| Worker notifies job resumed |
| `xcecli_notify_jobpostdone()`| Worker notifies job post-exec done |
| `xcecli_update_jusage()` | Worker updates job resource usage |
| `xcecli_notify()` | Generic notification |
| `xcecli_notify_taskstart()`| Worker notifies task started |
| `xcecli_notify_taskfail()`| Worker notifies task failed |
| `xcecli_notify_taskfinish()`| Worker notifies task finished |
| `xcecli_clear_reason()` | Clear pend reasons for jobs |
| `xcecli_block_tasks()` | Block task processing |
| `xcecli_unblock_tasks()` | Unblock task processing |
| `xcecli_get_my_endpoint()`| Get local endpoint info |
| `xcecli_get_myinfo()` | Get local peer info |
[feedback] I think we just need heartbeat to tell controller that I'm alive, and other
functions for worker and scheduler
These are primarily worker->controller notifications. They would be needed if
the API server were to act as a proxy for worker daemons, which is not the
current design.

View file

@ -1,459 +0,0 @@
# Integration Test Plan — P0 + P1 Features
## Purpose
Verify end-to-end correctness of every feature delivered by tasks P0-1 through P1-3,
exercising the full stack: handler → store → handler merge → store readback.
These tests **do not** require a live C controller or CGO — they use the pure-Go stores
and `DefaultHandler` directly, which is what unit tests already do. The integration layer
adds: **multi-step sequences**, **cross-feature interactions**, and **edge-case coverage**
that unit tests leave out.
---
## Test Environment
- All tests run with `CGO_ENABLED=0` (pure Go)
- Each test calls `store.InitStores()` to reset all global state
- `utils.Log = zap.NewNop()` to suppress log output
- **§1–§5, §7, §8**: call handler/store methods directly — no HTTP server required
- **§6 (RBAC)**: requires a test HTTP server via `httptest.NewRecorder` with a full Gin router; JWT tokens must be real HS256-signed tokens using the configured signing secret (typically `"xce-api-key"` in test config); `ctx.Set("user", "admin")` does **not** satisfy `GetUsernameFromToken()` which reads and parses the `Authorization` header
- **JWT IP binding:** `isValidToken` checks `claims.IP == ctx.ClientIP()`; in `httptest`, `ClientIP()` reads from `req.RemoteAddr` — set `req.RemoteAddr = "127.0.0.1:0"` on every test request and embed `"ip": "127.0.0.1"` in the JWT claims; a mismatch returns 401 for the wrong reason, silently invalidating the test
- **Auth config:** `isValidToken` dereferences `utils.AuthenticationYamlConfig`, a package-level pointer that is `nil` until `LoadConfig()` is called — before any JWT test set `utils.AuthenticationYamlConfig = &utils.Authentication{Token: utils.TokenConfig{SecretKey: "xce-api-key"}}` directly; do not call `LoadConfig()` (reads a file that won't exist in CI)
---
## 1. P0-1: Cluster Config Push
### 1.1 Config push populates all stores in a single call
**Steps:**
1. Call `dispatchConfigPush(handler, queues, resources, hostConfigs, cluster)` with non-nil data for all four.
2. Verify `store.Queues`, `store.Resources`, `store.Hosts`, `store.Clusters` all contain the pushed data.
**Asserts:** A single config push updates all four store types in sequence (queues → resources → hosts → cluster); the four updates are NOT atomic — a concurrent reader may see a partial state.
### 1.2 Second config push merges (does not replace)
**Steps:**
1. Seed `store.Hosts` with worker-sourced data (HostStatus, CPUFactor, NumCPUs, etc.)
2. Call `h.OnHostConfigUpdated()` with config-sourced fields (HostModel, HostType, MaxSlot)
3. Call `h.OnHostConfigUpdated()` a second time with `HostModel: "modelB"` (distinct from step 2's "modelA")
4. Verify worker-sourced fields from step 1 are still preserved
5. Verify `HostModel == "modelB"` (config-sourced field reflects the second push, not the first)
**Asserts:** Partial merge is idempotent and correctly preserves the other source's fields across multiple pushes.
### 1.3 Cluster update with zero HeartbeatInterval preserves existing value
**Steps:**
1. `store.Clusters.Put("c1", ClusterInfo{HeartbeatInterval: 45})`
2. Call `h.OnClusterUpdated(&ClusterInfo{ClusterName: "c1", HeartbeatInterval: 0, Vcpus: 8})`
3. Verify `HeartbeatInterval` is still 45 (zero is ignored per `if incoming.HeartbeatInterval > 0` guard)
**Asserts:** Cluster push with `HeartbeatInterval=0` does not zero out the stored interval.
### 1.4 Config push with nil cluster argument is a no-op
**Steps:**
1. Call `dispatchConfigPush(handler, nil, nil, nil, nil)`
2. Verify `store.Clusters.Count() == 0`
**Covers:** `dispatchConfigPush` skips the cluster update when the cluster argument is nil. (The empty-name guard lives in the CGO layer `cClusterConfToClusterInfo` and is not reachable in `CGO_ENABLED=0` tests; a `ClusterInfo{ClusterName:""}` passed directly would be stored under an empty key.)
### 1.5 Config push with nil queues/resources/hosts is safe
**Steps:**
1. Call `dispatchConfigPush(handler, nil, nil, nil, nil)`
2. Verify no panics, no store entries.
**Covers:** Nil-safety of the dispatch loop.
---
## 2. P0-2: Host Control via Backend
### 2.1 Valid control action (open/close) routes through to backend
**Steps:**
1. `store.Hosts.Put("node01", HostInfo{HostName: "node01"})`
2. Call `preCheckHostControlRequest(&HostControlRequest{ControlAction: "open", Hostname: []string{"node01"}})`
3. Assert `allFlag == false`, `err == nil`
**Asserts:** Valid actions pass pre-check.
### 2.2 Invalid action is rejected
**Steps:**
1. Call `preCheckHostControlRequest(&HostControlRequest{ControlAction: "reboot", Hostname: []string{"node01"}})`
2. Assert error is returned.
**Asserts:** Only "open" and "close" are accepted.
### 2.3 Comment length limit (512 chars)
**Steps:**
1. Call `preCheckHostControlRequest` with `Comment` of 513 chars
2. Assert error
3. Call with 512 chars — assert no error
**Already covered in unit tests.** Integration adds: verify the error message is human-readable.
### 2.4 Hostname validation checks store existence
**Steps:**
1. `store.Hosts.Put("node01", HostInfo{HostName: "node01"})`
2. Call `validateHost(ctx, action, []string{"node01", "nonexistent"})`
3. Assert `node01` in success list, `nonexistent` in failed map
**Note:** `validateHost` calls `GetUsernameFromToken()` internally, requiring the same JWT setup as §6 — set `utils.AuthenticationYamlConfig`, set `req.RemoteAddr = "127.0.0.1:0"`, embed `"ip": "127.0.0.1"` in the JWT claims, and pass the token via `Authorization` header. `ctx.Set("user", "admin")` is **not** sufficient. See §6 Test Environment notes for the full setup checklist. Unlike §2.1–2.3, this test is grouped with the §6 JWT-required tests in `controllers/integration_test.go`.
### 2.5 RBAC: host:control requires cluster_admin role
**Steps:**
1. Load RBAC config with `cluster_admin` having `host:control`
2. Use `httptest.NewRecorder` + Gin router with a spy handler that sets a response body
3. Call with a user that is NOT cluster_admin — assert 403 response
4. Call with cluster_admin user — assert 2xx and spy handler body present
**Note:** `RequirePermission` short-circuits for `cluster_admin` via early `return` (without calling `ctx.Next()`). Asserting `ctx.IsAborted() == false` gives a **false pass** — the middleware doesn't abort, but the handler is also never called. Use an HTTP recorder to verify the handler response actually appeared.
**Covers:** Route middleware integration.
### 2.6 RBAC: host:view accessible to basic_user
**Steps:**
1. Load RBAC config with `basic_user` having `host:view`
2. Call `RequirePermission("host:view")` with basic_user
3. Assert request proceeds
### 2.7 Concurrent control requests do not race
**Already covered:** `TestPreCheckHostControlRequest_NoRace` (50 goroutines).
Integration adds: test that concurrent `ControlHost` calls to the same host don't corrupt store state.
### 2.8 Batch response construction
**Steps:**
1. Build success and failed slices directly (do NOT call `ControlHost` — it calls the CGO stub which returns `errNoCGO` under `CGO_ENABLED=0`):
- `succeededItems := []map[string]interface{}{{"node": "node01"}, {"node": "node02"}}`
- `failedItems := []map[string]interface{}{{"node": "bad", "reason": "not found"}}`
2. Call `BuildBatchOperationResponse("close", "host", succeededItems, failedItems)`
3. Verify returned status is `"partially_accepted"`, success list has 2 entries, failed map has 1
**Covers:** Batch response construction logic; the CGO backend call is out of scope for pure-Go integration tests.
---
## 3. P1-1: Resource Value Push
### 3.1 All 7 value types convert correctly (pure-Go path)
**Steps:**
1. For each value type (i32, i64, u32, u64, fval, sval, bval):
- Create `rawResvalData` with that type
- Call `rawResvalToDistribution(r)`
- Assert `ValueType`, `NumericValue`/`StringValue`/`BooleanValue` match expected
**Already covered in unit tests.** Integration adds: round-trip through `OnResourceLocationUpdated`:
2. Call `h.OnResourceLocationUpdated(rd)` for each type
3. Read back from `store.ResourceLocations` using composite key `name:host`
4. Assert stored values match
### 3.2 Nil val produces nil return (no store update)
**Steps:**
1. Create `rawResvalData{valNil: true, ...}`
2. Assert `rawResvalToDistribution` returns nil
3. Assert `store.ResourceLocations.Count() == 0`
### 3.3 Unknown discriminant produces nil return with warn log
**Steps:**
1. Create `rawResvalData{valWhich: 99, ...}`
2. Assert `rawResvalToDistribution` returns nil
3. Assert `store.ResourceLocations.Count() == 0`
### 3.4 Multiple resource values in a single push
**Steps:**
1. Call `h.OnResourceLocationUpdated(rd1)` then `h.OnResourceLocationUpdated(rd2)` with different `name:host` keys
2. Assert `store.ResourceLocations.Count() == 2`
3. Assert each key returns the correct value
### 3.5 Resource value update overwrites previous value
**Steps:**
1. `h.OnResourceLocationUpdated(rd)` with `NumericValue: 100.0`
2. `h.OnResourceLocationUpdated(rd)` with same `name:host` but `NumericValue: 200.0`
3. Assert stored value is 200.0 (last-write-wins)
**Covers:** Store `Put` is a full replacement, not merge.
### 3.6 Composite key format is `res_name:location`
**Steps:**
1. Push a resource with `ResourceName: "mem"` and `Location: "node01"`
2. Assert `store.ResourceLocations.Has("mem:node01") == true`
3. Assert `store.ResourceLocations.Has("mem") == false`
---
## 4. P1-3: Heartbeat Keep-Alive
> **Isolation requirement:** `heartbeatIntervalSecs` is a package-level `var int32` in the `backend` package shared across all tests in that package. Tests in this section MUST NOT call `t.Parallel()`. Run the full heartbeat test group sequentially to avoid cross-test interference via `SetHeartbeatInterval`.
### 4.1 Heartbeat interval updates on cluster config push
**Steps:**
1. `atomic.StoreInt32(&heartbeatIntervalSecs, 30)` (default)
2. Call `h.OnClusterUpdated(&ClusterInfo{ClusterName: "c1", HeartbeatInterval: 45})`
3. Assert `atomic.LoadInt32(&heartbeatIntervalSecs) == 45`
**Covers:** The P1-3 integration point — cluster config push triggers heartbeat interval change.
### 4.2 Cluster push with HeartbeatInterval=0 preserves current interval
**Steps:**
1. `atomic.StoreInt32(&heartbeatIntervalSecs, 45)`
2. Call `h.OnClusterUpdated(&ClusterInfo{ClusterName: "c1", HeartbeatInterval: 0})`
3. Assert `atomic.LoadInt32(&heartbeatIntervalSecs) == 45` (unchanged)
**Covers:** The `> 0` guard in `OnClusterUpdated` and `SetHeartbeatInterval`.
---
## 5. Cross-Feature Integration
### 5.1 Config push updates heartbeat interval AND stores cluster data
**Steps:**
1. Default `heartbeatIntervalSecs = 30`
2. Call `dispatchConfigPush(h, nil, nil, nil, &ClusterInfo{ClusterName: "c1", HeartbeatInterval: 60, Vcpus: 32})`
3. Assert `store.Clusters.Get("c1")` has `HeartbeatInterval=60, Vcpus=32`
4. Assert `atomic.LoadInt32(&heartbeatIntervalSecs) == 60`
**Covers:** P0-1 + P1-3 interaction in a single dispatch.
### 5.2 Config push + host control on same host
**Steps:**
1. `dispatchConfigPush(h, nil, nil, []HostInfo{{HostName: "node01", HostModel: "x86_64"}}, nil)`
2. Assert `store.Hosts.Get("node01")` has `HostModel="x86_64"`
3. Assert `store.Hosts.Has("node01") == true` — verifies config-push data is visible in the host store
4. Call `preCheckHostControlRequest(&HostControlRequest{ControlAction: "close", Hostname: []string{"node01"}})` — assert `allFlag == false`, `err == nil` (validates action format; store lookup is handled by `validateHost` which requires JWT — see §2.4 note)
**Covers:** Data created by config push is written to the host store and is visible to subsequent operations.
### 5.3 Resource push after host config push — same host referenced
**Steps:**
1. `h.OnHostConfigUpdated(&HostInfo{HostName: "node01", HostModel: "x86_64"})`
2. `h.OnResourceLocationUpdated(&ResourceDistributionInfo{ResourceName: "mem", Location: "node01", NumericValue: 65536.0, ValueType: NUMERIC, IsDynamic: true})`
3. Assert `store.Hosts.Get("node01")` has `HostModel="x86_64"` (preserved)
4. Assert `store.ResourceLocations.Get("mem:node01")` has `NumericValue=65536.0`
5. Assert `store.Hosts.Get("node01").HostModel` still "x86_64" (host config was not overwritten)
**Covers:** Two independent push paths don't interfere with each other's store data.
### 5.4 Worker push after config push preserves config fields
**Steps:**
1. `h.OnHostConfigUpdated(&HostInfo{HostName: "node01", HostModel: "x86_64", MaxSlot: 8})`
2. `h.OnHostUpdated(&HostInfo{HostName: "node01", HostStatus: 3, CPUFactor: 2.5, NumCPUs: 16})`
3. Assert `got.HostModel == "x86_64" && got.MaxSlot == 8` (config preserved)
4. Assert `got.HostStatus == 3 && got.CPUFactor == 2.5` (worker updated)
**Covers:** Two-push merge correctness — already tested in unit tests but verified here at integration scope.
### 5.5 Multiple sequential config pushes (idempotent)
**Steps:**
1. Push config with `ClusterInfo{Vcpus: 16, HeartbeatInterval: 30}`
2. Push config with `ClusterInfo{Vcpus: 32, HeartbeatInterval: 60}`
3. Assert store has `Vcpus=32, HeartbeatInterval=60` (last-write-wins for config fields)
4. Assert `heartbeatIntervalSecs == 60`
**Covers:** Rapid config push updates don't leave stale data.
---
## 6. RBAC Integration (End-to-End)
> **RBAC isolation:** Call `resetRBACState()` (not `store.InitStores()`) at the start of each RBAC test. `resetRBACState()` zeros `rbacConfig`, preventing `AddPermissionsForRole` from appending duplicate permissions across test reloads. `store.InitStores()` does not reset RBAC state.
### 6.1 Full middleware chain: JWT → RBAC → handler
**Steps:**
1. Set up Gin router with real middleware (`RequirePermission("host:control")`)
2. Create a valid JWT token for a cluster_admin user
3. POST `/xce/v1/hosts/control` with `action=close, host_name=["node01"]`
4. Assert 202 Accepted (or mocked backend response)
5. Create a valid JWT token for a basic_user
6. POST `/xce/v1/hosts/control` — assert 403 Forbidden
7. POST `/xce/v1/hosts/select` with basic_user — assert passes (host:view)
**Note:** This requires spinning up a test HTTP server via `httptest.NewRecorder`. Do not mock at the middleware level — the purpose of §6 is to exercise the real middleware chain end-to-end.
### 6.2 RBAC: cluster_admin bypasses permission check
**Steps:**
1. Set up Gin router with `RequirePermission("host:control")`
2. Sign a JWT for "admin1" with role "cluster_admin" and issue `POST /xce/v1/hosts/control`
3. Assert response status is **not 403** — verifies the middleware did not block the request
**Note:** `RequirePermission` returns early for `cluster_admin` without calling `ctx.Next()`, so downstream handlers are **never reached**. Do not assert a spy handler body or `ctx.IsAborted() == false` — both give false results. To verify full handler execution for a privileged user, write a separate test with a non-cluster_admin user that has explicit `host:control` permission (see §6.3).
### 6.3 RBAC: basic_user cannot control hosts but can view
**Steps:**
1. Assign user "user1" role "basic_user"
2. Call `RequirePermission("host:control")` — assert 403
3. Call `RequirePermission("host:view")` — assert passes
### 6.4 JWT algorithm confusion is rejected
**Steps:**
1. Configure test server with HS256 signing secret `"xce-api-key"`
2. Craft a token header claiming `"alg": "none"` or a mismatched algorithm (RS256)
3. POST to `/xce/v1/hosts/control` with that token (use valid IP binding so IP mismatch is not the cause of rejection)
4. Assert 401 Unauthorized
5. Assert response body contains a token/signature error string (e.g., `"token"`, `"invalid"`, or `"signature"`) — not `"ip"` or `"expired"` — to confirm the rejection is due to algorithm/signature failure, not an unrelated check
**Covers:** Prevents algorithm confusion attacks where an attacker downgrades signing to `none`.
### 6.5 Expired JWT is rejected
**Steps:**
1. Sign a JWT with `exp = time.Now().Unix() - 3600` (expired 1 hour ago)
2. POST to any protected endpoint
3. Assert 401 Unauthorized
**Covers:** Token replay after expiry — ensures expired tokens cannot be reused.
### 6.6 Known gap: hostgroup domain restriction bypass
> **Documents a known limitation, not a working feature.** `validateHost` at `hosts.go:505-507` contains a `// TODO` that silently skips hostgroup domain enforcement. This test makes the gap explicit so it is visible in code review and prevents accidental regression if the TODO is removed.
**Steps:**
1. Provision a user with `host:control` scoped to hostgroup "gpu-nodes"
2. Issue `POST /xce/v1/hosts/control` targeting a host NOT in "gpu-nodes"
3. **Assert request is accepted** (not 403) — documents that hostgroup scoping is not yet enforced
**Note:** When P1-2b implements hostgroup enforcement, flip this assertion to assert 403 and remove the `// TODO` marker.
### 6.7 Post-logout token reuse
> **Design gap:** JWTs are stateless and the current server does not maintain a JTI blacklist. A token valid at logout time remains valid until expiry. This test documents the current behavior as an explicit design decision.
**Steps:**
1. Sign a valid JWT with a 1-hour expiry
2. Call `POST /xce/v1/users/logout` (or equivalent) — record any logout response
3. Immediately reuse the same token on `GET /xce/v1/hosts` — assert **200 OK** (not 401)
**Note:** If a JTI blacklist is added in future, flip this assertion to expect 401 and add the blacklist provisioning step.
---
## 7. Store Thread Safety
> **CI requirement:** All §7 tests must run with `-race`: `go test -race ./store/...`. A test that passes without `-race` provides no guarantee of absence of data races — the Go race detector instruments memory accesses at runtime and is the authoritative check.
### 7.1 Concurrent config pushes don't corrupt data
**Steps:**
1. Start 10 goroutines, each calling `h.OnHostConfigUpdated(&HostInfo{HostName: "node01", ...})` with slightly different HostModel values
2. Wait for all goroutines to complete
3. Assert `store.Hosts.Get("node01")` returns a valid HostInfo (no zero-value corruption)
4. Assert `HostName` field is "node01"
**Race detection note:** A simple value-validity assertion cannot prove absence of a data race — concurrent writes all produce plausible values. Run this test with `go test -race ./store/...` to detect RMW races via the Go race detector. The assertion above verifies structural integrity; `-race` detects the underlying race condition.
### 7.2 Concurrent host control + config push
**Steps:**
1. Goroutine A: call `OnHostConfigUpdated` for "node01" in a loop
2. Goroutine B: call `validateHost` checking "node01" existence in a loop
3. Assert no panics and no store corruption after 100 iterations
### 7.3 Concurrent resource value pushes for same key
**Steps:**
1. Start 10 goroutines, each calling `OnResourceLocationUpdated` for `"mem:node01"` with different NumericValue
2. Assert final stored value is one of the 10 values (no corruption)
3. Assert key exists exactly once
---
## 8. Input Safety
### 8.1 Hostname length limits
> **Skip until P1-2b is implemented.** No hostname length validation exists in `preCheckHostControlRequest` yet — this test will pass vacuously. Add `//go:build p1_2b` build tag or comment out pending that task.
**Steps:**
1. Call `preCheckHostControlRequest` with hostname exceeding the P1-2b limit (>1024 chars)
2. Assert `err != nil` with message indicating length exceeded
### 8.2 Empty hostname list
**Steps:**
1. Call `preCheckHostControlRequest` with `Hostname: []`
2. Assert `err != nil` — inspect the actual error message from `preCheckHostControlRequest` and assert it contains the expected string (e.g., "no hostnames provided" or similar)
### 8.3 Select expression length (current limit: 4094)
**Steps:**
1. Call handler with `Select` string > 4094 chars
2. Assert error
3. Call with 4094 chars — assert accepted
### 8.4 Special characters in hostnames
**Steps:**
1. Call with hostname containing `<script>alert(1)</script>`
2. Assert the hostname is stored as a string without interpretation (XSS safety at API layer)
---
## Test File Organization
| File | Tests |
|------|-------|
| `backend/integration_test.go` | §1 (config push), §3 (resource push), §4 (heartbeat), §5 (cross-feature) |
| `controllers/integration_test.go` | §2.1–2.3, §2.5–2.8 (host control — no JWT required), §2.4 (host validation — **requires full JWT setup; see §6 prerequisites**), §6 (RBAC middleware), §8 (input safety) |
| `store/concurrency_test.go` | §7 (thread safety) |
All test files use `//go:build !cgo` or `CGO_ENABLED=0` compatible patterns.
**Package declarations:**
- `backend/integration_test.go` must declare `package backend` (not `package backend_test`) to access unexported symbols: `heartbeatIntervalSecs`, `rawResvalToDistribution`, `dispatchConfigPush`
- `controllers/integration_test.go` may use `package controllers_test` for black-box tests, but any test accessing unexported helpers must use `package controllers`
**JWT signing secret in tests:** Integration tests in `controllers/integration_test.go` that create real JWTs must use a test-only signing secret (e.g., `"xce-api-key"` from the test config file). Never use the production signing secret. Document the expected test secret in a comment at the top of the test file so it is clearly identifiable during security audits.
---
## Priority Classification
| Priority | Tests | Rationale |
|----------|-------|-----------|
| **P0 — Must have** | 1.1, 1.2, 1.3, 2.1, 2.2, 2.5, 3.1, 3.4, 4.1, 5.1 | Core functionality and merge correctness |
| **P1 — Should have** | 1.4, 1.5, 2.4, 2.6, 2.8, 3.2, 3.3, 3.5, 3.6, 4.2, 5.2, 5.3, 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7 | Edge cases, RBAC, JWT security, and known gap documentation |
| **P2 — Nice to have** | 2.3, 2.7, 5.4, 5.5, 7.1, 7.2, 7.3, 8.1–8.4 | Robustness and stress |
---
## Existing Test Coverage
The following unit tests already cover much of the ground above and should NOT be duplicated:
| Existing Test File | What It Covers | Integration Gap |
|--------------------|----------------|-----------------|
| `backend/handler_test.go` | Single-push merge for hosts, clusters, dispatch | Multi-push sequences, cross-feature |
| `backend/convert_test.go` | All 7 value types, nil val, unknown discriminant | Store round-trip after conversion |
| `backend/heartbeat_test.go` | SetHeartbeatInterval, runHeartbeat call/cancel | Interval change triggered by OnClusterUpdated |
| `controllers/host_control_test.go` | preCheck validation, concurrent no-race | Full handler flow with store interaction |
| `controllers/rbac_check_test.go` | host:control/host:view permissions | Full middleware chain with JWT |

View file

@ -1,325 +0,0 @@
---
reviewers: [adversarial, feasibility, cgo-correctness, security, architecture]
reviewed_at: 2026-04-16T07:00:00Z
document_reviewed: apiserver-backend-enhancement-plan.md
prior_review_at: 2026-04-15T04:00:00Z
---
# Final Cross-Perspective Review — API Server Backend Enhancement Plan
> **Prior review** (adversarial + feasibility, 2026-04-15) is summarised at the bottom.
> This document covers only **new findings** from the final three-reviewer pass.
> All issues flagged in the prior review are confirmed fixed in the current plan.
---
## Round 2 Verification (2026-04-16)
### Claims Verified Against Codebase
**RBAC `cluster_admin` role**: ✅ PASS. `rbac.yaml:88` assigns `action: host:control` to `cluster_admin` role with `domain: type: all`. `routers.go:112` already wires `RequirePermission("host:control")` for `POST /xce/v1/hosts/control`. No `host_admin` role exists. The plan's update to `cluster_admin` is correct.
**`go_push_handler` is single-threaded**: ✅ PASS. `clib.go:32` calls `xcecli_set_handler(ctx, handler, NULL)` — this registers ONE handler function. `clib.go:150` `go_push_handler` runs on the C library's callback thread. The C library dispatches events sequentially on this thread. There is no concurrent push scenario for a single cluster.
### Remaining HIGH Issues Still in the Plan
| # | Issue | Status |
|---|-------|--------|
| CGO-1 | `unsafe.Slice(conf.hconfigs, n)` — missing `conf.hconfigs == nil` guard | **FIXED** — plan now guards `if n == 0 \|\| conf.hconfigs == nil { return nil }` |
| CGO-2 | `unsafe.Slice(rvl.vals, n)` — missing `rvl.vals == nil` guard | **FIXED** — plan now guards `if n == 0 \|\| rvl.vals == nil { return 0 }` |
| SEC-1 | `recover()` claim in plan said it catches C crashes | **FIXED** — plan now states `recover()` does NOT catch C SIGSEGV; C-level hardening required |
| SEC-2 | `allFlag` package-level data race in `hosts.go:22` | **FIXED** — plan now specifies converting `allFlag` to local return value |
| ARCH-1 | `cResourceToModel` naming collision — duplicate function name | **FIXED** — P0-1 new function renamed to `cResdefToModel(r *C.xce_resdef_t) *models.ResourceInfo` |
| ARCH-4 | No RED test for `OnHostUpdated` behavioral change | **FIXED** — plan now requires `backend/handler_test.go` before implementation |
| ARCH-5 | No unit tests for `rawHostConfigToModel`/`rawClusterToModel` | **FIXED** — plan now requires table-driven tests in `convert_test.go` |
| ARCH-6 | `HostStateStr(0)` = `"OK"` for hosts with no worker data | **FIXED** — plan now specifies `HostStateStr(0)` → `"UNKNOWN"` + `LastConfigSeenAt`/`LastWorkerSeenAt` timestamps |
### Fresh Findings
**SEC-7 — `C.GoString(nil)` undefined behavior on NULL C strings (MEDIUM)**
- `cClusterConfToClusterInfo` calls `C.GoString(conf.clustername)`. If `conf->clustername` is NULL, `C.GoString(nil)` is undefined behavior per CGO rules.
- The pattern in existing `convert.go` (e.g., `cResourceToModel` line 98) uses `goString(res.name)` — a helper that should guard against NULL.
- **Verdict**: The plan should use `C.GoString` only after a nil guard, or use the existing `goString` helper which presumably handles nil.
- **Severity**: MEDIUM (Go's `C.GoString` typically panics on nil, but this depends on C library always providing a non-NULL string for `clustername`)
**ARCH-12 — Action string-to-integer mapping not documented (LOW)**
- The Go HTTP handler receives JSON `{"action": "open"}` but C `HOST_OPEN`/`HOST_CLOSE` are integer enum constants. The plan's P0-2 does not document where this string-to-integer mapping happens.
- The existing `ValidateControlAction` at `controllers/hosts.go:557` validates the string. The actual mapping to C constants must happen in `ControlHost()`.
- **Verdict**: Include a comment or table in `ControlHost()` documenting the mapping: `"open"` → `HOST_OPEN` (C constant), `"close"` → `HOST_CLOSE`.
**ARCH-13 — `parse_select` errmsg ownership not specified (LOW)**
- P1-2 does `C.free(unsafe.Pointer(errmsg))` when errmsg != nil. This is correct only if `parse_select` allocates errmsg with `malloc` (or equivalent). The plan should explicitly state the memory ownership contract for the errmsg output parameter.
- **Verdict**: Add a one-line contract note: "parse_select allocates errmsg via malloc on error; caller owns and must free it."
---
## CGO / C Interop Correctness Review
### CGO-1 — `unsafe.Slice(conf.hconfigs, n)` panics when pointer is nil (HIGH)
**Location:** P0-1, `cClusterConfHostConfigs()` in `backend/convert.go`
**Bug:** The guard is `if n == 0 { return nil }` but there is no check for `conf.hconfigs == nil`. The Go specification states that `unsafe.Slice(ptr, n)` panics if `ptr == nil` and `n > 0`. A controller or partial-initialization path that sets `nhconfigs = N` and leaves `hconfigs = NULL` will crash the apiserver. This pattern is a systemic gap: the same nil guard is missing in the existing `cClusterConfQueues` and `cClusterConfResources` functions — the new function replicates the existing defect.
**Fix:**
```go
if n == 0 || conf.hconfigs == nil {
return nil
}
```
### CGO-2 — `unsafe.Slice(rvl.vals, n)` panics when pointer is nil (HIGH)
**Location:** P1-1, `handleNotifyData()` in `backend/clib.go`
**Bug:** The guard `if rvl == nil { return 0 }` does not check `rvl.vals`. `xce_resval_list_t` in `typemap.h` has `int count` and `xce_resval_t** vals` — a null `vals` with non-zero `count` is a valid C state on error paths or partial serialization. `unsafe.Slice(rvl.vals, n)` with `n > 0` and `rvl.vals == nil` panics.
**Fix:**
```go
if rvl == nil || rvl.vals == nil || n == 0 {
return 0
}
```
### CGO-3 — `StartHeartbeat` goroutine blocked in CGO during shutdown (MEDIUM)
**Location:** P1-3, `StartHeartbeat()` in `backend/clib.go`
**Issue:** `C.xcecli_heartbeat()` is a blocking CGO call. When `ctx.Done()` fires, the goroutine can only exit after the current C call returns. If the controller is unreachable and the C call has a long (or no) timeout, the process shutdown is delayed by up to one heartbeat cycle. This is not a goroutine leak but will delay `defer cancel()` in `main.go`. Add a comment documenting this known limitation. No API change needed for initial implementation.
### CGO-4 — Items confirmed safe (informational)
- `req.body_which` field access: `xce_request_t.body_which` is a plain `int` at struct level, not inside the union. CGO-accessible. ✓
- `nd.event` field: `xce_notify_data_t.event` is a plain top-level `int`. CGO-accessible. ✓
- `(*[0]byte)(C.xce_select_get_attr)`: correct standard CGO idiom for `//export` function pointer. ✓
- `C.uintptr_t(h)` where `h` is `cgo.Handle`: integer-to-integer cast, not a pointer conversion. Avoids `go vet` warning. ✓
---
## Security Review
### SEC-1 — `recover()` does NOT catch C SIGSEGV — plan's stated protection is false (HIGH)
**Location:** P1-2, "Input safety" section — "Wrap the EvalSelectForHost call in a `recover()` block"
**Bug:** Go's `recover()` catches Go panics only. A null pointer dereference or out-of-bounds access inside `eval_select` or `parse_select` (C code) raises SIGSEGV via the OS, which terminates the process immediately — Go defers and recover() are bypassed. The plan's claim that `recover()` protects the server from C-side crashes is incorrect and must not be used as justification for skipping C-level validation.
**Fix:** Remove or correct the recover() claim. The actual protection must come from:
1. Hardening `parse_select`/`eval_select` with internal bounds checking and null guards in C.
2. Fuzz testing the PEG parser with a corpus before shipping.
3. Optionally running the parse/eval in a subprocess with a timeout (more complex, not required for initial implementation, but the only true isolation).
Add a comment in the Go handler: `// Note: recover() does not catch C SIGSEGV. C-level null dereference in parse_select/eval_select will crash the process. Rely on C-level validation, not Go recovery.`
### SEC-2 — `allFlag` package-level global is a data race on concurrent requests (HIGH)
**Location:** `controllers/hosts.go:22`, `controllers/queues.go` (pre-existing, exposed by this plan)
**Bug:** `var allFlag bool` is a package-level shared variable. `preCheckHostControlRequest` sets it, `ControlHost` reads it — with no synchronization between goroutines. Two concurrent `POST /hosts/control` requests can interleave: Request A sets `allFlag = false` (targeted), Request B sets `allFlag = true` (all hosts), then Request A reads `true` and silently operates on the entire cluster instead of a single host. This is a logic race that can trigger unintended cluster-wide operations.
The plan's P0-2 test coverage (Verification step 2) will make this race more observable. The architecture reviewer confirmed it is present in the existing code; it must be fixed before P0-2 lands since the plan adds the real implementation path.
**Fix:** Eliminate the package-level variable. Return the `allFlag` value from `preCheckHostControlRequest` as a local `bool` and pass it to `ControlHost`. The same fix applies to `queues.go`.
### SEC-3 — In-process push data has no Go-side range check on `nrows` (MEDIUM)
**Location:** `backend/clib.go`, `handleDataset()` — pre-existing, not introduced by this plan
**Issue:** `unsafe.Slice(C.dataset_jobs(d), n)` constructs a Go slice over C memory where `n = int(d.nrows)`. If the C library delivers a dataset with a crafted or corrupted `nrows` exceeding the actual allocation, the slice construction succeeds but iteration reads out-of-bounds C memory. This can corrupt the in-memory store. The plan does not address this because it is pre-existing, but the new P1-1 resource push handler follows the same pattern and has the same risk.
**Fix for P1-1:** Add a sanity bound on `rvl.count` before the `unsafe.Slice` call:
```go
const maxResVals = 65536 // sanity cap
if n > maxResVals {
logger.Warn("resval count exceeds sanity limit, truncating", zap.Int("count", n))
n = maxResVals
}
```
### SEC-4 — PEG parser DoS: 1024-byte limit alone is insufficient (MEDIUM)
**Location:** P1-2, "Input safety" — expression length limit
**Issue:** PEG parsers without memoization (packrat parsing) have O(2^n) backtracking on adversarial inputs like `((((((((a`. A 1024-byte limit does not prevent CPU exhaustion — it sets the budget. 1024 bytes of adversarial expression can still pin a CPU core for seconds in a naive PEG implementation.
**Mitigation required:** Verify that `xce_select.peg` generates a **packrat** (memoized) parser — packcc supports this with the `--packrat` flag or equivalent option. Document this requirement explicitly in `lib/meson.build`'s `custom_target` invocation. Without packrat, the DoS risk is real. `/hosts/select` rate limiting (already inherited from RateLimit middleware) reduces but does not eliminate the risk.
### SEC-5 — Error message leaks internal C error codes (LOW)
**Location:** P0-2, `ControlHost()` — `fmt.Errorf("xcecli_hostctrl failed: %d", int(rc))`
**Issue:** Negative C return codes propagate to the HTTP response body. In HPC contexts these are typically library-specific codes (not OS errno values) and are not meaningfully sensitive. However, the current stub error at `clib.go:311` already leaks the hostname and action integer in the error string — this must be cleaned up when the stub is replaced.
**Fix:** Map C error codes to generic messages at the API boundary: `"host control failed"` (with the code logged server-side at WARN level, not returned to the client).
### SEC-6 — Confirmed safe (informational)
- `host:view` permission for all authenticated users: intentional HPC design. Users need host capacity info to craft job resource requests. ✓
- Hostname as `C.CString()`: the store lookup in `validateHost` acts as an allowlist — only controller-pushed hostnames reach C. ✓
---
## Architecture & Design Consistency Review
### ARCH-1 — `cResourceToModel` naming collision: plan adds a duplicate with a different signature (HIGH)
**Location:** P0-1, "convert.go" section; P1-1, "cResourceToModel note"
**Bug:** `convert.go` already contains `cResourceToModel(res *C.xce_resource_t) *models.ResourceDefinitionInfo` (line 95-104). The plan's P0-1 describes adding `cResourceToModel(r *C.xce_resdef_t) *models.ResourceInfo` — a different input type (`xce_resdef_t` vs `xce_resource_t`) and a different output type (`ResourceInfo` vs `ResourceDefinitionInfo`). Two functions with the same name in the same package is a compile error.
The plan's P1-1 note "cResourceToModel already exists — do NOT add a duplicate" is internally contradictory: it refers to the **existing** function (xce_resource_t → ResourceDefinitionInfo), not the new P0-1 function described in the same document.
**Fix:** Rename the new function in P0-1 to `cResdefToModel(r *C.xce_resdef_t) *models.ResourceInfo`. Update all call sites. P1-1 can continue to use the existing `cResourceToModel` (the old one) without conflict.
### ARCH-2 — `host_admin` RBAC role referenced in plan does not exist (HIGH)
**Location:** P0-2, "Go Side / router/routers.go"
**Bug:** The plan states "Permitted roles: admin users with the `host_admin` role." The actual `rbac.yaml` has three roles: `basic_user`, `cluster_admin`, `queue_admin`. There is no `host_admin` role. The `host:control` permission is already assigned to `cluster_admin`. Both routes at `routers.go:111-112` already exist with `RequirePermission("host:control")` and `RequirePermission("host:view")`.
**Fix:** Remove all references to `host_admin` from the plan. Clarify: `host:control` → `cluster_admin` (already configured). Verification step 7 should read "returns 403 for `basic_user` and `queue_admin`, succeeds for `cluster_admin`."
### ARCH-3 — P0-2 stubs reference P1-3 symbols: CGO build fails between phases (HIGH)
**Location:** P0-2, "backend/stub.go"
**Issue:** The plan adds `StartHeartbeat(ctx context.Context) {}` and `SetHeartbeatInterval(secs int) {}` stubs to `stub.go` in P0-2, but the corresponding CGO implementations in `clib.go` are not added until P1-3. The `!cgo` build will compile correctly with the stubs. The CGO build (used for integration testing) will fail with "undefined: backend.StartHeartbeat" if P0-2 is tested before P1-3.
**Fix:** Add a note: "The CGO build will fail to compile after P0-2 until P1-3 adds `StartHeartbeat` and `SetHeartbeatInterval` to `clib.go`. Implement P0-2 stub additions and P1-3 `clib.go` additions in the same commit, or accept a temporary CGO build break between phases."
### ARCH-4 — `OnHostUpdated` behavioral change has no RED test written first (HIGH)
**Location:** P0-1, "OnHostUpdated fix"
**Issue:** The plan changes `handler.go:27` from full-overwrite to partial merge — a behavioral change to existing code. There are zero tests for `DefaultHandler` in the entire test suite. CLAUDE.md mandates TDD: "Write a failing test first." A wrong field classification in the partial merge (e.g., accidentally preserving a worker field that should be updated) will silently corrupt host state with no test catching it.
**Fix:** Before implementing the `OnHostUpdated` change, add `backend/handler_test.go` with:
- A test that proves the current full-overwrite behavior (RED for the new code)
- Tests for `OnHostConfigUpdated` partial merge: config fields update, worker fields preserved
- Tests for `OnHostUpdated` partial merge: worker fields update, config fields preserved
- Tests for `OnClusterUpdated` partial merge
Use `store.InitStores()` for store reset between test cases.
### ARCH-5 — Pure conversion functions have no unit tests specified (HIGH)
**Location:** P0-1, "backend/convert_pure.go"
**Issue:** `rawHostConfigToModel` and `rawClusterToModel` are placed in `convert_pure.go` (no CGO, fully testable) but the plan's Verification section specifies only post-hoc integration checks (start server, hit endpoints). The `convert_test.go` file already tests `rawJobToModel` and `rawWorkerToModel` with table-driven tests — the same pattern must be applied here per TDD rules.
**Fix:** Add to the P0-1 implementation instructions: "Before implementing `rawHostConfigToModel` and `rawClusterToModel`, add table-driven tests in `backend/convert_test.go` covering: nil/empty string fields, zero integer fields (MaxSlot=0, HeartbeatInterval=0), normal values."
### ARCH-6 — First-startup host state appears "OK" with zero worker fields (HIGH)
**Location:** P0-1, "Partial merge semantics"
**Issue:** After P0-1, the config push fires and creates host entries with `HostModel`, `HostType`, `MaxSlot` set but all worker-sourced fields (`HostStatus`, `MaxMem`, `NumRun`, etc.) at zero. `HostStateStr` in `models/hosts.go:20` maps `state=0` to `"OK"`. A `GET /xce/v1/hosts` call in the window before any worker has checked in will return hosts showing status `"OK"` with 0 CPUs and 0 memory. Operators and monitoring dashboards will see misleading data.
**Fix (minimal, non-breaking):** Change `HostStateStr(0)` from `"OK"` to `"UNKNOWN"` or `"PENDING"`. This is a one-line change in `models/hosts.go` that accurately reflects the not-yet-reported state. Alternatively, add a `WorkerDataReceived bool` field to `HostInfo` set by `OnHostUpdated`.
### ARCH-7 — `meson.build` custom_target block is not specified (MEDIUM)
**Location:** P1-2, "Build Integration"
**Issue:** The plan says "add `xce_select.peg` to that explicit list." The actual `meson.build` pattern requires a full `custom_target` block mirroring the existing `xce_resreq_parser` block — the packcc tool, input/output filenames, command invocation — followed by adding `xce_select_parser[0]` to `libxce_src`. Simply adding the filename to `libxce_src` will fail because meson does not auto-run packcc on `.peg` files.
**Fix:** The plan must include the complete `custom_target` block for `xce_select.peg`.
### ARCH-8 — Heartbeat test uses `time.Sleep` despite plan prohibiting it (MEDIUM)
**Location:** Verification section, item 5
**Issue:** The plan states "The test must not depend on real time" then immediately provides a test using `time.Sleep(3500ms)`. This is a 3.5-second wall-clock delay per test run, and the pass/fail boundary (`n < 2 || n > 5`) will be flaky on loaded CI systems.
**Fix:** Expose the ticker as injectable: `StartHeartbeat(ctx, tickCh <-chan time.Time)` where production code passes `time.NewTicker(...).C` and tests pass a manually-driven `chan time.Time`. The test sends exactly N ticks and asserts the counter equals N — zero wall-clock time required.
### ARCH-9 — No code-level TODO for O(H) parse-per-call bottleneck (MEDIUM)
**Location:** P1-2, "Performance" note
**Issue:** The plan acknowledges that `EvalSelectForHost` calls `parse_select` once per host (O(H) parse calls per request) and says "do not over-engineer." The optimization path is described only in the design document — future developers reading the code will not see it.
**Fix:** Add this comment in `EvalSelectForHost` in `clib.go`:
```go
// TODO(perf): parse_select is called once per host. For large clusters, pre-parse
// the expression once and pass the *C.xce_select_t to a batch evaluator.
// See EvalSelectForHostParsed design note in apiserver-backend-enhancement-plan.md.
```
### ARCH-10 — Stale decommissioned hosts have no API signal (MEDIUM)
**Location:** P0-1, "Partial merge semantics" — host deletion paragraph
**Issue:** Hosts removed from the cluster configuration remain in `store.Hosts` indefinitely. There is no `LastSeenAt` field, no stale flag, no way for operators to distinguish a decommissioned host from a healthy-but-unreachable one. The plan defers TTL, but adds no groundwork for it.
**Fix:** Add `LastConfigSeenAt time.Time` and `LastWorkerSeenAt time.Time` fields to `HostInfo`. Set them in `OnHostConfigUpdated` and `OnHostUpdated` respectively. These fields cost nothing in correctness and provide the data source for a future TTL implementation without requiring a model change later.
---
## Consensus Summary
### Issues Requiring Plan Correction Before Implementation
| # | Finding | Severity | Phase |
|---|---------|----------|-------|
| CGO-1 | `unsafe.Slice` nil pointer — `cClusterConfHostConfigs` | HIGH | P0-1 |
| CGO-2 | `unsafe.Slice` nil pointer — `handleNotifyData` | HIGH | P1-1 |
| SEC-1 | `recover()` does not catch C SIGSEGV — stated protection is false | HIGH | P1-2 |
| SEC-2 | `allFlag` package-level data race — concurrent requests corrupt state | HIGH | P0-2 |
| ARCH-1 | `cResourceToModel` naming collision — compile error | HIGH | P0-1 |
| ARCH-2 | `host_admin` role does not exist in `rbac.yaml` | HIGH | P0-2 |
| ARCH-3 | P0-2 stubs cause CGO link failure before P1-3 | HIGH | P0-2 |
| ARCH-4 | `OnHostUpdated` change has no RED test (TDD violation) | HIGH | P0-1 |
| ARCH-5 | `rawHostConfigToModel`/`rawClusterToModel` have no unit tests specified | HIGH | P0-1 |
| ARCH-6 | Zero worker fields at first startup show as `"OK"` status | HIGH | P0-1 |
### Issues Requiring Plan Clarification
| # | Finding | Severity | Phase |
|---|---------|----------|-------|
| SEC-3 | No Go-side range check on `nrows` before `unsafe.Slice` in push handler | MEDIUM | P1-1 |
| SEC-4 | PEG parser packrat requirement not specified (DoS risk) | MEDIUM | P1-2 |
| ARCH-7 | `meson.build` custom_target block missing from plan | MEDIUM | P1-2 |
| ARCH-8 | Heartbeat test uses `time.Sleep` despite plan prohibiting it | MEDIUM | P1-3 |
| ARCH-9 | No code-level TODO for O(H) parse-per-call bottleneck | MEDIUM | P1-2 |
| ARCH-10 | Stale decommissioned hosts have no API signal | MEDIUM | P0-1 |
| CGO-3 | `StartHeartbeat` goroutine cannot be interrupted during CGO call | MEDIUM | P1-3 |
| SEC-5 | C error codes leak to HTTP response body | LOW | P0-2 |
### Confirmed Strengths (all reviewers agreed)
- Partial merge semantics are correctly designed: field ownership lists are explicit and the read-then-write pattern is sound for the single-threaded push handler.
- `OPCode_notify` placed at the top-level switch (not inside `handlePush`) is architecturally correct.
- `cgo.Handle` usage for the select callback context is the right CGO approach.
- `RegisterToBackend` being synchronous ensures heartbeat starts with the correct interval.
- PEG grammar placed in `libxce` (not Go layer) is correct for scheduler reuse.
- The two-step extraction (`xce_request_body` then `xce_notify_data_ptr`) is now correct in the plan.
### Items Confirmed Not Issues
- `req.body_which` and `nd.event` are plain struct fields, CGO-accessible. ✓
- `(*[0]byte)(C.xce_select_get_attr)` is the correct CGO function pointer idiom. ✓
- `C.uintptr_t(h)` for cgo.Handle is an integer cast (not pointer), go-vet clean. ✓
- `host:view` for all authenticated users is intentional HPC design. ✓
- `xcecli_hostctrl` blocking CGO: consistent with existing queuectrl pattern, local socket. ✓
---
## Prior Review Summary (2026-04-15)
The prior adversarial + feasibility review identified the following issues, all of which are **confirmed fixed** in the current plan:
| Issue | Fix Confirmed |
|-------|---------------|
| `handleNotifyData()` used wrong function to extract body | Fixed: plan now uses `xce_request_body(req)` first |
| `xce_val_t.which` referenced as `.type` | Fixed: plan uses `val.which` throughout |
| `handleUpdate()` union field accessed directly | Fixed: plan explicitly states OPCode_update is NOT used by apiserver |
| P1-2 CGO map-as-context was undefined behavior | Fixed: plan uses `cgo.Handle` |
| `stub.go` stubs omitted for `!cgo` builds | Fixed: comprehensive stub section added |
| `helper.c` missing from file list | Fixed: included with `Request_body_hostctrlmsg` case |
| `free_select()` / `kill_resreq()` linkage — memory leak | Fixed: plan shows both `free_select` + `FREEUP(resreq->select)` |
| Heartbeat interval not reconfigurable after start | Fixed: `SetHeartbeatInterval` + `ticker.Reset` per tick |
---
*To incorporate this feedback: update `apiserver-backend-enhancement-plan.md` addressing all HIGH-severity items before implementation begins. MEDIUM items should be resolved before the first PR is opened.*

View file

@ -0,0 +1,271 @@
# ClusterConfig Schema Split Analysis
> Analysis date: 2026-06-08. Branch: persistent_ha.
> Source schema: `src/schema/controller.capnp:680`, struct `ClusterConfig`.
## Summary
The `ClusterConfig` struct (20 fields) mixes three distinct configuration concerns:
cluster-wide business config, HA peer membership, and per-controller performance
tunables. These should be split into separate objects so each can evolve
independently without forcing a re-push of the full config blob to all standbys.
## Current State Discovery
### Peer list is already structurally separate
The `--peers` CLI flag (`main.c:178-210`) parses into `myconf.peers_cfg`
(`xce_cluster_config_t`), which is a **separate struct** from the config blob's
`cluster_config_t`. At startup, `cluster_init_phase1` uses `peers_cfg.candidates`
to determine `my_name`, `my_port`, `my_priority`, and the peer list — all before
the config blob is loaded (`cluster.c:865-880`). The config blob's `candidates`
field is **not consulted** for HA peer discovery; it is carried for backward
compatibility in the blob but superseded by `--peers`.
### HA tunables are hardcoded at phase1
`cluster_init_phase1` uses compile-time defaults for all HA timing parameters
(`cluster.c:854-863`):
```c
ha_config_t ha_cfg = {
.heartbeat_interval_ms = HA_DEFAULT_HEARTBEAT_INTERVAL_MS,
.heartbeat_timeout_ms = HA_DEFAULT_HEARTBEAT_TIMEOUT_MS,
.election_timeout_ms = HA_DEFAULT_ELECTION_TIMEOUT_MS,
...
};
```
The `heartbeatInterval` and `inactiveHeartbeatCount` fields in
`ClusterConfig` are consumed later (e.g., `cluster.c:581-585` writes them into
a config key-value store for runtime access), but the HA init path does not
read them. The heartbeat timeout validation in `startup_validation.c:144` works
against the hardcoded defaults, not the config blob values.
### vcpus is already deprecated
Field `@7 vcpus` is marked deprecated in the schema comment: "use --vcpus CLI
flag; kept for config blob backward compatibility." `cluster_init_phase2`
reads `c->myconf->vcpus` (set from CLI), not the blob's `vcpus` field
(`cluster.c:1045`).
## Proposed Split: Three Configuration Objects
```
┌──────────────────────────────┐
│ Current ClusterConfig │
│ (20 fields) │
└──────────────┬───────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌─────────────────┐ ┌──────────────────────┐
│ ClusterConfig │ │ PeersConfig │ │ ControllerConfig │
│ (cluster-wide) │ │ (HA membership) │ │ (per-controller) │
└───────────────────┘ └─────────────────┘ └──────────────────────┘
```
### Group 1: Cluster-Wide Business Config (keep in `ClusterConfig`)
These fields define the cluster's behavior, resources, and policy. They **must
be identical** across all controllers. When modified via `OP_upgradeConfig`,
the new blob is pushed to all standbys and committed on majority.
| Field | Ord | Type | Rationale |
|-------|-----|------|-----------|
| `clusterName` | @0 | Text | Cluster identity — must be identical cluster-wide |
| `resources` | @8 | List(Resource) | Resource catalog — shared state across all nodes |
| `queues` | @9 | List(QueueConf) | Queue definitions — cluster scheduling policy |
| `ugroups` | @10 | List(GroupConfig) | User group membership — access control |
| `hgroups` | @11 | List(GroupConfig) | Host group membership — scheduling constraints |
| `hostConfigs` | @12 | List(HostConfig) | Per-host overrides — cluster-wide host knowledge |
| `hproviders` | @18 | List(ScalerConfig) | Autoscaler provider config — cluster infrastructure |
| `limits` | @19 | List(LimitConfig) | Resource limits — cluster policy enforcement |
| `configGeneration` | @20 | UInt64 | Monotonic version counter — replication consistency |
**How consumed**: `cluster_init_phase2` (after election and config load)
iterates these to populate `c->resmgr`, `c->jobmgr`, queue tables, host
groups, etc.
### Group 2: HA Peer Membership (extract to `PeersConfig`)
These fields define **who** is in the cluster. They change independently when
controllers are added, removed, or re-IP'd — without any change to the
business config.
| Field | Ord | Type | Rationale |
|-------|-----|------|-----------|
| `candidates` | @2 | List(MasterCandidate) | HA peer list — changes independently of business config |
| `port` | @1 | UInt32 | Fallback listener port — derived from candidates; per-controller in practice |
**Already structurally separate**: The `--peers` CLI flag populates
`myconf.peers_cfg` (`xce_cluster_config_t` in `src/include/clusterconfig.h`),
a struct distinct from the config blob's `cluster_config_t`. The candidates
list in the blob is carried for backward compatibility but is not read for HA
peer discovery at startup. Making the separation formal means:
- `PeersConfig` travels via its own capnp message type and replication path
- Cluster config upgrades via `OP_upgradeConfig` no longer need to carry the candidate list
- Peer list changes (add/remove controllers) are a dedicated operation with its own quorum semantics
**Candidate list has special quorum requirements**: Changing the peer set is a
Raft cluster membership change. It must not use the same
`repl_wait_majority_ack` path as business config because the majority
denominator itself is changing. A separate message type enables a two-phase
membership change protocol (joint consensus) in the future.
**Impact of removing `candidates` from `ClusterConfig` (verified against codebase):**
`cluster_build_config()` (`cluster.c:575–609`) is the **only** place that reads
`cluster_config->candidates` and `cluster_config->ncandidates` — it copies them
into the runtime KV store as `ncandidates` and `candidate_N_{name,host,port}`.
No other component (scheduler, apiserver, opget, sched) reads those KV keys.
The live peer list used by `ha.c:3266–3272` and `cluster_open_listener` comes
exclusively from `peers_cfg.candidates[]` (parsed by `--peers`). Removing
candidates from the blob is therefore a **zero-blast-radius** change.
| File | Location | Change |
|------|----------|--------|
| `src/controller/cluster.c` | 587–606 | Remove `ncandidates` + `candidate_N_*` KV loop from `cluster_build_config()` |
| `src/include/clusterconfig.h` | 34–35 | Remove `int ncandidates` and `master_candidate_t** candidates` from `cluster_config_t` |
| `src/schema/controller.capnp` | 683 | Remove `candidates @2: List(MasterCandidate)` from `ClusterConfig` |
**`MasterCandidate` fields**:
```
struct MasterCandidate {
name @0: Text; // controller identity (e.g., "ctrl-1")
host @1: Text; // hostname or IP
port @2: UInt32; // peer listener port
}
```
### Group 3: Per-Controller Performance Tunables (extract to `ControllerConfig`)
These fields configure **how** an individual controller process behaves. They
can legitimately differ across controllers (e.g., a lower-memory node with
fewer vcpus, or a WAN-connected standby with a longer heartbeat timeout).
| Field | Ord | Type | Rationale |
|-------|-----|------|-----------|
| `heartbeatInterval` | @3 | UInt32 | Primary heartbeat send interval — can be tuned per primary |
| `inactiveHeartbeatCount` | @4 | UInt32 | Missed-heartbeat tolerance multiplier |
| `fetchJobInterval` | @5 | UInt32 | Job fetch poll interval — local scheduler interaction |
| `netioTimeout` | @6 | UInt32 | Network I/O timeout — environment-specific |
| `vcpus` | @7 | UInt32 | **Already deprecated** — use `--vcpus` CLI flag; remove from schema |
| `jobmgr` | @13 | HashZArrayConfig | Job manager hash table sizing (nHashSlots, delta) |
| `workermgr` | @14 | HashZArrayConfig | Worker manager hash table sizing |
| `qmgr` | @15 | HashZArrayConfig | Queue manager hash table sizing |
| `resmgr` | @16 | ResmgrConfig | Resource manager hash + instance table sizing |
| `xstrings` | @17 | HashZArrayConfig | String interning table sizing |
**How consumed**:
- `jobmgr`/`workermgr`/`qmgr`/`resmgr`/`xstrings` are read once at
`cluster_init_phase2` to construct the internal key-value stores
(`cluster.c:803-808`). After construction, the config values are not referenced again.
- `heartbeatInterval`/`inactiveHeartbeatCount` are read at runtime by the
heartbeat and liveness tasks (`cluster.c:1180, 1195`).
- `fetchJobInterval` drives the scheduler polling loop.
- `netioTimeout` is used by the iomgr layer for TCP timeouts.
**Why these can differ per controller**: A controller on a high-memory machine
can be configured with larger hash tables. A controller on a high-latency WAN
link can have a longer netioTimeout. These differences do not affect cluster
correctness — they only affect local process behavior.
**`vcpus` removal**: Already deprecated. The `--vcpus` CLI flag
(`main.c:1045`) is the canonical source. Remove field @7 in the next
schema revision.
## Fields With Dual Usage (require care)
### `port` (@1)
**Decision: Remove. No fallback bind port.**
`cluster_config->port` currently serves as a fallback in `cluster_open_listener`
(`cluster.c:953–975`) when `--peers` does not specify a port for the matching
candidate. This fallback is removed: the `--peers` port is mandatory.
**Affected code (all changes mechanical):**
| File | Location | Change |
|------|----------|--------|
| `src/controller/cluster.c` | 953–975 | Remove the `if (my_port == 0) { cluster_config->port }` branch; return `EINVAL` immediately if no port found in `peers_cfg` |
| `src/controller/myconf.c` | 74 | Remove `SET_DEFAULT_IF_UNSET(c->port, 0, 9990)` |
| `src/controller/config_repl.c` | 55 | Remove `SET_DEFAULT_IF_UNSET(c->port, 0, 9990)` |
| `src/include/clusterconfig.h` | 33 | Remove `int port` from `cluster_config_t` |
| `src/schema/controller.capnp` | 682 | Remove `port @1: UInt32` from `ClusterConfig` |
Note: `"port"` in `testsuite/test.c:124,266` is the scheduler/worker
connect-to-primary port (via `get_setting("port")`), not the listener bind
port. It is unaffected.
## Implementation Approach
### Phase 1: Remove `port` and `candidates` from `ClusterConfig` (immediate)
No backward-compat transition needed for these two fields — the live code
already ignores both (peer discovery uses `peers_cfg`; port fallback is being
deleted). All changes are mechanical.
1. `src/schema/controller.capnp` — Remove `port @1` and `candidates @2` from
`ClusterConfig`; mark or remove `MasterCandidate` struct if no longer
referenced elsewhere
2. `src/include/clusterconfig.h` — Remove `int port`, `int ncandidates`,
`master_candidate_t** candidates` from `cluster_config_t`
3. `src/controller/cluster.c` — Remove `if (my_port == 0)` fallback block in
`cluster_open_listener` (lines 967–975); remove `ncandidates` + `candidate_N_*`
KV loop in `cluster_build_config` (lines 587–606)
4. `src/controller/myconf.c` and `src/controller/config_repl.c` — Remove
`SET_DEFAULT_IF_UNSET(c->port, 0, 9990)` from `fill_default_cluster_config`
5. Regenerate capnp bindings
### Phase 2: Schema split (wire-compatible)
1. Define `PeersConfig` and `ControllerConfig` structs in `controller.capnp`
2. Add them as new fields in the top-level `ConfigData` union alongside
the existing `clusterconfig @2`
3. Regenerate capnp bindings
4. **Keep the remaining `ClusterConfig` fields intact** during transition —
new fields are additive
### Phase 3: Controller reads from new structs
1. `cluster_init_phase2`: read `ControllerConfig` for tunables
2. `cluster_config_t` retains business config fields only
3. Fall back to old `ClusterConfig` fields when new structs are absent
(backward compatibility for existing `cluster.bin` files)
### Phase 4: Remove superseded tunables from `ClusterConfig`
1. Remove `heartbeatInterval`, `inactiveHeartbeatCount`, `fetchJobInterval`,
`netioTimeout`, `vcpus` from `ClusterConfig`
2. Remove `jobmgr`, `workermgr`, `qmgr`, `resmgr`, `xstrings` from
`ClusterConfig`
### Phase 5 (future): Peer list replication
1. Define `OPCode_peersUpdate` / `OPCode_peersUpdateAck` for peer list
replication with joint-consensus semantics
2. Cluster membership changes become a dedicated operation with its own
quorum protocol
## Effort and Risk
| Phase | Effort | Risk | Justification |
|-------|--------|------|---------------|
| 1 — Remove port + candidates | XS (½d) | Low | No transition needed; dead code removal + schema field removal |
| 2 — Schema split | S (1d) | Low | Additive capnp change; old fields preserved |
| 3 — Read from new structs | M (2-3d) | Medium | Touches cluster_init paths; backward compat needed |
| 4 — Remove superseded tunables | S (1d) | Low | Mechanical removal after Phase 3 is validated |
| 5 — Peer replication | L (1-2wk) | High | New protocol; joint consensus; extensive testing |
## References
- `src/schema/controller.capnp:680-700` — `ClusterConfig` definition
- `src/schema/controller.capnp:253` — `ConfigData.clusterconfig @2`
- `src/controller/main.c:178-213` — `--peers` CLI parsing into `peers_cfg`
- `src/controller/cluster.c:854-880` — `ha_config_t` hardcoded defaults + priority derivation
- `src/controller/cluster.c:945-977` — port derivation from candidates vs config blob
- `src/controller/cluster.c:803-808` — internal manager construction
- `src/controller/ha.h:134` — `ha_config_t.heartbeat_interval_ms` default via `ha_init`
- `src/controller/startup_validation.c:133-163` — heartbeat config validation
- `src/include/clusterconfig.h` — `xce_cluster_config_t` (separate peers struct)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,116 @@
# Config Replication — Implementation Status
> **Branch**: `persistent_ha`
> **Date**: 2026-05-22 (updated)
> **Design doc**: `config-replication-design.md`
---
## Phase 1 (Pull-only replication) — WIRE PROTOCOL COMPLETE
Phase 1 is fully implemented. Standbys fetch `ClusterConfig` from the primary on
every startup over a real SSL connection, eliminating manual per-node provisioning.
The wire protocol (capnp encode/decode, SHA-256 verify, disk save) is complete as
of 2026-05-22 (commits `2ed7e34`–`5053c8d`).
| Component | Status | Commit | Notes |
|---|---|---|---|
| Cap'n Proto schema additions | ✅ Done | `683c092` | 3 opcodes, 6 structs, `configGeneration`, `HeartbeatMsg` |
| Struct fields (`myconf_t`, `cluster_t`, `ha_state_t`, `persist_t`) | ✅ Done | `2c375cf` | `config_version_tag`, `config_sha256`, `conf_keep_n`, `conf_stale_timeout`, `restart_requested`, `standby_endpoints`, `wal_record_count`, `checkpoint_in_progress` |
| `byteorder.h` portability header | ✅ Done | `2c375cf` | `be64toh()` for glibc + musl + macOS |
| HEAD-file config storage (`config_repl.c` Phase 1) | ✅ Done | `491c255` | `conf_save_config`, `conf_read_current_filename`, `conf_read_raw`, `conf_get_saved_tag`, `conf_check_stale`, `conf_trim_old_versions` |
| `conf_save_config()` path fix | ✅ Done | `2ed7e34` | Was writing to `workdir/conf`; fixed to `workdir/name/conf` to match `load_primary_config()` |
| `main.c` — null `conffile`, `--conf-keep-n`, `--conf-stale-timeout` | ✅ Done | `e74fd2a` | Stack buffer removed; conffile heap-allocated at runtime |
| `load_primary_config()` + `myconf_fini()` | ✅ Done | `cd02cf4` | FATAL on missing config; SHA-256 cached; reserved-name check |
| Real `am_i_primary()` — election probing | ✅ Done | `fe3cc57` | Non-blocking connect + poll; priority step + xorshift64 jitter; `ha_is_primary()`, `ha_check_priority_stepdown()` |
| SSL double-call fix + TOCTOU fix | ✅ Done | `ec3cab4` | `SSL_get_error` called once; `ha->lock` held in stepdown |
| `cluster_init` phase split + `myapp_run()` two-phase flow | ✅ Done | `6b06aa0` | Phase 1: hooks/HA/cbuf; Phase 2: iomgr/kvmgr/persist; `am_i_primary` gates config load |
| `ha->role` set before Phase 2 | ✅ Done | `93b8616` | Election result stored via `atomic_store` before `cluster_init_phase2` |
| Raw capnp shims (`request.c`) | ✅ Done | `26393e9` | `xce_replconfig_reply_raw()`, `xce_replconfig_req_ssl()`, `xce_replconfig_reply_ssl_decode()`, `xce_upgrade_config_req_extract_raw()` — bypass `xce_data_t` for raw bytes |
| `handle_config_request()` (primary handler) | ✅ Done | `4ef2089` | Tag compare, SHA-256 double-check; sends real bytes via `xce_replconfig_reply_raw()` |
| `fetch_config_from_primary()` + `do_one_config_fetch()` | ✅ Done | `2aae45f` | Full SSL roundtrip: non-blocking connect+poll, `xce_replconfig_req_ssl()`, SHA-256 verify, `conf_save_config()`, reload; 20-attempt exponential backoff |
| `handle_controller_join()` | ✅ Done | `8f4db30` | Registers endpoint in `standby_endpoints` under lock |
| WAL record-count `schedule_checkpoint()` | ✅ Done | `a95476b` | Idempotent CAS; coalesced via `delaymgr` 1s delay; bounds `persist_recover` to ≤10k records |
| `typemap.h` union arms + capnp shims | ✅ Done | `491c255` | `replconfigreplymsg`, `upgradeconfigreplymsg`, etc. wired |
| API server rejects `lost_and_found` job submissions | ✅ Done | `bc92166` | `preCheckJobRequest()` returns "internal recovery queue" error before store lookup; 3 tests in `jobs_test.go` |
---
## Phase 2 (Remote config push) — COMPILED, GATED
Phase 2 infrastructure ships compiled but inert. The `CONF_ENABLE_UPGRADE_CONFIG`
compile-time gate (default: 0) prevents `OP_upgradeConfig` from being reachable
in production builds until both prerequisites are met (see §5.3 and §2 of the
design doc).
| Component | Status | Notes |
|---|---|---|
| `handle_upgrade_config()` | ✅ Wired, gated | `5053c8d` — pre-decode opcode peek in `myreq_handler()` captures raw packet bytes; `xce_upgrade_config_req_extract_raw()` extracts sha256+bytes; delegates to `do_upgrade_config_with_bytes()`. Active only when `CONF_ENABLE_UPGRADE_CONFIG=1` |
| `push_config_to_standbys()` | ✅ Timeout enforced | `f3babec` — `wait_all_with_timeout()` with `CONF_PUSH_TIMEOUT_MS` (500ms) aggregate deadline. Push fan-out wire protocol still best-effort; standbys catch up via pull on reconnect |
| `create_lost_and_found()` | ✅ Done | `91e66b8` | Inserts synthetic queue via `create_queue()` and host via `makeWorker()` before `persist_recover()`; gated with `#ifndef XCE_TESTING` for unit test binary |
**Prerequisites before enabling** (`CONF_ENABLE_UPGRADE_CONFIG=1`):
1. `validate_host()` implemented with real CN allowlist check (see `FUTURE_WORK.md §6`)
2. Admin config tool exists and tested end-to-end (see `FUTURE_WORK.md §1`)
---
## Test Coverage
| Suite | Tests | Covers |
|---|---|---|
| `test_config_repl` | 24 | Storage, SHA-256, trim, stale, sanitise_cn, path consistency, push timeout, capnp shim roundtrips |
| `test_am_i_primary` | 3 | Priority-0 immediate, priority-0 with peers, deadline-expire |
| `test_ha_role` | (existing) | Role transitions, watchdog, redirect |
| `test_persist_wal` | 6 (+3 new) | WAL append, CAS idempotency, `max_wal_records` default |
| `test_ha_cluster` | 5 | Cluster HA integration |
| `test_p1_integration` | 5 | Phase 1 init structure |
| All other suites | ~41 | 0 failures |
| **Total** | **84** | **0 failures, 0 ignored** |
---
## Known Limitations / Deferred Work
| Item | Severity | Tracked |
|---|---|---|
| Heartbeat listener (`start_heartbeat_listener`) is a no-op stub — simultaneous-boot symmetry relies on priority-step delay only | LOW | `FUTURE_WORK.md §7` |
| Peers cross-validation hash in `OP_heartbeat` not implemented | LOW | `FUTURE_WORK.md` |
| `OP_replConfigPush` standby-side handler not implemented — pushed config accepted only via pull on reconnect | LOW | `FUTURE_WORK.md` |
| WAL routing helpers (`resolve_queue_or_lost_and_found` etc.) not wired in `apply_wal_entry()` | LOW | `FUTURE_WORK.md` |
---
## Files Changed Summary
| File | Change |
|---|---|
| `src/schema/controller.capnp` | +3 opcodes, +6 structs, `configGeneration`, `HeartbeatMsg` |
| `src/include/myconf.h` | +6 fields to `myconf_t`; `load_primary_config`, `fetch_config_from_primary`, `myconf_fini` declared |
| `src/include/xce/cluster_def.h` | +4 fields to `cluster_t` |
| `src/controller/ha.h` | +`probe_ctx_t`; +3 fields to `ha_state_t`; +2 fields to `ha_config_t`; `xorshift64_next` inline |
| `src/include/persist.h` | +`max_wal_records`; +`wal_record_count`, `checkpoint_in_progress` atomics |
| `src/include/byteorder.h` | **New** — portable `be64toh()` |
| `src/include/config.h` | +`DEFAULT_MAX_WAL_RECORDS 10000` |
| `src/include/typemap.h` | +7 union arms for new message types |
| `src/include/request.h` | +4 raw shim declarations; `SSL*` parameters |
| `src/lib/request.c` | +`xce_replconfig_reply_raw()`, `xce_replconfig_req_ssl()`, `xce_replconfig_reply_ssl_decode()`, `xce_upgrade_config_req_extract_raw()` |
| `src/controller/config_repl.h` | **New** — Phase 1+2 API declarations; `handle_upgrade_config` raw-pkt params |
| `src/controller/config_repl.c` | **New** — ~900 LOC: storage, SHA-256, full SSL fetch, handlers, push timeout, upgrade wiring |
| `src/controller/ha.c` | Real `am_i_primary()`, `probe_thread()`, `ha_is_primary()`, `ha_check_priority_stepdown()`, `ha_seed_jitter()` |
| `src/controller/myconf.c` | `load_primary_config()`, `myconf_fini()`, static `cf` global removed |
| `src/controller/cluster.c` | `cluster_init_phase1/2` split; opcode dispatch; pre-decode peek for `OP_upgradeConfig`; `standby_endpoints` pruning; `restart_requested` check |
| `src/controller/myapp.c` | Two-phase startup; `am_i_primary` gates config load; `myconf_fini` on all exits |
| `src/controller/join.c` | `handle_controller_join` implemented (was `-ENOSYS`) |
| `src/controller/persist.c` | `schedule_checkpoint()`, WAL record-count trigger, `persist_init` defaults |
| `src/controller/main.c` | `conffile=NULL`; `--conf-keep-n`; `--conf-stale-timeout`; peers validation |
| `src/controller/meson.build` | `config_repl.c` added; `ssl_dep` for unit tests |
| `src/controller/include/cluster.h` | `cluster_init_phase1/2` declared |
| `src/controller/testsuite/test_config_repl.c` | 24 unit tests (was 18; +path consistency, +push timeout, +capnp roundtrips) |
| `src/controller/testsuite/test_ha_role.c` | +3 election probing tests |
| `src/controller/testsuite/test_persist_wal.c` | +3 WAL bound tests |
| `src/controller/testsuite/controller_test.c` | New suites registered |
| `src/frontend/apiserver/controllers/jobs.go` | +reserved-name guard in `preCheckJobRequest()` |
| `src/frontend/apiserver/controllers/jobs_test.go` | **New** — 3 tests: lost_and_found rejected, normal queue passes, empty queue passes |
| `src/docs/config-replication-design.md` | §3.2 `effective_dir` definition; §6.3 timeout implementation note |
| `src/docs/config-replication-impl-status.md` | This file |

View file

@ -1,71 +1,52 @@
# P1-2: Host Resource Requirement Select — Investigation
## Status
- [>] Under investigation (moved from apiserver-backend-tasks.md)
- **C layer (P1-2a): COMPLETE** — `eval_select_simple` wired in `xce_select.c`
(PEG grammar → AST → CSE, memoized eval). Scheduler integration (Phase 6) done.
See `src/docs/resreq-ast-cse-design.md` for architecture reference.
- **Go bridge (P1-2b): PENDING** — `EvalSelectForHost` remains a stub returning
`errNoCGO`. CGO boundary, resource value lookup from in-memory store, and
`FilterHostByResReqSelect` are still to implement.
---
## Background
The `select` field in an LSF-style resource requirement string (e.g. `select[mem>4096 && type==X86_64]`)
filters candidate hosts before slot allocation. Currently `EvalSelectForHost` is a no-CGO stub that
always returns `errNoCGO`.
The `select` field in an LSF-style resource requirement string (e.g.
`select[mem>4096 && type==X86_64]`) filters candidate hosts before slot
allocation. Currently `EvalSelectForHost` is a no-CGO stub that always returns
`errNoCGO`.
The full feature requires two layers:
1. **C parser + evaluator** — parse the boolean select expression into an AST and evaluate it against
a host's resource values.
2. **Go bridge** — call the C evaluator via CGO and wire it into `FilterHostByResReqSelect` in the
controllers layer.
1. **C parser + evaluator** — parse the boolean select expression into an
AST and evaluate it against a host's resource values.
2. **Go bridge** — call the C evaluator via CGO and wire it into
`FilterHostByResReqSelect` in the controllers layer.
---
## Sub-tasks
### #11 P1-2a: C select expression parser (PEG + eval)
### #11 P1-2a: C select expression parser (PEG + eval) ✓ COMPLETE
**Files implemented:**
- `include/resreq.h` — expression-tree types, enums, `extern` declarations.
- `lib/xce_select.peg` — PEG grammar for select boolean expressions.
- `lib/xce_select.c` — `parse_select()`, `eval_select_simple()`, CSE tree.
- `lib/meson.build` — `xce_select.peg` in build.
### #12 P1-2b: Go EvalSelectForHost + FilterHostByResReqSelect (remaining)
**Files to touch:**
- `include/resreq.h` — Replace empty `xce_resreq_select_` stub with expression-tree types, enums,
and `extern` declarations for `parse_select`, `eval_select`, `free_select`.
- `lib/xce_select.peg` — New PEG grammar for select boolean expressions (right-recursive, handles
`&&`, `||`, `!`, comparison operators, numeric literals, string literals, resource names).
- `lib/resreq.c` — Implement `parse_select()`, `eval_select()`, `free_select()`; update
`kill_primary_resreq()` to call `free_select()`.
- `lib/meson.build` — Add `xce_select.peg` to explicit source list so the PEG tool generates C.
- `lib/testsuite/` — TDD tests for parse/eval before implementing.
**Open questions:**
- Which PEG library is already available in the Nix/Meson build? (`peg`, `lemon`, `re2c`?)
- Exact grammar for comparison operators — LSF allows `mem > 4096`, `type == X86_64`, `swp >= 0`.
Need to confirm all supported operators and value types.
- Does `eval_select` receive a flat key-value map of resource values, or a pointer to the full
`xce_host_t` struct?
- Error reporting strategy — return a bool+error-string, or a tri-state (true/false/parse-error)?
### #12 P1-2b: Go EvalSelectForHost + FilterHostByResReqSelect (blocked by #11)
**Files to touch:**
- `backend/clib.go` — Implement `EvalSelectForHost(hostname, selectExpr string) error` using
`cgo.Handle` for callback context; add `#include "resreq.h"`, extern C declarations,
`xce_select_destroy` helper.
- `backend/clib.go` — Implement `EvalSelectForHost(hostname, selectExpr string) error`
using `cgo.Handle` for callback context; add `#include "resreq.h"`, extern C
declarations, `xce_select_destroy` helper.
- `controllers/hosts.go` — Implement `FilterHostByResReqSelect(hosts []models.HostInfo, expr string)`,
update `preCheckHostRequest()` limit from 4094 → 1024 bytes.
**Open questions:**
- Does `EvalSelectForHost` need to look up live resource values from the in-memory store, or are
values passed in directly by the C layer?
- `cgo.Handle` usage pattern — confirm whether a per-call handle or a session-level handle is needed.
- Does `EvalSelectForHost` look up live resource values from the in-memory
store, or are values passed directly from C?
- `cgo.Handle` usage pattern — per-call or session-level?
- Input validation: max expression length (1024 bytes), allowed characters.
---
## Investigation Checklist
- [ ] Survey existing PEG tooling in the Meson/Nix build environment.
- [ ] Read `include/resreq.h` current state to understand `xce_resreq_select_` stub.
- [ ] Trace how `kill_primary_resreq()` works today and where `free_select()` must be called.
- [ ] Confirm resource value structure passed to `eval_select`.
- [ ] Confirm LSF select grammar spec (operators, value types, nesting depth).
- [ ] Prototype PEG grammar for a subset (numeric comparisons only) and get it compiling.
- [ ] Decide error propagation strategy across the CGO boundary.
- [ ] Write TDD tests for C parser before any implementation.

File diff suppressed because it is too large Load diff

View file

@ -1,21 +0,0 @@
# Phase 6 Pre-Audit: Single-Threaded Invariant and Stub Locations
## Audit Results
- **sched_meet_host_selection stub confirmed** at `src/scheduler/src/resource.c:127-131`
- Declaration: `src/scheduler/include/scheduler.h:111`
- Call site: `src/scheduler/src/resource.c:368` (within sched_try_alloc_on_host_)
- Stub body: returns 1 unconditionally
- **Allocator-thread-only invariant confirmed**: Single `while(1)` loop in `src/scheduler/src/main.c:119`
- `sched_begin_session()` at line 121
- `sched_go()` at line 122
- `sched_end_session()` at line 161
- All three calls executed sequentially in the same thread context
- **eval_select_simple signature confirmed**: `int eval_select_simple(const xce_select_node_t* root, const host_eval_ctx_t* host)`
- Declaration: `src/include/resreq.h:110-111`
- Definition: `src/lib/xce_select.c:558`
- Returns: 1 (match), 0 (no match), negative (error)
No unexpected references to `sched_meet_host_selection`, `eval_select`, `alloc_thread`, or `sched_session` found in scheduler source.

File diff suppressed because it is too large Load diff

View file

@ -1,233 +0,0 @@
# Resreq AST + CSE Implementation Plan
> Generated from: `resreq-ast-cse-design.md`
> Date: 2026-04-28
> Branch: merge_api_server
---
## Summary Priority Order
```
P0 (blocking): makeString() TOCTOU fix, section.peg boundary bug ✓ DONE
Phase 1: TDD test skeletons (parallel with Phase 3) ✓ DONE
Phase 2: Header definitions + ABI audit ✓ DONE
Phase 3: PEG grammar (parallel with Phase 1) ✓ DONE 143 PASS / 3 FAIL (stubs)
Phase 4a: Core interning + sel_optimize_level ✓ DONE 145 PASS / 1 FAIL (Phase 4b stub)
Phase 4b: Type validation (gated on P0-1) ✓ DONE 203 PASS (2026-04-29)
Phase 4c: AND/OR associativity flattening ✓ DONE 157 PASS (2026-04-29)
Phase 4d: Const-fold transform ✓ DONE (2026-04-29)
Phase 4e: Memoized eval ✓ DONE 73 PASS (2026-04-29)
Phase 4f: GC + recompaction ✓ DONE 73 PASS (2026-04-29)
Phase 5: Build wiring ✓ DONE
Phase 6: Scheduler integration ✓ DONE 10 PASS (2026-04-30)
```
---
## P0 — Critical Prerequisites (blocking gate)
Must complete before any Phase 4 work begins.
| # | Task | Section | Rationale |
|---|------|---------|-----------|
| P0-1 | ~~**Fix `makeString()` TOCTOU in `src/lib/xstring.c`**~~ **DONE** — extended lock region to cover `makeInstance` + `key_add`, eliminating the TOCTOU window. `key_add` can no longer return `-EEXIST` on a miss path. | §5.4 | Silent NULL return corrupts the interned AST in all §7 numeric-to-string coercions; blocks Phase 4b entirely |
| P0-2 | ~~**Fix `section.peg` clause-boundary grammar**~~ **DONE** — Root cause: the original grammar used multiple ordered-choice alternatives in `select_statement` / `usage_statement` / `span_statement`, but PEG's `e*` always succeeds (matches empty), so alternatives 2+ were dead code (confirmed by packcc generating "defined but not used" for all `pcc_action_select_statement_N` functions). Fix: collapsed each statement rule into a single alternative with a compound negative lookahead covering all subsequent clause keywords. `select_statement` now stops at any of `rusage[`, `span[`, `same[`, `affinity[`, or `]`; `usage_statement` stops at `span[`, `same[`, `affinity[`, or `]`; `span_statement` stops at `same[`, `affinity[`, or `]`. Both implicit (`type==any span[ptile=1]`) and explicit (`select[type==any] span[ptile=1]`) select forms verified by TDD: `test_parse_section3` (implicit), `test_parse_section_explicit_select_with_span`, `test_parse_section_explicit_select_rusage_span`, `test_parse_section_span_not_in_select` — all PASS, no packcc dead-code warnings. | §3.1 | Silent span/rusage content bleed into selectString in all implicit-select + multi-clause inputs |
---
## Phase 1 — TDD Tests (write failing skeletons first) ✓ DONE
**Priority: HIGH — must come before implementation (TDD contract)**
Develop concurrently with Phase 3 (grammar is the test oracle).
| # | Task | Notes |
|---|------|-------|
| 1-1 | ~~Write `lib/testsuite/test_select.c` skeletons: parse valid expressions, unknown resource names (error), type mismatches (error)~~ **DONE** | |
| 1-2 | ~~Write tests: `SELECT_MAX_INPUT_LEN` (8192) overflow, `SELECT_MAX_DEPTH` (64) nesting overflow~~ **DONE** | |
| 1-3 | ~~Write CSE tests: `A&&B` and `B&&A` produce same pointer; two `makeSelectNode` calls with same args return same pointer~~ **DONE** — FAIL until Phase 4a | |
| 1-4 | ~~Write eval tests: numeric `>/<`/`==`, string `==`, bool standalone, `!`/`&&`/`||`, short-circuit~~ **DONE** | |
| 1-5 | ~~Write GC tests: unlink job root → `gc_sweep_select_nodes` frees unreachable node; subsequent `makeSelectNode` re-interns fresh node~~ **DONE** — FAIL until Phase 4f | |
| 1-6 | ~~Write GC predicate contract tests (`sched_session_active`, `sched_eval_in_progress`, `staging_merge_complete`) using `fake_scheduler_t` scaffold in `test_select.c`~~ **DONE** — FAIL until Phase 4f | Phase 6 wires real scheduler — signatures stable |
| 1-7 | ~~Create shared resmgr fixture: `src/lib/testsuite/test_resmgr_fixture.h` + `.c` with `make_test_resmgr()`/`free_test_resmgr()`~~ **DONE** | |
---
## Phase 2 — Header Definitions ✓ DONE
**Priority: HIGH — unblocks all implementation work**
| # | Task | Notes |
|---|------|-------|
| 2-1 | ~~Add `res_type_t`, `sel_node_type_t`, `xce_select_node_t` typedef + struct to `include/resreq.h`~~ **DONE** | |
| 2-2 | ~~Fill `struct xce_resreq_select_` with `xce_select_node_t* root`~~ **DONE** | |
| 2-3 | ~~Declare all public APIs: `makeSelectNode`, `eval_select`, `eval_select_simple`, `select_eval_ctx_*`, `sel_node_table_*`, `gc_sweep_select_nodes`, `recompact_node_ids`~~ **DONE** | `gc_mark_tree` stays `static` in `.c` |
| 2-4 | ~~Add `var resmgr` field to `resreq_context_t` (outer PEG auxil)~~ **DONE** | |
| 2-5 | ~~Add `valtype_to_res_type` helper; omit `res_type_to_valtype` (no current consumer — YAGNI)~~ **DONE** | |
| 2-6 | ~~Add `xce_str_match` as `static inline` wrapping `strcmp`~~ **DONE** | |
| 2-7 | ~~**CGO audit**: `grep -rn 'parse_resreq\|C\.parse_resreq' src/frontend/`~~ **DONE** — no CGO callers found | |
| 2-8 | ~~Update `parse_resreq_primary` and `parse_resreq` signatures to accept `var resmgr`~~ **DONE** — call sites updated: `schedobj.c:171`, `test_resreq.c:244/301/330` | |
---
## Phase 3 — PEG Grammar (`lib/xce_select.peg`) ✓ DONE
**Priority: HIGH — develop concurrently with Phase 1**
| # | Task | Notes |
|---|------|-------|
| 3-1 | ~~Implement `select_expr` grammar (§3.1): `\|\|`, `&&`, `!`, comparisons, resname, literals, `any`~~ **DONE** | |
| 3-2 | ~~Implement `select_context_t` auxil with `resmgr`, `depth`, `errmsg[256]`, `has_error`~~ **DONE** | |
| 3-3 | ~~Implement `resname` action: `key_find`, `res_idx` validity guard, `valtype_to_res_type` mapping~~ **DONE** | |
| 3-4 | ~~**Clause-keyword conflict check** in `resname` action: check against `{select, rusage, span, same, affinity}`~~ **DONE** | |
| 3-5 | ~~Implement `SELECT_MAX_INPUT_LEN` (8192) and `SELECT_MAX_DEPTH` (64) guards~~ **DONE** | |
| 3-6 | ~~LSF-compatible disambiguation: bare word vs known resource name → `SEL_RESNAME` vs `SEL_STR`; `any` excluded by grammar; `true`/`false` produce migration diagnostic~~ **DONE** | |
| 3-R1 | ~~**[Review fix — HIGH]** Switch `SEL_STR` from `strdup`/`free` to `makeString`/`dereference` in `xce_select.peg` and `xce_select.c`~~ **DONE** — xstring-interned strings now refcounted; `kill_select_node` calls `dereference()` | |
| 3-R2 | ~~**[Review fix — MEDIUM]** Add `_Static_assert(sizeof(uintptr_t) >= sizeof(double))` before `makeSelectNode` in `xce_select.c`~~ **DONE** — guards the `SEL_NUM` bit-cast at compile time | |
| 3-R3 | ~~**[Review fix — MEDIUM]** Add `!("any" ![a-zA-Z_0-9])` negative lookahead to `resname_node` and `resname_leaf` in `xce_select.peg`~~ **DONE** — prevents `any` keyword from being tried as a resource name | |
---
## Phase 4a — Core Node Allocation and Interning ✓ DONE
**Priority: HIGH — foundational; unblocks 4b–4f**
| # | Task | Notes |
|---|------|-------|
| 4a-1 | ~~Implement `sel_optimize_level` (`static _Atomic int`, sentinel -1) with `sel_optimize_set()` write-once CAS and `sel_get_optimize_level()` accessor~~ **DONE** | |
| 4a-2 | ~~Implement `sel_make_key()` — zero padding bytes via struct literal init; SEL_RESNAME zeroes `op2` for key purposes (res_type stored in node, not in key)~~ **DONE** | |
| 4a-3 | ~~Implement `sel_key_hash` over the 3 key fields~~ **DONE** | |
| 4a-4 | ~~Implement `sel_node_table_init` / `sel_node_table_fini` — `makeInstance(&kvtab_type, &params)` with **`datakiller=NULL`**, `zarray_init` for linear nodes array~~ **DONE** | |
| 4a-5 | ~~Implement `makeSelectNode` — CSE-disabled fast path; CSE-enabled path with commutativity sort, key lookup, TOCTOU double-check under lock, node_id assignment via `atomic_fetch_add`, zarray rollback via `atomic_fetch_sub`~~ **DONE** | |
| 4a-6 | ~~Implement `sel_node_to_key()` for GC key reconstruction (must apply same commutativity sort as `makeSelectNode`)~~ **DONE** | |
| 4a-7 | ~~Add `sel_optimize_test_reset()` test-only helper (`#ifdef UNIT_TEST`): `atomic_store_explicit` reset to -1 plus `sel_node_table_fini`~~ **DONE** | |
| 4a-R1 | ~~**[Review fix — HIGH]** Move `node_id` assignment inside lock after `key_add` succeeds so TOCTOU losers need no counter rollback~~ **DONE** | Counter divergence under concurrent load |
| 4a-R2 | ~~**[Review fix — HIGH]** Add `dereference((char*)op1)` on first-lock cache hit for SEL_STR — caller transfers xstring ownership~~ **DONE** | Ref leak on every cache hit for string nodes |
| 4a-R3 | ~~**[Review fix — HIGH]** Add `SEL_MAX_INTERNED_NODES` cap check in both lock sections (first miss + TOCTOU re-check)~~ **DONE** | Unbounded table growth without cap |
| 4a-R4 | ~~**[Review fix — HIGH]** Fix `sel_node_table_fini` to iterate and free all interned nodes before `destroy(table)` — NULL datakiller does not free them~~ **DONE** | All nodes leaked at teardown; LSAN clean now |
| 4a-R5 | ~~**[Review fix — MEDIUM]** Set `node->hashcode = sel_key_hash_fn(&key)` in CSE path — was left 0, breaking Phase 4e memoization~~ **DONE** | |
| 4a-R6 | ~~**[Review fix — MEDIUM]** Add `dereference((char*)op1)` on `calloc` failure in level==0 path~~ **DONE** | xstring ref leak on OOM |
| 4a-R7 | ~~**[Review fix — MEDIUM]** Add `-EINVAL` guard on `key_find` — corrupt table must not silently proceed~~ **DONE** | |
| 4a-R8 | ~~**[Review fix — MEDIUM]** Add `-EEXIST` recovery in `key_add` failure path — re-find and return the racing winner~~ **DONE** | Rare concurrent insert race |
| 4a-R9 | ~~**[Review fix — LOW]** Change `_Static_assert(sizeof(uintptr_t) >= ...)` to `==` per spec requirement~~ **DONE** | |
---
## Phase 4b — Parse-Time Type Validation ✓ DONE (2026-04-29)
**Priority: HIGH — gate: P0-1 `makeString()` fix must be merged first**
| # | Task | Notes |
|---|------|-------|
| 4b-1 | ~~Wire `parse_select()` into `parse_resreq_primary` **before** `kill_resreq_data`~~ **DONE** | Use-after-free if called after `kill_resreq_data` frees `data.selectString` |
| 4b-2 | ~~Implement EQ/NE type validation: reject BOOL×STR; coerce SEL_NUM to SEL_STR when comparing with string resource (`snprintf "%.17g"`), NULL-check `makeString` result, symmetric coercion~~ **DONE** | Free orphaned SEL_NUM node in unoptimized mode before overwriting pointer |
| 4b-3 | ~~Implement `>/<`/`>=`/`<=` type validation: reject non-numeric operands~~ **DONE** | `log_fatal` (not `assert`) for belt-and-suspenders at eval time too |
| 4b-4 | ~~Implement `res_idx` validity guard and unknown-resource error; add migration diagnostic for `true`/`false` identifiers (parse as SEL_RESNAME with no match)~~ **DONE** | |
| 4b-5 | ~~Update `kill_primary_resreq` for conditional teardown: CSE-enabled → null root only; CSE-disabled → `free_select_tree()` then `FREEUP`~~ **DONE** | NULL store before `FREEUP` to prevent GC mark dangling-pointer race |
| 4b-6 | ~~**LC_NUMERIC enforcement**: wrap PEG parser call in `parse_select` with `newlocale(LC_NUMERIC_MASK, "C")` / `uselocale` / `freelocale`; `log_fatal` on failure in scheduler, `log_error`+return in API server~~ **DONE** | `_XOPEN_SOURCE 700` required before system headers |
| 4b-7 | ~~**strtod locale coverage check**~~ **DONE — SAFE**: packcc emits single `strtod` in `pcc_action_value_node_1` (line 1593 of generated file), inside the `uselocale` scope — no locale vulnerability | |
| 4b-R1 | ~~**[Review fix — HIGH]** Add `lock(auxil->resmgr, RTTI_LOCK_READ)` / `unlock(auxil->resmgr)` around `key_find` in `resolve_resname()` (`xce_select.peg:38`) — missing read lock caused data race in multi-threaded scheduler~~ **DONE** | |
| 4b-R2 | **[Review check — F2]** STRING×NUM coercion error paths reviewed: `makeSelectNode` releases the xstring on OOM failure (level-0 path calls `dereference(op1)` before returning NULL); outer `makeSelectNode(op_type)` failure correctly calls `SEL_DISCARD(l)` + `SEL_DISCARD(r)` — **no leak, false positive** | |
---
## Phase 4c — Commutativity + Associativity Flattening ✓ DONE (2026-04-29)
| # | Task | Notes |
|---|------|-------|
| 4c-1 | ~~Verify commutativity sort in `makeSelectNode` step 1 covers AND/OR/EQ/NE; non-commutative GT/LT/GE/LE/NOT unchanged~~ **DONE** — `test_cse_gt_not_commutative` confirms GT is not commutative; AND/OR/EQ/NE sort verified. | Already integrated in §5.4 design |
| 4c-2 | ~~Implement post-parse **flatten pass** for `&&`/`||` chains: `do { changed = flatten_pass(root); } while (changed)`~~ **DONE** — `sel_flatten_pass` added to `xce_select.c`; do-while loop in `parse_select()` in `resreq.c`; 7 tests: noop, single-level, deep 3+, OR, mixed-ops, CSE mode. 157 PASS. | Repeat-until-stable needed for depth-3+ explicit parenthesization |
| 4c-3 | ~~In unoptimized mode: `flatten_pass` must `free()` each orphaned node — not its children (re-parented children must not be freed)~~ **DONE** — level==0 path frees orphaned intermediate node (`free(new_right); free(node)`) after re-parenting A/B/C; SEL_NOT branch frees `node` only. | Children freed would cause double-free when `free_select_tree` walks from root |
**Senior review fixes applied (2026-04-29):**
- **[MEDIUM — OOM safety]** In AND/OR branch: update `node->u.binop.left/right` in-place (level==0) immediately after recursive calls, before any `makeSelectNode`. This ensures `return node` after OOM returns a valid tree with live (not dangling) children.
- **[LOW — dead code]** Removed dead `if (node->type == SEL_STR ...)` / `if (new_right->type == SEL_STR ...)` guards in AND/OR and SEL_NOT branches — those nodes are never SEL_STR in those arms.
---
## Phase 4d — Const-Fold Transform ✓ DONE (2026-04-29)
| # | Task | Notes |
|---|------|-------|
| 4d-1 | ~~`resname == any` → `SEL_TRUE`; `resname != any` → `SEL_FALSE` — check **both** child slots for SEL_ANY pointer (commutativity sort is ASLR-nondeterministic across runs)~~ **DONE** | |
| 4d-2 | ~~Identity laws: `SEL_TRUE && X` → `X`; `SEL_FALSE \|\| X` → `X`~~ **DONE** | |
| 4d-3 | ~~Annihilation laws: `SEL_FALSE && X` → `SEL_FALSE`; `SEL_TRUE \|\| X` → `SEL_TRUE`~~ **DONE** | |
| 4d-4 | ~~Complement laws: `!SEL_TRUE` → `SEL_FALSE`; `!SEL_FALSE` → `SEL_TRUE`~~ **DONE** | Single-pass — const-fold shrinks trees, does not create new flattening opportunities |
---
## Phase 4e — Evaluation with Memoization ✓ DONE (2026-04-29)
| # | Task | Notes |
|---|------|-------|
| 4e-1 | ~~Implement `eval_get_bool`/`eval_set_bool` 2-bit bitmap accessors with `node_id >= bool_bits_cap` guards using `log_fatal`~~ **DONE** | `_Static_assert(EVAL_NONE == 0)` for memset-reset correctness |
| 4e-2 | ~~Implement `eval_select_inner` — recursive eval with **explicit** error propagation (`if (lv < 0) return -1`), NOT C `&&`/`||` (C's `-1` is truthy, would swallow errors)~~ **DONE** | SEL_ANY reaching eval → `log_fatal("SEL_ANY reached eval — const-fold not applied")` |
| 4e-3 | ~~Leaf cache writes in `eval_select_inner`: SEL_RESNAME writes to `ctx->num_vals[node_id]` or `ctx->str_vals[node_id]`; comparison ops call `eval_select(child, ctx)` then read cached arrays~~ **DONE** | **Critical**: `get_leaf_value()` must NOT be called directly — bypasses cache writes, silently reads 0.0/NULL |
| 4e-4 | ~~Implement memoized `eval_select` wrapper — bitmap cache check before calling `eval_select_inner`; cache EVAL_ERROR too (node-structural errors repeat per host)~~ **DONE** | |
| 4e-5 | ~~Implement `eval_select_simple` for unoptimized path — plain recursive tree-walk, no `select_eval_ctx_t`, thread-safe (no shared state)~~ **DONE** | |
| 4e-6 | ~~Add `eval_select` guard: `log_fatal` if `sel_get_optimize_level() <= 0`~~ **DONE** | |
| 4e-7 | ~~Implement `select_eval_ctx_init` / `select_eval_ctx_reset` / `select_eval_ctx_fini`~~ **DONE** | `node_count` snapshot at init; `reset` uses snapshot, never re-reads `sel_node_next_id` |
| 4e-8 | ~~Pre-session capacity check: `memset` extended bool_bits bytes after realloc (realloc does not zero extension); partial-OOM contract — all three reallocs (bool_bits, num_vals, str_vals) must succeed atomically or `log_fatal`~~ **DONE** | Mismatched capacities allow out-of-bounds write to num_vals with no inline bounds check |
---
## Phase 4f — Mark-and-Sweep GC + Recompaction ✓ DONE (2026-04-29)
| # | Task | Notes |
|---|------|-------|
| 4f-1 | ~~Implement `gc_mark_tree` — recursive DFS, early return on `node == NULL \|\| node->gc_mark`; handles leaf / SEL_NOT / binary node types~~ **DONE** — `__attribute__((unused))` for Phase 6 wire-in | Recursion bounded by `SELECT_MAX_DEPTH` (64); any increase requires stack analysis |
| 4f-2 | ~~Implement `gc_sweep_select_nodes` — Phase 6 guard placed **before** mark-clear (prevents two-call sweep-of-reachable-nodes bug); mark-clear iterates `caps`; sweep iterates `caps`, `zarray_del` before `free`~~ **DONE** — live_jobs traversal deferred to Phase 6 (sched/types.h not in libxce scope) | GC does NOT acquire `sel_node_table.lock` (idle-phase invariant) |
| 4f-3 | Insert **`atomic_thread_fence(memory_order_seq_cst)`** after all `primary->select->root = NULL` unlinks and before `gc_sweep_select_nodes` call — **Phase 6 call-site task** | Without fence: CPU/compiler may reorder NULL store after GC mark pass |
| 4f-4 | Runtime GC idle invariant guards at GC call site (`sched_session_active`, `sched_eval_in_progress`, `staging_merge_complete`) — `log_fatal` not `assert` — **Phase 6 call-site task** → see 6-3/6-4 | `assert` compiled out by NDEBUG; use-after-free would result |
| 4f-5 | ~~Implement `recompact_node_ids` — iterate `nodes` zarray over `caps` slots (not `count`, gaps exist after `zarray_del`), assign sequential `node_id`, reset `sel_node_next_id` via `atomic_store_explicit(..., memory_order_release)`~~ **DONE** | `node_id` and zarray slot index are **decoupled** — do not conflate |
| 4f-R1 | ~~**[Review fix — HIGH]** Move Phase 6 early-return guard to before mark-clear loop — prior ordering allowed two consecutive calls (njobs>0 then njobs==0) to sweep still-reachable nodes~~ **DONE** | Two-call scenario: first call clears all marks and returns; second call sweeps everything |
| 4f-R2 | ~~**[Review fix — HIGH]** Swap `free(node)` / `zarray_del` order — `zarray_del` NULLs slot first, then `free`; eliminates latent use-after-free if killer is ever installed on nodes zarray~~ **DONE** | |
| 4f-R3 | ~~**[Bug fix]** `sel_node_table_fini` iterated `nodes.count` instead of `nodes.caps` — after `zarray_del` creates gaps, new nodes slot into positions beyond `count` boundary and leaked~~ **DONE** | Manifested as 32-byte ASAN leak after GC sweep + reintern in same lifetime |
---
## Phase 5 — Build Wiring
| # | Task | Notes |
|---|------|-------|
| 5-1 | ~~Add `custom_target` for `xce_select.peg` in `lib/meson.build` matching `section_parser`/`rusage_parser` pattern~~ **DONE** | Verify `-o 'lib/xce_select'` path format: `grep -A8 'custom_target' src/lib/meson.build \| grep '\-o'` |
| 5-2 | ~~Add `xce_select_parser[0]` (generated `.c`) to `libxce_src`~~ **DONE** | |
| 5-3 | ~~Add `test_resmgr_fixture.c` to testsuite `executable()` sources in `lib/meson.build`~~ **DONE** | |
---
## Phase 6 — Scheduler Integration
**Status: ✓ DONE — 2026-04-30 — 10/10 scheduler tests pass**
| # | Task | Notes |
|---|------|-------|
| 6-1 | ~~**Pre-Phase-6 audit**: confirm allocator-thread-only invariant~~ **DONE** `bb4a95c` | Single-threaded confirmed; stub at `resource.c:127` |
| 6-2 | ~~**Blocking gate**: all `sched_meet_host_selection` call sites accounted for~~ **DONE** `bb4a95c` | No missed call sites |
| 6-3 | ~~Add `_Atomic int session_active`, `eval_in_progress`, `staging_merge_complete` to `xce_scheduler_t`~~ **DONE** `823a66e` | Use `_Atomic int` not `volatile int` — ARM atomicity guarantee |
| 6-4 | ~~Implement GC predicate wrappers referencing real `xce_scheduler_t` fields~~ **DONE** `acda9bb` | Signatures unchanged; only wrapper body changed |
| 6-5 | ~~Implement `sched_meet_host_selection` via `eval_select_simple`~~ **DONE** `40261b7` | Full implementation replacing stub that always returned 1 |
| 6-6 | ~~`sched_meet_host_selection` returns int (0/1); preason on fail~~ **DONE** `40261b7` | int return kept; preason wired |
| 6-7 | ~~Implement `build_host_eval_ctx`~~ **DONE** `907d8d8` | Flattens `worker->resvals` zarray to `numvals`/`strvals` using `Value_u_fvalue/svalue/bvalue` |
| 6-8 | ~~Materialize `live_jobs` flat array from `sched->jobs` kvmgr~~ **DONE** `5267d36` | Growth-loop heap alloc; OOM-safe (double-guarded: `calloc` fail + `realloc` null-sentinel `742d79e`, `fb5127a`) |
| 6-9 | ~~Wire `sel_optimize_set(1)` at scheduler startup~~ **DONE** `acda9bb` | Called in `sched_init`; no `sel_optimize_set(0)` needed (API server has separate process) |
| 6-10 | ~~Set `sel_alloc_thread_id`; add thread-ID checks in `eval_select` and `gc_sweep_select_nodes`~~ **DONE** `b722e5c` `1eb4eb1` | `_Atomic int` registered flag + `pthread_equal` guard; registered before `while(1)` loop |
| 6-11 | ~~Write integration/parity tests against known-good resreq/host pairs~~ **DONE** `5fcf85c` | Numeric (`SEL_GE`) and string (`SEL_EQ`) pass/fail cases |
| 6-12 | ~~`atomic_thread_fence(memory_order_seq_cst)` before `gc_sweep_select_nodes`~~ **DONE** `5267d36` | After all `gc_mark_select_tree` calls in `sched_end_session` |
| R1 | ~~**[Review fix — CRITICAL]** `staging_merge_complete` bootstrap deadlock — GC never ran~~ **DONE** `d438742` | `sched_begin_session` never set it to 1; guard in `sched_end_session` always fired |
| R2 | ~~**[Review fix — HIGH]** `recompact_node_ids()` missing from GC protocol~~ **DONE** `d438742` | Added after `gc_sweep_select_nodes` in `sched_end_session` |
---
## Key Implementation Traps
1. **`free_select_tree` vs GC mode confusion** — calling `free_select_tree` in CSE-enabled mode or forgetting it in disabled mode both produce memory corruption. The `sel_get_optimize_level()` guard inside `free_select_tree` catches this with `log_fatal`.
2. **`get_leaf_value()` bypass bug** — calling `get_leaf_value()` directly in comparison branches bypasses the `num_vals`/`str_vals` cache write. Comparisons silently read 0.0/NULL (calloc default) instead of actual host values. Every comparison operator must call `eval_select(child, ctx)` first, then read the typed cache array.
3. **TOCTOU in `makeSelectNode`** — the double-check under lock (step 5) prevents node leaks when two threads race to intern the same node. The re-find must happen inside the lock or the winner could be freed before the loser reads it.
4. **`atomic_thread_fence` before GC** — root-unlink writes (`primary->select->root = NULL`) must be visible to the GC mark pass. Without a seq_cst fence, CPU/compiler reordering causes GC to retain a node that should be swept (or vice versa).
5. **Partial-OOM in `select_eval_ctx_t` resize** — if one of the three reallocs (bool_bits, num_vals, str_vals) succeeds and another fails, the capacities diverge and `eval_select_inner` can write out-of-bounds to `num_vals` (no inline bounds guard at write site). Treat all three as atomic — `log_fatal` on partial failure.

View file

@ -1,32 +1,58 @@
Help of XCE
===========
This is the entrypoint of xce tool. Currently, we have following sub-commands available:
This is the entrypoint of xce tool. Currently, we have following sub-commands
available:
- controller
- worker
- scheduler
- apiserver
- shell
- **controller** — start xce controller daemon
- **worker** — start xcecd worker daemon
- **scheduler** — start xce scheduler daemon
- **apiserver** — start xce REST API server
- **shell** — interactive bash shell for troubleshooting
- **xcecli** — CLI tool for cluster interaction (job submit, query, admin)
If you run xce without any parameter or with parameter which is not in above, then it will show this help screen
If you run xce without any parameter or with a parameter not listed above, it
will show this help screen.
## controller
This command with start xce controller with given parameters after controller sub-command
Start the xce controller daemon with the given parameters after the
`controller` sub-command.
Example:
```
xce controller --config /etc/xce/controller.yaml
```
## scheduler
This command with start xce scheduler with given parameters after scheduler sub-command
Start the xce scheduler daemon with the given parameters after the
`scheduler` sub-command.
## worker
This command will start xcecd with given parameters after worker sub-command
Start xcecd with the given parameters after the `worker` sub-command.
## apiserver
This command will start xce apiserver with given parameters after apiserver sub-command
Start the xce REST API server with the given parameters after the
`apiserver` sub-command. The API server provides HTTP endpoints for job
submission, queue management, and cluster queries, backed by JWT
authentication and RBAC authorization.
## xcecli
The xce CLI (`src/frontend/xcecli/`) provides command-line access to the
cluster. Common operations:
- `xcecli job submit <jobspec>` — submit a job
- `xcecli job list` — list jobs
- `xcecli queue status` — show queue state
Built as a standalone Go binary; communicates with the API server over
its REST interface.
## shell
An interactive bash shell to start, this is just for trouble shoot
An interactive bash shell used for troubleshooting and inspection. Not
intended for normal cluster operation.