View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.chat;
17  
18  import java.time.LocalDateTime;
19  import java.util.Iterator;
20  import java.util.Map;
21  import java.util.concurrent.ConcurrentHashMap;
22  import java.util.stream.Collectors;
23  
24  import org.apache.logging.log4j.LogManager;
25  import org.apache.logging.log4j.Logger;
26  import org.codelibs.core.timer.TimeoutManager;
27  import org.codelibs.core.timer.TimeoutTask;
28  import org.codelibs.fess.entity.ChatMessage;
29  import org.codelibs.fess.entity.ChatSession;
30  import org.codelibs.fess.util.ComponentUtil;
31  
32  import jakarta.annotation.PostConstruct;
33  import jakarta.annotation.PreDestroy;
34  
35  /**
36   * Manager class for chat sessions.
37   * Sessions are stored in memory with automatic expiration.
38   *
39   * <p><b>Note:</b> Sessions are stored in a local in-memory ConcurrentHashMap.
40   * In multi-instance deployments (e.g., behind a load balancer), sessions are
41   * not shared between instances. Use sticky sessions or an external session
42   * store if session affinity across instances is required.</p>
43   *
44   * @author FessProject
45   */
46  public class ChatSessionManager {
47  
48      private static final Logger logger = LogManager.getLogger(ChatSessionManager.class);
49  
50      private final Map<String, ChatSession> sessionCache = new ConcurrentHashMap<>();
51      private TimeoutTask cleanupTask;
52  
53      /**
54       * Default constructor.
55       */
56      public ChatSessionManager() {
57          // Default constructor
58      }
59  
60      /**
61       * Initializes the session manager and starts the cleanup scheduler.
62       */
63      @PostConstruct
64      public void init() {
65          // 5 minutes = 300 seconds
66          cleanupTask = TimeoutManager.getInstance().addTimeoutTarget(this::cleanupExpiredSessions, 300, true);
67          if (logger.isDebugEnabled()) {
68              logger.debug("Initialized ChatSessionManager");
69          }
70      }
71  
72      /**
73       * Destroys the session manager and shuts down the cleanup scheduler.
74       */
75      @PreDestroy
76      public void destroy() {
77          if (cleanupTask != null && !cleanupTask.isCanceled()) {
78              cleanupTask.cancel();
79          }
80      }
81  
82      /**
83       * Creates a new chat session.
84       *
85       * @param userId the user ID (can be null for anonymous users)
86       * @return the created session
87       */
88      public ChatSession createSession(final String userId) {
89          final ChatSession session = new ChatSession(userId);
90          sessionCache.put(session.getSessionId(), session);
91          if (logger.isDebugEnabled()) {
92              logger.debug("Created chat session: sessionId={}, userId={}", session.getSessionId(), userId);
93          }
94          enforceMaxSize();
95          return session;
96      }
97  
98      /**
99       * Finds a session by ID without updating the last accessed time.
100      *
101      * @param sessionId the session ID
102      * @return the session, or null if not found or expired
103      */
104     private ChatSession findSession(final String sessionId) {
105         final ChatSession session = sessionCache.get(sessionId);
106         if (session == null) {
107             if (logger.isDebugEnabled()) {
108                 logger.debug("Session not found. sessionId={}", sessionId);
109             }
110             return null;
111         }
112         if (isExpired(session)) {
113             sessionCache.remove(sessionId);
114             if (logger.isDebugEnabled()) {
115                 logger.debug("Session expired and removed. sessionId={}, lastAccessedAt={}", sessionId, session.getLastAccessedAt());
116             }
117             return null;
118         }
119         return session;
120     }
121 
122     /**
123      * Gets a session by ID.
124      *
125      * @param sessionId the session ID
126      * @return the session, or null if not found or expired
127      */
128     public ChatSession getSession(final String sessionId) {
129         final ChatSession session = findSession(sessionId);
130         if (session == null) {
131             return null;
132         }
133         session.touch();
134         if (logger.isDebugEnabled()) {
135             logger.debug("Session retrieved. sessionId={}, messageCount={}", sessionId, session.getMessages().size());
136         }
137         return session;
138     }
139 
140     /**
141      * Gets or creates a session.
142      *
143      * @param sessionId the session ID (can be null to create a new session)
144      * @param userId the user ID
145      * @return the existing or new session
146      */
147     public ChatSession getOrCreateSession(final String sessionId, final String userId) {
148         if (sessionId != null) {
149             final ChatSession session = findSession(sessionId);
150             if (session != null) {
151                 final String sessionUserId = session.getUserId();
152                 // Validate userId matches - prevent cross-user session access
153                 if (userId != null && !userId.equals(sessionUserId)) {
154                     logger.warn("Session userId mismatch. sessionId={}, requestUserId={}", sessionId, userId);
155                 } else if (userId == null && sessionUserId == null) {
156                     // Both null (unauthenticated + no userCode) - allow by sessionId only
157                     session.touch();
158                     if (logger.isDebugEnabled()) {
159                         logger.debug("Reusing existing session (both userId null). sessionId={}", sessionId);
160                     }
161                     return session;
162                 } else if (userId != null && userId.equals(sessionUserId)) {
163                     session.touch();
164                     if (logger.isDebugEnabled()) {
165                         logger.debug("Reusing existing session. sessionId={}, userId={}", sessionId, userId);
166                     }
167                     return session;
168                 } else {
169                     // userId is null but sessionUserId is not - create new session
170                     logger.warn("Session userId mismatch (null vs non-null). sessionId={}, sessionUserId={}", sessionId, sessionUserId);
171                 }
172             }
173         }
174         if (logger.isDebugEnabled()) {
175             logger.debug("Creating new session. requestedSessionId={}, userId={}", sessionId, userId);
176         }
177         return createSession(userId);
178     }
179 
180     /**
181      * Adds a message to a session.
182      *
183      * @param sessionId the session ID
184      * @param message the message to add
185      * @return true if the message was added, false if the session was not found
186      */
187     public boolean addMessage(final String sessionId, final ChatMessage message) {
188         final ChatSession session = getSession(sessionId);
189         if (session == null) {
190             if (logger.isDebugEnabled()) {
191                 logger.debug("Cannot add message, session not found. sessionId={}", sessionId);
192             }
193             return false;
194         }
195         session.addMessage(message);
196 
197         // Trim history if needed
198         final int maxMessages = getMaxHistoryMessages();
199         session.trimHistory(maxMessages);
200 
201         if (logger.isDebugEnabled()) {
202             logger.debug("Message added to session. sessionId={}, role={}, messageCount={}", sessionId, message.getRole(),
203                     session.getMessages().size());
204         }
205         return true;
206     }
207 
208     /**
209      * Clears the messages in a session with userId ownership check.
210      *
211      * @param sessionId the session ID
212      * @param userId the user ID for ownership verification (can be null)
213      * @return true if the session was found, owned by the user, and cleared; false otherwise
214      */
215     public boolean clearSession(final String sessionId, final String userId) {
216         final ChatSession session = findSession(sessionId);
217         if (session == null) {
218             if (logger.isDebugEnabled()) {
219                 logger.debug("Cannot clear session, not found. sessionId={}", sessionId);
220             }
221             return false;
222         }
223         // Verify ownership
224         final String sessionUserId = session.getUserId();
225         if (userId != null && !userId.equals(sessionUserId)) {
226             logger.warn("Cannot clear session, userId mismatch. sessionId={}, requestUserId={}", sessionId, userId);
227             return false;
228         }
229         if (userId == null && sessionUserId != null) {
230             logger.warn("Cannot clear session, userId mismatch (null vs non-null). sessionId={}, sessionUserId={}", sessionId,
231                     sessionUserId);
232             return false;
233         }
234         session.touch();
235         session.clearMessages();
236         if (logger.isDebugEnabled()) {
237             logger.debug("Session cleared. sessionId={}, userId={}", sessionId, userId);
238         }
239         return true;
240     }
241 
242     /**
243      * Clears the messages in a session without ownership check.
244      * Used for internal/admin operations where ownership verification is not needed.
245      *
246      * @param sessionId the session ID
247      * @return true if the session was found and cleared, false otherwise
248      */
249     public boolean clearSession(final String sessionId) {
250         final ChatSession session = getSession(sessionId);
251         if (session == null) {
252             if (logger.isDebugEnabled()) {
253                 logger.debug("Cannot clear session, not found. sessionId={}", sessionId);
254             }
255             return false;
256         }
257         session.clearMessages();
258         if (logger.isDebugEnabled()) {
259             logger.debug("Session cleared (no ownership check). sessionId={}", sessionId);
260         }
261         return true;
262     }
263 
264     /**
265      * Removes a session.
266      *
267      * @param sessionId the session ID
268      * @return the removed session, or null if not found
269      */
270     public ChatSession removeSession(final String sessionId) {
271         final ChatSession session = sessionCache.remove(sessionId);
272         if (logger.isDebugEnabled()) {
273             logger.debug("Session removed. sessionId={}, found={}", sessionId, session != null);
274         }
275         return session;
276     }
277 
278     /**
279      * Cleans up expired sessions.
280      */
281     protected void cleanupExpiredSessions() {
282         if (logger.isDebugEnabled()) {
283             logger.debug("Running chat session cleanup. currentSessionCount={}", sessionCache.size());
284         }
285 
286         int removed = 0;
287         final Iterator<Map.Entry<String, ChatSession>> iterator = sessionCache.entrySet().iterator();
288         while (iterator.hasNext()) {
289             final Map.Entry<String, ChatSession> entry = iterator.next();
290             if (isExpired(entry.getValue())) {
291                 iterator.remove();
292                 removed++;
293             }
294         }
295 
296         if (removed > 0) {
297             if (logger.isDebugEnabled()) {
298                 logger.debug("Removed expired chat sessions. removedCount={}, remainingCount={}", removed, sessionCache.size());
299             }
300         }
301     }
302 
303     /**
304      * Enforces the maximum session cache size.
305      */
306     protected void enforceMaxSize() {
307         final int maxSize = getMaxSessionSize();
308         synchronized (sessionCache) {
309             if (sessionCache.size() <= maxSize) {
310                 return;
311             }
312 
313             final int toRemove = sessionCache.size() - maxSize;
314             logger.warn("Session cache reached maximum size. Removing oldest sessions. currentSize={}, maxSize={}, removing={}",
315                     sessionCache.size(), maxSize, toRemove);
316 
317             // Remove oldest sessions
318             sessionCache.entrySet()
319                     .stream()
320                     .sorted((e1, e2) -> e1.getValue().getLastAccessedAt().compareTo(e2.getValue().getLastAccessedAt()))
321                     .limit(toRemove)
322                     .map(Map.Entry::getKey)
323                     .collect(Collectors.toList())
324                     .forEach(sessionCache::remove);
325         }
326     }
327 
328     /**
329      * Checks if a session is expired.
330      *
331      * @param session the session to check
332      * @return true if the session is expired
333      */
334     protected boolean isExpired(final ChatSession session) {
335         final int timeoutMinutes = getSessionTimeoutMinutes();
336         final LocalDateTime expirationTime = session.getLastAccessedAt().plusMinutes(timeoutMinutes);
337         return LocalDateTime.now().isAfter(expirationTime);
338     }
339 
340     /**
341      * Gets the session timeout in minutes.
342      *
343      * @return the session timeout in minutes
344      */
345     protected int getSessionTimeoutMinutes() {
346         final int value = ComponentUtil.getFessConfig().getRagChatSessionTimeoutMinutesAsInteger();
347         if (value <= 0) {
348             logger.warn("Invalid session timeout: {}. Using default: 30", value);
349             return 30;
350         }
351         return value;
352     }
353 
354     /**
355      * Gets the maximum session cache size.
356      *
357      * @return the maximum session cache size
358      */
359     protected int getMaxSessionSize() {
360         final int value = ComponentUtil.getFessConfig().getRagChatSessionMaxSizeAsInteger();
361         if (value <= 0) {
362             logger.warn("Invalid max session size: {}. Using default: 100", value);
363             return 100;
364         }
365         return value;
366     }
367 
368     /**
369      * Gets the maximum number of history messages to retain.
370      *
371      * @return the maximum number of history messages
372      */
373     protected int getMaxHistoryMessages() {
374         final int value = ComponentUtil.getFessConfig().getRagChatHistoryMaxMessagesAsInteger();
375         if (value <= 0) {
376             logger.warn("Invalid max history messages: {}. Using default: 20", value);
377             return 20;
378         }
379         return value;
380     }
381 
382     /**
383      * Gets the current number of active sessions.
384      *
385      * @return the number of active sessions
386      */
387     public int getActiveSessionCount() {
388         return sessionCache.size();
389     }
390 }