001/*
002 * (C) Copyright 2006-2016 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 *     Thierry Delprat
018 *     Florent Guillaume
019 */
020package org.nuxeo.ecm.quota.size;
021
022import static org.nuxeo.ecm.core.api.LifeCycleConstants.DELETED_STATE;
023import static org.nuxeo.ecm.core.api.LifeCycleConstants.DELETE_TRANSITION;
024
025import java.io.Serializable;
026import java.util.Collection;
027import java.util.HashSet;
028import java.util.List;
029import java.util.Map;
030
031import org.apache.commons.logging.Log;
032import org.apache.commons.logging.LogFactory;
033import org.nuxeo.ecm.core.api.Blob;
034import org.nuxeo.ecm.core.api.CoreSession;
035import org.nuxeo.ecm.core.api.DocumentModel;
036import org.nuxeo.ecm.core.api.IdRef;
037import org.nuxeo.ecm.core.api.IterableQueryResult;
038import org.nuxeo.ecm.core.event.Event;
039import org.nuxeo.ecm.core.utils.BlobsExtractor;
040import org.nuxeo.ecm.quota.AbstractQuotaStatsUpdater;
041import org.nuxeo.ecm.quota.QuotaStatsInitialWork;
042import org.nuxeo.runtime.api.Framework;
043
044/**
045 * {@link org.nuxeo.ecm.quota.QuotaStatsUpdater} counting space used by Blobs in document. This implementation does not
046 * track the space used by non-Blob properties.
047 *
048 * @since 8.3
049 */
050public class DocumentsSizeUpdater extends AbstractQuotaStatsUpdater {
051
052    private static Log log = LogFactory.getLog(DocumentsSizeUpdater.class);
053
054    public static final String DISABLE_QUOTA_CHECK_LISTENER = "disableQuotaListener";
055
056    public static final String USER_WORKSPACES_ROOT = "UserWorkspacesRoot";
057
058    @Override
059    public void computeInitialStatistics(CoreSession session, QuotaStatsInitialWork currentWorker) {
060        log.debug("Starting initial Quota computation");
061        String query = "SELECT ecm:uuid FROM Document WHERE ecm:isCheckedInVersion = 0 AND ecm:isProxy = 0";
062
063        // reset on all documents
064        // this will force an update if the quota addon was installed and then removed
065        long count = 0;
066        IterableQueryResult res = session.queryAndFetch(query, "NXQL");
067        try {
068            count = res.size();
069            log.debug("Start iteration on " + count + " items");
070            for (Map<String, Serializable> r : res) {
071                String uuid = (String) r.get("ecm:uuid");
072                DocumentModel doc = session.getDocument(new IdRef(uuid));
073                QuotaAwareDocumentFactory.unmake(doc);
074            }
075        } finally {
076            res.close();
077        }
078        QuotaAwareDocumentFactory.unmake(session.getRootDocument());
079        session.save();
080
081        // recompute quota on each doc
082        res = session.queryAndFetch(query, "NXQL");
083        try {
084            long idx = 0;
085            for (Map<String, Serializable> r : res) {
086                String uuid = (String) r.get("ecm:uuid");
087                DocumentModel doc = session.getDocument(new IdRef(uuid));
088                if (log.isTraceEnabled()) {
089                    log.trace("process Quota initial computation on uuid " + doc.getId());
090                    log.trace("doc with uuid " + doc.getId() + " started update");
091                }
092                initDocument(session, doc);
093                if (log.isTraceEnabled()) {
094                    log.trace("doc with uuid " + doc.getId() + " update completed");
095                }
096                currentWorker.notifyProgress(++idx, count);
097            }
098        } finally {
099            res.close();
100        }
101    }
102
103    protected void initDocument(CoreSession session, DocumentModel doc) {
104        boolean isDeleted = DELETED_STATE.equals(doc.getCurrentLifeCycleState());
105        long size = getBlobsSize(doc);
106        long versionsSize = getVersionsSize(session, doc);
107        updateDocumentAndAncestors(session, doc, size, size + versionsSize, isDeleted ? size : 0, versionsSize);
108    }
109
110    @Override
111    protected void handleQuotaExceeded(QuotaExceededException e, Event event) {
112        String msg = "Current event " + event.getName() + " would break Quota restriction, rolling back";
113        log.info(msg);
114        e.addInfo(msg);
115        event.markRollBack("Quota Exceeded", e);
116    }
117
118    @Override
119    protected void processDocumentCreated(CoreSession session, DocumentModel doc) {
120        if (doc.isVersion()) {
121            // version taken into account by checkout
122            return;
123        }
124        QuotaAware quotaDoc = doc.getAdapter(QuotaAware.class);
125        if (quotaDoc == null) {
126            // always add the quota facet
127            quotaDoc = QuotaAwareDocumentFactory.make(doc);
128            quotaDoc.save();
129        }
130        long size = getBlobsSize(doc);
131        checkQuota(session, doc, size);
132        updateDocumentAndAncestors(session, doc, size, size, 0, 0);
133    }
134
135    @Override
136    protected void processDocumentCheckedIn(CoreSession session, DocumentModel doc) {
137        // on checkin the versions size is incremented (and also the total)
138        long size = getBlobsSize(doc);
139        // no constraints check as total size is not impacted
140        updateDocumentAndAncestors(session, doc, 0, size, 0, size);
141    }
142
143    @Override
144    protected void processDocumentCheckedOut(CoreSession session, DocumentModel doc) {
145        // on checkout we account in the total for the last version size
146        long size = getBlobsSize(doc);
147        checkQuota(session, doc, size);
148        // all quota computation handled on checkin
149    }
150
151    @Override
152    protected void processDocumentUpdated(CoreSession session, DocumentModel doc) {
153        // nothing to do, we do things at beforeDocumentModification time
154    }
155
156    @Override
157    protected void processDocumentBeforeUpdate(CoreSession session, DocumentModel doc) {
158        QuotaAware quotaDoc = doc.getAdapter(QuotaAware.class);
159        long oldSize = quotaDoc == null ? 0 : quotaDoc.getInnerSize();
160        long delta = getBlobsSize(doc) - oldSize;
161        checkQuota(session, doc, delta);
162        updateDocument(doc, delta, delta, 0, 0, false); // DO NOT SAVE as this is a "before" event
163        updateAncestors(session, doc, delta, 0, 0);
164    }
165
166    @Override
167    protected void processDocumentCopied(CoreSession session, DocumentModel doc) {
168        QuotaAware quotaDoc = doc.getAdapter(QuotaAware.class);
169        if (quotaDoc == null) {
170            return;
171        }
172        long size = quotaDoc.getTotalSize() - quotaDoc.getVersionsSize() - quotaDoc.getTrashSize();
173        checkQuota(session, doc, size);
174        if (!doc.isFolder() && size > 0) {
175            // when we copy some doc that is not folderish, we don't
176            // copy the versions so we can't rely on the copied quotaDocInfo
177            quotaDoc.resetInfos();
178            quotaDoc.save();
179            updateDocument(doc, size, size, 0, 0);
180        }
181        updateAncestors(session, doc, size, 0, 0);
182    }
183
184    @Override
185    protected void processDocumentMoved(CoreSession session, DocumentModel doc, DocumentModel sourceParent) {
186        QuotaAware quotaDoc = doc.getAdapter(QuotaAware.class);
187        long size = quotaDoc == null ? 0 : quotaDoc.getTotalSize();
188        checkQuota(session, doc, size);
189        long versionsSize = quotaDoc.getVersionsSize();
190        // add on new ancestors
191        updateAncestors(session, doc, size, 0, versionsSize);
192        // remove from old ancestors
193        if (sourceParent != null) {
194            updateDocumentAndAncestors(session, sourceParent, 0, -size, 0, -versionsSize);
195        }
196    }
197
198    @Override
199    protected void processDocumentAboutToBeRemoved(CoreSession session, DocumentModel doc) {
200        if (doc.isVersion()) {
201            // for versions we need to decrement the live doc + its parents
202            // We only have to decrement the inner size of this doc
203            // we do not write the right quota on the version, so we need to recompute it instead of
204            // quotaDoc#getInnerSize
205            long size = getBlobsSize(doc);
206            String sourceId = doc.getSourceId();
207            if (size > 0 && sourceId != null) {
208                DocumentModel liveDoc = session.getDocument(new IdRef(sourceId));
209                updateDocumentAndAncestors(session, liveDoc, 0, -size, 0, -size);
210            }
211            return;
212        }
213
214        QuotaAware quotaDoc = doc.getAdapter(QuotaAware.class);
215        long size;
216        long versionsSize;
217        if (quotaDoc == null) {
218            // the document could have been just created and the previous computation
219            // hasn't finished yet, see NXP-13665
220            size = getBlobsSize(doc);
221            versionsSize = 0;
222            if (log.isTraceEnabled()) {
223                log.trace(
224                        "Document " + doc.getId() + " doesn't have the facet quotaDoc. Compute impacted size:" + size);
225            }
226        } else {
227            size = quotaDoc.getTotalSize();
228            versionsSize = quotaDoc.getVersionsSize();
229            if (log.isTraceEnabled()) {
230                log.trace("Found facet quotaDoc on document  " + doc.getId() + ". Total size: " + size
231                        + " and versions size: " + versionsSize);
232            }
233        }
234        // remove size for all its versions from sizeVersions on parents
235        boolean isDeleted = DELETED_STATE.equals(doc.getCurrentLifeCycleState());
236        // when permanently deleting the doc clean the trash if the doc is in trash
237        // and all archived versions size
238        if (log.isTraceEnabled()) {
239            log.trace("Processing document about to be removed on parents. Total: " + size + " , trash size: "
240                    + (isDeleted ? size : 0) + " , versions size: " + versionsSize);
241        }
242        long deltaTrash = isDeleted ? versionsSize - size : 0;
243        updateAncestors(session, doc, -size, deltaTrash, -versionsSize);
244    }
245
246    @Override
247    protected void processDocumentTrashOp(CoreSession session, DocumentModel doc, String transition) {
248        QuotaAware quotaDoc = doc.getAdapter(QuotaAware.class);
249        if (quotaDoc == null) {
250            return;
251        }
252        long size = quotaDoc.getInnerSize();
253        if (log.isTraceEnabled()) {
254            if (quotaDoc.getDoc().isFolder()) {
255                log.trace(quotaDoc.getDoc().getPathAsString() + " is a folder, just inner size (" + size
256                        + ") taken into account for trash size");
257            }
258        }
259        boolean isDelete = DELETE_TRANSITION.equals(transition); // otherwise undelete
260        long delta = isDelete ? size : -size;
261        // constraints check not needed, since the documents stays in the same folder
262        updateDocumentAndAncestors(session, doc, 0, 0, delta, 0);
263    }
264
265    @Override
266    protected void processDocumentBeforeRestore(CoreSession session, DocumentModel doc) {
267        QuotaAware quotaDoc = doc.getAdapter(QuotaAware.class);
268        if (quotaDoc == null) {
269            return;
270        }
271        // remove versions size on parents since they will be recalculated on restore
272        long size = quotaDoc.getTotalSize();
273        long versionsSize = quotaDoc.getVersionsSize();
274        updateAncestors(session, doc, -size, 0, -versionsSize);
275    }
276
277    @Override
278    protected void processDocumentRestored(CoreSession session, DocumentModel doc) {
279        QuotaAware quotaDoc = QuotaAwareDocumentFactory.make(doc);
280        quotaDoc.resetInfos();
281        quotaDoc.save();
282        long size = getBlobsSize(doc);
283        long versionsSize = getVersionsSize(session, doc);
284        updateDocumentAndAncestors(session, doc, size, size + versionsSize, 0, versionsSize);
285    }
286
287    @Override
288    protected boolean needToProcessEventOnDocument(Event event, DocumentModel doc) {
289        if (doc == null) {
290            return false;
291        }
292        if (doc.isProxy()) {
293            return false;
294        }
295        if (Boolean.TRUE.equals(doc.getContextData(DISABLE_QUOTA_CHECK_LISTENER))) {
296            // avoid reentrancy
297            return false;
298        }
299        return true;
300    }
301
302    /** Checks the size delta against the maximum quota specified for this document or an ancestor. */
303    protected void checkQuota(CoreSession session, DocumentModel doc, long delta) {
304        if (delta <= 0) {
305            return;
306        }
307        for (DocumentModel parent : getAncestors(session, doc)) {
308            if (log.isTraceEnabled()) {
309                log.trace("processing " + parent.getId() + " " + parent.getPathAsString());
310            }
311            QuotaAware quotaDoc = parent.getAdapter(QuotaAware.class);
312            // when enabling quota on user workspaces, the max size set on the
313            // UserWorkspacesRoot is the max size set on every user workspace
314            if (quotaDoc == null || quotaDoc.getMaxQuota() <= 0 || USER_WORKSPACES_ROOT.equals(parent.getType())) {
315                continue;
316            }
317            if (quotaDoc.getTotalSize() + delta > quotaDoc.getMaxQuota()) {
318                log.info("Raising Quota Exception on " + doc.getId() + " (" + doc.getPathAsString() + ")");
319                throw new QuotaExceededException(parent, doc, quotaDoc.getMaxQuota());
320            }
321        }
322    }
323
324    /** Gets the sum of all blobs sizes for all the document's versions. */
325    protected long getVersionsSize(CoreSession session, DocumentModel doc) {
326        long versionsSize = 0;
327        for (DocumentModel version : session.getVersions(doc.getRef())) {
328            versionsSize += getBlobsSize(version);
329        }
330        return versionsSize;
331    }
332
333    /** Gets the sum of all blobs sizes for this document. */
334    protected long getBlobsSize(DocumentModel doc) {
335        long size = 0;
336        for (Blob blob : getAllBlobs(doc)) {
337            size += blob.getLength();
338        }
339        return size;
340    }
341
342    /** Returns the list of blobs for this document. */
343    protected List<Blob> getAllBlobs(DocumentModel doc) {
344        QuotaSizeService sizeService = Framework.getService(QuotaSizeService.class);
345        Collection<String> excludedPaths = sizeService.getExcludedPathList();
346        BlobsExtractor extractor = new BlobsExtractor();
347        extractor.setExtractorProperties(null, new HashSet<>(excludedPaths), true);
348        return extractor.getBlobs(doc);
349    }
350
351    protected void updateDocument(DocumentModel doc, long deltaInner, long deltaTotal, long deltaTrash,
352            long deltaVersions) {
353        updateDocument(doc, deltaInner, deltaTotal, deltaTrash, deltaVersions, true);
354    }
355
356    protected void updateDocument(DocumentModel doc, long deltaInner, long deltaTotal, long deltaTrash,
357            long deltaVersions, boolean allowSave) {
358        QuotaAware quotaDoc = doc.getAdapter(QuotaAware.class);
359        boolean save = false;
360        if (quotaDoc == null) {
361            if (log.isTraceEnabled()) {
362                log.trace("   add quota on: " + doc.getId() + " (" + doc.getPathAsString() + ")");
363            }
364            quotaDoc = QuotaAwareDocumentFactory.make(doc);
365            save = true;
366        } else {
367            if (log.isTraceEnabled()) {
368                log.trace("   update quota on: " + doc.getId() + " (" + doc.getPathAsString() + ") (" + quotaDoc.getQuotaInfo() + ")");
369            }
370        }
371        if (deltaInner != 0) {
372            quotaDoc.addInnerSize(deltaInner);
373            save = true;
374        }
375        if (deltaTotal != 0) {
376            quotaDoc.addTotalSize(deltaTotal);
377            save = true;
378        }
379        if (deltaTrash != 0) {
380            quotaDoc.addTrashSize(deltaTrash);
381            save = true;
382        }
383        if (deltaVersions != 0) {
384            quotaDoc.addVersionsSize(deltaVersions);
385            save = true;
386        }
387        if (save && allowSave) {
388            quotaDoc.save();
389        }
390        if (log.isTraceEnabled()) {
391            log.trace("     ==> " + doc.getId() + " (" + doc.getPathAsString() + ") (" + quotaDoc.getQuotaInfo() + ")");
392        }
393    }
394
395    protected void updateAncestors(CoreSession session, DocumentModel doc, long deltaTotal, long deltaTrash,
396            long deltaVersions) {
397        if (deltaTotal == 0 && deltaTrash == 0 && deltaVersions == 0) {
398            // avoids computing ancestors if there's no update to do
399            return;
400        }
401        List<DocumentModel> ancestors = getAncestors(session, doc);
402        for (DocumentModel ancestor : ancestors) {
403            updateDocument(ancestor, 0, deltaTotal, deltaTrash, deltaVersions);
404        }
405    }
406
407    protected void updateDocumentAndAncestors(CoreSession session, DocumentModel doc, long deltaInner, long deltaTotal,
408            long deltaTrash, long deltaVersions) {
409        updateDocument(doc, deltaInner, deltaTotal, deltaTrash, deltaVersions);
410        updateAncestors(session, doc, deltaTotal, deltaTrash, deltaVersions);
411    }
412
413}