Skip to main content

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}