saluki_components/encoders/buffered_incremental/
mod.rs

1use std::time::Duration;
2
3use agent_data_plane_config::defaults::DEFAULT_ENCODER_FLUSH_TIMEOUT;
4use async_trait::async_trait;
5use saluki_common::sync::shutdown::ShutdownCoordinator;
6use saluki_core::accounting::{MemoryBounds, MemoryBoundsBuilder};
7use saluki_core::runtime;
8use saluki_core::{
9    components::{encoders::*, BuildContext},
10    data_model::{event::EventType, payload::PayloadType},
11    observability::ComponentMetricsExt,
12};
13use saluki_error::GenericError;
14use saluki_metrics::MetricsBuilder;
15use tokio::{pin, select, time::sleep};
16use tracing::{debug, error};
17
18mod telemetry;
19use self::telemetry::ComponentTelemetry;
20
21/// Buffered incremental encoder.
22///
23/// Wraps an `IncrementalEncoder` and drives it with incoming events, allowing buffering by utilizing a configurable
24/// flush timeout. Payloads are encoded on the global thread pool to avoid affecting latency-sensitive tasks.
25pub struct BufferedIncrementalConfiguration<EB> {
26    /// Flush timeout for pending requests.
27    ///
28    /// When the encoder has written events to the in-flight request payload, but it hasn't yet reached the
29    /// payload size limits that would force the payload to be flushed, the encoder will wait for a period of time
30    /// before flushing the in-flight request payload. This allows for the possibility of other events to be processed
31    /// and written into the request payload, thereby maximizing the payload size and reducing the number of requests
32    /// generated and sent overall.
33    ///
34    /// Defaults to 2 seconds.
35    flush_timeout: Duration,
36
37    encoder_builder: EB,
38}
39
40impl<EB> BufferedIncrementalConfiguration<EB>
41where
42    EB: IncrementalEncoderBuilder,
43{
44    /// Creates a new `BufferedIncrementalConfiguration` from the given incremental encoder builder.
45    pub fn from_encoder_builder(encoder_builder: EB) -> Self {
46        Self {
47            flush_timeout: DEFAULT_ENCODER_FLUSH_TIMEOUT,
48            encoder_builder,
49        }
50    }
51}
52
53#[async_trait]
54impl<EB> EncoderBuilder for BufferedIncrementalConfiguration<EB>
55where
56    EB: IncrementalEncoderBuilder + Sync,
57    EB::Output: Send + 'static,
58{
59    fn input_event_type(&self) -> EventType {
60        self.encoder_builder.input_event_type()
61    }
62
63    fn output_payload_type(&self) -> PayloadType {
64        self.encoder_builder.output_payload_type()
65    }
66
67    async fn build(&self, context: BuildContext) -> Result<Box<dyn Encoder + Send>, GenericError> {
68        let metrics_builder = MetricsBuilder::from_component_context(context.component_context());
69        let telemetry = ComponentTelemetry::from_builder(&metrics_builder);
70
71        let encoder = self.encoder_builder.build(context).await?;
72
73        let flush_timeout = match self.flush_timeout {
74            // We always give ourselves a minimum flush timeout of 10ms to allow for some very minimal amount of
75            // batching, while still practically flushing things almost immediately.
76            Duration::ZERO => Duration::from_millis(10),
77            dur => dur,
78        };
79
80        Ok(Box::new(BufferedIncremental {
81            encoder,
82            telemetry,
83            flush_timeout,
84        }))
85    }
86}
87
88impl<EB> MemoryBounds for BufferedIncrementalConfiguration<EB>
89where
90    EB: IncrementalEncoderBuilder,
91{
92    fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
93        builder
94            .minimum()
95            .with_single_value::<BufferedIncremental<EB::Output>>("component struct");
96
97        self.encoder_builder.specify_bounds(builder);
98    }
99}
100
101pub struct BufferedIncremental<E> {
102    encoder: E,
103    telemetry: ComponentTelemetry,
104    flush_timeout: Duration,
105}
106
107#[async_trait]
108impl<E> Encoder for BufferedIncremental<E>
109where
110    E: IncrementalEncoder + Send + 'static,
111{
112    async fn run(mut self: Box<Self>, context: EncoderContext) -> Result<(), GenericError> {
113        let Self {
114            encoder,
115            telemetry,
116            flush_timeout,
117        } = *self;
118
119        // Run our encoder task on the worker pool.
120        //
121        // The encoder task owns _everything_ -- health checking, encoding, dispatching payloads, etc -- so we just
122        // end up as a glorified watchdog here, where we block until the encoder task has completed. The encoder task
123        // itself is what waits for the shutdown signal and all of that.
124        //
125        // We're abusing `ShutdownCoordinator` here to basically detect when the encoder task exits: we immediately
126        // call `shutdown_and_wait` after spawning the task, which will wait until the `ShutdownHandle` we've given to
127        // the encoder tasks is dropped... which only happens when the task exits.
128        let worker_pool = context.topology_context().global_thread_pool().clone();
129
130        let mut shutdown_coordinator = ShutdownCoordinator::default();
131        let task_shutdown_handle = shutdown_coordinator.register();
132
133        runtime::worker("incremental_encoder", async move {
134            // TODO: Conceptually, all components downstream of sources/relays should drain their incoming events
135            // consumer until it's empty, so on and so forth until forwarders/destinations have done so and nothing
136            // is left to process.
137            //
138            // However, this isn't feasible if we ever want to support restarting individual components or stitching
139            // in new components to a topology... which we eventually do. We'd have to respond to shutdown (which
140            // we're ignoring here by aliasing the handle as `_shutdown`) to shut down in a timely fashion.
141            //
142            // We should consider a mechanism to expose consumers/dispatchers such that they're borrowed in an owned
143            // fashion, allowing a component to hold on to them when running, but then effectively release them when
144            // they exit. This would allow us to stop a component, and without dropping its interconnect channel and
145            // losing all of the events or payloads within it, reconnect it to the new instance of the component (or
146            // whatever component takes its place, etc) which would allow us to more cleanly respond to shutdown
147            // here as more of an exceptional thing: an immediate stoppage of work, without throwing away _pending_
148            // work.
149            let _task_shutdown_handle = task_shutdown_handle;
150            run_incremental_encoder(context, encoder, telemetry, flush_timeout).await
151        })
152        .on_runtime(worker_pool)
153        .spawn();
154
155        debug!("Buffered Incremental encoder started.");
156
157        shutdown_coordinator.shutdown_and_wait().await;
158
159        debug!("Buffered Incremental encoder stopped.");
160
161        Ok(())
162    }
163}
164
165async fn run_incremental_encoder<E>(
166    mut context: EncoderContext, mut encoder: E, telemetry: ComponentTelemetry, flush_timeout: Duration,
167) -> Result<(), GenericError>
168where
169    E: IncrementalEncoder,
170{
171    let mut health = context.take_health_handle();
172
173    health.mark_ready();
174
175    let mut pending_flush = false;
176    let pending_flush_timeout = sleep(flush_timeout);
177    pin!(pending_flush_timeout);
178
179    loop {
180        select! {
181            _ = health.live() => continue,
182            maybe_event_buffer = context.events().next() => {
183                // Break out of our loop when the events channel is closed.
184                let event_buffer = match maybe_event_buffer {
185                    Some(event_buffer) => event_buffer,
186                    None => break,
187                };
188
189                for event in event_buffer {
190                    // Try to process the event.
191                    //
192                    // If we're informed that we need to flush, we'll hold on to this event before triggering a flush and then
193                    // retry processing it after flushing.
194                    let event_to_retry = match encoder.process_event(event).await? {
195                        ProcessResult::Continue => continue,
196                        ProcessResult::FlushRequired(event) => event,
197                    };
198
199                    // Flush the encoder, waiting any payloads it has generated.
200                    encoder.flush(context.dispatcher()).await?;
201
202                    // Now try to process the event again.
203                    //
204                    // If this fails, then we drop the event because it's a logical bug to not be able to encode an event after
205                    // flushing, and we don't want to get stuck in an infinite loop.
206                    match encoder.process_event(event_to_retry).await? {
207                        ProcessResult::Continue => {},
208                        ProcessResult::FlushRequired(_) => {
209                            error!("Failed to process event after flushing.");
210                            telemetry.events_dropped_encoder().increment(1);
211                        },
212                    }
213                }
214
215                debug!("Processed event buffer.");
216
217                // If we're not already pending a flush, we'll start the countdown.
218                if !pending_flush {
219                    pending_flush_timeout.as_mut().reset(tokio::time::Instant::now() + flush_timeout);
220                    pending_flush = true;
221                }
222            },
223            _ = &mut pending_flush_timeout, if pending_flush => {
224                debug!("Flushing encoder of any pending payload(s).");
225
226                pending_flush = false;
227
228                encoder.flush(context.dispatcher()).await?;
229
230                debug!("All pending payloads flushed.");
231            }
232        }
233    }
234
235    // Do a final flush since we may have had a pending payloads before breaking out of the loop.
236    encoder.flush(context.dispatcher()).await?;
237
238    Ok(())
239}