Skip to content

Commit b2d3396

Browse files
Adds client session ID propagation for user actions
Introduces client session ID to APIs and project update notifications, enabling session-aware updates and improved multi-tab/user experience. Allows the frontend to distinguish between updates from different user sessions, preventing redundant UI refreshes for the originating session when optimistic updates are used. Relates to collaborative editing and real-time notification enhancements.
1 parent ef83d12 commit b2d3396

File tree

14 files changed

+161
-56
lines changed

14 files changed

+161
-56
lines changed

packages/service-library/src/servicelib/rest_constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ class PydanticExportParametersDict(TypedDict):
2323

2424
# Headers keys
2525
X_PRODUCT_NAME_HEADER: Final[str] = "X-Simcore-Products-Name"
26+
X_CLIENT_SESSION_ID_HEADER: Final[str] = "X-Client-Session-Id"

services/web/server/src/simcore_service_webserver/db_listener/_db_comp_tasks_listening_task.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ async def _update_project_state(
5252
node_errors: list[ErrorDict] | None,
5353
) -> None:
5454
project = await _projects_service.update_project_node_state(
55-
app, user_id, project_uuid, node_uuid, new_state
55+
app,
56+
user_id,
57+
project_uuid,
58+
node_uuid,
59+
new_state,
60+
client_session_id=None, # <-- The trigger for this update is not from the UI (its db listener)
5661
)
5762

5863
await _projects_service.notify_project_node_update(
@@ -95,6 +100,7 @@ async def _handle_db_notification(
95100
changed_row.run_hash,
96101
node_errors=changed_row.errors,
97102
ui_changed_keys=None,
103+
client_session_id=None, # <-- The trigger for this update is not from the UI (its db listener)
98104
)
99105

100106
if "state" in payload.changes and (changed_row.state is not None):

services/web/server/src/simcore_service_webserver/folders/_workspaces_repository.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ async def move_folder_into_workspace(
2626
folder_id: FolderID,
2727
workspace_id: WorkspaceID | None,
2828
product_name: ProductName,
29+
client_session_id: str | None = None,
2930
) -> None:
3031
# 1. User needs to have delete permission on source folder
3132
folder_db = await _folders_repository.get(
@@ -84,6 +85,7 @@ async def move_folder_into_workspace(
8485
project_uuid=project_id,
8586
patch_project_data={"workspace_id": workspace_id},
8687
user_primary_gid=user["primary_gid"],
88+
client_session_id=client_session_id,
8789
)
8890

8991
# 5. BATCH update of folders with workspace_id

services/web/server/src/simcore_service_webserver/folders/_workspaces_rest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from aiohttp import web
44
from servicelib.aiohttp import status
55
from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as
6+
from servicelib.rest_constants import X_CLIENT_SESSION_ID_HEADER
67

78
from .._meta import api_version_prefix as VTAG
89
from ..login.decorators import login_required
@@ -28,11 +29,14 @@ async def move_folder_to_workspace(request: web.Request):
2829
req_ctx = FoldersRequestContext.model_validate(request)
2930
path_params = parse_request_path_parameters_as(FolderWorkspacesPathParams, request)
3031

32+
client_session_id: str | None = request.headers.get(X_CLIENT_SESSION_ID_HEADER)
33+
3134
await _workspaces_repository.move_folder_into_workspace(
3235
app=request.app,
3336
user_id=req_ctx.user_id,
3437
folder_id=path_params.folder_id,
3538
workspace_id=path_params.workspace_id,
3639
product_name=req_ctx.product_name,
40+
client_session_id=client_session_id,
3741
)
3842
return web.json_response(status=status.HTTP_204_NO_CONTENT)

services/web/server/src/simcore_service_webserver/projects/_controller/nodes_rest.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
ServiceWaitingForManualInterventionError,
5151
ServiceWasNotFoundError,
5252
)
53+
from servicelib.rest_constants import X_CLIENT_SESSION_ID_HEADER
5354
from servicelib.services_utils import get_status_as_dict
5455
from simcore_postgres_database.models.users import UserRole
5556

@@ -97,6 +98,8 @@ async def create_node(request: web.Request) -> web.Response:
9798
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
9899
body = await parse_request_body_as(NodeCreate, request)
99100

101+
client_session_id = request.headers.get(X_CLIENT_SESSION_ID_HEADER)
102+
100103
if await _projects_service.is_service_deprecated(
101104
request.app,
102105
req_ctx.user_id,
@@ -124,6 +127,7 @@ async def create_node(request: web.Request) -> web.Response:
124127
body.service_key,
125128
body.service_version,
126129
body.service_id,
130+
client_session_id=client_session_id,
127131
)
128132
}
129133
assert NodeCreated.model_validate(data) is not None # nosec
@@ -179,6 +183,8 @@ async def patch_project_node(request: web.Request) -> web.Response:
179183
path_params = parse_request_path_parameters_as(NodePathParams, request)
180184
node_patch = await parse_request_body_as(NodePatch, request)
181185

