Skip to content

Commit a2c06a0

Browse files
spa-rajNiveditJain
andauthored
feat: add TTL-based cleanup strategy for DatabaseTriggers collection (#464)
* feat: add TTL-based cleanup strategy for DatabaseTriggers collection Implements automatic cleanup of completed/failed triggers using MongoDB TTL index to prevent unbounded growth of the DatabaseTriggers collection. Changes: - Added expires_at field to DatabaseTriggers model for TTL tracking - Created MongoDB TTL index on expires_at with expireAfterSeconds=0 - Added TRIGGER_RETENTION_DAYS setting (default: 30 days, configurable via env) - Updated mark_as_triggered() to set expiration time on completed triggers - Updated mark_as_failed() to set expiration time on failed triggers - PENDING/TRIGGERING triggers remain without expiration (never cleaned up) Implementation: - MongoDB automatically deletes documents when expires_at timestamp is reached - TTL runs in background every 60 seconds (MongoDB default) - Retention period configurable via TRIGGER_RETENTION_DAYS environment variable - Only terminal states (TRIGGERED, FAILED) are marked for cleanup Tests: - Added comprehensive unit tests for TTL expiration logic - Tests verify expires_at is set correctly for both TRIGGERED and FAILED states - Tests verify custom retention periods are respected - All 4 new tests passing Also bumped python-sdk version to 0.0.3b2 Resolves #433 * perf: optimize settings access and fix timezone handling for TTL Performance optimization: - Fetch settings once at trigger_cron() level instead of per-trigger - Pass retention_days as parameter to mark functions - Prevents repeated environment variable reads in high-volume scenarios - Eliminates performance degradation when processing triggers in loops Timezone fix (critical): - Use timezone-aware datetime.now(timezone.utc) instead of naive datetime.now() - Prevents shifted expirations on non-UTC hosts - PyMongo treats naive datetimes as UTC, causing incorrect TTL windows - All expires_at timestamps now explicitly UTC for consistency CANCELLED trigger cleanup: - Added mark_as_cancelled() function for future cancellation logic - CANCELLED triggers now expire under same retention policy as TRIGGERED/FAILED - Ensures all terminal states (TRIGGERED, FAILED, CANCELLED) are cleaned up Test improvements: - Refactored using pytest.mark.parametrize to reduce duplication - Added timezone-awareness assertions to verify UTC timestamps - Combined similar tests for all 3 terminal states - Reduced test code from ~120 lines to ~85 lines while increasing coverage - 6 parameterized tests passing (3 states × 2 test scenarios) Signed-off-by: Sparsh <sparsh.raj30@gmail.com> * docs: add docstring to mark_as_cancelled and improve test coverage - Add comprehensive docstring to mark_as_cancelled() explaining it's reserved for future cancellation feature implementation - Rename test_trigger_ttl.py to test_trigger_cron.py to match module name - Expand parametrized tests: test all 3 mark functions with 3 retention periods (9 combinations) for consistent coverage - Add 9 new tests covering all trigger_cron.py functions: - get_due_triggers (with/without triggers) - call_trigger_graph - create_next_triggers (success, DuplicateKeyError, other exceptions) - handle_trigger (success and failure paths) - trigger_cron orchestration - Total: 18 comprehensive tests with timezone-aware datetime assertions Signed-off-by: Sparsh <sparsh.raj30@gmail.com> * Removed Cancelled state from partial index Signed-off-by: Sparsh <sparsh.raj30@gmail.com> * refactor: update trigger status filtering and add expiration logic - Changed the partial filter expression in DatabaseTriggers to exclude PENDING and TRIGGERING statuses. - Introduced an expires_at field in create_crons to set expiration time for triggers based on retention settings, ensuring proper cleanup of triggers after their designated retention period. This enhances the management of trigger states and ensures that only relevant triggers are retained in the database. * chore: update codespell ignore words list - Added "nin" to the list of ignored words in .codespellignorewords. - Ensured consistency by maintaining the existing format of the file. * nin->in * fix: update trigger retention hours to 720 - Changed the default value of trigger_retention_hours from 24 to 720 in settings.py to extend the retention period for triggers, allowing for better management of trigger states. * docs: add TRIGGER_WORKERS and TRIGGER_RETENTION_HOURS to state manager setup - Updated the state manager setup documentation to include new environment variables: TRIGGER_WORKERS (default: 1) and TRIGGER_RETENTION_HOURS (default: 720) for better configuration of trigger management. * feat: add initialization tasks for server startup - Introduced a new module for initialization tasks that run when the server starts. - Implemented a task to delete old triggers from the DatabaseTriggers collection based on their status. - Updated the main application file to call the new initialization tasks during the lifespan of the FastAPI app, ensuring proper cleanup of outdated triggers at startup. * feat: enhance create_next_triggers with expiration logic - Updated create_next_triggers function to accept retention_hours as a parameter. - Introduced expires_at calculation to set expiration time for triggers based on retention settings. - Ensured proper handling of trigger creation with expiration in the database, improving trigger management. * docs: update MongoDB database name in state manager setup - Changed the default value of MONGO_DATABASE_NAME from 'exosphere' to 'exosphere-state-manager' in the state manager setup documentation for clarity and accuracy in configuration. * fix: update trigger retention hours in tests and settings - Changed the default value of trigger_retention_hours in settings.py from 24 to 720 to extend the retention period for triggers. - Updated test cases in test_main.py and test_trigger_cron.py to reflect the new retention_hours parameter in create_next_triggers function, ensuring consistency in trigger management across the application. --------- Signed-off-by: Sparsh <sparsh.raj30@gmail.com> Co-authored-by: NiveditJain <nivedit@aikin.club>
1 parent 2747e24 commit a2c06a0

10 files changed

Lines changed: 389 additions & 21 deletions

File tree

.github/.codespellignorewords

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ YML
1414
SDK
1515
S3
1616
Kusto
17-
NotIn
17+
NotIn
18+
nin

docs/docs/exosphere/state-manager-setup.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ The Exosphere state manager is the core backend service that handles workflow ex
2929
-e MONGO_DATABASE_NAME="your-database-name" \
3030
-e STATE_MANAGER_SECRET="your-secret-key" \
3131
-e SECRETS_ENCRYPTION_KEY="your-base64-encoded-encryption-key" \
32+
-e TRIGGER_WORKERS="1" \
33+
-e TRIGGER_RETENTION_HOURS="720" \
3234
ghcr.io/exospherehost/exosphere-state-manager:latest
3335
```
3436

@@ -84,6 +86,8 @@ The Exosphere state manager is the core backend service that handles workflow ex
8486
export MONGO_DATABASE_NAME="your-database-name"
8587
export STATE_MANAGER_SECRET="your-secret-key"
8688
export SECRETS_ENCRYPTION_KEY="your-base64-encoded-encryption-key"
89+
export TRIGGER_WORKERS="1"
90+
export TRIGGER_RETENTION_HOURS="720"
8791
```
8892

8993
4. **Run the state manager**:
@@ -151,6 +155,8 @@ The state manager uri and key would be configured accordingly while setting up n
151155
- MONGO_DATABASE_NAME=${MONGO_DATABASE_NAME}
152156
- STATE_MANAGER_SECRET=${STATE_MANAGER_SECRET}
153157
- SECRETS_ENCRYPTION_KEY=${SECRETS_ENCRYPTION_KEY}
158+
- TRIGGER_WORKERS=${TRIGGER_WORKERS:-1}
159+
- TRIGGER_RETENTION_HOURS=${TRIGGER_RETENTION_HOURS:-720}
154160
deploy:
155161
replicas: 3
156162
update_config:
@@ -182,6 +188,8 @@ The state manager uri and key would be configured accordingly while setting up n
182188
data:
183189
MONGO_URI: "your-mongodb-connection-string"
184190
MONGO_DATABASE_NAME: "your-database-name"
191+
TRIGGER_WORKERS: "1"
192+
TRIGGER_RETENTION_HOURS: "720"
185193
LOG_LEVEL: "INFO"
186194
```
187195

@@ -212,9 +220,11 @@ The state manager uri and key would be configured accordingly while setting up n
212220
| Variable | Description | Required | Default |
213221
|----------|-------------|----------|---------|
214222
| `MONGO_URI` | MongoDB connection string | Yes | - |
215-
| `MONGO_DATABASE_NAME` | Database name | Yes | `exosphere` |
223+
| `MONGO_DATABASE_NAME` | Database name | Yes | `exosphere-state-manager` |
216224
| `STATE_MANAGER_SECRET` | Secret API key for authentication | Yes | - |
217225
| `SECRETS_ENCRYPTION_KEY` | Base64-encoded key for data encryption | Yes | - |
226+
| `TRIGGER_WORKERS` | Number of workers to run the trigger cron | No | `1` |
227+
| `TRIGGER_RETENTION_HOURS` | Number of hours to retain completed/failed triggers before cleanup | No | `720` (30 days) |
218228
| `LOG_LEVEL` | Logging level (DEBUG, INFO, WARNING, ERROR) | No | `INFO` |
219229

220230
## Monitoring and Health Checks

state-manager/app/config/settings.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66

77
class Settings(BaseModel):
88
"""Application settings loaded from environment variables."""
9-
9+
1010
# MongoDB Configuration
1111
mongo_uri: str = Field(..., description="MongoDB connection URI" )
1212
mongo_database_name: str = Field(default="exosphere-state-manager", description="MongoDB database name")
1313
state_manager_secret: str = Field(..., description="Secret key for API authentication")
1414
secrets_encryption_key: str = Field(..., description="Key for encrypting secrets")
1515
trigger_workers: int = Field(default=1, description="Number of workers to run the trigger cron")
16+
trigger_retention_hours: int = Field(default=720, description="Number of hours to retain completed/failed triggers before cleanup")
1617

1718
@classmethod
1819
def from_env(cls) -> "Settings":
@@ -21,7 +22,8 @@ def from_env(cls) -> "Settings":
2122
mongo_database_name=os.getenv("MONGO_DATABASE_NAME", "exosphere-state-manager"), # type: ignore
2223
state_manager_secret=os.getenv("STATE_MANAGER_SECRET"), # type: ignore
2324
secrets_encryption_key=os.getenv("SECRETS_ENCRYPTION_KEY"), # type: ignore
24-
trigger_workers=int(os.getenv("TRIGGER_WORKERS", 1)) # type: ignore
25+
trigger_workers=int(os.getenv("TRIGGER_WORKERS", 1)), # type: ignore
26+
trigger_retention_hours=int(os.getenv("TRIGGER_RETENTION_HOURS", 720)) # type: ignore
2527
)
2628

2729

state-manager/app/main.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
from apscheduler.schedulers.asyncio import AsyncIOScheduler
3939
from apscheduler.triggers.cron import CronTrigger
4040
from .tasks.trigger_cron import trigger_cron
41+
42+
# init tasks
43+
from .tasks.init_tasks import init_tasks
4144

4245
# Define models list
4346
DOCUMENT_MODELS = [State, GraphTemplate, RegisteredNode, Store, Run, DatabaseTriggers]
@@ -59,6 +62,10 @@ async def lifespan(app: FastAPI):
5962
await init_beanie(db, document_models=DOCUMENT_MODELS)
6063
logger.info("beanie dbs initialized")
6164

65+
# performing init tasks
66+
await init_tasks()
67+
logger.info("init tasks completed")
68+
6269
# initialize secret
6370
if not settings.state_manager_secret:
6471
raise ValueError("STATE_MANAGER_SECRET is not set")

state-manager/app/models/db/trigger.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ class DatabaseTriggers(Document):
1313
namespace: str = Field(..., description="Namespace of the graph")
1414
trigger_time: datetime = Field(..., description="Trigger time of the trigger")
1515
trigger_status: TriggerStatusEnum = Field(..., description="Status of the trigger")
16+
expires_at: Optional[datetime] = Field(default=None, description="Expiration time for automatic cleanup of completed triggers")
1617

17-
class Settings:
18+
class Settings:
1819
indexes = [
1920
IndexModel(
2021
[
@@ -32,5 +33,20 @@ class Settings:
3233
],
3334
name="uniq_graph_type_expr_time",
3435
unique=True
36+
),
37+
IndexModel(
38+
[
39+
("expires_at", 1),
40+
],
41+
name="ttl_expires_at",
42+
expireAfterSeconds=0, # Delete immediately when expires_at is reached
43+
partialFilterExpression={
44+
"trigger_status": {
45+
"$in": [
46+
TriggerStatusEnum.TRIGGERED,
47+
TriggerStatusEnum.FAILED
48+
]
49+
}
50+
}
3551
)
3652
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# tasks to run when the server starts
2+
from app.models.db.trigger import DatabaseTriggers
3+
from app.models.trigger_models import TriggerStatusEnum
4+
import asyncio
5+
6+
async def delete_old_triggers():
7+
await DatabaseTriggers.get_pymongo_collection().delete_many(
8+
{
9+
"trigger_status": {
10+
"$in": [TriggerStatusEnum.TRIGGERED, TriggerStatusEnum.FAILED]
11+
},
12+
"expires_at": None
13+
}
14+
)
15+
16+
async def init_tasks():
17+
await asyncio.gather(
18+
*[
19+
delete_old_triggers()
20+
])

state-manager/app/tasks/trigger_cron.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime
1+
from datetime import datetime, timedelta, timezone
22
from uuid import uuid4
33
from app.models.db.trigger import DatabaseTriggers
44
from app.models.trigger_models import TriggerStatusEnum, TriggerTypeEnum
@@ -34,18 +34,24 @@ async def call_trigger_graph(trigger: DatabaseTriggers):
3434
x_exosphere_request_id=str(uuid4())
3535
)
3636

37-
async def mark_as_failed(trigger: DatabaseTriggers):
37+
async def mark_as_failed(trigger: DatabaseTriggers, retention_hours: int):
38+
expires_at = datetime.now(timezone.utc) + timedelta(hours=retention_hours)
39+
3840
await DatabaseTriggers.get_pymongo_collection().update_one(
3941
{"_id": trigger.id},
40-
{"$set": {"trigger_status": TriggerStatusEnum.FAILED}}
42+
{"$set": {
43+
"trigger_status": TriggerStatusEnum.FAILED,
44+
"expires_at": expires_at
45+
}}
4146
)
4247

43-
async def create_next_triggers(trigger: DatabaseTriggers, cron_time: datetime):
48+
async def create_next_triggers(trigger: DatabaseTriggers, cron_time: datetime, retention_hours: int):
4449
assert trigger.expression is not None
4550
iter = croniter.croniter(trigger.expression, trigger.trigger_time)
4651

4752
while True:
4853
next_trigger_time = iter.get_next(datetime)
54+
expires_at = next_trigger_time + timedelta(hours=retention_hours)
4955

5056
try:
5157
await DatabaseTriggers(
@@ -54,7 +60,8 @@ async def create_next_triggers(trigger: DatabaseTriggers, cron_time: datetime):
5460
graph_name=trigger.graph_name,
5561
namespace=trigger.namespace,
5662
trigger_time=next_trigger_time,
57-
trigger_status=TriggerStatusEnum.PENDING
63+
trigger_status=TriggerStatusEnum.PENDING,
64+
expires_at=expires_at
5865
).insert()
5966
except DuplicateKeyError:
6067
logger.error(f"Duplicate trigger found for expression {trigger.expression}")
@@ -65,24 +72,30 @@ async def create_next_triggers(trigger: DatabaseTriggers, cron_time: datetime):
6572
if next_trigger_time > cron_time:
6673
break
6774

68-
async def mark_as_triggered(trigger: DatabaseTriggers):
75+
async def mark_as_triggered(trigger: DatabaseTriggers, retention_hours: int):
76+
expires_at = datetime.now(timezone.utc) + timedelta(hours=retention_hours)
77+
6978
await DatabaseTriggers.get_pymongo_collection().update_one(
7079
{"_id": trigger.id},
71-
{"$set": {"trigger_status": TriggerStatusEnum.TRIGGERED}}
80+
{"$set": {
81+
"trigger_status": TriggerStatusEnum.TRIGGERED,
82+
"expires_at": expires_at
83+
}}
7284
)
7385

74-
async def handle_trigger(cron_time: datetime):
86+
async def handle_trigger(cron_time: datetime, retention_hours: int):
7587
while(trigger:= await get_due_triggers(cron_time)):
7688
try:
7789
await call_trigger_graph(trigger)
78-
await mark_as_triggered(trigger)
90+
await mark_as_triggered(trigger, retention_hours)
7991
except Exception as e:
80-
await mark_as_failed(trigger)
92+
await mark_as_failed(trigger, retention_hours)
8193
logger.error(f"Error calling trigger graph: {e}")
8294
finally:
83-
await create_next_triggers(trigger, cron_time)
95+
await create_next_triggers(trigger, cron_time, retention_hours)
8496

8597
async def trigger_cron():
8698
cron_time = datetime.now()
99+
settings = get_settings()
87100
logger.info(f"starting trigger_cron: {cron_time}")
88-
await asyncio.gather(*[handle_trigger(cron_time) for _ in range(get_settings().trigger_workers)])
101+
await asyncio.gather(*[handle_trigger(cron_time, settings.trigger_retention_hours) for _ in range(settings.trigger_workers)])

state-manager/app/tasks/verify_graph.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010
from app.singletons.logs_manager import LogsManager
1111
from app.models.trigger_models import TriggerStatusEnum, TriggerTypeEnum
1212
from app.models.db.trigger import DatabaseTriggers
13+
from app.config.settings import get_settings
14+
from datetime import timedelta
1315

1416
logger = LogsManager().get_logger()
1517

18+
settings = get_settings()
19+
1620
async def verify_node_exists(graph_template: GraphTemplate, registered_nodes: list[RegisteredNode]) -> list[str]:
1721
errors = []
1822
template_nodes_set = set([(node.node_name, node.namespace) for node in graph_template.nodes])
@@ -110,6 +114,7 @@ async def create_crons(graph_template: GraphTemplate):
110114
iter = croniter.croniter(expression, current_time)
111115

112116
next_trigger_time = iter.get_next(datetime)
117+
expires_at = next_trigger_time + timedelta(hours=settings.trigger_retention_hours)
113118

114119
new_db_triggers.append(
115120
DatabaseTriggers(
@@ -118,7 +123,8 @@ async def create_crons(graph_template: GraphTemplate):
118123
graph_name=graph_template.name,
119124
namespace=graph_template.namespace,
120125
trigger_status=TriggerStatusEnum.PENDING,
121-
trigger_time=next_trigger_time
126+
trigger_time=next_trigger_time,
127+
expires_at=expires_at
122128
)
123129
)
124130

0 commit comments

Comments
 (0)