ca.nrc.cadc.uws.server.JobDAO.java Source code

Java tutorial

Introduction

Here is the source code for ca.nrc.cadc.uws.server.JobDAO.java

Source

/*
************************************************************************
*******************  CANADIAN ASTRONOMY DATA CENTRE  *******************
**************  CENTRE CANADIEN DE DONNES ASTRONOMIQUES  **************
*
*  (c) 2009.                            (c) 2009.
*  Government of Canada                 Gouvernement du Canada
*  National Research Council            Conseil national de recherches
*  Ottawa, Canada, K1A 0R6              Ottawa, Canada, K1A 0R6
*  All rights reserved                  Tous droits rservs
*
*  NRC disclaims any warranties,        Le CNRC dnie toute garantie
*  expressed, implied, or               nonce, implicite ou lgale,
*  statutory, of any kind with          de quelque nature que ce
*  respect to the software,             soit, concernant le logiciel,
*  including without limitation         y compris sans restriction
*  any warranty of merchantability      toute garantie de valeur
*  or fitness for a particular          marchande ou de pertinence
*  purpose. NRC shall not be            pour un usage particulier.
*  liable in any event for any          Le CNRC ne pourra en aucun cas
*  damages, whether direct or           tre tenu responsable de tout
*  indirect, special or general,        dommage, direct ou indirect,
*  consequential or incidental,         particulier ou gnral,
*  arising from the use of the          accessoire ou fortuit, rsultant
*  software.  Neither the name          de l'utilisation du logiciel. Ni
*  of the National Research             le nom du Conseil National de
*  Council of Canada nor the            Recherches du Canada ni les noms
*  names of its contributors may        de ses  participants ne peuvent
*  be used to endorse or promote        tre utiliss pour approuver ou
*  products derived from this           promouvoir les produits drivs
*  software without specific prior      de ce logiciel sans autorisation
*  written permission.                  pralable et particulire
*                                       par crit.
*
*  This file is part of the             Ce fichier fait partie du projet
*  OpenCADC project.                    OpenCADC.
*
*  OpenCADC is free software:           OpenCADC est un logiciel libre ;
*  you can redistribute it and/or       vous pouvez le redistribuer ou le
*  modify it under the terms of         modifier suivant les termes de
*  the GNU Affero General Public        la GNU Affero General Public
*  License as published by the          License? telle que publie
*  Free Software Foundation,            par la Free Software Foundation
*  either version 3 of the              : soit la version 3 de cette
*  License, or (at your option)         licence, soit ( votre gr)
*  any later version.                   toute version ultrieure.
*
*  OpenCADC is distributed in the       OpenCADC est distribu
*  hope that it will be useful,         dans lespoir quil vous
*  but WITHOUT ANY WARRANTY;            sera utile, mais SANS AUCUNE
*  without even the implied             GARANTIE : sans mme la garantie
*  warranty of MERCHANTABILITY          implicite de COMMERCIALISABILIT
*  or FITNESS FOR A PARTICULAR          ni dADQUATION  UN OBJECTIF
*  PURPOSE.  See the GNU Affero         PARTICULIER. Consultez la Licence
*  General Public License for           Gnrale Publique GNU Affero
*  more details.                        pour plus de dtails.
*
*  You should have received             Vous devriez avoir reu une
*  a copy of the GNU Affero             copie de la Licence Gnrale
*  General Public License along         Publique GNU Affero avec
*  with OpenCADC.  If not, see          OpenCADC ; si ce nest
*  <http://www.gnu.org/licenses/>.      pas le cas, consultez :
*                                       <http://www.gnu.org/licenses/>.
*
*  $Revision: 4 $
*
************************************************************************
*/

package ca.nrc.cadc.uws.server;

import java.lang.reflect.Field;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.security.AccessControlContext;
import java.security.AccessController;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.Types;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import javax.security.auth.Subject;
import javax.sql.DataSource;

import org.apache.log4j.Logger;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.core.ResultSetExtractor;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;

import ca.nrc.cadc.auth.IdentityManager;
import ca.nrc.cadc.date.DateUtil;
import ca.nrc.cadc.db.DBUtil;
import ca.nrc.cadc.net.TransientException;
import ca.nrc.cadc.profiler.Profiler;
import ca.nrc.cadc.uws.ErrorSummary;
import ca.nrc.cadc.uws.ErrorType;
import ca.nrc.cadc.uws.ExecutionPhase;
import ca.nrc.cadc.uws.Job;
import ca.nrc.cadc.uws.JobInfo;
import ca.nrc.cadc.uws.JobRef;
import ca.nrc.cadc.uws.Parameter;
import ca.nrc.cadc.uws.Result;

/**
 * JobDAO class that stores the jobs in a RDBMS. This is an abstract class;
 * users of this class must implement the abstract methods to return the names of tables
 * where the job is to be stored. The subclass must also call setDataSource before any
 * persistence methods are called.
 * <p>
 * Users must create at least 3 tables (possibly multiple parameter tables) with the
 * following columns. TODO: List the required columns for each table.
 * </p>
 * 
 * @author pdowler, jburke
 */
public class JobDAO {
    private static Logger log = Logger.getLogger(JobDAO.class);

    private static final int BATCH_SIZE = 1000;
    private static final String TYPE_PARAMETER = "P";
    private static final String TYPE_RESULT = "R";

    private static String[] JOB_COLUMNS = {
            // jobID must be first
            // creationTime must be last

            "jobID", "creationTime", "executionPhase", "executionDuration", "destructionTime", "quote", "startTime",
            "endTime", "error_summaryMessage", "error_type", "error_documentURL", "ownerID", "runID", "requestPath",
            "remoteIP", "jobInfo_content", "jobInfo_contentType", "jobInfo_valid", "lastModified" };
    private static String[] DETAIL_COLUMNS = { "jobID", "type", "name", "value" };

    private JobSchema jobSchema;
    private IdentityManager identManager;
    private StringIDGenerator idGenerator;

    private JdbcTemplate jdbc;
    private DataSourceTransactionManager transactionManager;
    private DefaultTransactionDefinition defaultTransactionDef;
    private TransactionStatus transactionStatus;
    private boolean inTransaction = false;

    private DateFormat idFormat = DateUtil.getDateFormat("yyyy-MM-dd", DateUtil.UTC);
    private DateFormat dateFormat = DateUtil.getDateFormat(DateUtil.IVOA_DATE_FORMAT, DateUtil.UTC);
    private DateFormat isoDateFormat = DateUtil.getDateFormat(DateUtil.ISO_DATE_FORMAT, DateUtil.UTC);
    private Calendar cal = Calendar.getInstance(DateUtil.UTC);

    private Profiler prof = new Profiler(JobDAO.class);

    /**
     * Simple class that describes the database implementation. The caller can
     * specify the names for the job and job details tables.
     */
    public static class JobSchema {
        public String jobTable;
        public String detailTable;
        boolean limitWithTop;
        /*
         * Map of columns that have size limits in the jobTable imposed by the
         * underlying database system.
         *
         * If a value destined for a column is longer than the limit, the
         * JobDAO will use an alternate column with <code>_text</code> appended to
         * the original column name. For example, the jobInfo_content column could
         * contain an arbitrary-length string. One could make this column type
         * VARCHAR(1024) to store small values, set the limit to 1024, and then
         * have an extra column named jobInfo_content_text of type TEXT for longer
         * values.
         */
        public Map<String, Integer> jobColumnLimits;

