Skip to content

Caching and streaming

Conditional GET

fresh-when sets validators on the response and renders a 304 Not Modified when the request's If-None-Match/If-Modified-Since show the client already has the current version. is-stale is its inverse: it returns true when you should render.

1
2
3
method show {
  self.render('show') if self.is-stale(etag => $post, last-modified => $post.updated-at);
}

etag-for computes an ETag from a value, using its cache-key or to-hash when available. ETags are weak by default (W/"..."); pass weak => False for a strong ETag. Matching uses a weak comparison, so W/ prefixes are ignored, and * matches anything.

Cache headers

expires-in sets Cache-Control with a max-age, private by default or public, plus any extra directives. expires-now marks the response uncacheable:

1
2
3
4
5
self.expires-in(3600, public => True, must-revalidate => True);
# Cache-Control: public, max-age=3600, must-revalidate

self.expires-now;                  # Cache-Control: no-cache
self.expires-now(no-store => True); # Cache-Control: no-store

Cache store

MVC::Keayl::Cache provides a Store role with a low-level key-value API shared by every backend:

1
2
3
4
5
6
$store.write('user/1', $user, expires-in => 300);
$store.read('user/1');
$store.exist('user/1');
$store.delete('user/1');

$store.fetch('user/1', { load-user(1) }, expires-in => 300);

fetch returns a cached value or computes, stores, and returns it. force recomputes, skip-nil declines to store an undefined result, and race-condition-ttl serves the stale value to other readers while one recomputes an expired entry.

increment and decrement maintain counters, preserving an entry's expiry window across updates. read-multi and write-multi work on several keys at once, and delete-matched removes every key matching a string glob or a regex:

1
2
3
4
$store.increment('visits', 1, expires-in => 3600);
$store.write-multi({ a => 1, b => 2 });
$store.read-multi('a', 'b');
$store.delete-matched('user/*');

Entries carry an optional version; a read with a mismatched version misses. A store can take a namespace that prefixes its keys and a default-expires-in applied when a write gives no expiry.

Backends

  • MemoryStore keeps entries in process, with an optional max-entries bound that evicts the least recently used entry.
  • FileStore persists each entry under a root directory, surviving across instances.
  • NullStore stores nothing, so fetch always recomputes. It is the no-op backend for test and development.
  • ExternalStore delegates to an injected client shaped like a Redis or Memcache driver (get, set with a ttl, del, keys), serializing each entry through it.

Fragment caching

A store's fetch backs view fragment caching. cache-key derives a key from parts (an object contributes its own cache-key), under a views/ namespace, with an optional template digest:

1
cache-key($post, digest => template-digest($source));   # views/posts/1-2021/<digest>

A view caches a fragment with cache-fragment, which computes the content once per key through its configured store:

1
$view.cache-fragment([$post], { render-the-fragment() });

Rate limiting

rate-limit is a controller class method that caps how often a client may reach an action, backed by a cache store. It registers a before-action that counts requests per discriminator within a window and blocks once the count passes the limit:

1
PostsController.rate-limit(to => 100, within => 60);

It also has an is rate-limit trait form for the class header:

1
class PostsController is MVC::Keayl::Controller is rate-limit(to => 100, within => 60) { }

By default the discriminator is the request's remote IP and an over-limit request gets 429 Too Many Requests with a Retry-After header set to the window. by supplies a custom discriminator and with a custom over-limit handler; store and name choose the backing store and the counter name:

1
2
3
4
5
6
ApiController.rate-limit(
  to => 1000, within => 3600,
  by    => -> $controller { $controller.api-key },
  with  => -> $controller { $controller.head(503) },
  store => $cache,
);

Streaming

A response body can be streamed instead of buffered. stream takes an iterable of chunks (strings or blobs); stream-chunks yields them encoded, and is-streaming reports whether a stream is set, for an adapter to write chunked:

1
$response.stream(gather { for @rows { take row-html($_) } });

This is the pull-based primitive: the whole sequence is known up front. For a push-based response, where an action writes chunks over time, see live streaming.

Range requests

send-file honours a Range request header, responding 206 Partial Content with a Content-Range header and the requested byte slice, and advertises Accept-Ranges: bytes.