This chapter covers deploying compiled RPG programs in production environments: web servers, containers, batch schedulers, and any other JVM host. The audience is the Java developer or DevOps engineer responsible for getting compiled programs running reliably outside the rpgc CLI.

A compiled program is an ordinary .class file that extends RpgProgram and runs on any JVM 17 or later. There is nothing IBM i-specific in the deployment - no native libraries, no special JVM flags, no OS dependencies. Everything in this chapter follows from that fact and from the runtime library's threading and lifecycle model.

Embedding in a Web Application

A compiled RPG program is stateful: its fields, indicators, and file cursors belong to the instance and change as it runs. A single instance cannot serve two requests concurrently. The safe pattern is: one program instance per request, one activation group per request, and an endJob() call when the request ends.

The simplest integration is a servlet filter that clears per-thread state at the end of every request, paired with an endpoint that creates and runs the program:

import ltd.whitehorn.rpg.runtime.RpgRuntime;

import javax.servlet.*;
import java.io.IOException;

/**
 * Clears RPG per-thread state at the end of every request so pooled
 * threads start clean. Place this filter before any RPG-calling servlet.
 */
public class RpgCleanupFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res,
                         FilterChain chain) throws IOException, ServletException {
        try {
            chain.doFilter(req, res);
        } finally {
            RpgRuntime.endJob();
        }
    }
}

An endpoint that runs a program:

import ltd.whitehorn.rpg.host.*;
import ltd.whitehorn.rpg.runtime.RpgProgram;

@WebServlet("/customers")
public class CustomerServlet extends HttpServlet {

    private RpgHost host;

    @Override
    public void init() {
        DataSource ds = /* look up your container-managed DataSource */;
        host = new RpgHost(ds);
    }

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp)
            throws IOException {
        ListCapture capture = new ListCapture();
        host.run(new CUSTBRW(), capture);

        resp.setContentType("application/json");
        // ... serialize capture.rows() to JSON
    }
}

The RpgHost is thread-safe and reusable across requests - build it once (typically at init time) and share it. Each run creates a fresh activation group, borrows a JDBC connection from the DataSource, runs the program, returns the connection, and calls endJob() internally. When you use RpgHost, the filter above is still good practice (it catches programs run outside the host on the same request), but the host itself does not leak state.

In a Spring application the same pattern applies as a @Bean and a HandlerInterceptor:

@Bean
public RpgHost rpgHost(DataSource dataSource) {
    return new RpgHost(dataSource);
}
public class RpgCleanupInterceptor implements HandlerInterceptor {
    @Override
    public void afterCompletion(HttpServletRequest req,
                                HttpServletResponse resp,
                                Object handler, Exception ex) {
        RpgRuntime.endJob();
    }
}

What endJob() does

RpgRuntime.endJob() reclaims all named activation groups on the current thread, then clears the per-thread activation-group registry. When a job ends, its activation groups are reclaimed and their resources are released. Per-request state (operational descriptors, DATA-INTO/DATA-GEN sessions, data areas, cached programs, and the JDBC connection) lives on the activation group and is cleaned up by the reclaim. In a server environment, thread pools reuse threads across requests; without endJob(), a subsequent request on the same thread would inherit the previous request's activation groups and all their state. Always call it at request boundaries.

Thread Safety

No program-visible state in the Triton RPG runtime is process-global. Named activation groups are scoped per thread via the activation-group registry; per-request data (operational descriptors, DATA-INTO/DATA-GEN sessions, data areas, cached programs) lives on the activation group instance itself. Two requests running on different threads do not share activation-group state, so neither needs a synchronized block or external lock around ordinary program execution. Objects you deliberately share between activation groups - a DataSource, a data-queue provider - remain your responsibility.

What is not thread-safe is a single program instance. An RpgProgram holds its fields, indicators, and I/O state as instance data - running one instance from two threads simultaneously corrupts its state, just as calling it simultaneously from two jobs would if it were possible. The rule is simple:

A compiled program never calls System.exit() - reaching *INLR = *ON or falling through its mainline simply returns from run(), so a program embedded in an application server or servlet container cannot bring the process down. Two shipped entry points do exit deliberately: HeadlessBridge's main(), and the File → Exit item on the GuiScreenHandler window. Do not wire GuiScreenHandler into a hosted process.

Container Deployment (Docker)

A compiled program is pure Java with no native dependencies. Any JVM 17+ base image works - there is no platform-specific code, no JNI, and no filesystem layout requirement beyond the classpath.

Classpath layout

A minimal deployment needs three things on the classpath:

Artifact What it is
Your compiled .class files The output of rpgc
triton-rpg.jar The Triton RPG runtime library (ships with the compiler)
A JDBC driver jar Required only if the program uses embedded SQL or disk file I/O

If the program uses display files with the terminal screen handler, jline-3.25.1.jar is also required. In a headless deployment (web service, batch), the program is driven through RpgHost and JLine is not needed.

Example Dockerfile

FROM eclipse-temurin:17-jre-alpine

WORKDIR /app

# Runtime library and JDBC driver
COPY lib/triton-rpg.jar lib/
COPY lib/postgresql-42.7.1.jar lib/

# Compiled RPG programs
COPY out/ classes/

# Your Java host application
COPY target/my-app.jar app.jar

# JDBC credentials come from the orchestrator, never baked into the image
ENV JDBC_URL=""
ENV JDBC_USER=""
ENV JDBC_PASSWORD=""

ENTRYPOINT ["java", "-cp", "app.jar:lib/*:classes", "com.example.MyApp"]

Replace the PostgreSQL driver with whichever JDBC driver your database requires. The key point is that credentials are injected as environment variables at container start time, never written into the image.

Health and readiness

Compiled programs do not open listening sockets or manage their own lifecycle - your host application does. A Spring Boot or Micronaut app provides its own health endpoint; a batch container reports health through its exit code. There is nothing RPG-specific to wire into a health check.

Connection Pooling

How a program gets its JDBC connection depends on how it is run.

Standalone (main())

When a compiled program runs from its own main() - the default output of rpgc without --lib - it reads JDBC credentials from environment variables (JDBC_URL, JDBC_USER, JDBC_PASSWORD, JDBC_DRIVER by default; overridable with --jdbc-url-env and friends at compile time). The program opens one connection at startup and closes it when it ends. There is no pool.

Embedded (RpgHost)

When you run a program through RpgHost, you supply a javax.sql.DataSource. The host borrows a connection from that data source for each run and returns it when the run completes. This is where connection pooling lives:

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;

HikariConfig config = new HikariConfig();
config.setJdbcUrl(System.getenv("JDBC_URL"));
config.setUsername(System.getenv("JDBC_USER"));
config.setPassword(System.getenv("JDBC_PASSWORD"));
config.setMaximumPoolSize(20);

DataSource pool = new HikariDataSource(config);
RpgHost host = new RpgHost(pool);

Any DataSource implementation works: HikariCP, Apache DBCP, Tomcat JDBC, or a container-managed JNDI data source. The runtime calls dataSource.getConnection() at the start of each run and connection.close() in a finally block at the end - standard JDBC resource management that every pool expects.

Sizing the pool

Each program run holds one connection for its entire duration. For a screen-driven program run through a capture, that duration is typically milliseconds. For a batch program that processes a large result set, it may be longer. Size your pool to the expected concurrency: if twenty requests may run programs simultaneously, the pool needs at least twenty connections.

Activation Groups in Production

An activation group is the scope boundary for state shared across cooperating programs within a job: the SQL connection and its commitment boundary, named data areas, and file overrides. On the JVM, RpgActivationGroup is the equivalent.

The default: one per program

When you construct a program with new MYPGM(), it gets its own private activation group. This is the right default for most deployments - each program is isolated, and there is nothing to share or clean up beyond what endJob() handles.

Named activation groups: sharing state across programs

When two programs must share a transaction boundary or data areas - they use a named activation group. The runtime scopes named activation groups per thread, so a name like 'ORDERENTRY' on thread A is a different instance from 'ORDERENTRY' on thread B:

// Both programs share one connection and one commitment boundary
// on this thread - a COMMIT in either commits both.
RpgActivationGroup ag = RpgRuntime.getNamedActivationGroup("ORDERENTRY");
ag.setConnection(rpgConnection);

ORDERHDR hdr = new ORDERHDR();
hdr.setActivationGroup(ag);
hdr.run();

ORDERDTL dtl = new ORDERDTL();
dtl.setActivationGroup(ag);
dtl.run();

Use named activation groups when you need:

Do not share an activation group across threads. The model is one job, one thread of RPG execution; the per-thread scoping enforces this. Passing an activation group to a program on a different thread produces undefined behavior.

Identity: user profile, job name, and job number

On IBM i, programs report their job user, job name, and job number through PSDS subfields. These identities have no JVM equivalent - the OS login of the JVM process is unrelated to the logical RPG user. An activation group defaults to blank for all three. An embedding host sets them to the authenticated session user:

RpgActivationGroup ag = new RpgActivationGroup();
ag.setUserProfile("JSMITH");
ag.setJobName("WEBAPP");
ag.setJobNumber("123456");

These values flow into the PSDS of every program bound to that activation group, so any RPG code that reads its user profile sees the value the host supplied.

Observability

Program output

A program's DSPLY output is routed through the program's ConsoleIo interface, not hard-wired to System.out. When a program runs under RpgHost, all DSPLY messages are collected on the RpgRunResult:

RpgRunResult result = host.run(program);
for (String msg : result.messages()) {
    logger.info("RPG DSPLY: {}", msg);
}

This keeps RPG output out of the container's stdout unless you explicitly route it there.

Error reporting

A program that encounters a runtime error sets %STATUS to the appropriate status code and, depending on the error monitor in effect, either resumes (if the RPG source has an error handler) or throws an RpgStatusException. The exception carries the status code and the operation that failed:

try {
    host.run(program, capture);
} catch (RpgHostException e) {
    // Budget exceeded, capture aborted, or argument mismatch
    logger.error("Host error: {}", e.getMessage());
} catch (RpgStatusException e) {
    // Unhandled RPG runtime error (e.g., divide by zero, record not found)
    logger.error("RPG status {}: {}", e.status(), e.getMessage());
}

Metrics and tracing

The runtime does not include a metrics or tracing framework. Your host application provides observability through whatever library it already uses (Micrometer, OpenTelemetry, etc.). Instrument around the host.run() call:

Timer.Sample sample = Timer.start(registry);
try {
    host.run(program, capture);
} finally {
    sample.stop(registry.timer("rpg.program.run", "program", "CUSTBRW"));
}

Security

Credential handling

JDBC credentials are supplied through environment variables, never on the command line and never embedded in source or compiled output. The runtime reads them at connection time and does not log or persist them. In a container deployment, inject them through the orchestrator's secret management (Kubernetes secrets, Docker secrets, AWS Secrets Manager, etc.).

SQL injection

Host variables in embedded SQL (EXEC SQL) are bound as parameters on a PreparedStatement, never interpolated into the query text, so a static EXEC SQL statement carries no injection risk from its host variables.

Dynamic SQL is the exception, and it is the program's responsibility. A statement your program assembles at run time and issues with PREPARE or EXECUTE IMMEDIATE is sent as your program built it: if it concatenates untrusted input into that text, the result is injectable. Use parameter markers (?) with PREPARE, and bind the values, rather than building the text.

No inbound network surface

A compiled program opens no listening sockets and adds no inbound network surface to its host. Its only outbound connection is the JDBC connection its own SQL and file I/O require - supplied by the caller when embedded, or opened from the documented environment variables when it runs standalone. The screen handler and console are always injected.

Request isolation

All per-request state is scoped to the program instance and its activation group. No program-visible state is shared between requests, so one program cannot reach another request's data. Thread confinement is enforced by the per-thread activation-group registry, not by the caller remembering to synchronize.

Cryptography and authentication

The runtime does not perform authentication or cryptography. These are the host application's responsibility. Licensing is enforced by the rpgc compiler at compile time; compiled programs carry no licence check and add no runtime dependency on one.