Source code for simstack.core.services.runner_cleanup_service
import logging
import os
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from simstack.core.context import context
from simstack.models.files import FileStack
from simstack.models.parameters import Resource
from simstack.models.runner_model import RunnerEvent, RunnerType
from simstack.core.services.base_service import BaseService
logger = logging.getLogger("NodeRunner")
def _resource_to_str(resource: Any) -> str:
if hasattr(resource, "resource_str"):
return str(getattr(resource, "resource_str"))
raw = getattr(resource, "__dict__", {}).get("value")
if raw is not None:
return str(raw)
return str(resource)
def _cache_ttl_days() -> int:
raw = os.environ.get("SIMSTACK_FILE_CACHE_TTL_DAYS")
if raw is None:
return 14
try:
value = int(raw)
except ValueError:
return 14
return max(value, 0)
def _cache_cleanup_enabled() -> bool:
return os.environ.get("SIMSTACK_FILE_CACHE_CLEANUP_ENABLED", "1").lower() not in {
"0",
"false",
"no",
}
def _as_naive_datetime(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value
return value.astimezone().replace(tzinfo=None)
[docs]
class RunnerCleanupService(BaseService):
"""
Service that cleans up old RunnerEvent logs and expired local file cache
copies for the current resource.
"""
def __init__(self, resource: Resource, interval: int = 300) -> None:
# Default interval 5 minutes
super().__init__("RunnerCleanup", resource, interval)
[docs]
async def execute(self) -> None:
cutoff_time = datetime.now() - timedelta(minutes=30)
# Find and delete events matching the criteria
old_events = await context.db.find(
RunnerEvent,
(RunnerEvent.runner_type == RunnerType.RESOURCE_RUNNER)
& (RunnerEvent.resource == self._resource)
& (RunnerEvent.timestamp < cutoff_time),
)
if old_events:
logger.info(
f"Cleaning up {len(old_events)} old RunnerEvent logs for resource {self._resource}"
)
for event in old_events:
await context.db.delete(event)
if _cache_cleanup_enabled():
await self._cleanup_file_cache()
async def _cleanup_file_cache(self) -> None:
ttl_days = _cache_ttl_days()
cutoff_time = datetime.now() - timedelta(days=ttl_days)
resource_name = _resource_to_str(self._resource)
workdir = Path(context.config.workdir).resolve()
file_stacks = await context.db.find_all(FileStack)
updated_count = 0
deleted_count = 0
for file_stack in file_stacks:
changed = False
for location in file_stack.locations or []:
if _resource_to_str(location.resource) != resource_name:
continue
if getattr(location, "location_type", "local_path") != "local_path":
continue
if getattr(location, "status", "available") != "available":
continue
if not getattr(location, "is_cached", False):
continue
accessed_at = _as_naive_datetime(
getattr(location, "last_accessed_at", None) or location.created_at
)
if accessed_at and accessed_at > cutoff_time:
continue
path = Path(location.path)
resolved_path = path if path.is_absolute() else workdir / path
try:
resolved_path = resolved_path.resolve()
if (
workdir != resolved_path
and workdir not in resolved_path.parents
):
logger.warning(
"Skipping cached FileInstance outside workdir: file_stack=%s path=%s",
file_stack.id,
resolved_path,
)
continue
if resolved_path.exists():
resolved_path.unlink()
deleted_count += 1
location.status = "deleted"
location.expires_at = datetime.now()
changed = True
except Exception as exc:
logger.warning(
"Failed to clean cached FileInstance file_stack=%s path=%s: %s",
file_stack.id,
resolved_path,
exc,
)
if changed:
await context.db.save(file_stack)
updated_count += 1
if updated_count:
logger.info(
"Cleaned file cache for resource %s: marked %s FileStack(s), deleted %s file(s)",
resource_name,
updated_count,
deleted_count,
)