Skip to content

feat(id): introduce IdGeneratorV2 with random nonce, base36, and suffix support - #109

Open
r0goyal wants to merge 104 commits into
mainfrom
id-v2
Open

r0goyal wants to merge 104 commits into
mainfrom
id-v2

Conversation

@r0goyal

@r0goyal r0goyal commented Jun 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Introduces IdGeneratorV2 — a next-generation ID generator that runs parallel to the existing IdGenerator with an extended feature set, while keeping the core IdGeneratorBase shared.

What's new

IdGeneratorV2

  • Fluent IdGenerationRequestV2 builder with .includeRandomNonce() and .includeBase36() options
  • Optional suffix support in generated IDs
  • Decorator pattern (IdDecorator) for post-format transformations (e.g. base36 encoding)
  • IdParsersV2 for parsing V2-format IDs back to Id objects

New formatter: RandomNonceIdFormatter

  • Format: {yyMMddHHmmssSSS}{nodeId:4d}{randomNonce:3d} — 22 chars total
  • Fully reversible via parse()

New decorator: Base36IdDecorator

  • Converts the 22-digit numeric payload to a 16-char base36 string, reducing ID length

IdGeneratorType enum

  • Encodes formatter + decorator combination as a 2-digit value embedded in the ID
  • Enables IdParsersV2 to auto-detect format and reverse the decorator chain on parse

Infrastructure

  • NodeUtils: thread-local + default node ID management, shared across both generators
  • NonceUtils: extracted retry count and retry event listener from NonceGenerator
  • InternalId: internal ID representation decoupled from the public Id type
  • IdGeneratorBase refactored to be formatter-agnostic via IdProvider function

Breaking changes (intentional)

  • IdGenerator.initialize(int, List, Map) → initialize(List, Map) (node now via NodeUtils)
  • IdGenerator.generate(IdGenerationRequest) now returns Optional<InternalId>
  • Domain.builder() no longer accepts idFormatter — formatter is now per-request

Notes

  • IdGenerator and IdGeneratorV2 maintain independent constraint/domain registries. Register constraints on each generator separately if both are used in the same service.

@r0goyal
r0goyal marked this pull request as ready for review June 25, 2026 07:12
@sonarqubecloud

Copy link
Copy Markdown

@santanusinha santanusinha left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR — the direction of introducing IdGeneratorV2 as a parallel, non-breaking (from a runtime perspective) API alongside IdGenerator is good. The new formatter/decorator separation is a cleaner design than the old Base36IdFormatter-wrapping-a-formatter approach. That said, there are a few concerns worth addressing before merging.


Breaking Changes

1. IdValidationConstraint.isValid(Id) → isValid(InternalId) is a binary-breaking change

Any downstream library or application implementing IdValidationConstraint will fail to compile against this version. This is not just an internal refactor — IdValidationConstraint is a public API. If this is intentional, it needs a prominent callout in the PR description and ideally a major version bump.

2. IdGenerator.initialize(int, List, Map) → initialize(List, Map)

Callers that previously passed the node ID into initialize() will now get a compile error. The PR description calls this out, but I'd suggest keeping the old overload as @Deprecated rather than removing it — especially since the IdGenerator.initialize(int) overload still exists (which is confusing: one overload still takes node, the other doesn't).

3. GenerationResult field renaming

Removing nonceInfo and adding exponent, time, and internalId breaks any caller using GenerationResult directly. If this class is considered internal (package-private), it is fine — but it is currently public.


Design / Correctness

4. NodeUtils node re-setting is silently allowed, but IdGeneratorBase.setNodeId throws on re-set

// NodeUtils — silently overwrites
public static void setNode(int nodeId) { node = nodeId; }

// IdGeneratorBase — throws on second call
if (this.nodeId > 0) { throw new RuntimeException("Node ID already set"); }

These two guards are inconsistent. A caller who calls NodeUtils.setNode() twice (e.g. in test teardown/setup) will silently succeed, but IdGeneratorBase.setNodeId() will then throw on the second initialize() call. This is exactly why the test files now need IdGenerator.cleanUp() / IdGeneratorV2.cleanUp() in @BeforeEach.

Consider making NodeUtils also throw (or log a warning) on re-set, consistent with IdGeneratorBase; or relax IdGeneratorBase to allow re-set, consistent with NodeUtils. The current state mixes both.

5. IdGeneratorType.LOOKUP_MAP relies on object identity for IdFormatter

private static final Map<IdFormatter, Map<List<IdDecorator>, Integer>> LOOKUP_MAP =
    Arrays.stream(values())
          .collect(Collectors.groupingBy(type -> type.idFormatter, ...));

IdFormatter is an interface with no equals()/hashCode() override — the map key is the specific singleton instances held in IdFormatters. This works as long as callers always use IdFormatters.original() / IdFormatters.randomNonce() (the cached singletons), but will silently return an empty Optional if anyone passes a freshly constructed formatter. The IdGeneratorRequestBuilder.includeRandomNonce() method does use the singleton, so it's fine today — but this is fragile. A comment or a guard would help.

