View Javadoc
1   /*
2    * Copyright 2012-2021 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.thumbnail;
17  
18  import java.io.File;
19  import java.io.IOException;
20  import java.nio.file.FileAlreadyExistsException;
21  import java.nio.file.FileVisitResult;
22  import java.nio.file.FileVisitor;
23  import java.nio.file.Files;
24  import java.nio.file.Path;
25  import java.nio.file.attribute.BasicFileAttributes;
26  import java.util.ArrayList;
27  import java.util.HashMap;
28  import java.util.List;
29  import java.util.Map;
30  import java.util.concurrent.BlockingQueue;
31  import java.util.concurrent.ExecutorService;
32  import java.util.concurrent.LinkedBlockingQueue;
33  import java.util.concurrent.TimeUnit;
34  import java.util.stream.Stream;
35  
36  import javax.annotation.PostConstruct;
37  import javax.annotation.PreDestroy;
38  
39  import org.apache.logging.log4j.LogManager;
40  import org.apache.logging.log4j.Logger;
41  import org.codelibs.core.lang.StringUtil;
42  import org.codelibs.core.lang.ThreadUtil;
43  import org.codelibs.core.misc.Tuple3;
44  import org.codelibs.fesen.index.query.QueryBuilders;
45  import org.codelibs.fess.Constants;
46  import org.codelibs.fess.es.client.SearchEngineClient;
47  import org.codelibs.fess.es.config.exbhv.ThumbnailQueueBhv;
48  import org.codelibs.fess.es.config.exentity.ThumbnailQueue;
49  import org.codelibs.fess.exception.FessSystemException;
50  import org.codelibs.fess.exception.JobProcessingException;
51  import org.codelibs.fess.helper.SystemHelper;
52  import org.codelibs.fess.mylasta.direction.FessConfig;
53  import org.codelibs.fess.util.ComponentUtil;
54  import org.codelibs.fess.util.DocumentUtil;
55  import org.codelibs.fess.util.ResourceUtil;
56  
57  import com.google.common.collect.Lists;
58  
59  public class ThumbnailManager {
60      private static final String NOIMAGE_FILE_SUFFIX = ".txt";
61  
62      protected static final String THUMBNAILS_DIR_NAME = "thumbnails";
63  
64      private static final Logger logger = LogManager.getLogger(ThumbnailManager.class);
65  
66      protected File baseDir;
67  
68      private final List<ThumbnailGenerator> generatorList = new ArrayList<>();
69  
70      private BlockingQueue<Tuple3<String, String, String>> thumbnailTaskQueue;
71  
72      private volatile boolean generating;
73  
74      private Thread thumbnailQueueThread;
75  
76      protected int thumbnailPathCacheSize = 10;
77  
78      protected String imageExtention = "png";
79  
80      protected int splitSize = 3;
81  
82      protected int thumbnailTaskQueueSize = 10000;
83  
84      protected int thumbnailTaskBulkSize = 100;
85  
86      protected long thumbnailTaskQueueTimeout = 10 * 1000L;
87  
88      protected long noImageExpired = 24 * 60 * 60 * 1000L; // 24 hours
89  
90      @PostConstruct
91      public void init() {
92          if (logger.isDebugEnabled()) {
93              logger.debug("Initialize {}", this.getClass().getSimpleName());
94          }
95          final String thumbnailPath = System.getProperty(Constants.FESS_THUMBNAIL_PATH);
96          if (thumbnailPath != null) {
97              baseDir = new File(thumbnailPath);
98          } else {
99              final String varPath = System.getProperty(Constants.FESS_VAR_PATH);
100             if (varPath != null) {
101                 baseDir = new File(varPath, THUMBNAILS_DIR_NAME);
102             } else {
103                 baseDir = ResourceUtil.getThumbnailPath().toFile();
104             }
105         }
106         if (baseDir.mkdirs()) {
107             logger.info("Created: {}", baseDir.getAbsolutePath());
108         }
109         if (!baseDir.isDirectory()) {
110             throw new FessSystemException("Not found: " + baseDir.getAbsolutePath());
111         }
112 
113         if (logger.isDebugEnabled()) {
114             logger.debug("Thumbnail Directory: {}", baseDir.getAbsolutePath());
115         }
116 
117         thumbnailTaskQueue = new LinkedBlockingQueue<>(thumbnailTaskQueueSize);
118         generating = !Constants.TRUE.equalsIgnoreCase(System.getProperty("fess.thumbnail.process"));
119         thumbnailQueueThread = new Thread((Runnable) () -> {
120             final List<Tuple3<String, String, String>> taskList = new ArrayList<>();
121             while (generating) {
122                 try {
123                     final Tuple3<String, String, String> task = thumbnailTaskQueue.poll(thumbnailTaskQueueTimeout, TimeUnit.MILLISECONDS);
124                     if (task == null) {
125                         if (!taskList.isEmpty()) {
126                             storeQueue(taskList);
127                         }
128                     } else if (!taskList.contains(task)) {
129                         taskList.add(task);
130                         if (taskList.size() > thumbnailTaskBulkSize) {
131                             storeQueue(taskList);
132                         }
133                     }
134                 } catch (final InterruptedException e) {
135                     if (logger.isDebugEnabled()) {
136                         logger.debug("Interupted task.", e);
137                     }
138                 } catch (final Exception e) {
139                     if (generating) {
140                         logger.warn("Failed to generate thumbnail.", e);
141                     }
142                 }
143             }
144             if (!taskList.isEmpty()) {
145                 storeQueue(taskList);
146             }
147         }, "ThumbnailGenerator");
148         thumbnailQueueThread.start();
149     }
150 
151     @PreDestroy
152     public void destroy() {
153         generating = false;
154         thumbnailQueueThread.interrupt();
155         try {
156             thumbnailQueueThread.join(10000);
157         } catch (final InterruptedException e) {
158             logger.warn("Thumbnail thread is timeouted.", e);
159         }
160         generatorList.forEach(g -> {
161             try {
162                 g.destroy();
163             } catch (final Exception e) {
164                 logger.warn("Failed to stop thumbnail generator.", e);
165             }
166         });
167     }
168 
169     public String getThumbnailPathOption() {
170         return "-D" + Constants.FESS_THUMBNAIL_PATH + "=" + baseDir.getAbsolutePath();
171     }
172 
173     protected void storeQueue(final List<Tuple3<String, String, String>> taskList) {
174         final FessConfig fessConfig = ComponentUtil.getFessConfig();
175         final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
176         final String[] targets = fessConfig.getThumbnailGeneratorTargetsAsArray();
177         final List<ThumbnailQueue> list = new ArrayList<>();
178         taskList.stream().filter(entity -> entity != null).forEach(task -> {
179             for (final String target : targets) {
180                 final ThumbnailQueue entity = new ThumbnailQueue();
181                 entity.setGenerator(task.getValue1());
182                 entity.setThumbnailId(task.getValue2());
183                 entity.setPath(task.getValue3());
184                 entity.setTarget(target);
185                 entity.setCreatedBy(Constants.SYSTEM_USER);
186                 entity.setCreatedTime(systemHelper.getCurrentTimeAsLong());
187                 list.add(entity);
188             }
189         });
190         taskList.clear();
191         if (logger.isDebugEnabled()) {
192             logger.debug("Storing {} thumbnail tasks.", list.size());
193         }
194         final ThumbnailQueueBhv thumbnailQueueBhv = ComponentUtil.getComponent(ThumbnailQueueBhv.class);
195         thumbnailQueueBhv.batchInsert(list);
196     }
197 
198     public int generate(final ExecutorService executorService, final boolean cleanup) {
199         final FessConfig fessConfig = ComponentUtil.getFessConfig();
200         final List<String> idList = new ArrayList<>();
201         final ThumbnailQueueBhv thumbnailQueueBhv = ComponentUtil.getComponent(ThumbnailQueueBhv.class);
202         thumbnailQueueBhv.selectList(cb -> {
203             if (StringUtil.isBlank(fessConfig.getSchedulerTargetName())) {
204                 cb.query().setTarget_Equal(Constants.DEFAULT_JOB_TARGET);
205             } else {
206                 cb.query().setTarget_InScope(Lists.newArrayList(Constants.DEFAULT_JOB_TARGET, fessConfig.getSchedulerTargetName()));
207             }
208             cb.query().addOrderBy_CreatedTime_Asc();
209             cb.fetchFirst(fessConfig.getPageThumbnailQueueMaxFetchSizeAsInteger());
210         }).stream().map(entity -> {
211             idList.add(entity.getId());
212             if (!cleanup) {
213                 return executorService.submit(() -> process(fessConfig, entity));
214             }
215             if (logger.isDebugEnabled()) {
216                 logger.debug("Removing thumbnail queue: {}", entity);
217             }
218             return null;
219         }).filter(f -> f != null).forEach(f -> {
220             try {
221                 f.get();
222             } catch (final Exception e) {
223                 logger.warn("Failed to process a thumbnail generation.", e);
224             }
225         });
226 
227         if (!idList.isEmpty()) {
228             thumbnailQueueBhv.queryDelete(cb -> {
229                 cb.query().setId_InScope(idList);
230             });
231             thumbnailQueueBhv.refresh();
232         }
233         return idList.size();
234     }
235 
236     protected void process(final FessConfig fessConfig, final ThumbnailQueue entity) {
237         ComponentUtil.getSystemHelper().calibrateCpuLoad();
238 
239         if (logger.isDebugEnabled()) {
240             logger.debug("Processing thumbnail: {}", entity);
241         }
242         final String generatorName = entity.getGenerator();
243         try {
244             final File outputFile = new File(baseDir, entity.getPath());
245             final File noImageFile = new File(outputFile.getAbsolutePath() + NOIMAGE_FILE_SUFFIX);
246             if (!noImageFile.isFile() || System.currentTimeMillis() - noImageFile.lastModified() > noImageExpired) {
247                 if (noImageFile.isFile() && !noImageFile.delete()) {
248                     logger.warn("Failed to delete {}", noImageFile.getAbsolutePath());
249                 }
250                 final ThumbnailGenerator generator = ComponentUtil.getComponent(generatorName);
251                 if (generator.isAvailable()) {
252                     if (!generator.generate(entity.getThumbnailId(), outputFile)) {
253                         new File(outputFile.getAbsolutePath() + NOIMAGE_FILE_SUFFIX).setLastModified(System.currentTimeMillis());
254                     } else {
255                         final long interval = fessConfig.getThumbnailGeneratorIntervalAsInteger().longValue();
256                         if (interval > 0) {
257                             ThreadUtil.sleep(interval);
258                         }
259                     }
260                 } else {
261                     logger.warn("{} is not available.", generatorName);
262                 }
263             } else if (logger.isDebugEnabled()) {
264                 logger.debug("No image file exists: {}", noImageFile.getAbsolutePath());
265             }
266         } catch (final Exception e) {
267             logger.warn("Failed to create thumbnail for {}", entity, e);
268         }
269     }
270 
271     public boolean offer(final Map<String, Object> docMap) {
272         for (final ThumbnailGenerator generator : generatorList) {
273             if (generator.isTarget(docMap)) {
274                 final String path = getImageFilename(docMap);
275                 final Tuple3<String, String, String> task = generator.createTask(path, docMap);
276                 if (task != null) {
277                     if (logger.isDebugEnabled()) {
278                         logger.debug("Add thumbnail task: {}", task);
279                     }
280                     if (!thumbnailTaskQueue.offer(task)) {
281                         logger.warn("Failed to add thumbnail task: {}", task);
282                     }
283                     return true;
284                 }
285                 return false;
286             }
287         }
288         if (logger.isDebugEnabled()) {
289             logger.debug("Thumbnail generator is not found: {}", (docMap != null ? docMap.get("url") : docMap));
290         }
291         return false;
292     }
293 
294     protected String getImageFilename(final Map<String, Object> docMap) {
295         final FessConfig fessConfig = ComponentUtil.getFessConfig();
296         final String docid = DocumentUtil.getValue(docMap, fessConfig.getIndexFieldDocId(), String.class);
297         return getImageFilename(docid);
298     }
299 
300     protected String getImageFilename(final String docid) {
301         final StringBuilder buf = new StringBuilder(50);
302         for (int i = 0; i < docid.length(); i++) {
303             if (i > 0 && i % splitSize == 0) {
304                 buf.append('/');
305             }
306             buf.append(docid.charAt(i));
307         }
308         buf.append('.').append(imageExtention);
309         return buf.toString();
310     }
311 
312     public File getThumbnailFile(final Map<String, Object> docMap) {
313         final String thumbnailPath = getImageFilename(docMap);
314         if (StringUtil.isNotBlank(thumbnailPath)) {
315             final File file = new File(baseDir, thumbnailPath);
316             if (file.isFile()) {
317                 return file;
318             }
319         }
320         return null;
321     }
322 
323     public void add(final ThumbnailGenerator generator) {
324         if (logger.isDebugEnabled()) {
325             logger.debug("{} is available.", generator.getName());
326         }
327         if (generator.isAvailable()) {
328             generatorList.add(generator);
329         }
330     }
331 
332     public long purge(final long expiry) {
333         if (!baseDir.exists()) {
334             return 0;
335         }
336         try {
337             final FilePurgeVisitor visitor = new FilePurgeVisitor(baseDir.toPath(), imageExtention, expiry);
338             Files.walkFileTree(baseDir.toPath(), visitor);
339             return visitor.getCount();
340         } catch (final Exception e) {
341             throw new JobProcessingException(e);
342         }
343     }
344 
345     protected static class FilePurgeVisitor implements FileVisitor<Path> {
346 
347         protected final long expiry;
348 
349         protected long count;
350 
351         protected final int maxPurgeSize;
352 
353         protected final List<Path> deletedFileList = new ArrayList<>();
354 
355         protected final Path basePath;
356 
357         protected final String imageExtention;
358 
359         protected final SearchEngineClient searchEngineClient;
360 
361         protected final FessConfig fessConfig;
362 
363         FilePurgeVisitor(final Path basePath, final String imageExtention, final long expiry) {
364             this.basePath = basePath;
365             this.imageExtention = imageExtention;
366             this.expiry = expiry;
367             this.fessConfig = ComponentUtil.getFessConfig();
368             this.maxPurgeSize = fessConfig.getPageThumbnailPurgeMaxFetchSizeAsInteger();
369             this.searchEngineClient = ComponentUtil.getSearchEngineClient();
370         }
371 
372         protected void deleteFiles() {
373             final Map<String, Path> deleteFileMap = new HashMap<>();
374             for (final Path path : deletedFileList) {
375                 final String docId = getDocId(path);
376                 if (StringUtil.isBlank(docId) || deleteFileMap.containsKey(docId)) {
377                     deleteFile(path);
378                 } else {
379                     deleteFileMap.put(docId, path);
380                 }
381             }
382             deletedFileList.clear();
383 
384             if (!deleteFileMap.isEmpty()) {
385                 final String docIdField = fessConfig.getIndexFieldDocId();
386                 searchEngineClient.getDocumentList(fessConfig.getIndexDocumentSearchIndex(), searchRequestBuilder -> {
387                     searchRequestBuilder.setQuery(
388                             QueryBuilders.termsQuery(docIdField, deleteFileMap.keySet().toArray(new String[deleteFileMap.size()])));
389                     searchRequestBuilder.setFetchSource(new String[] { docIdField }, StringUtil.EMPTY_STRINGS);
390                     return true;
391                 }).forEach(m -> {
392                     final Object docId = m.get(docIdField);
393                     if (docId != null) {
394                         deleteFileMap.remove(docId);
395                         if (logger.isDebugEnabled()) {
396                             logger.debug("Keep thumbnail: {}", docId);
397                         }
398                     }
399                 });
400 
401                 deleteFileMap.values().forEach(this::deleteFile);
402                 count += deleteFileMap.size();
403             }
404         }
405 
406         protected void deleteFile(final Path path) {
407             try {
408                 Files.delete(path);
409                 if (logger.isDebugEnabled()) {
410                     logger.debug("Delete {}", path);
411                 }
412 
413                 Path parent = path.getParent();
414                 while (deleteEmptyDirectory(parent)) {
415                     parent = parent.getParent();
416                 }
417             } catch (final IOException e) {
418                 logger.warn("Failed to delete {}", path, e);
419             }
420         }
421 
422         protected String getDocId(final Path file) {
423             final String s = file.toUri().toString();
424             final String b = basePath.toUri().toString();
425             final String id = s.replace(b, StringUtil.EMPTY).replace("." + imageExtention, StringUtil.EMPTY).replace("/", StringUtil.EMPTY);
426             if (logger.isDebugEnabled()) {
427                 logger.debug("Base: {} File: {} DocId: {}", b, s, id);
428             }
429             return id;
430         }
431 
432         public long getCount() {
433             if (!deletedFileList.isEmpty()) {
434                 deleteFiles();
435             }
436             return count;
437         }
438 
439         @Override
440         public FileVisitResult preVisitDirectory(final Path dir, final BasicFileAttributes attrs) throws IOException {
441             return FileVisitResult.CONTINUE;
442         }
443 
444         @Override
445         public FileVisitResult visitFile(final Path file, final BasicFileAttributes attrs) throws IOException {
446             if (System.currentTimeMillis() - Files.getLastModifiedTime(file).toMillis() > expiry) {
447                 deletedFileList.add(file);
448                 if (deletedFileList.size() > maxPurgeSize) {
449                     deleteFiles();
450                 }
451             }
452             return FileVisitResult.CONTINUE;
453         }
454 
455         @Override
456         public FileVisitResult visitFileFailed(final Path file, final IOException e) throws IOException {
457             if (e != null) {
458                 logger.warn("I/O exception on {}", file, e);
459             }
460             return FileVisitResult.CONTINUE;
461         }
462 
463         @Override
464         public FileVisitResult postVisitDirectory(final Path dir, final IOException e) throws IOException {
465             if (e != null) {
466                 logger.warn("I/O exception on {}", dir, e);
467             }
468             deleteEmptyDirectory(dir);
469             return FileVisitResult.CONTINUE;
470         }
471 
472         private boolean deleteEmptyDirectory(final Path dir) throws IOException {
473             if (dir == null) {
474                 return false;
475             }
476             final File directory = dir.toFile();
477             if (directory.list() != null && directory.list().length == 0 && !THUMBNAILS_DIR_NAME.equals(directory.getName())) {
478                 Files.delete(dir);
479                 if (logger.isDebugEnabled()) {
480                     logger.debug("Delete {}", dir);
481                 }
482                 return true;
483             }
484             return false;
485         }
486 
487     }
488 
489     public void migrate() {
490         new Thread(() -> {
491             final Path basePath = baseDir.toPath();
492             final String suffix = "." + imageExtention;
493             try (Stream<Path> paths = Files.walk(basePath)) {
494                 paths.filter(path -> path.toFile().getName().endsWith(imageExtention)).forEach(path -> {
495                     final Path subPath = basePath.relativize(path);
496                     final String docId = subPath.toString().replace("/", StringUtil.EMPTY).replace(suffix, StringUtil.EMPTY);
497                     final String filename = getImageFilename(docId);
498                     final Path newPath = basePath.resolve(filename);
499                     if (!path.equals(newPath)) {
500                         try {
501                             try {
502                                 Files.createDirectories(newPath.getParent());
503                             } catch (final FileAlreadyExistsException e) {
504                                 // ignore
505                             }
506                             Files.move(path, newPath);
507                             logger.info("Move {} to {}", path, newPath);
508                         } catch (final IOException e) {
509                             logger.warn("Failed to move {}", path, e);
510                         }
511                     }
512                 });
513             } catch (final IOException e) {
514                 logger.warn("Failed to migrate thumbnail images.", e);
515             }
516         }, "ThumbnailMigrator").start();
517     }
518 
519     public void setThumbnailPathCacheSize(final int thumbnailPathCacheSize) {
520         this.thumbnailPathCacheSize = thumbnailPathCacheSize;
521     }
522 
523     public void setImageExtention(final String imageExtention) {
524         this.imageExtention = imageExtention;
525     }
526 
527     public void setSplitSize(final int splitSize) {
528         this.splitSize = splitSize;
529     }
530 
531     public void setThumbnailTaskQueueSize(final int thumbnailTaskQueueSize) {
532         this.thumbnailTaskQueueSize = thumbnailTaskQueueSize;
533     }
534 
535     public void setNoImageExpired(final long noImageExpired) {
536         this.noImageExpired = noImageExpired;
537     }
538 
539 }