001/*
002 * (C) Copyright 2015 Nuxeo SA (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 * Nuxeo - initial API and implementation
018 */
019
020package org.nuxeo.ecm.platform.rendition.lazy;
021
022import java.security.MessageDigest;
023import java.security.NoSuchAlgorithmException;
024import java.util.Calendar;
025import java.util.Collections;
026import java.util.List;
027import java.util.Objects;
028import java.util.stream.Collectors;
029
030import org.apache.commons.logging.Log;
031import org.apache.commons.logging.LogFactory;
032import org.nuxeo.ecm.core.api.Blob;
033import org.nuxeo.ecm.core.api.DocumentModel;
034import org.nuxeo.ecm.core.api.NuxeoException;
035import org.nuxeo.ecm.core.api.impl.blob.StringBlob;
036import org.nuxeo.ecm.core.transientstore.api.TransientStore;
037import org.nuxeo.ecm.core.transientstore.api.TransientStoreService;
038import org.nuxeo.ecm.core.work.api.Work;
039import org.nuxeo.ecm.core.work.api.WorkManager;
040import org.nuxeo.ecm.platform.rendition.Rendition;
041import org.nuxeo.ecm.platform.rendition.extension.DefaultAutomationRenditionProvider;
042import org.nuxeo.ecm.platform.rendition.extension.RenditionProvider;
043import org.nuxeo.ecm.platform.rendition.impl.LazyRendition;
044import org.nuxeo.ecm.platform.rendition.service.RenditionDefinition;
045import org.nuxeo.runtime.api.Framework;
046
047/**
048 * Default implementation of an asynchronous {@link RenditionProvider}
049 *
050 * @author <a href="mailto:tdelprat@nuxeo.com">Tiry</a>
051 * @since 7.2
052 */
053public abstract class AbstractLazyCachableRenditionProvider extends DefaultAutomationRenditionProvider {
054
055    public static final String SOURCE_DOCUMENT_MODIFICATION_DATE_KEY = "sourceDocumentModificationDate";
056
057    public static final String CACHE_NAME = "LazyRenditionCache";
058
059    protected static Log log = LogFactory.getLog(AbstractLazyCachableRenditionProvider.class);
060
061    @Override
062    public List<Blob> render(DocumentModel doc, RenditionDefinition definition) {
063        if (log.isDebugEnabled()) {
064            log.debug(String.format("Asking \"%s\" rendition lazy rendering for document %s (id=%s).",
065                    definition.getName(), doc.getPathAsString(), doc.getId()));
066        }
067
068        // Build the rendition key and get the current source document modification date
069        String key = buildRenditionKey(doc, definition);
070        String sourceDocumentModificationDate = getSourceDocumentModificationDate(doc, definition);
071
072        // If rendition is not already in progress schedule it
073        List<Blob> blobs = null;
074        TransientStore ts = getTransientStore();
075        if (!ts.exists(key)) {
076            blobs = handleNewRendition(key, doc, definition, sourceDocumentModificationDate);
077        } else {
078            String storedSourceDocumentModificationDate = (String) ts.getParameter(key,
079                    SOURCE_DOCUMENT_MODIFICATION_DATE_KEY);
080            blobs = ts.getBlobs(key);
081            if (ts.isCompleted(key)) {
082                handleCompletedRendition(key, doc, definition, sourceDocumentModificationDate,
083                        storedSourceDocumentModificationDate, blobs);
084            } else {
085                handleIncompleteRendition(key, doc, definition, sourceDocumentModificationDate,
086                        storedSourceDocumentModificationDate);
087            }
088        }
089
090        if (log.isDebugEnabled()) {
091            String blobInfo = null;
092            if (blobs != null) {
093                blobInfo = blobs.stream()
094                                .map(blob -> String.format("{filename=%s, MIME type=%s}", blob.getFilename(),
095                                        blob.getMimeType()))
096                                .collect(Collectors.joining(",", "[", "]"));
097            }
098            log.debug(String.format("Returning blobs: %s.", blobInfo));
099        }
100        return blobs;
101    }
102
103    public String buildRenditionKey(DocumentModel doc, RenditionDefinition def) {
104        StringBuilder sb = new StringBuilder(doc.getId());
105        sb.append("::");
106        String variant = getVariant(doc, def);
107        if (variant != null) {
108            sb.append(variant);
109            sb.append("::");
110        }
111        sb.append(def.getName());
112
113        String key = getDigest(sb.toString());
114        if (log.isDebugEnabled()) {
115            log.debug(String.format("Built rendition key for document %s (id=%s): %s.", doc.getPathAsString(),
116                    doc.getId(), key));
117        }
118        return key;
119    }
120
121    public String getSourceDocumentModificationDate(DocumentModel doc, RenditionDefinition definition) {
122        String modificationDatePropertyName = definition.getSourceDocumentModificationDatePropertyName();
123        Calendar modificationDate = (Calendar) doc.getPropertyValue(modificationDatePropertyName);
124        if (modificationDate == null) {
125            return null;
126        }
127        long millis = modificationDate.getTimeInMillis();
128        // the date may have been rounded by the storage layer, normalize it to the second
129        millis -= millis % 1000;
130        return String.valueOf(millis);
131    }
132
133    protected String getDigest(String key) {
134        MessageDigest digest;
135        try {
136            digest = MessageDigest.getInstance("MD5");
137        } catch (NoSuchAlgorithmException e) {
138            return key;
139        }
140        byte[] buf = digest.digest(key.getBytes());
141        return toHexString(buf);
142    }
143
144    private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray();
145
146    protected String toHexString(byte[] data) {
147        StringBuilder buf = new StringBuilder(2 * data.length);
148        for (byte b : data) {
149            buf.append(HEX_DIGITS[(0xF0 & b) >> 4]);
150            buf.append(HEX_DIGITS[0x0F & b]);
151        }
152        return buf.toString();
153    }
154
155    protected TransientStore getTransientStore() {
156        TransientStoreService tss = Framework.getService(TransientStoreService.class);
157        TransientStore ts = tss.getStore(CACHE_NAME);
158        if (ts == null) {
159            throw new NuxeoException("Unable to find Transient Store  " + CACHE_NAME);
160        }
161        return ts;
162    }
163
164    protected List<Blob> handleNewRendition(String key, DocumentModel doc, RenditionDefinition definition,
165            String sourceDocumentModificationDate) {
166        Work work = getRenditionWork(key, doc, definition);
167        if (log.isDebugEnabled()) {
168            log.debug(String.format(
169                    "No entry found for key %s in the %s transient store, scheduling rendition work with id %s and storing an empty blob for now.",
170                    key, CACHE_NAME, work.getId()));
171        }
172        if (sourceDocumentModificationDate != null) {
173            getTransientStore().putParameter(key, SOURCE_DOCUMENT_MODIFICATION_DATE_KEY,
174                    sourceDocumentModificationDate);
175        }
176        StringBlob emptyBlob = new StringBlob("");
177        emptyBlob.setFilename(LazyRendition.IN_PROGRESS_MARKER);
178        emptyBlob.setMimeType("text/plain;" + LazyRendition.EMPTY_MARKER);
179        getTransientStore().putBlobs(key, Collections.singletonList(emptyBlob));
180        Framework.getService(WorkManager.class).schedule(work);
181        return Collections.singletonList(emptyBlob);
182    }
183
184    protected void handleCompletedRendition(String key, DocumentModel doc, RenditionDefinition definition,
185            String sourceDocumentModificationDate, String storedSourceDocumentModificationDate, List<Blob> blobs) {
186        if (log.isDebugEnabled()) {
187            log.debug(String.format("Completed entry found for key %s in the %s transient store.", key, CACHE_NAME));
188        }
189
190        // No or more than one blob
191        if (blobs == null || blobs.size() != 1) {
192            if (log.isDebugEnabled()) {
193                log.debug(String.format(
194                        "No (or more than one) rendition blob for key %s, releasing entry from the transient store.",
195                        key));
196            }
197            getTransientStore().release(key);
198            return;
199        }
200
201        // Blob in error
202        Blob blob = blobs.get(0);
203        String mimeType = blob.getMimeType();
204        if (mimeType != null && mimeType.contains(LazyRendition.ERROR_MARKER)) {
205            if (log.isDebugEnabled()) {
206                log.debug(String.format("Rendition blob is in error for key %s.", key));
207            }
208            // Check if rendition is up-to-date
209            if (Objects.equals(storedSourceDocumentModificationDate, sourceDocumentModificationDate)) {
210                log.debug("Removing entry from the transient store.");
211                getTransientStore().remove(key);
212                return;
213            }
214            Work work = getRenditionWork(key, doc, definition);
215            if (log.isDebugEnabled()) {
216                log.debug(String.format(
217                        "Source document modification date %s is different from the stored one %s, scheduling rendition work with id %s and returning an error/stale rendition.",
218                        sourceDocumentModificationDate, storedSourceDocumentModificationDate, work.getId()));
219            }
220            if (sourceDocumentModificationDate != null) {
221                getTransientStore().putParameter(key, SOURCE_DOCUMENT_MODIFICATION_DATE_KEY,
222                        sourceDocumentModificationDate);
223            }
224            Framework.getService(WorkManager.class).schedule(work);
225            blob.setMimeType(blob.getMimeType() + ";" + LazyRendition.STALE_MARKER);
226            return;
227        }
228
229        // Check if rendition is up-to-date
230        if (Objects.equals(storedSourceDocumentModificationDate, sourceDocumentModificationDate)) {
231            if (log.isDebugEnabled()) {
232                log.debug(String.format(
233                        "Rendition blob is up-to-date for key %s, returning it and releasing entry from the transient store.",
234                        key));
235            }
236            getTransientStore().release(key);
237            return;
238        }
239
240        // Stale rendition
241        Work work = getRenditionWork(key, doc, definition);
242        if (log.isDebugEnabled()) {
243            log.debug(String.format(
244                    "Source document modification date %s is different from the stored one %s, scheduling rendition work with id %s and returning a stale rendition.",
245                    sourceDocumentModificationDate, storedSourceDocumentModificationDate, work.getId()));
246        }
247        if (sourceDocumentModificationDate != null) {
248            getTransientStore().putParameter(key, SOURCE_DOCUMENT_MODIFICATION_DATE_KEY,
249                    sourceDocumentModificationDate);
250        }
251        Framework.getService(WorkManager.class).schedule(work);
252        blob.setMimeType(blob.getMimeType() + ";" + LazyRendition.STALE_MARKER);
253    }
254
255    protected void handleIncompleteRendition(String key, DocumentModel doc, RenditionDefinition definition,
256            String sourceDocumentModificationDate, String storedSourceDocumentModificationDate) {
257        if (log.isDebugEnabled()) {
258            log.debug(String.format("Incomplete entry found for key %s in the %s transient store.", key, CACHE_NAME));
259        }
260        WorkManager workManager = Framework.getService(WorkManager.class);
261        Work work = getRenditionWork(key, doc, definition);
262        String workId = work.getId();
263        boolean scheduleWork = false;
264        if (Objects.equals(storedSourceDocumentModificationDate, sourceDocumentModificationDate)) {
265            Work.State workState = workManager.getWorkState(workId);
266            if (workState == null) {
267                if (log.isDebugEnabled()) {
268                    log.debug(String.format("Found no existing work with id %s.", workId));
269                }
270                scheduleWork = true;
271            } else {
272                if (log.isDebugEnabled()) {
273                    log.debug(String.format("Found an existing work with id %s in sate %s.", workId, workState));
274                }
275            }
276        } else {
277            if (log.isDebugEnabled()) {
278                log.debug(String.format("Source document modification date %s is different from the stored one %s.",
279                        sourceDocumentModificationDate, storedSourceDocumentModificationDate));
280            }
281            if (sourceDocumentModificationDate != null) {
282                getTransientStore().putParameter(key, SOURCE_DOCUMENT_MODIFICATION_DATE_KEY,
283                        sourceDocumentModificationDate);
284            }
285            scheduleWork = true;
286        }
287        if (scheduleWork) {
288            if (log.isDebugEnabled()) {
289                log.debug(String.format("Scheduling rendition work with id %s.", workId));
290            }
291            workManager.schedule(work);
292        }
293    }
294
295    /**
296     * Return the {@link Work} that will compute the {@link Rendition}. {@link AbstractRenditionBuilderWork} can be used
297     * as a base class
298     *
299     * @param key the key used to rendition
300     * @param doc the target {@link DocumentModel}
301     * @param def the {@link RenditionDefinition}
302     * @return
303     */
304    protected abstract Work getRenditionWork(final String key, final DocumentModel doc, final RenditionDefinition def);
305
306}