ManualScheduler
ManualScheduler ist eine Scheduler-Implementierung, bei der
Zeit nicht von selbst voranschreitet. Plane einen
5-Sekunden-Timer; nichts feuert, bis du explizit
scheduler.advance(5_000) aufrufst. Der Test läuft in
deterministischer virtueller Zeit, ohne Echt-Uhr-
Abhängigkeiten — keine flakigen „schlafe lang genug”-Muster.
import { TestKit } from 'actor-ts/testkit';
const { kit, scheduler } = TestKit.withManualScheduler();const probe = kit.createTestProbe();
const ref = kit.system.spawnAnonymous(() => new Heartbeat(probe));ref.tell({ kind: 'start' });
scheduler.advance(5_000); // virtuelle Zeit springt 5 Sekundenawait probe.expectMessage('tick');
await kit.shutdown();Heartbeat plant einen Tick via
context.timers.startSingleTimer('tick', tickMessage, 5_000). Echte
Zeit verstreicht nie; der advance(5_000) feuert den Timer.
Warum das wichtig ist
Abschnitt betitelt „Warum das wichtig ist“// Ohne ManualScheduler:ref.tell({ kind: 'start' });await new Promise(r => setTimeout(r, 5_100)); // hoffe, echte Zeit verstrichawait probe.expectMessage('tick');Das funktioniert meistens. Dann läuft CI langsamer; der Test
schläft 5,1 s, aber der Scheduler feuert bei 5,0 s, während
expectMessage bei 5,1 s prüft — flakey. Oder deine lokale Maschine
ist schnell; der Test wartet jedes Mal 5,1 s, obwohl der Timer
bei 5,0 s feuert — langsam.
Die Toleranz aufzuweiten rettet es auch nicht, denn Timer feuern
ebenso zu früh. Timer-Quanten der Plattform sind grob — unter
Windows sind 15,625 ms der Default — und eine Event-Loop, die
Deadlines auf der Tick-Grenze prüft, kann einen Timer als fällig
ansehen, bevor sein nominales Delay verstrichen ist: ein
30-ms-setTimeout wurde unter Bun/Windows mit nur 18,7 ms
gemessen, und unter CPU-Last mit bis zu 201 ms. Ein nominales
Delay, eine Zehnerpotenz Streuung. Keine Wall-Clock-Grenze ist
gleichzeitig eng genug, um etwas zu bedeuten, und weit genug, um
zu halten — und eine präzisere Uhr hilft nicht: zu früh ist der
Timer, nicht die Messung.
ManualScheduler entfernt die Abhängigkeit von echter Zeit.
Tests sind schnell (kein Warten) und deterministisch (advance
feuert Timer in Reihenfolge).
Die API
Abschnitt betitelt „Die API“class ManualScheduler extends Scheduler { now(): number; // aktuelle virtuelle Zeit (ms) advance(durationMs: number): void; // vorwärts springen, gereifte Timer feuern advanceToNext(): void; // zum Feuerzeitpunkt des nächsten Timers springen pendingCount: number; // nicht abgebrochene geplante Aufgaben}Vom Scheduler geerbt:
scheduleOnce(delayMs, target, message, sender?): CancellablescheduleOnceFunction(delayMs, task): CancellablescheduleAtFixedRate(initialDelay, interval, target, message, sender?): CancellablescheduleAtFixedRateFunction(initialDelay, interval, task): CancellableEchtes setTimeout wird nicht genutzt. Jeder Schedule ist
eine Virtual-Time-Aufgabe, die nur via advance feuert.
Über TestKit nutzen
Abschnitt betitelt „Über TestKit nutzen“const { kit, scheduler } = TestKit.withManualScheduler('my-spec');Zwei Werte:
kit— ein normalesTestKit, dessensystemden Manual-Scheduler verdrahtet hat.scheduler— der Manual-Scheduler. Rufeadvancedarauf, um Timer zu feuern.
Wenn der zu testende Actor context.timers.startSingleTimer
oder system.scheduler.scheduleOnce nutzt, routet das Framework
durch den Scheduler des Systems — der hier der manuelle ist.
Virtuelle Zeit kontrolliert alle Actor-Timer.
Ohne TestKit nutzen
Abschnitt betitelt „Ohne TestKit nutzen“import { ActorSystem, ActorSystemOptions, ManualScheduler } from 'actor-ts';
const scheduler = new ManualScheduler();const actorSystemOptions = ActorSystemOptions.create().withScheduler(scheduler);const system = ActorSystem.create('my-spec', actorSystemOptions);
// ... Test-Code ...
await system.terminate();Gleiche Idee — übergib den Manual-Scheduler an
ActorSystem.create via die Settings.
Zeit voranbringen
Abschnitt betitelt „Zeit voranbringen“scheduler.advance(1_000); // 1 Sekunde springen, Timer in dem Fenster feuernscheduler.advanceToNext(); // direkt zum nächsten anstehenden Timer springen und ihn feuernscheduler.now(); // → aktuelle virtuelle Zeitadvance(ms) ist der gängige Fall. Aufgaben, die im
voraneilenden Fenster feuern sollen, laufen in Reihenfolge,
sequenziell, bevor advance zurückkehrt.
advanceToNext() springt direkt zum Feuerzeitpunkt des nächsten
anstehenden Timers (und feuert ihn) und überspringt jede
Leerlauf-Lücke — praktisch, wenn du die genaue Verzögerung nicht
von Hand berechnen willst. Ist nichts geplant, ist es ein No-op.
Wiederholende Timer
Abschnitt betitelt „Wiederholende Timer“ref.tell({ kind: 'start-heartbeat' }); // plant alle 1sscheduler.advance(3_500);// → feuert bei virtuell 1000, 2000, 3000 (dreimal)Eine scheduleAtFixedRate-Aufgabe feuert in jedem Intervall,
während virtuelle Zeit jedes Vielfache überquert. advance(3500)
ab t=0 feuert die Aufgabe dreimal.
Reihenfolge zählt
Abschnitt betitelt „Reihenfolge zählt“Innerhalb eines advance feuern Aufgaben in ihrer geplanten
Reihenfolge (früheste zuerst; Gleichstände werden durch
Einfüge-Reihenfolge gebrochen). Wenn eine Aufgabe nach dem
Feuern weitere Aufgaben plant (z. B. ein Heartbeat re-armed),
werden diese der Queue hinzugefügt und feuern, falls ihre
Planungszeit ins selbe advance-Fenster fällt.
Ein praktisches Test-Muster
Abschnitt betitelt „Ein praktisches Test-Muster“import { describe, it, beforeEach, afterEach } from 'bun:test';import { TestKit, type ManualScheduler } from 'actor-ts/testkit';
describe('Session-Expiry', () => { let kit: TestKit; let scheduler: ManualScheduler; let probe: TestProbe;
beforeEach(() => { const setup = TestKit.withManualScheduler(); kit = setup.kit; scheduler = setup.scheduler; probe = kit.createTestProbe(); });
afterEach(async () => { await kit.shutdown(); });
it('läuft nach 15 Minuten Inaktivität ab', async () => { const session = kit.system.spawnAnonymous(() => new Session(probe, 'user-42'));
session.tell({ kind: 'activity' }); // setzt den Timer zurück scheduler.advance(14 * 60_000); await probe.expectNoMessage(0); // noch nicht abgelaufen
scheduler.advance(60_000 + 1); // überquert 15 Min await probe.expectMessage({ kind: 'session-expired', userId: 'user-42' }); });});Der Test läuft in Mikrosekunden Wall-Clock-Zeit, übt aber ein 15-Minuten-Idle-Timeout exakt aus.
Interaktion mit await und Microtasks
Abschnitt betitelt „Interaktion mit await und Microtasks“ref.tell({ kind: 'start' });scheduler.advance(5_000);await probe.expectMessage('tick');Zweistufige Verarbeitung:
advancefeuert den Timer synchron — ertellt den Actor.- Der
tellwird vom Dispatcher in der nächsten Microtask- Runde verarbeitet, die läuft, wenn du etwasawaitest.
expectMessage gibt eine Promise zurück, die intern awaitet —
also wurde der Tick bis zur Auflösung verarbeitet und die
Nachricht ist im Buffer der Probe.
Du musst meistens nicht darüber nachdenken. Aber wenn du
synchron nach advance zu probe.messageCount greifst und 0
siehst, wurde der Actor noch nicht dispatched — await etwas,
um zu yielden.
Stolperfallen
Abschnitt betitelt „Stolperfallen“Wo es weitergeht
Abschnitt betitelt „Wo es weitergeht“- TestKit — die Fassade mit
withManualScheduler(). - Timer und Scheduling — die Timer-API, die der Scheduler antreibt.
- Receive-Timeout — ein weiterer zeitgetriebener Mechanismus, der von virtueller Zeit profitiert.
- Testing — Überblick — das größere Bild.
Die ManualScheduler-API-
Referenz deckt die volle Oberfläche ab.