6. IdParsersV2 DEFAULT_PATTERN ambiguity with V2 format

private static final Pattern DEFAULT_PATTERN = Pattern.compile("([A-Za-z]*)([\\d]{22})(.*)");

A V2 ID like PREFIX0122digitpayload... could match this pattern if the generator-type digits happen to be all digits too. The parsing strategy first checks DEFAULT_PATTERN and bails out if group(3) is empty — but for a V2 ID with no suffix, group(3) contains just the 2-digit type code and is not empty, so it falls through to PATTERN. This seems intentional, but the logic is subtle. A comment explaining the two-step disambiguation would significantly help future maintainers.

7. Dual registration burden on callers

The Javadoc on IdGeneratorV2 already mentions this, which is good:

"If both generators are used in the same service, ensure constraints and domains are registered on each independently."

However, ServiceDiscoveryBundle.start() initializes both generators with Collections.emptyMap() domain constraints. A real user who upgrades will need to remember to register the same constraints on both generators. Consider whether a shared domain/constraint registry (or at least a factory method that initializes both from the same config) would reduce this footgun.


Minor / Style

8. Missing license headers on new files

Several new files are missing the Apache 2.0 license header that all other files in the project carry:

  • InternalId.java
  • IdGenerationInternalRequest.java
  • IdProvider.java
  • NonceGenerators.java
  • NonceUtils.java
  • IdDecorators.java
  • FormattedId.java
  • IdGenerationRequestV2.java
  • NodeUtils.java

9. Stale @SneakyThrows in benchmark methods

In IdGeneratorPerfTest, the benchmark methods annotated @SneakyThrows no longer throw any checked exceptions. The annotation can be removed.

10. Unused import in IdGeneratorPerfV2Test and IdParsersV2Test

IdGeneratorBase is imported in IdParsersV2Test but not used.

11. IdGenerationRequest still has an idFormatter field, but Domain no longer has one

After removing idFormatter from Domain, the IdGenerationRequest (the old public-facing request class) still carries idFormatter. This creates confusion: is IdGenerationRequest still used? It appears to no longer be used anywhere after the refactor — if so, it should be deprecated or removed.


Tests

The new test suite for IdGeneratorV2Test is comprehensive and covers happy paths, retries, constraints, suffix/prefix validation, and round-trip parse/generate. Well done. Two suggestions:

  • Add a cross-generator test: generate with IdGenerator, parse with IdGeneratorV2.parse() and vice versa — the testOriginalIdParseSuccessAfterGeneration test already does the first direction, but the reverse (generate with V2, parse with legacy IdGenerator) is not covered and would document the interop contract.
  • IdGeneratorV2.cleanUp() should reset NodeUtils: Currently cleanUp() resets the base generator but NodeUtils.node remains set. After cleanUp(), calling IdGeneratorV2.initialize() without first calling NodeUtils.setNode() will silently use whatever the old node value was. This is especially surprising in tests where cleanUp() is called in @AfterEach but the node re-setup happens in @BeforeEach. This works today because @BeforeEach always calls NodeUtils.setNode() explicitly, but it is a latent footgun.

- IdValidationConstraint: restore isValid(Id) as the abstract method
  (binary-compat break flagged in review); isValid(InternalId) is now
  a default bridging via toInternalId()/toId() helpers
- GenerationResult: document as internal-only (not public API contract)
- IdFormatter: add id() so IdGeneratorType.LOOKUP_MAP keys off a stable
  identifier instead of formatter object identity, which only worked
  for the cached IdFormatters singletons
- IdParsersV2: comment DEFAULT_PATTERN's two-step V1/V2 disambiguation
- NodeUtils: add reset(), called from IdGenerator/IdGeneratorV2
  cleanUp() so a stale node id isn't silently reused by a later
  initialize() that forgets to call setNode() again
- add missing Apache license headers on 9 files
- drop stale @SneakyThrows from perf benchmarks, unused import in
  IdParsersV2Test, deprecate unused IdGenerationRequest
- add IdGeneratorV2Test covering the (asymmetric) V2->legacy parse
  interop contract

Refs review comments on #109

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
royrahulop and others added 10 commits July 21, 2026 00:39
Keep V1 and V2 generators on the same process-level node while allowing idempotent startup calls. Reset remains the explicit lifecycle boundary.
Resolve equivalent formatter instances without adding an internal lookup identifier to the public IdFormatter contract.
fix(id): address Santanu's review feedback on #109
@sonarqubecloud

Copy link
Copy Markdown

Keep the shared node reserved until all initialized generators stop.
Snapshot request lists so builder reuse cannot change encoded formats.
Reject partial IDs and invalid suffixes; join test workers before cleanup.

V2 requests no longer expose setConstraints; use the builder instead.
NodeUtils.reset rejects resets while a generator remains initialized.
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants