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
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
//! Allocation regression guard for Issue #1673 (Epic R, finding R2).
//!
//! Before R2, EVERY row written by `DataWriter` allocated **two throwaway
//! `Vec`s**:
//! 1. a per-row body `Vec` (`build_merged_row_body` / `build_static_row_body`
//! returned a fresh `Vec<u8>`), then copied into `self.buffer`; and
//! 2. a gratuitous size-VInt `Vec` (`row_size_buf`) holding 1–9 bytes, also
//! copied into `self.buffer`.
//!
//! R2 replaces (1) with a reusable `row_scratch` buffer on the writer (cleared,
//! not reallocated, per row) and (2) with a direct `encode_unsigned(_, &mut
//! self.buffer)`. After warmup neither the row body nor the size VInt allocates.
//!
//! ## How the guard isolates exactly those two allocations
//!
//! Both throwaway buffers are **freshly-empty `Vec<u8>`s**. A `Vec<u8>`'s FIRST
//! growth always allocates `RawVec::MIN_NON_ZERO_CAP == 8` bytes (the minimum
//! non-zero capacity for a 1-byte element type), regardless of the final body
//! size — a body larger than 8 bytes reallocates 8→16→… afterward, but its FIRST
//! allocation is always exactly 8. So on `main` each written row produces two
//! extra 8-byte allocations (body Vec + `row_size_buf`) that R2 removes.
//!
//! After R2 the reused `row_scratch` is already grown past 8 after the first row,
//! and `self.buffer` grows at power-of-two sizes far above 8, so neither the body
//! nor the size VInt allocates 8 bytes per row anymore. The merge path's
//! `HashMap`/`Vec<MergedOp>` allocations are larger than 8 bytes (and, for a
//! pure-PK row, are never created), so they do not pollute the 8-byte count.
//!
//! To cancel one-time warmup + fixed per-writer overhead the guard measures the
//! **slope** of 8-byte allocations across two sizes (the #1046 rigorous form):
//! * regular path — one partition, M pure-PK-insert clustering rows, M ∈ {64, 256};
//! * static path — P static-only partitions (one static row each), P ∈ {32, 128}.
//!
//! Measured (Rust 1.88, this repo):
//! * regular: `main` slope 576 (≈3 eight-byte allocs/row), post-R2 192 (≈1/row);
//! * static: `main` slope 288 (≈3/static-row), post-R2 96 (≈1/row).
//!
//! Each path retains ONE pre-existing per-row 8-byte allocation that R2 does not
//! touch (an incidental writer-path `Vec<u8>` unrelated to the row body/size — the
//! regular residual is the clustering-prefix comparator path). The R2 signal is
//! the DROP of exactly the two removed buffers: 576→192 (−2/row) on the regular
//! path and 288→96 (−2/row) on the static path. `K` is set at the midpoint of the
//! `main` and post-R2 slopes on each path, so `main` FAILs and post-R2 PASSes with
//! a wide margin, and re-introducing either throwaway buffer trips the guard.
//!
//! Single `#[test]` in its own binary so the process-global counter observes only
//! this thread's allocations (regular then static, measured sequentially).
#![cfg(feature = "write-support")]
use std::alloc::{GlobalAlloc, Layout, System};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use cqlite_core::schema::{ClusteringColumn, ClusteringOrder, Column, KeyColumn, TableSchema};
use cqlite_core::storage::sstable::writer::SSTableWriter;
use cqlite_core::storage::write_engine::mutation::{
CellOperation, ClusteringKey, Mutation, PartitionKey, TableId,
};
use cqlite_core::types::Value;
use tempfile::TempDir;
struct CountingAlloc;
/// Total allocations in the counting window.
static ALLOCS: AtomicUsize = AtomicUsize::new(0);
/// Allocations/reallocations of EXACTLY 8 bytes — a freshly-empty `Vec<u8>`'s
/// first growth. On `main` this includes the per-row body Vec + `row_size_buf`
/// pair (see module docs); R2 removes both, dropping the per-row count by 2.
static ALLOCS_8B: AtomicUsize = AtomicUsize::new(0);
static COUNTING: AtomicBool = AtomicBool::new(false);
unsafe impl GlobalAlloc for CountingAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
if COUNTING.load(Ordering::Relaxed) {
ALLOCS.fetch_add(1, Ordering::Relaxed);
if layout.size() == 8 {
ALLOCS_8B.fetch_add(1, Ordering::Relaxed);
}
}
System.alloc(layout)
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
System.dealloc(ptr, layout);
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
if COUNTING.load(Ordering::Relaxed) {
ALLOCS.fetch_add(1, Ordering::Relaxed);
if new_size == 8 {
ALLOCS_8B.fetch_add(1, Ordering::Relaxed);
}
}
System.realloc(ptr, layout, new_size)
}
}
#[global_allocator]
static GLOBAL: CountingAlloc = CountingAlloc;
const KEYSPACE: &str = "issue1673_ks";
const TABLE: &str = "t";
/// `pk int` partition key + `ck int` clustering key + one regular `v int`.
fn regular_schema() -> TableSchema {
TableSchema {
keyspace: KEYSPACE.to_string(),
table: TABLE.to_string(),
partition_keys: vec![KeyColumn {
name: "pk".to_string(),
data_type: "int".to_string(),
position: 0,
}],
clustering_keys: vec![ClusteringColumn {
name: "ck".to_string(),
data_type: "int".to_string(),
position: 0,
order: ClusteringOrder::Asc,
}],
columns: vec![
Column {
name: "pk".to_string(),
data_type: "int".to_string(),
nullable: false,
default: None,
is_static: false,
},
Column {
name: "ck".to_string(),
data_type: "int".to_string(),
nullable: false,
default: None,
is_static: false,
},
Column {
name: "v".to_string(),
data_type: "int".to_string(),
nullable: true,
default: None,
is_static: false,
},
],
comments: HashMap::new(),
dropped_columns: HashMap::new(),
}
}
/// `pk int` partition key + one static `s int` (no clustering).
fn static_schema() -> TableSchema {
TableSchema {
keyspace: KEYSPACE.to_string(),
table: TABLE.to_string(),
partition_keys: vec![KeyColumn {
name: "pk".to_string(),
data_type: "int".to_string(),
position: 0,
}],
clustering_keys: vec![],
columns: vec![
Column {
name: "pk".to_string(),
data_type: "int".to_string(),
nullable: false,
default: None,
is_static: false,
},
Column {
name: "s".to_string(),
data_type: "int".to_string(),
nullable: true,
default: None,
is_static: true,
},
],
comments: HashMap::new(),
dropped_columns: HashMap::new(),
}
}
/// Count total + 8-byte allocations while writing ONE partition of `m`
/// pure-primary-key-insert clustering rows (empty ops → a row marker, no cells,
/// tiny body). Everything is built before the counting window.
fn regular_allocs(m: i32) -> (usize, usize) {
let dir = TempDir::new().expect("temp dir");
let schema = regular_schema();
let mut writer = SSTableWriter::new(dir.path().to_path_buf(), 1, &schema).expect("writer");
let mutations: Vec<Mutation> = (0..m)
.map(|i| {
Mutation::new(
TableId::new(KEYSPACE, TABLE),
PartitionKey::single("pk", Value::Integer(1)),
Some(ClusteringKey::single("ck", Value::Integer(i))),
Vec::new(), // pure-PK insert: row marker only, no cells
1_000_000,
None,
)
})
.collect();
let key = mutations[0].decorated_key(&schema).expect("decorated key");
let total_base = ALLOCS.load(Ordering::Relaxed);
let eight_base = ALLOCS_8B.load(Ordering::Relaxed);
COUNTING.store(true, Ordering::Relaxed);
writer.write_partition(key, mutations).expect("write");
COUNTING.store(false, Ordering::Relaxed);
(
ALLOCS.load(Ordering::Relaxed) - total_base,
ALLOCS_8B.load(Ordering::Relaxed) - eight_base,
)
}
/// Count total + 8-byte allocations while writing `p` static-only partitions
/// (one static row per partition, no clustering rows). Everything is built
/// before the counting window.
fn static_allocs(p: i32) -> (usize, usize) {
let dir = TempDir::new().expect("temp dir");
let schema = static_schema();
let mut writer = SSTableWriter::new(dir.path().to_path_buf(), 1, &schema).expect("writer");
let mut partitions: Vec<(_, Vec<Mutation>)> = (0..p)
.map(|i| {
let mutation = Mutation::new(
TableId::new(KEYSPACE, TABLE),
PartitionKey::single("pk", Value::Integer(i)),
None,
vec![CellOperation::Write {
column: "s".to_string(),
value: Value::Integer(i),
}],
1_000_000,
None,
);
let key = mutation.decorated_key(&schema).expect("decorated key");
(key, vec![mutation])
})
.collect();
// `write_partition` requires partitions in token order.
partitions.sort_by(|a, b| a.0.cmp(&b.0));
let total_base = ALLOCS.load(Ordering::Relaxed);
let eight_base = ALLOCS_8B.load(Ordering::Relaxed);
COUNTING.store(true, Ordering::Relaxed);
for (key, mutations) in partitions {
writer.write_partition(key, mutations).expect("write");
}
COUNTING.store(false, Ordering::Relaxed);
(
ALLOCS.load(Ordering::Relaxed) - total_base,
ALLOCS_8B.load(Ordering::Relaxed) - eight_base,
)
}
#[test]
fn row_scratch_kills_per_row_body_and_size_allocs() {
// ---- Regular clustering-row path ----
const M1: i32 = 64;
const M2: i32 = 256;
let (reg_total_1, reg_8b_1) = regular_allocs(M1);
let (_reg_total_2, reg_8b_2) = regular_allocs(M2);
let reg_slope_8b = reg_8b_2.saturating_sub(reg_8b_1);
eprintln!(
"#1673 regular: M1={M1} 8b={reg_8b_1} total={reg_total_1} | M2={M2} 8b={reg_8b_2} | \
8b-slope={reg_slope_8b}"
);
// Non-vacuous: writing rows must actually allocate SOMETHING.
assert!(
reg_total_1 > 0,
"#1673: writing {M1} clustering rows allocated nothing — measurement is vacuous"
);
// Each row allocates a body Vec + a `row_size_buf` (two 8-byte first-growths)
// on `main` PLUS one pre-existing per-row 8-byte alloc R2 does not touch, so
// the measured slope is ~576 (3/row). R2 removes the two throwaway buffers,
// dropping it to ~192 (1/row). K is the midpoint (384): `main` (576) FAILs,
// post-R2 (192) PASSes, each with a ~192 margin.
const K_REGULAR: usize = 384;
assert!(
reg_slope_8b <= K_REGULAR,
"#1673: regular-row 8-byte alloc slope regressed — {M1}->{M2} rows added \
{reg_slope_8b} eight-byte allocations (> K={K_REGULAR}). `main` measures ~576 \
(per-row body Vec + row_size_buf + 1 pre-existing); after R2 the reusable \
row_scratch + in-place size VInt remove the two throwaway buffers (~192)."
);
// ---- Static-row path (companion) ----
const P1: i32 = 32;
const P2: i32 = 128;
let (stat_total_1, stat_8b_1) = static_allocs(P1);
let (_stat_total_2, stat_8b_2) = static_allocs(P2);
let stat_slope_8b = stat_8b_2.saturating_sub(stat_8b_1);
eprintln!(
"#1673 static: P1={P1} 8b={stat_8b_1} total={stat_total_1} | P2={P2} 8b={stat_8b_2} | \
8b-slope={stat_slope_8b}"
);
assert!(
stat_total_1 > 0,
"#1673: writing {P1} static partitions allocated nothing — measurement is vacuous"
);
// Same shape as the regular path: `main` measures ~288 (3/static-row: body
// Vec + row_size_buf + 1 pre-existing); R2 drops it to ~96 (1/row). K is the
// midpoint (192): `main` (288) FAILs, post-R2 (96) PASSes, ~96 margin each.
const K_STATIC: usize = 192;
assert!(
stat_slope_8b <= K_STATIC,
"#1673: static-row 8-byte alloc slope regressed — {P1}->{P2} partitions added \
{stat_slope_8b} eight-byte allocations (> K={K_STATIC}). `main` measures ~288 \
(per-static-row body Vec + row_size_buf + 1 pre-existing); after R2 the reusable \
row_scratch + in-place size VInt remove the two throwaway buffers (~96)."
);
}