Scheduled Tasks (Cron)

Sometimes a document needs to do things on its own — clean up old data, send a daily digest, recalculate stats. I didn't want people setting up external cron jobs or Lambda functions just to poke a document on a schedule. So I built @cron directly into the language.

The @cron Annotation

The @cron annotation defines a named block of code that runs on a schedule:

@cron job_name daily 12:00 {
  // Code to execute on schedule
}

The components are:

  • job_name: A unique identifier for this scheduled task
  • schedule_type: One of daily, hourly, or monthly
  • schedule_value: The time or interval specification (depends on schedule type)

Daily Tasks

Daily tasks execute once per day at a specified time. Specify the time in 24-hour format as HH:MM:

procedure generateDailyReport() {}
procedure cleanupExpiredSessions() {}
procedure sendMorningReminders() {}

@cron daily_report daily 14:30 {
  // Runs every day at 2:30 PM
  generateDailyReport();
}

@cron midnight_cleanup daily 00:00 {
  // Runs at midnight each day
  cleanupExpiredSessions();
}

@cron morning_notification daily 08:00 {
  // Runs at 8:00 AM
  sendMorningReminders();
}

Dynamic Daily Schedules

You can use a time field instead of a literal to make the schedule configurable at runtime:

procedure generateReport() {}

message SetTimeMessage {
  time new_time;
}

private time report_time;

@cron dynamic_report daily report_time {
  // Runs at whatever time is stored in report_time
  generateReport();
}

channel set_report_time(SetTimeMessage msg) {
  report_time = msg.new_time;
}

This lets administrators (or the document itself) adjust when tasks run without redeploying code. I find this useful for letting users pick their own notification times.

Hourly Tasks

Hourly tasks execute once per hour at a specified minute (0-59):

procedure syncExternalData() {}
procedure hourlyMetricsSnapshot() {}
procedure checkPendingTasks() {}

@cron hourly_sync hourly 30 {
  // Runs at 30 minutes past every hour (1:30, 2:30, 3:30, etc.)
  syncExternalData();
}

@cron top_of_hour hourly 0 {
  // Runs at the top of every hour (1:00, 2:00, 3:00, etc.)
  hourlyMetricsSnapshot();
}

@cron quarter_past hourly 15 {
  // Runs at 15 minutes past every hour
  checkPendingTasks();
}

Dynamic Hourly Schedules

Use an int field for configurable hourly schedules:

procedure performSync() {}

private int sync_minute;

@cron configurable_sync hourly sync_minute {
  // Runs at the minute specified by sync_minute
  performSync();
}

Monthly Tasks

Monthly tasks execute once per month on a specified day (1-31):

procedure processMonthlyBilling() {}
procedure generateMidMonthReport() {}
procedure closeMonthlyAccounts() {}

@cron monthly_billing monthly 1 {
  // Runs on the 1st of each month
  processMonthlyBilling();
}

@cron mid_month_report monthly 15 {
  // Runs on the 15th of each month
  generateMidMonthReport();
}

@cron end_of_month monthly 28 {
  // Runs on the 28th (safe for all months including February)
  closeMonthlyAccounts();
}
Note

If you specify a day that doesn't exist in a given month (like 31 for February), the behavior depends on the platform implementation. Stick to day 28 or earlier if you want it to run every month reliably.

Dynamic Monthly Schedules

Use an int field for configurable monthly schedules:

procedure processBilling() {}

private int billing_day;

@cron configurable_billing monthly billing_day {
  // Runs on the day specified by billing_day
  processBilling();
}

Multiple Cron Jobs

A document can have as many cron jobs as you need, each with a unique name:

procedure performDailyMaintenance() {}
procedure sendDailySummary() {}
procedure checkSystemHealth() {}
procedure archiveOldRecords() {}

int daily_count;
int hourly_count;
int monthly_count;

@cron morning_task daily 8:00 {
  daily_count++;
  performDailyMaintenance();
}

@cron afternoon_task daily 17:00 {
  daily_count++;
  sendDailySummary();
}

@cron hourly_check hourly 30 {
  hourly_count++;
  checkSystemHealth();
}

@cron monthly_archive monthly 1 {
  monthly_count++;
  archiveOldRecords();
}

Time Zone Considerations

Important

Cron schedules operate in UTC. If your users are in different time zones, you need to account for that when setting schedule times. I know — time zones are awful. But UTC is the least-bad option for a shared system.

Handling Time Zones

For applications that need local time scheduling, here are a couple of approaches:

Store the offset and calculate:

procedure sendLocalizedNotification() {}

private int timezone_offset_hours;  // e.g., -5 for EST

@cron daily_notification daily 13:00 {
  // This runs at 1 PM UTC
  // For EST users (UTC-5), this is 8 AM local time
  sendLocalizedNotification();
}

Use dynamic scheduling based on user timezone:

procedure sendNotification() {}

private time notification_time;

procedure setNotificationForLocalTime(int local_hour, int timezone_offset) {
  // Convert local time to UTC
  // notification_time = calculated UTC time
}

@cron user_notification daily notification_time {
  sendNotification();
}

Best Practices for Time Zones

  1. Document your assumptions: Comment whether times are UTC or local
  2. Use UTC for shared resources: When multiple users share a document, UTC is the safest choice
  3. Store timezone preferences: Keep user timezone preferences in the document state
  4. Consider DST: Daylight Saving Time will bite you if you forget about it

