meathook-rs
Capture ephemeral data.
Lose nothing.
Store-backed tiers and file encoders compose in code. No YAML plugin system, no boxed errors.
crash and panic loss follow the outermost store; SIGTERM drains the full stack
built on the satay-rs sans-IO action model
ephemeral → durable
APIs forget. meathook doesn't.
A weather reading at 14:02 is gone from the source by 14:03. The API only ever serves latest. If you don't poll and store it continuously, that reading is lost forever.
now
gone in 60s
- 2024-06-18/13.parquet
- 2024-06-18/14.parquet
- 2024-06-19/14.parquet
- 2024-06-20/14.parquet
- 2024-07-18/14.parquet
- 2024-08-18/14.parquet
A single reading is worthless.
A year of hourly readings is a dataset.
tick · flush · ship
Each pipeline keeps its own rhythm.
Collectors tick every minute, every five, or every hour. Store-backed tiers choose when to flush; the selected encoder writes one file when its window closes.
data/{pipeline}/{YYYY-MM-DD}/{HH}-{MM}-{SS}-{hash}.{ext}failure inventory
Durability is a store choice.
store/jsonl.rs
Tier owns windowing, flush policy, and replay over any Store<R>. An outer MemStore trades a bounded volatile window for fewer fsyncs; aJsonlStore-backed tier fsyncs batches when they reach it and retains them until its downstream accepts them. Implement the same trait for SQLite, object storage, or another backend.
| failure | what happens | data lost |
|---|---|---|
| SIGKILL / OOM-kill | JsonlStore replays batches it received; outer MemStore state is gone | outer memory window + ≤ 1 torn JSONL record |
| Task panic | supervisor rebuilds; JsonlStore replays batches it received | records still held in MemStore |
| Sink outage (HF 5xx) | the tier retains each window and retries on its next firing | none |
| Graceful SIGTERM | runtime drains every tier and terminal before exit | none |
| Backing store lost | unflushed records in that Store are gone | choose JsonlStore on a PVC for crash recovery |
four slots, four swaps
One stack. Four independent choices.
A tier owns policy, a Store owns records, an Encoder owns bytes, and the terminal owns delivery. Swap one without rewriting the others.
Example stacks rotate through MemStore, JsonlStore, custom Store implementations, ParquetEncoder, JsonEncoder, CsvEncoder, HfSink, and custom terminal sinks. Tiers are optional and may be nested.
footprint
A year-long collector that fits in 14.8 megabytes.
A release binary polling three NEA weather endpoints on a single tokio runtime. I/O-bound between ticks; memory stays flat.
sampled live from a release binary running the nea example
example · singapore weather → huggingface
A real collector, not a toy.
The reference consumer polls Singapore's NEA / data.gov.sg realtime weather via the satay-generatednea-rsclient. Three pipelines, each its own tokio task, deduped by key.
per-station readings
dedupe key: (station_id, timestamp)
per-station readings
dedupe key: (station_id, timestamp)
regional readings
dedupe key: (region, timestamp)
Each flush ships one encoded file at a deterministic, content-keyed Hive-style path. Replays are safe and distinct payloads never collide.
repo:
zeon256/nea-weather
path: data/{pipeline}/{YYYY-MM-DD}/{HH}-{MM}-{SS}-{hash}.parquetstore · encoder · sink
Stores, encoders, and sinks compose.
Implement Store<R>,Encoder, orSink<R>. Start with SinkStack::new(), add tiers in record-flow order, then finish with.terminal(). Configure formats directly on the terminal with.encoder().
1use std::{ time:: Duration , env}; 2 3use meathook::{ 4 FlushPolicy , HfSink , JsonEncoder , JsonlStore , Meathook , MemStore , 5 Pipeline , SatayCollector , SinkStack , 6}; 7use satay_reqwest:: ReqwestActionExt as _; 8use reqwest:: Client ; 9 10# [ tokio :: main ] 11async fn main () -> Result <(), meathook:: runtime:: RuntimeError > { 12 let client = Client :: new (); 13 let token = env:: var ( "HF_TOKEN" ). expect ( "HF_TOKEN must be set" ); 14 15 Meathook :: builder () 16 . pipeline ( move || { 17 let api = nea_rs:: Api :: new (); 18 let collector = SatayCollector :: new ( 19 "air_temperature" , 20 client. clone (), 21 move |client| { 22 let api = api. clone (); 23 async move { api. air_temperature (). send_with ( & client). await } 24 }, 25 |response| flatten ( response), 26 ); 27 28 // Declaration order is record flow: memory batches for five minutes 29 // or 10k records, JSONL fsyncs each batch and forwards hourly, then 30 // the terminal uploads it. Disk is reached only when memory fires. 31 let sink = SinkStack :: new () 32 . tier ( 33 MemStore :: new (), 34 FlushPolicy :: new ( Duration :: from_secs ( 300 ), 10_000 ), 35 ) 36 . tier ( 37 JsonlStore :: new ( "/var/lib/meathook/spool/air_temperature" ), 38 FlushPolicy :: hourly (), 39 ) 40 . terminal ( 41 HfSink :: new ( client. clone (), "you/your-dataset" , token. clone ()) 42 . encoder ( JsonEncoder ), 43 ); 44 45 Pipeline :: new ( collector, sink, Duration :: from_secs ( 60 )) 46 . with_key_fn ( |r : & MyRecord | ( r. station_id . clone (), r. timestamp . clone ())) 47 }) 48 . run () 49 . await 50} records stay plain structs: #[derive(Serialize, Deserialize)] is enough for every built-in encoder
cargo features
Bring only the I/O you need.
With --no-default-features, the core keeps its traits and JsonEncoder. Cargo features add Parquet, CSV, satay collectors, and Hugging Face delivery.
Feature flags
| feature | default | what it enables |
|---|---|---|
| parquet | ✓ | ParquetEncoder via arrow, parquet, and serde_arrow |
| csv | · | CsvEncoder for flat record types |
| satay | · | SatayCollector for satay-generated API clients |
| huggingface | ✓ | HfSink and CommitAction; implies parquet and satay |
Missing an integration? Request one. Built one? Open a PR.
Start capturing before the API forgets.
docs.rs