Zurück zu Technology

Unser Batch-Scheduler ist in Wahrheit ein Load Balancer

Jede Nacht balanciert Norman zwei sehr unterschiedliche Tenants auf denselben Workern: geduldige, netzwerkgebundene Bank-Syncs und hungrige, rechengebundene KI-Jobs, die Belege lesen und einen stetigen Strom von Transaktionen kategorisieren. So behandeln wir den Scheduler als Load Balancer, messen den Queue-Druck pro Workload und wohin wir es als Nächstes bringen: die schwere KI-Arbeit nach Live-Last platzieren statt nach einer handverlesenen Cron-Minute.

Kategorie
Allgemein
Aktualisiert
Autor:in
Stan Kharlap

Das meiste, was Norman tut, kannst du sehen. Du öffnest die App, eine Transaktion ist kategorisiert, ein Beleg ist zugeordnet, eine Umsatzsteuervoranmeldung ist fertig. Was du nicht siehst, ist die Nachtschicht. Während Deutschland schläft, balanciert eine Flotte von Hintergrund-Workern zwei sehr unterschiedliche Arten von Tenant auf derselben Hardware: geduldige, netzwerkgebundene Bank-Syncs über tausende Verbindungen und hungrige, rechengebundene KI-Jobs, die Belege lesen, einen stetigen Strom neuer Transaktionen kategorisieren und die Agenten-Evals vom Vortag erneut abspielen. Nichts davon ist glamourös, und nichts davon lag dort, wo ich die schweren Probleme erwartet hatte.

Das schwere Problem ist, dass diese beiden Tenants gleichzeitig dieselbe Maschine wollen und sie auf völlig unterschiedliche Weise belasten. Ein Hintergrund-Scheduler sieht aus wie eine Liste von „führe das über Nacht aus"-Einträgen. In der Praxis ist er ein Load Balancer mit Cron-Hut: Seine eigentliche Aufgabe ist es, zu verhindern, dass ein einzelner Workload, und es ist fast immer der KI-Workload, die anderen aushungert. Die langweiligen Entscheidungen darüber, was neben was läuft, sind der größte Teil dessen, was die Morgen ruhig hält.

Das Fenster ist eine geteilte Ressource

Hier ist der Fehlerfall, in den wir hineingelaufen sind. Unsere schwersten wiederkehrenden Jobs waren alle in dasselbe schmale Fenster in den frühen Morgenstunden gerutscht, weil das der naheliegende Ort für „nächtliche" Arbeit ist. Innerhalb dieses einen Fensters sah der Plan so aus:

  • update_all_transactions, das einen Open-Banking-Sync über jedes verbundene Konto ausfächert, tausende davon, jeder ein langsamer Aufruf an einen externen Aggregator.
  • update_accounts, eine Saldenaktualisierung, weitere externe Aufrufe.
  • categorize_uncategorized_transactions, der schwere KI- und OCR-Nachbearbeitungslauf, direkt obendrauf.
  • sync_all_active_integrations, das das Fenster schließt.

Die beiden gipfelten gegeneinander, und sie sind Gegensätze. Der Open-Banking-Fan-out ist geduldig und langsam und verbringt seine Zeit damit, auf die API eines anderen zu warten. Der KI-Lauf ist ungeduldig und hungrig: Er will Worker und Modellkapazität jetzt. Im selben Fenster addieren sie sich nicht, sie stören sich. Die Sync-Jobs halten Worker als Geiseln, während sie auf I/O warten, und die KI-Jobs stauen sich dahinter.

Die Lösung war fast peinlich in ihrer Einfachheit, und ich komme darauf zurück, warum. Aber du kannst sie nicht sicher machen, bevor die Arbeit nach Art getrennt ist, und genau dort steckt das eigentliche Design.

Zuerst: die Arbeit danach trennen, worauf sie wartet

Das mit Abstand Nützlichste, was wir getan haben, war, aufzuhören, „einen Hintergrund-Job" als eine einzige Kategorie zu behandeln. Ein Job, der auf eine Bank-API wartet, und ein Job, der auf ein Sprachmodell wartet, scheitern unterschiedlich, wiederholen sich unterschiedlich und lassen einander verhungern, wenn sie sich einen Worker-Pool teilen. Also teilen sie sich keinen.

Arbeit wird danach, worauf sie wartet, auf eigene Queues geroutet, jede mit ihrem eigenen Worker-Pool:

# Jeden Task nach dem routen, worauf er wartet (illustrativ):
app.conf.task_routes = {
    # nutzernahe KI / OCR / Dokumentenarbeit
    "categorize_transaction": {"queue": "transaction_postprocess"},
    "extract_data_from_attachment": {"queue": "transaction_postprocess"},

    # Open-Banking-API-Aufrufe: netzwerkgebunden und langsam
    "update_transactions_by_bank_account": {"queue": "transaction_sync"},

    # Agenten-Vorbereitung im Hintergrund vs. nutzersichtbare Agenten-Aktionen
    "run_evals": {"queue": "agent_batch"},
    "submit_approved_run": {"queue": "agent_realtime"},
    # ...
}

