Scheduler.java

/*
** Module   : Scheduler.java
** Abstract : cron-like scheduler for P2j processes.
**
** Copyright (c) 2014-2024, Golden Code Development Corporation.
**
** -#- -I- --Date-- --------------------------------------Description---------------------------------------
** 001 CA  20140206 First version.
** 002 CA  20140509 Fixed jobs which launch a Java class - it must implement Runnable.
** 003 CA  20161027 Allow Runnable instances to be configured as jobs, at runtime.
**     CA  20170228 Moved Scheduler.initialize to Scheduler.scheduleJobs.
** 004 CA  20191211 When spawning a Java process programmatically, allow the caller to pass 
**                  environment options specific to that call (beside any jvmArgs configured in 
**                  the directory).
** 005 CA  20200116 Javadoc fixes.
** 006 GES 20210415 Changed RECURRENT to RECURRING.
** 007 GBB 20230512 Logging methods replaced by CentralLogger/ConversionStatus.
** 008 GBB 20241010 Adding getSchedulerModeFor to find the mode for an appserver scheduler.
*/

/*
** 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.scheduler;

import java.lang.reflect.*;
import java.text.*;
import java.util.*;
import java.util.logging.*;

import com.goldencode.p2j.util.logging.*;
import org.quartz.*;

import com.goldencode.p2j.directory.*;
import com.goldencode.p2j.security.*;
import com.goldencode.p2j.security.SecurityManager;
import com.goldencode.p2j.util.*;

/**
 * Implements a cron-like scheduler, which can run P2J processes or arbitrary Java classes or
 * methods.
 * <p>
 * The scheduler is configured in the {@code scheduler} container, which resides as a child of the
 * {@code server/<server-id>} or the {@code server/default} nodes.
 * <p>
 * The {@code scheduler} node has a children nodes of class {@code job}. For each {@code job} node,
 * the node's name represents the job name (which must be unique accross all jobs).  Also, 
 * {@code node-attribute} nodes can be provided, with the following name:
 * <ul>
 *    <li>{@code type}, mandatory, case insensitive: string representation of the job type,  
 *        following the description at the {@link JobType} enum.</li>
 *    <li>{@code target}, mandatory: the job target, which can be a process name, a java class or
 *        a java method (see javadoc for {@link Job} class for more details.</li>
 *    <li>{@code enabled}, mandatory: boolean value flag indicating if this job is enabled.</li>
 *    <li>{@code mode}, mandatory: string representation of the job mode, following the 
 *         description at the {@link JobMode} enum.</li>
 *    <li>{@code minute}: the scheduled minute.</li>
 *    <li>{@code hour}: the scheduled hour.</li>
 *    <li>{@code day-of-week}: the scheduled day of the week.</li>
 *    <li>{@code day-of-month}: the scheduled day of the month.</li>
 *    <li>{@code month}: the scheduled month.</li>
 * </ul>
 * <p>
 * An example of a job starting an appserver at server startup:
 * <pre>
 * {@code
 *   <node class="container" name="scheduler">
 *     <node class="job" name="start_appserver">
 *       <node-attribute name="type" value="process"/>
 *       <node-attribute name="target" value="appserver-name"/>
 *       <node-attribute name="enabled" value="TRUE"/>
 *       <node-attribute name="mode" value="now"/>
 *     </node>
 *   </node>
 * }
 * </pre>
 * The {@link JobType#INSTANCE} can't be configured from the directory.
 * <p>
 * WARNING: when searching for the scheduler node, if there are both 
 * {@code server/<server-id>/scheduler} and {@code server/default/scheduler}, only the server's
 * scheduler node will be used.  Currently, there is no way of merging the default and server's
 * scheduler. 
 * <p>
 * When scheduling jobs for execution, each job must run under the server's context.  This is not
 * possible using the {@link #timer}'s threads.  To solve this, the approach is to use the 
 * {@link #timer} as a proxy which sends the jobs to the {@link JobProcessor}, which runs in its
 * own thread, under the server's context.  When the {@link JobProcessor} receives a new job,
 * it will create a dedicated thread (with the server's context) and use that thread to execute 
 * the job.
 * 
 * @see   Job
 *        For more details about how jobs are scheduled.
 */
