rightsize_modules/flink.rs
1//! An Apache Flink **JobManager**, optionally paired with a companion **TaskManager**
2//! via [`FlinkContainer::with_task_manager`] for a real session cluster that can
3//! actually run jobs (a bare JobManager has zero task slots and can only accept/reject
4//! submissions, never execute them).
5//!
6//! ### Topology
7//!
8//! A real Flink session cluster is two processes that must find each other over a
9//! **persistent bidirectional RPC connection** (Pekko/Akka remoting): the TaskManager
10//! dials the JobManager's RPC port (6123) at boot and *stays connected* —
11//! registration is not a one-shot request/response, it is the first message on a
12//! connection the TaskManager keeps open for as long as it runs, carrying heartbeats
13//! and slot offers/task deployments both ways for the cluster's whole lifetime.
14//! [`FlinkContainer::with_task_manager`] puts both containers on one
15//! internally-created [`rightsize::Network`], aliases this JobManager as
16//! `"jobmanager"`, and sets `FLINK_PROPERTIES=jobmanager.rpc.address: jobmanager` on
17//! **both** containers (the image's own env-driven config mechanism) — not just the
18//! TaskManager. Verified directly: setting it on the TaskManager alone leaves the
19//! JobManager's own Pekko actor system bound under its container hostname rather than
20//! the alias, so every registration attempt from the TaskManager (correctly dialing
21//! `pekko.tcp://flink@jobmanager:6123`) gets silently dropped as a non-local recipient
22//! (`dropping message ... arriving at [pekko.tcp://flink@jobmanager:6123] inbound
23//! addresses are [pekko.tcp://flink@<container-id>:6123]`) — the JobManager must be
24//! told its own address is the alias too.
25//!
26//! ### Backend support — full on docker, JobManager-only on msb
27//!
28//! **Docker**: verified end-to-end — the TaskManager registers with the JobManager
29//! (`Successful registration at resource manager ...` in its own log) and `GET
30//! /taskmanagers` on the JobManager's REST port shows one slot-bearing TM within
31//! seconds of both containers starting.
32//!
33//! **microsandbox**: [`FlinkContainer::with_task_manager`] returns
34//! [`rightsize::RightsizeError::UnsupportedByBackend`] before ever booting anything.
35//! The actual blocker is more basic than a Pekko/tunnel incompatibility: msb's
36//! `install_network_links` requires `nc`/busybox *inside the consumer image* to serve
37//! the tunnel's in-guest listener, and the official `flink:1.20.5` image is a bare
38//! JRE + Flink install with neither — the attempt failed immediately with
39//! `UnsupportedByBackend: network links (no nc/busybox in consumer image
40//! 'flink:1.20.5')`, thrown from the msb backend's own `nc` prerequisite probe before
41//! a single byte of Pekko traffic could be exchanged. Whether Pekko's
42//! persistent-connection RPC registration would work over the tunnel's
43//! single-connection-at-a-time model was never reached or tested — the missing
44//! `nc`/busybox prerequisite stops the attempt before that question is even in play.
45//! A bare JobManager (REST `/overview` only, no TM) works fine on msb — it needs no
46//! network-link emulation at all, just the ordinary published-port HTTP path — so this
47//! module still supports msb for JobManager-only use; only
48//! [`FlinkContainer::with_task_manager`] is gated.
49//!
50//! ### Memory — JVM, ladder applies to both roles
51//!
52//! A JobManager settles around ~310 MiB RSS and a TaskManager around ~375 MiB RSS at
53//! rest on docker with no cap (`docker stats`, real boot) — both comfortably over
54//! msb's ~450 MB default *individually*, and this module runs the JobManager on msb
55//! too (see above), so `with_memory_limit(1024)` is this module's default for both
56//! roles, matching the family's established single-JVM floor
57//! ([`crate::keycloak::KeycloakContainer`], [`crate::neo4j::Neo4jContainer`]).
58//!
59//! No control characters were found in the image's baked env (checked via
60//! `docker image inspect`).
61
62use std::sync::Arc;
63use std::time::Duration;
64
65use rightsize::{Container, ContainerGuard, Network, Result, RightsizeError, Wait};
66
67const REST_PORT: u16 = 8081;
68const RPC_PORT: u16 = 6123;
69const JOBMANAGER_ALIAS: &str = "jobmanager";
70
71/// An Apache Flink JobManager, with an optional companion TaskManager (see
72/// [`FlinkContainer::with_task_manager`]).
73pub struct FlinkContainer {
74 container: Container,
75 image: String,
76 network: Option<Arc<Network>>,
77 task_manager: Option<Container>,
78}
79
80impl FlinkContainer {
81 /// Builds a container from the pinned default image (`flink:1.20.5`).
82 pub fn new() -> Self {
83 Self::with_image("flink:1.20.5")
84 }
85
86 /// Builds a container from a caller-chosen image.
87 pub fn with_image(image: &str) -> Self {
88 let container = Container::new(image)
89 .with_exposed_ports(&[REST_PORT, RPC_PORT])
90 .with_command(&["jobmanager"])
91 .with_memory_limit(1024)
92 .waiting_for(
93 Wait::for_http("/overview")
94 .for_port(REST_PORT)
95 .with_startup_timeout(Duration::from_secs(120)),
96 );
97 Self {
98 container,
99 image: image.to_string(),
100 network: None,
101 task_manager: None,
102 }
103 }
104
105 /// Adds a companion TaskManager, giving the cluster real task slots. **Docker
106 /// only** — returns [`RightsizeError::UnsupportedByBackend`] on microsandbox (see
107 /// the module doc's backend-support section); the remedy is to run this test
108 /// under the docker backend instead. Must be called before [`FlinkContainer::start`].
109 pub fn with_task_manager(mut self) -> Result<Self> {
110 if rightsize::backends::active_name() == "microsandbox" {
111 return Err(RightsizeError::unsupported_with_remedy(
112 "Flink TaskManager topology (network links; no nc/busybox in the flink image)",
113 "microsandbox",
114 "run this test with RIGHTSIZE_BACKEND=docker instead; JobManager-only \
115 (no with_task_manager()) is still supported on microsandbox",
116 ));
117 }
118
119 let net = self
120 .network
121 .get_or_insert_with(|| Arc::new(Network::new_network()))
122 .clone();
123 self.container = self
124 .container
125 .with_network(&net)
126 .with_network_aliases(&[JOBMANAGER_ALIAS])
127 // The JobManager must ALSO be told its own rpc.address is the alias, not
128 // its container hostname — otherwise its Pekko actor system binds as
129 // "flink@<container-id>" while the TaskManager (below) dials
130 // "flink@jobmanager", and every registration attempt is silently dropped
131 // as a non-local recipient (reproduced directly; see the module doc).
132 .with_env(
133 "FLINK_PROPERTIES",
134 &format!("jobmanager.rpc.address: {JOBMANAGER_ALIAS}"),
135 );
136 self.task_manager = Some(
137 Container::new(&self.image)
138 .with_command(&["taskmanager"])
139 .with_network(&net)
140 .with_env(
141 "FLINK_PROPERTIES",
142 &format!("jobmanager.rpc.address: {JOBMANAGER_ALIAS}"),
143 )
144 .with_memory_limit(1024)
145 // The TaskManager has no REST port of its own to wait on; it's ready
146 // enough to start once its process is up, and the real proof of
147 // life is its JobManager registration, which the module's own IT
148 // polls for over the JobManager's REST API instead of a wait
149 // strategy here.
150 .waiting_for(Wait::for_log_message(".*", 0)),
151 );
152 Ok(self)
153 }
154
155 /// Boots the JobManager, then (if [`FlinkContainer::with_task_manager`] was
156 /// called) the companion TaskManager.
157 pub async fn start(self) -> Result<FlinkGuard> {
158 let jobmanager = self.container.start().await?;
159 let task_manager = match self.task_manager {
160 Some(tm) => match tm.start().await {
161 Ok(guard) => Some(guard),
162 Err(e) => {
163 jobmanager.stop().await.ok();
164 return Err(e);
165 }
166 },
167 None => None,
168 };
169 Ok(FlinkGuard {
170 jobmanager,
171 task_manager,
172 network: self.network,
173 })
174 }
175}
176
177impl Default for FlinkContainer {
178 fn default() -> Self {
179 Self::new()
180 }
181}
182
183/// The running guard for a [`FlinkContainer`].
184pub struct FlinkGuard {
185 jobmanager: ContainerGuard,
186 task_manager: Option<ContainerGuard>,
187 network: Option<Arc<Network>>,
188}
189
190impl FlinkGuard {
191 /// The JobManager REST base URI (`/overview`, `/taskmanagers`, job submission,
192 /// etc.).
193 pub fn rest_url(&self) -> String {
194 format!(
195 "http://{}:{}",
196 self.jobmanager.host(),
197 self.jobmanager.get_mapped_port(REST_PORT).unwrap()
198 )
199 }
200
201 /// Stops and removes the TaskManager (if any) and the JobManager, then closes the
202 /// network created by [`FlinkContainer::with_task_manager`] (if any) — this module
203 /// created that network internally, not the caller, so it owns closing it too, or
204 /// a repeated-boot test/process leaks one per run.
205 pub async fn stop(self) -> Result<()> {
206 if let Some(tm) = self.task_manager {
207 tm.stop().await?;
208 }
209 self.jobmanager.stop().await?;
210 if let Some(net) = self.network {
211 net.close().await?;
212 }
213 Ok(())
214 }
215}
216
217impl std::ops::Deref for FlinkGuard {
218 type Target = ContainerGuard;
219 fn deref(&self) -> &ContainerGuard {
220 &self.jobmanager
221 }
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn with_image_smoke() {
230 let _ = FlinkContainer::new();
231 let _ = FlinkContainer::with_image("flink:1.20.5");
232 }
233}