1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
//! v4.33 slow-query log — `SPG_SLOW_QUERY_LOG_MS` thresholds when
//! the server emits a JSON line on stderr.
//!
//! Test contract: with the threshold set, a query that exceeds it
//! produces exactly one `{"event":"slow_query",...}` line carrying
//! the SQL text, elapsed microseconds, role; a query that comes in
//! under threshold produces nothing.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStderr, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use spg_wire::{Frame, Op, build_query, encode};
const STARTUP_TIMEOUT: Duration = Duration::from_secs(8);
const READ_TIMEOUT: Duration = Duration::from_secs(5);
/// v6.0.x — race-free port allocation. Pass `127.0.0.1:0` to the
/// child, parse the actual bound address from the captured stderr
/// buffer. See `tests/common/mod.rs` for the broader rationale.
fn extract_listen_addr_from_buf(buf: &Arc<Mutex<String>>) -> String {
let deadline = Instant::now() + STARTUP_TIMEOUT;
while Instant::now() < deadline {
let snap = buf.lock().unwrap().clone();
if let Some(after) = snap.find("listening on ") {
let tail = &snap[after + "listening on ".len()..];
let end = tail.find([' ', '\n', '\r']).unwrap_or(tail.len());
return tail[..end].to_string();
}
thread::sleep(Duration::from_millis(20));
}
panic!("server didn't publish listen addr in stderr buffer");
}
fn unique_tmpdir(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let p = std::env::temp_dir().join(format!("spg-slowlog-{tag}-{nanos}"));
std::fs::create_dir_all(&p).unwrap();
p
}
fn spawn_server_capture_stderr(
addr: &str,
db: &Path,
wal: &Path,
env: &[(&str, String)],
) -> (Child, Arc<Mutex<String>>) {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_spg-server"));
cmd.arg(addr)
.arg(db)
.arg("-")
.arg(wal)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.env_remove("SPG_PASSWORD")
.env_remove("SPG_ADMIN_PASSWORD")
.env_remove("SPG_PG_ADDR");
for (k, v) in env {
cmd.env(k, v);
}
let mut child = cmd.spawn().unwrap();
let stderr: ChildStderr = child.stderr.take().expect("stderr piped");
let buf = Arc::new(Mutex::new(String::new()));
let buf_for_thread = Arc::clone(&buf);
thread::spawn(move || {
let mut reader = stderr;
let mut chunk = [0u8; 4096];
loop {
match reader.read(&mut chunk) {
Ok(0) | Err(_) => return,
Ok(n) => {
if let Ok(s) = std::str::from_utf8(&chunk[..n]) {
buf_for_thread.lock().unwrap().push_str(s);
}
}
}
}
});
(child, buf)
}
struct ChildGuard(Child);
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn read_frame(s: &mut TcpStream) -> Frame {
let mut header = [0u8; spg_wire::FRAME_HEADER_LEN];
s.read_exact(&mut header).unwrap();
let payload_len = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize;
let op = Op::from_byte(header[4]).unwrap();
let mut payload = vec![0u8; payload_len];
if payload_len > 0 {
s.read_exact(&mut payload).unwrap();
}
Frame { op, payload }
}
fn send(s: &mut TcpStream, f: &Frame) {
let mut out = Vec::new();
encode(f, &mut out).unwrap();
s.write_all(&out).unwrap();
}
fn drain_to_cc(s: &mut TcpStream) {
loop {
let f = read_frame(s);
match f.op {
Op::CommandComplete => return,
Op::ErrorResponse | Op::Error => {
let msg = spg_wire::parse_error_response(&f).unwrap_or("<undecodable>");
panic!("server returned error: {msg}");
}
_ => {}
}
}
}
/// SPG_SLOW_QUERY_LOG_MS thresholded: slow query crosses, fast
/// query stays silent. JSON line carries sql/elapsed_us/role.
#[test]
fn slow_query_log_fires_above_threshold_and_silent_below() {
let dir = unique_tmpdir("th");
let db = dir.join("a.db");
let wal = dir.join("a.wal");
// 5 ms threshold — well above a no-op `SELECT 7` (microseconds
// in release) and well below the recursive CTE we use below
// (tens of ms in release at N=90000). The threshold has to
// straddle the *release-build* timings since CI runs --release.
let (child, stderr_buf) = spawn_server_capture_stderr(
"127.0.0.1:0",
&db,
&wal,
&[("SPG_SLOW_QUERY_LOG_MS", "5".to_string())],
);
let mut c = ChildGuard(child);
let addr = extract_listen_addr_from_buf(&stderr_buf);
let mut s = TcpStream::connect(&addr).expect("connect");
s.set_read_timeout(Some(READ_TIMEOUT)).unwrap();
// Bootstrap (DDL — should NOT count as slow because trivial).
send(&mut s, &build_query("CREATE TABLE t (id INT NOT NULL)"));
drain_to_cc(&mut s);
// Negative probe. The contract is "logged if and only if elapsed
// reached the threshold", and the half this checks is that nothing
// UNDER the threshold is logged.
//
// v7.37 (round 826) — it used to assert the marker never appears,
// which quietly assumes `SELECT 7` finishes inside 5ms of WALL
// time. On a loaded box it does not: with a gate run on the same
// host this probe measured 11026µs, was logged — correctly — and
// the assertion failed a test whose feature was working. So allow
// the line, and hold it to the contract: if it was logged, its own
// elapsed_us must say why.
let fast_marker = "SELECT 7 as fast_marker_for_negative_check";
send(&mut s, &build_query(fast_marker));
drain_to_cc(&mut s);
// Deliberately fixed: this waits for a line that in the healthy
// case never arrives — absence has no event to poll for.
thread::sleep(Duration::from_millis(100));
{
let captured = stderr_buf.lock().unwrap().clone();
if let Some(line) = captured.lines().find(|l| l.contains(fast_marker)) {
let elapsed: u64 = line
.split("\"elapsed_us\":")
.nth(1)
.and_then(|rest| {
rest.split(|c: char| !c.is_ascii_digit())
.next()?
.parse()
.ok()
})
.unwrap_or_else(|| panic!("unparseable slow-query line: {line}"));
assert!(
elapsed >= 5000,
"a query below the threshold was logged as slow \
(elapsed_us={elapsed} < 5000):\n{line}"
);
}
}
// Slow probe — recursive CTE that takes well above 5 ms even
// in --release. 90 000 iterations stays under the 100 000-iter
// runaway cap documented in PROD_READY row 6.8.
let slow_sql = "WITH RECURSIVE seq(n) AS (\
SELECT 1 UNION ALL SELECT n+1 FROM seq WHERE n<90000\
) SELECT count(*) FROM seq";
send(&mut s, &build_query(slow_sql));
drain_to_cc(&mut s);
// Give the Drop guard a moment to flush its eprintln onto the
// captured stderr buffer.
//
// v7.37 (round 826) — wait for EVERY field the assertions need, not
// just for the line to have started. The capture thread reads the
// child's stderr through a pipe, so half a line is a perfectly
// normal thing to observe; a full-load gate run caught this poll
// waking between `elapsed_us` and `role`, satisfying the old
// two-substring condition, and then failing the `role` assertion on
// a line whose remainder arrived microseconds later. Poll until the
// whole payload is there; the deadline turns a genuinely missing
// field into the same failure it always was.
let deadline = Instant::now() + Duration::from_secs(3);
let wanted = [
"\"event\":\"slow_query\"",
"WITH RECURSIVE",
"\"sql\":\"",
"\"elapsed_us\":",
"\"role\":\"",
// Threshold echoes back so operators can correlate.
"\"threshold_us\":5000",
];
loop {
let captured = stderr_buf.lock().unwrap().clone();
if wanted.iter().all(|w| captured.contains(w)) {
return;
}
assert!(
Instant::now() < deadline,
"slow-query log incomplete after 3s; wanted all of {wanted:?}; stderr:\n{captured}"
);
thread::sleep(Duration::from_millis(50));
}
}