-
Notifications
You must be signed in to change notification settings - Fork 2
feat: PAUSED+PAGED app tier — freeze idle apps, page to swap, wake in ms (#81) #145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 9 commits
979eb49
3be7a5f
695421b
22df263
e0d8e26
58bdb30
4c0fff1
0028bc5
8a4cb16
41ecc27
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,32 @@ | ||
| # DO NOT MODIFY - copied from freeshard-controller | ||
|
|
||
| import datetime | ||
| from typing import Dict, List, Optional | ||
|
|
||
| from pydantic import BaseModel | ||
|
|
||
|
|
||
| class PauseTierTelemetry(BaseModel): | ||
| """Metrics from the shard_core PAUSED+PAGED app tier (FreeshardBase/freeshard#81). | ||
|
|
||
| Sent by shards with the pause tier enabled; absent (None) from older shards | ||
| and from shards with the feature flag off. | ||
| """ | ||
|
|
||
| # per-app transition counts, e.g. {"immich": {"running_to_paused": 3, "paused_to_running": 2}} | ||
| transitions: Dict[str, Dict[str, int]] = {} | ||
| pause_latency_ms_p50: Optional[float] = None | ||
| pause_latency_ms_p95: Optional[float] = None | ||
| unpause_latency_ms_p50: Optional[float] = None | ||
| unpause_latency_ms_p95: Optional[float] = None | ||
| # PSI `some avg10` sampled at each control cycle within the reporting interval | ||
| psi_some_avg10_snapshots: List[float] = [] | ||
| swap_total_kib: Optional[int] = None | ||
| swap_free_kib: Optional[int] = None | ||
|
|
||
|
|
||
| class Telemetry(BaseModel): | ||
| start_time: datetime.datetime | ||
| end_time: datetime.datetime | ||
| no_of_requests: int | ||
| pause_tier: Optional[PauseTierTelemetry] = None |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,25 +1,31 @@ | ||
| import asyncio | ||
| import logging | ||
| import time | ||
| from typing import Dict | ||
| from typing import Dict, List | ||
|
|
||
| from shard_core.database.connection import db_conn | ||
| from shard_core.database import installed_apps as db_installed_apps | ||
| from shard_core.data_model.app_meta import InstalledApp, Status | ||
| from shard_core.service import disk | ||
| from shard_core.service import disk, memory_pressure, pause_metrics | ||
| from shard_core.service.app_tools import ( | ||
| docker_start_app, | ||
| docker_stop_app, | ||
| docker_pause_app, | ||
| docker_unpause_app, | ||
| get_app_metadata, | ||
| size_is_compatible, | ||
| ) | ||
| from shard_core.settings import settings | ||
| from shard_core.util import signals | ||
|
|
||
| log = logging.getLogger(__name__) | ||
|
|
||
| last_access_dict: Dict[str, float] = dict() | ||
| background_tasks = set() | ||
|
|
||
| # Pressure demotion never touches an app accessed within this window (seconds). | ||
| RECENT_ACCESS_GRACE = 5 | ||
|
|
||
|
|
||
| @signals.on_request_to_app.connect | ||
| async def ensure_app_is_running(app: InstalledApp): | ||
|
|
@@ -29,38 +35,103 @@ async def ensure_app_is_running(app: InstalledApp): | |
| if await size_is_compatible(app_meta.minimum_portal_size): | ||
| global last_access_dict | ||
| last_access_dict[app.name] = time.time() | ||
| task = asyncio.create_task( | ||
| docker_start_app(app.name), name=f"ensure {app.name} is running" | ||
| ) | ||
| # PAUSED wakes via unpause (ms to ~2s), everything else via the legacy | ||
| # start path. Calling docker_unpause_app directly also dodges the | ||
| # global 5s throttle on docker_start_app, which would silently drop | ||
| # the wake. This dispatch stays active even with pause_enabled=false, | ||
| # so already-paused apps still wake after a backout. | ||
| if app.status == Status.PAUSED: | ||
| coro = docker_unpause_app(app.name) | ||
| else: | ||
| coro = docker_start_app(app.name) | ||
| task = asyncio.create_task(coro, name=f"ensure {app.name} is running") | ||
| background_tasks.add(task) | ||
| task.add_done_callback(background_tasks.discard) | ||
|
|
||
|
|
||
| async def control_apps(): | ||
| lifecycle_settings = settings().apps.lifecycle | ||
| pause_enabled = lifecycle_settings.pause_enabled | ||
| psi = memory_pressure.read_memory_pressure() if pause_enabled else 0.0 | ||
| pressure_high = pause_enabled and psi > lifecycle_settings.psi_threshold | ||
| if pause_enabled: | ||
| pause_metrics.record_psi_snapshot(psi) | ||
|
|
||
| async with db_conn() as conn: | ||
| all_apps = await db_installed_apps.get_all(conn) | ||
| installed_apps = [ | ||
| InstalledApp.model_validate(a) | ||
| for a in all_apps | ||
| if a["status"] not in (Status.INSTALLATION_QUEUED, Status.INSTALLING) | ||
| ] | ||
| tasks = [_control_app(app.name) for app in installed_apps] | ||
| tasks = [_control_app_time(app, pause_enabled) for app in installed_apps] | ||
| await asyncio.gather(*tasks) | ||
| if pressure_high: | ||
| log.info(f"memory pressure high (PSI some avg10 = {psi}), demoting LRU app") | ||
| await _demote_lru(installed_apps) | ||
|
|
||
|
|
||
| async def _control_app(name: str): | ||
| global last_access_dict | ||
| app_meta = get_app_metadata(name) | ||
| async def _control_app_time(app: InstalledApp, pause_enabled: bool): | ||
| app_meta = get_app_metadata(app.name) | ||
|
|
||
| if app_meta.lifecycle.always_on: | ||
| if app.status != Status.RUNNING and await size_is_compatible( | ||
| app_meta.minimum_portal_size | ||
| ): | ||
| await docker_start_app(app.name) | ||
| return | ||
|
|
||
| if disk.current_disk_usage.disk_space_low: | ||
| await docker_stop_app(name) | ||
| await docker_stop_app(app.name) | ||
| return | ||
|
|
||
| if app_meta.lifecycle.always_on: | ||
| if await size_is_compatible(app_meta.minimum_portal_size): | ||
| await docker_start_app(app_meta.name) | ||
| else: | ||
| last_access = last_access_dict.get(app_meta.name, 0.0) | ||
| idle_time_for_shutdown = app_meta.lifecycle.idle_time_for_shutdown | ||
| if last_access < time.time() - idle_time_for_shutdown: | ||
| await docker_stop_app(app_meta.name) | ||
| idle = time.time() - last_access_dict.get(app.name, 0.0) | ||
| t2 = ( | ||
| app_meta.lifecycle.idle_for_stop | ||
| or settings().apps.lifecycle.default_idle_for_stop | ||
| ) | ||
|
|
||
| # Feature flag off, or app opts out of the pause tier: legacy stop-only. | ||
| if not pause_enabled or app_meta.lifecycle.skip_pause: | ||
| if app.status == Status.RUNNING and idle >= t2: | ||
| await docker_stop_app(app.name) | ||
| return | ||
|
|
||
| t1 = ( | ||
| app_meta.lifecycle.idle_for_pause | ||
| or settings().apps.lifecycle.default_idle_for_pause | ||
| ) | ||
| if app.status == Status.RUNNING and idle >= t1: | ||
| await docker_pause_app(app.name) | ||
| elif app.status == Status.PAUSED and idle >= t2: | ||
| await docker_stop_app(app.name) | ||
|
|
||
|
|
||
| async def _demote_lru(apps: List[InstalledApp]): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. paused apps will mostly have a less recent last access, so this favours stopping over pausing. Since pausing is the thing with less user impact, it should be preferred. Consider running apps first and pause one, only if no candidates exist, go to paused apps and stop one.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reworked in 41ecc27: candidates split by status — LRU among RUNNING is paused first (or stopped for skip_pause apps); a PAUSED app is only stopped when nothing is left to pause. Tests pin both preferences, including the case where the paused app is older. |
||
| """Demote the least-recently-used demotable app one tier. | ||
|
|
||
| One demotion per control cycle: the next cycle re-reads PSI and demotes | ||
| again only if pressure is still high, so a spike frees memory gradually | ||
| instead of stopping everything at once. | ||
| """ | ||
| candidates = [] | ||
| for app in apps: | ||
| app_meta = get_app_metadata(app.name) | ||
| if app_meta.lifecycle.always_on: | ||
| continue | ||
| if time.time() - last_access_dict.get(app.name, 0.0) <= RECENT_ACCESS_GRACE: | ||
| continue | ||
| if app.status not in (Status.RUNNING, Status.PAUSED): | ||
| continue | ||
| candidates.append(app) | ||
| candidates.sort(key=lambda app: last_access_dict.get(app.name, 0.0)) | ||
| if not candidates: | ||
| return | ||
| victim = candidates[0] | ||
| if victim.status == Status.RUNNING: | ||
| if get_app_metadata(victim.name).lifecycle.skip_pause: | ||
| await docker_stop_app(victim.name) | ||
| else: | ||
| await docker_pause_app(victim.name) | ||
| elif victim.status == Status.PAUSED: | ||
| await docker_stop_app(victim.name) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use
int | Noneif possible.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 41ecc27 — both fields now
int | None.