TRIP over QUIC — the tentackle-quic module¶
Overview¶
tentackle-quic adds a QUIC (RFC 9000) transport to
TRIP, Tentackle's Remote
Invocation Protocol. It lets PDOs and remote calls travel between JVMs over UDP
instead of TCP, while reusing all of TRIP's serialization, dictionary, registry,
and remote-proxy machinery unchanged.
QUIC is a modern, UDP-based transport that bundles features TCP-based stacks have to assemble from several layers:
- Built-in TLS 1.3 — encryption and authentication are part of the protocol, not an optional layer on top.
- Stream multiplexing without head-of-line blocking — many independent bidirectional streams share a single connection; a lost packet on one stream does not stall the others. This maps naturally onto TRIP's connection-pool model, where each pooled connection is one QUIC stream.
- Faster connection establishment — the TLS handshake is folded into the transport handshake, saving round-trips compared to TCP+TLS.
- Connection migration — a QUIC connection survives the client changing its IP address or port (e.g., switching networks).
The module is a thin adapter: it wires the KWIK
QUIC library (tech.kwik.core) into TRIP's pluggable transport SPI. No
application code changes are required to use it — selecting QUIC is purely a
matter of the connection URI scheme.
- Module:
org.tentackle.quic - Maven artifact:
org.tentackle:tentackle-quic - Protocol schemes:
tripq(plain QUIC),tripcq(QUIC + compression) - License: LGPL 2.1
Two QUIC implementations¶
Tentackle ships QUIC twice. tentackle-quiche
is this module's sibling: the same transport, but built on
Cloudflare quiche — the Rust implementation — bound through
the JDK's FFM API instead of on KWIK. Both speak RFC-9000 and interoperate: a KWIK client can call
a quiche server and vice versa, which QuicheKwikInteropTest verifies on every build.
tentackle-quic (KWIK) |
tentackle-quiche (Cloudflare quiche) |
|
|---|---|---|
| Implementation | pure Java | Rust, called through FFM |
| Platforms | anywhere a JVM runs | linux/macOS/Windows on x86-64 and aarch64 |
| Extra JVM flags | none | --enable-native-access |
| Schemes | tripq, tripcq |
tripj, tripcj |
Start here. Reach for tentackle-quiche when you want the reference implementation's congestion control
and packet processing, or when you are already shipping native code anyway. The two register different
schemes, so nothing stops an application from deploying both and letting each client pick.
How it fits into TRIP¶
TRIP's network layer is fully pluggable behind the Transport interface, and
transports advertise themselves through the @TransportService annotation
(see Writing a Custom Transport).
tentackle-quic is a complete, real-world implementation of that SPI built on
AbstractTransport — the base class intended for adding a protocol from scratch.
Because the transport is discovered by URI scheme, the rest of the framework is unaware of QUIC. A client obtains a QUIC transport exactly like any other:
// Client — talk to a server over QUIC
Transport transport = TransportManager.getInstance().requestTransport(
URI.create("tripq://server.example.site:9090?ksfile=keystore.p12&kspass=secret&ksalias=server"));
// Server — accept incoming QUIC connections
Transport serverTransport = TransportManager.getInstance().requestTransport(
URI.create("tripq://0.0.0.0:9090?ksfile=keystore.p12&kspass=secret&ksalias=server"));
serverTransport.accept();
Why a separate module?¶
QUIC support is deliberately not baked into tentackle-core. Pulling in the
KWIK library (and its transitive dependencies) is only worthwhile for
applications that actually use QUIC. The module therefore:
- depends on
tentackle-coreas anoptionaldependency, so it does not alter the dependency chain of projects that merely use TCP transports, and - is not
requiresd by any other Tentackle module. It plugs itself in purely via the@TransportService-annotated transport classes, discovered at runtime through the service index.
To activate QUIC, an application simply needs tentackle-quic (and tech.kwik.core)
on the module/class path; the transports register themselves automatically.
Architecture¶
The module consists of a handful of classes that mirror TRIP's transport contract. Server and client responsibilities are clearly separated.
QuicTripTransport (@TransportService "tripq")
/ \
server: accept() client: createClientConnectionPool()
| |
ServerConnector (KWIK) QuicTripConnectionPool
| |
QuicProtocolConnectionFactory QuicClientConnection (KWIK)
| |
QuicProtocolConnection createStream(true) -> QuicStream
acceptPeerInitiatedStream(s) |
| QuicTripConnection
QuicStream -> createConnectionHandler (wraps a TripStream)
| Class | Role | Side |
|---|---|---|
QuicTripTransport |
The Transport implementation; scheme tripq. Starts the KWIK server connector and creates the client pool. |
both |
CompressedQuicTripTransport |
Subclass adding deflate compression; scheme tripcq. |
both |
QuicTripConnectionPool |
Opens one QuicClientConnection and creates a pooled QuicTripConnection (one QUIC stream) per slot. |
client |
QuicTripConnection |
An AbstractConnection wrapping a single bidirectional QuicStream as a TripStream. |
client |
QuicProtocolConnectionFactory |
KWIK ApplicationProtocolConnectionFactory; creates a protocol connection per incoming QUIC connection. |
server |
QuicProtocolConnection |
Accepts peer-initiated QUIC streams and dispatches each to a TRIP connection handler on a virtual thread. | server |
QuicLogger |
Bridges KWIK's logging API onto Tentackle's Logger. |
both |
service.Hook |
ModuleHook for resource-bundle resolution within the module. |
both |
Server side¶
QuicTripTransport.accept() builds and starts a KWIK ServerConnector:
- TLS keystore — QUIC mandates TLS, so a server certificate is required. The
keystore, certificate alias and password are resolved by
SslParameters— the very same configuration the TCP-basedtrips/tripcstransports use — and the loaded keystore is handed to the connector viawithKeyStore(keyStore, alias, password). This means thetentackle.ssl.*system-property fallbacks and the single-entry keystore alias auto-selection apply to QUIC too (see below). - Connection config —
maxOpenPeerInitiatedBidirectionalStreamsis set from themaxstreamsparameter (default 128), capping how many concurrent streams a single client may open. The flow control windows are set fromstreamwindow(see Flow control). - Bind address — the host/port come from the URI. A host of
"0"binds all interfaces (IPv4 and IPv6) viawithPort(port); any other host binds a specificDatagramSocket. - ALPN protocol handler —
registerApplicationProtocol(alpnId, factory)registers aQuicProtocolConnectionFactory. The ALPN id defaults to the URI scheme but can be overridden withalpnid.
When a client opens a stream, KWIK calls
QuicProtocolConnection.acceptPeerInitiatedStream(QuicStream). The stream is
handed to transport.createConnectionHandler(...) and run on a virtual thread
from the transport's connection thread pool — exactly the same handler path used
by every other TRIP transport, so call dispatch, registry lookups, and serialization
behave identically. The stream is closed in a finally block when the handler
returns.
The factory declares no unidirectional streams
(maxConcurrentPeerInitiatedUnidirectionalStreams() == 0, since TRIP is
request/response over bidirectional streams) and effectively unlimited
bidirectional streams (Integer.MAX_VALUE), with the real cap enforced by the
maxstreams server config above.
Client side¶
QuicTripConnectionPool is created lazily by AbstractTransport on first use:
- It opens a single
QuicClientConnectionto the URI's host and port, using the ALPN id (alpnid, default = scheme). - By default, server-certificate checking is disabled
(
noServerCertificateCheck()), matching TRIP's design assumption that the transport runs on a trusted, non-public network. Setcertcheck=trueto enforce certificate validation. - Establishing that connection is bounded by
connecttimeout(default 10 seconds). Since QUIC runs over UDP, an unreachable or unresponsive server produces no answer at all, so the client waits for the full timeout before failing with aTripRuntimeException. Lower it when a client should fail over quickly rather than wait. - Each pool slot (
createSlot) opens a new bidirectional QUIC stream (connection.createStream(true)) and wraps it in aQuicTripConnection.
So one QUIC connection carries many TRIP connections, each being one QUIC
stream — taking direct advantage of QUIC's multiplexing without head-of-line
blocking. Pool sizing (initial, increment, minimum, maximum, idle,
usage) follows the standard TRIP connection-pool parameters read from the URI
query string, with one QUIC-specific difference: maximum defaults to
maxstreams rather than to unlimited. See Stream limit.
shutdown() closes the underlying QuicClientConnection, tearing down all of its
streams at once.
Stream-to-TripStream bridging¶
The single integration point with TRIP serialization is createStream:
public TripStream createStream(Dictionary dictionary, QuicStream quicStream) {
TripFactory factory = TripFactory.getInstance();
return factory.createStream(dictionary,
factory.createSerializer(quicStream.getOutputStream()),
factory.createDeserializer(quicStream.getInputStream()));
}
A QuicStream exposes ordinary InputStream/OutputStream objects; wrapping
them in a TRIP Serializer/Deserializer is all that is needed to run the full
TRIP protocol — dictionary compression, back-references, deduplication, and remote
proxying — over QUIC. The streams handed to TRIP are the KWIK ones wrapped by
inputStream/outputStream, see Interrupts.
Interrupts¶
Everything a remote invocation blocks on here — the handshake, opening a pooled stream, and the stream reads and writes — is interrupt-agnostic, exactly like the blocking socket streams of the TCP transports: an interrupt of the calling thread neither aborts the operation nor gets lost. It is withheld for the duration of the call and re-asserted before returning.
This is not a detail. Tentackle's task dispatchers
interrupt their own thread whenever work is queued, to cut a pending sleep
short — see DefaultTaskDispatcher.interrupt(). Such an interrupt regularly lands
while the thread is inside a remote invocation (DbModificationTracker polls from
its dispatcher thread), and a transport that treated it as a cancellation would tear
down a perfectly healthy TRIP connection.
KWIK itself does not cooperate: its connect() aborts the handshake and throws a
bare RuntimeException, createStream reports a TimeoutException, a write waiting
for flow control credits fails with an InterruptedIOException, and a blocked read
consumes the interrupt without restoring it. Hence
connect()runs KWIK's handshake on a thread of its own and waits for it with an absolute deadline (connecttimeoutplusCONNECT_TIMEOUT_GRACE),createQuicStreamdoes the same forcreateStream(which it has to do anyway to bound the wait for stream credits, see Stream limit),- and
InterruptAgnosticStreamskeeps the interrupt status away from KWIK's stream operations, restoring it once they return.
An interrupt that arrives while KWIK is already blocked is still KWIK's to handle,
which no wrapper can change. The tentackle-quiche
transport does not have that limitation because it implements the blocking streams itself.
Compression: tripcq¶
CompressedQuicTripTransport (scheme tripcq) extends QuicTripTransport and
layers Tentackle's streaming deflate compression on top of every QUIC stream, on
both the server and client paths:
createCompressedOutputStreamwraps the QUIC output in aFastCompressedOutputStream. Themincompressparameter (default 1024 bytes) sets the threshold below which a block is sent uncompressed, avoiding the overhead of compressing tiny payloads.createCompressedInputStreamwraps the QUIC input in aCompressedInputStream.
Both createConnectionHandler (server) and createStream (the TripStream bridge)
are overridden to insert the compression streams, so client and server agree on
the wire format. Use tripcq when payloads are large and bandwidth is the
bottleneck; use plain tripq when payloads are small or already compact.
Connection URI parameters¶
All configuration is carried in the connection URI's query string. The same URI
shape is used on both client and server (with host 0 / 0.0.0.0 selecting the
server bind-all case).
tripq://<host>:<port>?ksfile=...&kspass=...&ksalias=...[&kstype=...][&alpnid=...][&maxstreams=...][&streamwindow=...][&certcheck=...][&connecttimeout=...][&streamtimeout=...][&quicidle=...][&keepalive=...]
tripcq://<host>:<port>?...same as tripq...&mincompress=1024
The keystore parameters (ksfile, kspass, ksalias, kstype) are resolved by the shared
SslParameters, so each
also honors a tentackle.ssl.<name> system property and, as a fallback, the standard
javax.net.ssl.<name> property. See that table for the full precedence rules.
| Parameter | Side | Default | Description |
|---|---|---|---|
ksfile |
server | tentackle.ssl.keyStore / javax.net.ssl.keyStore |
Path to the TLS keystore file. Required (here or via a system property). |
kspass |
server | tentackle.ssl.keyStorePassword / javax.net.ssl.keyStorePassword |
Keystore password; also used as the key password unless kmpass is set. |
ksalias |
server | the sole key entry, if the keystore has exactly one | Alias of the server certificate within the keystore. Required only for multi-entry keystores. |
kstype |
server | tentackle.ssl.keyStoreType / javax.net.ssl.keyStoreType / JDK default |
Keystore type, e.g. PKCS12 or JKS. |
kmpass |
server | the keystore password (kspass) |
Private key password, if it differs from the keystore password. |
alpnid |
both | the URI scheme (tripq / tripcq) |
ALPN application-protocol identifier. Client and server must match. |
maxstreams |
server | 128 |
Maximum concurrent peer-initiated bidirectional streams per connection. |
streamwindow |
both | 250000 |
Flow control window per stream in bytes; must be >= 1500. See below. |
certcheck |
client | false |
When true, the client validates the server certificate. When false, validation is skipped. |
connecttimeout |
client | 10 |
Seconds to wait for the QUIC handshake before giving up. Must be > 0. |
streamtimeout |
client | 10 |
Seconds to wait for the peer to grant the credits to open another stream. Must be > 0. See below. |
quicidle |
both | 30 |
QUIC max idle timeout in seconds. See below. |
keepalive |
client | unlimited | Seconds to keep the QUIC connection alive. 0 disables the keep-alive. See below. |
mincompress |
both (tripcq) |
1024 |
Minimum block size in bytes before deflate compression is applied. |
On the server, if the keystore file (
ksfile) or the certificate alias (ksalias, unless the keystore holds a single key entry) cannot be resolved,accept()throws aTripRuntimeException. The port must be in the range0..65535.
Idle timeout and keep-alive¶
QUIC connections are not kept open indefinitely: according to
RFC-9000, 10.1, both peers
silently discard a connection that stays idle for longer than the minimum of the two peers'
max_idle_timeout values — without notifying the application. quicidle configures that
timeout on both ends, so the effective value is well-defined rather than being the minimum of two
differing library defaults.
Note that this has nothing to do with the connection pool's idle parameter, which is given in
minutes and is always much longer. All pooled connections are streams multiplexed over a single
QUIC connection, so the pool would keep handing out connections belonging to a QUIC connection that
is long gone. To prevent that, the client keeps the QUIC connection alive by sending PING frames,
for as long as the pool exists. Set keepalive=0 to disable this, for example, when the connection
is short-lived anyway, or keepalive=<seconds> to limit it. Should the QUIC connection be lost
nevertheless (network loss, peer restart, keepalive=0), the pool drops the dead connections and
re-establishes the QUIC connection on the next request. The connection it replaces is closed
explicitly: KWIK stops a connection's keep-alive actor when the connection is closed, but not when it
is terminated silently, and that actor runs on a non-daemon thread that would otherwise keep the JVM
from terminating.
The corresponding pool parameters (initial, increment, minimum, maximum, idle, usage)
are described in trip.md.
Stream limit¶
A peer may only open as many concurrent streams as the other end grants it
(RFC-9000, 4.6), and
maxstreams configures that limit. Fresh credits are granted only when a stream is closed — but
every pooled connection holds its stream for as long as it sits in the pool, so a busy client walks
straight into the limit. KWIK's createStream does not fail there, it waits, and it waits with the
connection pool's and the transport's monitor held. No other thread could return a connection to
free a stream, so the wait would never end, and the whole client transport would be stuck.
Two things prevent this. The pool's maximum defaults to maxstreams instead of unlimited, so the
pool stops enlarging itself before it reaches the limit and requests fail with a temporary
TripRuntimeException ("max. pool size reached") — the caller can retry, and the transport stays
usable. And should the peer nevertheless grant fewer streams than configured here — the client cannot
detect a maxstreams mismatch — streamtimeout bounds the wait, again with a temporary exception.
Raising maximum above maxstreams therefore only makes sense if the server grants more.
Flow control¶
QUIC limits how much data a peer may send before the receiving side grants further credits
(RFC-9000, 4), both per stream and per
connection. streamwindow configures the per-stream window on both ends — on the server it bounds
the size of an incoming remote call, on the client the size of the response — and the connection window
is ten times that value.
Both ends are configured explicitly, because KWIK's defaults are asymmetric: its client advertises 250kB per
stream, while its server falls back to the 5kB minimum of ApplicationProtocolSettings unless the buffer
sizes are set. A call larger than the window still succeeds, but has to squeeze through it in stop-and-go
fashion, one credit update per window, which costs a round-trip each time. The default is therefore chosen
to cover ordinary calls in full, and only needs raising when calls routinely carry payloads larger than it.
Do not size the connection window down to the stream window. Both limits would then be exhausted at the very same offset, and since KWIK never retransmits a lost
MAX_DATAframe and ignores an incomingDATA_BLOCKEDframe, a dropped datagram carrying that update would wedge the stream permanently: the receiver waits for data the sender is not allowed to send. Keeping the connection window a multiple of the stream window makes the stream limit the binding one, andMAX_STREAM_DATAis retransmitted.Mind that the connection window is shared by all streams of the connection, so this only holds as long as fewer than ten streams transfer at full window simultaneously; beyond that the connection limit can become binding after all. The factor cannot simply be raised to cover all
maxstreamsstreams, because it has to match on both ends and KWIK's client hardcodes exactly this factor indefaultStreamReceiveBufferSize(), with no way to configure it. Sizingstreamwindowso that ordinary calls fit into it keeps the number of streams blocked on flow control — and with it the exposure — small.
Build and packaging¶
The module declares its dependencies as:
<dependency>
<groupId>org.tentackle</groupId>
<artifactId>tentackle-core</artifactId>
<optional>true</optional> <!-- don't modify the dependency chain! -->
</dependency>
<dependency>
<groupId>tech.kwik</groupId>
<artifactId>kwik</artifactId>
</dependency>
JPMS descriptor¶
module org.tentackle.quic {
exports org.tentackle.quic;
opens org.tentackle.quic to org.tentackle.core; // TRIP instantiates the transport reflectively
requires transitive tech.kwik.core;
requires transitive org.tentackle.core;
provides org.tentackle.common.ModuleHook with org.tentackle.quic.service.Hook;
}
The package is opensed to org.tentackle.core because TRIP instantiates the
transport reflectively through its (URI) constructor when resolving the URI
scheme.
Choosing a QUIC scheme¶
| Scheme | Transport | Encryption | Compression | When to use |
|---|---|---|---|---|
tripq |
UDP / QUIC | TLS 1.3 (built in) | no | Lossy or high-latency networks; many concurrent streams; mobile clients that change networks. |
tripcq |
UDP / QUIC | TLS 1.3 (built in) | deflate | As tripq, when payloads are large and bandwidth-bound. |
Compared with the TCP-based schemes (trip, trips, tripe, tripc, …), QUIC
provides TLS without a separate handshake layer and avoids TCP head-of-line
blocking across the many pooled connections a busy client maintains. The trade-off
is a UDP-based stack and the additional KWIK dependency, which is why it ships as
its own optional module.
The tripj and tripcj schemes of
tentackle-quiche are the same two choices on
the quiche stack, and they talk to tripq/tripcq peers — the scheme selects the local implementation,
not the wire protocol. The compression of tripcq/tripcj, on the other hand, must match on both ends.
See also¶
- TRIP — Tentackle Remote Invocation Protocol — the protocol this transport plugs into, including the custom-transport guide.
- TRIP over Cloudflare quiche — the sibling module providing the same transport on the quiche stack, and how the two compare.
- KWIK — the underlying QUIC implementation
(
tech.kwik.core). - RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport.