Skip to content

Commit

Permalink
Add support for user session containers (#61)
Browse files Browse the repository at this point in the history
  • Loading branch information
anders-kiaer authored May 10, 2023
1 parent 7b84361 commit 4ceb4c9
Show file tree
Hide file tree
Showing 39 changed files with 522 additions and 63 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/webviz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
- name: 🕵️ Check auto-generated frontend code is in sync with backend
run: |
docker build -f backend.Dockerfile -t backend:latest .
CONTAINER_ID=$(docker run --detach -p 5000:5000 --env WEBVIZ_CLIENT_SECRET=0 backend:latest)
CONTAINER_ID=$(docker run --detach -p 5000:5000 --env UVICORN_PORT=5000 --env UVICORN_ENTRYPOINT=src.backend.primary.main:app --env WEBVIZ_CLIENT_SECRET=0 backend:latest)
sleep 5 # Ensure the backend server is up and running exposing /openapi.json
npm run generate-api --prefix ./frontend
docker stop $CONTAINER_ID
Expand Down
22 changes: 0 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,25 +47,3 @@ in the Python backend. In order to update the auto-generated code you can either

In both cases the backend needs to already be running (e.g. using `docker-compose`
as stated above).


# Update `poetry.lock` through Docker

If you do not want to install the correct Python version and/or `poetry` on your host
machine, you can update `pyproject.toml` and `poetry.lock` through `docker`.
As an example, if you wanted to add the Python package `httpx`:

```bash
# Start container. This assumes you have previously ran docker-compose
CONTAINER_ID=$(docker run --detach --env WEBVIZ_CLIENT_SECRET=0 webviz_backend)
# Copy pyproject.toml and poetry.lock from host to container in case they have changed since it was built:
docker cp ./backend/pyproject.toml $CONTAINER_ID:/home/appuser/backend/
docker cp ./backend/poetry.lock $CONTAINER_ID:/home/appuser/backend/
# Run your poetry commands:
docker exec -it $CONTAINER_ID sh -c "poetry add httpx"
# Copy updated pyproject.toml and poetry.lock from container back to host:
docker cp $CONTAINER_ID:/home/appuser/backend/pyproject.toml ./backend/
docker cp $CONTAINER_ID:/home/appuser/backend/poetry.lock ./backend/
# Stop container
docker stop $CONTAINER_ID
```
2 changes: 1 addition & 1 deletion backend.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ RUN pip install poetry \
&& poetry export --without-hashes -f requirements.txt -o requirements.txt \
&& pip install -r requirements.txt

CMD ["uvicorn", "--proxy-headers", "--host=0.0.0.0", "--port=5000", "src.fastapi_app.main:app"]
CMD exec uvicorn --proxy-headers --host=0.0.0.0 $UVICORN_ENTRYPOINT
91 changes: 91 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Update `poetry.lock` through Docker

If you do not want to install the correct Python version and/or `poetry` on your host
machine, you can update `pyproject.toml` and `poetry.lock` through `docker`.
As an example, if you wanted to add the Python package `httpx`:

```bash
# Start container. This assumes you have previously ran docker-compose
CONTAINER_ID=$(docker run --detach --env WEBVIZ_CLIENT_SECRET=0 --env UVICORN_ENTRYPOINT=src.backend.primary.main:app webviz_backend-primary)
# Copy pyproject.toml and poetry.lock from host to container in case they have changed since it was built:
docker cp ./pyproject.toml $CONTAINER_ID:/home/appuser/backend/
docker cp ./poetry.lock $CONTAINER_ID:/home/appuser/backend/
docker exec -u root -it $CONTAINER_ID chown appuser:appuser /home/appuser/backend/{pyproject.toml,poetry.lock}
# Run your poetry commands:
docker exec -it $CONTAINER_ID sh -c "poetry add httpx"
# Copy updated pyproject.toml and poetry.lock from container back to host:
docker cp $CONTAINER_ID:/home/appuser/backend/pyproject.toml .
docker cp $CONTAINER_ID:/home/appuser/backend/poetry.lock .
# Stop container
docker stop $CONTAINER_ID
```


# Cache data in memory

Sometimes large data sets must to be loaded from external sources. If the user interacts
with this data through a series of requests to the backend, it is inefficient to load
the same data every time. Instead the recommended pattern is to load these large data sets
using a separate job container instance bound to the user where it can then easily be cached.

Technically this is done like this:
1) The frontend makes a requests to the (primary) backend as usual.
2) The "data demanding endpoints" in the primary backend proxies the request to a separate
job container runnings its own server (also using `FastAPI` as framework).
3) If the user does not already have a job container bound to her/his user ID, the
cloud infrastructure will spin it up (takes some seconds). The job container will
have single-user scope and automatically stop when it has not seen any new requests
for some time. Since the job container has lifetime and scope of a user session,
the Python code can keep large data sets in memory for its lifetime and be sure
it is always the same user accessing it.

