Releases / TinyStore 0.2.0
TinyStore 0.2.0
TinyStore 0.2.0 came out on 26 October 2026. Records and metrics now accept a retried write without writing it twice, metrics takes a large exporter's whole scrape in one write, and aggregates can follow a time zone's calendar. Jobs can limit each group to a rate, and a Rust program can read its settings without a store.
- Records
- Events with an id: a retried append writes its event once.
- Metrics
- Ingests that are safe to retry: samples sent again after packing change nothing.
- A whole scrape in one write: up to 100,000 samples from every SDK, all or nothing.
- Help, units and quoted names: everything a Prometheus 3 exporter writes.
- Days in a time zone: aggregates by a local day, week or month.
- Jobs
- A rate for each group of jobs: one message a second per chat, thirty in all.
- Config
- Settings without a store:
config::loadfor a Rust program that keeps nothing.
- Settings without a store:
Events with an id
A job step runs at least once, so a worker that crashes after append sends
the same event again on its next attempt. Give the event an id, such as a
webhook's delivery id, and the second append writes nothing:
const deploys = store.events('deploys')
await deploys.append('push', { sha }, { id: delivery }) // true
await deploys.append('push', { sha }, { id: delivery }) // false: written alreadydeploys = store.events("deploys")
await deploys.append("push", {"sha": sha}, id=delivery) # True
await deploys.append("push", {"sha": sha}, id=delivery) # False: written alreadyThe stream keeps an id as long as a read can still return its event, and a read returns the id with the event. An event without an id is always written. Events has the details.
Ingests that are safe to retry
The same goes for metrics. A collector's step that sends the same samples again changes nothing, even after the store has packed those samples into a block:
await store.metrics.ingest(revenue) // three hours of samples, read from a database
await store.metrics.ingest(revenue) // the step's next attempt: nothing changesawait store.metrics.ingest(revenue) # three hours of samples, read from a database
await store.metrics.ingest(revenue) # the step's next attempt: nothing changesA sample counts as sent again when its time and its value's bits match the packed one. A different value behind the packed part still fails with a too old error, which now says when the sample was and up to when its series is packed. Ingesting has the details.
A whole scrape in one write
kube-state-metrics and cAdvisor on a busy cluster serve tens of thousands of
series. One ingestPrometheus call now takes a text of up to 100,000
samples and writes it whole, or writes nothing and says why:
const text = await (await fetch('http://ksm:8080/metrics')).text()
await store.metrics.ingestPrometheus(text, { labels: { instance: 'ksm' } })text = httpx.get("http://ksm:8080/metrics").text
await store.metrics.ingest_prometheus(text, labels={"instance": "ksm"})resp, err := http.Get("http://ksm:8080/metrics")
body, err := io.ReadAll(resp.Body)
err = metrics.IngestPrometheus(ctx, store, body, metrics.Labels{"instance": "ksm"})let stats = store.metrics()?;
stats.prometheus().label("instance", "ksm").ingest(&body)?; // body: the text of http://ksm:8080/metrics- Bun, Node, Python and Go send a text larger than one message, 2 MiB, in pieces. The store writes nothing until the last piece arrives, so a dropped connection leaves nothing half written.
- A store with a memory budget reserves the text's memory before it reads a line, about half a KiB per series, and refuses a text that cannot fit instead of running out of memory.
- While a large text is written, other writes of metrics wait behind it.
Help, units and quoted names
A family's # HELP and # UNIT now describe the names it writes, and a
name in quotes keeps its dots, as Prometheus 3 and OpenTelemetry write it:
# HELP rpc_seconds How long a call took.
# UNIT rpc_seconds seconds
# TYPE rpc_seconds histogram
rpc_seconds_sum 41.7
{"http.server.request.duration", "url.scheme"="https"} 0.25await store.metrics.description('rpc_seconds_sum') // { unit: 'seconds', help: 'How long a call took.' }await store.metrics.description("rpc_seconds_sum") # Description(unit="seconds", help="How long a call took.")described, err := metrics.DescriptionOf(ctx, store, "rpc_seconds_sum") // {Unit: "seconds", Help: "How long a call took."}let described = stats.description("rpc_seconds_sum")?; // unit "seconds", help "How long a call took."The unit goes only to the names whose values are in it: rpc_seconds_sum
takes seconds, while _bucket and _count, which count observations,
take none. What describe set in your code always stays.
Days in a time zone
A day of every: '1d' runs from midnight to midnight in UTC. A shop in
Moscow counts its orders from its own midnight, so ask for buckets of the
calendar instead, with the zone's IANA name:
const days = await store.metrics.aggregate({ name: 'orders', since: '30d', op: 'sum', calendar: 'day', timeZone: 'Europe/Moscow' })days = await store.metrics.aggregate(name="orders", since="30d", op="sum", calendar="day", time_zone="Europe/Moscow")calendar is day, week or month, and records' aggregates take it too.
A day is 23 or 25 hours long on the days the clock moves, and a rate
divides by the seconds its own bucket has.
Aggregates has the
details.
A rate for each group of jobs
Telegram lets a bot send thirty messages a second in all, and one a second to each chat. A queue can now say both:
const telegram = store.queue<Message>('telegram', { rate: '30/s', groupRate: '1/s' })
await telegram.add({ chat, text }, { group: `chat:${chat}` })telegram = store.queue("telegram", Message, rate="30/s", group_rate="1/s")
await telegram.add(Message(chat=chat, text=text), group=f"chat:{chat}")A job whose group has used its rate waits for its group's next turn, out of the way of every other group's jobs. A busy chat never holds back a quiet one. Concurrency and rate has the details.
Settings without a store
A Rust program that only reads its settings at start, such as an agent that runs beside someone else's database, can load them without opening a store. Nothing is written to disk:
Only Rust has it so far. Bun, Node, Python and Go reach the config engine through a store. Layers has the details.
Changed names
append returns whether it wrote the event, and a list's append returns
how many events it wrote:
// 0.1
await deploys.append('push', { sha })
// 0.2
const added = await deploys.append('push', { sha }) // true when it wrote the event
const written = await deploys.append(events) // how many it wrote# 0.1
await deploys.append("push", {"sha": sha})
# 0.2
added = await deploys.append("push", {"sha": sha}) # True when it wrote the event
written = await deploys.append(events) # how many it wrote// 0.1
err := deploys.Append(ctx, "push", records.Fields{"sha": sha})
// 0.2
added, err := deploys.Append(ctx, "push", records.Fields{"sha": sha}) // true when it wrote the event
written, err := deploys.AppendMany(ctx, events) // how many it wrote// 0.1: Result<()>
deploys.append("push", &push)?;
// 0.2
let added: bool = deploys.append("push", &push)?; // true when it wrote the event
let written: usize = deploys.append_all(events)?; // how many it wroteCode that ignores the result still builds in Bun, Node, Python and Rust. Only Go's two return values need a change.
Fixes
- A Prometheus text reserves the memory its series hold before the store reads it. Before, a store with a memory budget counted only a small part of what a large text held.
- A job of a group with a rate no longer takes a handler that another job could use, and a claim that fails gives back the starts it counted.
- An event's id is let go once no part of the stream can return the event,
and also when a drop of damaged events removes it.
tinystore logsprints the id. - An empty quoted name in an exposition fails at its line, and the samples counter says that it counts the samples sent again too.
- A Rust program's awaited config that was loaded without a store answers as the blocking one does.
Faster
- A first scrape of a large exporter registers its new series faster.
- A job's add, claim and settlement each run fewer statements.
Install
bun add @tinyshed/tinystore@0.2.0
npm install @tinyshed/tinystore@0.2.0pip install tinyshed-tinystore==0.2.0go get tinyshed.org/tinystore@v0.2.0cargo add tinystore@0.2.0
cargo install tinystore-cli@0.2.0A store that 0.1 wrote opens in 0.2.0 as it is: records adds a table of
event ids the first time it opens. If your program reaches a server, update
the server with the SDK, because a 0.1 server knows neither event ids nor a
text sent in pieces. The server image is ghcr.io/tinyshed/tinystore:0.2.0.
Every commit since 0.1.1 is in the GitHub release.