Skip to content

Repository files navigation

automata

CI Hex.pm Downloads Hex Docs License

Cron, RRULE, retries, filesystem events, and finite automata for Gleam. Pure data: every module produces the same answers on the BEAM and on the JavaScript target.

Features

  • automata — deterministic finite automaton helper.
  • automata/cron — UNIX 5-field cron with separate parse, validate, normalise, match, iterate, and next phases.
  • automata/rrule — RFC 5545 RRULE subset (FREQ, INTERVAL, COUNT, UNTIL, BYDAY, BYMONTH, BYMONTHDAY, BYHOUR, BYMINUTE) anchored on a ValidDateTime.
  • automata/ical — RFC 5545 iCalendar parser and emitter for the VCALENDAR envelope around automata/rrule, with line folding, parameter quoting, and TEXT escaping.
  • automata/schedule — one matcher / iterator / next-after API across compiled cron, RRULE, fixed-interval, and one-shot schedules.
  • automata/event — typed Event(body) values with source kinds, correlation / causation / trace metadata, and filter / match combinators.
  • automata/fsevent — fsnotify-style Create / Write / Remove / Rename / Chmod ops derived from two filesystem snapshots, with file_id-based rename detection.
  • automata/retry — deterministic retry policies (fixed, exponential, capped exponential) with optional jitter and a step-by-step Decision interface.

Install

gleam add automata

Automaton

The vanilla automata module is a deterministic finite automaton helper: a current state, a pure transition function, and an acceptance predicate. Construct one with automata.new, advance it with step / run, and ask accepts whether the input is in the language.

import automata
import gleam/io

pub type Door {
  Closed
  Open
  Locked
}

pub type Action {
  OpenIt
  CloseIt
  LockIt
  UnlockIt
}

fn transition(state: Door, action: Action) -> Door {
  case state, action {
    Closed, OpenIt -> Open
    Open, CloseIt -> Closed
    Closed, LockIt -> Locked
    Locked, UnlockIt -> Closed
    other, _ -> other
  }
}

pub fn main() {
  let dfa =
    automata.new(
      initial_state: Closed,
      transition: transition,
      accepting: fn(state) { state == Open },
    )

  automata.run(dfa, [OpenIt, CloseIt, LockIt]) |> io.debug
  // -> Locked

  automata.accepts(dfa, [OpenIt]) |> io.debug
  // -> True
}

Automaton(state, symbol) is generic over both the state and symbol types, so the transition function can pattern-match on whatever your domain actually looks like (type Door, type LifecycleStage, Int, String, etc.). Unlike cron.builder / rrule.builder, the FSM helper has no builder layer — automata.new is the single entry point, and the transition function does the work.

Cron

import automata/cron
import automata/schedule/ast as schedule_ast
import gleam/io

pub fn main() {
  let assert Ok(raw) = cron.parse("*/15 9-17 * * MON-FRI")
  let assert Ok(spec) = cron.validate(raw)
  let assert Ok(after) =
    schedule_ast.try_valid_datetime(
      year: 2026,
      month: 5,
      day: 11,
      hour: 9,
      minute: 7,
      second: 0,
    )

  cron.next_after(spec, after: after) |> io.debug
}

Reuse a normalised plan when you evaluate the same spec many times:

let plan = cron.normalize(spec)
cron.matches_plan(plan: plan, at: now)
cron.next_after_plan(plan: plan, after: now)

Builder form, without importing the validator submodule:

import automata/cron

pub fn business_hours() {
  cron.builder()
  |> cron.with_minute(cron.every(15))
  |> cron.with_hour(cron.between(from: 9, to: 17))
  |> cron.with_day_of_week(cron.one_of([cron.item_range(from: 1, to: 5)]))
  |> cron.build
}

RRULE

import automata/rrule
import automata/rrule/validator as rule_validator
import automata/schedule/ast as schedule_ast

pub fn every_other_week() {
  let assert Ok(anchor) =
    schedule_ast.try_valid_datetime(
      year: 2026,
      month: 1,
      day: 5,
      hour: 9,
      minute: 30,
      second: 0,
    )

  let assert Ok(spec) =
    rrule.builder(rule_validator.Weekly)
    |> rrule.with_interval(2)
    |> rrule.with_by_day([
      rrule.weekday(day: schedule_ast.Monday),
      rrule.weekday(day: schedule_ast.Wednesday),
    ])
    |> rrule.with_by_hour([9])
    |> rrule.with_by_minute([30])
    |> rrule.build

  rrule.next_after(spec, anchor: anchor, after: anchor)
}

