The Wasm Component Model on Fastly Compute

HTTP Cache: After the Send

Last time got as far as a suggested backend request, which you send yourself with send-uncached. A response comes back. You are holding an obligation, a response, and no obvious idea which of six functions discharges it.

The same guest cache pipeline as the previous post, with the emphasis moved to the second half. The before-send phase is drawn dimmed: it looks the request up with transaction-lookup, which is where concurrent lookups on the same key collapse, and asks the cache what request to make with get-suggested-backend-request. That request is fetched with send-uncached, the one call neither phase owns. The after-send phase below it is drawn at full strength, and is what this post covers: change the response however you like, in the window host cache never opens, then call prepare-response-for-storage to ask what should happen to it. The resulting storage action decides which of six discharge functions writes back to the host cache. the same picture, with the other half lit your code before-send phase look up the request transaction-lookup the host cache collapses on the key ask what to send get-suggested-backend-request covered last time send-uncached neither phase owns this after-send phase change it however you like the window host cache never opens ask what to do with it prepare-response-for-storage one of six discharge functions the host cache storage-action decides which write this post is the bottom half

Six is not an exaggeration, and choosing between them is most of the work in guest mode.

Ask what to do with it

Before deciding anything, hand the response back and ask:

WIT
/// Adjusts a response into the appropriate form for storage and provides a storage action
/// recommendation.
///
/// For example, if the looked-up request contains conditional headers, this function will
/// interpret a `304 Not Modified` response for revalidation by updating headers.
///
/// In addition to the updated response, this function returns the recommended storage action.
prepare-response-for-storage: func(
  response: borrow<response>,
) -> result<tuple<storage-action, response>, error>;

Two things come back in that tuple, and both pieces are very important.

The response has been adjusted. If you sent a revalidation request and the origin answered 304 Not Modified, that 304 is not what belongs in the cache; the stored response with refreshed headers is. This function does that merge, which, as you could imagine, would be very annoying to implement by hand.

And the recommendation tells you which function to call next:

WIT
enum storage-action {
  /// Insert the response into cache (for `transaction-insert` and
  /// `transaction-insert-and-stream-back`).
  insert,
  /// Update the stale response in cache (for `transaction-update` and
  /// `transaction-update-and-return-fresh`).
  update,
  /// Do not store this response.
  do-not-store,
  /// Do not store this response, and furthermore record its non-cacheability for other pending
  /// requests (`transaction-record-not-cacheable`).
  record-uncacheable,
}

This enum is unusual in a way that's easy to read past. Three of its four doc comments name the functions you should call in that case, in the WIT itself. It isn't describing a state, it's dispatching.

Which is a clue about how much the interface expects you to disagree. If following the recommendation were the only sensible path, the interface would just do it. Instead you get an enum whose cases are annotated with their corresponding calls, and the calls stay yours to make.

Six ways to discharge an obligation

insert and update each come in two flavors, plain and stream-back:

WIT
/// Inserts a response into the cache with the given options, returning a streaming body handle
/// that is ready for writing or appending.
///
/// Can only be used if the cache handle state includes the `must-insert-or-update` flag.
///
/// The response is consumed.
transaction-insert: func(
  resp-handle: response,
  options: write-options,
) -> result<body, error>;
WIT
/// Updates freshness lifetime, response headers, and caching settings without updating the
/// response body.
///
/// Can only be used in if the cache handle state includes both of the flags:
/// - `found`
/// - `must-insert-or-update`
///
/// The response is consumed.
transaction-update: func(
  resp-handle: response,
  options: write-options,
) -> result<_, error>;

You can see these have the same flag preconditions as the Core Cache's equivalents, same "can only be used in if" typo carried across, and the same reason update returns nothing: the body isn't changing, so there's nothing to stream. "The response is consumed" is new though. These take response by value, not borrow<response>, so the handle is gone afterwards.

Then the same two again, each returning a handle to read back through:

