/* $Id: TestThrottler.java 1643535 2014-12-06 14:16:00Z kwright $ */

/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.manifoldcf.connectorcommon.throttler;

import org.apache.manifoldcf.core.interfaces.*;
import org.apache.manifoldcf.connectorcommon.interfaces.*;
import java.io.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import org.junit.*;
import static org.junit.Assert.*;

public class TestThrottler extends org.apache.manifoldcf.core.tests.BaseHSQLDB
{
  @Test
  public void multiThreadConnectionPoolTest()
    throws Exception
  {
    // First, create the throttle group.
    IThreadContext threadContext = ThreadContextFactory.make();
    IThrottleGroups tg = ThrottleGroupsFactory.make(threadContext);
    tg.createOrUpdateThrottleGroup("test","test",new ThrottleSpec());
    
    // We create a pretend connection pool
    IConnectionThrottler connectionThrottler = tg.obtainConnectionThrottler("test","test",new String[]{"A","B","C"});
    System.out.println("Connection throttler obtained");
    
    // How best to test this?
    // Well, what I'm going to do is to have multiple threads active.  Each one will do perfectly sensible things
    // while generating a log that includes timestamps for everything that happens.  At the end, the log will be
    // analyzed for violations of throttling policy.
    
    PollingThread pt = new PollingThread();
    pt.start();

    EventLog eventLog = new EventLog();
    
    int numThreads = 10;
    
    TesterThread[] threads = new TesterThread[numThreads];
    for (int i = 0; i < numThreads; i++)
    {
      threads[i] = new TesterThread(connectionThrottler, eventLog);
      threads[i].start();
    }
    
    // Now, join all the threads at the end
    for (int i = 0; i < numThreads; i++)
    {
      threads[i].finishUp();
    }
    
    pt.interrupt();
    pt.finishUp();

    // Shut down the throttle group
    tg.removeThrottleGroup("test","test");

    // Finally, do the log analysis
    eventLog.analyze();
    
    System.out.println("Done test");
  }
  
  protected static class PollingThread extends Thread
  {
    protected Throwable exception = null;
    
    public PollingThread()
    {
    }
    
    public void run()
    {
      try
      {
        IThreadContext threadContext = ThreadContextFactory.make();
        IThrottleGroups throttleGroups = ThrottleGroupsFactory.make(threadContext);
        
        while (true)
        {
          throttleGroups.poll("test");
          Thread.sleep(1000L);
        }
      }
      catch (InterruptedException e)
      {
      }
      catch (Exception e)
      {
        exception = e;
      }

    }
    
    public void finishUp()
      throws Exception
    {
      join();
      if (exception != null)
      {
        if (exception instanceof RuntimeException)
          throw (RuntimeException)exception;
        else if (exception instanceof Error)
          throw (Error)exception;
        else if (exception instanceof Exception)
          throw (Exception)exception;
        else
          throw new RuntimeException("Unknown exception: "+exception.getClass().getName()+": "+exception.getMessage(),exception);
      }
    }

  }
  
  protected static class TesterThread extends Thread
  {
    protected final EventLog eventLog;
    protected final IConnectionThrottler connectionThrottler;
    protected Throwable exception = null;
    
    public TesterThread(IConnectionThrottler connectionThrottler, EventLog eventLog)
    {
      this.connectionThrottler = connectionThrottler;
      this.eventLog = eventLog;
    }
    
