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}