Die Unterscheidungen, auf die es ankommt, sind die Lastprofile, nicht die Namen. transaction_sync ist voller Jobs, die für uns billig und wegen eines anderen langsam sind, also hält sein Pool viele Jobs, die meist untätig an einem Socket hängen. transaction_postprocess ist das Gegenteil: teuer pro Job, und du willst nicht, dass eine Flut auf einmal landet. Die beiden Agenten-Queues kodieren eher eine Prioritäts- als eine Ressourcentrennung: agent_batch ist Vorbereitung, auf die niemand wartet, agent_realtime ist der Moment, in dem ein Mensch auf „Einreichen" geklickt hat und auf einen Spinner starrt. Sie auseinanderzurouten heißt, dass ein nächtlicher Eval-Lauf niemals vor einer Anmeldung sitzen kann, die ein Mensch abzuschicken versucht.

Sobald die Spuren existieren, wird Planung zur Frage, welche Spuren du gemeinsam aufleuchten lässt.

Dann: nicht zwei schwere Dinge gleichzeitig planen

Mit getrennter Arbeit war die eigentliche Scheduler-Änderung ein paar Zeilen Cron. Wir haben das KI- und Agenten-Trio komplett aus dem Bank-Fenster geschoben:

"categorize_uncategorized_transactions": {
    # Aus dem Open-Banking-Fenster geschoben, damit der schwere KI/OCR-
    # Nachbearbeitungslauf nicht gleichzeitig mit dem Sync-Fan-out gipfelt;
    # er läuft jetzt in einem ruhigeren Slot später in der Nacht.
    "schedule": crontab(hour=..., minute=...),
    "options": {"expires": ...},  # ein großzügiges Fenster, ein bis zwei Stunden
},
"promote_findings": {
    "schedule": crontab(hour=..., minute=...),  # kurz vor run_evals
    "options": {"expires": ...},
},
"run_evals": {
    "schedule": crontab(hour=..., minute=...),  # nächtlicher Eval-Replay
    "options": {"expires": ...},
},

Der KI-Lauf läuft jetzt in einem ruhigeren Slot später in der Nacht, nach dem nächtlichen Housekeeping-Audit und bequem vor dem morgendlichen Rechnungsversand. Das Bank-Fenster behält seinen frühen Slot für sich. An den Jobs hat sich nichts geändert, sie haben nur aufgehört zu kollidieren.

Eine nächtliche Zeitachse mit zwei Spuren. Die Bank-Spur enthält den Open-Banking-Sync-Fan-out über tausende Konten in einem frühen Fenster. Die KI- und Agenten-Spur zeigt einen gestrichelten Geisterblock im selben Fenster mit der Beschriftung 'war hier' und einen soliden Block, wohin die Jobs categorize, promote und evals in einen ruhigeren Slot später in der Nacht verschoben wurden. Marker zeigen das nächtliche Housekeeping-Audit und den morgendlichen Rechnungsversand.
Netzwerkgebundene Bank-Syncs und rechengebundene KI-Jobs gipfelten früher im selben nächtlichen Fenster. Das Trio in einen eigenen ruhigen Slot zu verschieben, war eine winzige Plan-Bearbeitung, sicher nur, weil die Jobs isoliert und idempotent sind.

Ich will ehrlich sein: Das ist keine clevere Änderung. Es ist eine Plan-Bearbeitung. Sie ist nur als Plan-Bearbeitung verfügbar, weil die weniger sichtbare Arbeit weiter unten, die Queue-Trennung und die Retry-Semantik, jeden dieser Jobs sicher gemacht hat, ihn um ein paar Stunden zu verschieben. Wenn Jobs verstrickt sind, ist das Verschieben eine Risikoabschätzung; wenn sie isoliert und idempotent sind, ist es ein Einzeiler. Der größte Teil des Werts liegt darin, sich das Recht auf langweilige Änderungen zu verdienen.

expires: einen verspäteten Job willst du nicht

Sieh dir diese Einträge noch einmal an, und du wirst sehen, dass jeder ein expires-Fenster trägt, in Stunden bemessen. Das ist keine Deko. Es ist die Antwort auf eine konkrete Frage: Was soll passieren, wenn ein geplanter Job losgeschickt wird, die Worker aber zu beschäftigt sind, um ihn rechtzeitig aufzunehmen?

