Skip to content

Commit 4681c7b

Browse files
Record SLURM usage correctly and rename the cluster-user lookup
Fixes #535 Fixes #536
1 parent 368032a commit 4681c7b

10 files changed

Lines changed: 311 additions & 115 deletions

File tree

config/custos.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ connectors:
4646
username: "${SLURM_API_USERNAME}"
4747
token: "${SLURM_TOKEN}"
4848
cluster_id: "${CUSTOS_CLUSTER_ID}"
49+
# How far back each poll re-scans to catch jobs SLURM writes to its
50+
# accounting database late. Tune to the cluster's slurmdbd commit lag.
51+
usage_lookback: "15m"
4952

5053
comanage-provisioner:
5154
type: "comanage-identity-provisioner"

connectors/SLURM/Usage-Monitor/internal/smonitor/smonitor.go

Lines changed: 115 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,38 @@ import (
3131

3232
const monitorInterval = 30 * time.Second
3333

34+
// defaultPollOverlap is the look-back used when usage_lookback is not set.
35+
// The look-back makes each poll re-scan a short way before where the last one
36+
// stopped. SLURM saves a finished job to its accounting database a little
37+
// after the job ends, so a late-arriving record can land after the poll
38+
// window it belongs to has already passed. The look-back re-covers that gap,
39+
// and a repeat of the same job id replaces its earlier row. Its right value
40+
// depends on the cluster's slurmdbd commit lag, so it is configurable.
41+
const defaultPollOverlap = 15 * time.Minute
42+
43+
type jobLister interface {
44+
ListJobs(filter client.JobFilter) ([]client.JobInfo, error)
45+
}
46+
3447
type SlurmMonitor struct {
35-
slurmClient *client.Client
48+
slurmClient jobLister
3649
eventBus *events.Bus
3750
coreService service.CoreService
3851
clusterId string
52+
pollOverlap time.Duration
3953
lastMonitorTime int64
4054
}
4155

42-
func NewSlurmMonitor(slurmClient *client.Client, eventBus *events.Bus, coreService service.CoreService, clusterId string) *SlurmMonitor {
56+
func NewSlurmMonitor(slurmClient *client.Client, eventBus *events.Bus, coreService service.CoreService, clusterId string, pollOverlap time.Duration) *SlurmMonitor {
57+
if pollOverlap <= 0 {
58+
pollOverlap = defaultPollOverlap
59+
}
4360
return &SlurmMonitor{
4461
slurmClient: slurmClient,
4562
eventBus: eventBus,
4663
coreService: coreService,
4764
clusterId: clusterId,
65+
pollOverlap: pollOverlap,
4866
lastMonitorTime: 1, // initialize to 1 to avoid issues with zero value
4967
}
5068
}
@@ -81,8 +99,12 @@ func (m *SlurmMonitor) poll() {
8199
return
82100
}
83101

102+
windowStart := m.lastMonitorTime - int64(m.pollOverlap.Seconds())
103+
if windowStart < 1 {
104+
windowStart = 1
105+
}
84106
jobFilter := client.JobFilter{
85-
StartTime: m.lastMonitorTime,
107+
StartTime: windowStart,
86108
EndTime: time.Now().Unix(),
87109
}
88110

@@ -94,89 +116,98 @@ func (m *SlurmMonitor) poll() {
94116
m.lastMonitorTime = jobFilter.EndTime
95117

96118
for _, job := range jobs {
97-
//slog.Debug("processing SLURM job", "job_id", job.JobID, "job_name", job.Name)
98-
//m.coreService.GetComputeAllocationResource()
99-
slog.Info("Job object", "job", job)
100-
targetAccount := job.Account
101-
for _, alloc := range allocations {
102-
if alloc.Name == targetAccount {
103-
slog.Info("found matching compute allocation for SLURM job", "job_id", job.JobID, "allocation_id", alloc.ID)
104-
105-
user, err := m.coreService.GetComputeClusterUserByLocalUsernameAndCluster(context, job.User, cluster.ID)
106-
if err != nil {
107-
if err == service.ErrNotFound {
108-
slog.Warn("compute cluster user not found for SLURM job, skipping usage recording", "local_username", job.User, "cluster_id", cluster.ID)
109-
return
110-
} else {
111-
slog.Error("failed to get compute cluster user", "error", err)
112-
return
113-
}
114-
}
115-
116-
resource, err := m.coreService.GetComputeAllocationResourceByNameAndCluster(context, job.Partition, cluster.ID)
117-
118-
if err != nil {
119-
if err == service.ErrNotFound {
120-
slog.Warn("compute allocation resource not found for SLURM job, skipping usage recording", "resource_name", job.Partition, "cluster_id", cluster.ID)
121-
return
122-
} else {
123-
slog.Error("failed to get compute allocation resource", "error", err)
124-
return
125-
}
126-
}
127-
128-
jobId := strconv.FormatInt(job.JobID, 10)
129-
existing, err := m.coreService.GetComputeAllocationUsageByComputeAllocationIDAndJobID(context, alloc.ID, jobId)
130-
131-
if err != nil && err != service.ErrNotFound {
132-
slog.Error("failed to check for existing compute allocation usage", "error", err)
133-
return
134-
}
135-
136-
jobDurationMs := job.Time.End - job.Time.Start
137-
138-
if jobDurationMs <= 0 {
139-
slog.Warn("SLURM job has non-positive duration, skipping usage recording", "job_id", job.JobID, "duration", jobDurationMs)
140-
return
141-
}
142-
143-
tresType := resource.ResourceType
144-
145-
resourceAmount := int64(0)
146-
nodeCount := int64(0)
147-
for _, tres := range job.Tres.Allocated {
148-
// Process each TRES type and its allocated amount as needed
149-
// Example tres entry Allocated:[{Type:cpu Name: Count:1} {Type:mem Name: Count:8000} {Type:energy Name: Count:-2} {Type:node Name: Count:1} {Type:billing Name: Count:1}]
150-
if tres.Type == tresType {
151-
resourceAmount = tres.Count
152-
}
153-
if tres.Type == "node" {
154-
nodeCount = tres.Count
155-
}
156-
}
157-
158-
calulatedRawAmount := float64(resourceAmount) * float64(nodeCount) * float64(jobDurationMs) / (1000 * 3600) // Convert to minutes, adjust as needed based on how you want to calculate usage
159-
160-
usageModel := &models.ComputeAllocationUsage{
161-
ComputeAllocationID: alloc.ID,
162-
UsedRawAmount: calulatedRawAmount, // This is a simplification, adjust as needed based on how you want to calculate usage
163-
UsedSUAmount: calulatedRawAmount * 1, // Assuming 1 SU per second for simplicity, adjust as needed based on your SU calculation logic
164-
CalculatedTime: time.Now(),
165-
UserID: user.ID,
166-
JobID: jobId,
167-
ComputeAllocationResourceID: resource.ID,
168-
}
169-
170-
if existing != nil {
171-
m.coreService.DeleteComputeAllocationUsage(context, existing.ID)
172-
slog.Info("deleted existing compute allocation usage for SLURM job", "job_id", job.JobID, "existing_usage_id", existing.ID)
173-
}
174-
m.coreService.CreateComputeAllocationUsage(context, usageModel)
175-
break
176-
}
177-
}
119+
m.recordJob(context, job, cluster, allocations)
178120
}
179121

180122
slog.Info("successfully polled SLURM usage", "num_allocations", len(allocations), "num_jobs", len(jobs))
181123

182124
}
125+
126+
// recordJob writes one usage row for a matched job; returning skips only this
127+
// job so one bad lookup cannot abort the rest of the poll cycle.
128+
func (m *SlurmMonitor) recordJob(ctx context.Context, job client.JobInfo, cluster *models.ComputeCluster, allocations []models.ComputeAllocation) {
129+
slog.Info("Job object", "job", job)
130+
targetAccount := job.Account
131+
for _, alloc := range allocations {
132+
if alloc.Name != targetAccount {
133+
continue
134+
}
135+
slog.Info("found matching compute allocation for SLURM job", "job_id", job.JobID, "allocation_id", alloc.ID)
136+
137+
user, err := m.coreService.GetComputeClusterUserByClusterAndLocalUsername(ctx, cluster.ID, job.User)
138+
if err != nil {
139+
if err == service.ErrNotFound {
140+
slog.Warn("compute cluster user not found for SLURM job, skipping usage recording", "local_username", job.User, "cluster_id", cluster.ID)
141+
} else {
142+
slog.Error("failed to get compute cluster user", "error", err)
143+
}
144+
return
145+
}
146+
147+
resource, err := m.coreService.GetComputeAllocationResourceByNameAndCluster(ctx, job.Partition, cluster.ID)
148+
if err != nil {
149+
if err == service.ErrNotFound {
150+
slog.Warn("compute allocation resource not found for SLURM job, skipping usage recording", "resource_name", job.Partition, "cluster_id", cluster.ID)
151+
} else {
152+
slog.Error("failed to get compute allocation resource", "error", err)
153+
}
154+
return
155+
}
156+
157+
jobId := strconv.FormatInt(job.JobID, 10)
158+
existing, err := m.coreService.GetComputeAllocationUsageByComputeAllocationIDAndJobID(ctx, alloc.ID, jobId)
159+
if err != nil && err != service.ErrNotFound {
160+
slog.Error("failed to check for existing compute allocation usage", "error", err)
161+
return
162+
}
163+
164+
jobDurationSec := job.Time.End - job.Time.Start
165+
if jobDurationSec <= 0 {
166+
slog.Warn("SLURM job has non-positive duration, skipping usage recording", "job_id", job.JobID, "duration_seconds", jobDurationSec)
167+
return
168+
}
169+
170+
tresType := resource.ResourceType
171+
172+
resourceAmount := int64(0)
173+
nodeCount := int64(0)
174+
for _, tres := range job.Tres.Allocated {
175+
// Example tres entry Allocated:[{Type:cpu Name: Count:1} {Type:mem Name: Count:8000} {Type:energy Name: Count:-2} {Type:node Name: Count:1} {Type:billing Name: Count:1}]
176+
if tres.Type == tresType {
177+
resourceAmount = tres.Count
178+
}
179+
if tres.Type == "node" {
180+
nodeCount = tres.Count
181+
}
182+
}
183+
184+
calculatedRawAmount := float64(resourceAmount) * float64(nodeCount) * float64(jobDurationSec) / 3600
185+
186+
rate, err := m.coreService.GetEffectiveRateForResource(ctx, resource.ID, time.Unix(job.Time.End, 0))
187+
if err != nil {
188+
if err == service.ErrNotFound {
189+
slog.Warn("no rate covers the job end time, skipping usage recording", "job_id", job.JobID, "resource_id", resource.ID)
190+
} else {
191+
slog.Error("failed to get effective rate for resource", "error", err, "job_id", job.JobID, "resource_id", resource.ID)
192+
}
193+
return
194+
}
195+
196+
usageModel := &models.ComputeAllocationUsage{
197+
ComputeAllocationID: alloc.ID,
198+
UsedRawAmount: calculatedRawAmount,
199+
UsedSUAmount: calculatedRawAmount * rate.Rate,
200+
CalculatedTime: time.Now(),
201+
UserID: user.UserID,
202+
JobID: jobId,
203+
ComputeAllocationResourceID: resource.ID,
204+
}
205+
206+
if existing != nil {
207+
m.coreService.DeleteComputeAllocationUsage(ctx, existing.ID)
208+
slog.Info("deleted existing compute allocation usage for SLURM job", "job_id", job.JobID, "existing_usage_id", existing.ID)
209+
}
210+
m.coreService.CreateComputeAllocationUsage(ctx, usageModel)
211+
return
212+
}
213+
}

connectors/SLURM/Usage-Monitor/internal/smonitor/smonitor_integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ func TestSlurmMonitorIntegration(t *testing.T) {
207207
}, nil
208208
},
209209

210-
GetComputeClusterUserByLocalUsernameAndClusterFunc: func(ctx context.Context, localUsername string, clusterId string) (*models.ComputeClusterUser, error) {
210+
GetComputeClusterUserByClusterAndLocalUsernameFunc: func(ctx context.Context, localUsername string, clusterId string) (*models.ComputeClusterUser, error) {
211211
return &models.ComputeClusterUser{
212212
ID: "user-" + localUsername,
213213
ComputeClusterID: clusterId,

0 commit comments

Comments
 (0)