Anchor seconds are preserved end-to-end. BYSECOND, BYYEARDAY, BYWEEKNO, BYSETPOS, WKST, DTSTART, RDATE, and EXDATE are not in the supported subset. rrule.normalize/2 returns an RRulePlan for use with rrule.matches_plan / iterator_after_plan / next_after_plan when you reuse the same spec/anchor pair.

iCalendar

automata/ical is an RFC 5545 parser and emitter built on top of automata/rrule. It handles the surrounding VCALENDAR envelope that RRULE almost always ships inside, with line folding, parameter quoting, and TEXT escaping handled per §3.1, §3.2, and §3.3.11.

import automata/ical
import gleam/io
import gleam/list
import gleam/option

pub fn main() {
  let feed =
    "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//example//EN\r\n"
    <> "BEGIN:VEVENT\r\nUID:abc@example.com\r\n"
    <> "DTSTAMP:20260511T000000Z\r\nDTSTART:20260511T130000\r\n"
    <> "SUMMARY:Coffee\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"

  let assert Ok(cal) = ical.parse(feed)

  ical.events(cal)
  |> list.map(ical.event_summary)
  |> list.map(option.unwrap(_, ""))
  |> io.debug
  // -> ["Coffee"]
}

event_rrule(event) returns Option(RawRRule) straight from automata/rrule, so recurrence rules compose without re-parsing:

case ical.event_rrule(event) {
  option.Some(raw) -> rrule.validate(raw: raw)
  option.None -> Error(Nil)
}

ical.parse |> ical.encode |> ical.parse is stable: parsing an emitted calendar yields the same property set. Output folds at 75 UTF-8 octets and uses CRLF line terminators per RFC 5545.

Schedule

import automata/cron
import automata/schedule

pub fn shared_api(now, after) {
  let assert Ok(raw) = cron.parse("*/30 9-17 * * 1-5")
  let assert Ok(spec) = cron.validate(raw)
  let assert Ok(compiled) = schedule.from_cron(spec)

  schedule.matches(compiled, at: now)
  schedule.next_after(compiled, after: after)
}

schedule.from_every(interval_seconds:, anchor:) builds a fixed-interval schedule; schedule.from_once(at:) fires exactly once. All four constructors return Result(Schedule, ScheduleError) so they share one shape, even though from_cron and from_once cannot fail today (let assert Ok(_) = ... is the canonical idiom for those two). Beyond construction, the four schedules share the same matcher / iterator / next-after API.

Events

import automata/event/builtin/body
import automata/event/builtin/filter as builtin_filter
import automata/event/filter
import automata/fsevent/ast as fs_ast
import automata/schedule/ast as schedule_ast

pub fn cron_event(now) {
  // Smart constructor: in the common case `source_id == plan_id` and
  // `occurred_at == fired_at`, so `scheduled_event` derives both from a
  // single (id, plan_id, at) tuple. Reach for `body.new/4` only when
  // those values genuinely differ.
  body.scheduled_event(
    id: "evt-001",
    plan_id: "daily-report",
    at: now,
    schedule_kind: body.CronSchedule,
  )
}

pub fn watch_logs() {
  filter.all_of([
    builtin_filter.is_file_with_op(op: fs_ast.Write),
    builtin_filter.by_path_prefix(prefix: "/var/log/"),
    filter.negate(builtin_filter.by_path_suffix(suffix: ".tmp")),
  ])
}

body.scheduled_event/4 is a thin wrapper over body.new/4 for scheduled events, where source_id and plan_id are typically the same string and occurred_at and fired_at are typically the same instant. Use body.new/4 directly for the rare case where the values genuinely differ (delayed dispatch recorded after the fact, fan-out under a different source_id).

body.new/4 derives the canonical SourceKind from the body, so Scheduled pairs with ScheduleSource, FileSystem(_) with FileSystemSource, and Custom("vendor.kind", _) with CustomSource("vendor.kind"). Event.occurred_at is a ValidDateTime, which rejects impossible calendar dates such as 2026-02-30. event.continue_from/5 chains a child event from a parent, inheriting correlation_id, trace_id, and the full attributes dict, and setting causation_id to the parent's id. Use event.new plus event.with_metadata(metadata.empty()) when the child should start with a fresh attribute set.

