1use crate::config::PubsubSinkConfig;
11use crate::encode::{Prepared, prepare};
12use faucet_common_pubsub::PubsubMessage;
13use faucet_core::{FaucetError, RowOutcome};
14use gcloud_pubsub::client::Client;
15use gcloud_pubsub::publisher::{Publisher, PublisherConfig};
16use serde_json::Value;
17use std::collections::BTreeMap;
18
19pub struct PubsubSink {
21 config: PubsubSinkConfig,
22 client: Client,
23 publisher: Publisher,
24}
25
26pub(crate) fn encode_records(
30 records: &[Value],
31 config: &PubsubSinkConfig,
32) -> (Vec<(usize, Prepared)>, BTreeMap<usize, FaucetError>) {
33 let mut prepared = Vec::with_capacity(records.len());
34 let mut failures = BTreeMap::new();
35 for (index, record) in records.iter().enumerate() {
36 match prepare(
37 record,
38 config.value_format,
39 &config.ordering_key,
40 config.attributes_field.as_deref(),
41 ) {
42 Ok(p) => prepared.push((index, p)),
43 Err(e) => {
44 failures.insert(index, e);
45 }
46 }
47 }
48 (prepared, failures)
49}
50
51pub(crate) fn assemble_row_outcomes(
54 len: usize,
55 mut encode_failures: BTreeMap<usize, FaucetError>,
56 mut shipped: BTreeMap<usize, Result<(), FaucetError>>,
57) -> Vec<RowOutcome> {
58 (0..len)
59 .map(|i| {
60 if let Some(err) = encode_failures.remove(&i) {
61 Err(err)
62 } else {
63 shipped.remove(&i).unwrap_or(Ok(()))
64 }
65 })
66 .collect()
67}
68
69impl PubsubSink {
70 pub async fn new(config: PubsubSinkConfig) -> Result<Self, FaucetError> {
76 config.validate()?;
77 let client = faucet_common_pubsub::build_client(&config.connection).await?;
78 let publisher = client
79 .topic(&config.topic)
80 .new_publisher(Some(PublisherConfig::default()));
81 Ok(Self {
82 config,
83 client,
84 publisher,
85 })
86 }
87
88 async fn publish_chunk(
91 &self,
92 chunk: Vec<(usize, Prepared)>,
93 ) -> BTreeMap<usize, Result<(), FaucetError>> {
94 use futures::StreamExt;
95 let mut awaiters = Vec::with_capacity(chunk.len());
97 for (index, p) in chunk {
98 let msg = PubsubMessage {
99 data: p.data,
100 attributes: p.attributes,
101 ordering_key: p.ordering_key,
102 ..Default::default()
103 };
104 let awaiter = self.publisher.publish(msg).await;
105 awaiters.push((index, awaiter));
106 }
107 let mut outcomes = BTreeMap::new();
108 let mut stream =
109 futures::stream::iter(awaiters.into_iter().map(|(index, awaiter)| async move {
110 let result = awaiter
111 .get()
112 .await
113 .map(|_message_id| ())
114 .map_err(|e| FaucetError::Sink(format!("pubsub: publish failed: {e}")));
115 (index, result)
116 }))
117 .buffer_unordered(self.config.concurrency);
118 while let Some((index, result)) = stream.next().await {
119 outcomes.insert(index, result);
120 }
121 outcomes
122 }
123
124 async fn publish_all(
126 &self,
127 prepared: Vec<(usize, Prepared)>,
128 ) -> BTreeMap<usize, Result<(), FaucetError>> {
129 let mut merged = BTreeMap::new();
130 let mut iter = prepared.into_iter();
131 loop {
132 let chunk: Vec<_> = iter.by_ref().take(self.config.batch_size).collect();
133 if chunk.is_empty() {
134 break;
135 }
136 merged.extend(self.publish_chunk(chunk).await);
137 }
138 merged
139 }
140}
141
142#[faucet_core::async_trait]
143impl faucet_core::Sink for PubsubSink {
144 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
145 if records.is_empty() {
146 return Ok(0);
147 }
148 let (prepared, encode_failures) = encode_records(records, &self.config);
149 let outcomes = self.publish_all(prepared).await;
150 let delivered = outcomes.values().filter(|o| o.is_ok()).count();
151 let failed = encode_failures.len() + (outcomes.len() - delivered);
152 if failed > 0 {
153 let first = encode_failures
154 .values()
155 .next()
156 .map(ToString::to_string)
157 .or_else(|| {
158 outcomes
159 .values()
160 .find_map(|o| o.as_ref().err().map(ToString::to_string))
161 })
162 .unwrap_or_default();
163 return Err(FaucetError::Sink(format!(
164 "pubsub: {failed} of {} record(s) failed to publish (first: {first})",
165 records.len()
166 )));
167 }
168 tracing::info!(
169 topic = %self.config.topic,
170 records = delivered,
171 "pubsub sink write complete"
172 );
173 Ok(delivered)
174 }
175
176 async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
179 let (prepared, encode_failures) = encode_records(records, &self.config);
180 let shipped = self.publish_all(prepared).await;
181 Ok(assemble_row_outcomes(
182 records.len(),
183 encode_failures,
184 shipped,
185 ))
186 }
187
188 fn config_schema(&self) -> Value {
189 serde_json::to_value(faucet_core::schema_for!(PubsubSinkConfig))
190 .expect("schema serialization")
191 }
192
193 fn connector_name(&self) -> &'static str {
194 "pubsub"
195 }
196
197 fn dataset_uri(&self) -> String {
198 format!(
199 "pubsub://{}/topics/{}",
200 self.config
201 .connection
202 .project_id
203 .as_deref()
204 .unwrap_or("default"),
205 self.config.topic
206 )
207 }
208
209 async fn check(
211 &self,
212 ctx: &faucet_core::CheckContext,
213 ) -> Result<faucet_core::CheckReport, FaucetError> {
214 use faucet_core::{CheckReport, Probe};
215 let start = std::time::Instant::now();
216 let topic = self.client.topic(&self.config.topic);
217 let fut = topic.exists(None);
218 let probe = match tokio::time::timeout(ctx.timeout, fut).await {
219 Err(_) => Probe::fail("topic_exists", start.elapsed(), "timed out"),
220 Ok(Ok(true)) => Probe::pass("topic_exists", start.elapsed()),
221 Ok(Ok(false)) => Probe::fail(
222 "topic_exists",
223 start.elapsed(),
224 format!("topic '{}' does not exist", self.config.topic),
225 ),
226 Ok(Err(e)) => Probe::fail("topic_exists", start.elapsed(), e.to_string()),
227 };
228 Ok(CheckReport::single(probe))
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use crate::config::{OrderingKey, ValueFormat};
236 use serde_json::json;
237
238 #[test]
239 fn encode_records_partitions_failures_by_index() {
240 let mut cfg = PubsubSinkConfig::new("orders");
241 cfg.ordering_key = OrderingKey::Field { name: "id".into() };
242 let records = vec![
243 json!({"id": "a", "v": 1}), json!({"v": 2}), json!({"id": "c", "v": 3}), ];
247 let (prepared, failures) = encode_records(&records, &cfg);
248 assert_eq!(prepared.len(), 2);
249 assert_eq!(prepared[0].0, 0);
250 assert_eq!(prepared[1].0, 2);
251 assert_eq!(failures.len(), 1);
252 assert!(failures[&1].to_string().contains("id"), "{}", failures[&1]);
253 }
254
255 #[test]
256 fn encode_records_string_format_failure_is_per_record() {
257 let mut cfg = PubsubSinkConfig::new("orders");
258 cfg.value_format = ValueFormat::String;
259 let (prepared, failures) = encode_records(&[json!({"not": "a string"}), json!("ok")], &cfg);
260 assert_eq!(prepared.len(), 1, "only the string record encodes");
261 assert_eq!(prepared[0].0, 1);
262 assert_eq!(failures.len(), 1);
263 assert!(failures.contains_key(&0));
264 }
265
266 #[test]
267 fn assemble_row_outcomes_interleaves_encode_and_publish_results() {
268 let mut encode_failures = BTreeMap::new();
269 encode_failures.insert(1usize, FaucetError::Sink("bad encode".into()));
270 let mut shipped = BTreeMap::new();
271 shipped.insert(0usize, Ok(()));
272 shipped.insert(2usize, Err(FaucetError::Sink("publish rejected".into())));
273 let outcomes = assemble_row_outcomes(4, encode_failures, shipped);
276 assert_eq!(outcomes.len(), 4);
277 assert!(outcomes[0].is_ok());
278 assert!(
279 outcomes[1]
280 .as_ref()
281 .unwrap_err()
282 .to_string()
283 .contains("bad encode")
284 );
285 assert!(
286 outcomes[2]
287 .as_ref()
288 .unwrap_err()
289 .to_string()
290 .contains("publish rejected")
291 );
292 assert!(outcomes[3].is_ok());
293 }
294
295 #[test]
296 fn config_schema_exposes_fields() {
297 let schema = serde_json::to_value(faucet_core::schema_for!(PubsubSinkConfig)).unwrap();
298 assert!(schema["properties"]["topic"].is_object());
299 assert!(schema["properties"]["ordering_key"].is_object());
300 }
301
302 #[tokio::test]
303 async fn new_rejects_invalid_config() {
304 let mut cfg = PubsubSinkConfig::new("orders");
306 cfg.batch_size = 0;
307 let err = match PubsubSink::new(cfg).await {
310 Ok(_) => panic!("expected a config error for batch_size = 0"),
311 Err(e) => e,
312 };
313 assert!(err.to_string().contains("batch_size"), "{err}");
314 }
315}