The Wasm Component Model on Fastly Compute
KV Store: Metadata and Generation
August 15, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
EDIT (2026-08-18): added comparisons against the real Rust SDK's metadata() and its deprecated generation() getter.
Last time, insert-options and entry both went by in full, but two fields on each got quoted without being explained: insert-options.metadata / entry.metadata(), and insert-options.if-generation-match / entry.generation(). Read on their own, metadata looks like a place to stash a label, and generation looks like a version counter you'd use to answer "who wrote this last." They turn out to be useful enough beyond that to be worth a post of their own.
What they're actually for is deciding things about a key without paying for its body, and catching it when someone else changed a key out from under you before you write to it, without ever taking a lock to do it.
metadata: reading without the body
/// Sets an arbitrary data field which can contain up to 2000B of data.
metadata: option<string>,
/// Read the metadata of the KV Store item, if present.
metadata: func(max-len: u64) -> result<option<string>, error>;
metadata is round-tripped as an opaque string: set it in insert-options at write time, read it back with entry.metadata(max-len) at lookup time. The part last time's example didn't make obvious is that entry.metadata() and entry.take-body() are independent calls on the same handle. Last time's example called both every time, back to back, which makes them look like a package deal. They aren't: you can call entry.metadata() and never call take-body() at all, and the body sits there untouched, exactly as available as it was before you asked about the metadata.
That independence is the entire point. A Fastly blog post on KV Store's advanced features (opens in a new window) (written by yours truly) says it plainly: "metadata is accessible without streaming the body. You can make decisions based on metadata alone, saving bandwidth and processing time." A content hash for conditional responses, a content type, a deployment version—any small fact you'd otherwise have to read the whole value just to check—can live in metadata and cost you one small string read instead of a full body stream.
Fastly's Rust SDK is slightly less trusting about what's in there than the WIT is. LookupResponse::metadata() returns Option<Bytes>, where compute.wit types the same field as option<string> on the way in and on the way out. That's the SDK declining to promise UTF-8 about a field described as "arbitrary data," which is the same hedge header values make at the ABI level, arriving here one layer higher up.
generation: detecting a stale write
/// Requests for keys will return a “generation” header specific to the version of a key. The
/// generation header is a unique, non-serial 64-bit unsigned integer that can be used for
/// testing against a specific KV store value.
if-generation-match: option<u64>,
/// Read the current generation of the KV Store item.
generation: func() -> u64;
Every value in a KV Store has a generation—an opaque number that changes whenever it's written. entry.generation() reads it off whatever you last looked up. if-generation-match, set on a subsequent insert, turns that plain write into a conditional one: the KV Store only performs the write if the key's current generation still matches the one you supplied. If it doesn't—because some other request inserted a new value in between—the write is rejected instead of silently overwriting whatever that other request just did.
The aforementioned blog post spells out the exact same pattern this section is describing, from the outside: read an entry and note its generation, include that generation in your update request, and if someone else has modified the entry since your read, the write is rejected. There, on the REST API, that rejection shows up as an HTTP 412. At the ABI level it's kv-error.precondition-failed—the same variant last time already quoted and mapped to "the api's 412 codes," without yet having a reason to trigger it on purpose.
This is optimistic concurrency control: no lock is ever held, two requests can race to read the same generation at the same time, and the KV Store doesn't stop them from both trying to write. It only guarantees that at most one of those writes succeeds, and tells the loser plainly that it lost, rather than letting it clobber the winner's value without either side finding out.
Fastly's Rust SDK has a cautionary tale attached to this exact field. LookupResponse (opens in a new window) exposes both generation() and current_generation(), and the first is marked deprecated since version 0.11.0 with a blunt explanation: "generation has a bug in this version of the SDK, and will always return 0." Its return type is u32. compute.wit declares generation: func() -> u64, and current_generation() is the one that matches.
The lesson generalizes past the bug. A generation is documented as "unique, non-serial," which is another way of saying it's opaque, so any narrower type you pick for it is a guess about a number you were told not to interpret. InsertBuilder::if_generation_match takes a u64, matching the ABI, so a caller reading through the deprecated getter would have been feeding a value of the wrong width into a full-width comparison. Nothing about that mismatch shows up as a compile error, which is the whole problem: an optimistic-concurrency check comparing the wrong number still runs, still returns, and still looks from the outside like it's doing its job.
Watching it happen
mod bindings;
use bindings::{
exports::fastly::compute::http_incoming,
fastly::compute::{http_body, http_resp, kv_store},
};
fn read_body_to_string(body: &http_body::Body) -> Result<String, http_body::Error> {
let mut buf = Vec::new();
loop {
let chunk = http_body::read(body, 8192)?;
if chunk.is_empty() {
break;
}
buf.extend_from_slice(&chunk);
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
struct KvStoreMetadataAndGenerationExample;
impl http_incoming::Guest for KvStoreMetadataAndGenerationExample {
fn handle(_request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
let key = "beta-checkout";
let store = kv_store::Store::open("flags").map_err(|_| ())?;
let mut log = String::new();
// First write: nothing to compare a generation against yet.
let seed_body = http_body::new().map_err(|_| ())?;
http_body::write(&seed_body, b"off").map_err(|_| ())?;
let seed_options = kv_store::InsertOptions {
background_fetch: false,
if_generation_match: None,
metadata: Some("note=seeded off".to_string()),
time_to_live_sec: None,
mode: kv_store::InsertMode::Overwrite,
extra: None,
};
let pending = store
.insert_async(key, seed_body, &seed_options)
.map_err(|_| ())?;
kv_store::await_insert(pending).map_err(|_| ())?;
log.push_str(&format!("seeded \"{key}\" = \"off\"\n"));
// Read metadata and generation without ever calling take-body.
let pending_lookup = store.lookup_async(key).map_err(|_| ())?;
let entry = kv_store::await_lookup(pending_lookup)
.map_err(|_| ())?
.ok_or(())?;
let metadata = entry.metadata(256).map_err(|_| ())?;
let generation = entry.generation();
log.push_str(&format!(
"read metadata = {metadata:?}, generation = {generation}, without touching the body\n"
));
// A correct conditional write: the generation we just read still matches the store's.
let update_body = http_body::new().map_err(|_| ())?;
http_body::write(&update_body, b"on").map_err(|_| ())?;
let update_options = kv_store::InsertOptions {
background_fetch: false,
if_generation_match: Some(generation),
metadata: Some("note=turned on".to_string()),
time_to_live_sec: None,
mode: kv_store::InsertMode::Overwrite,
extra: None,
};
let pending = store
.insert_async(key, update_body, &update_options)
.map_err(|_| ())?;
match kv_store::await_insert(pending) {
Ok(()) => log.push_str("conditional write with the current generation: succeeded\n"),
Err(e) => log.push_str(&format!(
"conditional write with the current generation: unexpectedly failed: {e:?}\n"
)),
}
// A conflicting write: reuse the generation from before that last successful write,
// the way a second, slower request that read the entry at the same time we did would.
let stale_body = http_body::new().map_err(|_| ())?;
http_body::write(&stale_body, b"on-again").map_err(|_| ())?;
let stale_options = kv_store::InsertOptions {
background_fetch: false,
if_generation_match: Some(generation),
metadata: Some("note=should not land".to_string()),
time_to_live_sec: None,
mode: kv_store::InsertMode::Overwrite,
extra: None,
};
let pending = store
.insert_async(key, stale_body, &stale_options)
.map_err(|_| ())?;
match kv_store::await_insert(pending) {
Ok(()) => log.push_str("conditional write with the stale generation: unexpectedly succeeded\n"),
Err(kv_store::KvError::PreconditionFailed) => log.push_str(
"conditional write with the stale generation: rejected, precondition-failed\n",
),
Err(e) => log.push_str(&format!(
"conditional write with the stale generation: failed with {e:?}\n"
)),
}
// Confirm which write actually won, this time reading the body too.
let pending_lookup = store.lookup_async(key).map_err(|_| ())?;
let final_entry = kv_store::await_lookup(pending_lookup)
.map_err(|_| ())?
.ok_or(())?;
let final_generation = final_entry.generation();
let final_body = final_entry.take_body().ok_or(())?;
let final_value = read_body_to_string(&final_body).map_err(|_| ())?;
log.push_str(&format!(
"final value of \"{key}\" = \"{final_value}\", generation = {final_generation}\n"
));
let response = http_resp::Response::new().map_err(|_| ())?;
response
.insert_header("content-type", b"text/plain")
.map_err(|_| ())?;
let out_body = http_body::new().map_err(|_| ())?;
http_body::write(&out_body, log.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, out_body).map_err(|_| ())?;
Ok(())
}
}
bindings::export!(KvStoreMetadataAndGenerationExample with_types_in bindings);
One request, four moves against the same key: seed a value, read its metadata and generation without a body in sight, make a conditional write that should succeed, then make a second conditional write that reuses the now-stale generation on purpose—standing in for a second request that read the entry at the same moment the first one did, and lost the race. The final lookup, body included this time, confirms which write actually won.
Running it
Full working code: full example on GitHub (opens in a new window). fastly.toml just needs the store name declared:
[local_server.kv_stores]
flags = []
fastly compute serve
curl http://127.0.0.1:7676/ (opens in a new window)
seeded "beta-checkout" = "off"
read metadata = Some("note=seeded off"), generation = 1786439861770041000, without touching the body
conditional write with the current generation: succeeded
conditional write with the stale generation: rejected, precondition-failed
final value of "beta-checkout" = "on", generation = 1786439861770155000
(Your own generation numbers will differ—it's documented as "unique, non-serial," not a counter you can predict.)
Beyond the WIT
if-generation-match gives you detection, not prevention. Nothing in compute.wit stops two sandboxes from reading the same generation at the same instant and both attempting to write; the guarantee is only that at most one of those writes lands, and the other finds out. That means the pattern this post just walked through is half of a real implementation, not the whole thing—a caller that gets back precondition-failed is expected to re-read the entry, reconsider whatever it was about to write in light of the new value, and try again. This example stops at the rejection and reports it, on purpose, so the retry loop wouldn't bury the one line that actually matters. Build the retry into anything real.
It's also worth noticing what generation-matching doesn't cover: it's scoped to one key. Nothing in kv-store lets you condition a write to one key on the state of another, or update two keys as a single atomic unit—there's no batch or transaction operation anywhere in the interface. If an SDK wraps this in a nicer API, that's a real design decision worth making visible rather than smoothing over: a "safe update" helper built on if-generation-match is exactly as safe as the single key it's touching, and not one bit more.
Next: we step back from data entirely for a while, to look at what compute.wit can tell you about the environment your service is actually running in—the request's origin, the client's device, and the sandbox itself.