Thursday, June 4, 2026

From hours to minutes: GlassFish pool for Jakarta EE TCKs

Jakarta EE TCKs are notoriously slow. The bulk of the wall clock is not test execution but GlassFish cold-start: every test module unpacks a dist, boots a domain, deploys, undeploys, and stops the domain. With one hundred-ish test modules and several seconds of start/stop per module, a full TCK run easily takes hours. The arquillian-glassfish-server-pool module and the glassfish-pool-maven-plugin, both released as part of OmniFish arquillian-container-glassfish 2.2.0, eliminate that overhead by sharing a pool of pre-started GlassFish instances across the entire reactor.

The proof of concept: Faces TCK

The Jakarta Faces TCK historically consisted of two parts. The "old TCK" was the original Oracle suite: an Ant-driven JavaTest harness inherited from the JSF 1.x days, with around 5000 tests. The "new TCK" was the body of contributed and later-added tests built on JUnit + Arquillian, optionally with HtmlUnit or Selenium for browser interaction. Running both ends to end easily took over 3 hours on Jenkins CI, dominated by the old TCK.

Folding the old TCK into the new-TCK style was always the goal, but per-test manual conversion was prohibitively cumbersome; AI-assisted development is what finally made it feasible. Pull requests #2145, #2146, #2147, #2149, and #2150 mechanically migrated the entire old TCK, with Claude Code doing the bulk of the rewriting and consolidating the remaining HtmlUnit assertions onto Selenium along the way. WAR consolidation (one WAR per feature group instead of one per test) brought wall clock down to ~1h.

