Eine Cron-Datei.
Eine Maschine oder eine Flotte.

Ein geplanter Job bricht normalerweise in dem Moment, in dem du eine zweite Replica fährst: entweder feuert ihn jede Instanz, oder du schraubst ein Lock dran und hoffst. Hier ist Koordination Teil des Primitivs — dieselbe Datei läuft einmal pro Tick, egal ob du einen Container deployst oder zwanzig, und nichts darin ändert sich beim Skalieren.

nightly.cron.tsx
TypeScript
// billing/nightly.cron.tsx — one file, discovered automatically
export default defineSchedule({
  name: 'invoices.nightly',
  cron: '0 3 * * *',
  handler: (ctx) => ctx.store.query(...),
})
// Twenty replicas fire this ONCE. No lock, no designated cron box.
// $ voltro schedule run invoices.nightly     # fire it on purpose

Was das Primitiv übernimmt.

Eine Datei ist ein Schedule

Eine Cron-Definition deklariert ihren Ausdruck und ihren Handler, und das Framework entdeckt sie. Keine Registry zum Anhängen, nichts, woran man beim Boot denken muss, und kein separater Worker-Entrypoint, den man mit der App synchron halten muss, zu der er gehört.

N Replicas, eine Ausführung

Koordination ist eingebaut: die Flotte einigt sich, wer eine gegebene Ausführung übernimmt — Rausskalieren vervielfacht deine Mails, Rechnungen oder Exporte also nicht. Du schreibst kein Lock, und du betreibst keine designierte „Cron-Maschine", die zum Single Point of Failure wird.

Verpasste Ticks werden nachgeholt

Ein Deploy, ein Crash oder ein Scale-to-Zero-Fenster heißt: Ausführungen vergehen, während nichts läuft. Beim Boot läuft der Scheduler vom letzten festgehaltenen Lauf vorwärts, statt so zu tun, als hätte es die Lücke nicht gegeben — begrenzt, damit ein langer Ausfall keine Lawine auslöst.

Einen gezielt auslösen

`voltro schedule run NAME` feuert ein registriertes Schedule auf Zuruf — gegen einen laufenden Prozess oder eigenständig, markiert als manueller Trigger, damit der Eintrag festhält, wie es dazu kam. Einen Job zu testen heißt nicht mehr „auf die volle Stunde warten" oder „kurz den Ausdruck ändern".

Einen expliziten Zeitraum backfillen

Füll ein Fenster, das der Boot-Catch-up nicht erreicht: jede Ausführung zwischen zwei Zeitpunkten, sequenziell, jede gegen ihre eigene cron-abgeleitete Zeit verbucht. Über einer Schwelle verweigert es ohne Bestätigung, über einer harten Obergrenze verweigert es ganz — nie ein stiller Teil dessen, worum du gebeten hast.

Oder gib den Trigger ab

Lieber Kubernetes-CronJobs, ein Cloud-Scheduler oder ein externer Orchestrator? Dieselbe Definition kann extern getriggert werden, statt intern zu ticken — die Wahl des Schedulers ist damit eine Deployment-Entscheidung und kein Rewrite.

Der Fehler, den dieses Design entfernt.

Das Subject eines Jobs ist nicht das Subject eines Requests

Ein geplanter Job hat keinen Aufrufer — die Identität, unter der er läuft, wird also von dem Code erfunden, der ihn startet. Und wenn zwei Boot-Pfade je eine erfinden, können sie sich widersprechen, während beide korrekt aussehen. Genau das ist hier passiert: die Entwicklung lieferte einen mandanten-gefärbten Fallback, die Produktion einen mandantenlosen — derselbe Cron las in dev einen Mandanten und in Produktion alle, still. Heute reicht die Engine das Subject an den Job, statt dass jeder Pfad eines konstruiert. Ein Callback, der einen Wert BEKOMMT, kann nicht driften; einer, der ihn erfindet, schon — und tat es.

Ein nächtlicher Job auf mehr als einer Replica.

Ein Cron-Container in der Hoffnung, allein zu sein
Zusammengesetzt
# A cron container beside the app.
0 7 * * *  node dist/jobs/digest.js