        /*
         * Map of columns that have size limits in the detailTable imposed by the
         * underlying database system.
         *
         * If a value destined for a column is longer than the limit, the
         * JobDAO will use an alternate column with <code>_text</code> appended to
         * the original column name. For example, the jobInfo_content column could
         * contain an arbitrary-length string. One could make this column type
         * VARCHAR(1024) to store small values, set the limit to 1024, and then
         * have an extra column named jobInfo_content_text of type TEXT for longer
         * values.
         */
        public Map<String, Integer> detailColumnLimits;

        // Map of alternate columns when the size limit in <code>columnLimits</code>
        private Map<String, String> alternateJobColumns;
        private Map<String, String> alternateDetailColumns;
        private List<String> jobColumns;
        private List<String> detailColumns;

        public JobSchema(String jobTable, String detailTable, boolean limitWithTop) {
            this(jobTable, detailTable, limitWithTop, null, null);
        }

        public JobSchema(String jobTable, String detailTable, boolean limitWithTop,
                Map<String, Integer> jobColumnLimits, Map<String, Integer> detailColumnLimits) {
            this.jobTable = jobTable;
            this.detailTable = detailTable;
            this.limitWithTop = limitWithTop;
            this.jobColumnLimits = jobColumnLimits;
            this.detailColumnLimits = detailColumnLimits;

            if (this.jobColumnLimits == null)
                this.jobColumnLimits = new HashMap<String, Integer>();
            this.alternateJobColumns = new HashMap<String, String>();
            for (String col : this.jobColumnLimits.keySet())
                alternateJobColumns.put(col, col + "_text");

            if (this.detailColumnLimits == null)
                this.detailColumnLimits = new HashMap<String, Integer>();
            this.alternateDetailColumns = new HashMap<String, String>();
            for (String col : this.detailColumnLimits.keySet())
                alternateDetailColumns.put(col, col + "_text");

            this.jobColumns = new ArrayList<String>();
            for (String col : JOB_COLUMNS) {
                jobColumns.add(col);
                String alt = alternateJobColumns.get(col);
                if (alt != null)
                    jobColumns.add(alt);
            }
            this.detailColumns = new ArrayList<String>();
            for (String col : DETAIL_COLUMNS) {
                detailColumns.add(col);
                String alt = alternateDetailColumns.get(col);
                if (alt != null)
                    detailColumns.add(alt);
            }
        }

        @Override
        public String toString() {
            return jobTable + "/" + detailTable + "/" + jobColumnLimits.size();
        }

        /**
        * Get the physical column length in the database. The default implementation always
        * returns null (no limit).
        *
        * @param tableName
        * @param columnName
        * @return max string size that can be stored, or null for no limit
        */
        public Integer getColumnLength(String tableName, String columnName) {
            if (jobColumnLimits != null && jobTable.equals(tableName))
                return jobColumnLimits.get(columnName);
            if (detailColumnLimits != null && detailTable.equals(tableName))
                return detailColumnLimits.get(columnName);
            return null; // no limit
        }

        public String getAlternateColumn(String tableName, String columnName) {
            String ret = null;
            if (alternateJobColumns != null && jobTable.equals(tableName))
                ret = alternateJobColumns.get(columnName);
            if (alternateDetailColumns != null && detailTable.equals(tableName))
                ret = alternateDetailColumns.get(columnName);
            return ret;
        }

    }

    public JobDAO(DataSource dataSource, JobSchema jobSchema, IdentityManager identManager,
            StringIDGenerator idGenerator) {
        this.jobSchema = jobSchema;
        this.identManager = identManager;
        this.idGenerator = idGenerator;

        this.defaultTransactionDef = new DefaultTransactionDefinition();
        defaultTransactionDef.setIsolationLevel(DefaultTransactionDefinition.ISOLATION_REPEATABLE_READ);
        this.transactionManager = new DataSourceTransactionManager(dataSource);

        this.jdbc = new JdbcTemplate(dataSource);
    }

    /**
     * Start a transaction to the data source.
     */
    protected void startTransaction() {
        if (transactionStatus != null)
            throw new IllegalStateException("transaction already in progress");
        log.debug("startTransaction");
        this.transactionStatus = transactionManager.getTransaction(defaultTransactionDef);
        this.inTransaction = true;
        log.debug("startTransaction: OK");
    }

    /**
     * Commit the transaction to the data source.
     */
    protected void commitTransaction() {
        if (transactionStatus == null)
            throw new IllegalStateException("no transaction in progress");
        log.debug("commitTransaction");
        transactionManager.commit(transactionStatus);
        this.transactionStatus = null;
        this.inTransaction = false;
        log.debug("commit: OK");
    }

    /**
     * Rollback the transaction to the data source.
     */
    protected void rollbackTransaction() {
        if (!inTransaction)
            throw new IllegalStateException("no transaction in progress");
        if (transactionStatus == null) // the startTransaction failed
            return;
        log.debug("rollbackTransaction");
        transactionManager.rollback(transactionStatus);
        this.transactionStatus = null;
        this.inTransaction = false;
        log.debug("rollback: OK");
    }

    /**
     * Obtain a Job from the persistence layer.
     *
     * @param jobID
     * @return the job
     * @throws JobNotFoundException
     * @throws JobPersistenceException
     * @throws TransientException
     */
    public Job get(String jobID) throws JobNotFoundException, JobPersistenceException, TransientException {
        log.debug("get: " + jobID);
        try {
            JobSelectStatementCreator sc = new JobSelectStatementCreator();
            sc.setJobID(jobID);
            Job ret = (Job) jdbc.query(sc, new JobExtractor(jobSchema));
            prof.checkpoint("JobSelectStatementCreator");
            if (ret != null) {
                // call IdentityManager outside the resource lock to avoid deadlock
                if (ret.appData != null) {
                    Subject s = identManager.toSubject(ret.appData);
                    ret.setOwnerID(identManager.toOwnerString(s));
                    ret.ownerSubject = s; // for later authorization checks
                    ret.appData = null;
                    prof.checkpoint("IdentityManager.toSubject");
                }
                return ret;
            }

        } catch (Throwable t) {
            if (DBUtil.isTransientDBException(t))
                throw new TransientException("failed to get job: " + jobID, t);
            else
                throw new JobPersistenceException("failed to get job: " + jobID, t);
        }
        throw new JobNotFoundException(jobID);
    }

    /**
     * Load all stored Parameter(s) and Result(s) into the specified job.
     *
     * @param job
     * @throws JobPersistenceException
     * @throws TransientException
     */
    public void getDetails(Job job) throws JobPersistenceException, TransientException {
        log.debug("getDetails: " + job.getID());
        expectPersistentJob(job);
        try {
            JobDetailSelectStatementCreator sc = new JobDetailSelectStatementCreator();
            sc.setJobID(job.getID());
            jdbc.query(sc, new DetailExtractor(jobSchema, job)); // ignore unnecessary return value
            prof.checkpoint("JobDetailSelectStatementCreator");
        } catch (Throwable t) {
            if (DBUtil.isTransientDBException(t))
                throw new TransientException("failed to get job details: " + job.getID(), t);
            else
                throw new JobPersistenceException("failed to get job details: " + job.getID(), t);
        }
    }