public final class Scheduler
{
   /** Logger. */
   private static final CentralLogger LOG = CentralLogger.get(Scheduler.class);
   
   /** A map identifying all the available jobs. */
   static Map<String, Job> jobs = null;
   
   /** A registry with the enabled jobs, scheduled to be ran. */
   static Map<String, JobTask> jobRegistry = new HashMap<>();
   
   /** Job launcher timer.  Will execute each job at its scheduled time. */
   static Timer timer = new Timer("Job launcher", true);
   
   /**
    * This instance will run on a dedicated thread, under the server's context, as job launching
    * requires access to the directory.
    */
   static final JobProcessor processor = new JobProcessor();
   
   /**
    * Do not allow instances of this class.
    */
   private Scheduler()
   {
      // do not allow instances of this class
   }

   /**
    * Returns the string representation of the scheduler mode for the appserver, or <code>null</code> if 
    * no job is defined, or no job is enabled.
    *
    * @param    appserverProcess
    *           The appserver process name.
    *
    * @return   See above.
    */
   public static String getSchedulerModeFor(String appserverProcess)
   {
      if (jobs == null || jobs.size() == 0)
      {
         return null;
      }

      return jobs.values()
                 .stream()
                 .filter(job -> job.getTarget().equals(appserverProcess) && job.isEnabled())
                 .map(job -> job.getMode().getMode())
                 .findAny()
                 .orElse(null);
   }
   
   /**
    * Check if the specified job is scheduled for execution.
    * 
    * @param    jobName
    *           The job name.
    *           
    * @return   See above.
    */
   public static boolean isScheduled(String jobName)
   {
      jobName = jobName.toLowerCase();
      
      synchronized (jobRegistry)
      {
         return jobRegistry.containsKey(jobName);
      }
   }
   
   /**
    * Get the next execution time for the given job.
    * 
    * @param    jobName
    *           The job name.
    *           
    * @return   The next execution time or {@code -1}, if not scheduled.
    */
   public static long scheduledExecutionTime(String jobName)
   {
      jobName = jobName.toLowerCase();
      
      synchronized (jobRegistry)
      {
         if (jobRegistry.containsKey(jobName))
         {
            return jobRegistry.get(jobName).scheduledExecutionTime();
         }
      }
      
      return -1;
   }
   
   /**
    * Remove the given job from the {@link #jobs global job registry}.  Active jobs (which are
    * scheduled for execution) can not be removed.  Use {@link #cancelJob} to cancel it first. 
    * 
    * @param    jobName
    *           The job name.
    *           
    * @return   An {@link Job} instance with the removed job details or {@code null} if the job
    *           is scheduled for execution or it does not exist. 
    */
   public static Job removeJob(String jobName)
   {
      jobName = jobName.toLowerCase();
    
      synchronized (jobRegistry)
      {
         if (jobRegistry.containsKey(jobName))
         {
            if (LOG.isLoggable(Level.WARNING))
            {
               String msg = "Can not remove job - there are pending tasks! Cancel the job first.";
               LOG.logp(Level.WARNING, "Scheduler", "removeJob", msg);
               return null;
            }
         }
      }
      
      synchronized (jobs)
      {
         return jobs.remove(jobName);
      }
   }

   /**
    * Get the {@link Job} with the given name.
    * 
    * @param    jobName
    *           The job name.
    *           
    * @return   The {@link Job} instance or {@code null} if it does not exist.
    */
   public static Job getJob(String jobName)
   {
      jobName = jobName.toLowerCase();
      
      synchronized (jobs)
      {
         return jobs.get(jobName);
      }
   }
   
   /**
    * Add a new {@link Job} to the {@link #jobs global registry}.
    *  
    * @param    job
    *           The job name.  Must not already be in use for another job.
    *           
    * @return   <code>true</code> if the job was added. <code>false</code> if there is already
    *           a job with the same name,
    */
   public static boolean addJob(Job job)
   {
      String jobName = job.getName().toLowerCase();
      
      synchronized (jobs)
      {
         if (jobs.containsKey(jobName))
         {
            return false;
         }
         
         jobs.put(jobName, job);
      }
      
      return true;
   }
   
