1use {
20 super::{
21 Endpoint, now_ms,
22 windows::{Latencies, Windows, measurement},
23 },
24 crate::{
25 scenario::{Acks as ContractAcks, Compression as ContractCompression},
26 soak::{
27 contract::{Contract, Isolation, LoadPath, Semantics},
28 evidence::{PathMeasurement, ProducerLedger, TxnLedger, TxnOutcome, record_tag},
29 ledger::SeqSet,
30 recovery::{Ack, CommitAck},
31 },
32 },
33 bytes::Bytes,
34 futures::{
35 StreamExt as _,
36 future::{OptionFuture, try_join_all},
37 stream::FuturesUnordered,
38 },
39 hdrhistogram::CreationError,
40 krabka_client_consumer::{
41 AutoOffsetReset, Consumer, ConsumerError, IsolationLevel, OffsetAndMetadata,
42 },
43 krabka_client_producer::{
44 Acks, Compression, Producer, ProducerError, ProducerRecord, RecordMetadata,
45 },
46 krabka_units::prelude::*,
47 std::{
48 collections::{BTreeMap, HashMap, VecDeque, btree_map::Entry},
49 future::Future,
50 pin::Pin,
51 str,
52 sync::{
53 Arc, Mutex,
54 atomic::{AtomicBool, Ordering},
55 },
56 time::{Duration, Instant},
57 },
58 tokio::{
59 sync::Notify,
60 task::{JoinError, JoinHandle},
61 time::{self, MissedTickBehavior, interval, sleep},
62 },
63};
64
65const RECONNECT_BACKOFF: Duration = Duration::from_secs(1);
67
68pub struct LoadContext {
70 pub contract: Contract,
71 pub endpoint: Endpoint,
72 pub windows: Arc<Windows>,
73 record_ack_times: bool,
77 running: AtomicBool,
78 acknowledged: Mutex<BTreeMap<String, SeqSet>>,
82 abandoned: AtomicBool,
83 abandon: Notify,
84}
85
86#[bon::bon]
87impl LoadContext {
88 #[builder]
89 pub fn new(
90 contract: Contract,
91 endpoint: Endpoint,
92 windows: Arc<Windows>,
93 #[builder(default)] record_ack_times: bool,
94 ) -> Self {
95 Self {
96 contract,
97 endpoint,
98 windows,
99 record_ack_times,
100 running: AtomicBool::new(true),
101 acknowledged: Mutex::new(BTreeMap::new()),
102 abandoned: AtomicBool::new(false),
103 abandon: Notify::new(),
104 }
105 }
106
107 fn acknowledge(&self, path: &str, seq: u64) {
109 if let Ok(mut acknowledged) = self.acknowledged.lock() {
110 acknowledged.entry(path.to_owned()).or_default().insert(seq);
111 }
112 }
113
114 pub fn acknowledged(&self, path: &str) -> SeqSet {
116 self.acknowledged
117 .lock()
118 .ok()
119 .and_then(|acknowledged| acknowledged.get(path).cloned())
120 .unwrap_or_default()
121 }
122
123 fn running(&self) -> bool { self.running.load(Ordering::Relaxed) }
124
125 fn is_abandoned(&self) -> bool { self.abandoned.load(Ordering::Relaxed) }
126
127 pub fn stop(&self) { self.running.store(false, Ordering::Relaxed); }
129
130 pub fn abandon(&self) {
135 self.stop();
136 self.abandoned.store(true, Ordering::Relaxed);
137 self.abandon.notify_waiters();
138 }
139
140 async fn abandoned(&self) {
142 let notified = self.abandon.notified();
143 if self.is_abandoned() {
144 return;
145 }
146 notified.await;
147 }
148}
149
150#[must_use]
152pub fn producer_name(path: &str) -> String { format!("{path}-0") }
153
154#[must_use]
156pub fn group_name(path: &str, isolation: Isolation) -> String { format!("soak-{path}-{isolation}") }
157
158#[must_use]
161pub fn record_value(producer: &str, seq: u64, payload: usize) -> Bytes {
162 let mut value = record_tag(producer, seq).into_bytes();
163 if value.len() < payload {
164 value.resize(payload, b'x');
165 }
166 Bytes::from(value)
167}
168
169#[must_use]
171pub fn compacted_key(producer: &str, seq: u64, key_space: u64) -> String {
172 format!("{producer}/k{}", seq % key_space.max(1))
173}
174
175#[must_use]
177pub fn key_index(key: &[u8]) -> Option<u64> {
178 let text = str::from_utf8(key).ok()?;
179 let (_, index) = text.rsplit_once("/k")?;
180 index.parse().ok()
181}
182
183#[must_use]
189pub fn superseded(seq: u64, key_space: u64, next_seq: u64) -> bool {
190 seq.saturating_add(key_space) < next_seq
191}
192
193#[must_use]
195pub fn aborted_on_purpose(index: u64, abort_every: u32) -> bool {
196 (index + 1).is_multiple_of(u64::from(abort_every.max(1)))
197}
198
199pub struct ProducerOutput {
201 pub ledger: ProducerLedger,
202 pub latencies: Latencies,
203 pub acked_at: Vec<Ack>,
206}
207
208fn acks(acks: ContractAcks) -> Acks {
209 match acks {
210 ContractAcks::None => Acks::Zero,
211 ContractAcks::Leader => Acks::One,
212 ContractAcks::All => Acks::All,
213 }
214}
215
216fn compression(compression: ContractCompression) -> Compression {
217 match compression {
218 ContractCompression::None => Compression::None,
219 ContractCompression::Gzip => Compression::Gzip,
220 ContractCompression::Snappy => Compression::Snappy,
221 ContractCompression::Lz4 => Compression::Lz4,
222 ContractCompression::Zstd => Compression::Zstd,
223 }
224}
225
226async fn build_producer(
229 ctx: &LoadContext,
230 name: &str,
231 transactional_id: Option<String>,
232) -> Result<Producer, ProducerError> {
233 let settings = &ctx.contract.clients.producer;
234 let transaction_timeout = transactional_id
235 .as_ref()
236 .map(|_| ctx.contract.clients.transactions.timeout.to_std());
237 Producer::builder()
238 .bootstrap(ctx.endpoint.bootstrap.clone())
239 .maybe_security(ctx.endpoint.security.clone())
240 .client_id(name.to_owned())
241 .acks(acks(settings.acks))
242 .enable_idempotence(true)
243 .compression(compression(settings.compression))
244 .linger(settings.linger.to_std())
245 .batch_size(settings.batch_size.bytes_usize())
246 .request_timeout(settings.request_timeout.to_std())
247 .delivery_timeout(settings.delivery_timeout.to_std())
248 .maybe_transactional_id(transactional_id)
249 .maybe_transaction_timeout(transaction_timeout)
250 .build()
251 .await
252}
253
254async fn connect_producer(ctx: &LoadContext, name: &str, transactional: bool) -> Option<Producer> {
258 let transactional_id = transactional.then(|| format!("soak-{name}"));
259 while ctx.running() {
260 match build_producer(ctx, name, transactional_id.clone()).await {
261 Ok(producer) if !transactional => return Some(producer),
262 Ok(producer) => match producer.init_transactions().await {
263 Ok(()) => return Some(producer),
264 Err(error) => {
265 eprintln!("soak-contract: {name}: init_transactions: {error}");
266 let _ = producer.close().await;
267 }
268 },
269 Err(error) => eprintln!("soak-contract: {name}: build producer: {error}"),
270 }
271 sleep(RECONNECT_BACKOFF).await;
272 }
273 None
274}
275
276type Delivery =
279 Pin<Box<dyn Future<Output = (u64, u32, i64, Result<RecordMetadata, ProducerError>)> + Send>>;
280type Admission<'a> = Pin<Box<dyn Future<Output = Delivery> + Send + 'a>>;
281
282async fn enqueue(producer: &Producer, record: ProducerRecord, seq: u64, attempt: u32) -> Delivery {
283 let sent = now_ms();
284 match producer.enqueue(record).await {
285 Ok(handle) => Box::pin(async move { (seq, attempt, sent, handle.await) }),
286 Err(error) => Box::pin(async move { (seq, attempt, sent, Err(error)) }),
287 }
288}
289
290fn pipelined_ticker(period: Duration) -> time::Interval {
293 let mut ticker = interval(period);
294 ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
295 ticker
296}
297
298fn transactional_ticker(period: Duration) -> time::Interval {
301 let mut ticker = interval(period);
302 ticker.set_missed_tick_behavior(MissedTickBehavior::Burst);
303 ticker
304}
305
306fn empty_ledger(path: &LoadPath, producer: &str) -> ProducerLedger {
307 ProducerLedger {
308 path: path.name.clone(),
309 producer: producer.to_owned(),
310 semantics: path.semantics,
311 attempted: 0,
312 acked: SeqSet::new(),
313 failed: SeqSet::new(),
314 retried: SeqSet::new(),
315 transactions: Vec::new(),
316 }
317}
318
319fn record(path: &LoadPath, producer: &str, seq: u64) -> ProducerRecord {
321 let key = path
322 .key_space
323 .filter(|_| path.semantics == Semantics::Compacted)
324 .map(|keys| Bytes::from(compacted_key(producer, seq, keys)));
325 ProducerRecord {
326 topic: path.name.clone(),
327 key,
328 value: Some(record_value(producer, seq, path.payload.bytes_usize())),
329 ..Default::default()
330 }
331}
332
333pub async fn run_idempotent(
341 ctx: Arc<LoadContext>,
342 path: LoadPath,
343 latencies: Latencies,
344) -> ProducerOutput {
345 let name = producer_name(&path.name);
346 let mut output = ProducerOutput {
347 ledger: empty_ledger(&path, &name),
348 latencies,
349 acked_at: Vec::new(),
350 };
351 let max_attempts = ctx.contract.clients.producer.max_send_attempts;
352 let Some(producer) = connect_producer(&ctx, &name, false).await else {
353 return output;
354 };
355 let mut ticker = pipelined_ticker(path.rate.period().to_std());
356 let mut inflight: FuturesUnordered<Delivery> = FuturesUnordered::new();
357 let mut next_seq = 0_u64;
358 let mut admission: Option<Admission<'_>> = None;
359 let mut retries = VecDeque::new();
360 loop {
361 if admission.is_none()
362 && let Some((seq, attempt)) = retries.pop_front()
363 {
364 admission = Some(Box::pin(enqueue(
365 &producer,
366 record(&path, &name, seq),
367 seq,
368 attempt,
369 )));
370 }
371 if !ctx.running() && inflight.is_empty() && admission.is_none() {
372 break;
373 }
374 tokio::select! {
375 () = ctx.abandoned() => break,
376 _ = ticker.tick(), if ctx.running() && admission.is_none() => {
377 let seq = next_seq;
378 next_seq += 1;
379 admission = Some(Box::pin(enqueue(&producer, record(&path, &name, seq), seq, 1)));
380 }
381 Some(delivery) = OptionFuture::from(admission.as_mut()), if admission.is_some() => {
382 admission = None;
383 inflight.push(delivery);
384 }
385 Some((seq, attempt, sent, result)) = inflight.next() => {
386 let at = now_ms();
387 match result {
388 Ok(_) => {
389 output.ledger.acked.insert(seq);
390 ctx.acknowledge(&path.name, seq);
391 output.latencies.record(ctx.windows.disturbed(at), Time::from_millis(at - sent));
392 if ctx.record_ack_times {
393 output.acked_at.push(Ack { seq, unix_ms: at });
394 }
395 }
396 Err(error) => {
397 output.ledger.failed.insert(seq);
398 let stale = path
399 .key_space
400 .is_some_and(|keys| superseded(seq, keys, next_seq));
401 if attempt < max_attempts && !stale {
402 eprintln!("soak-contract: {name}: send {seq} attempt {attempt} failed, resending: {error}");
403 output.ledger.retried.insert(seq);
404 retries.push_back((seq, attempt + 1));
405 } else {
406 eprintln!("soak-contract: {name}: send {seq} attempt {attempt} failed: {error}");
407 }
408 }
409 }
410 }
411 }
412 }
413 output.ledger.attempted = next_seq;
414 drop(admission);
415 if !ctx.is_abandoned() {
418 let _ = producer.close().await;
419 }
420 output
421}
422
423pub async fn run_transactional(
435 ctx: Arc<LoadContext>,
436 path: LoadPath,
437 latencies: Latencies,
438) -> ProducerOutput {
439 let mut run = TransactionalRun {
440 name: producer_name(&path.name),
441 ledger: empty_ledger(&path, &producer_name(&path.name)),
442 latencies,
443 acked_at: Vec::new(),
444 ticker: transactional_ticker(path.rate.period().to_std()),
445 next_seq: 0,
446 index: 0,
447 };
448 if let Some(producer) = run.transact_until_stopped(&ctx, &path).await {
449 let _ = producer.close().await;
450 }
451 run.ledger.attempted = run.next_seq;
452 ProducerOutput {
453 ledger: run.ledger,
454 latencies: run.latencies,
455 acked_at: run.acked_at,
456 }
457}
458
459struct TransactionalRun {
461 name: String,
462 ledger: ProducerLedger,
463 latencies: Latencies,
464 acked_at: Vec<Ack>,
465 ticker: time::Interval,
466 next_seq: u64,
467 index: u64,
468}
469
470impl TransactionalRun {
471 async fn transact_until_stopped(
474 &mut self,
475 ctx: &LoadContext,
476 path: &LoadPath,
477 ) -> Option<Producer> {
478 let mut producer: Option<Producer> = None;
479 while ctx.running() {
480 match producer.as_ref() {
481 None => producer = connect_producer(ctx, &self.name, true).await,
482 Some(current) => {
483 if !self.transaction(ctx, path, current).await
484 && let Some(stale) = producer.take()
485 {
486 let _ = stale.close().await;
487 }
488 }
489 }
490 }
491 producer
492 }
493
494 async fn transaction(
497 &mut self,
498 ctx: &LoadContext,
499 path: &LoadPath,
500 producer: &Producer,
501 ) -> bool {
502 let settings = &ctx.contract.clients.transactions;
503 let name = self.name.clone();
504 let transaction = match producer.begin_transaction().await {
505 Ok(transaction) => transaction,
506 Err(error) => {
507 eprintln!("soak-contract: {name}: begin_transaction: {error}");
508 return false;
509 }
510 };
511 let start = self.next_seq;
512 let failed = self
513 .deliveries(ctx, path, |seq| {
514 enqueue(producer, record(path, &name, seq), seq, 1)
515 })
516 .await;
517 let index = self.index;
518 let (outcome, usable) = if failed || aborted_on_purpose(index, settings.abort_every) {
519 match transaction.abort().await {
520 Ok(()) => (TxnOutcome::Aborted, true),
521 Err(error) => {
522 eprintln!("soak-contract: {name}: abort of transaction {index}: {error}");
523 (TxnOutcome::Aborted, false)
524 }
525 }
526 } else {
527 match transaction.commit().await {
528 Ok(()) => (TxnOutcome::Committed, true),
529 Err(error) => {
530 eprintln!("soak-contract: {name}: commit of transaction {index}: {error}");
531 (TxnOutcome::Unknown, false)
532 }
533 }
534 };
535 self.ledger.transactions.push(TxnLedger {
536 id: index,
537 seqs: SeqSet::range(start, self.next_seq),
538 outcome,
539 });
540 self.index += 1;
541 usable
542 }
543
544 async fn deliveries<F: Future<Output = Delivery> + Send>(
547 &mut self,
548 ctx: &LoadContext,
549 path: &LoadPath,
550 mut enqueue_record: impl FnMut(u64) -> F + Send,
551 ) -> bool {
552 let mut deliveries = FuturesUnordered::new();
553 let mut admission: Option<Pin<Box<F>>> = None;
554 let mut admitted = 0;
555 let count = ctx.contract.clients.transactions.records_per_transaction;
556 let mut failed = false;
557 loop {
558 if (admitted == count || !ctx.running()) && deliveries.is_empty() && admission.is_none()
559 {
560 break;
561 }
562 tokio::select! {
563 _ = self.ticker.tick(), if admitted < count && ctx.running() && admission.is_none() => {
564 if !ctx.running() {
565 continue;
566 }
567 let seq = self.next_seq;
568 self.next_seq += 1;
569 admitted += 1;
570 admission = Some(Box::pin(enqueue_record(seq)));
571 }
572 Some(delivery) = OptionFuture::from(admission.as_mut()), if admission.is_some() => {
573 admission = None;
574 deliveries.push(delivery);
575 }
576 Some((seq, _, sent, result)) = deliveries.next(), if !deliveries.is_empty() => {
577 let at = now_ms();
578 match result {
579 Ok(_) => {
580 self.ledger.acked.insert(seq);
581 ctx.acknowledge(&path.name, seq);
582 self.latencies.record(ctx.windows.disturbed(at), Time::from_millis(at - sent));
583 if ctx.record_ack_times {
584 self.acked_at.push(Ack { seq, unix_ms: at });
585 }
586 }
587 Err(error) => {
588 eprintln!("soak-contract: {}: transactional send {seq} failed: {error}", self.name);
589 self.ledger.failed.insert(seq);
590 failed = true;
591 }
592 }
593 }
594 else => break,
595 }
596 }
597 failed
598 }
599}
600
601pub struct CommitTracker {
603 pub group: String,
604 pub topic: String,
605 pub partitions: i32,
606 acknowledged: Mutex<BTreeMap<i32, i64>>,
607 history: Mutex<Vec<CommitAck>>,
610}
611
612impl CommitTracker {
613 #[must_use]
614 pub fn new(group: String, topic: String, partitions: i32) -> Self {
615 Self {
616 group,
617 topic,
618 partitions,
619 acknowledged: Mutex::new(BTreeMap::new()),
620 history: Mutex::new(Vec::new()),
621 }
622 }
623
624 #[must_use]
626 pub fn history(&self) -> Vec<CommitAck> {
627 self.history
628 .lock()
629 .map(|history| history.clone())
630 .unwrap_or_default()
631 }
632
633 pub fn snapshot(&self) -> BTreeMap<i32, i64> {
635 self.acknowledged
636 .lock()
637 .map(|acked| acked.clone())
638 .unwrap_or_default()
639 }
640
641 fn acknowledge(&self, positions: &BTreeMap<i32, i64>) {
642 if let Ok(mut acked) = self.acknowledged.lock() {
643 acked.extend(positions);
644 }
645 if let Ok(mut history) = self.history.lock() {
646 history.push(CommitAck {
647 unix_ms: now_ms(),
648 offsets: positions.clone(),
649 });
650 }
651 }
652}
653
654async fn build_consumer(
655 contract: &Contract,
656 endpoint: &Endpoint,
657 path: &LoadPath,
658 isolation: Isolation,
659) -> Result<Consumer, ConsumerError> {
660 let consumer = &contract.clients.consumer;
661 Consumer::builder()
662 .bootstrap(endpoint.bootstrap.clone())
663 .maybe_security(endpoint.security.clone())
664 .client_id(group_name(&path.name, isolation))
665 .group_id(group_name(&path.name, isolation))
666 .subscribe(vec![path.name.clone()])
667 .auto_offset_reset(AutoOffsetReset::Earliest)
668 .isolation_level(isolation_level(isolation))
669 .session_timeout(consumer.session_timeout)
670 .enable_auto_commit(false)
671 .default_api_timeout(consumer.session_timeout)
672 .build()
673 .await
674}
675
676#[must_use]
677pub fn isolation_level(isolation: Isolation) -> IsolationLevel {
678 match isolation {
679 Isolation::ReadCommitted => IsolationLevel::ReadCommitted,
680 Isolation::ReadUncommitted => IsolationLevel::ReadUncommitted,
681 }
682}
683
684pub async fn run_consumer(
690 ctx: Arc<LoadContext>,
691 path: LoadPath,
692 isolation: Isolation,
693 tracker: Option<Arc<CommitTracker>>,
694 mut consumer: Consumer,
695 mut latencies: Latencies,
696) -> Latencies {
697 let settings = ctx.contract.clients.consumer.clone();
698 let group = group_name(&path.name, isolation);
699 let mut positions: BTreeMap<i32, i64> = BTreeMap::new();
700 let mut committed: BTreeMap<i32, i64> = BTreeMap::new();
701 let mut last_commit = Instant::now();
702 while ctx.running() {
703 let polled = tokio::select! {
704 () = ctx.abandoned() => break,
705 polled = consumer.poll(settings.poll_timeout) => polled,
706 };
707 match polled {
708 Ok(records) => {
709 let at = now_ms();
710 let disturbed = ctx.windows.disturbed(at);
711 for record in records {
712 latencies.record(disturbed, Time::from_millis((at - record.timestamp).max(0)));
713 let next = positions.entry(record.partition).or_insert(0);
714 *next = (*next).max(record.offset + 1);
715 }
716 }
717 Err(error) => {
718 eprintln!("soak-contract: {group}: poll: {error}");
719 sleep(Duration::from_millis(200)).await;
720 }
721 }
722 if let Some(tracker) = &tracker
723 && last_commit.elapsed() >= settings.commit_interval.to_std()
724 && positions != committed
725 {
726 last_commit = Instant::now();
727 let offsets: HashMap<(String, i32), OffsetAndMetadata> = positions
728 .iter()
729 .map(|(partition, offset)| {
730 (
731 (path.name.clone(), *partition),
732 OffsetAndMetadata::new(*offset),
733 )
734 })
735 .collect();
736 let commit = tokio::select! {
737 () = ctx.abandoned() => break,
738 commit = consumer.commit_offsets_sync(offsets) => commit,
739 };
740 match commit {
741 Ok(()) => {
742 tracker.acknowledge(&positions);
743 committed.clone_from(&positions);
744 }
745 Err(error) => eprintln!("soak-contract: {group}: commit: {error}"),
746 }
747 }
748 }
749 if !ctx.is_abandoned() {
750 let _ = consumer.close().await;
751 }
752 latencies
753}
754
755pub struct RunningLoad {
759 pub ctx: Arc<LoadContext>,
760 pub trackers: Vec<Arc<CommitTracker>>,
761 producers: Vec<JoinHandle<ProducerOutput>>,
762 consumers: Vec<(String, JoinHandle<Latencies>)>,
763}
764
765pub struct LoadOutcome {
767 pub producers: Vec<ProducerOutput>,
768 pub consume_latency: BTreeMap<String, Latencies>,
770}
771
772pub struct PreparedLoad {
774 consumers: Vec<(LoadPath, Isolation, Consumer)>,
775}
776
777pub async fn prepare(
784 contract: &Contract,
785 endpoint: &Endpoint,
786) -> Result<PreparedLoad, ConsumerError> {
787 let consumers = contract.paths.iter().flat_map(|path| {
788 path.consumers.iter().map(move |&isolation| async move {
789 let consumer = build_consumer(contract, endpoint, path, isolation).await?;
790 Ok::<_, ConsumerError>((path.clone(), isolation, consumer))
791 })
792 });
793 Ok(PreparedLoad {
794 consumers: try_join_all(consumers).await?,
795 })
796}
797
798pub fn start(ctx: &Arc<LoadContext>, prepared: PreparedLoad) -> Result<RunningLoad, CreationError> {
806 let contract = &ctx.contract;
807 let mut producer_latencies = Vec::with_capacity(contract.paths.len());
808 for _ in &contract.paths {
809 producer_latencies.push(Latencies::new()?);
810 }
811 let mut consumer_latencies = Vec::with_capacity(prepared.consumers.len());
812 for _ in &prepared.consumers {
813 consumer_latencies.push(Latencies::new()?);
814 }
815 let producers = contract
816 .paths
817 .iter()
818 .zip(producer_latencies)
819 .map(|(path, latencies)| match path.semantics {
820 Semantics::Transactional => {
821 tokio::spawn(run_transactional(Arc::clone(ctx), path.clone(), latencies))
822 }
823 Semantics::Idempotent | Semantics::Compacted => {
824 tokio::spawn(run_idempotent(Arc::clone(ctx), path.clone(), latencies))
825 }
826 })
827 .collect();
828 let mut trackers = Vec::new();
829 let mut consumers = Vec::new();
830 for ((path, isolation, consumer), latencies) in
831 prepared.consumers.into_iter().zip(consumer_latencies)
832 {
833 let tracker = path.commit_offsets.then(|| {
834 Arc::new(CommitTracker::new(
835 group_name(&path.name, isolation),
836 path.name.clone(),
837 path.partitions,
838 ))
839 });
840 if let Some(tracker) = &tracker {
841 trackers.push(Arc::clone(tracker));
842 }
843 consumers.push((
844 path.name.clone(),
845 tokio::spawn(run_consumer(
846 Arc::clone(ctx),
847 path,
848 isolation,
849 tracker,
850 consumer,
851 latencies,
852 )),
853 ));
854 }
855 Ok(RunningLoad {
856 ctx: Arc::clone(ctx),
857 trackers,
858 producers,
859 consumers,
860 })
861}
862
863impl RunningLoad {
864 pub async fn finish(self) -> Result<LoadOutcome, JoinError> {
871 let mut producers = Vec::new();
872 for task in self.producers {
873 producers.push(task.await?);
874 }
875 let mut consume_latency: BTreeMap<String, Latencies> = BTreeMap::new();
876 for (path, task) in self.consumers {
877 let latencies = task.await?;
878 match consume_latency.entry(path) {
879 Entry::Occupied(mut merged) => merged.get_mut().merge(&latencies),
880 Entry::Vacant(first) => {
881 first.insert(latencies);
882 }
883 }
884 }
885 Ok(LoadOutcome {
886 producers,
887 consume_latency,
888 })
889 }
890}
891
892impl LoadOutcome {
893 pub fn measurements(
900 &self,
901 contract: &Contract,
902 steady: Time,
903 ) -> Result<Vec<PathMeasurement>, CreationError> {
904 let empty = Latencies::new()?;
905 Ok(contract
906 .paths
907 .iter()
908 .map(|path| {
909 let produce = self
910 .producers
911 .iter()
912 .find(|output| output.ledger.path == path.name)
913 .map_or(&empty, |output| &output.latencies);
914 measurement(
915 &path.name,
916 produce,
917 self.consume_latency.get(&path.name).unwrap_or(&empty),
918 steady,
919 )
920 })
921 .collect())
922 }
923}
924
925#[cfg(test)]
926mod tests {
927 use {
928 super::{
929 Delivery, Endpoint, Latencies, LoadContext, ProducerError, RecordMetadata,
930 TransactionalRun, Windows, aborted_on_purpose, compacted_key, empty_ledger, key_index,
931 now_ms, pipelined_ticker, record_value, superseded, transactional_ticker,
932 },
933 crate::soak::{
934 contract::{Semantics, fixture},
935 evidence::parse_record_tag,
936 ledger::SeqSet,
937 },
938 assert2::check,
939 futures::FutureExt as _,
940 std::{
941 sync::{Arc, Mutex},
942 time::Duration,
943 },
944 tokio::time::{Instant, Interval, sleep, sleep_until},
945 };
946
947 const PERIOD: Duration = Duration::from_millis(10);
948
949 #[yare::parameterized(
950 acknowledged = { false, false },
951 rejected = { true, false },
952 stalled_admission = { false, true },
953 )]
954 #[test_macro(tokio::test(start_paused = true))]
955 async fn transaction_deliveries_are_observed_before_the_next_paced_send(
956 reject_last: bool,
957 stall_admission: bool,
958 ) {
959 let mut contract = fixture::self_check();
960 contract.clients.transactions.records_per_transaction = 3;
961 let path = contract
962 .paths
963 .iter()
964 .find(|path| path.semantics == Semantics::Transactional)
965 .unwrap()
966 .clone();
967 let ctx = LoadContext::builder()
968 .contract(contract)
969 .endpoint(Endpoint::plaintext("unused:9092"))
970 .windows(Arc::new(Windows::new(Vec::new())))
971 .build();
972 let mut run = TransactionalRun {
973 name: "paced".into(),
974 ledger: empty_ledger(&path, "paced"),
975 latencies: Latencies::new().unwrap(),
976 acked_at: Vec::new(),
977 ticker: transactional_ticker(PERIOD),
978 next_seq: 0,
979 index: 0,
980 };
981 let start = Instant::now();
982 let observed = Arc::new(Mutex::new(Vec::new()));
983 let failed = run
984 .deliveries(&ctx, &path, |seq| {
985 let observed = Arc::clone(&observed);
986 let delay = if stall_admission && seq == 1 {
987 PERIOD * 3
988 } else {
989 Duration::ZERO
990 };
991 sleep(delay).map(move |()| {
992 let ready_at = Instant::now() + PERIOD / 2;
993 let delivery: Delivery = Box::pin(async move {
994 sleep_until(ready_at).await;
995 observed.lock().unwrap().push(start.elapsed());
996 let result = if reject_last && seq == 2 {
997 Err(ProducerError::Server(19))
998 } else {
999 Ok(RecordMetadata {
1000 topic: "paced".into(),
1001 partition: 0,
1002 offset: i64::try_from(seq).unwrap(),
1003 timestamp_ms: 0,
1004 serialized_key_size: -1,
1005 serialized_value_size: 1,
1006 })
1007 };
1008 (seq, 1, now_ms(), result)
1009 });
1010 delivery
1011 })
1012 })
1013 .await;
1014 check!(
1015 *observed.lock().unwrap()
1016 == if stall_admission {
1017 vec![PERIOD / 2, PERIOD * 4 + PERIOD / 2, PERIOD * 4 + PERIOD / 2]
1018 } else {
1019 vec![PERIOD / 2, PERIOD + PERIOD / 2, PERIOD * 2 + PERIOD / 2]
1020 }
1021 );
1022 check!(failed == reject_last);
1023 check!(run.ledger.acked == SeqSet::range(0, if reject_last { 2 } else { 3 }));
1024 check!(
1025 run.ledger.failed
1026 == if reject_last {
1027 SeqSet::range(2, 3)
1028 } else {
1029 SeqSet::new()
1030 }
1031 );
1032 check!(run.latencies.steady_count == if reject_last { 2 } else { 3 });
1033 }
1034
1035 async fn ten_ticks_across_a_stall(mut ticker: Interval) -> Duration {
1038 let start = Instant::now();
1039 ticker.tick().await;
1040 sleep(PERIOD * 5).await;
1041 for _ in 1..10 {
1042 ticker.tick().await;
1043 }
1044 start.elapsed()
1045 }
1046
1047 #[yare::parameterized(
1048 transactional_keeps_its_average_rate = { transactional_ticker, PERIOD * 9 },
1049 pipelined_drops_what_it_missed = { pipelined_ticker, PERIOD * 13 },
1050 )]
1051 #[test_macro(tokio::test(start_paused = true))]
1052 async fn only_a_pipelined_producer_drops_the_ticks_a_stall_missed(
1053 ticker: fn(Duration) -> Interval,
1054 took: Duration,
1055 ) {
1056 check!(ten_ticks_across_a_stall(ticker(PERIOD)).await == took);
1057 }
1058
1059 #[yare::parameterized(
1060 padded = { 64, 64 },
1061 tag_longer_than_payload = { 4, 13 },
1062 )]
1063 fn a_value_carries_its_tag_padded_to_the_payload(payload: usize, length: usize) {
1064 let value = record_value("path-0", 12_345, payload);
1065 check!(value.len() == length);
1066 check!(parse_record_tag(&value) == Some(("path-0", 12_345)));
1067 }
1068
1069 #[test]
1070 fn a_compacted_key_names_its_index() {
1071 let key = compacted_key("classic-compacted-0", 130, 64);
1072 check!(key == "classic-compacted-0/k2");
1073 check!(key_index(key.as_bytes()) == Some(2));
1074 check!(key_index(b"no-index") == None);
1075 }
1076
1077 #[yare::parameterized(
1078 nothing_later_sent = { 10, 64, 70, false },
1079 next_value_of_the_key_not_yet_sent = { 10, 64, 74, false },
1080 next_value_of_the_key_sent = { 10, 64, 75, true },
1081 )]
1082 fn a_failed_compacted_send_is_superseded_once_its_key_moved_on(
1083 seq: u64,
1084 keys: u64,
1085 next: u64,
1086 expected: bool,
1087 ) {
1088 check!(superseded(seq, keys, next) == expected);
1089 }
1090
1091 #[yare::parameterized(
1092 first = { 0, false },
1093 third = { 2, false },
1094 fourth = { 3, true },
1095 eighth = { 7, true },
1096 )]
1097 fn every_fourth_transaction_is_aborted(index: u64, aborted: bool) {
1098 check!(aborted_on_purpose(index, 4) == aborted);
1099 }
1100}