    /**
     * Get the current execution phase.
     * @param jobID
     * @return the current phase
     * @throws JobNotFoundException
     * @throws JobPersistenceException
     * @throws TransientException
     */
    public ExecutionPhase getPhase(String jobID)
            throws JobNotFoundException, JobPersistenceException, TransientException {
        log.debug("getPhase: " + jobID);
        try {
            JobPhaseSelectStatementCreator sc = new JobPhaseSelectStatementCreator();
            sc.setJobID(jobID);
            ExecutionPhase ret = (ExecutionPhase) jdbc.query(sc, new PhaseExtractor());
            prof.checkpoint("JobPhaseSelectStatementCreator");
            if (ret != null)
                return ret;
        } catch (Throwable t) {
            if (DBUtil.isTransientDBException(t))
                throw new TransientException("failed to get job phase: " + jobID, t);
            else
                throw new JobPersistenceException("failed to get job phase: " + jobID, t);
        }
        throw new JobNotFoundException(jobID);
    }

    /**
     * Set the phase of the specified job.
     *
     * @param jobID
     * @param ep
     * @throws JobNotFoundException
     * @throws JobPersistenceException
     * @throws TransientException
     */
    public void set(String jobID, ExecutionPhase ep)
            throws JobNotFoundException, JobPersistenceException, TransientException {
        set(jobID, null, ep, null, null, null);
    }

    /**
     * Conditionally set the phase from <em>start</em> to <em>end</em>. The transition is
     * successful IFF the current phase is equal to the starting phase.
     *
     * @param jobID
     * @param start
     * @param end
     * @param date
     * @return the resulting phase or null if the the transition was not successful.
     * @throws JobNotFoundException
     * @throws JobPersistenceException
     * @throws TransientException
     */
    public ExecutionPhase set(String jobID, ExecutionPhase start, ExecutionPhase end, Date date)
            throws JobNotFoundException, JobPersistenceException, TransientException {
        log.debug("set: " + jobID + "," + start + "," + end + "," + date);
        return set(jobID, start, end, null, null, date);
    }

    /**
     * Conditionally set the phase from <em>start</em> to <em>end</em> and set the
     * results. The transition is successful IFF the current phase is equal to the
     * starting phase. Results are only set if the phase transition is successful.
     * Note: this method is unconditional when <em>start</em> is null.
     *
     * @param jobID
     * @param start
     * @param end
     * @param date
     * @param results
     * @return the resulting phase or null if the the transition was not successful.
     * @throws JobNotFoundException
     * @throws JobPersistenceException
     * @throws TransientException
     */
    public ExecutionPhase set(String jobID, ExecutionPhase start, ExecutionPhase end, List<Result> results,
            Date date) throws JobNotFoundException, JobPersistenceException, TransientException {
        log.debug("set: " + jobID + "," + start + "," + end + ", <results>," + date);
        return set(jobID, start, end, null, results, date);
    }

    /**
     * Conditionally set the phase from <em>start</em> to <em>end</em> and set the
     * error summary. The transition is successful IFF the current phase is equal to the
     * starting phase. Results are only set if the phase transition is successful.
     * Note: this method is unconditional when <em>start</em> is null.
     *
     * @param jobID
     * @param start
     * @param end
     * @param error
     * @param date
     * @return the resulting phase or null if the the transition was not successful.
     * @throws JobNotFoundException
     * @throws JobPersistenceException
     * @throws TransientException
     */
    public ExecutionPhase set(String jobID, ExecutionPhase start, ExecutionPhase end, ErrorSummary error, Date date)
            throws JobNotFoundException, JobPersistenceException, TransientException {
        log.debug("set: " + jobID + "," + start + "," + end + ", <error>," + date);
        return set(jobID, start, end, error, null, date);
    }

    /**
     * Conditionally set the phase from <em>start</em> to <em>end</em> and set the
     * results and/or error. The transition is successful IFF the current phase is equal to the
     * starting phase. Results are only set if the phase transition is successful.
     *  Note: this method is unconditional when <em>start</em> is null.
     *
     * @param jobID
     * @param start
     * @param end
     * @param error
     * @param results
     * @param date
     * @return the resulting phase or null if the the transition was not successful.
     * @throws JobNotFoundException
     * @throws JobPersistenceException
     * @throws TransientException
     */
    public ExecutionPhase set(String jobID, ExecutionPhase start, ExecutionPhase end, ErrorSummary error,
            List<Result> results, Date date)
            throws JobNotFoundException, JobPersistenceException, TransientException {
        log.debug("set: " + jobID + "," + start + "," + end + ", <error>, <results>, " + date);
        boolean txn = false; // need explicit transaction or autocommit?
        if (results != null && results.size() > 0)
            txn = true;
        try {
            if (txn)
                startTransaction();
            JobPhaseUpdateStatementCreator sc = new JobPhaseUpdateStatementCreator();
            sc.setValues(jobID, start, end, error, date);
            int n = jdbc.update(sc);
            prof.checkpoint("JobPhaseUpdateStatementCreator");
            if (n == 1) {
                if (results != null && results.size() > 0) {
                    DetailInsertStatementCreator disc = new DetailInsertStatementCreator();
                    for (Result r : results) {
                        disc.setValues(jobID, r);
                        jdbc.update(disc);
                        prof.checkpoint("DetailInsertStatementCreator");
                    }
                }
            }
            if (txn) {
                commitTransaction();
                prof.checkpoint("commit.JobPhaseUpdateStatementCreator");
            }
            if (n == 1)
                return end;
            return null;
        } catch (Throwable t) {
            log.error("rollback for job: " + jobID, t);
            try {
                if (txn) {
                    rollbackTransaction();
                    prof.checkpoint("rollback.JobPhaseUpdateStatementCreator");
                }
            } catch (Throwable oops) {
                log.error("failed to rollback transaction", oops);
            }

            if (DBUtil.isTransientDBException(t))
                throw new TransientException("failed to persist job: " + jobID, t);
            else
                throw new JobPersistenceException("failed to persist job: " + jobID, t);
        } finally {
            if (transactionStatus != null)
                try {
                    log.warn("put: BUG - transaction still open in finally... calling rollback");
                    if (txn)
                        rollbackTransaction();
                } catch (Throwable oops) {
                    log.error("failed to rollback transaction in finally", oops);
                }
        }
    }

    /**
     * Iterate over the jobs owned by the user in the subject contained in the
     * access control context.
     *
     * @param phases Show only these phases
     * @param after Only show jobs after this time
     * @param last Show the last <i>last</i> jobs, ordererd by startTime ascending
     * @return job iterator
     * @throws JobPersistenceException
     * @throws TransientException
     */
    public Iterator<JobRef> iterator(String appname, List<ExecutionPhase> phases, Date after, Integer last)
            throws TransientException, JobPersistenceException {
        AccessControlContext acContext = AccessController.getContext();
        Subject subject = Subject.getSubject(acContext);
        return iterator(subject, appname, phases, after, last);
    }

    /**
     * Iterator over jobs owned by the specified owner.
     *
     * @param subject
     * @param phases Show only these phases
     * @param after Only show jobs after this time
     * @param last Show the last <i>last</i> jobs, ordererd by startTime ascending
     * @return job iterator
     */
    public Iterator<JobRef> iterator(Subject subject, String appname, List<ExecutionPhase> phases, Date after,
            Integer last) throws TransientException, JobPersistenceException {
        Object owner = identManager.toOwner(subject);
        log.debug("iterator(" + owner + ")");

        try {
            JobListIterator jobListIterator = new JobListIterator(jdbc, owner, appname, phases, after, last);
            prof.checkpoint("JobListStatementCreator");
            return jobListIterator;
        } catch (Throwable t) {
            if (DBUtil.isTransientDBException(t))
                throw new TransientException("failed to get job list for owner: " + owner, t);
            else
                throw new JobPersistenceException("failed to get job list for owner: " + owner, t);
        }
    }