# Scale the app to three replicas and this is three digests.
# The usual fixes:
#   - a lock in redis that nobody owns when the holder dies
#   - a designated leader pod, which is a single point of failure
#   - one special replica, which is the same thing with fewer words
Ein deklarierter Schedule, koordiniert
Eingebaut
// schedules/digest.schedule.ts
export default defineSchedule({
  name: 'digest.daily',
  cron: '0 7 * * *',

  // Coordinated through the database you already run: one run is
  // ONE execution, however many replicas are up. A process that
  // dies mid-run releases its claim rather than blocking the next.
  run: (ctx) => sendDigest(ctx),
})

Drei Replicas und ein naives Cron sind drei Rechnungen, drei E-Mails, drei Abbuchungen. Die üblichen Abhilfen sind ein Lock, dem niemand gehört, oder ein designierter Leader-Pod, der zum Single Point of Failure wird. Ein deklarierter Schedule koordiniert sich über die Datenbank, die du ohnehin hast — ein Lauf heißt eine Ausführung, wie viele Prozesse auch laufen.

Geplante Jobs im Detail.

Warum brechen geplante Jobs beim Rausskalieren?

Weil eine Cron-Bibliothek in jedem Prozess tickt. Fahr zwei Container, und der nächtliche Rechnungsjob läuft zweimal; fahr zwanzig, und du hast zwanzig Mails an denselben Kunden geschickt. Die üblichen Antworten sind ein verteiltes Lock, das jemand pflegt, oder eine designierte Cron-Maschine, die zum Single Point of Failure wird.

Hier ist Koordination Teil des Primitivs. Die Flotte einigt sich, wer eine gegebene Ausführung übernimmt — dieselbe Datei feuert also einmal pro Tick, egal ob du einen Container deployst oder zwanzig, und am Job ändert sich beim Skalieren nichts.

Das nimmt die Entscheidung weg, die die meisten Teams an dem Tag falsch treffen, an dem sie eine zweite Replica hinzufügen — denn in diesem Moment sieht der Job in der Entwicklung gut aus und hat in Produktion bereits doppelt gefeuert.

Was passiert mit verpassten Ausführungen?

Sie werden nachgeholt statt weggedacht. Ein Deploy, ein Absturz oder ein Scale-to-Zero-Fenster heißt: Ticks vergehen, ohne dass etwas läuft; beim Boot läuft der Scheduler vom letzten festgehaltenen Lauf vorwärts und feuert das Verpasste — begrenzt, damit ein langer Ausfall keine Lawine auslöst.

Die Begrenzung zählt genauso wie das Nachholen. Vierhundert verpasste Ausführungen auf einmal zu feuern macht aus einem Ausfall einen zweiten — das Framework deckelt die Arbeit also, statt „irgendwann konsistent“ als Erlaubnis zum Fluten zu lesen.

Für ein Fenster, das der Catch-up nicht erreicht, feuert `voltro schedule backfill` jede Ausführung zwischen zwei Zeitpunkten, sequenziell, jede gegen ihre eigene cron-abgeleitete Zeit. Über einer Schwelle verweigert es ohne Bestätigung, über einer harten Obergrenze ganz — nie ein stiller Teil dessen, worum du gebeten hast.

Wie teste oder triggere ich einen Job auf Zuruf?

`voltro schedule run` feuert ein registriertes Schedule sofort, gegen einen laufenden Prozess oder eigenständig, markiert als manueller Trigger, damit der Eintrag festhält, wie es dazu kam. Einen Job zu testen heißt nicht mehr „bis drei Uhr nachts warten“ oder „kurz den Ausdruck ändern und hoffen, dass man ihn zurückstellt“.

Läufe werden wie jede andere Arbeit festgehalten: Timing, Ergebnis und Fehler sind über dieselbe Inspect-Oberfläche sichtbar wie Traces und Workflows — „lief der Job letzte Nacht?“ ist also eine Abfrage und keine Logsuche.

Muss die Arbeit einen Absturz auf halbem Weg überleben statt nur pünktlich zu starten, startet der Job einen durablen Workflow. Scheduling beantwortet wann; Durability beantwortet, was passiert, wenn der Prozess bei Schritt drei stirbt.

