/hlc

A Hybrid Logical Clock implementation in JavaScript. You can use this in a decentralized system to sort statements created by tow separate devices.

Primary LanguageJavaScriptMIT LicenseMIT

Hybrid Logical Clock

@consento/hlc is a Hybrid Logical Clock implementation in JavaScript. You can use this in a decentralized system to sort statements created by tow separate devices.

TL;DR: This clock will use the system clock unless the system clock is behind another known timestamp, in which case it will increment a counter.

It is comparable to CockroachDB's implementation. It creates Timestamps with a nanosecond WallClock (using bigint-time) that supports de-/encoding to fixed size 96bit Uint8Arrays (compatible with codecs) or JSON object.

Usage

const HLC = require('@consento/hlc')
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)
const restored = HLC.codec.decode(bytes)

const buffer = timestamp.encode(Buffer.allocUnsafe(16)) // If you prefer a Buffer instance

FAQ

Clock Drift

TL;DR There is no one-size-fits all solution to clock drifts, so you need to consider your strategy for this.

In a decentralized system, the wallclock of a different device may be ahead of the current device's wall-clock (see "Clock Drift" at Wikipedia). This means we need to allow wallclocks in updates to be newer/bigger than our own.

But if we allow this a malicous or broken implementation could create a timestamp that is set to UINT64_MAX_VALUE with a logical clock of UINT32_MAX_VALUE and then we can not have a newer clock.

The maxOffset option comes into play when syncing with other devices and how accurate the timestamp is on these devices. If they are perfectly in sync a maxOffset=0 might be a good idea, but in real life conditions: 20 seconds up to a minute should be considered a good idea [ref].

Provoking a clock-drift error with a set maxOffset:

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))
)
ClockOffsetError: The received time is 119061ms ahead of the wall time, exceeding the 'maxOffset' limit of 60000ms..
    at HLC.update (/consento/hlc/index.js:166:15) {
  type: 'ClockOffsetError',
  offset: 119061660718n,
  maxOffset: 60000000000n
}

Wallclock Limits

TL;DR Timestamps can be broken and you may want to set a limit in order for the broken timestamps to not ruin the whole system.

Additional to the previous explanation, a clock of a users/server device may be set to any given number.

This means that your physical clock could simply be set to a ridiculous value that breaks the clocks functionality. The wallTimeUpperBound option allows you to prevent this and make sure that even if a devices clock is tuned to 11 your application/service can be fixed with an update.

For example: If you know that any application running will be updated within a two year period and you are okay for the older versions to not work anymore, then it makes sense to set wallTimeUpperBound to a fixed bigint timestamp of "now + 2years". Applications older than 2 years will stop working. Maybe 2 years is a bit tight, then you could set it to a 100 years.

Provoking a wall-time-verflow error with a set wallTimeUpperBound:

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()
WallTimeOverflowError: The wall time 1640995200000ms exceeds the max time of 1640995200000ms.
    at HLC.update (/consento/hlc/index.js:185:13)
    at HLC.now (/consento/hlc/index.js:154:17) {
  type: 'WallTimeOverflowError',
  time: 1640995200000000001n,
  maxTime: 1640995200000000000n
}

Runtime Clock Manipulation

TL;DR The system clock can be manipulated and you need to some extra effort to have some safeguard against it.

An alternative way to wallTimeUpperBound for restricting clock drifts is toleratedForwardClockJump. It will prevent that the wallClock exceeds the last known wall-clock by a given amount. Combined with an interval this makes sure that an error occurs when the physical clock suddenly jumped too far like this:

const clock = new HLC({
  toleratedForwardClockJump: 10 * 1e9 /* 10 seconds in nanoseconds */
})
// We update the clock every second, which should prevent that error from happening
setInterval(() => clock.now(), 1)

If we forget to update the clock in time or someone manipulates the clock an error is thrown:

const clock = new HLC({
  toleratedForwardClockJump: 1e6 /* 1 ms in nanoseconds */
})
setTimeout(() => clock.now(), 10) // we didn't update the clock in 10 seconds
ForwardJumpError: Detected a forward time jump of 11ms, which exceed the allowed tolerance of 1ms.
    at HLC.update (/consento/hlc/index.js:165:13)
    at HLC.now (/consento/hlc/index.js:157:17) {
  type: 'ForwardJumpError',
  timejump: 11036527n,
  tolerance: 1000000n
}

Clock drift monitoring

Running a system you may want to track how much a clock drifts.

The cockroach db counts how often the 1/10th of maxOffset is reached in the monotonicityErrorCount property.

Since there may be various ways how to track this, you can just extend the HLC class for monitoring any way you like:

const HLC = require('@consento/hlc')

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({ maxOffset: 20 })

Deserialization

The system allows not just the de-/serialization of timestamps using sortable Uint8Array or Buffers:

HLC.codec.encode(timestamp, [byob, offset])
HLC.codec.decode(uint8Array, offset=0)

The deserialization to JSON works simply using .toJSON:

const jsonObject = clock.now().toJSON()
const timestamp = new HLC.Timestamp(json)

However unlikely, it is also possible to save/restore the entire clock using JSON:

const clock = new HLC()
const restored = new HLC(clock.toJSON())

License

MIT