-
Notifications
You must be signed in to change notification settings - Fork 26
Expand file tree
/
Copy pathdb.rs
More file actions
2584 lines (2393 loc) · 83.6 KB
/
Copy pathdb.rs
File metadata and controls
2584 lines (2393 loc) · 83.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2026 Advanced Micro Devices, Inc. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use sqlx::postgres::{PgConnection, PgRow};
use sqlx::{PgPool, QueryBuilder, Row};
/// Apply the database schema, serialized across controllers by a fixed advisory
/// lock: migrate now also rewrites data (default-account dedup) and builds a
/// unique index, so concurrent runs against a shared database must not overlap.
/// The lock is transaction-scoped, so it releases even if the apply fails.
pub async fn migrate(pool: &PgPool) -> anyhow::Result<()> {
let mut tx = pool.begin().await?;
sqlx::query("SELECT pg_advisory_xact_lock($1, $2)")
.bind(SCHEMA_LOCK_CLASS)
.bind(SCHEMA_LOCK_OBJ)
.execute(&mut *tx)
.await?;
sqlx::raw_sql(SCHEMA).execute(&mut *tx).await?;
tx.commit().await?;
Ok(())
}
// Advisory-lock keys that serialize `migrate` across controllers. The two-int4
// form is a distinct lock space from the single-bigint per-user locks
// `add_user` takes, so the keys can never collide.
const SCHEMA_LOCK_CLASS: i32 = 0x5350_5552; // 'SPUR'
const SCHEMA_LOCK_OBJ: i32 = 1;
const SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS jobs (
job_id INTEGER PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
user_name TEXT NOT NULL,
uid INTEGER NOT NULL DEFAULT 0,
account TEXT NOT NULL DEFAULT '',
partition_name TEXT NOT NULL DEFAULT '',
qos TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'PENDING',
exit_code INTEGER NOT NULL DEFAULT 0,
exit_signal INTEGER NOT NULL DEFAULT 0,
derived_exit_code INTEGER NOT NULL DEFAULT 0,
num_nodes INTEGER NOT NULL DEFAULT 1,
num_tasks INTEGER NOT NULL DEFAULT 1,
cpus_per_task INTEGER NOT NULL DEFAULT 1,
memory_mb BIGINT NOT NULL DEFAULT 0,
nodelist TEXT NOT NULL DEFAULT '',
submit_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
time_limit_min INTEGER,
work_dir TEXT NOT NULL DEFAULT '',
script_hash TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS accounts (
name TEXT PRIMARY KEY,
description TEXT NOT NULL DEFAULT '',
organization TEXT NOT NULL DEFAULT '',
parent_account TEXT,
fairshare_weight INTEGER NOT NULL DEFAULT 1,
max_running_jobs INTEGER,
grp_tres TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS users (
name TEXT NOT NULL,
account TEXT NOT NULL REFERENCES accounts(name),
admin_level TEXT NOT NULL DEFAULT 'none',
default_account TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (name, account)
);
CREATE TABLE IF NOT EXISTS usage (
user_name TEXT NOT NULL,
account TEXT NOT NULL,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
cpu_seconds BIGINT NOT NULL DEFAULT 0,
gpu_seconds BIGINT NOT NULL DEFAULT 0,
job_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_name, account, period_start)
);
CREATE TABLE IF NOT EXISTS qos (
name TEXT PRIMARY KEY,
description TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0,
preempt_mode TEXT NOT NULL DEFAULT 'off',
usage_factor REAL NOT NULL DEFAULT 1.0,
max_jobs_per_user INTEGER,
max_submit_per_user INTEGER,
max_tres_per_job TEXT,
max_tres_per_user TEXT,
grp_tres TEXT,
max_wall_min INTEGER,
grp_wall_min INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS associations (
id SERIAL PRIMARY KEY,
user_name TEXT NOT NULL,
account TEXT NOT NULL REFERENCES accounts(name),
partition_name TEXT,
fairshare_weight INTEGER NOT NULL DEFAULT 1,
max_running_jobs INTEGER,
max_submit_jobs INTEGER,
max_tres_per_job TEXT,
grp_tres TEXT,
max_wall_min INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_name, account, partition_name)
);
CREATE TABLE IF NOT EXISTS tres_usage (
job_id INTEGER NOT NULL,
tres_type TEXT NOT NULL,
alloc_value BIGINT NOT NULL DEFAULT 0,
used_value BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (job_id, tres_type)
);
CREATE INDEX IF NOT EXISTS idx_jobs_user ON jobs(user_name);
CREATE INDEX IF NOT EXISTS idx_jobs_account ON jobs(account);
CREATE INDEX IF NOT EXISTS idx_jobs_state ON jobs(state);
CREATE INDEX IF NOT EXISTS idx_jobs_submit_time ON jobs(submit_time);
CREATE INDEX IF NOT EXISTS idx_jobs_start_time ON jobs(start_time);
CREATE INDEX IF NOT EXISTS idx_usage_period ON usage(period_start, period_end);
CREATE INDEX IF NOT EXISTS idx_assoc_user ON associations(user_name);
CREATE INDEX IF NOT EXISTS idx_assoc_account ON associations(account);
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS exit_signal INTEGER NOT NULL DEFAULT 0;
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS derived_exit_code INTEGER NOT NULL DEFAULT 0;
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS reservation TEXT NOT NULL DEFAULT '';
-- No FK to qos(name): a stale reference (QOS deleted after being set as a
-- default) must degrade gracefully at read time, not be blocked here.
ALTER TABLE associations ADD COLUMN IF NOT EXISTS default_qos TEXT;
-- Comma-separated QOS names, same degrade-gracefully rationale as default_qos.
ALTER TABLE associations ADD COLUMN IF NOT EXISTS allowed_qos TEXT;
ALTER TABLE qos ADD COLUMN IF NOT EXISTS grp_wall_min INTEGER;
ALTER TABLE accounts ADD COLUMN IF NOT EXISTS grp_tres TEXT;
-- users.default_account is the single source of truth for a user's default
-- account (the scheduler reads it via the association cache). associations
-- once carried a redundant is_default flag that nothing read; drop it so the
-- two representations can't drift.
ALTER TABLE associations DROP COLUMN IF EXISTS is_default;
-- One default account per user. Pre-fix rows could mark several accounts
-- default; collapse each user to one (the lowest account name) before the index
-- below, which would otherwise fail to build. Idempotent: a no-op once clean.
UPDATE users u SET default_account = NULL
WHERE default_account IS NOT NULL
AND EXISTS (
SELECT 1 FROM users o
WHERE o.name = u.name
AND o.default_account IS NOT NULL
AND o.account < u.account
);
CREATE UNIQUE INDEX IF NOT EXISTS one_default_account_per_user
ON users (name) WHERE default_account IS NOT NULL;
"#;
/// Record a job start in the database.
///
/// Takes a `&mut PgConnection` (not a hard `&PgPool`) so callers can either
/// acquire a standalone connection from a pool (as the notifier does) or pass
/// one borrowed from an open `Transaction` (`Transaction` derefs to
/// `PgConnection`) to run this alongside other writes atomically, as
/// reconciliation's backfill-then-finalize does.
#[allow(clippy::too_many_arguments)]
pub async fn record_job_start(
conn: &mut PgConnection,
job_id: i32,
name: &str,
user: &str,
account: &str,
partition: &str,
num_nodes: i32,
num_tasks: i32,
cpus_per_task: i32,
memory_mb: i64,
submit_time: DateTime<Utc>,
start_time: DateTime<Utc>,
reservation: &str,
) -> anyhow::Result<()> {
// job_id reuse after a Raft wipe means a conflict is a new, unrelated job.
sqlx::query(
r#"
INSERT INTO jobs (job_id, name, user_name, account, partition_name, num_nodes, num_tasks, cpus_per_task, memory_mb, submit_time, start_time, state, reservation)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'RUNNING', $12)
ON CONFLICT (job_id) DO UPDATE SET
name = EXCLUDED.name,
user_name = EXCLUDED.user_name,
account = EXCLUDED.account,
partition_name = EXCLUDED.partition_name,
num_nodes = EXCLUDED.num_nodes,
num_tasks = EXCLUDED.num_tasks,
cpus_per_task = EXCLUDED.cpus_per_task,
memory_mb = EXCLUDED.memory_mb,
submit_time = EXCLUDED.submit_time,
start_time = EXCLUDED.start_time,
state = EXCLUDED.state,
exit_code = 0,
exit_signal = 0,
derived_exit_code = 0,
end_time = NULL
"#,
)
.bind(job_id)
.bind(name)
.bind(user)
.bind(account)
.bind(partition)
.bind(num_nodes)
.bind(num_tasks)
.bind(cpus_per_task)
.bind(memory_mb)
.bind(submit_time)
.bind(start_time)
.bind(reservation)
.execute(&mut *conn)
.await?;
// If end_time is already set, the end notification arrived first and skipped
// usage computation (start_time was NULL at that point). Compute it now.
let row = sqlx::query(
"SELECT user_name, account, start_time, num_tasks, cpus_per_task, end_time FROM jobs WHERE job_id = $1",
)
.bind(job_id)
.fetch_one(&mut *conn)
.await?;
let end_time: Option<DateTime<Utc>> = row.get("end_time");
if let Some(end_time) = end_time {
update_usage(conn, row, end_time).await?;
}
Ok(())
}
/// Record a job completion in the database. See `record_job_start` for why
/// this takes a `&mut PgConnection` rather than a `&PgPool`.
#[allow(clippy::too_many_arguments)]
pub async fn record_job_end(
conn: &mut PgConnection,
job_id: i32,
state: &str,
exit_code: i32,
end_time: DateTime<Utc>,
exit_signal: i32,
derived_exit_code: i32,
) -> anyhow::Result<()> {
// RETURNING closes the record_job_start job_id-reuse race by reading in the same statement.
let row = sqlx::query(
r#"
INSERT INTO jobs (job_id, user_name, state, exit_code, end_time, exit_signal, derived_exit_code)
VALUES ($1, '', $2, $3, $4, $5, $6)
ON CONFLICT (job_id) DO UPDATE SET
state = $2,
exit_code = $3,
end_time = $4,
exit_signal = $5,
derived_exit_code = $6
RETURNING user_name, account, start_time, num_tasks, cpus_per_task
"#,
)
.bind(job_id)
.bind(state)
.bind(exit_code)
.bind(end_time)
.bind(exit_signal)
.bind(derived_exit_code)
.fetch_one(&mut *conn)
.await?;
update_usage(conn, row, end_time).await?;
Ok(())
}
/// A job row's accounting state, as seen by the reconciliation pass.
pub struct AccountingRowState {
pub state: String,
/// True when the row is missing metadata that a proper `record_job_start`
/// would have populated (e.g. `record_job_end` created a bare row from
/// scratch because `record_job_start` never landed). A row in this shape
/// needs a `record_job_start` backfill even if `state` already matches.
pub needs_start_backfill: bool,
}
/// The accounting DB's current state for a batch of jobs, in a single query.
/// Jobs with no row in `jobs` are simply absent from the returned map. Used
/// by the reconciliation pass to detect jobs missing or stale in accounting.
pub async fn job_accounting_states(
pool: &PgPool,
job_ids: &[i32],
) -> anyhow::Result<HashMap<i32, AccountingRowState>> {
if job_ids.is_empty() {
return Ok(HashMap::new());
}
let rows =
sqlx::query("SELECT job_id, state, user_name, start_time FROM jobs WHERE job_id = ANY($1)")
.bind(job_ids)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| {
let job_id: i32 = r.get("job_id");
let user_name: String = r.get("user_name");
let start_time: Option<DateTime<Utc>> = r.get("start_time");
let row = AccountingRowState {
state: r.get("state"),
needs_start_backfill: user_name.is_empty() || start_time.is_none(),
};
(job_id, row)
})
.collect())
}
/// Update usage accounting for a completed job, from the row `record_job_end` just wrote.
async fn update_usage(
conn: &mut PgConnection,
row: PgRow,
end_time: DateTime<Utc>,
) -> anyhow::Result<()> {
let user: String = row.get("user_name");
let account: String = row.get("account");
let start_time: Option<DateTime<Utc>> = row.get("start_time");
let Some(start_time) = start_time else {
// End arrived before start; usage will be computed when start lands.
return Ok(());
};
let num_tasks: i32 = row.get("num_tasks");
let cpus_per_task: i32 = row.get("cpus_per_task");
let duration_secs = (end_time - start_time).num_seconds().max(0);
let cpu_seconds = duration_secs * (num_tasks as i64) * (cpus_per_task as i64);
// Truncate to hourly period for aggregation
let period_start = start_time
.date_naive()
.and_hms_opt(start_time.hour(), 0, 0)
.unwrap()
.and_utc();
let period_end = period_start + chrono::Duration::hours(1);
sqlx::query(
r#"
INSERT INTO usage (user_name, account, period_start, period_end, cpu_seconds, job_count)
VALUES ($1, $2, $3, $4, $5, 1)
ON CONFLICT (user_name, account, period_start) DO UPDATE SET
cpu_seconds = usage.cpu_seconds + $5,
job_count = usage.job_count + 1
"#,
)
.bind(&user)
.bind(&account)
.bind(period_start)
.bind(period_end)
.bind(cpu_seconds)
.execute(&mut *conn)
.await?;
Ok(())
}
/// Job record returned from history queries.
#[derive(Debug)]
pub struct JobRecord {
pub job_id: i32,
pub name: String,
pub user_name: String,
pub account: String,
pub partition: String,
pub state: String,
pub exit_code: i32,
pub exit_signal: i32,
pub derived_exit_code: i32,
pub num_nodes: i32,
pub num_tasks: i32,
pub nodelist: String,
pub submit_time: DateTime<Utc>,
pub start_time: Option<DateTime<Utc>>,
pub end_time: Option<DateTime<Utc>>,
pub reservation: String,
}
/// Query job history.
pub async fn get_job_history(
pool: &PgPool,
user: Option<&str>,
account: Option<&str>,
start_after: Option<DateTime<Utc>>,
start_before: Option<DateTime<Utc>>,
states: &[String],
limit: u32,
) -> anyhow::Result<Vec<JobRecord>> {
let mut qb = QueryBuilder::<sqlx::Postgres>::new(
"SELECT job_id, name, user_name, account, partition_name, state, exit_code, \
exit_signal, derived_exit_code, num_nodes, num_tasks, nodelist, \
submit_time, start_time, end_time, reservation \
FROM jobs WHERE 1=1",
);
if let Some(u) = user.filter(|u| !u.is_empty()) {
qb.push(" AND user_name = ").push_bind(u);
}
if let Some(a) = account.filter(|a| !a.is_empty()) {
qb.push(" AND account = ").push_bind(a);
}
if let Some(after) = start_after {
qb.push(" AND start_time >= ").push_bind(after);
}
if let Some(before) = start_before {
qb.push(" AND start_time <= ").push_bind(before);
}
if !states.is_empty() {
qb.push(" AND state IN (");
let mut sep = qb.separated(", ");
for s in states {
sep.push_bind(s.clone());
}
sep.push_unseparated(")");
}
qb.push(" ORDER BY submit_time DESC");
let effective_limit: i64 = if limit > 0 { limit.into() } else { 1000 };
qb.push(" LIMIT ").push_bind(effective_limit);
let rows = qb.build().fetch_all(pool).await?;
let records = rows
.iter()
.map(|row| JobRecord {
job_id: row.get("job_id"),
name: row.get("name"),
user_name: row.get("user_name"),
account: row.get("account"),
partition: row.get("partition_name"),
state: row.get("state"),
exit_code: row.get("exit_code"),
exit_signal: row.get("exit_signal"),
derived_exit_code: row.get("derived_exit_code"),
num_nodes: row.get("num_nodes"),
num_tasks: row.get("num_tasks"),
nodelist: row.get("nodelist"),
submit_time: row.get("submit_time"),
start_time: row.get("start_time"),
end_time: row.get("end_time"),
reservation: row.get("reservation"),
})
.collect();
Ok(records)
}
/// Get usage data for fair-share calculation.
pub async fn get_usage(
pool: &PgPool,
user: Option<&str>,
account: Option<&str>,
since: DateTime<Utc>,
) -> anyhow::Result<Vec<UsageRecord>> {
let rows = sqlx::query(
r#"
SELECT user_name, account,
SUM(cpu_seconds)::BIGINT as total_cpu_seconds,
SUM(gpu_seconds)::BIGINT as total_gpu_seconds,
SUM(job_count)::BIGINT as total_jobs,
period_start
FROM usage
WHERE period_start >= $1
AND ($2::text IS NULL OR user_name = $2)
AND ($3::text IS NULL OR account = $3)
GROUP BY user_name, account, period_start
ORDER BY period_start
"#,
)
.bind(since)
.bind(user)
.bind(account)
.fetch_all(pool)
.await?;
let records = rows
.iter()
.map(|row| UsageRecord {
user_name: row.get("user_name"),
account: row.get("account"),
cpu_seconds: row.get::<i64, _>("total_cpu_seconds"),
gpu_seconds: row.get::<i64, _>("total_gpu_seconds"),
job_count: row.get::<i64, _>("total_jobs") as u64,
period_start: row.get("period_start"),
})
.collect();
Ok(records)
}
#[derive(Debug)]
pub struct UsageRecord {
pub user_name: String,
pub account: String,
pub cpu_seconds: i64,
pub gpu_seconds: i64,
pub job_count: u64,
pub period_start: DateTime<Utc>,
}
use chrono::Timelike;
// ============================================================
// Account / User / QOS management (sacctmgr operations)
// ============================================================
/// A single bound value in a dynamically built accounting write. The variant
/// carries the concrete type so one builder can mix columns; the `Null*`
/// variants bind SQL `NULL` for `None`, which is how a nullable column is
/// cleared.
#[derive(Clone, Copy)]
enum SqlVal<'a> {
Text(&'a str),
NullText(Option<&'a str>),
Int(i32),
NullInt(Option<i32>),
Real(f64),
}
fn push_bound(qb: &mut QueryBuilder<sqlx::Postgres>, val: SqlVal<'_>) {
match val {
SqlVal::Text(v) => qb.push_bind(v),
SqlVal::NullText(v) => qb.push_bind(v),
SqlVal::Int(v) => qb.push_bind(v),
SqlVal::NullInt(v) => qb.push_bind(v),
SqlVal::Real(v) => qb.push_bind(v),
};
}
/// The tables `upsert_row` can target. A closed set of variants (not a free
/// `&str`) so the table name and ON CONFLICT target spliced into the SQL text
/// can only ever be known literals — no caller can route user input into a
/// SQL identifier position, even by mistake in future edits.
#[derive(Clone, Copy)]
enum UpsertTable {
Accounts,
Qos,
}
impl UpsertTable {
fn name(self) -> &'static str {
match self {
UpsertTable::Accounts => "accounts",
UpsertTable::Qos => "qos",
}
}
fn conflict_target(self) -> &'static str {
match self {
UpsertTable::Accounts | UpsertTable::Qos => "name",
}
}
}
/// Insert `keys` + `updates`, updating only `updates` columns on conflict
/// (identity `keys` never overwritten; absent columns keep their stored value,
/// take the schema default on insert — the partial-patch contract). No `updates`
/// = create-if-absent. Table is a closed enum, columns `&'static str` — injection-safe.
async fn upsert_row(
pool: &PgPool,
table: UpsertTable,
keys: &[(&'static str, SqlVal<'_>)],
updates: &[(&'static str, SqlVal<'_>)],
) -> anyhow::Result<()> {
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new("INSERT INTO ");
qb.push(table.name()).push(" (");
let mut first = true;
for (col, _) in keys.iter().chain(updates.iter()) {
if !first {
qb.push(", ");
}
first = false;
qb.push(*col);
}
qb.push(") VALUES (");
first = true;
for (_, val) in keys.iter().chain(updates.iter()) {
if !first {
qb.push(", ");
}
first = false;
push_bound(&mut qb, *val);
}
qb.push(") ON CONFLICT (")
.push(table.conflict_target())
.push(")");
if updates.is_empty() {
qb.push(" DO NOTHING");
} else {
qb.push(" DO UPDATE SET ");
first = true;
for (col, _) in updates {
if !first {
qb.push(", ");
}
first = false;
qb.push(*col).push(" = EXCLUDED.").push(*col);
}
}
qb.build().execute(pool).await?;
Ok(())
}
/// Partial-patch fields for [`upsert_account`]. Outer `None` leaves the column
/// unchanged (partial patch on modify); for nullable columns the inner `None`
/// clears it to SQL `NULL`.
#[derive(Default)]
pub struct AccountUpdate<'a> {
pub description: Option<&'a str>,
pub organization: Option<&'a str>,
pub parent: Option<Option<&'a str>>,
pub fairshare: Option<i32>,
pub max_running_jobs: Option<Option<i32>>,
pub grp_tres: Option<Option<&'a str>>,
}
/// Create or update an account, writing only the fields set in `u`. `modify`
/// sends just the restated fields, so unset columns are preserved; `add` sets
/// all of them.
pub async fn upsert_account<'a>(
pool: &PgPool,
name: &'a str,
u: AccountUpdate<'a>,
) -> anyhow::Result<()> {
let keys = [("name", SqlVal::Text(name))];
let mut updates: Vec<(&'static str, SqlVal)> = Vec::new();
if let Some(v) = u.description {
updates.push(("description", SqlVal::Text(v)));
}
if let Some(v) = u.organization {
updates.push(("organization", SqlVal::Text(v)));
}
if let Some(v) = u.parent {
updates.push(("parent_account", SqlVal::NullText(v)));
}
if let Some(v) = u.fairshare {
updates.push(("fairshare_weight", SqlVal::Int(v)));
}
if let Some(v) = u.max_running_jobs {
updates.push(("max_running_jobs", SqlVal::NullInt(v)));
}
if let Some(v) = u.grp_tres {
updates.push(("grp_tres", SqlVal::NullText(v)));
}
upsert_row(pool, UpsertTable::Accounts, &keys, &updates).await
}
/// Delete an account.
pub async fn delete_account(pool: &PgPool, name: &str) -> anyhow::Result<()> {
sqlx::query("DELETE FROM accounts WHERE name = $1")
.bind(name)
.execute(pool)
.await?;
Ok(())
}
/// List all accounts.
pub async fn list_accounts(pool: &PgPool) -> anyhow::Result<Vec<AccountRecord>> {
let rows = sqlx::query(
"SELECT name, description, organization, parent_account, fairshare_weight, max_running_jobs, grp_tres FROM accounts ORDER BY name"
).fetch_all(pool).await?;
Ok(rows
.iter()
.map(|r| AccountRecord {
name: r.get("name"),
description: r.get("description"),
organization: r.get("organization"),
parent: r.get("parent_account"),
fairshare_weight: r.get("fairshare_weight"),
max_running_jobs: r.get("max_running_jobs"),
grp_tres: r.get("grp_tres"),
})
.collect())
}
#[derive(Debug)]
pub struct AccountRecord {
pub name: String,
pub description: String,
pub organization: String,
pub parent: Option<String>,
pub fairshare_weight: i32,
pub max_running_jobs: Option<i32>,
/// Account resource allocation as a TRES string; None = unlimited.
pub grp_tres: Option<String>,
}
/// Update the partition-less association's columns, inserting the row if none
/// exists. `partition_name` is nullable (NULL != NULL), so `ON CONFLICT` can't
/// dedupe; the caller's per-user advisory lock serializes concurrent `add_user`s
/// so two can't double-insert. (`remove_user` takes no lock but only deletes.)
async fn upsert_association(
conn: &mut PgConnection,
user: &str,
account: &str,
updates: &[(&'static str, SqlVal<'_>)],
) -> anyhow::Result<()> {
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new("UPDATE associations SET ");
let mut first = true;
for (col, val) in updates {
if !first {
qb.push(", ");
}
first = false;
qb.push(*col).push(" = ");
push_bound(&mut qb, *val);
}
qb.push(" WHERE user_name = ").push_bind(user);
qb.push(" AND account = ").push_bind(account);
qb.push(" AND (partition_name IS NULL OR partition_name = '')");
let updated = qb.build().execute(&mut *conn).await?;
if updated.rows_affected() > 0 {
return Ok(());
}
let mut qb: QueryBuilder<sqlx::Postgres> =
QueryBuilder::new("INSERT INTO associations (user_name, account");
for (col, _) in updates {
qb.push(", ").push(*col);
}
qb.push(") VALUES (");
qb.push_bind(user).push(", ").push_bind(account);
for (_, val) in updates {
qb.push(", ");
push_bound(&mut qb, *val);
}
qb.push(")");
qb.build().execute(&mut *conn).await?;
Ok(())
}
/// Partial-patch fields for [`add_user`]. Outer `None` leaves the field
/// unchanged (partial patch on modify); for nullable columns the inner `None`
/// clears it. Numeric limits use `None` (not 0) for "no limit", matching how
/// `list_associations`/`AssociationCache` read an unset limit back out.
#[derive(Default)]
pub struct UserUpdate<'a> {
pub admin_level: Option<&'a str>,
pub is_default: Option<bool>,
pub default_qos: Option<Option<&'a str>>,
pub allowed_qos: Option<Option<&'a str>>,
pub max_running_jobs: Option<Option<i32>>,
pub max_submit_jobs: Option<Option<i32>>,
pub max_tres_per_job: Option<Option<&'a str>>,
pub grp_tres: Option<Option<&'a str>>,
pub max_wall_min: Option<Option<i32>>,
}
/// Add or modify a user-account association, writing only the fields set in `u`
/// (partial patch). `is_default`: `Some(true)` makes this the default (demoting the
/// user's others), `Some(false)` clears it, `None` preserves it but still defaults a
/// brand-new user's first account. Limits/QOS live in a separate association row.
pub async fn add_user<'a>(
pool: &PgPool,
user: &'a str,
account: &'a str,
u: UserUpdate<'a>,
) -> anyhow::Result<()> {
// Per-user advisory lock so concurrent modifies of two different accounts
// for the same user serialize — otherwise both could win the demote race
// below and end up default. Cheap: add_user is admin-path.
let mut tx = pool.begin().await?;
sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1)::bigint)")
.bind(user)
.execute(&mut *tx)
.await?;
// Clear the default on the user's other rows *before* the upsert sets it
// here, so the `one_default_account_per_user` unique index never sees two
// non-null default_account rows mid-transaction.
if u.is_default == Some(true) {
sqlx::query(
"UPDATE users SET default_account = NULL \
WHERE name = $1 AND account <> $2 AND default_account IS NOT NULL",
)
.bind(user)
.bind(account)
.execute(&mut *tx)
.await?;
}
// Partial-patch: COALESCE/CASE keep stored admin_level/default_account when
// omitted ($3/$4 NULL); a brand-new user's first row still claims the default.
sqlx::query(
r#"
INSERT INTO users (name, account, admin_level, default_account)
VALUES ($1, $2, COALESCE($3, 'none'), CASE
WHEN $4::bool THEN $2
WHEN $4::bool IS NULL AND NOT EXISTS (
SELECT 1 FROM users WHERE name = $1 AND default_account IS NOT NULL
) THEN $2
END)
ON CONFLICT (name, account) DO UPDATE SET
admin_level = COALESCE($3, users.admin_level),
default_account = CASE
WHEN $4::bool IS NULL THEN users.default_account
WHEN $4::bool THEN $2
ELSE NULL
END
"#,
)
.bind(user)
.bind(account)
.bind(u.admin_level)
.bind(u.is_default)
.execute(&mut *tx)
.await?;
let mut assoc: Vec<(&'static str, SqlVal)> = Vec::new();
if let Some(v) = u.default_qos {
assoc.push(("default_qos", SqlVal::NullText(v)));
}
if let Some(v) = u.allowed_qos {
assoc.push(("allowed_qos", SqlVal::NullText(v)));
}
if let Some(v) = u.max_running_jobs {
assoc.push(("max_running_jobs", SqlVal::NullInt(v)));
}
if let Some(v) = u.max_submit_jobs {
assoc.push(("max_submit_jobs", SqlVal::NullInt(v)));
}
if let Some(v) = u.max_tres_per_job {
assoc.push(("max_tres_per_job", SqlVal::NullText(v)));
}
if let Some(v) = u.grp_tres {
assoc.push(("grp_tres", SqlVal::NullText(v)));
}
if let Some(v) = u.max_wall_min {
assoc.push(("max_wall_min", SqlVal::NullInt(v)));
}
// Touch the association row (limits/QOS) only when a field was restated.
if !assoc.is_empty() {
upsert_association(&mut tx, user, account, &assoc).await?;
}
tx.commit().await?;
Ok(())
}
/// Remove a user from one account, or every account when `account` is empty.
pub async fn remove_user(pool: &PgPool, user: &str, account: &str) -> anyhow::Result<u64> {
let mut tx = pool.begin().await?;
let associations =
sqlx::query("DELETE FROM associations WHERE user_name = $1 AND ($2 = '' OR account = $2)")
.bind(user)
.bind(account)
.execute(&mut *tx)
.await?;
let users = sqlx::query("DELETE FROM users WHERE name = $1 AND ($2 = '' OR account = $2)")
.bind(user)
.bind(account)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(associations.rows_affected() + users.rows_affected())
}
/// List users, joining each one's own association row for `default_qos`/
/// `allowed_qos`. `DISTINCT ON ... a.id DESC` picks the newest row if legacy
/// duplicates exist (pre-dating the add_user upsert fix); it never touches
/// the others.
pub async fn list_users(
pool: &PgPool,
account: Option<&str>,
user: Option<&str>,
) -> anyhow::Result<Vec<UserRecord>> {
let rows = sqlx::query(
r#"
SELECT DISTINCT ON (u.name, u.account)
u.name, u.account, u.admin_level, u.default_account,
a.default_qos, a.allowed_qos
FROM users u
LEFT JOIN associations a
ON a.user_name = u.name AND a.account = u.account
AND (a.partition_name IS NULL OR a.partition_name = '')
WHERE ($1::TEXT IS NULL OR u.account = $1)
AND ($2::TEXT IS NULL OR u.name = $2)
ORDER BY u.name, u.account, a.id DESC NULLS LAST
"#,
)
.bind(account)
.bind(user)
.fetch_all(pool)
.await?;
Ok(rows
.iter()
.map(|r| UserRecord {
name: r.get("name"),
account: r.get("account"),
admin_level: r.get("admin_level"),
default_account: r.get("default_account"),
default_qos: r.get("default_qos"),
allowed_qos: r.get("allowed_qos"),
})
.collect())
}
#[derive(Debug)]
pub struct UserRecord {
pub name: String,
pub account: String,
pub admin_level: String,
pub default_account: Option<String>,
pub default_qos: Option<String>,
pub allowed_qos: Option<String>,
}
/// List every user-account association's resource limits, one row per
/// partition-less association — the row the scheduler's admission check
/// enforces against. `DISTINCT ON ... id DESC` mirrors `list_users`: it
/// picks the newest row if legacy duplicates exist.
pub async fn list_associations(pool: &PgPool) -> anyhow::Result<Vec<AssociationRecord>> {
let rows = sqlx::query(
r#"
SELECT DISTINCT ON (user_name, account)
user_name, account, max_running_jobs, max_submit_jobs,
max_tres_per_job, grp_tres, max_wall_min
FROM associations
WHERE partition_name IS NULL OR partition_name = ''
ORDER BY user_name, account, id DESC
"#,
)
.fetch_all(pool)
.await?;
Ok(rows
.iter()
.map(|r| AssociationRecord {
user_name: r.get("user_name"),
account: r.get("account"),
max_running_jobs: r.get("max_running_jobs"),
max_submit_jobs: r.get("max_submit_jobs"),
max_tres_per_job: r.get("max_tres_per_job"),
grp_tres: r.get("grp_tres"),
max_wall_min: r.get("max_wall_min"),
})
.collect())
}
#[derive(Debug)]
pub struct AssociationRecord {
pub user_name: String,
pub account: String,
pub max_running_jobs: Option<i32>,
pub max_submit_jobs: Option<i32>,
pub max_tres_per_job: Option<String>,
pub grp_tres: Option<String>,
pub max_wall_min: Option<i32>,
}
/// Partial-patch fields for [`upsert_qos`]. Outer `None` leaves the column
/// unchanged (partial patch on modify); for nullable columns the inner `None`
/// clears it to SQL `NULL`.
#[derive(Default)]
pub struct QosUpdate<'a> {
pub description: Option<&'a str>,
pub priority: Option<i32>,
pub preempt_mode: Option<&'a str>,
pub usage_factor: Option<f64>,
pub max_jobs_per_user: Option<Option<i32>>,
pub max_wall_min: Option<Option<i32>>,
pub max_tres_per_job: Option<Option<&'a str>>,
pub max_submit_per_user: Option<Option<i32>>,
pub max_tres_per_user: Option<Option<&'a str>>,
pub grp_tres: Option<Option<&'a str>>,
pub grp_wall_min: Option<Option<i32>>,
}
/// Create or update a QOS, writing only the fields set in `u`. `modify` sends
/// just the restated fields, so unset columns are preserved; `add` sets all of
/// them.
pub async fn upsert_qos<'a>(pool: &PgPool, name: &'a str, u: QosUpdate<'a>) -> anyhow::Result<()> {
let keys = [("name", SqlVal::Text(name))];
let mut updates: Vec<(&'static str, SqlVal)> = Vec::new();
if let Some(v) = u.description {
updates.push(("description", SqlVal::Text(v)));
}
if let Some(v) = u.priority {
updates.push(("priority", SqlVal::Int(v)));
}
if let Some(v) = u.preempt_mode {