|
2 | 2 | import json |
3 | 3 | import logging |
4 | 4 | import time |
| 5 | +from collections import defaultdict |
5 | 6 | from pathlib import Path |
| 7 | +from typing import Literal |
6 | 8 |
|
7 | 9 | import shard_core.data_model.profile |
8 | 10 | from shard_core.database.connection import db_conn |
@@ -31,41 +33,135 @@ async def docker_create_app_containers(name: str): |
31 | 33 | await subprocess(*_app_compose(name), "up", "--no-start") |
32 | 34 |
|
33 | 35 |
|
34 | | -@throttle(5) |
35 | | -async def docker_start_app(name: str): |
36 | | - async with db_conn() as conn: |
37 | | - app = await db_installed_apps.get_by_name(conn, name) |
38 | | - app_status = app["status"] if app else None |
| 36 | +ContainerState = Literal["running", "paused", "exited", "missing"] |
39 | 37 |
|
40 | | - if app_status == Status.PAUSED: |
41 | | - # a paused app wakes by unfreezing, not by compose up |
42 | | - await docker_unpause_app(name) |
43 | | - return |
| 38 | +# Statuses a lifecycle revive may act on. Transitional/terminal ones |
| 39 | +# (INSTALLING, *_QUEUED, UNINSTALLING, REINSTALLING, ERROR) must never be |
| 40 | +# started, or the control tick recreates containers mid-uninstall/reinstall. |
| 41 | +_REVIVABLE_STATUS = (Status.STOPPED, Status.RUNNING, Status.DOWN, Status.PAUSED) |
44 | 42 |
|
45 | | - if app_status in [Status.STOPPED, Status.RUNNING, Status.DOWN]: |
46 | | - log.debug(f"starting app {name=}") |
47 | | - try: |
| 43 | +# Per-app op lock serializing revive against teardown: start_app and the |
| 44 | +# uninstall worker both take it, so a wake-on-access or control-tick revive can |
| 45 | +# never recreate a container the uninstall worker just removed (issue #185). |
| 46 | +_app_op_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) |
| 47 | + |
| 48 | + |
| 49 | +def app_op_lock(name: str) -> asyncio.Lock: |
| 50 | + return _app_op_locks[name] |
| 51 | + |
| 52 | + |
| 53 | +async def get_app_container_state(name: str) -> ContainerState: |
| 54 | + """Real docker state of an app's containers: running | paused | exited | missing. |
| 55 | +
|
| 56 | + Reads the daemon rather than the stored app status, so a caller can revive an |
| 57 | + app whose containers changed state out-of-band (crash, OOM, core-upgrade |
| 58 | + converge stop) while the database still says PAUSED or RUNNING. |
| 59 | + """ |
| 60 | + compose = _app_compose(name) |
| 61 | + try: |
| 62 | + ids_out = await subprocess(*compose, "ps", "-a", "-q") |
| 63 | + ids = [line.strip() for line in ids_out.splitlines() if line.strip()] |
| 64 | + if not ids: |
| 65 | + return "missing" |
| 66 | + states_out = await subprocess( |
| 67 | + "docker", "inspect", "--format", "{{.State.Status}}", *ids |
| 68 | + ) |
| 69 | + except SubprocessError: |
| 70 | + return "missing" |
| 71 | + states = [line.strip() for line in states_out.splitlines() if line.strip()] |
| 72 | + if any(s == "paused" for s in states): |
| 73 | + return "paused" |
| 74 | + if states and all(s == "running" for s in states): |
| 75 | + return "running" |
| 76 | + return "exited" |
| 77 | + |
| 78 | + |
| 79 | +async def _compose_up(name: str): |
| 80 | + try: |
| 81 | + await subprocess(*_app_compose(name), "up", "-d") |
| 82 | + except SubprocessError as e: |
| 83 | + if "network" in str(e) and "not found" in str(e): |
| 84 | + log.warning( |
| 85 | + f"stale network reference for app {name=}, recreating containers" |
| 86 | + ) |
| 87 | + await subprocess(*_app_compose(name), "down") |
| 88 | + await subprocess(*_app_compose(name), "up", "-d") |
| 89 | + elif "Conflict" in str(e) and "already in use" in str(e): |
| 90 | + log.warning(f"stale containers for app {name=}, removing and recreating") |
| 91 | + await subprocess(*_app_compose(name), "down") |
48 | 92 | await subprocess(*_app_compose(name), "up", "-d") |
49 | | - except SubprocessError as e: |
50 | | - if "network" in str(e) and "not found" in str(e): |
| 93 | + else: |
| 94 | + raise |
| 95 | + |
| 96 | + |
| 97 | +async def _do_unpause(name: str): |
| 98 | + unpause_started = time.monotonic() |
| 99 | + await subprocess(*_app_compose(name), "unpause") |
| 100 | + pause_metrics.record_unpause_latency((time.monotonic() - unpause_started) * 1000) |
| 101 | + pause_metrics.record_app_transition(name, Status.PAUSED, Status.RUNNING) |
| 102 | + |
| 103 | + |
| 104 | +async def _mark_running(name: str): |
| 105 | + async with db_conn() as conn: |
| 106 | + await db_installed_apps.update_status(conn, name, Status.RUNNING) |
| 107 | + await signals.on_apps_update.send_async() |
| 108 | + |
| 109 | + |
| 110 | +@throttle(5) |
| 111 | +async def start_app(name: str) -> None: |
| 112 | + """Bring a revivable app up, deciding from the real container state. |
| 113 | +
|
| 114 | + The single revive primitive for wake-on-access and the always-on control |
| 115 | + tick. It acts only on a revivable app (see _REVIVABLE_STATUS) and dispatches |
| 116 | + on the real container state, not the stored status, so an app that exited |
| 117 | + out-of-band while the database still says PAUSED still starts (via up) rather |
| 118 | + than erroring on unpause. Idempotent: an already-running stack is a no-op. |
| 119 | +
|
| 120 | + Holds the per-app op lock so a revive can never race the uninstall worker's |
| 121 | + teardown and recreate a container it just removed (issue #185). |
| 122 | + """ |
| 123 | + async with app_op_lock(name): |
| 124 | + async with db_conn() as conn: |
| 125 | + app = await db_installed_apps.get_by_name(conn, name) |
| 126 | + db_status = app["status"] if app else None |
| 127 | + if db_status not in _REVIVABLE_STATUS: |
| 128 | + log.debug(f"app {name=} has {db_status=}, skipping start") |
| 129 | + return |
| 130 | + |
| 131 | + state = await get_app_container_state(name) |
| 132 | + |
| 133 | + if state == "running": |
| 134 | + if db_status != Status.RUNNING: |
51 | 135 | log.warning( |
52 | | - f"stale network reference for app {name=}, recreating containers" |
| 136 | + f"app {name=} container is running but db says {db_status=}; reconciling" |
53 | 137 | ) |
54 | | - await subprocess(*_app_compose(name), "down") |
55 | | - await subprocess(*_app_compose(name), "up", "-d") |
56 | | - elif "Conflict" in str(e) and "already in use" in str(e): |
| 138 | + await _mark_running(name) |
| 139 | + return |
| 140 | + |
| 141 | + if state == "paused": |
| 142 | + if db_status != Status.PAUSED: |
57 | 143 | log.warning( |
58 | | - f"stale containers for app {name=}, removing and recreating" |
| 144 | + f"app {name=} container is paused but db says {db_status=}; unpausing" |
59 | 145 | ) |
60 | | - await subprocess(*_app_compose(name), "down") |
61 | | - await subprocess(*_app_compose(name), "up", "-d") |
62 | | - else: |
63 | | - raise |
64 | | - async with db_conn() as conn: |
65 | | - await db_installed_apps.update_status(conn, name, Status.RUNNING) |
66 | | - await signals.on_apps_update.send_async() |
67 | | - else: |
68 | | - log.debug(f"app {name=} has status {app_status}, skipping start") |
| 146 | + log.debug(f"unpausing app {name=}") |
| 147 | + try: |
| 148 | + await _do_unpause(name) |
| 149 | + except SubprocessError: |
| 150 | + # a partially-paused stack (some containers already exited) can't |
| 151 | + # be revived by unpause — fall back to a plain start |
| 152 | + log.warning(f"unpause failed for {name=}, starting instead") |
| 153 | + await _compose_up(name) |
| 154 | + await _mark_running(name) |
| 155 | + return |
| 156 | + |
| 157 | + # exited / created / missing |
| 158 | + if db_status in (Status.RUNNING, Status.PAUSED): |
| 159 | + log.warning( |
| 160 | + f"app {name=} container is {state} but db says {db_status=}; starting it" |
| 161 | + ) |
| 162 | + log.debug(f"starting app {name=}") |
| 163 | + await _compose_up(name) |
| 164 | + await _mark_running(name) |
69 | 165 |
|
70 | 166 |
|
71 | 167 | async def docker_pause_app(name: str): |
@@ -93,15 +189,8 @@ async def docker_unpause_app(name: str): |
93 | 189 | app_status = app["status"] if app else None |
94 | 190 | if app_status == Status.PAUSED: |
95 | 191 | log.debug(f"unpausing app {name=}") |
96 | | - unpause_started = time.monotonic() |
97 | | - await subprocess(*_app_compose(name), "unpause") |
98 | | - pause_metrics.record_unpause_latency( |
99 | | - (time.monotonic() - unpause_started) * 1000 |
100 | | - ) |
101 | | - pause_metrics.record_app_transition(name, Status.PAUSED, Status.RUNNING) |
102 | | - async with db_conn() as conn: |
103 | | - await db_installed_apps.update_status(conn, name, Status.RUNNING) |
104 | | - await signals.on_apps_update.send_async() |
| 192 | + await _do_unpause(name) |
| 193 | + await _mark_running(name) |
105 | 194 | else: |
106 | 195 | log.debug(f"app {name=} has {app_status=}, skipping unpause") |
107 | 196 |
|
|
0 commit comments