Repository navigation
Port MemoryCache.read() to Rust, behind an autogate - #8066
bartlomieju wants to merge 8 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #8066 +/- ##
==========================================
+ Coverage 68.84% 68.98% +0.14%
==========================================
Files 734 742 +8
Lines 192834 194499 +1665
Branches 30009 30095 +86
==========================================
+ Hits 132751 134173 +1422
- Misses 42441 42667 +226
- Partials 17642 17659 +17 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
73517e9 to
0a14de1
Compare
guybedford
left a comment
There was a problem hiding this comment.
AI review suggestions:
- Argument errors reject instead of throwing (
lib.rs:62). C++ JSG throws synchronously when an argument has the wrong type, soCACHE.read(123)andCACHE.read('k', 5)throw in C++ but return a rejected promise in Rust. Only "Key too large." and later failures should reject. delete()records nomemory_cache_deletespan. C++ records one withkeyanddelete_completed. The parity scenario never callsdelete, so the span parity test doesn't catch it.- A thenable or non-object fallback result behaves differently (
fallback.rs:177,fallback.rs:315). If the fallback returns{ then(r) { r({ value: 'th' }) } }, C++ resolvesundefined(it treats the object itself as the result) and Rust resolves'th'. If it returns42, C++ rejects with "Callback returned incorrect type; expected 'Promise'" and Rust with "The fallback must produce an object with a value property." - Errors thrown while serializing keep their original identity in Rust (
fallback.rs:292). If a getter on the value throws aMyErr, C++ rejects with a new plainErrorand Rust rejects with the originalMyErr. A thrown string becomes anErrorin C++ but stays a string in Rust, and thefallback_errortag changes with it. Rust's behaviour is arguably better, but it's a divergence to either accept explicitly or match. - A JS-level parity test with these cases, run in both the
@and@all-autogatesvariants, would back up the parity claim for what JS sees and not only for the spans.
…8030) Give Rust APIs the two IoContext operations that porting MemoryCache.read() to Rust (#8066) needs: await_io_and_run(), to wait for I/O and then resume the request, and add_object(), to tie a value's lifetime to the request. await_io_and_run() wraps a new C++ IoContext::awaitIoAndRun(), which shares its implementation with awaitIo() but returns no promise: the callback settles whatever it needs to itself, and failures fail the task as for addTask(). add_object() wraps addObject() and returns an IoOwned with IoOwn's lifetime rules. These replace the unimplemented jsg::Lock::await_io() stub, and jsg::Error's new_internal_error() becomes public for the port to use. Tests cover both operations in requests to a real worker, including Durable Objects, failures and panics. No JS-visible change; nothing outside tests uses these yet.
Native code that holds no jsg::Lock, such as Rust JSG's shims, can create a DOMException with any name through it. It takes and returns only V8 and KJ types, so its callers don't depend on how DOMException is implemented. newDataCloneError() becomes a wrapper around it, unchanged in behaviour.
Rust JSG's DOMException error types (AbortError, DataCloneError, NotSupportedError, ...) were thrown as a plain Error. They are now thrown as a DOMException with that name, as C++ JSG_REQUIRE(..., DOMAbortError, ...) throws, created through jsg::newDOMException(). Lock::dom_exception() creates one with any name, as jsg::Lock::domException() does. Creating a DOMException needs the current context, unlike creating an Error. So while an exception or a termination is pending, or if creating it throws, Lock::exception() and Lock::dom_exception() fail with a pending error and Lock::throw_exception() throws nothing, so the pending exception propagates. Error::to_local() now returns a Result for this. A fast callback never throws one: only methods returning no Result have a fast callback, and they throw only TypeErrors (now debug-asserted). SyntaxError stays the JavaScript SyntaxError. A DOMException named SyntaxError needs its own ExceptionType variant, which is left for later. Compatibility: no shipped Rust API returns a DOMException error type, so no Worker sees a change. Safety rule 3 lists newDOMException() beside the ser.h hooks that Rust shims may call.
A #[jsg_struct] field can now hold a JavaScript value, as a C++
JSG_STRUCT field of type jsg::JsRef<T> does. Such a field is a
jsg::v8::TracedGlobal<M>, which now implements Type, ToJS and FromJS
and converts as a v8::Local<'s, M> does:
- TracedGlobal<Value> takes any value, and a missing property reads as
undefined, as with jsg::JsRef<jsg::JsValue>.
- TracedGlobal<Object> (and the other markers with a FromJS for their
Local) takes only a value of that type, with the Local's TypeError
("Expected Object but got number").
- Converting to JavaScript gives back the value itself.
The macro needed no change: it already converts each field through its
own type and traces every field. So a struct with such a field holds GC
references, and a resource that keeps it traces the value, so a cycle
through it is collected.
Porting MemoryCache (#8066) needs this. Its fallback produces a
CacheValueProduceResult, whose value is a jsg::JsRef<jsg::JsValue>.
The Rust port reads that object by hand today. With this change, and
with typed promises (#8153), it can be a struct carried by a
jsg::Promise<'s, T>. That needs a 'static T, so the field is a
TracedGlobal and not a Local with the struct's lifetime.
Tests cover keeping the value's identity (with and without
fast_jsg_struct), any value and missing properties, a rejected
non-object, a struct kept past the scope it was converted in, and the
collection of a cycle through such a field. No JS-visible change;
nothing outside tests uses this yet.
Wraps v8::Value::ToDetailString(), which describes a value as C++ kj::str() of a v8::Local does, without running JavaScript such as a toString() method. The Rust MemoryCache uses it for the error tag of a failed fallback's trace span, which must match the C++ one.
Adds src/workerd/api/memory-cache-api, a Rust MemoryCache over the cache in src/rust/memory-cache, built on the io-context crate's await_io_and_run(), add_object() and trace spans. With the RUST_MEMORY_CACHE autogate enabled, memory cache bindings are the Rust MemoryCache; otherwise they stay the C++ one. MemoryCacheProvider::bindForRust() hands the Rust MemoryCache the same shared namespaces that the C++ one uses. A new test in memory-cache-test.c++ runs reads that cover every path of read() against both implementations, and checks they record the same spans. SpanRecorder now records spans in the order they open, since the order they end in depends on when each implementation releases them. New JS tests cover a fallback abandoned when its request ends, the async context a fallback runs in, the key a fallback gets for a lone surrogate, a NaN expiration, and a fallback inside a Durable Object's blockConcurrencyWhile(). They run against both implementations through the @all-autogates variant.
Returns the global object of the entered context, as C++ JSG's jsg::Lock::global() does. C++ JSG calls a jsg::Function with it as the receiver; the Rust MemoryCache needs it to call a fallback the same way.
- delete() records a memory_cache_delete span tagged with the key and delete_completed, and so, like C++, fails outside a request. - The fallback is called with globalThis as `this`, as C++ JSG calls a jsg::Function, rather than undefined. - A key that is not a string names the method that got it, and a fallback that is not a function is "not of type 'function'", as in C++. The parity scenario in memory-cache-test.c++ now deletes a cached key and one never cached, and runs with memory_cache_delete on. New JS tests cover the fallback's receiver, the TypeErrors for invalid arguments, and delete() in the global scope; each fails against the Rust implementation without these changes.
JSG calls a C++ method that returns a promise through LiftKj_<v8::Local<v8::Promise>>: under capture_async_api_throws (on by default since 2022-10-31) what the method throws rejects the promise, and otherwise it is thrown. The Rust read() rejected in both cases. It now throws unless the flag is on, and under the flag catches what it throws, a JavaScript exception included, and rejects with it, as LiftKj_ does. A key that is too large still rejects either way, since memory-cache.c++ rejects it explicitly. memory-cache-throws-test.js checks, for a key that is not a string, a fallback that is not a function and a read in the global scope, that read() throws or rejects as the flag says. It also checks that a key that is too large rejects and that delete() throws whatever the flag. memory-cache-test.wd-test runs it in two services, one with the flag as the compatibility date sets it and one that enables it, so the @ and @all-autogates variants cover both settings against both implementations. Without this change, the Rust implementation fails it with the flag off. Also restores the TODO(#7951) in memory-cache-test.c++ to its original form.
…+ does With Rust JSG throwing DOMExceptions, the Rust MemoryCache rejects a fallback's value that cannot be serialized with a DataCloneError DOMException, as the C++ one does. The parity scenario in memory-cache-test.c++ now includes such a value, so both implementations record the same memory_cache_fallback tags for it, and the JS test checks the rejection's name, code and that it is a DOMException.
f73a613 to
6ace636
Compare
|
Rebased onto #8210 (Throw DOMExceptions from Rust JSG), so this branch now carries its two commits until it lands. With it, the Rust Differences to fix before mergingI compared C++ and Rust on about 40 observable behaviours of
The leader and waiter paths cross an event-loop turn, so their microtask counts still need measuring. Coverage to add before merging
The plan for the first three is a second Open questionWhatever Rust copies from C++ for A and C becomes its permanent behaviour once the C++ implementation is removed, including the mangled type name in A's message. Should we copy these exactly, or accept them as differences? |
Ports
MemoryCache.read()to Rust JSG, behind a newRUST_MEMORY_CACHEautogate. This is the last PR of a stack: #7963 → #8030 → #8034 → this one.Note
This branch includes a cherry-pick of #8064 (Drop Rust JSG promise reaction data once a reaction runs), which the port depends on. When #8064 lands, I'll rebase and that commit will drop out. Please review it in #8064.
Commits to review
Local::to_detail_string()to Rust JSG. Wrapsv8::Value::ToDetailString(). It describes a value the way C++kj::str()of av8::Localdoes ("TypeError: message","[object Object]", and so on) without running any JavaScript. The Rust MemoryCache uses it for a failed fallback'sfallback_errortag.MemoryCache.read()to Rust, behind an autogate.src/workerd/api/memory-cache-apiholds the RustMemoryCache. It sits on top of the cache insrc/rust/memory-cacheand uses the io-context crate'sawait_io(),add_object()and trace spans.lib.rs:read(), key validation, and cache hits.fallback.rs: a read that waits as the leader (until it runs its fallback) or as a waiter (until the read it waits for finishes).trace.rs: the same spans and tags thatmemory-cache.c++records.MemoryCacheProvider::bindForRust()gives the Rust implementation the same shared namespaces the C++ one uses.workerd-api.c++creates the RustMemoryCachefor memory cache bindings when the autogate is enabled.Behaviour
The Rust implementation is meant to be indistinguishable from the C++ one, including its trace spans. A few details:
blockConcurrencyWhile()critical section, rather than waiting on the input gate.Testing
Span parity: a new test in
memory-cache-test.c++runs the same scenario against both implementations and checks they record the same spans.SpanRecordernow records spans in the order they open, not the order they end. When each implementation releases its spans is an implementation detail.New JS tests in
memory-cache-test.js:blockConcurrencyWhile().The
@all-autogatesvariant runs all the JS tests against the Rust implementation.Known gap
Rust JSG doesn't throw DOMExceptions yet (#7951). For a value that can't be serialized, Rust throws a plain
Errorwhere C++ throws aDataCloneError, so thefallback_errortags differ. The parity scenario leaves that case out, with aTODO(#7951).