WIT
/// Inserts a response into the cache with the given options, and return a fresh cache handle
/// that can be used to retrieve and stream the response while it's being inserted.
///
/// This helps avoid the “slow reader” problem on a teed stream, for example when a program
/// wishes to store a backend request in the cache while simultaneously streaming to a client
/// in an HTTP response.
///
/// The response is consumed.
transaction-insert-and-stream-back: func(
  resp-handle: response,
  options: write-options,
) -> result<tuple<body, entry>, error>;
WIT
/// Updates freshness lifetime, response headers, and caching settings without updating the
/// response body, and return a fresh cache handle that can be used to retrieve and stream the
/// stored response.
///
/// Can only be used in if the cache handle state includes both of the flags:
/// - `found`
/// - `must-insert-or-update`
///
/// The response is consumed.
transaction-update-and-return-fresh: func(
  resp-handle: response,
  options: write-options,
) -> result<entry, error>;

Four names is more than it sounds like, because the differences between them are two independent questions rather than one list to memorize.

A decision matrix for choosing between the four writes that store a response. prepare-response-for-storage hands back the storage action, which answers the first question: insert when nothing usable is stored, update when there is a stale entry to refresh. The second question is whether you also need to serve this object right now. If you only need to store it, insert is transaction-insert, which returns a body to write into, and update is transaction-update, which returns nothing because an update changes headers and freshness rather than bytes. If you need to serve it as well, insert becomes transaction-insert-and-stream-back, returning both that body and an entry to read through while the object is still landing, and update becomes transaction-update-and-return-fresh, returning the read handle alone. Each box also carries its exact return type: result<body, error> for transaction-insert, result<tuple<body, entry>, error> for transaction-insert-and-stream-back, result<_, error> for transaction-update, and result<entry, error> for transaction-update-and-return-fresh. The preconditions differ too: insert requires must-insert-or-update, and update requires found alongside it. two questions, and the four writes fall out prepare-response-for-storage hands back the storage action insert nothing usable is stored update a stale entry to refresh then: do you also need to serve this object right now? no store only transaction-insert result<body, error> a body to write the object into transaction-update result<_, error> no payload at all yes serve it too transaction-insert-and-stream-back result<tuple<body, entry>, error> that same body, plus a handle to read it back transaction-update-and-return-fresh result<entry, error> the read handle, and no body the left column needs must-insert-or-update in the lookup state the right column needs found alongside it, since there has to be something to update an update has no bytes to write, which is why its column never hands you a body

The first question is answered for you: prepare-response-for-storage hands back insert or update, and you follow it. The second is yours, and it is only ever whether you need to send this object downstream while you are storing it.

The return types fall out of that grid rather than having to be learned. The body is what you write the object into, so it appears exactly where there is an object to write. The entry is what you read back through, so it appears exactly where you said you also needed to serve it. An update changes headers and freshness rather than bytes, which is why its column never hands you a body at all, and why the plain form is the one function here that returns nothing.

The problem they solve is the teed-stream one the Core Cache's transactions worked through at length, and the mechanism is unchanged. Calling either stream-back form completes the request collapse, so everything queued behind you on this key wakes at that moment, each waiter holding a handle of its own onto the same half-written object. They read at the rate you write instead of waiting for you to finish.

Then there are two ways to give up.

First, transaction-choose-stale:

WIT
/// Fulfill an obligation to provide a response to the cache by selecting a stale-if-error response.
///
/// A guest that is obligated to insert/update the cache may not be able to produce an acceptable
/// response (e.g. unreachable backend, 5xx response). If the cache contains a response in the
/// stale-if-error period, the guest may prefer to use that response rather than returning an error.
/// If so, they can call transaction-choose-stale, after which the cache handle will reflect the stale
/// response (via get-found-response, get-state, etc).
///
/// `transaction-choose-stale` is an alternative to `transaction-update-and-return-fresh` or
/// `transaction-insert-and-stream-back`. Like those methods, it completes a request collapse,
/// providing the stale response to all collapsed transactions; and, after calling
/// `transaction-choose-stale`, the cache handle provides the (stale) response to send to the client.
///
/// However, `transaction-choose-stale` does not change the cached item. The next lookup will again
/// collapse and/or get an obligation to revalidate.
transaction-choose-stale: func() -> result<_, error>;

