Skip to main content

rightsize_modules/
pinot.rs

1//! A single-container Apache Pinot "QuickStart" cluster: controller, broker, server,
2//! minion, and an embedded ZooKeeper, all as one process tree inside one image,
3//! started with `QuickStart -type EMPTY` (a clean cluster with no demo tables — this
4//! module is a real-cluster smoke fixture, not a data-loading harness).
5//!
6//! ### Ports — empirically verified, not the QuickStart docs' assumption
7//!
8//! The controller REST API is on 9000 as documented. The broker's **query** port is
9//! **8000**, not 8099 — confirmed from a real `QuickStart -type EMPTY` boot log:
10//!
11//! ```text
12//! StartControllerCommand ... -controllerPort 9000 ...
13//! INFO: Started listener bound to [0.0.0.0:9000]
14//! StartBrokerCommand ... -brokerPort 8000 -brokerGrpcPort 8010 ...
15//! INFO: Started listener bound to [0.0.0.0:8000]
16//! ```
17//!
18//! `curl http://<host>:8000/health` returns `503` while the broker is still
19//! registering with the cluster and `200` once it's live; 8099 is not opened by
20//! QuickStart at all. This module exposes 8000 and names the helper `broker_url`
21//! accordingly.
22//!
23//! ### Memory — measured, not the originally-planned 2048 MB
24//!
25//! QuickStart runs a ZooKeeper + controller + broker + server + minion JVM in one
26//! container, and the image itself bakes in `JAVA_OPTS=-Xms4G -Xmx4G` (confirmed via
27//! `docker inspect`) — the QuickStartRunner driver JVM alone wants a 4 GiB heap before
28//! the four sub-JVMs it spawns take anything. `with_memory_limit(2048)` (the
29//! SpringCloudConfig/Paketo analogy, scaled up for four JVMs instead of one) badly
30//! under-shot. Measured directly against a real `apachepinot/pinot:1.5.1
31//! QuickStart -type EMPTY` boot:
32//!
33//! ```text
34//! --memory=2048m -> OOMKilled=true (timed out at 180s waiting for /health, reaped by the kernel)
35//! --memory=2560m -> OOMKilled=true
36//! --memory=3072m -> OOMKilled=false; /health 200 within ~15s; BUT settles at ~99% of the 3 GiB
37//!                   limit, and under that pressure the controller's Helix-backed schema/table
38//!                   RPCs intermittently time out (a schema POST returns
39//!                   {"code":500,"error":"java.util.concurrent.TimeoutException"}) even though
40//!                   /health reports 200. Reproduced repeatedly at 3072m.
41//! --memory=4096m -> stable: settles at ~73-75% of the limit, comfortable headroom; schema POST
42//!                   succeeded on every attempt across a 60s repeated-POST probe.
43//! ```
44//!
45//! So [`Container::with_memory_limit`] is **4096 MB** — the lowest round number with
46//! real headroom above the image's own 4 GiB heap request, not merely enough to dodge
47//! the OOM killer. Verified stable on both Docker and microsandbox.
48
49use std::time::Duration;
50
51use rightsize::{Container, ContainerGuard, Result, Wait};
52
53const CONTROLLER_PORT: u16 = 9000;
54const BROKER_PORT: u16 = 8000;
55
56/// A single-container Apache Pinot QuickStart cluster.
57pub struct PinotContainer(Container);
58
59impl PinotContainer {
60    /// Builds a container from the pinned default image (`apachepinot/pinot:1.5.1`).
61    pub fn new() -> Self {
62        Self::with_image("apachepinot/pinot:1.5.1")
63    }
64
65    /// Builds a container from a caller-chosen image.
66    pub fn with_image(image: &str) -> Self {
67        Self(
68            Container::new(image)
69                .with_exposed_ports(&[CONTROLLER_PORT, BROKER_PORT])
70                .with_command(&["QuickStart", "-type", "EMPTY"])
71                // Four JVMs (ZK, controller, broker, server/minion) in one container,
72                // image bakes in -Xmx4G — see the module doc for the measured
73                // 2048/2560/3072 MB failures and the 4096 MB fix.
74                .with_memory_limit(4096)
75                // A four-JVM cluster booting cold on a laptop is legitimately slow
76                // (observed 60-120s); the controller's REST listener is up well
77                // before the whole cluster has stabilized, but it's the only
78                // readiness signal that's both meaningful and doesn't require
79                // polling the broker separately before every test can proceed.
80                .waiting_for(
81                    Wait::for_http("/health")
82                        .for_port(CONTROLLER_PORT)
83                        .with_startup_timeout(Duration::from_secs(180)),
84                ),
85        )
86    }
87
88    /// Boots the container.
89    pub async fn start(self) -> Result<PinotGuard> {
90        crate::register_default_backends();
91        Ok(PinotGuard(self.0.start().await?))
92    }
93}
94
95impl Default for PinotContainer {
96    fn default() -> Self {
97        Self::new()
98    }
99}
100
101/// The running guard for a [`PinotContainer`].
102pub struct PinotGuard(ContainerGuard);
103
104impl PinotGuard {
105    /// The controller's REST base URI (schema/table/segment admin).
106    pub fn controller_url(&self) -> String {
107        format!(
108            "http://{}:{}",
109            self.0.host(),
110            self.0.get_mapped_port(CONTROLLER_PORT).unwrap()
111        )
112    }
113
114    /// The broker's query base URI (port 8000 — see the module doc on the 8099
115    /// assumption).
116    pub fn broker_url(&self) -> String {
117        format!(
118            "http://{}:{}",
119            self.0.host(),
120            self.0.get_mapped_port(BROKER_PORT).unwrap()
121        )
122    }
123
124    /// Stops and removes the container, releasing its host ports.
125    pub async fn stop(self) -> Result<()> {
126        self.0.stop().await
127    }
128}
129
130impl std::ops::Deref for PinotGuard {
131    type Target = ContainerGuard;
132    fn deref(&self) -> &ContainerGuard {
133        &self.0
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn with_image_smoke() {
143        let _ = PinotContainer::new();
144    }
145}