Apurva Tiwari commited on
Commit
602eeec
·
1 Parent(s): 19c073e

Implement simplified session manager with persistence

Browse files

- New SimpleSessionManager: thread-safe in-memory with filesystem persistence
- JSON index for session metadata and alias mapping
- Automatic cleanup of idle sessions (>6 hours)
- Human-readable session aliases
- Much simpler and more maintainable than previous complex system
- Updated app.py to use new manager

Files changed (2) hide show
  1. app.py +40 -82
  2. simple_session_manager.py +270 -0
app.py CHANGED
@@ -1,8 +1,8 @@
1
  """
2
- Quantum Applications - Unified Single-Server App with Multi-User Session Management
3
 
4
  This app provides EM scattering and QLBM pages in a single Trame server
5
- with support for multiple concurrent users and session persistence.
6
  """
7
  import os
8
  import errno
@@ -17,30 +17,12 @@ import threading
17
  import time
18
  import base64
19
 
20
- from session_integration import SessionIntegration
21
- from landing_page import build_landing_page_ui
22
 
23
  # Create a single server for the entire app
24
  server = get_server()
25
  state, ctrl = server.state, server.controller
26
 
27
- # --- User & Session Management ---
28
- # Generate or retrieve user ID (in production, use HF auth)
29
- def _get_or_create_user_id():
30
- """Get user ID from HF auth or create a session-specific one."""
31
- import secrets
32
-
33
- # Try to get HF username from environment (when using HF launcher)
34
- hf_user = os.environ.get("HF_USER")
35
- if hf_user:
36
- return hf_user
37
-
38
- # Fallback: create a unique ID per session
39
- return secrets.token_hex(8)
40
-
41
- user_id = _get_or_create_user_id()
42
- session_integration = SessionIntegration(user_id)
43
-
44
  # --- App state
45
  state.current_page = None # None = landing, "EM" or "QLBM"
46
  state.session_active = False
@@ -89,43 +71,29 @@ em.init_state()
89
  em.register_handlers()
90
 
91
  # --- Session Callbacks ---
92
- def on_session_selected(session_id: str, app_type: str):
93
  """Callback when user selects a session from landing page."""
94
  try:
95
- metadata, session_state = session_integration.load_session(session_id)
 
 
 
 
96
  state.current_session_id = session_id
97
- state.session_alias = metadata.alias
98
  state.session_active = True
99
-
100
- # Restore app state from session
101
- session_integration.restore_to_trame_state(state)
102
-
103
- print(f"Session loaded: {metadata.alias} ({app_type})")
104
  except Exception as e:
105
- print(f"Error loading session: {e}")
 
106
  state.session_active = False
107
 
108
- def on_app_selected(app_type: str):
109
- """Callback when user selects an app after session."""
110
- state.current_page = app_type
111
-
112
-
113
  # --- Controller to reset UI on page load ---
114
  @ctrl.add("reset_ui")
115
  def _reset_ui():
116
- """Reset UI to landing page (called when browser loads the page or new browser session detected)."""
117
- print(f"[RESET_UI] Called. Current state before reset:")
118
- print(f" current_page={state.current_page}, session_active={state.session_active}")
119
 
120
- # Save current session before resetting if one is active
121
- if state.session_active and session_integration.current_session_id:
122
- try:
123
- session_integration.save_current_session(state)
124
- print(f"[RESET_UI] Session saved: {state.session_alias}")
125
- except Exception as e:
126
- print(f"[RESET_UI WARN] Could not save session: {e}")
127
-
128
- # Reset all UI and session state to landing page
129
  state.current_page = None
130
  state.session_card_visible = True
131
  state.session_active = False
@@ -134,13 +102,6 @@ def _reset_ui():
134
  state.session_alias_input = ""
135
  state.session_error = ""
136
  state.session_action_trigger = None
137
-
138
- # Clear session integration
139
- session_integration.current_session_id = None
140
- session_integration.current_metadata = None
141
- session_integration.current_state = None
142
-
143
- print(f"[RESET_UI] State reset. New state: current_page={state.current_page}")
144
 
