YmerNode.Schedules (Ymer Node v0.3.0)

Copy Markdown View Source

The node's schedules: standing instructions to run one action of one accepted script, with fixed args, on a cron expression in the node's time zone, until each one's lifetime ends. They are added, listed, updated and removed through the schedules tool and the CLI, and fired by YmerNode.Schedules.Scheduler.

The node is a script runner with scheduling flexibility — not a reliable scheduler, and not a work engine. A firing starts one run through YmerNode.Scripts.run/3, the door an MCP call uses, so it meets the same acceptance check, batteries, deadline and isolation; nothing routes, chains, retries or catches up. Where a reliable scheduler would have a policy, this module has the simplest rule that never runs anything twice or without end.

flowchart TD
    Schedules[YmerNode.Schedules]

    subgraph owned
        Schedule[Schedule schema]
        Cron
        Lifetime
        LastRun
        InFlight
        Scheduler
    end

    subgraph external
        Scripts[YmerNode.Scripts]
        Runner[Scripts.Runner]
        Context[Script.Context]
        Repo[(YmerNode.Repo)]
    end

    Scheduler -->|"active/1 each minute, fire/2 per firing"| Schedules
    Schedules --> Schedule
    Schedules -->|"parse, next firing"| Cron
    Schedules -->|"where a lifetime ends"| Lifetime
    Schedules -->|"the last run's fields"| LastRun
    Schedules -->|"a firing's run in flight"| InFlight
    Schedules -->|"check/3 before storing and firing, longest/1"| Runner
    Schedules -->|"run/3 at a firing"| Scripts
    Schedules -->|"the node time zone"| Context
    Schedules --> Repo

What add and update refuse

What can be checked at the keyboard is checked there, rather than at a firing nobody watches: the name (YmerNode.Schedules.Schedule), the cron expression (YmerNode.Schedules.Cron), the lifetime (YmerNode.Schedules.Lifetime), and — through YmerNode.Scripts.Runner.check/3, which reads this boot's compile and runs no script code — a script that is unknown or not accepted, an action it does not serve, and args that action's schema rejects. update runs the same checks on what it changes. Every firing still meets the runner's own checks, because a script can change after its schedule was added: a firing of an action the script has since dropped is refused, and the refusal lands on the last run.

The args are kept on the row and shown by every list for as long as the schedule lives, and a call's args are open-world: a key the action's schema does not name is let through, not refused. So a secret never goes in them — a script that needs one declares it, and reads it at run time with YmerNode.Script.Context.secret/2.

A schedule's life

stateDiagram-v2
    [*] --> Active : add
    Active --> Active : update
    Active --> Expired : its lifetime ends
    Expired --> Active : update with a lifetime
    Active --> [*] : remove, or its script's remove
    Expired --> [*] : remove, or its script's remove

    note right of Active
        fires at each firing of its cron
        expression before its end
    end note

Two states and nothing else: no pause, no "expires soon". A lifetime given to update is read as at add, from the moment of the update, and that is how a schedule is renewed. An update without one leaves the end where it stands, so adjusting a schedule's cron expression or args never extends its life: renewing stays a deliberate act.

A firing

A firing whose minute the node was not up for is skipped — a laptop asleep at 07:00 does not run the 07:00 report when it wakes, perhaps off the network the report needs. So is a firing while the same schedule's previous run is still in flight: per schedule and never per script, so two schedules of one script, or a schedule and a manual run, never block each other. Firings due in one minute all start in that minute; a host that needs pacing gets it from its throttle. A skipped firing leaves no trace.

A firing's run holds an entry in YmerNode.Schedules.InFlight for as long as it lasts, keyed by the schedule's row rather than its name, so a schedule removed and added again under that name while the old run finishes is not skipped for it. That is how the next firing knows to skip, and how an edit of the script, refused while the run is in flight, names the schedule holding it and the second the run is over by at the latest.

Times

Every instant an answer carries — the next firing, the lifetime's end, the last run's firing — is ISO-8601 in the node's time zone, with its offset, because that is the zone a cron expression and an offset-less lifetime are read in: 0 7 * * * reads back as 07:00, and a change of clock shows as a new offset.

Summary

Functions

The schedules still inside their lifetime at at — what YmerNode.Schedules.Scheduler reads every minute. Their scripts are not loaded: a firing reads its script afresh (fire/2).

Adds a schedule, answered as list/0 shows it — its end and its next firing among the rest, which is what the caller needs next.

Fires one schedule for the minute fired_at came due: starts its run and keeps the last run on the row, answering the outcome — or answers :skipped and keeps nothing while the schedule's previous run is still in flight.

Every schedule, ordered by name: what runs, when next, until when, and how the last run went — the shape add/1 and update/2 answer too.

Removes a schedule, answering the row that went.

Changes a schedule's :cron_expression, :args or :lifetime — any of them — checking each the way add/1 does, and keeps its last run.

Functions

active(at)

The schedules still inside their lifetime at at — what YmerNode.Schedules.Scheduler reads every minute. Their scripts are not loaded: a firing reads its script afresh (fire/2).

add(attrs)

Adds a schedule, answered as list/0 shows it — its end and its next firing among the rest, which is what the caller needs next.

Takes :name, :script, :action and :cron_expression, and optionally :args (none by default) and :lifetime (the 90-day ceiling by default).

fire(schedule, fired_at)

Fires one schedule for the minute fired_at came due: starts its run and keeps the last run on the row, answering the outcome — or answers :skipped and keeps nothing while the schedule's previous run is still in flight.

Runs in the calling process for as long as the run lasts; YmerNode.Schedules.Scheduler calls it from a task of its own for each firing. The script is read again here, by the row the schedule holds, so the firing runs whatever is accepted under that name at this moment.

list()

Every schedule, ordered by name: what runs, when next, until when, and how the last run went — the shape add/1 and update/2 answer too.

Each entry carries name, script, action, args, cron_expression, state ("active" or "expired"), ends_at, next_firing — nil once expired, when the expression comes due no more before the end, when its walk gives out, or when the stored expression no longer parses under the running code, which leaves the row listed and every other row answered — and last_run: nil before the first run, else fired_at, outcome, message and took_ms.

remove(name)

Removes a schedule, answering the row that went.

update(name, changes)

Changes a schedule's :cron_expression, :args or :lifetime — any of them — checking each the way add/1 does, and keeps its last run.

A lifetime is read from now, and renews an expired schedule as readily as an active one; without one the end stays where it is. The script and the action are fixed: another one is another schedule.