Recently, in a fit of narcissism, I decided that my blog needed to be Twitter-aware; that is, I wanted all my posts to display automatically-generated lists of all Twitter tweets that reference them (via the backtweets.com API). Loading these backtweets is a bit resource-intensive, so I figured it'd be best to offload the work to a scheduled background task (e.g., a cron job). Since I figure I'll be implementing more such tasks in the future, I took the opportunity to build out a simple cron task architecture that can handle any number of such jobs with relative ease. Here's how it works.

Requirements

Now, any cron task we might envision will most likely need access to a fully-bootstrapped instance of the application; that way we'll be able to use all of the framework's usual development conveniences. However, loading the full MVC architecture is a bit of overkill, since we're going to be performing the same basic sequence of tasks every time the script is hit (see Matthew Weier-O'Phinney's recent discussion of service APIs for some other relevant concerns). So, we're going to need a new application bootstrap and entry point, one that eschews the MVC routing and dispatch process in favor of something simpler. Essentially, all we'll need is to be able to run an arbitrary collection of cron "task plugins," the list of which can be configured in plain text via any of the various Zend_Config formats (e.g., the default application.ini file).

Here, then, is the plan, broadly stated:

  1. Cron will be accessible via its own distinct entry point (say, services/cron.php).
  2. The cron entry point will bootstrap() and run() its Zend_Application instance using a custom bootstrap class.
  3. Instead of running through the usual MVC routing/dispatching cycle, the bootstrap class will configure and run a cron service class, returning its output to the user.
  4. The cron service class will run a set of arbitrary cron task plugins assigned to it in the bootstrap from the application-level config file.

The task plugin interface

Let's look at the simplest piece first: the task plugin interface. Each task plugin should have a run() method containing the code that performs its task, along with a constructor that accepts any arguments the task may require (e.g., my backtweet loader task requires a backtweets.com API key). The interface is very simple:

<?php
interface Blahg_Plugin_Cron_CronInterface
{
    public function __construct($args = null);
 
    /**
     * Run the cron task
     *
     * @return void
     * @throws Blahg_Plugin_Cron_Exception to describe any errors that should be returned to the user
     */
    public function run();
}

For example, if for some reason you wanted to update the timestamp on a particular file each cron run, you could do so like this:

<?php
class Blahg_Plugin_Cron_TouchFile implements Blahg_Plugin_Cron_CronInterface
{
    protected $_filename;
 
    public function __construct($args = null)
    {
        if (!is_array($args) || !array_key_exists('filename', $args)) {
            throw new Blahg_Plugin_Cron_Exception('The FileToucher cron task plugin is not configured correctly.');
        }
        $this->_filename = $args['filename'];
    }
 
    public function run()
    {
        $result = touch($this->_filename);
        if (!$result) {
            throw new Blahg_Plugin_Cron_Exception('The file timestamp could not be updated.');
        }
    }
}

Contrived example, I know, but it should demonstrate the usage pretty well.

The cron service

Once we've got our task plugin classes built, we'll need a service that knows how to instantiate and run them. Since we want to be able to list and configure our cron task plugins in an INI file, we'll leverage Zend_Loader_PluginLoader; that way the calling code will only need to know the short names of the task plugins ("TouchFile", "LoadBackTweets", etc.).

<?php
class Blahg_Service_Cron
{
    protected $_loader;
    protected $_actions = array();
    protected $_actionsArgs = array();
    protected $_errors = array();
 
    public function __construct(array $pluginPaths)
    {
        $this->_loader = new Zend_Loader_PluginLoader($pluginPaths);
    }
 
    /**
     * Get loader
     *
     * @return Zend_Loader_PluginLoader
     */
    public function getLoader()
    {
        return $this->_loader;
    }
 
    /**
     * Runs all registered cron actions.
     *
     * @return string any errors that may have occurred
     */
    public function run()
    {
        foreach ($this->_actions as $key => $action) {
            $class = $this->getLoader()->load($action);
            if (null !== $this->_actionsArgs[$key]) {
                $action = new $class($this->_actionsArgs[$key]);
            } else {
                $action = new $class;
            }
 
            if (!($action instanceof Blahg_Plugin_Cron_CronInterface)) {
                throw new Blahg_Service_Exception('One of the specified actions is not the right kind of class.');
            }
 
            try {
                $action->run();
            } catch (Blahg_Plugin_Cron_Exception $e) {
                $this->addError($e->getMessage());
            } catch (Exception $e) {
                if (APPLICATION_ENV == 'development') {
                    $this->addError('[DEV]: ' . $e->getMessage());
                } else {
                    $this->addError('An undefined error occurred.');
                }
            }
        }
 
        $errors = $this->getErrors();
        if (count($errors) > 0) {
            $output = '&lt;html&gt;&lt;head&gt;&lt;title&gt;Cron errors&lt;/title&gt;&lt;/head&gt;&lt;body&gt;';
            $output .= '&lt;h1&gt;Cron errors&lt;/h1&gt;';
            $output .= '&lt;ul&gt;';
            foreach ($errors as $error) {
                $output .= '&lt;li&gt;' . $error . '&lt;/li&gt;';
            }
            $output .= '&lt;/ul&gt;';
            $output .= '&lt;/body&gt;&lt;/html&gt;';
        } else {
            $output = null;
        }
 
        return $output;
    }
 