   /**
    * Cancel the job with the given name.
    * 
    * @param    jobName
    *           The job name.
    *           
    * @return   <code>true</code> if the job was scheduled for execution and it was canceled.
    */
   public static boolean cancelJob(String jobName)
   {
      jobName = jobName.toLowerCase();
      
      synchronized (jobRegistry)
      {
         JobTask task = jobRegistry.get(jobName);

         if (task != null)
         {
            task.cancel();

            // remove the reference from the timer
            timer.purge();
         }
         
         return (task != null);
      }
   }
   
   /**
    * Schedule a job for execution.
    * <p>
    * First, this will cancel any pending schedules and schedule it again, using the latest 
    * {@link Job} configuration.
    * <p>
    * Second, it will create a new {@link JobTask} which will be registered with the 
    * {@link #timer}.  At appropriate time, the {@link #timer} will pick up the {@link JobTask},
    * use {@link #getRunner} to create a new {@link Runnable} instance for this job and
    * send it to the {@link JobProcessor} thread.
    * 
    * @param    jobName
    *           The job name.
    * @param    lastExecutionTime
    *           The last time the job was executed. {@code null} if this is the first time the
    *           job needs to run.
    *           
    * @return   {@code true} if the job was scheduled.
    */
   public static boolean scheduleJob(String jobName, Long lastExecutionTime)
   {
      return scheduleJob(jobName, lastExecutionTime, null);
   }
   
   /**
    * Schedule a job for execution.
    * <p>
    * First, this will cancel any pending schedules and schedule it again, using the latest 
    * {@link Job} configuration.
    * <p>
    * Second, it will create a new {@link JobTask} which will be registered with the 
    * {@link #timer}.  At appropriate time, the {@link #timer} will pick up the {@link JobTask},
    * use {@link #getRunner} to create a new {@link Runnable} instance for this job and
    * send it to the {@link JobProcessor} thread.
    * 
    * @param    jobName
    *           The job name.
    * @param    lastExecutionTime
    *           The last time the job was executed. {@code null} if this is the first time the
    *           job needs to run.
    * @param    env
    *           Map of environment properties, in the <code>key=value</code> form.  For processes,
    *           these will be passed as JVM args.
    *           
    * @return   {@code true} if the job was scheduled.
    */
   public static boolean scheduleJob(String jobName, Long lastExecutionTime, Map<?, ?> env)
   {
      jobName = jobName.toLowerCase();

      synchronized (jobs)
      {
         Job job = getJob(jobName);
         
         if (job == null)
         {
            if (LOG.isLoggable(Level.WARNING))
            {
               String msg = String.format("Job '%s' is not registered with the scheduler!", 
                                          jobName);
               LOG.logp(Level.WARNING, "Scheduler", "scheduleJob", msg);
            }

            return false;
         }
         
         synchronized (jobRegistry)
         {
            // make sure this is not already scheduled
            cancelJob(jobName);
            
            // schedule the job for a new run
            long next = getNextScheduleTime(job, lastExecutionTime);
            if (next < 0)
            {
               // can not schedule it
               return false;
            }
            
            // convert the env to string pairs
            Map<String, String> jenv = null;
            if (env != null)
            {
               jenv = new HashMap<>();
               for (Object key : env.keySet())
               {
                  Object val = env.get(key);
                  
                  if (key instanceof BaseDataType)
                  {
                     key = ((BaseDataType) key).toStringMessage();
                  }
                  else
                  {
                     key = key.toString();
                  }
                  
                  if (val instanceof BaseDataType)
                  {
                     val = ((BaseDataType) val).toStringMessage();
                  }
                  else
                  {
                     val = val.toString();
                  }
                  
                  jenv.put((String) key, (String) val);
               }
            }
            
            JobTask runner = new JobTask(jobName, jenv);

            jobRegistry.put(jobName, runner);

            if (LOG.isLoggable(Level.FINE))
            {
               String msg = String.format("Scheduling job %s to execute at %tc.", jobName, next);
               LOG.logp(Level.WARNING, "Scheduler", "scheduleJob", msg);
            }
            
            timer.schedule(runner, new Date(next));
         }
      }
      
      return true;
   }
   
