The Wasm Component Model on Fastly Compute

Core Cache: Reading a Hit

EDIT (2026-08-29): quoted get-body's doc comment, which carries a precondition the example quietly depends on; added always-use-requested-range, the lookup-options field that decides whether a range is honored at all; and added a comparison against the ranged read in Fastly's Rust SDK.

Both of the previous Core Cache posts (insert and replace, then transactions) ended the same way: get an entry back, check get-state, move on. That's been enough to prove the object is there, but we haven't talked yet about how to get at the data inside the entry.

A found entry carries seven getters and a body-reading function with its own options record. This post is what's actually inside the hit.

Everything the entry knows

WIT
resource entry {
  ...

  /// Gets the user metadata of the found object, returning `ok(none)` if no object
  /// was found.
  get-user-metadata: func(max-len: u64) -> result<option<list<u8>>, error>;

  ...

  /// Gets the content length of the found object, returning `ok(none)` if
  /// there was no found object, or no content length was provided.
  get-length: func() -> result<option<object-length>, error>;

  /// Gets the configured max age of the found object, returning `ok(none)`
  /// if there was no found object.
  get-max-age-ns: func() -> result<option<duration-ns>, error>;

  /// Gets the configured stale-while-revalidate period of the found object, returning `ok(none)`
  /// if there was no found object.
  get-stale-while-revalidate-ns: func() -> result<option<duration-ns>, error>;

  /// Gets the age of the found object, returning `ok(none)` if there
  /// was no found object.
  get-age-ns: func() -> result<option<duration-ns>, error>;

  /// Gets the number of cache hits for the found object, returning `ok(none)`
  /// if there was no found object.
  get-hits: func() -> result<option<cache-hit-count>, error>;

  ...
}

Every one of these six is result<option<T>, error>, and every doc comment gives the same reason for the none: there was no found object. That's the shape you'd design if entry could represent a miss as well as a hit, which is exactly what it does. A transactional lookup that comes back with only must-insert-or-update set is still an entry, and asking it how old its object is has to answer somehow.

So the option isn't hedging about missing metadata. It's the miss case showing up in six places, because the resource is one type doing two jobs.

The seventh getter is the one that doesn't do it. get-state returns result<lookup-state, error>, with no option anywhere, because a miss still has a state: that's how must-insert-or-update reaches you with nothing found at all. Every getter describing the object has to admit there might not be one. The getter describing the lookup never does.

Three of the types are aliases rather than bare integers:

WIT
type object-length = u64;
type duration-ns = u64;
type cache-hit-count = u64;

All three are u64 underneath, and none of them survives into most bindings as a distinct type—but they document the unit at the ABI level, which is the part that matters when the alternative is guessing whether an age is in seconds or nanoseconds.

Ranges, and a surprise

Reading the body isn't a plain call. The function hints at that before you reach the record:

WIT
/// Gets a range of the found object body, returning `ok(none)` if there
/// was no found object.
///
/// The returned `body` must be closed before calling this function again on the same
/// `entry`.
///
...
get-body: func(
  options: get-body-options,
) -> result<body, error>;

"Gets a range of the found object body." Not the body, a range of it. That's why there's an options parameter at all, and it means every read here is a ranged read whether you meant it that way or not.

The second paragraph is also pretty important, because it's a runtime requirement, rather something enforced by types. You can only get one body at a time per entry, closed before you ask for another. The helper below honors that on each of its four reads, which is easy to miss, since a close that looks like ordinary cleanup is doubling as the precondition for the next call.

Now the record itself:

WIT
record get-body-options {
  %from: option<u64>,
  to: option<u64>,

  /// Additional options may be added in the future via this resource type.
  extra: option<borrow<extra-get-body-options>>,
}

%from carries a % for the same reason the KV Store's %list did: from is a reserved word, and % is WIT's escape for using one as an identifier anyway. It's an escape rather than part of the name, and like %list it doesn't survive into the generated bindings—the Rust field is plain from.

Two optional bounds, and the obvious reading is "start here, end there, leave either out for the natural end." That reading is almost right, but we should look at the details of this before shipping anything.

With a 26-byte object holding the lowercase alphabet, from: Some(3), to: Some(10) returns defghijk. Eight bytes, so the range is inclusive at both ends. Leaving to out returns everything from d onward, as expected.

