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(Props.create(() => new Heartbeat(probe)));
ref.tell({ kind: 'start' });
scheduler.advance(5_000); // virtuelle Zeit springt 5 Sekunden
await probe.expectMsg('tick');
await kit.shutdown();

Heartbeat plant einen Tick via context.timers.startSingleTimer('tick', tickMsg, 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.expectMsg('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 expectMsg 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.

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
advanceTo(absoluteMs: number): void; // zu einer spezifischen virtuellen Zeit springen
pendingCount: number; // nicht abgebrochene geplante Aufgaben
}

Vom Scheduler geerbt:

scheduleOnce(delayMs, target, message, sender?): Cancellable
scheduleOnceFn(delayMs, fn): Cancellable
scheduleAtFixedRate(initialDelay, interval, target, message, sender?): Cancellable
scheduleAtFixedRateFn(initialDelay, interval, fn): 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, ManualScheduler } from 'actor-ts';
const scheduler = new ManualScheduler();
const system = ActorSystem.create('my-spec', { scheduler });
// ... 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.advanceTo(5_000); // zu virtueller Zeit = 5000 ms springen
scheduler.now(); // → 5000

advance(ms) ist der gängige Fall. Aufgaben, die im voraneilenden Fenster feuern sollen, laufen in Reihenfolge, sequenziell, bevor advance zurückkehrt.

advanceTo(absoluteMs) ist für den Fall, dass du eine Zielzeit aufgenommen hast und dorthin vorspringen willst (nützlich für „nach-N-von-etwas”-Muster zu testen).

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(Props.create(() =>
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.expectMsg({ 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.expectMsg('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.

expectMsg 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.