    /**
     * Persist the specified job.
     *
     * @param job
     * @param owner
     * @return
     * @throws JobPersistenceException
     * @throws TransientException
     */
    public Job put(Job job, Subject owner) throws JobPersistenceException, TransientException {
        try {
            boolean update = (job.getID() != null);
            if (!update) // insert
            {
                JobPersistenceUtil.assignID(job, idGenerator.getID());
                job.setCreationTime(new Date());
            }
            log.debug("put: " + job.getID());

            // call IdentityManager outside the resource lock to avoid deadlock
            if (owner != null) {
                job.appData = identManager.toOwner(owner);
                prof.checkpoint("IdentityManager.toOwner");
            }

            startTransaction();
            prof.checkpoint("start.JobPutStatementCreator");

            JobPutStatementCreator npsc = new JobPutStatementCreator(update);
            npsc.setValues(job);
            jdbc.update(npsc);
            prof.checkpoint("JobPutStatementCreator");

            int numP = 0;
            if (job.getParameterList() != null)
                numP = job.getParameterList().size();
            int numR = 0;
            if (job.getResultsList() != null)
                numR = job.getResultsList().size();

            if (numP + numR > 0) {
                DetailDeleteStatementCreator dd = new DetailDeleteStatementCreator();
                dd.setJobID(job.getID());
                jdbc.update(dd);
                prof.checkpoint("DetailDeleteStatementCreator");
            }

            DetailInsertStatementCreator disc = new DetailInsertStatementCreator();
            if (numP > 0) {
                Iterator<Parameter> pi = job.getParameterList().iterator();
                while (pi.hasNext()) {
                    Parameter param = pi.next();
                    disc.setValues(job.getID(), param);
                    jdbc.update(disc);
                    prof.checkpoint("DetailInsertStatementCreator");
                }
            }
            if (numR > 0) {
                Iterator<Result> ri = job.getResultsList().iterator();
                while (ri.hasNext()) {
                    Result res = ri.next();
                    disc.setValues(job.getID(), res);
                    jdbc.update(disc);
                    prof.checkpoint("DetailInsertStatementCreator");
                }
            }

            commitTransaction();
            prof.checkpoint("commit.JobPutStatementCreator");

            // OK to modify the job now
            job.ownerSubject = owner;
            job.setOwnerID(identManager.toOwnerString(owner));
            prof.checkpoint("IdentityManager.toOwnerString");
            return job;
        } catch (Throwable t) {
            log.error("rollback for job: " + job.getID(), t);
            try {
                rollbackTransaction();
                prof.checkpoint("rollback.JobPutStatementCreator");
            } catch (Throwable oops) {
                log.error("failed to rollback transaction", oops);
            }
            if (DBUtil.isTransientDBException(t))
                throw new TransientException("failed to persist job: " + job.getID(), t);
            else
                throw new JobPersistenceException("failed to persist job: " + job.getID(), t);
        } finally {
            if (transactionStatus != null)
                try {
                    log.warn("put: BUG - transaction still open in finally... calling rollback");
                    rollbackTransaction();
                } catch (Throwable oops) {
                    log.error("failed to rollback transaction in finally", oops);
                }
        }
    }

    public void addParameters(String jobID, List<Parameter> params)
            throws JobNotFoundException, JobPersistenceException, TransientException {
        log.debug("addParameters: " + jobID);
        try {
            if (params != null && params.size() > 0) {
                startTransaction();
                DetailInsertStatementCreator disc = new DetailInsertStatementCreator();
                for (Parameter p : params) {
                    disc.setValues(jobID, p);
                    jdbc.update(disc);
                    prof.checkpoint("DetailInsertStatementCreator");
                }
                commitTransaction();
                prof.checkpoint("commit.DetailInsertStatementCreator");
            }
        } catch (DataIntegrityViolationException notFound) {
            throw new JobNotFoundException("not found: " + jobID);
        } catch (Throwable t) {
            log.error("rollback for job: " + jobID, t);
            try {
                rollbackTransaction();
                prof.checkpoint("rollback.DetailInsertStatementCreator");
            } catch (Throwable oops) {
                log.error("failed to rollback transaction", oops);
            }
            if (DBUtil.isTransientDBException(t))
                throw new TransientException("failed to persist job parameters: " + jobID, t);
            else
                throw new JobPersistenceException("failed to persist job parameters: " + jobID, t);
        } finally {
            if (transactionStatus != null)
                try {
                    log.warn("put: BUG - transaction still open in finally... calling rollback");
                    rollbackTransaction();
                } catch (Throwable oops) {
                    log.error("failed to rollback transaction in finally", oops);
                }
        }
    }

    /**
     * Delete the specified job.
     *
     * @param jobID
     * @throws JobPersistenceException
     * @throws TransientException
     */
    public void delete(String jobID) throws JobPersistenceException, TransientException {
        log.debug("delete: " + jobID);
        try {
            //startTransaction();
            JobDeleteStatementCreator jdl = new JobDeleteStatementCreator();
            jdl.setJobID(jobID);
            jdbc.update(jdl);
            prof.checkpoint("JobDeleteStatementCreator");
            //commitTransaction();
        } catch (Throwable t) {
            log.error("failed to delete job", t);
            if (transactionStatus != null)
                try {
                    rollbackTransaction();
                    prof.checkpoint("rollback.DetailInsertStatementCreator");
                } catch (Throwable oops) {
                    log.error("failed to rollback transaction", oops);
                }
            if (DBUtil.isTransientDBException(t))
                throw new TransientException("failed to delete job: " + jobID, t);
            else
                throw new JobPersistenceException("failed to delete job: " + jobID, t);
        } finally {
            if (transactionStatus != null)
                try {
                    log.warn("delete - BUG - transaction still open in finally... calling rollback");
                    rollbackTransaction();
                } catch (Throwable oops) {
                    log.error("failed to rollback transaction in finally", oops);
                }
        }
    }

    protected void expectPersistentJob(Job job) {
        if (job == null)
            throw new IllegalArgumentException("job cannot be null");
        if (job.getLastModified() == null)
            throw new IllegalArgumentException("node is not a persistent job: has null lastModified");
    }

    private void appendColumnNames(StringBuilder sb, List<String> arr, int start) {
        for (int c = start; c < arr.size(); c++) {
            String col = arr.get(c);
            if (c > start)
                sb.append(",");
            sb.append(col);
        }
    }

    private void appendStatementParams(StringBuilder sb, int num) {
        for (int c = 0; c < num; c++) {
            if (c > 0)
                sb.append(",");
            sb.append("?");
        }
    }

    private int setString(PreparedStatement ps, int col, String tableName, String columnName, String value,
            StringBuilder sb) throws SQLException {
        Integer limit = jobSchema.getColumnLength(tableName, columnName);

        if (limit == null) {
            // single column
            if (value == null)
                ps.setNull(col, Types.VARCHAR);
            else
                ps.setString(col, value);
            sb.append(value);
            sb.append(",");
            return 1;
        }

        // pair of columns
        if (value == null) {
            ps.setNull(col, Types.VARCHAR);
            ps.setNull(col + 1, Types.VARCHAR);
            sb.append("null,null,");
        } else if (value.length() <= limit) {
            ps.setString(col, value);
            ps.setNull(col + 1, Types.VARCHAR);
            sb.append(value);
            sb.append(",null,");
        } else {
            ps.setNull(col, Types.VARCHAR);
            ps.setString(col + 1, value);
            sb.append("null,");
            sb.append(value);
            sb.append(",");
        }
        return 2;
    }