Idempotency

Cron jobs should be idempotent — running the same job multiple times should produce the same result as running it once. This matters because network issues might cause retries, document recovery might replay cron executions, and you might need to manually trigger a job for testing.

Making Jobs Idempotent

Track when the job last ran:

procedure performCleanup() {}

private datetime last_cleanup;

@cron daily_cleanup daily 02:00 {
  datetime now = Time.datetime();

  // Only run if we have not run today
  if (last_cleanup.date() != now.date()) {
    performCleanup();
    last_cleanup = now;
  }
}

Use upsert patterns instead of insert:

record DailyStat {
  public int id;
  public date stat_date;
  public int count;
}

table<DailyStat> _stats;

function calculateDailyCount() -> int {
  return 0;
}

@cron daily_stats daily 00:00 {
  // Instead of inserting a new record each time
  // Update or create based on a unique key
  let today = Time.today();
  list<DailyStat> existing = iterate _stats where stat_date == today;

  if (existing.size() == 0) {
    _stats <- {stat_date: today, count: calculateDailyCount()};
  } else {
    existing.count = calculateDailyCount();
  }
}

Use transactions that can be safely repeated:

record Invoice {
  public int id;
  public bool billed;
  public double amount;
}

table<Invoice> _invoices;

procedure processInvoice(Invoice inv) {}

@cron monthly_billing monthly 1 {
  // Process only unbilled items
  list<Invoice> unbilled = iterate _invoices where billed == false;

  foreach (invoice in unbilled) {
    processInvoice(invoice);
    invoice.billed = true;  // Mark as processed
  }
}

Common Patterns

Cleanup and Data Retention

Remove old data to keep document size manageable:

record LogEntry {
  public int id;
  public date created_at;
  public string message;
}

table<LogEntry> _logs;

@cron cleanup_old_logs daily 03:00 {
  date cutoff = Time.today().offsetDay(-30);

  // Delete logs older than 30 days
  (iterate _logs where created_at < cutoff).delete();
}

Aggregation and Statistics

Calculate summary statistics periodically:

record Event {
  public int id;
  public datetime timestamp;
  public double value;
}

table<Event> _events;

record HourlyStat {
  public int id;
  public datetime hour;
  public int event_count;
  public double avg_value;
}

table<HourlyStat> _hourly_stats;

function calculateAverage(list<Event> events) -> double {
  return 0.0;
}

@cron hourly_aggregation hourly 0 {
  // Calculate stats for the previous hour
  datetime now = Time.datetime();
  list<Event> events = iterate _events;

  int count = events.size();
  double avg = (count > 0) ? calculateAverage(events) : 0.0;

  _hourly_stats <- {
    hour: now,
    event_count: count,
    avg_value: avg
  };
}

Notifications and Reminders

Send scheduled notifications:

procedure sendNotification(principal user, string message) {}

record Reminder {
  public int id;
  public principal user;
  public string message;
  public datetime remind_at;
  public bool sent;
}

table<Reminder> _reminders;

@cron check_reminders hourly 0 {
  datetime now = Time.datetime();

  // Find due reminders that have not been sent
  list<Reminder> due = iterate _reminders
    where remind_at <= now && sent == false;

  foreach (reminder in due) {
    sendNotification(reminder.user, reminder.message);
    reminder.sent = true;
  }
}

Expiration and Timeout Handling

Handle time-based expirations:

record Session {
  public int id;
  public principal user;
  public date last_activity;
  public bool active;
}

table<Session> _sessions;

@cron expire_sessions hourly 15 {
  date cutoff = Time.today().offsetDay(-1);

  // Expire sessions inactive for more than 24 hours
  (iterate _sessions where active == true && last_activity < cutoff).active = false;
}

State Machine Integration

Trigger state machine transitions on schedule:

bool auction_active;
datetime auction_end_time;

procedure determinWinner() {}

@cron auction_check hourly 0 {
  // Check if auction should end
  if (auction_active && Time.datetime() >= auction_end_time) {
    transition #auction_closing;
  }
}

#auction_closing {
  // Process auction results
  determinWinner();
  auction_active = false;
  transition #auction_complete;
}

#auction_complete {
  // Auction finished
}

Cron Job Execution Context

Within a cron job, you have full access to the document state, but with some things to keep in mind:

  • No @who: Cron jobs don't have a connected user, so @who is @no_one
  • Full state access: You can read and modify all document fields
  • Transitions allowed: You can trigger state machine transitions
  • Durability: Changes made in cron jobs are persisted like any other mutation
record SystemLog {
  public int id;
  public datetime timestamp;
  public principal actor;
  public string action;
  public string details;
}

table<SystemLog> _system_logs;

procedure performMaintenance() {}

@cron system_maintenance daily 04:00 {
  // Log that system maintenance ran
  _system_logs <- {
    timestamp: Time.datetime(),
    actor: @no_one,  // Cron job has no user context
    action: "system_maintenance",
    details: "Automated maintenance completed"
  };

  performMaintenance();
}

The beauty of @cron is that it keeps everything inside the document. No external schedulers, no separate infrastructure, no coordination problems. The document just does its own housekeeping on its own schedule.

Previous Services
Next Testing