Skip to content

Commit f4cd338

Browse files
authored
feat: Alternative TLS graph stage implementation (#32914)
1 parent e2d37de commit f4cd338

12 files changed

Lines changed: 1529 additions & 40 deletions

File tree

1.31 KB
Binary file not shown.
637 Bytes
Binary file not shown.
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
/*
2+
* Copyright (C) 2025 Lightbend Inc. <https://www.lightbend.com>
3+
*/
4+
5+
package akka.stream.io
6+
7+
import java.security.KeyStore
8+
import java.security.SecureRandom
9+
import java.util.concurrent.CountDownLatch
10+
import java.util.concurrent.TimeUnit
11+
import javax.net.ssl._
12+
13+
import scala.collection.immutable
14+
import scala.concurrent.Await
15+
import scala.concurrent.duration._
16+
17+
import com.typesafe.config.ConfigFactory
18+
import org.openjdk.jmh.annotations._
19+
20+
import akka.actor.ActorSystem
21+
import akka.stream._
22+
import akka.stream.TLSProtocol._
23+
import akka.stream.scaladsl._
24+
import akka.util.ByteString
25+
26+
object TlsBenchmark {
27+
28+
val config = ConfigFactory.parseString("""
29+
akka {
30+
loglevel = "WARNING"
31+
actor.default-dispatcher {
32+
throughput = 1024
33+
}
34+
actor.default-mailbox {
35+
mailbox-type = "akka.dispatch.SingleConsumerOnlyUnboundedMailbox"
36+
}
37+
}""".stripMargin).withFallback(ConfigFactory.load())
38+
39+
private val password = "changeme".toCharArray
40+
41+
def initSslContext(): SSLContext = {
42+
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType)
43+
keyStore.load(classOf[TlsThroughputBenchmark].getResourceAsStream("/tls-bench/keystore"), password)
44+
val trustStore = KeyStore.getInstance(KeyStore.getDefaultType)
45+
trustStore.load(classOf[TlsThroughputBenchmark].getResourceAsStream("/tls-bench/truststore"), password)
46+
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm)
47+
kmf.init(keyStore, password)
48+
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm)
49+
tmf.init(trustStore)
50+
val ctx = SSLContext.getInstance("TLS")
51+
ctx.init(kmf.getKeyManagers, tmf.getTrustManagers, new SecureRandom)
52+
ctx
53+
}
54+
55+
def mkEngine(sslContext: SSLContext, role: TLSRole): SSLEngine = {
56+
val e = sslContext.createSSLEngine()
57+
e.setUseClientMode(role == Client)
58+
e
59+
}
60+
61+
/** Client TLS atop reversed server TLS with an echo flow that bounces
62+
* decrypted SessionBytes back as SendBytes. */
63+
def tlsEcho(sslContext: SSLContext, closing: TLSClosing): Flow[SslTlsOutbound, SslTlsInbound, akka.NotUsed] = {
64+
val echo = Flow[SslTlsInbound].collect { case SessionBytes(_, bytes) => SendBytes(bytes) }
65+
TLS(() => mkEngine(sslContext, Client), closing)
66+
.atop(TLS(() => mkEngine(sslContext, Server), closing).reversed)
67+
.join(echo)
68+
}
69+
}
70+
71+
// ==================== 1. Throughput ====================
72+
73+
/**
74+
* Measures sustained byte throughput through a TLS loopback (client bidi
75+
* atop server bidi with an echo flow). Parameterised by payload size to
76+
* exercise different wrap/unwrap batching characteristics.
77+
*
78+
* The `simulateNetwork` parameter inserts an `.async` boundary between the
79+
* two TLS stages. With it, the topology more closely matches real Akka
80+
* usage (one TLS instance per JVM with a network in between). Without it,
81+
* both TLS stages run in the same fused interpreter, which prevents the
82+
* pipelining the old actor-based TlsModule got from being its own island.
83+
*
84+
* Pass `-Dakka.stream.materializer.io.tls.use-graph-stage-implementation=true`
85+
* to benchmark the new GraphStage-based TLS implementation instead of the
86+
* default actor-based one.
87+
*/
88+
@State(Scope.Benchmark)
89+
@OutputTimeUnit(TimeUnit.SECONDS)
90+
@BenchmarkMode(Array(Mode.Throughput))
91+
class TlsThroughputBenchmark {
92+
import TlsBenchmark._
93+
94+
implicit var system: ActorSystem = _
95+
private var sslContext: SSLContext = _
96+
97+
@Param(Array("64", "1024", "16384", "65536"))
98+
var payloadSize = 0
99+
100+
private val messageCount = 1000
101+
private var messages: immutable.Seq[SslTlsOutbound] = _
102+
103+
@Setup(Level.Trial)
104+
def setup(): Unit = {
105+
system = ActorSystem("tls-throughput-bench", config)
106+
SystemMaterializer(system).materializer
107+
sslContext = initSslContext()
108+
messages = Vector.fill(messageCount)(SendBytes(ByteString(new Array[Byte](payloadSize))))
109+
}
110+
111+
@TearDown(Level.Trial)
112+
def teardown(): Unit = {
113+
Await.result(system.terminate(), 5.seconds)
114+
}
115+
116+
@Benchmark
117+
@OperationsPerInvocation(1000)
118+
def throughput(): Unit = {
119+
val expectedBytes = payloadSize.toLong * messageCount
120+
val latch = new CountDownLatch(1)
121+
Source(messages)
122+
.via(tlsEcho(sslContext, IgnoreComplete))
123+
.collect { case SessionBytes(_, b) => b }
124+
.scan(ByteString.empty)(_ ++ _)
125+
.filter(_.size >= expectedBytes)
126+
.take(1) // cancel stream so prior iterations don't leak
127+
.to(Sink.foreach(_ => latch.countDown()))
128+
.run()
129+
130+
if (!latch.await(60, TimeUnit.SECONDS))
131+
throw new RuntimeException("TLS throughput bench timed out")
132+
}
133+
}
134+
135+
// ==================== 2. Handshake ====================
136+
137+
/**
138+
* Measures TLS handshake cost by materialising a fresh TLS loopback per
139+
* invocation and sending a single byte through (forcing the handshake to
140+
* complete before any application data flows).
141+
*
142+
* Pass `-Dakka.stream.materializer.io.tls.use-graph-stage-implementation=true`
143+
* to benchmark the new GraphStage-based TLS implementation instead of the
144+
* default actor-based one.
145+
*/
146+
@State(Scope.Benchmark)
147+
@OutputTimeUnit(TimeUnit.SECONDS)
148+
@BenchmarkMode(Array(Mode.Throughput))
149+
class TlsHandshakeBenchmark {
150+
import TlsBenchmark._
151+
152+
implicit var system: ActorSystem = _
153+
private var sslContext: SSLContext = _
154+
155+
@Setup(Level.Trial)
156+
def setup(): Unit = {
157+
system = ActorSystem("tls-handshake-bench", config)
158+
SystemMaterializer(system).materializer
159+
sslContext = initSslContext()
160+
}
161+
162+
@TearDown(Level.Trial)
163+
def teardown(): Unit = {
164+
Await.result(system.terminate(), 5.seconds)
165+
}
166+
167+
@Benchmark
168+
def handshake(): Unit = {
169+
val latch = new CountDownLatch(1)
170+
Source
171+
.single(SendBytes(ByteString("x")))
172+
.via(tlsEcho(sslContext, EagerClose))
173+
.to(Sink.onComplete(_ => latch.countDown()))
174+
.run()
175+
176+
if (!latch.await(30, TimeUnit.SECONDS))
177+
throw new RuntimeException("TLS handshake bench timed out")
178+
}
179+
}
180+
181+
// ==================== 3. Framing (many small messages) ====================
182+
183+
/**
184+
* Pushes a large number of small messages through the TLS loopback to
185+
* stress the pump loop, chopping block, and demand management rather than
186+
* the SSLEngine crypto itself.
187+
*
188+
* Pass `-Dakka.stream.materializer.io.tls.use-graph-stage-implementation=true`
189+
* to benchmark the new GraphStage-based TLS implementation instead of the
190+
* default actor-based one.
191+
*/
192+
@State(Scope.Benchmark)
193+
@OutputTimeUnit(TimeUnit.SECONDS)
194+
@BenchmarkMode(Array(Mode.Throughput))
195+
class TlsFramingBenchmark {
196+
import TlsBenchmark._
197+
198+
implicit var system: ActorSystem = _
199+
private var sslContext: SSLContext = _
200+
201+
@Param(Array("10", "100"))
202+
var messageSize = 0
203+
204+
private val messageCount = 10000
205+
private var messages: immutable.Seq[SslTlsOutbound] = _
206+
207+
@Setup(Level.Trial)
208+
def setup(): Unit = {
209+
system = ActorSystem("tls-framing-bench", config)
210+
SystemMaterializer(system).materializer
211+
sslContext = initSslContext()
212+
messages = Vector.fill(messageCount)(SendBytes(ByteString(new Array[Byte](messageSize))))
213+
}
214+
215+
@TearDown(Level.Trial)
216+
def teardown(): Unit = {
217+
Await.result(system.terminate(), 5.seconds)
218+
}
219+
220+
@Benchmark
221+
@OperationsPerInvocation(10000)
222+
def framing(): Unit = {
223+
val expectedBytes = messageSize.toLong * messageCount
224+
val latch = new CountDownLatch(1)
225+
Source(messages)
226+
.via(tlsEcho(sslContext, IgnoreComplete))
227+
.collect { case SessionBytes(_, b) => b }
228+
.scan(ByteString.empty)(_ ++ _)
229+
.filter(_.size >= expectedBytes)
230+
.take(1) // cancel stream so prior iterations don't leak
231+
.to(Sink.foreach(_ => latch.countDown()))
232+
.run()
233+
234+
if (!latch.await(60, TimeUnit.SECONDS))
235+
throw new RuntimeException("TLS framing bench timed out")
236+
}
237+
}
238+
239+
// ==================== 4. Ping-pong (round-trip latency) ====================
240+
241+
/**
242+
* Serialised round-trip latency: sends one message, waits for the echo,
243+
* then sends the next. Each round trip exercises the full TLS wrap-unwrap
244+
* cycle under no pipelining, making it the most sensitive benchmark to
245+
* per-hop overhead (the async boundary this PR removes).
246+
*
247+
* Uses `Source.actorRef` with a feedback loop: on each received pong the
248+
* sink callback sends the next ping to the source actor, so exactly one
249+
* message is in flight at any time.
250+
*
251+
* Pass `-Dakka.stream.materializer.io.tls.use-graph-stage-implementation=true`
252+
* to benchmark the new GraphStage-based TLS implementation instead of the
253+
* default actor-based one.
254+
*/
255+
@State(Scope.Benchmark)
256+
@OutputTimeUnit(TimeUnit.SECONDS)
257+
@BenchmarkMode(Array(Mode.Throughput))
258+
class TlsPingPongBenchmark {
259+
import TlsBenchmark._
260+
261+
implicit var system: ActorSystem = _
262+
private var sslContext: SSLContext = _
263+
264+
private val roundTrips = 100
265+
266+
@Setup(Level.Trial)
267+
def setup(): Unit = {
268+
system = ActorSystem("tls-pingpong-bench", config)
269+
SystemMaterializer(system).materializer
270+
sslContext = initSslContext()
271+
}
272+
273+
@TearDown(Level.Trial)
274+
def teardown(): Unit = {
275+
Await.result(system.terminate(), 5.seconds)
276+
}
277+
278+
@Benchmark
279+
@OperationsPerInvocation(100)
280+
def pingPong(): Unit = {
281+
val ping = SendBytes(ByteString("ping"))
282+
val latch = new CountDownLatch(1)
283+
val remaining = new java.util.concurrent.atomic.AtomicInteger(roundTrips)
284+
val refHolder = new java.util.concurrent.atomic.AtomicReference[akka.actor.ActorRef]()
285+
286+
val ((ref, killSwitch), _) = Source
287+
.actorRef[SslTlsOutbound](
288+
completionMatcher = PartialFunction.empty,
289+
failureMatcher = PartialFunction.empty,
290+
bufferSize = 1,
291+
overflowStrategy = OverflowStrategy.dropBuffer)
292+
.via(tlsEcho(sslContext, IgnoreComplete))
293+
.viaMat(KillSwitches.single)(Keep.both)
294+
.toMat(Sink.foreach {
295+
case SessionBytes(_, _) =>
296+
if (remaining.decrementAndGet() > 0) refHolder.get().tell(ping, akka.actor.ActorRef.noSender)
297+
else latch.countDown()
298+
case _ => ()
299+
})(Keep.both)
300+
.run()
301+
302+
refHolder.set(ref)
303+
ref ! ping // kick off the first round trip
304+
305+
if (!latch.await(30, TimeUnit.SECONDS))
306+
throw new RuntimeException("TLS ping-pong bench timed out")
307+
308+
killSwitch.abort(new RuntimeException("done")) // tear down the stream to avoid leaking across iterations
309+
}
310+
}

