1use 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#[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#[derive(Debug, Clone)]
44pub struct CommitAction {
45 pub repo: String,
46 pub branch: String,
47 pub token: String,
48 pub summary: String,
49 pub path_in_repo: String,
51 pub content: Vec<u8>,
52}
53
54#[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
145pub 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 #[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 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}