Leaving from out is where it gets interesting. to: Some(5) with no from does not return abcde. It returns vwxyz.

That's an HTTP suffix range. Range: bytes=-5 means the last five bytes, not the first five, and get-body-options is following the same convention—a lone to is a length from the end. Nothing in the WIT says so; both fields are bare option<u64> with no doc comment between them.

Four byte ranges read out of one 26-byte cache object holding the lowercase alphabet, each labeled with the get-body-options record that produced it. from 3 to 10 returns d through k, eight bytes, so the range is inclusive at both ends. from 3 with no to returns d through z. from None with to 5 returns v through z, the last five bytes rather than the first five, because a lone to is an HTTP suffix range counting backwards from the end. from None with to None returns the whole object. The only difference between the second case and the third is which of the two fields was left out. one 26-byte object, four reads of it 0 5 10 15 20 25 a b c d e f g h i j k l m n o p q r s t u v w x y z { from: 3, to: 10 } d e f g h i j k inclusive at both ends { from: 3, to: None } d e f g h i j k l m n o p q r s t u v w x y z { from: None, to: 5 } v w x y z counts backwards from the end { from: None, to: None } a b c d e f g h i j k l m n o p q r s t u v w x y z The second read and the third differ only in which field was left out.

For an interface that otherwise refuses to know anything about HTTP, inheriting HTTP's least intuitive range rule is a genuine trap. If your binding exposes these as start and end, callers will write end: 5 meaning "the first five bytes" and get the wrong end of the object with no error to tell them.

Ranges are full of surprises

There's actually a second trap here that can catch you off-guard. If you've been paying close attention you'll remember I mentioned briefly in the previous posts that you should set always-use-requested-range to true in lookup-options when performing the lookup.

This is lookup-options:

WIT
record lookup-options {
  /// A full request handle, but used only for its headers
  ///
  /// May be `none` if the `request-headers` option isn't enabled.
  ///
  request-headers: option<borrow<request>>,

  always-use-requested-range: bool,

  /// Additional options may be added in the future via this resource type.
  extra: option<borrow<extra-lookup-options>>,
}

Two of those three fields carry a doc comment, but to our inconvenience there is no doc comment for always-use-requested-range, the one that we want to talk about, and that actually makes a difference in what get-body does.

(And if request-headers has you wondering what a cache wants with a whole request handle, hold that thought. It's next time's, along with the vary rule it exists to serve.)

Digging into Fastly's Rust SDK reveals the answer to that question. Its description for always_use_requested_range (opens in a new window) explains its default (false) as legacy behavior: "if a range is specified but the length is not known, the contents of the entire item will be provided instead of the requested range."

This means that performing a lookup with always_use_requested_range set to false means whether your range is honored depends on whether the object's length is known, a fact about the object and the moment you asked, rather than about how you asked. Setting the flag to true opts into the range being respected either way.

Decision flowchart for whether a requested byte range is actually honored. You call get-body with a from/to range. If the object's length is not known, the always-use-requested-range lookup option decides: left at its default of false, the range is ignored and the entire object comes back with no error; set to true, you get your range, though the body raises a read error if the range never finishes. If the length is known, a range within the size returns exactly what you asked for, while a range outside the size returns the entire object, again with no error. On both halves the honored range sits on the left and the whole-object outcome on the right. Two of the four paths hand back the whole object without telling you. you asked for a range. do you get one? get-body({ from, to }) is the length known? no yes always-use-requested-range the lookup option is the range within it? true false (default) in range out of range your range read error if it ends short the entire object no error, no warning your range exactly what you asked the entire object no error, no warning Two of the four paths hand back the whole object without telling you.

Unfortunately, this means that in some cases, which you land in is a matter of timing rather than of anything your code decides. A popular object that has finished caching by the time you ask behaves one way; the same object, on the unlucky request that arrives while it's still filling, behaves the other.

Which makes the practical advice unusually blunt for this series: set always-use-requested-range to true and leave it set. The flag gets you the range you asked for instead of an object that may silently be of the wrong size. The cost of being wrong in the other direction is a read error you can see, rather than a body you have to notice is too big.

