Skip to content

Native-image build fails due to UlidCreator static initialization (Random in image heap) #812

@ricardozanini

Description

@ricardozanini

Summary

Building a native executable with GraalVM/Mandrel fails because the SDK’s default ID generation uses com.github.f4b6a3.ulid.UlidCreator, which initializes a SecureRandom during class initialization. This creates a Random instance in the image heap, and native-image aborts.

Current behavior

Default ID generation is defined in WorkflowApplication.Builder:

// current
private WorkflowInstanceIdFactory idFactory =
    () -> com.github.f4b6a3.ulid.UlidCreator.getMonotonicUlid().toString();

During native image analysis, UlidCreator$MonotonicFactoryHolder runs a static initializer that constructs a SecureRandom, which is then detected in the image heap.

Typical error (excerpt):

Error: Detected an instance of Random/SplittableRandom class in the image heap.
The culprit object has been instantiated by the 'com.github.f4b6a3.ulid.UlidCreator$MonotonicFactoryHolder' class initializer...
    at java.security.SecureRandom.<init>(SecureRandom.java:224)
    at com.github.f4b6a3.ulid.UlidFactory$ByteRandom.newRandomFunction(UlidFactory.java:460)
    ...
    at com.github.f4b6a3.ulid.UlidCreator$MonotonicFactoryHolder.<clinit>(UlidCreator.java:165)

Expected behavior

Native builds should succeed out of the box; any RNG instantiation should happen at runtime, not during image build.


Root cause

UlidCreator.getMonotonicUlid() relies on a static holder which initializes a SecureRandom in a class initializer. GraalVM/Mandrel forbids Random/SecureRandom instances in the image heap, hence the failure.


Proposed fixes (two alternatives)

Option 1 (keep ULID): lazy-initialize at runtime

Avoid UlidCreator (static singleton). Instead, construct an UlidFactory instance at runtime (e.g., inside build()), and use it to generate IDs. Do not store a static factory or RNG.

Suggested change:

- private WorkflowInstanceIdFactory idFactory =
-     () -> com.github.f4b6a3.ulid.UlidCreator.getMonotonicUlid().toString();
+ private WorkflowInstanceIdFactory idFactory; // set lazily in build()

 public WorkflowApplication build() {
   ...
+  if (idFactory == null) {
+    // Build the ULID factory at runtime (no static holder)
+    final com.github.f4b6a3.ulid.UlidFactory f =
+        com.github.f4b6a3.ulid.UlidFactory.newMonotonicInstance();
+    idFactory = () -> f.create().toString();
+  }
   return new WorkflowApplication(this);
 }

Pros

  • Preserves ULID format (monotonic, lexicographically sortable).
  • Minimal behavioral change; no downstream visible differences besides native compatibility.

Cons

  • Still depends on ulid-creator (which is fine if we’re happy with it).

Option 2 (replace with UUID): use JDK UUIDs by default

Remove the ulid-creator dependency and use UUID.randomUUID() as the default. Keep withIdFactory(...) so advanced users can plug their own ULID (or anything else) if needed.

Suggested change:

- import com.github.f4b6a3.ulid.UlidCreator;
+ import java.util.UUID;

- private WorkflowInstanceIdFactory idFactory =
-     () -> UlidCreator.getMonotonicUlid().toString();
+ private WorkflowInstanceIdFactory idFactory; // set lazily in build()

 public WorkflowApplication build() {
   ...
+  if (idFactory == null) {
+    idFactory = () -> UUID.randomUUID().toString();
+  }
   return new WorkflowApplication(this);
}

Pros

  • No extra dependency; JDK-only and Graal-friendly.
  • Simplest path; avoids ULID static init entirely.

Cons

  • ID format changes (not lexicographically sortable).
  • Downstream code or docs expecting ULIDs would need to adjust (if any).

Backward compatibility / migration notes

  • The SDK already exposes withIdFactory(WorkflowInstanceIdFactory) on the builder, so advanced users can keep ULIDs (or any other scheme).
  • If keeping ULID is important, Option 1 preserves the format.
  • If switching to UUID (Option 2), call out ID format change in release notes.

Acceptance criteria

  • Native builds complete successfully without additional flags (--initialize-at-run-time=...).
  • No Random/SecureRandom instances are created during image build (only at runtime).
  • Existing public API remains intact; existing apps continue to run unchanged (besides the ID format if Option 2 is chosen).

Tests & verification

  • Add/adjust a smoke test that starts the engine and creates at least one workflow instance in a native test environment.
  • (Optional) Add a unit test guarding against class initialization side-effects: ensure no references to UlidCreator are needed unless a custom ULID IdFactory is explicitly provided.

Proposed default

Either option works; Option 1 keeps existing semantics (ULID) with minimal change, while Option 2 simplifies dependencies. The team can choose based on preference for ULID properties vs. dependency reduction.

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions