meathook-rs

Capture ephemeral data.
Lose nothing.

Store-backed tiers and file encoders compose in code. No YAML plugin system, no boxed errors.

Read the docs

≤ 1 torn record on SIGKILL, none on panic or SIGTERM

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

{"k":"air_temperature","v":29.4,"st":"S24","ts":"14:02:00Z"}

gone in 60s

meathook
data/air_temperature/6 files
  • 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.

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.

air_temperature1m
rainfall5m
pm251h
0m15m30m45m60m
flush
window · 1h
data/{pipeline}/{YYYY-MM-DD}/{HH}-{MM}-{SS}-{hash}.{ext}

Durability is a store choice.

store/jsonl.rs

Tier owns windowing, flush policy, and replay over any Store<R>.MemStore stays process-local;JsonlStore fsyncs records before ingest returns. Implement the same trait for SQLite, object storage, or another backend.

failurewhat happensdata lost
SIGKILL / OOM-killJsonlStore replays leftover segments on the next start≤ 1 torn record
Task panicsupervisor rebuilds the pipeline and every tiernone
Sink outage (HF 5xx)the tier retains each window and retries on its next firingnone
Graceful SIGTERMruntime drains every tier and terminal before exitnone
Backing store lostunflushed records in that Store are gonechoose JsonlStore on a PVC for crash recovery

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.

50%0%
50mb0mb
cpu %
peak 0.3% · ~300/core
rss mb
0s5m10m
13.6mbrss avg
0%cpu avg
14.8mbrss peak
0.3%cpu peak
10 minsampled
303samples

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.

air_temperature1m

per-station readings

dedupe key: (station_id, timestamp)

rainfall5m

per-station readings

dedupe key: (station_id, timestamp)

pm251h

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}.parquet

Stores, encoders, and sinks compose.

Implement Store<R>,Encoder, orSink<R>. Compose them with .tier() and.encoder().

1use std::{time::Duration, env};2 3use meathook::{4    FlushPolicy, HfSink, JsonEncoder, JsonlStore, Meathook, MemStore,5    Pipeline, SatayCollector, SinkExt as _,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            // Format and storage are independent choices. The outer memory29            // tier batches for five minutes; the durable tier flushes hourly.30            let sink = HfSink::new(client.clone(), "you/your-dataset", token.clone())31                .encoder(JsonEncoder)32                .tier(33                    JsonlStore::new("/var/lib/meathook/spool/air_temperature"),34                    FlushPolicy::hourly(),35                )36                .tier(37                    MemStore::new(),38                    FlushPolicy::new(Duration::from_secs(300), 10_000),39                );40 41            Pipeline::new(collector, sink, Duration::from_secs(60))42                .with_key_fn(|r: &MyRecord| (r.station_id.clone(), r.timestamp.clone()))43        })44        .run()45        .await46}

records stay plain structs: #[derive(Serialize, Deserialize)] is enough for every built-in encoder

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

featuredefaultwhat it enables
parquetParquetEncoder via arrow, parquet, and serde_arrow
csv·CsvEncoder for flat record types
satay·SatayCollector for satay-generated API clients
huggingfaceHfSink and CommitAction; implies parquet and satay

Missing an integration? Request one. Built one? Open a PR.

Start capturing before the API forgets.

docs.rs