    class JobDeleteStatementCreator implements PreparedStatementCreator {
        private String jobID;

        public JobDeleteStatementCreator() {
        }

        public void setJobID(String jobID) {
            this.jobID = jobID;
        }

        public PreparedStatement createPreparedStatement(Connection conn) throws SQLException {
            String sql = "UPDATE " + jobSchema.jobTable + " SET deletedByUser = 1 WHERE jobID = ?";
            log.debug(sql);
            log.debug(jobID);
            PreparedStatement prep = conn.prepareStatement(sql);
            prep.setString(1, jobID);
            return prep;
        }
    }

    class JobPutStatementCreator implements PreparedStatementCreator {
        private boolean update;
        private Job job;
        private String sql;

        public JobPutStatementCreator(boolean update) {
            this.update = update;
            if (update)
                this.sql = getUpdateSQL();
            else
                this.sql = getInsertSQL();
        }

        public void setValues(Job job) {
            this.job = job;
        }

        public PreparedStatement createPreparedStatement(Connection conn) throws SQLException {
            log.debug(sql);
            PreparedStatement prep = conn.prepareStatement(sql);
            setValues(prep);
            return prep;
        }

        private void setValues(PreparedStatement ps) throws SQLException {
            StringBuilder sb = new StringBuilder();

            int col = 1;
            if (!update) {
                log.debug("jobID: " + col);
                ps.setString(col++, job.getID());
                sb.append(job.getID());
                sb.append(",");

                Timestamp ctTs = new Timestamp(job.getCreationTime().getTime());
                ps.setTimestamp(col++, ctTs, cal);
                sb.append(dateFormat.format(job.getCreationTime()));
                sb.append(",");
            }

            ps.setString(col++, job.getExecutionPhase().name());
            sb.append(job.getExecutionPhase().name());
            sb.append(",");

            if (job.getExecutionDuration() != null) {
                ps.setLong(col++, job.getExecutionDuration());
                sb.append(job.getExecutionDuration());
                sb.append(",");
            } else {
                ps.setNull(col++, Types.BIGINT);
                sb.append("null,");
            }

            if (job.getDestructionTime() != null) {
                Timestamp ts = new Timestamp(job.getDestructionTime().getTime());
                ps.setTimestamp(col++, ts, cal);
                sb.append(dateFormat.format(job.getDestructionTime()));
                sb.append(",");
            } else {
                ps.setNull(col++, Types.TIMESTAMP);
                sb.append("null,");
            }

            if (job.getQuote() != null) {
                Timestamp ts = new Timestamp(job.getQuote().getTime());
                ps.setTimestamp(col++, ts, cal);
                sb.append(dateFormat.format(job.getQuote()));
                sb.append(",");
            } else {
                ps.setNull(col++, Types.TIMESTAMP);
                sb.append("null,");
            }

            if (job.getStartTime() != null) {
                Timestamp ts = new Timestamp(job.getStartTime().getTime());
                ps.setTimestamp(col++, ts, cal);
                sb.append(dateFormat.format(job.getStartTime()));
                sb.append(",");
            } else {
                ps.setNull(col++, Types.TIMESTAMP);
                sb.append("null,");
            }

            if (job.getEndTime() != null) {
                Timestamp ts = new Timestamp(job.getEndTime().getTime());
                ps.setTimestamp(col++, ts, cal);
                sb.append(dateFormat.format(job.getEndTime()));
                sb.append(",");
            } else {
                ps.setNull(col++, Types.TIMESTAMP);
                sb.append("null,");
            }
            log.debug("error summary: " + col);
            String eMsg = null;
            String eType = null;
            String eURL = null;
            if (job.getErrorSummary() != null) {
                eMsg = job.getErrorSummary().getSummaryMessage();
                eType = job.getErrorSummary().getErrorType().name();
                if (job.getErrorSummary().getDocumentURL() != null)
                    eURL = job.getErrorSummary().getDocumentURL().toExternalForm();
            }
            col += setString(ps, col, jobSchema.jobTable, "error_summaryMessage", eMsg, sb);
            col += setString(ps, col, jobSchema.jobTable, "error_type", eType, sb);
            col += setString(ps, col, jobSchema.jobTable, "error_documentURL", eURL, sb);

            log.debug("owner: " + col);
            int ownerType = identManager.getOwnerType();
            if (job.appData != null) {
                Object ownerObject = job.appData;
                ps.setObject(col++, ownerObject, ownerType);
                sb.append(ownerObject);
                sb.append(",");
            } else // anonymous
            {
                ps.setNull(col++, ownerType);
                sb.append("null,");
            }

            log.debug("runID: " + col);
            ps.setString(col++, job.getRunID());
            sb.append(job.getRunID());
            sb.append(",");

            //ps.setString(col++, job.getRequestPath());
            //sb.append(job.getRequestPath());
            //sb.append(",");
            col += setString(ps, col, jobSchema.jobTable, "requestPath", job.getRequestPath(), sb);

            //ps.setString(col++, job.getRequesterIp());
            //sb.append(job.getRequesterIp());
            //sb.append(",");
            col += setString(ps, col, jobSchema.jobTable, "remoteIP", job.getRemoteIP(), sb);

            log.debug("jobInfo: " + col);
            String jiContent = null;
            String jiContentType = null;

            if (job.getJobInfo() != null) {
                jiContent = job.getJobInfo().getContent();
                jiContentType = job.getJobInfo().getContentType();

            }
            col += setString(ps, col, jobSchema.jobTable, "jobInfo_content", jiContent, sb);
            col += setString(ps, col, jobSchema.jobTable, "jobInfo_contentType", jiContentType, sb);
            if (job.getJobInfo() != null && job.getJobInfo().getValid() != null) {
                int jiValid = 0;
                if (job.getJobInfo().getValid())
                    jiValid = 1;
                ps.setInt(col++, jiValid);
                sb.append(Integer.toString(jiValid));
                sb.append(",");
            } else {
                ps.setNull(col++, Types.TINYINT);
                sb.append("null,");
            }

            log.debug("lastModified: " + col);
            Date now = new Date();
            Timestamp ts = new Timestamp(now.getTime());
            ps.setTimestamp(col++, ts, cal);
            sb.append(dateFormat.format(now));
            sb.append(",");

            if (update) {
                log.debug("update - jobID: " + col);
                ps.setString(col++, job.getID());
                sb.append(",");
                sb.append(job.getID());
            }

            log.debug(sb);
        }

        private String getInsertSQL() {
            StringBuilder sb = new StringBuilder();
            sb.append("INSERT INTO ");
            sb.append(jobSchema.jobTable);
            sb.append(" (");
            appendColumnNames(sb, jobSchema.jobColumns, 0);
            sb.append(") VALUES (");
            appendStatementParams(sb, jobSchema.jobColumns.size());
            sb.append(")");
            return sb.toString();
        }

        private String getUpdateSQL() {
            StringBuilder sb = new StringBuilder();
            sb.append("UPDATE ");
            sb.append(jobSchema.jobTable);

            // column-list syntax as in insert is not uniformly supported
            //sb.append(" SET (");
            //appendColumnNames(sb, jobSchema.jobColumns, 1);
            //sb.append(") = (");
            //appendStatementParams(sb, jobSchema.jobColumns.size());
            //sb.append(")");

            // don't include jobID (index 0) or creation time (index size-1)

            sb.append(" SET ");
            for (int i = 2; i < jobSchema.jobColumns.size(); i++) {
                if (i > 2)
                    sb.append(",");
                sb.append(jobSchema.jobColumns.get(i));
                sb.append(" = ?");
            }
            sb.append(" WHERE jobID = ?");
            return sb.toString();
        }
    }

