The Wasm Component Model on Fastly Compute
Purging by Surrogate Key
September 1, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
EDIT (2026-09-01): the derived surrogate key is now uppercase hex, matching what Fastly's SDKs actually emit. It's the same digest, and only the case changed, but the case is part of what makes a derived key interoperable—a lowercase digest purges nothing an SDK wrote. The example also stopped copying each cache key into a fresh Vec on the way into lookup and insert, since the generated binding takes a slice already.
Writing to the Core Cache means naming an object twice. There's the cache key, the list<u8> you hand to insert, which is how the object is stored and how you look it up again. Then there are surrogate keys, the optional space-delimited tags you can attach in write-options and have had no use for so far.
Purging only knows about the second kind. The whole ABI offers exactly two purge functions, both in an interface called purge, and both take a surrogate key. Nothing anywhere takes a cache key. So the name you fetch an object by is not a name you can remove it by, and if you never attached a tag on the way in, there is no way to reach the object on the way out.
The whole interface
purge is small enough to read in one sitting:
interface purge {
use types.{error};
record purge-options {
/// Perform a [soft purge] instead of a hard purge.
///
/// [soft purge]: https://www.fastly.com/documentation/guides/concepts/edge-state/cache/purging/#soft-vs-hard-purging (opens in a new window)
soft-purge: bool,
/// Additional options may be added in the future via this resource type.
extra: option<borrow<extra-purge-options>>,
}
...
/// Purge a surrogate key for the current service.
///
/// A surrogate key can be a max of 1024 characters.
/// A surrogate key must contain only printable ASCII characters (those between `0x21` and `0x7E`,
/// inclusive).
purge-surrogate-key: func(
surrogate-keys: string,
purge-options: purge-options,
) -> result<_, error>;
...
}
One record with one real field, one of the interface's two functions, and no open call to get a handle first. Compare that to cache, which needed a resource, three entry points and two option records to do its job. Purging is a fire-and-forget message to the platform.
There's no entry in there either. A purge isn't tied to a handle any more than it's tied to a key, which is why there's nothing to open first. And the surrogate keys it does name come from exactly one place: the surrogate-keys field on write-options, one of the two space-delimited strings that post pulled apart, chosen at insert time and never afterward.
That field isn't Core Cache's alone, which is what makes this interface worth more than the one arc. As we'll be encountering in the upcoming posts, http-cache declares its own surrogate-keys in its own write-options, and Simple Cache has no storage of its own at all—it writes through cache, so it inherits the field you just read. The map post put it as one surface shared by all three, and this is that surface: whichever interface wrote an object, this is the one that takes it away, and a surrogate key is the only handle it will accept. The three caches barely interoperate on storage. They share a single way out.
The character restriction is stricter than it first looks. 0x21 to 0x7E inclusive excludes 0x20, the space—which has to be true, because space is the delimiter for the list. It also excludes every byte above ASCII, so a surrogate key can't hold UTF-8. Cache keys are list<u8> and can be anything; surrogate keys are printable ASCII, 1024 characters, no spaces.
Hard and soft
The one option is soft-purge: bool, and the doc comment links out rather than explaining. The short version: a hard purge removes the object, and a soft purge marks it stale instead, so it can still be served under stale-while-revalidate while a fresh copy is fetched.
That connects directly to the flags from the transactions post. A soft-purged object still comes back found, with stale set, and probably must-insert-or-update for whoever gets elected to refresh it. A hard-purged one is simply gone.
The other function, the one elided above, returns more than nothing:
/// Purge a surrogate key for the current service, and return the purge id.
///
/// This is similar to `purge-surrogate-key`, but on success, returns a
/// [JSON purge response] containing an ASCII alphanumeric string identifying
/// a purging.
///
/// [JSON purge response]: https://developer.fastly.com/reference/api/purging/#purge-tag (opens in a new window)
purge-surrogate-key-verbose: func(
surrogate-keys: string,
purge-options: purge-options,
max-len: u64,
) -> result<string, error>;
A JSON blob with an id in it, for correlating a purge you triggered from edge code against Fastly's own purge records. Same max-len growable-buffer pattern this series has been using since the very first header read.
Purging what you actually cached
Here's the practical problem the model creates. You cache a rendered product page under the key product-42-en. The product changes. You want that page gone, and you know its cache key, and the cache key is useless to you.
The fix is to stop treating the surrogate key as metadata and start treating it as a second, purgeable name for the object: derive it from the cache key with a function you can run again later.
The map post already found three SDKs doing exactly that, independently, and here are the three places it lives: the Rust SDK's cache/simple.rs, the Go SDK's SurrogateKeyForCacheKey, and the JS runtime's C++ createGlobalSurrogateKeyFromCacheKey. Each hashes the cache key with SHA-256 and uses the hex digest as a surrogate key, at insert time and again at purge time.
Two properties make that work, and both matter more here than they did back then. Hashing is deterministic, so nothing has to be stored or tracked between the write and the purge. And a hex digest is printable ASCII with no spaces, so it satisfies the character rule above without anyone having to think about it.
Reading it directly
Full working code: full example on GitHub (opens in a new window).
The example caches three objects, tags two of them with a shared surrogate key, purges that one key, and then does the derived-key trick on a fourth.
mod bindings;
use bindings::{
exports::fastly::compute::http_incoming,
fastly::compute::{cache, http_body, http_resp, purge},
};
use sha2::{Digest, Sha256};
fn write_options(max_age_ns: u64, surrogate_keys: Option<String>) -> cache::WriteOptions<'static> {
cache::WriteOptions {
max_age_ns,
request_headers: None,
vary_rule: None,
initial_age_ns: None,
stale_while_revalidate_ns: None,
surrogate_keys,
length: None,
user_metadata: None,
edge_max_age_ns: None,
sensitive_data: false,
extra: None,
}
}
fn lookup_options() -> cache::LookupOptions<'static> {
cache::LookupOptions { request_headers: None, always_use_requested_range: true, extra: None }
}
fn state_of(key: &[u8]) -> String {
match cache::Entry::lookup(key, &lookup_options()) {
Ok(entry) => {
let s = format!("{:?}", entry.get_state());
let _ = cache::close_entry(entry);
s
}
Err(e) => format!("Err({e:?})"),
}
}
/// The technique every Fastly SDK's Simple Cache uses internally: derive a
/// surrogate key from the cache key by hashing it, so the same cache key always
/// produces the same surrogate key at insert time and at purge time.
fn surrogate_key_for(cache_key: &[u8]) -> String {
let digest = Sha256::digest(cache_key);
digest.iter().map(|b| format!("{b:02X}")).collect()
}
struct SurrogateKeyPurging;
impl http_incoming::Guest for SurrogateKeyPurging {
fn handle(_request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
let mut lines = String::new();
// Two objects, tagged with one shared surrogate key and one private each.
for (key, keys) in [
(&b"product-42-en"[..], "catalog product-42"),
(&b"product-42-fr"[..], "catalog product-42"),
(&b"unrelated"[..], "other"),
] {
let writing = cache::insert(key, &write_options(60_000_000_000, Some(keys.to_string())))
.map_err(|_| ())?;
http_body::write(&writing, key).map_err(|_| ())?;
http_body::close(writing).map_err(|_| ())?;
}
lines.push_str("-- before purge --\n");
for key in [&b"product-42-en"[..], &b"product-42-fr"[..], &b"unrelated"[..]] {
lines.push_str(&format!("{:<14} {}\n", String::from_utf8_lossy(key), state_of(key)));
}
// One purge, by a key that isn't any object's cache key.
let options = purge::PurgeOptions { soft_purge: false, extra: None };
let purged = purge::purge_surrogate_key("product-42", &options);
lines.push_str(&format!("\npurge-surrogate-key(\"product-42\"): {purged:?}\n"));
lines.push_str("\n-- after purge --\n");
for key in [&b"product-42-en"[..], &b"product-42-fr"[..], &b"unrelated"[..]] {
lines.push_str(&format!("{:<14} {}\n", String::from_utf8_lossy(key), state_of(key)));
}
// The verbose form returns a purge id.
let verbose = purge::purge_surrogate_key_verbose("catalog", &options, 1024);
lines.push_str(&format!("\nverbose form: {verbose:?}\n"));
// Deriving a surrogate key from the cache key, so a purge can find it later.
let key = b"derive-me".to_vec();
let derived = surrogate_key_for(&key);
lines.push_str(&format!("\nsha256(\"derive-me\") = {derived}\n"));
let writing =
cache::insert(&key, &write_options(60_000_000_000, Some(derived.clone()))).map_err(|_| ())?;
http_body::write(&writing, b"purgeable by its own key").map_err(|_| ())?;
http_body::close(writing).map_err(|_| ())?;
lines.push_str(&format!("before: {}\n", state_of(&key)));
let purged = purge::purge_surrogate_key(&surrogate_key_for(&key), &options);
lines.push_str(&format!("purge: {purged:?}\n"));
lines.push_str(&format!("after: {}\n", state_of(&key)));
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, lines.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, out_body).map_err(|_| ())?;
Ok(())
}
}
bindings::export!(SurrogateKeyPurging with_types_in bindings);
Against Viceroy:
-- before purge --
product-42-en Ok(LookupState(FOUND | USABLE))
product-42-fr Ok(LookupState(FOUND | USABLE))
unrelated Ok(LookupState(FOUND | USABLE))
purge-surrogate-key("product-42"): Ok(())
-- after purge --
product-42-en Ok(LookupState(0x0))
product-42-fr Ok(LookupState(0x0))
unrelated Ok(LookupState(FOUND | USABLE))
verbose form: Err(Error::Unsupported)
sha256("derive-me") = 3D93741796288B08924E06FE3C0F5C86B1E1397BAEE7E1C0AAA8466EB678F369
before: Ok(LookupState(FOUND | USABLE))
purge: Ok(())
after: Ok(LookupState(0x0))
One call, two objects gone, and the third untouched. That's the fan-out the model is for: the two language variants of product 42 share a tag, so one purge reaches both without either one's cache key being mentioned anywhere.
A miss prints as LookupState(0x0), which is the flags type's natural empty value: no bits set at all. Not found, and not must-insert-or-update either. compute.wit doesn't say whether a non-collapsing lookup can ever hand out an obligation, so take the empty value as what this call returned rather than as a rule, but it is what you would expect from a lookup whose whole doc comment is about the waiting it skips.
The derived-key round trip works exactly as advertised. Hash the cache key, store the digest as the surrogate key, throw the digest away, hash the same cache key again at purge time, and the object is gone.
purge-surrogate-key-verbose is Error::Unsupported locally, which is the fourth Viceroy gap in this arc after get-hits, the replace API, and get-stale-while-revalidate-ns. The plain form works, so the shape of a purge is testable locally; the purge id is not.
Beyond the WIT
Every other interface in this ABI is at least loosely typed around what it operates on. cache has an entry resource. The stores hand you a handle from open. purge has neither, and takes a string that names something the interface has no way to validate, resolve, or tell you about.
A binding that mirrors this faithfully gives its users a function taking an arbitrary string, which will silently do nothing when the key is misspelled, when nothing was ever tagged with it, or when the tag was written by a write-options field three modules away. Ok(()) comes back either way. There's no count, no confirmation, nothing that distinguishes "purged four objects" from "purged nothing at all."
That last part is worth stating plainly: result<_, error> means success carries no information. The verbose form gives you a purge id rather than a count, so even that doesn't tell you what was hit.
So the binding-design question isn't about wrapping the call, which is trivial. It's about whether the binding takes any responsibility for the relationship between writing a tag and purging it. Options exist and they aren't free. A typed SurrogateKey newtype that validates the character range at construction catches the malformed cases early, and does nothing for the misspelled ones. Deriving keys automatically from cache keys, the way Simple Cache does, makes the round trip reliable and takes away the fan-out that made surrogate keys worth having. Letting callers pass raw strings keeps all the power and all the silence.
The ABI made its choice, and it's the right one for an ABI: no opinion, no bookkeeping, minimum surface. An SDK sitting on top has more room, and the thing worth remembering is that "just expose what the ABI exposes" is itself a design decision, not the absence of one.
Next: the convenience layer that makes all of this disappear, along with the interface that turns out not to exist.