๐ค carlos
amadan.net/rastrillo/rastrillo/carlos
Work that has to happen at a particular time, on a platform where your process is usually asleep. The platform keeps the clock and POSTs to your app when something is due. This package is your side of that: one call to check the POST really came from the platform, and one to ask for a POST later on.
Recurring work is declared from outside the app, with the platform CLI:
carlos schedule set -name sync -every 6h -path /jobs/sync
Your app's whole part is a handler on /jobs/sync.
Tick
func Tick(r *http.Request) bool
A tick arrives on a public route, so a tick nobody authenticated is an
internet request to that path. Tick is the constant-time comparison
that tells them apart: the platform presents your instance's
$CARLOS_ADMIN_TOKEN as a bearer, and nothing else about the request
proves anything.
func handleSync(w http.ResponseWriter, r *http.Request) {
if !carlos.Tick(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if err := syncer.RunOnce(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
Do the work inside the request, as above. The platform holds the instance awake for as long as the request is open and starts the idle clock the moment you return, so returning 202 and finishing in a goroutine gets that goroutine hibernated mid-job. Your status is the whole reply: 2xx means done, 5xx asks for a retry with backoff, 4xx means don't bother.
With no token in the environment Tick is always false. An app the
platform never handed the secret to cannot tell a tick from a stranger,
and the X-Carlos-Schedule headers are not evidence โ anyone can set
those.
Tick looks at the bearer and nothing else. That is what lets the same
handler serve a "Sync now" button behind your own auth. The scheme is
matched case-insensitively, so bearer from a runbook curl works too;
the token itself is compared in constant time.
TickOccurrence
func TickOccurrence(r *http.Request) (string, bool)
Delivery is at-least-once, and you will see the same occurrence twice:
a handler that outruns the platform's timeout, a box that reboots
mid-tick, a deploy that cuts the instance out from under a long job.
TickOccurrence returns the key that stays the same across all of
those. It is the instant the delivery is for, not the moment it
arrived.
if !carlos.Tick(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if occ, ok := carlos.TickOccurrence(r); ok && alreadyDone(occ) {
w.WriteHeader(http.StatusNoContent)
return
}
Record it when the work succeeds and skip it when it comes round again. Never key on the wall clock: a retry twenty minutes later is the same occurrence, and treating it as a new one is how a job runs twice.
Keep Tick as the guard, the way the snippet does. TickOccurrence
refuses everything Tick refuses, but it also refuses a request with no
occurrence header โ and that is exactly what your own "Sync now" call
looks like. Guard on TickOccurrence alone and the manual path Tick
was written to allow gets a 403 over a header nobody has heard of. A
missing occurrence means "no dedupe key, run it".
Treat the key as opaque. It is unique within one schedule but not
across schedules, so if a single handler serves several, key on the
X-Carlos-Schedule name header alongside it.
ScheduleAt
func ScheduleAt(ctx context.Context, name string, at time.Time, path string) error
For work with no rhythm: one reminder, one expiry, one thing at 8am on
the first. Ask for it and the platform wakes your instance at at and
POSTs to path, which is the same tick your handler already guards.
err := carlos.ScheduleAt(ctx, "remind-"+id, when, "/jobs/remind")
name is your handle on the timer and matches
^[a-z0-9][a-z0-9-]{0,31}$. Registering the same name again replaces
the timer rather than adding one, so re-asserting everything you are
expecting at boot is safe. The control socket is bound before your
process starts, so that works from the first line of main. You
mostly won't need it: timers live in the box's registry, not in your
process, and a restart loses none of them.
at has to be set, and no more than MaxAhead (400 days) out. Both
bounds are checked here before anything is sent. The zero time is the
one worth knowing about: the platform would accept it, because an at
in the past is legal and fires on the next sweep, so a field nobody
filled in would reach someone as a reminder at boot rather than as an
error you can see. A deliberately past at is still fine โ pass a real
one.
The call goes to the agent over a unix socket. That is quick, but it is still I/O, so pass a context you are willing to wait on. One with no deadline of its own gets a ten-second one.
ScheduleCancel
func ScheduleCancel(ctx context.Context, name string) error
Drops a timer ScheduleAt registered. Cancelling something that
already fired, or never existed, succeeds, so a cleanup path doesn't
have to know which.
The errors
var (
ErrNotOnCarlos error
ErrUnauthorized error
ErrDeclaredSchedule error
ErrTooManyTimers error
)
All four are sentinels; compare with errors.Is.
ErrNotOnCarlos means there is no control socket in the environment,
which is what your laptop and your tests look like. Boot code that
registers timers should treat it as "skip", not as a failure.
ErrUnauthorized means no $CARLOS_ADMIN_TOKEN, or one the agent
would not take. It is what an instance sees if it was already running
when its box first learned these verbs, because the token is minted
into the environment at spawn; restarting the instance clears it.
ErrDeclaredSchedule means the name belongs to a recurring schedule
somebody declared with carlos schedule set. Both kinds share a
namespace, and an app cannot quietly overwrite or cancel work its
operator set up. Pick another name.
ErrTooManyTimers means the box is holding its limit of 1000 pending
one-shot timers for this app. Cancel finished ones, or if you are
registering the same shape of work over and over, that is a recurring
schedule wearing a disguise.
StatusError
type StatusError struct {
Status int
Name string
Body string
}
func (e *StatusError) Error() string
Anything the agent refused that has no sentinel comes back as this โ
a rejected path, a malformed body. Pull it out with errors.As when
you need to decide what to do next, because the two directions are
opposite: a 5xx is the agent's registry having a bad moment and is
worth retrying, a 4xx is a permanent complaint about this exact request
and retrying it is a loop. Body is the agent's own message, which is
usually the only place the real reason is written down.
Read this page as markdown โ exact, unstyled, and cheap for an agent to fetch.