@@ -34,6 +34,7 @@ use crate::Error;
3434use crate :: ErrorCode ;
3535use crate :: error;
3636use crate :: ingress:: decimal:: DecimalView ;
37+ use crate :: ingress:: gorilla;
3738use crate :: ingress:: ndarr:: { self , ArrayElementSealed } ;
3839use crate :: ingress:: { ArrayElement , MAX_ARRAY_DIMS , NdArrayView , Timestamp } ;
3940use 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 {
51105113const QWP_FLAG_DELTA_SYMBOL_DICT : u8 = 0x08 ;
51115114#[ cfg( feature = "_sender-qwp-ws" ) ]
51125115const 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