    public function addAction($action, $args = null)
    {
        $key = count($this->_actions) + 1;
        $this->_actions[$key] = $action;
        $this->_actionsArgs[$key] = $args;
        return $this;
    }
 
    public function addError($message)
    {
        $this->_errors[] = $message;
        return $this;
    }
 
    public function getErrors()
    {
        return $this->_errors;
    }
}

Most of that should be pretty straightforward if you've ever worked with Zend_Loader_PluginLoader; essentially, we allow the calling code to register tasks via the addAction() method, specifying their name and any arguments that should be passed to the constructor. Then, when the whole process is run(), we let Zend_Loader_PluginLoader find and load the class, instantiate it with the appropriate arguments, and run it.

Bootstrapping and running it all

So far so good. We have a service that's capable of running an arbitrary collection of cron tasks; now we just need something that can run the service. This will just be a simple, lightweight extension of the main bootstrap class; instead of setting up the MVC router, dispatcher and so forth, it'll just instantiate our cron service, configure it according to what's in application.ini, and run the thing.

For the configuration step, we can easily leverage one of Zend_Application's more powerful features: the resource plugin. Resource plugins allow you to define how application-level resources are to be set up and configured (for a good concise introduction to the concept, see this post from Rob Allen). In this system, we'll need to be able to configure three basic things: (1) where our task plugins are located, and (2) which task plugins to run, and (3) what arguments they should take. The class turns out to be pretty simple:

<?php
class Blahg_Plugin_Resource_Cron extends Zend_Application_Resource_ResourceAbstract
{
    public function init()
    {
        $options = $this->getOptions();
 
        if (array_key_exists('pluginPaths', $options)) {
            $cron = new Blahg_Service_Cron($options['pluginPaths']);
        } else {
            $cron = new Blahg_Service_Cron(array(
                'Blahg_Plugin_Cron' => realpath(APPLICATION_PATH . '/plugins/Cron/'),
            ));
        }
 
        if (array_key_exists('actions', $options)) {
            foreach ($options['actions'] as $name => $args) {
                $cron->addAction($name, $args);
            }
        }
 
        return $cron;
    }
}

With that resource available, you can set up your cron tasks as you like in application.ini. Here are a couple of examples:

resources.cron.actions.TouchFile.filename = "/path/to/a/file"
resources.cron.actions.LoadBackTweets.key = "XXXX"

From there, we can set up a simple custom bootstrap class whose run() method tells the cron service to do its business:

<?php
require_once APPLICATION_PATH . '/Bootstrap.php';
 
class Bootstrap_Cron extends Bootstrap
{
    public function run()
    {
        try {
            if ($this->hasPluginResource('cron')) {
                $this->bootstrap('cron');
                $server = $this->getResource('cron');
                echo $server->run();
            } else {
                echo 'The cron plugin resource needs to be configured in application.ini.' . PHP_EOL;
            }
        } catch (Exception $e) {
            echo 'An error has occured.' . PHP_EOL;
        }
    }
}

Finally, we need to set up a separate entry point that knows to use this modified bootstrap class. This can be anywhere in your public webroot; your cron daemon can then use curl to initiate the script (though you may want to configure your web server to only allow access from the machine with the cron daemon on it). Your entry point, wherever it's located, will look very similar to your main index.php; the only difference is in how you instantiate the Zend_Application object:

<?php
$application = new Zend_Application(
    APPLICATION_ENV,
    array(
        'bootstrap' => array(
            'class' => 'Bootstrap_Cron',
            'path' => APPLICATION_PATH . '/Bootstrap/Cron.php',
        ),
        'config' => APPLICATION_PATH . '/configs/application.ini',
    )
);
$application->bootstrap()
            ->run();

And that's pretty much it! Now that all that infrastructure is in place, it's easy to add new cron tasks as desired …just implement a new Blahg_Plugin_Cron_CronInterface class in your cron task plugin directory, add the appropriate configuration settings to application.ini, and the cron service will take care of the rest.

Review

After reading all this you may be saying to yourself, "gee, that seems like a lot of files." Or you may not. Either way, I thought it'd be worth listing off the names of the source files I've mentioned, and where they're located in my directory structure; most of these will be picked up by a standard ZF resource autoloader, so if your application's already configured with one of those you shouldn't run into any weird issues.

  • application/plugins/Cron/CronInterface.php
  • application/plugins/Cron/{TaskName}.php
  • application/services/Cron.php
  • application/plugins/Resource/Cron.php
  • application/Bootstrap/Cron.php
  • public/path/to/cron.php (doesn't really matter where this is as long as it's in the webroot)

To do

Now, this system isn't quite finished yet. Probably the biggest omission is a locking system to circumvent problems caused by cron run overlaps (see this recent post from Abhinav Singh for more information). Still, I think it's a very good step in the right direction.

If I get a chance I may try to turn this into a full-fledged Zend Framework proposal. In the meantime, if you'd like to use this code now, feel free to copy and paste:

All source code in this article is by Adam Jensen, and is licensed under a Creative Commons Attribution-Share Alike 3.0 United States License.

Thanks for reading!

Categories: