/* * This program is free software; you can redistribute it and/or modify it under the * terms of the GNU General Public License, version 2 as published by the Free Software * Foundation. * * You should have received a copy of the GNU General Public License along with this * program; if not, you can obtain a copy at http://www.gnu.org/licenses/gpl-2.0.html * or from the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. * See the GNU General Public License for more details. * * * Copyright 2006 - 2016 Pentaho Corporation. All rights reserved. */ package org.pentaho.reporting.platform.plugin.async; import java.io.Serializable; import java.util.Collections; import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import org.apache.commons.lang.ObjectUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.pentaho.platform.api.engine.ILogoutListener; import org.pentaho.platform.api.engine.IPentahoSession; import org.pentaho.platform.engine.core.system.PentahoSystem; import org.pentaho.platform.engine.security.SecurityHelper; import org.pentaho.platform.util.StringUtil; import org.pentaho.reporting.libraries.base.util.ArgumentNullException; import org.pentaho.reporting.libraries.base.util.StringUtils; import org.pentaho.reporting.platform.plugin.staging.AsyncJobFileStagingHandler; import org.pentaho.reporting.platform.plugin.staging.IFixedSizeStreamingContent; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; public class PentahoAsyncExecutor<TReportState extends IAsyncReportState> implements ILogoutListener, IPentahoAsyncExecutor<TReportState> { public static final String BEAN_NAME = "IPentahoAsyncExecutor"; private static final Log log = LogFactory.getLog( PentahoAsyncExecutor.class ); private Map<CompositeKey, ListenableFuture<IFixedSizeStreamingContent>> futures = new ConcurrentHashMap<>(); private Map<CompositeKey, IAsyncReportExecution<TReportState>> tasks = new ConcurrentHashMap<>(); private ListeningExecutorService executorService; private final int autoSchedulerThreshold; private final MemorizeSchedulingLocationListener schedulingLocationListener; private Map<CompositeKey, ISchedulingListener> writeToJcrListeners; /** * @param capacity thread pool capacity * @param autoSchedulerThreshold quantity of rows after which reports are automatically scheduled */ public PentahoAsyncExecutor( final int capacity, final int autoSchedulerThreshold ) { this.autoSchedulerThreshold = autoSchedulerThreshold; log.info( "Initialized reporting async execution fixed thread pool with capacity: " + capacity ); executorService = new DelegatedListenableExecutor( new ThreadPoolExecutor( capacity, capacity, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(), new ThreadFactory() { @Override public Thread newThread( Runnable r ) { Thread thread = Executors.defaultThreadFactory().newThread( r ); thread.setDaemon( true ); thread.setName( "PentahoAsyncExecutor Thread Pool" ); return thread; } } ) ); PentahoSystem.addLogoutListener( this ); this.writeToJcrListeners = new ConcurrentHashMap<>(); this.schedulingLocationListener = new MemorizeSchedulingLocationListener(); } @Deprecated public PentahoAsyncExecutor( final int capacity ) { this( capacity, 0 ); } /** * This executor stores jobs (identified by their id) in a separate partition for each user (identified by the * session-id). We don't let others access our session or job-id, but need to match against the session-id for * onLogout clean-ups. */ public static class CompositeKey { private String sessionId; private String uuid; // default visibility for testing purpose CompositeKey( final IPentahoSession session, final UUID id ) { this.uuid = id.toString(); this.sessionId = session.getId(); } public boolean isSameSession( final String sessionId ) { return StringUtils.equals( sessionId, this.sessionId ); } private String getSessionId() { return sessionId; } @Override public boolean equals( final Object o ) { if ( this == o ) { return true; } if ( o == null || getClass() != o.getClass() ) { return false; } final CompositeKey that = (CompositeKey) o; return Objects.equals( sessionId, that.sessionId ) && Objects.equals( uuid, that.uuid ); } @Override public int hashCode() { return Objects.hash( sessionId, uuid ); } } @Override public UUID addTask( final IAsyncReportExecution<TReportState> task, final IPentahoSession session ) { return addTask( task, session, UUID.randomUUID() ); } @Override public UUID addTask( final IAsyncReportExecution<TReportState> task, final IPentahoSession session, final UUID id ) { final CompositeKey key = new CompositeKey( session, id ); task.notifyTaskQueued( id, Collections.singletonList( new AutoScheduleListener( id, session, autoSchedulerThreshold, this ) ) ); log.debug( "register async execution for task: " + task.toString() ); final ListenableFuture<IFixedSizeStreamingContent> result = executorService.submit( task ); futures.put( key, result ); tasks.put( key, task ); return id; } @Override public Future<IFixedSizeStreamingContent> getFuture( final UUID id, final IPentahoSession session ) { validateParams( id, session ); return futures.get( new CompositeKey( session, id ) ); } @Override public void cleanFuture( final UUID id, final IPentahoSession session ) { final CompositeKey key = new CompositeKey( session, id ); futures.remove( key ); tasks.remove( key ); } @Override public void requestPage( final UUID id, final IPentahoSession session, final int page ) { validateParams( id, session ); final IAsyncReportExecution<TReportState> runningTask = tasks.get( new CompositeKey( session, id ) ); if ( runningTask != null ) { runningTask.requestPage( page ); } } @Override public boolean preSchedule( final UUID uuid, final IPentahoSession session ) { validateParams( uuid, session ); final CompositeKey compositeKey = new CompositeKey( session, uuid ); final IAsyncReportExecution<? extends TReportState> runningTask = tasks.get( compositeKey ); if ( runningTask != null ) { return runningTask.preSchedule(); } return false; } @SuppressWarnings( "unchecked" ) @Override public UUID recalculate( final UUID uuid, final IPentahoSession session ) { validateParams( uuid, session ); final CompositeKey compositeKey = new CompositeKey( session, uuid ); final IAsyncReportExecution<? extends TReportState> runningTask = tasks.get( compositeKey ); if ( runningTask == null ) { throw new IllegalStateException( "We must have a task at this point." ); } try { final IAsyncReportExecution<TReportState> recalcTask = (IAsyncReportExecution<TReportState>) new PentahoAsyncReportExecution( (PentahoAsyncReportExecution) runningTask, new AsyncJobFileStagingHandler( session ) ); return addTask( recalcTask, session ); } catch ( final Exception e ) { log.error( "Can't recalculate task: ", e ); } return null; } @Override public boolean schedule( final UUID id, final IPentahoSession session ) { validateParams( id, session ); final CompositeKey compositeKey = new CompositeKey( session, id ); final IAsyncReportExecution<TReportState> runningTask = tasks.get( compositeKey ); final ListenableFuture<IFixedSizeStreamingContent> future = futures.get( compositeKey ); if ( runningTask == null || future == null ) { // As long as we have a task, we should have a future-object, but checking both does not hurt. throw new IllegalStateException( "We must have a task and a future at this point." ); } final String userId = session.getName(); final String sessionId = session.getId(); if ( !StringUtils.isEmpty( userId ) ) { if ( runningTask.schedule() ) { Futures.addCallback( future, new TriggerScheduledContentWritingHandler( userId, sessionId, runningTask, compositeKey ), executorService ); return true; } } return false; } @Override public void updateSchedulingLocation( final UUID id, final IPentahoSession session, final Serializable folderId, final String newName ) { validateParams( id, session ); final CompositeKey key = new CompositeKey( session, id ); final IAsyncReportExecution<TReportState> runningTask = tasks.get( key ); if ( runningTask == null ) { throw new IllegalStateException( "We must have a task at this point." ); } final UpdateSchedulingLocationListener listener = getUpdateSchedulingLocationListener( folderId, newName ); try { this.schedulingLocationListener.lock(); final Serializable fileId = schedulingLocationListener.lookupOutputFile( key ); if ( fileId != null ) { //Report is already finished and saved to default scheduling directory. // move it to a new location. This operation may move the file multiple times, as the file-id is independent // of the location. listener.onSchedulingCompleted( fileId ); } else { //Report is not finished yet. Update the listener list within this synchronized block so that writeToJcrListeners.put( key, listener ); } } finally { this.schedulingLocationListener.unlock(); } } protected UpdateSchedulingLocationListener getUpdateSchedulingLocationListener( final Serializable folderId, final String newName ) { return new UpdateSchedulingLocationListener( folderId, newName ); } @Override public TReportState getReportState( final UUID id, final IPentahoSession session ) { validateParams( id, session ); // link to running task final IAsyncReportExecution<TReportState> runningTask = tasks.get( new CompositeKey( session, id ) ); return runningTask == null ? null : runningTask.getState(); } protected void validateParams( final UUID id, final IPentahoSession session ) { ArgumentNullException.validate( "uuid", id ); ArgumentNullException.validate( "session", session ); } @Override public void onLogout( final IPentahoSession session ) { if ( log.isDebugEnabled() ) { // don't expose full session id. log.debug( "killing async report execution cache for user: " + session.getName() ); } for ( final Map.Entry<CompositeKey, ListenableFuture<IFixedSizeStreamingContent>> entry : futures.entrySet() ) { if ( ObjectUtils.equals( entry.getKey().getSessionId(), session.getId() ) ) { final IAsyncReportExecution<TReportState> task = tasks.get( entry.getKey() ); final ListenableFuture<IFixedSizeStreamingContent> value = entry.getValue(); if ( task != null && task.getState() != null && AsyncExecutionStatus.SCHEDULED .equals( task.getState().getStatus() ) ) { //After the session end nobody can poll status, we can remove task //Keep future to have content in place tasks.remove( entry.getKey() ); continue; } // attempt to cancel running task value.cancel( true ); // remove all links to release GC futures.remove( entry.getKey() ); tasks.remove( entry.getKey() ); } } //User can't update scheduling directory after logout, so we can clean location locationMap try { this.schedulingLocationListener.lock(); this.schedulingLocationListener.onLogout( session.getId() ); } finally { this.schedulingLocationListener.unlock(); } //If some files are still open directory won't be removed AsyncJobFileStagingHandler.cleanSession( session ); } @Override public void shutdown() { // attempt to stop all for ( final Future<IFixedSizeStreamingContent> entry : futures.values() ) { entry.cancel( true ); } // forget all this.futures.clear(); this.tasks.clear(); this.writeToJcrListeners.clear(); this.executorService.shutdown(); try { this.schedulingLocationListener.lock(); this.schedulingLocationListener.shutdown(); } finally { this.schedulingLocationListener.unlock(); } AsyncJobFileStagingHandler.cleanStagingDir(); } protected Callable<Serializable> getWriteToJcrTask( final IFixedSizeStreamingContent result, final IAsyncReportExecution<? extends IAsyncReportState> runningTask ) { return new WriteToJcrTask( runningTask, result.getStream() ); } /** * This class is responsible for writing the content first to a pre-computed location (as specified by the * ISchedulingDirectoryStrategy implementation, and then optionally moves the content to a location specified by the * user (via the UI). */ class TriggerScheduledContentWritingHandler implements FutureCallback<IFixedSizeStreamingContent> { private final IAsyncReportExecution<TReportState> runningTask; private final CompositeKey compositeKey; private final String user; private final String sessionId; TriggerScheduledContentWritingHandler( final String user, final String sessionId, final IAsyncReportExecution<TReportState> runningTask, final CompositeKey compositeKey ) { this.user = user; this.sessionId = sessionId; this.runningTask = runningTask; this.compositeKey = compositeKey; } protected IFixedSizeStreamingContent notifyListeners( final IFixedSizeStreamingContent result ) throws Exception { final Serializable writtenTo = getWriteToJcrTask( result, runningTask ).call(); if ( writtenTo == null ) { log.debug( "Unable to move scheduled content, due to error while creating content in default location." ); return null; } try { PentahoAsyncExecutor.this.schedulingLocationListener.lock(); PentahoAsyncExecutor.this.schedulingLocationListener.recordOutputFile( compositeKey, writtenTo ); notifyListeners( writtenTo ); } finally { PentahoAsyncExecutor.this.schedulingLocationListener.unlock(); } return null; } protected void notifyListeners( final Serializable writtenTo ) { //We can be sure it succeed here and are ready to notify writeToJcrListeners final ISchedulingListener iSchedulingListener = writeToJcrListeners.get( compositeKey ); if ( iSchedulingListener != null ) { iSchedulingListener.onSchedulingCompleted( writtenTo ); writeToJcrListeners.remove( compositeKey ); } } @Override public void onSuccess( final IFixedSizeStreamingContent result ) { try { if ( user != null && !StringUtil.isEmpty( user ) ) { SecurityHelper.getInstance().runAsUser( user, () -> notifyListeners( result ) ); } } catch ( final Exception e ) { log.error( "Can't execute callback. : ", e ); } finally { //Time to remove future - nobody will ask for content at this moment //We need to keep task because status polling may still occur ( or it already has been removed on logout ) //Also we can try to remove directory futures.remove( compositeKey ); result.cleanContent(); AsyncJobFileStagingHandler.cleanSession( sessionId ); } } @Override public void onFailure( final Throwable t ) { log.error( "Can't execute callback. Parent task failed: ", t ); futures.remove( compositeKey ); AsyncJobFileStagingHandler.cleanSession( sessionId ); } } }