Skip to content

Commit 2a85d9b

Browse files
[App] Add status endpoint, enable ready (#16075)
Co-authored-by: thomas chaton <[email protected]>
1 parent f3157f3 commit 2a85d9b

File tree

14 files changed

+132
-58
lines changed

14 files changed

+132
-58
lines changed

examples/app_boring/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ def __init__(self):
4343
raise_exception=True,
4444
)
4545

46+
@property
47+
def ready(self) -> bool:
48+
return self.dest_work.is_running
49+
4650
def run(self):
4751
self.source_work.run()
4852
if self.source_work.has_succeeded:

src/lightning_app/components/serve/streamlit.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class ServeStreamlit(LightningWork, abc.ABC):
2020
def __init__(self, *args, **kwargs):
2121
super().__init__(*args, **kwargs)
2222

23+
self.ready = False
24+
2325
self._process = None
2426

2527
@property
@@ -58,6 +60,7 @@ def run(self) -> None:
5860
],
5961
env=env,
6062
)
63+
self.ready = True
6164
self._process.wait()
6265

6366
def on_exit(self) -> None:

src/lightning_app/core/api.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from lightning_app.core.queues import QueuingSystem
3535
from lightning_app.storage import Drive
3636
from lightning_app.utilities.app_helpers import InMemoryStateStore, Logger, StateStore
37+
from lightning_app.utilities.app_status import AppStatus
3738
from lightning_app.utilities.cloud import is_running_in_cloud
3839
from lightning_app.utilities.component import _context
3940
from lightning_app.utilities.enum import ComponentContext, OpenAPITags
@@ -66,18 +67,24 @@ class SessionMiddleware:
6667
lock = Lock()
6768

6869
app_spec: Optional[List] = None
70+
app_status: Optional[AppStatus] = None
71+
6972
# In the future, this would be abstracted to support horizontal scaling.
7073
responses_store = {}
7174

7275
logger = Logger(__name__)
7376

74-
7577
# This can be replaced with a consumer that publishes states in a kv-store
7678
# in a serverless architecture
7779

7880

7981
class UIRefresher(Thread):
80-
def __init__(self, api_publish_state_queue, api_response_queue, refresh_interval: float = 0.1) -> None:
82+
def __init__(
83+
self,
84+
api_publish_state_queue,
85+
api_response_queue,
86+
refresh_interval: float = 0.1,
87+
) -> None:
8188
super().__init__(daemon=True)
8289
self.api_publish_state_queue = api_publish_state_queue
8390
self.api_response_queue = api_response_queue
@@ -98,7 +105,8 @@ def run(self):
98105

