Heroku+Quartz Scheduler という選択肢

AppEngine、便利ですよね。
特に秀逸なのが、cron.xml !!

<cron>
  <url>/cron/honki</url>
  <description>honki</description>
  <schedule>every monday,tuesday,wednesday,thursday,friday of month 00:00</schedule>
  <timezone>Asia/Tokyo</timezone>
</cron>
<cron>
  <url>/cron/weather</url>
  <description>weather forecast</description>
  <schedule>every monday,tuesday,wednesday,thursday,friday of month 08:00</schedule>
  <timezone>Asia/Tokyo</timezone>
</cron>
<cron>
  <url>/cron/fortune</url>
  <description>fortune</description>
  <schedule>every monday,tuesday,wednesday,thursday,friday of month 08:00</schedule>
  <timezone>Asia/Tokyo</timezone>
</cron>
<cron>
  <url>/cron/xxx/login</url>
  <description>XXX login</description>
  <schedule>every 168 hours</schedule>
  <timezone>Asia/Tokyo</timezone>
</cron>
<cron>
  <url>/cron/yyy/login</url>
  <description>YYY login</description>
  <schedule>every 720 hours</schedule>
  <timezone>Asia/Tokyo</timezone>
</cron>

こんな感じで、毎営業日、日付が変わったタイミングで同僚のグループウェアに「本気出す!」というスケジュールを登録したり、毎朝天気予報や占いを通知したり(Amazon SNS Topic で一括通知、便利ですよね)、一定時間ログインしないとメールでアカウント消すぞと脅してくる○○○プリントさんやら○○急便さんやらに HttpClient で定期的に自動ログインさせたり・・・色々捗りますよね。


で、Heroku です。
昔は AppEngine は Cloud SQL がなかったため、Heroku を使うことも多かったと思います(AppEngine は色々特殊で tie-in されますしね)。
Heroku には、AppEngine の cron.xml がない代わりに、Heroku Scheduler なるものがあります。

しかし、これは /1h, /10m, /1d 毎の起動しかできず、使いすぎると無料枠を突き抜け課金されてしまいます。
というわけで、僕的には絶対に使いたく有りません(ドケチ)。

そこで・・・


QUARTZ!!!!!!


Quartz を使いましょう。Java で書かれたジョブスケジューラーです。組み込みで定義されている cron トリガーを使えば、秒単位でのきめ細やかなジョブ実行が可能です。
しかし、Heroku は1時間リクエストがないと idle 状態になってしまい、Quartz のスレッドも止まってしまいます。
なので、アプリケーションの起動時に、1時間毎に自分自身にリクエストを投げるジョブを登録してあげましょう。

これで、Heroku でも無課金できめ細やかなジョブ制御が可能です。

大ざっぱですが、以下のような感じで OK です(Jetty + PostgreSQL)。

Procfile

web:    java $JAVA_OPTS -cp target/classes:target/dependency/* test.Main

test.Main

public class Main {
    public static void main(final String[] args) throws Exception {
        final String webappDirLocation = "src/main/webapp/";

        System.setProperty("org.quartz.properties", webappDirLocation + "WEB-INF/classes/quartz.properties");

        // The port that we should run on can be set into an environment variable
        // Look for that variable and default to 8080 if it isn't there.
        String webPort = System.getenv("PORT");
        if (webPort == null || webPort.isEmpty()) {
            webPort = "8081";
        }

        final Server server = new Server(Integer.valueOf(webPort));
        final WebAppContext root = new WebAppContext();

        root.setInitParameter("org.eclipse.jetty.servlet.Default.dirAllowed", "false");
        root.setContextPath("/");
        root.setDescriptor(webappDirLocation + "/WEB-INF/web.xml");
        root.setResourceBase(webappDirLocation);
        root.setConfigurations(new Configuration[] { new WebInfConfiguration(), new WebXmlConfiguration(), new MetaInfConfiguration(), new FragmentConfiguration(), new EnvConfiguration(), new PlusConfiguration(), new JettyWebXmlConfiguration(), new TagLibConfiguration() });

        // Parent loader priority is a class loader setting that Jetty accepts.
        // By default Jetty will behave like most web containers in that it will
        // allow your application to replace non-server libraries that are part of the
        // container. Setting parent loader priority to true changes this behavior.
        // Read more here: http://wiki.eclipse.org/Jetty/Reference/Jetty_Classloading
        root.setParentLoaderPriority(true);

        server.setHandler(root);

        server.start();
        server.join();
    }
}

WEB-INF/classes/quartz.properties

org.quartz.scheduler.instanceName = HerokuScheduler
org.quartz.threadPool.threadCount = 4
org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX
org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
org.quartz.jobStore.tablePrefix = qrtz_
org.quartz.jobStore.dataSource = postgres
org.quartz.dataSource.postgres.jndiURL = java:comp/env/jdbc/postgres

QuartzSchedulerHolder

public class QuartzSchedulerHolder {
    public static final Scheduler SCHEDULER;

    static {
        try {
            SCHEDULER = StdSchedulerFactory.getDefaultScheduler();
        } catch (final SchedulerException e) {
            throw new RuntimeException(e.getLocalizedMessage(), e);
        }
    }
}

ServletContextListenerImpl

public class ServletContextListenerImpl implements ServletContextListener {
    @Override
    public void contextInitialized(final ServletContextEvent event) {
        try {
            QuartzSchedulerHolder.SCHEDULER.start();

            final String id = String.format("%018d", System.nanoTime());
            final JobDetail job = JobBuilder.newJob(KeepHerokuAlive.class).withIdentity(id, "KeepAlive").build();
            final Trigger trigger = TriggerBuilder.newTrigger().withIdentity(id, "KeepAlive").startAt(new Date(FIXME)).endAt(new Date(FIXME)).withSchedule(CronScheduleBuilder.cronSchedule("0 0/55 * * * ?").inTimeZone(TimeZone.getTimeZone("Asia/Tokyo"))).build();

            QuartzSchedulerHolder.SCHEDULER.scheduleJob(job, trigger); // 55分毎に自分自身に HTTP リクエストを送信
        } catch (final SchedulerException e) {
            throw new RuntimeException(e.getLocalizedMessage(), e);
        }
    }

    @Override
    public void contextDestroyed(final ServletContextEvent event) {
        try {
            QuartzSchedulerHolder.SCHEDULER.shutdown();
        } catch (final SchedulerException e) {
            throw new RuntimeException(e.getLocalizedMessage(), e);
        }
    }
}