wp_connector_api/runtime/sink.rs
1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::{path::PathBuf, sync::Arc};
4use wp_model_core::model::DataRecord;
5
6use crate::{SinkDefProvider, SinkResult};
7
8// Reuse workspace error type to avoid duplicating an error abstraction
9
10// ---------- Batch Metadata ----------
11
12/// Batch-level runtime metadata passed from engine to sink.
13///
14/// `BatchMeta` carries information that belongs to the batch/frame level
15/// (not to individual records), such as the OML output model name and output
16/// metadata field policy.
17/// Sinks decide how to consume it — e.g. `TcpArrowSink` uses `oml_name`
18/// as the Arrow frame tag in `arrow_framed` mode.
19#[derive(Clone, Debug, Default, PartialEq, Eq)]
20pub struct BatchMeta {
21 /// OML output model name — identifies the logical output stream.
22 pub oml_name: Option<String>,
23 /// Metadata payload fields disabled by the engine/group configuration.
24 pub output_disabled: Vec<String>,
25}
26
27impl BatchMeta {
28 pub fn new() -> Self {
29 Self::default()
30 }
31
32 pub fn with_oml_name(name: impl Into<String>) -> Self {
33 Self {
34 oml_name: Some(name.into()),
35 output_disabled: Vec::new(),
36 }
37 }
38
39 pub fn oml_name(&self) -> Option<&str> {
40 self.oml_name.as_deref()
41 }
42
43 pub fn with_output_disabled(fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
44 Self {
45 oml_name: None,
46 output_disabled: fields.into_iter().map(Into::into).collect(),
47 }
48 }
49
50 pub fn set_output_disabled(&mut self, fields: impl IntoIterator<Item = impl Into<String>>) {
51 self.output_disabled = fields.into_iter().map(Into::into).collect();
52 }
53
54 pub fn output_disabled(&self) -> &[String] {
55 &self.output_disabled
56 }
57
58 pub fn is_output_disabled(&self, field: &str) -> bool {
59 self.output_disabled.iter().any(|item| item == field)
60 }
61
62 pub fn is_empty(&self) -> bool {
63 self.oml_name.as_deref().is_none_or(str::is_empty) && self.output_disabled.is_empty()
64 }
65}
66
67// ---------- Core Sink Traits ----------
68
69/// Runtime control trait for managing sink lifecycle.
70///
71/// Implementors must ensure all methods are **idempotent** - calling them
72/// multiple times should be safe and produce consistent results.
73///
74/// # Example
75/// ```ignore
76/// #[async_trait]
77/// impl AsyncCtrl for MySink {
78/// async fn stop(&mut self) -> SinkResult<()> {
79/// self.connection.close().await?;
80/// Ok(())
81/// }
82/// async fn reconnect(&mut self) -> SinkResult<()> {
83/// self.connection = Connection::new(&self.config).await?;
84/// Ok(())
85/// }
86/// }
87/// ```
88#[async_trait]
89pub trait AsyncCtrl {
90 /// Gracefully stop the sink and release all resources.
91 ///
92 /// This method must be idempotent - subsequent calls should return `Ok(())`
93 /// without side effects. After calling `stop()`, the sink should not accept
94 /// any more data.
95 async fn stop(&mut self) -> SinkResult<()>;
96
97 /// Reconnect or refresh the sink's underlying connection.
98 ///
99 /// Use this to recover from transient failures without recreating the sink.
100 /// The method should preserve any configuration and state that doesn't
101 /// depend on the connection itself.
102 async fn reconnect(&mut self) -> SinkResult<()>;
103}
104
105/// Trait for sinking structured records.
106///
107/// Provides methods for writing parsed, typed data records to a destination.
108/// Implementations should handle batching efficiently when `sink_records` is called.
109#[async_trait]
110pub trait AsyncRecordSink {
111 /// Write a single data record to the sink.
112 ///
113 /// # Arguments
114 /// * `data` - Reference to the record to write
115 async fn sink_record(&mut self, data: &DataRecord) -> SinkResult<()>;
116
117 /// Write multiple data records in batch.
118 ///
119 /// Implementations should optimize for batch writes when possible.
120 /// The order of records in the vector should be preserved.
121 ///
122 /// # Arguments
123 /// * `data` - Vector of records wrapped in Arc for shared ownership
124 async fn sink_records(&mut self, data: Vec<Arc<DataRecord>>) -> SinkResult<()>;
125
126 /// Write multiple data records with batch-level metadata.
127 ///
128 /// Default implementation ignores [`BatchMeta`] and falls back to
129 /// [`sink_records`](Self::sink_records), so existing connectors compile
130 /// unchanged. Sinks that need the metadata should override this method.
131 ///
132 /// # Implementation Requirements
133 ///
134 /// Sink implementations SHOULD consume `meta.oml_name` according to their
135 /// output format:
136 ///
137 /// | Sink 类型 | `oml_name` 用途 |
138 /// |-----------|------------------|
139 /// | **Arrow** (`arrow_framed`) | 写入 frame header `tag`(`[tag_len][tag][IPC]`) |
140 /// | **Arrow** (`arrow_ipc`) | 忽略(裸 IPC Stream 无 tag 位置) |
141 /// | **JSON / CSV**(文本格式) | 作为 `wp_oml_name` 字段追加到每条记录中 |
142 ///
143 /// 当 `meta.oml_name` 为空时,所有 sink 保持原有行为(Arrow 使用 connector
144 /// 配置的固定 `tag`,文本格式不追加字段)。
145 ///
146 /// # Arguments
147 /// * `meta` - Batch-level runtime metadata (e.g. OML output name)
148 /// * `data` - Vector of records wrapped in Arc for shared ownership
149 async fn sink_records_with_meta(
150 &mut self,
151 meta: BatchMeta,
152 data: Vec<Arc<DataRecord>>,
153 ) -> SinkResult<()> {
154 let _ = meta;
155 self.sink_records(data).await
156 }
157}
158
159/// Trait for sinking raw data (strings and bytes).
160///
161/// Provides methods for writing unstructured data to a destination.
162/// Useful for pass-through scenarios or when data doesn't need parsing.
163#[async_trait]
164pub trait AsyncRawDataSink {
165 /// Write a single string payload.
166 async fn sink_str(&mut self, data: &str) -> SinkResult<()>;
167
168 /// Write a single byte payload.
169 async fn sink_bytes(&mut self, data: &[u8]) -> SinkResult<()>;
170
171 /// Write multiple string payloads in batch.
172 ///
173 /// Order of strings should be preserved in the output.
174 async fn sink_str_batch(&mut self, data: Vec<&str>) -> SinkResult<()>;
175
176 /// Write multiple byte payloads in batch.
177 ///
178 /// Order of byte slices should be preserved in the output.
179 async fn sink_bytes_batch(&mut self, data: Vec<&[u8]>) -> SinkResult<()>;
180}
181
182/// Combined trait for full-featured async sinks.
183///
184/// This is a marker trait that combines [`AsyncRecordSink`], [`AsyncRawDataSink`],
185/// and [`AsyncCtrl`]. Types implementing all three traits automatically implement
186/// `AsyncSink` through the blanket implementation.
187///
188/// Use this trait when you need a sink that supports all data formats and
189/// lifecycle management.
190pub trait AsyncSink: AsyncRecordSink + AsyncRawDataSink + AsyncCtrl + Send + Sync {}
191
192impl<T> AsyncSink for T where T: AsyncRecordSink + AsyncRawDataSink + AsyncCtrl + Send + Sync {}
193
194// ---------- Build Ctx ----------
195
196/// Build context passed to sink factories during construction.
197///
198/// Contains runtime configuration such as work directories, replica info,
199/// and rate limiting hints that sinks may use during initialization.
200#[derive(Clone, Debug)]
201pub struct SinkBuildCtx {
202 /// Root directory for sink-specific working files (state, checkpoints, etc.)
203 pub work_root: PathBuf,
204 /// Replica index for parallel group builds (0-based). Defaults to 0.
205 pub replica_idx: usize,
206 /// Replica count for the group (>=1). Defaults to 1.
207 pub replica_cnt: usize,
208 /// Upstream rate limit hint in requests per second. 0 means unlimited.
209 pub rate_limit_rps: usize,
210}
211
212impl SinkBuildCtx {
213 pub fn new(work_root: PathBuf) -> Self {
214 Self {
215 work_root,
216 replica_idx: 0,
217 replica_cnt: 1,
218 rate_limit_rps: 0,
219 }
220 }
221 pub fn new_with_replica(work_root: PathBuf, replica_idx: usize, replica_cnt: usize) -> Self {
222 Self {
223 work_root,
224 replica_idx,
225 replica_cnt: replica_cnt.max(1),
226 rate_limit_rps: 0,
227 }
228 }
229 pub fn with_limit(mut self, rate_limit_rps: usize) -> Self {
230 self.rate_limit_rps = rate_limit_rps;
231 self
232 }
233}
234
235/// Handle wrapping a boxed async sink instance.
236///
237/// Returned by [`SinkFactory::build`] and used by the orchestrator to
238/// manage sink lifecycle and data flow.
239pub struct SinkHandle {
240 /// The boxed sink implementing [`AsyncSink`]
241 pub sink: Box<dyn AsyncSink + 'static>,
242}
243
244impl std::fmt::Debug for SinkHandle {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 // Name matches the type to avoid confusion in logs/diagnostics
247 f.debug_struct("SinkHandle")
248 .field("sink", &"Box<dyn AsyncSink>")
249 .finish()
250 }
251}
252
253impl SinkHandle {
254 pub fn new(sink: Box<dyn AsyncSink + 'static>) -> Self {
255 Self { sink }
256 }
257}
258
259// ---------- Resolved Route Spec + Factory (for runtime decoupling) ----------
260
261/// Resolved sink specification with all parameters flattened.
262///
263/// Contains the fully resolved configuration for a sink instance,
264/// with all inheritance and defaults already applied.
265#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
266pub struct ResolvedSinkSpec {
267 /// Sink group name for routing and management
268 pub group: String,
269 /// Unique sink instance name within the group
270 pub name: String,
271 /// Sink type identifier (e.g., "kafka", "elasticsearch")
272 pub kind: String,
273 /// Reference to the connector definition
274 pub connector_id: String,
275 /// Flattened runtime parameters
276 pub params: crate::types::ParamMap,
277 /// Optional filter expression for selective routing
278 pub filter: Option<String>,
279}
280
281/// Factory trait for creating sink instances.
282///
283/// Implementors must also implement [`SinkDefProvider`] to provide
284/// connector metadata. The orchestrator uses this trait to construct
285/// sinks at runtime based on resolved specifications.
286///
287/// # Example
288/// ```ignore
289/// #[async_trait]
290/// impl SinkFactory for KafkaSinkFactory {
291/// fn kind(&self) -> &'static str { "kafka" }
292///
293/// async fn build(&self, spec: &ResolvedSinkSpec, ctx: &SinkBuildCtx) -> SinkResult<SinkHandle> {
294/// let sink = KafkaSink::new(&spec.params).await?;
295/// Ok(SinkHandle::new(Box::new(sink)))
296/// }
297/// }
298/// ```
299#[async_trait]
300pub trait SinkFactory: SinkDefProvider + Send + Sync + 'static {
301 /// Returns the unique type identifier for this sink factory.
302 fn kind(&self) -> &'static str;
303
304 /// Optional lightweight validation of the sink specification.
305 ///
306 /// Called before `build()` to catch configuration errors early.
307 /// Default implementation accepts all specifications.
308 fn validate_spec(&self, _spec: &ResolvedSinkSpec) -> SinkResult<()> {
309 Ok(())
310 }
311
312 /// Construct a new sink instance from the given specification.
313 ///
314 /// # Arguments
315 /// * `spec` - Resolved sink specification with parameters
316 /// * `ctx` - Build context with runtime configuration
317 ///
318 /// # Returns
319 /// A [`SinkHandle`] wrapping the constructed sink, or an error.
320 async fn build(&self, spec: &ResolvedSinkSpec, ctx: &SinkBuildCtx) -> SinkResult<SinkHandle>;
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use async_trait::async_trait;
327 use std::{path::PathBuf, sync::Arc};
328 use wp_model_core::model::DataRecord;
329
330 #[derive(Default)]
331 struct NoopSink;
332
333 #[async_trait]
334 impl AsyncCtrl for NoopSink {
335 async fn stop(&mut self) -> SinkResult<()> {
336 Ok(())
337 }
338
339 async fn reconnect(&mut self) -> SinkResult<()> {
340 Ok(())
341 }
342 }
343
344 #[async_trait]
345 impl AsyncRecordSink for NoopSink {
346 async fn sink_record(&mut self, _data: &DataRecord) -> SinkResult<()> {
347 Ok(())
348 }
349
350 async fn sink_records(&mut self, _data: Vec<Arc<DataRecord>>) -> SinkResult<()> {
351 Ok(())
352 }
353 }
354
355 #[async_trait]
356 impl AsyncRawDataSink for NoopSink {
357 async fn sink_str(&mut self, _data: &str) -> SinkResult<()> {
358 Ok(())
359 }
360
361 async fn sink_bytes(&mut self, _data: &[u8]) -> SinkResult<()> {
362 Ok(())
363 }
364
365 async fn sink_str_batch(&mut self, _data: Vec<&str>) -> SinkResult<()> {
366 Ok(())
367 }
368
369 async fn sink_bytes_batch(&mut self, _data: Vec<&[u8]>) -> SinkResult<()> {
370 Ok(())
371 }
372 }
373
374 #[test]
375 fn sink_build_ctx_defaults_and_limits() {
376 let ctx = SinkBuildCtx::new(PathBuf::from("/tmp/work"));
377 assert_eq!(ctx.work_root, PathBuf::from("/tmp/work"));
378 assert_eq!(ctx.replica_idx, 0);
379 assert_eq!(ctx.replica_cnt, 1);
380 assert_eq!(ctx.rate_limit_rps, 0);
381
382 let replica_ctx = SinkBuildCtx::new_with_replica(PathBuf::from("/tmp/work"), 2, 0);
383 assert_eq!(replica_ctx.replica_idx, 2);
384 assert_eq!(
385 replica_ctx.replica_cnt, 1,
386 "replica count should clamp to >=1"
387 );
388
389 let limited = SinkBuildCtx::new(PathBuf::from("/tmp/work")).with_limit(250);
390 assert_eq!(limited.rate_limit_rps, 250);
391 assert_eq!(limited.replica_cnt, 1);
392 }
393
394 #[test]
395 fn batch_meta_tracks_oml_name_and_output_disabled_fields() {
396 let mut meta = BatchMeta::with_oml_name("nginx_access");
397 assert_eq!(meta.oml_name(), Some("nginx_access"));
398 assert!(meta.output_disabled().is_empty());
399 assert!(!meta.is_empty());
400
401 meta.set_output_disabled(["wp_oml_name", "wp_event_id"]);
402 assert_eq!(meta.output_disabled(), &["wp_oml_name", "wp_event_id"]);
403 assert!(meta.is_output_disabled("wp_oml_name"));
404 assert!(!meta.is_output_disabled("wp_stream_tag"));
405
406 let disabled_only = BatchMeta::with_output_disabled(["wp_oml_name"]);
407 assert_eq!(disabled_only.oml_name(), None);
408 assert!(!disabled_only.is_empty());
409 }
410
411 #[test]
412 fn sink_handle_wraps_async_sink() {
413 let handle = SinkHandle::new(Box::new(NoopSink));
414 assert!(format!("{handle:?}").contains("SinkHandle"));
415 }
416}