   /**
    * Build a cron expression following the {@link CronExpression} syntax, from the specified
    * job.
    * 
    * @param    job
    *           Jhe job.
    * 
    * @return   A cron expression.
    */
   public static String getCronExpression(Job job)
   {
      return getCronExpression(job.getSecond(), job.getMinute(), job.getHour(),
                               job.getDayOfWeek(), job.getDayOfMonth(), job.getMonth(),
                               job.getYear());
   }
   
   /**
    * Build a cron expression following the {@link CronExpression} syntax, from the specified
    * fields.
    * 
    * @param    second
    *           The second when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * @param    minute
    *           The minute when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * @param    hour
    *           The hour when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * @param    dayOfWeek
    *           The day of the week when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * @param    dayOfMonth
    *           The day the month when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * @param    month
    *           The month when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * @param    year
    *           The year when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * 
    * @return   A cron expression.
    */
   public static String getCronExpression(String second,
                                          String minute,
                                          String hour,
                                          String dayOfWeek,
                                          String dayOfMonth,
                                          String month,
                                          String year)
   {
      String[] fields = { second, minute, hour, dayOfMonth, month, dayOfWeek, year};
      StringBuilder cronExpr = new StringBuilder();
      for (int i = 0; i < fields.length; i++)
      {
         String field = (fields[i] == null ? "*" : fields[i]);
         cronExpr.append(field).append(" ");
      }
      
      return cronExpr.toString().trim();
   }
   
