Skip to main content

fsqlite_core/
raptorq_codec.rs

1//! Production `SymbolCodec` implementation backed by asupersync RaptorQ (bd-3sj9w).
2//!
3//! This module lifts the test-only `AsupersyncCodec` from `raptorq_integration`
4//! into a public, production-ready codec.  The codec wraps asupersync's
5//! `RaptorQSenderBuilder` / `RaptorQReceiverBuilder` behind the
6//! `SymbolCodec` trait, translating between FrankenSQLite's packed symbol
7//! key format and asupersync's `Symbol` / `SymbolId` types.
8
9use std::collections::VecDeque;
10use std::pin::Pin;
11use std::task::{Context, Poll};
12#[cfg(test)]
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15#[cfg(test)]
16use asupersync::Budget as AsBudget;
17use asupersync::error::ErrorKind as AsErrorKind;
18use asupersync::raptorq::{RaptorQReceiverBuilder, RaptorQSenderBuilder};
19use asupersync::security::AuthenticationTag;
20use asupersync::security::authenticated::AuthenticatedSymbol;
21use asupersync::transport::error::{SinkError, StreamError};
22use asupersync::transport::sink::SymbolSink;
23use asupersync::transport::stream::SymbolStream;
24#[cfg(test)]
25use asupersync::types::Time as AsTime;
26use asupersync::types::{
27    CancelKind as AsCancelKind, CancelReason as AsCancelReason, ObjectId as AsObjectId,
28    ObjectParams, Symbol, SymbolId, SymbolKind,
29};
30use asupersync::{Cx as AsCx, RaptorQConfig};
31
32use fsqlite_error::{FrankenError, Result};
33use fsqlite_types::cx::Cx;
34
35use crate::raptorq_integration::{
36    CodecDecodeResult, CodecEncodeResult, DecodeFailureReason, SymbolCodec,
37};
38
39const BEAD_ID: &str = "bd-3sj9w";
40
41/// Fixed object ID for production codec operations.  The object ID is not
42/// semantically meaningful for FrankenSQLite's page-level FEC — each WAL
43/// commit group is a standalone encode/decode unit — so we use a constant
44/// derived from the bead lineage.
45const PRODUCTION_OBJECT_ID: u64 = 0xF5_3D9A_0001;
46
47// ---------------------------------------------------------------------------
48// Packed symbol key format
49// ---------------------------------------------------------------------------
50//
51// 32-bit key layout:
52//   [31]     = kind (0 = source, 1 = repair)
53//   [30..23] = source block number (SBN, 8 bits)
54//   [22..0]  = encoding symbol ID (ESI, 23 bits)
55
56const PACKED_KIND_REPAIR_BIT: u32 = 1_u32 << 31;
57const PACKED_SBN_SHIFT: u32 = 23;
58const PACKED_SBN_MASK: u32 = 0xFF;
59const PACKED_ESI_MASK: u32 = 0x7F_FFFF;
60
61/// Pack a `(kind, sbn, esi)` triple into a 32-bit key.
62///
63/// Returns an error if `esi` exceeds 23 bits.
64pub fn pack_symbol_key(kind: SymbolKind, sbn: u8, esi: u32) -> Result<u32> {
65    if esi > PACKED_ESI_MASK {
66        return Err(FrankenError::OutOfRange {
67            what: "packed symbol esi (must fit 23 bits)".to_owned(),
68            value: esi.to_string(),
69        });
70    }
71
72    let kind_bit = if kind.is_repair() {
73        PACKED_KIND_REPAIR_BIT
74    } else {
75        0
76    };
77    Ok(kind_bit | (u32::from(sbn) << PACKED_SBN_SHIFT) | esi)
78}
79
80/// Unpack a 32-bit key into `(kind, sbn, esi)`.
81#[must_use]
82pub fn unpack_symbol_key(packed: u32) -> (SymbolKind, u8, u32) {
83    let kind = if packed & PACKED_KIND_REPAIR_BIT == 0 {
84        SymbolKind::Source
85    } else {
86        SymbolKind::Repair
87    };
88    #[allow(clippy::cast_possible_truncation)]
89    let sbn = ((packed >> PACKED_SBN_SHIFT) & PACKED_SBN_MASK) as u8;
90    let esi = packed & PACKED_ESI_MASK;
91    (kind, sbn, esi)
92}
93
94// ---------------------------------------------------------------------------
95// In-memory transport adapters
96// ---------------------------------------------------------------------------
97
98/// In-memory symbol sink that collects symbols into a `Vec`.
99#[derive(Debug)]
100struct VecTransportSink {
101    symbols: Vec<Symbol>,
102}
103
104impl VecTransportSink {
105    fn new() -> Self {
106        Self {
107            symbols: Vec::new(),
108        }
109    }
110}
111
112impl SymbolSink for VecTransportSink {
113    fn poll_send(
114        mut self: Pin<&mut Self>,
115        _cx: &mut Context<'_>,
116        symbol: AuthenticatedSymbol,
117    ) -> Poll<std::result::Result<(), SinkError>> {
118        self.symbols.push(symbol.into_symbol());
119        Poll::Ready(Ok(()))
120    }
121
122    fn poll_flush(
123        self: Pin<&mut Self>,
124        _cx: &mut Context<'_>,
125    ) -> Poll<std::result::Result<(), SinkError>> {
126        Poll::Ready(Ok(()))
127    }
128
129    fn poll_close(
130        self: Pin<&mut Self>,
131        _cx: &mut Context<'_>,
132    ) -> Poll<std::result::Result<(), SinkError>> {
133        Poll::Ready(Ok(()))
134    }
135
136    fn poll_ready(
137        self: Pin<&mut Self>,
138        _cx: &mut Context<'_>,
139    ) -> Poll<std::result::Result<(), SinkError>> {
140        Poll::Ready(Ok(()))
141    }
142}
143
144/// In-memory symbol stream that drains from a `VecDeque`.
145#[derive(Debug)]
146struct VecTransportStream {
147    symbols: VecDeque<AuthenticatedSymbol>,
148}
149
150impl VecTransportStream {
151    fn new(symbols: Vec<Symbol>) -> Self {
152        let symbols = symbols
153            .into_iter()
154            .map(|symbol| AuthenticatedSymbol::from_parts(symbol, AuthenticationTag::zero()))
155            .collect();
156        Self { symbols }
157    }
158}
159
160impl SymbolStream for VecTransportStream {
161    fn poll_next(
162        mut self: Pin<&mut Self>,
163        _cx: &mut Context<'_>,
164    ) -> Poll<Option<std::result::Result<AuthenticatedSymbol, StreamError>>> {
165        match self.symbols.pop_front() {
166            Some(symbol) => Poll::Ready(Some(Ok(symbol))),
167            None => Poll::Ready(None),
168        }
169    }
170
171    fn size_hint(&self) -> (usize, Option<usize>) {
172        (self.symbols.len(), Some(self.symbols.len()))
173    }
174
175    fn is_exhausted(&self) -> bool {
176        self.symbols.is_empty()
177    }
178}
179
180// ---------------------------------------------------------------------------
181// Production SymbolCodec
182// ---------------------------------------------------------------------------
183
184/// Production [`SymbolCodec`] backed by asupersync's RaptorQ encoder/decoder.
185///
186/// Wraps `RaptorQSenderBuilder` for encode and `RaptorQReceiverBuilder` for
187/// decode, using in-memory transports.  The codec is stateless and can be
188/// shared across threads (`Send + Sync`).
189///
190/// # Configuration
191///
192/// - `max_block_size`: Maximum source block size in bytes (default: 64 KiB).
193///   This controls how asupersync partitions large objects into source blocks.
194///   For page-level FEC where each encode call handles a single commit group
195///   (typically a few pages), the default is sufficient.
196#[derive(Debug, Clone)]
197pub struct AsupersyncCodec {
198    /// Maximum source block size in bytes.
199    max_block_size: usize,
200}
201
202impl AsupersyncCodec {
203    /// Create a codec with the given maximum block size.
204    #[must_use]
205    pub const fn new(max_block_size: usize) -> Self {
206        Self { max_block_size }
207    }
208}
209
210impl Default for AsupersyncCodec {
211    fn default() -> Self {
212        Self::new(64 * 1024)
213    }
214}
215
216#[cfg(test)]
217fn native_budget_from_local(cx: &Cx) -> AsBudget {
218    let budget = cx.budget();
219    let mut native_budget = AsBudget::new()
220        .with_poll_quota(budget.poll_quota)
221        .with_priority(budget.priority);
222    if let Some(cost_quota) = budget.cost_quota {
223        native_budget = native_budget.with_cost_quota(cost_quota);
224    }
225    if let Some(deadline) = budget.deadline {
226        native_budget = native_budget.with_deadline(local_deadline_to_native_time(deadline));
227    }
228    native_budget
229}
230
231#[cfg(test)]
232fn wall_clock_now_since_epoch() -> Duration {
233    SystemTime::now()
234        .duration_since(UNIX_EPOCH)
235        .unwrap_or(Duration::ZERO)
236}
237
238#[cfg(test)]
239fn local_deadline_to_native_time(deadline: Duration) -> AsTime {
240    let absolute_deadline = wall_clock_now_since_epoch()
241        .checked_add(deadline)
242        .unwrap_or(Duration::MAX);
243    let nanos = u64::try_from(absolute_deadline.as_nanos()).unwrap_or(u64::MAX);
244    AsTime::from_nanos(nanos)
245}
246
247fn is_native_abort(kind: AsErrorKind) -> bool {
248    matches!(
249        kind,
250        AsErrorKind::Cancelled
251            | AsErrorKind::CancelTimeout
252            | AsErrorKind::DeadlineExceeded
253            | AsErrorKind::PollQuotaExhausted
254            | AsErrorKind::CostQuotaExhausted
255    )
256}
257
258fn native_reason_to_local(reason: &AsCancelReason) -> fsqlite_types::cx::CancelReason {
259    match reason.kind {
260        AsCancelKind::User => fsqlite_types::cx::CancelReason::UserInterrupt,
261        AsCancelKind::Timeout
262        | AsCancelKind::Deadline
263        | AsCancelKind::PollQuota
264        | AsCancelKind::CostBudget => fsqlite_types::cx::CancelReason::Timeout,
265        AsCancelKind::FailFast
266        | AsCancelKind::RaceLost
267        | AsCancelKind::ParentCancelled
268        | AsCancelKind::Shutdown
269        | AsCancelKind::LinkedExit => fsqlite_types::cx::CancelReason::RegionClose,
270        AsCancelKind::ResourceUnavailable => fsqlite_types::cx::CancelReason::Abort,
271    }
272}
273
274fn sync_local_cancel_from_native(codec_cx: &Cx, native_cx: &AsCx) {
275    if let Some(reason) = native_cx.cancel_reason() {
276        codec_cx.cancel_with_reason(native_reason_to_local(&reason));
277    } else if native_cx.is_cancel_requested() {
278        codec_cx.cancel();
279    }
280}
281
282fn missing_native_cx_error() -> FrankenError {
283    FrankenError::Internal(format!(
284        "{BEAD_ID}: asupersync RaptorQ codec requires an attached native Cx or an ambient asupersync runtime Cx"
285    ))
286}
287
288fn derive_native_request_cx(cx: &Cx) -> Result<(Cx, AsCx)> {
289    let codec_cx = cx.create_child();
290    if let Some(reason) = cx.cancel_reason() {
291        codec_cx.cancel_with_reason(reason);
292    } else if cx.is_cancel_requested() {
293        codec_cx.cancel();
294    }
295
296    let native_cx = cx
297        .attached_native_cx()
298        .or_else(AsCx::current)
299        .ok_or_else(missing_native_cx_error)?;
300    sync_local_cancel_from_native(&codec_cx, &native_cx);
301    codec_cx.set_native_cx(native_cx.clone());
302    Ok((codec_cx, native_cx))
303}
304
305fn decode_object_params(
306    object_id: AsObjectId,
307    k_source: u32,
308    symbol_size: u32,
309    max_block_size: usize,
310) -> Result<ObjectParams> {
311    let object_size = u64::from(k_source)
312        .checked_mul(u64::from(symbol_size))
313        .ok_or_else(|| FrankenError::OutOfRange {
314            what: "object_size for decode params".to_owned(),
315            value: format!("{k_source}*{symbol_size}"),
316        })?;
317    let symbol_size_u16 = u16::try_from(symbol_size).map_err(|_| FrankenError::OutOfRange {
318        what: "symbol_size as u16".to_owned(),
319        value: symbol_size.to_string(),
320    })?;
321    if object_size == 0 {
322        return Ok(ObjectParams::new(object_id, 0, symbol_size_u16, 0, 0));
323    }
324    if max_block_size == 0 {
325        return Err(FrankenError::OutOfRange {
326            what: "max_block_size (must be > 0)".to_owned(),
327            value: "0".to_owned(),
328        });
329    }
330
331    let max_block_size_u64 =
332        u64::try_from(max_block_size).map_err(|_| FrankenError::OutOfRange {
333            what: "max_block_size as u64".to_owned(),
334            value: max_block_size.to_string(),
335        })?;
336    let source_blocks = u16::try_from(object_size.div_ceil(max_block_size_u64)).map_err(|_| {
337        FrankenError::OutOfRange {
338            what: "source_blocks as u16".to_owned(),
339            value: object_size.div_ceil(max_block_size_u64).to_string(),
340        }
341    })?;
342    let symbols_per_block = u16::try_from(
343        object_size
344            .min(max_block_size_u64)
345            .div_ceil(u64::from(symbol_size_u16)),
346    )
347    .map_err(|_| FrankenError::OutOfRange {
348        what: "symbols_per_block as u16".to_owned(),
349        value: object_size
350            .min(max_block_size_u64)
351            .div_ceil(u64::from(symbol_size_u16))
352            .to_string(),
353    })?;
354
355    Ok(ObjectParams::new(
356        object_id,
357        object_size,
358        symbol_size_u16,
359        source_blocks,
360        symbols_per_block,
361    ))
362}
363
364#[allow(
365    clippy::cast_possible_truncation,
366    clippy::cast_lossless,
367    clippy::cast_precision_loss,
368    clippy::cast_sign_loss
369)]
370impl SymbolCodec for AsupersyncCodec {
371    fn encode(
372        &self,
373        cx: &Cx,
374        source_data: &[u8],
375        symbol_size: u32,
376        repair_overhead: f64,
377    ) -> Result<CodecEncodeResult> {
378        if symbol_size == 0 {
379            return Err(FrankenError::OutOfRange {
380                what: "symbol_size (must be > 0)".to_owned(),
381                value: "0".to_owned(),
382            });
383        }
384        let mut config = RaptorQConfig::default();
385        config.encoding.symbol_size = symbol_size as u16;
386        config.encoding.max_block_size = self.max_block_size;
387        config.encoding.repair_overhead = repair_overhead;
388
389        let (codec_cx, native_cx) = derive_native_request_cx(cx)?;
390        codec_cx.checkpoint().map_err(|_| FrankenError::Abort)?;
391        let object_id = AsObjectId::new_for_test(PRODUCTION_OBJECT_ID);
392        let mut sender = RaptorQSenderBuilder::new()
393            .config(config)
394            .transport(VecTransportSink::new())
395            .build()
396            .map_err(|e| FrankenError::Internal(format!("{BEAD_ID}: sender build: {e}")))?;
397
398        let outcome = sender
399            .send_object(&native_cx, object_id, source_data)
400            .map_err(|e| {
401                if is_native_abort(e.kind()) {
402                    FrankenError::Abort
403                } else {
404                    FrankenError::Internal(format!("{BEAD_ID}: send_object: {e}"))
405                }
406            })?;
407
408        let symbols = std::mem::take(&mut sender.transport_mut().symbols);
409        let k = outcome.source_symbols as u32;
410
411        let mut source_symbols = Vec::new();
412        let mut repair_symbols = Vec::new();
413        for s in &symbols {
414            let packed_key = pack_symbol_key(s.kind(), s.sbn(), s.esi())?;
415            if s.kind().is_source() {
416                source_symbols.push((packed_key, s.data().to_vec()));
417            } else {
418                repair_symbols.push((packed_key, s.data().to_vec()));
419            }
420        }
421
422        Ok(CodecEncodeResult {
423            source_symbols,
424            repair_symbols,
425            k_source: k,
426        })
427    }
428
429    fn decode(
430        &self,
431        cx: &Cx,
432        symbols: &[(u32, Vec<u8>)],
433        k_source: u32,
434        symbol_size: u32,
435    ) -> Result<CodecDecodeResult> {
436        if symbols.is_empty() {
437            return Ok(CodecDecodeResult::Failure {
438                reason: DecodeFailureReason::InsufficientSymbols,
439                symbols_received: 0,
440                k_required: k_source,
441            });
442        }
443
444        if symbol_size == 0 {
445            return Err(FrankenError::OutOfRange {
446                what: "symbol_size (must be > 0)".to_owned(),
447                value: "0".to_owned(),
448            });
449        }
450
451        let object_id = AsObjectId::new_for_test(PRODUCTION_OBJECT_ID);
452        let mut config = RaptorQConfig::default();
453        config.encoding.symbol_size = symbol_size as u16;
454        config.encoding.max_block_size = self.max_block_size;
455        let params = decode_object_params(object_id, k_source, symbol_size, self.max_block_size)?;
456
457        let mut rebuilt = Vec::with_capacity(symbols.len());
458        for (packed, data) in symbols {
459            let (kind, sbn, esi) = unpack_symbol_key(*packed);
460            rebuilt.push(Symbol::new(
461                SymbolId::new(object_id, sbn, esi),
462                data.clone(),
463                kind,
464            ));
465        }
466
467        let (codec_cx, native_cx) = derive_native_request_cx(cx)?;
468        codec_cx.checkpoint().map_err(|_| FrankenError::Abort)?;
469        let mut receiver = RaptorQReceiverBuilder::new()
470            .config(config)
471            .source(VecTransportStream::new(rebuilt))
472            .build()
473            .map_err(|e| FrankenError::Internal(format!("{BEAD_ID}: receiver build: {e}")))?;
474
475        match receiver.receive_object(&native_cx, &params) {
476            Ok(outcome) => Ok(CodecDecodeResult::Success {
477                data: outcome.data,
478                symbols_used: outcome.symbols_received as u32,
479                peeled_count: 0,
480                inactivated_count: 0,
481            }),
482            Err(err) if is_native_abort(err.kind()) => Err(FrankenError::Abort),
483            Err(err) => {
484                let reason = match err.kind() {
485                    AsErrorKind::InsufficientSymbols => DecodeFailureReason::InsufficientSymbols,
486                    _ => DecodeFailureReason::SingularMatrix,
487                };
488                Ok(CodecDecodeResult::Failure {
489                    reason,
490                    symbols_received: symbols.len() as u32,
491                    k_required: k_source,
492                })
493            }
494        }
495    }
496}
497
498// ---------------------------------------------------------------------------
499// Tests
500// ---------------------------------------------------------------------------
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use fsqlite_types::cx::{CancelReason, Cx};
506
507    fn test_cx() -> Cx {
508        let cx = Cx::new();
509        cx.set_native_cx(AsCx::for_testing());
510        cx
511    }
512
513    #[test]
514    fn test_pack_unpack_source_symbol() {
515        let packed = pack_symbol_key(SymbolKind::Source, 0, 42).unwrap();
516        let (kind, sbn, esi) = unpack_symbol_key(packed);
517        assert_eq!(kind, SymbolKind::Source);
518        assert_eq!(sbn, 0);
519        assert_eq!(esi, 42);
520    }
521
522    #[test]
523    fn test_pack_unpack_repair_symbol() {
524        let packed = pack_symbol_key(SymbolKind::Repair, 3, 100).unwrap();
525        let (kind, sbn, esi) = unpack_symbol_key(packed);
526        assert_eq!(kind, SymbolKind::Repair);
527        assert_eq!(sbn, 3);
528        assert_eq!(esi, 100);
529    }
530
531    #[test]
532    fn test_pack_esi_overflow() {
533        let result = pack_symbol_key(SymbolKind::Source, 0, PACKED_ESI_MASK + 1);
534        assert!(result.is_err());
535    }
536
537    #[test]
538    fn test_pack_max_esi() {
539        let packed = pack_symbol_key(SymbolKind::Source, 0, PACKED_ESI_MASK).unwrap();
540        let (_, _, esi) = unpack_symbol_key(packed);
541        assert_eq!(esi, PACKED_ESI_MASK);
542    }
543
544    #[test]
545    fn test_pack_max_sbn() {
546        let packed = pack_symbol_key(SymbolKind::Repair, 255, 0).unwrap();
547        let (kind, sbn, esi) = unpack_symbol_key(packed);
548        assert_eq!(kind, SymbolKind::Repair);
549        assert_eq!(sbn, 255);
550        assert_eq!(esi, 0);
551    }
552
553    #[test]
554    fn test_codec_encode_decode_roundtrip() {
555        let codec = AsupersyncCodec::default();
556        let cx = test_cx();
557        let data = vec![0xAB_u8; 4096];
558        let symbol_size = 512_u32;
559        let repair_overhead = 1.25;
560
561        let encoded = codec
562            .encode(&cx, &data, symbol_size, repair_overhead)
563            .unwrap();
564        assert!(encoded.k_source > 0);
565        assert!(!encoded.source_symbols.is_empty());
566        assert!(!encoded.repair_symbols.is_empty());
567
568        // Decode with all symbols (source + repair).
569        let mut all_symbols: Vec<(u32, Vec<u8>)> = encoded.source_symbols.clone();
570        all_symbols.extend(encoded.repair_symbols.clone());
571
572        let decoded = codec
573            .decode(&cx, &all_symbols, encoded.k_source, symbol_size)
574            .unwrap();
575        match decoded {
576            CodecDecodeResult::Success {
577                data: recovered, ..
578            } => {
579                assert_eq!(recovered, data);
580            }
581            CodecDecodeResult::Failure { reason, .. } => {
582                panic!("decode failed: {reason:?}");
583            }
584        }
585    }
586
587    #[test]
588    fn test_codec_decode_source_only() {
589        let codec = AsupersyncCodec::default();
590        let cx = test_cx();
591        let data = vec![0xCD_u8; 2048];
592        let symbol_size = 512_u32;
593
594        let encoded = codec.encode(&cx, &data, symbol_size, 1.25).unwrap();
595
596        // Decode with source symbols only (no repair needed).
597        let decoded = codec
598            .decode(&cx, &encoded.source_symbols, encoded.k_source, symbol_size)
599            .unwrap();
600        match decoded {
601            CodecDecodeResult::Success {
602                data: recovered, ..
603            } => {
604                assert_eq!(recovered, data);
605            }
606            CodecDecodeResult::Failure { reason, .. } => {
607                panic!("source-only decode failed: {reason:?}");
608            }
609        }
610    }
611
612    #[test]
613    fn test_codec_decode_with_erasures() {
614        let codec = AsupersyncCodec::default();
615        let cx = test_cx();
616        let data = vec![0xEF_u8; 4096];
617        let symbol_size = 512_u32;
618
619        let encoded = codec.encode(&cx, &data, symbol_size, 1.5).unwrap();
620        let k = encoded.k_source as usize;
621
622        // Drop first source symbol, replace with repair symbols.
623        let mut symbols: Vec<(u32, Vec<u8>)> = encoded.source_symbols[1..].to_vec();
624        symbols.extend(encoded.repair_symbols.iter().take(2).cloned());
625
626        assert!(symbols.len() >= k, "need at least K symbols");
627
628        let decoded = codec
629            .decode(&cx, &symbols, encoded.k_source, symbol_size)
630            .unwrap();
631        match decoded {
632            CodecDecodeResult::Success {
633                data: recovered, ..
634            } => {
635                assert_eq!(recovered, data);
636            }
637            CodecDecodeResult::Failure { reason, .. } => {
638                panic!("erasure decode failed: {reason:?}");
639            }
640        }
641    }
642
643    #[test]
644    fn test_codec_decode_empty() {
645        let codec = AsupersyncCodec::default();
646        let cx = test_cx();
647        let result = codec.decode(&cx, &[], 4, 512).unwrap();
648        assert!(matches!(
649            result,
650            CodecDecodeResult::Failure {
651                reason: DecodeFailureReason::InsufficientSymbols,
652                ..
653            }
654        ));
655    }
656
657    #[test]
658    fn test_codec_default_max_block_size() {
659        let codec = AsupersyncCodec::default();
660        assert_eq!(codec.max_block_size, 64 * 1024);
661    }
662
663    #[test]
664    fn test_codec_custom_max_block_size() {
665        let codec = AsupersyncCodec::new(128 * 1024);
666        let cx = test_cx();
667        assert_eq!(codec.max_block_size, 128 * 1024);
668
669        // Should still encode/decode correctly.
670        let data = vec![0x42_u8; 2048];
671        let encoded = codec.encode(&cx, &data, 512, 1.25).unwrap();
672        let decoded = codec
673            .decode(&cx, &encoded.source_symbols, encoded.k_source, 512)
674            .unwrap();
675        assert!(matches!(decoded, CodecDecodeResult::Success { .. }));
676    }
677
678    #[test]
679    fn test_codec_send_sync() {
680        // SymbolCodec requires Send + Sync.
681        fn assert_send_sync<T: Send + Sync>() {}
682        assert_send_sync::<AsupersyncCodec>();
683    }
684
685    #[test]
686    fn test_codec_large_data_4096_page() {
687        let codec = AsupersyncCodec::default();
688        let cx = test_cx();
689        // 4 pages of 4096 bytes each.
690        let data = vec![0x77_u8; 4 * 4096];
691        let encoded = codec.encode(&cx, &data, 4096, 1.25).unwrap();
692        assert!(encoded.k_source >= 4);
693
694        let decoded = codec
695            .decode(&cx, &encoded.source_symbols, encoded.k_source, 4096)
696            .unwrap();
697        match decoded {
698            CodecDecodeResult::Success {
699                data: recovered, ..
700            } => {
701                assert_eq!(recovered, data);
702            }
703            CodecDecodeResult::Failure { reason, .. } => {
704                panic!("large page decode failed: {reason:?}");
705            }
706        }
707    }
708
709    #[test]
710    fn test_codec_repair_symbol_count_scales_with_overhead() {
711        let codec = AsupersyncCodec::default();
712        let cx = test_cx();
713        let data = vec![0x55_u8; 8192];
714
715        let low = codec.encode(&cx, &data, 512, 1.1).unwrap();
716        let high = codec.encode(&cx, &data, 512, 2.0).unwrap();
717
718        // Higher overhead should produce more repair symbols.
719        assert!(
720            high.repair_symbols.len() > low.repair_symbols.len(),
721            "2.0x overhead ({}) should produce more repairs than 1.1x ({})",
722            high.repair_symbols.len(),
723            low.repair_symbols.len()
724        );
725    }
726
727    #[test]
728    fn test_codec_decode_multiple_source_blocks_roundtrip() {
729        let codec = AsupersyncCodec::new(1024);
730        let cx = test_cx();
731        let data = vec![0x5A_u8; 3 * 1024];
732        let symbol_size = 512_u32;
733
734        let encoded = codec.encode(&cx, &data, symbol_size, 1.25).unwrap();
735        assert!(
736            encoded.source_symbols.iter().any(|(packed, _)| {
737                let (_, sbn, _) = unpack_symbol_key(*packed);
738                sbn > 0
739            }),
740            "test data should span multiple source blocks"
741        );
742
743        let decoded = codec
744            .decode(&cx, &encoded.source_symbols, encoded.k_source, symbol_size)
745            .unwrap();
746        match decoded {
747            CodecDecodeResult::Success {
748                data: recovered, ..
749            } => {
750                assert_eq!(recovered, data);
751            }
752            CodecDecodeResult::Failure { reason, .. } => {
753                panic!("multi-block decode failed: {reason:?}");
754            }
755        }
756    }
757
758    #[test]
759    fn test_pack_all_bits_combined() {
760        // Test with all bit fields populated.
761        let packed = pack_symbol_key(SymbolKind::Repair, 127, 0x3F_FFFF).unwrap();
762        let (kind, sbn, esi) = unpack_symbol_key(packed);
763        assert_eq!(kind, SymbolKind::Repair);
764        assert_eq!(sbn, 127);
765        assert_eq!(esi, 0x3F_FFFF);
766    }
767
768    #[test]
769    fn test_codec_encode_respects_cancelled_cx() {
770        let codec = AsupersyncCodec::default();
771        let cx = test_cx();
772        cx.cancel_with_reason(CancelReason::Abort);
773
774        let err = codec.encode(&cx, &[0xAB; 512], 512, 1.25).unwrap_err();
775        assert!(matches!(err, FrankenError::Abort));
776    }
777
778    #[test]
779    fn test_codec_decode_respects_cancelled_cx() {
780        let codec = AsupersyncCodec::default();
781        let setup_cx = test_cx();
782        let encoded = codec.encode(&setup_cx, &[0xBC; 512], 512, 1.25).unwrap();
783
784        let cx = test_cx();
785        cx.cancel_with_reason(CancelReason::Abort);
786
787        let err = codec
788            .decode(&cx, &encoded.source_symbols, encoded.k_source, 512)
789            .unwrap_err();
790        assert!(matches!(err, FrankenError::Abort));
791    }
792
793    #[test]
794    fn test_local_deadline_converts_to_future_native_time() {
795        let before = wall_clock_now_since_epoch();
796        let cx = Cx::with_budget(
797            fsqlite_types::cx::Budget::INFINITE.with_deadline(Duration::from_millis(50)),
798        );
799
800        let native_budget = native_budget_from_local(&cx);
801        let native_deadline = Duration::from_nanos(
802            native_budget
803                .deadline
804                .expect("native budget should carry a deadline")
805                .as_nanos(),
806        );
807        let lower_bound = before
808            .checked_add(Duration::from_millis(25))
809            .unwrap_or(Duration::MAX);
810
811        assert!(
812            native_deadline >= lower_bound,
813            "native deadline should be an absolute future instant, got {native_deadline:?}"
814        );
815    }
816
817    #[test]
818    fn test_codec_encode_respects_attached_native_cancellation() {
819        let codec = AsupersyncCodec::default();
820        let cx = test_cx();
821        let native = AsCx::for_testing();
822        cx.set_native_cx(native.clone());
823        native.set_cancel_reason(AsCancelReason::timeout());
824
825        let err = codec.encode(&cx, &[0xAB; 512], 512, 1.25).unwrap_err();
826        assert!(matches!(err, FrankenError::Abort));
827    }
828
829    #[test]
830    fn test_codec_decode_respects_attached_native_cancellation() {
831        let codec = AsupersyncCodec::default();
832        let setup_cx = test_cx();
833        let encoded = codec.encode(&setup_cx, &[0xBC; 512], 512, 1.25).unwrap();
834
835        let cx = test_cx();
836        let native = AsCx::for_testing();
837        cx.set_native_cx(native.clone());
838        native.set_cancel_reason(AsCancelReason::timeout());
839
840        let err = codec
841            .decode(&cx, &encoded.source_symbols, encoded.k_source, 512)
842            .unwrap_err();
843        assert!(matches!(err, FrankenError::Abort));
844    }
845
846    #[test]
847    fn test_derive_native_request_cx_mirrors_attached_native_cancellation() {
848        let cx = test_cx();
849        let native = AsCx::for_testing();
850        cx.set_native_cx(native.clone());
851        native.set_cancel_reason(AsCancelReason::timeout());
852
853        let (codec_cx, derived_native) =
854            derive_native_request_cx(&cx).expect("attached native cx should derive");
855
856        assert_eq!(codec_cx.cancel_reason(), Some(CancelReason::Timeout));
857        assert!(codec_cx.is_cancel_requested());
858        assert!(codec_cx.checkpoint().is_err());
859        assert!(derived_native.is_cancel_requested());
860    }
861
862    #[test]
863    fn test_derive_native_request_cx_requires_runtime_or_attachment() {
864        let cx = Cx::new();
865
866        let err = derive_native_request_cx(&cx).unwrap_err();
867
868        assert!(
869            matches!(err, FrankenError::Internal(message) if message.contains("requires an attached native Cx"))
870        );
871    }
872}