Skip to main content

faucet_sink_pubsub/
sink.rs

1//! The Pub/Sub `Sink` implementation: encode → batched publish with bounded
2//! concurrency → per-message partial-failure outcomes (DLQ-routable).
3//!
4//! **SDK-touching module.** All `gcloud-pubsub` calls live here (client
5//! construction is in `faucet-common-pubsub`), so a real-compile fixup for a
6//! differing SDK version is localised to `PubsubSink::new`, `publish_chunk`,
7//! and `check`. The record→message logic (`encode_records`,
8//! `assemble_row_outcomes`) is pure and unit-tested offline.
9
10use 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
19/// Google Cloud Pub/Sub sink. See the crate README for semantics.
20pub struct PubsubSink {
21    config: PubsubSinkConfig,
22    client: Client,
23    publisher: Publisher,
24}
25
26/// Encode every record; per-record failures (bad payload, unresolvable
27/// ordering key, malformed attributes) land in the error map keyed by input
28/// index. Pure.
29pub(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
51/// Merge encode failures + publish outcomes into input-ordered per-row
52/// results. Pure.
53pub(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    /// Create a new Pub/Sub sink. Validates the config, builds the client, and
71    /// opens a reusable publisher. Ordered delivery is driven per-message: when
72    /// an `ordering_key` strategy is configured, `encode` stamps each message's
73    /// ordering key and the publisher sequences same-key messages automatically
74    /// (the `gcloud-pubsub` publisher has no client-level ordering toggle).
75    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    /// Publish one chunk of prepared records concurrently, returning per-index
89    /// outcomes.
90    async fn publish_chunk(
91        &self,
92        chunk: Vec<(usize, Prepared)>,
93    ) -> BTreeMap<usize, Result<(), FaucetError>> {
94        use futures::StreamExt;
95        // Enqueue every message; the publisher bundles them internally.
96        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    /// Publish all prepared records in `batch_size` chunks, merging outcomes.
125    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    /// Per-row outcomes in input order: encode failures and per-message publish
177    /// rejections come back as `Err` rows for the DLQ router.
178    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    /// Side-effect-free probe: confirm the topic exists (no messages written).
210    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}), // ok
244            json!({"v": 2}),            // no ordering-key field
245            json!({"id": "c", "v": 3}), // ok
246        ];
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        // index 3 absent from both → defaults to Ok
274
275        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        // Validation runs before any client build → offline config error.
305        let mut cfg = PubsubSinkConfig::new("orders");
306        cfg.batch_size = 0;
307        // `PubsubSink` holds a non-`Debug` SDK publisher, so `unwrap_err` (which
308        // needs the `Ok` type to be `Debug`) is unavailable — match instead.
309        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}