Der Benchmark der Hintergrund-Jobs¶
Bis 1.9.0 lief jeder Hintergrund-Job in AgenticOS auf Prefect, das für jeden Run
einen neuen Python-Prozess startete. Am 18. Oktober 2026 stauten sich rund 1.650
verspätete Runs auf einem Host, der ohnehin CPU durch Steal verlor, und die
Website fiel aus. Bevor wir Prefect ersetzten, haben wir zehn Engines in siebzehn
Konfigurationen durch dieselben Lasten geschickt und gleich gemessen. Diese Seite
beschreibt, was gemessen wurde, was die Wahl entschied und was die Zahlen nicht
zeigen. Der Harness ist experiments/task-queues auf dem Branch
spike/task-queue-benchmark im Commit a239dfdd7; dort liegen auch die
Rohereignisse jedes Laufs.
Was verglichen wurde¶
| Variante | Engine | Wie sie Arbeit ausführt |
|---|---|---|
prefect-serve |
Prefect 3 serve, Limit 5; auch mit Limit 20 und mit Prefects Datenbank auf Postgres getestet |
Ein neuer Python-Prozess pro Run: AgenticOS vor 1.9.0 |
prefect-tasks |
Prefects Hintergrund-Tasks | Ein langlebiger Task-Worker |
celery-prefork, celery-threads |
Celery 5.6 und Beat, auf Redis | Ein Pool aus Prozessen oder aus Threads |
taskiq |
Taskiq auf Redis Streams, mit eigenem Scheduler | Asynchron, eine Event-Loop pro Prozess |
dramatiq |
Dramatiq auf Redis | Prozesse mal Threads |
saq |
SAQ auf Redis | Asynchron, eine Event-Loop pro Prozess |
procrastinate |
Procrastinate 3.10 auf Postgres | Asynchron, eine Event-Loop |
pgqueuer |
PgQueuer 1.6.0 auf Postgres | Asynchron, eine Event-Loop |
dbos, dbos-poll100 |
DBOS 3.3.0 auf Postgres; -poll100 fragt alle 0,1 s statt jede Sekunde ab |
Dauerhafte Workflows |
hatchet, hatchet-fair |
Hatchet Lite auf Postgres; -fair wechselt zwischen Organisationen |
Asynchrone Worker-Slots |
pgqueue, pgqueue-fair |
Eine einfache Warteschlange in Postgres: SKIP LOCKED, LISTEN/NOTIFY, Leases |
Asynchrone Slots; -fair holt der Reihe nach aus jeder Organisation |
Jeder Task-Prozess importiert zuerst die echte AgenticOS-Anwendung. Diesen Import bezahlte Prefect bei jedem Run, ein Benchmark ohne ihn würde also genau die untersuchten Kosten verstecken. Die Worker bekamen 3 CPUs und 4 GB, den Anteil des Produktions-Hosts für Hintergrundarbeit. Ein Dienst, den eine Engine mitbringt, etwa Prefects Server oder Hatchets Engine, bekam eigene Limits, und seine CPU und sein Speicher zählen zu den Kosten dieser Engine.
Die Szenarien¶
workflow: 50 Runs aus je 10 aufeinanderfolgenden leichten Schritten, gleichzeitig gestartet. Jeder Schritt macht einen Datenbank-Roundtrip und reiht den nächsten ein. Gemessen wird der Overhead der Engine pro Sprung.mix: 5 Minuten Modellaufrufe (2 pro Sekunde, jeder wartet 2 bis 10 s) und PDF-Parsing (eines alle 2 s, je etwa 1 s CPU), dazu der eigene minütliche Sweep der Engine. Gemessen wird, wie wartende Arbeit und CPU-Arbeit sich die Worker teilen und wie pünktlich der Zeitplan ist.tenants: Eine Organisation reiht 1.000 Jobs auf einmal ein, eine andere schickt 90 s lang einen Job pro Sekunde. Nach 20 s wird jeder Worker beendet und 10 s später neu gestartet. Gemessen werden Fairness und die Kosten eines Absturzes.oom: Ein Job vergrößert seinen Speicher, bis der Kernel seinen Worker bei 1 GB beendet, während daneben 210 normale Jobs laufen. Ein toter Worker startet nach 3 s neu, der Lauf wird 240 s beobachtet, und jede Engine mit einem Wiederholungslimit steht auf 3 Versuchen.
Jeder Task schreibt eine Zeile started und eine Zeile finished, der
Einreichende eine Zeile enqueued, in eine Postgres-Tabelle mit einer Uhr.
Wartezeiten, Lücken zwischen Schritten sowie verlorene und doppelte Jobs ergeben
sich aus diesen Zeilen. Der Driver liest docker stats einmal pro Sekunde und den
WAL-Zähler von Postgres vor und nach dem Lauf.
Overhead pro Schritt¶
| Variante | Sprung p50 | Sprung p95 | Schritte pro Sekunde | Spitzenspeicher der Worker |
|---|---|---|---|---|
taskiq |
0,01 s | 0,02 s | 948 | 184 MB |
saq |
0,01 s | 0,05 s | 818 | 564 MB |
dramatiq |
0,02 s | 0,05 s | 1.338 | 583 MB |
pgqueue |
0,03 s | 0,06 s | 506 | 288 MB |
pgqueuer |
0,03 s | 0,08 s | 609 | 584 MB |
procrastinate |
0,05 s | 0,06 s | 829 | 294 MB |
celery-prefork |
0,05 s | 0,13 s | 485 | 1.294 MB |
hatchet |
0,42 s | 0,82 s | 92 | 642 MB |
dbos-poll100 |
0,56 s | 1,11 s | 71 | 601 MB |
dbos |
2,51 s | 3,12 s | 19 | 1.488 MB |
prefect-tasks |
2,60 s | 6,82 s | 11 | 861 MB |
prefect-serve |
218 s | 246 s | 0,2 | 1.736 MB |
prefect-serve wurde nicht fertig: Nach 20 Minuten hatte es 273 der 500 Schritte
erledigt. Mit einem Limit von 20 Prozessen erledigte es keinen, weil 20 Kopien
der Anwendung nicht in 4 GB passten. Ab dbos-poll100 abwärts fügt jede Engine
jedem Schritt eines Workflows mindestens eine halbe Sekunde hinzu.
Wartende Arbeit neben CPU-Arbeit¶
| Variante | Wartezeit eines Modellaufrufs p50 | Wartezeit eines Modellaufrufs p95 | Spätester Start des Sweeps |
|---|---|---|---|
taskiq |
0,00 s | 0,01 s | 0,0 s |
pgqueue |
0,01 s | 0,57 s | 0,0 s |
pgqueuer |
0,01 s | 0,41 s | 0,6 s |
celery-threads |
0,01 s | 0,04 s | 0,0 s |
hatchet |
0,01 s | 0,27 s | 1,0 s |
procrastinate |
0,01 s | 0,61 s | 29,4 s |
dbos |
0,60 s | 1,48 s | 3,7 s |
prefect-tasks |
0,10 s | 2,15 s | Kein Sweep lief |
celery-prefork |
148 s | 279 s | 63,1 s |
Ein Modellaufruf wartet fast die ganze Zeit. Eine Engine, die für jeden Aufruf
einen ganzen Prozess belegt, reiht die nächsten dahinter ein: Unter
celery-prefork warteten Aufrufe mehr als zwei Minuten. Postgres selbst
verbrauchte unter pgqueue, pgqueuer und dbos 3 bis 6 % einer CPU. DBOS
schrieb 2,3-mal so viel WAL wie pgqueue (4,4 MB gegenüber 1,9 MB), weil jeder
seiner Workflows mehrere Zeilen für Status und Schritte ist.
Eine Flut und ein Absturz¶
| Variante | Verloren | Doppelt | Wartezeit der kleinen Organisation p95 | Längster Stillstand |
|---|---|---|---|---|
pgqueue |
0 | 0 | 11,1 s | 17,5 s |
pgqueuer |
0 | 0 | 11,0 s | 16,8 s |
taskiq |
0 | 0 | 12,0 s | 17,5 s |
procrastinate |
0 | 0 | 14,0 s | 20,4 s |
dbos-poll100 |
0 | 0 | 14,1 s | 18,3 s |
hatchet-fair |
0 | 0 | 18,3 s | 24,0 s |
dbos |
0 | 0 | 64,8 s | 18,8 s |
celery-prefork |
0 | 0 | 50,3 s | 39,5 s |
saq |
0 | 0 | 10,0 s | 551,5 s |
pgqueue-fair |
0 | 137 | 10,1 s | 15,4 s |
Keine Engine in der Tabelle hat einen Job verloren; prefect-tasks hatte noch
zwei unerledigte Jobs, als sein Lauf das Zeitlimit erreichte. Die meisten Engines
führten einen Job ein zweites Mal aus: den, den ein beendeter Worker gehalten
hatte. Das muss jede Warteschlange tun, die den Job eines toten Workers
zurückgibt. Der Prototyp pgqueue-fair erledigte 137 Jobs doppelt. Sein Abholen
der Reihe nach ist nicht ausgeliefert worden, und die Duplikate sind der Grund.
SAQ ließ die Jobs der beendeten Worker mehr als neun Minuten liegen, bevor es sie
zurückholte.
Der Job, der seinen Worker beendet¶
Dieses Szenario hat die Wahl entschieden. Bei einem früheren Produktionsvorfall, in einem anderen Projekt auf Celery, startete ein vom OOM-Killer beendeter Job immer wieder, und sein Fehler wurde nie festgehalten. Die neue Warteschlange durfte keins von beidem tun.
| Variante | Starts des Jobs in 240 s | Was passierte | Normale Jobs |
|---|---|---|---|
pgqueue |
3 | Nach dem dritten Versuch als dead markiert, den Fehler hielt der Sweeper fest |
Alle erledigt |
dbos |
3 | MAX_RECOVERY_ATTEMPTS_EXCEEDED, festgehalten vom neu gestarteten Prozess |
Alle erledigt |
procrastinate |
4 | Startete jede Minute neu; das dokumentierte Rezept für hängende Jobs zählt Versuche, begrenzt aber nichts | Alle erledigt |
taskiq |
5 | Startete alle 60 s neu; der Container starb nie, weil sein Prozessmanager das Kind neu startet, also war von außen nichts zu sehen | Alle erledigt |
pgqueuer |
8 | Jedes Mal neu abgeholt, wenn sein Heartbeat ablief, ohne einen Versuch zu zählen; 7 Worker beendet | Alle erledigt |
DBOS hielt ebenfalls bei 3 an, aber eine zweite Probe fand das andere verbotene
Ergebnis. Als ein Worker mit drei Jobs starb und als neuer Container zurückkam,
wie bei jedem Deploy, waren alle drei 120 s später noch PENDING unter der ID
des alten Executors. Der neue Prozess sieht sie nicht. DBOS Conductor erkennt
einen toten Executor, ist für kommerzielles Self-Hosting aber separat lizenziert.
Celery lief nicht durch dieses Szenario. Seine Dokumentation beschreibt beide
Fehler: Eine Nachricht kommt ohne Zustelllimit zurück, oder der Job geht still
verloren.
Was ausgeliefert wurde¶
1.9.0 ersetzte Prefect durch die einfache Warteschlange in Postgres (pgqueue)
und langlebige asyncio-Worker. Sie zählt einen Versuch, wenn ein Job abgeholt
wird, nicht wenn er endet. Jeder Worker führt außerdem einen Rescuer aus: Stirbt einer,
findet der nächste Rescuer - in einem überlebenden Worker oder im neu gestarteten
selbst - seinen abgelaufenen Lease und hält den Tod fest, egal wie der Worker
gestorben ist. Oban, River und Solid
Queue folgen derselben Regel.
Die Ingestion läuft außerdem in Kindprozessen unter einer Speicherüberwachung, damit ein schweres Dokument seinen Worker nicht mitreißt. Hintergrundarbeit aus einem Request übergeben zeigt, wie Code einen Job einreiht, und Über einen Host hinaus, wie man Worker hinzufügt.
Die Fairness zwischen Organisationen bleibt, wie sie gemessen wurde. Die einfache Warteschlange gab der kleinen Organisation fast dasselbe p95 wie der Prototyp mit Abholen der Reihe nach (11,1 s gegenüber 10,1 s), ohne seine Duplikate.
Was die Zahlen nicht sagen¶
- Eine Maschine. Alle Läufe fanden auf Docker Desktop auf Apple Silicon statt. Auf einem Server sind die absoluten Zahlen andere. CPU-Steal auf einem geteilten Host verlangsamt jeden Prozessstart, was den Abstand für einen Prozess pro Run nur vergrößert.
- Ein Lauf pro Zelle. Die Abstände zwischen den Engines sind groß genug, um sie
mit einem Lauf zu ordnen. Wo zwei Engines nah beieinander liegen, wie
pgqueueundpgqueuerbeim Overhead, sagen die Tabellen nicht, dass eine schneller ist. - Jede Engine in ihrer üblichen Konfiguration. Jede Engine hatte dasselbe Budget an CPU und Speicher, nicht dieselbe Nebenläufigkeit, weil ein Prozess-Pool und eine Event-Loop keine gemeinsame Einheit haben. Eine andere Einstellung kann eine Zahl ändern, besonders im OOM-Test.
- Ein korrigierter Fehler im Harness. Der erste
tenants-Lauf von Procrastinate verlor einen Job, weil der Harness eine Methode aufrief, die 3.10 nicht mehr hat. Das war unser Fehler, nicht der von Procrastinate. Der wiederholte Lauf in der Tabelle erledigte alle 1.090 Jobs.
Erneut ausführen¶
# Aus dem Wurzelverzeichnis des Repositorys, im gemessenen Commit.
git switch --detach a239dfdd7
docker build -f backend/Dockerfile -t agenticos-backend:bench-base .
cd experiments/task-queues
docker build -t tqbench:latest .
./run.sh pgqueue oom # eine Variante, ein Szenario, in einem frischen Projekt
./matrix.sh # jede Zelle; ein paar Stunden
python3 -m bench.report results > results/TABLES.md
Die Lasttests beantworten eine andere Frage: wie viel ein Deployment von AgenticOS bedienen kann, nicht welche Engine seine Jobs ausführen sollte.