   /**
    * Validate the given configuration and create a new job.
    * 
    * @param    jobName
    *           The name of this job.
    * @param    type
    *           The type of this job, one of the {@link JobType} values.
    * @param    target
    *           The target of this job.
    * @param    enabled
    *           Flag indicating if this job is enabled.
    * @param    mode
    *           The execution mode of this job, one of the {@link JobMode} values.
    * @param    second
    *           The second when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * @param    minute
    *           The minute when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * @param    hour
    *           The hour when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * @param    dayOfWeek
    *           The day of the week when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * @param    dayOfMonth
    *           The day the month when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * @param    month
    *           The month when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * @param    year
    *           The year when the job needs to be executed, following {@link CronExpression} 
    *           syntax.
    * 
    * @return   A new job instance.
    * 
    * @throws   JobValidationException
    *           If the configuration can't be validated.  See the {@link Job} class javadoc for
    *           details about the validations being performed.
    */
   public static Job createJob(String  jobName,
                               String  type,
                               String  target,
                               boolean enabled,
                               String  mode,
                               String  second,
                               String  minute,
                               String  hour,
                               String  dayOfWeek,
                               String  dayOfMonth,
                               String  month,
                               String  year)
   throws JobValidationException
   {
      if (type == null)
      {
         final String msg = "Type for job %s is missing: use 'process' or 'java'.";
         throw new JobValidationException(String.format(msg, jobName));
      }
      
      JobType jobType = JobType.resolveJobType(type);
      if (jobType == JobType.PROCESS)
      {
         SecurityManager sm = SecurityManager.getInstance();

         // only processes can be set to be scheduled
         if (!sm.isProcessDefined(target))
         {
            final String msg = "Scheduler: process '%s' does not exist for job %s!";
            throw new JobValidationException(String.format(msg, target, jobName));
         }
         
      }
      else if (jobType == JobType.CLASS)
      {
         String msg = null;

         try
         {
            Class<?> cls = Class.forName(target);
            int mods = cls.getModifiers();

            if (Modifier.isAbstract(mods) || Modifier.isInterface(mods))
            {
               msg = "Job '%s': class '%s' must be a non-abstract Java class!";
            }
            else if (!Runnable.class.isAssignableFrom(cls))
            {
               // must implement runnable
               msg = "Job '%s': class '%s' must implement Runnable interface!";
            }
         }
         catch (ClassNotFoundException e)
         {
            msg = "Job '%s': class '%s' could not be found!";

            if (LOG.isLoggable(Level.SEVERE))
            {
               LOG.logp(Level.SEVERE, "Scheduler", "createJob",
                        String.format(msg, jobName, target), e);
            }
         }
         
         if (msg != null)
         {
            throw new JobValidationException(String.format(msg, jobName, target));
         }
      }
      else if (jobType == JobType.METHOD)
      {
         if (target.indexOf('.') == -1)
         {
            String msg = "Job '%s': target '%s' has no method name!";
            throw new JobValidationException(String.format(msg, jobName, target));
         }
         else
         {
            String msg = null;
            String clsName = target.substring(0, target.lastIndexOf('.'));
            String mthdName = target.substring(target.lastIndexOf('.') + 1);

            try
            {
               Class<?> cls = Class.forName(target);
               int mods = cls.getModifiers();
   
               if (Modifier.isAbstract(mods)  ||
                   Modifier.isInterface(mods) ||
                   !Modifier.isPublic(mods))
               {
                  msg = "Job '%s': class '%s' must be a non-abstract, public Java class!";
               }
               else if (!Runnable.class.isAssignableFrom(cls))
               {
                  // must implement runnable
                  msg = "Job '%s': class '%s' must implement Runnable interface!";
               }
               else
               {
                  // validate the method
                  Method mthd = cls.getMethod(mthdName);
                  
                  mods = mthd.getModifiers();
                  if (!Modifier.isPublic(mods))
                  {
                     msg = "Job '%s' class '%s': method '%s' must be public!";
                  }
               }
            }
            catch (ClassNotFoundException e)
            {
               msg = "Job '%s': class '%s' for method '%s' could not be found!";
   
               if (LOG.isLoggable(Level.SEVERE))
               {
                  LOG.logp(Level.SEVERE, "Scheduler", "createJob",
                           String.format(msg, jobName, clsName, mthdName), e);
               }
            }
            catch (NoSuchMethodException | SecurityException e)
            {
               msg = "Job '%s' class '%s': method '%s' could not be found!";
               
               if (LOG.isLoggable(Level.SEVERE))
               {
                  LOG.logp(Level.SEVERE, "Scheduler", "createJob",
                           String.format(msg, jobName, clsName, mthdName), e);
               }
            }

            if (msg != null)
            {
               throw new JobValidationException(String.format(msg, jobName, target));
            }
         }
      }
      else
      {
         String msg = "Invalid type '%s' for job %s (use 'process', 'java' or 'method').";
         throw new JobValidationException(String.format(msg, type, jobName));
      }
      
      if (mode == null)
      {
         String msg = "Mode for job %s is missing: use 'now', 'recurrent' or 'one-time'";
         throw new JobValidationException(String.format(msg, jobName));
      }
      
      JobMode jobMode = JobMode.resolveJobMode(mode);
      if (jobMode == null)
      {
         String msg = "Invalid mode '%s' for job %s (use 'now', 'recurrent' or 'one-time').";
         throw new JobValidationException(String.format(msg, mode, jobName));
      }
      
      // validate the scheduled time
      if (jobMode.isTimed())
      {
         String cronExpr = getCronExpression(second, minute, hour, 
                                             dayOfWeek, dayOfMonth, month, year);
         if (!CronExpression.isValidExpression(cronExpr))
         {
            final String msg = "The %s cron expression is not valid!";
            throw new JobValidationException(String.format(msg, cronExpr));
         }
      }
      
      Job job = new Job(jobName, jobType, target, enabled, jobMode);
      job.setSecond(second);
      job.setMinute(minute);
      job.setHour(hour);
      job.setDayOfWeek(dayOfWeek);
      job.setDayOfMonth(dayOfMonth);
      job.setMonth(month);
      job.setYear(year);
      
      return job;
   }
   
   /**
    * Must be called only on server startup.  It starts new {@link #processor job processor} 
    * thread using the server's context. Once the server's sockets have been connected, it reads 
    * the scheduler configuration and schedules the jobs.
    */
   public static void scheduleJobs()
   {
      Scheduler.initialize();
      
      Thread t = new AssociatedThread(processor);
      t.setDaemon(true);
      t.setName(CentralLogger.generate("Job processor"));
      t.start();
   }
   
