make TimeService abstract and add support for managed timers
This commit is contained in:
parent
5f6578c8cd
commit
48cc7e21e3
2 changed files with 195 additions and 8 deletions
|
|
@ -3,25 +3,117 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides abstractions to access the current time.
|
* Provides abstractions to access the current time.
|
||||||
* Exists for unit testing purposes, so that tests can "simulate" any given time for consistency.
|
* Exists for unit testing purposes, so that tests can "simulate" any given time for consistency.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
export abstract class TimeService<TTimer extends Timer = Timer> implements OnApplicationShutdown {
|
||||||
export class TimeService {
|
protected readonly timers = new Map<symbol, TTimer>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns Date.now()
|
* Returns Date.now()
|
||||||
*/
|
*/
|
||||||
public get now() {
|
public abstract get now(): number;
|
||||||
return Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a new Date instance.
|
* Returns a new Date instance.
|
||||||
*/
|
*/
|
||||||
public get date() {
|
public get date(): Date {
|
||||||
return new Date();
|
return new Date(this.now);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public startTimer(callback: () => void, delay: number, opts?: { repeated?: boolean }): symbol {
|
||||||
|
const timerId = Symbol();
|
||||||
|
const repeating = opts?.repeated ?? false;
|
||||||
|
|
||||||
|
const timer = this.startNativeTimer(timerId, repeating, callback, delay);
|
||||||
|
this.timers.set(timerId, timer);
|
||||||
|
|
||||||
|
return timerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract startNativeTimer(timerId: symbol, repeating: boolean, callback: () => void, delay: number): TTimer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears a registered timeout or interval.
|
||||||
|
* Returns true if the registration exists and was still active, false otherwise.
|
||||||
|
* Safe to call with invalid or expired IDs.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public stopTimer(id: symbol): boolean {
|
||||||
|
const reg = this.timers.get(id);
|
||||||
|
if (!reg) return false;
|
||||||
|
|
||||||
|
this.stopNativeTimer(reg);
|
||||||
|
this.timers.delete(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract stopNativeTimer(reg: TTimer): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup all handles and references.
|
||||||
|
* Safe to call multiple times.
|
||||||
|
*
|
||||||
|
* **Must be called before shutting down the app!**
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public dispose(): void {
|
||||||
|
for (const reg of this.timers.values()) {
|
||||||
|
this.stopNativeTimer(reg);
|
||||||
|
}
|
||||||
|
this.timers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
onApplicationShutdown(): void {
|
||||||
|
this.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Timer {
|
||||||
|
timerId: symbol;
|
||||||
|
repeating: boolean;
|
||||||
|
delay: number;
|
||||||
|
callback: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation of TimeService, uses Date.now() as time source and setTimeout/setInterval for timers.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class NativeTimeService extends TimeService<NativeTimer> implements OnApplicationShutdown {
|
||||||
|
public get now(): number {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected startNativeTimer(timerId: symbol, repeating: boolean, callback: () => void, delay: number): NativeTimer {
|
||||||
|
// Wrap the caller's callback to make sure we clean up the registration.
|
||||||
|
const wrappedCallback = () => {
|
||||||
|
this.timers.delete(timerId);
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeout = repeating
|
||||||
|
? global.setInterval(wrappedCallback, delay)
|
||||||
|
: global.setTimeout(wrappedCallback, delay);
|
||||||
|
|
||||||
|
return { callback, timerId, repeating, delay, timeout };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected stopNativeTimer(reg: NativeTimer): void {
|
||||||
|
if (reg.repeating) {
|
||||||
|
global.clearInterval(reg.timeout);
|
||||||
|
} else {
|
||||||
|
global.clearTimeout(reg.timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeTimer extends Timer {
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
|
||||||
95
packages/backend/test/misc/GodOfTimeService.ts
Normal file
95
packages/backend/test/misc/GodOfTimeService.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { TimeService, Timer } from '@/core/TimeService.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake implementation of TimeService that allows manual control of time.
|
||||||
|
* When this service is used, the flow of time is fully stopped.
|
||||||
|
*
|
||||||
|
* Test cases can manually adjust the "now" parameter to move time forwards and backwards.
|
||||||
|
* When moving forward, timers (interval and timeout) will automatically fire as appropriate.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class GodOfTimeService extends TimeService<GodsOwnTimer> {
|
||||||
|
private _now = 0;
|
||||||
|
|
||||||
|
constructor(opts?: { start?: { at: number } | 'now' }) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
// Jump to the correct start time
|
||||||
|
if (opts?.start === 'now') {
|
||||||
|
this.resetToNow();
|
||||||
|
} else if (opts?.start?.at) {
|
||||||
|
this.resetTo(opts.start.at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or set the current time, in milliseconds since the unix epoch.
|
||||||
|
*/
|
||||||
|
public get now() {
|
||||||
|
return this._now;
|
||||||
|
}
|
||||||
|
public set now(value: number) {
|
||||||
|
// Moving backwards is allowed, for now.
|
||||||
|
if (value > this._now) {
|
||||||
|
// Fire all expiring timers in chronological order.
|
||||||
|
const expiringTimers = this.timers
|
||||||
|
.values()
|
||||||
|
.filter(t => t.expiresAt >= value)
|
||||||
|
.toArray()
|
||||||
|
.sort((a, b) => a.expiresAt - b.expiresAt);
|
||||||
|
|
||||||
|
// Since we sorted the list, this will progressively increase "now" as we handle later and later events.
|
||||||
|
for (const timer of expiringTimers) {
|
||||||
|
// When the timer fires, "now" should equal the time that was originally waited for.
|
||||||
|
this._now = timer.expiresAt;
|
||||||
|
|
||||||
|
// Cleanup first in case timer throws an exception.
|
||||||
|
this.timers.delete(timer.timerId);
|
||||||
|
timer.callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bump up to the final target value
|
||||||
|
this._now = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all timers and resets to time=0.
|
||||||
|
*/
|
||||||
|
public reset() {
|
||||||
|
this.resetTo(0);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Clears all timers and resets to the real-world time.
|
||||||
|
*/
|
||||||
|
public resetToNow() {
|
||||||
|
this.resetTo(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all timers and resets to a given time.
|
||||||
|
*/
|
||||||
|
public resetTo(to: number) {
|
||||||
|
this.timers.clear();
|
||||||
|
this.now = to;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected startNativeTimer(timerId: symbol, repeating: boolean, callback: () => void, delay: number): GodsOwnTimer {
|
||||||
|
const expiresAt = this.now + delay;
|
||||||
|
return { timerId, repeating, delay, expiresAt, callback };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected stopNativeTimer(): void {
|
||||||
|
// no-op - fake timers have no side effects to clean up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GodsOwnTimer extends Timer {
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue