Skip to main content

nisshi_generator/
lib.rs

1// Copyright ⓒ 2024-2026 Peter Morgan <peter.james.morgan@gmail.com>
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Nisshi Message Generator
16//!
17//! Generate fake data for schema backed topics.
18
19use std::{
20    fmt, io,
21    marker::PhantomData,
22    num::NonZeroU32,
23    pin::Pin,
24    result,
25    sync::{Arc, LazyLock, PoisonError},
26    time::{Duration, SystemTime},
27};
28
29use governor::{InsufficientCapacity, Jitter, Quota, RateLimiter};
30use nisshi_client::{Client, ConnectionManager};
31use nisshi_otel::meter_provider;
32use nisshi_sans_io::{
33    ByteSize, ErrorCode, ProduceRequest,
34    produce_request::{PartitionProduceData, TopicProduceData},
35    record::{deflated, inflated},
36};
37use nisshi_schema::{Generator as _, Registry, Schema};
38use nonzero_ext::nonzero;
39use opentelemetry::{
40    InstrumentationScope, KeyValue, global,
41    metrics::{Counter, Histogram, Meter},
42};
43use opentelemetry_otlp::ExporterBuildError;
44use opentelemetry_sdk::error::OTelSdkError;
45use opentelemetry_semantic_conventions::SCHEMA_URL;
46use tokio::{
47    signal::unix::{SignalKind, signal},
48    task::JoinSet,
49    time::sleep,
50};
51use tokio_util::sync::CancellationToken;
52use tracing::{Instrument, Level, debug, span};
53use url::Url;
54
55pub(crate) static METER: LazyLock<Meter> = LazyLock::new(|| {
56    global::meter_with_scope(
57        InstrumentationScope::builder(env!("CARGO_PKG_NAME"))
58            .with_version(env!("CARGO_PKG_VERSION"))
59            .with_schema_url(SCHEMA_URL)
60            .build(),
61    )
62});
63
64pub type Result<T, E = Error> = result::Result<T, E>;
65
66#[derive(thiserror::Error, Debug)]
67pub enum Error {
68    Api(ErrorCode),
69    Client(#[from] nisshi_client::Error),
70    ExporterBuild(#[from] ExporterBuildError),
71    InsufficientCapacity(#[from] InsufficientCapacity),
72    Io(Arc<io::Error>),
73    Otel(#[from] nisshi_otel::Error),
74    OtelSdk(#[from] OTelSdkError),
75    Poison,
76    Protocol(#[from] nisshi_sans_io::Error),
77    Schema(Box<nisshi_schema::Error>),
78    SchemaNotFoundForTopic(String),
79    UnknownHost(String),
80    Url(#[from] url::ParseError),
81}
82
83impl<T> From<PoisonError<T>> for Error {
84    fn from(_value: PoisonError<T>) -> Self {
85        Self::Poison
86    }
87}
88
89impl From<nisshi_schema::Error> for Error {
90    fn from(error: nisshi_schema::Error) -> Self {
91        Self::Schema(Box::new(error))
92    }
93}
94
95impl From<io::Error> for Error {
96    fn from(value: io::Error) -> Self {
97        Self::Io(Arc::new(value))
98    }
99}
100
101impl fmt::Display for Error {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        write!(f, "{self:?}")
104    }
105}
106
107#[derive(Copy, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
108pub enum CancelKind {
109    Interrupt,
110    Terminate,
111    Timeout,
112}
113
114#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
115pub struct Configuration {
116    broker: Url,
117    topic: String,
118    partition: i32,
119    schema_registry: Url,
120    batch_size: u32,
121    per_second: Option<u32>,
122    throughput: Option<u32>,
123    producers: u32,
124    duration: Option<Duration>,
125    otlp_endpoint_url: Option<Url>,
126}
127
128#[derive(Clone, Debug)]
129pub struct Generate {
130    configuration: Configuration,
131    registry: Registry,
132}
133
134impl TryFrom<Configuration> for Generate {
135    type Error = Error;
136
137    fn try_from(configuration: Configuration) -> Result<Self, Self::Error> {
138        Registry::builder_try_from_url(&configuration.schema_registry)
139            .map(|builder| builder.build())
140            .map(|registry| Self {
141                configuration,
142                registry,
143            })
144            .map_err(Into::into)
145    }
146}
147
148static GENERATE_PRODUCE_BATCH_DURATION: LazyLock<Histogram<u64>> = LazyLock::new(|| {
149    METER
150        .u64_histogram("generate_produce_batch_duration")
151        .with_unit("ms")
152        .with_description("Generate a produce batch in milliseconds")
153        .build()
154});
155
156static PRODUCE_REQUEST_RESPONSE_DURATION: LazyLock<Histogram<u64>> = LazyLock::new(|| {
157    METER
158        .u64_histogram("produce_request_response_duration")
159        .with_unit("ms")
160        .with_description("Latency of receiving an produce response in milliseconds")
161        .build()
162});
163
164fn frame(name: String, index: i32, schema: Schema, batch_size: i32) -> Result<deflated::Frame> {
165    let attributes = [
166        KeyValue::new("topic", name.clone()),
167        KeyValue::new("partition", index.to_string()),
168        KeyValue::new("batch_size", batch_size.to_string()),
169    ];
170
171    let start = SystemTime::now();
172
173    let mut batch = inflated::Batch::builder();
174    let offset_deltas = 0..batch_size;
175
176    for offset_delta in offset_deltas {
177        batch = schema
178            .generate()
179            .map(|record| record.offset_delta(offset_delta))
180            .map(|record| batch.record(record))?;
181    }
182
183    batch
184        .last_offset_delta(batch_size)
185        .build()
186        .map(|batch| inflated::Frame {
187            batches: vec![batch],
188        })
189        .and_then(deflated::Frame::try_from)
190        .inspect(|_| {
191            GENERATE_PRODUCE_BATCH_DURATION.record(
192                start
193                    .elapsed()
194                    .map_or(0, |duration| duration.as_millis() as u64),
195                &attributes,
196            )
197        })
198        .map_err(Into::into)
199}
200
201pub async fn produce(
202    client: Client,
203    name: String,
204    index: i32,
205    batch_size: i32,
206    frame: deflated::Frame,
207) -> Result<()> {
208    debug!(?client, %name, index, batch_size);
209
210    let attributes = [
211        KeyValue::new("topic", name.clone()),
212        KeyValue::new("partition", index.to_string()),
213        KeyValue::new("batch_size", batch_size.to_string()),
214    ];
215
216    let req = ProduceRequest::default().topic_data(Some(
217        [TopicProduceData::default().name(name).partition_data(Some(
218            [PartitionProduceData::default()
219                .index(index)
220                .records(Some(frame))]
221            .into(),
222        ))]
223        .into(),
224    ));
225
226    let start = SystemTime::now();
227
228    let response = client.call(req).await.inspect(|_| {
229        PRODUCE_REQUEST_RESPONSE_DURATION.record(
230            start
231                .elapsed()
232                .map_or(0, |duration| duration.as_millis() as u64),
233            &attributes,
234        )
235    })?;
236
237    assert!(
238        response
239            .responses
240            .unwrap_or_default()
241            .into_iter()
242            .all(|topic| {
243                topic
244                    .partition_responses
245                    .unwrap_or_default()
246                    .iter()
247                    .inspect(|partition| debug!(topic = %topic.name, ?partition))
248                    .all(|partition| partition.error_code == i16::from(ErrorCode::None))
249            })
250    );
251
252    Ok(())
253}
254
255static RATE_LIMIT_DURATION: LazyLock<Histogram<u64>> = LazyLock::new(|| {
256    METER
257        .u64_histogram("rate_limit_duration")
258        .with_unit("ms")
259        .with_description("Rate limit latencies in milliseconds")
260        .build()
261});
262
263static PRODUCE_RECORD_COUNT: LazyLock<Counter<u64>> = LazyLock::new(|| {
264    METER
265        .u64_counter("produce_record_count")
266        .with_description("Produced record count")
267        .build()
268});
269
270static PRODUCE_API_DURATION: LazyLock<Histogram<u64>> = LazyLock::new(|| {
271    METER
272        .u64_histogram("produce_duration")
273        .with_unit("ms")
274        .with_description("Produce API latencies in milliseconds")
275        .build()
276});
277
278impl Generate {
279    pub async fn main(self) -> Result<ErrorCode> {
280        debug!(configuration = ?self.configuration);
281
282        let meter_provider = self
283            .configuration
284            .otlp_endpoint_url
285            .map_or(Ok(None), |otlp_endpoint_url| {
286                meter_provider(otlp_endpoint_url, env!("CARGO_PKG_NAME")).map(Some)
287            })?;
288
289        let Some(schema) = self.registry.schema(&self.configuration.topic).await? else {
290            return Err(Error::SchemaNotFoundForTopic(
291                self.configuration.topic.clone(),
292            ));
293        };
294
295        let mut interrupt_signal = signal(SignalKind::interrupt()).unwrap();
296        debug!(?interrupt_signal);
297
298        let mut terminate_signal = signal(SignalKind::terminate()).unwrap();
299        debug!(?terminate_signal);
300
301        let rate_limiter = self
302            .configuration
303            .per_second
304            .or(self.configuration.throughput)
305            .and_then(NonZeroU32::new)
306            .map(Quota::per_second)
307            .map(RateLimiter::direct)
308            .map(Arc::new)
309            .inspect(|rate_limiter| debug!(?rate_limiter));
310
311        let batch_size = NonZeroU32::new(self.configuration.batch_size)
312            .inspect(|batch_size| debug!(batch_size = batch_size.get()))
313            .unwrap_or(nonzero!(10u32));
314
315        let mut set = JoinSet::new();
316
317        let token = CancellationToken::new();
318
319        let client = ConnectionManager::builder(self.configuration.broker)
320            .client_id(Some(env!("CARGO_PKG_NAME").into()))
321            .build()
322            .await
323            .inspect(|pool| debug!(?pool))
324            .map(Client::new)?;
325
326        for producer in 0..self.configuration.producers {
327            let rate_limiter = rate_limiter.clone();
328            let schema = schema.clone();
329            let topic = self.configuration.topic.clone();
330            let partition = self.configuration.partition;
331            let token = token.clone();
332            let client = client.clone();
333
334            _ = set.spawn(async move {
335                    let span = span!(Level::DEBUG, "producer", producer);
336
337                    async move {
338                        let attributes = [KeyValue::new("producer", producer.to_string())];
339
340                        loop {
341                            debug!(%topic, partition);
342
343                           let Ok(frame) = frame(topic.clone(), partition, schema.clone(), batch_size.get() as i32) else {
344                               break
345                           };
346
347                           if let Some(ref rate_limiter) = rate_limiter {
348                                let rate_limit_start = SystemTime::now();
349
350
351                                let cells = self.configuration.throughput.and(frame.size_in_bytes().ok().and_then(|bytes|NonZeroU32::new(bytes as u32))).unwrap_or(batch_size);
352                                debug!(cells);
353
354
355                                tokio::select! {
356                                    cancelled = token.cancelled() => {
357                                        debug!(?cancelled);
358                                        break
359                                    },
360
361                                    Ok(_) = rate_limiter.until_n_ready_with_jitter(cells, Jitter::up_to(Duration::from_millis(50))) => {
362                                        RATE_LIMIT_DURATION.record(
363                                        rate_limit_start
364                                            .elapsed()
365                                            .inspect(|duration|debug!(rate_limit_duration_ms = duration.as_millis()))
366                                            .map_or(0, |duration| duration.as_millis() as u64),
367                                            &attributes)
368
369                                    },
370                                }
371                            }
372
373                            let produce_start = SystemTime::now();
374
375                            tokio::select! {
376                                cancelled = token.cancelled() => {
377                                    debug!(?cancelled);
378                                    break
379                                },
380
381                                Ok(_) = produce(client.clone(), topic.clone(), partition,  batch_size.get() as i32, frame) => {
382                                    PRODUCE_RECORD_COUNT.add(batch_size.get() as u64, &attributes);
383                                    PRODUCE_API_DURATION.record(produce_start.elapsed().inspect(|duration|debug!(produce_duration_ms = duration.as_millis())).map_or(0, |duration| duration.as_millis() as u64), &attributes);
384                                },
385                            }
386                        }
387
388                    }.instrument(span).await;
389
390
391                });
392        }
393
394        let join_all = async {
395            while !set.is_empty() {
396                debug!(len = set.len());
397                _ = set.join_next().await;
398            }
399        };
400
401        let duration = self
402            .configuration
403            .duration
404            .map(sleep)
405            .map(Box::pin)
406            .map(|pinned| pinned as Pin<Box<dyn Future<Output = ()>>>)
407            .unwrap_or(Box::pin(std::future::pending()) as Pin<Box<dyn Future<Output = ()>>>);
408
409        let cancellation = tokio::select! {
410
411            timeout = duration => {
412                debug!(?timeout);
413                token.cancel();
414                Some(CancelKind::Timeout)
415            }
416
417            completed = join_all => {
418                debug!(?completed);
419                None
420            }
421
422            interrupt = interrupt_signal.recv() => {
423                debug!(?interrupt);
424                Some(CancelKind::Interrupt)
425            }
426
427            terminate = terminate_signal.recv() => {
428                debug!(?terminate);
429                Some(CancelKind::Terminate)
430            }
431
432        };
433
434        debug!(?cancellation);
435
436        if let Some(meter_provider) = meter_provider {
437            meter_provider
438                .force_flush()
439                .inspect(|force_flush| debug!(?force_flush))?;
440
441            meter_provider
442                .shutdown()
443                .inspect(|shutdown| debug!(?shutdown))?;
444        }
445
446        if let Some(CancelKind::Timeout) = cancellation {
447            sleep(Duration::from_secs(5)).await;
448        }
449
450        debug!(abort = set.len());
451        set.abort_all();
452
453        while !set.is_empty() {
454            _ = set.join_next().await;
455        }
456
457        Ok(ErrorCode::None)
458    }
459
460    pub fn builder()
461    -> Builder<PhantomData<Url>, PhantomData<String>, PhantomData<i32>, PhantomData<Url>> {
462        Builder::default()
463    }
464}
465
466#[derive(Clone, Debug)]
467pub struct Builder<B, T, P, S> {
468    broker: B,
469    topic: T,
470    partition: P,
471    schema_registry: S,
472    batch_size: u32,
473    per_second: Option<u32>,
474    throughput: Option<u32>,
475    producers: u32,
476    duration: Option<Duration>,
477    otlp_endpoint_url: Option<Url>,
478}
479
480impl Default
481    for Builder<PhantomData<Url>, PhantomData<String>, PhantomData<i32>, PhantomData<Url>>
482{
483    fn default() -> Self {
484        Self {
485            broker: Default::default(),
486            topic: Default::default(),
487            partition: Default::default(),
488            schema_registry: Default::default(),
489            batch_size: 1,
490            per_second: None,
491            throughput: None,
492            producers: 1,
493            duration: None,
494            otlp_endpoint_url: None,
495        }
496    }
497}
498
499impl<B, T, P, S> Builder<B, T, P, S> {
500    pub fn broker(self, broker: impl Into<Url>) -> Builder<Url, T, P, S> {
501        Builder {
502            broker: broker.into(),
503            topic: self.topic,
504            partition: self.partition,
505            schema_registry: self.schema_registry,
506            batch_size: self.batch_size,
507            per_second: self.per_second,
508            throughput: self.throughput,
509            producers: self.producers,
510            duration: self.duration,
511            otlp_endpoint_url: self.otlp_endpoint_url,
512        }
513    }
514
515    pub fn topic(self, topic: impl Into<String>) -> Builder<B, String, P, S> {
516        Builder {
517            broker: self.broker,
518            topic: topic.into(),
519            partition: self.partition,
520            schema_registry: self.schema_registry,
521            batch_size: self.batch_size,
522            per_second: self.per_second,
523            throughput: self.throughput,
524            producers: self.producers,
525            duration: self.duration,
526            otlp_endpoint_url: self.otlp_endpoint_url,
527        }
528    }
529
530    pub fn partition(self, partition: i32) -> Builder<B, T, i32, S> {
531        Builder {
532            broker: self.broker,
533            topic: self.topic,
534            partition,
535            schema_registry: self.schema_registry,
536            batch_size: self.batch_size,
537            per_second: self.per_second,
538            throughput: self.throughput,
539            producers: self.producers,
540            duration: self.duration,
541            otlp_endpoint_url: self.otlp_endpoint_url,
542        }
543    }
544
545    pub fn schema_registry(self, schema_registry: Url) -> Builder<B, T, P, Url> {
546        Builder {
547            broker: self.broker,
548            topic: self.topic,
549            partition: self.partition,
550            schema_registry,
551            batch_size: self.batch_size,
552            per_second: self.per_second,
553            throughput: self.throughput,
554            producers: self.producers,
555            duration: self.duration,
556            otlp_endpoint_url: self.otlp_endpoint_url,
557        }
558    }
559
560    pub fn batch_size(self, batch_size: u32) -> Self {
561        Self { batch_size, ..self }
562    }
563
564    pub fn per_second(self, per_second: Option<u32>) -> Self {
565        Self { per_second, ..self }
566    }
567
568    pub fn throughput(self, throughput: Option<u32>) -> Self {
569        Self { throughput, ..self }
570    }
571
572    pub fn producers(self, producers: u32) -> Self {
573        Self { producers, ..self }
574    }
575
576    pub fn duration(self, duration: Option<Duration>) -> Self {
577        Self { duration, ..self }
578    }
579
580    pub fn otlp_endpoint_url(self, otlp_endpoint_url: Option<Url>) -> Self {
581        Self {
582            otlp_endpoint_url,
583            ..self
584        }
585    }
586}
587
588impl Builder<Url, String, i32, Url> {
589    pub fn build(self) -> Result<Generate> {
590        Generate::try_from(Configuration {
591            broker: self.broker,
592            topic: self.topic,
593            partition: self.partition,
594            schema_registry: self.schema_registry,
595            batch_size: self.batch_size,
596            per_second: self.per_second,
597            throughput: self.throughput,
598            producers: self.producers,
599            duration: self.duration,
600            otlp_endpoint_url: self.otlp_endpoint_url,
601        })
602    }
603}