YmerNode.Schedules (Ymer Node v0.5.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 — and the watches that keep references' cache entries current (§ Watches). They are added, listed, updated and removed through the schedules tool and the CLI, a watch is started and stopped through the references tool, and all of them are 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]
        Cache[References.Cache]
        Sources[References.Sources]
        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 -->|"a watch's recipe, and its refresh"| Cache
    Schedules -->|"the declarations a watch's firing reads"| Sources
    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, or watch
    Active --> Active : update, or watch again
    Active --> Expired : its lifetime ends
    Expired --> Active : update with a lifetime, or watch again
    Active --> [*] : remove, unwatch, or its script's or reference's remove
    Expired --> [*] : remove, unwatch, or its script's or reference'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.

Watches

A watch is a reference's own schedule: it keeps that reference's cache entry current, refreshing it at a cadence of 5, 15, 30 or 60 minutes for a lifetime of 1 to 12 hours. It names no script and no action. Each firing looks the reference up, derives its fetch recipe at that moment and runs the cache's conditional refresh (YmerNode.References.Cache.refresh/2), so a watch follows a changed uri, a newly accepted claimant or the web fallback without being touched; a firing that finds no script to fetch the reference is kept as refused and the watch lives on to its end.

watch/3 starts one or replaces the one a reference has — cadence and lifetime both set afresh, the last run kept — and unwatch/1 stops it. Its name is the node's, reference-<id>, and add refuses that prefix to anyone else. list shows a watch among the other schedules, marked with its reference, and remove stops it; update refuses it, because the cadence and the lifetime are bounded by the watch and a free cron expression would step outside them. The reference's delete takes its watch with it.

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 — or, for a watch, derives its recipe — 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, a watch included, answering the row that went.

Stops a reference's watch, 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.

Starts a watch on a reference, or replaces the one it has — its cadence and its lifetime both set afresh from now, its last run kept — answered as list/0 shows it. cadence_minutes is one of 5, 15, 30 or 60, and lifetime_hours one of 1 to 12; anything else is refused by name.

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 — or, for a watch, derives its recipe — 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; a watch's recipe is derived here from its reference, as a read derives it. A firing that raises is recorded as an error like any failed run.

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 and args — for a watch, watch: %{reference: id} in their place — 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, a watch included, answering the row that went.

unwatch(reference_id)

Stops a reference's watch, 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.

watch(reference, cadence_minutes, lifetime_hours)

Starts a watch on a reference, or replaces the one it has — its cadence and its lifetime both set afresh from now, its last run kept — answered as list/0 shows it. cadence_minutes is one of 5, 15, 30 or 60, and lifetime_hours one of 1 to 12; anything else is refused by name.