@consento/hlc
Version:
A Hybrid Logical Clock implementation that comes with a codec to store it as Uint8Array (compatible to codecs)
297 lines (289 loc) • 9.19 kB
JavaScript
const { test } = require('fresh-tape')
const { Timestamp } = require('.')
const HLC = require('.')
test('.now() returns a new timestamp', t => {
const clock = new HLC()
t.equals(clock.maxOffset, 0n)
t.equals(clock.toleratedForwardClockJump, 0n)
t.equals(clock.wallTimeUpperBound, 0n)
const time = clock.now()
t.equals(time.logical, 0)
t.ok(time instanceof HLC.Timestamp)
const time2 = clock.now()
t.equals(time2.compare(time), 1)
t.end()
})
test('.update() can override the internal clock', t => {
const clock = new HLC()
const time = clock.now()
time.wallTime += BigInt(1e9) // Stepping 1s into the future
clock.update(time)
const time2 = clock.now()
t.equals(time2.wallTime, time.wallTime)
t.end()
})
test('repeat clocks on the same walltime increment logical parts', t => {
const clock = new HLC()
const time = clock.now()
time.wallTime += BigInt(1e9) // Stepping 1s into the future
clock.update(time)
const time2 = clock.now()
const time3 = clock.now()
t.equals(time2.wallTime, time3.wallTime)
t.equals(time2.logical, 2)
t.equals(time3.logical, 3)
t.end()
})
test('Timestamp comparison', t => {
const a = new HLC.Timestamp(0, 0)
const b = new HLC.Timestamp(0, 1)
const c = new HLC.Timestamp(1, 1)
t.equals(a.compare(a), 0)
t.equals(a.compare(b), -1)
t.equals(a.compare(c), -1)
t.equals(b.compare(a), 1)
t.equals(b.compare(b), 0)
t.equals(b.compare(c), -1)
t.equals(c.compare(a), 1)
t.equals(c.compare(b), 1)
t.equals(c.compare(c), 0)
t.equals(HLC.Timestamp.bigger(a, a), a)
t.equals(HLC.Timestamp.bigger(a, b), b)
t.equals(HLC.Timestamp.bigger(a, c), c)
t.equals(HLC.Timestamp.bigger(b, a), b)
t.equals(HLC.Timestamp.bigger(b, b), b)
t.equals(HLC.Timestamp.bigger(b, c), c)
t.equals(HLC.Timestamp.bigger(c, a), c)
t.equals(HLC.Timestamp.bigger(c, b), c)
t.equals(HLC.Timestamp.bigger(c, c), c)
t.end()
})
test('JSON de/serialization', t => {
const clock = new HLC({
wallTime: () => 0n,
maxOffset: 3,
toleratedForwardClockJump: 4,
wallTimeUpperBound: BigInt(Number.MAX_SAFE_INTEGER + 1),
last: new HLC.Timestamp(1)
})
const expectedJSON = {
maxOffset: 3,
wallTimeUpperBound: '0x20000000000000',
toleratedForwardClockJump: 4,
last: {
wallTime: 1,
logical: 0
}
}
t.deepEquals(clock.toJSON(), expectedJSON)
const restored = new HLC({
wallTime: () => 0n,
...clock.toJSON()
})
t.deepEquals(restored.toJSON(), expectedJSON)
t.end()
})
test('Bytes de/encoding', t => {
const time = new HLC.Timestamp(15n, 5)
const bytes = time.encode()
t.ok(bytes instanceof Uint8Array)
t.equals(bytes.length, 12)
const restored = HLC.codec.decode(bytes)
t.equals(restored.compare(time), 0)
t.end()
})
test('Bytes de-/encoding with byob and offset', t => {
const time = new HLC.Timestamp(0x1fedcba987654321n, 0xabcdef98)
const bytes = Buffer.alloc(20)
t.equals(HLC.codec.encode(time, bytes).toString('hex'), '1fedcba987654321abcdef980000000000000000')
bytes.fill(0)
t.equals(time.encode(bytes, 2), bytes)
t.equals(bytes.toString('hex'), '00001fedcba987654321abcdef98000000000000')
const restored = HLC.codec.decode(bytes, 2)
t.equals(restored.compare(time), 0)
t.end()
})
test('Bytes encoding on a too-small array', t => {
t.throws(
() => new HLC.Timestamp().encode(new Uint8Array(2)),
new Error('The provided Uint8Array is too small. 12 byte required but only 2 byte given.')
)
t.end()
})
test('restoring from a past timestamp', t => {
const clockOlder = new HLC({
wallTime: () => 0n,
last: new HLC.Timestamp(1)
})
t.equals(clockOlder.last.wallTime, 1n)
const clockNewer = new HLC({
wallTime: () => 2n,
last: new HLC.Timestamp(1)
})
t.equals(clockNewer.last.wallTime, 2n)
t.end()
})
test('updating with newer logical', t => {
const clock = new HLC({
wallTime: () => 0n,
last: new HLC.Timestamp(1, 2)
})
clock.update(new HLC.Timestamp(1, 5))
t.equals(clock.last.wallTime, 1n)
t.equals(clock.last.logical, 6)
t.end()
})
test('updating with older logical', t => {
const clock = new HLC({
wallTime: () => 0n,
last: new HLC.Timestamp(1, 5)
})
clock.update(new HLC.Timestamp(1, 2))
t.equals(clock.last.wallTime, 1n)
t.equals(clock.last.logical, 6)
t.end()
})
test('forward clock jump error', t => {
let myTime = 1n
const wallTime = () => myTime
const clockNoError = new HLC({ wallTime })
const clockError = new HLC({ wallTime, toleratedForwardClockJump: 10 })
t.equals(clockError.toleratedForwardClockJump, 10n)
myTime = 2n
t.deepEquals(clockError.now(), clockNoError.now())
myTime = 20n
t.equals(clockNoError.now().compare(new HLC.Timestamp(20, 0)), 0)
t.throws(() => clockError.now(), new HLC.ForwardJumpError(18n, 10n))
t.end()
})
test('maxOffset error', t => {
const wallTime = () => 0n
const clockNoError = new HLC({ wallTime })
const clockError = new HLC({ wallTime, maxOffset: 10n })
t.equals(clockError.maxOffset, 10n)
const jumpStamp = new HLC.Timestamp(20n)
clockNoError.update(jumpStamp)
t.deepEquals(clockNoError.now().toJSON(), {
wallTime: 20,
logical: 2
})
t.throws(() => clockError.update(jumpStamp), new HLC.ClockOffsetError(20n, 10n))
t.end()
})
test('wall overflow error', t => {
t.throws(() => {
(new HLC({ wallTime: () => 18446744073709551615n + 1n })).now()
}, new HLC.WallTimeOverflowError(18446744073709551616n, 18446744073709551615n))
t.throws(() => {
(new HLC({
wallTime: () => 2n,
wallTimeUpperBound: 1
})).now()
}, new HLC.WallTimeOverflowError(2n, 1n))
t.end()
})
test('logical overflow leads to physical increase', t => {
const clock = new HLC({
wallTime: () => 0n,
last: new Timestamp(0, 0xFFFFFFFF - 1)
})
t.deepEquals(clock.now().toJSON(), {
wallTime: 0,
logical: 0xFFFFFFFF
})
t.deepEquals(clock.now().toJSON(), {
wallTime: 1,
logical: 0
})
t.end()
})
test('example: usage', t => {
const clock = new HLC({
wallTime: require('bigint-time'), // [default=bigint-time] alternative implementation, in case `bigint-time` doesn't solve your needs
maxOffset: 0, // [default=0] Maximum time in nanosecons that another timestamp may exceed the wall-clock before an error is thrown.
toleratedForwardClockJump: 0, // [default=0] Maximum time in nanoseconds that the wall-clock may exceed the previous timestamp before an error is thrown. Setting it 0 will disable it.
wallTimeUpperBound: 0, // [default=0] will throw an error if the wallTime exceeds this value. Setting it to 0 will limit it to the uint64 max-value.
last: null // [default=undefined] The last known timestamp to start off, useful for restoring a clock's state
})
const timestamp = clock.now()
// Makes sure that the next timestamp is bigger than the other timestamp
clock.update(new HLC.Timestamp(1))
// Turn the clock into an Uint8Array
const bytes = timestamp.encode() // Shortform for HLC.codec.encode(timestamp)
HLC.codec.decode(bytes)
timestamp.encode(Buffer.allocUnsafe(16)) // If you prefer a Buffer instance
t.end()
})
test('example: clock drift', t => {
try {
const clock = new HLC({
maxOffset: 60 * 1e9 /* 1 minute in nanoseconds */
})
const timestamp = clock.now()
clock.update(
new HLC.Timestamp(timestamp.wallTime + BigInt(120 * 1e9))
)
t.fail('error should have thrown')
} catch (error) {
if (error.type !== 'ClockOffsetError') {
throw error
}
t.deepEquals(error, new HLC.ClockOffsetError(error.offset, error.maxOffset))
}
t.end()
})
test('example: clock drift', t => {
try {
const wallTimeUpperBound = BigInt(new Date('2022-01-01T00:00:00.000Z').getTime()) * BigInt(1e6)
const clock = new HLC({
wallTime: () => wallTimeUpperBound + 1n, // Faking a wallTime that is beyond the max we allow
wallTimeUpperBound
})
clock.now()
t.fail('error should have thrown')
} catch (error) {
if (error.type !== 'WallTimeOverflowError') {
throw error
}
t.deepEquals(error, new HLC.WallTimeOverflowError(error.time, error.maxTime))
}
t.end()
})
test('example: clock drift', t => {
const clock = new HLC({
toleratedForwardClockJump: 1e6 /* 1 ms in nanoseconds */
})
setTimeout(() => {
try {
clock.now()
t.fail('error should have thrown')
} catch (error) {
if (error.type !== 'ForwardJumpError') {
throw error
}
t.deepEquals(error, new HLC.ForwardJumpError(error.timejump, error.tolerance))
}
t.end()
}, 10) // we didn't update the clock in 10 seconds
})
test('example: drift monitoring', t => {
class CockroachHLC extends HLC {
constructor (opts) {
super(opts)
this.monotonicityErrorCount = 0
}
validateOffset (offset) {
super.validateOffset(offset)
if (this.maxOffset > 10n && offset > this.maxOffset / 10n) {
this.monotonicityErrorCount += 1
}
}
}
const clock = new CockroachHLC({
wallTime: () => 10n,
maxOffset: 20
})
clock.update(new Timestamp(13))
t.equals(clock.monotonicityErrorCount, 1)
t.end()
})