PSTimer.java

/*
** Module   : PSTimer.java
** Abstract : An implementation of the Timer FWD extension to 4GL.
**
** Copyright (c) 2017-2023, Golden Code Development Corporation.
**
** -#- -I- --Date-- --------------------------------Description-----------------------------------
** 001 OM  20170530 First commit.
** 002 OM  20180425 Keep a reference to procedure that created the PSTimer to locate the callback.
** 003 CA  20180517 Fixed comhandle FWD-specific resource management.
** 004 OM  20190225 Pushed creator field up to super class.
**     CA  20190315 Fixed resource cleanup on session end.
**                  Resources are added to the unnamed pools, if exist (TODO: add named pool 
**                  support).
**     CA  20190322 Wait for the fired async event to finish before continuing. 
** 005 HC  20190810 Fixed initial timer values.
** 006 CA  20200930 Avoid string calculation for log messages, if logging is disabled.
** 007 VVT 20220329 Typos fixed in Javadoc.
** 008 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
*/

/*
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU Affero General Public License as
** published by the Free Software Foundation, either version 3 of the
** License, or (at your option) any later version.
**
** 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 Affero General Public License for more details.
**
** You may find a copy of the GNU Affero GPL version 3 at the following
** location: https://www.gnu.org/licenses/agpl-3.0.en.html
** 
** Additional terms under GNU Affero GPL version 3 section 7:
** 
**   Under Section 7 of the GNU Affero GPL version 3, the following additional
**   terms apply to the works covered under the License.  These additional terms
**   are non-permissive additional terms allowed under Section 7 of the GNU
**   Affero GPL version 3 and may not be removed by you.
** 
**   0. Attribution Requirement.
** 
**     You must preserve all legal notices or author attributions in the covered
**     work or Appropriate Legal Notices displayed by works containing the covered
**     work.  You may not remove from the covered work any author or developer
**     credit already included within the covered work.
** 
**   1. No License To Use Trademarks.
** 
**     This license does not grant any license or rights to use the trademarks
**     Golden Code, FWD, any Golden Code or FWD logo, or any other trademarks
**     of Golden Code Development Corporation. You are not authorized to use the
**     name Golden Code, FWD, or the names of any author or contributor, for
**     publicity purposes without written authorization.
** 
**   2. No Misrepresentation of Affiliation.
** 
**     You may not represent yourself as Golden Code Development Corporation or FWD.
** 
**     You may not represent yourself for publicity purposes as associated with
**     Golden Code Development Corporation, FWD, or any author or contributor to
**     the covered work, without written authorization.
** 
**   3. No Misrepresentation of Source or Origin.
** 
**     You may not represent the covered work as solely your work.  All modified
**     versions of the covered work must be marked in a reasonable way to make it
**     clear that the modified work is not originating from Golden Code Development
**     Corporation or FWD.  All modified versions must contain the notices of
**     attribution required in this license.
*/

package com.goldencode.p2j.extension;

import com.goldencode.p2j.comauto.*;
import com.goldencode.p2j.net.*;
import com.goldencode.p2j.security.*;
import com.goldencode.p2j.ui.*;
import com.goldencode.p2j.ui.client.event.*;
import com.goldencode.p2j.util.*;
import com.goldencode.p2j.util.logging.*;

import java.util.concurrent.*;
import java.util.function.*;
import java.util.logging.*;