145
 
146
  @ctrl.add("on_page_load")
@@ -150,21 +111,17 @@ def _on_page_load(is_new_session: bool):
150
  if is_new_session:
151
  print(f"[PAGE_LOAD] New session detected. Resetting to landing page.")
152
  _reset_ui()
153
- else:
154
- print(f"[PAGE_LOAD] Page refresh detected. Keeping current state.")
155
- print(f"[PAGE_LOAD] Current state: current_page={state.current_page}, session_active={state.session_active}")
156
 
157
 
158
  # Watcher to reset navigation when no session is active
159
- # (handles case where user closes browser and comes back)
160
  @state.change("session_active")
161
  def _on_session_status_change(session_active, **kwargs):
162
  """Reset landing page if session becomes inactive."""
163
  if not session_active and state.current_page is not None:
164
- # Session became inactive; reset to landing page
165
  state.current_page = None
166
  state.session_card_visible = True
167
- print("[OK] Session inactive; reset to landing page")
 
168
 
169
  # --- Session action watchers (respond to state changes from UI) ---
170
  @state.change("session_action_trigger")
@@ -174,32 +131,34 @@ def _on_session_action(session_action_trigger, **kwargs):
174
  try:
175
  state.session_action_busy = True
176
  alias = state.session_alias_input.strip() if state.session_alias_input else f"session-{uuid.uuid4().hex[:6]}"
177
- session_id, metadata = session_integration.create_new_session(alias, app_type="MULTI")
178
- state.session_alias = metadata.alias
 
 
 
 
179
  state.session_active = True
180
  state.current_session_id = session_id
181
- state.current_page = None # ALWAYS reset to landing page
182
- state.session_alias_input = "" # Clear input field after success
183
  state.session_action_success = True
184
  state.session_error = ""
185
- print(f"[OK] Session created: {alias}")
186
 
187
- # Schedule card to hide and success icon to disappear
188
  def _hide():
189
- time.sleep(1.5) # Show success animation for 1.5 sec
190
  state.session_card_visible = False
191
  state.session_action_success = False
192
  state.session_action_busy = False
193
  state.session_action_trigger = None
194
- print("[OK] Card hidden")
195
 
196
  threading.Thread(target=_hide, daemon=True).start()
197
- return True
198
  except Exception as e:
199
  state.session_error = str(e)
200
  state.session_action_busy = False
201
  state.session_action_trigger = None
202
- print(f"[ERROR] Session create error: {e}")
203
 
204
  elif session_action_trigger == "load":
205
  try:
@@ -211,38 +170,37 @@ def _on_session_action(session_action_trigger, **kwargs):
211
  state.session_action_trigger = None
212
  return
213
 
214
- result = session_integration.load_by_alias(alias)
215
- if not result:
 
216
  state.session_error = f"No session found with alias '{alias}'."
217
  state.session_action_busy = False
218
  state.session_action_trigger = None
219
  return
220
 
221
- metadata, session_state = result
222
- state.session_alias = metadata.alias
223
  state.session_active = True
224
- state.current_session_id = session_state.session_id
225
- state.current_page = None # ALWAYS reset to landing page
226
- state.session_alias_input = "" # Clear input field after success
227
- session_integration.restore_to_trame_state(state)
228
  state.session_action_success = True
229
  state.session_error = ""
230
- print(f"[OK] Session loaded: {alias}")
231
 
232
  def _hide_load():
233
- time.sleep(1.5) # Show success animation for 1.5 sec
234
  state.session_card_visible = False
235
  state.session_action_success = False
236
  state.session_action_busy = False
237
  state.session_action_trigger = None
238
 
239
  threading.Thread(target=_hide_load, daemon=True).start()
240
- return True
241
  except Exception as e:
242
  state.session_error = str(e)
243
  state.session_action_busy = False
244
  state.session_action_trigger = None
