Repository navigation
GRAIN🌾 v0.8.2
Writes acked only once stored, faster queries, relay branding
Release · 11.13.2026
Full Changelog: v0.8.1...v0.8.2
A relay release. If you run grain as a relay, upgrade.
In November grain was benchmarked against strfry and wisp with relay-tester, privkeyio's nostr-bench and Amethyst's relayBench. The engine held up. Pipelined ingest matched wisp and came close to strfry. The runs also turned up real bugs. The Alpine image could crash on startup. An OK went out before the event was written. Two versions of a replaceable event could both survive. And grain was off spec on a handful of NIP-01 and NIP-09 edge cases. All of that is fixed here. grain now passes every required relay-tester check, and small queries return faster than strfry's.
This is also arkin0x's first release as a contributor. They run grain as their own relay, and their three PRs came out of that. The PRs gave grain's pages the relay's own branding and fixed two bugs.
✅ OK means stored
nostrdb queues an event and writes it on its own thread in batches. grain used to send OK as soon as the event was queued. A client that read right after its OK often found nothing, and an event acked just before a crash could be lost.
Now the writer reports each batch it commits, and grain sends OK only once the event is readable. A write not confirmed within 10 s gets OK false "error: ...". OKs, live broadcasts and backup relay forwards all wait for the commit. A client that sends events back to back doesn't stall, because they are still queued in the order they arrived.
Waiting for the commit costs one disk sync per batch. Pipelined ingest is unchanged at around 16k events/s. A client that sends one event and waits for its OK now pays the sync, about 5 ms on the benchmark machine. strfry pays the same.
💥 The Alpine image no longer crashes at startup
nostrdb's writer thread needs about 290 KiB of stack, and musl gives a new thread 128 KiB. About half of all startups of the Alpine image crashed, and some that didn't could corrupt memory. Every nostrdb thread now gets an 8 MiB stack, the same as glibc's default. This was also the intermittent startup crash CI has been retrying around.
🔁 Replaceable and addressable events
Two versions of one replaceable event sent back to back could both be stored. The second was checked against the database before the first had landed. Each address now stays locked from the "is there a newer version" check until the write commits.
🪦 Deletions are tombstones
A deleted event can't be published again. Before storing an event grain looks for a stored deletion request from the same author that names its id, or its address with a deletion no older than the event. If one is there the event is refused with blocked:. This also covers a deletion that arrives before its target has been written, which used to be acked as a no-op.
Deletion requests are what block those events, so they need to stay stored. Per-kind purge policies (#109) are closed for that reason.
🛡️ Protocol behavior
untilis inclusive on every index. 0.8.0 fixed the in-memory match, but the index seeks still stepped past events at exactlyuntil.limit: 0returns no stored events, only live ones.- COUNT is exact. Events sharing a second across a page boundary were skipped.
- Duplicates are acked
OK true "duplicate: ..."as NIP-01 shows, without the extra NOTICE. - An empty tag gets
invalid: empty tags are not allowedinstead of an id-mismatch error. - A field that doesn't fit its type, like a
created_atpast int64, getsOK false. It used to get no reply. - Reposts. nostrdb stored the note inside every kind 6 repost. The relay then served notes nobody had published to it, and refused the original author's publish as a duplicate. It no longer does.
- NIP-11
supported_nipslists the NIPs grain implements on the relay side: 1, 9, 11, 40, 42, 45, 50, 70 and 86. It used to come fromrelay_metadata.json, which listed client-side NIPs like 7, 19, 55 and 65.
⚡ Faster queries
Every query built each filter twice, each time in a 256 KiB buffer that musl maps fresh every time, plus a 64 KiB scratch buffer. Filters are now built once, sized to the filter, with pooled scratch. A deletion check per event made this cost visible on ingest before it was fixed.
| REQ to EOSE, single client, median | before | v0.8.2 | strfry 1.1.3 |
|---|---|---|---|
| By id | 0.30 ms | 0.12 ms | 0.19 ms |
| By author and kind | 0.37 ms | 0.19 ms | 0.25 ms |
🔒 Admin page
/admin is served to a session whose pubkey is the owner's, and the web login takes a pubkey without a signature. Anyone could open a session as the owner and read the page. Writes were never at risk, since each one is a signed NIP-86 call, and most of the page is public through /api/v1/relay/config anyway. The permanently blocked IPs were not. The page no longer renders them. "Show blocked IPs" reads the list with a signed listblockedips call (#110).
🎨 Relay branding on grain's pages
From arkin0x (#113). grain's own pages now use the name, icon and banner set in the admin "Relay information" form instead of always showing grain's wheat. There are two new fields, a header logo and an optional logo for light themes. They are stored in relay_metadata.json but not served in NIP-11. Each image field can upload to a Blossom server or to the relay. "Upload to relay" keeps grain's own copy under /branding/ so visitors never load it from an outside host. With nothing set, every page looks as it did.
🔧 Fixes from arkin0x
- The owner set by
GRAIN_OWNER_PUBKEYsurvives admin saves (#112). Every NIP-86 write reloadedrelay_metadata.jsonand put its empty pubkey back. After the owner's first save, every later admin call was refused until a restart, and/setupwould take a new claim from anyone. - Messages split across websocket frames (#111). Some relays send anything over 1 KiB as more than one frame. The client library read each frame as its own message, so those events never arrived. The relay side had its own joiner, which re-parsed the whole buffer after every frame. A 1 MiB message in 1 KiB frames cost about 0.9 s of CPU. arkin0x's joiner from #111 now lives in
server/utils/wsframeand both sides use it. That move (a0126af) is their code and went in without their name on the commit.
🗄️ nostrdb+
Changes in grain's nostrdb fork (0ceanSlim/nostrdb) for this release:
- Every nostrdb thread gets an 8 MiB stack.
- The writer counts its commits, and a caller can wait for the next one. This is what OK-after-commit is built on.
NDB_FLAG_NO_REPOST_INGESTstops the note inside a kind 6 from being stored.NDB_FLAG_NO_STATSnow also skips reply and repost counts. grain opens the database with both, and without note blocks.- Index seeks include events at exactly
until.
⬆️ Upgrading from v0.8.1
server.max_connections: 0now means unlimited, as the config docs always said. It was being rewritten to 1000 at startup. If you relied on that, set 1000.- NIP-11
supported_nipsis set by grain, likeversionand the limitation fields. The list inrelay_metadata.jsonis ignored. - Notes stored from reposts before this release stay until they're purged or deleted.
- Clients get
OK truefor a duplicate. A client that treatedOK false "duplicate:"as success keeps working. - The blocked IP list in the admin Blacklist section is behind a Show button and asks your signer once.
- No database migration.
Benchmark detail
Docker Desktop on Windows 11 (WSL2), relay pinned to 4 cores, fresh volume per run. strfry 1.1.3, wisp 0.7.0. "Before" is main just after v0.8.1 (ec4b923). One fsync costs about 4.8 ms on this setup.
| grain before | grain v0.8.2 | strfry | wisp | |
|---|---|---|---|---|
| relay-tester required checks | 30 of 43 | 43 of 43 | 39, 4 untested | 36, 3 failed, 4 untested |
| Pipelined ingest, 4 connections, 200 in flight | 16.9k ev/s | about 16k ev/s | 19.1k | 16.5k |
| One event at a time, 8 workers | 14.0k ev/s (OK before commit) | 435 ev/s (939 with search off) | 801 | 892 |
Full-text indexing of kind 1 is most of what one write costs now. That and the rest of the speed work is in the v0.9.3 milestone: streaming results (#115), counting inside nostrdb (#116), memory (#117), indexing cost (#118) and JSONL export (#120).