1use std::collections::BTreeMap;
8
9use bytes::Bytes;
10use krabka_client_admin::{AdminClient, groups::ListGroupsOptions};
11use krabka_client_consumer::{AutoOffsetReset, Consumer, IsolationLevel};
12use krabka_client_producer::{Acks, Producer, ProducerRecord};
13use krabka_units::{
14 fmt::Human as _,
15 prelude::{Time, TimeExt as _},
16};
17use tracing::warn;
18
19use crate::{
20 config::{ClientResourcePolicy, NamingPolicy, ReplicatorRuntimePolicy},
21 error::ReplicatorError,
22 ids::{CommittedOffset, PartitionIndex},
23 mm2::{Checkpoint, OffsetSync},
24 naming::Renamer,
25 offset_sync_store::OffsetSyncStore,
26 selector::Selector,
27};
28
29pub struct CheckpointParams {
31 pub source_bootstrap: String,
33 pub target_bootstrap: String,
35 pub source_alias: String,
37 pub naming: NamingPolicy,
41 pub group_selector: Selector,
43 pub security: Option<krabka_client_core::security::ClientSecurity>,
45}
46
47#[tracing::instrument(
53 level = "info",
54 skip_all,
55 fields(source_alias = %params.source_alias, groups = tracing::field::Empty),
56 err,
57)]
58pub async fn run_once(
59 params: &CheckpointParams,
60 store: &OffsetSyncStore,
61) -> Result<(), ReplicatorError> {
62 run_once_with_policy(params, store, ClientResourcePolicy::default()).await
63}
64
65pub async fn run_once_with_policy(
72 params: &CheckpointParams,
73 store: &OffsetSyncStore,
74 client_resource_policy: ClientResourcePolicy,
75) -> Result<(), ReplicatorError> {
76 run_once_with_runtime_policy(
77 params,
78 store,
79 client_resource_policy,
80 &ReplicatorRuntimePolicy::default(),
81 )
82 .await
83}
84
85async fn run_once_with_runtime_policy(
86 params: &CheckpointParams,
87 store: &OffsetSyncStore,
88 client_resource_policy: ClientResourcePolicy,
89 runtime_policy: &ReplicatorRuntimePolicy,
90) -> Result<(), ReplicatorError> {
91 let checkpoint_topic = Checkpoint::topic_name(¶ms.source_alias);
92
93 crate::admin_util::ensure_topic_with_runtime_policy(
95 ¶ms.target_bootstrap,
96 &checkpoint_topic,
97 1,
98 params.security.clone(),
99 client_resource_policy,
100 runtime_policy,
101 runtime_policy.internal_topic_replication_factor,
102 )
103 .await
104 .map_err(|e| ReplicatorError::Client(format!("ensure checkpoints topic: {e}")))?;
105
106 let mut admin = AdminClient::connect_with_options(
108 std::slice::from_ref(¶ms.source_bootstrap),
109 krabka_client_core::ConnectionOptions {
110 dns_timeout: krabka_client_core::ClientDnsTimeout::new(
111 runtime_policy.client_dns_timeout,
112 )
113 .map_err(ReplicatorError::Client)?,
114 socket_connection_setup_timeout: runtime_policy.client_connect_timeout,
115 socket_connection_setup_timeout_max: runtime_policy.client_connect_timeout,
116 request_timeout: runtime_policy.client_request_timeout,
117 client_id: "krabka-operator".to_owned(),
118 dispatch_queue_capacity: client_resource_policy.dispatch_queue_capacity,
119 frame_max: client_resource_policy.frame_max,
120 security: None, ..Default::default()
122 },
123 )
124 .await
125 .map_err(|e| ReplicatorError::Client(format!("source admin connect: {e}")))?;
126
127 let all_groups = admin
129 .list_groups(&ListGroupsOptions::default())
130 .await
131 .map_err(|e| ReplicatorError::Client(format!("list_groups: {e}")))?
132 .all()
133 .map_err(|error| ReplicatorError::Client(format!("list_groups: {error:?}")))?;
134
135 let groups: Vec<String> = all_groups
136 .into_iter()
137 .map(|group| group.group_id)
138 .filter(|g| !g.starts_with("krabka-replicator-"))
139 .filter(|g| params.group_selector.matches(g))
140 .collect();
141 tracing::Span::current().record("groups", groups.len());
142
143 let producer = build_producer(
145 ¶ms.target_bootstrap,
146 params.security.clone(),
147 client_resource_policy,
148 )
149 .await
150 .map_err(|e| ReplicatorError::Client(format!("build target producer: {e}")))?;
151
152 let renamer = Renamer::new(params.naming, ¶ms.source_alias);
157 for group in &groups {
158 let offsets = match admin.list_consumer_group_offsets(group).await {
159 Ok(o) => o,
160 Err(e) => {
161 warn!(group = %group, error = %e, "list_consumer_group_offsets failed; skipping group");
162 continue;
163 }
164 };
165
166 for ((topic, partition), committed) in offsets {
167 let partition = PartitionIndex(partition);
171 let committed = CommittedOffset(committed);
172 let Some(downstream) = store.translate(&topic, partition, committed) else {
173 continue;
174 };
175
176 let checkpoint = Checkpoint {
177 group: group.clone(),
178 topic: renamer.target_name(&topic),
179 partition,
180 upstream: committed.into(),
183 downstream,
184 metadata: String::new(),
185 };
186
187 let _rx = producer
188 .enqueue(ProducerRecord {
189 topic: checkpoint_topic.clone(),
190 partition: None,
191 key: Some(Bytes::from(checkpoint.key_bytes())),
192 value: Some(Bytes::from(checkpoint.value_bytes())),
193 headers: Vec::new(),
194 timestamp_ms: None,
195 })
196 .await
197 .map_err(|error| ReplicatorError::Client(format!("checkpoint produce: {error}")))?;
198 }
199 }
200
201 producer
203 .flush()
204 .await
205 .map_err(|e| ReplicatorError::Client(format!("producer flush: {e}")))?;
206
207 Ok(())
208}
209
210pub struct CheckpointTask {
213 handle: tokio::task::JoinHandle<()>,
214 shutdown: tokio::sync::watch::Sender<bool>,
215}
216
217impl CheckpointTask {
218 #[tracing::instrument(
228 level = "info",
229 skip_all,
230 fields(source_alias = %params.source_alias, interval = %interval.human()),
231 )]
232 pub fn start(params: CheckpointParams, interval: Time) -> Self {
233 Self::start_with_policy(params, interval, ClientResourcePolicy::default())
234 }
235
236 #[must_use]
238 pub fn start_with_policy(
239 params: CheckpointParams,
240 interval: Time,
241 client_resource_policy: ClientResourcePolicy,
242 ) -> Self {
243 Self::start_with_runtime_policy(
244 params,
245 interval,
246 client_resource_policy,
247 ReplicatorRuntimePolicy::default(),
248 )
249 }
250
251 pub(crate) fn start_with_runtime_policy(
252 params: CheckpointParams,
253 interval: Time,
254 client_resource_policy: ClientResourcePolicy,
255 runtime_policy: ReplicatorRuntimePolicy,
256 ) -> Self {
257 let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
258
259 let handle = tokio::spawn(async move {
260 let offset_syncs_topic = OffsetSync::topic_name(¶ms.source_alias);
261 let mut reader: Option<OffsetSyncReader> = None;
262 let mut store = OffsetSyncStore::default();
265 loop {
266 let cycle = async {
267 if reader.is_none() {
268 reader = Some(
269 OffsetSyncReader::connect(
270 ¶ms.target_bootstrap,
271 offset_syncs_topic.clone(),
272 params.security.clone(),
273 client_resource_policy,
274 &runtime_policy,
275 )
276 .await?,
277 );
278 }
279 let active = reader.as_mut().expect("reader connected above");
280 active.read_cycle(&mut store, &runtime_policy).await?;
281 run_once_with_runtime_policy(
282 ¶ms,
283 &store,
284 client_resource_policy,
285 &runtime_policy,
286 )
287 .await
288 .map_err(|error| error.to_string())
289 };
290 let result = tokio::select! {
291 result = cycle => Some(result),
292 _ = shutdown_rx.changed() => None,
293 };
294 match result {
295 None => break,
296 Some(Err(error)) => {
297 warn!(%error, "checkpoint cycle failed; retrying next interval");
298 }
299 Some(Ok(())) => {}
300 }
301
302 tokio::select! {
304 () = tokio::time::sleep(interval.to_std()) => {}
305 _ = shutdown_rx.changed() => {
306 if *shutdown_rx.borrow() {
307 break;
308 }
309 }
310 }
311 }
312
313 if let Some(reader) = reader {
314 let _ = reader.close().await;
315 }
316 });
317
318 Self {
319 handle,
320 shutdown: shutdown_tx,
321 }
322 }
323
324 #[cfg_attr(test, mutants::skip)]
327 pub async fn shutdown(self) {
328 let _ = self.shutdown.send(true);
329 let _ = self.handle.await;
330 }
331
332 pub async fn crash(self) {
334 self.handle.abort();
335 let _ = self.handle.await;
336 }
337}
338
339struct OffsetSyncReader {
346 admin: AdminClient,
347 topic_id: Option<String>,
348 consumer: Consumer,
349 topic: String,
350 cursors: BTreeMap<(String, i32), i64>,
351}
352
353impl OffsetSyncReader {
354 async fn connect(
355 bootstrap: &str,
356 topic: String,
357 security: Option<krabka_client_core::security::ClientSecurity>,
358 policy: ClientResourcePolicy,
359 runtime_policy: &ReplicatorRuntimePolicy,
360 ) -> Result<Self, String> {
361 let admin = AdminClient::connect_with_options(
362 &[bootstrap.to_owned()],
363 krabka_client_core::ConnectionOptions {
364 dns_timeout: krabka_client_core::ClientDnsTimeout::new(
365 runtime_policy.client_dns_timeout,
366 )?,
367 socket_connection_setup_timeout: runtime_policy.client_connect_timeout,
368 socket_connection_setup_timeout_max: runtime_policy.client_connect_timeout,
369 request_timeout: runtime_policy.client_request_timeout,
370 dispatch_queue_capacity: policy.dispatch_queue_capacity,
371 frame_max: policy.frame_max,
372 security: security.clone().map(Box::new),
373 ..Default::default()
374 },
375 )
376 .await
377 .map_err(|error| error.to_string())?;
378 let builder = Consumer::builder()
379 .bootstrap(bootstrap)
380 .client_id("krabka-replicator-offset-sync-reader")
381 .dispatch_queue_capacity(policy.dispatch_queue_capacity.get())
382 .frame_max(policy.frame_max.size())
383 .auto_offset_reset(AutoOffsetReset::Earliest)
384 .enable_auto_commit(false)
385 .allow_auto_create_topics(false)
386 .isolation_level(IsolationLevel::ReadCommitted);
387 let consumer = match security {
388 Some(security) => builder.security(security).build().await,
389 None => builder.build().await,
390 }
391 .map_err(|error| error.to_string())?;
392 Ok(Self {
393 admin,
394 topic_id: None,
395 consumer,
396 topic,
397 cursors: BTreeMap::new(),
398 })
399 }
400
401 async fn read_cycle(
402 &mut self,
403 store: &mut OffsetSyncStore,
404 runtime_policy: &ReplicatorRuntimePolicy,
405 ) -> Result<usize, String> {
406 let metadata = self
410 .admin
411 .metadata(&[&self.topic])
412 .await
413 .map_err(|error| error.to_string())?;
414 let entry = metadata
415 .topics
416 .into_iter()
417 .find(|entry| entry.name == self.topic);
418 if let Some(error) = entry.as_ref().and_then(|entry| entry.error.as_ref())
419 && error.code != 3
420 {
421 return Err(format!("{error:?}"));
422 }
423 let topic_id = entry
424 .as_ref()
425 .and_then(|entry| entry.topic_id)
426 .map(|id| id.to_string());
427 if topic_id != self.topic_id {
428 self.consumer
431 .unsubscribe()
432 .await
433 .map_err(|error| error.to_string())?;
434 store.clear();
435 self.cursors.clear();
436 self.topic_id = topic_id;
437 }
438 let partitions = match self.consumer.partitions_for(&self.topic).await {
439 Ok(partitions) => partitions
440 .into_iter()
441 .map(|partition| (self.topic.clone(), partition.partition))
442 .collect::<Vec<_>>(),
443 Err(error) if crate::admin_util::is_unknown_topic_error(&error.to_string()) => {
444 Vec::new()
445 }
446 Err(error) => return Err(error.to_string()),
447 };
448 self.consumer
449 .assign(&partitions)
450 .await
451 .map_err(|error| error.to_string())?;
452 if partitions.is_empty() {
453 store.clear();
454 self.cursors.clear();
455 self.topic_id = None;
456 return Ok(0);
457 }
458
459 let beginnings = self
460 .consumer
461 .beginning_offsets(&partitions)
462 .await
463 .map_err(|error| error.to_string())?;
464 let targets = self
467 .consumer
468 .end_offsets(&partitions)
469 .await
470 .map_err(|error| error.to_string())?;
471
472 let must_rebuild = beginnings.iter().any(|(partition, beginning)| {
473 let target = targets.get(partition).copied().unwrap_or(*beginning);
474 self.cursors
475 .get(partition)
476 .is_some_and(|cursor| cursor < beginning || *cursor > target)
477 });
478 if must_rebuild {
479 store.clear();
480 self.cursors.clear();
481 }
482
483 for ((topic, partition), beginning) in &beginnings {
484 let cursor = self
485 .cursors
486 .get(&(topic.clone(), *partition))
487 .copied()
488 .unwrap_or(*beginning);
489 let target = targets
490 .get(&(topic.clone(), *partition))
491 .copied()
492 .unwrap_or(*beginning);
493 let next = if cursor < *beginning || cursor > target {
494 *beginning
495 } else {
496 cursor
497 };
498 self.consumer
499 .seek(topic, *partition, next)
500 .await
501 .map_err(|error| error.to_string())?;
502 }
503
504 let mut ingested = 0;
505 while !self.reached(&partitions, &targets).await? {
506 let records = self
507 .consumer
508 .poll(runtime_policy.internal_drain_poll_timeout)
509 .await
510 .map_err(|error| error.to_string())?;
511 for record in records {
512 let key = (record.topic.clone(), record.partition);
513 if record.offset >= targets.get(&key).copied().unwrap_or(0) {
514 continue;
515 }
516 if let (Some(key), Some(value)) = (record.key, record.value) {
517 match OffsetSync::from_bytes(&key, &value) {
518 Ok(sync) => {
519 store.ingest(sync);
520 ingested += 1;
521 }
522 Err(error) => {
523 warn!(%error, "failed to decode offset-sync record; skipping");
524 }
525 }
526 }
527 }
528 }
529
530 for ((topic, partition), target) in &targets {
533 self.consumer
534 .seek(topic, *partition, *target)
535 .await
536 .map_err(|error| error.to_string())?;
537 self.cursors.insert((topic.clone(), *partition), *target);
538 }
539 Ok(ingested)
540 }
541
542 async fn reached(
543 &self,
544 partitions: &[(String, i32)],
545 targets: &std::collections::HashMap<(String, i32), i64>,
546 ) -> Result<bool, String> {
547 for (topic, partition) in partitions {
548 let position = self
549 .consumer
550 .position(topic, *partition)
551 .await
552 .map_err(|error| error.to_string())?;
553 if position
554 < targets
555 .get(&(topic.clone(), *partition))
556 .copied()
557 .unwrap_or(0)
558 {
559 return Ok(false);
560 }
561 }
562 Ok(true)
563 }
564
565 async fn close(self) -> Result<(), String> {
566 self.consumer
567 .close()
568 .await
569 .map_err(|error| error.to_string())
570 }
571}
572
573async fn build_producer(
576 bootstrap: &str,
577 security: Option<krabka_client_core::security::ClientSecurity>,
578 client_resource_policy: ClientResourcePolicy,
579) -> Result<Producer, krabka_client_producer::ProducerError> {
580 let builder = Producer::builder()
581 .bootstrap(bootstrap)
582 .dispatch_queue_capacity(client_resource_policy.dispatch_queue_capacity.get())
583 .frame_max(client_resource_policy.frame_max.size())
584 .enable_idempotence(false)
585 .acks(Acks::All);
586 match security {
587 Some(s) => builder.security(s).build().await,
588 None => builder.build().await,
589 }
590}
591
592#[cfg(test)]
593mod tests {
594
595 use super::*;
596 use crate::{
597 ids::{DownstreamOffset, UpstreamOffset},
598 mm2::{Checkpoint, OffsetSync},
599 offset_sync_store::OffsetSyncStore,
600 };
601
602 async fn produce_sync(bootstrap: &str, topic: &str, offset: i64) {
603 let sync = OffsetSync {
604 topic: "orders".into(),
605 partition: PartitionIndex(0),
606 upstream: UpstreamOffset(offset),
607 downstream: DownstreamOffset(offset),
608 };
609 crate::test_util::produce(bootstrap, topic, &sync.key_bytes(), &sync.value_bytes()).await;
610 }
611
612 #[tokio::test]
613 async fn reader_initial_admin_request_obeys_configured_timeout() {
614 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
615 let bootstrap = listener.local_addr().unwrap().to_string();
616 let server = tokio::spawn(async move {
619 let (_socket, _) = listener.accept().await.unwrap();
620 std::future::pending::<()>().await;
621 });
622 let policy = ReplicatorRuntimePolicy {
623 client_request_timeout: krabka_units::millis(50),
624 ..ReplicatorRuntimePolicy::default()
625 };
626 let result = tokio::time::timeout(
627 std::time::Duration::from_secs(2),
628 OffsetSyncReader::connect(
629 &bootstrap,
630 "offset-syncs".into(),
631 None,
632 ClientResourcePolicy::default(),
633 &policy,
634 ),
635 )
636 .await;
637 server.abort();
638 assert2::assert!(matches!(result, Ok(Err(_))));
639 }
640
641 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
642 async fn reader_streams_only_offsets_appended_since_previous_cycle() {
643 let dir = tempfile::TempDir::new().unwrap();
644 let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
645 dir.path().to_path_buf(),
646 ))
647 .await
648 .unwrap();
649 let bootstrap = broker.listen_addr().to_string();
650 let topic = OffsetSync::topic_name("us-east");
651 crate::test_util::create_topic(&bootstrap, &topic, 1).await;
652 for offset in 0..3 {
653 produce_sync(&bootstrap, &topic, offset).await;
654 }
655
656 let mut reader = OffsetSyncReader::connect(
657 &bootstrap,
658 topic.clone(),
659 None,
660 ClientResourcePolicy::default(),
661 &ReplicatorRuntimePolicy::default(),
662 )
663 .await
664 .unwrap();
665 let mut store = OffsetSyncStore::default();
666 let policy = ReplicatorRuntimePolicy::default();
667 assert2::assert!(reader.read_cycle(&mut store, &policy).await.unwrap() == 3);
668
669 for offset in 3..5 {
670 produce_sync(&bootstrap, &topic, offset).await;
671 }
672 assert2::assert!(reader.read_cycle(&mut store, &policy).await.unwrap() == 2);
673 assert2::assert!(
674 store.translate("orders", PartitionIndex(0), CommittedOffset(4))
675 == Some(DownstreamOffset(4))
676 );
677
678 reader.close().await.unwrap();
681 let mut restarted = OffsetSyncReader::connect(
682 &bootstrap,
683 topic,
684 None,
685 ClientResourcePolicy::default(),
686 &policy,
687 )
688 .await
689 .unwrap();
690 let mut rebuilt = OffsetSyncStore::default();
691 assert2::assert!(restarted.read_cycle(&mut rebuilt, &policy).await.unwrap() == 5);
692 restarted.close().await.unwrap();
693 }
694
695 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
696 async fn reader_rebuilds_after_topic_recreation_with_overlapping_offsets() {
697 let dir = tempfile::TempDir::new().unwrap();
698 let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
699 dir.path().to_path_buf(),
700 ))
701 .await
702 .unwrap();
703 let bootstrap = broker.listen_addr().to_string();
704 let topic = OffsetSync::topic_name("recreated");
705 crate::test_util::create_topic(&bootstrap, &topic, 1).await;
706 produce_sync(&bootstrap, &topic, 100).await;
707 let mut reader = OffsetSyncReader::connect(
708 &bootstrap,
709 topic.clone(),
710 None,
711 ClientResourcePolicy::default(),
712 &ReplicatorRuntimePolicy::default(),
713 )
714 .await
715 .unwrap();
716 let mut store = OffsetSyncStore::default();
717 let policy = ReplicatorRuntimePolicy::default();
718 assert2::assert!(reader.read_cycle(&mut store, &policy).await.unwrap() == 1);
719 store.ingest(OffsetSync {
720 topic: "removed".into(),
721 partition: PartitionIndex(0),
722 upstream: UpstreamOffset(0),
723 downstream: DownstreamOffset(999),
724 });
725 reader
726 .admin
727 .delete_topics(
728 &[&topic],
729 krabka_client_admin::topics::TopicMutationOptions::default(),
730 )
731 .await
732 .unwrap();
733 crate::test_util::create_topic(&bootstrap, &topic, 1).await;
734 for offset in 0..3 {
735 produce_sync(&bootstrap, &topic, offset).await;
736 }
737 assert2::assert!(reader.read_cycle(&mut store, &policy).await.unwrap() == 3);
738 assert2::assert!(
739 store
740 .translate("removed", PartitionIndex(0), CommittedOffset(0))
741 .is_none()
742 );
743 reader.close().await.unwrap();
744 }
745
746 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
747 async fn reader_treats_a_missing_topic_as_an_empty_cycle() {
748 let dir = tempfile::TempDir::new().unwrap();
749 let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
750 dir.path().to_path_buf(),
751 ))
752 .await
753 .unwrap();
754 let mut reader = OffsetSyncReader::connect(
755 &broker.listen_addr().to_string(),
756 "missing-offset-syncs".into(),
757 None,
758 ClientResourcePolicy::default(),
759 &ReplicatorRuntimePolicy::default(),
760 )
761 .await
762 .unwrap();
763 assert2::assert!(
764 reader
765 .read_cycle(
766 &mut OffsetSyncStore::default(),
767 &ReplicatorRuntimePolicy::default()
768 )
769 .await
770 .unwrap()
771 == 0
772 );
773 reader.close().await.unwrap();
774 }
775
776 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
777 async fn writes_translated_checkpoints() {
778 let s_dir = tempfile::TempDir::new().unwrap();
779 let t_dir = tempfile::TempDir::new().unwrap();
780 let source = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
781 s_dir.path().to_path_buf(),
782 ))
783 .await
784 .unwrap();
785 let target = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
786 t_dir.path().to_path_buf(),
787 ))
788 .await
789 .unwrap();
790 let sb = source.listen_addr().to_string();
791 let tb = target.listen_addr().to_string();
792
793 crate::test_util::create_topic(&sb, "orders", 1).await;
794 for _ in 0..200 {
795 crate::test_util::produce(&sb, "orders", b"k", b"v").await;
796 }
797 crate::test_util::commit_group(&sb, "g", "orders").await;
798
799 let mut syncs = OffsetSyncStore::default();
800 syncs.ingest(OffsetSync {
801 topic: "orders".into(),
802 partition: PartitionIndex(0),
803 upstream: UpstreamOffset(0),
804 downstream: DownstreamOffset(0),
805 });
806 syncs.ingest(OffsetSync {
807 topic: "orders".into(),
808 partition: PartitionIndex(0),
809 upstream: UpstreamOffset(200),
810 downstream: DownstreamOffset(165),
811 });
812
813 run_once(
814 &CheckpointParams {
815 source_bootstrap: sb,
816 target_bootstrap: tb.clone(),
817 source_alias: "us-east".into(),
818 naming: NamingPolicy::Default,
819 group_selector: Selector::compile(&["g".into()], &[]).unwrap(),
820 security: None,
821 },
822 &syncs,
823 )
824 .await
825 .unwrap();
826
827 let raw = crate::admin_util::read_last_value_for_key(
828 &tb,
829 &Checkpoint::topic_name("us-east"),
830 b"",
831 None,
832 )
833 .await
834 .unwrap()
835 .unwrap();
836 assert2::assert!(!raw.is_empty());
837 }
838}