    class JobListStatementCreator implements PreparedStatementCreator {
        private Object owner;
        private String appname;
        private List<ExecutionPhase> phases;
        private Date after;
        private Integer last;
        private String lastJobID;
        private Date lastCreationTime;

        public JobListStatementCreator(String lastJobID, Date lastCreationTime, Object owner, String appname,
                List<ExecutionPhase> phases, Date after, Integer last) {
            this.lastJobID = lastJobID;
            this.lastCreationTime = lastCreationTime;
            this.appname = appname;
            this.owner = owner;
            this.phases = phases;
            this.after = after;
            this.last = last;
        }

        public PreparedStatement createPreparedStatement(Connection conn) throws SQLException {
            String sql = getSQL();
            log.debug(sql);
            PreparedStatement ret = conn.prepareStatement(sql);
            int arg = 1;
            if (owner != null) {
                log.debug(arg + " : " + owner);
                ret.setObject(arg++, owner);
            }

            if (phases != null && !phases.isEmpty()) {
                for (ExecutionPhase ep : phases) {
                    log.debug(arg + " : " + ep);
                    ret.setString(arg++, ep.getValue());
                }
            } else {
                log.debug(arg + " : " + ExecutionPhase.ARCHIVED);
                ret.setString(arg++, ExecutionPhase.ARCHIVED.getValue());
            }

            if (lastJobID != null && lastCreationTime == null) {
                log.debug(arg + " : " + lastJobID);
                ret.setString(arg++, lastJobID);
            }

            if (appname != null) {
                appname += "%"; // like
                log.debug(arg + " : " + appname);
                ret.setString(arg++, appname);
            }
            if (after != null) {
                // value is set in getSQL()
            }
            return ret;
        }

        protected String getSQL() {
            int rowLimit = BATCH_SIZE;
            if (last != null && last < BATCH_SIZE)
                rowLimit = last;

            StringBuilder sb = new StringBuilder();

            sb.append("SELECT ");
            if (jobSchema.limitWithTop)
                sb.append("TOP ").append(rowLimit);

            sb.append(" jobID, executionPhase, startTime, runID, ownerID FROM ");
            sb.append(jobSchema.jobTable);
            sb.append(" WHERE deletedByUser = 0");
            if (owner != null)
                sb.append(" AND ownerID = ?");
            if (phases != null && !phases.isEmpty()) {
                sb.append(" AND executionPhase IN (");
                Iterator<ExecutionPhase> i = phases.iterator();
                while (i.hasNext()) {
                    i.next();
                    sb.append("?");
                    if (i.hasNext())
                        sb.append(",");
                }
                sb.append(")");
            } else
                sb.append(" AND executionPhase != ?");
            if (lastJobID != null && lastCreationTime == null)
                sb.append(" AND jobID > ?");
            if (lastCreationTime != null) {
                // need to set the value here in quotes
                // for precise date comparison in iso format
                String lastStartStr = isoDateFormat.format(lastCreationTime);
                log.debug("lastCreationTime: " + lastStartStr);
                sb.append(" AND creationTime < '" + lastStartStr + "'");
            }
            if (appname != null)
                sb.append(" AND requestPath LIKE ?");
            if (after != null) {
                // need to set the value here in quotes
                // for precise date comparison in iso format
                String afterStr = isoDateFormat.format(after);
                log.debug("after: " + afterStr);
                sb.append(" AND creationTime > '" + afterStr + "'");
            }

            if (after != null || last != null)
                sb.append(" AND creationTime is not null");

            if (last != null)
                sb.append(" ORDER BY creationTime DESC, jobID ASC ");
            else
                sb.append(" ORDER BY jobID ASC ");

            if (!jobSchema.limitWithTop)
                sb.append(" LIMIT " + rowLimit);

            return sb.toString();
        }
    }

    class JobSelectStatementCreator implements PreparedStatementCreator {
        private String jobID;
        private String sql;

        public JobSelectStatementCreator() {
            this.sql = getSQL();
        }

        public void setJobID(String jobID) {
            this.jobID = jobID;
        }

        public PreparedStatement createPreparedStatement(Connection conn) throws SQLException {
            PreparedStatement ret = conn.prepareStatement(sql);
            ret.setString(1, jobID);
            log.debug(sql);
            log.debug(jobID);
            return ret;
        }

        String getSQL() {
            if (sql != null)
                return sql;
            StringBuilder sb = new StringBuilder();
            sb.append("SELECT ");
            appendColumnNames(sb, jobSchema.jobColumns, 0);
            sb.append(" FROM ");
            sb.append(jobSchema.jobTable);
            sb.append(" WHERE jobID = ? AND deletedByUser = 0");
            return sb.toString();
        }
    }

    // select parameters and results from the param table
    class JobDetailSelectStatementCreator implements PreparedStatementCreator {
        private String jobID;
        private String sql;

        public JobDetailSelectStatementCreator() {
            this.sql = getSQL();
        }

        public void setJobID(String jobID) {
            this.jobID = jobID;
        }

        public PreparedStatement createPreparedStatement(Connection conn) throws SQLException {
            PreparedStatement ret = conn.prepareStatement(sql);
            ret.setString(1, jobID);
            log.debug(sql);
            log.debug(jobID);
            return ret;
        }

        String getSQL() {
            if (sql != null)
                return sql;
            StringBuilder sb = new StringBuilder();
            sb.append("SELECT ");
            appendColumnNames(sb, jobSchema.detailColumns, 0);
            sb.append(" FROM ");
            sb.append(jobSchema.detailTable);
            sb.append(" WHERE jobID = ?");
            return sb.toString();
        }
    }

    class DetailInsertStatementCreator implements PreparedStatementCreator {
        private String jobID;
        private Parameter p;
        private Result r;

        private String sql;

        public DetailInsertStatementCreator() {
            this.sql = getSQL();
        }

        public void setValues(String jobID, Parameter p) {
            this.jobID = jobID;
            this.p = p;
            this.r = null;
        }

        public void setValues(String jobID, Result r) {
            this.jobID = jobID;
            this.p = null;
            this.r = r;
        }

        public PreparedStatement createPreparedStatement(Connection conn) throws SQLException {
            log.debug(sql);
            PreparedStatement ps = conn.prepareStatement(sql);
            StringBuilder sb = new StringBuilder();
            ps.setString(1, jobID);
            sb.append(jobID);
            sb.append(",");
            if (p != null) {
                ps.setString(2, TYPE_PARAMETER);
                ps.setString(3, p.getName());
                sb.append(TYPE_PARAMETER);
                sb.append(",");
                sb.append(p.getName());
                sb.append(",");
                setString(ps, 4, jobSchema.detailTable, "value", p.getValue(), sb);
            } else if (r != null) {
                ps.setString(2, TYPE_RESULT);
                ps.setString(3, r.getName());
                sb.append(TYPE_RESULT);
                sb.append(",");
                sb.append(r.getName());
                sb.append(",");
                setString(ps, 4, jobSchema.detailTable, "value", r.getURI().toASCIIString(), sb);
            }
            log.debug(sb);
            return ps;
        }

