Skip to content

Commit

Permalink
feat: get_kernel_id and get_session_id for custom storage (#452)
Browse files Browse the repository at this point in the history
* feat: get_kernel_id and get_session_id for custom storage

If you want to store data in a custom storage, you need to know the
kernel_id or session_id to scope.

This can be used to implement something similar to reactive variables.
  • Loading branch information
maartenbreddels authored Feb 9, 2024
1 parent f60f68e commit eefd358
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 1 deletion.
1 change: 1 addition & 0 deletions solara/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def _using_solara_server():
from .routing import use_route, use_router, use_route_level, find_route, use_pathname, resolve_path
from .autorouting import generate_routes, generate_routes_directory, RenderPage, RoutingProvider, DefaultLayout
from .checks import check_jupyter
from .scope import get_kernel_id, get_session_id


def display(*objs, **kwargs):
Expand Down
44 changes: 44 additions & 0 deletions solara/scope/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
import sys
import threading
from typing import MutableMapping
Expand Down Expand Up @@ -41,3 +42,46 @@ def _get_dict(self) -> MutableMapping:

worker = ObservableDict()
connection = ConnectionScope()


def get_kernel_id(ipython_fallback=True) -> str:
"""Returns the kernel id, a unique string for each virtual kernel.
See [Understanding solara server](/docs/understanding/solara-server) for understanding the concept of virtual kernels
and their lifetime.
This unique ID can be useful to to implement storing state, scoped to a kernel. See [the scope example](/examples/general/scopes) for an example.
If `ipython_fallback` is `True` (default), this function will also work in IPython notebooks, where it will return the IPython kernel id.
"""
import solara.server.kernel_context

try:
context = solara.server.kernel_context.get_current_context()
except RuntimeError as e:
if not ipython_fallback:
raise
import IPython

ipython = IPython.get_ipython()
if not ipython or not hasattr(ipython, "kernel"):
raise RuntimeError("Not in a kernel") from e
kernel = ipython.kernel
regex = r"[\\/]kernel-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.json$"
connection_file = kernel.config["IPKernelApp"]["connection_file"]
return re.compile(regex).search(connection_file).group(1) # type: ignore
return context.id


def get_session_id() -> str:
"""Returns the session id, which is stored using a browser cookie.
See [Understanding solara server](/docs/understanding/solara-server#session) for more information about the Solara sessions.
This unique ID can be useful to to implement storing state, scoped to a browser session. See [the scope example](/examples/general/scopes) for an example.
"""
import solara.server.kernel_context

context = solara.server.kernel_context.get_current_context()
return context.session_id
10 changes: 9 additions & 1 deletion solara/website/pages/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,15 @@
{
"name": "Utils",
"icon": "mdi-hammer-wrench",
"pages": ["display", "memoize", "reactive", "widget", "component_vue"],
"pages": [
"display",
"get_kernel_id",
"get_session_id",
"memoize",
"reactive",
"widget",
"component_vue",
],
},
{
"name": "Advanced",
Expand Down
16 changes: 16 additions & 0 deletions solara/website/pages/api/get_kernel_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""
# get_kernel_id
"""
import solara
from solara.website.utils import apidoc

from . import NoPage

title = "get_kernel_id"


Page = NoPage


__doc__ += apidoc(solara.get_kernel_id) # type: ignore
16 changes: 16 additions & 0 deletions solara/website/pages/api/get_session_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""
# get_session_id
"""
import solara
from solara.website.utils import apidoc

from . import NoPage

title = "get_session_id"


Page = NoPage


__doc__ += apidoc(solara.get_session_id) # type: ignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@

The solara server enables running ipywidgets based applications without a real Jupyter kernel, allowing multiple "Virtual kernels" to share the same process for better performance and scalability.

## WebSocket in Solara
Solara uses a WebSocket to transmit state and updates directly from the server to the browser. This ensures that the state remains centralized on the server, facilitating state transitions server-side and enabling live updates to be pushed directly to the browser.


## Virtual Kernels
Normally when a browser page connects to a Solara server, a virtual kernel is created and is assigned a unique identifier termed a "Kernel ID." Should a WebSocket disconnection occur, Solara attempts to re-establish the connection, sending the Kernel ID during this process. If the server recognizes this ID (and the requested kernel hasn't expired) the Solara app resumes operations seamlessly.

### Virtual kernel lifecycle
Closing a browser page will directly shut the virtual kernel down (if this page was the last known page to the Solara server). This ensures that active closing of pages will directly clean up any memory usage on the server side for this kernel.

However, when the websocket between the web page and the server disconnects, the server keeps the kernel alive for 24 hours after the closure of the last WebSocket connection. The duration is customizable through the `SOLARA_KERNEL_CULL_TIMEOUT` environment variable. This feature is particularly handy in scenarios where devices like computers hibernate, leading to WebSocket disconnections. Upon awakening and subsequent WebSocket reconnection, the Solara app picks up right where it left off.

To optimize memory usage or address specific needs, one might opt for a shorter expiration duration. For instance, setting `SOLARA_KERNEL_CULL_TIMEOUT=1m` will cause sessions to expire after just 1 minute. Other possible options are `2d` (2 days), `3h` (3 hours), `30s` (30 seconds), etc. If no units are given, seconds are assumed.


## Handling Multiple Workers
In setups with multiple workers, it's possible for a page to reconnect to a different worker than its original. This would result in a loss of the virtual kernel (since it lives on a different worker), prompting the Solara app to initiate a fresh start. To prevent this scenario, a sticky session configuration is recommended, ensuring consistent client-worker connections. Utilizing a load balancer, such as [nginx](https://www.nginx.com/), can achieve this.

If you have questions about setting this up, or require assistance, please [contact us](https://solara.dev/docs/contact).

## Sessions

Solara uses a browser cookie (named `solara-session-id`) to store a unique session id. This session id is available via [get_session_id()](https://solara.dev/api/get_session_id) and is the same for all
browser pages. This can be used to store state that outlives a page refresh.

We recommend storing the state in an external database, especially in the case of multiple workers/nodes. If you want to store state associated to a session in-memory, make sure to set up sticky sessions.




## Readiness check

To check if the server is ready to accept request, the `/readyz` endpoint is added, and should return a 200 HTTP status code, e.g.:
Expand Down
69 changes: 69 additions & 0 deletions solara/website/pages/examples/general/custom_storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""# Custom state storage
Solara makes it easy to store state/data on the server side, scoped to a kernel, using [reactive variables](/api/reactive).
However, sometimes you want to store state yourself in an external system (i.e. not Solara), and for this you can use the
[get_kernel_id()](/api/get_kernel_id) function to get a unique id for each kernel.
If you want to store state/data scoped to a browser session, you can use the [get_session_id()](/api/get_session_id)
function to get a unique id tied to the users browser. This can be used to store state that outlives a page refresh.
In case you want to store state/data scoped to a user, you can use a similar strategy, but use a unique identifier based on the user,
instead of the session id. You can take a look at [Our oauth example](examples/general/login_oauth) or
[the authorization example](/examples/apps/authorization) for inspiration.
"""
from typing import Dict

import solara
import solara.lab

# used only to force updating of the page
force_update_counter = solara.reactive(0)

# Kernel storage is scoped to the kernel, and will be cleared when the kernel is stopped.
kernel_storage: Dict[str, str] = {}


def store_in_kernel_storage(value):
kernel_storage[solara.get_kernel_id()] = value
force_update_counter.value += 1


@solara.lab.on_kernel_start
def initialize_kernel_storage():
# when a kernel gets started, we initialize the dict entry
kernel_storage[solara.get_kernel_id()] = "This does not"

def cleanup():
# when a kernel gets stopped, we remove the dict entry
del kernel_storage[solara.get_kernel_id()]

# cleaning up kernel storage, we prevent memory leaks
return cleanup


# session storage has no lifecycle management, and will only be cleared when the server is restarted.
session_storage: Dict[str, str] = {}


def store_in_session_storage(value):
session_storage[solara.get_session_id()] = value
force_update_counter.value += 1


@solara.component
def Page():
solara.InputText(
"Stored under the kernel id key",
value=kernel_storage[solara.get_kernel_id()],
on_value=store_in_kernel_storage,
continuous_update=True,
)

solara.InputText(
"Stored under the session id key",
value=session_storage.get(solara.get_session_id(), "This outlives a page refresh"),
on_value=store_in_session_storage,
continuous_update=True,
)

0 comments on commit eefd358

Please sign in to comment.