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}