245
- print(f"[ERROR] Session load error: {e}")
246
 
247
 
248
  # --- Build the Layout ---
 
1
  """
2
+ Quantum Applications - Unified Single-Server App with Simplified Session Management
3
 
4
  This app provides EM scattering and QLBM pages in a single Trame server
5
+ with simplified session management based on filesystem directories and persistent indices.
6
  """
7
  import os
8
  import errno
 
17
  import time
18
  import base64
19
 
20
+ from simple_session_manager import SESSION_MANAGER, get_session_by_alias
 
21
 
22
  # Create a single server for the entire app
23
  server = get_server()
24
  state, ctrl = server.state, server.controller
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  # --- App state
27
  state.current_page = None # None = landing, "EM" or "QLBM"
28
  state.session_active = False
 
71
  em.register_handlers()
72
 
73
  # --- Session Callbacks ---
74
+ def on_session_selected(session_id: str):
75
  """Callback when user selects a session from landing page."""
76
  try:
77
+ session_info = SESSION_MANAGER.get_session(session_id=session_id)
78
+ if not session_info:
79
+ state.session_error = f"Session not found: {session_id}"
80
+ return
81
+
82
  state.current_session_id = session_id
83
+ state.session_alias = session_info.get("alias", "Untitled")
84
  state.session_active = True
85
+ print(f"[APP] Session selected: {state.session_alias} ({session_id})")
 
 
 
 
86
  except Exception as e:
87
+ print(f"[APP] Error selecting session: {e}")
88
+ state.session_error = str(e)
89
  state.session_active = False
90
 
 
 
 
 
 
91
  # --- Controller to reset UI on page load ---
92
  @ctrl.add("reset_ui")
93
  def _reset_ui():
94
+ """Reset UI to landing page (called when browser closes/reopens)."""
95
+ print(f"[RESET_UI] Resetting to landing page")
 
96
 
 
 
 
 
 
 
 
 
 
97
  state.current_page = None
98
  state.session_card_visible = True
99
  state.session_active = False
 
102
  state.session_alias_input = ""
103
  state.session_error = ""
104
  state.session_action_trigger = None
 
 
 
 
 
 
 
105
 
106
 
107
  @ctrl.add("on_page_load")
 
111
  if is_new_session:
112
  print(f"[PAGE_LOAD] New session detected. Resetting to landing page.")
113
  _reset_ui()
 
 
 
114
 
115
 
116
  # Watcher to reset navigation when no session is active
 
117
  @state.change("session_active")
118
  def _on_session_status_change(session_active, **kwargs):
119
  """Reset landing page if session becomes inactive."""
120
  if not session_active and state.current_page is not None:
 
121
  state.current_page = None
122
  state.session_card_visible = True
123
+ print("[APP] Session inactive; reset to landing page")
124
+
125
 
126
  # --- Session action watchers (respond to state changes from UI) ---
127
  @state.change("session_action_trigger")
 
131
  try:
132
  state.session_action_busy = True
133
  alias = state.session_alias_input.strip() if state.session_alias_input else f"session-{uuid.uuid4().hex[:6]}"
134
+
135
+ # Create new session
136
+ session_info = SESSION_MANAGER.create_session(alias=alias)
137
+ session_id = session_info["session_id"]
138
+
139
+ state.session_alias = session_info.get("alias", "Untitled")
140
  state.session_active = True
141
  state.current_session_id = session_id
142
+ state.current_page = None
143
+ state.session_alias_input = ""
144
  state.session_action_success = True
145
  state.session_error = ""
146
+ print(f"[APP] Session created: {alias} ({session_id})")
147
 
148
+ # Hide card after success animation
149
  def _hide():
150
+ time.sleep(1.5)
151
  state.session_card_visible = False
152
  state.session_action_success = False
153
  state.session_action_busy = False
154
  state.session_action_trigger = None
 
155
 
156
  threading.Thread(target=_hide, daemon=True).start()
 
157
  except Exception as e:
