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.http.Header;
038import org.apache.http.HttpResponse;
039import org.apache.http.client.methods.HttpUriRequest;
040import org.apache.http.impl.client.CloseableHttpClient;
041
042import org.ametys.plugins.site.proxy.BackOfficeRequestProxyExtensionPoint;
043import org.ametys.runtime.exception.ServiceUnavailableException;
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    /** The extension point for adding request headers in BO request */
071    protected BackOfficeRequestProxyExtensionPoint _requestProxyExtensionPoint;
072    
073    @Override
074    public void initialize() throws Exception
075    {
076        _pageCacheable = new ConcurrentHashMap<>();
077        _pageLocks = new ConcurrentHashMap<>();
078    }
079    
080    @Override
081    public void contextualize(Context context) throws ContextException
082    {
083        _context = context;
084    }
085    
086    @Override
087    public void service(ServiceManager serviceManager) throws ServiceException
088    {
089        _requestProxyExtensionPoint = (BackOfficeRequestProxyExtensionPoint) serviceManager.lookup(BackOfficeRequestProxyExtensionPoint.ROLE);
090    }
091    
092    /**
093     * Tests if the page is cacheable.
094     * If more than one thread call this method concurrently, only one tests the
095     * cacheability and (possibly) generates the corresponding resource.
096     * The concurrent threads are blocked until the page is known not to be cacheable,
097     * or known to be cacheable and put into the cache (in {@link GeneratePageAction}).
098     * @param pagePath the page path.
099     * @return true if the page is cacheable, false otherwise.
100     * @throws IOException if an error occurs sending the cacheability request to the back-office.
101     * @throws ServiceUnavailableException If the server is in maintenance
102     * @see GeneratePageAction
103     */
104    public boolean isCacheable(String pagePath) throws IOException, ServiceUnavailableException
105    {
106        boolean cacheable = false;
107        
108        // If we don't know if the page is cacheable, or if the page is known to be cacheable, block.
109        if (!_pageCacheable.containsKey(pagePath) || _pageCacheable.get(pagePath))
110        {
111            if (getLogger().isDebugEnabled())
112            {
113                getLogger().debug("Page cacheability unknown or known to be cacheable, testing : " + pagePath);
114            }
115            
116            // Retrieve the shared lock.
117            ReentrantLock lock = new ReentrantLock();
118            
119            ReentrantLock oldLock = _pageLocks.putIfAbsent(pagePath, lock);
120            if (oldLock != null)
121            {
122                lock = oldLock;
123            }
124            
125            if (getLogger().isDebugEnabled())
126            {
127                getLogger().debug("Acquiring lock for page " + pagePath + (lock.isLocked() ? " (already locked)" : " (unlocked)") + ", queue length: " + lock.getQueueLength() + ", hold count: " + lock.getHoldCount());
128            }
129            
130            // Try to acquire the lock. If the thread is the first to acquire the lock,
131            // it will go on. If not, it will be blocked until the owner unlocks.
132            // It MUST be unlocked (call to unlock) by the same thread.
133            lock.lock();
134            
135            // At this point, the cacheable status could already be computed by another thread.
136            if (_pageCacheable.containsKey(pagePath))
137            {
138                cacheable = _pageCacheable.get(pagePath);
139                
140                if (getLogger().isDebugEnabled())
141                {
142                    getLogger().debug("The page " + pagePath + " was computed to be " + (cacheable ? "" : "not ") + "cacheable by another thread, unlocking...");
143                }
144            }
145            else
146            {
147                if (getLogger().isDebugEnabled())
148                {
149                    getLogger().debug("Asking the CMS if the page is cacheable: " + pagePath);
150                }
151                
152                cacheable = _isCacheable(pagePath);
153                
154                _pageCacheable.putIfAbsent(pagePath, cacheable);
155                
156                if (getLogger().isDebugEnabled())
157                {
158                    getLogger().debug("The CMS answered that the page " + pagePath + " is " + (cacheable ? "" : "not ") + "cacheable.");
159                }
160            }
161        }
162        
163        return cacheable;
164    }
165    
166    /**
167     * Release the lock associated with a page.
168     * @param pagePath the page path.
169     */
170    public void unlock(String pagePath)
171    {
172        if (_pageLocks.containsKey(pagePath))
173        {
174            if (getLogger().isDebugEnabled())
175            {
176                getLogger().debug("The page " + pagePath + " is being unlocked.");
177            }
178            
179            ReentrantLock lock = _pageLocks.get(pagePath);
180            
181            // This method is called from several locations, where it's hard to know if the resource has actually been locked or not.
182            // So simply ask the lock itself
183            if (lock.isHeldByCurrentThread())
184            {
185                if (getLogger().isDebugEnabled())
186                {
187                    getLogger().debug("Releasing lock for page " + pagePath + ", queue length: " + lock.getQueueLength() + ", hold count: " + lock.getHoldCount());
188                }
189                
190                lock.unlock();
191                
192                if (getLogger().isDebugEnabled())
193                {
194                    getLogger().debug("The page " + pagePath + " has been successfully unlocked.");
195                }
196            }
197        }
198    }
199    
200    /**
201     * Resets all the local caches and locks.
202     */
203    public void reset()
204    {
205        _pageCacheable = new ConcurrentHashMap<>();
206    }
207    
208    /**
209     * Remove a specified page path from the local cache and lock.
210     * @param pagePath the page path.
211     */
212    public void resetPage(String pagePath)
213    {
214        _pageCacheable.remove(pagePath);
215    }
216    
217    /**
218     * Get the cacheable status of a page from the back-office.
219     * @param pagePath the page path.
220     * @return true if the page is cacheable, false otherwise.
221     * @throws IOException  in case of a problem or the connection was aborted
222     */
223    protected boolean _isCacheable(String pagePath) throws IOException
224    {
225        Map objectModel = ContextHelper.getObjectModel(_context);
226        
227        CloseableHttpClient httpClient = BackOfficeRequestHelper.getHttpClient();
228        HttpUriRequest cmsRequest = BackOfficeRequestHelper.getRequest(objectModel, pagePath, _requestProxyExtensionPoint);
229        
230        HttpResponse cmsResponse = httpClient.execute(cmsRequest);
231        Request request = ObjectModelHelper.getRequest(objectModel);
232        request.setAttribute("cms-request", cmsRequest);
233        request.setAttribute("cms-response", cmsResponse);
234        request.setAttribute("http-client", httpClient);
235        
236        Header header = cmsResponse.getFirstHeader("X-Ametys-Cacheable");
237        return header != null && "true".equals(header.getValue());
238    }
239}