Your backend is down and there's a stale copy that's usable in stale-if-error. Serving stale beats serving a 502, and this action lets you do it: the obligation is discharged, everyone collapsed behind you gets the stale response, and the cached object is left exactly as it was so the next request tries again.

The other is transaction-record-not-cacheable:

WIT
/// Fulfill an obligation to provide a response to the cache by disabling request collapsing and
/// response caching for this cache entry.
///
/// In Varnish terms, this function stores a hit-for-pass object.
///
/// Only the max age and, optionally, the vary rule are read from the `options` argument.
transaction-record-not-cacheable: func(
  options: write-options,
) -> result<_, error>;

If the response turned out to be uncacheable, it may be useful to be able to mark that object in the cache as so. Otherwise, every subsequent request would collapse, wait for a new leader, which would then discover the same uncacheability, and serialize behind each other for no benefit. A hit-for-pass object records "don't bother" for a while. Fastly documents that behavior, and what collapsing does when there is no such object, under request collapsing (opens in a new window).

You'll also see it says "Only the max age and, optionally, the vary rule are read from the options argument". That is the write-options record from the Core Cache posts showing its seams again: a record whose fields are conditionally meaningful depending on which function you hand it to, with the rule in prose.

Finally, if you can't take any of the six actions, you use transaction-abandon:

WIT
/// Abandons an obligation to provide a response to the cache.
///
/// Useful if there is an error before streaming is possible, for example if a backend is
/// unreachable.
///
/// If there are other requests collapsed on this transaction, one of those other requests will
/// be awoken and given the obligation to provide a response. If subsequent requests
/// are unlikely to yield cacheable responses, this may lead to undesired serialization of
/// requests. Consider using `transaction-record-not-cacheable` to make lookups for this request
/// bypass the cache.
transaction-abandon: func() -> result<_, error>;

Note that it recommends its own alternative. Abandoning wakes the next waiter, who will probably fail the same way, and the doc comment tells you so and points at the better call.

How to fill in the fields of write-options

write-options needs filling in, and the interface will suggest values:

WIT
/// Prepares a suggested set of cache write options for a given request and response pair.
///
/// The response is not consumed.
get-suggested-write-options: func(
  response: borrow<response>,
) -> result<suggested-write-options, error>;

The suggested write options are not handed to you as a write-options record. Instead, it's a resource called suggested-write-options, and unusually for this file, it says why:

WIT
/// The methods in this resource return values that correspond to the fields in a
/// `write-options`. This type is used when a `write-options` value would
/// be returned, so that it can use `max-len` parameters when returning
/// dynamically-sized data, and so that it excludes the `extra` field, since borrowed
/// handles cannot be returned from functions.
resource suggested-write-options {
  /// Returns the suggested value for the `write-options.max-age-ns` field.
  get-max-age-ns: func() -> duration-ns;
  /// Returns the suggested value for the `write-options.vary-rule` field.
  get-vary-rule: func(max-len: u64) -> result<string, error>;
  /// Returns the suggested value for the `write-options.initial-age-ns` field.
  get-initial-age-ns: func() -> duration-ns;
  /// Returns the suggested value for the `write-options.stale-while-revalidate-ns` field.
  get-stale-while-revalidate-ns: func() -> duration-ns;
  /// Returns the suggested value for the `write-options.stale-if-error-ns` field.
  get-stale-if-error-ns: func() -> duration-ns;
  /// Returns the suggested value for the `write-options.surrogate-keys` field.
  get-surrogate-keys: func(max-len: u64) -> result<string, error>;
  /// Returns the suggested value for the `write-options.length` field.
  get-length: func() -> option<object-length>;
  /// Returns the suggested value for the `write-options.sensitive-data` field.
  get-sensitive-data: func() -> bool;
}

It has eight getters, one per field of the record you're trying to build, and every doc comment says the same sentence with a different field name in it.

You cannot pass this to transaction-insert. You have to call all eight, assemble a write-options yourself, and change whichever values you disagreed with along the way. The suggestion is a thing you interrogate, not a thing you edit.

One more oddity

Retrieving the stored response has a parameter worth stopping on:

WIT
/// Retrieves a stored response from the cache, returning `ok(none)` if
/// there was no response found.
///
/// If `transform-for-client` is set, the response will be adjusted according to the looked-up
/// request. For example, a response retrieved for a range request may be transformed into a
/// `206 Partial Content` response with an appropriate `content-range` header.
get-found-response: func(
  transform-for-client: u32,
) -> result<option<response-with-body>, error>;

transform-for-client is a u32. Its doc comment describes it in purely boolean terms: "if transform-for-client is set". Even though WIT has a bool type, this is a flag typed as a 32-bit integer. We'll presume it's an artifact of an older hostcall signature for now, but reading compute.wit alone can't tell you which, and this series can't make proclamations about meanings in a WIT block.

The transformation it controls, though, is very useful. It turns a stored 200 into a 206 Partial Content with the right content-range for a range request: again, this is exactly the type of thing you don't want to hand-roll.

It is also the mirror of something the previous post noticed. get-suggested-backend-request strips a range on the way out so the cache stores whole objects; get-found-response puts one back on the way in. Host mode does both halves silently, and Fastly documents them together as automatic request transformations (opens in a new window). In guest mode each half is a parameter you pass.

Where the other hook goes

Put the whole phase in order and the second hook falls out of it, in the same place the first one did: between the suggestion and the action.

A flowchart of the after-send phase. The response arrives from send-uncached. prepare-response-for-storage adjusts it and names a recommended storage action. get-suggested-write-options offers eight values, which you assemble into a write-options record yourself. SDKs typically invoke your after-send hook at this point, where any suggestion can be overridden before it is applied. The final storage action then selects the discharge: insert goes to transaction-insert or transaction-insert-and-stream-back, update goes to transaction-update or transaction-update-and-return-fresh, do-not-store stores nothing and the Rust SDK calls transaction-abandon so that only a single waiter wakes, and record-uncacheable goes to transaction-record-not-cacheable, which stores a hit-for-pass object. Separately, if the backend failed and a stale copy is usable, transaction-choose-stale discharges the obligation without changing the cached item. every recommendation arrives as data, and every action is yours send-uncached returns prepare-response-for-storage adjusts the response, names an action get-suggested-write-options eight getters, assembled by you your after-send hook override any suggestion before it is applied the final storage-action insert transaction-insert or -insert-and-stream-back update transaction-update or -update-and-return-fresh do-not-store nothing is stored the Rust SDK calls transaction-abandon record-uncacheable transaction-record-not-cacheable a hit-for-pass object and if the backend failed: transaction-choose-stale discharges the obligation with a stale copy, changing nothing in the cache six ways out, and the interface will not pick one for you

