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