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
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
//! Conservative pre-materialization Arrow payload-byte estimator (issue #2825).
//!
//! # Why this exists
//!
//! The `cqlite-flight` egress path finishes a record batch on **row count**
//! alone, so a batch's byte size is `batch_size × row_width` — an unbounded
//! function of schema shape. Bounding it needs a byte decision made **while rows
//! accumulate**, before any `RecordBatch` exists: building the batch to
//! discover it is too big defeats the purpose, so
//! `RecordBatch::get_array_memory_size()` cannot be the production trigger (it
//! is only readable *after* every value has been allocated and copied).
//!
//! [`estimate_arrow_row_bytes`] supplies that pre-batch number: given the
//! authoritative projected [`ColumnInfo`] set and one decoded [`QueryRow`], it
//! reports an **upper bound** on that row's contribution to the resulting Arrow
//! batch's *payload* bytes.
//!
//! # Currency: payload bytes, not `get_array_memory_size()`
//!
//! "Payload bytes" means the sum of Arrow buffer **lengths**, recursively
//! including child data — exactly what [`arrow_payload_bytes`] measures.
//! `get_array_memory_size()` reports buffer **capacity**, which the batch
//! construction path grows by power-of-two doubling, so it runs up to ~2× the
//! payload (measured 1.72–1.80× on realistic shapes). Payload bytes are
//! estimable from the rows in hand, monotonic in row count, and stable across
//! arrow versions and allocator policy; capacity is none of those. Consumers
//! that need a capacity figure convert with the published capacity factor
//! (`cqlite_flight::batch_bytes::BATCH_BYTES_CAPACITY_FACTOR`).
//!
//! # The charging model, from first principles
//!
//! Arrow buffer **lengths are exact** — measured against this tree's arrow 53, a
//! one-row `Int32` column is 4 bytes, a nine-row `Boolean` column is
//! `ceil(9/8) = 2` bytes, and a null buffer is `ceil(n/8)` (absent entirely when
//! a column has no nulls). Nothing is rounded up to an allocator quantum, so the
//! estimate is built from the three real per-slot costs and one small per-column
//! residual — **never** from a large fudge charged per cell:
//!
//! | term | charged | covers |
//! |---|---|---|
//! | `ARROW_VALIDITY_BYTES` | every slot | the slot's validity **bit**; `n` bytes ≥ `ceil(n/8)` |
//! | content | every slot | the slot's data-buffer bytes |
//! | `ARROW_CELL_OVERHEAD_BYTES` | variable-width slots | its offsets entry + the buffer's trailing `n+1`-th entry |
//! | `ARROW_COLUMN_SLACK_BYTES` | once per **column whose builder materializes childless array nodes** | those nodes' empty offsets buffers |
//!
//! The per-cell terms are therefore tight (a 1000-element `list<int>` estimates
//! ~1.2× its realized payload, not ~9×), and the residual is charged ONLY where
//! a childless node can exist — the flat `list`/`set`/`map` builders, whose
//! `MapArray`/`ListArray` always materialize a rendered `Utf8` child (see
//! [`column_slack_bytes`]). A fixed-width column materializes exactly one array
//! node with one data buffer, no offsets and no children, so it pays no residual
//! at all: an `int` column costs `1 + 4 = 5` B/row against ~4.1 B/row realized.
//! A 100-column `int` row therefore estimates 500 B and a full 8192-row batch
//! 4,096,000 B — still inside the 4 MiB default, so the ROW-cap keeps binding on
//! wide narrow-cell schemas up to 102 `int` columns (issue #2825 review C1;
//! charging the residual per column put that cliff at 40 columns).
//!
//! # Conservatism is a contract, not an aspiration
//!
//! `Σ estimate_arrow_row_bytes(columns, row) >= arrow_payload_bytes(batch)` for
//! every shape, enforced by the property test in `arrow_size_tests.rs` over a
//! corpus covering fixed-width columns, `text`, `blob`, `list`/`set`, `map`,
//! `tuple`/UDT, JSON, deeply nested empty collections, all-null rows, empty
//! strings and empty collections.
//!
//! **The production consumer of that contract is the FUSED accounting, not this
//! function (issue #3552).** `cqlite-flight`'s two `do_get` row routes take each
//! row's width from [`super::ArrowRowAccumulator::stage`], which charges it from
//! the cells it resolved for the Arrow build pass instead of re-resolving them
//! here; `estimate_arrow_row_bytes` remains the aggregate route's estimator, this
//! module's tested surface, and the ORACLE the fused width is pinned against.
//! Both charge through the private `charge_row` core and differ ONLY in cell
//! resolution, and their per-row equality over the SHARED shape corpus — absent
//! columns, duplicate output columns and the saturating fan-out included — is
//! asserted by `arrow_row_accumulator`'s
//! `fused_width_equals_the_standalone_estimate_over_the_shape_corpus`. That test
//! is what transfers the conservatism contract above to the fused path: weaken it
//! and the contract stops covering production. Read the `# Cross-issue
//! dependency` section below with that substitution in mind. The three pre-existing per-`Value` estimators
//! are all *under*-estimators for this purpose (see the issue-#2825 design §d):
//! `Value::size_estimate` models the SERIALIZED size (a 1-byte vint prefix where
//! Arrow spends a 4-byte offset plus a validity bit), and
//! `memory::estimate_value_size` / `Memtable::estimate_value_size` are
//! content-only. None is Arrow-aware, and an under-estimator cannot found a
//! memory bound.
//!
//! # Cross-issue dependency: a per-stream MEMORY BOUND rests on that contract
//!
//! `cqlite-flight`'s per-stream in-flight egress ceiling (issue #2821,
//! `cqlite-flight/src/egress_credit.rs`) reserves credit for a batch BEFORE it is
//! materialized, converting THIS estimate into Arrow capacity bytes with
//! `worst_case_batch_capacity_bytes`. The reservation is a true upper bound on
//! the realized `get_array_memory_size()` ONLY while the conservatism above
//! holds; weaken it and that published memory bound is silently voided (the
//! governor then fails closed with a terminal internal error rather than
//! exceeding its pool, but the stream breaks). Any change here that could make
//! the estimate non-conservative must be made together with that consumer.
//!
//! # No-heuristics (issue #28)
//!
//! Width is derived from the authoritative `ColumnInfo` CQL/flat types plus the
//! already-decoded [`Value`]s. Nothing is inferred from byte patterns and no
//! decode decision is influenced.
//!
//! # Hardening
//!
//! The walk is **iterative** (an explicit worklist) and every arithmetic step is
//! saturating. Two budgets, both reset **per column**, bound the work:
//!
//! * [`MAX_ESTIMATE_NODES`] caps the **branching** slots — those that can queue
//! further slots. Only a branching slot ever enters the worklist, so the
//! worklist can never hold more entries than the budget (a stronger form of
//! the pre-push fan-out check it replaces), and a value nested deeper than the
//! budget fails closed.
//! * [`MAX_ESTIMATE_LEAF_SLOTS`] caps the leaf slots charged inline. A leaf costs
//! no worklist entry and no structural node, so a collection's ELEMENT COUNT —
//! the one dimension a legal Cassandra row pushes to 65,535 — no longer
//! consumes the structural budget (issue #2825 review C2): a 65,535-entry
//! `map<text,text>` spends ONE node rather than 131,070, and is estimated
//! exactly instead of failing closed and degrading the stream to one row per
//! batch for the rest of the scan.
//!
//! Per-column budgets mean one wide column can no longer starve the columns
//! after it. A row that exhausts either budget, or whose widths would overflow
//! `usize`, **fails closed** to `usize::MAX` — which trips the byte-cap and cuts
//! the batch, the safe direction. It never panics and never hangs.
use crate;
use crateCqlType;
use crate;
// Column shape resolution (`column_shape`, `column_slack_bytes`, `branches`)
// and the rendered-representation bounds (`charge_rendered`,
// `json_render_bytes`) live in child modules so this file stays under the
// campsite threshold (epic #1116). A child module sees this module's private
// items, and this module re-exposes theirs to each other.
// Per-ROW charging over a resolved column set: the shared `charge_row` loop and
// the fused accounting's `PreparedColumns` cache (issue #3552). Its own file for
// the same reason as its two siblings — and re-exported below under its OWN
// visibility, so `arrow_row_accumulator`'s import path is unchanged.
use charge_row;
use RENDER_CONTAINER_BYTES;
// The estimator ⇄ accumulator seam, re-exported at its unchanged
// `pub(in crate::export)` visibility (issue #3552 review N5).
pub use PreparedColumns;
use ;
// ============================================================================
// Structural constants
// ============================================================================
//
// Deliberately NOT part of the crate's public surface (issue #2825 review N4):
// these are tuning parameters of the estimate, not a contract. The public
// surface is `estimate_arrow_row_bytes` / `arrow_payload_bytes` /
// `MAX_ESTIMATE_NODES`.
/// Width of one Arrow 32-bit offset entry (`Utf8`/`Binary`/`List`/`Map`).
const ARROW_OFFSET_BYTES: usize = 4;
/// Validity charged per Arrow slot. Arrow spends one **bit** per slot, so a
/// buffer over `n` slots is `ceil(n / 8)` bytes long; charging one whole byte
/// per slot covers that for every `n` with room to spare.
const ARROW_VALIDITY_BYTES: usize = 1;
/// Per-cell structural overhead of a **variable-width** Arrow slot, charged on
/// top of the universal per-slot validity byte.
///
/// Derivation (not a fitted constant): an offsets buffer over `n` slots is
/// `(n + 1) * 4` bytes — one entry per slot plus a trailing entry. Charging
/// **two** entries per slot covers the trailing entry however few slots the
/// buffer has, with `n = 1` the tight case: realized
/// `4 * (1 + 1) + ceil(1/8) = 9`, charged `4 + 4 + 1 = 9`. The extra validity
/// byte here (the second one a variable-width slot pays) is deliberate margin.
const ARROW_CELL_OVERHEAD_BYTES: usize = 2 * ARROW_OFFSET_BYTES + ARROW_VALIDITY_BYTES;
/// Residual slack charged ONCE per row for a column that materializes Arrow
/// array nodes corresponding to no value slot — never per cell, per element or
/// per field, and **never for a column that has no such node** (see
/// [`column_slack_bytes`]).
///
/// Derivation: each childless node still carries an empty 4-byte offsets buffer.
/// The tight case is the flat `DataType::Map` builder, whose `MapArray` always
/// materializes a key `Utf8` and a value `Utf8` child even for a cell with zero
/// entries: `2 × 4 = 8` bytes with no slot to attach them to. (The high-fidelity
/// path charges such nodes explicitly — see `charge_cql`'s empty-collection
/// rule — so this stays a residual, not the mechanism.)
///
/// Charged per row because the accumulate-as-you-push cap needs a per-row
/// number and a per-batch term has nowhere to live; per COLUMN rather than per
/// SLOT so it cannot multiply by a cell's element count.
const ARROW_COLUMN_SLACK_BYTES: usize = 2 * ARROW_OFFSET_BYTES;
/// Maximum number of **branching** slots one COLUMN's estimate may queue — a
/// slot that can fan out into further slots (a collection, a map, a struct, or a
/// rendered container).
///
/// Leaf slots do not count against it (they never enter the worklist — see
/// [`MAX_ESTIMATE_LEAF_SLOTS`]), so this bounds the value's *structure*: nesting
/// depth and container-of-container fan-out, neither of which a legitimate
/// Cassandra schema drives anywhere near 65,536. A column exceeding it fails
/// closed to `usize::MAX` (cut the batch) instead of spending unbounded time,
/// and because only branching slots are queued the worklist itself can never
/// exceed this many entries.
///
/// Reset per column (issue #2825 review C2): one wide column can no longer
/// starve the columns after it.
pub const MAX_ESTIMATE_NODES: usize = 65_536;
/// Maximum number of **leaf** slots one COLUMN's estimate may charge inline.
///
/// A leaf costs no worklist entry, so this is purely a linear-work bound: it
/// exists so a `Value` tree far larger than any decoded Cassandra row still
/// terminates in bounded time. Sized ~8× above the largest legal single cell
/// (Cassandra's classic 65,535-element collection limit is 131,070 leaf slots
/// for a `map`), so the shapes review C2 called out — one near-limit collection,
/// or several thousand-element collections per row — are estimated exactly
/// rather than failing closed.
pub const MAX_ESTIMATE_LEAF_SLOTS: usize = 1 << 20;
// ============================================================================
// Public API
// ============================================================================
/// Upper bound, in Arrow **payload** bytes, on `row`'s contribution to a batch
/// built from `columns` by [`rows_to_record_batch`](super::rows_to_record_batch).
///
/// Walks `columns` (never the whole `row.values` map — an unprojected cell never
/// reaches the batch) and resolves each cell exactly as `transpose_columns`
/// does, then charges, per Arrow slot, the slot's structural overhead plus its
/// value-driven content bytes.
///
/// Returns `usize::MAX` when the row exhausts [`MAX_ESTIMATE_NODES`] or its
/// widths saturate — a fail-closed signal that cuts the batch.
///
/// # Example
///
/// ```
/// # use std::collections::HashMap;
/// # use std::sync::Arc;
/// # use cqlite_core::export::estimate_arrow_row_bytes;
/// # use cqlite_core::query::{ColumnInfo, QueryRow};
/// # use cqlite_core::types::{DataType, Value};
/// # use cqlite_core::schema::CqlType;
/// # use cqlite_core::RowKey;
/// let columns = vec![ColumnInfo {
/// name: "b".into(),
/// data_type: DataType::Blob,
/// nullable: true,
/// position: 0,
/// table_name: None,
/// cql_type: Some(CqlType::Blob),
/// }];
/// let row_with = |n: usize| {
/// let mut values: HashMap<Arc<str>, Value> = HashMap::new();
/// values.insert(Arc::from("b"), Value::Blob(vec![0u8; n].into()));
/// QueryRow::with_interned_values(RowKey::new(Vec::new()), values)
/// };
/// // Width-sensitive: the estimates differ by at least the content difference.
/// assert!(
/// estimate_arrow_row_bytes(&columns, &row_with(1024))
/// - estimate_arrow_row_bytes(&columns, &row_with(16))
/// >= 1024 - 16
/// );
/// ```
/// Sum of Arrow buffer **lengths** across `batch`, recursively including child
/// data — the cap's currency, and the oracle [`estimate_arrow_row_bytes`] must
/// never under-count.
///
/// Distinct from `RecordBatch::get_array_memory_size()`, which sums buffer
/// *capacity* (allocator growth policy) and so reports up to ~2× this value.
/// Public because the byte-cap is normatively denominated in this quantity: the
/// flight-side tests and issue #2821's per-stream ceiling both need to measure
/// it, and duplicating the walk per consumer would let it drift.
// ============================================================================
// The iterative estimator
// ============================================================================
/// Iterative worklist over (slot shape, slot value) pairs.
///
/// One popped item is exactly one Arrow array slot; children (collection
/// elements, map entries, struct fields) are charged as further slots. Only
/// BRANCHING children are queued — a leaf child is charged where it is found —
/// so the worklist is bounded by [`MAX_ESTIMATE_NODES`] and is lazily allocated:
/// a row of scalar columns never heap-allocates.
/// Bytes a `Value::Text` contributes to a `Utf8` slot (`0` when absent/null).
///
/// Exact, not tripled: this is the STRICT typed path, where `build_string_array`
/// borrows the `&str` after a non-lossy `str::from_utf8` and hard-errors on
/// invalid UTF-8 (so no replacement-character expansion is possible). The lossy
/// rendered path charges 3× instead — see `charge_rendered`.
/// Bytes a `Value::Blob` contributes to a `Binary` slot (`0` when absent/null).