/**
 * Implementation of the FWD TIMER Extension. An {@code PSTimer} object can be scheduled to run
 * repetitive tasks at fixed intervals. This is not fully compatible with ABL standard language,
 * it is an extension to 4GL specific to FWD.
 * 
 * <p>
 * To create a timer use the following syntax:
 * <pre>
 *    CREATE TIMER handle [ASSIGN
 *       [INTERVAL = n-millis]
 *       [CALLBACK = procedure]
 *       [ENABLED = auto-start]].
 * </pre>
 * Where:
 * <ul>
 *    <li><strong>handle</strong> is the handle that will hold the new timer object;</li>
 *    <li><strong>n-millis</strong> is the interval between two consecutive ticks, in 
 *       milliseconds. Note that the actual time where the callback procedure is executed may
 *       be delayed, depending on how the event messages get processed by FWD. It is illegal to
 *       set a negative value. Default is 0, not active.</li>
 *    <li><strong>procedure</strong> is the callback procedure legacy name that will be called
 *       when the timer elapses. The procedure must not have any parameters;</li>
 *    <li><strong>auto-start</strong>. If set to {@code yes} the timer is started automatically
 *       at the moment it is created, otherwise a call to {@link #start()} or setting the 
 *       {@code enabled} property to {@code true} is needed to arm it.</li>
 * </ul>
 * Setting any of the properties to same value has no effect. Setting a new value for 
 * {@code interval} when the timer is armed will force the timer to restart, he next event will
 * be fired after the timeout of the newly set interval. There are a few ways to stop the timer,
 * with small differences among them:
 * <ul>
 *    <li>set the {@code interval} to 0. It remain enabled, setting the {@code interval} to a new
 *       value will automatically arm it;</li>
 *    <li>set the {@code enabled} to {@code false}. The timer will stop until it is restarted by 
 *       setting the value back to {@code yes} or calling {@code start};</li>
 *    <li>call {@code cease}, has the same effect like above;</li>
 *    <li>setting the {@code callback} to unknown will make the timer unable to call an event
 *       procedure, but it will continue to tick.</li>
 * </ul>
 * At the end of lifetime of the object, it must be deleted, as usual with
 * <pre>DELETE OBJECT th.</pre>
 * 
 * <p>
 * The {@code PSTimer} object extends the {@code ComObject} class. This means that the programmer 
 * can use a {@link comhandle} interface to access any properties and/or methods annotated as such
 * of the object, even they are known at compile time. The COM mode of operation is somewhat
 * compatible with legacy ActiveX objects from ABL.
 */