For example, the Rust SDK builds a "candidate response" (essentially the SDK's interface for suggested-write-options) as soon as the backend answers, and prepare-response-for-storage runs inside that constructor, so the recommended storage action is already attached to the candidate before your code sees it. The after-send hook runs next, against that candidate. Only then does the SDK finalize its options, taking the suggested storage action and the suggested write options and letting any override the hook set win over them.

That is the separation this interface was built around. Suggestions are computed early, actions happen late, and the hook is the gap between the two. A caller who wants the standard behavior attaches nothing and never learns any of these function names. A caller who wants the suggested max age but different surrogate keys overrides one field and leaves the rest alone.

The dispatch at the bottom is worth one more look. Six functions can discharge the obligation and this table reaches for four of them, always taking the stream-back flavor over the plain one. do-not-store is the interesting arm, because it calls no storage function at all—it calls transaction-abandon, and the SDK explains why: "to only wake a single waiter in the non-hit-for-pass case, so concurrent requests remain serialized." Abandoning is the give-up path everywhere else. Here it's chosen deliberately, to stop a herd forming behind a response nobody is going to cache.

Reading it directly

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

None of this runs under Viceroy. Every function in http-cache returns Error::Unsupported there, as last time showed, so the example was deployed to a real Compute service instead. It runs the whole round trip—lookup, suggested request, send-uncached, prepare-response-for-storage, assemble write-options from all eight getters, then dispatch—and reports which storage action came back and which function discharged the obligation.

For the origin we resurface http-me (opens in a new window), because it can return whatever we ask it for. Since the example passes the incoming path straight through, the URL picks the origin's behavior and therefore the branch.

A cold miss against a cacheable response:

Terminal output
lookup state:              LookupState(MUST_INSERT_OR_UPDATE)
origin status:             Ok(200)
origin cache-control:      max-age=60
storage-action:            StorageAction::Insert
suggested max-age-ns:      60000000000
suggested swr-ns:          Some(0)
suggested vary-rule:       Some("")
discharged with:           transaction-insert (5 bytes)

Sixty seconds of max-age arrives as 60000000000 nanoseconds, so get-suggested-write-options is reading the origin's cache directives and converting them, not guessing. Ask for the same URL again and there is no send at all:

Terminal output
lookup state:              LookupState(FOUND | USABLE)
no obligation; serving what is stored
found status:              Ok(200)
found body bytes:          5

The interesting case takes three steps: seed the cache with a response that stays available past its own max-age, wait for it to go stale, then revalidate it against an origin that answers 304.

Here's the log output:

Terminal output
origin status:             Ok(304)
storage-action:            StorageAction::Update
adjusted status:           Ok(200)
discharged with:           transaction-update

Read origin status against adjusted status. A 304 went in and a 200 came out. That is the merge described at the top of this post, the one worth the whole interface's existence, happening in a single call: prepare-response-for-storage took a bodiless 304, found the stored response it belonged to, applied the fresh headers, and handed back something storable. Nothing in the guest code knew which validator was used or which headers to copy.

The two give-up paths are reachable the same way. An origin that sends no-store:

Terminal output
origin cache-control:      no-store,private
storage-action:            StorageAction::RecordUncacheable
discharged with:           transaction-record-not-cacheable

And an origin that fails outright:

Terminal output
origin status:             Ok(500)
storage-action:            StorageAction::DoNotStore
discharged with:           transaction-abandon

That last pairing is the dispatch table's odd corner, now observed rather than read: do-not-store names no function of its own, so the guest picks one, and transaction-abandon is the pick here for the reason the SDK gave above.

One gap remains. transaction-choose-stale needs a stale entry and a failing backend on the same request, which needs an origin that turns hostile between two calls rather than one you address by URL. It stays unverified here.

Beyond the WIT

Count the decisions this interface asks a guest to make after a single backend response arrives: which storage action, out of four; which of six functions discharges the obligation; which of eight suggested values to accept and which to override; whether to transform for client; and whether the response is even cacheable, given that the built-in check told you POST isn't and that check is explicitly optional.

Any binding has to decide how much of that to answer on the caller's behalf, and there's a strong pull toward answering all of it. The full pipeline is long, most callers want the standard behavior, and a cache.fetch(request, backend) that follows every suggestion is both easy to write and easy to use.

The pull is worth resisting a little, because of a property this interface has that's easy to miss: the suggestions are separable from the actions. prepare-response-for-storage tells you what to do without doing it. get-suggested-write-options computes values without applying them. Every recommendation in this interface comes back as data, and every action is a separate call you make yourself.

That separation is the feature. It's what lets an application accept RFC 9111's answer about cacheability but override the vary rule, or take the suggested max age and add its own surrogate keys, without reimplementing the parts it agrees with. A wrapper that fuses recommendation and action back together throws away the one thing this design was built to give you, and gets it back only as a growing pile of override flags.

So we arrive at the same conclusion as last time, from the other side of the send. The honest shape is two layers rather than one: a high-level call for the common path, and the suggestion functions still public underneath it, so disagreeing with the default costs a caller a different function rather than a fork of your SDK. Landing there twice is worth noticing. The before-send phase reached it with one hook and four suggestions; this half has six discharge functions and a recommendation engine in front of them, and the answer didn't change. That's more surface area to document and maintain, which is a real cost, and it's the cost of not deciding on behalf of people who know their own traffic better than you do.

Next: serving a copy the cache has already called stale, which needs one of those six functions, two windows that look identical in the WIT, and a flag whose meaning depends on what it arrives with.