@lf-lang/reactor-ts
Version:
A reactor-oriented programming framework in TypeScript
699 lines (625 loc) • 20.7 kB
text/typescript
/**
* Time-related helper functions for reactors.
* @author Marten Lohstroh (marten@berkeley.edu)
* @author Matt Weber (matt.weber@berkeley.edu)
*/
import MicroTime from "microtime";
/**
* Units (and conversion factors from nanoseconds) for time values.
**/
export enum TimeUnit {
nsec = 1,
usec = 1000,
msec = 1000000,
sec = 1000000000,
minute = 60000000000,
hour = 3600000000000,
day = 86400000000000,
week = 604800000000000
}
/**
* A time value given in nanosecond precision. To prevent overflow (which would
* occur for time intervals spanning more than 0.29 years if a single JavaScript
* number, which has 2^53 bits of precision, were to be used), we use _two_
* numbers to store a time value. The first number denotes the number of whole
* seconds in the interval; the second number denotes the remaining number of
* nanoseconds in the interval. This class serves as a base class for
* `UnitBasedTimeValue`, which provides the convenience of defining time values
* as a single number accompanied by a unit.
* @see TimeUnit
* @see UnitBasedTimeValue
*/
export class TimeValue {
/**
* Create a new time value. Both parameters must be non-negative integers;
* an error will be thrown otherwise. The second parameter is optional.
* @param seconds Number of seconds in the interval.
* @param nanoseconds Remaining number of nanoseconds (defaults to zero).
*/
constructor(
protected seconds: number,
protected nanoseconds: number = 0
) {
if (
!Number.isInteger(seconds) ||
!Number.isInteger(nanoseconds) ||
seconds < 0 ||
nanoseconds < 0
) {
if (seconds !== Number.MIN_SAFE_INTEGER) {
throw new Error(
"Cannot instantiate a time interval based on negative or non-integer numbers."
);
}
}
}
static zero(): TimeValue {
return new TimeValue(0, 0);
}
static never(): TimeValue {
return new TimeValue(Number.MIN_SAFE_INTEGER, 0);
}
static forever(): TimeValue {
return new TimeValue(Number.MAX_SAFE_INTEGER, 0);
}
static secsAndNs(seconds: number, nanoSeconds: number): TimeValue {
return new TimeValue(seconds, nanoSeconds);
}
static secs(seconds: number): TimeValue {
return TimeValue.secsAndNs(seconds, 0);
}
static sec(seconds: number): TimeValue {
return TimeValue.secsAndNs(seconds, 0);
}
static msecs(microseconds: number): TimeValue {
return TimeValue.withUnits(microseconds, TimeUnit.msec);
}
static msec(microseconds: number): TimeValue {
return TimeValue.withUnits(microseconds, TimeUnit.msec);
}
static usecs(nanoseconds: number): TimeValue {
return TimeValue.withUnits(nanoseconds, TimeUnit.usec);
}
static usec(nanoseconds: number): TimeValue {
return TimeValue.withUnits(nanoseconds, TimeUnit.usec);
}
static nsecs(nanoseconds: number): TimeValue {
return TimeValue.withUnits(nanoseconds, TimeUnit.nsec);
}
static nsec(nanoseconds: number): TimeValue {
return TimeValue.withUnits(nanoseconds, TimeUnit.nsec);
}
// FIXME: Add more convenience methods
/**
* Return a new time value that denotes the duration of the time interval
* encoded by this time value plus the time interval encoded by the time
* value given as a parameter.
* @param other The time value to add to this one.
*/
add(other: TimeValue): TimeValue {
if (
(this.isNever() && other.isForever()) ||
(this.isForever() && other.isNever())
) {
// The sum of minus infinity and infinity is zero.
return TimeValue.zero();
} else if (this.isNever() || other.isNever()) {
// The sum of minus infinity and a normal tag is minus infinity.
return TimeValue.never();
} else if (this.isForever() || other.isForever()) {
// The sum of infinity and a normal tag is infinity.
return TimeValue.forever();
}
let seconds = this.seconds + other.seconds;
let nanoseconds = this.nanoseconds + other.nanoseconds;
if (nanoseconds >= TimeUnit.sec.valueOf()) {
// Carry the second.
seconds += 1;
nanoseconds -= TimeUnit.sec;
}
// Check an overflow.
if (!Number.isSafeInteger(seconds)) {
// The overflow happens.
return TimeValue.forever();
} else {
return TimeValue.secsAndNs(seconds, nanoseconds);
}
}
/**
* Return a new time value that denotes the duration of the time interval
* encoded by this time value minus the time interval encoded by the time
* value given as a parameter.
* @param other The time value to subtract from this one.
*/
subtract(other: TimeValue): TimeValue {
let s = this.seconds - other.seconds;
let ns = this.nanoseconds - other.nanoseconds;
if (ns < 0) {
// Borrow a second
s -= 1;
ns += TimeUnit.sec;
}
if (s < 0) {
throw new Error("Negative time value.");
}
return TimeValue.secsAndNs(s, ns);
}
multiply(factor: number): TimeValue {
let seconds = this.seconds * factor;
let nanoseconds = this.nanoseconds * factor;
if (nanoseconds >= TimeUnit.sec.valueOf()) {
// Carry seconds.
const carry = Math.floor(nanoseconds / TimeUnit.sec);
seconds += carry;
nanoseconds -= carry * TimeUnit.sec;
}
return TimeValue.secsAndNs(seconds, nanoseconds);
}
difference(other: TimeValue): TimeValue {
if (this.isEarlierThan(other)) {
return other.subtract(this);
} else {
return this.subtract(other);
}
}
/**
* Return true if this time value denotes a time interval of equal length as
* the interval encoded by the time value given as a parameter.
* @param other The time value to compare to this one.
*/
isEqualTo(other: TimeValue): boolean {
return (
this.seconds === other.seconds && this.nanoseconds === other.nanoseconds
);
}
/**
* Return true if this denotes a time interval of length zero.
*/
isZero(): boolean {
if (this.seconds === 0 && this.nanoseconds === 0) {
return true;
} else {
return false;
}
}
isNever(): boolean {
if (this.seconds === Number.MIN_SAFE_INTEGER && this.nanoseconds === 0) {
return true;
} else {
return false;
}
}
isForever(): boolean {
if (this.seconds === Number.MAX_SAFE_INTEGER) {
return true;
} else {
return false;
}
}
/**
* Return true if this time value denotes a time interval of smaller length
* than the time interval encoded by the time value given as a parameter;
* return false otherwise.
*
* NOTE: Performing this comparison involves a conversion to a big integer
* and is therefore relatively costly.
*
* @param other The time value to compare to this one.
* @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt|BigInt} for further information.
*/
isEarlierThan(other: TimeValue): boolean {
if (this.seconds < other.seconds) {
return true;
}
if (
this.seconds === other.seconds &&
this.nanoseconds < other.nanoseconds
) {
return true;
}
return false;
}
/**
* Return true if this time value is later than the time given as a parameter.
*
* @param other The time value to compare to this one.
*/
isLaterThan(other: TimeValue): boolean {
if (this.seconds > other.seconds) {
return true;
}
if (
this.seconds === other.seconds &&
this.nanoseconds > other.nanoseconds
) {
return true;
}
return false;
}
/**
* Return a millisecond representation of this time value.
*/
public toMilliseconds(): number {
return this.seconds * 1000 + Math.ceil(this.nanoseconds / 1000000);
}
/**
* Print the number of seconds and nanoseconds in the time interval encoded
* by this time value.
*/
public toString(): string {
return `(${this.seconds} secs; ${this.nanoseconds} nsecs)`;
}
/**
* Return a tuple that holds the seconds and remaining nanoseconds that
* jointly represent this time value.
*/
public toTimeTuple(): [number, number] {
return [this.seconds, this.nanoseconds];
}
/**
* Get a 64 bit binary, little endian representation of this TimeValue.
* Used by federates.
*/
public toBinary(): Buffer {
const buff = Buffer.alloc(8);
if (this.seconds === Number.MIN_SAFE_INTEGER) {
buff.writeBigUInt64LE(BigInt(0x8000000000000000n), 0);
} else if (this.seconds === Number.MAX_SAFE_INTEGER) {
buff.writeBigUInt64LE(BigInt(0x7fffffffffffffffn), 0);
} else {
const billion = BigInt(TimeUnit.sec);
const bigTime = BigInt(this.nanoseconds) + BigInt(this.seconds) * billion;
// Ensure the TimeValue fits into a 64 unsigned integer.
const clampedTime = BigInt.asUintN(64, bigTime);
if (clampedTime !== bigTime) {
throw new Error(
`TimeValue ${this.toString()} is too big to fit into ` +
"a 64 bit unsigned integer"
);
}
buff.writeBigUInt64LE(bigTime, 0);
}
return buff;
}
/**
* Create a TimeValue from a 64bit little endian unsigned integer in a buffer.
* @param buffer A 64 bit unsigned integer. Little endian.
*/
public static fromBinary(buffer: Buffer): TimeValue {
const billion = BigInt(TimeUnit.sec);
// To avoid overflow and floating point errors, work with BigInts.
const bigTime = buffer.readBigUInt64LE(0);
if (bigTime === BigInt(0x8000000000000000n)) {
return TimeValue.never();
} else if (bigTime === BigInt(0x7fffffffffffffffn)) {
return TimeValue.forever();
}
const bigSeconds = bigTime / billion;
const bigNSeconds = bigTime % billion;
return TimeValue.secsAndNs(Number(bigSeconds), Number(bigNSeconds));
}
/**
* Give a value and time unit, return a new time value.
*
* The value is a `number` that is required to be
* a positive integer. The time unit must be a member of the `TimeUnit` enum.
* @param value A number (which must be a positive integer) that denotes
* the length of the specified time interval, expressed as a multiple of
* the given time unit.
* @param unit The unit of measurement that applies to the given value.
* @returns
*/
public static withUnits(value: number, unit: TimeUnit): TimeValue {
if (!Number.isInteger(value)) {
throw new Error("Non-integer time values are illegal.");
}
if (value < 0) {
throw new Error("Negative time values are illegal.");
}
const billion = BigInt(TimeUnit.sec);
// To avoid overflow and floating point errors, work with BigInts.
const bigT = BigInt(value) * BigInt(unit);
const bigSeconds = bigT / billion;
if (bigSeconds > Number.MAX_SAFE_INTEGER) {
throw new Error("Unable to instantiate time value: value too large.");
}
return TimeValue.secsAndNs(Number(bigSeconds), Number(bigT % billion));
}
}
/**
* A superdense time instant, represented as a time value `time` (i.e.,
* time elapsed since Epoch) paired with a microstep index to keep track of
* iterations in superdense time. For each such iteration, `time` remains
* the same, but `microstep` is incremented.
*/
export class Tag {
/**
* Time elapsed since Epoch.
*/
readonly time: TimeValue;
/**
* Create a new tag using a time value and a microstep.
* @param timeSinceEpoch Time elapsed since Epoch.
* @param microstep Superdense time index.
*/
constructor(
timeSinceEpoch: TimeValue,
readonly microstep: number = 0
) {
if (!Number.isInteger(microstep)) {
throw new Error("Microstep must be integer.");
}
if (microstep < 0) {
throw new Error("Microstep must be positive.");
}
this.time = timeSinceEpoch;
}
/**
* Return `true` if this time instant is earlier than the tag given as a
* parameter, false otherwise. For two tags with an equal `time`, one
* instant is earlier than the other if its `microstep` is less than the
* `microstep` of the other.
* @param other The time instant to compare against this one.
*/
isSmallerThan(other: Tag): boolean {
return (
this.time.isEarlierThan(other.time) ||
(this.time.isEqualTo(other.time) && this.microstep < other.microstep)
);
}
/**
* Return `true` if the tag is smaller than or equal to the tag given as a parameter.
* @param other The time instant to compare against this one.
*/
isSmallerThanOrEqualTo(other: Tag): boolean {
return !this.isGreaterThan(other);
}
/**
* Return `true` if the tag is greater than the tag given as a parameter.
* @param other The time instant to compare against this one.
*/
isGreaterThan(other: Tag): boolean {
return (
this.time.isLaterThan(other.time) ||
(this.time.isEqualTo(other.time) && this.microstep > other.microstep)
);
}
/**
* Return `true` if the tag is greater than or equal to the tag given as a parameter.
* @param other The time instant to compare against this one.
*/
isGreaterThanOrEqualTo(other: Tag): boolean {
return !this.isSmallerThan(other);
}
/**
* Return `true` if this tag is simultaneous with the tag given as
* a parameter, false otherwise. Both `time` and `microstep` must be equal
* for two tags to be simultaneous.
* @param other The time instant to compare against this one.
*/
isSimultaneousWith(other: Tag): boolean {
return (
this.time.isEqualTo(other.time) && this.microstep === other.microstep
);
}
/**
* Get a new time instant that represents this time instant plus
* the given delay. The `microstep` of this time instant is ignored;
* the returned time instant has a `microstep` of zero if the delay
* is greater than zero. If the delay equals zero or is undefined,
* the tag is returned unchanged with its current `microstep`.
* @param delay The time interval to add to this time instant.
*/
getLaterTag(delay: TimeValue | undefined): Tag {
if (delay === undefined) {
return this;
} else if (delay.isZero()) {
return new Tag(this.time, this.microstep + 1);
} else {
return new Tag(delay.add(this.time), 0);
}
}
/**
* Get a new time instant that has the same `time` but n `microsteps` later.
*/
getMicroStepsLater(n: number): Tag {
return new Tag(this.time, this.microstep + n);
}
/**
* Obtain a time interval that represents the absolute (i.e., positive) time
* difference between this time interval and the tag given as a parameter.
* @param other The time instant for which to compute the absolute
* difference with this time instant.
*/
getTimeDifference(other: Tag): TimeValue {
if (this.isSmallerThan(other)) {
return other.time.subtract(this.time);
} else {
return this.time.subtract(other.time);
}
}
/**
* Return a human-readable string presentation of this time instant.
*/
public toString(): string {
return `(${this.time.toString()}, ${this.microstep})`;
}
/**
* Get a 12-byte buffer for the tag.
* Used by federates.
*/
public toBinary(): Buffer {
const timeBuffer = this.time.toBinary();
const buf = Buffer.alloc(12);
timeBuffer.copy(buf, 0, 0);
buf.writeInt32LE(this.microstep, 8);
return buf;
}
/**
* Create a Tag from 8-byte TimeValue and 4-byte microstep in a buffer.
* @param buffer A buffer of TimeValue and microstep.
*/
public static fromBinary(buffer: Buffer): Tag {
const timeBuffer = Buffer.alloc(8);
buffer.copy(timeBuffer, 0, 0, 8);
const time = TimeValue.fromBinary(timeBuffer);
const microstep = buffer.readUInt32LE(8);
return new Tag(time, microstep);
}
}
/**
* A descriptor to be used when scheduling events to denote as to whether
* an event should occur with a delay relative to _physical time_ or _logical
* time_.
*/
export enum Origin {
physical = "physical",
logical = "logical"
}
/**
* Return a time value that reflects the current physical time as reported
* by the platform.
*/
export function getCurrentPhysicalTime(): TimeValue {
const t = MicroTime.now();
const seconds: number = Math.floor(t / 1000000);
const nseconds: number = t * 1000 - seconds * TimeUnit.sec;
return TimeValue.secsAndNs(seconds, nseconds);
}
/**
* Simple but accurate alarm that makes use of high-resolution timer.
* The algorithm is inspired by nanotimer, written by Kevin Briggs.
* @author Marten Lohstroh (marten@berkeley.edu)
*/
export class Alarm {
/**
* Handle for regular timeout, used for delays > 25 ms.
*/
deferredRef: NodeJS.Timeout | undefined;
/**
* Handle for immediate, used for polling once the remaining delay is < 25
* ms.
*/
immediateRef: NodeJS.Immediate | undefined;
/**
* Delay in terms of milliseconds, used when deferring to regular timeout.
*/
loResDelay = 0;
/**
* Start of the delay interval; tuple of seconds and nanoseconds.
*/
hiResStart: [number, number] = [0, 0];
/**
* The delay interval in high resolution; tuple of seconds and nanoseconds.
*/
hiResDelay: [number, number] = [0, 0];
/**
* Indicates whether the alarm has been set or not.
*/
active = false;
/**
* Disable any scheduled timeouts or immediate events, and set the timer to
* inactive.
*/
unset(): void {
if (this.deferredRef != null) {
clearTimeout(this.deferredRef);
this.deferredRef = undefined;
}
if (this.immediateRef != null) {
clearImmediate(this.immediateRef);
this.immediateRef = undefined;
}
this.active = false;
}
/**
* Once the alarm has been initialized, see if the task can be performed or
* a longer wait is necessary.
* @param task The task to perform.
* @param callback Optional callback used to report the wait time.
*/
private try(
task: () => void,
callback?: (waitTime: TimeValue) => void
): void {
// Record the current time.
const hiResDif = process.hrtime(this.hiResStart);
// See whether the requested delay has elapsed.
if (
this.hiResDelay[0] < hiResDif[0] ||
(this.hiResDelay[0] === hiResDif[0] && this.hiResDelay[1] < hiResDif[1])
) {
// No more immediates a scheduled.
this.immediateRef = undefined;
// The delay has elapsed; perform the task.
if (this.active) {
// If this attempt is the result of a deferred request, perform
// the task synchronously.
this.active = false;
task();
if (callback != null) {
callback(TimeValue.secsAndNs(...hiResDif));
}
} else {
// If this attempt is the result of a direct call to `set`, push
// task onto the nextTick queue. This unwinds the stack, but it
// bypasses the regular event queue. If events are recursively
// requested to be performed with zero or near zero delay, this
// will not cause the call stack to exceed its maximum size, but
// it will starve I/O.
this.active = true;
process.nextTick(() => {
if (this.active) {
this.active = false;
task();
if (callback != null) {
callback(TimeValue.secsAndNs(...hiResDif));
}
}
});
}
} else {
// The delay has not yet elapsed.
// The following logic is based on the implementation of nanotimer.
if (this.loResDelay > 25) {
if (!this.active) {
this.deferredRef = setTimeout(() => {
this.try(task, callback);
}, this.loResDelay - 25);
} else {
this.deferredRef = undefined;
this.immediateRef = setImmediate(() => {
this.try(task, callback);
});
}
} else {
this.immediateRef = setImmediate(() => {
this.try(task, callback);
});
}
this.active = true;
}
}
/**
* Set the alarm.
* @param task The task to be performed.
* @param delay The time has to elapse before the task can be performed.
* @param callback Optional callback used to report the wait time.
*/
public set(
task: () => void,
delay: TimeValue,
callback?: (waitTime: TimeValue) => void
): void {
// Reset the alarm if it was already active.
if (this.active) {
this.unset();
}
// Compute the delay
this.hiResDelay = delay.toTimeTuple();
this.loResDelay = delay.toMilliseconds();
// Record the beginning of the delay interval.
this.hiResStart = process.hrtime();
this.try(task, callback);
}
}