Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,43 @@ What each release changed, why, and what it costs you. The versioning rules and

While the major is 0, a minor release may break anything, including the on-disk format. The format is frozen at `M6`, not before.

## 0.3.9 — 2026-09-01

Five pull requests, no milestone, so this is a patch. It is two things at once. The first is the rest of what a cache has to do about time and space that 0.3.8 started: keys that expire now come back on their own rather than waiting for somebody to read them, and picking what to evict stopped forgetting everything it learned between one eviction and the next. The second is the beginning of the memory campaign, which is where the gate says the work is.

The `.yo` layout is untouched. A file written by any 0.3.x opens unchanged. Everything that got smaller here got smaller in the arena, not on disk.

The reason memory is the campaign is the gate report from gamingpc. Every failing row fails on memory and none of them fail on speed. The command itself is cheaper on our side on all nine rows where both sides had a PING to subtract, over a range of 1.07x to 92.09x, and then the peak memory column loses: SADD on the memtier profile at 1.41x the leaner rival, SPOP at 1.29x, SRANDMEMBER at 1.24x. So the question this release starts answering is what a set member and a hash field actually cost.

### Added

- **An active expiry cycle, so dead keys come back without being read.** Lazy expiry answers the correctness question on its own, because every read reaps a key past its deadline on the way past and no client ever sees one. What it does not answer is memory. A cache that writes ten million keys with a one hour deadline and never reads them again holds all ten million forever, because nothing goes past them to do the reaping. The sweep follows Redis's rule, which is to sample, drop what is dead, and go round again while more than a quarter of the sample was dead, so a database full of dead keys gets swept hard and a clean one gets one cheap look.
- **A count of the keys that carry a deadline, which `INFO keyspace` was reporting as zero.** That is a number monitoring reads to know how much of a cache is volatile, and a constant is worse than nothing there. It is kept exact and it is free, because both funnels already have the record in hand: every record written goes through one place that now peeks at the old value on the branch that had already loaded its header, and every record removed goes through a sibling of the same shape.
- **An eviction pool, so sampled policies keep their good candidates between rounds.** Before this, picking a victim drew five keys, kept the worst one and forgot the other four, so the key that came second in a five key draw was gone by the next eviction even though it was still there and still a better answer than most of what the next draw would find. The pool holds sixteen, which is what Redis holds and which is about three rounds of sampling at the default of five.

### Changed

- **An element row is eight bytes instead of twelve.** A row held six bytes of name reference and four of home slot, and the alignment of the wide field made it twelve, so the row array of every set, hash and sorted set was carrying a third of itself as padding. The blob offset keeps its whole word because a large collection's names run to megabytes, and the other word now holds the name length in the low byte and the home slot in the twenty four above it. Twenty four bits of home covers every table up to sixteen million slots, which holds twelve million elements, and past there the home is recovered by hashing the name on a cold out of line arm. A name over two hundred and fifty five bytes writes its real length into the two bytes ahead of it in the blob and puts a sentinel in the byte.
- **A hash value's length lives in the blob instead of beside the offset.** The row payload was an offset and a length at four bytes each, and the length did not need to be there, because a value already has somewhere to keep its own length. One byte of prefix under two hundred and fifty five bytes, five above, against four bytes beside every offset. HSTRLEN is the one thing that got worse: it used to read the length out of the row and now takes a byte off the front of the value, which costs the cache miss a caller reading the value would have taken anyway.
- **A volatile eviction policy on a database with no deadlines answers in one comparison.** It used to draw four rounds of buckets and be told there was nothing eligible by every key in all of them, on a path where a client is waiting for the write that eviction is making room for. The count of keys carrying a deadline is what makes that one comparison, and it is the first step towards the second dictionary the `ROUNDS` doc has been naming for a while.
- **`allkeys-random` and `volatile-random` skip the pool entirely.** They pick by a fair draw and have no ordering for a pool to approximate, so under those two every eligible key already is the answer and sixteen slots would be pure overhead. Changing the policy throws the candidates away, because a score under one policy means nothing under another, and so does a flush.

### Performance

Development measurements on the arm dev machine unless the machine is named. None of these are gate numbers.

- **An element row array is a third smaller, which on sixteen byte members is between four and five bytes off every member.** A thousand members went from 36.96 bytes a member to 32.83, a hundred thousand from 43.53 to 38.72, and a million from 42.11 to 37.23. The last of those is 4.9 bytes a member, which is 11.6 percent of what the set costs and 18.7 percent of what it costs on top of the bytes the caller actually stored.
- **A hash field is three bytes cheaper by arithmetic and 2.18 by peak RSS.** On server3, five hundred thousand fields of `field-%08d` and `value-%08d`, peak resident set went from 27,576 KiB to 26,512 KiB. The gap between the two numbers is the value blob's own growth rounding. The in process reading is not the one to trust for this because it reports capacity rather than live bytes, and it moved in both directions at different sizes since one extra byte per value crossed a growth threshold.
- **A warm eviction pool allocates nothing.** Its sixteen slots own their key buffers and reuse them, and the array itself is not allocated until the first candidate is offered, so a database that never evicts pays nothing at all for it. There is a test asserting each of those.
- **The removal path is the one to watch on the row packing.** Backshift deletion reads the home slot once per slot of the run behind the hole. Left as a single function with the hash call in the cold arm, that call stopped the whole thing inlining into the loop and a pop of a thousand members measured 52 percent slower. Split with the cold arm out of line, a pop is level at a thousand and faster at a hundred thousand and a million, because the row array the backshift walks is a third smaller.

