Zum Inhalt springen
Deutsch

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 Sekunden
await 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.

// Ohne ManualScheduler:
ref.tell({ kind: 'start' });
await new Promise(r => setTimeout(r, 5_100)); // hoffe, echte Zeit verstrich
await 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).

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?): Cancellable
scheduleOnceFunction(delayMs, task): Cancellable
scheduleAtFixedRate(initialDelay, interval, target, message, sender?): Cancellable
scheduleAtFixedRateFunction(initialDelay, interval, task): Cancellable

Echtes setTimeout wird nicht genutzt. Jeder Schedule ist eine Virtual-Time-Aufgabe, die nur via advance feuert.

const { kit, scheduler } = TestKit.withManualScheduler('my-spec');

Zwei Werte:

  • kit — ein normales TestKit, dessen system den Manual-Scheduler verdrahtet hat.
  • scheduler — der Manual-Scheduler. Rufe advance darauf, 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.

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.

scheduler.advance(1_000); // 1 Sekunde springen, Timer in dem Fenster feuern
scheduler.advanceToNext(); // direkt zum nächsten anstehenden Timer springen und ihn feuern
scheduler.now(); // → aktuelle virtuelle Zeit

advance(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.

ref.tell({ kind: 'start-heartbeat' }); // plant alle 1s
scheduler.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.

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.

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.

ref.tell({ kind: 'start' });
scheduler.advance(5_000);
await probe.expectMessage('tick');

Zweistufige Verarbeitung:

  1. advance feuert den Timer synchron — er tellt den Actor.
  2. Der tell wird vom Dispatcher in der nächsten Microtask- Runde verarbeitet, die läuft, wenn du etwas awaitest.

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.

Die ManualScheduler-API- Referenz deckt die volle Oberfläche ab.