        private String getSQL() {
            StringBuilder sb = new StringBuilder();
            sb.append("INSERT INTO ");
            sb.append(jobSchema.detailTable);
            sb.append(" (");
            appendColumnNames(sb, jobSchema.detailColumns, 0);
            sb.append(") VALUES (");
            appendStatementParams(sb, jobSchema.detailColumns.size());
            sb.append(")");

            return sb.toString();
        }
    }

    class DetailDeleteStatementCreator implements PreparedStatementCreator {
        private String jobID;
        private String sql;

        public DetailDeleteStatementCreator() {
            this.sql = getSQL();
        }

        public void setJobID(String jobID) {
            this.jobID = jobID;
        }

        public PreparedStatement createPreparedStatement(Connection conn) throws SQLException {
            log.debug(sql);
            log.debug("values: " + jobID);
            PreparedStatement ps = conn.prepareStatement(sql);
            ps.setString(1, jobID);
            return ps;
        }

        private String getSQL() {
            StringBuilder sb = new StringBuilder();
            sb.append("DELETE FROM ");
            sb.append(jobSchema.detailTable);
            sb.append(" WHERE jobID = ?");
            return sb.toString();
        }
    }

    class JobPhaseSelectStatementCreator implements PreparedStatementCreator {
        private String jobID;
        private String sql;

        public JobPhaseSelectStatementCreator() {
            this.sql = "SELECT executionPhase FROM " + jobSchema.jobTable + " WHERE jobID = ?";
        }

        public void setJobID(String jobID) {
            this.jobID = jobID;
        }

        public PreparedStatement createPreparedStatement(Connection conn) throws SQLException {
            log.debug(sql);
            log.debug("values: " + jobID);
            PreparedStatement ps = conn.prepareStatement(sql);
            ps.setString(1, jobID);
            return ps;
        }

    }

    class JobPhaseUpdateStatementCreator implements PreparedStatementCreator {
        private String jobID;
        private ExecutionPhase start;
        private ExecutionPhase end;
        private ErrorSummary error;
        private Date date;

        public JobPhaseUpdateStatementCreator() {
        }

        public void setValues(String jobID, ExecutionPhase start, ExecutionPhase end, ErrorSummary error,
                Date date) {
            this.jobID = jobID;
            this.start = start;
            this.end = end;
            this.error = error;
            this.date = date;
        }

        public PreparedStatement createPreparedStatement(Connection conn) throws SQLException {
            StringBuilder sb = new StringBuilder();
            String sql = getUpdateSQL();
            log.debug(sql);
            PreparedStatement ps = conn.prepareStatement(sql);

            int col = 1;

            ps.setString(col++, end.name());
            sb.append(end.name());
            sb.append(",");

            log.debug("error summary: " + col);
            String eMsg = null;
            String eType = null;
            String eURL = null;
            if (error != null) {
                eMsg = error.getSummaryMessage();
                eType = error.getErrorType().name();
                if (error.getDocumentURL() != null)
                    eURL = error.getDocumentURL().toExternalForm();
                col += setString(ps, col, jobSchema.jobTable, "error_summaryMessage", eMsg, sb);
                col += setString(ps, col, jobSchema.jobTable, "error_type", eType, sb);
                col += setString(ps, col, jobSchema.jobTable, "error_documentURL", eURL, sb);
            }
            if (date != null) {
                Timestamp ts = new Timestamp(date.getTime());
                ps.setTimestamp(col++, ts, cal);
                sb.append(dateFormat.format(date));
                sb.append(",");
            }

            Date now = new Date();
            Timestamp nowts = new Timestamp(now.getTime());
            ps.setTimestamp(col++, nowts, cal);
            sb.append(dateFormat.format(now));
            sb.append(",");

            ps.setString(col++, jobID);
            sb.append(jobID);
            sb.append(",");
            if (start != null) {
                ps.setString(col++, start.name());
                sb.append(start.name());
            }

            log.debug(sb.toString());
            return ps;
        }

        private String getUpdateSQL() {
            StringBuilder sb = new StringBuilder();
            sb.append("UPDATE ");
            sb.append(jobSchema.jobTable);
            sb.append(" SET executionPhase = ?");
            if (error != null) {
                sb.append(", error_summaryMessage = ?");
                String alt = jobSchema.getAlternateColumn(jobSchema.jobTable, "error_summaryMessage");
                if (alt != null) {
                    sb.append(",");
                    sb.append(alt);
                    sb.append("= ?");
                }
                sb.append(", error_type = ?");
                alt = jobSchema.getAlternateColumn(jobSchema.jobTable, "error_type");
                if (alt != null) {
                    sb.append(",");
                    sb.append(alt);
                    sb.append("= ?");
                }
                sb.append(", error_documentURL = ?");
                alt = jobSchema.getAlternateColumn(jobSchema.jobTable, "error_documentURL");
                if (alt != null) {
                    sb.append(",");
                    sb.append(alt);
                    sb.append("= ?");
                }
            }
            if (date != null) {
                if (ExecutionPhase.EXECUTING.equals(end))
                    sb.append(", startTime = ?");
                else if (JobPersistenceUtil.isFinalPhase(end))
                    sb.append(", endTime = ?");
                else
                    date = null; // ignore
            }
            sb.append(", lastModified = ?");
            sb.append(" WHERE jobID = ?");
            if (start != null)
                sb.append(" AND executionPhase = ?");
            return sb.toString();
        }
    }

    /**
     * Get the string value from the specified column. The default implementation
     * simply calls rs.getString(columnName) which should work in most cases.
     * Applications could override this method to check additional alternate
     * columns, for example if the value of some strings might be stored in a
     * TEXT type column instead of a VARCHAR column.
     *
     * @param rs
     * @param tableName
     * @param columnName
     * @return
     * @throws SQLException
     */
    protected String getString(ResultSet rs, String tableName, String columnName) throws SQLException {
        String value = rs.getString(columnName);
        if (value == null) {
            String extCol = jobSchema.getAlternateColumn(tableName, columnName);
            if (extCol != null)
                value = rs.getString(extCol);
        }
        return value;
    }

    // extract a single job job from the result set
    private class JobExtractor implements ResultSetExtractor {
        private JobSchema js;

        public JobExtractor(JobSchema js) {
            this.js = js;
        }

