Skip to content

Commit 093044b

Browse files
authored
init slack integration (#18)
1 parent 8ef78ba commit 093044b

4 files changed

Lines changed: 94 additions & 6 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ S3_BUCKET_NAME=
3535
S3_DEFAULT_DEPLOY_BUCKET=
3636
JOB_MONITOR_INTERVAL=5
3737

38+
# slack webhook for notifications
39+
# SLACK_WEBHOOK_URL=
40+
3841
## development flags ##
3942
# enable the job monitor that updates the database. deploy seperately for prod.
4043
DEV_LOCAL_JOB_MONITOR=True

app/core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class Settings(BaseSettings):
4848
MONGODB_DATABASE: str = "default"
4949
# job monitor
5050
JOB_MONITOR_INTERVAL: int = 2
51+
SLACK_WEBHOOK_URL: str | None = None
5152
DEV_LOCAL_JOB_MONITOR: bool = False
5253
AWS_JOB_SYNC_INTERVAL: int = 60
5354
# aws configuration

app/core/monitor.py

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77
from app.core.config import settings
88
from app.database.db import db_manager
99
from app.schemas.db_schemas import JobStatus
10-
from app.schemas.kubeflow_schemas import KubeflowStatusEnum, TrainingJobStatus
10+
from app.schemas.kubeflow_schemas import (
11+
KubeflowStatusEnum,
12+
TrainingJobStatus,
13+
DatabaseStatusEnum,
14+
)
1115
from app.utils.kf_config import kubeflow_api
1216
from app.utils.S3Handler import s3_handler
1317
from app.utils.kueue_helpers import get_kueue_queue, get_kubeflow_queue
18+
from app.utils.slack_helpers import send_slack_notification
1419

1520
logger = logging.getLogger(__name__)
1621

@@ -19,6 +24,8 @@ class JobMonitor:
1924
def __init__(self):
2025
self.stop_monitoring = False
2126
self.monitoring_task = None
27+
self.active_jobs = set()
28+
self.notified_queued_jobs = set()
2229

2330
async def _get_queue_info(self) -> dict[str, int]:
2431
"""Get queue information with fallback"""
@@ -128,13 +135,33 @@ async def monitor_jobs(self):
128135
while not self.stop_monitoring:
129136
try:
130137
# Get current state from Kubeflow API
131-
jobs = kubeflow_api.list_jobs(namespace=settings.NAMESPACE)
132-
queue_positions = await self._get_queue_info()
138+
api_jobs = {
139+
job.metadata.name: job
140+
for job in kubeflow_api.list_jobs(namespace=settings.NAMESPACE)
141+
}
142+
current_job_ids = set(api_jobs.keys())
143+
144+
# Find jobs that have disappeared
145+
disappeared_jobs = self.active_jobs - current_job_ids
146+
for job_id in disappeared_jobs:
147+
job_info = await db_manager.get_job(job_id)
148+
if (
149+
job_info
150+
and job_info.status not in TrainingJobStatus.running_states
151+
):
152+
logger.warning(
153+
f"Job {job_id} disappeared from API, probably canceled by user."
154+
)
155+
await send_slack_notification(
156+
f"Job `{job_info.status}`: `{job_info.job_name} ({job_id})` by `{job_info.user_id}`\nModel: `{job_info.model_name}` | Started: `{job_info.created_at}`"
157+
)
158+
self.notified_queued_jobs.discard(job_id)
133159

134-
for job in jobs:
135-
job_id = job.metadata.name
160+
self.active_jobs = current_job_ids
161+
queue_positions = await self._get_queue_info()
136162

137-
if not job.status.conditions:
163+
for job_id, job in api_jobs.items():
164+
if not job.status or not job.status.conditions:
138165
logger.warning(f"Job conditions not ready for {job_id}")
139166
await asyncio.sleep(0.1)
140167
continue
@@ -160,6 +187,31 @@ async def monitor_jobs(self):
160187
logger.info(
161188
f"Job {job_id} status changed from {prev_job_info.status} to {status}"
162189
)
190+
# Notify if job in queue
191+
if (
192+
TrainingJobStatus.map_status(status)
193+
== DatabaseStatusEnum.queued
194+
and job_id not in self.notified_queued_jobs
195+
):
196+
await send_slack_notification(
197+
f"Job `{TrainingJobStatus.map_status(status)}`: `{prev_job_info.job_name} ({job_id})` by `{prev_job_info.user_id}`\nModel: `{prev_job_info.model_name}` | Queue Position: `{queue_positions.get(job_id, 'N/A')}`"
198+
)
199+
self.notified_queued_jobs.add(job_id)
200+
# Notify on start
201+
elif (
202+
status == KubeflowStatusEnum.running
203+
and prev_job_info.status.lower()
204+
!= KubeflowStatusEnum.running.lower()
205+
):
206+
await send_slack_notification(
207+
f"Job `{TrainingJobStatus.map_status(status)}`: `{prev_job_info.job_name} ({job_id})` by `{prev_job_info.user_id}`\nModel: `{prev_job_info.model_name}` | Started: `{prev_job_info.created_at}`"
208+
)
209+
# Notify on stop
210+
elif status in TrainingJobStatus.stopped_states:
211+
await send_slack_notification(
212+
f"Job `{TrainingJobStatus.map_status(status)}`: `{prev_job_info.job_name} ({job_id})` by `{prev_job_info.user_id}`\nModel: `{prev_job_info.model_name}` | Started: `{prev_job_info.created_at}` | Ended: `{job.status.completion_time}`"
213+
)
214+
self.notified_queued_jobs.discard(job_id)
163215

164216
# Update status in database
165217
job_info = await self._update_job_status(
@@ -188,6 +240,7 @@ async def monitor_jobs(self):
188240
logger.error(
189241
f"Job {job_id} failed. Manual investigation required."
190242
)
243+
self.active_jobs.discard(job_id)
191244

192245
except Exception as e:
193246
logger.error(f"Error in job monitoring loop: {e}", exc_info=True)

app/utils/slack_helpers.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import httpx
2+
import logging
3+
4+
from app.core.config import settings
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
async def send_slack_notification(message: str):
10+
"""
11+
Sends a notification to a Slack channel via a webhook.
12+
"""
13+
if not settings.SLACK_WEBHOOK_URL:
14+
logger.debug("SLACK_WEBHOOK_URL not set, skipping notification.")
15+
return
16+
17+
try:
18+
async with httpx.AsyncClient() as client:
19+
response = await client.post(
20+
settings.SLACK_WEBHOOK_URL,
21+
json={"text": message},
22+
timeout=10.0,
23+
)
24+
response.raise_for_status()
25+
logger.debug(f"Slack notification sent successfully: {message}")
26+
except httpx.RequestError as e:
27+
logger.error(f"Error sending Slack notification: {e}")
28+
except Exception as e:
29+
logger.error(
30+
f"An unexpected error occurred while sending a Slack notification: {e}"
31+
)

0 commit comments

Comments
 (0)