158
  state.session_error = str(e)
159
  state.session_action_busy = False
160
  state.session_action_trigger = None
161
+ print(f"[APP] Error creating session: {e}")
162
 
163
  elif session_action_trigger == "load":
164
  try:
 
170
  state.session_action_trigger = None
171
  return
172
 
173
+ # Load session by alias
174
+ session_info = get_session_by_alias(alias)
175
+ if not session_info:
176
  state.session_error = f"No session found with alias '{alias}'."
177
  state.session_action_busy = False
178
  state.session_action_trigger = None
179
  return
180
 
181
+ session_id = session_info["session_id"]
182
+ state.session_alias = session_info.get("alias", "Untitled")
183
  state.session_active = True
184
+ state.current_session_id = session_id
185
+ state.current_page = None
186
+ state.session_alias_input = ""
 
187
  state.session_action_success = True
188
  state.session_error = ""
189
+ print(f"[APP] Session loaded: {alias} ({session_id})")
190
 
191
  def _hide_load():
192
+ time.sleep(1.5)
193
  state.session_card_visible = False
194
  state.session_action_success = False
195
  state.session_action_busy = False
196
  state.session_action_trigger = None
197
 
198
  threading.Thread(target=_hide_load, daemon=True).start()
 
199
  except Exception as e:
200
  state.session_error = str(e)
201
  state.session_action_busy = False
202
  state.session_action_trigger = None
203
+ print(f"[APP] Error loading session: {e}")
204
 
205
 
206
  # --- Build the Layout ---