Unter welcher Identität läuft ein Job?

Unter einer, die die Engine ihm reicht, nicht unter einer, die der aufrufende Code erfindet. Das klingt pedantisch, bis man den Fehler sieht: ein geplanter Job hat keinen Aufrufer, das Subject wird also von dem Pfad konstruiert, der ihn gestartet hat — und wenn zwei Boot-Pfade je eines konstruieren, können sie sich widersprechen, während beide korrekt aussehen.

Dieses Framework hat genau das ausgeliefert. Die Entwicklung lieferte einen mandanten-gefärbten Fallback, die Produktion ein mandantenloses Subject — und weil ein mandantenloses Subject als „System, ungescoped“ gelesen wird, las derselbe Cron lokal einen Mandanten und in Produktion alle. Still, ohne dass an einer der beiden Stellen etwas hätte auffallen können.

Der Fix verallgemeinert sich: brauchen beide Pfade einen Wert, der weder aus dem Request stammt noch konfiguriert ist, reicht die Engine ihn hinein. Ein Callback, der einen Wert bekommt, kann nicht driften; einer, der ihn erfindet, schon — und tat es.

Was das Primitiv koordiniert.

Was das Scheduling-Primitiv übernimmt
AspektWie er behandelt wird
DefinitionEine Datei mit Ausdruck und Handler, per Konvention entdeckt — keine Registry zum Anhängen.
KoordinationN Replicas feuern eine Ausführung einmal, ohne handgeschriebenes Lock oder designierten Cron-Host.
Catch-upVerpasste Ticks werden beim Boot ab dem letzten Lauf gefeuert — begrenzt, damit kein Ausfall zur Lawine wird.
Manuelle LäufeAuf Zuruf feuern, als manueller Trigger festgehalten statt als geplanter getarnt.
BackfillEin expliziter Bereich, sequenziell, jede gegen ihre Cron-Zeit; verweigert über einer Obergrenze statt zu kürzen.
Externe TriggerDieselbe Definition lässt sich von Kubernetes-CronJobs oder einem Cloud-Scheduler treiben statt intern zu ticken.

Häufige Fragen

Läuft mein Job zweimal, wenn ich zwei Container fahre?

Nein. Koordination ist Teil des Primitivs: die Flotte einigt sich, wer jede Ausführung übernimmt — Rausskalieren vervielfacht deine Mails, Rechnungen oder Exporte also nicht. Du schreibst kein Lock und brauchst keinen designierten Cron-Host.

Was passiert mit Jobs, die während eines Deploys verpasst wurden?

Sie werden beim Boot nachgeholt, ausgehend vom letzten festgehaltenen Lauf, und der Catch-up ist begrenzt, damit ein langer Ausfall nicht Hunderte Ausführungen auf einmal feuert. Für ein Fenster, das der Boot-Catch-up nicht erreicht, backfillst du einen expliziten Bereich.

Kann ich ein Schedule manuell auslösen?

Ja, mit `voltro schedule run`, gegen einen laufenden Prozess oder eigenständig. Es wird als manueller Trigger festgehalten, die Lauf-Historie unterscheidet ihn also von einer geplanten Auslösung, statt beides zu vermischen.

Kann ich stattdessen Kubernetes-CronJobs nutzen?

Ja. Dieselbe Definition kann extern getriggert werden, statt intern zu ticken — welchen Scheduler du nutzt, ist damit eine Deployment-Entscheidung statt eines Umbaus des Jobs.

Welchen Mandanten sieht ein geplanter Job?

Den, den die Engine ihm ausdrücklich reicht. Das ist Absicht: als zwei Boot-Pfade je ein Subject erfanden, las derselbe Cron in der Entwicklung einen Mandanten und in Produktion alle, still — der Wert wird deshalb hineingereicht statt pro Pfad konstruiert.

Worauf Scheduling aufsetzt.

Öffne das Framework. Schau es dir selbst an.

Jede Primitive auf dieser Seite ist heute im Framework. Klone den Starter, lass `voltro dev` laufen, in zwei Minuten ist es auf dem Bildschirm.