New posts for Spring and Hibernate

This commit is contained in:
2026-11-06 01:05:17 +05:30
parent b83cc9988e
commit 0245c00033
57 changed files with 2113 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# id-generation
Companion module for the ankurm.com article **Hibernate ID Generation: IDENTITY vs SEQUENCE vs UUID v7, and Why Batch Inserts Break**
(https://ankurm.com/hibernate-id-generation-identity-sequence-uuid-v7-batch-inserts/).
Every claim in the article comes from a test here; each test writes what it observed to `output/` and asserts the same facts.
## Versions
| Component | Version | Notes |
|---|---|---|
| Hibernate ORM | `7.4.14.Final` | newest 7.x GA on Maven Central on 2026-11-05 (`8.0.0.Beta4` is listed but is not GA). Overrides the `7.4.5.Final` that Spring Boot 4.1.1 manages, same as `soft-delete` |
| Spring Boot parent | `4.1.1` | same parent as the sibling modules (`4.1.2` was already on Maven Central; not adopted so the modules stay comparable) |
| uuid-creator | `6.1.1` | only for one comparison generator |
| H2 | `2.4.240` | in memory, plus file databases for the index-size test |
| PostgreSQL | `17` (`postgres:17-alpine`) | Testcontainers. **Not run for the committed transcripts**, see below |
| Java | `25` (Temurin/Oracle 25.0.3) | |
## Run
```bash
./scripts/run-all.sh # javap dump + mvn clean test; regenerates everything in output/
```
Tests that can use PostgreSQL run against it when a Docker daemon answers and otherwise write `SKIPPED: no Docker daemon ...` into the
transcript. **The committed transcripts were produced without Docker, so every PostgreSQL section says SKIPPED and the PostgreSQL code paths
(including `06b-index-size-postgres.txt`: `pg_relation_size`, `pgstatindex`) have not been executed.** Run the script on a machine with Docker to fill them in.
## Output
| File | What it shows |
|---|---|
| `00-javap.txt` | `javap` of `UuidGenerator`, `UuidGenerator$Style`, `UuidValueGenerator`, `UuidVersion7Strategy`, `SequenceMismatchStrategy`, `StandardOptimizerDescriptor` and the static members of `java.util.UUID` |
| `00b-style-by-version.txt` | the `Style` constants in hibernate-core 6.5.2, 6.6.8, 7.0.0, 7.4.0, 7.4.14 (hand-run `javap`, not regenerated by the script) |
| `01-uuid-styles.txt` | version nibble, variant and ordering of ids from every `Style`, the JDK's UUID factories, what `Style.TIME` really contains |
| `01b-uuid-generation-rate.txt` | in-memory cost of generating one id per generator (indicative) |
| `02-identity-vs-sequence.txt` | DDL per strategy, IDENTITY insert at `persist()`, sequence calls for `allocationSize` 1 vs 50, TABLE generator statements |
| `02b-default-sequence-naming.txt` | default sequence names and the `legacy` / `single` naming switch |
| `03-batch-inserts.txt` | 10,000 inserts per strategy: prepared statements, `addBatch`, `executeBatch` counts, indicative timings |
| `03b-order-inserts.txt` | `hibernate.order_inserts` on a parent/child graph |
| `04-sequence-increment-mismatch.txt` | mapping `allocationSize=50` over a sequence `INCREMENT BY 1`, under each mismatch strategy |
| `04b-sequence-step-bigger-than-mapping.txt` | mapping `allocationSize=1` over a sequence `INCREMENT BY 50` |
| `05-auto-and-uuid-per-dialect.txt` | what `@GeneratedValue` and UUID columns become on H2, PostgreSQL, MySQL, MariaDB, Oracle, SQL Server (DDL only) |
| `06-index-size-h2.txt` | 200,000 rows per key type in an H2 file database: file size |
| `06b-index-size-postgres.txt` | the same on PostgreSQL (`SKIPPED` without Docker) |
| `07-spring-data-save.txt` | `save()` on assigned vs generated ids: which ones cost a `SELECT` first |
## Not covered (not run)
MySQL / InnoDB clustered-index behaviour, SQL Server, Oracle (DDL only), PostgreSQL index measurements, multi-node clock skew, `hibernate.id.optimizer.pooled.preferred` variants.
+76
View File
@@ -0,0 +1,76 @@
# javap of the shipped hibernate-core-7.4.14.Final.jar
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.annotations.UuidGenerator
Compiled from "UuidGenerator.java"
public interface org.hibernate.annotations.UuidGenerator extends java.lang.annotation.Annotation {
public abstract org.hibernate.annotations.UuidGenerator$Style style();
public abstract java.lang.Class<? extends org.hibernate.id.uuid.UuidValueGenerator> algorithm();
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.annotations.UuidGenerator$Style
Compiled from "UuidGenerator.java"
public final class org.hibernate.annotations.UuidGenerator$Style extends java.lang.Enum<org.hibernate.annotations.UuidGenerator$Style> {
public static final org.hibernate.annotations.UuidGenerator$Style AUTO;
public static final org.hibernate.annotations.UuidGenerator$Style RANDOM;
public static final org.hibernate.annotations.UuidGenerator$Style TIME;
public static final org.hibernate.annotations.UuidGenerator$Style VERSION_6;
public static final org.hibernate.annotations.UuidGenerator$Style VERSION_7;
public static org.hibernate.annotations.UuidGenerator$Style[] values();
public static org.hibernate.annotations.UuidGenerator$Style valueOf(java.lang.String);
static {};
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.id.uuid.UuidValueGenerator
Compiled from "UuidValueGenerator.java"
public interface org.hibernate.id.uuid.UuidValueGenerator {
public abstract java.util.UUID generateUuid(org.hibernate.engine.spi.SharedSessionContractImplementor);
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.id.uuid.UuidVersion7Strategy
Compiled from "UuidVersion7Strategy.java"
public class org.hibernate.id.uuid.UuidVersion7Strategy implements org.hibernate.id.UUIDGenerationStrategy,org.hibernate.id.uuid.UuidValueGenerator {
public static final org.hibernate.id.uuid.UuidVersion7Strategy INSTANCE;
public org.hibernate.id.uuid.UuidVersion7Strategy();
public org.hibernate.id.uuid.UuidVersion7Strategy(java.time.Instant, long);
public int getGeneratedVersion();
public java.util.UUID generateUUID(org.hibernate.engine.spi.SharedSessionContractImplementor);
public java.util.UUID generateUuid(org.hibernate.engine.spi.SharedSessionContractImplementor);
static {};
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.id.SequenceMismatchStrategy
Compiled from "SequenceMismatchStrategy.java"
public final class org.hibernate.id.SequenceMismatchStrategy extends java.lang.Enum<org.hibernate.id.SequenceMismatchStrategy> {
public static final org.hibernate.id.SequenceMismatchStrategy LOG;
public static final org.hibernate.id.SequenceMismatchStrategy EXCEPTION;
public static final org.hibernate.id.SequenceMismatchStrategy FIX;
public static final org.hibernate.id.SequenceMismatchStrategy NONE;
public static org.hibernate.id.SequenceMismatchStrategy[] values();
public static org.hibernate.id.SequenceMismatchStrategy valueOf(java.lang.String);
public static org.hibernate.id.SequenceMismatchStrategy interpret(java.lang.Object);
static {};
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.id.enhanced.StandardOptimizerDescriptor
Compiled from "StandardOptimizerDescriptor.java"
public final class org.hibernate.id.enhanced.StandardOptimizerDescriptor extends java.lang.Enum<org.hibernate.id.enhanced.StandardOptimizerDescriptor> implements org.hibernate.id.enhanced.OptimizerDescriptor {
public static final org.hibernate.id.enhanced.StandardOptimizerDescriptor NONE;
public static final org.hibernate.id.enhanced.StandardOptimizerDescriptor HILO;
public static final org.hibernate.id.enhanced.StandardOptimizerDescriptor LEGACY_HILO;
public static final org.hibernate.id.enhanced.StandardOptimizerDescriptor POOLED;
public static final org.hibernate.id.enhanced.StandardOptimizerDescriptor POOLED_LO;
public static final org.hibernate.id.enhanced.StandardOptimizerDescriptor POOLED_LOTL;
public static org.hibernate.id.enhanced.StandardOptimizerDescriptor[] values();
public static org.hibernate.id.enhanced.StandardOptimizerDescriptor valueOf(java.lang.String);
public java.lang.String getExternalName();
public java.lang.Class<? extends org.hibernate.id.enhanced.Optimizer> getOptimizerClass();
public boolean isPooled();
public static org.hibernate.id.enhanced.OptimizerDescriptor fromExternalName(java.lang.String);
static {};
}
$ javap java.util.UUID (static members only)
public static java.util.UUID randomUUID();
public static java.util.UUID nameUUIDFromBytes(byte[]);
public static java.util.UUID fromString(java.lang.String);
static {};
@@ -0,0 +1,7 @@
# UuidGenerator.Style constants per hibernate-core release (javap of each jar; produced by hand while writing the article, not by scripts/run-all.sh)
hibernate-core-6.5.2.Final: AUTO RANDOM TIME | UuidVersion7Strategy in jar: 0
hibernate-core-6.6.8.Final: AUTO RANDOM TIME | UuidVersion7Strategy in jar: 0
hibernate-core-7.0.0.Final: AUTO RANDOM TIME VERSION_6 VERSION_7 | UuidVersion7Strategy in jar: 1
hibernate-core-7.4.0.Final: AUTO RANDOM TIME VERSION_6 VERSION_7 | UuidVersion7Strategy in jar: 1
hibernate-core-7.4.14.Final: AUTO RANDOM TIME VERSION_6 VERSION_7 | UuidVersion7Strategy in jar: 1
+57
View File
@@ -0,0 +1,57 @@
UuidGenerator.Style values and what the generated UUIDs look like. Hibernate 7.4.14.Final, JDK 25.0.3+9-LTS-195
== The enum and the annotation, from the shipped jar
Style values : [AUTO, RANDOM, TIME, VERSION_6, VERSION_7]
annotation attributes : [algorithm, style]
== Does the JDK itself generate version 7? (public static methods of java.util.UUID)
static factories: [fromString, nameUUIDFromBytes, randomUUID]
== GenerationType.UUID (JPA) (UuidV4Item, 2000 persists in one transaction)
first three : [5b257f75-63bc-4f48-905d-65bac3459d13, 8b636cac-72ba-4740-baa4-960522598e0f, 7563a4d5-dfe5-4be5-b1f5-55e71a83eaec]
version nibble of every id : [4], variant : [2]
string/byte order vs generation order : 1017 of 1999 neighbours are out of order
== bare @GeneratedValue on a UUID (AUTO) (AutoUuidItem, 2000 persists in one transaction)
first three : [35ad5be5-7f04-4dc8-83b9-f2266e101ab0, 78a0cefb-b172-46e4-94aa-60212ad0b190, 162be54a-33b8-4018-8df9-5e43836cc2af]
version nibble of every id : [4], variant : [2]
string/byte order vs generation order : 1005 of 1999 neighbours are out of order
== Style.TIME (UuidTimeItem, 2000 persists in one transaction)
first three : [c0a802bb-a1ad-1754-81a1-ad076a3225a0, c0a802bb-a1ad-1754-81a1-ad076a3225a1, c0a802bb-a1ad-1754-81a1-ad076a3225a2]
version nibble of every id : [1], variant : [2]
string/byte order vs generation order : 0 of 1999 neighbours are out of order
UUID.timestamp() of first / last id (100ns ticks since 1582) : 528224823942841019 / 528224823942841019
most significant 32 bits (time_low) of first three : [c0a802bb, c0a802bb, c0a802bb]
== Style.VERSION_6 (UuidV6Item, 2000 persists in one transaction)
first three : [1f1d9490-534d-6627-8be9-341e19966c04, 1f1d9490-534d-6627-8bea-9c10655ddb7b, 1f1d9490-534d-6627-8beb-b5f923905e48]
version nibble of every id : [6], variant : [2]
string/byte order vs generation order : 0 of 1999 neighbours are out of order
== Style.VERSION_7 (UuidV7Item, 2000 persists in one transaction)
first three : [01a1ad07-6af9-7f60-91e5-a074a2e61b15, 01a1ad07-6af9-7f60-bafb-92ecaabdd57e, 01a1ad07-6af9-7f61-84be-68a7813aa2e1]
version nibble of every id : [7], variant : [2]
string/byte order vs generation order : 0 of 1999 neighbours are out of order
first 48 bits as Unix milliseconds : 1793904306937 -> 2026-11-05T18:45:06.937Z
== uuid-creator getTimeOrderedEpoch() (UuidCreatorV7Item, 2000 persists in one transaction)
first three : [01a1ad07-6b4a-7727-8708-c7b485cd7cc5, 01a1ad07-6b4a-7727-8709-75a505ff49d2, 01a1ad07-6b4a-7727-870a-1991eca1f5b5]
version nibble of every id : [7], variant : [2]
string/byte order vs generation order : 0 of 1999 neighbours are out of order
first 48 bits as Unix milliseconds : 1793904307018 -> 2026-11-05T18:45:07.018Z
== hand-written UuidV7Generator (HandRolledV7Item, 2000 persists in one transaction)
first three : [01a1ad07-6ba5-7041-a4e3-8df974d6a001, 01a1ad07-6ba5-7042-8be9-c72dadba0e18, 01a1ad07-6ba5-7043-87de-73e7de8dbb54]
version nibble of every id : [7], variant : [2]
string/byte order vs generation order : 0 of 1999 neighbours are out of order
first 48 bits as Unix milliseconds : 1793904307109 -> 2026-11-05T18:45:07.109Z
== Style.TIME is not a clock-ordered v1: what changes between ids created 30 ms apart
c0a802bb-a1ad-1754-81a1-ad076be82d70 msb=c0a802bba1ad1754 lsb=81a1ad076be82d70
c0a802bb-a1ad-1754-81a1-ad076c092d71 msb=c0a802bba1ad1754 lsb=81a1ad076c092d71
c0a802bb-a1ad-1754-81a1-ad076c282d72 msb=c0a802bba1ad1754 lsb=81a1ad076c282d72
c0a802bb-a1ad-1754-81a1-ad076c482d73 msb=c0a802bba1ad1754 lsb=81a1ad076c482d73
distinct most-significant halves among them : 1 (IPv4 address of this host + a per-JVM value, fixed for the life of the JVM)
first 32 bits equal this host's IPv4 address : true
UUID.timestamp() (what a real version 1 would use as its clock) is the same for all four : 1 distinct value(s)
@@ -0,0 +1,7 @@
Cost of generating one UUID in memory (single thread, 2,000,000 ids after 500,000 warm-up; indicative, machine dependent)
UUID.randomUUID() 351 ns per id
Hibernate VERSION_7 769 ns per id
Hibernate VERSION_6 680 ns per id
Hibernate TIME (v1) 22 ns per id
uuid-creator v7 352 ns per id
UuidV7Generator (hand-written) 709 ns per id
@@ -0,0 +1,43 @@
IDENTITY vs SEQUENCE vs TABLE: generated DDL, SQL, and when the INSERT runs
######## H2 ########
database: H2Dialect / H2 2.4.240 (2025-09-22)
== DDL Hibernate generates
create sequence seq_one_item start with 1 increment by 1;
create sequence seq_pooled_item start with 1 increment by 50;
create sequence SeqDefaultItem_SEQ start with 1 increment by 50;
create table hibernate_sequences (next_val bigint, sequence_name varchar(255) not null, primary key (sequence_name));
insert into hibernate_sequences(sequence_name, next_val) values ('default',0);
create table IdentityItem (id bigint generated by default as identity, name varchar(255), primary key (id));
create table SeqDefaultItem (id bigint not null, name varchar(255), primary key (id));
create table SeqOneItem (id bigint not null, name varchar(255), primary key (id));
create table SeqPooledItem (id bigint not null, name varchar(255), primary key (id));
create table TableItem (id bigint not null, name varchar(255), primary key (id));
== IDENTITY: persist() runs the INSERT immediately
-- persist(item) -- no flush(), no commit() yet
sql> insert into IdentityItem (name,id) values (?,default)
item.getId() right after persist : 1
rows visible after rollback : 0
== SEQUENCE, allocationSize = 1: one sequence call per entity
persist 120 entities, one transaction
sequence calls : 120 inserts : 120
first ids : [1, 2, 3, 4, 5] ... last id : 120
== SEQUENCE, allocationSize = 50 (pooled optimizer): one sequence call per 50 entities
persist 120 entities, one transaction
sequence calls : 4 inserts : 120
first ids : [1, 2, 3, 4, 5] ... last id : 120
sequence statements : [select next value for seq_pooled_item]
(the first allocation of a fresh sequence costs two calls: one to learn the starting value, one to learn the upper bound of the first block of 50)
persist another 120 entities, same SessionFactory : sequence calls : 2
== TABLE generator: one read + one update of the generator row per allocation
persist 120 entities: statements other than the 120 INSERTs : 8 (seen by the JDBC counter; Hibernate's StatementInspector does not see them)
sql> select next_val from hibernate_sequences where sequence_name=? for update
sql> update hibernate_sequences set next_val=? where next_val=? and sequence_name=?
######## POSTGRES ########
SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run.
@@ -0,0 +1,17 @@
Sequence names Hibernate 7 picks when you name nothing, and the legacy switch
######## H2 ########
database: H2Dialect / H2 2.4.240 (2025-09-22)
== hibernate.id.db_structure_naming_strategy = standard (entities: AutoLongItem, SeqDefaultItem)
create sequence AutoLongItem_SEQ start with 1 increment by 50;
create sequence SeqDefaultItem_SEQ start with 1 increment by 50;
== hibernate.id.db_structure_naming_strategy = legacy (entities: AutoLongItem, SeqDefaultItem)
create sequence hibernate_sequence start with 1 increment by 1;
== hibernate.id.db_structure_naming_strategy = single (entities: AutoLongItem, SeqDefaultItem)
create sequence hibernate_sequence start with 1 increment by 1;
######## POSTGRES ########
SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run.
+19
View File
@@ -0,0 +1,19 @@
10,000 inserts: IDENTITY vs SEQUENCE vs UUID, hibernate.jdbc.batch_size=50, flush+clear every 50 rows
######## H2 ########
database: H2Dialect / H2 2.4.240 (2025-09-22)
timings are wall-clock milliseconds on one developer machine, one run each after a warm-up pass: indicative, not a benchmark
IDENTITY 331 ms prepared=10000 (insert=10000, sequence=0) addBatch=0 executeBatch=0 executeSingle=10000
SEQUENCE allocationSize=1 226 ms prepared=10200 (insert=200, sequence=10000) addBatch=10000 executeBatch=200 executeSingle=10000
SEQUENCE allocationSize=50 46 ms prepared=400 (insert=200, sequence=200) addBatch=10000 executeBatch=200 executeSingle=200
UUID v4 (GenerationType.UUID) 98 ms prepared=200 (insert=200, sequence=0) addBatch=10000 executeBatch=200 executeSingle=0
UUID v7 (Style.VERSION_7) 64 ms prepared=200 (insert=200, sequence=0) addBatch=10000 executeBatch=200 executeSingle=0
Hibernate Statistics for the whole session factory (all rows above, including the warm-up): entityInsertCount=55000 prepareStatementCount=23101
Same SEQUENCE allocationSize=50 entity, hibernate.jdbc.batch_size NOT set:
SEQUENCE allocationSize=50 68 ms prepared=10201 (insert=10000, sequence=201) addBatch=0 executeBatch=0 executeSingle=10201
######## POSTGRES ########
SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run.
@@ -0,0 +1,9 @@
hibernate.order_inserts on a parent/child graph (1,000 parents x 3 children, batch_size=50, flush+clear every 50 parents)
######## H2 ########
database: H2Dialect / H2 2.4.240 (2025-09-22)
order_inserts=false 266 ms prepared=2082 (insert=2000, sequence=82) addBatch=4000 executeBatch=2000 executeSingle=82
order_inserts=true 118 ms prepared=122 (insert=40, sequence=82) addBatch=4000 executeBatch=80 executeSingle=82
######## POSTGRES ########
SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run.
@@ -0,0 +1,30 @@
Mapping says allocationSize=50, the database sequence says INCREMENT BY 1 (schema created by hand)
######## H2 ########
== hibernate.id.sequence.increment_size_mismatch_strategy = (unset)
SessionFactory startup FAILED: MappingException -> MappingException: The increment size of the [seq_pooled_item] sequence is set to [50] in the entity mapping but the mapped database sequence increment size is [1]
== hibernate.id.sequence.increment_size_mismatch_strategy = EXCEPTION
SessionFactory startup FAILED: MappingException -> MappingException: The increment size of the [seq_pooled_item] sequence is set to [50] in the entity mapping but the mapped database sequence increment size is [1]
== hibernate.id.sequence.increment_size_mismatch_strategy = LOG
SessionFactory started
application instance A : ids [1, -47, -46]
application instance B : FAILED after ids [-46, -45, -44] : ConstraintViolationException -> JdbcSQLIntegrityConstraintViolationException: SQLState 23505, Unique index or primary key violation: "PUBLIC.CONSTRAINT_D PRIMARY KEY ON PUBLIC.SEQPOOLEDITEM(ID) ( /* key:-46 */ CAST(-46 AS BIGINT), 'a2')"; SQL statement:
database sequence INCREMENT BY, as the database sees it : 1
== hibernate.id.sequence.increment_size_mismatch_strategy = FIX
SessionFactory started
application instance A : ids [1, 2, 3]
application instance B : ids [4, 5, 6]
database sequence INCREMENT BY, as the database sees it : 1
== hibernate.id.sequence.increment_size_mismatch_strategy = NONE
SessionFactory started
application instance A : ids [1, -47, -46]
application instance B : FAILED after ids [-46, -45, -44] : ConstraintViolationException -> JdbcSQLIntegrityConstraintViolationException: SQLState 23505, Unique index or primary key violation: "PUBLIC.CONSTRAINT_D PRIMARY KEY ON PUBLIC.SEQPOOLEDITEM(ID) ( /* key:-46 */ CAST(-46 AS BIGINT), 'a2')"; SQL statement:
database sequence INCREMENT BY, as the database sees it : 1
######## POSTGRES ########
SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run.
@@ -0,0 +1,12 @@
Mapping says allocationSize=1, the database sequence says INCREMENT BY 50
######## H2 ########
== hibernate.id.sequence.increment_size_mismatch_strategy = (unset)
ids : [1, 51, 101, 151]
== hibernate.id.sequence.increment_size_mismatch_strategy = FIX
ids : [1, 51, 101, 151]
######## POSTGRES ########
SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run.
@@ -0,0 +1,49 @@
What @GeneratedValue (AUTO) and UUID ids become per dialect. DDL only: no database connection is opened for the dialects other than H2. Hibernate 7.4.14.Final
== H2 (org.hibernate.dialect.H2Dialect)
create sequence AutoLongItem_SEQ start with 1 increment by 50;
create table AutoLongItem (id bigint not null, name varchar(255), primary key (id));
create table AutoUuidItem (id uuid not null, name varchar(255), primary key (id));
create table IdentityItem (id bigint generated by default as identity, name varchar(255), primary key (id));
create table UuidCharItem (id char(36) not null, name varchar(255), primary key (id));
create table UuidV7Item (id uuid not null, name varchar(255), primary key (id));
== PostgreSQL (org.hibernate.dialect.PostgreSQLDialect)
create sequence AutoLongItem_SEQ start with 1 increment by 50;
create table AutoLongItem (id bigint not null, name varchar(255), primary key (id));
create table AutoUuidItem (id uuid not null, name varchar(255), primary key (id));
create table IdentityItem (id bigint generated by default as identity, name varchar(255), primary key (id));
create table UuidCharItem (id char(36) not null, name varchar(255), primary key (id));
create table UuidV7Item (id uuid not null, name varchar(255), primary key (id));
== MySQL (org.hibernate.dialect.MySQLDialect)
create table AutoLongItem (id bigint not null, name varchar(255), primary key (id)) engine=InnoDB;
create table AutoLongItem_SEQ (next_val bigint) engine=InnoDB;
create table AutoUuidItem (id binary(16) not null, name varchar(255), primary key (id)) engine=InnoDB;
create table IdentityItem (id bigint not null auto_increment, name varchar(255), primary key (id)) engine=InnoDB;
create table UuidCharItem (id varchar(36) not null, name varchar(255), primary key (id)) engine=InnoDB;
create table UuidV7Item (id binary(16) not null, name varchar(255), primary key (id)) engine=InnoDB;
== MariaDB (org.hibernate.dialect.MariaDBDialect)
create sequence AutoLongItem_SEQ start with 1 increment by 50 nocache;
create table AutoLongItem (id bigint not null, name varchar(255), primary key (id)) engine=InnoDB;
create table AutoUuidItem (id binary(16) not null, name varchar(255), primary key (id)) engine=InnoDB;
create table IdentityItem (id bigint not null auto_increment, name varchar(255), primary key (id)) engine=InnoDB;
create table UuidCharItem (id varchar(36) not null, name varchar(255), primary key (id)) engine=InnoDB;
create table UuidV7Item (id binary(16) not null, name varchar(255), primary key (id)) engine=InnoDB;
== Oracle (org.hibernate.dialect.OracleDialect)
create sequence AutoLongItem_SEQ start with 1 increment by 50;
create table AutoLongItem (id number(19,0) not null, name varchar2(255 char), primary key (id));
create table AutoUuidItem (id raw(16) not null, name varchar2(255 char), primary key (id));
create table IdentityItem (id number(19,0) generated by default as identity, name varchar2(255 char), primary key (id));
create table UuidCharItem (id char(36) not null, name varchar2(255 char), primary key (id));
create table UuidV7Item (id raw(16) not null, name varchar2(255 char), primary key (id));
== SQL Server (org.hibernate.dialect.SQLServerDialect)
create sequence AutoLongItem_SEQ start with 1 increment by 50;
create table AutoLongItem (id bigint not null, name varchar(255), primary key (id));
create table AutoUuidItem (id uniqueidentifier not null, name varchar(255), primary key (id));
create table IdentityItem (id bigint identity not null, name varchar(255), primary key (id));
create table UuidCharItem (id char(36) not null, name varchar(255), primary key (id));
create table UuidV7Item (id uniqueidentifier not null, name varchar(255), primary key (id));
@@ -0,0 +1,8 @@
Primary-key choice vs database file size, H2 file database (MVStore B-tree), file size after the database is closed, 200000 rows per variant, batch_size=50, flush+clear every 50 rows
H2 specific: this is H2's storage engine, not PostgreSQL's or InnoDB's. Timings are indicative (one run, one machine).
variant load ms file KiB
bigint sequence (allocationSize 50) 1244 4944
UUID v4 random 2280 101428
UUID v7 time-ordered 1314 12960
UUID v7 as CHAR(36) 1528 52948
@@ -0,0 +1,2 @@
Primary-key choice vs index size and leaf density, PostgreSQL postgres:17-alpine, 200000 rows per variant, batch_size=50
SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run.
@@ -0,0 +1,20 @@
Spring Data JPA save() with application-assigned vs generated ids. Spring Boot parent 4.1.1, Hibernate 7.4.14.Final, H2
== AssignedBook: UUID.randomUUID() assigned in the constructor, no @Version
sql> select ab1_0.id,ab1_0.title from assigned_book ab1_0 where ab1_0.id=?
sql> insert into assigned_book (title,id) values (?,?)
== VersionedBook: same, plus a boxed @Version column
sql> insert into versioned_book (title,version,id) values (?,?,?)
== PersistableBook: same, entity implements Persistable.isNew()
sql> insert into persistable_book (title,id) values (?,?)
== GeneratedBook: @UuidGenerator(style = VERSION_7), id is null until persist
sql> insert into generated_book (title,id) values (?,?)
== saveAll of 100 new AssignedBook rows: selects issued
select statements : 100, insert statements : 100
== saveAll of 100 new GeneratedBook rows: selects issued
select statements : 0, insert statements : 100
+73
View File
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>id-generation</artifactId>
<version>1.0.0</version>
<name>id-generation</name>
<description>Hibernate 7 primary keys: IDENTITY vs SEQUENCE vs TABLE vs UUID (random and time-ordered), JDBC batching, and index locality on H2 and PostgreSQL</description>
<properties>
<java.version>25</java.version>
<!-- Boot 4.1.1 manages 7.4.5.Final; this module overrides to 7.4.14.Final, the newest 7.x GA on Maven Central (checked 2026-11-05), same as the soft-delete module. -->
<hibernate.version>7.4.14.Final</hibernate.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.f4b6a3</groupId>
<artifactId>uuid-creator</artifactId>
<version>6.1.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-Xmx512m</argLine>
<forkCount>1</forkCount>
</configuration>
</plugin>
</plugins>
</build>
</project>
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Regenerates every file under output/. Needs JDK 25 and Maven 3.9 with access to Maven Central.
# PostgreSQL sections need a running Docker daemon (Testcontainers starts postgres:17-alpine); without one they are
# skipped and each transcript says so. H2 sections always run.
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p output
CP_FILE="$(mktemp)"
mvn -B -q dependency:build-classpath -Dmdep.outputFile="$CP_FILE" >/dev/null
SEP=':'; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) SEP=';';; esac
JAR="$(tr "$SEP" '\n' < "$CP_FILE" | grep 'hibernate-core-')"
{
echo "# javap of the shipped $(basename "$JAR")"
for c in org.hibernate.annotations.UuidGenerator 'org.hibernate.annotations.UuidGenerator$Style' \
org.hibernate.id.uuid.UuidValueGenerator org.hibernate.id.uuid.UuidVersion7Strategy \
org.hibernate.id.SequenceMismatchStrategy org.hibernate.id.enhanced.StandardOptimizerDescriptor; do
echo; echo "\$ javap -cp $(basename "$JAR") $c"
javap -cp "$JAR" "$c" 2>&1 | grep -v '^Picked up'
done
echo; echo "\$ javap java.util.UUID (static members only)"
javap java.util.UUID | grep static | grep -v assertionsDisabled
} > output/00-javap.txt
rm -f "$CP_FILE"
mvn -B clean test
ls -1 output
@@ -0,0 +1,22 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** Bare @GeneratedValue (strategy AUTO) on a Long. */
@Entity
public class AutoLongItem {
@Id
@GeneratedValue
private Long id;
private String name;
public AutoLongItem() {}
public AutoLongItem(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,22 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** Bare @GeneratedValue (strategy AUTO) on a UUID. */
@Entity
public class AutoUuidItem {
@Id
@GeneratedValue
private UUID id;
private String name;
public AutoUuidItem() {}
public AutoUuidItem(String name) { this.name = name; }
public UUID getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,25 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
/** Child of {@link Parent}. */
@Entity
public class Child {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_child")
@SequenceGenerator(name = "seq_child", sequenceName = "seq_child", allocationSize = 50)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Parent parent;
public Child() {}
public Child(String name) { this.name = name; }
void setParent(Parent p) { this.parent = p; }
public Long getId() { return id; }
}
@@ -0,0 +1,22 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** Version 7 from the hand-written {@link UuidV7Generator}, plugged in through @UuidGenerator(algorithm = ...). */
@Entity
public class HandRolledV7Item {
@Id
@UuidGenerator(algorithm = UuidV7Generator.class)
private UUID id;
private String name;
public HandRolledV7Item() {}
public HandRolledV7Item(String name) { this.name = name; }
public UUID getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,22 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** GenerationType.IDENTITY: the database assigns the id while the INSERT runs. */
@Entity
public class IdentityItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
public IdentityItem() {}
public IdentityItem(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,27 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
/** Parent of a small graph, used to show what hibernate.order_inserts changes. Sequence ids, so JDBC batching is possible. */
@Entity
public class Parent {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_parent")
@SequenceGenerator(name = "seq_parent", sequenceName = "seq_parent", allocationSize = 50)
private Long id;
private String name;
@OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
private List<Child> children = new ArrayList<>();
public Parent() {}
public Parent(String name) { this.name = name; }
public void add(Child c) { c.setParent(this); children.add(c); }
public Long getId() { return id; }
}
@@ -0,0 +1,22 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** SEQUENCE with no @SequenceGenerator at all: Hibernate chooses the sequence name and allocation size. */
@Entity
public class SeqDefaultItem {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private String name;
public SeqDefaultItem() {}
public SeqDefaultItem(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,23 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** SEQUENCE with allocationSize = 1: one sequence round trip per entity. */
@Entity
public class SeqOneItem {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_one_item")
@SequenceGenerator(name = "seq_one_item", sequenceName = "seq_one_item", allocationSize = 1)
private Long id;
private String name;
public SeqOneItem() {}
public SeqOneItem(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,23 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** SEQUENCE with allocationSize = 50 (the JPA default): one sequence round trip per 50 entities (pooled optimizer). */
@Entity
public class SeqPooledItem {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_pooled_item")
@SequenceGenerator(name = "seq_pooled_item", sequenceName = "seq_pooled_item", allocationSize = 50)
private Long id;
private String name;
public SeqPooledItem() {}
public SeqPooledItem(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,22 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** GenerationType.TABLE: ids come from a row in a generator table. */
@Entity
public class TableItem {
@Id
@GeneratedValue(strategy = GenerationType.TABLE)
private Long id;
private String name;
public TableItem() {}
public TableItem(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,25 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.annotations.UuidGenerator;
import org.hibernate.type.SqlTypes;
/** Version 7 UUID stored as text, CHAR(36), instead of the dialect's native/binary uuid type. */
@Entity
public class UuidCharItem {
@Id
@UuidGenerator(style = UuidGenerator.Style.VERSION_7)
@JdbcTypeCode(SqlTypes.CHAR)
private UUID id;
private String name;
public UuidCharItem() {}
public UuidCharItem(String name) { this.name = name; }
public UUID getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,14 @@
package com.ankurm.idgen;
import com.github.f4b6a3.uuid.UuidCreator;
import java.util.UUID;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.id.uuid.UuidValueGenerator;
/** Adapter: lets {@code @UuidGenerator(algorithm = UuidCreatorV7.class)} use the uuid-creator library's version 7 generator. */
public class UuidCreatorV7 implements UuidValueGenerator {
@Override
public UUID generateUuid(SharedSessionContractImplementor session) {
return UuidCreator.getTimeOrderedEpoch();
}
}
@@ -0,0 +1,22 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** Version 7 from the uuid-creator library, plugged in through @UuidGenerator(algorithm = ...). */
@Entity
public class UuidCreatorV7Item {
@Id
@UuidGenerator(algorithm = UuidCreatorV7.class)
private UUID id;
private String name;
public UuidCreatorV7Item() {}
public UuidCreatorV7Item(String name) { this.name = name; }
public UUID getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,22 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** @UuidGenerator(style = TIME): a version 1 (Gregorian time) UUID with Hibernate's own layout. Not version 7. */
@Entity
public class UuidTimeItem {
@Id
@UuidGenerator(style = UuidGenerator.Style.TIME)
private UUID id;
private String name;
public UuidTimeItem() {}
public UuidTimeItem(String name) { this.name = name; }
public UUID getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,22 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** GenerationType.UUID (JPA 3.1): random, version 4. */
@Entity
public class UuidV4Item {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String name;
public UuidV4Item() {}
public UuidV4Item(String name) { this.name = name; }
public UUID getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,22 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** @UuidGenerator(style = VERSION_6): RFC 9562 version 6, the time-ordered re-arrangement of version 1. */
@Entity
public class UuidV6Item {
@Id
@UuidGenerator(style = UuidGenerator.Style.VERSION_6)
private UUID id;
private String name;
public UuidV6Item() {}
public UuidV6Item(String name) { this.name = name; }
public UUID getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,42 @@
package com.ankurm.idgen;
import java.security.SecureRandom;
import java.util.UUID;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.id.uuid.UuidValueGenerator;
/**
* A hand-written RFC 9562 version 7 generator, about twenty lines of logic. It exists to show what the layout is
* (the shipped {@code Style.VERSION_7} does the same job); do not prefer it to the built-in one.
*
* <pre>
* 48 bits unix_ts_ms milliseconds since 1970-01-01T00:00Z, big-endian: this is what makes ids sort by creation time
* 4 bits version = 0111
* 12 bits rand_a used here as a counter, so two ids created in the same millisecond still increase
* 2 bits variant = 10
* 62 bits rand_b random
* </pre>
*/
public class UuidV7Generator implements UuidValueGenerator {
private static final SecureRandom RNG = new SecureRandom();
private long lastMs = -1;
private int counter;
@Override
public UUID generateUuid(SharedSessionContractImplementor session) { return next(); }
public synchronized UUID next() {
long now = System.currentTimeMillis();
if (now > lastMs) {
lastMs = now;
counter = RNG.nextInt(1 << 11); // start in the lower half so a burst has room to count up
} else {
counter++; // same millisecond (or the clock stepped back): keep counting
if (counter > 0xFFF) { lastMs++; counter = 0; } // counter exhausted: borrow the next millisecond
}
long msb = (lastMs << 16) | (0x7L << 12) | counter;
long lsb = (RNG.nextLong() & 0x3FFF_FFFF_FFFF_FFFFL) | 0x8000_0000_0000_0000L;
return new UUID(msb, lsb);
}
}
@@ -0,0 +1,22 @@
package com.ankurm.idgen;
import jakarta.persistence.*;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** @UuidGenerator(style = VERSION_7): RFC 9562 version 7, Unix-millisecond timestamp first. */
@Entity
public class UuidV7Item {
@Id
@UuidGenerator(style = UuidGenerator.Style.VERSION_7)
private UUID id;
private String name;
public UuidV7Item() {}
public UuidV7Item(String name) { this.name = name; }
public UUID getId() { return id; }
public String getName() { return name; }
}
@@ -0,0 +1,19 @@
package com.ankurm.idgen.spring;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.util.UUID;
/** Application-assigned UUID id, no @Version: Spring Data cannot tell a new instance from a detached one. */
@Entity
public class AssignedBook {
@Id
private UUID id;
private String title;
public AssignedBook() {}
public AssignedBook(UUID id, String title) { this.id = id; this.title = title; }
public UUID getId() { return id; }
}
@@ -0,0 +1,7 @@
package com.ankurm.idgen.spring;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AssignedBookRepository extends JpaRepository<AssignedBook, UUID> {
}
@@ -0,0 +1,21 @@
package com.ankurm.idgen.spring;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.util.UUID;
import org.hibernate.annotations.UuidGenerator;
/** Generated version 7 id: id is null until persist, so Spring Data treats a fresh instance as new. */
@Entity
public class GeneratedBook {
@Id
@UuidGenerator(style = UuidGenerator.Style.VERSION_7)
private UUID id;
private String title;
public GeneratedBook() {}
public GeneratedBook(String title) { this.title = title; }
public UUID getId() { return id; }
}
@@ -0,0 +1,7 @@
package com.ankurm.idgen.spring;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface GeneratedBookRepository extends JpaRepository<GeneratedBook, UUID> {
}
@@ -0,0 +1,34 @@
package com.ankurm.idgen.spring;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.PostLoad;
import jakarta.persistence.PostPersist;
import jakarta.persistence.Transient;
import java.util.UUID;
import org.springframework.data.domain.Persistable;
/** Assigned UUID id; implements Persistable so the entity itself says whether it is new. */
@Entity
public class PersistableBook implements Persistable<UUID> {
@Id
private UUID id;
private String title;
@Transient
private boolean isNew = true;
public PersistableBook() {}
public PersistableBook(UUID id, String title) { this.id = id; this.title = title; }
@Override
public UUID getId() { return id; }
@Override
public boolean isNew() { return isNew; }
@PostLoad
@PostPersist
void markNotNew() { this.isNew = false; }
}
@@ -0,0 +1,7 @@
package com.ankurm.idgen.spring;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PersistableBookRepository extends JpaRepository<PersistableBook, UUID> {
}
@@ -0,0 +1,8 @@
package com.ankurm.idgen.spring;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Only used by the Spring Data test; the other tests bootstrap Hibernate directly. Scans just this package. */
@SpringBootApplication
public class SpringIdApplication {
}
@@ -0,0 +1,22 @@
package com.ankurm.idgen.spring;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import java.util.UUID;
/** Assigned UUID id plus a boxed @Version: a null version tells Spring Data the instance is new, so save() calls persist. */
@Entity
public class VersionedBook {
@Id
private UUID id;
@Version
private Long version;
private String title;
public VersionedBook() {}
public VersionedBook(UUID id, String title) { this.id = id; this.title = title; }
public UUID getId() { return id; }
}
@@ -0,0 +1,7 @@
package com.ankurm.idgen.spring;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
public interface VersionedBookRepository extends JpaRepository<VersionedBook, UUID> {
}
@@ -0,0 +1,143 @@
package com.ankurm.idgen;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.Modifier;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.function.Function;
import org.hibernate.annotations.UuidGenerator;
import org.junit.jupiter.api.Test;
/** What each UuidGenerator.Style really produces: version nibble, variant, time ordering. Read from persisted ids, not from documentation. */
class A01_UuidStylesTest {
record Probe(String label, Class<?> entity, Function<Object, UUID> idOf, int expectedVersion) {}
static final List<Probe> PROBES = List.of(
new Probe("GenerationType.UUID (JPA)", UuidV4Item.class, o -> ((UuidV4Item) o).getId(), 4),
new Probe("bare @GeneratedValue on a UUID (AUTO)", AutoUuidItem.class, o -> ((AutoUuidItem) o).getId(), 4),
new Probe("Style.TIME", UuidTimeItem.class, o -> ((UuidTimeItem) o).getId(), 1),
new Probe("Style.VERSION_6", UuidV6Item.class, o -> ((UuidV6Item) o).getId(), 6),
new Probe("Style.VERSION_7", UuidV7Item.class, o -> ((UuidV7Item) o).getId(), 7),
new Probe("uuid-creator getTimeOrderedEpoch()", UuidCreatorV7Item.class, o -> ((UuidCreatorV7Item) o).getId(), 7),
new Probe("hand-written UuidV7Generator", HandRolledV7Item.class, o -> ((HandRolledV7Item) o).getId(), 7));
@Test
void whatEachStyleProduces() throws Exception {
Transcript t = new Transcript("01-uuid-styles.txt");
t.line("UuidGenerator.Style values and what the generated UUIDs look like. Hibernate " + org.hibernate.Version.getVersionString()
+ ", JDK " + Runtime.version());
t.heading("The enum and the annotation, from the shipped jar");
t.line("Style values : " + Arrays.toString(UuidGenerator.Style.values()));
t.line("annotation attributes : " + Arrays.stream(UuidGenerator.class.getDeclaredMethods()).map(m -> m.getName()).sorted().toList());
t.heading("Does the JDK itself generate version 7? (public static methods of java.util.UUID)");
List<String> statics = Arrays.stream(UUID.class.getDeclaredMethods())
.filter(m -> Modifier.isStatic(m.getModifiers()) && Modifier.isPublic(m.getModifiers()))
.map(m -> m.getName()).sorted().toList();
t.line("static factories: " + statics);
assertThat(statics).containsExactlyInAnyOrder("fromString", "nameUUIDFromBytes", "randomUUID");
try (Db db = Db.of(Db.Kind.H2)) {
db.open(AutoUuidItem.class, UuidV4Item.class, UuidTimeItem.class, UuidV6Item.class, UuidV7Item.class, UuidCreatorV7Item.class, HandRolledV7Item.class);
final int n = 2000;
for (Probe p : PROBES) {
List<UUID> ids = new ArrayList<>();
long before = System.currentTimeMillis();
db.txv(s -> {
for (int i = 0; i < n; i++) {
Object e = construct(p.entity(), "n" + i);
s.persist(e);
ids.add(p.idOf().apply(e));
}
});
long after = System.currentTimeMillis();
t.heading(p.label() + " (" + p.entity().getSimpleName() + ", " + n + " persists in one transaction)");
t.line("first three : " + ids.subList(0, 3));
int version = ids.get(0).version();
t.line("version nibble of every id : " + ids.stream().map(UUID::version).distinct().toList() + ", variant : "
+ ids.stream().map(UUID::variant).distinct().toList());
assertThat(ids).allSatisfy(u -> assertThat(u.version()).isEqualTo(p.expectedVersion()));
assertThat(version).isEqualTo(p.expectedVersion());
// Sorted the way PostgreSQL / a B-tree sorts a uuid column: unsigned, byte by byte, i.e. the canonical string order.
int descents = 0;
for (int i = 1; i < n; i++) if (ids.get(i - 1).toString().compareTo(ids.get(i).toString()) > 0) descents++;
t.line("string/byte order vs generation order : " + descents + " of " + (n - 1) + " neighbours are out of order");
if (p.expectedVersion() >= 6) assertThat(descents).isZero();
else if (p.expectedVersion() == 4) assertThat(descents).isGreaterThan(n / 4);
assertThat(ids).doesNotHaveDuplicates();
if (p.expectedVersion() == 7) {
long ms = (ids.get(0).getMostSignificantBits() >>> 16);
Instant embedded = Instant.ofEpochMilli(ms);
t.line("first 48 bits as Unix milliseconds : " + ms + " -> " + embedded);
assertThat(ms).isBetween(before - 1000, after + 1000);
}
if (p.expectedVersion() == 1) {
t.line("UUID.timestamp() of first / last id (100ns ticks since 1582) : " + ids.get(0).timestamp() + " / " + ids.get(n - 1).timestamp());
t.line("most significant 32 bits (time_low) of first three : "
+ ids.subList(0, 3).stream().map(u -> String.format("%08x", u.getMostSignificantBits() >>> 32)).toList());
}
}
t.heading("Style.TIME is not a clock-ordered v1: what changes between ids created 30 ms apart");
List<UUID> spaced = new ArrayList<>();
for (int i = 0; i < 4; i++) {
spaced.add(db.tx(s -> { var e = new UuidTimeItem("x"); s.persist(e); return e.getId(); }));
Thread.sleep(30);
}
for (UUID u : spaced) {
t.line(u + " msb=" + String.format("%016x", u.getMostSignificantBits()) + " lsb=" + String.format("%016x", u.getLeastSignificantBits()));
}
long msbs = spaced.stream().map(UUID::getMostSignificantBits).distinct().count();
t.line("distinct most-significant halves among them : " + msbs + " (IPv4 address of this host + a per-JVM value, fixed for the life of the JVM)");
assertThat(msbs).isEqualTo(1);
byte[] ip = java.net.InetAddress.getLocalHost().getAddress();
long ipAsInt = ((ip[0] & 0xffL) << 24) | ((ip[1] & 0xffL) << 16) | ((ip[2] & 0xffL) << 8) | (ip[3] & 0xffL);
t.line("first 32 bits equal this host's IPv4 address : " + (ip.length == 4 && (spaced.get(0).getMostSignificantBits() >>> 32) == ipAsInt));
t.line("UUID.timestamp() (what a real version 1 would use as its clock) is the same for all four : "
+ spaced.stream().map(UUID::timestamp).distinct().count() + " distinct value(s)");
assertThat(spaced.stream().map(UUID::timestamp).distinct().count()).isEqualTo(1);
}
t.save();
}
@Test
void generationRate() throws Exception {
// indicative only: single thread, no database
Transcript t = new Transcript("01b-uuid-generation-rate.txt");
t.line("Cost of generating one UUID in memory (single thread, 2,000,000 ids after 500,000 warm-up; indicative, machine dependent)");
var v4 = (java.util.function.Supplier<UUID>) UUID::randomUUID;
var v7 = (java.util.function.Supplier<UUID>) () -> org.hibernate.id.uuid.UuidVersion7Strategy.INSTANCE.generateUuid(null);
var v6 = (java.util.function.Supplier<UUID>) () -> org.hibernate.id.uuid.UuidVersion6Strategy.INSTANCE.generateUuid(null);
var v1 = (java.util.function.Supplier<UUID>) () -> new org.hibernate.id.uuid.CustomVersionOneStrategy().generateUuid(null);
var lib = (java.util.function.Supplier<UUID>) com.github.f4b6a3.uuid.UuidCreator::getTimeOrderedEpoch;
var mine = (java.util.function.Supplier<UUID>) new UuidV7Generator()::next;
record Row(String label, java.util.function.Supplier<UUID> s) {}
for (Row r : List.of(new Row("UUID.randomUUID()", v4), new Row("Hibernate VERSION_7", v7), new Row("Hibernate VERSION_6", v6),
new Row("Hibernate TIME (v1)", v1), new Row("uuid-creator v7", lib), new Row("UuidV7Generator (hand-written)", mine))) {
long sink = 0;
for (int i = 0; i < 500_000; i++) sink += r.s().get().getLeastSignificantBits();
long t0 = System.nanoTime();
for (int i = 0; i < 2_000_000; i++) sink += r.s().get().getLeastSignificantBits();
long ns = System.nanoTime() - t0;
SINK = sink; t.line(String.format("%-32s %5d ns per id", r.label(), ns / 2_000_000));
}
t.save();
}
static volatile long SINK;
static Object construct(Class<?> c, String name) {
try {
return c.getConstructor(String.class).newInstance(name);
} catch (ReflectiveOperationException e) { throw new IllegalStateException(e); }
}
}
@@ -0,0 +1,91 @@
package com.ankurm.idgen;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
/** DDL and SQL for IDENTITY, SEQUENCE (allocationSize 1 and 50), the default sequence, TABLE; when each INSERT actually runs. */
class A02_IdentityVsSequenceTest {
static long count(List<String> sql, String needle) {
return sql.stream().filter(s -> s.toLowerCase().contains(needle)).count();
}
@Test
void ddlAndSql() throws Exception {
Both.run("02-identity-vs-sequence.txt", "IDENTITY vs SEQUENCE vs TABLE: generated DDL, SQL, and when the INSERT runs", (db, t) -> {
db.open(IdentityItem.class, SeqOneItem.class, SeqPooledItem.class, SeqDefaultItem.class, TableItem.class);
Both.header(db, t);
t.heading("DDL Hibernate generates");
for (String l : db.ddl(IdentityItem.class, SeqOneItem.class, SeqPooledItem.class, SeqDefaultItem.class, TableItem.class)) {
if (l.startsWith("create table") || l.startsWith("create sequence") || l.startsWith("insert into")) t.line(l);
}
// ---- IDENTITY: the INSERT happens at persist(), before any flush or commit
t.heading("IDENTITY: persist() runs the INSERT immediately");
try (Session s = db.sf().openSession()) {
var tx = s.beginTransaction();
var item = new IdentityItem("first");
t.step("persist(item) -- no flush(), no commit() yet", db.sql(() -> s.persist(item)));
t.line("item.getId() right after persist : " + item.getId());
assertThat(item.getId()).isNotNull();
tx.rollback();
}
t.line("rows visible after rollback : " + db.scalar("select count(*) from IdentityItem"));
// ---- SEQUENCE allocationSize = 1: one sequence call per entity
t.heading("SEQUENCE, allocationSize = 1: one sequence call per entity");
List<String> one = new ArrayList<>();
List<Long> oneIds = new ArrayList<>();
one.addAll(db.sql(() -> db.txv(s -> { for (int i = 0; i < 120; i++) { var e = new SeqOneItem("n" + i); s.persist(e); oneIds.add(e.getId()); } })));
t.line("persist 120 entities, one transaction");
long oneCalls = count(one, "next value for") + count(one, "nextval");
t.line("sequence calls : " + oneCalls + " inserts : " + count(one, "insert into"));
t.line("first ids : " + oneIds.subList(0, 5) + " ... last id : " + oneIds.get(119));
assertThat(oneCalls).isEqualTo(120);
// ---- SEQUENCE allocationSize = 50: the pooled optimizer
t.heading("SEQUENCE, allocationSize = 50 (pooled optimizer): one sequence call per 50 entities");
List<Long> poolIds = new ArrayList<>();
List<String> pooled = db.sql(() -> db.txv(s -> { for (int i = 0; i < 120; i++) { var e = new SeqPooledItem("n" + i); s.persist(e); poolIds.add(e.getId()); } }));
long poolCalls = count(pooled, "next value for") + count(pooled, "nextval");
t.line("persist 120 entities, one transaction");
t.line("sequence calls : " + poolCalls + " inserts : " + count(pooled, "insert into"));
t.line("first ids : " + poolIds.subList(0, 5) + " ... last id : " + poolIds.get(119));
t.line("sequence statements : " + pooled.stream().filter(x -> x.toLowerCase().contains("next value for") || x.toLowerCase().contains("nextval")).distinct().toList());
assertThat(poolCalls).isEqualTo(4);
t.line("(the first allocation of a fresh sequence costs two calls: one to learn the starting value, one to learn the upper bound of the first block of 50)");
List<String> again = db.sql(() -> db.txv(s -> { for (int i = 0; i < 120; i++) s.persist(new SeqPooledItem("m" + i)); }));
long againCalls = count(again, "next value for") + count(again, "nextval");
t.line("persist another 120 entities, same SessionFactory : sequence calls : " + againCalls);
assertThat(againCalls).isBetween(2L, 3L);
// ---- TABLE
t.heading("TABLE generator: one read + one update of the generator row per allocation");
JdbcCounter.reset();
db.txv(s -> { for (int i = 0; i < 120; i++) s.persist(new TableItem("n" + i)); });
List<String> other = JdbcCounter.OTHER_SQL;
t.line("persist 120 entities: statements other than the 120 INSERTs : " + other.size() + " (seen by the JDBC counter; Hibernate's StatementInspector does not see them)");
other.stream().distinct().forEach(x -> t.line(" sql> " + x));
assertThat(other).isNotEmpty();
});
}
@Test
void namingOfDefaultSequences() throws Exception {
Both.run("02b-default-sequence-naming.txt", "Sequence names Hibernate 7 picks when you name nothing, and the legacy switch", (db, t) -> {
db.open(AutoLongItem.class);
Both.header(db, t);
for (String strat : new String[] {"standard", "legacy", "single"}) {
t.heading("hibernate.id.db_structure_naming_strategy = " + strat + " (entities: AutoLongItem, SeqDefaultItem)");
for (String l : db.ddl(new String[] {"hibernate.id.db_structure_naming_strategy", strat}, AutoLongItem.class, SeqDefaultItem.class)) {
if (l.startsWith("create sequence")) t.line(l);
}
}
});
}
}
@@ -0,0 +1,121 @@
package com.ankurm.idgen;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.function.Function;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
/** 10,000 inserts per id strategy with flush/clear every 50 rows and hibernate.jdbc.batch_size = 50, then the order_inserts effect on a parent/child graph. */
class A03_BatchInsertTest {
static final int N = 10_000;
static final int BATCH = 50;
record Row(String label, Function<Integer, Object> factory) {}
/** Persists n entities, flushing and clearing every BATCH rows. Returns elapsed milliseconds. Counters are reset first. */
static long insert(Db db, int n, Function<Integer, Object> factory) {
JdbcCounter.reset();
long t0 = System.nanoTime();
try (Session s = db.sf().openSession()) {
var tx = s.beginTransaction();
for (int i = 0; i < n; i++) {
s.persist(factory.apply(i));
if ((i + 1) % BATCH == 0) { s.flush(); s.clear(); }
}
tx.commit();
}
return (System.nanoTime() - t0) / 1_000_000;
}
@Test
void tenThousandRows() throws Exception {
Both.run("03-batch-inserts.txt", "10,000 inserts: IDENTITY vs SEQUENCE vs UUID, hibernate.jdbc.batch_size=50, flush+clear every 50 rows", (db, t) -> {
String[] withBatch = {"hibernate.jdbc.batch_size", "" + BATCH};
Class<?>[] all = {IdentityItem.class, SeqOneItem.class, SeqPooledItem.class, UuidV4Item.class, UuidV7Item.class};
db.open(withBatch, all);
Both.header(db, t);
t.line("timings are wall-clock milliseconds on one developer machine, one run each after a warm-up pass: indicative, not a benchmark");
// warm-up so class loading and JIT do not land on the first row of the table
for (Class<?> c : all) {
insert(db, 1000, i -> make(c, "w" + i));
}
Row[] rows = {
new Row("IDENTITY", i -> new IdentityItem("n" + i)),
new Row("SEQUENCE allocationSize=1", i -> new SeqOneItem("n" + i)),
new Row("SEQUENCE allocationSize=50", i -> new SeqPooledItem("n" + i)),
new Row("UUID v4 (GenerationType.UUID)", i -> new UuidV4Item("n" + i)),
new Row("UUID v7 (Style.VERSION_7)", i -> new UuidV7Item("n" + i)),
};
t.blank();
for (Row r : rows) {
long ms = insert(db, N, r.factory());
var stats = db.sf().getStatistics();
t.line(String.format("%-30s %6d ms %s", r.label(), ms, JdbcCounter.summary()));
switch (r.label()) {
case "IDENTITY" -> {
assertThat(JdbcCounter.ADD_BATCH.get()).isZero();
assertThat(JdbcCounter.EXECUTE_BATCH.get()).isZero();
assertThat(JdbcCounter.INSERT_PREPARED.get()).isEqualTo(N);
}
case "SEQUENCE allocationSize=1" -> {
assertThat(JdbcCounter.EXECUTE_BATCH.get()).isEqualTo(N / BATCH);
assertThat(JdbcCounter.SEQUENCE_PREPARED.get()).isEqualTo(N);
}
case "SEQUENCE allocationSize=50" -> {
assertThat(JdbcCounter.EXECUTE_BATCH.get()).isEqualTo(N / BATCH);
assertThat(JdbcCounter.SEQUENCE_PREPARED.get()).isLessThanOrEqualTo(N / BATCH + 1);
}
default -> {
assertThat(JdbcCounter.EXECUTE_BATCH.get()).isEqualTo(N / BATCH);
assertThat(JdbcCounter.SEQUENCE_PREPARED.get()).isZero();
}
}
}
t.blank();
t.line("Hibernate Statistics for the whole session factory (all rows above, including the warm-up): entityInsertCount="
+ db.sf().getStatistics().getEntityInsertCount() + " prepareStatementCount=" + db.sf().getStatistics().getPrepareStatementCount());
// the same sequence entity with batching switched off, to show what batch_size itself buys
db.open(new String[] {}, SeqPooledItem.class);
long ms = insert(db, N, i -> new SeqPooledItem("n" + i));
t.blank().line("Same SEQUENCE allocationSize=50 entity, hibernate.jdbc.batch_size NOT set:");
t.line(String.format("%-30s %6d ms %s", "SEQUENCE allocationSize=50", ms, JdbcCounter.summary()));
assertThat(JdbcCounter.EXECUTE_BATCH.get()).isZero();
});
}
@Test
void orderInserts() throws Exception {
Both.run("03b-order-inserts.txt", "hibernate.order_inserts on a parent/child graph (1,000 parents x 3 children, batch_size=50, flush+clear every 50 parents)", (db, t) -> {
for (String order : new String[] {"false", "true"}) {
db.open(new String[] {"hibernate.jdbc.batch_size", "" + BATCH, "hibernate.order_inserts", order}, Parent.class, Child.class);
if (order.equals("false")) Both.header(db, t);
JdbcCounter.reset();
long t0 = System.nanoTime();
try (Session s = db.sf().openSession()) {
var tx = s.beginTransaction();
for (int i = 0; i < 1000; i++) {
Parent p = new Parent("p" + i);
for (int c = 0; c < 3; c++) p.add(new Child("c" + i + "." + c));
s.persist(p);
if ((i + 1) % 50 == 0) { s.flush(); s.clear(); }
}
tx.commit();
}
long ms = (System.nanoTime() - t0) / 1_000_000;
t.line(String.format("order_inserts=%-5s %5d ms %s", order, ms, JdbcCounter.summary()));
assertThat(db.scalar("select count(*) from Child")).isEqualTo("3000");
if (order.equals("true")) assertThat(JdbcCounter.EXECUTE_BATCH.get()).isLessThan(200);
else assertThat(JdbcCounter.EXECUTE_BATCH.get()).isGreaterThan(1000);
}
});
}
static Object make(Class<?> c, String name) {
try { return c.getConstructor(String.class).newInstance(name); } catch (ReflectiveOperationException e) { throw new IllegalStateException(e); }
}
}
@@ -0,0 +1,100 @@
package com.ankurm.idgen;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.SessionFactory;
import org.junit.jupiter.api.Test;
/** What happens when the database sequence and the mapping disagree about the step; and what AUTO and UUID map to per dialect. */
class A04_MismatchAndAutoTest {
static final String[] TABLE_AND_SEQ_INC1 = {
"create table SeqPooledItem (id bigint not null primary key, name varchar(255))",
"create sequence seq_pooled_item start with 1 increment by 1"};
static String persistThree(SessionFactory sf, String tag) {
List<Long> ids = new ArrayList<>();
try {
Db.tx(sf, s -> { for (int i = 0; i < 3; i++) { var e = new SeqPooledItem(tag + i); s.persist(e); ids.add(e.getId()); } return null; });
return "ids " + ids;
} catch (RuntimeException e) {
return "FAILED after ids " + ids + " : " + Db.describe(e);
}
}
@Test
void incrementMismatch() throws Exception {
Both.run("04-sequence-increment-mismatch.txt", "Mapping says allocationSize=50, the database sequence says INCREMENT BY 1 (schema created by hand)", (db, t) -> {
for (String strategy : new String[] {"(unset)", "EXCEPTION", "LOG", "FIX", "NONE"}) {
t.heading("hibernate.id.sequence.increment_size_mismatch_strategy = " + strategy);
String[] settings = strategy.equals("(unset)") ? new String[0]
: new String[] {"hibernate.id.sequence.increment_size_mismatch_strategy", strategy};
SqlLog.clear();
try {
db.openAfter(TABLE_AND_SEQ_INC1, settings, SeqPooledItem.class);
} catch (RuntimeException e) {
t.line("SessionFactory startup FAILED: " + Db.describe(e));
continue;
}
if (strategy.equals("(unset)")) Both.header(db, t);
t.line("SessionFactory started");
SessionFactory second = db.secondFactory(settings, SeqPooledItem.class);
try {
t.line("application instance A : " + persistThree(db.sf(), "a"));
t.line("application instance B : " + persistThree(second, "b"));
} finally { second.close(); }
t.line("database sequence INCREMENT BY, as the database sees it : " + (db.isPostgres()
? db.scalar("select increment_by from pg_sequences where sequencename = 'seq_pooled_item'")
: db.scalar("select increment from information_schema.sequences where sequence_name = 'SEQ_POOLED_ITEM'")));
}
});
}
@Test
void incrementMismatchOtherWay() throws Exception {
Both.run("04b-sequence-step-bigger-than-mapping.txt", "Mapping says allocationSize=1, the database sequence says INCREMENT BY 50", (db, t) -> {
String[] ddl = {
"create table SeqOneItem (id bigint not null primary key, name varchar(255))",
"create sequence seq_one_item start with 1 increment by 50"};
for (String strategy : new String[] {"(unset)", "FIX"}) {
t.heading("hibernate.id.sequence.increment_size_mismatch_strategy = " + strategy);
String[] settings = strategy.equals("(unset)") ? new String[0]
: new String[] {"hibernate.id.sequence.increment_size_mismatch_strategy", strategy};
try {
db.openAfter(ddl, settings, SeqOneItem.class);
} catch (RuntimeException e) {
t.line("SessionFactory startup FAILED: " + Db.describe(e));
continue;
}
List<Long> ids = new ArrayList<>();
db.txv(s -> { for (int i = 0; i < 4; i++) { var e = new SeqOneItem("x" + i); s.persist(e); ids.add(e.getId()); } });
t.line("ids : " + ids);
}
});
}
@Test
void autoPerDialect() throws Exception {
Transcript t = new Transcript("05-auto-and-uuid-per-dialect.txt");
t.line("What @GeneratedValue (AUTO) and UUID ids become per dialect. DDL only: no database connection is opened for the dialects other than H2. Hibernate "
+ org.hibernate.Version.getVersionString());
String[][] dialects = {
{"H2", "org.hibernate.dialect.H2Dialect"},
{"PostgreSQL", "org.hibernate.dialect.PostgreSQLDialect"},
{"MySQL", "org.hibernate.dialect.MySQLDialect"},
{"MariaDB", "org.hibernate.dialect.MariaDBDialect"},
{"Oracle", "org.hibernate.dialect.OracleDialect"},
{"SQL Server", "org.hibernate.dialect.SQLServerDialect"}};
for (String[] d : dialects) {
t.heading(d[0] + " (" + d[1] + ")");
for (String l : DdlOnly.script(d[1], AutoLongItem.class, AutoUuidItem.class, UuidV7Item.class, UuidCharItem.class, IdentityItem.class)) {
if (l.startsWith("create sequence") || l.startsWith("create table")) t.line(l);
}
}
t.save();
assertThat(DdlOnly.script("org.hibernate.dialect.PostgreSQLDialect", AutoLongItem.class)).anyMatch(l -> l.contains("create sequence AutoLongItem_SEQ"));
assertThat(DdlOnly.script("org.hibernate.dialect.MySQLDialect", AutoLongItem.class)).noneMatch(l -> l.startsWith("create sequence"));
}
}
@@ -0,0 +1,100 @@
package com.ankurm.idgen;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.Function;
import org.hibernate.Session;
import org.junit.jupiter.api.Test;
/**
* Does the key type change how big the primary-key index gets? Same rows, same table shape, only the id differs.
* H2 (file database, MVStore B-tree) always runs. PostgreSQL (pg_relation_size, pgstatindex) runs only when Docker is available.
*/
class A05_IndexLocalityTest {
static final int N = 200_000;
static final int BATCH = 50;
record Variant(String label, String table, Function<Integer, Object> factory) {}
static final Variant[] VARIANTS = {
new Variant("bigint sequence (allocationSize 50)", "SeqPooledItem", i -> new SeqPooledItem("row-" + i)),
new Variant("UUID v4 random", "UuidV4Item", i -> new UuidV4Item("row-" + i)),
new Variant("UUID v7 time-ordered", "UuidV7Item", i -> new UuidV7Item("row-" + i)),
new Variant("UUID v7 as CHAR(36)", "UuidCharItem", i -> new UuidCharItem("row-" + i)),
};
static long load(Db db, Function<Integer, Object> factory) {
long t0 = System.nanoTime();
try (Session s = db.sf().openSession()) {
var tx = s.beginTransaction();
for (int i = 0; i < N; i++) {
s.persist(factory.apply(i));
if ((i + 1) % BATCH == 0) { s.flush(); s.clear(); }
}
tx.commit();
}
return (System.nanoTime() - t0) / 1_000_000;
}
static long dirSize(Path dir) throws IOException {
try (var st = Files.list(dir)) { return st.mapToLong(p -> p.toFile().length()).sum(); }
}
@Test
void h2FileSize() throws Exception {
Transcript t = new Transcript("06-index-size-h2.txt");
t.line("Primary-key choice vs database file size, H2 file database (MVStore B-tree), file size after the database is closed, " + N + " rows per variant, batch_size=" + BATCH
+ ", flush+clear every " + BATCH + " rows");
t.line("H2 specific: this is H2's storage engine, not PostgreSQL's or InnoDB's. Timings are indicative (one run, one machine).");
t.blank();
t.line(String.format("%-38s %10s %12s", "variant", "load ms", "file KiB"));
for (int v = 0; v < VARIANTS.length; v++) {
Variant var = VARIANTS[v];
Path dir = Files.createTempDirectory("idgen-h2-");
try {
String url = "jdbc:h2:file:" + dir.resolve("db").toString().replace('\\', '/');
long ms;
try (Db db = Db.of(Db.Kind.H2).withUrl(url)) {
db.open(new String[] {"hibernate.jdbc.batch_size", "" + BATCH}, SeqPooledItem.class, UuidV4Item.class, UuidV7Item.class, UuidCharItem.class);
ms = load(db, var.factory());
assertThat(db.scalar("select count(*) from " + var.table())).isEqualTo("" + N);
}
long raw = dirSize(dir);
t.line(String.format("%-38s %10d %12d", var.label(), ms, raw / 1024));
} finally {
try (var st = Files.walk(dir)) { st.sorted(java.util.Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); }
}
}
t.save();
}
@Test
void postgresIndex() throws Exception {
Transcript t = new Transcript("06b-index-size-postgres.txt");
t.line("Primary-key choice vs index size and leaf density, PostgreSQL " + Db.PG_IMAGE + ", " + N + " rows per variant, batch_size=" + BATCH);
if (!Db.dockerAvailable()) {
t.line("SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run.");
t.save();
return;
}
try (Db db = Db.of(Db.Kind.POSTGRES)) {
db.open(new String[] {"hibernate.jdbc.batch_size", "" + BATCH}, SeqPooledItem.class, UuidV4Item.class, UuidV7Item.class, UuidCharItem.class);
Both.header(db, t);
db.exec("create extension if not exists pgstattuple");
t.blank().line(String.format("%-38s %9s %12s %12s %14s %14s", "variant", "load ms", "table KiB", "pkey KiB", "leaf density%", "leaf fragm.%"));
for (Variant v : VARIANTS) {
long ms = load(db, v.factory());
String name = v.table().toLowerCase() + "_pkey";
long tableKb = Long.parseLong(db.scalar("select pg_table_size('" + v.table().toLowerCase() + "')")) / 1024 - Long.parseLong(db.scalar("select pg_relation_size('" + name + "')")) / 1024;
long idxKb = Long.parseLong(db.scalar("select pg_relation_size('" + name + "')")) / 1024;
String[] st = db.scalar("select avg_leaf_density, leaf_fragmentation from pgstatindex('" + name + "')").split(" \\| ");
t.line(String.format("%-38s %9d %12d %12d %14s %14s", v.label(), ms, tableKb, idxKb, st[0], st[1]));
}
}
t.save();
}
}
@@ -0,0 +1,65 @@
package com.ankurm.idgen;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.idgen.spring.*;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/** save() on four entity shapes: which ones cost a SELECT before the INSERT. */
@SpringBootTest(classes = SpringIdApplication.class, properties = {
"spring.datasource.url=jdbc:h2:mem:springdata;DB_CLOSE_DELAY=-1",
"spring.jpa.hibernate.ddl-auto=create-drop",
"spring.jpa.open-in-view=false",
"spring.jpa.properties.hibernate.session_factory.statement_inspector=com.ankurm.idgen.SqlLog"})
class A06_SpringDataTest {
@Autowired AssignedBookRepository assigned;
@Autowired VersionedBookRepository versioned;
@Autowired PersistableBookRepository persistable;
@Autowired GeneratedBookRepository generated;
static long selects(List<String> sql) { return sql.stream().filter(s -> s.toLowerCase().startsWith("select")).count(); }
@Test
void saveOnAssignedIds() throws Exception {
Transcript t = new Transcript("07-spring-data-save.txt");
t.line("Spring Data JPA save() with application-assigned vs generated ids. Spring Boot parent 4.1.1, Hibernate " + org.hibernate.Version.getVersionString() + ", H2");
t.heading("AssignedBook: UUID.randomUUID() assigned in the constructor, no @Version");
var a = Db.capture(() -> assigned.save(new AssignedBook(UUID.randomUUID(), "x")));
a.forEach(s -> t.line(" sql> " + s));
assertThat(selects(a)).isEqualTo(1);
t.heading("VersionedBook: same, plus a boxed @Version column");
var v = Db.capture(() -> versioned.save(new VersionedBook(UUID.randomUUID(), "x")));
v.forEach(s -> t.line(" sql> " + s));
assertThat(selects(v)).isZero();
t.heading("PersistableBook: same, entity implements Persistable.isNew()");
var p = Db.capture(() -> persistable.save(new PersistableBook(UUID.randomUUID(), "x")));
p.forEach(s -> t.line(" sql> " + s));
assertThat(selects(p)).isZero();
t.heading("GeneratedBook: @UuidGenerator(style = VERSION_7), id is null until persist");
var g = Db.capture(() -> generated.save(new GeneratedBook("x")));
g.forEach(s -> t.line(" sql> " + s));
assertThat(selects(g)).isZero();
t.heading("saveAll of 100 new AssignedBook rows: selects issued");
List<AssignedBook> many = java.util.stream.IntStream.range(0, 100).mapToObj(i -> new AssignedBook(UUID.randomUUID(), "b" + i)).toList();
var m = Db.capture(() -> assigned.saveAll(many));
t.line("select statements : " + selects(m) + ", insert statements : " + m.stream().filter(s -> s.startsWith("insert")).count());
assertThat(selects(m)).isEqualTo(100);
t.heading("saveAll of 100 new GeneratedBook rows: selects issued");
List<GeneratedBook> many2 = java.util.stream.IntStream.range(0, 100).mapToObj(i -> new GeneratedBook("b" + i)).toList();
var m2 = Db.capture(() -> generated.saveAll(many2));
t.line("select statements : " + selects(m2) + ", insert statements : " + m2.stream().filter(s -> s.startsWith("insert")).count());
assertThat(selects(m2)).isZero();
t.save();
}
}
@@ -0,0 +1,29 @@
package com.ankurm.idgen;
/** Runs one scenario body against H2 and, when Docker is available, PostgreSQL; writes a single transcript with every run. */
public final class Both {
@FunctionalInterface
public interface Body { void run(Db db, Transcript t) throws Exception; }
private Both() {}
public static void run(String file, String title, Body body) throws Exception {
Transcript t = new Transcript(file);
t.line(title);
for (Db.Kind k : Db.Kind.values()) {
t.blank().line("######## " + k + " ########");
if (k == Db.Kind.POSTGRES && !Db.dockerAvailable()) {
t.line("SKIPPED: no Docker daemon was reachable when this transcript was generated, so PostgreSQL was not run.");
continue;
}
try (Db db = Db.of(k)) {
body.run(db, t);
}
}
t.save();
}
public static void header(Db db, Transcript t) {
t.line("database: " + db.productName());
}
}
@@ -0,0 +1,232 @@
package com.ankurm.idgen;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.function.Function;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
/** One database (H2 in memory, or a throw-away PostgreSQL 17 container) plus a plain Hibernate SessionFactory over it. */
public final class Db implements AutoCloseable {
public enum Kind { H2, POSTGRES }
public static final String PG_IMAGE = "postgres:17-alpine";
private static PostgreSQLContainer pg;
private static Boolean docker;
private final Kind kind;
private SessionFactory sf;
private String url;
private String fixedUrl;
private Db(Kind kind) { this.kind = kind; }
/** True when a Docker daemon answers; PostgreSQL scenarios are skipped (and the transcript says so) when it does not. */
public static synchronized boolean dockerAvailable() {
if (docker == null) {
try { docker = DockerClientFactory.instance().isDockerAvailable(); } catch (Throwable t) { docker = false; }
}
return docker;
}
private static synchronized PostgreSQLContainer pg() {
if (pg == null) {
pg = new PostgreSQLContainer(PG_IMAGE);
pg.start();
Runtime.getRuntime().addShutdownHook(new Thread(() -> pg.stop()));
}
return pg;
}
public static Db of(Kind kind) { return new Db(kind); }
/** H2 only: use this JDBC URL (for example a file database) instead of a fresh in-memory one. */
public Db withUrl(String jdbcUrl) { this.fixedUrl = jdbcUrl; this.url = jdbcUrl; return this; }
public boolean isPostgres() { return kind == Kind.POSTGRES; }
public Kind kind() { return kind; }
public Configuration config(Class<?>... classes) {
Configuration c = new Configuration();
if (kind == Kind.H2) {
if (url == null) url = "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1";
c.setProperty("jakarta.persistence.jdbc.url", url);
c.setProperty("jakarta.persistence.jdbc.user", "sa");
c.setProperty("jakarta.persistence.jdbc.password", "");
} else {
PostgreSQLContainer p = pg();
c.setProperty("jakarta.persistence.jdbc.url", p.getJdbcUrl());
c.setProperty("jakarta.persistence.jdbc.user", p.getUsername());
c.setProperty("jakarta.persistence.jdbc.password", p.getPassword());
}
c.setProperty("hibernate.session_factory.statement_inspector", SqlLog.class.getName());
c.setProperty("hibernate.connection.provider_class", JdbcCounter.class.getName());
c.setProperty("hibernate.generate_statistics", "true");
for (Class<?> k : classes) c.addAnnotatedClass(k);
return c;
}
/** PostgreSQL: drops and recreates the public schema. H2: a brand-new in-memory database name. */
public void wipe() {
if (!isPostgres()) { url = fixedUrl; return; }
Configuration bare = config();
try (var cn = java.sql.DriverManager.getConnection(bare.getProperty("jakarta.persistence.jdbc.url"),
bare.getProperty("jakarta.persistence.jdbc.user"), bare.getProperty("jakarta.persistence.jdbc.password"));
var st = cn.createStatement()) {
st.execute("drop schema public cascade");
st.execute("create schema public");
} catch (SQLException e) { throw new IllegalStateException(e); }
}
private static void apply(Configuration c, String[] settings) {
for (int i = 0; i + 1 < settings.length; i += 2) c.setProperty(settings[i], settings[i + 1]);
}
/** Opens a fresh schema containing the tables of {@code classes}, with extra Hibernate settings (key, value, key, value ...). */
public Db open(String[] settings, Class<?>... classes) {
close();
wipe();
Configuration c = config(classes);
c.setProperty("hibernate.hbm2ddl.auto", "create");
apply(c, settings);
sf = c.buildSessionFactory();
return this;
}
public Db open(Class<?>... classes) { return open(new String[0], classes); }
/** Opens a SessionFactory over a fresh, EMPTY database: the caller creates tables and sequences by hand. */
public Db openEmpty(String[] settings, Class<?>... classes) {
close();
wipe();
Configuration c = config(classes);
apply(c, settings);
sf = c.buildSessionFactory();
return this;
}
/** Wipes the database, runs hand-written DDL over plain JDBC, then opens a SessionFactory that does NOT touch the schema. */
public Db openAfter(String[] preDdl, String[] settings, Class<?>... classes) {
close();
wipe();
Configuration c = config(classes);
try (var cn = java.sql.DriverManager.getConnection(c.getProperty("jakarta.persistence.jdbc.url"),
c.getProperty("jakarta.persistence.jdbc.user"), c.getProperty("jakarta.persistence.jdbc.password"));
var st = cn.createStatement()) {
for (String d : preDdl) st.execute(d);
} catch (SQLException e) { throw new IllegalStateException(e); }
apply(c, settings);
sf = c.buildSessionFactory();
return this;
}
/** A second SessionFactory over the same database, as a second application instance would have. Caller closes it. */
public SessionFactory secondFactory(String[] settings, Class<?>... classes) {
Configuration c = config(classes);
apply(c, settings);
return c.buildSessionFactory();
}
public SessionFactory sf() { return sf; }
public <T> T tx(Function<Session, T> work) { return tx(sf, work); }
public static <T> T tx(SessionFactory factory, Function<Session, T> work) {
try (Session s = factory.openSession()) {
var t = s.beginTransaction();
try {
T r = work.apply(s);
t.commit();
return r;
} catch (RuntimeException e) {
if (t.isActive()) t.rollback();
throw e;
}
}
}
public void txv(java.util.function.Consumer<Session> work) { tx(s -> { work.accept(s); return null; }); }
public List<String> sql(Runnable action) { return capture(action); }
public static List<String> capture(Runnable action) {
SqlLog.clear();
Transcript.parking = true;
try { action.run(); } finally { Transcript.parking = false; }
return SqlLog.snapshot();
}
/** Raw JDBC (invisible to the SqlLog): run DDL or any statement. */
public int exec(String sql) {
return tx(s -> s.doReturningWork(cn -> {
try (var st = cn.createStatement()) {
st.execute(sql);
return st.getUpdateCount();
}
}));
}
public List<String> rows(String sql) {
return tx(s -> s.doReturningWork(cn -> {
List<String> out = new ArrayList<>();
try (var st = cn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
ResultSetMetaData md = rs.getMetaData();
while (rs.next()) {
StringBuilder b = new StringBuilder();
for (int i = 1; i <= md.getColumnCount(); i++) {
if (i > 1) b.append(" | ");
b.append(rs.getObject(i));
}
out.add(b.toString());
}
}
return out;
}));
}
public String scalar(String sql) { return rows(sql).get(0); }
public String productName() {
return sf.unwrap(org.hibernate.engine.spi.SessionFactoryImplementor.class)
.getJdbcServices().getDialect().getClass().getSimpleName() + " / "
+ tx(s -> s.doReturningWork(cn -> cn.getMetaData().getDatabaseProductName() + " " + cn.getMetaData().getDatabaseProductVersion()));
}
/** The CREATE script Hibernate would emit for these entities on this database (script only, nothing executed). */
public List<String> ddl(Class<?>... classes) throws Exception { return ddl(new String[0], classes); }
public List<String> ddl(String[] settings, Class<?>... classes) throws Exception {
java.nio.file.Path tmp = java.nio.file.Files.createTempFile("ddl", ".sql");
wipe();
Configuration c = config(classes);
apply(c, settings);
c.setProperty("jakarta.persistence.schema-generation.scripts.action", "create");
c.setProperty("jakarta.persistence.schema-generation.scripts.create-target", tmp.toUri().toString());
c.buildSessionFactory().close();
List<String> lines = java.nio.file.Files.readAllLines(tmp);
java.nio.file.Files.delete(tmp);
return lines;
}
public static String describe(Throwable t) {
Throwable root = t;
while (root.getCause() != null && root.getCause() != root) root = root.getCause();
String state = root instanceof SQLException se ? " SQLState " + se.getSQLState() + "," : "";
String msg = String.valueOf(root.getMessage()).lines().findFirst().orElse("");
return t.getClass().getSimpleName() + " -> " + root.getClass().getSimpleName() + ":" + state + " " + msg;
}
@Override
public void close() {
if (sf != null && sf.isOpen()) sf.close();
sf = null;
}
}
@@ -0,0 +1,29 @@
package com.ankurm.idgen;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.hibernate.cfg.Configuration;
/**
* The CREATE script for a dialect that has no database behind it: JDBC metadata access is switched off and only the
* JPA script target is used, so no connection is opened. Shows what AUTO / UUID map to per dialect.
*/
public final class DdlOnly {
private DdlOnly() {}
public static List<String> script(String dialect, Class<?>... entities) throws Exception {
Path out = Files.createTempFile("ddl", ".sql");
Configuration c = new Configuration();
c.setProperty("hibernate.dialect", dialect);
c.setProperty("hibernate.boot.allow_jdbc_metadata_access", "false");
c.setProperty("jakarta.persistence.schema-generation.database.action", "none");
c.setProperty("jakarta.persistence.schema-generation.scripts.action", "create");
c.setProperty("jakarta.persistence.schema-generation.scripts.create-target", out.toUri().toString());
for (Class<?> e : entities) c.addAnnotatedClass(e);
c.buildSessionFactory().close();
List<String> lines = Files.readAllLines(out);
Files.delete(out);
return lines;
}
}
@@ -0,0 +1,80 @@
package com.ankurm.idgen;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import org.hibernate.engine.jdbc.connections.internal.DriverManagerConnectionProvider;
/**
* A ConnectionProvider that wraps every Connection and PreparedStatement in a JDK proxy and counts what Hibernate asks of JDBC:
* statements prepared, rows queued with addBatch, executeBatch calls, and single executes. Hibernate's own Statistics API
* exposes prepareStatementCount but no batch counter, so this is how "did batching happen" is measured here.
*/
public class JdbcCounter extends DriverManagerConnectionProvider {
public static final AtomicLong PREPARED = new AtomicLong(), INSERT_PREPARED = new AtomicLong(), SEQUENCE_PREPARED = new AtomicLong(),
ADD_BATCH = new AtomicLong(), EXECUTE_BATCH = new AtomicLong(), EXECUTE_SINGLE = new AtomicLong();
/** Every non-INSERT statement text prepared since the last reset (Hibernate's StatementInspector does not see the id generators' own JDBC work). */
public static final java.util.List<String> OTHER_SQL = new java.util.concurrent.CopyOnWriteArrayList<>();
public static void reset() {
OTHER_SQL.clear();
for (AtomicLong a : new AtomicLong[] {PREPARED, INSERT_PREPARED, SEQUENCE_PREPARED, ADD_BATCH, EXECUTE_BATCH, EXECUTE_SINGLE}) a.set(0);
}
/** One-line summary used in transcripts. */
public static String summary() {
return "prepared=" + PREPARED.get() + " (insert=" + INSERT_PREPARED.get() + ", sequence=" + SEQUENCE_PREPARED.get() + ")"
+ " addBatch=" + ADD_BATCH.get() + " executeBatch=" + EXECUTE_BATCH.get() + " executeSingle=" + EXECUTE_SINGLE.get();
}
/** Statements sent to the database: one per executeBatch plus one per single execute. */
public static long roundTrips() { return EXECUTE_BATCH.get() + EXECUTE_SINGLE.get(); }
private final Map<Connection, Connection> realOf = Collections.synchronizedMap(new IdentityHashMap<>());
@Override
public void closeConnection(Connection conn) throws SQLException {
Connection real = realOf.remove(conn);
super.closeConnection(real != null ? real : conn);
}
@Override
public Connection getConnection() throws SQLException {
Connection real = super.getConnection();
Connection proxy = (Connection) Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] {Connection.class}, (p, m, a) -> {
try {
Object r = m.invoke(real, a);
if (m.getName().equals("prepareStatement") && a != null && a[0] instanceof String sql) {
PREPARED.incrementAndGet();
String s = sql.toLowerCase();
if (s.startsWith("insert")) INSERT_PREPARED.incrementAndGet(); else OTHER_SQL.add(sql.replaceAll("\s+", " ").trim());
if (s.contains("nextval") || s.contains("next value for")) SEQUENCE_PREPARED.incrementAndGet();
return wrap((PreparedStatement) r);
}
return r;
} catch (InvocationTargetException e) { throw e.getCause(); }
});
realOf.put(proxy, real);
return proxy;
}
private static PreparedStatement wrap(PreparedStatement real) {
return (PreparedStatement) Proxy.newProxyInstance(JdbcCounter.class.getClassLoader(), new Class<?>[] {PreparedStatement.class}, (p, m, a) -> {
switch (m.getName()) {
case "addBatch" -> ADD_BATCH.incrementAndGet();
case "executeBatch" -> EXECUTE_BATCH.incrementAndGet();
case "execute", "executeUpdate", "executeQuery" -> EXECUTE_SINGLE.incrementAndGet();
default -> { }
}
try { return m.invoke(real, a); } catch (InvocationTargetException e) { throw e.getCause(); }
});
}
}
@@ -0,0 +1,24 @@
package com.ankurm.idgen;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.resource.jdbc.spi.StatementInspector;
/**
* Registered through {@code hibernate.session_factory.statement_inspector}. Records every SQL string Hibernate sends
* to JDBC (with {@code ?} placeholders, before binding). Raw JDBC issued by the tests themselves is not recorded,
* so the transcripts show only what Hibernate generated.
*/
public class SqlLog implements StatementInspector {
private static final List<String> LOG = new ArrayList<>();
@Override
public String inspect(String sql) {
synchronized (LOG) { LOG.add(sql.replaceAll("\\s+", " ").trim()); }
return sql;
}
public static void clear() { synchronized (LOG) { LOG.clear(); } }
public static List<String> snapshot() { synchronized (LOG) { return new ArrayList<>(LOG); } }
}
@@ -0,0 +1,40 @@
package com.ankurm.idgen;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/** Collects what a test observed and writes it to {@code output/NN-name.txt}; the tests assert the same facts. */
public final class Transcript {
private final StringBuilder sb = new StringBuilder();
private final String file;
/** While a Db.sql(..) action runs, lines are parked here and written after the step's SQL, so a step reads label / SQL / result. */
static final StringBuilder PARKED = new StringBuilder();
static boolean parking;
public Transcript(String file) { this.file = file; }
public Transcript line(String s) { (parking ? PARKED : sb).append(s).append('\n'); return this; }
public Transcript blank() { return line(""); }
public Transcript heading(String s) { return line("").line("== " + s); }
/** A step: the label, then every SQL statement Hibernate issued during it. */
public Transcript step(String label, List<String> sql) {
line("-- " + label);
if (sql.isEmpty()) line(" (no SQL)");
for (String s : sql) line(" sql> " + s);
sb.append(PARKED);
PARKED.setLength(0);
return this;
}
public void save() throws IOException {
Path out = Path.of("output", file);
Files.createDirectories(out.getParent());
Files.writeString(out, sb.toString(), StandardCharsets.UTF_8);
}
}