Die falsche Antwort ist „führ ihn aus, wann immer ein Worker frei wird." Ein nächtlicher Kategorisierungslauf ist nützlich, wenn er feuert, in den frühen Morgenstunden. Wenn der Pool ausgelastet war und derselbe Lauf schließlich erst mitten am Tag aufgenommen wird, konkurriert er nun mit Live-Nutzerverkehr, um Arbeit zu tun, die ein späterer Lauf ohnehin neu macht. Ein veralteter Batch-Job, der mitten in den Tag hinein wiederholt wird, ist schlimmer als ein übersprungener Batch-Job.

Also verfallen Beat-Einträge. Wird der geplante Job nicht innerhalb seines Fensters gestartet, verwirft ihn der Broker, und der nächste geplante Lauf kümmert sich um den Rückstand. expires verwandelt „irgendwann" in „jetzt, oder gar nicht", was genau der Vertrag ist, den du für periodische Arbeit willst. Die Fenster sind bewusst großzügig, damit eine normale Schicht bequem hineinpasst und nur ein wirklich festgefahrener Pool je den Verwurf auslöst.

Die Last laufend messen

Ein Load Balancer, der die Last nicht sehen kann, ist nur ein statischer Router. Also läuft im Scheduler eine Sonde in kurzen Intervallen, die die tatsächliche Tiefe jeder Workload-Queue liest, keine einzelne „ist die Kiste ausgelastet"-Zahl:

QUEUE_SIZE_THRESHOLD = ...  # ein paar hundert wartende Nachrichten
MONITORED_QUEUES = [
    "transaction_sync",        # Bank-Sync-Fan-out
    "transaction_postprocess", # KI / OCR
    "agent_batch", "agent_realtime",
]

@app.task(name="monitoring.check_queue_health")
def check_queue_health() -> None:
    """Alarmiert, wenn eine Workload-Queue sich staut."""
    for name in MONITORED_QUEUES:
        if queue_depth(name) > QUEUE_SIZE_THRESHOLD:
            logger.critical("Queue %s staut sich", name)
    # ...und ein separater Blick auf die Dead-Letter-Queue

Zwei Dinge sind hier wichtig. Erstens liest sie jeden Workload getrennt, sodass wir nie nur „das System ist ausgelastet" sehen, sondern welcher Tenant unter Druck steht: die Bank-Sync-Spur, die auf einem langsamen Aggregator hängt, oder die KI- und OCR-Spur, die sich durch einen Rückstand mahlt. Die kannst du an einem einzigen CPU-Graphen nicht auseinanderhalten, und sie verlangen entgegengesetzte Reaktionen. Zweitens beobachtet sie eine Dead-Letter-Queue, wo Tasks landen, nachdem sie einmal zu oft gescheitert sind, denn ein wachsender Dead-Letter-Zähler heißt, dass etwas nicht bloß langsam, sondern kaputt ist.

Heute ist die Aufgabe der Sonde, einen Menschen zu pagen. Aber sie ist auch der Sensor, den ein klügerer Scheduler braucht. Du kannst eine Last nicht balancieren, die du nicht messen kannst, und dies ist der Teil, der sie misst, pro Workload, laufend.

Mindestens einmal heißt idempotent oder gar nicht

Das andere, was Jobs sicher zum Umplanen macht, ist, was passiert, wenn einer mittendrin stirbt. Wir betreiben die Worker mit zwei Einstellungen, die ein Problem gegen ein besseres tauschen:

task_acks_late=True,
task_reject_on_worker_lost=True,

acks_late bedeutet, dass ein Job erst nach Abschluss bestätigt wird, nicht beim Aufnehmen, sodass ein Worker, der auf halbem Weg abstürzt, den Job nicht stillschweigend verliert. reject_on_worker_lost legt diesen Job zurück auf die Queue. Zusammen kaufen sie dir eine Zustellung mindestens einmal: Bei einem Absturz geht nichts verloren. Der Preis ist, dass ein Job gelegentlich zweimal laufen kann, einmal auf dem Worker, der starb, und einmal auf seinem Ersatz.

Was heißt, dass jeder Job sicher zweimal laufbar sein muss, und diese Invariante prägt das ganze System still. Die KI-Agenten-Schicht stempelt jeden Lauf mit einem idempotency_key, sodass ein wiederholter Modellaufruf sich zum bestehenden Lauf auflöst statt zu einem frischen Duplikat, über die zehntausenden Agenten-Läufe hinweg, die wir aufgezeichnet haben. Der Bank-Sync-Pfad dedupliziert auf Kontoebene, mit einem Sperren pro Konto bei jeder Auslieferung, sodass aggressive Planung nie zu aggressiver Doppelarbeit wird. Der Sinn der Zustellung mindestens einmal ist, dass du aufhörst, Duplikate an der Queue zu verhindern, und sie stattdessen am Job harmlos machst.

Nimm an, dass er sich festfährt, und bau den Besen

