Skip to content

Commit

Permalink
heartbeat [nfc]: extend heartbeat-test event system
Browse files Browse the repository at this point in the history
Because the mocked version of `Date.now()` doesn't actually advance
while we have timers running, a succession of events recorded between
timer-advances will appear to take place on the same timestamp.

We therefore extend the timestamp system using dual numbers [1].
Conveniently, these also provide a sound mathematical basis with which
to formalize away the fuzziness involved in timing guarantees on a
(barely-even-)soft-real-time system.

[1] https://en.wikipedia.org/wiki/Dual_number
  • Loading branch information
rk-for-zulip committed Dec 17, 2019
1 parent 0d590a5 commit 5491e5e
Showing 1 changed file with 129 additions and 6 deletions.
135 changes: 129 additions & 6 deletions src/presence/__tests__/heartbeat-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,107 @@ describe('Heartbeat', () => {
// arbitrarily, one full hour between heartbeats
const HEARTBEAT_TIME = 60 * 60 * 1000;

/**
* "The course of false time never did run smooth." -- Shakespeare, probably
*
* Since we're using fake timers, two sequential events can appear to occur
* "at the same time". This can confuse simple comparisons terribly.
*
* We therefore represent an event time as a dual number (a + bε). The real
* part (a) is the readout of Date.now(), while the infinitesimal part (b) is
* an integer which differentiates the two.
*
* The dual number itself we represent as a 2-tuple [a, b]. (We could just say
* "we represent times as 2-tuples with the lexicographic ordering", but the
* "real"/"infinitesimal" terminology is useful.)
*/
type TimeVector = [number, number];

type HeartbeatEventType = 'activate' | 'deactivate' | 'callback';
type HeartbeatEventRaw = {
type: HeartbeatEventType,
timeVector: TimeVector,
};
type HeartbeatEvent = {
type: 'activate' | 'deactivate' | 'callback',
type: HeartbeatEventType,
time: number,
};

/**
* Create an (order-preserving) homomorphism M: V → ℝ, for some finite set V
* of TimeVectors.
*/
const createTimeMapping = (times: TimeVector[]): (TimeVector => number) => {
/*
The reals ℝ and the dual numbers ⅅ are of different order types: in
particular, there is no injective homomorphism ⅅ ↣ ℝ. However, as we only
need to worry about a known, finite domain V ⊊ ⅅ, we have our choice of
uncountably many homomorphisms. We (ab)use this freedom to encode
additional useful properties in our chosen map.
Below, we denote arbitrary TimeVectors as v, v1, v2, ... and their images
as t = M(v), t1 = M(v1), t2 = M(v2)...; the real and infinitesimal parts
of a TimeVector are denoted Re(v) and In(v).
We ensure that the mapping we create has the following qualities:
(1) v1 < v2 ⇒ t1 < t2.
This is the whole point, of course.
(2) |t1 - t2| ≤ 1 ⟺ Re(v1) = Re(v2).
This allows us to trivially test, without keeping around additional
metadata, whether two times differ from each other only infinitesimally.
This is useful for testing predicates which should approximately hold:
we formalize that as meaning the predicate holds everywhere except
possibly over finitely many intervals of strictly infinitesimal measure.
(3) ∀k ∈ ℝ: t1 + k = M(v1 + k).
This ensures that it makes sense to add a number representing a
sufficiently-large time interval (like HEARTBEAT_TIME) to values in the
image of M. The result will be a plausible, correctly-ordered distance.
(This property can't actually be satisfied for all possible inputs -- at
least, not while also satisfying (1) and (2). We _could_ substitute one
of
(2′) ∃ℓ ∈ ℝ: |t1 - t2| ≤ ℓ ⟺ Re(v1) = Re(v2)
(3′) ∃ℎ ∈ ℝ: t1 + ℎ⋅k = Re(v1 + k)
to satisfy instead, but then we'd have to pass around and use at least
one scaling factor in various places. It's simpler just to fail if we
can't use ℓ = ℎ = 1.)
(4) The values of Re(v) and In(v) are human-readable in t.
This is just for convenience when looking at logs. We don't rely on it
for correctness.
*/

// minimum difference between real parts of consecutive timeVector groups
const min_delta_a: number = Math.min(
...slidingWindow(times.map(([a]) => a), 2).map(([l, r]) => r - l || Infinity),
);

// This ensures that (2) and (3) can hold simultaneously. (The unusual
// condition ensures that we also throw on NaN.)
if (!(min_delta_a >= 2)) {
throw new Error(`minimum real Δt ${min_delta_a} is too low!`);
}

// maximum value of infinitesimal part of timeVector
const max_b: number = Math.max(...times.map(([, b]) => b));

// Any value greater than max_b will satisfy (2); we choose a power of ten
// to also satisfy (4).
const adjustment = 10 ** (1 + Math.floor(Math.log10(max_b + 1)));

return ([a, b]) => a + b / adjustment;
};

/**
* Wrapper class for Heartbeat.
*
Expand All @@ -80,16 +176,32 @@ describe('Heartbeat', () => {
* by test cases.
*/
class JestHeartbeatHelper {
callback: CallbackType;
heartbeat: Heartbeat;
_events: HeartbeatEvent[] = [];

/** List of heartbeats used in the current test. */
static _currentHeartbeats: Array<JestHeartbeatHelper> = [];

// ==============================================================
// Event tracking

_last_event_time: TimeVector = [-Infinity, 0];
_events_raw: HeartbeatEventRaw[] = [];
_events: HeartbeatEvent[] | null = null; // for memoization

_recordEvent(type: 'activate' | 'deactivate' | 'callback') {
this._events.push({ type, time: Date.now() });
const now = Date.now();
const [lastA, lastB] = this._last_event_time;
const timeVector: TimeVector = lastA === now ? [now, lastB + 1] : [now, 0];

this._events_raw.push({ type, timeVector });
this._last_event_time = timeVector;
this._events = null; // clear cache
}

// ==============================================================
// Public interface

callback: CallbackType;
heartbeat: Heartbeat;

constructor() {
this.callback = jest.fn().mockImplementation(() => this._recordEvent('callback'));
this.heartbeat = new Heartbeat(this.callback, HEARTBEAT_TIME);
Expand All @@ -110,6 +222,17 @@ describe('Heartbeat', () => {
}

getEvents(): $ReadOnlyArray<HeartbeatEvent> {
if (this._events === null) {
const mapping: TimeVector => number = createTimeMapping(
this._events_raw.map(({ timeVector }) => timeVector),
);

this._events = this._events_raw.map(({ type, timeVector }) => ({
type,
time: mapping(timeVector),
}));
}

return this._events;
}

Expand Down

0 comments on commit 5491e5e

Please sign in to comment.