001/*
002 *  Copyright 2011 Anyware Services
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 */
016package org.ametys.site;
017
018import java.io.IOException;
019import java.util.Map;
020import java.util.concurrent.ConcurrentHashMap;
021import java.util.concurrent.ConcurrentMap;
022import java.util.concurrent.locks.ReentrantLock;
023
024import org.apache.avalon.framework.activity.Initializable;
025import org.apache.avalon.framework.component.Component;
026import org.apache.avalon.framework.context.Context;
027import org.apache.avalon.framework.context.ContextException;
028import org.apache.avalon.framework.context.Contextualizable;
029import org.apache.avalon.framework.logger.AbstractLogEnabled;
030import org.apache.avalon.framework.service.ServiceException;
031import org.apache.avalon.framework.service.ServiceManager;
032import org.apache.avalon.framework.service.Serviceable;
033import org.apache.avalon.framework.thread.ThreadSafe;
034import org.apache.cocoon.components.ContextHelper;
035import org.apache.cocoon.environment.ObjectModelHelper;
036import org.apache.cocoon.environment.Request;
037import org.apache.excalibur.xml.sax.SAXParser;
038import org.apache.http.Header;
039import org.apache.http.HttpResponse;
040import org.apache.http.client.methods.HttpUriRequest;
041import org.apache.http.impl.client.CloseableHttpClient;
042
043import org.ametys.plugins.site.headers.RequestHeaderExtensionPoint;
044
045/**
046 * Component that regulates access to the page cache.
047 */
048public class CacheAccessManager extends AbstractLogEnabled implements Component, Initializable, Serviceable, Contextualizable, ThreadSafe
049{
050    
051    /** The avalon role. */
052    public static final String ROLE = CacheAccessManager.class.getName();
053    
054    /**
055     * A Map keeping record of whether the pages are cacheable or not.
056     * The key is the page path, the value indicates if the page is cacheable.
057     */
058    protected ConcurrentMap<String, Boolean> _pageCacheable;
059    
060    /**
061     * Map of page cache locks.
062     * The lock is acquired on the first call to the page, when the cache is empty,
063     * and released when the page is written into the cache.
064     */
065    protected ConcurrentMap<String, ReentrantLock> _pageLocks;
066    
067    /** The cocoon context. */
068    protected Context _context;
069    
070    /** A SAX parser. */
071    protected SAXParser _parser;
072
073    /** The extension point for adding request headers in BO request */
074    protected RequestHeaderExtensionPoint _requestHeaderEP;
075    
076    @Override
077    public void initialize() throws Exception
078    {
079        _pageCacheable = new ConcurrentHashMap<>();
080        _pageLocks = new ConcurrentHashMap<>();
081    }
082    
083    @Override
084    public void contextualize(Context context) throws ContextException
085    {
086        _context = context;
087    }
088    
089    @Override
090    public void service(ServiceManager serviceManager) throws ServiceException
091    {
092        _parser = (SAXParser) serviceManager.lookup(SAXParser.ROLE);
093        _requestHeaderEP = (RequestHeaderExtensionPoint) serviceManager.lookup(RequestHeaderExtensionPoint.ROLE);
094    }
095    
096    /**
097     * Tests if the page is cacheable.
098     * If more than one thread call this method concurrently, only one tests the
099     * cacheability and (possibly) generates the corresponding resource.
100     * The concurrent threads are blocked until the page is known not to be cacheable,
101     * or known to be cacheable and put into the cache (in {@link GeneratePageAction}).
102     * @param pagePath the page path.
103     * @return true if the page is cacheable, false otherwise.
104     * @throws IOException if an error occurs sending the cacheability request to the back-office.
105     * @see GeneratePageAction
106     */
107    public boolean isCacheable(String pagePath) throws IOException
108    {
109        boolean cacheable = false;
110        
111        // If we don't know if the page is cacheable, or if the page is known to be cacheable, block.
112        if (!_pageCacheable.containsKey(pagePath) || _pageCacheable.get(pagePath))
113        {
114            if (getLogger().isDebugEnabled())
115            {
116                getLogger().debug("Page cacheability unknown or known to be cacheable, testing : " + pagePath);
117            }
118            
119            // Retrieve the shared lock.
120            ReentrantLock lock = new ReentrantLock();
121            
122            ReentrantLock oldLock = _pageLocks.putIfAbsent(pagePath, lock);
123            if (oldLock != null)
124            {
125                lock = oldLock;
126            }
127            
128            if (getLogger().isDebugEnabled())
129            {
130                getLogger().debug("Acquiring lock for page " + pagePath + (lock.isLocked() ? " (already locked)" : " (unlocked)") + ", queue length: " + lock.getQueueLength() + ", hold count: " + lock.getHoldCount());
131            }
132            
133            // Try to acquire the lock. If the thread is the first to acquire the lock,
134            // it will go on. If not, it will be blocked until the owner unlocks.
135            // It MUST be unlocked (call to unlock) by the same thread.
136            lock.lock();
137            
138            // At this point, the cacheable status could already be computed by another thread.
139            if (_pageCacheable.containsKey(pagePath))
140            {
141                cacheable = _pageCacheable.get(pagePath);
142                
143                if (getLogger().isDebugEnabled())
144                {
145                    getLogger().debug("The page " + pagePath + " was computed to be " + (cacheable ? "" : "not ") + "cacheable by another thread, unlocking...");
146                }
147            }
148            else
149            {
150                if (getLogger().isDebugEnabled())
151                {
152                    getLogger().debug("Asking the CMS if the page is cacheable: " + pagePath);
153                }
154                
155                cacheable = _isCacheable(pagePath);
156                
157                _pageCacheable.putIfAbsent(pagePath, cacheable);
158                
159                if (getLogger().isDebugEnabled())
160                {
161                    getLogger().debug("The CMS answered that the page " + pagePath + " is " + (cacheable ? "" : "not ") + "cacheable.");
162                }
163            }
164        }
165        
166        return cacheable;
167    }
168    
169    /**
170     * Release the lock associated with a page.
171     * @param pagePath the page path.
172     */
173    public void unlock(String pagePath)
174    {
175        if (_pageLocks.containsKey(pagePath))
176        {
177            if (getLogger().isDebugEnabled())
178            {
179                getLogger().debug("The page " + pagePath + " is being unlocked.");
180            }
181            
182            ReentrantLock lock = _pageLocks.get(pagePath);
183            
184            // This method is called from several locations, where it's hard to know if the resource has actually been locked or not.
185            // So simply ask the lock itself
186            if (lock.isHeldByCurrentThread())
187            {
188                if (getLogger().isDebugEnabled())
189                {
190                    getLogger().debug("Releasing lock for page " + pagePath + ", queue length: " + lock.getQueueLength() + ", hold count: " + lock.getHoldCount());
191                }
192                
193                lock.unlock();
194                
195                if (getLogger().isDebugEnabled())
196                {
197                    getLogger().debug("The page " + pagePath + " has been successfully unlocked.");
198                }
199            }
200        }
201    }
202    
203    /**
204     * Resets all the local caches and locks.
205     */
206    public void reset()
207    {
208        _pageCacheable = new ConcurrentHashMap<>();
209    }
210    
211    /**
212     * Remove a specified page path from the local cache and lock.
213     * @param pagePath the page path.
214     */
215    public void resetPage(String pagePath)
216    {
217        _pageCacheable.remove(pagePath);
218    }
219    
220    /**
221     * Get the cacheable status of a page from the back-office.
222     * @param pagePath the page path.
223     * @return true if the page is cacheable, false otherwise.
224     * @throws IOException  in case of a problem or the connection was aborted
225     */
226    protected boolean _isCacheable(String pagePath) throws IOException
227    {
228        Map objectModel = ContextHelper.getObjectModel(_context);
229        
230        CloseableHttpClient httpClient = BackOfficeRequestHelper.getHttpClient();
231        HttpUriRequest cmsRequest = BackOfficeRequestHelper.getRequest(objectModel, pagePath, _requestHeaderEP);
232        
233        HttpResponse cmsResponse = httpClient.execute(cmsRequest);
234        Request request = ObjectModelHelper.getRequest(objectModel);
235        request.setAttribute("cms-request", cmsRequest);
236        request.setAttribute("cms-response", cmsResponse);
237        request.setAttribute("http-client", httpClient);
238        
239        Header header = cmsResponse.getFirstHeader("X-Ametys-Cacheable");
240        return header != null && "true".equals(header.getValue());
241    }
242}