From d7c32358fcdc6553a5175dc873c0650fd6374fb9 Mon Sep 17 00:00:00 2001 From: Fayupable <90789180+Fayupable@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:53:00 +0300 Subject: [PATCH] Update Snowflake ID generator to support datacenter and worker ID configuration, add batch ID generation method, and log epoch overflow warnings --- CHANGELOG.md | 21 ++- README.md | 36 +++-- pom.xml | 2 +- snowflake-benchmark/pom.xml | 4 +- .../SnowflakeIdGeneratorBenchmark.java | 2 +- snowflake-core/pom.xml | 2 +- .../snowflake/SnowflakeIdGenerator.java | 20 +++ .../snowflake/config/SnowflakeConfig.java | 44 +++-- .../fayupable/snowflake/port/IdGenerator.java | 20 +++ .../snowflake/SnowflakeIdGeneratorTest.java | 152 ++++++++++++++++-- snowflake-jpa-spring/pom.xml | 2 +- .../jpaspring/SnowflakeAutoConfiguration.java | 8 +- .../jpaspring/SnowflakeProperties.java | 50 ++++-- ...akeIdentifierGeneratorIntegrationTest.java | 2 +- snowflake-jpa/pom.xml | 2 +- .../jpa/SnowflakeIdGeneratorHolder.java | 3 +- 16 files changed, 296 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da4d54..f8db559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,26 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.2.0] - 2026-07-29 + +### Changed +- **Breaking:** `SnowflakeConfig` now splits the node identifier into a genuine 5-bit datacenter id and 5-bit + worker id, matching Twitter's original Snowflake bit layout, instead of a single 10-bit node id. + `SnowflakeConfig.defaultConfig(epoch, nodeId)` is replaced by + `SnowflakeConfig.defaultConfig(epoch, datacenterId, workerId)`. `snowflake.node-id` is replaced by + `snowflake.datacenter-id` and `snowflake.worker-id` in `snowflake-jpa-spring`. The generated id's binary format + and total addressable node count (1024) are unchanged; only how a node's identity is split and configured + changes. + +### Added +- `IdGenerator.nextIds(int count)` default method, generating a batch of ids in one call for bulk-insert + scenarios. Implementations may override it for a more efficient batch path; the default simply loops over + `nextId()`. +- Epoch overflow warning: `SnowflakeIdGenerator` now logs a `WARNING` (via `System.Logger`, no extra dependency) + when the configured epoch is within a year of overflowing its 41-bit timestamp field, so misconfiguration is + caught early instead of silently approaching a hard failure decades later. + +## [1.1.1] - 2026-07-25 ### Fixed - `SnowflakeId.fromLong()` used a signed right shift (`>>`) instead of an unsigned one (`>>>`), inconsistent with diff --git a/README.md b/README.md index ef4c72e..9a2309d 100644 --- a/README.md +++ b/README.md @@ -34,18 +34,18 @@ A consumer picks the module that matches their stack: ## How ID generation works -Every generated ID is a 64-bit signed `long`, composed of four bit fields packed together: +Every generated ID is a 64-bit signed `long`, composed of five bit fields packed together, following the same datacenter/worker split as Twitter's original Snowflake design: ``` - 1 bit 41 bits 10 bits 12 bits - unused timestamp node id sequence - (sign) (ms since epoch) (0-1023) (0-4095) + 1 bit 41 bits 5 bits 5 bits 12 bits + unused timestamp datacenter id worker id sequence + (sign) (ms since epoch) (0-31) (0-31) (0-4095) ``` - The sign bit is always 0, so the value is always a positive `long`. -- The timestamp field holds the number of milliseconds elapsed since a configurable epoch, not since the Unix epoch. Using a recent epoch (for example, January 1st of the year the project started) rather than 1970 leaves the full 41-bit range, about 69 years, available before it overflows. -- The node id field identifies which application instance produced the ID. Every instance in a deployment must be configured with a distinct node id; otherwise two instances could produce the same ID at the same millisecond. -- The sequence field is a counter that increments for every ID produced within the same millisecond by the same node, resetting to 0 when the clock advances to the next millisecond. This allows a single node to produce up to 4096 IDs per millisecond, over 4 million per second, without colliding with itself. +- The timestamp field holds the number of milliseconds elapsed since a configurable epoch, not since the Unix epoch. Using a recent epoch (for example, January 1st of the year the project started) rather than 1970 leaves the full 41-bit range, about 69 years, available before it overflows. If the configured epoch is within a year of overflowing, the generator logs a `WARNING` (via the JDK's built-in `System.Logger`, no extra dependency) as soon as it is constructed, rather than failing silently decades later. +- The datacenter id and worker id fields together identify which application instance produced the ID: up to 32 datacenters, each with up to 32 workers, for 1024 independent instances in total. Every instance in a deployment must be configured with a distinct `(datacenterId, workerId)` pair; otherwise two instances could produce the same ID at the same millisecond. +- The sequence field is a counter that increments for every ID produced within the same millisecond by the same worker, resetting to 0 when the clock advances to the next millisecond. This allows a single worker to produce up to 4096 IDs per millisecond, over 4 million per second, without colliding with itself. ### Generation algorithm @@ -94,14 +94,18 @@ This is all `snowflake-core` provides: an `IdGenerator` interface and a thread-s ```java SnowflakeConfig config = SnowflakeConfig.defaultConfig( Instant.parse("2024-01-01T00:00:00Z").toEpochMilli(), // epoch - 3L // node id + 1L, // datacenter id + 3L // worker id ); IdGenerator generator = new SnowflakeIdGenerator(config, new SystemClock()); long id = generator.nextId(); + +// Generating many ids at once, e.g. before a bulk insert: +long[] ids = generator.nextIds(1_000); ``` -`SnowflakeConfig.defaultConfig` uses the standard 41/10/12 bit split described above. A custom split is available through the full constructor if a deployment needs, for example, more node bits at the cost of sequence capacity. +`SnowflakeConfig.defaultConfig` uses the standard 41/5/5/12 bit split described above. A custom split is available through the full constructor if a deployment needs, for example, more datacenter or worker bits at the cost of sequence capacity. This same `IdGenerator` can be used to key entries in a cache, produce message keys for a queue, or generate identifiers for a document store; it has no dependency on any persistence framework. @@ -109,15 +113,16 @@ This same `IdGenerator` can be used to key entries in a cache, produce message k Add the `snowflake-jpa-spring` dependency. No `@Import` or other wiring is needed: the module registers its auto-configuration through Spring Boot's standard discovery mechanism (`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`), the same mechanism every official Spring Boot starter uses. As soon as the dependency is on the classpath, Spring Boot picks it up automatically. -Configure a node id in `application.yml`. This is required; the application fails to start without it, rather than silently defaulting to a value that could collide with another instance: +Configure a datacenter id and worker id in `application.yml`. Both are required; the application fails to start without them, rather than silently defaulting to a value that could collide with another instance: ```yaml snowflake: - node-id: 3 + datacenter-id: 1 + worker-id: 3 epoch: 2024-01-01T00:00:00Z ``` -That is the entire setup: add the dependency, set `snowflake.node-id`, annotate the entity field shown below. +That is the entire setup: add the dependency, set `snowflake.datacenter-id` and `snowflake.worker-id`, annotate the entity field shown below. Annotate the entity's identifier field: @@ -162,7 +167,7 @@ This check is implemented as an annotation processor bundled in `snowflake-jpa`. com.github.Fayupable.snowflake-id-java snowflake-jpa - v1.1.1 + v1.2.0 @@ -175,7 +180,7 @@ Depend on `snowflake-jpa` directly. The `@SnowflakeGeneratedId` annotation works ```java IdGenerator generator = new SnowflakeIdGenerator( - SnowflakeConfig.defaultConfig(epochMillis, nodeId), + SnowflakeConfig.defaultConfig(epochMillis, datacenterId, workerId), new SystemClock()); SnowflakeIdGeneratorHolder.setInstance(generator); ``` @@ -243,5 +248,6 @@ java -jar target/benchmarks.jar | Property | Required | Description | |---|---|---| -| `snowflake.node-id` | Yes | Unique identifier for this application instance, `0` to `1023` with the default bit layout. Must not overlap with any other concurrently running instance. | +| `snowflake.datacenter-id` | Yes | Datacenter identifier for this application instance, `0` to `31` with the default bit layout. | +| `snowflake.worker-id` | Yes | Worker identifier for this application instance within its datacenter, `0` to `31` with the default bit layout. The `(datacenter-id, worker-id)` pair must not overlap with any other concurrently running instance. | | `snowflake.epoch` | No, defaults to `2024-01-01T00:00:00Z` | Reference instant that generated timestamps are measured from. Choosing a value close to the project's actual start date maximizes the years available before the 41-bit timestamp field overflows. | diff --git a/pom.xml b/pom.xml index 0b2cfb8..32e7b1d 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.fayupable snowflake-id-java - 1.1.1 + 1.2.0 pom diff --git a/snowflake-benchmark/pom.xml b/snowflake-benchmark/pom.xml index 81d39ea..298ef27 100644 --- a/snowflake-benchmark/pom.xml +++ b/snowflake-benchmark/pom.xml @@ -6,7 +6,7 @@ com.fayupable snowflake-benchmark - 1.1.1 + 1.2.0 21 @@ -19,7 +19,7 @@ com.fayupable snowflake-core - 1.1.1 + 1.2.0 org.openjdk.jmh diff --git a/snowflake-benchmark/src/main/java/com/fayupable/snowflake/benchmark/SnowflakeIdGeneratorBenchmark.java b/snowflake-benchmark/src/main/java/com/fayupable/snowflake/benchmark/SnowflakeIdGeneratorBenchmark.java index 3ce726f..fa36632 100644 --- a/snowflake-benchmark/src/main/java/com/fayupable/snowflake/benchmark/SnowflakeIdGeneratorBenchmark.java +++ b/snowflake-benchmark/src/main/java/com/fayupable/snowflake/benchmark/SnowflakeIdGeneratorBenchmark.java @@ -56,7 +56,7 @@ public class SnowflakeIdGeneratorBenchmark { @Setup public void setup() { - SnowflakeConfig config = SnowflakeConfig.defaultConfig(1_700_000_000_000L, 1L); + SnowflakeConfig config = SnowflakeConfig.defaultConfig(1_700_000_000_000L, 1L, 1L); generator = new SnowflakeIdGenerator(config, new SystemClock()); } diff --git a/snowflake-core/pom.xml b/snowflake-core/pom.xml index a14c713..6fb0af6 100644 --- a/snowflake-core/pom.xml +++ b/snowflake-core/pom.xml @@ -7,7 +7,7 @@ com.fayupable snowflake-id-java - 1.1.1 + 1.2.0 snowflake-core diff --git a/snowflake-core/src/main/java/com/fayupable/snowflake/SnowflakeIdGenerator.java b/snowflake-core/src/main/java/com/fayupable/snowflake/SnowflakeIdGenerator.java index 4eb8da1..52eafc7 100644 --- a/snowflake-core/src/main/java/com/fayupable/snowflake/SnowflakeIdGenerator.java +++ b/snowflake-core/src/main/java/com/fayupable/snowflake/SnowflakeIdGenerator.java @@ -6,6 +6,9 @@ import com.fayupable.snowflake.port.IdGenerator; import com.fayupable.snowflake.port.SnowflakeMetrics; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; + import java.util.concurrent.atomic.AtomicLong; /** @@ -15,6 +18,9 @@ */ public final class SnowflakeIdGenerator implements IdGenerator { + private static final Logger LOGGER = System.getLogger(SnowflakeIdGenerator.class.getName()); + private static final long OVERFLOW_WARNING_THRESHOLD_MILLIS = 365L * 24 * 60 * 60 * 1000; // 1 year + private final SnowflakeConfig config; private final ClockSource clock; private final SnowflakeMetrics metrics; @@ -28,6 +34,7 @@ public SnowflakeIdGenerator(SnowflakeConfig config, ClockSource clock, Snowflake this.config = config; this.clock = clock; this.metrics = metrics; + warnIfEpochNearOverflow(); } @Override @@ -54,4 +61,17 @@ public long nextId() { } } } + + private void warnIfEpochNearOverflow() { + long maxRelativeMillis = (1L << config.timestampBits()) - 1; + long overflowInstant = config.epoch() + maxRelativeMillis; + long remainingMillis = overflowInstant - clock.millis(); + + if (remainingMillis < OVERFLOW_WARNING_THRESHOLD_MILLIS) { + LOGGER.log(Level.WARNING, + "Snowflake epoch will overflow its {0}-bit timestamp in less than a year (at {1}). " + + "Consider migrating to a new epoch before that date.", + config.timestampBits(), java.time.Instant.ofEpochMilli(overflowInstant)); + } + } } \ No newline at end of file diff --git a/snowflake-core/src/main/java/com/fayupable/snowflake/config/SnowflakeConfig.java b/snowflake-core/src/main/java/com/fayupable/snowflake/config/SnowflakeConfig.java index 99a1904..e041a81 100644 --- a/snowflake-core/src/main/java/com/fayupable/snowflake/config/SnowflakeConfig.java +++ b/snowflake-core/src/main/java/com/fayupable/snowflake/config/SnowflakeConfig.java @@ -2,48 +2,64 @@ /** * Configuration for the Snowflake id layout. - * Bit layout (MSB to LSB): 1 unused sign bit | timestampBits | nodeBits | sequenceBits. - * Default: 41-bit timestamp (~69 years from epoch), 10-bit node (up to 1024 nodes), - * 12-bit sequence (up to 4096 ids per millisecond per node). + * Bit layout (MSB to LSB): 1 unused sign bit | timestampBits | datacenterBits | workerBits | sequenceBits. + * Default: 41-bit timestamp (~69 years from epoch), 5-bit datacenter (up to 32 datacenters), + * 5-bit worker (up to 32 workers per datacenter), 12-bit sequence (up to 4096 ids per millisecond per worker). */ public record SnowflakeConfig( long epoch, - long nodeId, + long datacenterId, + long workerId, int timestampBits, - int nodeBits, + int datacenterBits, + int workerBits, int sequenceBits ) { private static final int TOTAL_USABLE_BITS = 63; public SnowflakeConfig { - if (timestampBits <= 0 || nodeBits < 0 || sequenceBits < 0) { + if (timestampBits <= 0 || datacenterBits < 0 || workerBits < 0 || sequenceBits < 0) { throw new IllegalArgumentException("bit counts must be positive"); } - if (timestampBits + nodeBits + sequenceBits != TOTAL_USABLE_BITS) { + if (timestampBits + datacenterBits + workerBits + sequenceBits != TOTAL_USABLE_BITS) { throw new IllegalArgumentException( - "timestampBits + nodeBits + sequenceBits must equal %d".formatted(TOTAL_USABLE_BITS)); + "timestampBits + datacenterBits + workerBits + sequenceBits must equal %d" + .formatted(TOTAL_USABLE_BITS)); } - long maxNodeId = (1L << nodeBits) - 1; - if (nodeId < 0 || nodeId > maxNodeId) { + long maxDatacenterId = (1L << datacenterBits) - 1; + if (datacenterId < 0 || datacenterId > maxDatacenterId) { throw new IllegalArgumentException( - "nodeId must be between 0 and %d".formatted(maxNodeId)); + "datacenterId must be between 0 and %d".formatted(maxDatacenterId)); + } + long maxWorkerId = (1L << workerBits) - 1; + if (workerId < 0 || workerId > maxWorkerId) { + throw new IllegalArgumentException( + "workerId must be between 0 and %d".formatted(maxWorkerId)); } } - public static SnowflakeConfig defaultConfig(long epoch, long nodeId) { - return new SnowflakeConfig(epoch, nodeId, 41, 10, 12); + public static SnowflakeConfig defaultConfig(long epoch, long datacenterId, long workerId) { + return new SnowflakeConfig(epoch, datacenterId, workerId, 41, 5, 5, 12); } public long maxSequence() { return (1L << sequenceBits) - 1; } + public long nodeId() { + return (datacenterId << workerBits) | workerId; + } + + public int nodeBits() { + return datacenterBits + workerBits; + } + public int nodeShift() { return sequenceBits; } public int timestampShift() { - return sequenceBits + nodeBits; + return sequenceBits + nodeBits(); } } \ No newline at end of file diff --git a/snowflake-core/src/main/java/com/fayupable/snowflake/port/IdGenerator.java b/snowflake-core/src/main/java/com/fayupable/snowflake/port/IdGenerator.java index 39477af..c4eccf1 100644 --- a/snowflake-core/src/main/java/com/fayupable/snowflake/port/IdGenerator.java +++ b/snowflake-core/src/main/java/com/fayupable/snowflake/port/IdGenerator.java @@ -7,4 +7,24 @@ public interface IdGenerator { long nextId(); + + /** + * Generates {@code count} ids in one call. The default implementation simply + * calls {@link #nextId()} in a loop; implementations that can do better under + * contention (e.g. advancing the internal sequence in a single CAS) may override + * this for a more efficient batch path. + * + * @param count how many ids to generate, must be positive + * @return an array of {@code count} ids, in generation order + */ + default long[] nextIds(int count) { + if (count <= 0) { + throw new IllegalArgumentException("count must be positive"); + } + long[] ids = new long[count]; + for (int i = 0; i < count; i++) { + ids[i] = nextId(); + } + return ids; + } } \ No newline at end of file diff --git a/snowflake-core/src/test/java/com/fayupable/snowflake/SnowflakeIdGeneratorTest.java b/snowflake-core/src/test/java/com/fayupable/snowflake/SnowflakeIdGeneratorTest.java index 6439371..9764b26 100644 --- a/snowflake-core/src/test/java/com/fayupable/snowflake/SnowflakeIdGeneratorTest.java +++ b/snowflake-core/src/test/java/com/fayupable/snowflake/SnowflakeIdGeneratorTest.java @@ -11,15 +11,21 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class SnowflakeIdGeneratorTest { private static final long EPOCH = 1_700_000_000_000L; - private static final long NODE_ID = 3L; + private static final long DATACENTER_ID = 1L; + private static final long WORKER_ID = 3L; @Nested @DisplayName("nextId") @@ -28,8 +34,8 @@ class NextId { @Test @DisplayName("generates unique ids under concurrent access") void generatesUniqueIdsConcurrently() throws InterruptedException { - SnowflakeIdGenerator generator = - new SnowflakeIdGenerator(SnowflakeConfig.defaultConfig(EPOCH, NODE_ID), new SystemClock()); + SnowflakeIdGenerator generator = new SnowflakeIdGenerator( + SnowflakeConfig.defaultConfig(EPOCH, DATACENTER_ID, WORKER_ID), new SystemClock()); int threadCount = 4; int idsPerThread = 2_000; @@ -56,8 +62,8 @@ void generatesUniqueIdsConcurrently() throws InterruptedException { @Test @DisplayName("produces monotonically increasing ids on a single thread") void producesMonotonicIds() { - SnowflakeIdGenerator generator = - new SnowflakeIdGenerator(SnowflakeConfig.defaultConfig(EPOCH, NODE_ID), new SystemClock()); + SnowflakeIdGenerator generator = new SnowflakeIdGenerator( + SnowflakeConfig.defaultConfig(EPOCH, DATACENTER_ID, WORKER_ID), new SystemClock()); long previous = generator.nextId(); for (int i = 0; i < 10_000; i++) { @@ -70,7 +76,7 @@ void producesMonotonicIds() { @Test @DisplayName("rolls over to the next millisecond when sequence is exhausted") void rollsOverSequenceWithinSameMillisecond() { - SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, NODE_ID); + SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, DATACENTER_ID, WORKER_ID); FakeClockSource clock = new FakeClockSource(EPOCH); SnowflakeIdGenerator generator = new SnowflakeIdGenerator(config, clock); @@ -90,7 +96,7 @@ void rollsOverSequenceWithinSameMillisecond() { @Test @DisplayName("throws when the clock source moves backwards") void throwsWhenClockMovesBackwards() { - SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, NODE_ID); + SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, DATACENTER_ID, WORKER_ID); FakeClockSource clock = new FakeClockSource(EPOCH + 1000); SnowflakeIdGenerator generator = new SnowflakeIdGenerator(config, clock); @@ -112,7 +118,7 @@ class Metrics { @Test @DisplayName("records a generated event for every successful id") void recordsGeneratedEvents() { - SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, NODE_ID); + SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, DATACENTER_ID, WORKER_ID); FakeClockSource clock = new FakeClockSource(EPOCH); RecordingSnowflakeMetrics metrics = new RecordingSnowflakeMetrics(); SnowflakeIdGenerator generator = new SnowflakeIdGenerator(config, clock, metrics); @@ -128,7 +134,7 @@ void recordsGeneratedEvents() { @Test @DisplayName("records a spin-wait event when the sequence is exhausted") void recordsSpinWaitEvents() throws InterruptedException { - SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, NODE_ID); + SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, DATACENTER_ID, WORKER_ID); FakeClockSource clock = new FakeClockSource(EPOCH); RecordingSnowflakeMetrics metrics = new RecordingSnowflakeMetrics(); SnowflakeIdGenerator generator = new SnowflakeIdGenerator(config, clock, metrics); @@ -155,7 +161,7 @@ class IdDecomposition { @Test @DisplayName("decomposes a generated id back into timestamp, node and sequence") void decomposesGeneratedId() { - SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, NODE_ID); + SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, DATACENTER_ID, WORKER_ID); FakeClockSource clock = new FakeClockSource(EPOCH + 42); SnowflakeIdGenerator generator = new SnowflakeIdGenerator(config, clock); @@ -163,14 +169,14 @@ void decomposesGeneratedId() { SnowflakeId parsed = SnowflakeId.fromLong(id, config); assertEquals(EPOCH + 42, parsed.timestamp()); - assertEquals(NODE_ID, parsed.nodeId()); + assertEquals((DATACENTER_ID << 5) | WORKER_ID, parsed.nodeId()); assertEquals(0L, parsed.sequence()); } @Test @DisplayName("uses unsigned shifts so a crafted negative id does not corrupt decoded fields") void decomposesNegativeIdWithUnsignedShift() { - SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, NODE_ID); + SnowflakeConfig config = SnowflakeConfig.defaultConfig(EPOCH, DATACENTER_ID, WORKER_ID); SnowflakeId parsed = SnowflakeId.fromLong(Long.MIN_VALUE, config); @@ -178,22 +184,134 @@ void decomposesNegativeIdWithUnsignedShift() { } } + @Nested + @DisplayName("nextIds") + class NextIds { + + @Test + @DisplayName("generates the requested count of unique, increasing ids") + void generatesRequestedCountOfUniqueIds() { + SnowflakeIdGenerator generator = new SnowflakeIdGenerator( + SnowflakeConfig.defaultConfig(EPOCH, DATACENTER_ID, WORKER_ID), new SystemClock()); + + long[] ids = generator.nextIds(100); + + assertEquals(100, ids.length); + assertEquals(100, java.util.Arrays.stream(ids).distinct().count()); + for (int i = 1; i < ids.length; i++) { + assertTrue(ids[i] > ids[i - 1]); + } + } + + @Test + @DisplayName("rejects a non-positive count") + void rejectsNonPositiveCount() { + SnowflakeIdGenerator generator = new SnowflakeIdGenerator( + SnowflakeConfig.defaultConfig(EPOCH, DATACENTER_ID, WORKER_ID), new SystemClock()); + + assertThrows(IllegalArgumentException.class, () -> generator.nextIds(0)); + assertThrows(IllegalArgumentException.class, () -> generator.nextIds(-5)); + } + } + @Nested @DisplayName("configuration validation") class ConfigurationValidation { @Test - @DisplayName("rejects a nodeId outside the allowed range") - void rejectsInvalidNodeId() { + @DisplayName("rejects a datacenterId outside the allowed range") + void rejectsInvalidDatacenterId() { + assertThrows(IllegalArgumentException.class, + () -> SnowflakeConfig.defaultConfig(EPOCH, 32L, WORKER_ID)); + } + + @Test + @DisplayName("rejects a workerId outside the allowed range") + void rejectsInvalidWorkerId() { assertThrows(IllegalArgumentException.class, - () -> SnowflakeConfig.defaultConfig(EPOCH, 1024L)); + () -> SnowflakeConfig.defaultConfig(EPOCH, DATACENTER_ID, 32L)); } @Test @DisplayName("rejects a bit layout that does not sum to 63") void rejectsInvalidBitLayout() { assertThrows(IllegalArgumentException.class, - () -> new SnowflakeConfig(EPOCH, NODE_ID, 40, 10, 12)); + () -> new SnowflakeConfig(EPOCH, DATACENTER_ID, WORKER_ID, 40, 5, 5, 12)); + } + } + + @Nested + @DisplayName("epoch overflow warning") + class EpochOverflowWarning { + + @Test + @DisplayName("logs a warning when the epoch is close to its timestamp overflow") + void logsWarningWhenCloseToOverflow() { + long maxRelativeMillis = (1L << 41) - 1; + long overflowInstant = EPOCH + maxRelativeMillis; + long sixMonthsMillis = 182L * 24 * 60 * 60 * 1000; + FakeClockSource clock = new FakeClockSource(overflowInstant - sixMonthsMillis); + + TestLogHandler handler = attachHandler(); + try { + new SnowflakeIdGenerator(SnowflakeConfig.defaultConfig(EPOCH, DATACENTER_ID, WORKER_ID), clock); + } finally { + detachHandler(handler); + } + + assertTrue(handler.hasWarningContaining("overflow")); + } + + @Test + @DisplayName("does not log when the epoch has decades of headroom left") + void doesNotLogWhenFarFromOverflow() { + FakeClockSource clock = new FakeClockSource(EPOCH + 1_000); + + TestLogHandler handler = attachHandler(); + try { + new SnowflakeIdGenerator(SnowflakeConfig.defaultConfig(EPOCH, DATACENTER_ID, WORKER_ID), clock); + } finally { + detachHandler(handler); + } + + assertFalse(handler.hasWarningContaining("overflow")); + } + + private TestLogHandler attachHandler() { + TestLogHandler handler = new TestLogHandler(); + Logger julLogger = Logger.getLogger(SnowflakeIdGenerator.class.getName()); + julLogger.addHandler(handler); + julLogger.setLevel(Level.ALL); + return handler; + } + + private void detachHandler(TestLogHandler handler) { + Logger.getLogger(SnowflakeIdGenerator.class.getName()).removeHandler(handler); + } + } + + private static final class TestLogHandler extends Handler { + + private final java.util.List records = new java.util.ArrayList<>(); + + @Override + public void publish(LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + + boolean hasWarningContaining(String snippet) { + return records.stream() + .anyMatch(r -> r.getLevel() == Level.WARNING + && r.getMessage() != null + && r.getMessage().contains(snippet)); } } -} \ No newline at end of file +} diff --git a/snowflake-jpa-spring/pom.xml b/snowflake-jpa-spring/pom.xml index f40c32d..0e9bca9 100644 --- a/snowflake-jpa-spring/pom.xml +++ b/snowflake-jpa-spring/pom.xml @@ -7,7 +7,7 @@ com.fayupable snowflake-id-java - 1.1.1 + 1.2.0 snowflake-jpa-spring diff --git a/snowflake-jpa-spring/src/main/java/com/fayupable/snowflake/jpaspring/SnowflakeAutoConfiguration.java b/snowflake-jpa-spring/src/main/java/com/fayupable/snowflake/jpaspring/SnowflakeAutoConfiguration.java index 9b54d92..40fefeb 100644 --- a/snowflake-jpa-spring/src/main/java/com/fayupable/snowflake/jpaspring/SnowflakeAutoConfiguration.java +++ b/snowflake-jpa-spring/src/main/java/com/fayupable/snowflake/jpaspring/SnowflakeAutoConfiguration.java @@ -22,8 +22,8 @@ * Registered automatically through * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}, * the same mechanism every official Spring Boot starter uses. Consumers only need to - * add the {@code snowflake-jpa-spring} dependency and set {@code snowflake.node-id}; - * no {@code @Import} or other manual wiring is required. + * add the {@code snowflake-jpa-spring} dependency and set {@code snowflake.datacenter-id} + * and {@code snowflake.worker-id}; no {@code @Import} or other manual wiring is required. */ @Configuration @EnableConfigurationProperties(SnowflakeProperties.class) @@ -36,7 +36,7 @@ public class SnowflakeAutoConfiguration { * Hibernate. * * @param properties the bound {@code snowflake.*} configuration, providing - * {@code nodeId} and {@code epoch} + * {@code datacenterId}, {@code workerId} and {@code epoch} * @param registryProvider the application's {@link MeterRegistry}, if one exists; * when absent, generator activity is simply not measured, * no metrics backend is required @@ -48,7 +48,7 @@ public class SnowflakeAutoConfiguration { @Bean public IdGenerator idGenerator(SnowflakeProperties properties, ObjectProvider registryProvider) { SnowflakeConfig config = SnowflakeConfig.defaultConfig( - properties.getEpoch().toEpochMilli(), properties.getNodeId()); + properties.getEpoch().toEpochMilli(), properties.getDatacenterId(), properties.getWorkerId()); MeterRegistry registry = registryProvider.getIfAvailable(); SnowflakeMetrics metrics = registry != null ? new MicrometerSnowflakeMetrics(registry) diff --git a/snowflake-jpa-spring/src/main/java/com/fayupable/snowflake/jpaspring/SnowflakeProperties.java b/snowflake-jpa-spring/src/main/java/com/fayupable/snowflake/jpaspring/SnowflakeProperties.java index cff8338..48cd7a9 100644 --- a/snowflake-jpa-spring/src/main/java/com/fayupable/snowflake/jpaspring/SnowflakeProperties.java +++ b/snowflake-jpa-spring/src/main/java/com/fayupable/snowflake/jpaspring/SnowflakeProperties.java @@ -10,36 +10,58 @@ * Example configuration: *
{@code
  * snowflake:
- *   node-id: 3
+ *   datacenter-id: 1
+ *   worker-id: 3
  *   epoch: 2024-01-01T00:00:00Z
  * }