    public void run()
    {
      try
      {
        int numberConnectionCycles = 3;
        int numberFetchesPerCycle = 3;
        
        for (int k = 0; k < numberConnectionCycles; k++)
        {
          // First grab a connection.
          int rval = connectionThrottler.waitConnectionAvailable();
          if (rval == IConnectionThrottler.CONNECTION_FROM_NOWHERE)
            throw new Exception("Unexpected return value from waitConnectionAvailable()");
          IFetchThrottler fetchThrottler;
          if (rval == IConnectionThrottler.CONNECTION_FROM_CREATION)
          {
            // Pretend to create the connection
            eventLog.addLogEntry(new ConnectionCreatedEvent());
          }
          else
          {
            // Pretend to get it from the pool
            eventLog.addLogEntry(new ConnectionFromPoolEvent());
          }
          fetchThrottler = connectionThrottler.getNewConnectionFetchThrottler();

          for (int l = 0; l < numberFetchesPerCycle; l++)
          {
            // Perform a fake fetch
            if (fetchThrottler.obtainFetchDocumentPermission() == false)
              throw new Exception("Unexpected return value for obtainFetchDocumentPermission()");
            eventLog.addLogEntry(new FetchStartEvent());
            IStreamThrottler streamThrottler = fetchThrottler.createFetchStream();
            try
            {
              // Do one read
              if (streamThrottler.obtainReadPermission(1000) == false)
                throw new Exception("False from obtainReadPermission!");
              eventLog.addLogEntry(new ReadStartEvent(1000));
              streamThrottler.releaseReadPermission(1000, 1000);
              eventLog.addLogEntry(new ReadDoneEvent(1000));
              // Do another read
              if (streamThrottler.obtainReadPermission(1000) == false)
                throw new Exception("False from obtainReadPermission!");
              eventLog.addLogEntry(new ReadStartEvent(1000));
              streamThrottler.releaseReadPermission(1000, 1000);
              eventLog.addLogEntry(new ReadDoneEvent(1000));
              // Do a third read
              if (streamThrottler.obtainReadPermission(1000) == false)
                throw new Exception("False from obtainReadPermission!");
              eventLog.addLogEntry(new ReadStartEvent(1000));
              streamThrottler.releaseReadPermission(1000, 100);
              eventLog.addLogEntry(new ReadDoneEvent(100));
            }
            finally
            {
              // Close the stream
              streamThrottler.closeStream();
            }
            eventLog.addLogEntry(new FetchDoneEvent());
          }
          
          // Pretend to release the connection
          boolean destroyIt = connectionThrottler.noteReturnedConnection();
          if (destroyIt)
          {
            eventLog.addLogEntry(new ConnectionDestroyedEvent());
            connectionThrottler.noteConnectionDestroyed();
          }
          else
          {
            eventLog.addLogEntry(new ConnectionReturnedToPoolEvent());
            connectionThrottler.noteConnectionReturnedToPool();
          }
        }
      }
      catch (Exception e)
      {
        e.printStackTrace();
        exception = e;
      }
    }
    
    public void finishUp()
      throws Exception
    {
      join();
      if (exception != null)
      {
        if (exception instanceof RuntimeException)
          throw (RuntimeException)exception;
        else if (exception instanceof Error)
          throw (Error)exception;
        else if (exception instanceof Exception)
          throw (Exception)exception;
        else
          throw new RuntimeException("Unknown exception: "+exception.getClass().getName()+": "+exception.getMessage(),exception);
      }
    }
    
  }
  
  protected static class ThrottleSpec implements IThrottleSpec
  {
    public ThrottleSpec()
    {
    }
    
    /** Given a bin name, find the max open connections to use for that bin.
    *@return -1 if no limit found.
    */
    @Override
    public int getMaxOpenConnections(String binName)
    {
      if (binName.equals("A"))
        return 3;
      if (binName.equals("B"))
        return 4;
      return Integer.MAX_VALUE;
    }

    /** Look up minimum milliseconds per byte for a bin.
    *@return 0.0 if no limit found.
    */
    @Override
    public double getMinimumMillisecondsPerByte(String binName)
    {
      if (binName.equals("B"))
        return 0.5;
      if (binName.equals("C"))
        return 0.75;
      return 0.0;
    }

    /** Look up minimum milliseconds for a fetch for a bin.
    *@return 0 if no limit found.
    */
    @Override
    public long getMinimumMillisecondsPerFetch(String binName)
    {
      if (binName.equals("A"))
        return 5;
      if (binName.equals("C"))
        return 20;
      return 0;
    }

  }
  
  protected static class EventLog
  {
    protected final List<LogEntry> logList = new ArrayList<LogEntry>();
    
    public EventLog()
    {
    }
    
    public synchronized void addLogEntry(LogEntry x)
    {
      System.out.println(x.toString());
      logList.add(x);
    }
    
