From 745bd55ffc4e1636d85a70db120249af06ca4837 Mon Sep 17 00:00:00 2001 From: Fabio Lima Date: Thu, 21 Apr 2022 14:53:06 -0300 Subject: [PATCH] feat: handle clock drift #18 Added drift tolerance of 10 seconds to preserve monotonicity when the system clock is adjusted by NTP after a small clock drift or when the system clock jumps back by 1 second due to leap second. The random component is incremented when the current time: - is the same as the previous time; - has moved backwards up to 10 seconds. The time component is also incremented if the random component is exceeded, which is quite rare. --- CHANGELOG.md | 9 +- README.md | 2 +- .../java/com/github/f4b6a3/ulid/Ulid.java | 13 ++- .../com/github/f4b6a3/ulid/UlidCreator.java | 2 +- .../com/github/f4b6a3/ulid/UlidFactory.java | 53 +++++++++-- .../f4b6a3/ulid/UlidFactoryDefaultfTest.java | 95 +++++++++++++++++++ .../f4b6a3/ulid/UlidFactoryMonotonicTest.java | 93 ++++++++++++++++++ 7 files changed, 250 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 536ac9e..8edc7e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ All notable changes to this project will be documented in this file. Nothing unreleased. -## [4.1.1] - 2021-11-06 +## [4.2.0] - 2022-04-21 + +Handle clock drift. #18 + +## [4.1.2] - 2021-11-06 Compare internal fields as unsigned integers. @@ -278,7 +282,8 @@ Project created as an alternative Java implementation of [ULID spec](https://git - Added `LICENSE` - Added test cases -[unreleased]: https://github.com/f4b6a3/ulid-creator/compare/ulid-creator-4.1.2...HEAD +[unreleased]: https://github.com/f4b6a3/ulid-creator/compare/ulid-creator-4.2.0...HEAD +[4.2.0]: https://github.com/f4b6a3/ulid-creator/compare/ulid-creator-4.1.2...ulid-creator-4.2.0 [4.1.2]: https://github.com/f4b6a3/ulid-creator/compare/ulid-creator-4.1.1...ulid-creator-4.1.2 [4.1.1]: https://github.com/f4b6a3/ulid-creator/compare/ulid-creator-4.1.0...ulid-creator-4.1.1 [4.1.0]: https://github.com/f4b6a3/ulid-creator/compare/ulid-creator-4.0.0...ulid-creator-4.1.0 diff --git a/README.md b/README.md index 1d03f96..ca9cf3c 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Add these lines to your `pom.xml`. com.github.f4b6a3 ulid-creator - 4.1.2 + 4.2.0 ``` See more options in [maven.org](https://search.maven.org/artifact/com.github.f4b6a3/ulid-creator). diff --git a/src/main/java/com/github/f4b6a3/ulid/Ulid.java b/src/main/java/com/github/f4b6a3/ulid/Ulid.java index 908ead1..da89da1 100644 --- a/src/main/java/com/github/f4b6a3/ulid/Ulid.java +++ b/src/main/java/com/github/f4b6a3/ulid/Ulid.java @@ -1,7 +1,7 @@ /* * MIT License * - * Copyright (c) 2020-2021 Fabio Lima + * Copyright (c) 2020-2022 Fabio Lima * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -584,12 +584,12 @@ public final class Ulid implements Serializable, Comparable { * millisecond; * * (2) This method can generate monotonic increasing ULIDs 99.999999999999992% - * ((2^80 - 10^9) / (2^80)) of the time, considering an unrealistic rate of - * 1,000,000,000 ULIDs per millisecond. + * ((2^80 - 10^9) / (2^80)) of the time within a single millisecond interval, + * considering an unrealistic rate of 1,000,000,000 ULIDs per millisecond. * * Due to (1) and (2), it does not throw the error message recommended by the - * specification. When an overflow occurs in the last 80 bits, the random - * component simply wraps around. + * specification. When an overflow occurs in the random 80 bits, the time + * component is simply incremented. * * @return a ULID */ @@ -599,8 +599,7 @@ public final class Ulid implements Serializable, Comparable { long newLsb = this.lsb + 1; // increment the LEAST significant bits if (newLsb == INCREMENT_OVERFLOW) { - // carrying the extra bit by incrementing the MOST significant bits - newMsb = (newMsb & 0xffffffffffff0000L) | ((newMsb + 1) & 0x000000000000ffffL); + newMsb += 1; // increment the MOST significant bits } return new Ulid(newMsb, newLsb); diff --git a/src/main/java/com/github/f4b6a3/ulid/UlidCreator.java b/src/main/java/com/github/f4b6a3/ulid/UlidCreator.java index 8446421..ef56a09 100644 --- a/src/main/java/com/github/f4b6a3/ulid/UlidCreator.java +++ b/src/main/java/com/github/f4b6a3/ulid/UlidCreator.java @@ -1,7 +1,7 @@ /* * MIT License * - * Copyright (c) 2020-2021 Fabio Lima + * Copyright (c) 2020-2022 Fabio Lima * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal diff --git a/src/main/java/com/github/f4b6a3/ulid/UlidFactory.java b/src/main/java/com/github/f4b6a3/ulid/UlidFactory.java index d5437c9..e2e4403 100644 --- a/src/main/java/com/github/f4b6a3/ulid/UlidFactory.java +++ b/src/main/java/com/github/f4b6a3/ulid/UlidFactory.java @@ -1,7 +1,7 @@ /* * MIT License * - * Copyright (c) 2020-2021 Fabio Lima + * Copyright (c) 2020-2022 Fabio Lima * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -25,6 +25,7 @@ package com.github.f4b6a3.ulid; import java.security.SecureRandom; +import java.time.Clock; import java.util.Random; import java.util.function.LongFunction; import java.util.function.Supplier; @@ -42,14 +43,20 @@ import java.util.function.Supplier; */ public final class UlidFactory { + private final Clock clock; // for tests private final LongFunction ulidFunction; public UlidFactory() { - this.ulidFunction = new UlidFunction(); + this(new UlidFunction(), null); } private UlidFactory(LongFunction ulidFunction) { + this(ulidFunction, null); + } + + private UlidFactory(LongFunction ulidFunction, Clock clock) { this.ulidFunction = ulidFunction; + this.clock = clock != null ? clock : Clock.systemUTC(); } /** @@ -85,6 +92,19 @@ public final class UlidFactory { return new UlidFactory(new UlidFunction(randomSupplier)); } + /** + * Returns a new factory. + * + * The given random supplier must return an array of 10 bytes. + * + * @param randomSupplier a random supplier that returns 10 bytes + * @param clock a custom clock instance for tests + * @return {@link UlidFactory} + */ + protected static UlidFactory newInstance(Supplier randomSupplier, Clock clock) { + return new UlidFactory(new UlidFunction(randomSupplier), clock); + } + /** * Returns a new monotonic factory. * @@ -116,13 +136,26 @@ public final class UlidFactory { return new UlidFactory(new MonotonicFunction(randomSupplier)); } + /** + * Returns a new monotonic factory. + * + * The given random supplier must return an array of 10 bytes. + * + * @param randomSupplier a random supplier that returns 10 bytes + * @param clock a custom clock instance for tests + * @return {@link UlidFactory} + */ + protected static UlidFactory newMonotonicInstance(Supplier randomSupplier, Clock clock) { + return new UlidFactory(new MonotonicFunction(randomSupplier), clock); + } + /** * Returns a UUID. * * @return a ULID */ public Ulid create() { - return create(System.currentTimeMillis()); + return create(clock.millis()); } /** @@ -168,9 +201,14 @@ public final class UlidFactory { */ protected static final class MonotonicFunction implements LongFunction { - private long lastTime = -1; + private long lastTime = 0; private Ulid lastUlid = null; + // Used to preserve monotonicity when the system clock is + // adjusted by NTP after a small clock drift or when the + // system clock jumps back by 1 second due to leap second. + protected static final int CLOCK_DRIFT_TOLERANCE = 10_000; + // it must return an array of 10 bytes private Supplier randomSupplier; @@ -189,13 +227,16 @@ public final class UlidFactory { @Override public synchronized Ulid apply(final long time) { - if (time == this.lastTime) { + // Check if the current time is the same as the previous time or has moved + // backwards after a small system clock adjustment or after a leap second. + // Drift tolerance = (previous_time - 10s) < current_time <= previous_time + if ((time > this.lastTime - CLOCK_DRIFT_TOLERANCE) && (time <= this.lastTime)) { this.lastUlid = lastUlid.increment(); } else { this.lastUlid = new Ulid(time, this.randomSupplier.get()); } - this.lastTime = time; + this.lastTime = lastUlid.getTime(); return new Ulid(this.lastUlid); } } diff --git a/src/test/java/com/github/f4b6a3/ulid/UlidFactoryDefaultfTest.java b/src/test/java/com/github/f4b6a3/ulid/UlidFactoryDefaultfTest.java index eca2121..55d9729 100644 --- a/src/test/java/com/github/f4b6a3/ulid/UlidFactoryDefaultfTest.java +++ b/src/test/java/com/github/f4b6a3/ulid/UlidFactoryDefaultfTest.java @@ -8,7 +8,11 @@ import com.github.f4b6a3.ulid.UlidFactory; import static org.junit.Assert.*; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; import java.util.Random; +import java.util.function.Supplier; public class UlidFactoryDefaultfTest extends UlidFactoryTest { @@ -29,6 +33,97 @@ public class UlidFactoryDefaultfTest extends UlidFactoryTest { checkCreationTime(list, startTime, endTime); } + @Test + public void testClockDrift() { + + long diff = UlidFactory.MonotonicFunction.CLOCK_DRIFT_TOLERANCE; + long time = Instant.parse("2021-12-31T23:59:59.000Z").toEpochMilli(); + long times[] = { time, time + 0, time + 1, time + 2, time + 3 - diff, time + 4 - diff, time + 5 }; + + Clock clock = new Clock() { + private int i; + + @Override + public long millis() { + return times[i++ % times.length]; + } + + @Override + public ZoneId getZone() { + return null; + } + + @Override + public Clock withZone(ZoneId zone) { + return null; + } + + @Override + public Instant instant() { + return null; + } + }; + + Supplier randomSupplier = UlidFactory.getRandomSupplier(new Random()); + UlidFactory factory = UlidFactory.newInstance(randomSupplier, clock); + + long ms1 = factory.create().getTime(); // time + long ms2 = factory.create().getTime(); // time + 0 + long ms3 = factory.create().getTime(); // time + 1 + long ms4 = factory.create().getTime(); // time + 2 + long ms5 = factory.create().getTime(); // time + 3 - 10000 (CLOCK DRIFT) + long ms6 = factory.create().getTime(); // time + 4 - 10000 (CLOCK DRIFT) + long ms7 = factory.create().getTime(); // time + 5 + assertEquals(times[0], ms1); + assertEquals(times[1], ms2); + assertEquals(times[2], ms3); + assertEquals(times[3], ms4); + assertEquals(times[4], ms5); + assertEquals(times[5], ms6); + assertEquals(times[6], ms7); + } + + @Test + public void testLeapSecond() { + + long second = Instant.parse("2021-12-31T23:59:59.000Z").getEpochSecond(); + long leapSecond = second - 1; // simulate a leap second + long times[] = { second, leapSecond }; + + Clock clock = new Clock() { + private int i; + + @Override + public long millis() { + return times[i++ % times.length] * 1000; + } + + @Override + public ZoneId getZone() { + return null; + } + + @Override + public Clock withZone(ZoneId zone) { + return null; + } + + @Override + public Instant instant() { + return null; + } + }; + + Supplier randomSupplier = UlidFactory.getRandomSupplier(new Random()); + UlidFactory factory = UlidFactory.newInstance(randomSupplier, clock); + + long ms1 = factory.create().getTime(); // second + long ms2 = factory.create().getTime(); // leap second + + assertEquals(times[0] * 1000, ms1); + assertEquals(times[1] * 1000, ms2); + } + @Test public void testGetUlidInParallel() throws InterruptedException { diff --git a/src/test/java/com/github/f4b6a3/ulid/UlidFactoryMonotonicTest.java b/src/test/java/com/github/f4b6a3/ulid/UlidFactoryMonotonicTest.java index 662787d..582c5f5 100644 --- a/src/test/java/com/github/f4b6a3/ulid/UlidFactoryMonotonicTest.java +++ b/src/test/java/com/github/f4b6a3/ulid/UlidFactoryMonotonicTest.java @@ -8,8 +8,12 @@ import com.github.f4b6a3.ulid.UlidFactory; import static org.junit.Assert.*; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; import java.util.Arrays; import java.util.Random; +import java.util.function.Supplier; public class UlidFactoryMonotonicTest extends UlidFactoryTest { @@ -31,6 +35,95 @@ public class UlidFactoryMonotonicTest extends UlidFactoryTest { checkCreationTime(list, startTime, endTime); } + @Test + public void testClockDrift() { + + long diff = UlidFactory.MonotonicFunction.CLOCK_DRIFT_TOLERANCE; + long time = Instant.parse("2021-12-31T23:59:59.000Z").toEpochMilli(); + long times[] = { time, time + 0, time + 1, time + 2, time + 3 - diff, time + 4 - diff, time + 5 }; + + Clock clock = new Clock() { + private int i; + + @Override + public long millis() { + return times[i++ % times.length]; + } + + @Override + public ZoneId getZone() { + return null; + } + + @Override + public Clock withZone(ZoneId zone) { + return null; + } + + @Override + public Instant instant() { + return null; + } + }; + + Supplier randomSupplier = UlidFactory.getRandomSupplier(new Random()); + UlidFactory factory = UlidFactory.newMonotonicInstance(randomSupplier, clock); + + long ms1 = factory.create().getTime(); // time + long ms2 = factory.create().getTime(); // time + 0 + long ms3 = factory.create().getTime(); // time + 1 + long ms4 = factory.create().getTime(); // time + 2 + long ms5 = factory.create().getTime(); // time + 3 - 10000 (CLOCK DRIFT) + long ms6 = factory.create().getTime(); // time + 4 - 10000 (CLOCK DRIFT) + long ms7 = factory.create().getTime(); // time + 5 + assertEquals(ms1 + 0, ms2); // clock repeats. + assertEquals(ms1 + 1, ms3); // clock advanced. + assertEquals(ms1 + 2, ms4); // clock advanced. + assertEquals(ms1 + 2, ms5); // CLOCK DRIFT! DON'T MOVE BACKWARDS! + assertEquals(ms1 + 2, ms6); // CLOCK DRIFT! DON'T MOVE BACKWARDS! + assertEquals(ms1 + 5, ms7); // clock advanced. + } + + @Test + public void testLeapSecond() { + + long second = Instant.parse("2021-12-31T23:59:59.000Z").getEpochSecond(); + long leapSecond = second - 1; // simulate a leap second + long times[] = { second, leapSecond }; + + Clock clock = new Clock() { + private int i; + + @Override + public long millis() { + return times[i++ % times.length] * 1000; + } + + @Override + public ZoneId getZone() { + return null; + } + + @Override + public Clock withZone(ZoneId zone) { + return null; + } + + @Override + public Instant instant() { + return null; + } + }; + + Supplier randomSupplier = UlidFactory.getRandomSupplier(new Random()); + UlidFactory factory = UlidFactory.newMonotonicInstance(randomSupplier, clock); + + long ms1 = factory.create().getTime(); // second + long ms2 = factory.create().getTime(); // leap second + + assertEquals(ms1, ms2); // LEAP SECOND! DON'T MOVE BACKWARDS! + } + private void checkOrdering(Ulid[] list) { Ulid[] other = Arrays.copyOf(list, list.length); Arrays.sort(other);