   /**
    * Read the scheduler configuration and schedule the jobs.
    */
   synchronized static void initialize()
   {
      if (jobs != null)
      {
         return;
      }
      
      jobs = new HashMap<>();
      
      // access the directory service
      DirectoryService ds = DirectoryService.getInstance();
      
      if (!ds.bind())
         throw new RuntimeException("directory bind failed");
   
      String schedulerPath = Utils.findDirectoryNodePath(ds, "scheduler", "container", false);
      if (schedulerPath == null)
      {
         if (LOG.isLoggable(Level.WARNING))
         {
            LOG.logp(Level.WARNING, "Scheduler", "initialize",
                     "No scheduler configured.");
         }
   
         return;
      }
      
      // enumerate jobs
      String[] jobNodes = ds.enumerateNodes(schedulerPath);
      for (String jobName : jobNodes)
      {
         String jobPath = schedulerPath + "/" + jobName;
   
         String type   = ds.getNodeString(jobPath, "type");
         String target = ds.getNodeString(jobPath, "target");
   
         Boolean oenabled   = ds.getNodeBoolean(jobPath, "enabled");
         String  mode       = ds.getNodeString(jobPath,  "mode");
         String  second     = ds.getNodeString(jobPath,  "second");
         String  minute     = ds.getNodeString(jobPath,  "minute");
         String  hour       = ds.getNodeString(jobPath,  "hour");
         String  dayOfWeek  = ds.getNodeString(jobPath,  "day-of-Week");
         String  dayOfMonth = ds.getNodeString(jobPath,  "day-of-Month");
         String  month      = ds.getNodeString(jobPath,  "month");
         String  year       = ds.getNodeString(jobPath,  "year");
   
         Job job = createJob(jobName, type, target, (oenabled != null && oenabled.booleanValue()), 
                             mode, second, minute, hour, dayOfWeek, dayOfMonth, month, year);
         
         if (!addJob(job))
         {
            throw new RuntimeException(String.format("Job '%s' is already defined!", jobName));
         }
      }
      
      // last use of the directory, end our session with it
      ds.unbind();
   }

   /**
    * Get a runner to execute this job, depending on the job's {@link Job#getType() type}.
    * 
    * @param    job
    *           The job to be executed.
    * @param    env
    *           Map of environment properties, in the <code>key=value</code> form.  For processes,
    *           these will be passed as JVM args.
    *           
    * @return   A runnable instance for this job.
    */
   static Runnable getRunner(Job job, Map<String, String> env)
   {
      switch (job.getType())
      {
         case PROCESS:
            return new ProcessLaunchTask(job.getTarget(), env);
            
         case CLASS:
            return new JavaLaunchTask(job.getTarget(), "run");
   
         case METHOD:
            String target = job.getTarget();
            int    idx    = target.lastIndexOf(".");
            String cls    = target.substring(0, idx);
            String method = target.substring(idx + 1);
            
            return new JavaLaunchTask(cls, method);
            
         case INSTANCE:
            return job.getInstance();
      }
      
      return null;
   }

   /**
    * Get the next job execution time.
    * 
    * @param    job
    *           The job name.
    * @param    lastExecutionTime
    *           The last time the job was executed, in millis.  If {@code null}, it means it is
    *           the first execution and the current time will be used.
    * 
    * @return   The next execution time, in millis, or -1 if the job's cron expression could not
    *           be parsed.
    */
   private static long getNextScheduleTime(Job job, Long lastExecutionTime)
   throws JobValidationException
   {
      if (lastExecutionTime != null && job.getMode() != JobMode.RECURRING)
      {
         // only recurrent mode allows more than one time
         return -1;
      }
      
      if (job.getMode() == JobMode.NOW)
      {
         // return a past time, so it will be executed immediately
         return System.currentTimeMillis() - 1000;
      }
      
      if (lastExecutionTime == null)
      {
         // use current time as offset, when scheduling for the first time
         lastExecutionTime = System.currentTimeMillis() + 60 * 1000;
      }
      
      // compute the next execution time
      String cronExpression = getCronExpression(job);
      try
      {
         CronExpression ce = new CronExpression(cronExpression);
         Date next = ce.getNextValidTimeAfter(new Date(lastExecutionTime));
         
         return next.getTime();
      }
      catch (ParseException e)
      {
         if (LOG.isLoggable(Level.SEVERE))
         {
            final String msg = "Cron expression %s could not be parsed!";
            LOG.logp(Level.SEVERE, "Scheduler", "getNextScheduleTime",
                     String.format(msg, cronExpression), e);
         }

         return -1;
      }
   }
}