186+
client_session_id = request.headers.get(X_CLIENT_SESSION_ID_HEADER)
187+
182188
await _projects_service.patch_project_node(
183189
request.app,
184190
product_name=req_ctx.product_name,
@@ -187,6 +193,7 @@ async def patch_project_node(request: web.Request) -> web.Response:
187193
project_id=path_params.project_id,
188194
node_id=path_params.node_id,
189195
partial_node=node_patch.to_domain_model(),
196+
client_session_id=client_session_id,
190197
)
191198

192199
return web.json_response(status=status.HTTP_204_NO_CONTENT)
@@ -200,6 +207,8 @@ async def delete_node(request: web.Request) -> web.Response:
200207
req_ctx = AuthenticatedRequestContext.model_validate(request)
201208
path_params = parse_request_path_parameters_as(NodePathParams, request)
202209

210+
client_session_id = request.headers.get(X_CLIENT_SESSION_ID_HEADER)
211+
203212
# ensure the project exists
204213
await _projects_service.get_project_for_user(
205214
request.app,
@@ -213,6 +222,7 @@ async def delete_node(request: web.Request) -> web.Response:
213222
NodeIDStr(path_params.node_id),
214223
req_ctx.product_name,
215224
product_api_base_url=get_api_base_url(request),
225+
client_session_id=client_session_id,
216226
)
217227

218228
return web.json_response(status=status.HTTP_204_NO_CONTENT)
@@ -250,6 +260,8 @@ async def update_node_outputs(request: web.Request) -> web.Response:
250260
path_params = parse_request_path_parameters_as(NodePathParams, request)
251261
node_outputs = await parse_request_body_as(NodeOutputs, request)
252262

263+
client_session_id = request.headers.get(X_CLIENT_SESSION_ID_HEADER)
264+
253265
ui_changed_keys = set()
254266
ui_changed_keys.add(f"{path_params.node_id}")
255267
await nodes_utils.update_node_outputs(
@@ -261,6 +273,7 @@ async def update_node_outputs(request: web.Request) -> web.Response:
261273
run_hash=None,
262274
node_errors=None,
263275
ui_changed_keys=ui_changed_keys,
276+
client_session_id=client_session_id,
264277
)
265278
return web.json_response(status=status.HTTP_204_NO_CONTENT)
266279

services/web/server/src/simcore_service_webserver/projects/_controller/ports_rest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
parse_request_body_as,
2020
parse_request_path_parameters_as,
2121
)
22+
from servicelib.rest_constants import X_CLIENT_SESSION_ID_HEADER
2223

2324
from ..._meta import API_VTAG as VTAG
2425
from ...login.decorators import login_required
@@ -89,6 +90,8 @@ async def update_project_inputs(request: web.Request) -> web.Response:
8990
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
9091
inputs_updates = await parse_request_body_as(list[ProjectInputUpdate], request)
9192

93+
client_session_id: str | None = request.headers.get(X_CLIENT_SESSION_ID_HEADER)
94+
9295
assert request.app # nosec
9396

9497
workbench = await _get_validated_workbench_model(
@@ -123,6 +126,7 @@ async def update_project_inputs(request: web.Request) -> web.Response:
123126
project_uuid=path_params.project_id,
124127
product_name=req_ctx.product_name,
125128
partial_workbench_data=jsonable_encoder(partial_workbench_data),
129+
client_session_id=client_session_id,
126130
)
127131

128132
workbench = TypeAdapter(dict[NodeID, Node]).validate_python(

services/web/server/src/simcore_service_webserver/projects/_controller/projects_rest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
X_SIMCORE_USER_AGENT,
2626
)
2727
from servicelib.redis import get_project_locked_state
28+
from servicelib.rest_constants import X_CLIENT_SESSION_ID_HEADER
2829

2930
from ..._meta import API_VTAG as VTAG
3031
from ...login.decorators import login_required
@@ -313,12 +314,15 @@ async def patch_project(request: web.Request):
313314
path_params = parse_request_path_parameters_as(ProjectPathParams, request)
314315
project_patch = await parse_request_body_as(ProjectPatch, request)
315316

317+
client_session_id: str | None = request.headers.get(X_CLIENT_SESSION_ID_HEADER)
318+
316319
await _projects_service.patch_project_for_user(
317320
request.app,
318321
user_id=req_ctx.user_id,
319322
project_uuid=path_params.project_id,
320323
project_patch=project_patch,
321324
product_name=req_ctx.product_name,
325+
client_session_id=client_session_id,
322326
)
323327

324328
return web.json_response(status=status.HTTP_204_NO_CONTENT)

services/web/server/src/simcore_service_webserver/projects/_controller/workspaces_rest.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
99
from servicelib.aiohttp import status
1010
from servicelib.aiohttp.requests_validation import parse_request_path_parameters_as
11+
from servicelib.rest_constants import X_CLIENT_SESSION_ID_HEADER
1112

