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 --> RepoWhat 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 noteTwo 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.
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
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.
Takes :name, :script, :action and :cron_expression, and optionally
:args (none by default) and :lifetime (the 90-day ceiling by default).
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.
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.
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.
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.
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.