The second step was an in-house gf-pool prototype (#2156) that pre-started a pool of GlassFish instances and leased one slot per failsafe-forked JVM. With mvn clean verify -T8 (8 threads), the full Faces TCK reactor now finishes in under 4 minutes. The prototype proved the model works; the natural next step was extracting it into a reusable Maven plugin so other TCKs do not have to copy-paste the wiring.

PhaseLinuxMacBookJenkins
Pre-migration02:57 h02:53 h03:19 h
Post-migration01:05 h40:52 m01:18 h
With gf-pool3:46 m (-T8)4:47 m (-T5)13:06 m (-T2)

Linux: Intel Core i9-10900X with 32GB
MacBook: M1 Pro 10 Core with 16GB
Jenkins: Eclipse Jiro with "2 CPU" and "8 GB"

What gf-pool is

The pool ships in two artifacts:

  • arquillian-glassfish-server-pool: a runtime Arquillian DeployableContainer that leases a slot for the duration of a test JVM and deploys against the leased slot's DAS through the standard CommonGlassFishManager.
  • glassfish-pool-maven-plugin: lifecycle goals (up, down, provision, status, nuke) that provision and start slots before integration-test and stop them after.

Provisioning clones a single source GlassFish install into slot-1/, slot-2/, …, rewrites each slot's domain.xml so its ports land in a non-overlapping window (adminBase + (slot - 1) * portStride), and starts every slot in parallel. Each test JVM acquires an exclusive FileChannel.tryLock() on slot-N/lock, reads slot-N/ports.properties, and holds the lock for the JVM's lifetime. The lease protocol is pure Java; there's no -javaagent, no surefire argLine plumbing, and no shell scripts.

The pool grows on demand. A sequential build uses one slot; mvn clean verify -T4 grows to four; -T8 grows to eight. A JVM shutdown hook installed on Maven's own JVM stops every slot at session end (or on Ctrl+C), so no orphaned processes survive a hard build failure.

The optimal -TN for your machine is bounded by available RAM, not by core count. Each slot is a full GlassFish JVM plus a failsafe-forked test JVM, so the dominant cost is heap and resident memory, not CPU. Moreover, if your TCK drives a browser (as Faces does), each slot also spawns its own Chrome plus chromedriver, which pushes the total to ~1.5GB per slot. A 16-core box with 16GB will usually thrash at -T8 while a 8-core box with 32GB happily handles -T8; pick N by watching resident memory and swap, not number of processors.

Maven setup

Two plugin blocks: run the pool plugin (which resolves and unpacks GlassFish itself), and point failsafe at the same <poolDir> and <poolSource>.

<build>
    <plugins>
        <plugin>
            <groupId>ee.omnifish.arquillian</groupId>
            <artifactId>glassfish-pool-maven-plugin</artifactId>
            <version>2.2.0</version>
            <configuration>
                <poolDir>${project.build.directory}/pool</poolDir>
                <poolSource>${project.build.directory}/dist/glassfish9</poolSource>
                <distribution>
                    <groupId>org.glassfish.main.distributions</groupId>
                    <artifactId>glassfish</artifactId>
                    <version>9.0.0-M2</version>
                    <type>zip</type>
                </distribution>
            </configuration>
            <executions>
                <execution><id>pool-up</id><goals><goal>up</goal></goals></execution>
                <execution><id>pool-down</id><goals><goal>down</goal></goals></execution>
            </executions>
        </plugin>

        <plugin>
            <artifactId>maven-failsafe-plugin</artifactId>
            <configuration>
                <systemPropertyVariables>
                    <gf.pool.dir>${project.build.directory}/pool</gf.pool.dir>
                    <gf.pool.source>${project.build.directory}/dist/glassfish9</gf.pool.source>
                </systemPropertyVariables>
            </configuration>
        </plugin>
    </plugins>
</build>

The <distribution> block tells the plugin to resolve the named artifact through your usual Maven repositories and unpack it under ${project.build.directory}/dist before provisioning runs. Staging is idempotent: re-runs fast-exit when the marker file written after a successful unpack is still present.

Add the runtime as a test-scope dependency:

<dependency>
    <groupId>ee.omnifish.arquillian</groupId>
    <artifactId>arquillian-glassfish-server-pool</artifactId>
    <version>2.2.0</version>
    <scope>test</scope>
</dependency>

No arquillian.xml is needed. The failsafe <systemPropertyVariables> above forward gf.pool.dir and gf.pool.source to the test JVM, and the container adapter reads them at start() when it leases a slot. Drop in an arquillian.xml with <container qualifier="glassfish-pool"> only if you need to override inherited fields like adminPassword, or if you're running against a hand-staged pool without the plugin.

That's it. mvn clean verify works sequentially; mvn clean verify -T8 fans out across eight slots.

Need to peek at the pool? mvn glassfish-pool:status in a separate terminal redraws a top-style table once per second:

mvn glassfish-pool:status command output

Overlays

For TCK-style builds that test a SNAPSHOT impl jar against a released distribution (or vice versa), the plugin can copy overlay jars into glassfish/modules/ after unpack and before slot cloning. Declare zero or more <overlay> blocks:

<configuration>
    <overlays>
        <overlay>
            <groupId>org.glassfish</groupId>
            <artifactId>jakarta.faces</artifactId>
            <version>5.0.0-SNAPSHOT</version>
            <destFileName>mojarra.jar</destFileName>
        </overlay>
    </overlays>
</configuration>

Each overlay accepts a <skip> child that wires to your existing per-profile property switches, so you can hold back individual jars per release line without forking the pom.

Bring your own unpack

If you'd rather use maven-dependency-plugin for the unpack (e.g. because your build already has one for unrelated reasons), drop the <distribution> block from the pool plugin and add a regular unpack execution that lands in the same directory <poolSource> points at:

<plugin>
    <artifactId>maven-dependency-plugin</artifactId>
    <executions>
        <execution>
            <id>unpack-glassfish</id>
            <phase>process-test-classes</phase>
            <goals><goal>unpack</goal></goals>
            <configuration>
                <artifactItems>
                    <artifactItem>
                        <groupId>org.glassfish.main.distributions</groupId>
                        <artifactId>glassfish</artifactId>
                        <version>9.0.0-M2</version>
                        <type>zip</type>
                        <outputDirectory>${project.build.directory}/dist</outputDirectory>
                    </artifactItem>
                </artifactItems>
            </configuration>
        </execution>
    </executions>
</plugin>

The pool plugin will then skip staging and clone slots directly from ${project.build.directory}/dist/glassfish9.

JVM system properties at slot boot

Some properties have to be on the GF JVM at startup. The canonical example is javax.net.ssl.trustStorePassword: a PKCS12 truststore needs the password before SSL is used for the first time, because Java caches the default SSLContext after first use and never reloads from disk. The plugin's <systemProperties> hook bakes each key=value into every <java-config> of each slot's domain.xml at provisioning time:

<configuration>
    <systemProperties>
        javax.net.ssl.trustStorePassword=changeit
        java.awt.headless=true
    </systemProperties>
</configuration>

Adoption: Security TCK

The Jakarta Security TCK followed the same old-TCK / new-TCK split as Faces: a JavaTest "old TCK" from Oracle plus a JUnit + Arquillian "new TCK" of later contributions. The combined suite ran in just under 13 minutes. Unlike Faces, the old-TCK side wasn't the bottleneck: it deployed its apps onto a single long-running GlassFish domain and replayed all 83 of its JavaTest clients against them, so it was already efficient on its own. Migrating it was therefore not about runtime; it was about consolidating on a single test harness. Pull request #365 did exactly that, mechanically rewriting the old TCK into the new-TCK style with Claude Code doing the bulk of the assertion work. The unavoidable trade-off is that each migrated test now spins up its own Arquillian-managed GlassFish instead of sharing one domain, so single-threaded runtime nearly doubled to ~24 minutes. That is the cost of trading a shared harness for per-test isolation. Pull request #368 recovers that cost (and then some) by swapping the JVM-scoped arquillian-glassfish-server-managed container for arquillian-glassfish-server-pool. With mvn clean verify -T8, the Security TCK now finishes in under 2 minutes.

Per-test isolation is a much bigger deal for Faces, whose old TCK has ~5000 tests and takes 2+ hours on a single shared GlassFish; there the post-migration single-threaded runtime would be prohibitive without the pool. Security is the small-scale case where you can see the trade clearly; Faces is where parallelism stops being optional.

PhaseLinuxMacBookJenkins
Pre-migration12:50 m9:38 m19:05 m
Post-migration23:42 m16:34 m
With gf-pool1:49 m (-T8)1:30 m (-T5)

Linux: Intel Core i9-10900X with 32GB
MacBook: M1 Pro 10 Core with 16GB
Jenkins: Eclipse Jiro with "2 CPU" and "8 GB"

Parallelism: what you'll discover

Moving to a shared pool surfaces parallelism issues that a sequential build hides. The Security TCK migration is a good cross-section. None of these are pool bugs; they're latent contracts that finally see daylight when two slots run side by side.

Hardcoded ports. Tests that embed an LDAP server, a Tomcat instance, or any other side-process on a fixed port collide as soon as two slots co-run. Pick distinct ports per module, or derive them from the slot index (gf.pool.slot is published as a system property by the leaser). The Security TCK's embedded LDAP modules split 33389 onto 33390 and 33391 for its two extra app-ldap variants.

Hardcoded URLs. Tests that publish http://localhost:8080/... URLs to an external party (OAuth callback URIs, OIDC issuer metadata) break the moment the slot's HTTP port is anything but 8080. Replace literals with UriInfo-derived or request-derived URLs at runtime, so the URL tracks the slot's actual HTTP port.

Pre-registered redirect URIs for every slot. External identity providers that need redirect URIs pre-registered (Mitre OIDC in the Security TCK case) have to be told about every slot the pool may grow to. Maven exposes ${session.request.degreeOfConcurrency} as the -TN value; bsh-property can promote it to a regular property if your plugin only consumes typed properties.

Cross-app singletons. A java:global/ DataSource shared across apps, or any other JNDI/CDI/resource a previous app deploy leaves behind, can leak into the next app's lookup on the same slot. The Security TCK adoption uncovered a related upstream GlassFish bug where ComponentEnvManagerImpl.getResourceId returned an empty string for ScopeType.GLOBAL, fixed in eclipse-ee4j/glassfish#26029. Worth re-running your TCK against this fix if you exercise cross-app global resources.

Persisted state across re-runs. Anything written to work/, sessions/, or other on-disk caches survives a slot's lease release and can resurrect into the next consumer. If your test relies on a known starting state or an existing HTTP session variable, wipe the relevant directories on container start.

Aggregator goals on the reactor root. Goals like failsafe-report-only, cyclonedx:makeAggregateBom, or install-file bound to a per-module phase stall the -T reactor because Maven serialises them across modules. Move these to inherited=false on the reactor root only.

For Jakarta EE TCK maintainers

If your Jakarta EE TCK still drags a JavaTest "old TCK" alongside its JUnit + Arquillian "new TCK", or runs sequentially against a freshly-unpacked GlassFish per module, the migration path is the one Faces and Security walked. First, fold any remaining old TCK into the new-TCK style (JUnit + Arquillian, plus Selenium if your tests drive a browser, as Faces does); AI-assisted development handles the mechanical rewriting well enough that the bulk of the work is reviewing diffs, not writing them. Then wire arquillian-glassfish-server-pool and glassfish-pool-maven-plugin in. The result is a TCK that finishes in minutes instead of hours, and a build that still runs sequentially under mvn clean verify for vendors who prefer that.

The README at glassfish-pool-maven-plugin/README.md documents the full configuration surface; the working example at integration-tests/src/it/pool is ~150 lines of pom and runs as a smoke test in the project's own CI. Both Faces TCK (faces#2165) and Security TCK (security#368) are open and worth studying as real-world consumers.

Thursday, April 23, 2026

OmniFaces 5.3 released!

OmniFaces 5.3 has been released!

This is a relatively small feature release on top of 5.2. One new component has been added, and the rest of the changes are under the hood for better long-term maintenance: automated code formatting, a simpler and faster JavaScript build, and reorganized TypeScript sources. You can find the complete list of additions, changes and fixes at What's new in OmniFaces 5.3? in the showcase.

New: <o:lazyPanel>

Ever had a page with an expensive region below the fold which the user may never scroll to, but which gets built on every page load anyway? The traditional workaround is to wire up an IntersectionObserver in custom JavaScript and fire an ajax request yourself, which is a lot of boilerplate for something so common.

The new <o:lazyPanel> defers rendering of its children until the panel has scrolled into view:

<o:lazyPanel>
    <h:dataTable value="#{bean.expensiveList}" var="row">
        ...
    </h:dataTable>
</o:lazyPanel>

On initial render, the component writes a wrapper element with an optional placeholder and schedules a viewport intersection listener on it via OmniFaces.js, which uses IntersectionObserver when available and falls back to scroll/resize/orientationChange listeners otherwise. As soon as the wrapper intersects the viewport, a single faces.ajax.request targeting its own client id is fired. The component then flips its loaded flag, optionally invokes a listener bean method with a LazyPanelEvent, and renders its children in place of the placeholder.

The loaded attribute is a server-side escape hatch: when true, the children are rendered immediately without any client side observer. This is useful for print views, SEO crawlers, or tests.

<o:lazyPanel loaded="#{bean.printPreview}">
    ...
</o:lazyPanel>

Nested <f:param> or <o:param> children are sent along with the lazy panel ajax request, so that a single listener can serve multiple panels by distinguishing on an entity id, filter key, or page number:

<o:lazyPanel listener="#{bean.preload}">
    <f:param name="productId" value="#{product.id}" />
    ...
</o:lazyPanel>

The listener can read them via Faces#getRequestParameter(). Parameter values are evaluated at initial render (snapshot semantics), consistent with UIParameter usage elsewhere in Faces.

The closest equivalent in PrimeFaces is <p:outputPanel deferred="true" deferredMode="visible">, which loads its contents once the panel is scrolled into view. Under the hood however it uses jQuery scroll handlers on the window combined with $.offset() and window height math, which fires on every scroll event and scales poorly when you have multiple deferred panels on the same page. <o:lazyPanel> uses the native IntersectionObserver which is browser-native, more efficient, and only observes the panel itself; it falls back to scroll/resize/orientationChange listeners only when IntersectionObserver is unavailable. <o:lazyPanel> also has no jQuery or PrimeFaces runtime dependency, it's just a standard faces.ajax.request, so it works in vanilla Faces applications without PrimeFaces. On top of that, <o:lazyPanel> supports <f:param>/<o:param> for passing context to the listener, which <p:outputPanel> does not natively offer.

The homegrown alternative is to wire up an IntersectionObserver yourself which then calls a <h:commandScript> or <p:remoteCommand> from the intersection callback, and to manually swap placeholder markup on response. This works, but it's imperative JavaScript scattered across the view, and you'll have to repeat it for every lazy region. <o:lazyPanel> is the declarative equivalent: one tag, no JavaScript, and the placeholder and listener are just regular Faces markup.

Under the hood

Relatively a lot of things have been cleaned up in the build and source tree. These have no impact on runtime behavior, but they do make the project easier to maintain and contribute to:

  • Automated code formatting via Spotless and Stylistic; all Java, XML, XHTML and TypeScript sources are now formatted consistently on every build. This avoids inconsistently formatted source code coming in with pull requests.
  • The TypeScript sources have been reorganized into their own src/main/ts subfolder. This keeps the context of src/main/webapp clean.
  • The JavaScript build has been improved: browserify and closure-compiler-maven-plugin have been replaced by esbuild for performance and simplicity.
  • Vdlgen now also runs during Eclipse incremental builds, so workspace resolution into sandbox projects continues to work.

Fixes

MultiViews welcome file resolution failed on Windows-based servers due to wrong parent path handling. This has been fixed (#949).

<o:validateBean> did not collect nested properties of @Valid-annotated beans, so validation could silently skip nested constraints. This has been fixed (#951).

@ViewScoped unload threw a NullPointerException during pending view state removal in the specific combination of Spring WebFlow with MyFaces. This has been fixed (#952).

Installation

Non-Maven users: download OmniFaces 5.3.4 JAR and drop it in /WEB-INF/lib the usual way, replacing the older version if any.

Maven users: add below entry to pom.xml, replacing the older version if any.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnifaces</artifactId>
    <version>5.3.4</version>
</dependency>

The 5.3.4 fixes have also been backported to 4.x and 3.x, so OmniFaces 4.7.10 and OmniFaces 3.14.21 have been released as well.

Monday, April 20, 2026

OmniHai counts the cost

OmniHai 1.4 is out! After 1.1 gave the library ears, 1.2 a voice, and 1.3 the ability to step outside and browse the web, 1.4 teaches it to count. Token usage becomes actual money, runaway spend can be capped, reasoning effort is now dial-able across providers, and ChatOptions knows how to serialize itself to portable JSON.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnihai</artifactId>
    <version>1.4</version>
</dependency>

Cost Calculation

1.3 introduced ChatUsage so you could see how many tokens a call consumed. Useful, but tokens are not what the invoice at the end of the month is denominated in. 1.4 closes that gap with ChatPricing and ChatCost.

Attach a pricing to your ChatOptions, make a call, read back the cost:

ChatPricing pricing = new ChatPricing(
    new BigDecimal("3.00"),       // input price per 1M tokens
    new BigDecimal("0.30"),       // cached-input price per 1M tokens (optional)
    new BigDecimal("15.00"),      // output price per 1M tokens (includes reasoning)
    Currency.getInstance("USD")); // optional; purely for presentation.

ChatOptions options = ChatOptions.newBuilder()
    .pricing(pricing)
    .build();

String response = service.chat("Explain quantum computing", options);

ChatCost cost = options.getLastCost();
System.out.println("Input cost:        " + cost.inputCost());
System.out.println("Cached input cost: " + cost.cachedInputCost());
System.out.println("Output cost:       " + cost.outputCost());
System.out.println("Total cost:        " + cost.totalCost() + " " + cost.currency());

Prices are expressed per one million tokens to match how providers publish their rate sheets. There are deliberately no built-in rate presets; provider rates drift and differ per model tier, so you look up the current numbers for your chosen model and pass them in. The optional currency is passed through to ChatCost for display; it does not affect any arithmetic, so use whatever unit you supplied the prices in.

The cachedInputTokenPrice is optional. When null, cached tokens are billed at the regular input rate. Set it explicitly to reflect the provider's cache-read discount (Anthropic charges roughly 10% of the input rate for cache reads, OpenAI and Google roughly 25%). Reasoning tokens are always billed at the output rate, consistent with how providers invoice them.

If you want the full positional constructor to be a bit less ceremonial, there are two factory methods:

ChatPricing simple = ChatPricing.of(new BigDecimal("3.00"), new BigDecimal("15.00"));
ChatPricing withCache = ChatPricing.of(new BigDecimal("3.00"), new BigDecimal("0.30"), new BigDecimal("15.00"));

And if you have a ChatUsage in hand and want the cost ad-hoc without configuring options at all:

ChatCost cost = usage.calculateCost(pricing);

One caveat worth mentioning up front: this is a simplified three-tier scheme (base input, cached input, output) that covers the common case. Provider-specific billing axes like Anthropic's 5-minute and 1-hour cache-write premiums are not modeled and may cause under-counting for workloads that rely heavily on explicit prompt caching. For strict accuracy, reconcile against the provider's own billing API. For "roughly what did that call cost me" it is good enough.

Budget Cap

Cost visibility is nice. Cost protection is nicer. 1.4 also lets you attach a cumulative-cost ceiling alongside the pricing so runaway spend on a given ChatOptions instance gets stopped rather than logged after the fact:

ChatOptions options = ChatOptions.newBuilder()
    .pricing(pricing, new BigDecimal("1.00")) // hard stop at $1.00
    .build();

while (hasMoreWork()) {
    try {
        service.chat(next(), options);
    } catch (AIBudgetExceededException e) {
        log.warn("Spent {} of {} {} — stopping", e.getTotalCost(), e.getMaxTotalCost(), e.getCurrency());
        break;
    }
}

The cap is checked before each call using the accumulated ChatOptions.getTotalCost(). It is a soft ceiling: the call that pushes the running total at or over the cap still completes and is billed; the next call is refused with AIBudgetExceededException. That keeps the behavior predictable; the alternative of estimating an upcoming call's cost before dispatching it would require knowing the output token count in advance, which of course you don't.

After you have caught the exception, you can call options.resetBudget() to zero the counter and start a fresh window on the same instance, or switch to a different ChatOptions instance, or even fail over to a different AIService (e.g. a cheaper model) to continue processing.

Cached Input Tokens

While we are on the subject of prompt caches, ChatUsage has gained a fourth field: cachedInputTokens().

ChatUsage usage = options.getLastUsage();
System.out.println("Input tokens:         " + usage.inputTokens());
System.out.println("Cached input tokens:  " + usage.cachedInputTokens()); // subset of inputTokens
System.out.println("Output tokens:        " + usage.outputTokens());
System.out.println("Reasoning tokens:     " + usage.reasoningTokens());   // subset of outputTokens
System.out.println("Total tokens:         " + usage.totalTokens());

It reports the subset of input tokens that was served from the provider's prompt cache. This is the number that drives the cheaper cachedInputCost on ChatCost, and it is useful on its own too; a low cache-hit ratio on a workload that should mostly be reused content is a good signal that your system prompts are drifting or the provider's cache TTL has elapsed. As with the other fields, a value of -1 means the provider did not report it.

Reasoning Effort

Modern frontier models (GPT-5, Claude extended thinking, Gemini thinking, Grok reasoning) all let you tune how many tokens they should spend on internal reasoning before answering. The knobs are called different things across providers; in OmniHai they live behind a single enum:

ChatOptions options = ChatOptions.newBuilder()
    .reasoningEffort(ReasoningEffort.HIGH)
    .build();

String answer = service.chat("Prove the Pythagorean theorem.", options);

The available levels are AUTO (the default, defers to the provider's own default), NONE (actively disable reasoning where supported, for minimum cost and latency), LOW (~20% of budget), MEDIUM (~50% of budget), HIGH (~80% of budget), and XHIGH (~95% of budget). Providers that do not support a given level map to the closest equivalent, so you can leave the same ChatOptions in place while switching the underlying provider.

Higher levels typically improve answer quality on hard problems (math, multi-step planning, non-trivial code) at the cost of more tokens and latency. On trivial prompts they just spend money without any measurable upside, so do not set HIGH or XHIGH as the default for all your calls :) Keep in mind that a higher effort may also require a correspondingly higher maxTokens to avoid truncated responses.

Portable JSON for ChatOptions

ChatOptions has been Serializable since day one, which is enough to stash it in an HTTP session. For portable storage, REST payloads, JSON columns, audit logs, or cross-service transport, Java serialization is not what you want. 1.4 adds an explicit JSON form:

String json = options.toJson();
ChatOptions restored = ChatOptions.fromJson(json);

All user-facing settings are included: system prompt, JSON schema, temperature, maxTokens, reasoning effort, topP, web search location, pricing, maxTotalCost, maxHistory, and the full conversation history (including any recorded uploaded file references). Null or unset fields are omitted for a compact payload. Runtime state, the last usage and the cumulative total cost, is deliberately not serialized; a restored instance starts with a fresh zero total cost counter.

Round-tripping a shared default constant (DEFAULT, CREATIVE, DETERMINISTIC) yields a mutable copy, equivalent to calling copy(). That way you do never accidentally end up with a restored instance that still rejects mutations because it was derived from an immutable template.

Default Models

Under the hood, default model identifiers per provider have been refreshed to match the current state of technology. The exact identifiers are documented on the GitHub README. If you were relying on the provider default, you get the newer model automatically on upgrade; if you were pinning a specific model, nothing changes for you.

Getting 1.4

Non-Maven users: download the OmniHai 1.4 JAR and drop it in /WEB-INF/lib the usual way, replacing the older version if any.

Maven users:

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnihai</artifactId>
    <version>1.4</version>
</dependency>

Give It a Try

As always, feedback and contributions are welcome on GitHub. If you run into any issues, open an issue. Pull requests are welcome too.

Wednesday, March 25, 2026

OmniFaces 5.2 released!

OmniFaces 5.2 has been released! Relatively a lot of things have been added in barely 2 weeks (5.1 was kind of forcibly released 2 weeks ago because it had an important bugfix). Three new components, a new push transport, and a handful of improvements and fixes.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnifaces</artifactId>
    <version>5.2.3</version>
</dependency>

You can find the complete list of additions, changes and fixes at What's new in OmniFaces 5.2? in the showcase.

New: <o:sse>

Have you ever used <o:socket> for server-to-client push and then discovered that corporate proxies or firewalls block WebSocket connections?

The new <o:sse> component provides an alternative push transport based on Server-Sent Events. It uses plain HTTP, works through any proxy or CDN, has built-in reconnect, and benefits from HTTP/2 multiplexing. No additional dependencies needed. Just async servlet support which is already available since Servlet 3.0 in 2009.

The client side looks familiar:

<o:sse channel="liveUpdates" onmessage="handleUpdate" />

The server side uses the same PushContext interface as <o:socket>, but with a new type attribute on @Push:

@Inject @Push(type = SSE)
private PushContext liveUpdates;

public void sendUpdate() {
    liveUpdates.send("Hello from SSE!");
}

Both <o:sse> and <o:socket> provide one-way (server to client) push. The key difference is the transport. SSE runs over plain HTTP so it works through HTTP infrastructure that may block WebSocket. WebSocket on the other hand is not affected by the browser's per-origin connection limit when the server does not support HTTP/2.

When the server does not support HTTP/2, SSE is the worse choice because browsers hard-limit concurrent HTTP/1.1 connections per origin (current Chrome version has a limit of 6), and multiple SSE channels across tabs may hit this limit and queue further HTTP requests. So the <f:ajax> will simply stop working when the connection limit is hit by solely SSE connections. So in case you wish to use <o:sse>, you need to make absolutely sure that your server (and proxy!) supports HTTP/2, else your application will simply stop working when a page containing a SSE connection is being opened in multiple browser tabs.

When using <o:sse>, then you also need to make sure that every single servlet filter which is mapped on match-all URL pattern of /* is explicitly configured as @WebFilter(asyncSupported=true) on the class or as <async-supported>true</async-supported> in web.xml. In case that's not possible for your filter (e.g because it relies on some request/thread-specific state after invoking chain.doFilter() such as DB connection, locked/shared resource, ThreadLocal, etc), then you'll need to map it on a more specific URL-pattern excluding /omnifaces.sse/* and create yet another filter on /* which forwards to that filter when the request path does not match /omnifaces.sse/*.

In any case, this is a big candidate to end up as new <f:sse> component in a future Jakarta Faces version (like as that <o:socket> ultimately ended up as <f:websocket> in JSF 2.3).

New: <o:notification>

What if you could send browser notifications from the server side as easy as sending a push message? The new <o:notification> component basically extends <o:sse> with the Web Notifications API integration (which was only recently finished, at 16 March 2026). It opens an SSE connection and shows incoming push messages as browser notifications, even when the user is in another tab.

It requires the PWAResourceHandler for the service worker, and a user gesture to request permission:

<h:head>
    ...
    <link rel="manifest" href="#{resource['omnifaces:manifest.webmanifest']}" /> <!-- Activates PWAResourceHandler -->
</h:head>
<h:body>
    <button type="button" onclick="OmniFaces.Notification.requestPermission()">Enable Notifications</button>
    ...
    <o:notification channel="notifications" />
</h:body>

From the server side, inject a NOTIFICATION-typed push context and send a Notification.Message instance:

@Inject @Push(type = NOTIFICATION)
private PushContext notifications;

public void sendNotification() {
    notifications.send(Notification.createNotificationMessage("System maintenance", "The system will undergo maintenance at 22:00 UTC."));
}

You can optionally add a URL so that clicking the notification navigates to it. User-targeted notifications are supported via the user attribute.

public void sendOrderShippedNotification(@Observes OrderShippedEvent event) {
    var userId = event.getUserId();
    var orderId = event.getOrderId();
    notifications.send(Notification.createNotificationMessage("Order shipped", "Your order #" + orderId + " has been shipped.", "/orders/" + orderId), userId);
}

Stacking, silent mode, and requireInteraction can be configured via boolean attributes on <o:notification>.

New: <o:scriptErrorHandler>

Ever wondered how many JavaScript errors your users are silently swallowing and therefore you're unaware which bugs they're actually facing? The new <o:scriptErrorHandler> catches uncaught JavaScript errors and unhandled promise rejections on the client and sends them to the server via navigator.sendBeacon(), where they are fired as CDI events. No additional endpoint boilerplate needed. The servlet is auto-registered when at least one CDI observer on ScriptError is present.

Just put it in the head before any other <h:outputScript> components:

<h:head>
    <o:scriptErrorHandler />
    ...
</h:head>

And observe the events in any (typically application scoped) CDI bean:

@ApplicationScoped
public class ScriptErrorObserver {

    private static final Logger logger = Logger.getLogger(ScriptErrorObserver.class.getName());

    public void onScriptError(@Observes ScriptError error) {
        logger.warning(error.toString());
    }
}

The ScriptError event provides the page URL, error message, error name, stack trace, source URL, line/column number, remote address, user agent, and user principal. Client-side deduplication prevents flooding the server with repeated errors. You can customize default deduplication via maxRecentErrors and errorExpiry attributes which default to 100 errors and 1 minute respectively.

omnifaces.taglib.xml migrated to Vdlgen

The hand-maintained omnifaces.taglib.xml file, which had grown to over 8,000 lines of basically copypasted javadoc blocks, has been completely replaced by Vdlgen. This is a new OmniFaces project: a Java annotation processor that generates the .taglib.xml file from annotations on the source code during compilation. The taglib can never anymore drift from the actual component, converter, validator or function implementation.

Components annotated with @FacesComponent will automatically have their class javadoc copied as tag description and all attribute setters will automatically have their method javadoc copied as attribute descriptions. You can optionally add Vdlgen-provided @FacesAttribute(required = true) to the setter method in order to mark it as a required attribute.

Tag handlers, which don't have annotation support out the box by Faces API, need explicit metadata annotations like this:

/**
 * Tag description.
 */
@FacesTagHandler(namespace = "example.taglib")
public class ExampleTagHandler extends TagHandler {

    /** Tag attribute description. */
    @FacesAttribute(required = true)
    private final TagAttribute type;

    /** Tag attribute description. */
    @FacesAttribute(name = "var", description = "Tag attribute description which overrides javadoc")
    private final String varValue;
}

There are also @FacesComponentConfig, @FacesConverterTag, @FacesValidatorTag, @FacesFunctions, and @FacesFunction annotations. They cover all cases that the .taglib.xml supports. Jakarta Faces own @FacesComponent and @FacesConverter and @FacesValidator annotations already by default recognized; Vdlgen just extends them with the missing metadata.

Ultimately the .taglib.xml file will be read by Vdldoc to generate the VDL documentation (like this), so you only have to write those descriptions in one place. If you maintain your own component library, one should wonder whether this makes your life easier too :)

Changes

The <o:socket> web socket endpoint URL pattern has changed from /omnifaces.push/* to /omnifaces.socket/* because "push" is now not anymore exclusively for web sockets. If you have any web.xml security constraints or proxypass configurations on the old URL pattern, you need to update them!

The @Push annotation got a new type attribute which defaults to SOCKET. The existing @Inject @Push PushContext injection points continue to work unchanged.

The <o:socket> endpoint is now auto-registered when at least one @Inject @Push PushContext appears in the source code. You no longer need to configure the org.omnifaces.SOCKET_ENDPOINT_ENABLED context parameter for this.

SocketPushContextProducer has been deprecated and replaced by PushContextProducer.

Fixes

CombinedResourceHandler: was incompatible with mixed "use strict" scripts. When all scripts are "use strict" then it works fine but when at least one script is not "use strict", then it would break the entire script. So all "use strict" lines will now be stripped during combining. (#921)

@ViewScoped: unload threw IllegalArgumentException in Hacks#removeViewState() when session was already expired at same moment. It's now suppressed and logged as FINEST. (#937)

All 5.2.3 fixes are also available in 4.7.5 for Faces 3.0/4.0 and 3.14.16 for JSF 2.3.

AI assisted development

Large parts of this release were developed with the help of Claude Code. It was used as a pair programming partner for prototyping new components, refactoring shared code, writing javadocs, writing unit and integration tests, and backporting fixes across branches. All generated code was reviewed, tested, and adjusted by hand before committing. AI didn't design the features, it accelerated the implementation of decisions already made, reducing the estimated development time by more than half.

Monday, March 9, 2026

OmniFaces 5.1 released

OmniFaces 5.1 is released! This release brings two new additions and a handful of fixes.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnifaces</artifactId>
    <version>5.1</version>
</dependency>

As per What's new in OmniFaces? in the showcase:

New: <o:compositeConverter>

Have you ever needed to chain multiple converters on a single input? Until now you'd have to create a custom converter class that delegates to several others. With <o:compositeConverter> you can do it declaratively:

<h:inputText value="#{bean.value}">
    <o:compositeConverter converterIds="trimConverter, sanitizeConverter, entityConverter" />
</h:inputText>

The converterIds attribute takes a comma-separated list of converter IDs (registered via @FacesConverter or in faces-config.xml). The execution order follows a natural symmetry:

  • getAsObject (decode): executes converters left-to-right: 1st → 2nd → 3rd
  • getAsString (encode): executes converters right-to-left: 3rd → 2nd → 1st

The output of each converter is passed as the input to the next. This makes it trivial to compose small, single-responsibility converters without any glue code.

New: #{o:flagEmoji(countryCode)}

A small but a handy one. The new #{o:flagEmoji(countryCode)} EL function converts an ISO 3166-1 alpha-2 country code to its corresponding Unicode flag emoji:

#{o:flagEmoji('NL')}  →  🇳🇱
#{o:flagEmoji('US')}  →  🇺🇸
#{o:flagEmoji('BR')}  →  🇧🇷

The function is case-insensitive. It returns null for empty input (so EL will basically render it as empty string) and throws IllegalArgumentException for anything that is not a valid 2-letter alphabetic code.

Very useful whenever you want to display a country flag next to a locale selector, a phone prefix dropdown, or any other country-aware component. Never anymore fiddling with a boatload of flag icon files in your project or relying on a 3rd party flag icon library. You can now control it like a font.

Fixes

<o:validateMultiple>: The invalidateAll attribute was missing from the VDL documentation. Fixed. (#927)

CompressableResponseFilter: The IOException was being unnecessarily wrapped in UnsupportedOperationException before being rethrown, making it difficult to filter "connection reset" errors from server logs. It is now rethrown as-is. (#928)

<o:socket> on WildFly: The SocketEndpoint#onClose() failed to clean up socket sessions in memory and hence leaked memory (only on WildFly). This was a regression of #913 (introduced in 5.0 / 4.7.1 / 3.14.12 / 2.7.30). (#931)

All these fixes are also available in 4.7.2 / 3.14.13 / 2.7.31.

Friday, March 6, 2026

OmniHai goes online

OmniHai 1.3 is out. After 1.1 gave the library ears to transcribe audio and 1.2 gave it a voice to speak, 1.3 lets it step outside and look around. Web search is now a first-class citizen in the API, alongside token usage tracking, an AIServiceWrapper, and a handful of internal improvements.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnihai</artifactId>
    <version>1.3</version>
</dependency>

Web Search

AI models are great at reasoning over things they already know, but their knowledge has a cutoff date. Web search bridges this by allowing the model retrieve up-to-date information from the internet before formulating its answer. OmniHai 1.3 adds dedicated webSearch() method to AIService for exactly this purpose.

The simplest form is a single method call:

String answer = service.webSearch("What is the current stock price of Nvidia?");

That's it. The model searches the web, sources its answer, and returns a response based on current information rather than whatever it was trained on.

If you need results scoped to a specific geographic location, e.g. local news, weather forecasts, available restaurants, or store prices, then pass a Location:

Location miami = new Location("US", "Florida", "Miami");
String weather = service.webSearch("What is the weather like today?", miami);

Location takes a country code, region, and city, all optional. You can pass Location.GLOBAL if you want web search enabled but without any geographical restriction, which is also the default when you call the webSearch() method without location argument.

Structured Web Search Output

Like the regular chat() methods, webSearch() also supports typed responses. Define a record that represents the shape of the data you want, and pass it as class argument:

public record StockPrice(String ticker, BigDecimal price, String currencyCode) {}

StockPrice tsla = service.webSearch("What is the current stock price of Tesla?", StockPrice.class);

Here's another example:

public record Link(String title, String url) {}
public record Links(List items) {}

Links results = service.webSearch("Latest 5 news headlines about Jakarta EE", Links.class);
results.items().forEach(link -> System.out.println(link));

Example output:

Link[title=Java News Roundup: Jakarta EE 12, Spring Shell, Open Liberty ..., url=https://www.infoq.com/news/2026/02/java-news-roundup-jan26-2026]
Link[title=Jakarta EE 12 - The Eclipse Foundation, url=https://jakarta.ee/zh/release/12]
Link[title=Jakarta EE 12 M2 — Welcome to the Data Age, url=https://www.linkedin.com/pulse/jakarta-ee-12-m2-welcome-data-age-otavio-santana-zkgie]
Link[title=Jakarta EE 2025: a year of growth, innovation, and global engagement, url=https://blogs.eclipse.org/post/tatjana-obradovic/jakarta-ee-2025-year-growth-innovation-and-global-engagement]
Link[title=The Eclipse Foundation Releases the 2025 Jakarta EE Developer Survey Report, url=https://newsroom.eclipse.org/news/announcements/eclipse-foundation-releases-2025-jakarta-ee-developer-survey-report]

Of course, the webSearch() has also an async counterpart: webSearchAsync().

Web Search inside Chat

Sometimes you want web search as part of a larger chat flow rather than a standalone query. For those cases, you can use ChatOptions.Builder.webSearch() or webSearch(Location) methods, or the withWebSearch(location) copy method, so you can for example mix live web data into a memory-enabled conversation without leaving the chat API:

ChatOptions options = ChatOptions.newBuilder()
    .systemPrompt("You are a helpful travel assistant.")
    .withMemory()
    .webSearch()
    .build();

String response = service.chat("What are the current visa requirements for Dutch citizens visiting Japan?", options);
String followUp = service.chat("And what about travelling to South Korea from there?", options);

You can also derive a web-search-enabled or -disabled copy from an existing options instance without rebuilding from scratch:

ChatOptions withGlobalSearch = options.withWebSearch(Location.GLOBAL);
ChatOptions withLocalSearch = options.withWebSearch(new Location("CW", null, "Willemstad"));
ChatOptions withoutSearch = options.withWebSearch(null); // disables web search

Provider Support

Web search is supported on OpenAI (via the Responses API), Google AI, Anthropic (Claude 4 and later), xAI, Azure AI, and OpenRouter. OpenRouter handles this slightly differently from the rest: rather than a dedicated tool call, it activates web search by appending :online to the model name (e.g. openai/gpt-4o:online). OmniHai handles that detail internally with help of the new AIServiceWrapper decorator (more on this later); you just call webSearch() and it works. xAI always had it implicitly enabled when the model (Grok) realizes that the caller is asking for real time information (e.g. "current weather" or "current stock price"), so not really much of a change, except for that you can now force it to perform a web search when using non-obvious prompts. Mistral supports web search but only via a separate Agents API rather than the Chat Completions API, so OmniHai can't do much. If the underlying provider or model does not support web search, an UnsupportedOperationException is thrown, consistent with how other unsupported capabilities are handled in the library.

Token Usage Tracking

Every AI call costs tokens, and until now OmniHai gave you no visibility into how many. That changes in 1.3 with the introduction of ChatUsage, a record that reports the input, output, and reasoning token counts for each call:

ChatOptions options = ChatOptions.newBuilder()
    .systemPrompt("You are a helpful assistant.")
    .build();

String response = service.chat("Explain the visitor pattern in one paragraph.", options);
ChatUsage usage = options.getLastUsage();
System.out.printf("Tokens in: %d, out: %d, total: %d%n", usage.inputTokens(), usage.outputTokens(), usage.totalTokens());

reasoningTokens() is also available for providers and models that report internal thinking separately (such as OpenAI o-series, Anthropic extended thinking models, and Grok reasoning models). It is always a subset of outputTokens(), so totalTokens() does not add it separately to avoid double-counting. A value of -1 on any field means the AI provider did not report that number.

ChatUsage is stored on the ChatOptions instance itself, which brings up an important subtlety. The three shared constants ChatOptions.DEFAULT, ChatOptions.CREATIVE, and ChatOptions.DETERMINISTIC are immutable. If you wish to record usage on your instance, call copy() first to get a mutable instance with the same settings:

ChatOptions options = ChatOptions.DEFAULT.copy();
service.chat("Hello!", options);
ChatUsage usage = options.getLastUsage();

Any ChatOptions instance you build yourself via newBuilder() is always mutable and can track usage directly.

AIServiceWrapper

The new AIServiceWrapper is an abstract decorator base class that makes it straightforward to wrap any AIService implementation and intercept specific methods. All methods delegate to the wrapped service by default, so you only override what you actually care about.

A practical example is a provider fallback: try the primary service, and if it responds with a rate limit or is temporarily unavailable, then transparently retry on a backup provider instead of propagating the exception to the caller.

public class FallbackAIService extends AIServiceWrapper {

    private final AIService fallback;

    public FallbackAIService(AIService primary, AIService fallback) {
        super(primary);
        this.fallback = fallback;
    }

    @Override
    public CompletableFuture<String> chatAsync(ChatInput input, ChatOptions options) throws AIException {
        return super.chatAsync(input, options).exceptionallyCompose(completionException -> {
            var cause = completionException.getCause();

            if (cause instanceof AIRateLimitExceededException || cause instanceof AIServiceUnavailableException) {
                return fallback.chatAsync(input, options);
            }

            return CompletableFuture.failedFuture(completionException);
        });
    }
}

Wire it up by injecting two services and composing them:

@Inject @AI(apiKey = "#{keys.openai}")
private AIService gpt;

@Inject @AI(provider = ANTHROPIC, apiKey = "#{keys.anthropic}")
private AIService claude;

AIService resilient = new FallbackAIService(gpt, claude);
String response = resilient.chat("Explain the Jakarta EE security model.");

From the caller's perspective it is just an AIService. The fallback logic is entirely self-contained in the wrapper. You can add overloads for transcribe, generateImage, or any other method you want covered, or leave them delegating to the primary and let the caller handle those exceptions as normal.

Give It a Try

As always, feedback and contributions are welcome on GitHub. If you run into any issues, open an issue. Pull requests are welcome too.

Monday, March 2, 2026

OmniPersistence and OptimusFaces finally reach 1.0

After roughly ten years of 0.x releases, both OmniPersistence and OptimusFaces have finally reached 1.0. Both have been in active use in several production apps since 2015 (primarily with Hibernate). This post gives an refreshing overview of what they do and what went into the 1.0 releases.

OmniPersistence 1.0

OmniPersistence reduces boilerplate in the Jakarta Persistence layer. It provides a rich base service class, declarative soft-delete, field-level auditing, structured pagination with typed search criteria, and provider and database detection. It works with Hibernate, EclipseLink and OpenJPA on any Jakarta EE compatible runtime.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnipersistence</artifactId>
    <version>1.0</version>
</dependency>

It requires a minimum of Java 17 and Jakarta EE 10.

Entity model hierarchy

OmniPersistence ships with six base entity classes. Just pick the one that covers what your entity needs:

All four identity methods equals, hashCode, compareTo and toString default to the database ID. In case you want to base them on a business key instead, simply override identityGetters() once and all four are consistent:

@Entity
public class Phone extends GeneratedIdEntity<Long> {

    private Type type;
    private String number;
    private Person owner;

    @Override
    protected Stream<Function<Phone, Object>> identityGetters() {
        return Stream.of(Phone::getType, Phone::getNumber);
    }
}

BaseEntityService

Extend BaseEntityService<I, E> to get a full CRUD service for your entity. It works as an EJB or a CDI bean:

@ApplicationScoped // Or @Stateless if you're still on EJB
public class PersonService extends BaseEntityService<Long, Person> {
    // The @PersistenceContext is already injected. Nothing else needed.
}

Note: if you're still on EJBs, it might be wise to migrate to CDI, as development on the EJB spec has basically stalled and EJB is in long term going to be discommended in favor of CDI.

The inherited BaseEntityService API offers a lot of helpful shortcut methods for lookups, CRUD and JPQL:

// READ
Optional<Person> found = personService.findById(id);
Person person = personService.getById(id); // or null
List<Person> all = personService.list();
List<Person> subset = personService.list("WHERE e.verified = true");

// WRITE
Long id = personService.persist(newPerson);
Person updated = personService.update(person);
Person saved = personService.save(person); // persist-or-update
personService.delete(person);

Short JPQL fragments are auto-expanded to SELECT e FROM EntityName e <fragment>, so the alias e is always predefined.

Pagination with Page and search criteria

Page is an immutable value object that bundles offset, limit, ordering and search criteria. Pass it to getPage() and get back a PartialResultList that also carries the total count when requested:

Map<String, Object> criteria = new LinkedHashMap<>();
criteria.put("lastName", Like.contains("smith"));
criteria.put("age", Between.range(18, 65));
criteria.put("status", Not.value("BANNED"));
criteria.put("role", Role.ADMIN);

Page page = Page.with()
    .range(0, 10)
    .orderBy("lastName", true)
    .allMatch(criteria)
    .build();

PartialResultList<Person> result = personService.getPage(page, true); // true = include count as well
List<Person> list = result;
int count = result.getEstimatedTotalNumberOfResults(); // or -1 if count was not included

The available search criteria wrappers are:

  • Like - case-insensitive pattern matching (startsWith, endsWith, contains)
  • Between - range queries
  • Order - comparison operators (<, >, <=, >=)
  • Not - negation wrapper around any value or criteria
  • Bool, Numeric, Enumerated, IgnoreCase - specialised type handling
  • A plain value produces an exact equality predicate

Conditions can be grouped with allMatch() (AND) or anyMatch() (OR) and mixed freely.

In case you need stable performance on large datasets without SQL OFFSET, cursor-based (keyset) pagination is also available.

Page firstPage = Page.with().range(0, 10).build();
PartialResultList<Person> firstList = personService.getPage(firstPage, true);
Page secondPage = Page.with().range(firstList.getLast(), firstPage.getLimit(), false).build(); // false = not in reversed direction
PartialResultList<Person> secondList = personService.getPage(secondPage, true);

With large offsets this is much faster than e.g. Page hundredthPage = Page.with().range(990, 10).build();.

Soft delete and auditing

Mark a boolean field with @SoftDeletable and BaseEntityService will automatically exclude soft-deleted rows from all read methods and expose dedicated softDelete, softUndelete and listSoftDeleted methods:

@Entity
public class Comment extends GeneratedIdEntity<Long> {

    private String text;

    @SoftDeletable
    private boolean deleted;
}
commentService.softDelete(comment);
commentService.softUndelete(comment);
List<Comment> gone = commentService.listSoftDeleted();

Field-level change auditing fires a CDI event for every @Audit-annotated field that is modified during a transaction:

@Entity
@EntityListeners(AuditListener.class)
public class Config extends GeneratedIdEntity<Long> {

    private String key;

    @Audit
    private String value;
}
public void onAuditedChange(@Observes AuditedChange change) {
    log.info("{}.{}: {} -> {}",
        change.getEntityName(), change.getPropertyName(),
        change.getOldValue(), change.getNewValue());
}

Provider and database detection

OmniPersistence detects the active Jakarta Persistence provider and the underlying database at startup and makes them available via getProvider() and getDatabase() on BaseEntityService. This is used internally to emit provider-correct SQL, for example because string casting syntax and @ElementCollection pagination count subquery SQL syntax differs across providers and databases. In case you need it in your own service code, you can simply call them directly:

public void someMethod() {
    if (getProvider() == Provider.HIBERNATE) {
        // ...
    }

    if (getDatabase() == Database.POSTGRESQL) {
        // ...
    }
}

Supported databases as of 1.0: H2, MySQL, PostgreSQL, SQL Server and DB2.

What changed for OmniPersistence 1.0

The most impactful change is the decoupling from EJB. The initial 0.x versions required the service class to be a @Stateless EJB. It wasn't possible to make them an @ApplicationScoped CDI bean because some internals related to auditing (preupdate/postupdate) which rely on presence of EJB's SessionContext (in order to access the currently used EntityManager which could have been customized by the custom BaseEntityService implementation) would break. These internals have been improved in OmniPersistence 1.0 and you can finally freely choose between EJB or CDI.

The most visible API change is that Consumer<Map<String, Object>> parameters were replaced by direct Map<String, Object> parameters. The indirection was introduced to avoid the verbosity of new HashMap() at every call site. With Java 9's Map.of(), there is no longer a reason for it.

Other notable changes: explicit SQL Server and DB2 support was added to Database enum, compatibility with current versions of Hibernate, EclipseLink and OpenJPA was improved (in particular around @OneToMany and @ElementCollection pagination and filtering), and a comprehensive set of missing Javadocs and unit/integration tests was filled in.

OptimusFaces 1.0

OptimusFaces combines OmniFaces and PrimeFaces with OmniPersistence. The goal is to make it a breeze to create lazy-loaded, searchable, sortable and filterable <p:dataTable> based on a Jakarta Persistence model and a generic entity service.

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>optimusfaces</artifactId>
    <version>1.0</version>
</dependency>

Requires a minimum of Java 17, Jakarta EE 10 Web Profile, OmniFaces 4.0, PrimeFaces 15.0.0 and OmniPersistence 1.0.

PagedDataModel

PagedDataModel is the backing model for <op:dataTable>. It extends from PrimeFaces LazyDataModel. You create one in a CDI bean via the fluent builder:

@Named
@ViewScoped
public class Persons implements Serializable {

    private PagedDataModel<Person> model;

    @Inject
    private PersonService personService;

    @PostConstruct
    public void init() {
        model = PagedDataModel.lazy(personService)
            .criteria(this::getCriteria)
            .build();
    }

    private Map<Getter<Person>, Object> getCriteria() {
        Map<Getter<Person>, Object> criteria = new LinkedHashMap<>();
        criteria.put(Person::isActive, true);
        return criteria;
    }

    public PagedDataModel<Person> getModel() {
        return model;
    }
}

The criteria supplier is re-evaluated on every table interaction triggering LazyDataModel#load(), so backing bean fields can drive it dynamically from custom input fields outside the table (even though the <op:dataTable> already offers built-in filtering via searchable="true"). Here's an example:

private Map<Getter<Person>, Object> getCriteria() {
    Map<Getter<Person>, Object> criteria = new LinkedHashMap<>();

    if (searchName != null && !searchName.isBlank()) {
        criteria.put(Person::getName, Like.startsWith(searchName));
    }

    if (searchStatus != null) {
        criteria.put(Person::getStatus, searchStatus);
    }

    if (searchCreatedAfter != null) {
        criteria.put(Person::getCreated, Order.greaterThanOrEqualTo(searchCreatedAfter));
    }

    return criteria;
}

In case you already have a small list in memory, PagedDataModel.nonLazy(list) will apply sorting and filtering in-memory instead.

<op:dataTable> and <op:column>

Wire the model to the view with <op:dataTable> and declare columns with <op:column field="...">. Column ids, headers, sorting and filtering are all derived automatically from merely the field name:

<op:dataTable id="persons" value="#{persons.model}"
    searchable="true" exportable="true" selectable="true">

    <op:column field="firstName" />
    <op:column field="lastName" />
    <op:column field="dateOfBirth">
        <f:convertDateTime type="localDate" pattern="yyyy-MM-dd" />
    </op:column>
    <op:column field="address.city" />
    <op:column field="phones.number" />
    <op:column field="groups" filterMode="contains" />

</op:dataTable>

searchable="true" adds a global filter bar above the table. exportable="true" adds a column toggler and a CSV/PDF/XLSX export button. selectable="true" adds checkboxes and selected rows are available as model.getSelection().

The field attribute on <op:column> understands dot-notation across Jakarta Persistence relationship types. Thanks to the NestedBaseEntityELResolver address.city navigates a @ManyToOne, phones.number renders each element of a @OneToMany collection on a separate line, and groups on an @ElementCollection renders each element inline. Sorting and filtering on database side works for @ManyToOne and @OneToOne. For collections it depends on the Jakarta Persistence provider.

In case the generated cell output is not sufficient, you can fully customise it:

<op:column field="lastName">
    <ui:define name="cell">
        <h:link value="#{item.firstName} #{item.lastName}" outcome="person">
            <f:param name="id" value="#{item.id}" />
        </h:link>
    </ui:define>
</op:column>

Bookmarkability and stateless support

In plain PrimeFaces, a lazy <p:dataTable> requires a @ViewScoped backing bean. The reason is that the model instance needs to survive across postbacks, including its wrapped data. @ViewScoped achieves this by keeping the bean instance alive in the view map between requests, tracked by identifiers in the Jakarta Faces View State. When the bean is @RequestScoped the model is brand new on every request, its wrapped data is null at the point decode runs, and pagination, sorting and selection postbacks silently fail.

This also means that stateless Jakarta Faces views (<f:view transient="true">), which disable Jakarta Faces state saving entirely on a per-view basis, and therefore inherently break @ViewScoped beans and require @RequestScoped beans, are simply not an option when you need a paginable/sortable/filterable/selectable lazy PrimeFaces data table.

OptimusFaces solves this differently. After every table interaction, LazyPagedDataModel#updateQueryStringIfNecessary() collects the current table state and emits a JavaScript callback toOptimusFaces.Util.updateQueryString(). That function calls window.history.replaceState() to update the browser URL without a page reload and loops over all JSF forms to update the action URL. The URL now always reflects the exact current table state: page number, sort column, sort direction, active column filters and row selection.

On the next request, whether it is a postback, a browser refresh or a bookmarked URL, ExtendedDataTable takes over. It overrides preDecode() and detects that the lazy model has no wrapped data yet. Instead of letting decode fail, it calls LazyPagedDataModel#preloadPage(), which reads the table state back from the URL query string parameters and loads the correct page from the data store before decode proceeds. JSF state is never needed for this. The query string is the state.

The practical consequence is that <op:dataTable> works fully with @RequestScoped backing beans and stateless Jakarta Faces views, and the table is fully bookmarkable and shareable at the same time.

@Named
@RequestScoped // Works fine with both regular and stateless (transient) views.
public class Persons {

    private PagedDataModel<Person> model;

    @Inject
    private PersonService personService;

    @PostConstruct
    public void init() {
        model = PagedDataModel.lazy(personService).build();
    }

    public PagedDataModel<Person> getModel() {
        return model;
    }
}

Parameter names are derived from the table id. In case you have multiple tables on the same view, set a queryParameterPrefix on each to keep their parameters separate.

Ajax event marker classes

OptimusFaces uses predefined PFS (PrimeFaces Selectors) classes that components can use to opt in to automatic updates when specific table events fire:

  • updateOnDataTablePage - updated on every pagination change
  • updateOnDataTableSort - updated on sort
  • updateOnDataTableFilter - updated when the filter or global search changes
  • updateOnDataTableSelect - updated on row selection
<p:outputPanel styleClass="updateOnDataTableFilter">
    Found #{persons.model.rowCount} persons
</p:outputPanel>

What changed for OptimusFaces 1.0

Sort and filter metadata are now cached inside LazyPagedDataModel to avoid repeated map lookups on every render. SQL Server and DB2 were added to the integration test matrix. A TomEE 10.1.x/OpenWebBeans issue with <c:set scope="application"> inside column.xhtml (which caused a LinkageError on the second column inclusion due to a duplicate CDI proxy class definition) was fixed by removing the explicit scope from those tags. OpenJPA's pagination misbehaviour for @OneToMany fetch joins is now worked around with a postponed-fetch strategy that issues a secondary WHERE id IN (...) query instead of relying on a single JOIN FETCH that OpenJPA would incorrectly LIMIT before aggregating rows per entity.

Testing matrix

OptimusFaces runs its integration tests against three Jakarta Persistence providers:

  • Hibernate 7 as provided by WildFly
  • EclipseLink 5 as provided by GlassFish
  • OpenJPA 4 as provided by TomEE

And five databases:

  • H2 (embedded)
  • MySQL 8
  • PostgreSQL 15
  • SQL Server 2022
  • DB2 12

Not every combination of provider and database is exercised, but the 15-environment matrix each running 31 test cases on 19 XHTML files is wide enough to shake out a number of interesting provider-specific and database-specific bugs that would otherwise only surface in production.

A word on AI assistance

Getting both libraries to finally make it to 1.0 involved a lot of work that had been pending for a long time: missing Javadocs across both codebases, additional unit and integration tests, hardening against current versions of Hibernate, EclipseLink and OpenJPA, and fixing a number of subtle EclipseLink/OpenJPA-specific bugs around @OneToMany pagination, @ElementCollection filtering and nested correlated subqueries. I simply didn't want to release a 1.0 with halfbaked documentation or subtle EclipseLink/OpenJPA-specific bugs but I as being a Hibernate user never really got the time to get there so they stayed 0.x for fairly a long time. A substantial part of pending work was finally done with help of Claude Code.

AI-assisted development has come far enough to be genuinely useful for this kind of systematic, high-context work: completing patterns across many files, catching edge cases in tricky SQL generation code, and writing correct Javadoc for APIs as never seen before. Also the GitHub READMEs were thoroughly updated by Claude based on all it knows about the projects, including a section which carefully compares OmniPersistence to Jakarta Data.

Links

Monday, February 23, 2026

OmniHai finds its voice

OmniHai 1.2 is out. After 1.1 gave the library ears and the ability to transcribe audio, 1.2 completes the picture by giving it a voice. Text-to-Speech (TTS) joins the API alongside some improvements around file handling and conversation history to make life easier.

Text-to-Speech

Audio generation is now a first-class citizen alongside audio transcription. The simplest form is a single generateAudio() method call that returns raw audio bytes:

byte[] audio = service.generateAudio("Welcome to OmniHai 1.2!");

If you want to stream the result directly to a file without buffering it in memory, pass a Path instead:

service.generateAudio("Welcome to OmniHai 1.2!", Path.of("/path/to/welcome.mp3"));

Of course also available as async method: generateAudioAsync().

So far, only OpenAI and Google AI are supported. Other providers are not supported simply because they do not offer any HTTP based API endpoint yet (Anthropic, Mistral, Meta AI, Azure, OpenRouter, Ollama), or do not offer an unified API which is compatible with all TTS models (HuggingFace), or only expose it through WebSocket streaming (xAI), which does not fit the request/response model OmniHai is built on. Support will be added as providers introduce one.

Customization is available through the new GenerateAudioOptions builder, which exposes voice, speed, and output format. Here's an example for OpenAI GPT:

var options = GenerateAudioOptions.newBuilder()
    .voice("nova")
    .speed(1.25)
    .outputFormat("opus")
    .build();

gpt.generateAudio("Welcome to OmniHai 1.2!", Path.of("/path/to/welcome.opus"), options);

One thing worth mentioning about Google AI: Gemini returns raw PCM audio rather than a proper audio container. OmniHai transparently adds a WAV header before handing back the result, so callers get consistent, playable audio regardless of which provider is used.

Transcribing from a Path

The existing transcription API only accepted byte arrays, which meant loading the entire audio file into memory before making the API call. In 1.2, transcribe() and transcribeAsync() now also accept a Path:

String transcript = service.transcribe(Path.of("/recordings/meeting.mp3"));

For providers that support a files API the file is streamed directly from disk, no intermediate copy and no heap pressure. The byte array overload from 1.1 still works exactly as before.

Path-backed File Attachments

The same improvement extends to chat attachments. ChatInput.Builder#attach() now accepts Path arguments alongside the existing byte array support:

var input = ChatInput.newBuilder()
    .message("Summarize this contract.")
    .attach(Path.of("/path/to/contract.pdf"))
    .build();

var summary = service.chat(input);

MIME type detection still reads only the magic bytes rather than assuming it based on the file extension, and the upload itself streams the content from disk. For large PDFs or images this is a meaningful difference, and it requires no change to calling code beyond passing a path instead of a byte array.

History Initialisation

Conversation memory has always lived in ChatOptions, but there was no way to seed it with a prior exchange. In 1.2 the ChatOptions.Builder gains a history() method that accepts an existing message list:

// At the end of a session, persist the history
List<Message> saved = options.getHistory();

// On the next session, restore it
var options = ChatOptions.newBuilder()
    .systemPrompt("You are a helpful assistant.")
    .withMemory()
    .history(saved)
    .build();

var response = service.chat("Where were we?", options);

This makes it straightforward to persist a conversation to a database, load it back on the next session, and hand it straight to the service without any manual message reconstruction.

Getting 1.2

Add the following dependency to your project and you are ready to go:

<dependency>
    <groupId>org.omnifaces</groupId>
    <artifactId>omnihai</artifactId>
    <version>1.2</version>
</dependency>

Feedback and contributions are welcome on the GitHub repository.