1213
from ..._meta import api_version_prefix as VTAG
1314
from ...login.decorators import login_required
@@ -44,11 +45,14 @@ async def move_project_to_workspace(request: web.Request):
4445
_ProjectWorkspacesPathParams, request
4546
)
4647

48+
client_session_id: str | None = request.headers.get(X_CLIENT_SESSION_ID_HEADER)
49+
4750
await _workspaces_service.move_project_into_workspace(
4851
app=request.app,
4952
user_id=req_ctx.user_id,
5053
project_id=path_params.project_id,
5154
workspace_id=path_params.workspace_id,
5255
product_name=req_ctx.product_name,
56+
client_session_id=client_session_id,
5357
)
5458
return web.json_response(status=status.HTTP_204_NO_CONTENT)

services/web/server/src/simcore_service_webserver/projects/_projects_repository_legacy.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,7 @@ async def update_project_node_data(
881881
node_id: NodeID,
882882
product_name: str | None,
883883
new_node_data: dict[str, Any],
884+
client_session_id: str | None,
884885
) -> tuple[ProjectDict, dict[NodeIDStr, Any]]:
885886
with log_context(
886887
_logger,
@@ -897,6 +898,7 @@ async def update_project_node_data(
897898
project_uuid=project_uuid,
898899
product_name=product_name,
899900
allow_workbench_changes=False,
901+
client_session_id=client_session_id,
900902
)
901903

902904
async def update_project_multiple_node_data(
@@ -906,6 +908,7 @@ async def update_project_multiple_node_data(
906908
project_uuid: ProjectID,
907909
product_name: str | None,
908910
partial_workbench_data: dict[NodeIDStr, dict[str, Any]],
911+
client_session_id: str | None,
909912
) -> tuple[ProjectDict, dict[NodeIDStr, Any]]:
910913
"""
911914
Raises:
@@ -923,6 +926,7 @@ async def update_project_multiple_node_data(
923926
project_uuid=project_uuid,
924927
product_name=product_name,
925928
allow_workbench_changes=False,
929+
client_session_id=client_session_id,
926930
)
927931

928932
async def _update_project_workbench_with_lock_and_notify(
@@ -933,6 +937,7 @@ async def _update_project_workbench_with_lock_and_notify(
933937
project_uuid: ProjectID,
934938
product_name: str | None = None,
935939
allow_workbench_changes: bool,
940+
client_session_id: str | None,
936941
) -> tuple[ProjectDict, dict[NodeIDStr, Any]]:
937942
"""
938943
Updates project workbench with Redis lock and user notification.
@@ -1002,7 +1007,7 @@ async def _update_workbench_and_notify() -> (
10021007
),
10031008
)
10041009

1005-
# Increment document version and notify users
1010+
# Increment document version
10061011
redis_client_sdk = get_redis_document_manager_client_sdk(self._app)
10071012
document_version = await increment_and_return_project_document_version(
10081013
redis_client=redis_client_sdk, project_uuid=project_uuid
@@ -1020,6 +1025,7 @@ async def _update_workbench_and_notify() -> (
10201025
app=self._app,
10211026
project_id=project_uuid,
10221027
user_primary_gid=user_primary_gid,
1028+
client_session_id=client_session_id,
10231029
version=document_version,
10241030
document=project_document,
10251031
)
@@ -1103,6 +1109,7 @@ async def add_project_node(
11031109
node: ProjectNodeCreate,
11041110
old_struct_node: Node,
11051111
product_name: str,
1112+
client_session_id: str | None,
11061113
) -> None:
11071114
# NOTE: permission check is done currently in update_project_workbench!
11081115
partial_workbench_data: dict[NodeIDStr, Any] = {
@@ -1117,13 +1124,18 @@ async def add_project_node(
11171124
project_uuid=project_id,
11181125
product_name=product_name,
11191126
allow_workbench_changes=True,
1127+
client_session_id=client_session_id,
11201128
)
11211129
project_nodes_repo = ProjectNodesRepo(project_uuid=project_id)
11221130
async with self.engine.acquire() as conn:
11231131
await project_nodes_repo.add(conn, nodes=[node])
11241132

11251133
async def remove_project_node(
1126-
self, user_id: UserID, project_id: ProjectID, node_id: NodeID
1134+
self,
1135+
user_id: UserID,
1136+
project_id: ProjectID,
1137+
node_id: NodeID,
1138+
client_session_id: str | None,
11271139
) -> None:
11281140
# NOTE: permission check is done currently in update_project_workbench!
11291141
partial_workbench_data: dict[NodeIDStr, Any] = {
@@ -1134,6 +1146,7 @@ async def remove_project_node(
11341146
user_id=user_id,
11351147
project_uuid=project_id,
11361148
allow_workbench_changes=True,
1149+
client_session_id=client_session_id,
11371150
)
11381151
project_nodes_repo = ProjectNodesRepo(project_uuid=project_id)
11391152
async with self.engine.acquire() as conn:

0 commit comments

Comments
 (0)