Skip to main content

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        crate::register_default_backends();
159        let jobmanager = self.container.start().await?;
160        let task_manager = match self.task_manager {
161            Some(tm) => match tm.start().await {
162                Ok(guard) => Some(guard),
163                Err(e) => {
164                    jobmanager.stop().await.ok();
165                    return Err(e);
166                }
167            },
168            None => None,
169        };
170        Ok(FlinkGuard {
171            jobmanager,
172            task_manager,
173            network: self.network,
174        })
175    }
176}
177
178impl Default for FlinkContainer {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184/// The running guard for a [`FlinkContainer`].
185pub struct FlinkGuard {
186    jobmanager: ContainerGuard,
187    task_manager: Option<ContainerGuard>,
188    network: Option<Arc<Network>>,
189}
190
191impl FlinkGuard {
192    /// The JobManager REST base URI (`/overview`, `/taskmanagers`, job submission,
193    /// etc.).
194    pub fn rest_url(&self) -> String {
195        format!(
196            "http://{}:{}",
197            self.jobmanager.host(),
198            self.jobmanager.get_mapped_port(REST_PORT).unwrap()
199        )
200    }
201
202    /// Stops and removes the TaskManager (if any) and the JobManager, then closes the
203    /// network created by [`FlinkContainer::with_task_manager`] (if any) — this module
204    /// created that network internally, not the caller, so it owns closing it too, or
205    /// a repeated-boot test/process leaks one per run.
206    pub async fn stop(self) -> Result<()> {
207        if let Some(tm) = self.task_manager {
208            tm.stop().await?;
209        }
210        self.jobmanager.stop().await?;
211        if let Some(net) = self.network {
212            net.close().await?;
213        }
214        Ok(())
215    }
216}
217
218impl std::ops::Deref for FlinkGuard {
219    type Target = ContainerGuard;
220    fn deref(&self) -> &ContainerGuard {
221        &self.jobmanager
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn with_image_smoke() {
231        let _ = FlinkContainer::new();
232        let _ = FlinkContainer::with_image("flink:1.20.5");
233    }
234}