View Javadoc

1   // ========================================================================
2   // Copyright (c) 2008-2009 Mort Bay Consulting Pty. Ltd.
3   // ------------------------------------------------------------------------
4   // All rights reserved. This program and the accompanying materials
5   // are made available under the terms of the Eclipse Public License v1.0
6   // and Apache License v2.0 which accompanies this distribution.
7   // The Eclipse Public License is available at 
8   // http://www.eclipse.org/legal/epl-v10.html
9   // The Apache License v2.0 is available at
10  // http://www.opensource.org/licenses/apache2.0.php
11  // You may elect to redistribute this code under either of these licenses. 
12  // ========================================================================
13  
14  package org.eclipse.jetty.server.session;
15  
16  import java.io.ByteArrayInputStream;
17  import java.io.InputStream;
18  import java.sql.Blob;
19  import java.sql.Connection;
20  import java.sql.DatabaseMetaData;
21  import java.sql.DriverManager;
22  import java.sql.PreparedStatement;
23  import java.sql.ResultSet;
24  import java.sql.SQLException;
25  import java.sql.Statement;
26  import java.util.ArrayList;
27  import java.util.HashSet;
28  import java.util.List;
29  import java.util.Random;
30  import java.util.Timer;
31  import java.util.TimerTask;
32  
33  import javax.naming.InitialContext;
34  import javax.servlet.http.HttpServletRequest;
35  import javax.servlet.http.HttpSession;
36  import javax.sql.DataSource;
37  
38  import org.eclipse.jetty.server.Handler;
39  import org.eclipse.jetty.server.Server;
40  import org.eclipse.jetty.server.SessionManager;
41  import org.eclipse.jetty.server.handler.ContextHandler;
42  import org.eclipse.jetty.util.log.Logger;
43  
44  
45  
46  /**
47   * JDBCSessionIdManager
48   *
49   * SessionIdManager implementation that uses a database to store in-use session ids, 
50   * to support distributed sessions.
51   * 
52   */
53  public class JDBCSessionIdManager extends AbstractSessionIdManager
54  {    
55      final static Logger __log = SessionHandler.__log;
56      
57      protected final HashSet<String> _sessionIds = new HashSet<String>();
58      protected Server _server;
59      protected String _driverClassName;
60      protected String _connectionUrl;
61      protected DataSource _datasource;
62      protected String _jndiName;
63      protected String _sessionIdTable = "JettySessionIds";
64      protected String _sessionTable = "JettySessions";
65      protected String _sessionTableRowId = "rowId";
66      
67      protected Timer _timer; //scavenge timer
68      protected TimerTask _task; //scavenge task
69      protected long _lastScavengeTime;
70      protected long _scavengeIntervalMs = 1000 * 60 * 10; //10mins
71      protected String _blobType; //if not set, is deduced from the type of the database at runtime
72      
73      protected String _createSessionIdTable;
74      protected String _createSessionTable;
75                                              
76      protected String _selectExpiredSessions;
77      protected String _deleteOldExpiredSessions;
78  
79      protected String _insertId;
80      protected String _deleteId;
81      protected String _queryId;
82      
83      protected DatabaseAdaptor _dbAdaptor;
84  
85      
86      /**
87       * DatabaseAdaptor
88       *
89       * Handles differences between databases.
90       * 
91       * Postgres uses the getBytes and setBinaryStream methods to access
92       * a "bytea" datatype, which can be up to 1Gb of binary data. MySQL
93       * is happy to use the "blob" type and getBlob() methods instead.
94       * 
95       * TODO if the differences become more major it would be worthwhile
96       * refactoring this class.
97       */
98      public class DatabaseAdaptor 
99      {
100         String _dbName;
101         boolean _isLower;
102         boolean _isUpper;
103         
104         
105         public DatabaseAdaptor (DatabaseMetaData dbMeta)
106         throws SQLException
107         {
108             _dbName = dbMeta.getDatabaseProductName().toLowerCase(); 
109             __log.debug ("Using database "+_dbName);
110             _isLower = dbMeta.storesLowerCaseIdentifiers();
111             _isUpper = dbMeta.storesUpperCaseIdentifiers();
112         }
113         
114         /**
115          * Convert a camel case identifier into either upper or lower
116          * depending on the way the db stores identifiers.
117          * 
118          * @param identifier
119          * @return the converted identifier
120          */
121         public String convertIdentifier (String identifier)
122         {
123             if (_isLower)
124                 return identifier.toLowerCase();
125             if (_isUpper)
126                 return identifier.toUpperCase();
127             
128             return identifier;
129         }
130         
131         public String getDBName ()
132         {
133             return _dbName;
134         }
135         
136         public String getBlobType ()
137         {
138             if (_blobType != null)
139                 return _blobType;
140             
141             if (_dbName.startsWith("postgres"))
142                 return "bytea";
143             
144             return "blob";
145         }
146         
147         public InputStream getBlobInputStream (ResultSet result, String columnName)
148         throws SQLException
149         {
150             if (_dbName.startsWith("postgres"))
151             {
152                 byte[] bytes = result.getBytes(columnName);
153                 return new ByteArrayInputStream(bytes);
154             }
155             
156             Blob blob = result.getBlob(columnName);
157             return blob.getBinaryStream();
158         }
159     }
160     
161     
162     
163     public JDBCSessionIdManager(Server server)
164     {
165         super();
166         _server=server;
167     }
168     
169     public JDBCSessionIdManager(Server server, Random random)
170     {
171        super(random);
172        _server=server;
173     }
174 
175     /**
176      * Configure jdbc connection information via a jdbc Driver
177      * 
178      * @param driverClassName
179      * @param connectionUrl
180      */
181     public void setDriverInfo (String driverClassName, String connectionUrl)
182     {
183         _driverClassName=driverClassName;
184         _connectionUrl=connectionUrl;
185     }
186     
187     public String getDriverClassName()
188     {
189         return _driverClassName;
190     }
191     
192     public String getConnectionUrl ()
193     {
194         return _connectionUrl;
195     }
196     
197     public void setDatasourceName (String jndi)
198     {
199         _jndiName=jndi;
200     }
201     
202     public String getDatasourceName ()
203     {
204         return _jndiName;
205     }
206    
207     public void setBlobType (String name)
208     {
209         _blobType = name;
210     }
211     
212     public String getBlobType ()
213     {
214         return _blobType;
215     }
216     
217     public void setScavengeInterval (long sec)
218     {
219         if (sec<=0)
220             sec=60;
221 
222         long old_period=_scavengeIntervalMs;
223         long period=sec*1000;
224       
225         _scavengeIntervalMs=period;
226         
227         //add a bit of variability into the scavenge time so that not all
228         //nodes with the same scavenge time sync up
229         long tenPercent = _scavengeIntervalMs/10;
230         if ((System.currentTimeMillis()%2) == 0)
231             _scavengeIntervalMs += tenPercent;
232         
233         if (__log.isDebugEnabled()) __log.debug("Scavenging every "+_scavengeIntervalMs+" ms");
234         if (_timer!=null && (period!=old_period || _task==null))
235         {
236             synchronized (this)
237             {
238                 if (_task!=null)
239                     _task.cancel();
240                 _task = new TimerTask()
241                 {
242                     @Override
243                     public void run()
244                     {
245                         scavenge();
246                     }   
247                 };
248                 _timer.schedule(_task,_scavengeIntervalMs,_scavengeIntervalMs);
249             }
250         }  
251     }
252     
253     public long getScavengeInterval ()
254     {
255         return _scavengeIntervalMs/1000;
256     }
257     
258     
259     public void addSession(HttpSession session)
260     {
261         if (session == null)
262             return;
263         
264         synchronized (_sessionIds)
265         {
266             String id = ((JDBCSessionManager.Session)session).getClusterId();            
267             try
268             {
269                 insert(id);
270                 _sessionIds.add(id);
271             }
272             catch (Exception e)
273             {
274                 __log.warn("Problem storing session id="+id, e);
275             }
276         }
277     }
278     
279     public void removeSession(HttpSession session)
280     {
281         if (session == null)
282             return;
283         
284         removeSession(((JDBCSessionManager.Session)session).getClusterId());
285     }
286     
287     
288     
289     public void removeSession (String id)
290     {
291 
292         if (id == null)
293             return;
294         
295         synchronized (_sessionIds)
296         {  
297             if (__log.isDebugEnabled())
298                 __log.debug("Removing session id="+id);
299             try
300             {               
301                 _sessionIds.remove(id);
302                 delete(id);
303             }
304             catch (Exception e)
305             {
306                 __log.warn("Problem removing session id="+id, e);
307             }
308         }
309         
310     }
311     
312 
313     /** 
314      * Get the session id without any node identifier suffix.
315      * 
316      * @see org.eclipse.jetty.server.SessionIdManager#getClusterId(java.lang.String)
317      */
318     public String getClusterId(String nodeId)
319     {
320         int dot=nodeId.lastIndexOf('.');
321         return (dot>0)?nodeId.substring(0,dot):nodeId;
322     }
323     
324 
325     /** 
326      * Get the session id, including this node's id as a suffix.
327      * 
328      * @see org.eclipse.jetty.server.SessionIdManager#getNodeId(java.lang.String, javax.servlet.http.HttpServletRequest)
329      */
330     public String getNodeId(String clusterId, HttpServletRequest request)
331     {
332         if (_workerName!=null)
333             return clusterId+'.'+_workerName;
334 
335         return clusterId;
336     }
337 
338 
339     public boolean idInUse(String id)
340     {
341         if (id == null)
342             return false;
343         
344         String clusterId = getClusterId(id);
345         boolean inUse = false;
346         synchronized (_sessionIds)
347         {
348             inUse = _sessionIds.contains(clusterId);
349         }
350         
351         if (inUse)
352             return true; //optimisation - if this session is one we've been managing, we can check locally
353 
354         //otherwise, we need to go to the database to check
355         try
356         {
357             return exists(clusterId);
358         }
359         catch (Exception e)
360         {
361             __log.warn("Problem checking inUse for id="+clusterId, e);
362             return false;
363         }
364     }
365 
366     /** 
367      * Invalidate the session matching the id on all contexts.
368      * 
369      * @see org.eclipse.jetty.server.SessionIdManager#invalidateAll(java.lang.String)
370      */
371     public void invalidateAll(String id)
372     {            
373         //take the id out of the list of known sessionids for this node
374         removeSession(id);
375         
376         synchronized (_sessionIds)
377         {
378             //tell all contexts that may have a session object with this id to
379             //get rid of them
380             Handler[] contexts = _server.getChildHandlersByClass(ContextHandler.class);
381             for (int i=0; contexts!=null && i<contexts.length; i++)
382             {
383                 SessionHandler sessionHandler = (SessionHandler)((ContextHandler)contexts[i]).getChildHandlerByClass(SessionHandler.class);
384                 if (sessionHandler != null) 
385                 {
386                     SessionManager manager = sessionHandler.getSessionManager();
387 
388                     if (manager != null && manager instanceof JDBCSessionManager)
389                     {
390                         ((JDBCSessionManager)manager).invalidateSession(id);
391                     }
392                 }
393             }
394         }
395     }
396 
397 
398     /** 
399      * Start up the id manager.
400      * 
401      * Makes necessary database tables and starts a Session
402      * scavenger thread.
403      */
404     @Override
405     public void doStart()
406     {
407         try
408         {            
409             initializeDatabase();
410             prepareTables();        
411             super.doStart();
412             if (__log.isDebugEnabled()) __log.debug("Scavenging interval = "+getScavengeInterval()+" sec");
413             _timer=new Timer("JDBCSessionScavenger", true);
414             setScavengeInterval(getScavengeInterval());
415         }
416         catch (Exception e)
417         {
418             __log.warn("Problem initialising JettySessionIds table", e);
419         }
420     }
421     
422     /** 
423      * Stop the scavenger.
424      */
425     @Override
426     public void doStop () 
427     throws Exception
428     {
429         synchronized(this)
430         {
431             if (_task!=null)
432                 _task.cancel();
433             if (_timer!=null)
434                 _timer.cancel();
435             _timer=null;
436         }
437         super.doStop();
438     }
439   
440     /**
441      * Get a connection from the driver or datasource.
442      * 
443      * @return the connection for the datasource
444      * @throws SQLException
445      */
446     protected Connection getConnection ()
447     throws SQLException
448     {
449         if (_datasource != null)
450             return _datasource.getConnection();
451         else
452             return DriverManager.getConnection(_connectionUrl);
453     }
454 
455     
456     private void initializeDatabase ()
457     throws Exception
458     {
459         if (_jndiName!=null)
460         {
461             InitialContext ic = new InitialContext();
462             _datasource = (DataSource)ic.lookup(_jndiName);
463         }
464         else if (_driverClassName!=null && _connectionUrl!=null)
465         {
466             Class.forName(_driverClassName);
467         }
468         else
469             throw new IllegalStateException("No database configured for sessions");
470     }
471     
472     
473     
474     /**
475      * Set up the tables in the database
476      * @throws SQLException
477      */
478     private void prepareTables()
479     throws SQLException
480     {
481         _createSessionIdTable = "create table "+_sessionIdTable+" (id varchar(120), primary key(id))";
482         _selectExpiredSessions = "select * from "+_sessionTable+" where expiryTime >= ? and expiryTime <= ?";
483         _deleteOldExpiredSessions = "delete from "+_sessionTable+" where expiryTime >0 and expiryTime <= ?";
484 
485         _insertId = "insert into "+_sessionIdTable+" (id)  values (?)";
486         _deleteId = "delete from "+_sessionIdTable+" where id = ?";
487         _queryId = "select * from "+_sessionIdTable+" where id = ?";
488 
489         Connection connection = null;
490         try
491         {
492             //make the id table
493             connection = getConnection();
494             connection.setAutoCommit(true);
495             DatabaseMetaData metaData = connection.getMetaData();
496             _dbAdaptor = new DatabaseAdaptor(metaData);
497             _sessionTableRowId = (_dbAdaptor.getDBName() != null && _dbAdaptor.getDBName().contains("oracle") ? "srowId":_sessionTableRowId);
498 
499             //checking for table existence is case-sensitive, but table creation is not
500             String tableName = _dbAdaptor.convertIdentifier(_sessionIdTable);
501             ResultSet result = metaData.getTables(null, null, tableName, null);
502             if (!result.next())
503             {
504                 //table does not exist, so create it
505                 connection.createStatement().executeUpdate(_createSessionIdTable);
506             }
507             
508             //make the session table if necessary
509             tableName = _dbAdaptor.convertIdentifier(_sessionTable);   
510             result = metaData.getTables(null, null, tableName, null);
511             if (!result.next())
512             {
513                 //table does not exist, so create it
514                 String blobType = _dbAdaptor.getBlobType();
515                 _createSessionTable = "create table "+_sessionTable+" ("+_sessionTableRowId+" varchar(120), sessionId varchar(120), "+
516                                            " contextPath varchar(60), virtualHost varchar(60), lastNode varchar(60), accessTime bigint, "+
517                                            " lastAccessTime bigint, createTime bigint, cookieTime bigint, "+
518                                            " lastSavedTime bigint, expiryTime bigint, map "+blobType+", primary key("+_sessionTableRowId+"))";
519                 connection.createStatement().executeUpdate(_createSessionTable);
520             }
521             
522             //make some indexes on the JettySessions table
523             String index1 = "idx_"+_sessionTable+"_expiry";
524             String index2 = "idx_"+_sessionTable+"_session";
525             
526             result = metaData.getIndexInfo(null, null, tableName, false, false);
527             boolean index1Exists = false;
528             boolean index2Exists = false;
529             while (result.next())
530             {
531                 String idxName = result.getString("INDEX_NAME");
532                 if (index1.equalsIgnoreCase(idxName))
533                     index1Exists = true;
534                 else if (index2.equalsIgnoreCase(idxName))
535                     index2Exists = true;
536             }
537             if (!(index1Exists && index2Exists))
538             {
539                 Statement statement = connection.createStatement();
540                 if (!index1Exists)
541                     statement.executeUpdate("create index "+index1+" on "+_sessionTable+" (expiryTime)");
542                 if (!index2Exists)
543                     statement.executeUpdate("create index "+index2+" on "+_sessionTable+" (sessionId, contextPath)");
544             }
545         }
546         finally
547         {
548             if (connection != null)
549                 connection.close();
550         }
551     }
552     
553     /**
554      * Insert a new used session id into the table.
555      * 
556      * @param id
557      * @throws SQLException
558      */
559     private void insert (String id)
560     throws SQLException 
561     {
562         Connection connection = null;
563         try
564         {
565             connection = getConnection();
566             connection.setAutoCommit(true);            
567             PreparedStatement query = connection.prepareStatement(_queryId);
568             query.setString(1, id);
569             ResultSet result = query.executeQuery();
570             //only insert the id if it isn't in the db already 
571             if (!result.next())
572             {
573                 PreparedStatement statement = connection.prepareStatement(_insertId);
574                 statement.setString(1, id);
575                 statement.executeUpdate();
576             }
577         }
578         finally
579         {
580             if (connection != null)
581                 connection.close();
582         }
583     }
584     
585     /**
586      * Remove a session id from the table.
587      * 
588      * @param id
589      * @throws SQLException
590      */
591     private void delete (String id)
592     throws SQLException
593     {
594         Connection connection = null;
595         try
596         {
597             connection = getConnection();
598             connection.setAutoCommit(true);
599             PreparedStatement statement = connection.prepareStatement(_deleteId);
600             statement.setString(1, id);
601             statement.executeUpdate();
602         }
603         finally
604         {
605             if (connection != null)
606                 connection.close();
607         }
608     }
609     
610     
611     /**
612      * Check if a session id exists.
613      * 
614      * @param id
615      * @return
616      * @throws SQLException
617      */
618     private boolean exists (String id)
619     throws SQLException
620     {
621         Connection connection = null;
622         try
623         {
624             connection = getConnection();
625             connection.setAutoCommit(true);
626             PreparedStatement statement = connection.prepareStatement(_queryId);
627             statement.setString(1, id);
628             ResultSet result = statement.executeQuery();
629             return result.next();
630         }
631         finally
632         {
633             if (connection != null)
634                 connection.close();
635         }
636     }
637     
638     /**
639      * Look for sessions in the database that have expired.
640      * 
641      * We do this in the SessionIdManager and not the SessionManager so
642      * that we only have 1 scavenger, otherwise if there are n SessionManagers
643      * there would be n scavengers, all contending for the database.
644      * 
645      * We look first for sessions that expired in the previous interval, then
646      * for sessions that expired previously - these are old sessions that no
647      * node is managing any more and have become stuck in the database.
648      */
649     private void scavenge ()
650     {
651         Connection connection = null;
652         List<String> expiredSessionIds = new ArrayList<String>();
653         try
654         {            
655             if (__log.isDebugEnabled()) __log.debug("Scavenge sweep started at "+System.currentTimeMillis());
656             if (_lastScavengeTime > 0)
657             {
658                 connection = getConnection();
659                 connection.setAutoCommit(true);
660                 //"select sessionId from JettySessions where expiryTime > (lastScavengeTime - scanInterval) and expiryTime < lastScavengeTime";
661                 PreparedStatement statement = connection.prepareStatement(_selectExpiredSessions);
662                 long lowerBound = (_lastScavengeTime - _scavengeIntervalMs);
663                 long upperBound = _lastScavengeTime;
664                 if (__log.isDebugEnabled()) __log.debug (" Searching for sessions expired between "+lowerBound + " and "+upperBound);
665                 
666                 statement.setLong(1, lowerBound);
667                 statement.setLong(2, upperBound);
668                 ResultSet result = statement.executeQuery();
669                 while (result.next())
670                 {
671                     String sessionId = result.getString("sessionId");
672                     expiredSessionIds.add(sessionId);
673                     if (__log.isDebugEnabled()) __log.debug (" Found expired sessionId="+sessionId); 
674                 }
675 
676                 //tell the SessionManagers to expire any sessions with a matching sessionId in memory
677                 Handler[] contexts = _server.getChildHandlersByClass(ContextHandler.class);
678                 for (int i=0; contexts!=null && i<contexts.length; i++)
679                 {
680 
681                     SessionHandler sessionHandler = (SessionHandler)((ContextHandler)contexts[i]).getChildHandlerByClass(SessionHandler.class);
682                     if (sessionHandler != null) 
683                     { 
684                         SessionManager manager = sessionHandler.getSessionManager();
685                         if (manager != null && manager instanceof JDBCSessionManager)
686                         {
687                             ((JDBCSessionManager)manager).expire(expiredSessionIds);
688                         }
689                     }
690                 }
691 
692                 //find all sessions that have expired at least a couple of scanIntervals ago and just delete them
693                 upperBound = _lastScavengeTime - (2 * _scavengeIntervalMs);
694                 if (upperBound > 0)
695                 {
696                     if (__log.isDebugEnabled()) __log.debug("Deleting old expired sessions expired before "+upperBound);
697                     statement = connection.prepareStatement(_deleteOldExpiredSessions);
698                     statement.setLong(1, upperBound);
699                     statement.executeUpdate();
700                 }
701             }
702         }
703         catch (Exception e)
704         {
705             __log.warn("Problem selecting expired sessions", e);
706         }
707         finally
708         {           
709             _lastScavengeTime=System.currentTimeMillis();
710             if (__log.isDebugEnabled()) __log.debug("Scavenge sweep ended at "+_lastScavengeTime);
711             if (connection != null)
712             {
713                 try
714                 {
715                 connection.close();
716                 }
717                 catch (SQLException e)
718                 {
719                     __log.warn(e);
720                 }
721             }
722         }
723     }
724 }