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