automata/event/metadata exposes both with_* setters (with_correlation_id etc.) and matching getters (correlation_id, causation_id, trace_id, attributes, attribute(_, key), has_attribute(_, key)) so callers can read and write metadata symmetrically without reaching into the Metadata record's public fields directly.

Filesystem events

automata/fsevent reproduces fsnotify-style operations as a pure function over two Snapshot values. The library does not touch the filesystem; the caller produces snapshots from a real walk, mocks, or a log replay.

import automata/fsevent
import gleam/option.{Some}

pub fn detect_change() {
  let assert Ok(p) = fsevent.normalize("/tmp/a.log")
  let assert Ok(prev) =
    fsevent.entry_file(
      path: p,
      size: 100,
      mtime: 1_700_000_000,
      mode: 0o644,
      content_hash: Some("abc"),
      file_id: Some("inode-1"),
    )
  let assert Ok(curr) =
    fsevent.entry_file(
      path: p,
      size: 200,
      mtime: 1_700_000_500,
      mode: 0o644,
      content_hash: Some("def"),
      file_id: Some("inode-1"),
    )
  let assert Ok(prev_snap) = fsevent.from_entries([prev])
  let assert Ok(curr_snap) = fsevent.from_entries([curr])

  fsevent.diff(prev: prev_snap, curr: curr_snap, watch: fsevent.watch())
}

When both snapshots carry the same file_id at different paths, a move is reported as a single Rename event with renamed_from attached. Without file_id, the move falls back to Remove plus Create. Op masks (fsevent.with_ops, fsevent.unwatch_op) drop unwanted ops before they reach the result list.

Retry

import automata/retry

pub fn http_backoff() {
  let assert Ok(initial) = retry.from_milliseconds(milliseconds: 100)
  let assert Ok(cap) = retry.from_seconds(seconds: 30)
  let assert Ok(base) =
    retry.capped_exponential(
      initial: initial,
      multiplier: 2,
      cap: cap,
      max_attempts: 6,
    )
  retry.with_jitter(policy: base, jitter: retry.FullJitter)
}

pub fn drive(policy) {
  let assert Ok(zero) = retry.from_milliseconds(milliseconds: 0)
  let ctx0 = retry.start(policy: policy, seed: 12_345)

  case retry.decide(ctx: ctx0, failure: retry.Transient) {
    retry.Retry(delay: delay, next: _next) -> delay
    retry.GiveUp(reason: _reason) -> zero
  }
}

max_attempts: N means N total attempts (one initial try plus N - 1 retries). The retry module never sleeps; the caller drives the loop and sums retry.cumulative_delay/1 to apply a wall-clock deadline. The bundled PRNG runs in pure Gleam, so the same (policy, seed, failure-sequence) triple produces the same Decision list on the BEAM and the JavaScript target.

multiplier must be >= 2 for exponential and capped_exponential (smaller values would not grow). For a constant delay with retries, reach for retry.fixed instead — the policy types are otherwise interchangeable through retry.with_jitter / retry.start.

The available jitter strategies live in automata/retry:

Constructor Spread
retry.NoJitter none — use the base delay as-is
retry.FullJitter uniform on [0, base] (AWS "full jitter")
retry.EqualJitter uniform on [base / 2, base] (AWS "equal jitter")

NoJitter is the default for every retry.fixed / retry.exponential / retry.capped_exponential policy; reach for retry.with_jitter only when you want one of the spread strategies.

Validated date-time

automata/schedule/ast exposes try_datetime/6 and try_valid_datetime/6, which return Result when components fall outside the Gregorian range. The opaque ValidDateTime then flows through cron, RRULE, schedule, and event APIs without further revalidation.

Documentation

Full API reference: https://hexdocs.pm/automata.

Development setup, code style, and the contribution flow are documented in CONTRIBUTING.md. Released changes are tracked in CHANGELOG.md.

License

MIT

About

Cron, RRULE, retries, filesystem events, and finite automata for Gleam

Topics

Resources

Contributing

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages