Skip to content

Commit 0cfcdf3

Browse files
jovferclaude
andcommitted
feat(ingress): Gorilla delta-of-delta timestamp encoding for QWP/WS ingress
Add the Gorilla codec (ingress/gorilla.rs, exact mirror of the egress decoder's bit format) and wire it through every QWP/WebSocket encode path: row-API buffer (publish + both replay builders), columnar chunk encoder (incl. designated timestamps, column and scalar), Arrow batch bodies (micro/nano/second-widening), and NumPy temporal dtypes (direct and unit-converting). WS frames now always set FLAG_GORILLA (0x04) and TIMESTAMP/TIMESTAMP_NANOS columns carry a per-column encoding discriminator (0x00 raw / 0x01 Gorilla), falling back to raw when a column has <= 2 non-null values or a delta-of-delta overflows i32 — matching the Java client's QwpWebSocketEncoder byte for byte. QWP/UDP datagrams and DATE columns stay raw. Frame-size estimates and the WS columnar size hint gain the discriminator byte so up-front try_reserve stays an upper bound. Java-golden wire fixtures are regenerated for the Gorilla-era format; the old->new byte delta was verified to be confined to flags/payload_len/timestamp sections only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d8dac23 commit 0cfcdf3

11 files changed

Lines changed: 869 additions & 143 deletions

File tree

questdb-rs/src/ingress.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ pub(crate) mod ndarr;
8686

8787
mod timestamp;
8888

89+
pub(crate) mod gorilla;
90+
8991
mod buffer;
9092
pub use buffer::*;
9193

questdb-rs/src/ingress/buffer/qwp.rs

Lines changed: 200 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use crate::Error;
3434
use crate::ErrorCode;
3535
use crate::error;
3636
use crate::ingress::decimal::DecimalView;
37+
use crate::ingress::gorilla;
3738
use crate::ingress::ndarr::{self, ArrayElementSealed};
3839
use crate::ingress::{ArrayElement, MAX_ARRAY_DIMS, NdArrayView, Timestamp};
3940
use std::collections::hash_map::RandomState;
@@ -3682,9 +3683,9 @@ impl QwpWsColumnarBuffer {
36823683
magic: *b"QWP1",
36833684
version,
36843685
flags: if defer_commit {
3685-
QWP_FLAG_DELTA_SYMBOL_DICT | QWP_FLAG_DEFER_COMMIT
3686+
QWP_FLAG_DELTA_SYMBOL_DICT | QWP_FLAG_DEFER_COMMIT | QWP_FLAG_GORILLA
36863687
} else {
3687-
QWP_FLAG_DELTA_SYMBOL_DICT
3688+
QWP_FLAG_DELTA_SYMBOL_DICT | QWP_FLAG_GORILLA
36883689
},
36893690
table_count,
36903691
payload_len: checked_qwp_u32(
@@ -4576,7 +4577,9 @@ impl QwpWsColumnValues {
45764577
Self::I64 { .. } | Self::F64 { .. } => row_count.saturating_mul(8),
45774578
Self::F32 { .. } => row_count.saturating_mul(4),
45784579
Self::TimestampMicros { cells } | Self::TimestampNanos { cells } => {
4579-
cells.len().saturating_mul(8)
4580+
// 1 encoding-discriminator byte + worst-case raw payload;
4581+
// the Gorilla payload is never larger than raw.
4582+
1usize.saturating_add(cells.len().saturating_mul(8))
45804583
}
45814584
Self::String { cells, data } => (cells.len() + 1).saturating_mul(4) + data.len(),
45824585
Self::Symbol { cells, .. } => cells
@@ -4776,9 +4779,9 @@ impl QwpWsColumnValues {
47764779
Ok(())
47774780
}
47784781
Self::TimestampMicros { cells } | Self::TimestampNanos { cells } => {
4779-
for cell in cells {
4780-
out.extend_from_slice(&cell.value.to_le_bytes());
4781-
}
4782+
gorilla::write_temporal_column(out, cells.len(), || {
4783+
cells.iter().map(|cell| cell.value)
4784+
});
47824785
Ok(())
47834786
}
47844787
Self::String { cells, data } => {
@@ -5110,6 +5113,11 @@ fn type_mismatch_error_ws(entry_name: &[u8]) -> crate::Error {
51105113
const QWP_FLAG_DELTA_SYMBOL_DICT: u8 = 0x08;
51115114
#[cfg(feature = "_sender-qwp-ws")]
51125115
const QWP_FLAG_DEFER_COMMIT: u8 = 0x01;
5116+
/// Message-header flag: timestamp columns carry a per-column encoding
5117+
/// discriminator (raw vs Gorilla delta-of-delta). Matches the Java client's
5118+
/// `QwpConstants.FLAG_GORILLA`.
5119+
#[cfg(feature = "_sender-qwp-ws")]
5120+
const QWP_FLAG_GORILLA: u8 = 0x04;
51135121

51145122
/// Connection-scoped global symbol dictionary used by the QWP/WebSocket
51155123
/// transport's delta-symbol-dict mode.
@@ -5618,6 +5626,7 @@ impl QwpBuffer {
56185626
&planner.cells,
56195627
&planner.symbol_dict,
56205628
&self.value_bytes,
5629+
true,
56215630
out,
56225631
)?;
56235632
}
@@ -5628,7 +5637,7 @@ impl QwpBuffer {
56285637
let header = QwpMessageHeader {
56295638
magic: *b"QWP1",
56305639
version,
5631-
flags: QWP_FLAG_DELTA_SYMBOL_DICT,
5640+
flags: QWP_FLAG_DELTA_SYMBOL_DICT | QWP_FLAG_GORILLA,
56325641
table_count,
56335642
payload_len: checked_qwp_u32(out.len() - payload_start, "WS message payload length")?,
56345643
};
@@ -5752,6 +5761,7 @@ impl QwpBuffer {
57525761
&planner.cells,
57535762
&planner.symbol_dict,
57545763
&self.value_bytes,
5764+
true,
57555765
out,
57565766
)?;
57575767
}
@@ -5762,7 +5772,7 @@ impl QwpBuffer {
57625772
let header = QwpMessageHeader {
57635773
magic: *b"QWP1",
57645774
version,
5765-
flags: QWP_FLAG_DELTA_SYMBOL_DICT,
5775+
flags: QWP_FLAG_DELTA_SYMBOL_DICT | QWP_FLAG_GORILLA,
57665776
table_count,
57675777
payload_len: checked_qwp_u32(
57685778
out.len() - payload_start,
@@ -7174,6 +7184,7 @@ fn encode_row_group_from_scratch(
71747184
&planner.cells,
71757185
&planner.symbol_dict,
71767186
value_bytes,
7187+
false,
71777188
out,
71787189
)?;
71797190
}
@@ -7280,6 +7291,7 @@ fn encode_column_from_cells(
72807291
cells: &[CellRef],
72817292
symbol_dict: &[SymbolEntry],
72827293
value_bytes: &[u8],
7294+
use_gorilla: bool,
72837295
out: &mut Vec<u8>,
72847296
) -> crate::Result<()> {
72857297
let uses_null_bitmap = col.uses_null_bitmap(row_count);
@@ -7418,8 +7430,16 @@ fn encode_column_from_cells(
74187430
}
74197431

74207432
ColumnKind::TimestampMicros | ColumnKind::TimestampNanos => {
7421-
for cell in CellIter::new(cells, col.cell_head) {
7422-
if let ValueRef::TimestampMicros(v) | ValueRef::TimestampNanos(v) = cell.value {
7433+
let values = || {
7434+
CellIter::new(cells, col.cell_head).filter_map(|cell| match cell.value {
7435+
ValueRef::TimestampMicros(v) | ValueRef::TimestampNanos(v) => Some(v),
7436+
_ => None,
7437+
})
7438+
};
7439+
if use_gorilla {
7440+
gorilla::write_temporal_column(out, col.non_null_count as usize, values);
7441+
} else {
7442+
for v in values() {
74237443
out.extend_from_slice(&v.to_le_bytes());
74247444
}
74257445
}
@@ -9409,6 +9429,176 @@ mod tests {
94099429
);
94109430
}
94119431

9432+
/// Walk a single-table, two-column WS frame (dense i64 column first,
9433+
/// designated timestamp last) and return the timestamp column's
9434+
/// encoding discriminator plus its decoded values.
9435+
///
9436+
/// Gated on `_egress` too: the reference decoder lives behind that
9437+
/// feature and sender-only CI combos must still compile `cargo test`.
9438+
#[cfg(all(feature = "_sender-qwp-ws", feature = "_egress"))]
9439+
fn ws_two_column_ts_payload(
9440+
message: &[u8],
9441+
row_count: usize,
9442+
first_col_type: u8,
9443+
ts_type: u8,
9444+
) -> (u8, Vec<i64>) {
9445+
let (_, _, mut pos) = ws_delta_entries(message);
9446+
let _table_name = read_test_bytes(message, &mut pos);
9447+
assert_eq!(read_test_varint(message, &mut pos) as usize, row_count);
9448+
assert_eq!(
9449+
read_test_varint(message, &mut pos),
9450+
2,
9451+
"expected two columns"
9452+
);
9453+
let _first_name = read_test_bytes(message, &mut pos);
9454+
assert_eq!(message[pos], first_col_type);
9455+
pos += 1;
9456+
let _ts_name = read_test_bytes(message, &mut pos);
9457+
assert_eq!(message[pos], ts_type);
9458+
pos += 1;
9459+
9460+
// First column: sentinel-dense i64 (null_flag 0x00 + row_count * 8).
9461+
assert_eq!(message[pos], 0);
9462+
pos += 1 + row_count * 8;
9463+
9464+
// Timestamp column: null_flag, then encoding discriminator, then payload.
9465+
assert_eq!(message[pos], 0, "designated ts never uses a null bitmap");
9466+
pos += 1;
9467+
let disc = message[pos];
9468+
pos += 1;
9469+
let values: Vec<i64> = match disc {
9470+
0x00 => (0..row_count)
9471+
.map(|i| {
9472+
i64::from_le_bytes(message[pos + i * 8..pos + (i + 1) * 8].try_into().unwrap())
9473+
})
9474+
.collect(),
9475+
0x01 => {
9476+
let s0 = i64::from_le_bytes(message[pos..pos + 8].try_into().unwrap());
9477+
let s1 = i64::from_le_bytes(message[pos + 8..pos + 16].try_into().unwrap());
9478+
let mut vals = vec![s0, s1];
9479+
let mut dec =
9480+
crate::egress::gorilla::GorillaDecoder::new(s0, s1, &message[pos + 16..]);
9481+
for _ in 2..row_count {
9482+
vals.push(dec.decode_next().unwrap());
9483+
}
9484+
vals
9485+
}
9486+
other => panic!("unknown temporal discriminator 0x{other:02X}"),
9487+
};
9488+
(disc, values)
9489+
}
9490+
9491+
#[cfg(all(feature = "_sender-qwp-ws", feature = "_egress"))]
9492+
#[test]
9493+
fn qwp_ws_message_gorilla_encodes_designated_ts() {
9494+
let mut buf = QwpBuffer::new(127);
9495+
let base = 1_700_000_000_000_000_000i64;
9496+
let expected: Vec<i64> = (0..16).map(|i| base + i * 1_000_000).collect();
9497+
for (i, &ts) in expected.iter().enumerate() {
9498+
buf.table("trades")
9499+
.unwrap()
9500+
.column_i64("qty", i as i64)
9501+
.unwrap();
9502+
buf.at(TimestampNanos::new(ts)).unwrap();
9503+
}
9504+
let mut scratch = QwpWsEncodeScratch::new();
9505+
let mut global_dict = SymbolGlobalDict::new();
9506+
buf.encode_ws_message(&mut scratch, &mut global_dict, QWP_VERSION_1)
9507+
.unwrap();
9508+
let message = &scratch.message;
9509+
assert_eq!(message[5] & QWP_FLAG_GORILLA, QWP_FLAG_GORILLA);
9510+
let (disc, values) = ws_two_column_ts_payload(
9511+
message,
9512+
expected.len(),
9513+
QWP_TYPE_LONG,
9514+
QWP_TYPE_TIMESTAMP_NANOS,
9515+
);
9516+
assert_eq!(disc, 0x01, "regular intervals must gorilla-encode");
9517+
assert_eq!(values, expected);
9518+
}
9519+
9520+
#[cfg(all(feature = "_sender-qwp-ws", feature = "_egress"))]
9521+
#[test]
9522+
fn qwp_ws_message_gorilla_falls_back_to_raw_on_dod_overflow() {
9523+
let mut buf = QwpBuffer::new(127);
9524+
let base = 1_700_000_000_000_000_000i64;
9525+
// Jump of ~5 seconds in nanos: DoD ≈ 5e9 > i32::MAX → whole column raw.
9526+
let expected = vec![
9527+
base,
9528+
base + 1_000,
9529+
base + 5_000_000_000,
9530+
base + 5_000_001_000,
9531+
];
9532+
for (i, &ts) in expected.iter().enumerate() {
9533+
buf.table("trades")
9534+
.unwrap()
9535+
.column_i64("qty", i as i64)
9536+
.unwrap();
9537+
buf.at(TimestampNanos::new(ts)).unwrap();
9538+
}
9539+
let mut scratch = QwpWsEncodeScratch::new();
9540+
let mut global_dict = SymbolGlobalDict::new();
9541+
buf.encode_ws_message(&mut scratch, &mut global_dict, QWP_VERSION_1)
9542+
.unwrap();
9543+
let message = &scratch.message;
9544+
assert_eq!(message[5] & QWP_FLAG_GORILLA, QWP_FLAG_GORILLA);
9545+
let (disc, values) = ws_two_column_ts_payload(
9546+
message,
9547+
expected.len(),
9548+
QWP_TYPE_LONG,
9549+
QWP_TYPE_TIMESTAMP_NANOS,
9550+
);
9551+
assert_eq!(disc, 0x00, "i32 DoD overflow must fall back to raw");
9552+
assert_eq!(values, expected);
9553+
}
9554+
9555+
#[cfg(all(feature = "_sender-qwp-ws", feature = "_egress"))]
9556+
#[test]
9557+
fn qwp_ws_replay_message_gorilla_encodes_designated_ts() {
9558+
let mut buf = QwpBuffer::new(127);
9559+
let base = 1_700_000_000_000_000_000i64;
9560+
let expected: Vec<i64> = (0..16).map(|i| base + i * 1_000_000).collect();
9561+
for (i, &ts) in expected.iter().enumerate() {
9562+
buf.table("trades")
9563+
.unwrap()
9564+
.column_i64("qty", i as i64)
9565+
.unwrap();
9566+
buf.at(TimestampNanos::new(ts)).unwrap();
9567+
}
9568+
let mut scratch = QwpWsEncodeScratch::new();
9569+
let mut global_dict = SymbolGlobalDict::new();
9570+
buf.encode_ws_replay_message(&mut scratch, &mut global_dict, QWP_VERSION_1)
9571+
.unwrap();
9572+
let message = &scratch.message;
9573+
assert_eq!(message[5] & QWP_FLAG_GORILLA, QWP_FLAG_GORILLA);
9574+
let (disc, values) = ws_two_column_ts_payload(
9575+
message,
9576+
expected.len(),
9577+
QWP_TYPE_LONG,
9578+
QWP_TYPE_TIMESTAMP_NANOS,
9579+
);
9580+
assert_eq!(disc, 0x01);
9581+
assert_eq!(values, expected);
9582+
}
9583+
9584+
#[test]
9585+
fn qwp_udp_datagram_stays_raw_without_gorilla_flag() {
9586+
let mut buf = QwpBuffer::new(127);
9587+
for i in 0..8i64 {
9588+
buf.table("trades").unwrap().column_i64("qty", i).unwrap();
9589+
buf.at(TimestampNanos::new(1_000_000 * i + 1)).unwrap();
9590+
}
9591+
let datagrams = buf.encode_datagrams(64 * 1024).unwrap();
9592+
assert_eq!(datagrams.len(), 1);
9593+
assert_eq!(
9594+
datagrams[0][5] & 0x04,
9595+
0,
9596+
"UDP frames must not set FLAG_GORILLA"
9597+
);
9598+
// Raw layout (no discriminators) must still parse cleanly.
9599+
decode_datagram(&datagrams[0]).unwrap();
9600+
}
9601+
94129602
#[cfg(feature = "_sender-qwp-ws")]
94139603
#[test]
94149604
fn symbol_dict_enforces_entry_cap() {

0 commit comments

Comments
 (0)