        public Object extractData(ResultSet rs) throws SQLException, DataAccessException {
            if (rs.next()) {
                // jobID
                String jobID = rs.getString("jobID");

                // executionPhase
                ExecutionPhase executionPhase = ExecutionPhase
                        .valueOf(rs.getString("executionPhase").toUpperCase());

                // executionDuration
                long executionDuration = rs.getLong("executionDuration");

                // destructionTime
                Date destructionTime = rs.getTimestamp("destructionTime", Calendar.getInstance(DateUtil.UTC));

                // quote
                Date quote = rs.getTimestamp("quote", Calendar.getInstance(DateUtil.UTC));

                // startTime
                Date startTime = rs.getTimestamp("startTime", Calendar.getInstance(DateUtil.UTC));

                // endTime
                Date endTime = rs.getTimestamp("endTime", Calendar.getInstance(DateUtil.UTC));

                // creationTime
                Date creationTime = rs.getTimestamp("creationTime", Calendar.getInstance(DateUtil.UTC));

                // errorSummary
                ErrorSummary errorSummary = null;
                String eMsg = getString(rs, jobSchema.jobTable, "error_summaryMessage");
                String et = getString(rs, jobSchema.jobTable, "error_type");
                ErrorType eType = null;
                if (et != null)
                    eType = ErrorType.valueOf(et);
                if (eMsg != null) {
                    URL errorUrl = null;
                    try {
                        String surl = getString(rs, jobSchema.jobTable, "error_documentURL");
                        errorUrl = new URL(surl);
                    } catch (MalformedURLException e) {
                        errorUrl = null;
                    }
                    errorSummary = new ErrorSummary(eMsg, eType, errorUrl);
                }

                // owner
                Object appData = rs.getObject("ownerID");

                // runID
                String runID = getString(rs, jobSchema.jobTable, "runID");

                // request path
                String requestPath = getString(rs, jobSchema.jobTable, "requestPath");

                // remoteIP
                String remoteIP = getString(rs, jobSchema.jobTable, "remoteIP");

                // JobInfo content
                String content = getString(rs, jobSchema.jobTable, "jobInfo_content");

                // JobInfo ContentType
                String contentType = getString(rs, jobSchema.jobTable, "jobInfo_contentType");

                // JobInfo valid
                Boolean valid = null;
                int i = rs.getInt("jobInfo_valid");
                if (!rs.wasNull())
                    valid = i == 0 ? false : true;

                // JobInfo
                JobInfo jobInfo;
                if (content == null && contentType == null)
                    jobInfo = null;
                else
                    jobInfo = new JobInfo(content, contentType, valid);

                Date lastModified = rs.getTimestamp("lastModified", cal);

                Job job = new Job(executionPhase, executionDuration, destructionTime, quote, startTime, endTime,
                        creationTime, errorSummary, null, runID, requestPath, remoteIP, jobInfo, null, null);
                JobPersistenceUtil.assignID(job, jobID);
                assignLastModified(job, lastModified);
                job.appData = appData;
                return job;
            }
            return null;
        }

        private void assignLastModified(Job job, Date lastModified) {
            try {
                Field f = job.getClass().getDeclaredField("lastModified");
                f.setAccessible(true);
                f.set(job, lastModified);
            } catch (NoSuchFieldException fex) {
                throw new RuntimeException("BUG", fex);
            } catch (IllegalAccessException bug) {
                throw new RuntimeException("BUG", bug);
            }
        }
    }

    private class JobListIterator implements Iterator<JobRef> {
        private JdbcTemplate jdbcTemplate;
        private Iterator<JobRef> jobRefIterator;
        private String lastJobID = null;
        private Date lastCreationTime = null;
        private Object owner;
        private String appname;
        private List<ExecutionPhase> phases;
        private Date after;
        private Integer last;
        private long count = 0;

        JobListIterator(JdbcTemplate jdbc, Object owner, String appname, List<ExecutionPhase> phases, Date after,
                Integer last) {
            this.jdbcTemplate = jdbc;
            this.owner = owner;
            this.appname = appname;
            this.phases = phases;
            this.after = after;
            this.last = last;
            this.jobRefIterator = getNextBatchIterator();
        }

        @Override
        public boolean hasNext() {
            if (last != null && count >= last)
                return false;

            if (!jobRefIterator.hasNext())
                this.jobRefIterator = getNextBatchIterator();

            return this.jobRefIterator.hasNext();
        }

        @Override
        public JobRef next() {
            JobRef next = this.jobRefIterator.next();
            count++;
            return next;
        }

        @SuppressWarnings("unchecked")
        private Iterator<JobRef> getNextBatchIterator() {
            JobListStatementCreator sc = new JobListStatementCreator(lastJobID, lastCreationTime, owner, appname,
                    phases, after, last);
            List<JobRef> jobs = this.jdbcTemplate.query(sc, new RowMapper() {
                // mapRow is required to preserve the order of the ResultSet
                public Object mapRow(ResultSet rs, int rowNum) throws SQLException {
                    ExecutionPhase executionPhase = ExecutionPhase
                            .valueOf(rs.getString("executionPhase").toUpperCase());
                    Date startTime = rs.getTimestamp("startTime", Calendar.getInstance(DateUtil.UTC));
                    String runID = rs.getString("runID");
                    String ownerID = rs.getString("ownerID");
                    return new JobRef(rs.getString("jobID"), executionPhase, startTime, runID, ownerID);
                }
            });

            if (!jobs.isEmpty()) {
                JobRef lastEntry = jobs.get(jobs.size() - 1);
                this.lastJobID = lastEntry.getJobID();
                if (last != null)
                    this.lastCreationTime = lastEntry.getCreationTime();
            }

            // for startTime ordered queries, account for equal startDates
            // by throwing away entries until a jobID greater than the last
            // of the last batch is reached
            if (lastCreationTime != null && jobs.size() > 0) {
                int startIndex = 0;
                while (jobs.size() > startIndex && jobs.get(startIndex).equals(lastCreationTime)
                        && jobs.get(startIndex).getJobID().compareTo(lastJobID) <= 0) {
                    startIndex++;
                }

                if (jobs.size() <= startIndex) {
                    // ran out of rows
                    throw new IllegalStateException("loop detected");
                }

                if (startIndex > 0) {
                    // disregard the initial duplicates
                    log.debug("throwing away " + startIndex + " duplicate(s) in batch");
                    jobs = jobs.subList(startIndex, jobs.size() - 1);
                }
            }

            // for startTime ordered queries, ensure the first and
            // last record don't have the same startTime--this would
            // cause an infinite loop because the same query results
            // would be returned over and over.
            if (lastCreationTime != null && jobs.size() > 1 && (count + jobs.size() < last)) {
                JobRef firstEntry = jobs.get(0);
                JobRef lastEntry = jobs.get(jobs.size() - 1);
                if (firstEntry.getCreationTime().equals(lastEntry.getCreationTime()))
                    throw new IllegalStateException("loop detected");
            }

            return jobs.iterator();
        }

        @Override
        public void remove() {
            throw new UnsupportedOperationException();
        }
    }

    // extract a single phase value from the result set
    private class PhaseExtractor implements ResultSetExtractor {
        public Object extractData(ResultSet rs) throws SQLException, DataAccessException {
            if (rs.next())
                return ExecutionPhase.valueOf(getString(rs, jobSchema.jobTable, "executionPhase"));
            return null;
        }

    }

    // map rows from the JobDetailSelectStatementCreator into Parameter or Result
    // and store them in the specified job
    private class DetailExtractor implements ResultSetExtractor {
        private JobSchema js;
        private Job job;

        public DetailExtractor(JobSchema js, Job job) {
            this.js = js;
            this.job = job;
            if (job.getParameterList() == null)
                job.setParameterList(new ArrayList<Parameter>());
            if (job.getResultsList() == null)
                job.setResultsList(new ArrayList<Result>());
        }

        public Object extractData(ResultSet rs) throws SQLException, DataAccessException {
            while (rs.next())
                mapAndStoreRow(rs);
            return job;
        }

        public void mapAndStoreRow(ResultSet rs) throws SQLException {
            String type = rs.getString("type");
            String name = rs.getString("name");
            String value = getString(rs, jobSchema.detailTable, "value");

            if (TYPE_PARAMETER.equals(type)) {
                job.getParameterList().add(new Parameter(name, value));
            } else if (TYPE_RESULT.equals(type)) {
                try {
                    URI uri = new URI(value);
                    job.getResultsList().add(new Result(name, uri));
                } catch (URISyntaxException ex) {
                    throw new IllegalStateException("failed to convert " + value + " to a URI");
                }
            } else
                throw new IllegalStateException("unexpected type in param table: " + type);
        }
    }

}