    public synchronized void analyze()
      throws Exception
    {
      State s = new State();
      for (LogEntry l : logList)
      {
        l.apply(s);
      }
      // Success!
    }
    
  }
  
  protected static abstract class LogEntry
  {
    protected final long timestamp;
    
    public LogEntry(long timestamp)
    {
      this.timestamp = timestamp;
    }
    
    public abstract void apply(State state)
      throws Exception;
    
    public String toString()
    {
      return "Time: "+timestamp;
    }
    
  }
  
  protected static class ConnectionCreatedEvent extends LogEntry
  {
    public ConnectionCreatedEvent()
    {
      super(System.currentTimeMillis());
    }
    
    public void apply(State state)
      throws Exception
    {
      if (state.outstandingConnections + 1 > 3)
        throw new Exception("Too many outstanding connections at once!");
      state.outstandingConnections++;
    }
    
    public String toString()
    {
      return super.toString() + "; Connection created";
    }

  }

  protected static class ConnectionDestroyedEvent extends LogEntry
  {
    public ConnectionDestroyedEvent()
    {
      super(System.currentTimeMillis());
    }
    
    public void apply(State state)
      throws Exception
    {
      state.outstandingConnections--;
    }
    
    public String toString()
    {
      return super.toString() + "; Connection destroyed";
    }

  }

  protected static class ConnectionFromPoolEvent extends LogEntry
  {
    public ConnectionFromPoolEvent()
    {
      super(System.currentTimeMillis());
    }
    
    public void apply(State state)
      throws Exception
    {
    }
    
    public String toString()
    {
      return super.toString() + "; Connection from pool";
    }

  }

  protected static class ConnectionReturnedToPoolEvent extends LogEntry
  {
    public ConnectionReturnedToPoolEvent()
    {
      super(System.currentTimeMillis());
    }
    
    public void apply(State state)
      throws Exception
    {
    }
    
    public String toString()
    {
      return super.toString() + "; Connection back to pool";
    }

  }

  protected static class FetchStartEvent extends LogEntry
  {
    public FetchStartEvent()
    {
      super(System.currentTimeMillis());
    }
    
    public void apply(State state)
      throws Exception
    {
      if (timestamp < state.lastFetch + 20L - 1L)
        throw new Exception("Fetch too fast: took place in "+ (timestamp - state.lastFetch) + " milliseconds");
      state.lastFetch = timestamp;
    }
    
    public String toString()
    {
      return super.toString() + "; Fetch start";
    }
  }
  
  protected static class FetchDoneEvent extends LogEntry
  {
    public FetchDoneEvent()
    {
      super(System.currentTimeMillis());
    }
    
    public void apply(State state)
      throws Exception
    {
    }
    
    public String toString()
    {
      return super.toString() + "; Fetch done";
    }
  }
  
  protected static class ReadStartEvent extends LogEntry
  {
    final int proposed;
    
    public ReadStartEvent(int proposed)
    {
      super(System.currentTimeMillis());
      this.proposed = proposed;
    }
    
    public void apply(State state)
      throws Exception
    {
      if (state.firstByteReadTime == -1L)
        state.firstByteReadTime = timestamp;
      else
      {
        // Calculate running minimum amount of time it should have taken for the bytes given
        long minTime = (long)(((double)state.byteTotal) * 0.75 + 0.5);
        if (timestamp - state.firstByteReadTime < minTime)
          throw new Exception("Took too short a time to read "+state.byteTotal+" bytes: "+(timestamp - state.firstByteReadTime));
      }
    }
    
    public String toString()
    {
      return super.toString() + "; Read start("+proposed+")";
    }
  }

  protected static class ReadDoneEvent extends LogEntry
  {
    final int actual;
    
    public ReadDoneEvent(int actual)
    {
      super(System.currentTimeMillis());
      this.actual = actual;
    }
    
    public void apply(State state)
      throws Exception
    {
      state.byteTotal += actual;
    }
    
    public String toString()
    {
      return super.toString() + "; Read done("+actual+")";
    }
  }

  protected static class State
  {
    public int outstandingConnections = 0;
    public long lastFetch = 0L;
    public long firstByteReadTime = -1L;
    public long byteTotal = 0L;
  }
  
}
