View Javadoc
1   /**
2    * This file Copyright (c) 2009-2018 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.imaging.caching;
35  
36  import info.magnolia.cms.beans.config.MIMEMapping;
37  import info.magnolia.context.SystemContext;
38  import info.magnolia.imaging.AbstractImageStreamer;
39  import info.magnolia.imaging.ImageGenerator;
40  import info.magnolia.imaging.ImageResponse;
41  import info.magnolia.imaging.ImageStreamer;
42  import info.magnolia.imaging.ImagingException;
43  import info.magnolia.imaging.OutputFormat;
44  import info.magnolia.imaging.ParameterProvider;
45  import info.magnolia.jcr.util.NodeTypes;
46  import info.magnolia.jcr.util.NodeUtil;
47  
48  import java.io.ByteArrayInputStream;
49  import java.io.ByteArrayOutputStream;
50  import java.io.IOException;
51  import java.io.InputStream;
52  import java.io.OutputStream;
53  import java.util.Calendar;
54  import java.util.concurrent.ExecutionException;
55  import java.util.concurrent.TimeUnit;
56  import java.util.concurrent.locks.ReentrantLock;
57  
58  import javax.jcr.InvalidItemStateException;
59  import javax.jcr.Node;
60  import javax.jcr.Property;
61  import javax.jcr.RepositoryException;
62  import javax.jcr.Session;
63  
64  import org.apache.commons.io.IOUtils;
65  import org.apache.jackrabbit.JcrConstants;
66  import org.slf4j.Logger;
67  import org.slf4j.LoggerFactory;
68  
69  import com.google.common.cache.CacheBuilder;
70  import com.google.common.cache.CacheLoader;
71  import com.google.common.cache.LoadingCache;
72  import com.google.common.net.MediaType;
73  
74  /**
75   * An ImageStreamer which stores and serves generated images to/from a specific workspace.
76   *
77   * @param <P> type of ParameterProvider's parameter
78   */
79  public class CachingImageStreamer<P> extends AbstractImageStreamer<P> {
80  
81      private static final Logger log = LoggerFactory.getLogger(CachingImageStreamer.class);
82  
83      private static final String GENERATED_IMAGE_NODE_NAME = "generated-image";
84  
85      private final Session session;
86      private final CachingStrategy<P> cachingStrategy;
87      private final ImageStreamer<P> delegate;
88      private final SystemContext systemContext;
89  
90      /**
91       * This LoadingCache is the key to understanding how this class works.
92       * By using a LoadingCache, we are essentially locking all requests
93       * coming in for the same image (ImageGenerationJob) except the first one.
94       *
95       * CacheBuilder.build() returns a LoadingCache implemented as such that the
96       * first call to get(K) will generate the value (by calling <V> Function.apply(<K>).
97       * Further calls are blocked until the value is generated, and they all retrieve the same value.
98       *
99       * TODO: make static if we don't use the exact same instance for all threads ?
100      */
101     private final LoadingCache<ImageGenerationJob<P>, Property> currentJobs;
102 
103     /**
104      * Despite the currentJobs doing quite a good job at avoiding multiple requests
105      * for the same job, we still need to lock around JCR operations, otherwise multiple
106      * requests end up creating the same cachePath (or parts of it), thus yielding
107      * InvalidItemStateException: "Item cannot be saved because it has been modified externally".
108      * TODO - this is currently static because we *know* ImagingServlet uses a different instance
109      * of CachingImageStreamer for every request. This is not exactly the most elegant.
110      * TODO - see related TODO in currentJobs and info.magnolia.imaging.ImagingServlet#getStreamer
111      */
112     private static final ReentrantLock lock = new ReentrantLock();
113 
114     public CachingImageStreamer(Session session, CachingStrategy<P> cachingStrategy, ImageStreamer<P> delegate, SystemContext systemContext) {
115         this.session = session;
116         this.cachingStrategy = cachingStrategy;
117         this.delegate = delegate;
118         this.systemContext = systemContext;
119 
120         CacheBuilder<Object, Object> cb = CacheBuilder.newBuilder();
121         this.currentJobs = cb
122                 // entries from the LoadingCache will be removed 500ms after their creation,
123                 // thus unblocking further requests for an equivalent job.
124                 .expireAfterWrite(500, TimeUnit.MILLISECONDS)
125 
126                 // We're (ab)using CacheLoader -- this is NOT the cache. We're merely using it to schedule concurrent image generation jobs.
127                 .build(new CacheLoader<ImageGenerationJob<P>, Property>() {
128 
129                     @Override
130                     public Property load(ImageGenerationJob<P> job) throws Exception {
131                         try {
132                             return generateAndStore(job.getGenerator(), job.getParams());
133                         } catch (IOException | ImagingException e) {
134                             // the LoadingCache will further wrap these in ExecutionExceptions, and we will, in turn, unwrap them ...
135                             throw new RuntimeException(e);
136                         }
137                     }
138 
139                 });
140     }
141 
142     @Override
143     public void serveImage(ImageGenerator<ParameterProvider<P>> generator, ParameterProvider<P> params, ImageResponse imageResponse) throws ImagingException, IOException {
144         Property imgProp = fetchFromCache(generator, params);
145         if (imgProp == null) {
146             // image is not in cache or should be regenerated
147             try {
148                 imgProp = currentJobs.get(new ImageGenerationJob<>(generator, params));
149             } catch (ExecutionException e) {
150                 // thrown if the LoadingCache's Function failed
151                 unwrapRuntimeException(e);
152             }
153         }
154         serve(imgProp, imageResponse);
155     }
156 
157     /**
158      * @deprecate Since Imaging 3.3 (Magnolia 5.5) use {@link CachingImageStreamer#serveImage(ImageGenerator, ParameterProvider, ImageResponse)} instead.
159      */
160     @Deprecated
161     public void serveImage(ImageGenerator<ParameterProvider<P>> generator, ParameterProvider<P> params, OutputStream out) throws IOException, ImagingException {
162         serveImage(generator, params, new TemporaryImageResponse(out));
163     }
164 
165     /**
166      * Gets the binary property (NodeData) for the appropriate image, ready to be served,
167      * or null if the image should be regenerated.
168      */
169     protected Property fetchFromCache(ImageGenerator<ParameterProvider<P>> generator, ParameterProvider<P> parameterProvider) {
170         final String cachePath = cachingStrategy.getCachePath(generator, parameterProvider);
171         if (cachePath == null) {
172             // the CachingStrategy decided it doesn't want us to cache :(
173             return null;
174         }
175         try {
176             if (!session.itemExists(cachePath)) {
177                 return null;
178             }
179             final Node imageNode = session.getNode(cachePath);
180             if (!imageNode.hasNode(GENERATED_IMAGE_NODE_NAME)) {
181                 return null;
182             }
183             final Property property = imageNode.getNode(GENERATED_IMAGE_NODE_NAME).getProperty(JcrConstants.JCR_DATA);
184             InputStream in;
185             try {
186                 in = property.getBinary().getStream();
187             } catch (RepositoryException e) {
188                 // will happen, when stream is not yet stored properly (generateAndStore)
189                 // we prefer this handling over having to lock because of better performance especially with big images
190                 return null;
191             }
192             IOUtils.closeQuietly(in);
193 
194             if (cachingStrategy.shouldRegenerate(property, parameterProvider)) {
195                 return null;
196             }
197             return property;
198         } catch (RepositoryException e) {
199             throw new RuntimeException(e); // TODO
200         }
201     }
202 
203     protected void serve(Property binary, ImageResponse imageResponse) throws IOException {
204         final InputStream in;
205         try {
206             in = binary.getBinary().getStream();
207         } catch (RepositoryException e) {
208             throw new IllegalStateException("Can't get InputStream from " + binary);
209         }
210         final String contentType;
211         try {
212             contentType = binary.getParent().getProperty(JcrConstants.JCR_MIMETYPE).getString();
213         } catch (RepositoryException e) {
214             throw new IllegalStateException("Can't get content-type from " + binary);
215         }
216         imageResponse.setMediaType(MediaType.parse(contentType));
217 
218         final OutputStream out = imageResponse.getOutputStream();
219         IOUtils.copy(in, out);
220         IOUtils.closeQuietly(in);
221     }
222 
223     protected Property generateAndStore(final ImageGenerator<ParameterProvider<P>> generator, final ParameterProvider<P> parameterProvider) throws IOException, ImagingException {
224         // generate
225         final ByteArrayOutputStream tempOut = new ByteArrayOutputStream();
226         delegate.serveImage(generator, parameterProvider, new TemporaryImageResponse(tempOut));
227 
228         // it's time to lock now, we can only save one node at a time, since we'll be working on the same nodes as other threads
229         lock.lock();
230         try {
231             final OutputFormat outputFormat = generator.getOutputFormat(parameterProvider);
232             final MediaType contentType = getMediaType(outputFormat);
233 
234             final Session systemSession = systemContext.getJCRSession(session.getWorkspace().getName());
235             // create cachePath if needed
236             final String cachePath = cachingStrategy.getCachePath(generator, parameterProvider);
237             final Node cacheNode = NodeUtil.createPath(session.getRootNode(), cachePath, NodeTypes.Content.NAME);
238             final Node resourceNode = NodeUtil.createPath(cacheNode, GENERATED_IMAGE_NODE_NAME, NodeTypes.Resource.NAME);
239             // store generated image
240             final ByteArrayInputStream tempIn = new ByteArrayInputStream(tempOut.toByteArray());
241             Property imageData = resourceNode.setProperty(JcrConstants.JCR_DATA, cacheNode.getSession().getValueFactory().createBinary(tempIn));
242 
243             final String formatName = generator.getOutputFormat(parameterProvider).getFormatName();
244             final String mimeType = MIMEMapping.getMIMEType(formatName);
245             resourceNode.setProperty(JcrConstants.JCR_MIMETYPE, mimeType);
246             resourceNode.setProperty(JcrConstants.JCR_MIMETYPE, contentType.toString());
247             resourceNode.setProperty(JcrConstants.JCR_LASTMODIFIED, Calendar.getInstance());
248 
249             // Update metadata of the cache *after* a succesfull image generation (creationDate has been set when creating
250             // Since this might be called from a different thread than the actual request, we can't call cacheNode.updateMetaData(), which by default tries to set the authorId by using the current context
251             NodeTypes.LastModified.update(cacheNode);
252             // We need to explicitly update lastModified property of resourceNode
253             // when update is done to JcrConstants.JCR_DATA property, updating of lastMofified by MgnlPropertySettingContentDecorator is not invoked, because all JCR properties are ignored by info.magnolia.jcr.wrapper.MgnlPropertySettingContentDecorator#shouldIgnoreUpdate(final String propertyName)
254             NodeTypes.LastModified.update(resourceNode);
255 
256             // finally save it all
257             try {
258                 systemSession.save();
259             } catch (InvalidItemStateException e){
260                 log.warn("Item found in invalid state. Attempting to refresh the session.");
261                 systemSession.refresh(false);
262                 return systemSession.getNode(cachePath + "/" + GENERATED_IMAGE_NODE_NAME).getProperty(JcrConstants.JCR_DATA);
263             }
264 
265             return imageData;
266         } catch (RepositoryException e) {
267             throw new ImagingException("Can't store rendered image: " + e.getMessage(), e);
268         } finally {
269             lock.unlock();
270         }
271     }
272 
273     /**
274      * Unwrap ExecutionExceptions wrapping a RuntimeException wrapping an ImagingException or IOException,
275      * as thrown by the Function of the computing map.
276      *
277      * @see #currentJobs
278      */
279     private void unwrapRuntimeException(Exception e) throws ImagingException, IOException {
280         final Throwable cause = e.getCause();
281         if (cause instanceof ImagingException) {
282             throw (ImagingException) cause;
283         } else if (cause instanceof IOException) {
284             throw (IOException) cause;
285         } else if (cause instanceof RuntimeException) {
286             unwrapRuntimeException((RuntimeException) cause);
287         } else if (cause == null) {
288             // This really, really, should not happen... but we'll let this exception bubble up
289             throw new IllegalStateException("Unexpected and unhandled exception: " + (e.getMessage() != null ? e.getMessage() : ""), e);
290         } else {
291             // this shouldn't happen either, actually.
292             throw new ImagingException(e.getMessage(), cause);
293         }
294     }
295 
296     private static class TemporaryImageResponse implements ImageResponse {
297         private final OutputStream tempOut;
298 
299         public TemporaryImageResponse(OutputStream tempOut) {
300             this.tempOut = tempOut;
301         }
302 
303         @Override
304         public void setMediaType(MediaType mediaType) throws IOException {
305             // Do nothing - in the case of CachingImageStreamer, we rely on the "real" ImageResponse to set the mime-type on the response
306         }
307 
308         @Override
309         public OutputStream getOutputStream() throws IOException {
310             return tempOut;
311         }
312     }
313 }