public class PSTimer
extends ComObject
implements Runnable,
           FWDTimer,
           SessionListener
{
   /**
    * The logger used for tracing internal events.
    */
   private static final CentralLogger LOG = CentralLogger.get(PSTimer.class.getName());
   
   /** Flag indicating that the logging is enabled or not. */
   private static final boolean LOG_ENABLED = LOG.isLoggable(Level.FINE);

   /**
    * The background thread that does the power-horse work. It is {@code null} if this timer has
    * not been started yet, or if the object was released/deleted. 
    */
   private AssociatedThread timer = null;
   
   /**
    * The ticking interval, in milliseconds. Maps holds {@code INTERVAL} property/attribute. 
    * If it is zero, the timer is not enabled (does not fire). It is illegal to set it to a
    * negative value.
    */
   private volatile int interval = 1000;
   
   /**
    * Flags if this timer is enabled. If not enabled, the timer will not fire the {@code tick}
    * event.
    */
   private volatile boolean enabled = false;
   
   /**
    * Flags indicating whether this timer was reset while armed ({@code interval} changed to
    * another valid value while timer was sleeping). In this case it must be re-initiated with
    * the new interval.
    */
   private volatile boolean timerReset = false;
   
   /**
    * The callback procedure in non-COM mode (legacy name). If not set timer is useless. One may
    * set it to {@code unknown} / {@code null} if you want some ticks to be skipped. The
    * difference from setting the enabled to {@code NO} is that in this case the timer is not
    * restarted.
    */
   private String callback = null;
   
   /**
    * Flags the operation mode. In the COM-mode the object is compatible with ABL {@code PSTimer}
    * ActiveX object. There are two differences:
    * <ul>
    *    <li><strong>enabled</strong> attribute/property is by default {@code yes} in COM mode
    *       and {@code false} in extension mode. This means that in COM mode, setting the 
    *       {@code INTERVAL} will automatically arm the timer, while in extension mode, one
    *       need to explicitly set {@code enabled} to {@code yes} or invoke the {@code start}
    *       method;</li>
    *    <li>the <strong>callback</strong> is only used in extension mode, if set. In COM/ActiveX
    *       mode the {@code callback} property is ignored and ABL compatible algorithm is used
    *       to identify the proper event procedure to call. The legacy name of the procedure is
    *       computed using {@code <ControlFrame-name>.<ActiveX-name>.<Event-name>}.</li>
    * </ul>
    */
   private boolean isCom = true;
   
   /**
    * Flags if this object has been yet destroyed and is no longer valid. A {@code PSTimer} object
    * is valid throughout it lifetime until it is disposed using {@link #delete()}.
    */
   private boolean invalid = false;

   /**
    * A t<sub>0</sub> timestamp used to relate all events in LOG.
    */
   private long t0 = System.currentTimeMillis();
   
   /**
    * The constructor. It makes sure that the object is fully and safely initialized.
    * The super c'tor {@code ComObject} will be calling {@link comhandle#resourceId} and
    * configure the {@code parentProcedure}.
    */
   public PSTimer()
   {
      comhandle.resourceId(this);
   }
   
   /**
    * Static factory method for initialization of a fresh new TIMER as an extension object.
    * 
    * @param   h
    *          The {@link handle} to store the newly created object.
    */
   public static void create(handle h)
   {
      PSTimer newInstance = new PSTimer();
      newInstance.isCom = false;
      newInstance.enabled = false;
      
      h.assign(newInstance);
      
      // TODO: add named widget pool support
      WidgetPool.addResource(newInstance, null);
   }
   
   /**
    * Obtain the name of this ActiveX object.
    *
    * @return  the name of this ActiveX object.
    */
   public String getActivexName()
   {
      return "PSTimer";
   }
   
   /**
    * The emulation of the ActiveX {@code AboutBox} legacy method. In ABL it will open a modal
    * dialog with the name of the ActiveX and its version. The emulated method is a NO-OP, except
    * for the optional log of the call.
    */
   @ComMethod(name = "AboutBox")
   public void AboutBox()
   {
      log(() -> "Executing PSTimer:AboutBox() COM method...");
   }
   
   /**
    * Check whether this timer is armed. However, it will not fire unless the {@code interval} is
    * also set to a strictly positive value.
    * 
    * @return  The value of the {@code Enabled} property/attribute.
    */
   @ComProperty(name = "Enabled")
   public logical isEnabled()
   {
      return new logical(enabled); 
   }
   
   /**
    * Sets the value of the {@code Enabled} property/attribute. If {@code interval} property is
    * already set, the timer gets armed. 
    *  
    * @param   setOn
    *          new value of the {@code Enabled} property/attribute.
    */
   // @ComProperty(name = "Enabled", setter = true)
   public void setEnabled(boolean setOn)
   {
      if (setOn == enabled)
      {
         return;
      }
      
      log(() -> "Enabled = " + setOn);
   
      enabled = setOn;
      if (setOn)
      {
         startWorker();
      }
      else
      {
         if (timer != null)
         {
            timer.interrupt();
         }
      }
      // otherwise this is a NO-OP
   }
   
   /**
    * Sets the value of the {@code Enabled} property/attribute. If {@code interval} property is
    * already set, the timer gets armed. 
    *
    * @param   setOn
    *          new value of the {@code Enabled} property/attribute.
    */
   @ComProperty(name = "Enabled", setter = true)
   public void setEnabled(logical setOn)
   {
      if (setOn != null && !setOn.isUnknown())
      {
         setEnabled(setOn.booleanValue());
      }
   }
   
   /**
    * Obtain the current {@code interval} between two consecutive events.
    *  
    * @return  the current value of {@code interval} property.
    */
   @ComProperty(name = "Interval")
   public integer getInterval()
   {
      return new integer(interval);
   }
   
   /**
    * Sets the current {@code interval} between two consecutive events. If the timer is already
    * {@code enabled}, it will be automatically armed and will fire after {@code interval} millis 
    * elapses. Setting it to 0, will stop the timer. Setting to a negative value will lead to
    * an error message to be displayed.
    * 
    * @param   interval
    *          The new interval between two timer events, in milliseconds.
    */
   // @ComProperty(name = "Interval", setter = true)
   public void setInterval(long interval)
   throws PropertyAccessException
   {
      if (interval < 0)
      {
         String msg = "The Interval property must not be a negative number";
         throw new PropertyAccessException(msg, "0x80020009");
      }
      if (interval == this.interval)
      {
         return; // no-op ?
      }
      
      log(() -> "Interval = " + interval);
      this.interval = (int) interval;
      startWorker();
   }
   
   /**
    * Sets the current {@code interval} between two consecutive events. If the timer is already
    * {@code enabled}, it will be automatically armed and will fire after {@code interval} millis 
    * elapses. Setting it to 0, will stop the timer. Setting to a negative value will lead to
    * an error message to be displayed.
    *
    * @param   interval
    *          The new interval between two timer events, in milliseconds.
    */
   @ComProperty(name = "Interval", setter = true)
   public void setInterval(NumberType interval)
   throws PropertyAccessException
   {
      if (interval == null || interval.isUnknown())
      {
         throw new PropertyAccessException("Type mismatch", "0x80020005");
      }
      
      setInterval(interval.longValue());
   }
   
   /**
    * Gets the legacy name of the callback procedure to be called at fixed interval when running
    * in FWD extension mode (not as a COM).
    * 
    * @return  the legacy name of the callback procedure.
    */
   @Override
   public character getCallback()
   {
      return new character(callback);
   }
   
   /**
    * Sets the legacy name of the callback procedure to be called at fixed interval when running
    * in FWD extension mode (ignored when in COM mode). If set to {@code unknown} the timer will
    * continue 'ticking' but since there is no known peer to call, nothing will happen. This
    * property can be set while the timer is active, without resetting it.
    *
    * @param   callback
    *          the new legacy name of the callback procedure.
    */
   @Override
   public void setCallback(character callback)
   {
      if (callback != null && !callback.isUnknown())
      {
         setCallback(callback.toStringMessage());
      }
   }
   
   /**
    * Sets the legacy name of the callback procedure to be called at fixed interval when running
    * in FWD extension mode (ignored when in COM mode).  If set to {@code unknown} the timer will
    * continue 'ticking' but since there is no known peer to call, nothing will happen. This
    * property can be set while the timer is active, without resetting it.
    *
    * @param   callback
    *          the new legacy name of the callback procedure.
    */
   @Override
   public void setCallback(String callback)
   {
      this.callback = callback;
   }
   
   /**
    * Force the timer to cease firing events. It has the exact effect like setting the 
    * {@code enabled} property to {@code false}. 
    * <p>
    * The name of the method was selected intentionally for the FWD extension since {@code STOP}
    * would create some syntactic incompatibility with standard ABL attributes.  
    */
   @ComMethod(name = "cease")
   @Override
   public void cease()
   {
      setEnabled(false);
   }
   
   /**
    * Starts the timer. It has the exact effect like setting the {@code enabled} property to 
    * {@code true}. Note that timer will not be activated unless the {@code interval} property
    * was set to a strictly positive value.
    */
   @ComMethod(name = "start")
   @Override
   public void start()
   {
      setEnabled(true);
   }
   
   /**
    * Perform actual delete of all resources. At the time of this call, it is assumed the resource
    * is valid for deletion (the handle and the resource are both valid).
    * <p>
    * This is called from a handle when working as FWD extension object. 
    */
   @Override
   public void delete()
   {
      if (invalid)
      {
         return; // already deleted
      }
      
      destroy();
      
      log(() -> "Releasing");
      
      // end of lifetime: mark the object are invalid 
      invalid = true;
      
      AssociatedThread save = this.timer;
      if (save == null)
      {
         return;
      }
      
      this.timer = null;
      enabled = false;
      interval = 0;
      
      save.interrupt();
      SessionManager.get().deregisterAsyncThread(save);
   }
   
   /**
    * Checks whether this object is valid. The object is valid from the moment it was created 
    * (even if not active) and until expressly deleted.
    * 
    * @return  {@code true} if this object is valid.
    */
   @Override
   public boolean valid()
   {
      return !invalid;
   }

   /**
    * This callback will be called after the security context has been set up and is valid for
    * the life of this thread and before any of the client code it will be executed.
    * <p>
    * Implement this method if you some work must be done from the thread that has the proper
    * security context set up.
    * <p>
    * No-op for the {@link PSTimer} case.
    * 
    * @param    session
    *           The session that is starting.
    */
   @Override
   public void initialize(Session session)
   {
      // no-op
   }

   /**
    * This method is called when the session is ending.  This is useful for
    * cleaning up resources or state.
    * <p>
    * It ensures the runner thread gets terminated.
    * 
    * @param    session
    *           The session that is ending.
    */
   @Override
   public void terminate(Session session)
   {
      delete();
   }

   /**
    * Worker method. Checks the internal state and start the timer. On the first call, it makes
    * sure the {@link AssociatedThread} is created and started. If already armed, calling this
    * will cause the current interval to be dropped, and a new schedule is performed for the
    * current {@code interval}.
    */
   private void startWorker()
   {
      if (invalid)
      {
         return; // object already deleted/released cannot be brought back to life
      }
      
      if (enabled && interval > 0)
      {
         if (timer == null)
         {
            SessionManager sm = SessionManager.get();
            log(() -> "Creating timing thread.");
            if (!sm.getSession().addSessionListener(this, true))
            {
               // the session doesn't exist...
               invalid = true;
               return;
            }
            
            timer = new AssociatedThread(this);
            sm.registerAsyncThread(timer);
            timer.setDaemon(true);
            timer.setName("PSTimer");
            timer.start();
         }
         else
         {
            // restart: abort current sleep and restart with new
            log(() -> "Notify / restart (" + timer + ")");
            timerReset = true; // let it know the reason for interruption
            timer.interrupt();
         }
      }
   }
   
   /**
    * The background worker thread. It get repeatably suspended for {@code interval} milliseconds
    * and, if not interrupted, will trigger the event. In case of interruption, it will analyze
    * the internal state and rearm or wait for the timer to be re-enabled.
    */
   @Override
   public void run()
   {
      timerLoops:
      while (timer != null)
      {
         if (interval > 0 && enabled)
         {
            long sleepTo = System.currentTimeMillis() + interval;
            
            log(() -> "Arming timer (" + timer + "). See you in " + interval / 1000f + " secs.");
            do
            {
               try
               {
                  synchronized (timer)
                  {
                     long timeout = sleepTo - System.currentTimeMillis();
                     if (timeout > 0)
                     {
                        timer.wait(timeout);
                     }
                  }
               }
               catch (InterruptedException e)
               {
                  log(() -> "Timer interrupted.");
                  if (this.timer == null)
                  {
                     log(() -> "Timer released (1). Quitting thread.");
                     break timerLoops;
                  }
               }
               
               if (!enabled || interval <= 0)
               {
                  // something changed, firing is not allowed in these conditions
                  // let the big loop decide what to do 
                  continue timerLoops;
               }
               else if (timerReset) // && enabled && interval > 0
               {
                  log(() -> "Timer reset detected.");
                  timerReset = false;
                  // loop with the new interval
                  continue timerLoops;
               }
            }
            while (System.currentTimeMillis() <= sleepTo);
            
            log(() -> "Timer completed. Firing event.");
            if (getParentProcedure()._isValid())
            {
               fireEvent();
            }
            else
            {
               // if the external procedure of callback was deleted we cannot invoke the callback
               // anymore, end of object and thread lifetime
               delete();
               return;
            }
         }
         else
         {
            log(() -> "Waiting for restart...");
            try
            {
               synchronized (timer)
               {
                  timer.wait(0);
               }
            }
            catch (InterruptedException e)
            {
               if (this.timer == null)
               {
                  log(() -> "Timer released (2). Quitting thread.");
                  break;
               }
            }
         }
      }
   }
   
   /**
    * Calls the callback or the event procedure asynchronously. In both cases a 
    * {@link ServerEvent} will be posted with {@link LogicalTerminal#postServerEvent}. When
    * running as FWD extension, the {@code callback} procedure is invoked, if set. When running as
    * a COM/ActiveX, a new {@code ComEvent} event is dispatched through {@code ComServer}, which
    * will use the {@code ControlFrame} container, the name of this {@code ComObject} and the
    * declared event name to compute the legacy name of teh procedure to call.
    */
   private void fireEvent()
   {
      if (isCom)
      {
         // if the object ws created as a COM object, fire using COM protocol
         ComServer.emit(new ComEvent(PSTimer.this, "Tick"), false);
         return;
      }
      
      // this is a TIMER FWD extension. Fire event only if callback attribute was set.
      if (callback != null)
      {
         try
         {
            CountDownLatch latch = new CountDownLatch(1);
            
            LogicalTerminal.postServerEvent(
                  new ServerEvent(id(),
                                  "TIMER-EVENT",
                                  () -> 
                                  {
                                     try
                                     {
                                        if (ComServer.checkValid(this))
                                        {
                                           ControlFlowOps.invokeIn(callback, getParentProcedure());
                                        }
                                     }
                                     finally
                                     {
                                        latch.countDown();
                                     }
                                  }));
            
            try
            {
               latch.await();
            }
            catch (InterruptedException ie)
            {
               // ignore
            }
         }
         catch (Exception e)
         {
            if (LOG.isLoggable(Level.WARNING))
            {
               LOG.log(Level.WARNING, "Fail to asynchronously call '" + callback + ".", e);
            }
         }
      }
   }
   
   /**
    * Logs information mostly for debugging purposes. The method will automatically insert a
    * timestamp relative to creation of this object (for easy tracking) and the thread id this
    * call is performed.
    * 
    * @param   s
    *          The message to be logged.
    */
   private void log(Supplier<String> s)
   {
      if (LOG_ENABLED)
      {
         float dt = (System.currentTimeMillis() - t0) / 1000f;
         LOG.log(Level.FINE, dt + "\tT" + Thread.currentThread().getId() + "\t" + s.get());
      }
   }
}