Fastly's Rust SDK says as much in the same place: "In the future, the always_use_requested_range behavior will be the default, and this method will be removed." Setting it today is opting into the behavior that's coming anyway, which is about as safe as a bet on an ABI gets.

Keep that next to the get-length result below, because they are the same question asked twice. Whether the cache can tell you a length is whether it will honor your range.

Reading it directly

Full working code: full example on GitHub (opens in a new window).

The example writes the same 26-byte object twice, once declaring length, user-metadata and stale-while-revalidate-ns in its write options and once declaring none of them, then reads both back.

Rust

mod bindings;

use bindings::{
    exports::fastly::compute::http_incoming,
    fastly::compute::{cache, http_body, http_resp},
};

fn write_options(max_age_ns: u64) -> 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: None,
        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 decode(v: Result<Option<Vec<u8>>, cache::Error>) -> String {
    match v {
        Ok(Some(bytes)) => format!("{:?}", String::from_utf8_lossy(&bytes)),
        Ok(None) => "Ok(None)".to_string(),
        Err(e) => format!("Err({e:?})"),
    }
}

fn read_range(entry: &cache::Entry, from: Option<u64>, to: Option<u64>) -> String {
    let options = cache::GetBodyOptions { from, to, extra: None };
    let Ok(body) = entry.get_body(&options) else {
        return "<no body>".to_string();
    };
    let mut out = Vec::new();
    while let Ok(chunk) = http_body::read(&body, 1024) {
        if chunk.is_empty() {
            break;
        }
        out.extend_from_slice(&chunk);
    }
    let _ = http_body::close(body);
    String::from_utf8_lossy(&out).into_owned()
}

struct CoreCacheReadingAHit;

