View Javadoc
1   /*
2    * Tentackle - https://tentackle.org
3    *
4    * This library is free software; you can redistribute it and/or
5    * modify it under the terms of the GNU Lesser General Public
6    * License as published by the Free Software Foundation; either
7    * version 2.1 of the License, or (at your option) any later version.
8    *
9    * This library is distributed in the hope that it will be useful,
10   * but WITHOUT ANY WARRANTY; without even the implied warranty of
11   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
12   * Lesser General Public License for more details.
13   *
14   * You should have received a copy of the GNU Lesser General Public
15   * License along with this library; if not, write to the Free Software
16   * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
17   */
18  
19  
20  package org.tentackle.dbms.trip;
21  
22  import org.tentackle.daemon.Scavenger;
23  import org.tentackle.dbms.Db;
24  import org.tentackle.log.Logger;
25  
26  import java.lang.ref.WeakReference;
27  import java.util.HashMap;
28  import java.util.HashSet;
29  import java.util.Iterator;
30  import java.util.LinkedHashMap;
31  import java.util.Map;
32  import java.util.Set;
33  
34  
35  /**
36   * Cleans up dead or crashed sessions.
37   *
38   * @author harald
39   */
40  public class RemoteDbSessionCleanupThread extends Thread implements Scavenger {
41  
42    private static final Logger LOGGER = Logger.get(RemoteDbSessionCleanupThread.class);
43  
44  
45  
46    /** sleep time in ms. */
47    private final long checkInterval;
48  
49    /** maximum number of retries to clean up a session. */
50    private final int cleanupRetryMax;
51  
52    /** the sessions. */
53    private final Set<WeakReference<RemoteDbSessionImpl>> sessions;
54  
55    /** map of zombie sessions to clean up count. */
56    private final Map<RemoteDbSessionImpl,Integer> zombieSessions;
57  
58    /** request termination on server shutdown. */
59    private volatile boolean terminationRequested;
60  
61  
62    /**
63     * Keeps the timeout info for a session group.<br>
64     * Mitigates the race condition when the only active session of a session group is closed while the cleanup thread
65     * is checking the others -- and closing them.
66     * The remaining sessions should stay alive at least until the least recently active session (the closed one)
67     * would have timed out.
68     */
69    private static class GroupTimeoutInfo {
70      private int timeout = -1;         // timeout in polling intervals of the cleanup thread (<0 = uninitialized)
71      private int timeoutCount = -1;    // consecutive timeouts  (<0 = uninitialized)
72  
73      private void update(int timeout, int timeoutCount) {
74        if (this.timeout < 0 || this.timeout > timeout) {
75          this.timeout = timeout;
76        }
77        if (this.timeoutCount < 0 || this.timeoutCount > timeoutCount) {
78          this.timeoutCount = timeoutCount;
79        }
80      }
81  
82      private boolean hasTimedOut() {
83        return ++timeoutCount > timeout;
84      }
85    }
86  
87    /** map of group-number::timeout-info. */
88    private final Map<Integer, GroupTimeoutInfo> activeGroups;
89  
90  
91  
92    /**
93     * Creates the cleanup thread.
94     *
95     * @param checkInterval milliseconds to sleep between runs
96     * @param sessions the sessions
97     * @param cleanupRetryMax maximum number of retries to clean up a session
98     */
99    public RemoteDbSessionCleanupThread(long checkInterval, Set<WeakReference<RemoteDbSessionImpl>> sessions, int cleanupRetryMax) {
100     super("Remote Session Cleanup");
101     this.checkInterval = checkInterval;
102     this.sessions = sessions;
103     this.cleanupRetryMax = cleanupRetryMax;
104     this.zombieSessions = new HashMap<>();
105     this.activeGroups = new HashMap<>();
106     setDaemon(true);    // stop JVM if the last user process terminates
107   }
108 
109 
110   /**
111    * Requests thread termination.
112    */
113   public void requestTermination() {
114     terminationRequested = true;
115     interrupt();
116   }
117 
118 
119   @Override
120   public void run() {
121 
122     LOGGER.info("{0} started with interval={1} ms, max.retries={2}", getName(), checkInterval, cleanupRetryMax);
123 
124     while (!terminationRequested) {
125       try {
126         sleep(checkInterval);
127         cleanupZombieSessions();
128         verifySessions();
129       }
130       catch (InterruptedException ix) {
131         // daemon thread is terminated via requestTermination()
132       }
133       catch (RuntimeException ex) {
134         LOGGER.severe("cleanup sessions failed", ex);
135       }
136     }
137 
138     LOGGER.info("{0} terminated", getName());
139   }
140 
141 
142   /**
143    * Verifies and cleans up the sessions.
144    * <p>
145    * Finds all session-groups or non-grouped session that are not alive anymore
146    * and closes their sessions.
147    * If _all_ sessions of a group have timed out, the whole group is closed.
148    * Usually the modification tracker of a client sessions will keep up
149    * a session-group alive. If the application uses only a single session,
150    * it must set the alive flag manually.
151    */
152   protected void verifySessions() {
153 
154     Set<Integer> deadTxGroups = new HashSet<>(); // numbers of db groups that are dead while in a transaction
155     // the grouped sessions verified below with their group at that time.
156     Map<WeakReference<RemoteDbSessionImpl>, Integer> groupedSessions = new LinkedHashMap<>();
157 
158     for (Iterator<WeakReference<RemoteDbSessionImpl>> iter = sessions.iterator(); iter.hasNext(); ) {
159       WeakReference<RemoteDbSessionImpl> sessionRef = iter.next();
160       RemoteDbSessionImpl session = sessionRef.get();
161       if (session == null) {
162         iter.remove();    // was removed by GC: remove it from set too
163       }
164       else  {
165         try {
166           if (session.isOpen()) {
167             Db db = session.getSession();
168             if (db == null || !db.isOpen()) {
169               if (db != null) {
170                 LOGGER.warning("Db {0} already closed in {1} -> force cleanup", db, session);
171                 // we came too late or not MT-visible.
172                 // db is already closed (cleanup in parallel?)
173                 if (!forceCleanupIfNotInUse(session)) {
174                   continue;   // verify again in the next run
175                 }
176               } // else: already closed
177               iter.remove();
178               continue;
179             }
180 
181             int groupId = db.getExportedSessionGroupId();
182             boolean timedOut = session.hasTimedOut();
183 
184             if (timedOut) {
185               boolean inTransaction = db.isTxRunning();
186               // database has timed out
187               if (groupId == 0) {
188                 // database without a group: close session immediately
189                 LOGGER.info("disconnect dead " + session + ", ungrouped");
190                 if (cleanupSession(session)) {
191                   iter.remove();
192                   continue; // don't run into polled() below (would cause a NPE)
193                 }
194                 // else: an invocation started meanwhile -> verify again in the next run
195               }
196               else {
197                 if (inTransaction) {
198                   // there is a dead session within a transaction for this group
199                   deadTxGroups.add(groupId);
200                 }
201                 // grouped sessions are handled below
202               }
203             }
204             else if (groupId > 0) {
205               // not timed out and belongs to a group and at least one session still alive
206               GroupTimeoutInfo groupTimeoutInfo = activeGroups.computeIfAbsent(groupId, g -> new GroupTimeoutInfo());
207               groupTimeoutInfo.update(session.getTimeout(), session.getTimeoutCount());
208             }
209 
210             if (groupId != 0) {
211               groupedSessions.put(sessionRef, groupId);
212             }
213 
214             session.polled();   // reset alive flag
215           }
216         }
217         catch (RuntimeException rx) {
218           LOGGER.warning("verifying " + session + " failed -> force cleanup", rx);
219           if (forceCleanupIfNotInUse(session)) {
220             iter.remove();
221           }
222           // else: verify again in the next run
223         }
224       }
225     }
226 
227     // run the timeout count on all group infos
228     activeGroups.entrySet().removeIf(entry -> entry.getValue().hasTimedOut());
229 
230     // close all grouped sessions not belonging to alive groups
231     for (Map.Entry<WeakReference<RemoteDbSessionImpl>, Integer> entry : groupedSessions.entrySet()) {
232       WeakReference<RemoteDbSessionImpl> sessionRef = entry.getKey();
233       RemoteDbSessionImpl session = sessionRef.get();
234       if (session == null) {
235         sessions.remove(sessionRef);    // was removed by GC: remove it from set too
236       }
237       else  {
238         try {
239           Db db = session.getSession();   // local copy: the client may close the session concurrently
240           if (db != null) {
241             int groupId = entry.getValue();
242             // skip if regrouped in the meantime: will be verified in the next run
243             if (groupId == db.getExportedSessionGroupId()) {
244               boolean deadInTransaction = deadTxGroups.contains(groupId);
245               if (deadInTransaction || !activeGroups.containsKey(groupId)) {
246                 if (deadInTransaction) {
247                   LOGGER.info("disconnect dead {0}, group={1}, due to hanging transaction on {2}",
248                               session, groupId, db);
249                 }
250                 else  {
251                   LOGGER.info("disconnect dead {0}, group={1}", session, groupId);
252                 }
253                 if (cleanupSession(session)) {
254                   sessions.remove(sessionRef);
255                 }
256                 // else: an invocation started meanwhile -> verify again in the next run
257               }
258             }
259           }
260         }
261         catch (RuntimeException rx) {
262           LOGGER.warning("verifying " + session + " failed -> force cleanup", rx);
263           if (forceCleanupIfNotInUse(session)) {
264             sessions.remove(sessionRef);
265           }
266           // else: verify again in the next run
267         }
268       }
269     }
270   }
271 
272 
273   /**
274    * Cleans up a session.<br>
275    * The session is not cleaned up while a client's invocation is in progress, since the local session is not thread-safe.
276    * Invocations arriving during the cleanup are rejected.
277    *
278    * @param session the session
279    * @return true if cleaned up or moved to the zombie sessions, false if in use and still to be verified
280    */
281   protected boolean cleanupSession(RemoteDbSessionImpl session) {
282     if (!session.enterInvocation()) {
283       LOGGER.info("{0} in use by {1} -> not cleaned up", session, session.getInvokingThread());
284       return false;
285     }
286     try {
287       session.cleanup(true);
288       session.closeDb(true);
289     }
290     catch (RuntimeException ex) {
291       LOGGER.severe("session cleanup failed for " + session + "\n    -> moved to zombie sessions!", ex);
292       zombieSessions.put(session, 1);
293     }
294     finally {
295       session.exitInvocation();
296     }
297     return true;
298   }
299 
300 
301   /**
302    * Cleans up all zombie sessions.
303    */
304   protected void cleanupZombieSessions() {
305     for (Iterator<Map.Entry<RemoteDbSessionImpl,Integer>> iter = zombieSessions.entrySet().iterator(); iter.hasNext(); ) {
306       Map.Entry<RemoteDbSessionImpl,Integer> entry = iter.next();
307       RemoteDbSessionImpl session = entry.getKey();
308       if (!session.enterInvocation()) {
309         // in use: not a failed attempt, and the session must not be forced to clean up under the invocation's hands
310         LOGGER.info("zombie {0} in use by {1} -> not cleaned up", session, session.getInvokingThread());
311         continue;
312       }
313       try {
314         if (cleanupZombieSession(session)) {
315           iter.remove();
316         }
317         else {
318           // failed
319           int cleanupCount = entry.getValue();
320           if (cleanupCount >= cleanupRetryMax) {
321             LOGGER.severe("zombie session refused to cleanup " + cleanupCount + " times -> to GC: " + session);
322             session.forceCleanup();
323             iter.remove();
324           }
325           else  {
326             entry.setValue(cleanupCount + 1);
327           }
328         }
329       }
330       finally {
331         session.exitInvocation();
332       }
333     }
334   }
335 
336 
337   /**
338    * Forces the cleanup of a session, unless a client's invocation is in progress.<br>
339    * The local session is not thread-safe, hence it must not be closed under the invocation's hands.
340    *
341    * @param session the session
342    * @return true if cleaned up, false if in use and still to be verified
343    */
344   private boolean forceCleanupIfNotInUse(RemoteDbSessionImpl session) {
345     if (!session.enterInvocation()) {
346       LOGGER.info("{0} in use by {1} -> no forced cleanup", session, session.getInvokingThread());
347       return false;
348     }
349     try {
350       session.forceCleanup();
351     }
352     finally {
353       session.exitInvocation();
354     }
355     return true;
356   }
357 
358 
359   /**
360    * Cleans up a zombie session.
361    *
362    * @param session the session
363    * @return true if cleaned up, false if failed
364    */
365   protected boolean cleanupZombieSession(RemoteDbSessionImpl session) {
366     if (!session.enterInvocation()) {
367       LOGGER.info("zombie {0} in use by {1} -> not cleaned up", session, session.getInvokingThread());
368       return false;
369     }
370     try {
371       LOGGER.info("cleaning up zombie " + session);
372       session.cleanup(true);
373       session.closeDb(true);
374       return true;
375     }
376     catch (RuntimeException ex) {
377       LOGGER.severe("zombie session cleanup failed for " + session, ex);
378       return false;
379     }
380     finally {
381       session.exitInvocation();
382     }
383   }
384 
385 }