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