akka-stream-tests/src/test/scala/akka/stream/io/TlsSpec.scala

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,17 @@ object TlsSpec {
9595
"""
9696
}
9797

98-
class TlsSpec extends StreamSpec(TlsSpec.configOverrides) with WithLogCapturing {
98+
abstract class TlsSpecBase(extraConfig: String = "")
99+
extends StreamSpec(TlsSpec.configOverrides + "\n" + extraConfig)
100+
with WithLogCapturing {
99101
import GraphDSL.Implicits._
100102
import TlsSpec._
101103
import system.dispatcher
102104

105+
protected def createTls(
106+
createSSLEngine: () => SSLEngine,
107+
closing: TLSClosing): BidiFlow[SslTlsOutbound, ByteString, ByteString, SslTlsInbound, NotUsed]
108+
103109
"SslTls" must {
104110
"work for TLSv1.2" must { workFor("TLSv1.2", TLS12Ciphers) }
105111

@@ -148,13 +154,13 @@ class TlsSpec extends StreamSpec(TlsSpec.configOverrides) with WithLogCapturing
148154
}
149155

150156
def clientTls(closing: TLSClosing) =
151-
TLS(() => createSSLEngine(sslContext, Client), closing)
157+
createTls(() => createSSLEngine(sslContext, Client), closing)
152158

153159
def badClientTls(closing: TLSClosing) =
154-
TLS(() => createSSLEngine(initWithTrust("/badtruststore", protocol), Client), closing)
160+
createTls(() => createSSLEngine(initWithTrust("/badtruststore", protocol), Client), closing)
155161

156162
def serverTls(closing: TLSClosing) =
157-
TLS(() => createSSLEngine(sslContext, Server), closing)
163+
createTls(() => createSSLEngine(sslContext, Server), closing)
158164

159165
trait Named {
160166
def name: String =
@@ -555,7 +561,7 @@ class TlsSpec extends StreamSpec(TlsSpec.configOverrides) with WithLogCapturing
555561
case SessionTruncated => SendBytes(ByteString.empty)
556562
case SessionBytes(_, b) => SendBytes(b)
557563
}
558-
val clientTls = TLS(
564+
val clientTls = createTls(
559565
() => createSSLEngine2(sslContext, Client, hostnameVerification = true, hostInfo = Some((hostName, 80))),
560566
EagerClose)
561567

@@ -602,3 +608,32 @@ class TlsSpec extends StreamSpec(TlsSpec.configOverrides) with WithLogCapturing
602608
}
603609

604610
}
611+
612+
/** Runs all TLS tests against the default actor-based TLS implementation. */
613+
class TlsSpec extends TlsSpecBase {
614+
protected def createTls(
615+
createSSLEngine: () => SSLEngine,
616+
closing: TLSClosing): BidiFlow[SslTlsOutbound, ByteString, ByteString, SslTlsInbound, NotUsed] =
617+
TLS(createSSLEngine, closing)
618+
}
619+
620+
/** Runs all TLS tests against the new GraphStage-based TLS implementation. */
621+
class TlsGraphStageSpec extends TlsSpecBase {
622+
protected def createTls(
623+
createSSLEngine: () => SSLEngine,
624+
closing: TLSClosing): BidiFlow[SslTlsOutbound, ByteString, ByteString, SslTlsInbound, NotUsed] =
625+
TLS.graphStageApply(createSSLEngine, _ => scala.util.Success(()), closing)
626+
}
627+
628+
/**
629+
* Runs all TLS tests through TLS.apply with the graph-stage island enabled, exercising the
630+
* TlsGraphStageIsland materialisation path (port-id copying, island wiring) that production
631+
* traffic will use once the flag default is flipped to true.
632+
*/
633+
class TlsGraphStageIslandSpec
634+
extends TlsSpecBase("akka.stream.materializer.io.tls.use-graph-stage-implementation = true") {
635+
protected def createTls(
636+
createSSLEngine: () => SSLEngine,
637+
closing: TLSClosing): BidiFlow[SslTlsOutbound, ByteString, ByteString, SslTlsInbound, NotUsed] =
638+
TLS(createSSLEngine, closing)
639+
}

0 commit comments

Comments
 (0)