Skip to content

feat: configurable encoding with legacy 1.0.x compatibility mode - #22

Merged
mariuszs merged 5 commits into
masterfrom
feat/configurable-encoding
Sep 15, 2026
Merged

mariuszs merged 5 commits into
masterfrom
feat/configurable-encoding

Conversation

@mariuszs

@mariuszs mariuszs commented Jul 17, 2026 •

Copy link
Copy Markdown
Member

Version 1.1.0 switched the UUID pairing algorithm (ElegantPairing → BigIntegerPairing), silently changing every generated FriendlyId — and old identifiers decode to a different UUID without any error. Services still on 1.0.x whose identifiers are persisted by external consumers (e.g. a public API) could never upgrade past 1.0.4 without breaking their published ID space.

This makes the encoding selectable while keeping the current behaviour as the default:

  • New public enum FriendlyIdEncoding:
    • STANDARD — bit-shifting pairing, wire-compatible with 1.1.0+, default
    • LEGACY — Szudzik's elegant pairing, wire-compatible with 1.0.x (restored ElegantPairing)
  • Global selection via FriendlyIds.setEncoding(...) — every integration module (Jackson 3, Jackson 2, JPA, jOOQ, OpenFeign, Spring) funnels through FriendlyIds/Url62, so the setting applies everywhere
  • Spring Boot starter property: com.devskiller.friendly-id.encoding=legacy (new FriendlyIdProperties bound in FriendlyIdAutoConfiguration, plus first tests for the starter)
  • Wire format pinned by vectors generated from the released 1.0.4 and 1.1.0/2.0.0-beta5 artifacts, including edge cases (zero UUID, all-bits UUID, LSB sign bit)
  • README migration-guide section + CHANGELOG entry

LEGACY decoding performance

The restored 1.0.x ElegantPairing.unpair computed floor(sqrt) with a BigInteger binary search (~127 iterations), which made LEGACY unpairing ~50× slower than STANDARD (~3.4× end-to-end). It now uses a double estimate + one Newton step + off-by-one correction. The root is identical, so the wire format is unchanged: the 1.0.4 vectors still pass, and the new ElegantPairingTest checks sqrt against BigInteger.sqrt() around perfect squares up to 66-bit roots and at extreme long values.

BigInteger.sqrt() itself is not used yet: on JDK 21 it is ~9× slower than this implementation (it becomes faster on JDK 25), so a TODO marks the switch for after the JDK 25 upgrade.

JMH, JDK 21, ops/s (1 fork, 2×5s warmup, 5×5s measurement):

Benchmark LEGACY, 1.0.x sqrt LEGACY, this PR STANDARD
UuidConverter.toUuid 274k 5.43M 14.5M
FriendlyIds.toUuid (end-to-end, incl. Base62) 178k 529k ~545–600k

Encoding (FriendlyIds.toFriendlyId) is unaffected and on par with STANDARD (~1.5M ops/s for both).

JMH benchmarks

The jmh profile did not build: jmh.version was undefined, and UuidConverterBenchmark no longer compiled after UuidConverter started taking an encoding. Both benchmarks now run for STANDARD and LEGACY via @Param, and decode real encoder output instead of random 127-bit values.

Note for choosing an encoding: LEGACY identifiers are not shorter. Random (v4) and time-ordered (v7) UUIDs always encode to 22 characters, while STANDARD gives 21–22 for v4 and 21 for v7.

Full build: 7169 tests, 0 failures.

Motivation: lets 1.0.x-era services adopt 2.0 (Spring Boot 4 / Jackson 3) with legacy encoding, while 1.1.0+ services upgrade with no changes.

mariuszs added 5 commits July 17, 2026 15:38
Version 1.1.0 switched the UUID pairing algorithm from Szudzik's elegant
pairing to bit-shifting BigIntegerPairing, silently changing every generated
FriendlyId and making old identifiers decode to wrong UUIDs. Services still
on 1.0.x (with identifiers persisted by external consumers) could never
upgrade without breaking their public ID space.

- new public enum FriendlyIdEncoding: STANDARD (default, 1.1.0+ compatible)
  and LEGACY (1.0.x compatible, restored ElegantPairing)
- global selection via FriendlyIds.setEncoding(...); all modules (Jackson,
  Jackson2, JPA, jOOQ, OpenFeign, Spring) funnel through it
- Spring Boot starter property: com.devskiller.friendly-id.encoding=legacy
  (new FriendlyIdProperties, bound in FriendlyIdAutoConfiguration)
- wire-format pinned by test vectors generated from released 1.0.4 and
  1.1.0/2.0.0-beta5 artifacts, incl. edge cases (zero UUID, all-bits UUID,
  lsb sign bit)
Hide implicit public constructor (java:S1118) and use BigInteger.TWO
instead of the string constructor (java:S2129).
The jmh profile referenced an undefined jmh.version property, and
UuidConverterBenchmark no longer compiled after UuidConverter started
taking an encoding. Benchmarks now run for STANDARD and LEGACY via
@PARAM and decode real encoder output instead of random 127-bit values.
ElegantPairing.unpair computed floor(sqrt) with a ~130-step BigInteger
binary search. A double estimate followed by one Newton step and an
off-by-one correction returns the same root, so the wire format is
unchanged (1.0.4 vectors still pass).

JMH on JDK 21 (ops/s):
- UuidConverter.toUuid LEGACY: 274k -> 4.85M (~18x)
- FriendlyIds.toUuid LEGACY: 178k -> 515k (on par with STANDARD)

BigInteger.sqrt() is not used yet: on JDK 21 it is ~9x slower than this
implementation; it becomes faster on JDK 25.
SonarCloud flags new BigDecimal(double) as a bug (java:S2111), failing
the quality gate. Convert the double estimate to BigInteger exactly via
Math.scalb and shiftLeft instead, which is also slightly faster
(UuidConverter.toUuid LEGACY: 4.85M -> 5.43M ops/s).
@sonarqubecloud

Copy link
Copy Markdown

@mariuszs
mariuszs merged commit b44197a into master Sep 15, 2026
2 of 3 checks passed
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.

1 participant