myxce/src/frontend/apiserver/backend/handler.go
Rongsong Shen e53c086ec5 fix(schema): rename struct Data -> XceData to unshadow capnp builtin blob type
controller.capnp declared `struct Data $C.mapname("xce_data_t")` (an
OPCode_get query-result tagged union) at file scope, which shadows
Cap'n Proto's builtin Data blob type for every later `: Data` field in
the same file. This silently miscompiled 7 fields across 6 structs as
a struct-pointer type instead of a raw byte blob: UpgradeConfigRequest
.sha256/.bytes, ReplConfigReply.sha256/.bytes, ReplConfigPushRequest
.sha256/.bytes, ReplEnvelope.payload, SnapshotTransfer's chunk
payload, WalCatchupEntry.payload/.batchPayloads, and CtrlHelloRequest
.key. Production C code had already routed around it for years via
hand-rolled raw capn_ptr manipulation (see request.c's own comments);
nothing before now needed the generated codec/accessors to actually
work for these fields.

Renamed the local struct to XceData (capnp-level name only; the
$C.mapname("xce_data_t") C typedef is unchanged) and explicitly
repointed getdata/pushmsg at XceData by name, since those two fields
correctly want the local tagged union. Updated every derived
generated-identifier reference across 19 hand-written files spanning
controller, lib, scheduler, and the Go apiserver/xcecli CGo bindings.
Fixed 5 call sites in lib/request.c that had been typed against the
old workaround alias but now legitimately receive the correct
capn_data type (a pure type-name swap -- Data_ptr/XceData_ptr and
capn_data share the identical {capn_ptr p} layout).

Verified via full rebuild+test: lib (1/1), controller (3/3, incl. the
job-array push regression tests), scheduler (15/15), Go apiserver +
xcecli (all packages green), and a direct pycapnp round-trip proving
UpgradeConfigRequest.sha256/.bytes now encode/decode correctly.

Bug: bug-1461 (.wolf/buglog.json). Unblocks admin-config-tool task 3.3.
2026-07-22 09:22:04 +00:00

163 lines
5.5 KiB
Go

package backend
import (
"apiserver/models"
"apiserver/store"
logger "apiserver/utils"
"go.uber.org/zap"
)
// DefaultHandler implements NotificationHandler by writing to stores.
type DefaultHandler struct{}
func (h *DefaultHandler) OnJobCreated(job *models.JobInfo) {
store.Jobs.Put(uint64(job.Unique_JobID), *job)
}
func (h *DefaultHandler) OnJobUpdated(job *models.JobInfo) {
store.Jobs.Put(uint64(job.Unique_JobID), *job)
}
func (h *DefaultHandler) OnJobDeleted(jobid uint64) {
store.Jobs.Delete(jobid)
}
func (h *DefaultHandler) OnJobFinished(jobid uint64, exitCode int) {
existing, ok := store.Jobs.Get(jobid)
if !ok {
return
}
if exitCode == 0 {
existing.JobStatus = models.JOBSTAT["DONE"]
} else {
existing.JobStatus = models.JOBSTAT["EXIT"]
}
store.Jobs.Put(jobid, existing)
}
func (h *DefaultHandler) OnQueueUpdated(queue *models.QueueInfo) {
store.Queues.Put(queue.QueueName, *queue)
}
// OnHostUpdated merges worker-sourced fields into an existing host entry.
// Config-sourced fields (HostModel, HostType, MaxSlot, DisptWindow) are
// preserved from the prior entry if one exists.
func (h *DefaultHandler) OnHostUpdated(incoming *models.HostInfo) {
merged := *incoming
if existing, ok := store.Hosts.Get(merged.HostName); ok {
merged.HostModel = existing.HostModel
merged.HostType = existing.HostType
merged.MaxSlot = existing.MaxSlot
merged.DisptWindow = existing.DisptWindow
}
store.Hosts.Put(merged.HostName, merged)
}
// OnHostConfigUpdated applies config-sourced fields onto an existing worker-registered host.
// Hosts not yet registered by a worker are skipped — config entries like "default" (which sets
// cluster-wide defaults) or hosts whose daemons haven't started must not appear in the host list.
func (h *DefaultHandler) OnHostConfigUpdated(incoming *models.HostInfo) {
existing, ok := store.Hosts.Get(incoming.HostName)
if !ok {
return
}
existing.HostModel = incoming.HostModel
existing.HostType = incoming.HostType
existing.MaxSlot = incoming.MaxSlot
existing.DisptWindow = incoming.DisptWindow
store.Hosts.Put(existing.HostName, existing)
}
// OnClusterUpdated applies config-sourced fields onto the existing cluster entry.
// All other fields (built-in/worker-sourced: Version, Status, ControllerName,
// NumServers, Resources, HostTypes, HostModels, ClusterAdmins) are preserved
// from the prior entry.
func (h *DefaultHandler) OnClusterUpdated(incoming *models.ClusterInfo) {
existing, _ := store.Clusters.Get(incoming.ClusterName)
existing.ClusterName = incoming.ClusterName
if incoming.HeartbeatInterval > 0 {
existing.HeartbeatInterval = incoming.HeartbeatInterval
}
existing.Vcpus = incoming.Vcpus
existing.InactiveHeartbeatCount = incoming.InactiveHeartbeatCount
store.Clusters.Put(existing.ClusterName, existing)
SetHeartbeatInterval(incoming.HeartbeatInterval)
}
func (h *DefaultHandler) OnResourceUpdated(res *models.ResourceDefinitionInfo) {
store.Resources.Put(res.ResourceName, *res)
}
func (h *DefaultHandler) OnResourceLocationUpdated(res *models.ResourceDistributionInfo) {
key := res.ResourceName + ":" + res.Location
store.ResourceLocations.Put(key, *res)
}
// dispatchJobArray delivers a locally-expanded compact array push to the
// handler, one call per genuinely-new element (job-array-single-wal-record
// task 5.4, Req 6.2, 6.4, 6.5, 8.4). CGO-free (called from handleDataset's
// XceData_data_jobArray case in clib.go; the elements themselves are already
// decoded by cJobArrayToModels/rawJobArrayToModels before this is called),
// mirroring dispatchConfigPush's split for testability without CGO.
//
// Dedup reuses store.Jobs itself — the SAME jobid-keyed store the ordinary
// single-job push path already writes through (handleDataset's
// XceData_data_jobs case calls OnJobUpdated per job with no separate dedup
// step, relying on the map being keyed by jobid) — checked here BEFORE
// calling the handler so a redelivered push (e.g. after an apiserver
// reconnect) can never clobber a jobid whose state has since moved on.
// This mirrors the scheduler's sched_on_job_array() (handler.c) skip-if-
// exists behavior exactly, not a new dedup mechanism.
//
// Logs exactly one confirmation line (base jobid + array size + new-element
// count) when at least one element is genuinely new; a fully-duplicate
// redelivery (every index already exists) produces zero log lines, matching
// task 5.3's resolved semantics for the equivalent scheduler-side push.
// Returns the count of newly-dispatched elements (test-observable).
func dispatchJobArray(handler NotificationHandler, elements []*models.JobInfo, baseJobID, arraySize uint64) int {
if handler == nil {
return 0
}
nNew := 0
for _, job := range elements {
jobid := uint64(job.Unique_JobID)
if store.Jobs.Has(jobid) {
continue
}
handler.OnJobUpdated(job)
nNew++
}
if nNew > 0 {
logger.Info("job array push received and expanded",
zap.Uint64("base_jobid", baseJobID),
zap.Uint64("array_size", arraySize),
zap.Int("new_records", nNew))
}
return nNew
}
// dispatchConfigPush delivers converted config-push data to the handler (CGO-free, for testability).
func dispatchConfigPush(
handler NotificationHandler,
queues []models.QueueInfo,
resources []models.ResourceDefinitionInfo,
hostConfigs []models.HostInfo,
cluster *models.ClusterInfo,
) {
for _, q := range queues {
handler.OnQueueUpdated(&q)
}
for _, r := range resources {
handler.OnResourceUpdated(&r)
}
for _, hc := range hostConfigs {
handler.OnHostConfigUpdated(&hc)
}
if cluster != nil {
handler.OnClusterUpdated(cluster)
}
}