Skip to main content

rightsize_modules/
neo4j.rs

1//! A single-node Neo4j Community container, queried over its HTTP Cypher transaction
2//! endpoint (`/db/neo4j/tx/commit`) — no bolt driver dependency needed, matching the
3//! house convention for HTTP-first modules ([`crate::clickhouse::ClickHouseContainer`],
4//! [`crate::pinot::PinotContainer`]). The bolt port (7687) is still exposed and its URI
5//! available via [`Neo4jGuard::bolt_url`] for callers who do want a real driver.
6//!
7//! Defaults to a `neo4j`/`rightsize-test` username/password pair (the image refuses
8//! passwords under 8 characters — `neo4j`/`neo4j` is rejected at boot) so
9//! [`Neo4jGuard::http_url`] plus basic auth is usable with zero configuration; call
10//! [`Neo4jContainer::with_password`] before `start()` to override it. The username is
11//! fixed by the image at `neo4j` — there is no env var to change it.
12//!
13//! ### Readiness — `Started.` is the exact log line, verified against a real boot
14//!
15//! Captured verbatim from `neo4j:5-community`:
16//!
17//! ```text
18//! ... INFO  Bolt enabled on 0.0.0.0:7687.
19//! ... INFO  HTTP enabled on 0.0.0.0:7474.
20//! ... INFO  Remote interface available at http://localhost:7474/
21//! ... INFO  Started.
22//! ```
23//!
24//! `Started.` is logged only after both connectors are already listening, so it is
25//! both accurate and simpler than a two-port HTTP/bolt race; pinned as the wait
26//! strategy over `for_http`.
27//!
28//! ### Memory — measured, needed the ladder
29//!
30//! At msb's default ~450 MB microVM RAM, the server logs `ERROR Invalid memory
31//! configuration - exceeds physical memory. Check the configured values for
32//! server.memory.pagecache.size and server.memory.heap.max_size` and shuts itself down
33//! cleanly (`INFO Stopped.`) rather than hanging or getting OOM-killed — Neo4j's own
34//! memory-recommendation calculator sizes the page cache and heap off total visible
35//! RAM and refuses to start if the sums don't fit. A real docker boot with no memory
36//! cap sits at ~468 MiB RSS (`docker stats`), just over that default budget. Retried
37//! with `-m 1024M`: boots clean, the HTTP Cypher endpoint answers within the startup
38//! timeout. `with_memory_limit(1024)` is this module's default, same number as
39//! [`crate::keycloak::KeycloakContainer`] and matching this family's established floor
40//! for a single JVM.
41//!
42//! No control characters were found in the image's baked env (checked via
43//! `docker image inspect`), so no env override is needed here.
44
45use std::time::Duration;
46
47use rightsize::{Container, ContainerGuard, Result, Wait};
48
49const HTTP_PORT: u16 = 7474;
50const BOLT_PORT: u16 = 7687;
51
52/// A single-node Neo4j Community container.
53pub struct Neo4jContainer {
54    container: Container,
55    password: String,
56}
57
58impl Neo4jContainer {
59    /// Builds a container from the pinned default image (`neo4j:5-community`).
60    pub fn new() -> Self {
61        Self::with_image("neo4j:5-community")
62    }
63
64    /// Builds a container from a caller-chosen image.
65    pub fn with_image(image: &str) -> Self {
66        let password = "rightsize-test".to_string();
67        let container = Container::new(image)
68            .with_exposed_ports(&[HTTP_PORT, BOLT_PORT])
69            .with_env("NEO4J_AUTH", &format!("neo4j/{password}"))
70            // Single JVM, boots clean at 1024M — see the module doc for the
71            // measured default-memory refusal (Neo4j's own memory calculator, not
72            // an OOM kill) and the fix.
73            .with_memory_limit(1024)
74            .waiting_for(
75                Wait::for_log_message(r".*Started\..*", 1)
76                    .with_startup_timeout(Duration::from_secs(120)),
77            );
78        Self {
79            container,
80            password,
81        }
82    }
83
84    /// Overrides `NEO4J_AUTH`'s password half (default `rightsize-test`; the image
85    /// requires at least 8 characters).
86    pub fn with_password(mut self, password: &str) -> Self {
87        self.password = password.to_string();
88        self.container = self
89            .container
90            .with_env("NEO4J_AUTH", &format!("neo4j/{password}"));
91        self
92    }
93
94    /// Boots the container.
95    pub async fn start(self) -> Result<Neo4jGuard> {
96        crate::register_default_backends();
97        let guard = self.container.start().await?;
98        Ok(Neo4jGuard {
99            guard,
100            password: self.password,
101        })
102    }
103}
104
105impl Default for Neo4jContainer {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111/// The running guard for a [`Neo4jContainer`].
112pub struct Neo4jGuard {
113    guard: ContainerGuard,
114    password: String,
115}
116
117impl Neo4jGuard {
118    /// The fixed admin username (`neo4j` — the image has no env var to change it).
119    pub fn username(&self) -> &str {
120        "neo4j"
121    }
122
123    /// The configured admin password (default `rightsize-test`).
124    pub fn password(&self) -> &str {
125        &self.password
126    }
127
128    /// The HTTP interface's base URI (Cypher transactions via `POST
129    /// {http_url}/db/neo4j/tx/commit`).
130    pub fn http_url(&self) -> String {
131        format!(
132            "http://{}:{}",
133            self.guard.host(),
134            self.guard.get_mapped_port(HTTP_PORT).unwrap()
135        )
136    }
137
138    /// The bolt interface's URI, for callers using a real bolt driver instead of the
139    /// HTTP helpers.
140    pub fn bolt_url(&self) -> String {
141        format!(
142            "bolt://{}:{}",
143            self.guard.host(),
144            self.guard.get_mapped_port(BOLT_PORT).unwrap()
145        )
146    }
147
148    /// Stops and removes the container, releasing its host ports.
149    pub async fn stop(self) -> Result<()> {
150        self.guard.stop().await
151    }
152}
153
154impl std::ops::Deref for Neo4jGuard {
155    type Target = ContainerGuard;
156    fn deref(&self) -> &ContainerGuard {
157        &self.guard
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use rightsize::wait::WaitTarget;
165
166    #[test]
167    fn default_password_is_rightsize_test() {
168        let c = Neo4jContainer::new();
169        assert_eq!(c.password, "rightsize-test");
170    }
171
172    #[test]
173    fn with_password_overrides_the_default() {
174        let c = Neo4jContainer::new().with_password("s3cret123");
175        assert_eq!(c.password, "s3cret123");
176    }
177
178    // Pins this module's shipped pattern — `.*Started\..*`, a literal escaped dot,
179    // now that `for_log_message` runs on a real regex engine — against the captured
180    // line. (An earlier revision shipped the unescaped `.*Started..*` as a
181    // workaround for a hand-rolled mini-matcher with no escape syntax; that
182    // matcher is gone, so the pattern is back to meaning what it reads.)
183    #[tokio::test]
184    async fn readiness_pattern_matches_the_captured_started_line() {
185        struct FakeTarget(String);
186        #[async_trait::async_trait]
187        impl WaitTarget for FakeTarget {
188            fn host(&self) -> &str {
189                "127.0.0.1"
190            }
191            fn mapped_port(&self, guest_port: u16) -> u16 {
192                guest_port
193            }
194            fn exposed_guest_ports(&self) -> Vec<u16> {
195                vec![HTTP_PORT, BOLT_PORT]
196            }
197            async fn current_logs(&self) -> String {
198                self.0.clone()
199            }
200            fn describe(&self) -> String {
201                "fake-neo4j".to_string()
202            }
203        }
204
205        let captured_log = "\
206... INFO  Bolt enabled on 0.0.0.0:7687.
207... INFO  HTTP enabled on 0.0.0.0:7474.
208... INFO  Remote interface available at http://localhost:7474/
209... INFO  Started.";
210        let target = FakeTarget(captured_log.to_string());
211        let strategy = rightsize::Wait::for_log_message(r".*Started\..*", 1);
212        strategy
213            .wait_until_ready(&target)
214            .await
215            .expect("the escaped pattern must match the real captured log line");
216    }
217
218    // The escape is only meaningful if it actually narrows the match: this pins that
219    // `\.` in the shipped pattern rejects a line where that position holds a
220    // different character, distinguishing it from the unescaped `.*Started..*`
221    // this module shipped while the mini-matcher had no escape syntax.
222    #[tokio::test]
223    async fn escaped_dot_does_not_match_an_arbitrary_character_in_its_place() {
224        struct FakeTarget(&'static str);
225        #[async_trait::async_trait]
226        impl WaitTarget for FakeTarget {
227            fn host(&self) -> &str {
228                "127.0.0.1"
229            }
230            fn mapped_port(&self, guest_port: u16) -> u16 {
231                guest_port
232            }
233            fn exposed_guest_ports(&self) -> Vec<u16> {
234                vec![]
235            }
236            async fn current_logs(&self) -> String {
237                self.0.to_string()
238            }
239            fn describe(&self) -> String {
240                "fake-neo4j".to_string()
241            }
242        }
243
244        let target = FakeTarget("... INFO  Startedx");
245        let err = rightsize::Wait::for_log_message(r".*Started\..*", 1)
246            .with_startup_timeout(Duration::from_millis(300))
247            .wait_until_ready(&target)
248            .await
249            .expect_err("a non-dot character where the escaped dot is must not match");
250        assert!(err.to_string().contains(r"Started\..*' x1"));
251    }
252}