1use crate::config::SftpSinkConfig;
11use async_trait::async_trait;
12use faucet_common_sftp::{SftpSession, connect};
13use faucet_core::FaucetError;
14use russh_sftp::protocol::OpenFlags;
15use serde_json::Value;
16use tokio::io::AsyncWriteExt;
17use tokio::sync::Mutex;
18
19pub struct SftpSink {
21 config: SftpSinkConfig,
22 session: Mutex<Option<SftpSession>>,
25}
26
27impl SftpSink {
28 pub fn new(config: SftpSinkConfig) -> Result<Self, FaucetError> {
31 faucet_core::validate_batch_size(config.batch_size)?;
32 Ok(Self {
33 config,
34 session: Mutex::new(None),
35 })
36 }
37
38 fn serialize_jsonl(records: &[Value]) -> Result<Vec<u8>, FaucetError> {
40 let mut buf: Vec<u8> = Vec::new();
41 for record in records {
42 let line = serde_json::to_vec(record)
43 .map_err(|e| FaucetError::Sink(format!("JSON serialization failed: {e}")))?;
44 buf.extend_from_slice(&line);
45 buf.push(b'\n');
46 }
47 Ok(buf)
48 }
49
50 fn join_path(&self, name: &str) -> String {
52 let dir = &self.config.path;
53 if dir.is_empty() {
54 name.to_string()
55 } else if dir.ends_with('/') {
56 format!("{dir}{name}")
57 } else {
58 format!("{dir}/{name}")
59 }
60 }
61
62 fn final_key(&self) -> String {
64 let id = uuid::Uuid::new_v4();
65 self.join_path(&format!("{id}{}", self.config.file_extension))
66 }
67
68 async fn upload_atomic(
71 sftp: &SftpSession,
72 final_key: &str,
73 body: &[u8],
74 ) -> Result<(), FaucetError> {
75 let temp_key = format!("{final_key}.tmp");
76
77 let mut file = sftp
78 .open_with_flags(
79 temp_key.as_str(),
80 OpenFlags::CREATE | OpenFlags::WRITE | OpenFlags::TRUNCATE,
81 )
82 .await
83 .map_err(|e| {
84 FaucetError::Sink(format!("SFTP open '{temp_key}' for write failed: {e}"))
85 })?;
86
87 file.write_all(body)
88 .await
89 .map_err(|e| FaucetError::Sink(format!("SFTP write to '{temp_key}' failed: {e}")))?;
90 file.flush()
91 .await
92 .map_err(|e| FaucetError::Sink(format!("SFTP flush of '{temp_key}' failed: {e}")))?;
93 file.shutdown()
94 .await
95 .map_err(|e| FaucetError::Sink(format!("SFTP close of '{temp_key}' failed: {e}")))?;
96
97 sftp.rename(temp_key.as_str(), final_key)
98 .await
99 .map_err(|e| {
100 FaucetError::Sink(format!(
101 "SFTP rename '{temp_key}' -> '{final_key}' failed: {e}"
102 ))
103 })?;
104
105 tracing::debug!(key = %final_key, "wrote SFTP object");
106 Ok(())
107 }
108}
109
110#[async_trait]
111impl faucet_core::Sink for SftpSink {
112 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
113 if records.is_empty() {
114 return Ok(0);
115 }
116
117 let mut guard = self.session.lock().await;
118 if guard.is_none() {
119 let sftp = connect(&self.config.connection).await?;
120 if let Err(e) = sftp.create_dir(self.config.path.as_str()).await {
124 tracing::debug!(path = %self.config.path, error = %e, "SFTP create_dir (best-effort)");
125 }
126 *guard = Some(sftp);
127 }
128 let sftp = guard.as_ref().expect("session initialized above");
129
130 let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
131 vec![records]
132 } else {
133 records.chunks(self.config.batch_size).collect()
134 };
135
136 let files = chunks.len();
137 for chunk in chunks {
138 let body = Self::serialize_jsonl(chunk)?;
139 let key = self.final_key();
140 Self::upload_atomic(sftp, &key, &body).await?;
141 }
142
143 tracing::info!(records = records.len(), files, "SFTP batch write complete");
144 Ok(records.len())
145 }
146
147 fn config_schema(&self) -> Value {
148 serde_json::to_value(faucet_core::schema_for!(SftpSinkConfig))
149 .expect("schema serialization")
150 }
151
152 fn connector_name(&self) -> &'static str {
153 "sftp"
154 }
155
156 fn dataset_uri(&self) -> String {
157 format!(
158 "sftp://{}:{}/{}",
159 self.config.connection.host,
160 self.config.connection.port,
161 self.config.path.trim_start_matches('/')
162 )
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use faucet_common_sftp::SftpConnectionConfig;
170 use faucet_core::Sink;
171
172 fn cfg() -> SftpSinkConfig {
173 SftpSinkConfig::new(SftpConnectionConfig::with_password("h", "u", "p"), "/out")
174 }
175
176 #[test]
177 fn new_is_lazy_and_validates_batch_size() {
178 assert!(SftpSink::new(cfg()).is_ok());
179 let bad = cfg().with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
180 assert!(matches!(SftpSink::new(bad), Err(FaucetError::Config(_))));
181 }
182
183 #[test]
184 fn serialize_jsonl_is_newline_delimited() {
185 let records = vec![
186 serde_json::json!({"id": 1, "name": "Alice"}),
187 serde_json::json!({"id": 2, "name": "Bob"}),
188 ];
189 let bytes = SftpSink::serialize_jsonl(&records).unwrap();
190 let text = String::from_utf8(bytes).unwrap();
191 let lines: Vec<&str> = text.trim().split('\n').collect();
192 assert_eq!(lines.len(), 2);
193 let first: Value = serde_json::from_str(lines[0]).unwrap();
194 assert_eq!(first["id"], 1);
195 }
196
197 #[test]
198 fn serialize_jsonl_empty() {
199 assert!(SftpSink::serialize_jsonl(&[]).unwrap().is_empty());
200 }
201
202 #[test]
203 fn join_path_handles_trailing_slash() {
204 let sink = SftpSink::new(cfg()).unwrap();
205 assert_eq!(sink.join_path("f.jsonl"), "/out/f.jsonl");
206
207 let sink2 = SftpSink::new(SftpSinkConfig::new(
208 SftpConnectionConfig::with_password("h", "u", "p"),
209 "/out/",
210 ))
211 .unwrap();
212 assert_eq!(sink2.join_path("f.jsonl"), "/out/f.jsonl");
213 }
214
215 #[test]
216 fn final_key_uses_prefix_and_extension() {
217 let sink = SftpSink::new(cfg()).unwrap();
218 let key = sink.final_key();
219 assert!(key.starts_with("/out/"), "got {key}");
220 assert!(key.ends_with(".jsonl"), "got {key}");
221 }
222
223 #[test]
224 fn connector_name_is_sftp() {
225 let sink = SftpSink::new(cfg()).unwrap();
226 assert_eq!(sink.connector_name(), "sftp");
227 }
228
229 #[test]
230 fn dataset_uri_has_no_credentials() {
231 let sink = SftpSink::new(cfg()).unwrap();
232 assert_eq!(sink.dataset_uri(), "sftp://h:22/out");
233 }
234
235 #[test]
236 fn append_only_capabilities() {
237 let sink = SftpSink::new(cfg()).unwrap();
238 assert!(!sink.supports_idempotent_writes());
239 assert!(!sink.dedups_by_key());
240 assert!(
241 sink.supported_write_modes()
242 .contains(&faucet_core::write_mode::WriteMode::Append)
243 );
244 }
245}