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