@@ -927,3 +927,236 @@ async fn reader_writer_positions_aligned_through_multiple_files_and_records() {
927927 let parent = trace_span ! ( "reader_writer_positions_aligned_through_multiple_files_and_records" ) ;
928928 fut. instrument ( parent. or_current ( ) ) . await ;
929929}
930+
931+ #[ tokio:: test]
932+ async fn ledger_total_buffer_size_decrement_should_saturate_not_underflow_issue_21683 ( ) {
933+ // Failing demonstration of Vector #21683.
934+ //
935+ // CORRECT INVARIANT (asserted here): decrementing `total_buffer_size` by more
936+ // than its current value must saturate at 0. `Ledger::decrement_total_buffer_size`
937+ // instead uses an unsaturated `fetch_sub`, so the in-memory atomic wraps
938+ // toward 2^64 — after which `is_buffer_full()` returns true forever and the
939+ // writer deadlocks permanently. PR #23561 only fixed the metrics reporter,
940+ // not this control-path atomic.
941+ //
942+ // This test FAILS against current Vector (the failure IS the bug): in release
943+ // the atomic wraps so the saturation assert fails; in a debug build the
944+ // `prev - amount` in decrement_total_buffer_size's own `trace!` panics on the
945+ // same underflow. It will pass once the decrement saturates.
946+ with_temp_dir ( |dir| {
947+ let data_dir = dir. to_path_buf ( ) ;
948+ async move {
949+ let ( _writer, _reader, ledger) =
950+ create_default_buffer_v2 :: < _ , SizedRecord > ( data_dir) . await ;
951+
952+ ledger. increment_total_buffer_size ( 10 ) ;
953+ assert_eq ! ( ledger. get_total_buffer_size( ) , 10 ) ;
954+
955+ // Decrement by MORE than the current size. Correct behavior is to
956+ // saturate at 0.
957+ ledger. decrement_total_buffer_size ( 11 ) ;
958+
959+ let after = ledger. get_total_buffer_size ( ) ;
960+ assert_eq ! (
961+ after, 0 ,
962+ "#21683: total_buffer_size decremented below zero must saturate at \
963+ 0, but was {after} (~2^64 wrap) — is_buffer_full() then never \
964+ returns false -> permanent writer deadlock"
965+ ) ;
966+ }
967+ } )
968+ . await ;
969+ }
970+
971+ #[ tokio:: test]
972+ async fn get_total_records_should_be_zero_on_drained_buffer_issue_21683_metrics ( ) {
973+ // Failing demonstration (sibling of #21683): `Ledger::get_total_records`
974+ // computes `next_writer_id.wrapping_sub(last_reader_id) - 1`.
975+ //
976+ // CORRECT INVARIANT (asserted here): a fully drained buffer (writer and reader
977+ // at the same record ID) holds 0 records. The trailing `- 1` underflows
978+ // instead: in release it wraps to ~u64::MAX (which `synchronize_buffer_usage`
979+ // then feeds into the buffer event-count metric on the next restart, reporting
980+ // ~1.8e19 events for an empty buffer); in a debug build it panics on the same
981+ // subtraction. Either way this test FAILS until the count saturates.
982+ with_temp_dir ( |dir| {
983+ let data_dir = dir. to_path_buf ( ) ;
984+ async move {
985+ let ( _w, _r, ledger) = create_default_buffer_v2 :: < _ , SizedRecord > ( data_dir) . await ;
986+ // Simulate a drained buffer: the reader has caught up to the writer.
987+ unsafe {
988+ ledger. state ( ) . unsafe_set_writer_next_record_id ( 42 ) ;
989+ }
990+ unsafe {
991+ ledger. state ( ) . unsafe_set_reader_last_record_id ( 42 ) ;
992+ }
993+ let total = ledger. get_total_records ( ) ;
994+ assert_eq ! (
995+ total, 0 ,
996+ "get_total_records 0-1 underflow: a drained buffer (next==last) \
997+ must report 0 records, but yields {total} (~2^64); \
998+ synchronize_buffer_usage then reports ~1.8e19 buffer events on restart"
999+ ) ;
1000+ }
1001+ } )
1002+ . await ;
1003+ }
1004+
1005+ #[ tokio:: test]
1006+ async fn writer_drop_without_flush_should_not_lose_buffered_events_issue_24948 ( ) {
1007+ // Failing demonstration of Vector #24948 (config-reload silent data loss).
1008+ //
1009+ // CORRECT INVARIANT (asserted here): events accepted by the writer must
1010+ // survive a writer teardown and be recoverable when the buffer is reopened.
1011+ // Records sit in the writer's in-memory TrackingBufWriter (256KB) until
1012+ // flush(); `BufferWriter::Drop` calls close() (mark_writer_done + notify) but
1013+ // NOT flush(). During a config reload the old writer is dropped while events
1014+ // are still buffered, so they never reach the data file and the ledger's
1015+ // writer_next_record is never advanced. This test FAILS (reopened buffer has
1016+ // 0 records, not 3) — that loss is the bug.
1017+ with_temp_dir ( |dir| {
1018+ let data_dir = dir. to_path_buf ( ) ;
1019+ async move {
1020+ {
1021+ let ( mut writer, _reader, _ledger) =
1022+ create_default_buffer_v2 :: < _ , SizedRecord > ( data_dir. clone ( ) ) . await ;
1023+ for _ in 0 ..3u8 {
1024+ writer
1025+ . write_record ( SizedRecord :: new ( 64 ) )
1026+ . await
1027+ . expect ( "write should not fail" ) ;
1028+ }
1029+ // No flush(): Drop simulates config-reload teardown (close, not flush).
1030+ drop ( writer) ;
1031+ }
1032+ // Let the finalizer task observe the dropped reader and release the lock.
1033+ tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( 500 ) ) . await ;
1034+
1035+ // Reopen the same buffer directory.
1036+ let ( _writer2, _reader2, ledger2) =
1037+ create_default_buffer_v2 :: < _ , SizedRecord > ( data_dir) . await ;
1038+
1039+ // #24948: the 3 events written before the un-flushed Drop must
1040+ // survive. They don't — the reopened buffer is empty (0 records), so
1041+ // this assertion FAILS, demonstrating the silent data loss.
1042+ assert_buffer_records ! ( ledger2, 3 ) ;
1043+ }
1044+ } )
1045+ . await ;
1046+ }
1047+
1048+ #[ tokio:: test]
1049+ async fn file_id_rollover_compare_should_be_wrap_aware_reader_932 ( ) {
1050+ // Failing demonstration: seek_to_next_record decides the reader is
1051+ // synchronized with the writer using a raw `reader_file_id > writer_file_id`
1052+ // (reader.rs ~932), which is NOT wrap-aware.
1053+ //
1054+ // CORRECT INVARIANT (asserted here): after a file-ID rollover where the writer
1055+ // has done MORE rotations than the reader, the comparison must NOT report the
1056+ // reader as having advanced past the writer. The writer wraps to a value
1057+ // SMALLER than the (behind) reader's ID, so the raw `>` wrongly concludes the
1058+ // reader is ahead and breaks the seek early. This test FAILS (raw compare says
1059+ // reader > writer) until the comparison becomes wrap-aware.
1060+ with_temp_dir ( |dir| {
1061+ let data_dir = dir. to_path_buf ( ) ;
1062+ async move {
1063+ let ( _w, _r, ledger) = create_default_buffer_v2 :: < _ , SizedRecord > ( data_dir) . await ;
1064+ let n = u32:: from ( MAX_FILE_ID ) ; // 6 in test builds; IDs cycle 0..n-1
1065+ // Writer does n+1 rotations -> wraps past the end back to file ID 1.
1066+ for _ in 0 ..( n + 1 ) {
1067+ ledger. state ( ) . increment_writer_file_id ( ) ;
1068+ }
1069+ // Reader does n-1 rotations (unacked) -> file ID n-1 (still pre-wrap).
1070+ for _ in 0 ..( n - 1 ) {
1071+ ledger. increment_unacked_reader_file_id ( ) ;
1072+ }
1073+ let ( reader_file_id, writer_file_id) = ledger. get_current_reader_writer_file_id ( ) ;
1074+ assert_eq ! (
1075+ ( reader_file_id, writer_file_id) ,
1076+ ( MAX_FILE_ID - 1 , 1 ) ,
1077+ "constructed post-rollover state: writer lapped to ID 1, reader behind at ID {}" ,
1078+ MAX_FILE_ID - 1
1079+ ) ;
1080+ // The writer performed MORE rotations (n+1) than the reader (n-1), so it
1081+ // is genuinely AHEAD. A wrap-aware comparison must therefore NOT report
1082+ // the reader as past the writer:
1083+ assert ! (
1084+ !( reader_file_id > writer_file_id) ,
1085+ "reader.rs:932 must use a wrap-aware comparison: after rollover the \
1086+ writer lapped to ID {writer_file_id} and is AHEAD of the reader at \
1087+ ID {reader_file_id}, so the reader is NOT past the writer. The raw \
1088+ `reader_file_id > writer_file_id` ({reader_file_id} > {writer_file_id}) \
1089+ is true, so seek_to_next_record wrongly concludes the reader advanced \
1090+ past the writer and stops early."
1091+ ) ;
1092+ }
1093+ } )
1094+ . await ;
1095+ }
1096+
1097+ #[ tokio:: test]
1098+ async fn delete_completed_data_file_size_delta_should_saturate_reader_524 ( ) {
1099+ // Failing demonstration of the reader.rs:524 unguarded subtraction.
1100+ //
1101+ // `delete_completed_data_file` computes `size_delta = metadata.len() - bytes_read`
1102+ // (reader.rs ~524) and then `decrement_total_buffer_size(size_delta)`.
1103+ //
1104+ // CORRECT INVARIANT (asserted here): when a data file is truncated externally
1105+ // (crash / filesystem fault) so `bytes_read > metadata.len()`, the size-delta
1106+ // must saturate at 0 rather than underflow. The raw subtraction underflows: in
1107+ // release it wraps to ~2^64, which is fed straight into the unsaturated
1108+ // `decrement_total_buffer_size` -> the #21683 total_buffer_size wrap ->
1109+ // permanent writer deadlock; in a debug build the subtraction panics. Either
1110+ // way this test FAILS, demonstrating the bug.
1111+ //
1112+ // Here we reproduce the exact computation on a real file: write 100 bytes,
1113+ // record bytes_read=100, truncate the file to 40, then `metadata.len() - bytes_read`.
1114+ with_temp_dir ( |dir| {
1115+ let data_dir = dir. to_path_buf ( ) ;
1116+ async move {
1117+ let path = data_dir. join ( "buffer-data-0.dat" ) ;
1118+ {
1119+ let mut f = tokio:: fs:: OpenOptions :: new ( )
1120+ . create ( true )
1121+ . write ( true )
1122+ . open ( & path)
1123+ . await
1124+ . expect ( "create should not fail" ) ;
1125+ f. write_all ( & [ 0u8 ; 100 ] )
1126+ . await
1127+ . expect ( "write should not fail" ) ;
1128+ f. flush ( ) . await . expect ( "flush should not fail" ) ;
1129+ }
1130+ // The reader accounted 100 bytes read from this data file.
1131+ let bytes_read: u64 = 100 ;
1132+ // External truncation (crash / FS fault) shrinks the file below bytes_read.
1133+ tokio:: fs:: OpenOptions :: new ( )
1134+ . write ( true )
1135+ . open ( & path)
1136+ . await
1137+ . expect ( "open should not fail" )
1138+ . set_len ( 40 )
1139+ . await
1140+ . expect ( "truncate should not fail" ) ;
1141+
1142+ // Exactly what reader.rs:524 does: `metadata.len() - bytes_read`.
1143+ let metadata_len = tokio:: fs:: metadata ( & path)
1144+ . await
1145+ . expect ( "metadata should not fail" )
1146+ . len ( ) ;
1147+ assert_eq ! ( metadata_len, 40 ) ;
1148+ // In a debug build this raw subtraction panics (the bug manifesting);
1149+ // in release it wraps to ~2^64.
1150+ let size_delta = metadata_len - bytes_read;
1151+
1152+ assert_eq ! (
1153+ size_delta, 0 ,
1154+ "reader.rs:524: metadata.len()({metadata_len}) - bytes_read({bytes_read}) \
1155+ on a truncated file must saturate at 0, but underflowed to {size_delta} \
1156+ (~2^64); this is fed into the unsaturated decrement_total_buffer_size -> \
1157+ the #21683 wrap -> permanent writer deadlock"
1158+ ) ;
1159+ }
1160+ } )
1161+ . await ;
1162+ }
0 commit comments