Skip to main content

meathook/sink/
huggingface.rs

1//! [`HfSink`]: terminal sink committing parquet files to a `HuggingFace`
2//! dataset repo.
3//!
4//! Sans-IO, satay-style: a hand-written [`CommitAction`] implements
5//! [`satay_runtime::Action`] and is sent through
6//! `satay_reqwest::ReqwestActionExt::send_with` — the same transport path as
7//! every collector. A satay-*generated* HF client isn't possible yet
8//! (satay-codegen rejects non-JSON request bodies; NDJSON gets first-class
9//! `OpenAPI` treatment only in 3.2 `itemSchema`); once one exists, swapping it
10//! in is a drop-in change behind the `Action` boundary.
11
12use std::marker::PhantomData;
13
14use base64::Engine as _;
15use base64::engine::general_purpose::STANDARD as BASE64;
16use http::header;
17use satay_reqwest::ReqwestActionExt;
18use satay_runtime::{Action, RequestParts, ResponseParts, insert_header, into_request};
19use serde::de;
20use serde::{Deserialize, Serialize};
21use tracing::info;
22
23use crate::encode::{self, EncodeError};
24use crate::sink::{Sink, WindowMeta};
25
26/// Error from the `HuggingFace` sink.
27#[derive(Debug, thiserror::Error)]
28pub enum HfSinkError {
29    #[error(transparent)]
30    Encode(#[from] EncodeError),
31    #[error("transport error: {0}")]
32    Transport(#[from] satay_reqwest::Error),
33    #[error("hugging face rejected commit ({status}): {body}")]
34    Rejected {
35        status: http::StatusCode,
36        body: String,
37    },
38}
39
40/// One commit of a single file to a `HuggingFace` dataset repo, as a sans-IO
41/// [`Action`]: `POST /api/datasets/{repo}/commit/{branch}` with an NDJSON
42/// payload (commit header line + base64-inlined file line).
43#[derive(Debug, Clone)]
44pub struct CommitAction {
45    pub repo: String,
46    pub branch: String,
47    pub token: String,
48    pub summary: String,
49    /// Path of the file inside the repo, e.g. `data/pm25/2026-06-12/08.parquet`.
50    pub path_in_repo: String,
51    pub content: Vec<u8>,
52}
53
54/// Decoded result of a [`CommitAction`].
55///
56/// Non-2xx responses decode into [`Rejected`](CommitOutcome::Rejected)
57/// rather than an error so the typed status/body survive the fixed
58/// `satay_runtime::Error` decode signature.
59#[derive(Debug, Clone)]
60pub enum CommitOutcome {
61    Committed(CommitResponse),
62    Rejected {
63        status: http::StatusCode,
64        body: String,
65    },
66}
67
68#[derive(Debug, Clone, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct CommitResponse {
71    #[serde(default)]
72    pub commit_url: Option<String>,
73    #[serde(default)]
74    pub commit_oid: Option<String>,
75}
76
77#[derive(Serialize)]
78struct NdjsonLine<V> {
79    key: &'static str,
80    value: V,
81}
82
83impl Action for CommitAction {
84    type Response = CommitOutcome;
85
86    fn request(self) -> Result<http::Request<Vec<u8>>, satay_runtime::Error> {
87        let uri = format!(
88            "https://huggingface.co/api/datasets/{}/commit/{}",
89            self.repo, self.branch
90        );
91
92        let header_line = serde_json::to_vec(&NdjsonLine {
93            key: "header",
94            value: serde_json::json!({ "summary": self.summary }),
95        })?;
96        let file_line = serde_json::to_vec(&NdjsonLine {
97            key: "file",
98            value: serde_json::json!({
99                "path": self.path_in_repo,
100                "content": BASE64.encode(&self.content),
101                "encoding": "base64",
102            }),
103        })?;
104
105        let mut body = header_line;
106        body.push(b'\n');
107        body.extend_from_slice(&file_line);
108        body.push(b'\n');
109
110        let mut headers = http::HeaderMap::new();
111        insert_header(
112            &mut headers,
113            "authorization",
114            &format!("Bearer {}", self.token),
115        )?;
116        insert_header(&mut headers, "content-type", "application/x-ndjson")?;
117        if let Some(auth) = headers.get_mut(header::AUTHORIZATION) {
118            auth.set_sensitive(true);
119        }
120
121        into_request(RequestParts {
122            method: http::Method::POST,
123            uri,
124            headers,
125            body,
126        })
127    }
128
129    fn decode<B: AsRef<[u8]>>(
130        response: ResponseParts<B>,
131    ) -> Result<Self::Response, satay_runtime::Error> {
132        if response.status.is_success() {
133            Ok(CommitOutcome::Committed(satay_runtime::from_json_slice(
134                response.body.as_ref(),
135            )?))
136        } else {
137            Ok(CommitOutcome::Rejected {
138                status: response.status,
139                body: String::from_utf8_lossy(response.body.as_ref()).into_owned(),
140            })
141        }
142    }
143}
144
145/// Terminal sink: encodes each ingested window to parquet and commits it to
146/// a `HuggingFace` dataset repo at a deterministic, Hive-style path:
147///
148/// ```text
149/// data/{pipeline}/{YYYY-MM-DD}/{HH}.parquet
150/// ```
151///
152/// The path depends only on the window start, so replaying a window (crash
153/// after upload but before the spool segment was deleted) overwrites the
154/// same file with the same content — replays are idempotent.
155///
156/// Retry/backoff is *not* handled here: an upstream [`DiskSpool`] or
157/// [`Buffered`] tier retains records when this sink errors and retries at
158/// its next firing.
159///
160/// [`DiskSpool`]: crate::DiskSpool
161/// [`Buffered`]: crate::Buffered
162pub struct HfSink<R> {
163    client: reqwest::Client,
164    repo: String,
165    branch: String,
166    token: String,
167    _record: PhantomData<fn(R)>,
168}
169
170impl<R> HfSink<R> {
171    /// Sink committing to `repo` (e.g. `"zeon256/sg-weather"`) on branch
172    /// `main`. The token is a `HuggingFace` access token with write access,
173    /// typically from the `HF_TOKEN` env var.
174    #[must_use]
175    pub fn new(client: reqwest::Client, repo: impl Into<String>, token: impl Into<String>) -> Self {
176        Self {
177            client,
178            repo: repo.into(),
179            branch: "main".to_owned(),
180            token: token.into(),
181            _record: PhantomData,
182        }
183    }
184
185    #[must_use]
186    pub fn branch(mut self, branch: impl Into<String>) -> Self {
187        self.branch = branch.into();
188        self
189    }
190}
191
192fn object_path(meta: &WindowMeta) -> String {
193    let date = meta.start.date();
194    format!(
195        "data/{}/{:04}-{:02}-{:02}/{:02}.parquet",
196        meta.pipeline,
197        date.year(),
198        u8::from(date.month()),
199        date.day(),
200        meta.start.hour(),
201    )
202}
203
204impl<R> Sink<R> for HfSink<R>
205where
206    R: Serialize + de::DeserializeOwned + Send + 'static,
207{
208    type Error = HfSinkError;
209
210    async fn ingest(&mut self, meta: &WindowMeta, records: Vec<R>) -> Result<(), Self::Error> {
211        if records.is_empty() {
212            return Ok(());
213        }
214        let content = encode::to_parquet(&records)?;
215        let path_in_repo = object_path(meta);
216        let action = CommitAction {
217            repo: self.repo.clone(),
218            branch: self.branch.clone(),
219            token: self.token.clone(),
220            summary: format!(
221                "meathook: {} window {} → {}",
222                meta.pipeline, meta.start, meta.end
223            ),
224            path_in_repo: path_in_repo.clone(),
225            content,
226        };
227
228        match action.send_with(&self.client).await? {
229            CommitOutcome::Committed(commit) => {
230                info!(
231                    pipeline = %meta.pipeline,
232                    path = %path_in_repo,
233                    records = records.len(),
234                    commit = commit.commit_oid.as_deref().unwrap_or("?"),
235                    "committed window to hugging face"
236                );
237                Ok(())
238            }
239            CommitOutcome::Rejected { status, body } => Err(HfSinkError::Rejected { status, body }),
240        }
241    }
242
243    /// No-op: this terminal sink ships every batch as it is ingested.
244    async fn flush(&mut self) -> Result<(), Self::Error> {
245        Ok(())
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use time::macros::datetime;
253
254    fn action() -> CommitAction {
255        CommitAction {
256            repo: "zeon256/sg-weather".into(),
257            branch: "main".into(),
258            token: "hf_secret".into(),
259            summary: "meathook: pm25 window".into(),
260            path_in_repo: "data/pm25/2026-06-12/08.parquet".into(),
261            content: b"PARQUET".to_vec(),
262        }
263    }
264
265    #[test]
266    fn commit_request_shape() {
267        let request = action().request().unwrap();
268
269        assert_eq!(request.method(), http::Method::POST);
270        assert_eq!(
271            request.uri(),
272            "https://huggingface.co/api/datasets/zeon256/sg-weather/commit/main"
273        );
274        assert_eq!(
275            request.headers().get("content-type").unwrap(),
276            "application/x-ndjson"
277        );
278        assert_eq!(
279            request.headers().get("authorization").unwrap(),
280            "Bearer hf_secret"
281        );
282
283        let body = String::from_utf8(request.body().clone()).unwrap();
284        let lines = body.lines().collect::<Vec<&str>>();
285        assert_eq!(lines.len(), 2);
286
287        let header: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
288        assert_eq!(header["key"], "header");
289        assert_eq!(header["value"]["summary"], "meathook: pm25 window");
290
291        let file: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
292        assert_eq!(file["key"], "file");
293        assert_eq!(file["value"]["path"], "data/pm25/2026-06-12/08.parquet");
294        assert_eq!(file["value"]["encoding"], "base64");
295        let decoded = BASE64
296            .decode(file["value"]["content"].as_str().unwrap())
297            .unwrap();
298        assert_eq!(decoded, b"PARQUET");
299    }
300
301    #[test]
302    fn decode_success_and_rejection() {
303        let ok = CommitAction::decode(ResponseParts {
304            status: http::StatusCode::OK,
305            headers: http::HeaderMap::new(),
306            body: br#"{"commitUrl":"https://hf.co/c/abc","commitOid":"abc123"}"#.as_slice(),
307        })
308        .unwrap();
309        match ok {
310            CommitOutcome::Committed(c) => {
311                assert_eq!(c.commit_oid.as_deref(), Some("abc123"));
312            }
313            other @ CommitOutcome::Rejected { .. } => panic!("expected Committed, got {other:?}"),
314        }
315
316        let rejected = CommitAction::decode(ResponseParts {
317            status: http::StatusCode::UNAUTHORIZED,
318            headers: http::HeaderMap::new(),
319            body: b"Invalid credentials".as_slice(),
320        })
321        .unwrap();
322        match rejected {
323            CommitOutcome::Rejected { status, body } => {
324                assert_eq!(status, http::StatusCode::UNAUTHORIZED);
325                assert_eq!(body, "Invalid credentials");
326            }
327            other @ CommitOutcome::Committed(_) => panic!("expected Rejected, got {other:?}"),
328        }
329    }
330
331    #[test]
332    fn object_path_is_hive_partitioned() {
333        let meta = WindowMeta {
334            pipeline: "air_temperature".into(),
335            start: datetime!(2026-06-12 08:00 UTC),
336            end: datetime!(2026-06-12 09:00 UTC),
337        };
338        assert_eq!(
339            object_path(&meta),
340            "data/air_temperature/2026-06-12/08.parquet"
341        );
342    }
343}