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  package org.tentackle.dbms;
20  
21  import org.tentackle.common.Constants;
22  import org.tentackle.common.EncryptedProperties;
23  import org.tentackle.common.Service;
24  import org.tentackle.common.ServiceFactory;
25  import org.tentackle.common.Timestamp;
26  import org.tentackle.log.Logger;
27  import org.tentackle.misc.TimeKeeper;
28  
29  import java.lang.ref.WeakReference;
30  import java.sql.Connection;
31  import java.sql.SQLException;
32  import java.util.HashMap;
33  import java.util.Iterator;
34  import java.util.Map;
35  import java.util.Queue;
36  import java.util.concurrent.ConcurrentHashMap;
37  import java.util.concurrent.ConcurrentLinkedQueue;
38  import java.util.concurrent.ThreadLocalRandom;
39  import java.util.concurrent.atomic.AtomicLong;
40  
41  
42  interface DbTransactionFactoryHolder {
43    DbTransactionFactory INSTANCE = ServiceFactory.createService(DbTransactionFactory.class, DbTransactionFactory.class);
44  }
45  
46  
47  /**
48   * Factory for transactions.<br>
49   * Collects duration statistics and monitors transactions.
50   */
51  @Service(DbTransactionFactory.class)  // defaults to self
52  public class DbTransactionFactory {
53  
54    /**
55     * The singleton.
56     *
57     * @return the singleton
58     */
59    public static DbTransactionFactory getInstance() {
60      return DbTransactionFactoryHolder.INSTANCE;
61    }
62  
63  
64    private static final Logger LOGGER = Logger.get(DbTransactionFactory.class);
65  
66    private static final String TX_IDLE_INTERVAL = "txIdleInterval";
67    private static final String TX_IDLE_TIMEOUT = "txIdleTimeout";
68  
69    private static final long DEFAULT_TX_IDLE_INTERVAL = Constants.SECOND_MS;     // the default is to check every second
70    private static final int DEFAULT_TX_IDLE_TIMEOUT = 60;                        // 1-minute idle transaction timeout
71  
72  
73    private final Map<Long, WeakReference<DbTransaction>> transactions;           // txId::transaction
74    private final Queue<Long> finishedTransactions;                               // txIds of finished transactions
75    private final TransactionStatistics statistics;
76  
77    private volatile TransactionSupervisor transactionSupervisor;
78    private volatile long txIdleInterval = DEFAULT_TX_IDLE_INTERVAL;
79    private volatile int txIdleTimeout = DEFAULT_TX_IDLE_TIMEOUT;
80    private volatile boolean configChanged;
81    private volatile boolean collectingStatistics;
82  
83    // the transaction counter
84    private static final AtomicLong TX_COUNT = new AtomicLong();
85  
86  
87    /**
88     * Creates the transaction factory.
89     */
90    public DbTransactionFactory() {
91      transactions = new ConcurrentHashMap<>();
92      finishedTransactions = new ConcurrentLinkedQueue<>();
93      statistics = new TransactionStatistics();
94      configChanged = true;
95    }
96  
97    /**
98     * Configures the factory from the main session properties.
99     *
100    * @param properties the properties
101    */
102   public void configure(EncryptedProperties properties) {
103     String val = properties.getPropertyIgnoreCase(TX_IDLE_INTERVAL);
104     if (val != null) {
105       setTxIdleInterval(Long.parseLong(val));
106     }
107     val = properties.getPropertyIgnoreCase(TX_IDLE_TIMEOUT);
108     if (val != null) {
109       setTxIdleTimeout(Integer.parseInt(val));
110     }
111   }
112 
113   /**
114    * Creates a transaction.<br>
115    * Notice that this method is only invoked for local sessions, never for remote sessions.
116    *
117    * @param db the session
118    * @param txName the transaction name, null if &lt;unnamed&gt;
119    * @param fromRemote true if initiated from the remote client
120    * @return the transaction
121    */
122   public DbTransaction create(Db db, String txName, boolean fromRemote) {
123     DbTransaction transaction = new DbTransaction(db, createVoucher(fromRemote), txName, TX_COUNT.incrementAndGet());
124     transactions.put(transaction.getTxNumber(), new WeakReference<>(transaction));
125     ensureSupervisorAlive();
126     return transaction;
127   }
128 
129   /**
130    * Finishes a transaction.<br>
131    * Invoked when a transaction is committed or rolled back.<br>
132    * Notice that this method is only invoked for local sessions, never for remote sessions.
133    *
134    * @param transaction the transaction
135    * @param rolledBack true if transaction was rolled back, false if committed
136    */
137   public void finish(DbTransaction transaction, boolean rolledBack) {
138     transaction.complete();     // not supervised anymore (a commit of a timed out transaction is refused before)
139     long txId = transaction.getTxNumber();
140     transactions.remove(txId);
141     if (collectingStatistics) {
142       statistics.countTransaction(transaction, rolledBack);
143     }
144     finishedTransactions.add(txId);   // hand over to the supervisor thread asynchronously
145   }
146 
147   /**
148    * Sets whether statistics should be collected.
149    *
150    * @param collectingStatistics true if transactions are counted and execution times measured
151    */
152   public void setCollectingStatistics(boolean collectingStatistics) {
153     this.collectingStatistics = collectingStatistics;
154   }
155 
156   /**
157    * Returns whether statistics are collected.
158    *
159    * @return true if transactions are counted and execution times measured
160    */
161   public boolean isCollectingStatistics() {
162     return collectingStatistics;
163   }
164 
165   /**
166    * Logs the statistics.
167    *
168    * @param level the logging level
169    * @param clear true if clear statistics after dump
170    */
171   public void logStatistics(Logger.Level level, boolean clear) {
172     statistics.logStatistics(null, level, "    >TXN-Stats: ", clear);
173   }
174 
175   /**
176    * Gets the check interval for idle or unreferenced transactions in ms.
177    *
178    * @return the check interval
179    */
180   public long getTxIdleInterval() {
181     return txIdleInterval;
182   }
183 
184   /**
185    * Sets the check interval for idle or unreferenced transactions in ms.
186    *
187    * @param txIdleInterval the check interval
188    */
189   public void setTxIdleInterval(long txIdleInterval) {
190     if (this.txIdleInterval != txIdleInterval) {
191       this.txIdleInterval = txIdleInterval;
192       configChanged = true;   // after the assignment: the volatile write publishes the new value
193     }
194   }
195 
196   /**
197    * Gets the number of check intervals that must elapse for an idle transaction to time out.
198    *
199    * @return the idle count
200    */
201   public int getTxIdleTimeout() {
202     return txIdleTimeout;
203   }
204 
205   /**
206    * Sets the number of check intervals that must elapse for an idle transaction to time out.
207    *
208    * @param txIdleTimeout the idle count, &le; 0 to disable
209    */
210   public void setTxIdleTimeout(int txIdleTimeout) {
211     if (this.txIdleTimeout != txIdleTimeout) {
212       this.txIdleTimeout = txIdleTimeout;
213       configChanged = true;   // after the assignment: the volatile write publishes the new value
214     }
215   }
216 
217   /**
218    * Requests supervisor thread termination.<br>
219    * If transactions are still running, the supervisor keeps running until they are finished or timed out.
220    * The supervisor is started again when the next transaction is created.
221    */
222   public synchronized void requestTermination() {
223     if (transactionSupervisor != null) {
224       transactionSupervisor.requestTermination();
225       transactionSupervisor.interrupt();    // wake up to re-check promptly
226       if (transactions.isEmpty()) {
227         transactionSupervisor = null;       // nothing to supervise: the next transaction starts a new supervisor
228       }
229     }
230   }
231 
232   /**
233    * Returns whether the supervisor thread is running.
234    *
235    * @return true if running, false if not started yet or terminated
236    */
237   public synchronized boolean isSupervisorRunning() {
238     return transactionSupervisor != null && transactionSupervisor.isAlive();
239   }
240 
241 
242   /**
243    * Creates a new transaction voucher.
244    *
245    * @param fromRemote true if the transaction was started at the remote client
246    * @return the voucher
247    */
248   protected long createVoucher(boolean fromRemote) {
249     ThreadLocalRandom random = ThreadLocalRandom.current();
250     long magic;
251     do {
252       magic = random.nextLong();
253     }
254     while (magic == 0);
255 
256     // remote are negative, local positive
257     return (fromRemote && magic > 0 || !fromRemote && magic < 0) ? -magic : magic;
258   }
259 
260 
261   /**
262    * Starts the supervisor if not running.<br>
263    * A supervisor that died unexpectedly is replaced as well, see {@link #dropSupervisor(TransactionSupervisor)}.
264    * The new supervisor takes over all running transactions.
265    * <p>
266    * The happy path is lock-free.
267    */
268   private void ensureSupervisorAlive() {
269     TransactionSupervisor supervisor = transactionSupervisor;
270     if (supervisor == null || supervisor.terminationRequested || !supervisor.isAlive()) {
271       startSupervisorIfNotAlive();
272     }
273   }
274 
275   /**
276    * Starts the supervisor if not running.
277    */
278   private synchronized void startSupervisorIfNotAlive() {
279     if (transactionSupervisor == null || !transactionSupervisor.isAlive()) {
280       configChanged = true;   // log the configuration when started
281       transactionSupervisor = new TransactionSupervisor();
282       transactionSupervisor.start();
283     }
284   }
285 
286   /**
287    * Determines whether the supervisor should keep running.<br>
288    * Synchronized with {@link #ensureSupervisorAlive()}, so that a transaction created concurrently
289    * is either picked up by the terminating supervisor or starts a new one.
290    *
291    * @param supervisor the supervisor thread
292    * @return true if keep running, false if terminate
293    */
294   private synchronized boolean keepSupervising(TransactionSupervisor supervisor) {
295     if (transactionSupervisor != supervisor) {
296       return false;   // dropped by requestTermination()
297     }
298     if (supervisor.terminationRequested && transactions.isEmpty()) {
299       transactionSupervisor = null;   // the next transaction starts a new supervisor
300       return false;
301     }
302     return true;
303   }
304 
305   /**
306    * Drops the terminating supervisor, unless already replaced.<br>
307    * If the supervisor died unexpectedly, e.g., due to an {@link Error}, the next transaction starts a new one.
308    * Otherwise, the transactions would not be supervised anymore.
309    *
310    * @param supervisor the supervisor thread
311    */
312   private synchronized void dropSupervisor(TransactionSupervisor supervisor) {
313     if (transactionSupervisor == supervisor) {
314       transactionSupervisor = null;
315     }
316   }
317 
318 
319   /**
320    * The supervisor finds unreferenced transactions and cleans up.<br>
321    * It also rolls back and closes the JDBC connections of transactions being idle too long.
322    * The thread is started when the first transaction (local sessions only!) is created.
323    * For remote sessions, the thread will never be started and the {@code DbTransactionFactory} is just dummy,
324    * for example, in a remote {@code ServerApplication}.
325    */
326   private class TransactionSupervisor extends Thread {
327 
328     private static class TxIdleCounter {
329       final String txName;
330       final long txNumber;
331       final long creationTime;
332       final TimeKeeper timeKeeper;
333       int idleCount;
334 
335       TxIdleCounter(DbTransaction transaction) {
336         // we must not keep a reference to the transaction itself!
337         txName = transaction.getTxName();
338         txNumber = transaction.getTxNumber();
339         creationTime = transaction.getCreationTime();
340         timeKeeper = transaction.getTimeKeeper();
341       }
342 
343       int count() {
344         return ++idleCount;
345       }
346 
347       @Override
348       public String toString() {
349         return txName + ":" + txNumber + ", started " + new Timestamp(creationTime) + " (" + timeKeeper.end().millisToString() + " ms)";
350       }
351     }
352 
353     private final Map<Long, TxIdleCounter> idleMap = new HashMap<>();    // idle transactions suspected to time out
354     private volatile boolean terminationRequested;                         // true if this thread should stop
355 
356     private TransactionSupervisor() {
357       super("Transaction Supervisor");
358       setDaemon(true);
359     }
360 
361     /**
362      * Requests this thread to terminate.<br>
363      * The thread terminates as soon as no transactions are running anymore.
364      * The flag belongs to the thread, not to the factory, so that a supervisor started
365      * after the termination of a previous one is not stopped right away.
366      */
367     private void requestTermination() {
368       terminationRequested = true;
369     }
370 
371     @Override
372     public void run() {
373       try {
374         supervise();
375       }
376       catch (Throwable t) {
377         LOGGER.severe(getName() + " died -> restarted by the next transaction", t);
378         throw t;
379       }
380       finally {
381         dropSupervisor(this);
382       }
383       LOGGER.info("{0} terminated", getName());
384     }
385 
386     /**
387      * Supervises the transactions until terminated.
388      */
389     private void supervise() {
390       while (keepSupervising(this)) {
391         if (configChanged) {
392           LOGGER.info("{0} running with interval={1} ms, maxIdleCount={2}", getName(), txIdleInterval, txIdleTimeout);
393           configChanged = false;
394         }
395         try {
396           sleep(txIdleInterval);
397 
398           // remove finished transactions
399           Long finishedTxId;
400           while ((finishedTxId = finishedTransactions.poll()) != null) {
401             idleMap.remove(finishedTxId);
402           }
403 
404           // check running transaction for idle timeout
405           for (Iterator<Map.Entry<Long, WeakReference<DbTransaction>>> iter = transactions.entrySet().iterator(); iter.hasNext(); ) {
406             Map.Entry<Long, WeakReference<DbTransaction>> entry = iter.next();
407             DbTransaction transaction = entry.getValue().get();
408             if (transaction == null) {
409               // transactions cannot become unreferenced since the session is always attached when the tx starts. Attached sessions
410               // are referenced by the ManagedConnection until the tx ends, which will remove the tx from the transactions-map above.
411               // If we encounter an unreferenced tx, it must have been detached within the transaction or some other weird condition.
412               Long txId = entry.getKey();
413               TxIdleCounter idleCounter = idleMap.remove(txId);
414               LOGGER.severe("unreferenced transaction {0} detected", idleCounter == null ? txId : idleCounter);
415               iter.remove();
416             }
417             else {
418               if (transaction.pollAlive()) {   // reset: will be set alive as soon as some I/O happens again
419                 idleMap.remove(transaction.getTxNumber());
420               }
421               else {
422                 if (txIdleTimeout > 0 &&
423                     idleMap.computeIfAbsent(transaction.getTxNumber(), _ -> new TxIdleCounter(transaction)).count() > txIdleTimeout) {
424                   String msg = "idle transaction " + transaction.getSession() + " timed out after " +
425                                 txIdleTimeout * txIdleInterval / Constants.SECOND_MS + " s";
426                   idleMap.remove(transaction.getTxNumber());
427                   iter.remove();
428                   Db session = transaction.getSession();
429                   ManagedConnection mc;
430                   Connection con = null;
431                   // strictly serialized with attach and detach, which the connection managers synchronize on themselves
432                   synchronized (session.getConnectionManager()) {
433                     if (!transaction.timeOut()) {
434                       continue;   // completed by the session in the meantime
435                     }
436                     mc = session.getConnection();
437                     if (mc != null) {
438                       mc.setDead(true);
439                       if (!mc.isClosed()) {
440                         con = mc.getConnection();
441                       }
442                     }
443                   }
444                   if (mc != null) {
445                     LOGGER.warning(msg + ":\n" + mc.toDiagnosticString());  // with statements!
446                     if (con != null) {
447                       // do that in a separate thread (if it gets stuck, the supervisor still keeps running!)
448                       createTxCleanupThread(mc, con).start();
449                     }
450                     else {
451                       LOGGER.warning("managed connection {0} already closed", mc);
452                     }
453                   }
454                   else {
455                     LOGGER.warning(msg + " (managed connection already detached from session {0})", session);
456                   }
457                 }
458               }
459             }
460           }
461         }
462         catch (InterruptedException ix) {
463           // daemon thread is terminated via requestTermination() as soon as no transactions are running
464         }
465         catch (RuntimeException ex) {
466           LOGGER.severe("cleanup transactions failed", ex);
467         }
468       }
469     }
470 
471     private static Thread createTxCleanupThread(ManagedConnection mc, Connection con) {
472       Thread txCleanupThread = new Thread("transaction cleanup " + mc) {
473         @Override
474         public void run() {
475           try {
476             LOGGER.warning("performing low-level rollback and close of {0}", con);
477             // really low level (as if the database closed it suddenly)
478             try {
479               con.rollback();
480             }
481             catch (SQLException sr) {
482               LOGGER.warning("low-level rollback failed", sr);
483             }
484             con.close();   // just close -> leave the cleanup to the thread stuck in transaction
485           }
486           catch (SQLException | RuntimeException ex) {
487             LOGGER.warning("closing JDBC connection failed", ex);
488           }
489         }
490       };
491       txCleanupThread.setDaemon(true);
492       return txCleanupThread;
493     }
494   }
495 
496 }