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 Ok(PinotGuard(self.0.start().await?))
91 }
92}
93
94impl Default for PinotContainer {
95 fn default() -> Self {
96 Self::new()
97 }
98}
99
100/// The running guard for a [`PinotContainer`].
101pub struct PinotGuard(ContainerGuard);
102
103impl PinotGuard {
104 /// The controller's REST base URI (schema/table/segment admin).
105 pub fn controller_url(&self) -> String {
106 format!(
107 "http://{}:{}",
108 self.0.host(),
109 self.0.get_mapped_port(CONTROLLER_PORT).unwrap()
110 )
111 }
112
113 /// The broker's query base URI (port 8000 — see the module doc on the 8099
114 /// assumption).
115 pub fn broker_url(&self) -> String {
116 format!(
117 "http://{}:{}",
118 self.0.host(),
119 self.0.get_mapped_port(BROKER_PORT).unwrap()
120 )
121 }
122
123 /// Stops and removes the container, releasing its host ports.
124 pub async fn stop(self) -> Result<()> {
125 self.0.stop().await
126 }
127}
128
129impl std::ops::Deref for PinotGuard {
130 type Target = ContainerGuard;
131 fn deref(&self) -> &ContainerGuard {
132 &self.0
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn with_image_smoke() {
142 let _ = PinotContainer::new();
143 }
144}