diff --git a/README.md b/README.md index 7bcbbbd..8ad56fd 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,8 @@ been exercised under the race detector and adversarially reviewed. It is all in | [Running a fleet](docs/fleet.md) | Pooling evidence across replicas through Valkey or Redis | | [Project site](https://sshaplygin.github.io/as-cache/) | Landing page, plus an interactive explorer of the bandit's decisions on a phase-shift run | -Past releases are recorded in the [changelog](CHANGELOG.md) and in -[docs/release-notes-*.md](docs/), which are kept as published rather than -updated. +Past releases are recorded in the [changelog](CHANGELOG.md) and on the +[releases page](https://github.com/sshaplygin/as-cache/releases). ## License diff --git a/docs/release-notes-v0.1.0.md b/docs/release-notes-v0.1.0.md deleted file mode 100644 index b37295e..0000000 --- a/docs/release-notes-v0.1.0.md +++ /dev/null @@ -1,165 +0,0 @@ -## as-cache v0.1.1 - -> Historical record of the v0.1.1 release, kept as published. Numbers and -> behaviour described here were true of that version; for what is true now -> see [the evidence](evidence.md) and [the docs index](../README.md). - -A cache that measures eviction policies against your real traffic instead of -asking you to guess which one to use. - -Picking a replacement policy is normally a research task you do once, badly, and -never revisit. The cost of getting it wrong is not marginal: on a cyclic access -pattern just larger than the cache, LRU serves a **0%** hit rate where W-TinyLFU -serves **94%**. as-cache runs candidate policies side by side against your own -traffic, and either tells you which one wins or switches to it for you. - -This is the first release worth using. The project previously demonstrated that -shadow caching plus Thompson sampling *can* select a policy at runtime; this -release makes it correct under concurrency, cheap enough to deploy, stocked with -seven policies — and, for the first time, measured against published traces. - -### Install - -```bash -go get github.com/sshaplygin/as-cache -go get github.com/sshaplygin/as-cache/policies # ready-made policies -``` - -Requires Go 1.25 or later. The core module has **no dependencies**; policies and -integrations live in companion modules so you only pull in what you use. - -### Start by observing - -The lowest-risk way to adopt this is not to let it switch anything. In -`ObserveOnly` mode the cache behaves exactly like the first policy you give it, -while every other policy is measured in the background against your traffic: - -```go -cache, _ := ascache.NewAdaptiveCache( - []ascache.Policy[string, int]{lru, twoQ, tinyLFU}, - nil, // observing needs no bandit - &ascache.Settings{ - EpochDuration: time.Minute, - ObserveOnly: true, - ShadowSampleRate: 0.05, - }, -) - -// ... after real traffic ... -fmt.Println(cache.Advice()) -``` - -```text -On this traffic TwoQueue beats LRU by 3.28 points of hit rate, over 240 epochs. -Rates are estimated from 5.0% of the keyspace. - -policy hit rate hits misses - TwoQueue 59.62% 596200 403800 -*LRU 56.34% 563400 436600 - Random 54.80% 548000 452000 - -* currently active -``` - -Nothing changes about how your cache behaves. You just learn whether a different -policy would serve your traffic better, and by how much. - -### What the measurements say - -Reproduce any of this with `make evidence`. - -**No single policy wins everywhere.** Across five published traces the best -policy is 2Q on some and W-TinyLFU on others — and on the ARC OLTP trace, -W-TinyLFU, the strongest general-purpose baseline in wide use, lands -*second-worst*. - -| Trace | Best fixed | Worst fixed | Adaptive | -| --- | --- | --- | --- | -| Twitter Twemcache cluster052 | 2Q 59.6% | LFU 41.4% | 59.4% | -| ARC OLTP (FAST '03) | 2Q 68.3% | LFU 45.4% | 67.1% | -| ARC P3 (FAST '03) | W-TinyLFU 11.7% | LRU 1.9% | **12.7%** | -| LIRS 2_pools | W-TinyLFU 54.8% | Random 50.1% | 54.4% | -| LIRS loop | W-TinyLFU 45.9% | LRU/LFU 0.0% | 42.5% | - -Tuned sensibly, adaptive selection lands within about a point of the best fixed -policy and beats it on one trace — without being told in advance which that is. -Its real value is the floor, not the ceiling: it reliably avoids the policy that -would have been catastrophic for your workload. - -**Synthetic benchmarks will mislead you.** Classic LFU is the *best* policy on -synthetic Zipf (73.5%) and the *worst* on both large real traces. Synthetic Zipf -holds popularity stationary, which is exactly LFU's assumption; real traffic -shifts, and stale frequency counts pin entries long after they stop being -useful. If you take one thing from this release, take that. - -**Running seven policies does not cost seven caches.** Shadow policies hold keys -and eviction bookkeeping but never values: - -| Configuration | Memory | Per-`Get` | -| --- | --- | --- | -| single LRU | 18.5 MiB | 32 ns | -| adaptive, 6 policies | 48.9 MiB (2.65x) | 618 ns | -| adaptive, 6 policies, sampled at 5% | 24.5 MiB (1.32x) | 82 ns | - -Zero allocations per `Get` in all three. - -### Highlights - -- **Seven policies.** LRU, LFU, 2Q, Random, TTL, ARC and W-TinyLFU, each held to - a shared conformance suite. ARC ships in its own module because the algorithm - is patented by IBM, so importing `policies` never pulls a patented - implementation into your build. -- **Sampled shadow caching.** `ShadowSampleRate` has shadows track a - deterministic fraction of the keyspace and shrink to match, so per-operation - cost stops scaling with the number of policies. -- **Advisor mode.** `ObserveOnly` plus `Advice()`, as above. -- **Observability.** The `metrics` module publishes a snapshot through `expvar`, - evaluated on scrape. Standard library only. -- **Correctness.** The policy-switch data race, lost hit/miss counters, a stale - bandit posterior, a `Close` that neither waited nor was idempotent, and a - `Cap()` that never changed after construction are all fixed. See the - [CHANGELOG](../CHANGELOG.md) for the full list. -- **Drop-in.** The API remains a superset of `hashicorp/golang-lru/v2`. - -### Known limitations - -These are real and stated plainly rather than discovered later. - -- **It will not beat a policy you have already measured.** If you know - W-TinyLFU suits your traffic, use `otter` directly. This library is for when - you do not know. -- **Reads still take a lock.** The lock-free read path is deferred: combining it - with value-dropping needs a retry protocol across every read delegation and a - breaking change to `CacheStats`. -- **Epoch duration matters more than anything else.** Too short and the cache - spends its life migrating rather than serving — a 2ms epoch on a 20k cache - costs 8 points of hit rate and 30x the per-operation time. See the README. -- **A sampled shadow's absolute hit rate is not a forecast.** Sampling picks - the same best policy at every rate measured (5%, 10%, 30%, 50%) with zero - ranking inversions, but the absolute figure depends on which slice of the - keyspace the seed selected and can land either side of the true rate. Use it - to compare arms, not to predict what a policy would achieve. - -### Modules - -| Module | Contents | External dependencies | -| --- | --- | --- | -| `as-cache` | core cache, bandit interface | none | -| `as-cache/lfu` | O(1) LFU implementation | none | -| `as-cache/policies` | LRU, LFU, 2Q, Random, TTL adapters | `hashicorp/golang-lru/v2` | -| `as-cache/policies/arc` | ARC (patented — see above) | `hashicorp/golang-lru/arc/v2` | -| `as-cache/policies/tinylfu` | W-TinyLFU | `maypok86/otter/v2` | -| `as-cache/metrics` | expvar export | none | - -### Licence - -[Mozilla Public License 2.0](../LICENSE). File-level copyleft: use it in closed -source freely; modifications to these files, if distributed, are shared back. - -### Acknowledgements - -The evidence in this release replays traces published by others. Cited in full -in the README; briefly: Yang, Yue & Rashmi (OSDI '20) for the Twitter Twemcache -traces, Megiddo & Modha (FAST '03) for the ARC traces, and Jiang & Zhang -(SIGMETRICS '02) for the LIRS traces. Traces are downloaded by -`./scripts/fetch-traces.sh` and are never redistributed here. diff --git a/docs/release-notes-v0.2.0.md b/docs/release-notes-v0.2.0.md deleted file mode 100644 index 5cd69ee..0000000 --- a/docs/release-notes-v0.2.0.md +++ /dev/null @@ -1,181 +0,0 @@ -## as-cache v0.2.0 - -> Historical record of the v0.2.0 release, kept as published. Numbers and -> behaviour described here were true of that version; for what is true now -> see [the evidence](evidence.md) and [the docs index](../README.md). - -A cache that measures eviction policies against your real traffic instead of -asking you to guess which one to use. - -This release is for the case where a single replica cannot measure anything. -Split your traffic across fifty replicas and each cache sees a fiftieth of the -evidence; arms within noise of each other reorder run to run, and the cache -picks close to at random. The fleet has the evidence between them, so replicas -can now pool their per-epoch counts through Valkey or Redis and decide -together. It pays off in that regime and **costs hit rate outside it** — the -numbers below say where the line falls. - -### Install - -```bash -go get github.com/sshaplygin/as-cache -go get github.com/sshaplygin/as-cache/bandit # ready-made bandits -go get github.com/sshaplygin/as-cache/bandit/redis # only for a fleet -``` - -Requires Go 1.25 or later. - -**Every module is now published.** `policies`, `lfu`, `metrics`, -`policies/arc` and `policies/tinylfu` had never been tagged at any version, so -installing them failed with `unknown revision v0.0.0`. If you tried this -library before and could not get it, that was why. - -### You no longer have to write a bandit - -The `Bandit` interface was the fiddliest part of adopting this library and the -root module shipped no implementation. Two now live in `bandit`: - -```go -b := bandit.NewThompson(0.9, seed) // discounted Beta posteriors -cache, err := ascache.NewAdaptiveCache(arms, b, settings) -``` - -`NewGreedy` is there as a control, for measuring what the sampling buys you. - -### Pooling evidence across a fleet - -Each replica publishes its per-epoch counts; one replica per coordination epoch -reads the fleet's aggregate, chooses, and publishes the choice for the rest. - -```go -b, err := bandit.NewDistributed(bandit.Config{ - Store: store, // redisstore.New(redisstore.Options{Client: client}) - Namespace: "sessions", - CoordinationEpoch: time.Second, -}) -if err != nil { - return err -} -defer b.Close() - -cache, err := ascache.NewAdaptiveCache(arms, b, &ascache.Settings{ - EpochDuration: 50 * time.Millisecond, -}) -``` - -**Nothing touches the network on the cache's path.** The cache calls its bandit -while holding the write lock, and Go's `RWMutex` queues new readers behind a -waiting writer — so a store timeout there is not slow, it is an outage for -every `Get` in the process. `RecordEpoch` buffers, `SelectPolicy` is an atomic -load, and all I/O runs on the bandit's own goroutine. - -**Two clocks, not one.** `EpochDuration` is how often a cache measures; -`CoordinationEpoch` is how often the fleet decides. Cache epochs are tuned in -tens of milliseconds, below both a round trip and any clock agreement a fleet -can be assumed to have. A second is a sensible start for the slow clock. - -Time buckets come from the store's own clock inside a Lua script, so no -replica's clock is consulted and a skewed machine cannot poison a window. Only -integers cross the wire — per-policy counts, a node id, a policy name — never -keys or values. If the store is unreachable, each replica falls back to a local -Thompson bandit on its own evidence and nothing blocks; `Snapshot().Fallback` -is the field to alert on, because the cache looks healthy either way. - -### What the measurements say - -Eight replicas, capacity 300 to 500, reproducible with `make evidence`. - -**Pooling wins when replicas are starved.** Paced to roughly 8 requests per -cache epoch per replica: - -| Setup | Hit rate | Policies in use at the end | -| --- | --- | --- | -| best fixed (ARC) | 62.8% | 1 | -| pooled, leader-elected | 58.3-59.5% | 1-2 | -| each replica deciding alone | 55.5-55.9% | 5 | - -**2.3 to 3.9 points** over independent replicas across four runs. The last -column is the mechanism: a replica seeing eight requests an epoch cannot tell -its arms apart, so the fleet scatters across five policies, several of them -poor. Pooled, it has 64 requests an epoch of evidence and holds one. - -**Pooling loses when they are not.** Unpaced, it costs 1 to 2 points on uniform -traffic (68.2% vs 70.4% on split zipf) and **5.1 points** on a fleet whose -replicas serve different workloads (36.6% vs 41.7%) — where a fleet-wide -decision is a compromise nobody wanted. Full tables in the README. - -**The rule:** pool when your replicas are individually starved, run the same -workload shape, and are numerous enough for the pooled evidence to be -meaningfully thicker. Otherwise let each decide alone — simpler, no store, and -on this evidence better. `Advice()` in observe-only mode tells you which case -you are in before you deploy anything. - -### Two bugs worth reading about - -Both were reachable in normal operation, and neither was caught by a test that -existed at the time. - -- **An unrecognised bandit selection panicked the process.** The epoch loop - switched to whatever `SelectPolicy` returned without checking the cache held - it, then dereferenced a nil interface during migration. `Undefined` is the - natural return from a bandit that has not formed an opinion — which a - distributed one does for every epoch before its first sync. An unrecognised - selection now means no change. -- **The default migration strategy stopped purging shadow zeros.** When - `MigrationStrategy` was renumbered to `iota + 1`, its zero value — the - documented default — matched no case in the switch, so a policy switch served - the incoming policy's zero-value shadow entries to callers as real data. That - is the one invariant this library is built on. Cold is now the `default:` - arm, so no strategy value can skip the step every strategy must take. - -### Also in this release - -- **`EpochBandit`**, an optional extension of `Bandit`, delivering a whole - reporting epoch per call. A bandit that publishes evidence elsewhere needs - the epoch boundary, an epoch id and which arm was active; the per-arm - `RecordStats` stream carries none of those. -- **The non-blocking rule is documented on the interface itself**, with a test - asserting a bandit never waits on its store. -- **`bandit/redis` is tested against real servers**, not only miniredis. - `make redis-test` and CI both run the suite against Valkey 8 and Redis 7, - because the adapter leans on three things a fake can be too permissive about: - `TIME` inside a Lua script, `SET NX PX`, and `HINCRBY` on a key the script - names itself rather than declaring in `KEYS`. -- Epoch reports arrive in a stable sorted order, so evidence is reproducible - for anything that hashes, serialises or logs it. - -### Known limitations - -- **Coordination is free in these measurements and will not be in yours.** The - fleet replays use an in-process store, so the coordination epoch that looks - best there is the one a real round trip makes most expensive. -- **Verified against Valkey 8.1.9 and Redis 7.4.10.** Redis Cluster, failover - and Redis 6 are not covered; `bandit/redis/TESTING.md` records what was - tested and what was not. Redis 7 / Valkey 7.2 is the floor, because deriving - a bucket from the server's clock inside a script needs effects replication. -- **Reads still take a lock.** The lock-free path remains deferred: it needs a - retry protocol across every read delegation, retries are not free of side - effects, and gradual migration cannot go lock-free at all because promotion - mutates from inside `Get`. -- **It will not beat a policy you have already measured.** Unchanged from - v0.1.0, and still the most important sentence here. - -### Modules - -| Module | Contents | External dependencies | -| --- | --- | --- | -| `as-cache` | core cache, bandit interface | none | -| `as-cache/lfu` | O(1) LFU implementation | none | -| `as-cache/policies` | LRU, LFU, 2Q, Random, TTL adapters | `hashicorp/golang-lru/v2` | -| `as-cache/policies/arc` | ARC (patented by IBM — separate on purpose) | `hashicorp/golang-lru/arc/v2` | -| `as-cache/policies/tinylfu` | W-TinyLFU | `maypok86/otter/v2` | -| `as-cache/metrics` | expvar export | none | -| `as-cache/bandit` | Thompson, Greedy, distributed bandit | none | -| `as-cache/bandit/redis` | Valkey/Redis store for the above | `redis/go-redis/v9` | - -### Licence - -[Mozilla Public License 2.0](../LICENSE). File-level copyleft: use it in closed -source freely; modifications to these files, if distributed, are shared back. -Every published module carries its own copy, because a Go module zip contains -only its own directory. diff --git a/docs/release-notes-v0.3.0.md b/docs/release-notes-v0.3.0.md deleted file mode 100644 index 3e29457..0000000 --- a/docs/release-notes-v0.3.0.md +++ /dev/null @@ -1,151 +0,0 @@ -## as-cache v0.3.0 - -> Historical record of the v0.3.0 release, kept as published. Numbers and -> behaviour described here were true of that version; for what is true now -> see [the evidence](evidence.md) and [the docs index](../README.md). - -A cache that measures eviction policies against your real traffic instead of -asking you to guess which one to use. - -This release makes a replay reproducible, and then spends that reproducibility -on a question the previous two releases avoided: not which of this library's -policies is best, but whether reaching for this library beats reaching for -otter or theine. - -**It does not.** That result, and the two measurement traps that nearly -published a flattering version of it, are the substance of this release. - -### Install - -```bash -go get github.com/sshaplygin/as-cache -go get github.com/sshaplygin/as-cache/policies -go get github.com/sshaplygin/as-cache/bandit -``` - -Requires Go 1.25 or later. - -### Where this library actually places - -Same workloads, same capacity of 500, replayed through the Go caches people -actually reach for. Reproduce with `make evidence`. - -| Workload | otter v2 | theine | ristretto | sturdyc | as-cache | -| --- | --- | --- | --- | --- | --- | -| zipf | **73.19%** | 72.38% | 69.54% | 62.00% | 71.92% | -| uniform | 9.99% | **10.48%** | 9.97% | 9.52% | 10.23% | -| loop | 87.06% | 88.48% | **88.62%** | 45.42% | 68.78% | -| scan | **39.88%** | **39.88%** | 39.45% | 30.01% | 39.24% | -| phase-shift | 78.34% | **78.46%** | 72.53% | 53.19% | 75.06% | - -Within a point of the best library on three of five, 3.4 points down on -phase-shift, nearly 20 down on `loop`, and 4 to 15 times slower per operation. -**If you are choosing a cache library and have no particular reason to expect -your traffic to change shape, otter or theine is the better answer.** - -What this table does not contain is a workload where a fixed library is -catastrophic, because these five are kind to them: `loop` is the one designed -to defeat LRU, and W-TinyLFU-derived caches handle it. The case for this -library rests on [real traces](evidence.md), where the best fixed policy -changes from trace to trace - 2Q on Twitter and OLTP, W-TinyLFU on P3 and -LIRS, with W-TinyLFU landing second-*worst* on OLTP - and on advisor mode -telling you which one your traffic wants. - -### Reproducible replays - -**`Settings.EpochRequests`** ends an epoch every N `Get` calls instead of on a -wall clock: - -```go -&ascache.Settings{ - EpochRequests: 10_000, // deterministic under replay -} -``` - -Wall-clock epochs make adaptation a function of machine speed: the same trace -re-evaluates a different number of times when the machine is loaded, and the -hit rate moves with it. This is not theoretical - two runs of the comparison -above disagreed by **12 points** on phase-shift before the switch, and agree to -within half a point after it. `Get` is the unit because hits and misses are -recorded there and nowhere else, so this counts exactly the requests the bandit -is shown. `EpochDuration` may now be zero when this is set; prefer it in -production, where an epoch on a caller's goroutine pays for the migration. - -**The `benchclient` module** adapts the cache to the -`Init`/`Get`/`Set`/`Name`/`Close` contract used by Go cache benchmark suites, -`maypok86/benchmarks` in particular. It imports nothing from any suite - the -contract is five methods and Go interfaces are structural - and is configured -for reproducibility: request-counted epochs, a seeded bandit, no sampling. - -### Fixed: the bandit ignored its own seed - -`SelectPolicy` drew one sample per arm while ranging a map, so Go's randomised -iteration order handed each arm a different draw on every call. The seed fixed -the sequence of numbers, not who received them, and two replays of one trace -through an otherwise fully deterministic cache disagreed by hundreds of hits. -Draws now happen in a stable sorted arm order. - -This is the same defect the cache fixed in its own epoch reporting in 0.2.0, -in the module written to make selection reproducible. Anything that consumes -randomness while iterating a map has this bug. - -### Two traps that would have published a false result - -Both are worth reading if you benchmark caches yourself. - -**otter was silently being measured at four times the capacity of its rivals.** -It admits on the caller's goroutine and evicts on a maintenance pass, so a -replay writing flat out leaves it far over its limit: 5000 keys written into a -cache built with `MaximumSize: 500` left **1916** retrievable. The first -version of the table above had otter at 44.31% on uniform traffic where every -other cache served 10% - which reads as a decisive win and was entirely the -extra capacity. With `CleanUp` it is 9.99%. A test now fails if any competitor -drifts far over its stated size, with a threshold taken from five runs of -measured spread rather than a guess. - -**The same effect flatters this library's own W-TinyLFU arm.** Measured at a -nominal 500 under read-through replay: 514 entries on `zipf`, 533 on `loop`, -611 on `uniform`. On `uniform` that is the whole story - the arm held 611 keys -of a 5000-key keyspace and served 12.18%, and 611/5000 is 12.2%, so its edge -over the other policies there is capacity rather than eviction. The evidence -tables are read-heavy enough that their rankings stand, but a write-heavy win -by this arm is partly capacity. - -### Also in this release - -- **`release-check` runs in CI and in `make all`.** It ran in neither, while - the repository's own notes claimed it was part of `make all`. It is the only - gate that catches a module nobody outside the repo can install, and what it - guards against is invisible locally: the `replace` directive that hides an - unresolvable require is exactly what makes local builds work. -- **The README is split into `docs/`** - design, configuration, policies, - advisor mode, evidence, benchmarking, fleet. The pitch, the quick start and - seven chapters of measurement had begun competing for the same first screen. -- **A correction.** An earlier note claiming `hashicorp/golang-lru` v2.0.7 does - not build was wrong. That was a corrupted local module cache, not an upstream - defect: the v2.0.6 and v2.0.7 zips contain the same files, and v2.0.7 builds - against an empty `GOMODCACHE`. `policies` stays on v2.0.6 out of inertia, and - a consumer whose build resolves v2.0.7 through MVS is fine. - -### Known limitations - -- **W-TinyLFU is not reproducible**, so `benchclient.DefaultArms` leaves it - out. Measured directly, one trace replayed three times gave three different - hit counts and left 527, 504 and 545 entries against a capacity of 500. otter - evicts asynchronously and reports an approximate size. - `ArmsWithWindowTinyLFU` includes it where the strongest arm matters more than - repeatability. Do not "improve" the default by adding the best arm to it. -- **Reads still take a lock**, and the lock-free path is now measured rather - than assumed: against a policy that takes its own exclusive lock on `Get` - - which every LRU-family policy does, since a read moves recency - the cache - layer costs about 25% of per-operation time, not the 72% a contention-free - policy suggests. That is the ceiling for a change requiring a retry protocol - across six read delegations, a breaking `CacheStats` change, and a - `MigrationGradual` that cannot go lock-free at all. -- **It will not beat a policy you have already measured.** Unchanged since - v0.1.0, and this release is the clearest evidence for it. - -### Licence - -[Mozilla Public License 2.0](../LICENSE). Every published module carries its -own copy, because a Go module zip contains only its own directory. diff --git a/docs/release-notes-v0.3.1.md b/docs/release-notes-v0.3.1.md deleted file mode 100644 index edb77ba..0000000 --- a/docs/release-notes-v0.3.1.md +++ /dev/null @@ -1,40 +0,0 @@ -# as-cache v0.3.1 - -> Historical record of the v0.3.1 release, kept as published. Numbers and -> behaviour described here were true of that version; for what is true now -> see [the evidence](evidence.md) and [the docs index](../README.md). - -A packaging release. The library gains a project site; no Go code changed. - -## The site - - - -A static landing page with the pitch, the evidence highlights and the -documentation index, plus an interactive explorer of the bandit's decisions: -scrub a 240,000-request phase-shift run and see what the bandit knew at each -epoch and why it switched. The explorer was built for an article about this -library and is carried over verbatim apart from translation to English. - -The site is deliberately plain: system font stacks, light and dark themes -with a persisted toggle, no build step, no framework, no external requests. -It deploys from `site/` via `.github/workflows/pages.yml` whenever a push -touches it. - -## Why every module is re-tagged - -The site lives inside the root module's directory, so the root's file tree -changed and the root needed a new version. Sibling modules are re-tagged at -v0.3.1 with their requires moved up, even though none of their code changed, -because a uniform version graph is cheaper to reason about than a mixed one. -This is the same trade 0.2.0 made, for the same reason. - -## Install - -```bash -go get github.com/sshaplygin/as-cache@v0.3.1 -go get github.com/sshaplygin/as-cache/policies@v0.3.1 -go get github.com/sshaplygin/as-cache/bandit@v0.3.1 -``` - -Requires Go 1.25 or later. Behaviour is identical to v0.3.0.