Die letzte Kategorie geplanter Jobs ist die, die es nur gibt, weil die anderen sich danebenbenehmen. Verteilte Batch-Arbeit scheitert nicht sauber, sie fährt sich fest. Ein Worker stirbt zwischen zwei Commits, ein externer Aufruf hängt, eine Sperre wird von einem Prozess gehalten, den es nicht mehr gibt. Also ist ein guter Teil des Plans Hausmeisterei:

  • recover_stuck_syncs läuft in regelmäßigen Durchläufen und befreit Bankkonten, die mitten im Sync gestrandet sind.
  • requeue_stuck_runs stellt Agenten-Läufe wieder her, deren Worker-Task verloren ging.
  • update_all_transactions läuft mehrmals am Tag, nicht weil Banken sich so oft ändern, sondern als Sicherheitsnetz, das Konten neu antreibt, die ein vorheriger Durchlauf zurückgelassen hat.

Keiner davon tut in einer guten Nacht irgendetwas. Sie sind der Besen im Schrank. Aber die Annahme dahinter, dass jeder einzelne Lauf scheitern kann und wird, ist genau das, was den Happy Path einfach hält: Kein Job muss kugelsicher sein, weil ein Feger vorbeikommt und neu antreibt, was auch immer er in schlechtem Zustand hinterlassen hat.

Auf einen Scheduler zu, der sich selbst balanciert

Alles bisher ist bewusst statisch. Die Queues sind fest, die Cron-Minuten handverlesen, und als der KI-Lauf mit dem Bank-Sync-Fenster kollidierte, hat ein Mensch es bemerkt und verschoben. Das funktioniert, und langweilig-und-korrekt schlägt clever-und-fragil jedes Mal. Aber von hier aus siehst du die Form des nächsten Schritts.

Wir haben bereits die zwei Hälften, die ein adaptiver Balancer braucht. Wir können Last messen: Die Queue-Tiefen-Sonde liest den Live-Druck pro Workload und beobachtet die Dead-Letter-Queue auf Defekte. Und wir können Last platzieren: Die Routing-Tabelle entscheidet, wo ein Job läuft, der Zeitplan entscheidet, wann. Was fehlt, ist der Controller dazwischen, der Teil, der das Signal liest und die Arbeit verschiebt, ohne zu warten, bis ein Mensch es bemerkt.

Die Richtung, die wir erkunden, ist ein Scheduler, der die KI- und OCR-Jobs als elastische Tenants behandelt. Statt eines festen „führ den Kategorisierungslauf zu dieser Minute aus" würde er zum Dispatch-Zeitpunkt fragen, welches Fenster Luft hat: die schwere rechengebundene Arbeit zurückhalten, während der Bank-Sync-Fan-out gipfelt, und sie in den nächsten ruhigen Slot freigeben. Das ist die Entscheidung, die wir diesen Monat von Hand getroffen haben, laufend aus der Live-Last getroffen. Das Schwere an einem adaptiven Load Balancer war nie die Platzierungs-Policy; es ist, Arbeit verschieben zu können, ohne etwas kaputt zu machen, und dieses Fundament steht. Jeder Job ist idempotent, sodass der Controller frei umschichten kann; jeder Job hat ein expires-Fenster, sodass ein fehlplatzierter verworfen statt veraltet ausgeführt wird; jeder Workload ist isoliert, sodass das Verschieben eines nie einen anderen aushungert. Der kluge Teil ist klein. Der langweilige Teil darunter ist das ganze Produkt.

Die langweiligen Teile sind das Produkt

Der ganze Scheduler ist ein paar hundert Zeilen Python: ein Dictionary von Cron-Einträgen, eine Routing-Tabelle und eine Sonde, die die Queue-Tiefe liest. Die Änderung, die diesen Beitrag ausgelöst hat, hat eine Handvoll dieser Einträge in ein ruhigeres Fenster verschoben. Wenn du die KI in einem KI-Buchhaltungsprodukt gesucht hast: Das Modell, das deine Belege liest, ist hier nur der lauteste Tenant, nicht der schwere Teil.

Der schwere Teil ist die Schicht drumherum: ob dieses Modell tatsächlich vor deinem Morgen läuft, ob eine Anmeldung, die du einzureichen versuchst, hinter einem nächtlichen Eval wartet, und ob ein Worker-Absturz im falschen Moment still etwas neu einreicht, was er nicht sollte. Die interessante Ingenieursarbeit in einem System wie unserem ist selten der Modellaufruf. Es ist der unglamouröse Balancer, der entscheidet, wann das Modell läuft, neben was es läuft und was passiert, wenn es das nicht tut. Wir stecken viel Zeit in diesen Balancer, gerade weil du nie darüber nachdenken solltest.

Norman übernimmt die operative Arbeit im Hintergrund

Von Rechnungen bis Buchhaltung: Norman organisiert wiederkehrende Finanzarbeit, damit du Fristen sauber einhältst und weniger manuell nachhalten musst.