View Javadoc
1   /*
2    * Licensed under the GPL License. You may not use this file except in compliance with the License.
3    * You may obtain a copy of the License at
4    *
5    *   https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
6    *
7    * THIS PACKAGE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING,
8    * WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF MERCHANTIBILITY AND FITNESS FOR A PARTICULAR
9    * PURPOSE.
10   */
11  package psiprobe.model.stats;
12  
13  import com.thoughtworks.xstream.XStream;
14  
15  import jakarta.inject.Inject;
16  
17  import java.io.File;
18  import java.io.IOException;
19  import java.io.InputStream;
20  import java.io.OutputStream;
21  import java.nio.file.Files;
22  import java.nio.file.Path;
23  import java.util.ArrayList;
24  import java.util.Collections;
25  import java.util.HashMap;
26  import java.util.List;
27  import java.util.Map;
28  import java.util.Map.Entry;
29  import java.util.TreeMap;
30  
31  import org.jfree.data.xy.XYDataItem;
32  import org.slf4j.Logger;
33  import org.slf4j.LoggerFactory;
34  import org.springframework.beans.factory.DisposableBean;
35  import org.springframework.beans.factory.InitializingBean;
36  import org.springframework.beans.factory.annotation.Value;
37  import org.springframework.context.ApplicationContext;
38  import org.springframework.context.ApplicationContextAware;
39  import org.springframework.web.context.WebApplicationContext;
40  
41  import psiprobe.tools.UpdateCommitLock;
42  
43  /**
44   * The Class StatsCollection.
45   */
46  public class StatsCollection implements InitializingBean, DisposableBean, ApplicationContextAware {
47  
48    /** The Constant logger. */
49    private static final Logger logger = LoggerFactory.getLogger(StatsCollection.class);
50  
51    /** The stats data. */
52    private Map<String, List<XYDataItem>> statsData = new TreeMap<>();
53  
54    /** The xstream. */
55    @Inject
56    private XStream xstream;
57  
58    /** The swap file name. */
59    private String swapFileName;
60  
61    /** The storage path. */
62    private String storagePath;
63  
64    /** The context temp dir. */
65    private File contextTempDir;
66  
67    /** The max files. */
68    private int maxFiles = 2;
69  
70    /** The lock. */
71    private final UpdateCommitLock lock = new UpdateCommitLock();
72  
73    /**
74     * Gets the swap file name.
75     *
76     * @return the swap file name
77     */
78    public String getSwapFileName() {
79      return swapFileName;
80    }
81  
82    /**
83     * Sets the swap file name.
84     *
85     * @param swapFileName the new swap file name
86     */
87    @Value("stats.xml")
88    public void setSwapFileName(String swapFileName) {
89      this.swapFileName = swapFileName;
90    }
91  
92    /**
93     * Gets the storage path.
94     *
95     * @return the storage path
96     */
97    public String getStoragePath() {
98      return storagePath;
99    }
100 
101   /**
102    * Sets the storage path. The default location for the stat files is
103    * $CALALINA_BASE/work/&lt;hostname&gt;/&lt;context_name&gt;. Use this property to override it.
104    *
105    * @param storagePath the new storage path
106    */
107   // TODO We should make this configurable
108   public void setStoragePath(String storagePath) {
109     this.storagePath = storagePath;
110   }
111 
112   /**
113    * Checks if is collected.
114    *
115    * @param statsName the stats name
116    *
117    * @return true, if is collected
118    */
119   public synchronized boolean isCollected(String statsName) {
120     return statsData.get(statsName) != null;
121   }
122 
123   /**
124    * Gets the max files.
125    *
126    * @return the max files
127    */
128   public int getMaxFiles() {
129     return maxFiles;
130   }
131 
132   /**
133    * Sets the max files.
134    *
135    * @param maxFiles the new max files
136    */
137   public void setMaxFiles(int maxFiles) {
138     this.maxFiles = maxFiles > 0 ? maxFiles : 2;
139   }
140 
141   /**
142    * New stats.
143    *
144    * @param name the name
145    * @param maxElements the max elements
146    *
147    * @return the list
148    */
149   public synchronized List<XYDataItem> newStats(String name, int maxElements) {
150     List<XYDataItem> stats = Collections.synchronizedList(new ArrayList<>(maxElements));
151     statsData.put(name, stats);
152     return stats;
153   }
154 
155   /**
156    * Reset stats.
157    *
158    * @param name the name
159    */
160   public synchronized void resetStats(String name) {
161     List<XYDataItem> stats = getStats(name);
162     if (stats != null) {
163       stats.clear();
164     }
165   }
166 
167   /**
168    * Gets the stats.
169    *
170    * @param name the name
171    *
172    * @return the stats
173    */
174   public synchronized List<XYDataItem> getStats(String name) {
175     return statsData.get(name);
176   }
177 
178   /**
179    * Gets the last value for stat.
180    *
181    * @param statName the stat name
182    *
183    * @return the last value for stat
184    */
185   public long getLastValueForStat(String statName) {
186     long statValue = 0;
187 
188     List<XYDataItem> stats = getStats(statName);
189     if (stats != null && !stats.isEmpty()) {
190       XYDataItem xy = stats.get(stats.size() - 1);
191       if (xy != null && xy.getY() != null) {
192         statValue = xy.getY().longValue();
193       }
194     }
195 
196     return statValue;
197   }
198 
199   /**
200    * Returns series if stat name starts with the prefix.
201    *
202    * @param statNamePrefix they key under which the stats are stored
203    *
204    * @return a Map of matching stats. Map keys are stat names and map values are corresponding
205    *         series.
206    */
207   public synchronized Map<String, List<XYDataItem>> getStatsByPrefix(String statNamePrefix) {
208     Map<String, List<XYDataItem>> map = new HashMap<>();
209     for (Map.Entry<String, List<XYDataItem>> en : statsData.entrySet()) {
210       if (en.getKey().startsWith(statNamePrefix)) {
211         map.put(en.getKey(), en.getValue());
212       }
213     }
214     return map;
215   }
216 
217   /**
218    * Make file.
219    *
220    * @return the file
221    */
222   private File makeFile() {
223     return storagePath == null ? Path.of(contextTempDir.getPath(), swapFileName).toFile()
224         : Path.of(storagePath, swapFileName).toFile();
225   }
226 
227   /**
228    * Shift files.
229    *
230    * @param index the index
231    */
232   private void shiftFiles(int index) {
233     if (index >= maxFiles - 1) {
234       try {
235         if (Files.exists(Path.of(makeFile().getAbsolutePath() + "." + index))) {
236           Files.delete(Path.of(makeFile().getAbsolutePath() + "." + index));
237         }
238       } catch (IOException e) {
239         logger.error("Could not delete file {}",
240             Path.of(makeFile().getAbsolutePath() + "." + index).toFile().getName());
241       }
242     } else {
243       shiftFiles(index + 1);
244       File srcFile =
245           index == 0 ? makeFile() : Path.of(makeFile().getAbsolutePath() + "." + index).toFile();
246       if (Files.exists(srcFile.toPath())) {
247         File destFile = Path.of(makeFile().getAbsolutePath() + "." + (index + 1)).toFile();
248         if (!srcFile.renameTo(destFile)) {
249           logger.error("Could not rename file {} to {}", srcFile.getName(), destFile.getName());
250         }
251       }
252     }
253   }
254 
255   /**
256    * Writes stats data to file on disk.
257    *
258    * @throws InterruptedException if a lock cannot be obtained
259    */
260   public synchronized void serialize() throws InterruptedException {
261     lock.lockForCommit();
262     long start = System.currentTimeMillis();
263     try {
264       shiftFiles(0);
265       try (OutputStream os = Files.newOutputStream(makeFile().toPath())) {
266         xstream.toXML(statsData, os);
267       }
268     } catch (Exception e) {
269       logger.error("Could not write stats data to '{}'", makeFile().getAbsolutePath(), e);
270     } finally {
271       lock.releaseCommitLock();
272       logger.debug("stats serialized in {}ms", System.currentTimeMillis() - start);
273     }
274   }
275 
276   /**
277    * Deserialize.
278    *
279    * @param file the file
280    *
281    * @return the map
282    */
283   @SuppressWarnings("unchecked")
284   private Map<String, List<XYDataItem>> deserialize(File file) {
285     Map<String, List<XYDataItem>> stats = null;
286     if (file.exists() && file.canRead()) {
287       long start = System.currentTimeMillis();
288       try {
289         try (InputStream fis = Files.newInputStream(file.toPath())) {
290           stats = (Map<String, List<XYDataItem>>) xstream.fromXML(fis);
291 
292           if (stats != null) {
293             // adjust stats data so that charts look realistic.
294             // we do that by ending the previous stats group with 0 value
295             // and starting the current stats group also with 0
296             // thus giving the chart nice plunge to zero indicating downtime
297             // and lets not bother about rotating stats;
298             // regular stats collection cycle will do it
299 
300             for (Entry<String, List<XYDataItem>> set : stats.entrySet()) {
301               List<XYDataItem> list = set.getValue();
302               if (!list.isEmpty()) {
303                 XYDataItem xy = list.get(list.size() - 1);
304                 list.add(new XYDataItem(xy.getX().longValue() + 1, 0));
305                 list.add(new XYDataItem(System.currentTimeMillis(), 0));
306               }
307             }
308           }
309         }
310         logger.debug("stats data read in {}ms", System.currentTimeMillis() - start);
311       } catch (ExceptionInInitializerError e) {
312         if (e.getCause() != null && e.getCause().getMessage() != null && e.getCause().getMessage()
313             .contains("does not \"opens java.util\" to unnamed module")) {
314           logger.error(
315               "Stats deserialization disabled, use '--add-opens java.base/java.util=ALL-UNNAMED' to start Tomcat to enable again");
316         } else {
317           logger.error("Could not read stats data from '{}' during initialization",
318               file.getAbsolutePath(), e);
319         }
320       } catch (Exception e) {
321         logger.error("Could not read stats data from '{}'", file.getAbsolutePath(), e);
322       }
323     }
324 
325     return stats;
326   }
327 
328   /**
329    * Lock for update.
330    *
331    * @throws InterruptedException the interrupted exception
332    */
333   public void lockForUpdate() throws InterruptedException {
334     lock.lockForUpdate();
335   }
336 
337   /**
338    * Release lock.
339    */
340   public void releaseLock() {
341     lock.releaseUpdateLock();
342   }
343 
344   /**
345    * Reads stats data from file on disk.
346    */
347   @Override
348   public synchronized void afterPropertiesSet() {
349     int index = 0;
350     Map<String, List<XYDataItem>> stats;
351 
352     while (true) {
353       File file =
354           index == 0 ? makeFile() : Path.of(makeFile().getAbsolutePath() + "." + index).toFile();
355       stats = deserialize(file);
356       index += 1;
357       if (stats != null || index >= maxFiles - 1) {
358         break;
359       }
360     }
361 
362     if (stats != null) {
363       statsData = stats;
364     } else {
365       logger.debug("Stats data file not found. Empty file assumed.");
366     }
367 
368   }
369 
370   @Override
371   public void destroy() throws Exception {
372     serialize();
373   }
374 
375   @Override
376   public void setApplicationContext(ApplicationContext applicationContext) {
377     WebApplicationContext wac = (WebApplicationContext) applicationContext;
378     contextTempDir = (File) wac.getServletContext().getAttribute("jakarta.servlet.context.tempdir");
379   }
380 
381 }