Locally during development (single user scenario) there is a single job container
continuously running, started automatically by `docker-compose`.
Except from starting at the same time as the primary backend, not stopping after user
inactivity, and being limited by the developer machine resources (CPU / memory),
this job container during development behave similar to the on demand started job containers in cloud.

On route level this is implemented like the following:

**In `src/backend/primary`:**
```python
from fastapi import Depends, Request

from src.services.utils.authenticated_user import AuthenticatedUser
from src.backend.auth.auth_helper import AuthHelper
from src.backend.primary.user_session_proxy import proxy_to_user_session

...

@router.get("/some_endpoint")
async def my_function(
request: Request,
authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user),
):
return await proxy_to_user_session(request, authenticated_user)
```

**In `src/backend/user_session:**
```python
from functools import lru_cache

from fastapi import Depends

from src.services.utils.authenticated_user import AuthenticatedUser
from src.backend.auth.auth_helper import AuthHelper

...

@router.get("/some_endpoint")
async def my_function(
authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user),
):
return {"data": load_some_large_data_set(authenticated_user)}

@lru_cache
def load_some_large_data_set(authenticated_user):
...
```

The endpoint should have the same path as shown here
in both primary backend and the job backend.
75 changes: 74 additions & 1 deletion backend/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ sumo-wrapper-python = {git = "https://github.com/equinor/sumo-wrapper-python.git
fmu-sumo = {git = "https://github.com/equinor/fmu-sumo.git", branch = "main"}
orjson = "^3.8.10"
pandas = "<2"
httpx = "^0.24.0"
psutil = "^5.9.5"


[tool.poetry.group.dev.dependencies]
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from src.services.utils.authenticated_user import AuthenticatedUser
from src.services.utils.perf_timer import PerfTimer
from src.fastapi_app import config
from src.backend import config


class AuthHelper:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from fastapi.responses import PlainTextResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint

from .auth_helper import AuthHelper
from src.backend.auth.auth_helper import AuthHelper


class EnforceLoggedInMiddleware(BaseHTTPMiddleware):
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@

from fastapi import FastAPI
from fastapi.routing import APIRoute
from starsessions import SessionMiddleware
from starsessions.stores.redis import RedisStore
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware

from . import config
from .auth.auth_helper import AuthHelper
from .auth.enforce_logged_in_middleware import EnforceLoggedInMiddleware
from src.backend.shared_middleware import add_shared_middlewares
from src.backend.auth.auth_helper import AuthHelper
from .routers.explore import router as explore_router
from .routers.general import router as general_router
from .routers.inplace_volumetrics.router import router as inplace_volumetrics_router
Expand Down Expand Up @@ -49,20 +46,9 @@ def custom_generate_unique_id(route: APIRoute) -> str:
app.include_router(authHelper.router)
app.include_router(general_router)

# Add out custom middleware to enforce that user is logged in
# Also redirects to /login endpoint for some select paths
unprotected_paths = ["/logged_in_user", "/alive", "/openapi.json"]
paths_redirected_to_login = ["/", "/alive_protected"]
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
app.add_middleware(
EnforceLoggedInMiddleware,
unprotected_paths=unprotected_paths,
paths_redirected_to_login=paths_redirected_to_login,
)
add_shared_middlewares(app)

session_store = RedisStore(config.REDIS_URL)

app.add_middleware(SessionMiddleware, store=session_store)
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")


@app.get("/")
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
InplaceVolumetricsAccess,
)
from src.services.utils.authenticated_user import AuthenticatedUser
from src.fastapi_app.auth.auth_helper import AuthHelper
from src.backend.auth.auth_helper import AuthHelper


LOGGER = logging.getLogger(__name__)
Expand All @@ -35,7 +35,9 @@ def correlate_parameters_with_timeseries(

summary_access = SummaryAccess(authenticated_user.get_sumo_access_token(), case_uuid, ensemble_name)
parameter_access = ParameterAccess(
authenticated_user.get_sumo_access_token(), case_uuid=case_uuid, iteration_name=ensemble_name
authenticated_user.get_sumo_access_token(),
case_uuid=case_uuid,
iteration_name=ensemble_name,
)

ensemble_response = summary_access.get_vector_values_at_timestep(
Expand Down Expand Up @@ -63,7 +65,9 @@ def correlate_parameters_with_inplace_volumes(

inplace_access = InplaceVolumetricsAccess(authenticated_user.get_sumo_access_token(), case_uuid, ensemble_name)
parameter_access = ParameterAccess(
authenticated_user.get_sumo_access_token(), case_uuid=case_uuid, iteration_name=ensemble_name
authenticated_user.get_sumo_access_token(),
case_uuid=case_uuid,
iteration_name=ensemble_name,
)
ensemble_response = inplace_access.get_response(
table_name, response_name, categorical_filters=None, realizations=None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from src.services.sumo_access.sumo_explore import SumoExplore
from src.services.utils.authenticated_user import AuthenticatedUser
from src.fastapi_app.auth.auth_helper import AuthHelper
from src.backend.auth.auth_helper import AuthHelper

router = APIRouter()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
import logging

import starsessions
from fastapi import APIRouter, HTTPException, Request, status
from starlette.responses import StreamingResponse
from fastapi import APIRouter, HTTPException, Request, status, Depends
from pydantic import BaseModel

from src.fastapi_app.auth.auth_helper import AuthHelper
from src.backend.auth.auth_helper import AuthHelper, AuthenticatedUser
from src.backend.primary.user_session_proxy import proxy_to_user_session

LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -50,3 +52,11 @@ async def logged_in_user(request: Request) -> UserInfo:
)

return user_info


@router.get("/user_session_container")
async def user_session_container(
request: Request, authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user)
) -> StreamingResponse:
"""Get information about user session container (note that one is started if not already running)."""
return await proxy_to_user_session(request, authenticated_user)
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from src.services.sumo_access.generic_types import EnsembleScalarResponse
from src.services.utils.authenticated_user import AuthenticatedUser

from src.fastapi_app.auth.auth_helper import AuthHelper
from src.backend.auth.auth_helper import AuthHelper


router = APIRouter()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
)
from src.services.utils.authenticated_user import AuthenticatedUser
from src.services.utils.perf_timer import PerfTimer
from src.fastapi_app.auth.auth_helper import AuthHelper
from src.backend.auth.auth_helper import AuthHelper
from . import schemas

LOGGER = logging.getLogger(__name__)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from src.services.sumo_access.surface_types import StatisticFunction
from src.services.utils.authenticated_user import AuthenticatedUser
from src.services.utils.perf_timer import PerfTimer
from src.fastapi_app.auth.auth_helper import AuthHelper
from src.backend.auth.auth_helper import AuthHelper
from . import converters
from . import schemas

Expand Down Expand Up @@ -162,7 +162,9 @@ def get_statistical_static_surface_data(
service_stat_func_to_compute = StatisticFunction.from_string_value(statistic_function)
if service_stat_func_to_compute is not None:
xtgeo_surf = access.get_statistical_static_surf(
statistic_function=service_stat_func_to_compute, name=name, attribute=attribute
statistic_function=service_stat_func_to_compute,
name=name,
attribute=attribute,
)

if not xtgeo_surf:
Expand Down
Loading

0 comments on commit 4ceb4c9

Please sign in to comment.