99106
def run_once(self):
100107
try:
101-
state = self.api_publish_state_queue.get(timeout=0)
108+
global app_status
109+
state, app_status = self.api_publish_state_queue.get(timeout=0)
102110
with lock:
103111
global_app_state_store.set_app_state(TEST_SESSION_UUID, state)
104112
except queue.Empty:
@@ -326,6 +334,17 @@ async def upload_file(response: Response, filename: str, uploaded_file: UploadFi
326334
return f"Successfully uploaded '{filename}' to the Drive"
327335

328336

337+
@fastapi_service.get("/api/v1/status", response_model=AppStatus)
338+
async def get_status() -> AppStatus:
339+
"""Get the current status of the app and works."""
340+
global app_status
341+
if app_status is None:
342+
raise HTTPException(
343+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="App status hasn't been reported yet."
344+
)
345+
return app_status
346+
347+
329348
@fastapi_service.get("/healthz", status_code=200)
330349
async def healthz(response: Response):
331350
"""Health check endpoint used in the cloud FastAPI servers to check the status periodically."""

src/lightning_app/core/app.py

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
_should_dispatch_app,
3636
Logger,
3737
)
38+
from lightning_app.utilities.app_status import AppStatus
3839
from lightning_app.utilities.commands.base import _process_requests
3940
from lightning_app.utilities.component import _convert_paths_after_init, _validate_root_flow
4041
from lightning_app.utilities.enum import AppStage, CacheCallsKeys
@@ -140,6 +141,7 @@ def __init__(
140141
self.exception = None
141142
self.collect_changes: bool = True
142143

144+
self.status: Optional[AppStatus] = None
143145
# TODO: Enable ready locally for opening the UI.
144146
self.ready = False
145147

@@ -150,6 +152,7 @@ def __init__(
150152
self.checkpointing: bool = False
151153

152154
self._update_layout()
155+
self._update_status()
153156

154157
self.is_headless: Optional[bool] = None
155158

@@ -418,6 +421,7 @@ def run_once(self):
418421

419422
self._update_layout()
420423
self._update_is_headless()
424+
self._update_status()
421425
self.maybe_apply_changes()
422426

423427
if self.checkpointing and self._should_snapshot():
@@ -485,19 +489,12 @@ def _run(self) -> bool:
485489
self._original_state = deepcopy(self.state)
486490
done = False
487491

488-
# TODO: Re-enable the `ready` property once issues are resolved
489-
if not self.root.ready:
490-
warnings.warn(
491-
"One of your Flows returned `.ready` as `False`. "
492-
"This feature is not yet enabled so this will be ignored.",
493-
UserWarning,
494-
)
495-
self.ready = True
492+
self.ready = self.root.ready
496493

497494
self._start_with_flow_works()
498495

499-
if self.ready and self.should_publish_changes_to_api and self.api_publish_state_queue:
500-
self.api_publish_state_queue.put(self.state_vars)
496+
if self.should_publish_changes_to_api and self.api_publish_state_queue is not None:
497+
self.api_publish_state_queue.put((self.state_vars, self.status))
501498

502499
self._reset_run_time_monitor()
503500

@@ -506,8 +503,8 @@ def _run(self) -> bool:
506503

507504
self._update_run_time_monitor()
508505

509-
if self.ready and self._has_updated and self.should_publish_changes_to_api and self.api_publish_state_queue:
510-
self.api_publish_state_queue.put(self.state_vars)
506+
if self._has_updated and self.should_publish_changes_to_api and self.api_publish_state_queue is not None:
507+
self.api_publish_state_queue.put((self.state_vars, self.status))
511508

512509
self._has_updated = False
513510

@@ -532,6 +529,23 @@ def _update_is_headless(self) -> None:
532529
# This ensures support for apps which dynamically add a UI at runtime.
533530
_handle_is_headless(self)
534531

532+
def _update_status(self) -> None:
533+
old_status = self.status
534+
535+
work_statuses = {}
536+
for work in breadth_first(self.root, types=(lightning_app.LightningWork,)):
537+
work_statuses[work.name] = work.status
538+
539+
self.status = AppStatus(
540+
is_ui_ready=self.ready,
541+
work_statuses=work_statuses,
542+
)
543+
544+
# If the work statuses changed, the state delta will trigger an update.
545+
# If ready has changed, we trigger an update manually.
546+
if self.status != old_status:
547+
self._has_updated = True
548+
535549
def _apply_restarting(self) -> bool:
536550
self._reset_original_state()
537551
# apply stage after restoring the original state.

src/lightning_app/core/flow.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,7 @@ def __getattr__(self, item):
249249

250250
@property
251251
def ready(self) -> bool:
252-
"""Not currently enabled.
253-
254-
Override to customize when your App should be ready.
255-
"""
252+
"""Override to customize when your App should be ready."""
256253
flows = self.flows
257254
return all(flow.ready for flow in flows.values()) if flows else True
258255

src/lightning_app/core/work.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@
1212
from lightning_app.storage.drive import _maybe_create_drive, Drive
1313
from lightning_app.storage.payload import Payload
1414
from lightning_app.utilities.app_helpers import _is_json_serializable, _LightningAppRef, is_overridden
15+
from lightning_app.utilities.app_status import WorkStatus
1516
from lightning_app.utilities.component import _is_flow_context, _sanitize_state
1617
from lightning_app.utilities.enum import (
1718
CacheCallsKeys,
1819
make_status,
1920
WorkFailureReasons,
2021
WorkStageStatus,
21-
WorkStatus,
2222
WorkStopReasons,
2323
)
2424
from lightning_app.utilities.exceptions import LightningWorkException

src/lightning_app/runners/runtime.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def terminate(self) -> None:
121121
self._add_stopped_status_to_work(work)
122122

123123
# Publish the updated state and wait for the frontend to update.
124-
self.app.api_publish_state_queue.put(self.app.state)
124+
self.app.api_publish_state_queue.put((self.app.state, self.app.status))
125125

126126
for thread in self.threads + self.app.threads:
127127
thread.join(timeout=0)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from datetime import datetime
2+
from typing import Any, Dict, Optional
3+
4+
from pydantic import BaseModel
5+
6+
7+
class WorkStatus(BaseModel):
8+
"""The ``WorkStatus`` captures the status of a work according to the app."""
9+
10+
stage: str
11+
timestamp: float
12+
reason: Optional[str] = None
13+
message: Optional[str] = None
14+
count: int = 1
15+
16+
def __init__(self, *args: Any, **kwargs: Any) -> None:
17+
super().__init__(*args, **kwargs)
18+
19+
assert self.timestamp > 0 and self.timestamp < (int(datetime.now().timestamp()) + 10)
20+
21+
22+
class AppStatus(BaseModel):
23+
"""The ``AppStatus`` captures the current status of the app and its components."""
24+
25+
# ``True`` when the app UI is ready to be viewed
26+
is_ui_ready: bool
27+
28+
# The statuses of ``LightningWork`` objects currently associated with this app
29+
work_statuses: Dict[str, WorkStatus]

src/lightning_app/utilities/enum.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import enum
2-
from dataclasses import dataclass
32
from datetime import datetime, timezone
43
from typing import Optional
54

@@ -47,18 +46,6 @@ class WorkStageStatus:
4746
FAILED = "failed"
4847

4948

50-
@dataclass
51-
class WorkStatus:
52-
stage: WorkStageStatus
53-
timestamp: float
54-
reason: Optional[str] = None
55-
message: Optional[str] = None
56-
count: int = 1
57-
58-
def __post_init__(self):
59-
assert self.timestamp > 0 and self.timestamp < (int(datetime.now().timestamp()) + 10)
60-
61-
6249
def make_status(stage: str, message: Optional[str] = None, reason: Optional[str] = None):
6350
status = {
6451
"stage": stage,

tests/tests_app/core/test_lightning_api.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from lightning_app.runners import MultiProcessRuntime
3232
from lightning_app.storage.drive import Drive
3333
from lightning_app.testing.helpers import _MockQueue
34+
from lightning_app.utilities.app_status import AppStatus
3435
from lightning_app.utilities.component import _set_frontend_context, _set_work_context
3536
from lightning_app.utilities.enum import AppStage
3637
from lightning_app.utilities.load_app import extract_metadata_from_app
@@ -195,7 +196,7 @@ def test_update_publish_state_and_maybe_refresh_ui():
195196
publish_state_queue = _MockQueue("publish_state_queue")
196197
api_response_queue = _MockQueue("api_response_queue")
197198

198-
publish_state_queue.put(app.state_with_changes)
199+
publish_state_queue.put((app.state_with_changes, None))
199200

200201
thread = UIRefresher(publish_state_queue, api_response_queue)
201202
thread.run_once()
@@ -226,7 +227,7 @@ def get(self, timeout: int = 0):
226227
has_started_queue = _MockQueue("has_started_queue")
227228
api_response_queue = _MockQueue("api_response_queue")
228229
state = app.state_with_changes
229-
publish_state_queue.put(state)
230+
publish_state_queue.put((state, AppStatus(is_ui_ready=True, work_statuses={})))
230231
spec = extract_metadata_from_app(app)
231232
ui_refresher = start_server(
232233
publish_state_queue,
@@ -284,6 +285,9 @@ def get(self, timeout: int = 0):
284285
{"name": "main_4", "content": "https://te"},
285286
]
286287

288+
response = await client.get("/api/v1/status")
289+
assert response.json() == {"is_ui_ready": True, "work_statuses": {}}
290+
287291
response = await client.post("/api/v1/state", json={"state": new_state}, headers=headers)
288292
assert change_state_queue._queue[1].to_dict() == {
289293
"values_changed": {"root['vars']['counter']": {"new_value": 1}}

0 commit comments

Comments
 (0)