From 5afc8c4e8c138460fdafe9c6142c3b6231b2f19e Mon Sep 17 00:00:00 2001 From: Fayupable <90789180+Fayupable@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:05:00 +0300 Subject: [PATCH] Bump version to 1.3.0, add SnowflakeHealthIndicator and snowflake-cli for decoding Snowflake IDs, and update CHANGELOG --- CHANGELOG.md | 15 ++++ README.md | 43 ++++++++++ pom.xml | 2 +- snowflake-benchmark/pom.xml | 4 +- snowflake-cli/pom.xml | 58 +++++++++++++ .../snowflake/cli/SnowflakeDecodeCli.java | 81 +++++++++++++++++++ .../snowflake/cli/SnowflakeDecodeCliTest.java | 81 +++++++++++++++++++ snowflake-core/pom.xml | 2 +- snowflake-jpa-spring/pom.xml | 8 +- .../jpaspring/SnowflakeAutoConfiguration.java | 39 +++++++-- .../jpaspring/SnowflakeHealthIndicator.java | 35 ++++++++ .../SnowflakeHealthIndicatorTest.java | 36 +++++++++ snowflake-jpa/pom.xml | 2 +- 13 files changed, 395 insertions(+), 11 deletions(-) create mode 100644 snowflake-cli/pom.xml create mode 100644 snowflake-cli/src/main/java/com/fayupable/snowflake/cli/SnowflakeDecodeCli.java create mode 100644 snowflake-cli/src/test/java/com/fayupable/snowflake/cli/SnowflakeDecodeCliTest.java create mode 100644 snowflake-jpa-spring/src/main/java/com/fayupable/snowflake/jpaspring/SnowflakeHealthIndicator.java create mode 100644 snowflake-jpa-spring/src/test/java/com/fayupable/snowflake/jpaspring/SnowflakeHealthIndicatorTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index d307a63..e7d1a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ 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). +## [1.3.0] - 2026-07-30 + +### Added +- `SnowflakeHealthIndicator` in `snowflake-jpa-spring`, exposing `datacenterId`, `workerId`, `epoch` and + `timestampOverflowAt` through `/actuator/health`. Only registered when Spring Boot Actuator is on the classpath; + reports static identity/configuration details only, since live generation activity is already covered by + `MicrometerSnowflakeMetrics`. +- `snowflake-cli` module: a standalone `decode [--epoch=...]` command-line tool that decodes a Snowflake id + into its timestamp, datacenter id, worker id and sequence without writing any code. Not published as part of + the library, runnable directly as `java -jar snowflake-cli.jar`. + +### Changed +- `SnowflakeConfig` is now exposed as its own Spring bean (`snowflakeConfig`) in `snowflake-jpa-spring`, shared by + both `idGenerator` and `snowflakeHealthIndicator` instead of being built twice. + ## [1.2.1] - 2026-07-29 ### Fixed diff --git a/README.md b/README.md index 1a5fd53..0e09258 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,49 @@ When `snowflake-jpa-spring` finds a `MeterRegistry` bean in the application cont No metrics backend is required. If no `MeterRegistry` bean is present, these events are simply not recorded; nothing needs to be configured or installed to use the library without metrics. +## Health + +When Spring Boot Actuator is on the classpath, `snowflake-jpa-spring` automatically registers a health contributor under `/actuator/health`, reporting this instance's static Snowflake identity rather than live throughput (which is already covered by the metrics above): + +```json +{ + "status": "UP", + "components": { + "snowflake": { + "status": "UP", + "details": { + "datacenterId": 1, + "workerId": 3, + "epoch": "2024-01-01T00:00:00Z", + "timestampOverflowAt": "2093-07-21T14:00:55.551Z" + } + } + } +} +``` + +No Actuator dependency is required to use the rest of the library. If Actuator is not on the classpath, this bean is simply never registered. + +## Command-line decoding + +A standalone `snowflake-cli` module (not published as part of the library) decodes an existing id back into its timestamp, datacenter id, worker id and sequence, without writing any code: + +```bash +cd snowflake-cli +mvn clean package +java -jar target/snowflake-cli.jar decode 123456789 --epoch=2024-01-01T00:00:00Z +``` + +``` +id: 123456789 +timestamp: 2024-01-01T00:00:00.029Z +datacenter id: 13 +worker id: 28 +sequence: 3349 +``` + +`--epoch` defaults to `2024-01-01T00:00:00Z`, the library's own default epoch, if omitted. + ## Benchmarks A JMH benchmark module (`snowflake-benchmark`, not published as part of the library) measures raw generator throughput on a single thread and under four-thread contention. Settings are intentionally light, one JVM fork, 2 short warmup iterations, 3 short measurement iterations, so the full run takes about 10 seconds instead of JMH's usual multi-minute default configuration: diff --git a/pom.xml b/pom.xml index c43fd58..a944aa5 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.fayupable snowflake-id-java - 1.2.1 + 1.3.0 pom diff --git a/snowflake-benchmark/pom.xml b/snowflake-benchmark/pom.xml index a1a5b17..ccb807d 100644 --- a/snowflake-benchmark/pom.xml +++ b/snowflake-benchmark/pom.xml @@ -6,7 +6,7 @@ com.fayupable snowflake-benchmark - 1.2.1 + 1.3.0 21 @@ -19,7 +19,7 @@ com.fayupable snowflake-core - 1.2.1 + 1.3.0 org.openjdk.jmh diff --git a/snowflake-cli/pom.xml b/snowflake-cli/pom.xml new file mode 100644 index 0000000..17d9151 --- /dev/null +++ b/snowflake-cli/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + com.fayupable + snowflake-cli + 1.3.0 + + + 21 + 21 + UTF-8 + 5.10.2 + + + + + com.fayupable + snowflake-core + 1.3.0 + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + snowflake-cli + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + + + com.fayupable.snowflake.cli.SnowflakeDecodeCli + + + + + + + + + + diff --git a/snowflake-cli/src/main/java/com/fayupable/snowflake/cli/SnowflakeDecodeCli.java b/snowflake-cli/src/main/java/com/fayupable/snowflake/cli/SnowflakeDecodeCli.java new file mode 100644 index 0000000..9406d7a --- /dev/null +++ b/snowflake-cli/src/main/java/com/fayupable/snowflake/cli/SnowflakeDecodeCli.java @@ -0,0 +1,81 @@ +package com.fayupable.snowflake.cli; + +import com.fayupable.snowflake.SnowflakeId; +import com.fayupable.snowflake.config.SnowflakeConfig; + +import java.time.Instant; + +/** + * Standalone command-line tool that decodes a Snowflake id into its timestamp, + * datacenter id, worker id and sequence components, without requiring the caller + * to write any code or start an application. + */ +public final class SnowflakeDecodeCli { + + private static final Instant DEFAULT_EPOCH = Instant.parse("2024-01-01T00:00:00Z"); + private static final String EPOCH_FLAG = "--epoch="; + + private SnowflakeDecodeCli() { + } + + public static void main(String[] args) { + System.out.println(run(args)); + } + + static String run(String[] args) { + if (args.length < 2 || !"decode".equals(args[0])) { + return usage(); + } + + long id; + try { + id = Long.parseLong(args[1]); + } catch (NumberFormatException e) { + return "Invalid id: '" + args[1] + "' is not a valid long value."; + } + + Instant epoch; + try { + epoch = parseEpoch(args); + } catch (RuntimeException e) { + return "Invalid --epoch value: " + e.getMessage(); + } + + SnowflakeConfig config = SnowflakeConfig.defaultConfig(epoch.toEpochMilli(), 0L, 0L); + SnowflakeId parsed = SnowflakeId.fromLong(id, config); + long datacenterId = parsed.nodeId() >>> config.workerBits(); + long workerId = parsed.nodeId() & ((1L << config.workerBits()) - 1); + + return """ + id: %d + timestamp: %s + datacenter id: %d + worker id: %d + sequence: %d + """.formatted( + parsed.value(), + Instant.ofEpochMilli(parsed.timestamp()), + datacenterId, + workerId, + parsed.sequence()); + } + + private static Instant parseEpoch(String[] args) { + for (String arg : args) { + if (arg.startsWith(EPOCH_FLAG)) { + return Instant.parse(arg.substring(EPOCH_FLAG.length())); + } + } + return DEFAULT_EPOCH; + } + + private static String usage() { + return """ + Usage: java -jar snowflake-cli.jar decode [--epoch=] + + the Snowflake id to decode, as a signed long + --epoch the reference instant used to generate the id + (default: 2024-01-01T00:00:00Z, the library's default epoch) + """; + } +} diff --git a/snowflake-cli/src/test/java/com/fayupable/snowflake/cli/SnowflakeDecodeCliTest.java b/snowflake-cli/src/test/java/com/fayupable/snowflake/cli/SnowflakeDecodeCliTest.java new file mode 100644 index 0000000..1a92f8a --- /dev/null +++ b/snowflake-cli/src/test/java/com/fayupable/snowflake/cli/SnowflakeDecodeCliTest.java @@ -0,0 +1,81 @@ +package com.fayupable.snowflake.cli; + +import com.fayupable.snowflake.SnowflakeIdGenerator; +import com.fayupable.snowflake.SystemClock; +import com.fayupable.snowflake.config.SnowflakeConfig; +import com.fayupable.snowflake.port.IdGenerator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SnowflakeDecodeCliTest { + + private static final String EPOCH = "2024-01-01T00:00:00Z"; + + @Nested + @DisplayName("decode") + class Decode { + + @Test + @DisplayName("prints the datacenter id, worker id and sequence encoded into a real generated id") + void decodesARealGeneratedId() { + SnowflakeConfig config = SnowflakeConfig.defaultConfig( + java.time.Instant.parse(EPOCH).toEpochMilli(), 4L, 17L); + IdGenerator generator = new SnowflakeIdGenerator(config, new SystemClock()); + long id = generator.nextId(); + + String output = SnowflakeDecodeCli.run(new String[] {"decode", String.valueOf(id), "--epoch=" + EPOCH}); + + assertTrue(output.contains("datacenter id: 4")); + assertTrue(output.contains("worker id: 17")); + assertTrue(output.contains("sequence: 0")); + } + + @Test + @DisplayName("uses the default epoch when none is given") + void usesDefaultEpochWhenNotGiven() { + String output = SnowflakeDecodeCli.run(new String[] {"decode", "0"}); + + assertTrue(output.contains("timestamp: 2024-01-01T00:00:00Z")); + } + + @Test + @DisplayName("reports an error for a non-numeric id") + void reportsErrorForNonNumericId() { + String output = SnowflakeDecodeCli.run(new String[] {"decode", "not-a-number"}); + + assertTrue(output.contains("Invalid id")); + } + + @Test + @DisplayName("reports an error for a malformed epoch") + void reportsErrorForMalformedEpoch() { + String output = SnowflakeDecodeCli.run(new String[] {"decode", "0", "--epoch=not-an-instant"}); + + assertTrue(output.contains("Invalid --epoch")); + } + } + + @Nested + @DisplayName("usage") + class Usage { + + @Test + @DisplayName("prints usage when no arguments are given") + void printsUsageForNoArgs() { + String output = SnowflakeDecodeCli.run(new String[0]); + + assertTrue(output.contains("Usage:")); + } + + @Test + @DisplayName("prints usage for an unknown command") + void printsUsageForUnknownCommand() { + String output = SnowflakeDecodeCli.run(new String[] {"encode", "123"}); + + assertTrue(output.contains("Usage:")); + } + } +} diff --git a/snowflake-core/pom.xml b/snowflake-core/pom.xml index ef3e8b3..e93a53c 100644 --- a/snowflake-core/pom.xml +++ b/snowflake-core/pom.xml @@ -7,7 +7,7 @@ com.fayupable snowflake-id-java - 1.2.1 + 1.3.0 snowflake-core diff --git a/snowflake-jpa-spring/pom.xml b/snowflake-jpa-spring/pom.xml index 9eb16de..d5a7795 100644 --- a/snowflake-jpa-spring/pom.xml +++ b/snowflake-jpa-spring/pom.xml @@ -7,7 +7,7 @@ com.fayupable snowflake-id-java - 1.2.1 + 1.3.0 snowflake-jpa-spring @@ -52,6 +52,12 @@ micrometer-core 1.16.6 + + org.springframework.boot + spring-boot-starter-actuator + ${spring-boot.version} + true + 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 40fefeb..189c17e 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 @@ -9,7 +9,9 @@ import com.fayupable.snowflake.port.SnowflakeMetrics; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.health.contributor.HealthIndicator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -29,14 +31,28 @@ @EnableConfigurationProperties(SnowflakeProperties.class) public class SnowflakeAutoConfiguration { + /** + * Builds the {@link SnowflakeConfig} shared by the {@link #idGenerator} and + * {@link #snowflakeHealthIndicator} beans, so both read the same + * datacenter/worker/epoch values from a single source. + * + * @param properties the bound {@code snowflake.*} configuration, providing + * {@code datacenterId}, {@code workerId} and {@code epoch} + * @return the layout and identity configuration for this application instance + */ + @Bean + public SnowflakeConfig snowflakeConfig(SnowflakeProperties properties) { + return SnowflakeConfig.defaultConfig( + properties.getEpoch().toEpochMilli(), properties.getDatacenterId(), properties.getWorkerId()); + } + /** * Creates the singleton {@link IdGenerator} for this application instance and * registers it with {@link SnowflakeIdGeneratorHolder} so that * {@code @SnowflakeGeneratedId}-annotated entity fields can be populated by * Hibernate. * - * @param properties the bound {@code snowflake.*} configuration, providing - * {@code datacenterId}, {@code workerId} and {@code epoch} + * @param config the shared {@link SnowflakeConfig} for this instance * @param registryProvider the application's {@link MeterRegistry}, if one exists; * when absent, generator activity is simply not measured, * no metrics backend is required @@ -46,9 +62,7 @@ public class SnowflakeAutoConfiguration { * dependency injection instead of the static holder */ @Bean - public IdGenerator idGenerator(SnowflakeProperties properties, ObjectProvider registryProvider) { - SnowflakeConfig config = SnowflakeConfig.defaultConfig( - properties.getEpoch().toEpochMilli(), properties.getDatacenterId(), properties.getWorkerId()); + public IdGenerator idGenerator(SnowflakeConfig config, ObjectProvider registryProvider) { MeterRegistry registry = registryProvider.getIfAvailable(); SnowflakeMetrics metrics = registry != null ? new MicrometerSnowflakeMetrics(registry) @@ -57,4 +71,19 @@ public IdGenerator idGenerator(SnowflakeProperties properties, ObjectProvider com.fayupable snowflake-id-java - 1.2.1 + 1.3.0 snowflake-jpa