simple_session_manager.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Simplified Session Manager (inspired by Udbhav's approach)
3
+
4
+ Features:
5
+ - In-memory session tracking with thread-safe RLock
6
+ - Simple filesystem-based storage (one directory per session)
7
+ - Persistent session index (JSON) and alias map
8
+ - Automatic cleanup of idle sessions (>6 hours)
9
+ - Human-readable aliases for sessions
10
+ """
11
+
12
+ import os
13
+ import json
14
+ import shutil
15
+ import threading
16
+ import uuid
17
+ import time
18
+ from datetime import datetime, timedelta
19
+ from pathlib import Path
20
+ from typing import Dict, Optional
21
+
22
+ # Base directory for all sessions (HF persistent storage)
23
+ BASE_DATA_DIR = Path("/tmp/outputs/sessions")
24
+
25
+ # Session index file (persisted to disk)
26
+ SESSIONS_INDEX_FILE = BASE_DATA_DIR / "sessions_index.json"
27
+
28
+ # Alias map file (maps friendly names to session IDs)
29
+ ALIAS_MAP_FILE = BASE_DATA_DIR / "alias_map.json"
30
+
31
+
32
+ class SimpleSessionManager:
33
+ """
34
+ Thread-safe session manager with persistence and auto-cleanup.
35
+ """
36
+
37
+ def __init__(self):
38
+ self.sessions: Dict[str, dict] = {}
39
+ self.alias_map: Dict[str, str] = {} # alias -> session_id
40
+ self._lock = threading.RLock()
41
+ self._cleanup_thread = None
42
+
43
+ # Ensure directories exist
44
+ BASE_DATA_DIR.mkdir(parents=True, exist_ok=True)
45
+ print(f"[SESSION] Base directory: {BASE_DATA_DIR}")
46
+
47
+ # Load persisted data
48
+ self._load_index()
49
+ self._load_alias_map()
50
+
51
+ # Start cleanup thread
52
+ self._start_cleanup_thread()
53
+
54
+ def _load_index(self):
55
+ """Load sessions index from disk."""
56
+ if SESSIONS_INDEX_FILE.exists():
57
+ try:
58
+ with open(SESSIONS_INDEX_FILE, 'r') as f:
59
+ data = json.load(f)
60
+ self.sessions = data
61
+ print(f"[SESSION] Loaded {len(self.sessions)} sessions from index")
62
+ except Exception as e:
63
+ print(f"[SESSION] Error loading index: {e}")
64
+
65
+ def _load_alias_map(self):
66
+ """Load alias map from disk."""
67
+ if ALIAS_MAP_FILE.exists():
68
+ try:
69
+ with open(ALIAS_MAP_FILE, 'r') as f:
70
+ self.alias_map = json.load(f)
71
+ print(f"[SESSION] Loaded {len(self.alias_map)} aliases")
72
+ except Exception as e:
73
+ print(f"[SESSION] Error loading alias map: {e}")
74
+
75
+ def _save_index(self):
76
+ """Save sessions index to disk."""
77
+ try:
78
+ SESSIONS_INDEX_FILE.parent.mkdir(parents=True, exist_ok=True)
79
+ with open(SESSIONS_INDEX_FILE, 'w') as f:
80
+ json.dump(self.sessions, f, indent=2, default=str)
81
+ except Exception as e:
82
+ print(f"[SESSION] Error saving index: {e}")
83
+
84
+ def _save_alias_map(self):
85
+ """Save alias map to disk."""
86
+ try:
87
+ ALIAS_MAP_FILE.parent.mkdir(parents=True, exist_ok=True)
88
+ with open(ALIAS_MAP_FILE, 'w') as f:
89
+ json.dump(self.alias_map, f, indent=2)
90
+ except Exception as e:
91
+ print(f"[SESSION] Error saving alias map: {e}")
92
+
93
+ def _new_session(self, session_id: Optional[str] = None, alias: Optional[str] = None) -> Dict:
94
+ """Create a new session directory structure."""
95
+ if session_id is None:
96
+ session_id = str(uuid.uuid4())
97
+
98
+ session_root = BASE_DATA_DIR / f"user_{session_id}"
99
+
100
+ # Create subdirectories
101
+ subdirs = {
102
+ "data": session_root / "data",
103
+ "cache": session_root / "cache",
104
+ "results": session_root / "results",
105
+ }
106
+
107
+ for dir_path in subdirs.values():
108
+ dir_path.mkdir(parents=True, exist_ok=True)
109
+
110
+ session_info = {
111
+ "session_id": session_id,
112
+ "root": str(session_root),
113
+ "subdirs": {k: str(v) for k, v in subdirs.items()},
114
+ "created_at": datetime.now().isoformat(),
115
+ "last_accessed": datetime.now().isoformat(),
116
+ "alias": alias,
117
+ }
118
+
119
+ print(f"[SESSION] Created new session: {session_id} (alias: {alias})")
120
+ return session_info
121
+
122
+ def create_session(self, alias: Optional[str] = None) -> Dict:
123
+ """
124
+ Create a new session with optional alias.
125
+
126
+ Args:
127
+ alias: Human-readable name for the session
128
+
129
+ Returns:
130
+ Session info dict
131
+ """
132
+ with self._lock:
133
+ # If alias exists, return existing session
134
+ if alias and alias in self.alias_map:
135
+ session_id = self.alias_map[alias]
136
+ if session_id in self.sessions:
137
+ print(f"[SESSION] Session '{alias}' already exists: {session_id}")
138
+ self.sessions[session_id]["last_accessed"] = datetime.now().isoformat()
139
+ self._save_index()
140
+ return self.sessions[session_id]
141
+
142
+ # Create new session
143
+ session_id = str(uuid.uuid4())
144
+ session_info = self._new_session(session_id, alias)
145
+
146
+ self.sessions[session_id] = session_info
147
+
148
+ if alias:
149
+ self.alias_map[alias] = session_id
150
+ self._save_alias_map()
151
+
152
+ self._save_index()
153
+ return session_info
154
+
155
+ def get_session(self, session_id: Optional[str] = None, alias: Optional[str] = None) -> Optional[Dict]:
156
+ """
157
+ Get session by ID or alias.
158
+
159
+ Args:
160
+ session_id: Session UUID
161
+ alias: Human-readable session name
162
+
163
+ Returns:
164
+ Session info dict or None
165
+ """
166
+ with self._lock:
167
+ # Lookup by alias first
168
+ if alias:
169
+ session_id = self.alias_map.get(alias)
170
+
171
+ if not session_id:
172
+ return None
173
+
174
+ session_info = self.sessions.get(session_id)
175
+ if session_info:
176
+ session_info["last_accessed"] = datetime.now().isoformat()
177
+ self._save_index()
178
+
179
+ return session_info
180
+
181
+ def list_sessions(self) -> Dict[str, Dict]:
182
+ """List all active sessions."""
183
+ with self._lock:
184
+ return {alias: self.sessions[sid] for alias, sid in self.alias_map.items() if sid in self.sessions}
185
+
186
+ def delete_session(self, session_id: str) -> bool:
187
+ """Delete a session and its files."""
188
+ with self._lock:
189
+ session_info = self.sessions.pop(session_id, None)
190
+ if not session_info:
191
+ return False
192
+
193
+ # Remove alias mapping
194
+ for alias, sid in list(self.alias_map.items()):
195
+ if sid == session_id:
196
+ self.alias_map.pop(alias)
197
+
198
+ # Remove directory
199
+ session_root = Path(session_info["root"])
200
+ try:
201
+ if session_root.exists():
202
+ shutil.rmtree(session_root)
203
+ print(f"[SESSION] Deleted session directory: {session_root}")
204
+ except Exception as e:
205
+ print(f"[SESSION] Error deleting {session_root}: {e}")
206
+
207
+ self._save_index()
208
+ self._save_alias_map()
209
+ return True
210
+
211
+ def _cleanup_old(self, max_age_hours: int = 6):
212
+ """Remove sessions idle longer than max_age_hours."""
213
+ now = datetime.now()
214
+ to_remove = []
215
+
216
+ with self._lock:
217
+ for session_id, session_info in self.sessions.items():
218
+ last_accessed = datetime.fromisoformat(session_info["last_accessed"])
219
+ if now - last_accessed > timedelta(hours=max_age_hours):
220
+ to_remove.append(session_id)
221
+
222
+ for session_id in to_remove:
223
+ print(f"[SESSION] Cleaning up idle session: {session_id}")
224
+ self.delete_session(session_id)
225
+
226
+ def _start_cleanup_thread(self):
227
+ """Start background cleanup thread."""
228
+ if self._cleanup_thread and self._cleanup_thread.is_alive():
229
+ return
230
+
231
+ def loop():
232
+ while True:
233
+ try:
234
+ # Run cleanup every hour, remove sessions idle > 6 hours
235
+ time.sleep(3600)
236
+ self._cleanup_old(max_age_hours=6)
237
+ except Exception as e:
238
+ print(f"[SESSION] Cleanup error: {e}")
239
+
240
+ self._cleanup_thread = threading.Thread(target=loop, daemon=True)
241
+ self._cleanup_thread.start()
242
+ print("[SESSION] Cleanup thread started")
243
+
244
+
245
+ # Global session manager instance
246
+ SESSION_MANAGER = SimpleSessionManager()
247
+
248
+
249
+ def get_or_create_session(alias: Optional[str] = None) -> Dict:
250
+ """Convenience function to get or create a session."""
251
+ if alias:
252
+ session = SESSION_MANAGER.get_session(alias=alias)
253
+ if session:
254
+ return session
255
+ return SESSION_MANAGER.create_session(alias=alias)
256
+
257
+
258
+ def get_session(session_id: str) -> Optional[Dict]:
259
+ """Get session by ID."""
260
+ return SESSION_MANAGER.get_session(session_id=session_id)
261
+
262
+
263
+ def get_session_by_alias(alias: str) -> Optional[Dict]:
264
+ """Get session by alias."""
265
+ return SESSION_MANAGER.get_session(alias=alias)
266
+
267
+
268
+ def list_all_sessions() -> Dict[str, Dict]:
269
+ """List all sessions."""
270
+ return SESSION_MANAGER.list_sessions()