Skip to content

Commit 3af69a5

Browse files
committed
Feat(aws): Sentinel/scaling script
1 parent 356c1a8 commit 3af69a5

1 file changed

Lines changed: 135 additions & 0 deletions

File tree

aws/sentinel.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#! /usr/bin/env python3
2+
3+
"""For running things overnight, throttles the number of jobs running in parallel
4+
if things start going south.
5+
"""
6+
7+
import time
8+
from datetime import UTC, datetime, timedelta
9+
from typing import Any
10+
11+
import boto3
12+
from pydantic import BaseModel
13+
14+
from codeclash.utils.log import get_logger
15+
16+
17+
class StatusData(BaseModel):
18+
n_jobs_running: int
19+
n_jobs_failed_last_2h: int
20+
"""2h is relative to job start time, not submission time."""
21+
n_jobs_succeeded_last_2h: int
22+
"""2h is relative to job start time, not submission time."""
23+
n_jobs_runnable: int
24+
25+
@property
26+
def n_jobs_completed_last_2h(self) -> int:
27+
return self.n_jobs_failed_last_2h + self.n_jobs_succeeded_last_2h
28+
29+
@property
30+
def failed_ratio(self) -> float:
31+
if self.n_jobs_failed_last_2h + self.n_jobs_succeeded_last_2h == 0:
32+
return 0.0
33+
return self.n_jobs_failed_last_2h / (self.n_jobs_failed_last_2h + self.n_jobs_succeeded_last_2h)
34+
35+
36+
def get_status_data() -> StatusData:
37+
"""Collect AWS Batch status for the last 2 hours."""
38+
batch: Any = boto3.client("batch", region_name="us-east-1")
39+
40+
def list_jobs_count(status: str) -> tuple[int, list[dict[str, Any]]]:
41+
paginator = batch.get_paginator("list_jobs")
42+
pages = paginator.paginate(jobQueue="codeclash-queue", jobStatus=status)
43+
jobs: list[dict[str, Any]] = []
44+
for page in pages:
45+
jobs.extend(page.get("jobSummaryList", []))
46+
return len(jobs), jobs
47+
48+
n_running, _ = list_jobs_count("RUNNING")
49+
n_runnable, _ = list_jobs_count("RUNNABLE")
50+
51+
since = datetime.now(UTC) - timedelta(hours=2)
52+
since_ms = int(since.timestamp() * 1000)
53+
54+
def count_completed_recent(status: str) -> int:
55+
_, jobs = list_jobs_count(status)
56+
count = 0
57+
for j in jobs:
58+
# Prefer startedAt if present; fall back to createdAt
59+
started_at = j.get("startedAt") or j.get("createdAt")
60+
if isinstance(started_at, datetime):
61+
ts_ms = int(started_at.timestamp() * 1000)
62+
else:
63+
ts_ms = int(started_at) if started_at is not None else 0
64+
if ts_ms >= since_ms:
65+
count += 1
66+
return count
67+
68+
n_failed_recent = count_completed_recent("FAILED")
69+
n_succeeded_recent = count_completed_recent("SUCCEEDED")
70+
71+
return StatusData(
72+
n_jobs_running=n_running,
73+
n_jobs_failed_last_2h=n_failed_recent,
74+
n_jobs_succeeded_last_2h=n_succeeded_recent,
75+
n_jobs_runnable=n_runnable,
76+
)
77+
78+
79+
def set_max_vcpus(vcpus: int, logger: Any = None) -> None:
80+
batch: Any = boto3.client("batch", region_name="us-east-1")
81+
# See aws/setup/batch/environment.json for the environment name and defaults
82+
response = batch.update_compute_environment(
83+
computeEnvironment="codeclash-batch",
84+
computeResources={"maxvCpus": int(vcpus)},
85+
)
86+
if logger:
87+
logger.info(f"Update response: {response}")
88+
# Verify the update
89+
env = batch.describe_compute_environments(computeEnvironments=["codeclash-batch"])
90+
status = env["computeEnvironments"][0]["status"]
91+
current_max = env["computeEnvironments"][0]["computeResources"].get("maxvCpus")
92+
logger.info(f"Compute environment status: {status}, current maxvCpus in config: {current_max}")
93+
94+
95+
class AutoScaler:
96+
def __init__(self, max_vcpus: int = 40 * 4):
97+
self.default_max_vcpus = max_vcpus
98+
self.current_max_vcpus = max_vcpus
99+
self.logger = get_logger("AutoScaler", emoji="🔄")
100+
101+
def get_scale_factor(self, status_data: StatusData) -> float:
102+
if status_data.n_jobs_completed_last_2h >= 5:
103+
if status_data.failed_ratio > 0.2:
104+
return 0.0
105+
elif status_data.failed_ratio > 0.1:
106+
return 0.5
107+
elif status_data.failed_ratio > 0.05:
108+
return 0.75
109+
return 1.0
110+
# Run at least 1 vCPU, but otherwise don't change anything
111+
return max(4, self.current_max_vcpus / self.default_max_vcpus)
112+
113+
def run(self) -> None:
114+
self.logger.info(f"Starting AutoScaler with default max vCPUs: {self.default_max_vcpus}")
115+
set_max_vcpus(self.default_max_vcpus, logger=self.logger)
116+
self.current_max_vcpus = self.default_max_vcpus
117+
while True:
118+
try:
119+
status_data = get_status_data()
120+
print(status_data)
121+
scale_factor = self.get_scale_factor(status_data)
122+
new_max_vcpus = int(self.default_max_vcpus * scale_factor)
123+
if new_max_vcpus != self.current_max_vcpus:
124+
self.logger.info(f"Scaling from {self.current_max_vcpus} to {new_max_vcpus}")
125+
set_max_vcpus(new_max_vcpus, logger=self.logger)
126+
self.current_max_vcpus = new_max_vcpus
127+
except Exception as e:
128+
self.logger.error(f"Error scaling: {e}", exc_info=True)
129+
finally:
130+
time.sleep(600)
131+
132+
133+
if __name__ == "__main__":
134+
scaler = AutoScaler()
135+
scaler.run()

0 commit comments

Comments
 (0)