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