### Known

- **A hash field is eighteen bytes of overhead and the memory row wants sixteen.** Eight of row, four of offset, one of value length, and about five of slot array, on an eight byte name and an eight byte value. Eighteen is the floor for this layout, because the only way past the row is to stop interning field names, which puts the name back in front of every value and pays for it again on every rewrite. The remaining five is the slot array.
- **The slot array cannot be shrunk on its own.** Slots per member is one over the load factor and an unsuccessful linear probe costs about half of one plus one over the square of one minus the load, so the memory an open addressed array costs and the length of a probe through it are the same dial seen twice. Sizing the array to the table exactly and growing it by a quarter rather than doubling was tried and measured on gamingpc: a quarter of the memory back for probe_miss up fifty to ninety percent, fill up fifty six and pop up a hundred and forty five. That is not affordable against a SADD row that is 1.09x at memtier P16. The change that takes the memory without paying throughput is probing the array in groups with SIMD off the tag byte the row packing already put in the top of every slot, which is `#178` and is not in this release.
- **The active expiry ratio is measured over a different denominator than Redis's.** Redis samples out of its expires dictionary, so every key it looks at could be dead. There is no second dictionary here yet, so the sample comes off the main index and most of what it looks at may carry no deadline at all. A quarter of every key sampled is a bar a database that is one percent volatile could never clear however much dead memory is sitting in it, so the ratio is over the keys sampled that could have expired.
- **`avg_ttl` is still a zero.** Redis reports a zero there too on a server that has never run its active expiry cycle, because that number is a running estimate the cycle produces rather than something anybody measures on demand.

## 0.3.8 — 2026-09-01

Nine pull requests, no milestone, so this is a patch. Almost all of it is the first half of `M5`: eviction, which is the thing a cache does that a store does not, and which this server did not do at all until now. Before this release `maxmemory` was a setting you could write and nothing would read, and a server that filled up stayed full. After it, a server with a limit and a policy makes room the way Redis makes room, and one with no policy refuses the write with the same line Redis refuses it with.
Expand Down
38 changes: 19 additions & 19 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 19 additions & 19 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ exclude = ["fuzz"]
[workspace.package]
# One minor per milestone, patches in between, and nothing here promises
# anything while the major is 0. See RELEASING.md.
version = "0.3.8"
version = "0.3.9"
edition = "2024"
rust-version = "1.94"
license = "MIT OR Apache-2.0"
Expand All @@ -45,25 +45,25 @@ keywords = ["database", "redis", "embedded", "key-value", "storage"]
categories = ["database-implementations", "data-structures", "caching"]

[workspace.dependencies]
yo-alloc = { path = "crates/yo-alloc", version = "0.3.8" }
yo-alloc = { path = "crates/yo-alloc", version = "0.3.9" }
# The library is `yo`, the package on crates.io is `yodb`.
yodb = { path = "crates/yo", version = "0.3.8" }
yo-arena = { path = "crates/yo-arena", version = "0.3.8" }
yo-capi = { path = "crates/yo-capi", version = "0.3.8" }
yo-cli = { path = "crates/yo-cli", version = "0.3.8" }
yo-common = { path = "crates/yo-common", version = "0.3.8" }
yo-crash = { path = "crates/yo-crash", version = "0.3.8" }
yo-file = { path = "crates/yo-file", version = "0.3.8" }
yo-format = { path = "crates/yo-format", version = "0.3.8" }
yo-index = { path = "crates/yo-index", version = "0.3.8" }
yo-kv = { path = "crates/yo-kv", version = "0.3.8" }
yo-reactor = { path = "crates/yo-reactor", version = "0.3.8" }
yo-reader = { path = "crates/yo-reader", version = "0.3.8" }
yo-record = { path = "crates/yo-record", version = "0.3.8" }
yo-resp = { path = "crates/yo-resp", version = "0.3.8" }
yo-shape = { path = "crates/yo-shape", version = "0.3.8" }
yo-shard = { path = "crates/yo-shard", version = "0.3.8" }
yo-uring = { path = "crates/yo-uring", version = "0.3.8" }
yodb = { path = "crates/yo", version = "0.3.9" }
yo-arena = { path = "crates/yo-arena", version = "0.3.9" }
yo-capi = { path = "crates/yo-capi", version = "0.3.9" }
yo-cli = { path = "crates/yo-cli", version = "0.3.9" }
yo-common = { path = "crates/yo-common", version = "0.3.9" }
yo-crash = { path = "crates/yo-crash", version = "0.3.9" }
yo-file = { path = "crates/yo-file", version = "0.3.9" }
yo-format = { path = "crates/yo-format", version = "0.3.9" }
yo-index = { path = "crates/yo-index", version = "0.3.9" }
yo-kv = { path = "crates/yo-kv", version = "0.3.9" }
yo-reactor = { path = "crates/yo-reactor", version = "0.3.9" }
yo-reader = { path = "crates/yo-reader", version = "0.3.9" }
yo-record = { path = "crates/yo-record", version = "0.3.9" }
yo-resp = { path = "crates/yo-resp", version = "0.3.9" }
yo-shape = { path = "crates/yo-shape", version = "0.3.9" }
yo-shard = { path = "crates/yo-shard", version = "0.3.9" }
yo-uring = { path = "crates/yo-uring", version = "0.3.9" }

criterion = "0.8"
libc = "0.2"
Expand Down