*

- * {@code nodeId} has no valid default on purpose: {@link com.fayupable.snowflake.config.SnowflakeConfig} - * rejects a negative {@code nodeId}, so an application that forgets to set - * {@code snowflake.node-id} fails fast at startup instead of silently colliding - * with another node that happens to use the same default value. + * {@code datacenterId} and {@code workerId} have no valid default on purpose: + * {@link com.fayupable.snowflake.config.SnowflakeConfig} rejects a negative value + * for either, so an application that forgets to set them fails fast at startup + * instead of silently colliding with another node that happens to use the same + * default value. */ @ConfigurationProperties("snowflake") public class SnowflakeProperties { - private long nodeId = -1; + private long datacenterId = -1; + private long workerId = -1; private Instant epoch = Instant.parse("2024-01-01T00:00:00Z"); /** - * @return the node identifier this application instance will embed in every + * @return the datacenter identifier this application instance will embed in + * every generated id; must be unique per physical/logical datacenter + * sharing the same {@code epoch}, or -1 if unconfigured + */ + public long getDatacenterId() { + return datacenterId; + } + + /** + * @param datacenterId the datacenter identifier to embed in every id generated + * by this application instance; bound from + * {@code snowflake.datacenter-id} + */ + public void setDatacenterId(long datacenterId) { + this.datacenterId = datacenterId; + } + + /** + * @return the worker identifier this application instance will embed in every * generated id; must be unique across all concurrently running - * instances sharing the same {@code epoch}, or -1 if unconfigured + * instances within the same datacenter sharing the same + * {@code epoch}, or -1 if unconfigured */ - public long getNodeId() { - return nodeId; + public long getWorkerId() { + return workerId; } /** - * @param nodeId the node identifier to embed in every id generated by this - * application instance; bound from {@code snowflake.node-id} + * @param workerId the worker identifier to embed in every id generated by this + * application instance; bound from {@code snowflake.worker-id} */ - public void setNodeId(long nodeId) { - this.nodeId = nodeId; + public void setWorkerId(long workerId) { + this.workerId = workerId; } /** diff --git a/snowflake-jpa-spring/src/test/java/com/fayupable/snowflake/jpaspring/SnowflakeIdentifierGeneratorIntegrationTest.java b/snowflake-jpa-spring/src/test/java/com/fayupable/snowflake/jpaspring/SnowflakeIdentifierGeneratorIntegrationTest.java index 660cc67..ced020b 100644 --- a/snowflake-jpa-spring/src/test/java/com/fayupable/snowflake/jpaspring/SnowflakeIdentifierGeneratorIntegrationTest.java +++ b/snowflake-jpa-spring/src/test/java/com/fayupable/snowflake/jpaspring/SnowflakeIdentifierGeneratorIntegrationTest.java @@ -11,7 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @DataJpaTest -@TestPropertySource(properties = "snowflake.node-id=5") +@TestPropertySource(properties = {"snowflake.datacenter-id=1", "snowflake.worker-id=5"}) @ExtendWith(SnowflakeIdGeneratorHolderResetExtension.class) class SnowflakeIdentifierGeneratorIntegrationTest { diff --git a/snowflake-jpa/pom.xml b/snowflake-jpa/pom.xml index cdc8a39..2726296 100644 --- a/snowflake-jpa/pom.xml +++ b/snowflake-jpa/pom.xml @@ -7,7 +7,7 @@ com.fayupable snowflake-id-java - 1.1.1 + 1.2.0 snowflake-jpa diff --git a/snowflake-jpa/src/main/java/com/fayupable/snowflake/jpa/SnowflakeIdGeneratorHolder.java b/snowflake-jpa/src/main/java/com/fayupable/snowflake/jpa/SnowflakeIdGeneratorHolder.java index 920de22..14afeff 100644 --- a/snowflake-jpa/src/main/java/com/fayupable/snowflake/jpa/SnowflakeIdGeneratorHolder.java +++ b/snowflake-jpa/src/main/java/com/fayupable/snowflake/jpa/SnowflakeIdGeneratorHolder.java @@ -22,7 +22,8 @@ private SnowflakeIdGeneratorHolder() { * context is created (e.g. across independent test contexts in the same JVM). * * @param generator the generator to publish; typically a singleton configured - * with the application's {@code nodeId} and {@code epoch} + * with the application's {@code datacenterId}, {@code workerId} + * and {@code epoch} */ public static void setInstance(IdGenerator generator) { instance = generator;