From 56378f07b22c1b630f92e0dfe1e7780f21b7a8ca Mon Sep 17 00:00:00 2001 From: aditya-786 <42476079+aditya-786@users.noreply.github.com> Date: Sun, 5 Jul 2026 10:39:46 +0530 Subject: [PATCH] feat: time-based alert retention policy Delete alerts older than a configured number of days, together with their incident links, so the database stops growing forever. Disabled unless KEEP_ALERT_RETENTION_DAYS is set to a positive value. --- docs/deployment/configuration.mdx | 14 ++ keep/api/api.py | 25 +++- keep/api/arq_worker.py | 3 + keep/api/consts.py | 7 + keep/api/core/db.py | 64 ++++++++- keep/api/tasks/process_retention_task.py | 76 +++++++++++ tests/test_alert_retention.py | 160 +++++++++++++++++++++++ 7 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 keep/api/tasks/process_retention_task.py create mode 100644 tests/test_alert_retention.py diff --git a/docs/deployment/configuration.mdx b/docs/deployment/configuration.mdx index e70738df53..4c825b2bfc 100644 --- a/docs/deployment/configuration.mdx +++ b/docs/deployment/configuration.mdx @@ -349,6 +349,20 @@ These endpoints are rate-limited according to the `KEEP_LIMIT_CONCURRENCY` setti | **MAINTENANCE_WINDOW_STRATEGY** | Choose the strategy | No | "default" | "default" or "recover_previous_status" | | **WATCHER_LAPSED_TIME** | Time in seconds to execute the alert review | No | 60 | Valid positive integer | +### Alert Retention + + + Alert retention deletes alerts older than the configured number of days from + the database, together with their incident links. It is disabled unless + KEEP_ALERT_RETENTION_DAYS is set to a positive value. + + +| Env var | Purpose | Required | Default Value | Valid options | +| :-----------------------------------: | :----------------------------------------------------------: | :---------------------: | :-----------: | :-----------------------------------------------: | +| **KEEP_ALERT_RETENTION_DAYS** | Delete alerts older than this number of days (0 disables it) | No | 0 | Valid positive integer | +| **KEEP_ALERT_RETENTION_INTERVAL** | Time in seconds between retention runs | No | 86400 | Valid positive integer | +| **KEEP_ALERT_RETENTION_BATCH_SIZE** | Number of alerts deleted per batch | No | 1000 | Valid positive integer | + ## Frontend Environment Variables diff --git a/keep/api/api.py b/keep/api/api.py index 4ae2072457..626585056e 100644 --- a/keep/api/api.py +++ b/keep/api/api.py @@ -25,7 +25,7 @@ from keep.api.arq_pool import get_pool import keep.api.logging import keep.api.observability -from keep.api.tasks import process_watcher_task +from keep.api.tasks import process_retention_task, process_watcher_task import keep.api.utils.import_ee from keep.api.core.config import config from keep.api.core.db import dispose_session @@ -67,7 +67,7 @@ IdentityManagerTypes, ) from keep.topologies.topology_processor import TopologyProcessor -from keep.api.consts import KEEP_ARQ_QUEUE_MAINTENANCE, MAINTENANCE_WINDOW_ALERT_STRATEGY, REDIS +from keep.api.consts import KEEP_ALERT_RETENTION_DAYS, KEEP_ARQ_QUEUE_MAINTENANCE, MAINTENANCE_WINDOW_ALERT_STRATEGY, REDIS # load all providers into cache from keep.workflowmanager.workflowmanager import WorkflowManager @@ -187,6 +187,27 @@ async def startup(): "task": "task", }, ) + + if KEEP_ALERT_RETENTION_DAYS > 0: + if REDIS: + try: + logger.info("Starting the alert retention process") + redis: ArqRedis = await get_pool() + job = await redis.enqueue_job( + "async_process_retention", + _queue_name=KEEP_ARQ_QUEUE_MAINTENANCE, + ) + logger.info( + "Enqueued job", + extra={ + "job_id": job.job_id, + "queue": KEEP_ARQ_QUEUE_MAINTENANCE, + }, + ) + except Exception: + logger.exception("Failed to start the alert retention process") + else: + asyncio.create_task(process_retention_task.async_process_retention()) logger.info("Services started successfully") diff --git a/keep/api/arq_worker.py b/keep/api/arq_worker.py index f7550d0907..4567d5e697 100644 --- a/keep/api/arq_worker.py +++ b/keep/api/arq_worker.py @@ -14,6 +14,7 @@ import keep.api.logging from keep.api.consts import ( + KEEP_ALERT_RETENTION_DAYS, KEEP_ARQ_QUEUE_BASIC, KEEP_ARQ_TASK_POOL, KEEP_ARQ_TASK_POOL_ALL, @@ -158,6 +159,8 @@ class WorkerSettings: timeout = 30 functions: list = FUNCTIONS cron_jobs: list = [cron("keep.api.tasks.process_watcher_task.async_process_watcher", second=max(0, WATCHER_LAPSED_TIME-1))] + if KEEP_ALERT_RETENTION_DAYS > 0: + cron_jobs.append(cron("keep.api.tasks.process_retention_task.async_process_retention", hour=2, minute=0)) queue_name: str health_check_interval: int = 10 health_check_key: str diff --git a/keep/api/consts.py b/keep/api/consts.py index f2f8ce9e00..a8cb3fed53 100644 --- a/keep/api/consts.py +++ b/keep/api/consts.py @@ -32,6 +32,13 @@ "MAINTENANCE_WINDOW_STRATEGY", "default" ) # recover_previous_status or default WATCHER_LAPSED_TIME = int(os.environ.get("KEEP_WATCHER_LAPSED_TIME", 60)) # in seconds +KEEP_ALERT_RETENTION_DAYS = int(os.environ.get("KEEP_ALERT_RETENTION_DAYS", 0)) +KEEP_ALERT_RETENTION_INTERVAL = int( + os.environ.get("KEEP_ALERT_RETENTION_INTERVAL", 86400) +) # in seconds +KEEP_ALERT_RETENTION_BATCH_SIZE = int( + os.environ.get("KEEP_ALERT_RETENTION_BATCH_SIZE", 1000) +) ### # Set ARQ_TASK_POOL_TO_EXECUTE to "none", "all", "basic_processing" or "ai" # to split the tasks between the workers. diff --git a/keep/api/core/db.py b/keep/api/core/db.py index 878d0d877b..6e26eeb0c0 100644 --- a/keep/api/core/db.py +++ b/keep/api/core/db.py @@ -5989,4 +5989,66 @@ def recover_prev_alert_status(alert: Alert, session: Optional[Session] = None): ) ) session.exec(query) - session.commit() \ No newline at end of file + session.commit() + + +@retry_on_db_error +def delete_alerts_by_retention( + tenant_id: str, + purge_before: datetime, + batch_size: int = 1000, + session: Optional[Session] = None, +) -> int: + total_deleted = 0 + with existed_or_new_session(session) as session: + while True: + alert_ids = session.exec( + select(Alert.id) + .where( + Alert.tenant_id == tenant_id, + Alert.timestamp < purge_before, + ) + .limit(batch_size) + ).all() + if not alert_ids: + break + + fingerprints = session.exec( + select(LastAlert.fingerprint).where( + LastAlert.tenant_id == tenant_id, + LastAlert.alert_id.in_(alert_ids), + ) + ).all() + + if fingerprints: + session.query(LastAlertToIncident).filter( + LastAlertToIncident.tenant_id == tenant_id, + LastAlertToIncident.fingerprint.in_(fingerprints), + ).delete(synchronize_session=False) + + session.query(LastAlert).filter( + LastAlert.tenant_id == tenant_id, + LastAlert.alert_id.in_(alert_ids), + ).delete(synchronize_session=False) + + session.query(AlertToIncident).filter( + AlertToIncident.tenant_id == tenant_id, + AlertToIncident.alert_id.in_(alert_ids), + ).delete(synchronize_session=False) + + deleted = ( + session.query(Alert) + .filter(Alert.id.in_(alert_ids)) + .delete(synchronize_session=False) + ) + session.commit() + total_deleted += deleted + logger.info( + "Deleted batch of alerts by retention", + extra={ + "tenant_id": tenant_id, + "deleted": deleted, + "total_deleted": total_deleted, + }, + ) + return total_deleted \ No newline at end of file diff --git a/keep/api/tasks/process_retention_task.py b/keep/api/tasks/process_retention_task.py new file mode 100644 index 0000000000..23da54dd8d --- /dev/null +++ b/keep/api/tasks/process_retention_task.py @@ -0,0 +1,76 @@ +import asyncio +import datetime +import logging + +import redis +from filelock import FileLock, Timeout + +from keep.api.consts import ( + KEEP_ALERT_RETENTION_BATCH_SIZE, + KEEP_ALERT_RETENTION_DAYS, + KEEP_ALERT_RETENTION_INTERVAL, + REDIS, +) +from keep.api.core.db import delete_alerts_by_retention, get_tenants + +logger = logging.getLogger(__name__) + + +def process_retention(logger): + if KEEP_ALERT_RETENTION_DAYS <= 0: + return + purge_before = datetime.datetime.utcnow() - datetime.timedelta( + days=KEEP_ALERT_RETENTION_DAYS + ) + for tenant in get_tenants(): + deleted = delete_alerts_by_retention( + tenant.id, purge_before, KEEP_ALERT_RETENTION_BATCH_SIZE + ) + if deleted: + logger.info( + "Deleted alerts by retention policy", + extra={"tenant_id": tenant.id, "deleted": deleted}, + ) + + +async def async_process_retention(*args): + if REDIS: + ctx = args[0] + redis_instance: redis.Redis = ctx.get("redis") + lock_key = "lock:retention:process" + lock_acquired = await redis_instance.set(lock_key, "1", ex=3600, nx=True) + if not lock_acquired: + logger.info("Retention process is already running, skipping this run.") + return + logger.info("Retention process started, acquiring lock.") + try: + loop = asyncio.get_running_loop() + await loop.run_in_executor(ctx.get("pool"), process_retention, logger) + except Exception as e: + logger.error("Error in retention process: %s", e, exc_info=True) + raise + finally: + await redis_instance.delete(lock_key) + logger.info("Retention process completed and lock released.") + else: + while True: + init_time = datetime.datetime.now() + try: + with FileLock( + "/tmp/retention_process.lock", + timeout=KEEP_ALERT_RETENTION_INTERVAL // 2, + ): + logger.info("Retention process started, acquiring lock.") + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, process_retention, logger) + complete_time = datetime.datetime.now() + await asyncio.sleep( + max( + 0, + KEEP_ALERT_RETENTION_INTERVAL + - (complete_time - init_time).total_seconds(), + ) + ) + logger.info("Retention process completed.") + except Timeout: + logger.info("Retention process is already running, skipping this run.") diff --git a/tests/test_alert_retention.py b/tests/test_alert_retention.py new file mode 100644 index 0000000000..b11ae87297 --- /dev/null +++ b/tests/test_alert_retention.py @@ -0,0 +1,160 @@ +from datetime import datetime, timedelta + +from keep.api.core.db import delete_alerts_by_retention +from keep.api.core.dependencies import SINGLE_TENANT_UUID +from keep.api.models.db.alert import ( + Alert, + AlertToIncident, + LastAlert, + LastAlertToIncident, +) +from keep.api.models.db.incident import Incident + + +def _create_alert(db_session, fingerprint, timestamp): + alert = Alert( + tenant_id=SINGLE_TENANT_UUID, + provider_type="test", + provider_id="test", + event={"name": fingerprint, "fingerprint": fingerprint}, + fingerprint=fingerprint, + timestamp=timestamp, + ) + db_session.add(alert) + db_session.commit() + return alert + + +def _create_last_alert(db_session, alert): + last_alert = LastAlert( + tenant_id=SINGLE_TENANT_UUID, + fingerprint=alert.fingerprint, + alert_id=alert.id, + timestamp=alert.timestamp, + first_timestamp=alert.timestamp, + ) + db_session.add(last_alert) + db_session.commit() + return last_alert + + +def _create_incident(db_session): + incident = Incident( + tenant_id=SINGLE_TENANT_UUID, + user_generated_name="test-incident", + user_summary="test", + generated_summary="test", + ) + db_session.add(incident) + db_session.commit() + return incident + + +def test_delete_alerts_by_retention_purges_old_alerts(db_session): + incident = _create_incident(db_session) + old_timestamp = datetime.utcnow() - timedelta(days=100) + + for i in range(3): + alert = _create_alert(db_session, f"old-{i}", old_timestamp) + _create_last_alert(db_session, alert) + db_session.add( + AlertToIncident( + tenant_id=SINGLE_TENANT_UUID, + alert_id=alert.id, + incident_id=incident.id, + ) + ) + db_session.add( + LastAlertToIncident( + tenant_id=SINGLE_TENANT_UUID, + fingerprint=alert.fingerprint, + incident_id=incident.id, + ) + ) + db_session.commit() + + for i in range(2): + alert = _create_alert(db_session, f"fresh-{i}", datetime.utcnow()) + _create_last_alert(db_session, alert) + + purge_before = datetime.utcnow() - timedelta(days=30) + deleted = delete_alerts_by_retention( + SINGLE_TENANT_UUID, purge_before, batch_size=2, session=db_session + ) + + assert deleted == 3 + remaining_alerts = db_session.query(Alert).all() + assert sorted(alert.fingerprint for alert in remaining_alerts) == [ + "fresh-0", + "fresh-1", + ] + remaining_last_alerts = db_session.query(LastAlert).all() + assert sorted(last_alert.fingerprint for last_alert in remaining_last_alerts) == [ + "fresh-0", + "fresh-1", + ] + assert db_session.query(AlertToIncident).count() == 0 + assert db_session.query(LastAlertToIncident).count() == 0 + + +def test_delete_alerts_by_retention_keeps_active_fingerprint(db_session): + old_alert = _create_alert( + db_session, "service-down", datetime.utcnow() - timedelta(days=100) + ) + fresh_alert = _create_alert(db_session, "service-down", datetime.utcnow()) + _create_last_alert(db_session, fresh_alert) + old_alert_id = old_alert.id + fresh_alert_id = fresh_alert.id + + purge_before = datetime.utcnow() - timedelta(days=30) + deleted = delete_alerts_by_retention( + SINGLE_TENANT_UUID, purge_before, session=db_session + ) + + assert deleted == 1 + assert old_alert_id != fresh_alert_id + remaining_alerts = db_session.query(Alert).all() + assert [alert.id for alert in remaining_alerts] == [fresh_alert_id] + remaining_last_alerts = db_session.query(LastAlert).all() + assert [last_alert.fingerprint for last_alert in remaining_last_alerts] == [ + "service-down" + ] + + +def test_delete_alerts_by_retention_removes_soft_deleted_incident_links(db_session): + incident = _create_incident(db_session) + alert = _create_alert(db_session, "stale", datetime.utcnow() - timedelta(days=100)) + _create_last_alert(db_session, alert) + db_session.add( + LastAlertToIncident( + tenant_id=SINGLE_TENANT_UUID, + fingerprint=alert.fingerprint, + incident_id=incident.id, + deleted_at=datetime.utcnow() - timedelta(days=50), + ) + ) + db_session.commit() + + purge_before = datetime.utcnow() - timedelta(days=30) + deleted = delete_alerts_by_retention( + SINGLE_TENANT_UUID, purge_before, session=db_session + ) + + assert deleted == 1 + assert db_session.query(Alert).count() == 0 + assert db_session.query(LastAlert).count() == 0 + assert db_session.query(LastAlertToIncident).count() == 0 + + +def test_delete_alerts_by_retention_noop_when_nothing_expired(db_session): + alert = _create_alert(db_session, "fresh", datetime.utcnow()) + _create_last_alert(db_session, alert) + + purge_before = datetime.utcnow() - timedelta(days=30) + deleted = delete_alerts_by_retention( + SINGLE_TENANT_UUID, purge_before, session=db_session + ) + + assert deleted == 0 + assert db_session.query(Alert).count() == 1 + assert db_session.query(LastAlert).count() == 1