From 99d0b1a5084060f991eb7511f5b93e81defa9be9 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Fri, 13 Mar 2026 15:15:25 +0000 Subject: [PATCH 1/8] Draft implementation --- include/fastly/cache/core.h | 1215 +++++++++++++++++++++++++++++++++++ include/fastly/http/body.h | 19 + src/cpp/cache/core.cpp | 911 ++++++++++++++++++++++++++ src/cpp/fastly.h | 200 ++++++ src/cpp/http/body.cpp | 1 + src/http/body.rs | 11 + src/lib.rs | 2 + 7 files changed, 2359 insertions(+) create mode 100644 include/fastly/cache/core.h create mode 100644 src/cpp/cache/core.cpp diff --git a/include/fastly/cache/core.h b/include/fastly/cache/core.h new file mode 100644 index 0000000..81ff59f --- /dev/null +++ b/include/fastly/cache/core.h @@ -0,0 +1,1215 @@ +#ifndef FASTLY_CACHE_CORE_H +#define FASTLY_CACHE_CORE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fastly::cache::core { + +/// Cache key is a byte array used to identify cached items. +using CacheKey = std::vector; + +/// Errors that can arise during cache operations. +class CacheError { +public: + enum Code { + /// Operation failed due to a limit. + LimitExceeded, + /// Operation was not valid to be performed given the state of the cached + /// item. + InvalidOperation, + /// Cache operation is not supported. + Unsupported, + /// An unknown error occurred. + Unknown, + }; + CacheError(Code code) : code_(code) {} + Code code() const { return code_; } + +private: + Code code_; +}; + +namespace detail { +class CacheHandle { +public: + explicit CacheHandle(std::uint32_t handle) : handle_(handle) {} + std::uint32_t handle() const { return handle_; } + ~CacheHandle(); + +private: + std::uint32_t handle_; +}; + +class CacheReplaceHandle { +public: + explicit CacheReplaceHandle(std::uint32_t handle) : handle_(handle) {} + std::uint32_t handle() const { return handle_; } + ~CacheReplaceHandle(); + +private: + std::uint32_t handle_; +}; + +class CacheBusyHandle { +public: + explicit CacheBusyHandle(std::uint32_t handle) : handle_(handle) {} + std::uint32_t handle() const { return handle_; } + ~CacheBusyHandle(); + +private: + std::uint32_t handle_; +}; + +// TODO: this should really live somewhere else, but for now it's the only +// place that we need it, so we'll take an ad-hoc approach. +class RequestHandle { +public: + static RequestHandle make(); + explicit RequestHandle(std::uint32_t handle) : handle_(handle) {} + std::uint32_t handle() const { return handle_; } + ~RequestHandle(); + +private: + std::uint32_t handle_; +}; +} // namespace detail + +/// Options for cache lookup operations. +struct LookupOptions { + std::optional request_headers; + std::optional service; + bool always_use_requested_range = false; +}; + +/// Strategy to use when replacing an existing cached object. +enum class ReplaceStrategy { + /// Immediately start the replace and do not wait for any other pending + /// requests for the same object, including insert requests. + /// + /// With this strategy a replace will race all other pending requests to + /// update the object. + /// + /// The existing object will be accessible until this replace finishes + /// providing the replacement object. + /// + /// This is the default replace strategy. + Immediate, + /// Immediate, but remove the existing object immediately + /// + /// Requests for the same object that arrive after this replace starts will + /// wait until this replace starts providing the replacement object. + ImmediateForceMiss, + /// Join the wait list behind other pending requests before starting this + /// request. + /// + /// With this strategy this replace request will wait for an in-progress + /// replace or insert request before starting. + /// + /// This strategy allows implementing a counter, but may cause timeouts if + /// too many requests are waiting for in-progress and waiting updates to + /// complete. + Wait, +}; + +/// Options for cache replace operations. +struct ReplaceOptions { + std::optional request_headers; + ReplaceStrategy replace_strategy = ReplaceStrategy::Immediate; + std::optional service; + bool always_use_requested_range = false; +}; + +/// Options for cache write operations (insert/update). +struct WriteOptions { + std::chrono::nanoseconds max_age; + std::optional request_headers; + std::optional vary_rule; + std::optional initial_age; + std::optional stale_while_revalidate; + std::optional surrogate_keys; + std::optional length; + std::optional> user_metadata; + bool sensitive_data = false; + std::optional edge_max_age; + std::optional service; + explicit WriteOptions(std::chrono::nanoseconds max_age) : max_age(max_age) {} +}; + +/// A builder-style API for configuring a transactional cache update. +/// +/// This builder is used to update an existing cached item's metadata, such as +/// its age, without changing the object itself. All builder methods consume the +/// builder and return it for method chaining. +class TransactionUpdateBuilder { +public: + /// Sets the list of headers that must match when looking up this cached item. + /// + /// The header values must be provided by calling + /// `TransactionLookupBuilder::header()` or + /// `TransactionLookupBuilder::header_values()`. The header values may be a + /// subset or superset of the header names supplied here. + /// + /// Note: These headers are narrowly useful for implementing cache lookups + /// incorporating the semantics of the HTTP Vary header, but the APIs in this + /// module are not suitable for HTTP caching out-of-the-box. Future SDK + /// releases will contain an HTTP Cache API. + /// + /// The headers act as additional factors in object selection, and the choice + /// of which headers to factor in is determined during insertion, via e.g. + /// `crate::cache::core::InsertBuilder::vary_by`. A lookup will succeed when + /// there is at least one cached item that matches lookup’s cache key, and all + /// of the lookup’s headers included in the cache items’ vary_by list match + /// the corresponding headers in that cached item. + /// + /// A typical example is a cached HTTP response, where the request had an + /// `Accept-Encoding` header. In that case, the origin server may or may not + /// decide on a given encoding, and whether that same response is suitable for + /// a request with a different (or missing) `Accept-Encoding` header is + /// determined by whether `Accept-Encoding` is listed in `Vary` header in the + /// origin’s response. + TransactionUpdateBuilder vary_by(std::vector headers) &&; + + /// Sets the updated age of the cached item, to be used in freshness + /// calculations. + /// + /// The updated age is 0 by default. + TransactionUpdateBuilder age(std::chrono::nanoseconds age) &&; + + /// Sets the maximum time the cached item may live on a deliver node in a POP. + TransactionUpdateBuilder + deliver_node_max_age(std::chrono::nanoseconds duration) &&; + + /// Sets the stale-while-revalidate period for the cached item, which is the + /// time for which the item can be safely used despite being considered stale. + /// + /// Having a stale-while-revalidate period provides a signal that the cache + /// should be updated (or its contents otherwise revalidated for freshness) + /// asynchronously, while the stale cached item continues to be used, rather + /// than blocking on updating the cached item. The methods Found::is_usable + /// and Found::is_stale can be used to determine the current state of a found + /// item. + /// + /// The stale-while-revalidate period is 0 by default. + TransactionUpdateBuilder + stale_while_revalidate(std::chrono::nanoseconds duration) &&; + + /// Sets the surrogate keys that can be used for purging this cached item. + /// + /// Surrogate key purges are the only means to purge specific items from the + /// cache. At least one surrogate key must be set in order to remove an item + /// without performing a + /// [purge-all](https://www.fastly.com/documentation/guides/concepts/cache/purging/#purge-all), + /// waiting for the item’s TTL to elapse, or overwriting the item with + /// `crate::cache::core::insert()`. + /// + /// Surrogate keys must contain only printable ASCII characters (those between + /// 0x21 and 0x7E, inclusive). Any invalid keys will be ignored. + /// + /// See the [Fastly surrogate keys + /// guide](https://www.fastly.com/documentation/guides/full-site-delivery/purging/working-with-surrogate-keys/) + /// for details. + TransactionUpdateBuilder surrogate_keys(std::vector keys) &&; + + /// Sets the user-defined metadata to associate with the cached item. + TransactionUpdateBuilder user_metadata(std::vector metadata) &&; + + /// Perform this update on behalf of another service, using its data store. + /// + /// *Internal / Privileged* + /// This operation is privileged, and attempts to use this functionality + /// without proper privileges will cause errors. If you are interested in + /// having two or more of your services share the same cache, please talk to + /// your Fastly account representative. While we have no plans to offer this + /// ability widely – this capability is only currently allowed for + /// Fastly-internal services – we may revisit this decision given sufficient + /// customer input. + TransactionUpdateBuilder on_behalf_of(std::string service) &&; + + /// Perform the update of the cache item's metadata. + /// + /// This consumes the builder and executes the update operation. + tl::expected execute() &&; + +private: + TransactionUpdateBuilder(std::shared_ptr handle, + WriteOptions options) + : handle_(std::move(handle)), options_(std::move(options)) {} + + std::shared_ptr handle_; + WriteOptions options_; + friend class Transaction; +}; + +/// A builder-style API for configuring a transactional cache insertion. +/// +/// This builder is used to insert a new item into the cache during a +/// transaction. All builder methods consume the builder and return it for +/// method chaining. +class TransactionInsertBuilder { +public: + /// Sets the list of headers that must match when looking up this cached item. + /// + /// The header values must be provided by calling + /// `TransactionLookupBuilder::header()` or + /// `TransactionLookupBuilder::header_values()`. The header values may be a + /// subset or superset of the header names supplied here. + TransactionInsertBuilder vary_by(std::vector headers) &&; + + /// Sets the initial age of the cached item, to be used in freshness + /// calculations. The initial age is 0 by default. + TransactionInsertBuilder initial_age(std::chrono::nanoseconds age) &&; + + /// Sets the stale-while-revalidate period for the cached item, which is the + /// time for which the item can be safely used despite being considered stale. + /// + /// Having a stale-while-revalidate period provides a signal that the cache + /// should be updated (or its contents otherwise revalidated for freshness) + /// asynchronously, while the stale cached item continues to be used, rather + /// than blocking on updating the cached item. The methods `Found::is_usable` + /// and `Found::is_stale` can be used to determine the current state of a + /// found item. + /// + /// The stale-while-revalidate period is 0 by default. + TransactionInsertBuilder + stale_while_revalidate(std::chrono::nanoseconds duration) &&; + + /// Sets the surrogate keys that can be used for purging this cached item. + /// + /// Surrogate key purges are the only means to purge specific items from the + /// cache. At least one surrogate key must be set in order to remove an item + /// without performing a + /// [purge-all](https://www.fastly.com/documentation/guides/concepts/cache/purging/#purge-all), + /// waiting for the item’s TTL to elapse, or overwriting the item with + /// `crate::cache::core::insert()`. + /// + /// Surrogate keys must contain only printable ASCII characters (those between + /// 0x21 and 0x7E, inclusive). Any invalid keys will be ignored. + /// + /// See the [Fastly surrogate keys + /// guide](https://www.fastly.com/documentation/guides/full-site-delivery/purging/working-with-surrogate-keys/) + /// for details. + TransactionInsertBuilder surrogate_keys(std::vector keys) &&; + + /// Sets the size of the cached item, in bytes, when known prior to actually + /// providing the bytes. + /// + /// It is preferable to provide a length, if possible. Clients that begin + /// streaming the item’s contents before it is completely provided will see + /// the promised length which allows them to, for example, use + /// `content-length` instead of `transfer-encoding: chunked` if the item is + /// used as the body of a `Request` or `Response`. + TransactionInsertBuilder known_length(std::uint64_t length) &&; + + /// Sets the user-defined metadata to associate with the cached item. + TransactionInsertBuilder user_metadata(std::vector metadata) &&; + + /// Enable or disable PCI/HIPAA-compliant non-volatile caching. + /// + /// By default, this is false. + /// + /// See the [Fastly PCI-Compliant Caching and Delivery + /// documentation](https://docs.fastly.com/products/pci-compliant-caching-and-delivery) + /// for details. + TransactionInsertBuilder sensitive_data(bool is_sensitive) &&; + + /// Sets the maximum time the cached item may live on a deliver node in a POP. + TransactionInsertBuilder + deliver_node_max_age(std::chrono::nanoseconds duration) &&; + + /// Perform this insert on behalf of another service, using its data store. + /// + /// *Internal / Privileged* + /// This operation is privileged, and attempts to use this functionality + /// without proper privileges will cause errors. If you are interested in + /// having two or more of your services share the same cache, please talk to + /// your Fastly account representative. While we have no plans to offer this + /// ability widely – this capability is only currently allowed for + /// Fastly-internal services – we may revisit this decision given sufficient + /// customer input. + TransactionInsertBuilder on_behalf_of(std::string service) &&; + + /// Begin the insertion, returning a `StreamingBody` for providing the cached + /// object itself. + /// + /// For the insertion to complete successfully, the object must be written + /// into the `StreamingBody`, and then `StreamingBody::finish` must be called. + /// If the `StreamingBody` is dropped before calling `StreamingBody::finish`, + /// the insertion is considered incomplete, and any concurrent lookups that + /// may be reading from the object as it is streamed into the cache may + /// encounter a streaming error. + tl::expected execute() &&; + + /// Begin the insertion, and provide a `Found` object that can be used to + /// stream out of the newly-inserted object. + /// + /// For the insertion to complete successfully, the object must be written + /// into the `StreamingBody`, and then `StreamingBody::finish` must be called. + /// If the `StreamingBody` is dropped before calling `StreamingBody::finish`, + /// the insertion is considered incomplete, and any concurrent lookups that + /// may be reading from the object as it is streamed into the cache may + /// encounter a streaming error. + /// + /// The returned `Found` object allows the client inserting a cache item to + /// efficiently read back the contents of that item, avoiding the need to + /// buffer contents for copying to multiple destinations. This pattern is + /// commonly required when caching an item that also must be provided to, + /// e.g., the client response. + tl::expected, CacheError> + execute_and_stream_back() &&; + +private: + TransactionInsertBuilder(std::shared_ptr handle, + WriteOptions options) + : handle_(std::move(handle)), options_(std::move(options)) {} + + std::shared_ptr handle_; + WriteOptions options_; + friend class Transaction; +}; + +class Transaction; +class PendingTransaction; + +/// A cache transaction suspended between lookup and response. +/// +/// This handle is produced by `TransactionLookupBuilder::execute_async()`. +/// +/// Callers can check whether or not the request was instructed to wait behind +/// another caller by calling `PendingTransaction::pending()`, and can complete +/// the request (returning a `Transaction`) by calling +/// `PendingTransaction::wait()`. +class PendingTransaction { +public: + /// Returns `true` if this request was instructed to wait for another request + /// to insert the looked-up item, and `false` otherwise. + /// + /// Note that, even if `pending()` returns `true`, the `Transaction` returned + /// by `wait()` may still be required to insert (or update) the object. This + /// can happen if the caller providing the item called + /// `Transaction::cancel_insert_or_update()` or encountered an error. + tl::expected pending() const; + + /// Returns the `Transaction` resulting from the lookup that produced this + /// `PendingTransaction`, waiting for another caller to provide the requested + /// item if necessary. + tl::expected wait() &&; + +private: + explicit PendingTransaction(std::shared_ptr handle) + : handle_(std::move(handle)) {} + + std::shared_ptr handle_; + friend class TransactionLookupBuilder; +}; + +/// A builder-style API for configuring a transactional cache lookup. +class TransactionLookupBuilder { +public: + /// Sets a multi-value header for this lookup, discarding any previous values + /// associated with the header name. + /// + /// Note: These headers are narrowly useful for implementing cache lookups + /// incorporating the semantics of the [HTTP + /// Vary](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.5) header, but + /// the APIs in this module are not suitable for HTTP caching out-of-the-box. + /// Future SDK releases will contain an HTTP Cache API. + /// + /// The headers act as additional factors in object selection, and the choice + /// of which headers to factor in is determined during insertion, via e.g. + /// `crate::cache::core::InsertBuilder::vary_by`. A lookup will succeed when + /// there is at least one cached item that matches lookup’s cache key, and all + /// of the lookup’s headers included in the cache items’ vary_by list match + /// the corresponding headers in that cached item. + /// + /// A typical example is a cached HTTP response, where the request had an + /// Accept-Encoding header. In that case, the origin server may or may not + /// decide on a given encoding, and whether that same response is suitable for + /// a request with a different (or missing) Accept-Encoding header is + /// determined by whether Accept-Encoding is listed in Vary header in the + /// origin’s response. + TransactionLookupBuilder + header_values(std::string_view name, + std::span values) &&; + + /// Sets a single-value header for this lookup, discarding any previous values + /// associated with the header `name`. + + /// Note: These headers are narrowly useful for implementing cache lookups + /// incorporating the semantics of the [HTTP + /// Vary](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.5) + /// header, but the APIs in this module are not suitable for HTTP + /// caching out-of-the-box. Future SDK releases will contain an HTTP + /// Cache API. + /// + /// The headers act as additional factors in object selection, and + /// the choice of which headers to factor in is determined during + /// insertion, via e.g. + /// `crate::cache::core::InsertBuilder::vary_by`. A lookup will + /// succeed when there is at least one cached item that matches + /// lookup’s cache key, and all of the lookup’s headers included in + /// the cache items’ vary_by list match the corresponding headers in + /// that cached item. + /// + /// A typical example is a cached HTTP response, where the request + /// had an Accept-Encoding header. In that case, the origin server + /// may or may not decide on a given encoding, and whether that same + /// response is suitable for a request with a different (or missing) + /// Accept-Encoding header is determined by whether Accept-Encoding + /// is listed in Vary header in the origin’s response. + TransactionLookupBuilder header(std::string_view name, + const http::HeaderValue &value) &&; + + /// Perform this lookup on behalf of another service, using its + /// data store. + /// + /// *Internal / Privileged* + /// This operation is privileged, and attempts to use this + /// functionality without proper privileges will cause errors. + /// If you are interested in having two or more of your services + /// share the same cache, please talk to your Fastly account + /// representative. While we have no plans to offer this ability + /// widely – this capability is only currently allowed for + /// Fastly-internal services – we may revisit this decision + /// given sufficient customer input. + TransactionLookupBuilder on_behalf_of(std::string service) &&; + + /// Respect the range in to_stream_with_range even when the body length is not + /// yet known. + /// + /// When a cache item is Found, the length of the cached item may or may not + /// be known: + /// + /// - the item may be fully cached; + /// - the item may be streaming in, but have a known length; or + /// - the item may be streaming in progressively, without a known length. + /// + /// By default (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. + /// + /// always_use_requested_range indicates any cached item returned by this + /// lookup should provide only the requested range, regardless of whether the + /// length is known. An invalid range will eventually return a read error, + /// possibly after providing some data. + /// + /// NOTE: In the future, + /// the always_use_requested_range behavior will be the default, + /// and this method will be removed. + TransactionLookupBuilder always_use_requested_range() &&; + + /// Perform the lookup, entering a `Transaction`. + /// + /// If the lookup hits an object being provided by another client, this call + /// will block until that client provides the object's metadata or cancels. + tl::expected execute() &&; + + /// Perform the lookup, returning a `PendingTransaction`. + /// + /// If the lookup hits an object being provided by another client, this call + /// will not block. Instead, the returned `PendingTransaction` can be used to + /// wait for the request collapsed lookup to complete. See the documentation + /// of `PendingTransaction` for more details. + tl::expected execute_async() &&; + +private: + explicit TransactionLookupBuilder(CacheKey key) : key_(std::move(key)) {} + CacheKey key_; + LookupOptions options_; + friend class Transaction; +}; + +/// Represents a found cached item. This allows access to metadata and the +/// cached body. +/// +/// A `Found` instance is returned from `Transaction::found()` when the +/// transaction has found a cached item during the lookup operation. +class Found { +public: + /// The current age of the cached item. + std::chrono::nanoseconds age() const; + + /// How long the cached item is/was considered fresh, starting from the + /// start of the cached item's lifetime. + /// + /// Note that this is the maximum possible age, not the freshness time + /// remaining; see `remaining_ttl()` for that. + std::chrono::nanoseconds max_age() const; + + /// Returns the remaining TTL: how long this cached item has left before it + /// is considered stale. Zero if the cached item is stale. + /// + /// Note: this reports the _remaining_ freshness period; `max_age()` reports + /// the maximum possible TTL. + std::chrono::nanoseconds remaining_ttl() const; + + /// The time for which a cached item can safely be used despite being + /// considered stale. + std::chrono::nanoseconds stale_while_revalidate() const; + + /// Determines whether the cached item is stale. + /// + /// A cached item is stale if its age is greater than its max-age period. + bool is_stale() const; + + /// Determines whether the cached item is usable. + /// + /// A cached item is usable if its age is less than the sum of the max-age + /// and stale-while-revalidate periods. + bool is_usable() const; + + /// Determines the number of cache hits to this cached item. + /// + /// **Note**: this hit count only reflects the view of the server that + /// supplied the cached item. Due to clustering, this count may vary between + /// potentially many servers within the data center where the item is cached. + /// See the [clustering + /// documentation](https://www.fastly.com/documentation/guides/full-site-delivery/fastly-vcl/clustering-in-vcl/) + /// for details, though note that the exact caching architecture of Compute is + /// different from VCL services. + uint64_t hits() const; + + /// The size in bytes of the cached item, if known. + /// + /// The length of the cached item may be unknown if the item is currently + /// being streamed into the cache without a fixed length. + std::optional known_length() const; + + /// The user-controlled metadata associated with the cached item. + std::vector user_metadata() const; + + /// Retrieves the entire cached item as a `Body` that can be read in a + /// streaming fashion. + /// + /// 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`. `CacheError::InvalidOperation` will be returned if a + /// stream is already active for this `Found`. This restriction may be lifted + /// in future releases. + tl::expected to_stream() const; + + /// Retrieves a range of bytes from the cached item as a `Body` that can be + /// read in a streaming fashion. + /// + /// `from` and `to` will determine which bytes are returned: + /// - If `from` is provided and `to` is not provided, the stream will begin + /// at the `from` byte and continue to the end of the body. + /// - If `from` and `to` are both provided, only the range `from..=to` will + /// be provided. Note, both ends are inclusive. + /// - If `to` is provided but `from` is not provided, the last `to` bytes of + /// the body will be provided. + /// + /// ## Inherently invalid ranges + /// + /// If `to` is strictly before `from`, this call returns an error immediately. + /// (`to == from` is acceptable - the bounds are inclusive, so this addresses + /// a single byte.) + /// + /// ## Known size + /// + /// The size of a cached item is known if it has completed streaming or if + /// the expected length was provided at insert time + /// (`TransactionInsertBuilder::known_length` and similar). + /// + /// If the size of the cached item is known, the range is respected only if + /// the range is valid (`to` is after `from`, and both ranges are less than + /// or equal to the size of the item). **Otherwise, no error is returned, and + /// the entire body is returned instead.** + /// + /// ## Unknown size + /// + /// The size of a cached item may not be known (if `known_length` was not + /// provided and the body is still being streamed). If the size of the cached + /// item is unknown, **by default, the range is ignored, and the entire + /// cached item is returned.** + /// + /// This behavior can be overriden by calling + /// `TransactionLookupBuilder::always_use_requested_range`. If provided, this + /// call will block until the start of the interval is found, then begin + /// streaming content. The `Body` will generate a read error if the end of + /// the range exceeds the available data. + tl::expected + to_stream_from_range(std::optional from, + std::optional to) const; + +private: + explicit Found(std::shared_ptr handle) + : handle_(std::move(handle)) {} + explicit Found(std::shared_ptr handle) + : handle_(std::move(handle)) {} + + detail::CacheHandle *handle() const { + auto ptr = std::get_if>(&handle_); + return ptr ? ptr->get() : nullptr; + } + detail::CacheReplaceHandle *replace_handle() const { + auto ptr = + std::get_if>(&handle_); + return ptr ? ptr->get() : nullptr; + } + + std::variant, + std::shared_ptr> + handle_; + friend class Transaction; + friend class LookupBuilder; + friend class TransactionInsertBuilder; + friend class ReplaceBuilder; +}; + +/// A cache transaction resulting from a transactional lookup. +/// +/// This type is returned from `TransactionLookupBuilder::execute()` and +/// provides access to the result of the lookup as well as methods to insert +/// or update cached items when required. +class Transaction { +public: + /// Returns a `TransactionLookupBuilder` that will perform a transactional + /// cache lookup. + static TransactionLookupBuilder lookup(CacheKey key); + + /// Returns a `Found` object for this cache item, if one is available. + /// + /// Even if an object is found, the cache item might be stale and require + /// updating. Use `Transaction::must_insert_or_update()` to determine whether + /// this transaction client is expected to update the cached item. + std::optional found() const; + + /// Returns `true` if a usable cached item was not found, and this + /// transaction client is expected to insert one. + /// + /// Use `insert()` to insert the cache item, or `cancel_insert_or_update()` + /// to exit the transaction without providing an item. + bool must_insert() const; + + /// Returns `true` if a fresh cache item was not found, and this transaction + /// client is expected to insert a new item or update a stale item. + /// + /// Use: + /// - `update()` to freshen a found item by updating its metadata + /// - `insert()` to insert a new item (including object data) + /// - `cancel_insert_or_update()` to exit the transaction without providing + /// an item + bool must_insert_or_update() const; + + /// Cancels the obligation for this transaction client to insert or update a + /// cache item. + /// + /// If there are concurrent transactional lookups that were blocked waiting + /// on this client to provide the item, one of them will be chosen to be + /// unblocked and given the `must_insert_or_update()` obligation. + /// + /// This method should only be called when `must_insert_or_update()` is true; + /// otherwise, a `CacheError::InvalidOperation` will be returned. + tl::expected cancel_insert_or_update() const; + + /// Returns a `TransactionUpdateBuilder` that will perform a transactional + /// cache update. + /// + /// Updating an item freshens it by updating its metadata, e.g. its age, + /// without changing the object itself. + /// + /// This method should only be called when `must_insert_or_update()` is true + /// AND the item is found. Otherwise, a `CacheError::InvalidOperation` will + /// be returned when attempting to execute the update. + /// + /// **Important note**: The `TransactionUpdateBuilder` will replace ALL of + /// the configuration in the underlying cache item; if any configuration is + /// not set on the builder, it will revert to the default value. + TransactionUpdateBuilder update(std::chrono::nanoseconds max_age) &&; + + /// Returns a `TransactionInsertBuilder` that will perform a transactional + /// cache insertion. + /// + /// This method should only be called when `must_insert_or_update()` is true; + /// otherwise, a `CacheError::InvalidOperation` will be returned when + /// attempting to execute the insertion. + TransactionInsertBuilder insert(std::chrono::nanoseconds max_age) &&; + +private: + explicit Transaction(std::shared_ptr handle) + : handle_(std::move(handle)) {} + + std::shared_ptr handle_; + friend class TransactionLookupBuilder; + friend class PendingTransaction; +}; + +/// A builder-style API for configuring a non-transactional cache lookup. +/// +/// In contrast to `Transaction::lookup()`, a non-transactional `lookup` will +/// not attempt to coordinate with any concurrent cache lookups. If two +/// instances of the service perform a `lookup` at the same time for the same +/// cache key, and the item is not yet cached, they will both get `Ok(None)` +/// from the eventual lookup execution. +/// +/// To resolve races between concurrent lookups, use `Transaction::lookup()` +/// instead. +class LookupBuilder { +public: + /// Sets a multi-value header for this lookup, discarding any previous values + /// associated with the header name. + /// + /// Note: These headers are narrowly useful for implementing cache lookups + /// incorporating the semantics of the [HTTP + /// Vary](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.5) header, but + /// the APIs in this module are not suitable for HTTP caching out-of-the-box. + /// Future SDK releases will contain an HTTP Cache API. + /// + /// The headers act as additional factors in object selection, and the choice + /// of which headers to factor in is determined during insertion, via e.g. + /// `crate::cache::core::InsertBuilder::vary_by`. A lookup will succeed when + /// there is at least one cached item that matches lookup’s cache key, and all + /// of the lookup’s headers included in the cache items’ vary_by list match + /// the corresponding headers in that cached item. + /// + /// A typical example is a cached HTTP response, where the request had an + /// Accept-Encoding header. In that case, the origin server may or may not + /// decide on a given encoding, and whether that same response is suitable for + /// a request with a different (or missing) Accept-Encoding header is + /// determined by whether Accept-Encoding is listed in Vary header in the + /// origin’s response. + LookupBuilder header_values(std::string_view name, + std::span values) &&; + + /// Sets a single-value header for this lookup, discarding any previous values + /// associated with the header `name`. + + /// Note: These headers are narrowly useful for implementing cache lookups + /// incorporating the semantics of the [HTTP + /// Vary](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.5) + /// header, but the APIs in this module are not suitable for HTTP + /// caching out-of-the-box. Future SDK releases will contain an HTTP + /// Cache API. + /// + /// The headers act as additional factors in object selection, and + /// the choice of which headers to factor in is determined during + /// insertion, via e.g. + /// `crate::cache::core::InsertBuilder::vary_by`. A lookup will + /// succeed when there is at least one cached item that matches + /// lookup’s cache key, and all of the lookup’s headers included in + /// the cache items’ vary_by list match the corresponding headers in + /// that cached item. + /// + /// A typical example is a cached HTTP response, where the request + /// had an Accept-Encoding header. In that case, the origin server + /// may or may not decide on a given encoding, and whether that same + /// response is suitable for a request with a different (or missing) + /// Accept-Encoding header is determined by whether Accept-Encoding + /// is listed in Vary header in the origin’s response. + LookupBuilder header(std::string_view name, + const http::HeaderValue &value) &&; + + /// Perform this lookup on behalf of another service, using its data store. + /// + /// *Internal / Privileged* + /// This operation is privileged, and attempts to use this functionality + /// without proper privileges will cause errors. If you are interested in + /// having two or more of your services share the same cache, please talk to + /// your Fastly account representative. While we have no plans to offer this + /// ability widely – this capability is only currently allowed for + /// Fastly-internal services – we may revisit this decision given sufficient + /// customer input. + LookupBuilder on_behalf_of(std::string service) &&; + + /// Respect the range in to_stream_with_range even when the body length is not + /// yet known. + /// + /// When a cache item is Found, the length of the cached item may or may not + /// be known: + /// + /// - the item may be fully cached; + /// - the item may be streaming in, but have a known length; or + /// - the item may be streaming in progressively, without a known length. + /// + /// By default (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. + /// + /// always_use_requested_range indicates any cached item returned by this + /// lookup should provide only the requested range, regardless of whether the + /// length is known. An invalid range will eventually return a read error, + /// possibly after providing some data. + /// + /// NOTE: In the future, + /// the always_use_requested_range behavior will be the default, + /// and this method will be removed. + LookupBuilder always_use_requested_range() &&; + + /// Perform the lookup, returning a `Found` object if a usable cached item + /// was found. + /// + /// A cached item is _usable_ if its age is less than the sum of its max_age + /// and its stale-while-revalidate period. Items beyond that age are unusably + /// stale. + tl::expected, CacheError> execute() &&; + +private: + explicit LookupBuilder(CacheKey key) : key_(std::move(key)) {} + CacheKey key_; + LookupOptions options_; + friend LookupBuilder lookup(CacheKey key); +}; + +/// Returns a `LookupBuilder` that will perform a non-transactional cache +/// lookup. +/// +/// In contrast to `Transaction::lookup()`, a non-transactional lookup will not +/// attempt to coordinate with any concurrent cache lookups. If two instances of +/// the service perform a lookup at the same time for the same cache key, and +/// the item is not yet cached, they will both get nothing from the eventual +/// lookup execution. Without further coordination, they may both end up +/// performing the work needed to `insert()` the item (which usually involves +/// origin requests and/or computation) and racing with each other to insert. +/// +/// To resolve such races between concurrent lookups, use +/// `Transaction::lookup()` instead. +LookupBuilder lookup(CacheKey key); + +/// A builder-style API for configuring a non-transactional cache insertion. +/// +/// Like `lookup()`, `insert()` may race with concurrent lookups or insertions, +/// and will unconditionally overwrite existing cached items rather than +/// allowing for revalidation of an existing object. +/// +/// The transactional equivalent is `Transaction::insert()`, which may only be +/// called following a transactional lookup when +/// `Transaction::must_insert_or_update()` returns `true`. +class InsertBuilder { +public: + /// Sets a multi-value header for this lookup, discarding any previous values + /// associated with the header name. + /// + /// Note: These headers are narrowly useful for implementing cache lookups + /// incorporating the semantics of the [HTTP + /// Vary](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.5) header, but + /// the APIs in this module are not suitable for HTTP caching out-of-the-box. + /// Future SDK releases will contain an HTTP Cache API. + /// + /// The headers act as additional factors in object selection, and the choice + /// of which headers to factor in is determined during insertion, via e.g. + /// `crate::cache::core::InsertBuilder::vary_by`. A lookup will succeed when + /// there is at least one cached item that matches lookup’s cache key, and all + /// of the lookup’s headers included in the cache items’ vary_by list match + /// the corresponding headers in that cached item. + /// + /// A typical example is a cached HTTP response, where the request had an + /// Accept-Encoding header. In that case, the origin server may or may not + /// decide on a given encoding, and whether that same response is suitable for + /// a request with a different (or missing) Accept-Encoding header is + /// determined by whether Accept-Encoding is listed in Vary header in the + /// origin’s response. + InsertBuilder header_values(std::string_view name, + std::span values) &&; + + /// Sets a single-value header for this lookup, discarding any previous values + /// associated with the header `name`. + + /// Note: These headers are narrowly useful for implementing cache lookups + /// incorporating the semantics of the [HTTP + /// Vary](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.5) + /// header, but the APIs in this module are not suitable for HTTP + /// caching out-of-the-box. Future SDK releases will contain an HTTP + /// Cache API. + /// + /// The headers act as additional factors in object selection, and + /// the choice of which headers to factor in is determined during + /// insertion, via e.g. + /// `crate::cache::core::InsertBuilder::vary_by`. A lookup will + /// succeed when there is at least one cached item that matches + /// lookup’s cache key, and all of the lookup’s headers included in + /// the cache items’ vary_by list match the corresponding headers in + /// that cached item. + /// + /// A typical example is a cached HTTP response, where the request + /// had an Accept-Encoding header. In that case, the origin server + /// may or may not decide on a given encoding, and whether that same + /// response is suitable for a request with a different (or missing) + /// Accept-Encoding header is determined by whether Accept-Encoding + /// is listed in Vary header in the origin’s response. + InsertBuilder header(std::string_view name, + const http::HeaderValue &value) &&; + + /// Sets the list of headers that must match when looking up this cached item. + InsertBuilder vary_by(std::vector headers) &&; + + /// Sets the initial age of the cached item, to be used in freshness + /// calculations. + /// + /// The initial age is zero by default. + InsertBuilder initial_age(std::chrono::nanoseconds age) &&; + + /// Sets the time for which a cached item can safely be used despite being + /// considered stale. + InsertBuilder stale_while_revalidate(std::chrono::nanoseconds duration) &&; + + /// Sets the surrogate keys that can be used for purging this cached item. + /// + /// Surrogate key purges are the only means to purge specific items from the + /// cache. At least one surrogate key must be set in order to remove an item + /// without performing a + /// [purge-all](https://www.fastly.com/documentation/guides/concepts/cache/purging/#purge-all), + /// waiting for the item’s TTL to elapse, or overwriting the item with + /// `crate::cache::core::insert()`. + /// + /// Surrogate keys must contain only printable ASCII characters (those between + /// 0x21 and 0x7E, inclusive). Any invalid keys will be ignored. + /// + /// See the [Fastly surrogate keys + /// guide](https://www.fastly.com/documentation/guides/full-site-delivery/purging/working-with-surrogate-keys/) + /// for details. + InsertBuilder surrogate_keys(std::vector keys) &&; + + /// Sets the known length of the cached item. + InsertBuilder known_length(std::uint64_t length) &&; + + /// Sets the user-defined metadata to associate with the cached item. + InsertBuilder user_metadata(std::vector metadata) &&; + + /// Enable or disable PCI/HIPAA-compliant non-volatile caching. + /// + /// By default, this is false. + /// + /// See the [Fastly PCI-Compliant Caching and Delivery + /// documentation](https://docs.fastly.com/products/pci-compliant-caching-and-delivery) + /// for details. + InsertBuilder sensitive_data(bool is_sensitive) &&; + + /// Sets the maximum time the cached item may live on a deliver node in a POP. + InsertBuilder deliver_node_max_age(std::chrono::nanoseconds duration) &&; + + /// Perform this insertion on behalf of another service, using its data store. + /// + /// *Internal / Privileged* + /// This operation is privileged, and attempts to use this functionality + /// without proper privileges will cause errors. If you are interested in + /// having two or more of your services share the same cache, please talk to + /// your Fastly account representative. While we have no plans to offer this + /// ability widely – this capability is only currently allowed for + /// Fastly-internal services – we may revisit this decision given sufficient + /// customer input. + InsertBuilder on_behalf_of(std::string service) &&; + + /// Begin the insertion, returning a `StreamingBody` for providing the cached + /// object itself. + tl::expected execute() &&; + +private: + explicit InsertBuilder(CacheKey key, WriteOptions options) + : key_(std::move(key)), options_(std::move(options)) {} + CacheKey key_; + WriteOptions options_; + friend InsertBuilder insert(CacheKey key, std::chrono::nanoseconds max_age); +}; + +/// Returns an `InsertBuilder` that will perform a non-transactional cache +/// insertion. +/// +/// The required `max_age` argument is the maximal "time to live" for the cache +/// item: the time for which the item will be considered fresh, starting from +/// the start of its history. +InsertBuilder insert(CacheKey key, std::chrono::nanoseconds max_age); + +class Replace; + +/// A builder-style API for configuring a non-transactional cache replacement. +class ReplaceBuilder { +public: + /// Begin the replace, returning a `Replace` for reading the object to be + /// replaced (if it exists) which is also used to provide the new replacement + /// object. + /// + /// A `Replace` gives access to the existing object, if one is stored, that + /// may be used to construct the replacement object. Use `Replace::execute()` + /// to get a `StreamingBody` to write the replacement object into. The + /// existing object cannot be accessed after calling `Replace::execute()`. + /// + /// For the replace to complete successfully, the object must be written into + /// the `StreamingBody`, and then `StreamingBody::finish` must be called. If + /// the `StreamingBody` is dropped before calling `StreamingBody::finish`, the + /// replacement is considered incomplete, and any concurrent lookups that may + /// be reading from the object as it is streamed into the cache may encounter + /// a streaming error. + tl::expected begin() &&; + + // TODO: header and header_values + + /// Sets the strategy for performing the replace. + ReplaceBuilder replace_strategy(ReplaceStrategy strategy) &&; + + /// Respect the range in to_stream_with_range even when the body length is not + /// yet known. + /// + /// When a cache item is Found, the length of the cached item may or may not + /// be known: + /// + /// - the item may be fully cached; + /// - the item may be streaming in, but have a known length; or + /// - the item may be streaming in progressively, without a known length. + /// + /// By default (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. + /// + /// always_use_requested_range indicates any cached item returned by this + /// lookup should provide only the requested range, regardless of whether the + /// length is known. An invalid range will eventually return a read error, + /// possibly after providing some data. + /// + /// NOTE: In the future, + /// the always_use_requested_range behavior will be the default, + /// and this method will be removed. + ReplaceBuilder always_use_requested_range() &&; + +private: + explicit ReplaceBuilder(CacheKey key) : key_(std::move(key)) {} + CacheKey key_; + ReplaceOptions options_; + friend ReplaceBuilder replace(CacheKey key); +}; + +/// An in-progress Replace operation. +/// +/// This type is returned from `ReplaceBuilder::begin()`. +class Replace { +public: + /// Finish using the existing object and start writing a replacement object to + /// the `StreamingBody`. + /// + /// The required `max_age` argument is the "time to live" for the replacement + /// cache item: the time for which the item will be considered fresh, starting + /// from the start of its history (now, unless `initial_age` was provided). + tl::expected + execute(std::chrono::nanoseconds max_age) &&; + + /// The existing object, if one exists. The existing object may be stale. + const std::optional &existing_object() const { + return existing_object_; + } + + /// Sets a multi-value header for this lookup, discarding any previous values + /// associated with the header name. + /// + /// Note: These headers are narrowly useful for implementing cache lookups + /// incorporating the semantics of the [HTTP + /// Vary](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.5) header, but + /// the APIs in this module are not suitable for HTTP caching out-of-the-box. + /// Future SDK releases will contain an HTTP Cache API. + /// + /// The headers act as additional factors in object selection, and the choice + /// of which headers to factor in is determined during insertion, via e.g. + /// `crate::cache::core::InsertBuilder::vary_by`. A lookup will succeed when + /// there is at least one cached item that matches lookup’s cache key, and all + /// of the lookup’s headers included in the cache items’ vary_by list match + /// the corresponding headers in that cached item. + /// + /// A typical example is a cached HTTP response, where the request had an + /// Accept-Encoding header. In that case, the origin server may or may not + /// decide on a given encoding, and whether that same response is suitable for + /// a request with a different (or missing) Accept-Encoding header is + /// determined by whether Accept-Encoding is listed in Vary header in the + /// origin’s response. + Replace header_values(std::string_view name, + std::span values) &&; + + /// Sets a single-value header for this lookup, discarding any previous values + /// associated with the header `name`. + + /// Note: These headers are narrowly useful for implementing cache lookups + /// incorporating the semantics of the [HTTP + /// Vary](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.5) + /// header, but the APIs in this module are not suitable for HTTP + /// caching out-of-the-box. Future SDK releases will contain an HTTP + /// Cache API. + /// + /// The headers act as additional factors in object selection, and the choice + /// of which headers to factor in is determined during insertion, via e.g. + /// `crate::cache::core::InsertBuilder::vary_by`. A lookup will + /// succeed when there is at least one cached item that matches + /// lookup’s cache key, and all of the lookup’s headers included in + /// the cache items’ vary_by list match the corresponding headers in + /// that cached item. + /// + /// A typical example is a cached HTTP response, where the request + /// had an Accept-Encoding header. In that case, the origin server + /// may or may not decide on a given encoding, and whether that same + /// response is suitable for a request with a different (or missing) + /// Accept-Encoding header is determined by whether Accept-Encoding + /// is listed in Vary header in the origin’s response. + Replace header(std::string_view name, const http::HeaderValue &value) &&; + + /// Sets the list of headers that must match when looking up this cached item. + Replace vary_by(std::vector headers) &&; + + /// Sets the initial age of the cached item, to be used in freshness + /// calculations. The initial age is zero by default. + Replace initial_age(std::chrono::nanoseconds age) &&; + + /// Sets the time for which a cached item can safely be used despite being + /// considered stale. + Replace stale_while_revalidate(std::chrono::nanoseconds duration) &&; + + /// Sets the surrogate keys that can be used for purging this cached item. + /// + /// Surrogate key purges are the only means to purge specific items from the + /// cache. At least one surrogate key must be set in order to remove an item + /// without performing a + /// [purge-all](https://www.fastly.com/documentation/guides/concepts/cache/purging/#purge-all), + /// waiting for the item’s TTL to elapse, or overwriting the item with + /// `crate::cache::core::insert()`. + /// + /// Surrogate keys must contain only printable ASCII characters (those between + /// 0x21 and 0x7E, inclusive). Any invalid keys will be ignored. + /// + /// See the [Fastly surrogate keys + /// guide](https://www.fastly.com/documentation/guides/full-site-delivery/purging/working-with-surrogate-keys/) + /// for details. + Replace surrogate_keys(std::vector keys) &&; + + /// Sets the known length of the cached item. + Replace known_length(std::uint64_t length) &&; + + /// Sets the user-defined metadata to associate with the cached item. + Replace user_metadata(std::vector metadata) &&; + + /// Enable or disable PCI/HIPAA-compliant non-volatile caching. + /// + /// By default, this is false. + /// + /// See the [Fastly PCI-Compliant Caching and Delivery + /// documentation](https://docs.fastly.com/products/pci-compliant-caching-and-delivery) + /// for details. + Replace sensitive_data(bool is_sensitive) &&; + + /// Sets the maximum time the cached item may live on a deliver node in a POP. + Replace deliver_node_max_age(std::chrono::nanoseconds duration) &&; + +private: + Replace(std::shared_ptr handle, + std::optional existing_object) + : handle_(std::move(handle)), + existing_object_(std::move(existing_object)) {} + + std::shared_ptr handle_; + std::optional existing_object_; + WriteOptions options_{std::chrono::nanoseconds(0)}; + friend class ReplaceBuilder; +}; + +/// Returns a `ReplaceBuilder` that will perform a non-transactional cache +/// replacement. +ReplaceBuilder replace(CacheKey key); + +} // namespace fastly::cache::core + +#endif // FASTLY_CACHE_H diff --git a/include/fastly/http/body.h b/include/fastly/http/body.h index 2b4daef..14bd129 100644 --- a/include/fastly/http/body.h +++ b/include/fastly/http/body.h @@ -21,6 +21,13 @@ class LookupResponse; class KVStore; } // namespace fastly::kv_store +namespace fastly::cache::core { +class TransactionInsertBuilder; +class InsertBuilder; +class Replace; +class Found; +} // namespace fastly::cache::core + namespace fastly::http { class Response; @@ -130,9 +137,13 @@ class Body : public std::iostream, public std::streambuf { // pref(std::move(prefix)) {}; // }; private: + friend cache::core::Found; rust::Box bod; std::array pbuf; std::array gbuf; + static Body from_handle(uint32_t body_handle) { + return Body(fastly::sys::http::m_static_http_body_from_handle(body_handle)); + } Body(rust::Box body) : std::iostream(this), bod(std::move(body)) { this->setg(this->gbuf.data(), this->gbuf.data(), this->gbuf.data()); @@ -157,6 +168,9 @@ class Body : public std::iostream, public std::streambuf { class StreamingBody : public std::ostream, public std::streambuf { friend Response; friend Request; + friend cache::core::TransactionInsertBuilder; + friend cache::core::InsertBuilder; + friend cache::core::Replace; friend std::pair, std::vector> request::select(std::vector &reqs); @@ -184,6 +198,11 @@ class StreamingBody : public std::ostream, public std::streambuf { : std::ostream(this), bod(std::move(body)) { this->setp(this->pbuf.data(), this->pbuf.data() + this->pbuf.max_size()); }; + static StreamingBody from_body_handle(uint32_t body_handle) { + return StreamingBody( + fastly::sys::http::m_static_http_streaming_body_from_body_handle( + body_handle)); + } rust::Box bod; std::array pbuf; }; diff --git a/src/cpp/cache/core.cpp b/src/cpp/cache/core.cpp new file mode 100644 index 0000000..a2fb826 --- /dev/null +++ b/src/cpp/cache/core.cpp @@ -0,0 +1,911 @@ +#include "../fastly.h" +#include +#include +#include +#include + +namespace { +fastly::cache::core::CacheError from_status(const fastly::Status &status) { + using Code = fastly::Status::Code; + switch (status.code()) { + case Code::Unsupported: + return fastly::cache::core::CacheError( + fastly::cache::core::CacheError::Code::Unsupported); + case Code::LimitExceeded: + return fastly::cache::core::CacheError( + fastly::cache::core::CacheError::Code::LimitExceeded); + case Code::BadHandle: + return fastly::cache::core::CacheError( + fastly::cache::core::CacheError::Code::InvalidOperation); + default: + return fastly::cache::core::CacheError( + fastly::cache::core::CacheError::Code::Unknown); + } +} +} // namespace + +#define CACHE_TRY(expr) \ + do { \ + auto status = (expr); \ + if (!status.is_ok()) { \ + return tl::unexpected(from_status(status)); \ + } \ + } while (0) + +namespace { +std::string join_strings(const std::vector &items, + char delimiter) { + std::ostringstream oss; + bool first = true; + for (const auto &item : items) { + if (!first) { + oss << delimiter; + } + oss << item; + first = false; + } + return oss.str(); +} + +// Helper to set request headers on cache options +void set_request_header_values( + std::optional &request_headers, + std::string_view name, std::span values) { + if (!request_headers.has_value()) { + uint32_t req_handle = 0; + auto status = fastly::http_req_new(&req_handle); + if (!status.is_ok()) { + std::cerr << "http_req_new failed\n"; + abort(); + } + request_headers = fastly::cache::core::detail::RequestHandle(req_handle); + } + + std::vector bytes; + for (auto &&value : values) { + auto str = value.bytes(); + bytes.insert(bytes.end(), str.begin(), str.end()); + bytes.push_back('\0'); + } + auto req_handle = request_headers->handle(); + fastly::http_req_header_values_set( + req_handle, reinterpret_cast(name.data()), name.size(), + bytes.data(), bytes.size()); +} + +// Convert from public options struct to the ABI struct, returning both the ABI +// struct and a bitmask of which options were set. +std::pair +as_abi(fastly::cache::core::LookupOptions &options) { + fastly::CacheLookupOptions host_opt{}; + std::uint32_t options_mask = 0; + + if (options.request_headers.has_value()) { + host_opt.request_headers = options.request_headers->handle(); + options_mask |= FASTLY_CACHE_LOOKUP_OPTIONS_MASK_REQUEST_HEADERS; + } + + if (options.service.has_value()) { + host_opt.service = options.service->c_str(); + host_opt.service_len = options.service->size(); + options_mask |= FASTLY_CACHE_LOOKUP_OPTIONS_MASK_SERVICE; + } + + if (options.always_use_requested_range) { + options_mask |= FASTLY_CACHE_LOOKUP_OPTIONS_MASK_ALWAYS_USE_REQUESTED_RANGE; + } + + return {host_opt, options_mask}; +} + +// Similar to above, but for write options. +std::pair +as_abi(fastly::cache::core::WriteOptions &options) { + fastly::CacheWriteOptions host_opt{}; + std::uint32_t options_mask = 0; + + host_opt.max_age_ns = options.max_age.count(); + + if (options.request_headers.has_value()) { + host_opt.request_headers = options.request_headers->handle(); + options_mask |= FASTLY_CACHE_WRITE_OPTIONS_MASK_REQUEST_HEADERS; + } + + if (options.vary_rule.has_value()) { + host_opt.vary_rule_ptr = + reinterpret_cast(options.vary_rule->c_str()); + host_opt.vary_rule_len = options.vary_rule->size(); + options_mask |= FASTLY_CACHE_WRITE_OPTIONS_MASK_VARY_RULE; + } + + if (options.initial_age.has_value()) { + host_opt.initial_age_ns = options.initial_age->count(); + options_mask |= FASTLY_CACHE_WRITE_OPTIONS_MASK_INITIAL_AGE_NS; + } + + if (options.stale_while_revalidate.has_value()) { + host_opt.stale_while_revalidate_ns = + options.stale_while_revalidate->count(); + options_mask |= FASTLY_CACHE_WRITE_OPTIONS_MASK_STALE_WHILE_REVALIDATE_NS; + } + + if (options.surrogate_keys.has_value()) { + host_opt.surrogate_keys_ptr = + reinterpret_cast(options.surrogate_keys->c_str()); + host_opt.surrogate_keys_len = options.surrogate_keys->size(); + options_mask |= FASTLY_CACHE_WRITE_OPTIONS_MASK_SURROGATE_KEYS; + } + + if (options.length.has_value()) { + host_opt.length = options.length.value(); + options_mask |= FASTLY_CACHE_WRITE_OPTIONS_MASK_LENGTH; + } + + if (options.user_metadata.has_value()) { + host_opt.user_metadata_ptr = options.user_metadata->data(); + host_opt.user_metadata_len = options.user_metadata->size(); + options_mask |= FASTLY_CACHE_WRITE_OPTIONS_MASK_USER_METADATA; + } + + if (options.sensitive_data) { + options_mask |= FASTLY_CACHE_WRITE_OPTIONS_MASK_SENSITIVE_DATA; + } + + if (options.edge_max_age.has_value()) { + host_opt.edge_max_age_ns = options.edge_max_age->count(); + options_mask |= FASTLY_CACHE_WRITE_OPTIONS_MASK_EDGE_MAX_AGE_NS; + } + + if (options.service.has_value()) { + host_opt.service = options.service->c_str(); + host_opt.service_len = options.service->size(); + options_mask |= FASTLY_CACHE_WRITE_OPTIONS_MASK_SERVICE; + } + + return {host_opt, options_mask}; +} + +// Similar to above, but for replace options. +std::pair +as_abi(fastly::cache::core::ReplaceOptions &options) { + fastly::CacheReplaceOptions host_opt{}; + std::uint32_t options_mask = 0; + + if (options.request_headers.has_value()) { + host_opt.request_headers = options.request_headers->handle(); + options_mask |= FASTLY_CACHE_REPLACE_OPTIONS_MASK_REQUEST_HEADERS; + } + + host_opt.replace_strategy = + static_cast(options.replace_strategy); + options_mask |= FASTLY_CACHE_REPLACE_OPTIONS_MASK_REPLACE_STRATEGY; + + if (options.service.has_value()) { + host_opt.service = options.service->c_str(); + host_opt.service_len = options.service->size(); + options_mask |= FASTLY_CACHE_REPLACE_OPTIONS_MASK_SERVICE; + } + + if (options.always_use_requested_range) { + options_mask |= + FASTLY_CACHE_REPLACE_OPTIONS_MASK_ALWAYS_USE_REQUESTED_RANGE; + } + + return {host_opt, options_mask}; +} +} // namespace + +namespace fastly::cache::core { + +namespace detail { +// Really these should live somewhere else, maybe in fastly.h, but this is good +// enough for now, and it'll all be ripped out during componentisation anyway. +CacheHandle::~CacheHandle() { fastly::cache_close(handle_); } +CacheReplaceHandle::~CacheReplaceHandle() { fastly::cache_close(handle_); } +CacheBusyHandle::~CacheBusyHandle() { fastly::cache_close(handle_); } +RequestHandle::~RequestHandle() { fastly::http_req_close(handle_); } +RequestHandle RequestHandle::make() { + uint32_t req_handle = 0; + auto status = fastly::http_req_new(&req_handle); + if (!status.is_ok()) { + std::cerr << "http_req_new failed\n"; + abort(); + } + return RequestHandle(req_handle); +} +} // namespace detail + +bool Transaction::must_insert() const { + std::uint8_t state = 0; + auto status = fastly::cache_get_state(handle_->handle(), &state); + if (!status.is_ok()) { + std::cerr << "cache_get_state failed\n"; + abort(); + } + return !(state & FASTLY_HOST_CACHE_LOOKUP_STATE_FOUND) && + (state & FASTLY_HOST_CACHE_LOOKUP_STATE_MUST_INSERT_OR_UPDATE); +} + +bool Transaction::must_insert_or_update() const { + std::uint8_t state = 0; + auto status = fastly::cache_get_state(handle_->handle(), &state); + if (!status.is_ok()) { + std::cerr << "cache_get_state failed\n"; + abort(); + } + return (state & FASTLY_HOST_CACHE_LOOKUP_STATE_MUST_INSERT_OR_UPDATE) != 0; +} + +tl::expected Transaction::cancel_insert_or_update() const { + CACHE_TRY(fastly::cache_transaction_cancel(handle_->handle())); + return {}; +} + +TransactionUpdateBuilder +Transaction::update(std::chrono::nanoseconds max_age) && { + WriteOptions options(max_age); + return TransactionUpdateBuilder( + handle_, // intentional copy of the shared handle + std::move(options)); +} + +TransactionInsertBuilder +Transaction::insert(std::chrono::nanoseconds max_age) && { + WriteOptions options(max_age); + return TransactionInsertBuilder( + handle_, // intentional copy of the shared handle + std::move(options)); +} + +TransactionLookupBuilder Transaction::lookup(CacheKey key) { + return TransactionLookupBuilder(std::move(key)); +} + +std::optional Transaction::found() const { + std::uint8_t state = 0; + auto status = fastly::cache_get_state(handle_->handle(), &state); + if (!status.is_ok()) { + std::cerr << "cache_get_state failed\n"; + abort(); + } + if (state & FASTLY_HOST_CACHE_LOOKUP_STATE_FOUND) { + return Found(handle_); // intentional copy of the shared handle + } + return std::nullopt; +} + +tl::expected PendingTransaction::pending() const { + std::uint32_t is_ready = 0; + CACHE_TRY(fastly::async_is_ready(handle_->handle(), &is_ready)); + // pending() returns true if NOT ready + return !static_cast(is_ready); +} + +tl::expected PendingTransaction::wait() && { + // Wait for the busy handle to resolve into a cache handle + std::uint32_t cache_handle = 0; + CACHE_TRY(fastly::cache_busy_handle_wait(handle_->handle(), &cache_handle)); + + // Wait for the cache handle async item to be complete + CACHE_TRY(fastly::cache_wait(cache_handle)); + + return Transaction(std::make_shared(cache_handle)); +} + +TransactionLookupBuilder TransactionLookupBuilder::header_values( + std::string_view name, std::span values) && { + set_request_header_values(options_.request_headers, name, values); + return std::move(*this); +} + +TransactionLookupBuilder +TransactionLookupBuilder::header(std::string_view name, + const http::HeaderValue &value) && { + return std::move(*this).header_values(name, {&value, 1}); +} + +TransactionLookupBuilder +TransactionLookupBuilder::on_behalf_of(std::string service) && { + options_.service = std::move(service); + return std::move(*this); +} + +TransactionLookupBuilder +TransactionLookupBuilder::always_use_requested_range() && { + options_.always_use_requested_range = true; + return std::move(*this); +} + +tl::expected TransactionLookupBuilder::execute() && { + auto [options, options_mask] = as_abi(options_); + std::uint32_t cache_handle = 0; + CACHE_TRY(fastly::cache_transaction_lookup( + key_.data(), key_.size(), options_mask, &options, &cache_handle)); + + // Wait for the lookup to complete (synchronous behavior) + CACHE_TRY(fastly::cache_wait(cache_handle)); + + return Transaction(std::make_shared(cache_handle)); +} + +tl::expected +TransactionLookupBuilder::execute_async() && { + auto [options, options_mask] = as_abi(options_); + std::uint32_t busy_handle = 0; + CACHE_TRY(fastly::cache_transaction_lookup_async( + key_.data(), key_.size(), options_mask, &options, &busy_handle)); + + return PendingTransaction( + std::make_shared(busy_handle)); +} + +TransactionUpdateBuilder +TransactionUpdateBuilder::vary_by(std::vector headers) && { + if (!headers.empty()) { + options_.vary_rule = join_strings(headers, ' '); + } + return std::move(*this); +} + +TransactionUpdateBuilder +TransactionUpdateBuilder::age(std::chrono::nanoseconds age) && { + options_.initial_age = age; + return std::move(*this); +} + +TransactionUpdateBuilder TransactionUpdateBuilder::deliver_node_max_age( + std::chrono::nanoseconds duration) && { + options_.edge_max_age = duration; + return std::move(*this); +} + +TransactionUpdateBuilder TransactionUpdateBuilder::stale_while_revalidate( + std::chrono::nanoseconds duration) && { + options_.stale_while_revalidate = duration; + return std::move(*this); +} + +TransactionUpdateBuilder +TransactionUpdateBuilder::surrogate_keys(std::vector keys) && { + if (!keys.empty()) { + options_.surrogate_keys = join_strings(keys, ' '); + } + return std::move(*this); +} + +TransactionUpdateBuilder +TransactionUpdateBuilder::user_metadata(std::vector metadata) && { + options_.user_metadata = std::move(metadata); + return std::move(*this); +} + +TransactionUpdateBuilder +TransactionUpdateBuilder::on_behalf_of(std::string service) && { + options_.service = std::move(service); + return std::move(*this); +} + +tl::expected TransactionUpdateBuilder::execute() && { + std::uint32_t cache_handle = handle_->handle(); + auto [options, options_mask] = as_abi(options_); + CACHE_TRY( + fastly::cache_transaction_update(cache_handle, options_mask, &options)); + return {}; +} + +TransactionInsertBuilder +TransactionInsertBuilder::vary_by(std::vector headers) && { + if (!headers.empty()) { + options_.vary_rule = join_strings(headers, ' '); + } + return std::move(*this); +} + +TransactionInsertBuilder +TransactionInsertBuilder::initial_age(std::chrono::nanoseconds age) && { + options_.initial_age = age; + return std::move(*this); +} + +TransactionInsertBuilder TransactionInsertBuilder::stale_while_revalidate( + std::chrono::nanoseconds duration) && { + options_.stale_while_revalidate = duration; + return std::move(*this); +} + +TransactionInsertBuilder +TransactionInsertBuilder::surrogate_keys(std::vector keys) && { + if (!keys.empty()) { + options_.surrogate_keys = join_strings(keys, ' '); + } + return std::move(*this); +} + +TransactionInsertBuilder +TransactionInsertBuilder::known_length(std::uint64_t length) && { + options_.length = length; + return std::move(*this); +} + +TransactionInsertBuilder +TransactionInsertBuilder::user_metadata(std::vector metadata) && { + options_.user_metadata = std::move(metadata); + return std::move(*this); +} + +TransactionInsertBuilder +TransactionInsertBuilder::sensitive_data(bool is_sensitive) && { + options_.sensitive_data = is_sensitive; + return std::move(*this); +} + +TransactionInsertBuilder TransactionInsertBuilder::deliver_node_max_age( + std::chrono::nanoseconds duration) && { + options_.edge_max_age = duration; + return std::move(*this); +} + +TransactionInsertBuilder +TransactionInsertBuilder::on_behalf_of(std::string service) && { + options_.service = std::move(service); + return std::move(*this); +} + +tl::expected +TransactionInsertBuilder::execute() && { + std::uint32_t cache_handle = handle_->handle(); + auto [options, options_mask] = as_abi(options_); + std::uint32_t body_handle = 0; + CACHE_TRY(fastly::cache_transaction_insert(cache_handle, options_mask, + &options, &body_handle)); + return http::StreamingBody::from_body_handle(body_handle); +} + +tl::expected, CacheError> +TransactionInsertBuilder::execute_and_stream_back() && { + std::uint32_t cache_handle_val = handle_->handle(); + auto [options, options_mask] = as_abi(options_); + std::uint32_t body_handle = 0; + std::uint32_t cache_handle_out = 0; + CACHE_TRY(fastly::cache_transaction_insert_and_stream_back( + cache_handle_val, options_mask, &options, &body_handle, + &cache_handle_out)); + + return std::make_pair( + http::StreamingBody::from_body_handle(body_handle), + Found(std::make_shared(cache_handle_out))); +} + +std::chrono::nanoseconds Found::age() const { + std::uint64_t age_ns = 0; + fastly::Status status; + if (handle()) { + status = fastly::cache_get_age_ns(handle()->handle(), &age_ns); + } else { + status = + fastly::cache_replace_get_age_ns(replace_handle()->handle(), &age_ns); + } + + if (!status.is_ok()) { + std::cerr << "cache_get_age_ns failed\n"; + abort(); + } + return std::chrono::nanoseconds(age_ns); +} + +std::chrono::nanoseconds Found::max_age() const { + std::uint64_t max_age_ns = 0; + fastly::Status status; + if (handle()) { + status = fastly::cache_get_max_age_ns(handle()->handle(), &max_age_ns); + } else { + status = fastly::cache_replace_get_max_age_ns(replace_handle()->handle(), + &max_age_ns); + } + + if (!status.is_ok()) { + std::cerr << "cache_get_max_age_ns failed\n"; + abort(); + } + return std::chrono::nanoseconds(max_age_ns); +} + +std::chrono::nanoseconds Found::remaining_ttl() const { + auto max = max_age(); + auto current = age(); + if (current >= max) { + return std::chrono::nanoseconds(0); + } + return max - current; +} + +std::chrono::nanoseconds Found::stale_while_revalidate() const { + std::uint64_t swr_ns = 0; + fastly::Status status; + if (handle()) { + status = fastly::cache_get_stale_while_revalidate_ns(handle()->handle(), + &swr_ns); + } else { + status = fastly::cache_replace_get_stale_while_revalidate_ns( + replace_handle()->handle(), &swr_ns); + } + + if (!status.is_ok()) { + std::cerr << "cache_get_stale_while_revalidate_ns failed\n"; + abort(); + } + return std::chrono::nanoseconds(swr_ns); +} + +bool Found::is_stale() const { + std::uint8_t state = 0; + fastly::Status status; + if (handle()) { + status = fastly::cache_get_state(handle()->handle(), &state); + } else { + status = + fastly::cache_replace_get_state(replace_handle()->handle(), &state); + } + + if (!status.is_ok()) { + std::cerr << "cache_get_state failed\n"; + abort(); + } + return (state & FASTLY_HOST_CACHE_LOOKUP_STATE_STALE) != 0; +} + +bool Found::is_usable() const { + std::uint8_t state = 0; + fastly::Status status; + if (handle()) { + status = fastly::cache_get_state(handle()->handle(), &state); + } else { + status = + fastly::cache_replace_get_state(replace_handle()->handle(), &state); + } + + if (!status.is_ok()) { + std::cerr << "cache_get_state failed\n"; + abort(); + } + return (state & FASTLY_HOST_CACHE_LOOKUP_STATE_USABLE) != 0; +} + +uint64_t Found::hits() const { + std::uint64_t hits = 0; + fastly::Status status; + if (handle()) { + status = fastly::cache_get_hits(handle()->handle(), &hits); + } else { + status = fastly::cache_replace_get_hits(replace_handle()->handle(), &hits); + } + + if (!status.is_ok()) { + std::cerr << "cache_get_hits failed\n"; + abort(); + } + return hits; +} + +std::optional Found::known_length() const { + std::uint64_t length = 0; + fastly::Status status; + if (handle()) { + status = fastly::cache_get_length(handle()->handle(), &length); + } else { + status = + fastly::cache_replace_get_length(replace_handle()->handle(), &length); + } + + if (!status.is_ok()) { + // Length may not be known, return nullopt + return std::nullopt; + } + return length; +} + +std::vector Found::user_metadata() const { + std::vector buffer(16 * 1024); // reasonable initial size + size_t nwritten = 0; + + // Choose the appropriate function based on handle type + auto get_metadata = [this](uint8_t *buf, size_t buf_size, + size_t *nwritten) -> fastly::Status { + if (handle()) { + return fastly::cache_get_user_metadata(handle()->handle(), buf, buf_size, + nwritten); + } else { + return fastly::cache_replace_get_user_metadata(replace_handle()->handle(), + buf, buf_size, nwritten); + } + }; + + auto status = get_metadata(buffer.data(), buffer.size(), &nwritten); + + if (!status.is_ok()) { + // If buffer was too small, try again with the right size + if (status.code() == Status::Code::BufferLen) { + buffer.resize(nwritten); + status = get_metadata(buffer.data(), buffer.size(), &nwritten); + if (!status.is_ok()) { + std::cerr << "cache_get_user_metadata failed\n"; + abort(); + } + } else { + std::cerr << "cache_get_user_metadata failed\n"; + abort(); + } + } + + buffer.resize(nwritten); + return buffer; +} + +tl::expected Found::to_stream() const { + return to_stream_from_range(std::nullopt, std::nullopt); +} + +tl::expected +Found::to_stream_from_range(std::optional from, + std::optional to) const { + // Validate range: if both are provided, to must be >= from + if (from.has_value() && to.has_value() && to.value() < from.value()) { + return tl::unexpected(CacheError::InvalidOperation); + } + + std::uint32_t options_mask = 0; + fastly::CacheGetBodyOptions options{}; + + if (from.has_value()) { + options.from = from.value(); + options_mask |= FASTLY_CACHE_GET_BODY_OPTIONS_MASK_FROM; + } + if (to.has_value()) { + options.to = to.value(); + options_mask |= FASTLY_CACHE_GET_BODY_OPTIONS_MASK_TO; + } + + std::uint32_t body_handle = 0; + fastly::Status status; + if (handle()) { + status = fastly::cache_get_body(handle()->handle(), options_mask, &options, + &body_handle); + } else { + status = fastly::cache_replace_get_body( + replace_handle()->handle(), options_mask, &options, &body_handle); + } + + if (!status.is_ok()) { + return tl::unexpected(from_status(status)); + } + return Body::from_handle(body_handle); +} + +LookupBuilder lookup(CacheKey key) { return LookupBuilder(std::move(key)); } + +LookupBuilder +LookupBuilder::header_values(std::string_view name, + std::span values) && { + set_request_header_values(options_.request_headers, name, values); + return std::move(*this); +} + +LookupBuilder LookupBuilder::header(std::string_view name, + const http::HeaderValue &value) && { + return std::move(*this).header_values(name, {&value, 1}); +} + +LookupBuilder LookupBuilder::on_behalf_of(std::string service) && { + options_.service = std::move(service); + return std::move(*this); +} + +LookupBuilder LookupBuilder::always_use_requested_range() && { + options_.always_use_requested_range = true; + return std::move(*this); +} + +tl::expected, CacheError> LookupBuilder::execute() && { + auto [options, options_mask] = as_abi(options_); + std::uint32_t cache_handle = 0; + CACHE_TRY(fastly::cache_lookup(key_.data(), key_.size(), options_mask, + &options, &cache_handle)); + + // Wait for the lookup to complete (synchronous behavior) + CACHE_TRY(fastly::cache_wait(cache_handle)); + + // Check if we found something + std::uint8_t state = 0; + auto status = fastly::cache_get_state(cache_handle, &state); + if (!status.is_ok()) { + return tl::unexpected(from_status(status)); + } + + if (state & FASTLY_HOST_CACHE_LOOKUP_STATE_FOUND) { + return Found(std::make_shared(cache_handle)); + } + return std::nullopt; +} + +InsertBuilder insert(CacheKey key, std::chrono::nanoseconds max_age) { + WriteOptions options(max_age); + return InsertBuilder(std::move(key), std::move(options)); +} + +InsertBuilder +InsertBuilder::header_values(std::string_view name, + std::span values) && { + set_request_header_values(options_.request_headers, name, values); + return std::move(*this); +} + +InsertBuilder InsertBuilder::header(std::string_view name, + const http::HeaderValue &value) && { + return std::move(*this).header_values(name, {&value, 1}); +} + +InsertBuilder InsertBuilder::vary_by(std::vector headers) && { + if (!headers.empty()) { + options_.vary_rule = join_strings(headers, ' '); + } + return std::move(*this); +} + +InsertBuilder InsertBuilder::initial_age(std::chrono::nanoseconds age) && { + options_.initial_age = age; + return std::move(*this); +} + +InsertBuilder +InsertBuilder::stale_while_revalidate(std::chrono::nanoseconds duration) && { + options_.stale_while_revalidate = duration; + return std::move(*this); +} + +InsertBuilder InsertBuilder::surrogate_keys(std::vector keys) && { + if (!keys.empty()) { + options_.surrogate_keys = join_strings(keys, ' '); + } + return std::move(*this); +} + +InsertBuilder InsertBuilder::known_length(std::uint64_t length) && { + options_.length = length; + return std::move(*this); +} + +InsertBuilder +InsertBuilder::user_metadata(std::vector metadata) && { + options_.user_metadata = std::move(metadata); + return std::move(*this); +} + +InsertBuilder InsertBuilder::sensitive_data(bool is_sensitive) && { + options_.sensitive_data = is_sensitive; + return std::move(*this); +} + +InsertBuilder +InsertBuilder::deliver_node_max_age(std::chrono::nanoseconds duration) && { + options_.edge_max_age = duration; + return std::move(*this); +} + +InsertBuilder InsertBuilder::on_behalf_of(std::string service) && { + options_.service = std::move(service); + return std::move(*this); +} + +tl::expected InsertBuilder::execute() && { + auto [options, options_mask] = as_abi(options_); + std::uint32_t body_handle = 0; + CACHE_TRY(fastly::cache_insert(key_.data(), key_.size(), options_mask, + &options, &body_handle)); + return http::StreamingBody::from_body_handle(body_handle); +} + +ReplaceBuilder replace(CacheKey key) { return ReplaceBuilder(std::move(key)); } + +tl::expected ReplaceBuilder::begin() && { + auto [options, options_mask] = as_abi(options_); + std::uint32_t replace_handle = 0; + CACHE_TRY(fastly::cache_replace(key_.data(), key_.size(), options_mask, + &options, &replace_handle)); + + auto handle = std::make_shared(replace_handle); + + // Check if an existing object was found + std::uint8_t state = 0; + auto status = fastly::cache_replace_get_state(replace_handle, &state); + if (!status.is_ok()) { + return tl::unexpected(from_status(status)); + } + std::optional existing_object; + if (state & FASTLY_HOST_CACHE_LOOKUP_STATE_FOUND) { + existing_object = Found(handle); + } + + return Replace(std::move(handle), std::move(existing_object)); +} + +ReplaceBuilder ReplaceBuilder::replace_strategy(ReplaceStrategy strategy) && { + options_.replace_strategy = strategy; + return std::move(*this); +} + +ReplaceBuilder ReplaceBuilder::always_use_requested_range() && { + options_.always_use_requested_range = true; + return std::move(*this); +} + +tl::expected +Replace::execute(std::chrono::nanoseconds max_age) && { + options_.max_age = max_age; + + // Drop the existing_object first + existing_object_.reset(); + + // Now execute the replace insert + auto [options, options_mask] = as_abi(options_); + std::uint32_t body_handle = 0; + CACHE_TRY(fastly::cache_replace_insert(handle_->handle(), options_mask, + &options, &body_handle)); + return http::StreamingBody::from_body_handle(body_handle); +} + +Replace Replace::header_values(std::string_view name, + std::span values) && { + set_request_header_values(options_.request_headers, name, values); + return std::move(*this); +} + +Replace Replace::header(std::string_view name, + const http::HeaderValue &value) && { + return std::move(*this).header_values(name, {&value, 1}); +} + +Replace Replace::vary_by(std::vector headers) && { + if (!headers.empty()) { + options_.vary_rule = join_strings(headers, ' '); + } + return std::move(*this); +} + +Replace Replace::initial_age(std::chrono::nanoseconds age) && { + options_.initial_age = age; + return std::move(*this); +} + +Replace Replace::stale_while_revalidate(std::chrono::nanoseconds duration) && { + options_.stale_while_revalidate = duration; + return std::move(*this); +} + +Replace Replace::surrogate_keys(std::vector keys) && { + if (!keys.empty()) { + options_.surrogate_keys = join_strings(keys, ' '); + } + return std::move(*this); +} + +Replace Replace::known_length(std::uint64_t length) && { + options_.length = length; + return std::move(*this); +} + +Replace Replace::user_metadata(std::vector metadata) && { + options_.user_metadata = std::move(metadata); + return std::move(*this); +} + +Replace Replace::sensitive_data(bool is_sensitive) && { + options_.sensitive_data = is_sensitive; + return std::move(*this); +} + +Replace Replace::deliver_node_max_age(std::chrono::nanoseconds duration) && { + options_.edge_max_age = duration; + return std::move(*this); +} + +} // namespace fastly::cache::core diff --git a/src/cpp/fastly.h b/src/cpp/fastly.h index cc9ba21..f5e34c5 100644 --- a/src/cpp/fastly.h +++ b/src/cpp/fastly.h @@ -40,6 +40,9 @@ class Status { }; static_assert(std::is_trivial_v, "Status must be trivial"); +WASM_IMPORT("fastly_async_io", "is_ready") +Status async_is_ready(uint32_t busy_handle, uint32_t *is_ready_out); + WASM_IMPORT("fastly_erl", "check_rate") Status check_rate(const char *rc, size_t rc_len, const char *entry, size_t entry_len, uint32_t delta, uint32_t window, @@ -67,5 +70,202 @@ Status penaltybox_add(const char *pb, size_t pb_len, const char *entry, WASM_IMPORT("fastly_erl", "penaltybox_has") Status penaltybox_has(const char *pb, size_t pb_len, const char *entry, size_t entry_len, bool *has_out); + +typedef struct __attribute__((aligned(8))) { + uint64_t max_age_ns; + uint32_t request_headers; + const uint8_t *vary_rule_ptr; + size_t vary_rule_len; + uint64_t initial_age_ns; + uint64_t stale_while_revalidate_ns; + const uint8_t *surrogate_keys_ptr; + size_t surrogate_keys_len; + uint64_t length; + const uint8_t *user_metadata_ptr; + size_t user_metadata_len; + uint64_t edge_max_age_ns; + const char *service; + size_t service_len; +} CacheWriteOptions; + +#define FASTLY_CACHE_WRITE_OPTIONS_MASK_RESERVED (1 << 0) +#define FASTLY_CACHE_WRITE_OPTIONS_MASK_REQUEST_HEADERS (1 << 1) +#define FASTLY_CACHE_WRITE_OPTIONS_MASK_VARY_RULE (1 << 2) +#define FASTLY_CACHE_WRITE_OPTIONS_MASK_INITIAL_AGE_NS (1 << 3) +#define FASTLY_CACHE_WRITE_OPTIONS_MASK_STALE_WHILE_REVALIDATE_NS (1 << 4) +#define FASTLY_CACHE_WRITE_OPTIONS_MASK_SURROGATE_KEYS (1 << 5) +#define FASTLY_CACHE_WRITE_OPTIONS_MASK_LENGTH (1 << 6) +#define FASTLY_CACHE_WRITE_OPTIONS_MASK_USER_METADATA (1 << 7) +#define FASTLY_CACHE_WRITE_OPTIONS_MASK_SENSITIVE_DATA (1 << 8) +#define FASTLY_CACHE_WRITE_OPTIONS_MASK_EDGE_MAX_AGE_NS (1 << 9) +#define FASTLY_CACHE_WRITE_OPTIONS_MASK_SERVICE (1 << 10) + +typedef struct __attribute__((aligned(8))) { + uint32_t request_headers; + const char *service; + size_t service_len; +} CacheLookupOptions; + +#define FASTLY_CACHE_LOOKUP_OPTIONS_MASK_RESERVED (1 << 0) +#define FASTLY_CACHE_LOOKUP_OPTIONS_MASK_REQUEST_HEADERS (1 << 1) +#define FASTLY_CACHE_LOOKUP_OPTIONS_MASK_SERVICE (1 << 2) +#define FASTLY_CACHE_LOOKUP_OPTIONS_MASK_ALWAYS_USE_REQUESTED_RANGE (1 << 3) + +typedef struct __attribute__((aligned(8))) { + uint32_t request_headers; + uint32_t replace_strategy; + const char *service; + size_t service_len; +} CacheReplaceOptions; + +#define FASTLY_CACHE_REPLACE_OPTIONS_MASK_RESERVED (1 << 0) +#define FASTLY_CACHE_REPLACE_OPTIONS_MASK_REQUEST_HEADERS (1 << 1) +#define FASTLY_CACHE_REPLACE_OPTIONS_MASK_REPLACE_STRATEGY (1 << 2) +#define FASTLY_CACHE_REPLACE_OPTIONS_MASK_SERVICE (1 << 3) +#define FASTLY_CACHE_REPLACE_OPTIONS_MASK_ALWAYS_USE_REQUESTED_RANGE (1 << 4) + +// a cached object was found +#define FASTLY_HOST_CACHE_LOOKUP_STATE_FOUND (1 << 0) +// the cached object is valid to use (implies found) +#define FASTLY_HOST_CACHE_LOOKUP_STATE_USABLE (1 << 1) +// the cached object is stale (but may or may not be valid to use) +#define FASTLY_HOST_CACHE_LOOKUP_STATE_STALE (1 << 2) +// this client is requested to insert or revalidate an object +#define FASTLY_HOST_CACHE_LOOKUP_STATE_MUST_INSERT_OR_UPDATE (1 << 3) + +struct CacheGetBodyOptions { + uint64_t from; + uint64_t to; +}; + +#define FASTLY_CACHE_GET_BODY_OPTIONS_MASK_RESERVED (1 << 0) +#define FASTLY_CACHE_GET_BODY_OPTIONS_MASK_FROM (1 << 1) +#define FASTLY_CACHE_GET_BODY_OPTIONS_MASK_TO (1 << 2) + +WASM_IMPORT("fastly_cache", "transaction_lookup") +Status cache_transaction_lookup(const uint8_t *key_ptr, size_t key_len, + uint32_t options_mask, + CacheLookupOptions *options, + uint32_t *handle_out); + +WASM_IMPORT("fastly_cache", "transaction_lookup_async") +Status cache_transaction_lookup_async(const uint8_t *key_ptr, size_t key_len, + uint32_t options_mask, + CacheLookupOptions *options, + uint32_t *busy_handle_out); + +WASM_IMPORT("fastly_cache", "cache_busy_handle_wait") +Status cache_busy_handle_wait(uint32_t busy_handle, uint32_t *handle_out); + +WASM_IMPORT("fastly_cache", "lookup") +Status cache_lookup(const uint8_t *key_ptr, size_t key_len, + uint32_t options_mask, CacheLookupOptions *options, + uint32_t *handle_out); + +WASM_IMPORT("fastly_cache", "insert") +Status cache_insert(const uint8_t *key_ptr, size_t key_len, + uint32_t options_mask, const CacheWriteOptions *options, + uint32_t *body_handle_out); + +WASM_IMPORT("fastly_cache", "replace") +Status cache_replace(const uint8_t *key_ptr, size_t key_len, + uint32_t options_mask, CacheReplaceOptions *options, + uint32_t *replace_handle_out); + +WASM_IMPORT("fastly_cache", "replace_insert") +Status cache_replace_insert(uint32_t replace_handle, uint32_t options_mask, + const CacheWriteOptions *options, + uint32_t *body_handle_out); + +WASM_IMPORT("fastly_cache", "wait") +Status cache_wait(uint32_t handle); + +WASM_IMPORT("fastly_cache", "transaction_update") +Status cache_transaction_update(uint32_t handle, uint32_t options_mask, + const CacheWriteOptions *options); + +WASM_IMPORT("fastly_cache", "transaction_insert") +Status cache_transaction_insert(uint32_t handle, uint32_t options_mask, + const CacheWriteOptions *options, + uint32_t *body_handle_out); + +WASM_IMPORT("fastly_cache", "transaction_insert_and_stream_back") +Status cache_transaction_insert_and_stream_back( + uint32_t handle, uint32_t options_mask, const CacheWriteOptions *options, + uint32_t *body_handle_out, uint32_t *cache_handle_out); + +WASM_IMPORT("fastly_cache", "get_state") +Status cache_get_state(uint32_t handle, uint8_t *state_out); + +WASM_IMPORT("fastly_cache", "replace_get_state") +Status cache_replace_get_state(uint32_t handle, uint8_t *state_out); + +WASM_IMPORT("fastly_cache", "transaction_cancel") +Status cache_transaction_cancel(uint32_t handle); + +WASM_IMPORT("fastly_cache", "get_age_ns") +Status cache_get_age_ns(uint32_t handle, uint64_t *age_out); + +WASM_IMPORT("fastly_cache", "get_max_age_ns") +Status cache_get_max_age_ns(uint32_t handle, uint64_t *max_age_out); + +WASM_IMPORT("fastly_cache", "get_stale_while_revalidate_ns") +Status cache_get_stale_while_revalidate_ns(uint32_t handle, uint64_t *swr_out); + +WASM_IMPORT("fastly_cache", "get_hits") +Status cache_get_hits(uint32_t handle, uint64_t *hits_out); + +WASM_IMPORT("fastly_cache", "get_length") +Status cache_get_length(uint32_t handle, uint64_t *length_out); + +WASM_IMPORT("fastly_cache", "get_user_metadata") +Status cache_get_user_metadata(uint32_t handle, uint8_t *user_metadata_out, + size_t user_metadata_max_len, + size_t *nwritten_out); + +WASM_IMPORT("fastly_cache", "get_body") +Status cache_get_body(uint32_t handle, uint32_t options_mask, + CacheGetBodyOptions *options, uint32_t *body_handle_out); + +WASM_IMPORT("fastly_cache", "replace_get_age_ns") +Status cache_replace_get_age_ns(uint32_t handle, uint64_t *age_out); + +WASM_IMPORT("fastly_cache", "replace_get_max_age_ns") +Status cache_replace_get_max_age_ns(uint32_t handle, uint64_t *max_age_out); + +WASM_IMPORT("fastly_cache", "replace_get_stale_while_revalidate_ns") +Status cache_replace_get_stale_while_revalidate_ns(uint32_t handle, + uint64_t *swr_out); + +WASM_IMPORT("fastly_cache", "replace_get_hits") +Status cache_replace_get_hits(uint32_t handle, uint64_t *hits_out); + +WASM_IMPORT("fastly_cache", "replace_get_length") +Status cache_replace_get_length(uint32_t handle, uint64_t *length_out); + +WASM_IMPORT("fastly_cache", "replace_get_user_metadata") +Status cache_replace_get_user_metadata(uint32_t handle, + uint8_t *user_metadata_out, + size_t user_metadata_max_len, + size_t *nwritten_out); + +WASM_IMPORT("fastly_cache", "replace_get_body") +Status cache_replace_get_body(uint32_t handle, uint32_t options_mask, + CacheGetBodyOptions *options, + uint32_t *body_handle_out); + +WASM_IMPORT("fastly_cache", "close") +Status cache_close(uint32_t handle); + +WASM_IMPORT("fastly_http_req", "new") +Status http_req_new(uint32_t *req_handle_out); + +WASM_IMPORT("fastly_http_req", "close") +Status http_req_close(uint32_t req_handle); + +WASM_IMPORT("fastly_http_req", "header_values_set") +Status http_req_header_values_set(uint32_t req_handle, const uint8_t *name, + size_t name_len, const uint8_t *value, + size_t value_len); } // namespace fastly #endif // FASTLY_H \ No newline at end of file diff --git a/src/cpp/http/body.cpp b/src/cpp/http/body.cpp index 654f4a0..ff29913 100644 --- a/src/cpp/http/body.cpp +++ b/src/cpp/http/body.cpp @@ -1,6 +1,7 @@ #include #include #include +#include namespace fastly::http { diff --git a/src/http/body.rs b/src/http/body.rs index 257e074..c4d901b 100644 --- a/src/http/body.rs +++ b/src/http/body.rs @@ -14,6 +14,11 @@ pub fn m_static_http_body_new() -> Box { Box::new(Body(fastly::http::Body::new())) } +pub fn m_static_http_body_from_handle(handle: u32) -> Box { + let body_handle = unsafe { fastly::handle::BodyHandle::from_u32(handle) }; + Box::new(Body(body_handle.into())) +} + impl Body { pub fn append(&mut self, other: Box) { self.0.append(other.0); @@ -57,3 +62,9 @@ impl StreamingBody { try_fe!(err, self.0.write(bytes)) } } + +pub fn m_static_http_streaming_body_from_body_handle(handle: u32) -> Box { + let body_handle = unsafe { fastly::handle::BodyHandle::from_u32(handle) }; + let handle = fastly::handle::StreamingBodyHandle::from_body_handle(body_handle); + Box::new(StreamingBody(handle.into())) +} diff --git a/src/lib.rs b/src/lib.rs index 8998a6b..228047e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -637,6 +637,7 @@ mod ffi { extern "Rust" { type Body; fn m_static_http_body_new() -> Box; + fn m_static_http_body_from_handle(handle: u32) -> Box; fn append(&mut self, other: Box); fn append_trailer( &mut self, @@ -660,6 +661,7 @@ mod ffi { err: Pin<&mut *mut FastlyError>, ); fn write(&mut self, bytes: &[u8], err: Pin<&mut *mut FastlyError>) -> usize; + fn m_static_http_streaming_body_from_body_handle(handle: u32) -> Box; } #[namespace = "fastly::sys::http::purge"] From aa722e0ff0b2051a5314e20613054124fcd7c996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kat=20March=C3=A1n?= Date: Fri, 13 Mar 2026 13:30:15 -0700 Subject: [PATCH 2/8] get started with some tests and fixes --- src/cpp/cache/core.cpp | 26 ++++++++------- src/cpp/fastly.h | 7 ++-- test/cache_core.cpp | 73 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 test/cache_core.cpp diff --git a/src/cpp/cache/core.cpp b/src/cpp/cache/core.cpp index a2fb826..f5101ae 100644 --- a/src/cpp/cache/core.cpp +++ b/src/cpp/cache/core.cpp @@ -216,7 +216,7 @@ RequestHandle RequestHandle::make() { } // namespace detail bool Transaction::must_insert() const { - std::uint8_t state = 0; + std::uint32_t state = 0; auto status = fastly::cache_get_state(handle_->handle(), &state); if (!status.is_ok()) { std::cerr << "cache_get_state failed\n"; @@ -227,7 +227,7 @@ bool Transaction::must_insert() const { } bool Transaction::must_insert_or_update() const { - std::uint8_t state = 0; + std::uint32_t state = 0; auto status = fastly::cache_get_state(handle_->handle(), &state); if (!status.is_ok()) { std::cerr << "cache_get_state failed\n"; @@ -262,7 +262,7 @@ TransactionLookupBuilder Transaction::lookup(CacheKey key) { } std::optional Transaction::found() const { - std::uint8_t state = 0; + std::uint32_t state = 0; auto status = fastly::cache_get_state(handle_->handle(), &state); if (!status.is_ok()) { std::cerr << "cache_get_state failed\n"; @@ -287,7 +287,8 @@ tl::expected PendingTransaction::wait() && { CACHE_TRY(fastly::cache_busy_handle_wait(handle_->handle(), &cache_handle)); // Wait for the cache handle async item to be complete - CACHE_TRY(fastly::cache_wait(cache_handle)); + std::uint32_t cache_lookup_state_out = 0; + CACHE_TRY(fastly::cache_get_state(cache_handle, &cache_lookup_state_out)); return Transaction(std::make_shared(cache_handle)); } @@ -323,7 +324,8 @@ tl::expected TransactionLookupBuilder::execute() && { key_.data(), key_.size(), options_mask, &options, &cache_handle)); // Wait for the lookup to complete (synchronous behavior) - CACHE_TRY(fastly::cache_wait(cache_handle)); + std::uint32_t state_out = 0; + CACHE_TRY(fastly::cache_get_state(cache_handle, &state_out)); return Transaction(std::make_shared(cache_handle)); } @@ -538,7 +540,7 @@ std::chrono::nanoseconds Found::stale_while_revalidate() const { } bool Found::is_stale() const { - std::uint8_t state = 0; + std::uint32_t state = 0; fastly::Status status; if (handle()) { status = fastly::cache_get_state(handle()->handle(), &state); @@ -555,7 +557,7 @@ bool Found::is_stale() const { } bool Found::is_usable() const { - std::uint8_t state = 0; + std::uint32_t state = 0; fastly::Status status; if (handle()) { status = fastly::cache_get_state(handle()->handle(), &state); @@ -711,11 +713,13 @@ tl::expected, CacheError> LookupBuilder::execute() && { CACHE_TRY(fastly::cache_lookup(key_.data(), key_.size(), options_mask, &options, &cache_handle)); - // Wait for the lookup to complete (synchronous behavior) - CACHE_TRY(fastly::cache_wait(cache_handle)); + // Force await for the lookup to complete (synchronous behavior) + // NOTE(@zkat): the Rust SDK does, in fact, double-up on this get_state. + std::uint32_t throwaway_state = 0; + CACHE_TRY(fastly::cache_get_state(cache_handle, &throwaway_state)); // Check if we found something - std::uint8_t state = 0; + std::uint32_t state = 0; auto status = fastly::cache_get_state(cache_handle, &state); if (!status.is_ok()) { return tl::unexpected(from_status(status)); @@ -815,7 +819,7 @@ tl::expected ReplaceBuilder::begin() && { auto handle = std::make_shared(replace_handle); // Check if an existing object was found - std::uint8_t state = 0; + std::uint32_t state = 0; auto status = fastly::cache_replace_get_state(replace_handle, &state); if (!status.is_ok()) { return tl::unexpected(from_status(status)); diff --git a/src/cpp/fastly.h b/src/cpp/fastly.h index f5e34c5..be63fe1 100644 --- a/src/cpp/fastly.h +++ b/src/cpp/fastly.h @@ -177,9 +177,6 @@ Status cache_replace_insert(uint32_t replace_handle, uint32_t options_mask, const CacheWriteOptions *options, uint32_t *body_handle_out); -WASM_IMPORT("fastly_cache", "wait") -Status cache_wait(uint32_t handle); - WASM_IMPORT("fastly_cache", "transaction_update") Status cache_transaction_update(uint32_t handle, uint32_t options_mask, const CacheWriteOptions *options); @@ -195,10 +192,10 @@ Status cache_transaction_insert_and_stream_back( uint32_t *body_handle_out, uint32_t *cache_handle_out); WASM_IMPORT("fastly_cache", "get_state") -Status cache_get_state(uint32_t handle, uint8_t *state_out); +Status cache_get_state(uint32_t handle, uint32_t *state_out); WASM_IMPORT("fastly_cache", "replace_get_state") -Status cache_replace_get_state(uint32_t handle, uint8_t *state_out); +Status cache_replace_get_state(uint32_t handle, uint32_t *state_out); WASM_IMPORT("fastly_cache", "transaction_cancel") Status cache_transaction_cancel(uint32_t handle); diff --git a/test/cache_core.cpp b/test/cache_core.cpp new file mode 100644 index 0000000..54f60b5 --- /dev/null +++ b/test/cache_core.cpp @@ -0,0 +1,73 @@ +#include +#include +#include + +using namespace fastly::http; +using namespace fastly::cache::core; +using namespace std::string_literals; +using namespace std::chrono_literals; + +TEST_CASE("cache::core::insert", "[cache_core]") { + auto key_string("hello world"s); + std::vector key(key_string.begin(), key_string.end()); + auto contents("contents here"s); + auto writer = insert(key, 1234ns) + .surrogate_keys({"my_key"s}) + .known_length(contents.size()) + .execute(); + ; + REQUIRE(writer); + + *writer << contents; + + REQUIRE(writer->finish()); +} + +TEST_CASE("cache::core::lookup", "[cache_core]") { + auto key_string("hello world"s); + std::vector key(key_string.begin(), key_string.end()); + auto contents("deadbeef badc0ffee"s); + auto writer = insert(key, 1234ns).execute(); + ; + *writer << contents; + REQUIRE(writer->finish()); + + auto found = lookup(key).execute(); + REQUIRE(found); + REQUIRE(*found); + auto stream = (*found)->to_stream(); + REQUIRE(stream); + std::string from_lookup(std::istreambuf_iterator(*stream), {}); + + REQUIRE(contents == from_lookup); +} + +TEST_CASE("cache::core::replace", "[cache_core]") { + auto key_string("hello world"s); + std::vector key(key_string.begin(), key_string.end()); + auto contents("deadbeef badc0ffee"s); + + auto current = replace(key).begin(); + // TODO(@zkat): I'm not sure why this fails??? But everything else works??? + // REQUIRE(current); + + REQUIRE(!current->existing_object()); + + auto writer = insert(key, 1234ns).execute(); + ; + *writer << contents; + REQUIRE(writer->finish()); + + current = replace(key).begin(); + // TODO(@zkat): ???? + // REQUIRE(current); + + auto existing = current->existing_object(); + // TODO(@zkat): Not sure why this is failing. We _seem_ to be doing the same + // thing as the Rust code. Am I holding it wrong? + REQUIRE(existing.has_value()); +} + +// Required due to https://github.com/WebAssembly/wasi-libc/issues/485 +#include +int main(int argc, char *argv[]) { return Catch::Session().run(argc, argv); } From 0a42ab70aca45a905ba96de070a2b6c5d55564b9 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Mon, 16 Mar 2026 11:21:41 +0000 Subject: [PATCH 3/8] More testing work --- test/cache_core.cpp | 78 ++++++++++++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 22 deletions(-) diff --git a/test/cache_core.cpp b/test/cache_core.cpp index 54f60b5..8c9f4ef 100644 --- a/test/cache_core.cpp +++ b/test/cache_core.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -8,14 +9,14 @@ using namespace std::string_literals; using namespace std::chrono_literals; TEST_CASE("cache::core::insert", "[cache_core]") { - auto key_string("hello world"s); + auto key_string("cache::core::insert"s); std::vector key(key_string.begin(), key_string.end()); auto contents("contents here"s); auto writer = insert(key, 1234ns) .surrogate_keys({"my_key"s}) .known_length(contents.size()) .execute(); - ; + REQUIRE(writer); *writer << contents; @@ -23,12 +24,22 @@ TEST_CASE("cache::core::insert", "[cache_core]") { REQUIRE(writer->finish()); } +TEST_CASE("failed cache::core::lookup", "[cache_core]") { + auto key_string("cache::core::lookup"s); + std::vector key(key_string.begin(), key_string.end()); + auto found = lookup(key).execute(); + REQUIRE(found); + REQUIRE(!*found); +} + TEST_CASE("cache::core::lookup", "[cache_core]") { - auto key_string("hello world"s); + auto key_string("cache::core::lookup"s); std::vector key(key_string.begin(), key_string.end()); auto contents("deadbeef badc0ffee"s); - auto writer = insert(key, 1234ns).execute(); - ; + auto writer = + insert(key, std::chrono::duration_cast(1s)) + .execute(); + *writer << contents; REQUIRE(writer->finish()); @@ -42,30 +53,53 @@ TEST_CASE("cache::core::lookup", "[cache_core]") { REQUIRE(contents == from_lookup); } -TEST_CASE("cache::core::replace", "[cache_core]") { - auto key_string("hello world"s); +TEST_CASE("cache::core::Found::user_metadata", "[cache_core]") { + auto key_string("cache::core::Found::user_metadata"s); std::vector key(key_string.begin(), key_string.end()); + auto metadata = std::vector{0xde, 0xad, 0xbe, 0xef}; auto contents("deadbeef badc0ffee"s); + auto writer = + insert(key, std::chrono::duration_cast(1s)) + .user_metadata(metadata) + .execute(); - auto current = replace(key).begin(); - // TODO(@zkat): I'm not sure why this fails??? But everything else works??? - // REQUIRE(current); - - REQUIRE(!current->existing_object()); - - auto writer = insert(key, 1234ns).execute(); - ; *writer << contents; REQUIRE(writer->finish()); - current = replace(key).begin(); - // TODO(@zkat): ???? - // REQUIRE(current); + auto found = lookup(key).execute(); + REQUIRE(found); + REQUIRE(*found); + + REQUIRE((*found)->user_metadata() == metadata); +} + +TEST_CASE("cache::core::Transaction", "[cache_core]") { + auto key_string("cache::core::Transaction"s); + std::vector key(key_string.begin(), key_string.end()); + auto contents("contents here"s); + + auto transaction = Transaction::lookup(key).execute(); + if (!transaction) { + FAIL("transaction lookup failed: " << transaction.error().code()); + } + REQUIRE(transaction); - auto existing = current->existing_object(); - // TODO(@zkat): Not sure why this is failing. We _seem_ to be doing the same - // thing as the Rust code. Am I holding it wrong? - REQUIRE(existing.has_value()); + if (transaction->must_insert()) { + auto writer = + std::move(*transaction) + .insert(std::chrono::duration_cast(1s)) + .execute(); + REQUIRE(writer); + *writer << contents; + REQUIRE(writer->finish()); + } else { + auto found = transaction->found(); + REQUIRE(found); + auto stream = found->to_stream(); + REQUIRE(stream); + std::string from_lookup(std::istreambuf_iterator(*stream), {}); + REQUIRE(contents == from_lookup); + } } // Required due to https://github.com/WebAssembly/wasi-libc/issues/485 From 1283556aa49394876239d66cc7974164297bb971 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Mon, 16 Mar 2026 14:38:20 +0000 Subject: [PATCH 4/8] Simple cache implementation --- include/fastly/cache/core.h | 63 ++++-- include/fastly/cache/simple.h | 355 ++++++++++++++++++++++++++++++++++ src/cache.rs | 21 ++ src/cpp/cache/core.cpp | 18 +- src/cpp/cache/simple.cpp | 114 +++++++++++ src/lib.rs | 14 +- test/cache_core.cpp | 38 ++-- 7 files changed, 579 insertions(+), 44 deletions(-) create mode 100644 include/fastly/cache/simple.h create mode 100644 src/cache.rs create mode 100644 src/cpp/cache/simple.cpp diff --git a/include/fastly/cache/core.h b/include/fastly/cache/core.h index 81ff59f..0f7f1ac 100644 --- a/include/fastly/cache/core.h +++ b/include/fastly/cache/core.h @@ -16,9 +16,6 @@ namespace fastly::cache::core { -/// Cache key is a byte array used to identify cached items. -using CacheKey = std::vector; - /// Errors that can arise during cache operations. class CacheError { public: @@ -523,8 +520,8 @@ class TransactionLookupBuilder { tl::expected execute_async() &&; private: - explicit TransactionLookupBuilder(CacheKey key) : key_(std::move(key)) {} - CacheKey key_; + explicit TransactionLookupBuilder(std::vector key) : key_(std::move(key)) {} + std::vector key_; LookupOptions options_; friend class Transaction; }; @@ -676,7 +673,13 @@ class Transaction { public: /// Returns a `TransactionLookupBuilder` that will perform a transactional /// cache lookup. - static TransactionLookupBuilder lookup(CacheKey key); + static TransactionLookupBuilder lookup(std::span key); + + /// Transactional cache lookup with string key. + static TransactionLookupBuilder lookup(std::string_view key) { + return lookup(std::span(reinterpret_cast(key.data()), + key.size())); + } /// Returns a `Found` object for this cache item, if one is available. /// @@ -855,10 +858,10 @@ class LookupBuilder { tl::expected, CacheError> execute() &&; private: - explicit LookupBuilder(CacheKey key) : key_(std::move(key)) {} - CacheKey key_; + explicit LookupBuilder(std::vector key) : key_(std::move(key)) {} + std::vector key_; LookupOptions options_; - friend LookupBuilder lookup(CacheKey key); + friend LookupBuilder lookup(std::span key); }; /// Returns a `LookupBuilder` that will perform a non-transactional cache @@ -874,7 +877,13 @@ class LookupBuilder { /// /// To resolve such races between concurrent lookups, use /// `Transaction::lookup()` instead. -LookupBuilder lookup(CacheKey key); +LookupBuilder lookup(std::span key); + +/// Non-transactional cache lookup with string key. +inline LookupBuilder lookup(std::string_view key) { + return lookup(std::span(reinterpret_cast(key.data()), + key.size())); +} /// A builder-style API for configuring a non-transactional cache insertion. /// @@ -1005,11 +1014,12 @@ class InsertBuilder { tl::expected execute() &&; private: - explicit InsertBuilder(CacheKey key, WriteOptions options) + explicit InsertBuilder(std::vector key, WriteOptions options) : key_(std::move(key)), options_(std::move(options)) {} - CacheKey key_; + std::vector key_; WriteOptions options_; - friend InsertBuilder insert(CacheKey key, std::chrono::nanoseconds max_age); + friend InsertBuilder insert(std::span key, + std::chrono::nanoseconds max_age); }; /// Returns an `InsertBuilder` that will perform a non-transactional cache @@ -1018,7 +1028,16 @@ class InsertBuilder { /// The required `max_age` argument is the maximal "time to live" for the cache /// item: the time for which the item will be considered fresh, starting from /// the start of its history. -InsertBuilder insert(CacheKey key, std::chrono::nanoseconds max_age); +InsertBuilder insert(std::span key, + std::chrono::nanoseconds max_age); + +/// Non-transactional cache insert with string key. +inline InsertBuilder insert(std::string_view key, + std::chrono::nanoseconds max_age) { + return insert(std::span(reinterpret_cast(key.data()), + key.size()), + max_age); +} class Replace; @@ -1042,8 +1061,6 @@ class ReplaceBuilder { /// a streaming error. tl::expected begin() &&; - // TODO: header and header_values - /// Sets the strategy for performing the replace. ReplaceBuilder replace_strategy(ReplaceStrategy strategy) &&; @@ -1072,10 +1089,10 @@ class ReplaceBuilder { ReplaceBuilder always_use_requested_range() &&; private: - explicit ReplaceBuilder(CacheKey key) : key_(std::move(key)) {} - CacheKey key_; + explicit ReplaceBuilder(std::vector key) : key_(std::move(key)) {} + std::vector key_; ReplaceOptions options_; - friend ReplaceBuilder replace(CacheKey key); + friend ReplaceBuilder replace(std::span key); }; /// An in-progress Replace operation. @@ -1208,7 +1225,13 @@ class Replace { /// Returns a `ReplaceBuilder` that will perform a non-transactional cache /// replacement. -ReplaceBuilder replace(CacheKey key); +ReplaceBuilder replace(std::span key); + +/// Non-transactional cache replace with string key. +inline ReplaceBuilder replace(std::string_view key) { + return replace(std::span(reinterpret_cast(key.data()), + key.size())); +} } // namespace fastly::cache::core diff --git a/include/fastly/cache/simple.h b/include/fastly/cache/simple.h new file mode 100644 index 0000000..d8f1da0 --- /dev/null +++ b/include/fastly/cache/simple.h @@ -0,0 +1,355 @@ +#ifndef FASTLY_CACHE_SIMPLE_H +#define FASTLY_CACHE_SIMPLE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fastly::cache::simple { +/// Errors arising from cache operations. +class CacheError { +public: + enum Code { + /// Operation failed due to a limit. + LimitExceeded, + /// An underlying Core Cache API operation found an invalid state. + /// This should not arise during use of this API. If encountered, please + /// report it as a bug. + InvalidOperation, + /// Cache operation is not supported. + Unsupported, + /// An I/O error occurred. + Io, + /// An error occurred when purging a value. + Purge, + /// An error occurred while running the closure argument of get_or_set_with. + GetOrSet, + /// An unknown error occurred. + Other, + }; + + CacheError(Code code) : code_(code) {} + Code code() const { return code_; } + +private: + Code code_; +}; + +/// The return type of the closure provided to get_or_set_with(). +class CacheEntry { +public: + CacheEntry(http::Body val, std::chrono::nanoseconds ttl) + : value_(std::move(val)), ttl_(ttl) {} + + http::Body &value() { return value_; } + const http::Body &value() const { return value_; } + std::chrono::nanoseconds ttl() const { return ttl_; } + +private: + /// The value to cache. + http::Body value_; + /// The time-to-live for the cache entry. + std::chrono::nanoseconds ttl_; +}; + +/// Options for purge operations. +class PurgeOptions { +public: + enum Scope { + /// Purge the key from the current POP (default behavior). + Pop, + /// Purge the key globally (requires additional Fastly configuration). + Global, + }; + + /// Purge the key from the current POP (default behavior). + /// + /// This is the default option, and allows a higher throughput of purging + /// than purging globally. + static PurgeOptions pop_scope() { return PurgeOptions(Scope::Pop); } + + /// Purge the key globally. + /// + /// This requires the Fastly global purge feature to be enabled for your + /// service. See the [Fastly purge + /// documentation](https://developer.fastly.com/learning/concepts/purging/) + /// for details. + static PurgeOptions global_scope() { return PurgeOptions(Scope::Global); } + + Scope scope() const { return scope_; } + +private: + explicit PurgeOptions(Scope s) : scope_(s) {} + Scope scope_; +}; + +namespace detail { +/// Internal function to generate surrogate keys for cache entries. +/// This is used internally by the simple cache API to enable purging. +std::string surrogate_key_for_cache_key(std::span key, + PurgeOptions::Scope scope); + +fastly::cache::simple::CacheError +from_core_error(const fastly::cache::core::CacheError &err); +} // namespace detail + +/// Get the entry associated with the given cache key, if it exists. +/// +/// Returns `nullopt` if the key is not in the cache, or `Body` with the cached +/// value if found. +/// +/// ```cpp +/// std::vector key_bytes = {0x01, 0x02, 0x03}; +/// auto result = get(key_bytes); +/// ``` +tl::expected, CacheError> +get(std::span key); + +/// Get the entry associated with the given cache key, if it exists. +/// +/// Returns `nullopt` if the key is not in the cache, or `Body` with the cached +/// value if found. +/// +/// ```cpp +/// auto result = get("my_key"); +/// ``` +inline tl::expected, CacheError> +get(std::string_view key) { + return get(std::span(reinterpret_cast(key.data()), + key.size())); +} + +/// Get the entry associated with the given cache key if it exists, or insert +/// and return the specified entry. +/// +/// If the value is costly to compute, consider using `get_or_set_with()` +/// instead to avoid computation in the case where the value is already present. +/// +/// The returned `Body` is always valid; either the cached value was found and +/// returned, or the new value was inserted and returned. +/// +/// Example: +/// ```cpp +/// std::vector key_bytes = {0x01, 0x02, 0x03}; +/// auto value = get_or_set(key_bytes, http::Body("hello!"), +/// std::chrono::nanoseconds(6000)).value(); +/// std::string cached_string = value.take_body_string(); +/// ``` +tl::expected +get_or_set(std::span key, http::Body value, + std::chrono::nanoseconds ttl); + +/// Get the entry associated with the given cache key if it exists, or insert +/// and return the specified entry. +/// +/// If the value is costly to compute, consider using `get_or_set_with()` +/// instead to avoid computation in the case where the value is already present. +/// +/// The returned `Body` is always valid; either the cached value was found and +/// returned, or the new value was inserted and returned. +/// +/// Example: +/// ```cpp +/// auto value = get_or_set("my_key", http::Body("hello!"), +/// std::chrono::nanoseconds(6000)).value(); +/// std::string cached_string = value.take_body_string(); +/// ``` +inline tl::expected +get_or_set(std::string_view key, http::Body value, + std::chrono::nanoseconds ttl) { + return get_or_set( + std::span(reinterpret_cast(key.data()), key.size()), + std::move(value), ttl); +} + +/// Get the entry associated with the given cache key if it exists, or insert +/// and return an entry specified by running the given closure. +/// +/// The closure is only run when no value is present for the key, and no other +/// client is in the process of setting it. It takes no arguments, and returns +/// either a `CacheEntry` describing the entry to set, or `std::nullopt` in the +/// case of error. +/// +/// Example successful insertion: +/// ```cpp +/// std::vector key_bytes = {0x01, 0x02, 0x03}; +/// auto value = get_or_set_with(key_bytes, []() -> std::optional { +/// return CacheEntry{http::Body("hello!"), std::chrono::nanoseconds(6000)}; +/// }).value(); +/// ``` +template +tl::expected, CacheError> +get_or_set_with(std::span key, F make_entry) + requires std::invocable && + std::same_as, std::optional> +{ + auto lookup_result = core::Transaction::lookup(key).execute(); + if (!lookup_result.has_value()) { + return tl::unexpected(detail::from_core_error(lookup_result.error())); + } + + auto &lookup_tx = lookup_result.value(); + + if (!lookup_tx.must_insert_or_update()) { + if (auto found = lookup_tx.found()) { + auto stream_result = found->to_stream(); + if (!stream_result.has_value()) { + return tl::unexpected(detail::from_core_error(stream_result.error())); + } + return std::optional(std::move(stream_result.value())); + } else { + return tl::unexpected(CacheError(CacheError::Code::InvalidOperation)); + } + } + + // Run the user-provided closure to produce the entry + auto entry = make_entry(); + if (!entry.has_value()) { + return tl::unexpected(CacheError(CacheError::Code::GetOrSet)); + } + + // Create surrogate keys for both POP and global purging + std::vector surrogate_keys = { + detail::surrogate_key_for_cache_key(key, PurgeOptions::Scope::Pop), + detail::surrogate_key_for_cache_key(key, PurgeOptions::Scope::Global), + }; + + auto insert_result = std::move(lookup_tx) + .insert(entry->ttl()) + .surrogate_keys(surrogate_keys) + .execute_and_stream_back(); + + if (!insert_result.has_value()) { + return tl::unexpected(detail::from_core_error(insert_result.error())); + } + + auto [insert_body, found] = std::move(insert_result.value()); + + insert_body.append(std::move(entry->value())); + auto finish_result = insert_body.finish(); + if (!finish_result.has_value()) { + return tl::unexpected(CacheError(CacheError::Code::Other)); + } + + auto stream_result = found.to_stream(); + if (!stream_result.has_value()) { + return tl::unexpected(detail::from_core_error(stream_result.error())); + } + + return std::optional(std::move(stream_result.value())); +} + +/// Get the entry associated with the given cache key if it exists, or insert +/// and return an entry specified by running the given closure. +/// +/// The closure is only run when no value is present for the key, and no other +/// client is in the process of setting it. It takes no arguments, and returns +/// either a `CacheEntry` describing the entry to set, or `std::nullopt` in the +/// case of error. +/// +/// Example successful insertion: +/// ```cpp +/// auto value = get_or_set_with("my_key", []() -> std::optional { +/// return CacheEntry{http::Body("hello!"), std::chrono::nanoseconds(6000)}; +/// }).value(); +/// ``` +template +tl::expected, CacheError> +get_or_set_with(std::string_view key, F make_entry) { + return get_or_set_with( + std::span(reinterpret_cast(key.data()), key.size()), + std::move(make_entry)); +} + +/// Purge the entry associated with the given cache key. +/// +/// To configure the behavior of the purge, such as to purge globally +/// rather than within the POP, use `purge_with_opts()`. +/// +/// Note: Purged values may persist in cache for a short time (~150ms or +/// less) after this function returns. +/// +/// Example: +/// ```cpp +/// std::vector key_bytes = {0x01, 0x02, 0x03}; +/// purge(key_bytes); +/// ``` +tl::expected purge(std::span key); + +/// Purge the entry associated with the given cache key. +/// +/// To configure the behavior of the purge, such as to purge globally +/// rather than within the POP, use `purge_with_opts()`. +/// +/// Note: Purged values may persist in cache for a short time (~150ms or +/// less) after this function returns. +/// +/// Example: +/// ```cpp +/// purge("my_key"); +/// ``` +inline tl::expected purge(std::string_view key) { + return purge(std::span(reinterpret_cast(key.data()), + key.size())); +} + +/// Purge the entry associated with the given cache key with specific +/// options. +/// +/// The `PurgeOptions` argument determines the scope of the purge +/// operation. +/// +/// Note: Purged values may persist in cache for a short time (~150ms or +/// less) after this function returns. +/// +/// Example POP-scoped purge (default): +/// ```cpp +/// std::vector key_bytes = {0x01, 0x02, 0x03}; +/// purge_with_opts(key_bytes, PurgeOptions::pop_scope()); +/// // Equivalent to just: purge(key_bytes); +/// ``` +/// +/// Example global-scoped purge: +/// ```cpp +/// std::vector key_bytes = {0x01, 0x02, 0x03}; +/// purge_with_opts(key_bytes, PurgeOptions::global_scope()); +/// ``` +tl::expected +purge_with_opts(std::span key, const PurgeOptions &opts); + +/// Purge the entry associated with the given cache key with specific +/// options. +/// +/// The `PurgeOptions` argument determines the scope of the purge +/// operation. +/// +/// Note: Purged values may persist in cache for a short time (~150ms or +/// less) after this function returns. +/// +/// Example POP-scoped purge (default): +/// ```cpp +/// purge_with_opts("my_key", PurgeOptions::pop_scope()); +/// // Equivalent to just: purge("my_key"); +/// ``` +/// +/// Example global-scoped purge: +/// ```cpp +/// purge_with_opts("my_key", PurgeOptions::global_scope()); +/// ``` +inline tl::expected +purge_with_opts(std::string_view key, const PurgeOptions &opts) { + return purge_with_opts( + std::span(reinterpret_cast(key.data()), key.size()), + opts); +} + +} // namespace fastly::cache::simple + +#endif // FASTLY_CACHE_SIMPLE_H diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..e271e8d --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,21 @@ +use sha2::{Digest, Sha256}; +use std::fmt::Write; + +use crate::ffi::PurgeScope; + +// Copied from fastly/src/cache/simple.rs with some edits, as that function is not exposed in the public API. +pub fn f_cache_surrogate_key_for_cache_key(key: &[u8], scope: PurgeScope) -> String { + let mut sha = Sha256::new(); + sha.update(key); + if let PurgeScope::Pop = scope { + // if the POP string is empty for some reason, this will amount to a global purge + // for now which is the safer choice + let pop = fastly::compute_runtime::pop(); + sha.update(pop); + } + let mut sk_str = String::new(); + for b in sha.finalize() { + write!(&mut sk_str, "{b:02X}").expect("writing to a String is infallible"); + } + sk_str +} diff --git a/src/cpp/cache/core.cpp b/src/cpp/cache/core.cpp index f5101ae..090953f 100644 --- a/src/cpp/cache/core.cpp +++ b/src/cpp/cache/core.cpp @@ -257,8 +257,8 @@ Transaction::insert(std::chrono::nanoseconds max_age) && { std::move(options)); } -TransactionLookupBuilder Transaction::lookup(CacheKey key) { - return TransactionLookupBuilder(std::move(key)); +TransactionLookupBuilder Transaction::lookup(std::span key) { + return TransactionLookupBuilder(std::vector(key.begin(), key.end())); } std::optional Transaction::found() const { @@ -683,7 +683,9 @@ Found::to_stream_from_range(std::optional from, return Body::from_handle(body_handle); } -LookupBuilder lookup(CacheKey key) { return LookupBuilder(std::move(key)); } +LookupBuilder lookup(std::span key) { + return LookupBuilder(std::vector(key.begin(), key.end())); +} LookupBuilder LookupBuilder::header_values(std::string_view name, @@ -731,9 +733,11 @@ tl::expected, CacheError> LookupBuilder::execute() && { return std::nullopt; } -InsertBuilder insert(CacheKey key, std::chrono::nanoseconds max_age) { +InsertBuilder insert(std::span key, + std::chrono::nanoseconds max_age) { WriteOptions options(max_age); - return InsertBuilder(std::move(key), std::move(options)); + return InsertBuilder(std::vector(key.begin(), key.end()), + std::move(options)); } InsertBuilder @@ -808,7 +812,9 @@ tl::expected InsertBuilder::execute() && { return http::StreamingBody::from_body_handle(body_handle); } -ReplaceBuilder replace(CacheKey key) { return ReplaceBuilder(std::move(key)); } +ReplaceBuilder replace(std::span key) { + return ReplaceBuilder(std::vector(key.begin(), key.end())); +} tl::expected ReplaceBuilder::begin() && { auto [options, options_mask] = as_abi(options_); diff --git a/src/cpp/cache/simple.cpp b/src/cpp/cache/simple.cpp new file mode 100644 index 0000000..485ed26 --- /dev/null +++ b/src/cpp/cache/simple.cpp @@ -0,0 +1,114 @@ +// The Compute Simple Cache API. +// +// This is a non-durable key-value API backed by the same cache platform as the +// Core Cache API. +// +// ## Cache scope and purging +// +// Cache entries are scoped to Fastly points of presence (POPs): the value set +// for a key in one POP will not be visible in any other POP. +// +// Purging is also scoped to a POP by default, but can be configured to purge +// globally with Fastly's purging feature. +// +// ## Interoperability +// +// The Simple Cache API is implemented in terms of the Core Cache API. +// Items inserted with the Core Cache API can be read by the Simple Cache API, +// and vice versa. However, some metadata and advanced features like +// revalidation may be not be available via the Simple Cache API. + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fastly::cache::simple { +namespace detail { +// Helper to convert core::CacheError to simple::CacheError +fastly::cache::simple::CacheError +from_core_error(const fastly::cache::core::CacheError &err) { + using SimpleCode = fastly::cache::simple::CacheError::Code; + using CoreCode = fastly::cache::core::CacheError::Code; + + switch (err.code()) { + case CoreCode::LimitExceeded: + return fastly::cache::simple::CacheError(SimpleCode::LimitExceeded); + case CoreCode::InvalidOperation: + return fastly::cache::simple::CacheError(SimpleCode::InvalidOperation); + case CoreCode::Unsupported: + return fastly::cache::simple::CacheError(SimpleCode::Unsupported); + default: + return fastly::cache::simple::CacheError(SimpleCode::Other); + } +} +std::string surrogate_key_for_cache_key(std::span key, + PurgeOptions::Scope scope) { + return {fastly::sys::cache::f_cache_surrogate_key_for_cache_key( + rust::Slice(key.data(), key.size()), + static_cast(scope))}; +} +} // namespace detail + +tl::expected, CacheError> +get(std::span key) { + auto lookup_result = core::lookup(key).execute(); + if (!lookup_result.has_value()) { + return tl::unexpected(detail::from_core_error(lookup_result.error())); + } + + auto found_opt = lookup_result.value(); + if (!found_opt.has_value()) { + return std::nullopt; + } + + auto stream_result = found_opt->to_stream(); + if (!stream_result.has_value()) { + return tl::unexpected(detail::from_core_error(stream_result.error())); + } + + return std::optional(std::move(stream_result.value())); +} + +tl::expected +get_or_set(std::span key, http::Body value, + std::chrono::nanoseconds ttl) { + return get_or_set_with(key, + [val = std::move(value), + ttl]() mutable -> std::optional { + return CacheEntry{std::move(val), ttl}; + }) + .and_then([](std::optional opt) + -> tl::expected { + // The provided closure is infallible, so we always have a value + if (opt.has_value()) { + return std::move(opt.value()); + } + // Should never happen, but if it does, treat it as an error + std::cerr << "get_or_set_with did not return a value\n"; + abort(); + }); +} + +tl::expected purge(std::span key) { + return purge_with_opts(key, PurgeOptions::pop_scope()); +} + +tl::expected +purge_with_opts(std::span key, const PurgeOptions &opts) { + std::string surrogate_key = + detail::surrogate_key_for_cache_key(key, opts.scope()); + + auto purge_result = http::purge::purge_surrogate_key(surrogate_key); + if (!purge_result.has_value()) { + return tl::unexpected(CacheError(CacheError::Code::Purge)); + } + + return {}; +} + +} // namespace fastly::cache::simple \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 228047e..a99e582 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ #![allow(clippy::boxed_local, clippy::needless_lifetimes)] use backend::*; +use cache::*; use config_store::*; use device_detection::*; use error::*; @@ -17,6 +18,7 @@ use secret_store::*; use security::*; mod backend; +mod cache; mod config_store; mod device_detection; mod error; @@ -234,6 +236,12 @@ mod ffi { NoContent = 3, } + #[namespace = "fastly::sys::cache"] + pub enum PurgeScope { + Pop = 0, + Global = 1, + } + #[namespace = "fastly::sys::error"] extern "Rust" { type FastlyError; @@ -1118,7 +1126,6 @@ mod ffi { } #[namespace = "fastly::sys::esi"] - extern "Rust" { type Processor; pub unsafe fn m_esi_processor_process_response( @@ -1149,6 +1156,11 @@ mod ffi { is_escaped_content: bool, ) -> Box; } + + #[namespace = "fastly::sys::cache"] + extern "Rust" { + fn f_cache_surrogate_key_for_cache_key(key: &[u8], scope: PurgeScope) -> String; + } } // Some types (notably callback functions) are not supported by CXX at all, so we diff --git a/test/cache_core.cpp b/test/cache_core.cpp index 8c9f4ef..c9374d5 100644 --- a/test/cache_core.cpp +++ b/test/cache_core.cpp @@ -8,11 +8,21 @@ using namespace fastly::cache::core; using namespace std::string_literals; using namespace std::chrono_literals; +TEST_CASE("strings and spans", "[cache_core]") { + std::string key = "my_key"; + auto result = lookup(key).execute(); + REQUIRE(result); + REQUIRE(!*result); + + std::vector key_bytes = {0x01, 0x02, 0x03}; + auto result2 = lookup(key_bytes).execute(); + REQUIRE(result2); + REQUIRE(!*result2); +} + TEST_CASE("cache::core::insert", "[cache_core]") { - auto key_string("cache::core::insert"s); - std::vector key(key_string.begin(), key_string.end()); auto contents("contents here"s); - auto writer = insert(key, 1234ns) + auto writer = insert("cache::core::insert", 1234ns) .surrogate_keys({"my_key"s}) .known_length(contents.size()) .execute(); @@ -25,25 +35,21 @@ TEST_CASE("cache::core::insert", "[cache_core]") { } TEST_CASE("failed cache::core::lookup", "[cache_core]") { - auto key_string("cache::core::lookup"s); - std::vector key(key_string.begin(), key_string.end()); - auto found = lookup(key).execute(); + auto found = lookup("cache::core::lookup").execute(); REQUIRE(found); REQUIRE(!*found); } TEST_CASE("cache::core::lookup", "[cache_core]") { - auto key_string("cache::core::lookup"s); - std::vector key(key_string.begin(), key_string.end()); auto contents("deadbeef badc0ffee"s); - auto writer = - insert(key, std::chrono::duration_cast(1s)) - .execute(); + auto writer = insert("cache::core::lookup", + std::chrono::duration_cast(1s)) + .execute(); *writer << contents; REQUIRE(writer->finish()); - auto found = lookup(key).execute(); + auto found = lookup("cache::core::lookup").execute(); REQUIRE(found); REQUIRE(*found); auto stream = (*found)->to_stream(); @@ -54,10 +60,9 @@ TEST_CASE("cache::core::lookup", "[cache_core]") { } TEST_CASE("cache::core::Found::user_metadata", "[cache_core]") { - auto key_string("cache::core::Found::user_metadata"s); - std::vector key(key_string.begin(), key_string.end()); - auto metadata = std::vector{0xde, 0xad, 0xbe, 0xef}; + auto key = "cache::core::Found::user_metadata"; auto contents("deadbeef badc0ffee"s); + auto metadata = std::vector{0xde, 0xad, 0xbe, 0xef}; auto writer = insert(key, std::chrono::duration_cast(1s)) .user_metadata(metadata) @@ -74,8 +79,7 @@ TEST_CASE("cache::core::Found::user_metadata", "[cache_core]") { } TEST_CASE("cache::core::Transaction", "[cache_core]") { - auto key_string("cache::core::Transaction"s); - std::vector key(key_string.begin(), key_string.end()); + auto key = "cache::core::Transaction"; auto contents("contents here"s); auto transaction = Transaction::lookup(key).execute(); From 238721b75e39c6fb03e9c90d15a09301528a3e9b Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Mon, 16 Mar 2026 14:59:41 +0000 Subject: [PATCH 5/8] Tests --- test/cache_core.cpp | 110 ++++++++++++++++- test/cache_simple.cpp | 274 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 test/cache_simple.cpp diff --git a/test/cache_core.cpp b/test/cache_core.cpp index c9374d5..04b5c1a 100644 --- a/test/cache_core.cpp +++ b/test/cache_core.cpp @@ -15,7 +15,8 @@ TEST_CASE("strings and spans", "[cache_core]") { REQUIRE(!*result); std::vector key_bytes = {0x01, 0x02, 0x03}; - auto result2 = lookup(key_bytes).execute(); + std::span key_span(key_bytes); + auto result2 = lookup(key_span).execute(); REQUIRE(result2); REQUIRE(!*result2); } @@ -106,6 +107,113 @@ TEST_CASE("cache::core::Transaction", "[cache_core]") { } } +TEST_CASE("cache::core::Transaction with string_view", "[cache_core]") { + std::string_view key = "cache::core::Transaction::string_view"; + auto contents("string view key test"s); + + auto transaction = Transaction::lookup(key).execute(); + REQUIRE(transaction); + + if (transaction->must_insert()) { + auto writer = + std::move(*transaction) + .insert(std::chrono::duration_cast(1s)) + .execute(); + REQUIRE(writer); + *writer << contents; + REQUIRE(writer->finish()); + } +} + +TEST_CASE("cache::core::Transaction with byte span", "[cache_core]") { + std::vector key_bytes = {0xca, 0xfe, 0xba, 0xbe}; + auto contents("byte span key test"s); + + auto transaction = Transaction::lookup(key_bytes).execute(); + REQUIRE(transaction); + + if (transaction->must_insert()) { + auto writer = + std::move(*transaction) + .insert(std::chrono::duration_cast(1s)) + .execute(); + REQUIRE(writer); + *writer << contents; + REQUIRE(writer->finish()); + } +} + +TEST_CASE("cache::core::Found metadata", "[cache_core]") { + auto key = "cache::core::Found::metadata"; + auto contents("test content"s); + auto ttl = std::chrono::duration_cast(10s); + + auto writer = insert(key, ttl).execute(); + REQUIRE(writer); + *writer << contents; + REQUIRE(writer->finish()); + + auto found = lookup(key).execute(); + REQUIRE(found); + REQUIRE(*found); + + // Check metadata methods + auto age = (*found)->age(); + REQUIRE(age.count() >= 0); + + auto max_age = (*found)->max_age(); + REQUIRE(max_age == ttl); + + auto remaining = (*found)->remaining_ttl(); + REQUIRE(remaining.count() > 0); + REQUIRE(remaining.count() <= ttl.count()); + + REQUIRE((*found)->is_usable()); + REQUIRE_FALSE((*found)->is_stale()); +} + +TEST_CASE("cache::core::insert string overloads", "[cache_core]") { + std::string key = "cache::core::insert::string_overload"; + auto contents("string overload test"s); + + // Test the inline string_view overload + auto writer = insert(key, 1s).execute(); + REQUIRE(writer); + *writer << contents; + REQUIRE(writer->finish()); + + // Verify with string lookup + auto found = lookup(key).execute(); + REQUIRE(found); + REQUIRE(*found); +} + +TEST_CASE("cache::core::Transaction execute_and_stream_back", "[cache_core]") { + auto key = "cache::core::Transaction::stream_back"; + auto contents("stream back test"s); + + auto transaction = Transaction::lookup(key).execute(); + REQUIRE(transaction); + + if (transaction->must_insert()) { + auto result = + std::move(*transaction) + .insert(std::chrono::duration_cast(1s)) + .execute_and_stream_back(); + REQUIRE(result); + + auto &[writer, found] = *result; + writer << contents; + REQUIRE(writer.finish()); + + // Read back immediately using the found object + auto stream = found.to_stream(); + REQUIRE(stream); + std::string from_stream(std::istreambuf_iterator(*stream), {}); + REQUIRE(from_stream == contents); + } +} + // Required due to https://github.com/WebAssembly/wasi-libc/issues/485 #include int main(int argc, char *argv[]) { return Catch::Session().run(argc, argv); } diff --git a/test/cache_simple.cpp b/test/cache_simple.cpp new file mode 100644 index 0000000..fab3db7 --- /dev/null +++ b/test/cache_simple.cpp @@ -0,0 +1,274 @@ +#include +#include +#include +#include +#include +#include + +using namespace fastly::http; +using namespace fastly::cache::simple; +using namespace std::string_literals; +using namespace std::chrono_literals; + +TEST_CASE("cache::simple::get string key", "[cache_simple]") { + auto key = "cache::simple::get::string"; + + // First get should return nullopt (not found) + auto result = get(key); + REQUIRE(result); + REQUIRE(!result->has_value()); +} + +TEST_CASE("cache::simple::get byte span key", "[cache_simple]") { + std::vector key_bytes = {0xca, 0xfe, 0xba, 0xbe}; + std::span key_span(key_bytes); + + // First get should return nullopt (not found) + auto result = get(key_span); + REQUIRE(result); + REQUIRE(!result->has_value()); +} + +TEST_CASE("cache::simple::get_or_set string key", "[cache_simple]") { + auto key = "cache::simple::get_or_set::string"; + auto contents = "test content"s; + + // First call should insert + auto result1 = get_or_set(key, Body(contents), 10s); + REQUIRE(result1); + std::string cached1 = result1->take_body_string(); + REQUIRE(cached1 == contents); + + // Second call should retrieve cached value + auto result2 = get_or_set(key, Body("different content"), 10s); + REQUIRE(result2); + std::string cached2 = result2->take_body_string(); + REQUIRE(cached2 == contents); // Should be original, not "different content" +} + +TEST_CASE("cache::simple::get_or_set byte span key", "[cache_simple]") { + std::vector key_bytes = {0x01, 0x02, 0x03, 0x04}; + std::span key_span(key_bytes); + auto contents = "byte span test"s; + + // First call should insert + auto result1 = get_or_set(key_span, Body(contents), 10s); + REQUIRE(result1); + std::string cached1 = result1->take_body_string(); + REQUIRE(cached1 == contents); + + // Verify with get + auto result2 = get(key_span); + REQUIRE(result2); + REQUIRE(result2->has_value()); + std::string cached2 = (*result2)->take_body_string(); + REQUIRE(cached2 == contents); +} + +TEST_CASE("cache::simple::get_or_set_with string key success", + "[cache_simple]") { + auto key = "cache::simple::get_or_set_with::success"; + auto contents = "closure content"s; + + int call_count = 0; + auto make_entry = [&]() -> std::optional { + call_count++; + return CacheEntry{Body(contents), 10s}; + }; + + // First call should run closure + auto result1 = get_or_set_with(key, make_entry); + REQUIRE(result1); + REQUIRE(result1->has_value()); + REQUIRE(call_count == 1); + std::string cached1 = (*result1)->take_body_string(); + REQUIRE(cached1 == contents); + + // Second call should NOT run closure (cache hit) + auto result2 = get_or_set_with(key, make_entry); + REQUIRE(result2); + REQUIRE(result2->has_value()); + REQUIRE(call_count == 1); // Still 1, closure not called again +} + +TEST_CASE("cache::simple::get_or_set_with failure returns nullopt", + "[cache_simple]") { + auto key = "cache::simple::get_or_set_with::failure"; + + auto make_entry = []() -> std::optional { + // Simulate failure by returning nullopt + return std::nullopt; + }; + + auto result = get_or_set_with(key, make_entry); + REQUIRE(!result); + REQUIRE(result.error().code() == CacheError::Code::GetOrSet); +} + +TEST_CASE("cache::simple::get_or_set_with byte span key", "[cache_simple]") { + std::vector key_bytes = {0xde, 0xad, 0xbe, 0xef}; + std::span key_span(key_bytes); + auto contents = "byte span closure"s; + + auto make_entry = [&]() -> std::optional { + return CacheEntry{Body(contents), 10s}; + }; + + auto result = get_or_set_with(key_span, make_entry); + REQUIRE(result); + REQUIRE(result->has_value()); + std::string cached = (*result)->take_body_string(); + REQUIRE(cached == contents); +} + +TEST_CASE("cache::simple::purge string key", "[cache_simple]") { + auto key = "cache::simple::purge::string"; + auto contents = "to be purged"s; + + auto insert_result = get_or_set(key, Body(contents), 10s); + REQUIRE(insert_result); + auto get_result1 = get(key); + REQUIRE(get_result1); + REQUIRE(get_result1->has_value()); + auto purge_result = purge(key); + REQUIRE(purge_result); + + // Verify it's gone (may still be present for a short time due to eventual + // consistency) + sleep(1); // Sleep for 1 second to allow purge to propagate + auto get_result2 = get(key); + REQUIRE(get_result2); + REQUIRE(!get_result2->has_value()); +} + +TEST_CASE("cache::simple::purge byte span key", "[cache_simple]") { + std::vector key_bytes = {0x05, 0x06, 0x07, 0x08}; + std::span key_span(key_bytes); + auto contents = "byte span purge"s; + + auto insert_result = get_or_set(key_span, Body(contents), 10s); + REQUIRE(insert_result); + auto get_result1 = get(key_span); + REQUIRE(get_result1); + REQUIRE(get_result1->has_value()); + auto purge_result = purge(key_span); + REQUIRE(purge_result); + + // Verify it's gone (may still be present for a short time due to eventual + // consistency) + sleep(1); // Sleep for 1 second to allow purge to propagate + auto get_result2 = get(key_span); + REQUIRE(get_result2); + REQUIRE(!get_result2->has_value()); +} + +TEST_CASE("cache::simple::purge_with_opts pop scope", "[cache_simple]") { + auto key = "cache::simple::purge::pop_scope"; + auto contents = "pop scope test"s; + + auto insert_result = get_or_set(key, Body(contents), 10s); + REQUIRE(insert_result); + + auto purge_result = purge_with_opts(key, PurgeOptions::pop_scope()); + REQUIRE(purge_result); +} + +TEST_CASE("cache::simple::purge_with_opts global scope", "[cache_simple]") { + auto key = "cache::simple::purge::global_scope"; + auto contents = "global scope test"s; + + auto insert_result = get_or_set(key, Body(contents), 10s); + REQUIRE(insert_result); + + auto purge_result = purge_with_opts(key, PurgeOptions::global_scope()); + REQUIRE(purge_result); +} + +TEST_CASE("cache::simple::CacheEntry construction", "[cache_simple]") { + auto contents = "entry content"s; + auto ttl = 30s; + + CacheEntry entry(Body(contents), ttl); + + REQUIRE(entry.ttl() == ttl); + std::string body_str = entry.value().take_body_string(); + REQUIRE(body_str == contents); +} + +TEST_CASE("cache::simple::PurgeOptions", "[cache_simple]") { + auto pop_opts = PurgeOptions::pop_scope(); + REQUIRE(pop_opts.scope() == PurgeOptions::Scope::Pop); + + auto global_opts = PurgeOptions::global_scope(); + REQUIRE(global_opts.scope() == PurgeOptions::Scope::Global); +} + +TEST_CASE("cache::simple::get_or_set with different TTLs", "[cache_simple]") { + auto key = "cache::simple::ttl_test"; + auto contents = "ttl test"s; + + auto result = get_or_set(key, Body(contents), 1ns); + REQUIRE(result); + + // The value might expire quickly, but the operation should succeed + std::string cached = result->take_body_string(); + REQUIRE(cached == contents); +} + +TEST_CASE("cache::simple::round trip with get_or_set and get", + "[cache_simple]") { + auto key = "cache::simple::round_trip"; + auto contents = "round trip content"s; + + auto set_result = get_or_set(key, Body(contents), 60s); + REQUIRE(set_result); + std::string set_value = set_result->take_body_string(); + REQUIRE(set_value == contents); + + auto get_result = get(key); + REQUIRE(get_result); + REQUIRE(get_result->has_value()); + std::string get_value = (*get_result)->take_body_string(); + REQUIRE(get_value == contents); +} + +TEST_CASE("cache::simple::large value", "[cache_simple]") { + auto key = "cache::simple::large_value"; + + // Create a large string (100KB) + std::string large_content(100 * 1024, 'x'); + + auto result = get_or_set(key, Body(large_content), 60s); + REQUIRE(result); + std::string cached = result->take_body_string(); + REQUIRE(cached.size() == large_content.size()); + REQUIRE(cached == large_content); +} + +TEST_CASE("cache::simple::empty value", "[cache_simple]") { + auto key = "cache::simple::empty_value"; + std::string empty_content = ""; + + auto result = get_or_set(key, Body(empty_content), 10s); + REQUIRE(result); + std::string cached = result->take_body_string(); + REQUIRE(cached.empty()); +} + +TEST_CASE("cache::simple::binary data", "[cache_simple]") { + auto key = "cache::simple::binary_data"; + + // Create binary data with null bytes + std::vector binary_data = {0x00, 0x01, 0x02, 0xff, 0xfe, 0x00, 0x7f}; + std::string binary_string(binary_data.begin(), binary_data.end()); + + auto result = get_or_set(key, Body(binary_string), 60s); + REQUIRE(result); + std::string cached = result->take_body_string(); + REQUIRE(cached.size() == binary_data.size()); + REQUIRE(cached == binary_string); +} + +// Required due to https://github.com/WebAssembly/wasi-libc/issues/485 +#include +int main(int argc, char *argv[]) { return Catch::Session().run(argc, argv); } From 4f629178321344719f6209a7e1d17cb277530a1c Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Mon, 16 Mar 2026 14:59:51 +0000 Subject: [PATCH 6/8] Simple changes --- include/fastly/cache/simple.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/fastly/cache/simple.h b/include/fastly/cache/simple.h index d8f1da0..608908a 100644 --- a/include/fastly/cache/simple.h +++ b/include/fastly/cache/simple.h @@ -2,6 +2,7 @@ #define FASTLY_CACHE_SIMPLE_H #include +#include #include #include #include From 39417c55de99d93896e4484966677d721ae80767 Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Mon, 16 Mar 2026 15:09:37 +0000 Subject: [PATCH 7/8] sha2 --- Cargo.lock | 47 ++++++++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 6 +++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0b84006..704904a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bytes" version = "1.10.1" @@ -111,6 +120,16 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cxx" version = "1.0.158" @@ -189,6 +208,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -263,7 +292,7 @@ dependencies = [ "serde_json", "serde_repr", "serde_urlencoded", - "sha2", + "sha2 0.9.9", "smallvec", "thiserror 1.0.69", "time", @@ -305,6 +334,7 @@ dependencies = [ "log", "log-fastly", "quick-xml", + "sha2 0.10.9", "thiserror 2.0.12", ] @@ -733,13 +763,24 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ - "block-buffer", + "block-buffer 0.9.0", "cfg-if", "cpufeatures", - "digest", + "digest 0.9.0", "opaque-debug", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + [[package]] name = "shlex" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 5acd0f6..751e5c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,10 @@ [package] name = "fastly-sys" version = "0.6.0" -authors = ["Kat Marchán ", "Sy Brand "] +authors = [ + "Kat Marchán ", + "Sy Brand ", +] edition = "2024" [dependencies] @@ -14,6 +17,7 @@ log-fastly = "0.11.9" thiserror = "2.0.12" esi = "0.6.1" quick-xml = "0.38.3" +sha2 = "0.10.9" [build-dependencies] cxx-build = "=1.0.158" From 5adc228d56a00ce7aa7bd2874aa8700e58c4148e Mon Sep 17 00:00:00 2001 From: Sy Brand Date: Mon, 16 Mar 2026 15:17:24 +0000 Subject: [PATCH 8/8] Remove unused header inclued --- src/cpp/cache/simple.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/cpp/cache/simple.cpp b/src/cpp/cache/simple.cpp index 485ed26..66ba5b7 100644 --- a/src/cpp/cache/simple.cpp +++ b/src/cpp/cache/simple.cpp @@ -21,7 +21,6 @@ #include #include #include -#include #include #include #include