impl http_incoming::Guest for CoreCacheReadingAHit {
    fn handle(_request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
        let mut lines = String::new();


        let declared = b"declared-length".to_vec();
        let mut options = write_options(60_000_000_000);
        options.length = Some(26);
        options.user_metadata = Some(b"written-by=reading-a-hit".to_vec());
        options.stale_while_revalidate_ns = Some(30_000_000_000);
        let writing = cache::insert(&declared, &options).map_err(|_| ())?;
        http_body::write(&writing, b"abcdefghijklmnopqrstuvwxyz").map_err(|_| ())?;
        http_body::close(writing).map_err(|_| ())?;

        let undeclared = b"undeclared-length".to_vec();
        let writing = cache::insert(&undeclared, &write_options(60_000_000_000)).map_err(|_| ())?;
        http_body::write(&writing, b"abcdefghijklmnopqrstuvwxyz").map_err(|_| ())?;
        http_body::close(writing).map_err(|_| ())?;

        // Everything the entry will tell you about a found object.
        let entry = cache::Entry::lookup(&declared, &lookup_options()).map_err(|_| ())?;
        lines.push_str("-- declared length + metadata --\n");
        lines.push_str(&format!("state:      {:?}\n", entry.get_state()));
        lines.push_str(&format!("length:     {:?}\n", entry.get_length()));
        lines.push_str(&format!("max-age-ns: {:?}\n", entry.get_max_age_ns()));
        lines.push_str(&format!("swr-ns:     {:?}\n", entry.get_stale_while_revalidate_ns()));
        lines.push_str(&format!("age-ns:     {:?}\n", entry.get_age_ns()));
        lines.push_str(&format!("metadata:   {:?}\n", decode(entry.get_user_metadata(256))));

        // get-body-options is a byte range, not a whole-object read.
        lines.push_str(&format!("full:       {}\n", read_range(&entry, None, None)));
        lines.push_str(&format!("from 3:     {}\n", read_range(&entry, Some(3), None)));
        lines.push_str(&format!("3..10:      {}\n", read_range(&entry, Some(3), Some(10))));
        lines.push_str(&format!("..5:        {}\n", read_range(&entry, None, Some(5))));
        lines.push_str(&format!("length after reading: {:?}\n", entry.get_length()));
        cache::close_entry(entry).map_err(|_| ())?;

        // The same object, written without declaring a length.
        let entry = cache::Entry::lookup(&undeclared, &lookup_options()).map_err(|_| ())?;
        lines.push_str("\n-- no declared length --\n");
        lines.push_str(&format!("length:     {:?}\n", entry.get_length()));
        lines.push_str(&format!("metadata:   {:?}\n", decode(entry.get_user_metadata(256))));
        lines.push_str(&format!("full:       {}\n", read_range(&entry, None, None)));
        cache::close_entry(entry).map_err(|_| ())?;


        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!(CoreCacheReadingAHit with_types_in bindings);

Against Viceroy:

Terminal output
-- declared length + metadata --
state:      Ok(LookupState(FOUND | USABLE))
length:     Ok(Some(26))
max-age-ns: Ok(Some(60000000000))
swr-ns:     Err(Error::Unsupported)
age-ns:     Ok(Some(81833))
metadata:   "\"written-by=reading-a-hit\""
full:       abcdefghijklmnopqrstuvwxyz
from 3:     defghijklmnopqrstuvwxyz
3..10:      defghijk
..5:        vwxyz
length after reading: Ok(Some(26))

-- no declared length --
length:     Ok(Some(26))
metadata:   "\"\""
full:       abcdefghijklmnopqrstuvwxyz

There are four things in that output are worth more than a glance.

Again, the suffix range is real, not a misreading of the WIT. ..5 returns the last five bytes of the alphabet.

get-length returns Some(26) for the object that never declared a length. The doc comment says none comes back when "no content length was provided," which reads like it's reporting what you put in write-options.length. Something is filling it in anyway. What decides that, I can't tell you: the same call has come back Ok(None) under conditions I couldn't reliably distinguish from these, so take the number as observed here rather than as a rule to lean on.

Absent user metadata comes back as Ok(Some([])), an empty byte list, rather than Ok(None). The none case is reserved for "there was no found object" and nothing else, so a binding that maps this to a nullable field will hand callers an empty value where they might reasonably expect a null one.

And get-stale-while-revalidate-ns fails with Error::Unsupported even on the object that explicitly set stale_while_revalidate_ns in its write options. It joins the get-hits gap from the insert-and-replace post on the list of getters Viceroy hasn't implemented, which is worth knowing before you build local tests around a revalidation window.

Beyond the WIT

get-body-options is two option<u64> fields with no doc comment, and one of them silently changes meaning depending on whether the other is set. Leave from out and to stops being a position and becomes a count backwards from the end.

That is not something a caller can discover from the types, and it is not something a mechanically generated binding will help with. GetBodyOptions { from: None, to: Some(5) } is a perfectly ordinary-looking struct literal, and it does something most people writing it would not predict.

Any binding wrapping this has a choice, and "pass the record through faithfully" is the option that looks safest and is not. Faithfulness here means reproducing a footgun the ABI inherited from HTTP without inheriting the context that makes it legible: nobody is confused by Range: bytes=-5, because the minus sign is right there in the syntax. to: 5 has no minus sign.

The alternatives all cost something. You can name the fields for what they do, so the suffix case is a different constructor or a different method rather than an absent field. You can refuse the ambiguous combination outright and make callers say which they meant. Or you can keep the record and document it loudly, which is the cheapest and relies entirely on people reading documentation for a two-field struct that looks self-explanatory.

Fastly's Rust SDK took the third one. Found::to_stream_from_range(from, to) (opens in a new window) passes the same two Option<u64>s straight through, and its documentation does the work the types don't: "If to is provided but from is not provided, the last to bytes of the body will be provided." That sentence sits where a caller is most likely to already be looking, which is what makes the cheap fix a real one.

On the case next door it goes further than the ABI does: "If to is strictly before from, this call returns a error immediately." The WIT says nothing about a backwards range, so that is a guarantee the binding adds rather than relays.

And the close-before-you-read-again rule from earlier survives the trip, restated in that SDK's own terms: "Only one stream can be active at a time for a given Found. The stream must be fully consumed (read) before a new stream can be created from the same Found." Two interfaces built separately, the same restriction in both, which is decent evidence it belongs to the cache rather than to either interface's shape.

The general version of this keeps coming up in this series and is worth stating plainly: an ABI's shape encodes what the host needs, not what a caller expects, and the places where those diverge silently are exactly where a binding earns its keep. A wrapper that only renames things has skipped the job.

Next: we go back to the write side and take write-options apart properly, all ten fields of it, including the one that decides what a purge can reach.