CARLOS rastrillo docs

๐Ÿค– 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.