diff --git a/.threading_canary b/.threading_canary index 40d41071089a..eb1ae458f8ee 100644 --- a/.threading_canary +++ b/.threading_canary @@ -1 +1 @@ -THIS looks like a job for Threading Canard!y!1!.. +... diff --git a/doc/audit/builtin_maps.rst b/doc/audit/builtin_maps.rst index 305282ef2b36..411a44d3c2f7 100644 --- a/doc/audit/builtin_maps.rst +++ b/doc/audit/builtin_maps.rst @@ -284,7 +284,7 @@ JavaScript engine version of the module cache, accessible by JavaScript endpoint ``js_runtime_options`` ~~~~~~~~~~~~~~~~~~~~~~ -QuickJS runtime memory, accessible by Javascript endpoint function. +QuickJS runtime options, used to configure runtimes created by CCF. **Key** Sentinel value 0, represented as a little-endian 64-bit unsigned integer. @@ -294,6 +294,15 @@ QuickJS runtime memory, accessible by Javascript endpoint function. :project: CCF :members: +``interpreter.flush`` +~~~~~~~~~~~~~~~~~~~~~~ +Used by transactions that set the JS application to signal to the interpreter cache system +that existing instances need to be flushed. + +**Key** Sentinel value 0, represented as a little-endian 64-bit unsigned integer. + +**Value** Boolean, represented as JSON. + ``endpoints`` ~~~~~~~~~~~~~ diff --git a/doc/build_apps/js_app_bundle.rst b/doc/build_apps/js_app_bundle.rst index 02aeefee5f7e..74603998cea9 100644 --- a/doc/build_apps/js_app_bundle.rst +++ b/doc/build_apps/js_app_bundle.rst @@ -340,3 +340,59 @@ If CCF is updated and introduces a newer JavaScript engine version, then any pre } .. note:: The operator RPC :http:GET:`/node/js_metrics` returns the size of the bytecode and whether it is used. If it is not used, then either no bytecode is stored or it needs to be re-compiled due to a CCF update. + +Reusing interpreters +~~~~~~~~~~~~~~~~~~~~ + +By default, every request executes in a freshly-constructed JS interpreter. This provides extremely strict sandboxing - the only interaction with other requests is transactionally via the KV - and so forbids the sharing of any global state. For some applications, this may lead to unnecessarily duplicated work. + +For instance, if your application needs to construct a large, immutable singleton object to process a request, that construction cost will be paid in each and every request. Requests could execute significantly faster if they were able to access and reuse a previously-constructed object, rather than constructing their own. JS libraries designed for other runtimes (such as Node) may benefit from this, as they expect to have a persistent global state. + +CCF supports this pattern with `interpreter reuse`. Applications may opt-in to persisting an interpreter, and all of its global state, to be reused by multiple requests. This means that expensive initialisation work can be done once, and the resulting objects stashed in the global state where future requests will reuse them. + +Note that this removes the sandboxing protections described above. If the contents of the global state change the result of a request's execution, then the execution will no longer be reproducible from the state recorded in the ledger, since the state of the interpreter cache will not be recorded. This should be avoided - reuse should only be used to make a handler `faster`, not to `change its behaviour`. + +This behaviour is controlled in ``app.json``, with the ``"interpreter_reuse"`` property on each endpoint. The default behaviour, taken when the field is omitted, is to avoid any interpreter reuse, providing strict sandboxing safety. To reuse an interpreter, set ``"interpreter_reuse"`` to an object of the form ``{"key": "foo"}``, where ``foo`` is an arbitrary, app-defined string. Interpreters will be shared between endpoints where this string matches. For instance: + +.. code-block:: json + + { + "endpoints": { + "/admin/modify": { + "post": { + "js_module": ..., + "interpreter_reuse": {"key": "admin_interp"} + } + }, + "/admin/admins": { + "get": { + "js_module": ..., + "interpreter_reuse": {"key": "admin_interp"} + }, + "post": { + "js_module": ..., + "interpreter_reuse": {"key": "admin_interp"} + } + }, + "/sum/{a}/{b}": { + "get": { + "js_module": ..., + "interpreter_reuse": {"key": "sum"} + } + }, + "/fast/and/small": { + "get": { + "js_module": ... + // No "interpreter_reuse" field + } + } + } + } + +In this example, each CCF node will store up-to 2 interpreters, and divides the endpoints into 3 classes: + +- Requests to ``POST /admin/modify``, ``GET /admin/admins``, and ``POST /admin/admins`` will reuse the same interpreter (keyed by the string ``"admin_interp"``). +- Requests to ``GET /sum/{a}/{b}`` will use a separate interpreter (keyed by the string ``"sum"``). +- Requests to ``GET /fast/and/small`` will `not reuse any interpreters`, instead getting a fresh interpreter for each incoming request. + +Note that ``"interpreter_reuse"`` describes when interpreters `may` be reused, but does not ensure that an interpreter `is` reused. A CCF node may decide to evict interpreters to limit memory use, or for parallelisation. Additionally, interpreters are node-local, are evicted for semantic safety whenever the JS application is modified, and only constructed on-demand for an incoming request (so the first request will see no performance benefit, since it includes the initialisation cost that later requests can skip). In short, this reuse should be seen as a best-effort optimisation - when it takes effect it will make many request patterns significantly faster, but it should not be relied upon for correctness. diff --git a/doc/schemas/gov_openapi.json b/doc/schemas/gov_openapi.json index 37ad366af8d6..748006a20389 100644 --- a/doc/schemas/gov_openapi.json +++ b/doc/schemas/gov_openapi.json @@ -165,6 +165,9 @@ "forwarding_required": { "$ref": "#/components/schemas/ForwardingRequired" }, + "interpreter_reuse": { + "$ref": "#/components/schemas/InterpreterReusePolicy" + }, "js_function": { "$ref": "#/components/schemas/string" }, @@ -307,11 +310,29 @@ "HttpMethod": { "type": "string" }, + "InterpreterReusePolicy": { + "oneOf": [ + { + "properties": { + "key": { + "type": "string" + } + }, + "required": [ + "key" + ], + "type": "object" + } + ] + }, "JSRuntimeOptions": { "properties": { "log_exception_details": { "$ref": "#/components/schemas/boolean" }, + "max_cached_interpreters": { + "$ref": "#/components/schemas/uint64" + }, "max_execution_time_ms": { "$ref": "#/components/schemas/uint64" }, @@ -1270,7 +1291,7 @@ "info": { "description": "This API is used to submit and query proposals which affect CCF's public governance tables.", "title": "CCF Governance API", - "version": "4.1.3" + "version": "4.1.2" }, "openapi": "3.0.0", "paths": { @@ -1613,6 +1634,28 @@ } } }, + "/gov/kv/interpreter/flush": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/boolean" + } + } + }, + "description": "Default response description" + }, + "default": { + "$ref": "#/components/responses/default" + } + }, + "x-ccf-forwarding": { + "$ref": "#/components/x-ccf-forwarding/sometimes" + } + } + }, "/gov/kv/js_runtime_options": { "get": { "responses": { diff --git a/doc/schemas/node_openapi.json b/doc/schemas/node_openapi.json index a55a1e06fd14..ff89a678e547 100644 --- a/doc/schemas/node_openapi.json +++ b/doc/schemas/node_openapi.json @@ -479,6 +479,9 @@ "bytecode_used": { "$ref": "#/components/schemas/boolean" }, + "max_cached_interpreters": { + "$ref": "#/components/schemas/uint64" + }, "max_execution_time": { "$ref": "#/components/schemas/uint64" }, @@ -494,7 +497,8 @@ "bytecode_used", "max_heap_size", "max_stack_size", - "max_execution_time" + "max_execution_time", + "max_cached_interpreters" ], "type": "object" }, diff --git a/include/ccf/endpoint.h b/include/ccf/endpoint.h index 1203b21e1ea8..84cb6f938158 100644 --- a/include/ccf/endpoint.h +++ b/include/ccf/endpoint.h @@ -83,6 +83,23 @@ namespace ccf::endpoints {Mode::ReadOnly, "readonly"}, {Mode::Historical, "historical"}}); + struct InterpreterReusePolicy + { + enum + { + KeyBased + } kind; + + std::string key; + + bool operator==(const InterpreterReusePolicy&) const = default; + }; + + void to_json(nlohmann::json& j, const InterpreterReusePolicy& grp); + void from_json(const nlohmann::json& j, InterpreterReusePolicy& grp); + std::string schema_name(const InterpreterReusePolicy*); + void fill_json_schema(nlohmann::json& schema, const InterpreterReusePolicy*); + struct EndpointProperties { /// Endpoint mode @@ -99,13 +116,23 @@ namespace ccf::endpoints std::string js_module; /// JavaScript function name std::string js_function; + /// Determines how JS interpreters may be reused between multiple calls, + /// sharing global state in potentially unsafe ways. The default empty value + /// means no reuse is permitted. + std::optional interpreter_reuse = std::nullopt; }; DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(EndpointProperties); DECLARE_JSON_REQUIRED_FIELDS( EndpointProperties, forwarding_required, authn_policies); DECLARE_JSON_OPTIONAL_FIELDS( - EndpointProperties, openapi, openapi_hidden, mode, js_module, js_function); + EndpointProperties, + openapi, + openapi_hidden, + mode, + js_module, + js_function, + interpreter_reuse); struct EndpointDefinition { diff --git a/include/ccf/service/tables/jsengine.h b/include/ccf/service/tables/jsengine.h index 4dea68460611..06e74e344790 100644 --- a/include/ccf/service/tables/jsengine.h +++ b/include/ccf/service/tables/jsengine.h @@ -23,13 +23,18 @@ namespace ccf /// NOTE: this is a security risk as it may leak sensitive information, /// albeit to the caller only. bool return_exception_details = false; + /// @brief how many interpreters may be cached in-memory for future reuse + size_t max_cached_interpreters = 10; }; DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(JSRuntimeOptions) DECLARE_JSON_REQUIRED_FIELDS( JSRuntimeOptions, max_heap_bytes, max_stack_bytes, max_execution_time_ms) DECLARE_JSON_OPTIONAL_FIELDS( - JSRuntimeOptions, log_exception_details, return_exception_details); + JSRuntimeOptions, + log_exception_details, + return_exception_details, + max_cached_interpreters); using JSEngine = ServiceValue; diff --git a/include/ccf/service/tables/modules.h b/include/ccf/service/tables/modules.h index 8bdf254d34e5..5ec0c3f19c2c 100644 --- a/include/ccf/service/tables/modules.h +++ b/include/ccf/service/tables/modules.h @@ -16,6 +16,7 @@ namespace ccf using ModulesQuickJsBytecode = kv::RawCopySerialisedMap>; using ModulesQuickJsVersion = kv::RawCopySerialisedValue; + using InterpreterFlush = ServiceValue; namespace Tables { @@ -24,5 +25,7 @@ namespace ccf "public:ccf.gov.modules_quickjs_bytecode"; static constexpr auto MODULES_QUICKJS_VERSION = "public:ccf.gov.modules_quickjs_version"; + static constexpr auto INTERPRETER_FLUSH = + "public:ccf.gov.interpreter.flush"; } } \ No newline at end of file diff --git a/samples/apps/logging/js/app.json b/samples/apps/logging/js/app.json index 2eaddf506013..7f7be720b3d3 100644 --- a/samples/apps/logging/js/app.json +++ b/samples/apps/logging/js/app.json @@ -1,5 +1,33 @@ { "endpoints": { + "/custom_auth": { + "get": { + "js_module": "logging.js", + "js_function": "custom_auth", + "forwarding_required": "never", + "authn_policies": ["no_auth"], + "mode": "readonly", + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } + } + }, + "/multi_auth": { + "post": { + "js_module": "logging.js", + "js_function": "multi_auth", + "forwarding_required": "always", + "authn_policies": [ + "user_cert", + "member_cert", + "jwt", + "user_cose_sign1", + "no_auth" + ], + "mode": "readwrite", + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } + } + }, "/log/private": { "get": { "js_module": "logging.js", @@ -7,7 +35,8 @@ "forwarding_required": "sometimes", "authn_policies": ["jwt", "user_cert"], "mode": "readonly", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } }, "post": { "js_module": "logging.js", @@ -15,7 +44,8 @@ "forwarding_required": "always", "authn_policies": ["jwt", "user_cert"], "mode": "readwrite", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } }, "delete": { "js_module": "logging.js", @@ -23,7 +53,30 @@ "forwarding_required": "always", "authn_policies": ["jwt", "user_cert"], "mode": "readwrite", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } + } + }, + "/log/private/admin_only": { + "post": { + "js_module": "logging.js", + "js_function": "post_private_admin_only", + "forwarding_required": "always", + "authn_policies": ["user_cert"], + "mode": "readwrite", + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } + } + }, + "/log/private/anonymous": { + "post": { + "js_module": "logging.js", + "js_function": "post_private", + "forwarding_required": "always", + "authn_policies": ["no_auth"], + "mode": "readwrite", + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } } }, "/log/private/all": { @@ -33,7 +86,8 @@ "forwarding_required": "always", "authn_policies": ["jwt", "user_cert"], "mode": "readwrite", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } } }, "/log/private/count": { @@ -43,7 +97,8 @@ "forwarding_required": "sometimes", "authn_policies": ["jwt", "user_cert"], "mode": "readonly", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } } }, "/log/private/historical": { @@ -53,7 +108,8 @@ "forwarding_required": "never", "authn_policies": ["jwt", "user_cert"], "mode": "historical", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } } }, "/log/private/historical_receipt": { @@ -63,7 +119,8 @@ "forwarding_required": "never", "authn_policies": ["jwt", "user_cert"], "mode": "historical", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } } }, "/log/private/historical/range": { @@ -73,7 +130,19 @@ "forwarding_required": "never", "authn_policies": ["jwt", "user_cert"], "mode": "readonly", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } + } + }, + "/log/private/prefix_cert": { + "post": { + "js_module": "logging.js", + "js_function": "post_private_prefix_cert", + "forwarding_required": "always", + "authn_policies": ["jwt", "user_cert"], + "mode": "readwrite", + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } } }, "/log/public": { @@ -83,7 +152,8 @@ "forwarding_required": "sometimes", "authn_policies": ["jwt", "user_cert"], "mode": "readonly", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } }, "post": { "js_module": "logging.js", @@ -91,7 +161,8 @@ "forwarding_required": "always", "authn_policies": ["jwt", "user_cert"], "mode": "readwrite", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } }, "delete": { "js_module": "logging.js", @@ -99,7 +170,8 @@ "forwarding_required": "always", "authn_policies": ["jwt", "user_cert"], "mode": "readwrite", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } } }, "/log/public/all": { @@ -109,7 +181,8 @@ "forwarding_required": "always", "authn_policies": ["jwt", "user_cert"], "mode": "readwrite", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } } }, "/log/public/count": { @@ -119,7 +192,8 @@ "forwarding_required": "sometimes", "authn_policies": ["jwt", "user_cert"], "mode": "readonly", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } } }, "/log/public/historical": { @@ -129,7 +203,8 @@ "forwarding_required": "never", "authn_policies": ["jwt", "user_cert"], "mode": "historical", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } } }, "/log/public/historical_receipt": { @@ -139,7 +214,8 @@ "forwarding_required": "never", "authn_policies": ["jwt", "user_cert"], "mode": "historical", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } } }, "/log/public/historical/range": { @@ -149,7 +225,19 @@ "forwarding_required": "never", "authn_policies": ["jwt", "user_cert"], "mode": "readonly", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } + } + }, + "/log/private/raw_text/{id}": { + "post": { + "js_module": "logging.js", + "js_function": "post_private_raw_text", + "forwarding_required": "always", + "authn_policies": ["jwt", "user_cert"], + "mode": "readwrite", + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } } } } diff --git a/samples/apps/logging/js_perf/app.json b/samples/apps/logging/js_perf/app.json index 40eee8fb29c5..545d26572bb4 100644 --- a/samples/apps/logging/js_perf/app.json +++ b/samples/apps/logging/js_perf/app.json @@ -7,7 +7,8 @@ "forwarding_required": "sometimes", "authn_policies": ["jwt", "user_cert"], "mode": "readonly", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } }, "post": { "js_module": "logging.js", @@ -15,7 +16,8 @@ "forwarding_required": "always", "authn_policies": ["jwt", "user_cert"], "mode": "readwrite", - "openapi": {} + "openapi": {}, + "interpreter_reuse": { "key": "singleton_interpreter" } } } } diff --git a/samples/constitutions/default/actions.js b/samples/constitutions/default/actions.js index 7bb2da4de995..6f85fbb708e7 100644 --- a/samples/constitutions/default/actions.js +++ b/samples/constitutions/default/actions.js @@ -720,6 +720,7 @@ const actions = new Map([ ccf.kv["public:ccf.gov.modules_quickjs_bytecode"]; const modulesQuickJsVersionVal = ccf.kv["public:ccf.gov.modules_quickjs_version"]; + const interpreterFlushVal = ccf.kv["public:ccf.gov.interpreter.flush"]; const endpointsMap = ccf.kv["public:ccf.gov.endpoints"]; modulesMap.clear(); endpointsMap.clear(); @@ -739,6 +740,11 @@ const actions = new Map([ ccf.refreshAppBytecodeCache(); } + interpreterFlushVal.set( + getSingletonKvKey(), + ccf.jsonCompatibleToBuf(true), + ); + for (const [url, endpoint] of Object.entries( bundle.metadata.endpoints )) { @@ -762,12 +768,14 @@ const actions = new Map([ const modulesMap = ccf.kv["public:ccf.gov.modules"]; const modulesQuickJsBytecodeMap = ccf.kv["public:ccf.gov.modules_quickjs_bytecode"]; + const interpreterFlushVal = ccf.kv["public:ccf.gov.interpreter.flush"]; const modulesQuickJsVersionVal = ccf.kv["public:ccf.gov.modules_quickjs_version"]; const endpointsMap = ccf.kv["public:ccf.gov.endpoints"]; modulesMap.clear(); modulesQuickJsBytecodeMap.clear(); modulesQuickJsVersionVal.clear(); + interpreterFlushVal.clear(); endpointsMap.clear(); } ), @@ -793,6 +801,11 @@ const actions = new Map([ "boolean?", "return_exception_details" ); + checkType( + args.max_cached_interpreters, + "integer?", + "max_cached_interpreters", + ); }, function (args) { const js_engine_map = ccf.kv["public:ccf.gov.js_runtime_options"]; diff --git a/src/apps/js_generic/js_generic_base.cpp b/src/apps/js_generic/js_generic_base.cpp index 4966ef2b9bc5..bc387b02512f 100644 --- a/src/apps/js_generic/js_generic_base.cpp +++ b/src/apps/js_generic/js_generic_base.cpp @@ -7,6 +7,7 @@ #include "ccf/node/host_processes_interface.h" #include "ccf/version.h" #include "enclave/enclave_time.h" +#include "js/interpreter_cache_interface.h" #include "js/wrap.h" #include "kv/untyped_map.h" #include "named_auth_policies.h" @@ -31,10 +32,9 @@ namespace ccfapp class JSHandlers : public UserEndpointRegistry { private: - struct JSDynamicEndpoint : public ccf::endpoints::EndpointDefinition - {}; - ccfapp::AbstractNodeContext& context; + std::shared_ptr interpreter_cache = + nullptr; js::JSWrappedValue create_caller_obj( ccf::endpoints::EndpointContext& endpoint_ctx, js::Context& ctx) @@ -156,7 +156,7 @@ namespace ccfapp } js::JSWrappedValue create_request_obj( - const JSDynamicEndpoint* endpoint, + const ccf::js::JSDynamicEndpoint* endpoint, ccf::endpoints::EndpointContext& endpoint_ctx, js::Context& ctx) { @@ -234,7 +234,7 @@ namespace ccfapp } void execute_request( - const JSDynamicEndpoint* endpoint, + const ccf::js::JSDynamicEndpoint* endpoint, ccf::endpoints::EndpointContext& endpoint_ctx) { if (endpoint->properties.mode == ccf::endpoints::Mode::Historical) @@ -253,57 +253,89 @@ namespace ccfapp auto tx_id = state->transaction_id; auto receipt = state->receipt; assert(receipt); - do_execute_request(endpoint, endpoint_ctx, &tx, tx_id, receipt); + js::ReadOnlyTxContext historical_txctx{&tx}; + auto add_historical_globals = [&](js::Context& ctx) { + js::populate_global_ccf_historical_state( + &historical_txctx, tx_id, receipt, ctx); + }; + do_execute_request(endpoint, endpoint_ctx, add_historical_globals); }, context, is_tx_committed)(endpoint_ctx); } else { - do_execute_request( - endpoint, endpoint_ctx, nullptr, std::nullopt, nullptr); + do_execute_request(endpoint, endpoint_ctx); } } + using PreExecutionHook = std::function; + void do_execute_request( - const JSDynamicEndpoint* endpoint, + const ccf::js::JSDynamicEndpoint* endpoint, ccf::endpoints::EndpointContext& endpoint_ctx, - kv::ReadOnlyTx* historical_tx, - const std::optional& transaction_id, - ccf::TxReceiptImplPtr receipt) + const std::optional& pre_exec_hook = std::nullopt) { - js::Runtime rt(&endpoint_ctx.tx); - rt.add_ccf_classdefs(); - + // This KV Value should be updated by any governance actions which modify + // the JS app (including _any_ of its contained modules). We then use the + // version where it was last modified as a safe approximation of when an + // interpreter is unsafe to use. If this value is written to, the + // version_of_previous_write will advance, and all cached interpreters + // will be flushed. + const auto interpreter_flush = endpoint_ctx.tx.ro( + ccf::Tables::INTERPRETER_FLUSH); + const auto flush_marker = + interpreter_flush->get_version_of_previous_write().value_or(0); + + const std::optional js_runtime_options = + endpoint_ctx.tx.ro(ccf::Tables::JSENGINE)->get(); + if (js_runtime_options.has_value()) + { + interpreter_cache->set_max_cached_interpreters( + js_runtime_options->max_cached_interpreters); + } + + std::shared_ptr interpreter = + interpreter_cache->get_interpreter( + js::TxAccess::APP, *endpoint, flush_marker); + if (interpreter == nullptr) + { + throw std::logic_error("Cache failed to produce interpreter"); + } + js::Context& ctx = *interpreter; + + // Prevent any other thread modifying this interpreter, until this + // function completes. We could create interpreters per-thread, but then + // we would get no cross-thread caching benefit (and would need to either + // enforce, or share, caps across per-thread caches). We choose + // instead to allow interpreters to be maximally reused, even across + // threads, at the cost of locking (and potentially stalling another + // thread's request execution) here. + std::lock_guard guard(ctx.lock); + // Update the top of the stack for the current thread, used by the stack + // guard Note this is only active outside SGX + JS_UpdateStackTop(ctx.runtime()); + + ctx.runtime().set_runtime_options(&endpoint_ctx.tx); JS_SetModuleLoaderFunc( - rt, nullptr, js::js_app_module_loader, &endpoint_ctx.tx); + ctx.runtime(), nullptr, js::js_app_module_loader, &endpoint_ctx.tx); - js::Context ctx(rt, js::TxAccess::APP); js::TxContext txctx{&endpoint_ctx.tx}; - js::ReadOnlyTxContext historical_txctx{historical_tx}; js::register_request_body_class(ctx); - js::init_globals(ctx); js::populate_global_ccf_kv(&txctx, ctx); - if (historical_tx != nullptr) - { - CCF_ASSERT( - transaction_id.has_value(), - "Expected transaction_id to be passed with historical_tx"); - CCF_ASSERT( - receipt != nullptr, - "Expected receipt to be passed with historical_tx"); - js::populate_global_ccf_historical_state( - &historical_txctx, transaction_id.value(), receipt, ctx); - } - js::populate_global_ccf_rpc(endpoint_ctx.rpc_ctx.get(), ctx); js::populate_global_ccf_host( context.get_subsystem().get(), ctx); js::populate_global_ccf_consensus(this, ctx); js::populate_global_ccf_historical(&context.get_historical_state(), ctx); + if (pre_exec_hook.has_value()) + { + pre_exec_hook.value()(ctx); + } + js::JSWrappedValue export_func; try { @@ -337,6 +369,7 @@ namespace ccfapp auto [reason, trace] = js::js_error_message(ctx); + auto& rt = ctx.runtime(); if (rt.log_exception_details) { CCF_APP_FAIL("{}: {}", reason, trace.value_or("")); @@ -526,7 +559,7 @@ namespace ccfapp } void execute_request_locally_committed( - const JSDynamicEndpoint* endpoint, + const ccf::js::JSDynamicEndpoint* endpoint, ccf::endpoints::CommandEndpointContext& endpoint_ctx, const ccf::TxID& tx_id) { @@ -537,9 +570,17 @@ namespace ccfapp JSHandlers(AbstractNodeContext& context) : UserEndpointRegistry(context), context(context) - {} + { + interpreter_cache = + context.get_subsystem(); + if (interpreter_cache == nullptr) + { + throw std::logic_error( + "Unexpected: Could not access AbstractInterpreterCache subsytem"); + } + } - void instantiate_authn_policies(JSDynamicEndpoint& endpoint) + void instantiate_authn_policies(ccf::js::JSDynamicEndpoint& endpoint) { for (const auto& policy_name : endpoint.properties.authn_policies) { @@ -568,7 +609,7 @@ namespace ccfapp const auto it = endpoints->get(key); if (it.has_value()) { - auto endpoint_def = std::make_shared(); + auto endpoint_def = std::make_shared(); endpoint_def->dispatch = key; endpoint_def->properties = it.value(); endpoint_def->full_uri_path = @@ -583,55 +624,55 @@ namespace ccfapp { std::vector matches; - endpoints->foreach_key( - [this, &endpoints, &matches, &key, &rpc_ctx](const auto& other_key) { - if (key.verb == other_key.verb) + endpoints->foreach_key([this, &endpoints, &matches, &key, &rpc_ctx]( + const auto& other_key) { + if (key.verb == other_key.verb) + { + const auto opt_spec = + ccf::endpoints::PathTemplateSpec::parse(other_key.uri_path); + if (opt_spec.has_value()) { - const auto opt_spec = - ccf::endpoints::PathTemplateSpec::parse(other_key.uri_path); - if (opt_spec.has_value()) + const auto& template_spec = opt_spec.value(); + // This endpoint has templates in its path, and the correct verb + // - now check if template matches the current request's path + std::smatch match; + if (std::regex_match( + key.uri_path, match, template_spec.template_regex)) { - const auto& template_spec = opt_spec.value(); - // This endpoint has templates in its path, and the correct verb - // - now check if template matches the current request's path - std::smatch match; - if (std::regex_match( - key.uri_path, match, template_spec.template_regex)) + if (matches.empty()) { - if (matches.empty()) + auto ctx_impl = static_cast(&rpc_ctx); + if (ctx_impl == nullptr) { - auto ctx_impl = static_cast(&rpc_ctx); - if (ctx_impl == nullptr) - { - throw std::logic_error("Unexpected type of RpcContext"); - } - // Populate the request_path_params while we have the match, - // though this will be discarded on error if we later find - // multiple matches - auto& path_params = ctx_impl->path_params; - for (size_t i = 0; - i < template_spec.template_component_names.size(); - ++i) - { - const auto& template_name = - template_spec.template_component_names[i]; - const auto& template_value = match[i + 1].str(); - path_params[template_name] = template_value; - } + throw std::logic_error("Unexpected type of RpcContext"); + } + // Populate the request_path_params while we have the match, + // though this will be discarded on error if we later find + // multiple matches + auto& path_params = ctx_impl->path_params; + for (size_t i = 0; + i < template_spec.template_component_names.size(); + ++i) + { + const auto& template_name = + template_spec.template_component_names[i]; + const auto& template_value = match[i + 1].str(); + path_params[template_name] = template_value; } - - auto endpoint = std::make_shared(); - endpoint->dispatch = other_key; - endpoint->full_uri_path = fmt::format( - "/{}{}", method_prefix, endpoint->dispatch.uri_path); - endpoint->properties = endpoints->get(other_key).value(); - instantiate_authn_policies(*endpoint); - matches.push_back(endpoint); } + + auto endpoint = std::make_shared(); + endpoint->dispatch = other_key; + endpoint->full_uri_path = fmt::format( + "/{}{}", method_prefix, endpoint->dispatch.uri_path); + endpoint->properties = endpoints->get(other_key).value(); + instantiate_authn_policies(*endpoint); + matches.push_back(endpoint); } } - return true; - }); + } + return true; + }); if (matches.size() > 1) { @@ -685,7 +726,7 @@ namespace ccfapp ccf::endpoints::EndpointDefinitionPtr e, ccf::endpoints::EndpointContext& endpoint_ctx) override { - auto endpoint = dynamic_cast(e.get()); + auto endpoint = dynamic_cast(e.get()); if (endpoint != nullptr) { execute_request(endpoint, endpoint_ctx); @@ -700,7 +741,7 @@ namespace ccfapp ccf::endpoints::CommandEndpointContext& endpoint_ctx, const ccf::TxID& tx_id) override { - auto endpoint = dynamic_cast(e.get()); + auto endpoint = dynamic_cast(e.get()); if (endpoint != nullptr) { execute_request_locally_committed(endpoint, endpoint_ctx, tx_id); diff --git a/src/ds/lru.h b/src/ds/lru.h index ba21094407ea..e157518246f5 100644 --- a/src/ds/lru.h +++ b/src/ds/lru.h @@ -11,8 +11,8 @@ * that this still does what you want! * * The search methods (begin, end, find, contains) do _not_ count as access and - * do not alter the recently used order. Only insert() and operator[] modify the - * order. + * do not alter the recently used order. Only insert(), promote(), and + * operator[] modify the order. */ template class LRU @@ -101,14 +101,19 @@ class LRU return it != iter_map.end(); } + // Move an iterator (returned from find) to the most recently used + void promote(const Iterator& list_it) + { + entries_list.splice(entries_list.begin(), entries_list, list_it); + } + Iterator insert(const K& k, V&& v) { auto it = iter_map.find(k); if (it != iter_map.end()) { // If it already exists, move to the front - auto& list_it = it->second; - entries_list.splice(entries_list.begin(), entries_list, list_it); + promote(it->second); } else { @@ -122,9 +127,9 @@ class LRU return entries_list.begin(); } - V& operator[](K&& k) + V& operator[](const K& k) { - auto it = insert(std::forward(k), V{}); + auto it = insert(k, V{}); return it->second; } diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index 413a0866b580..8e381e5ca9b1 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -11,6 +11,7 @@ #include "indexing/enclave_lfs_access.h" #include "indexing/historical_transaction_fetcher.h" #include "interface.h" +#include "js/interpreter_cache.h" #include "js/wrap.h" #include "node/acme_challenge_frontend.h" #include "node/historical_queries.h" @@ -160,6 +161,11 @@ namespace ccf context->install_subsystem(std::make_shared(*node)); + static constexpr size_t max_interpreter_cache_size = 10; + auto interpreter_cache = + std::make_shared(max_interpreter_cache_size); + context->install_subsystem(interpreter_cache); + LOG_TRACE_FMT("Creating RPC actors / ffi"); rpc_map->register_frontend( std::make_unique( diff --git a/src/endpoints/endpoint.cpp b/src/endpoints/endpoint.cpp index 592dc3dc0c60..032fd65e227f 100644 --- a/src/endpoints/endpoint.cpp +++ b/src/endpoints/endpoint.cpp @@ -101,4 +101,53 @@ namespace ccf::endpoints installer->install(*this); } } + + void to_json(nlohmann::json& j, const InterpreterReusePolicy& grp) + { + switch (grp.kind) + { + case InterpreterReusePolicy::KeyBased: + { + j = nlohmann::json::object(); + j["key"] = grp.key; + } + } + } + + void from_json(const nlohmann::json& j, InterpreterReusePolicy& grp) + { + if (j.is_object()) + { + const auto key_it = j.find("key"); + if (key_it != j.end()) + { + grp.kind = InterpreterReusePolicy::KeyBased; + grp.key = key_it->get(); + } + } + } + + std::string schema_name(const InterpreterReusePolicy*) + { + return "InterpreterReusePolicy"; + } + + void fill_json_schema(nlohmann::json& schema, const InterpreterReusePolicy*) + { + auto one_of = nlohmann::json::array(); + + { + auto key_based = nlohmann::json::object(); + key_based["type"] = "object"; + + key_based["properties"] = + nlohmann::json::object({{"key", {{"type", "string"}}}}); + key_based["required"] = nlohmann::json::array({"key"}); + + one_of.push_back(key_based); + } + + schema = nlohmann::json::object(); + schema["oneOf"] = one_of; + } } diff --git a/src/js/interpreter_cache.h b/src/js/interpreter_cache.h new file mode 100644 index 000000000000..96486b490c96 --- /dev/null +++ b/src/js/interpreter_cache.h @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "./interpreter_cache_interface.h" +#include "ccf/pal/locking.h" +#include "ds/lru.h" + +namespace ccf::js +{ + class InterpreterCache : public AbstractInterpreterCache + { + protected: + // Locks access to all internal fields + ccf::pal::Mutex lock; + LRU> lru; + size_t cache_build_marker; + + public: + InterpreterCache(size_t max_cache_size) : lru(max_cache_size) {} + + std::shared_ptr get_interpreter( + js::TxAccess access, + const JSDynamicEndpoint& endpoint, + size_t freshness_marker) override + { + if (access != js::TxAccess::APP) + { + throw std::logic_error( + "JS interpreter reuse lru is only supported for APP " + "interpreters"); + } + + std::lock_guard guard(lock); + + if (cache_build_marker != freshness_marker) + { + LOG_INFO_FMT( + "Clearing interpreter lru at {} - rebuilding at {}", + cache_build_marker, + freshness_marker); + lru.clear(); + cache_build_marker = freshness_marker; + } + + if (endpoint.properties.interpreter_reuse.has_value()) + { + switch (endpoint.properties.interpreter_reuse->kind) + { + case ccf::endpoints::InterpreterReusePolicy::KeyBased: + { + const auto key = endpoint.properties.interpreter_reuse->key; + auto it = lru.find(key); + if (it == lru.end()) + { + LOG_TRACE_FMT( + "Inserting new interpreter into cache, with key {}", key); + it = lru.insert(key, std::make_shared(access)); + } + else + { + LOG_TRACE_FMT( + "Returning interpreter previously in cache, with key {}", key); + lru.promote(it); + } + + return it->second; + } + } + } + + // Return a fresh interpreter, not stored in the cache + LOG_TRACE_FMT("Returning freshly constructed interpreter"); + return std::make_shared(access); + } + + void set_max_cached_interpreters(size_t max) override + { + std::lock_guard guard(lock); + lru.set_max_size(max); + } + }; +} diff --git a/src/js/interpreter_cache_interface.h b/src/js/interpreter_cache_interface.h new file mode 100644 index 000000000000..a37374a85742 --- /dev/null +++ b/src/js/interpreter_cache_interface.h @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "./wrap.h" +#include "ccf/endpoint.h" +#include "ccf/node_subsystem_interface.h" + +namespace ccf::js +{ + struct JSDynamicEndpoint : public ccf::endpoints::EndpointDefinition + {}; + + class AbstractInterpreterCache : public ccf::AbstractNodeSubSystem + { + public: + virtual ~AbstractInterpreterCache() = default; + + static char const* get_subsystem_name() + { + return "InterpreterCache"; + } + + // Retrieve an interpreter, based on reuse policy specified in the endpoint. + // Note that in some cases, notably if the reuse policy does not permit + // reuse, this will actually return a freshly-constructed, non-cached + // interpreter. The caller should not care whether the returned value is + // fresh or previously used, and should treat it identically going forward. + // The only benefit of a reused value from the cache should be seen during + // execution, where some global initialisation may already be done. + virtual std::shared_ptr get_interpreter( + js::TxAccess access, + const JSDynamicEndpoint& endpoint, + size_t freshness_marker) = 0; + + // Cap the total number of interpreters which will be retained. The + // underlying cache functions as an LRU, evicting the interpreter which has + // been idle the longest when the cap is reached. + virtual void set_max_cached_interpreters(size_t max) = 0; + }; +} diff --git a/src/js/wrap.cpp b/src/js/wrap.cpp index 127140581241..517503213966 100644 --- a/src/js/wrap.cpp +++ b/src/js/wrap.cpp @@ -136,9 +136,6 @@ namespace ccf::js JSWrappedValue Context::call( const JSWrappedValue& f, const std::vector& argv) { - auto rt = JS_GetRuntime(ctx); - js::Runtime& jsrt = *(js::Runtime*)JS_GetRuntimeOpaque(rt); - std::vector argvn; argvn.reserve(argv.size()); for (auto& a : argv) @@ -147,14 +144,14 @@ namespace ccf::js } const auto curr_time = ccf::get_enclave_time(); interrupt_data.start_time = curr_time; - interrupt_data.max_execution_time = jsrt.get_max_exec_time(); + interrupt_data.max_execution_time = rt.get_max_exec_time(); interrupt_data.access = access; JS_SetInterruptHandler(rt, js_custom_interrupt_handler, &interrupt_data); return W(JS_Call(ctx, f, JS_UNDEFINED, argv.size(), argvn.data())); } - Runtime::Runtime(kv::Tx* tx) + Runtime::Runtime() { rt = JS_NewRuntime(); if (rt == nullptr) @@ -164,25 +161,7 @@ namespace ccf::js JS_SetRuntimeOpaque(rt, this); - size_t stack_size = default_stack_size; - size_t heap_size = default_heap_size; - - const auto jsengine = tx->ro(ccf::Tables::JSENGINE); - const std::optional js_runtime_options = jsengine->get(); - - if (js_runtime_options.has_value()) - { - heap_size = js_runtime_options.value().max_heap_bytes; - stack_size = js_runtime_options.value().max_stack_bytes; - max_exec_time = std::chrono::milliseconds{ - js_runtime_options.value().max_execution_time_ms}; - log_exception_details = js_runtime_options.value().log_exception_details; - return_exception_details = - js_runtime_options.value().return_exception_details; - } - - JS_SetMaxStackSize(rt, stack_size); - JS_SetMemoryLimit(rt, heap_size); + add_ccf_classdefs(); } Runtime::~Runtime() @@ -1386,6 +1365,13 @@ namespace ccf::js // conforms to quickjs' default module filename normalizer auto module_name_quickjs = module_name_kv.c_str() + 1; + auto loaded_module = jsctx.get_module_from_cache(module_name_quickjs); + if (loaded_module.has_value()) + { + LOG_TRACE_FMT("Using module from interpreter cache '{}'", module_name_kv); + return loaded_module.value(); + } + const auto modules = tx->ro(ccf::Tables::MODULES); std::optional> bytecode; @@ -1425,7 +1411,7 @@ namespace ccf::js } else { - LOG_TRACE_FMT("Loading module from cache '{}'", module_name_kv); + LOG_TRACE_FMT("Loading module from bytecode cache '{}'", module_name_kv); module_val = jsctx.read_object( bytecode->data(), bytecode->size(), JS_READ_OBJ_BYTECODE); @@ -1443,6 +1429,9 @@ namespace ccf::js } } + LOG_TRACE_FMT("Adding module to interpreter cache '{}'", module_name_kv); + jsctx.load_module_to_cache(module_name_quickjs, module_val); + return module_val; } @@ -1488,9 +1477,10 @@ namespace ccf::js auto& tx = *tx_ctx_ptr->tx; - js::Runtime rt(tx_ctx_ptr->tx); - JS_SetModuleLoaderFunc(rt, nullptr, js::js_app_module_loader, &tx); - js::Context ctx2(rt, js::TxAccess::APP); + js::Context ctx2(js::TxAccess::APP); + ctx2.runtime().set_runtime_options(tx_ctx_ptr->tx); + JS_SetModuleLoaderFunc( + ctx2.runtime(), nullptr, js::js_app_module_loader, &tx); auto modules = tx.ro(ccf::Tables::MODULES); auto quickjs_version = @@ -2360,6 +2350,29 @@ namespace ccf::js } } + void Runtime::set_runtime_options(kv::Tx* tx) + { + size_t stack_size = default_stack_size; + size_t heap_size = default_heap_size; + + const auto jsengine = tx->ro(ccf::Tables::JSENGINE); + const std::optional js_runtime_options = jsengine->get(); + + if (js_runtime_options.has_value()) + { + heap_size = js_runtime_options.value().max_heap_bytes; + stack_size = js_runtime_options.value().max_stack_bytes; + max_exec_time = std::chrono::milliseconds{ + js_runtime_options.value().max_execution_time_ms}; + log_exception_details = js_runtime_options.value().log_exception_details; + return_exception_details = + js_runtime_options.value().return_exception_details; + } + + JS_SetMaxStackSize(rt, stack_size); + JS_SetMemoryLimit(rt, heap_size); + } + #pragma clang diagnostic pop } diff --git a/src/js/wrap.h b/src/js/wrap.h index eaaeb973addd..b2d1bccc6850 100644 --- a/src/js/wrap.h +++ b/src/js/wrap.h @@ -249,12 +249,13 @@ namespace ccf::js JSRuntime* rt = nullptr; std::chrono::milliseconds max_exec_time = default_max_execution_time; + void add_ccf_classdefs(); public: bool log_exception_details = false; bool return_exception_details = false; - Runtime(kv::Tx* tx); + Runtime(); ~Runtime(); operator JSRuntime*() const @@ -262,7 +263,7 @@ namespace ccf::js return rt; } - void add_ccf_classdefs(); + void set_runtime_options(kv::Tx* tx); std::chrono::milliseconds get_max_exec_time() const { @@ -272,15 +273,26 @@ namespace ccf::js class Context { + private: JSContext* ctx; + Runtime rt; + + // The interpreter can cache loaded modules so they do not need to be loaded + // from the KV for every execution, which is particularly useful when + // re-using interpreters. A module can only be loaded once per interpreter, + // and the entire interpreter should be thrown away if _any_ of its modules + // needs to be refreshed. + std::map loaded_modules_cache; public: + ccf::pal::Mutex lock; + const TxAccess access; InterruptData interrupt_data; bool implement_untrusted_time = false; bool log_execution_metrics = true; - Context(JSRuntime* rt, TxAccess acc) : access(acc) + Context(TxAccess acc) : access(acc) { ctx = JS_NewContext(rt); if (ctx == nullptr) @@ -288,6 +300,8 @@ namespace ccf::js throw std::runtime_error("Failed to initialise QuickJS context"); } JS_SetContextOpaque(ctx, this); + + js::init_globals(*this); } ~Context() @@ -296,11 +310,44 @@ namespace ccf::js JS_FreeContext(ctx); } + // Delete copy and assignment operators, since this assumes sole ownership + // of underlying rt and ctx. Can implement move operator if necessary + Context(const Context&) = delete; + Context& operator=(const Context&) = delete; + + Runtime& runtime() + { + return rt; + } + operator JSContext*() const { return ctx; } + std::optional get_module_from_cache( + const std::string& module_name) + { + auto module = loaded_modules_cache.find(module_name); + if (module == loaded_modules_cache.end()) + { + return std::nullopt; + } + + return module->second; + } + + void load_module_to_cache( + const std::string& module_name, const JSWrappedValue& module) + { + if (get_module_from_cache(module_name).has_value()) + { + throw std::logic_error(fmt::format( + "Module '{}' is already loaded in interpreter cache", module_name)); + } + loaded_modules_cache[module_name] = module; + } + JSWrappedValue operator()(JSValue&& val) const { return W(std::move(val)); diff --git a/src/node/rpc/member_frontend.h b/src/node/rpc/member_frontend.h index 066801b05a8c..01b91bec89d4 100644 --- a/src/node/rpc/member_frontend.h +++ b/src/node/rpc/member_frontend.h @@ -150,11 +150,9 @@ namespace ccf std::optional vote_failures = std::nullopt; for (const auto& [mid, mb] : pi_->ballots) { - js::Runtime rt(&tx); - js::Context context(rt, js::TxAccess::GOV_RO); - rt.add_ccf_classdefs(); + js::Context context(js::TxAccess::GOV_RO); + context.runtime().set_runtime_options(&tx); js::TxContext txctx{&tx}; - js::init_globals(context); js::populate_global_ccf_kv(&txctx, context); auto ballot_func = context.function( mb, @@ -190,11 +188,9 @@ namespace ccf } { - js::Runtime rt(&tx); - js::Context js_context(rt, js::TxAccess::GOV_RO); - rt.add_ccf_classdefs(); + js::Context js_context(js::TxAccess::GOV_RO); + js_context.runtime().set_runtime_options(&tx); js::TxContext txctx{&tx}; - js::init_globals(js_context); js::populate_global_ccf_kv(&txctx, js_context); auto resolve_func = js_context.function( constitution, "resolve", "public:ccf.gov.constitution[0]"); @@ -289,9 +285,9 @@ namespace ccf } if (pi_.value().state == ProposalState::ACCEPTED) { - js::Runtime apply_rt(&tx); - js::Context apply_js_context(apply_rt, js::TxAccess::GOV_RW); - apply_rt.add_ccf_classdefs(); + js::Context apply_js_context(js::TxAccess::GOV_RW); + apply_js_context.runtime().set_runtime_options(&tx); + js::TxContext apply_txctx{&tx}; auto gov_effects = @@ -302,8 +298,7 @@ namespace ccf "Unexpected: Could not access GovEffects subsytem"); } - js::init_globals(apply_js_context); - js::populate_global_ccf_kv(&txctx, apply_js_context); + js::populate_global_ccf_kv(&apply_txctx, apply_js_context); js::populate_global_ccf_node(gov_effects.get(), apply_js_context); js::populate_global_ccf_network(&network, apply_js_context); js::populate_global_ccf_gov_actions(apply_js_context); @@ -580,7 +575,7 @@ namespace ccf openapi_info.description = "This API is used to submit and query proposals which affect CCF's " "public governance tables."; - openapi_info.document_version = "4.1.3"; + openapi_info.document_version = "4.1.2"; } static std::optional get_caller_member_id( @@ -1162,11 +1157,9 @@ namespace ccf auto validate_script = constitution.value(); - js::Runtime rt(&ctx.tx); - js::Context context(rt, js::TxAccess::GOV_RO); - rt.add_ccf_classdefs(); + js::Context context(js::TxAccess::GOV_RO); + context.runtime().set_runtime_options(&ctx.tx); js::TxContext txctx{&ctx.tx}; - js::init_globals(context); js::populate_global_ccf_kv(&txctx, context); auto validate_func = context.function( @@ -1690,8 +1683,8 @@ namespace ccf ctx.rpc_ctx->get_request_body()); { - js::Runtime rt(&ctx.tx); - js::Context context(rt, js::TxAccess::GOV_RO); + js::Context context(js::TxAccess::GOV_RO); + context.runtime().set_runtime_options(&ctx.tx); auto ballot_func = context.function(params["ballot"], "vote", "body[\"ballot\"]"); } diff --git a/src/node/rpc/node_frontend.h b/src/node/rpc/node_frontend.h index faf298f8811f..9f071a9d3e29 100644 --- a/src/node/rpc/node_frontend.h +++ b/src/node/rpc/node_frontend.h @@ -72,6 +72,7 @@ namespace ccf uint64_t max_heap_size; uint64_t max_stack_size; uint64_t max_execution_time; + uint64_t max_cached_interpreters = 10; }; DECLARE_JSON_TYPE(JavaScriptMetrics); @@ -81,7 +82,8 @@ namespace ccf bytecode_used, max_heap_size, max_stack_size, - max_execution_time); + max_execution_time, + max_cached_interpreters); struct JWTMetrics { @@ -1389,10 +1391,11 @@ namespace ccf m.max_execution_time = js::default_max_execution_time.count(); if (js_engine_options.has_value()) { - m.max_stack_size = js_engine_options.value().max_stack_bytes; - m.max_heap_size = js_engine_options.value().max_heap_bytes; - m.max_execution_time = - js_engine_options.value().max_execution_time_ms; + auto& options = js_engine_options.value(); + m.max_stack_size = options.max_stack_bytes; + m.max_heap_size = options.max_heap_bytes; + m.max_execution_time = options.max_execution_time_ms; + m.max_cached_interpreters = options.max_cached_interpreters; } return m; diff --git a/src/service/network_tables.h b/src/service/network_tables.h index 02e1a684dcb2..b39f5dc58db5 100644 --- a/src/service/network_tables.h +++ b/src/service/network_tables.h @@ -140,6 +140,7 @@ namespace ccf Tables::MODULES_QUICKJS_BYTECODE}; const ModulesQuickJsVersion modules_quickjs_version = { Tables::MODULES_QUICKJS_VERSION}; + const InterpreterFlush interpreter_flush = {Tables::INTERPRETER_FLUSH}; const JSEngine js_engine = {Tables::JSENGINE}; const endpoints::EndpointsMap js_endpoints = {endpoints::Tables::ENDPOINTS}; @@ -149,6 +150,7 @@ namespace ccf modules, modules_quickjs_bytecode, modules_quickjs_version, + interpreter_flush, js_engine, js_endpoints); } diff --git a/tests/infra/consortium.py b/tests/infra/consortium.py index 250c2374ea88..3a9979f19c44 100644 --- a/tests/infra/consortium.py +++ b/tests/infra/consortium.py @@ -535,6 +535,7 @@ def set_js_runtime_options( max_execution_time_ms, log_exception_details=False, return_exception_details=False, + max_cached_interpreters=None, ): proposal_body, careful_vote = self.make_proposal( "set_js_runtime_options", @@ -543,6 +544,7 @@ def set_js_runtime_options( max_execution_time_ms=max_execution_time_ms, log_exception_details=log_exception_details, return_exception_details=return_exception_details, + max_cached_interpreters=max_cached_interpreters, ) proposal = self.get_any_active_member().propose(remote_node, proposal_body) return self.vote_using_majority(remote_node, proposal, careful_vote) diff --git a/tests/js-custom-authorization/custom_authorization.py b/tests/js-custom-authorization/custom_authorization.py index f85878adb83f..cf9020ea2c32 100644 --- a/tests/js-custom-authorization/custom_authorization.py +++ b/tests/js-custom-authorization/custom_authorization.py @@ -18,6 +18,7 @@ import re from e2e_logging import test_multi_auth from http import HTTPStatus +import subprocess from loguru import logger as LOG @@ -659,6 +660,170 @@ def run_api(args): network = test_metrics_logging(network, args) +def test_reused_interpreter_behaviour(network, args): + primary, _ = network.find_nodes() + + def timed(fn): + start = datetime.datetime.now() + result = fn() + end = datetime.datetime.now() + duration = (end - start).total_seconds() + LOG.debug(f"({duration:.2f}s)") + return duration, result + + # Extremely crude "same order-of-magnitude" comparisons + def much_smaller(a, b): + return a < b / 2 + + # Not actual assertions because they'll often fail for unrelated + # reasons - the JS execution got a caching benefit, but some + # scheduling unluckiness caused the roundtrip time to be slow. + # Instead we assert on the deterministic wasCached bool, but log + # errors if this doesn't correspond with expected run time impact. + def expect_much_smaller(a, b): + if not (much_smaller(a, b)): + LOG.error( + f"Expected to complete much faster, but took {a:.4f} and {b:.4f} seconds" + ) + + def expect_similar(a, b): + if much_smaller(a, b) or much_smaller(b, a): + LOG.error( + f"Expected similar execution times, but took {a:.4f} and {b:.4f} seconds" + ) + + def was_cached(response): + return response.body.json()["wasCached"] + + fib_body = {"n": 25} + + with primary.client() as c: + LOG.info("Testing with no caching benefit") + baseline, res0 = timed(lambda: c.post("/fibonacci/reuse/none", fib_body)) + repeat1, res1 = timed(lambda: c.post("/fibonacci/reuse/none", fib_body)) + repeat2, res2 = timed(lambda: c.post("/fibonacci/reuse/none", fib_body)) + results = (res0, res1, res2) + assert all(r.status_code == http.HTTPStatus.OK for r in results), results + assert all(not was_cached(r) for r in results), results + expect_similar(baseline, repeat1) + expect_similar(baseline, repeat2) + + LOG.info("Testing cached interpreter benefit") + baseline, res0 = timed(lambda: c.post("/fibonacci/reuse/a", fib_body)) + repeat1, res1 = timed(lambda: c.post("/fibonacci/reuse/a", fib_body)) + repeat2, res2 = timed(lambda: c.post("/fibonacci/reuse/a", fib_body)) + results = (res0, res1, res2) + assert all(r.status_code == http.HTTPStatus.OK for r in results), results + assert not was_cached(res0), res0 + assert was_cached(res1), res1 + assert was_cached(res2), res2 + expect_much_smaller(repeat1, baseline) + expect_much_smaller(repeat2, baseline) + + LOG.info("Testing cached app behaviour") + # For this app, different key means re-execution, so same as no cache benefit, first time + baseline, res0 = timed(lambda: c.post("/fibonacci/reuse/a", {"n": 26})) + repeat1, res1 = timed(lambda: c.post("/fibonacci/reuse/a", {"n": 26})) + results = (res0, res1) + assert all(r.status_code == http.HTTPStatus.OK for r in results), results + assert not was_cached(res0), res0 + assert was_cached(res1), res1 + expect_much_smaller(repeat1, baseline) + + LOG.info("Testing behaviour of multiple interpreters") + baseline, res0 = timed(lambda: c.post("/fibonacci/reuse/b", fib_body)) + repeat1, res1 = timed(lambda: c.post("/fibonacci/reuse/b", fib_body)) + repeat2, res2 = timed(lambda: c.post("/fibonacci/reuse/b", fib_body)) + results = (res0, res1, res2) + assert all(r.status_code == http.HTTPStatus.OK for r in results), results + assert not was_cached(res0), res0 + assert was_cached(res1), res1 + assert was_cached(res2), res2 + expect_much_smaller(repeat1, baseline) + expect_much_smaller(repeat2, baseline) + + LOG.info("Testing cap on number of interpreters") + # Call twice so we should definitely be cached, regardless of what previous tests did + c.post("/fibonacci/reuse/a", fib_body) + c.post("/fibonacci/reuse/b", fib_body) + c.post("/fibonacci/reuse/c", fib_body) + resa = c.post("/fibonacci/reuse/a", fib_body) + resb = c.post("/fibonacci/reuse/b", fib_body) + resc = c.post("/fibonacci/reuse/c", fib_body) + results = (resa, resb, resc) + assert all(was_cached(res) for res in results), results + + # Get current metrics to pass existing/default values + r = c.get("/node/js_metrics") + body = r.body.json() + default_max_heap_size = body["max_heap_size"] + default_max_stack_size = body["max_stack_size"] + default_max_execution_time = body["max_execution_time"] + default_max_cached_interpreters = body["max_cached_interpreters"] + network.consortium.set_js_runtime_options( + primary, + max_heap_bytes=default_max_heap_size, + max_stack_bytes=default_max_stack_size, + max_execution_time_ms=default_max_execution_time, + max_cached_interpreters=2, + ) + + # If we round-robin through too many interpreters, we flush them from the LRU cache + c.post("/fibonacci/reuse/a", fib_body) + c.post("/fibonacci/reuse/b", fib_body) + c.post("/fibonacci/reuse/c", fib_body) + resa = c.post("/fibonacci/reuse/a", fib_body) + resb = c.post("/fibonacci/reuse/b", fib_body) + resc = c.post("/fibonacci/reuse/c", fib_body) + results = (resa, resb, resc) + assert all(not was_cached(res) for res in results), results + + # But if we stay within the interpreter cap, then we get a cached interpreter + resb = c.post("/fibonacci/reuse/b", fib_body) + resc = c.post("/fibonacci/reuse/c", fib_body) + results = (resb, resc) + assert all(was_cached(res) for res in results), results + + # Restoring original cap + network.consortium.set_js_runtime_options( + primary, + max_heap_bytes=default_max_heap_size, + max_stack_bytes=default_max_stack_size, + max_execution_time_ms=default_max_execution_time, + max_cached_interpreters=default_max_cached_interpreters, + ) + + LOG.info("Testing Dependency Injection sample endpoint") + baseline, res0 = timed(lambda: c.post("/app/di")) + repeat1, res1 = timed(lambda: c.post("/app/di")) + repeat2, res2 = timed(lambda: c.post("/app/di")) + repeat3, res3 = timed(lambda: c.post("/app/di")) + results = (res0, res1, res2, res3) + assert all(r.status_code == http.HTTPStatus.OK for r in results), results + expect_much_smaller(repeat1, baseline) + expect_much_smaller(repeat2, baseline) + expect_much_smaller(repeat3, baseline) + + return network + + +def run_interpreter_reuse(args): + # The js_app_bundle arg includes TS and Node dependencies, so must be built here + # before deploying (and then we deploy the produces /dist folder) + js_src_dir = args.js_app_bundle + LOG.info("Building mixed JS/TS app, with dependencies") + subprocess.run(["npm", "install", "--no-package-lock"], cwd=js_src_dir, check=True) + subprocess.run(["npm", "run", "build"], cwd=js_src_dir, check=True) + args.js_app_bundle = os.path.join(js_src_dir, "dist") + + with infra.network.network( + args.nodes, args.binary_dir, args.debug_nodes, args.perf_nodes, pdb=args.pdb + ) as network: + network.start_and_open(args) + + network = test_reused_interpreter_behaviour(network, args) + + if __name__ == "__main__": cr = ConcurrentRunner() @@ -699,4 +864,11 @@ def run_api(args): js_app_bundle=os.path.join(cr.args.js_app_bundle, "js-api"), ) + cr.add( + "interpreter_reuse", + run_interpreter_reuse, + nodes=infra.e2e_args.nodes(cr.args, 1), + js_app_bundle=os.path.join(cr.args.js_app_bundle, "js-interpreter-reuse"), + ) + cr.run() diff --git a/tests/js-interpreter-reuse/app.json b/tests/js-interpreter-reuse/app.json new file mode 100644 index 000000000000..7a4ec9838908 --- /dev/null +++ b/tests/js-interpreter-reuse/app.json @@ -0,0 +1,66 @@ +{ + "endpoints": { + "/fibonacci/reuse/none": { + "post": { + "js_module": "cache.js", + "js_function": "cachedFib", + "forwarding_required": "never", + "authn_policies": ["no_auth"], + "mode": "readonly", + "openapi": {} + } + }, + "/fibonacci/reuse/a": { + "post": { + "js_module": "cache.js", + "js_function": "cachedFib", + "forwarding_required": "never", + "authn_policies": ["no_auth"], + "mode": "readonly", + "openapi": {}, + "interpreter_reuse": { + "key": "a" + } + } + }, + "/fibonacci/reuse/b": { + "post": { + "js_module": "cache.js", + "js_function": "cachedFib", + "forwarding_required": "never", + "authn_policies": ["no_auth"], + "mode": "readonly", + "openapi": {}, + "interpreter_reuse": { + "key": "b" + } + } + }, + "/fibonacci/reuse/c": { + "post": { + "js_module": "cache.js", + "js_function": "cachedFib", + "forwarding_required": "never", + "authn_policies": ["no_auth"], + "mode": "readonly", + "openapi": {}, + "interpreter_reuse": { + "key": "c" + } + } + }, + "/di": { + "post": { + "js_module": "di_sample.js", + "js_function": "slowCall", + "forwarding_required": "never", + "authn_policies": ["no_auth"], + "mode": "readonly", + "openapi": {}, + "interpreter_reuse": { + "key": "arbitrary_string_goes_here" + } + } + } + } +} diff --git a/tests/js-interpreter-reuse/package.json b/tests/js-interpreter-reuse/package.json new file mode 100644 index 000000000000..0c9064937169 --- /dev/null +++ b/tests/js-interpreter-reuse/package.json @@ -0,0 +1,22 @@ +{ + "private": true, + "scripts": { + "build": "del-cli -f dist/ && rollup --config && cp app.json dist/" + }, + "type": "module", + "engines": { + "node": ">=14" + }, + "dependencies": { + "@microsoft/ccf-app": "file:../../js/ccf-app", + "inversify": "^6.0.1", + "reflect-metadata": "^0.1.13" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^17.1.0", + "@rollup/plugin-node-resolve": "^11.2.0", + "@rollup/plugin-typescript": "^8.2.0", + "del-cli": "^5.0.0", + "tslib": "^2.0.1" + } +} diff --git a/tests/js-interpreter-reuse/rollup.config.js b/tests/js-interpreter-reuse/rollup.config.js new file mode 100644 index 000000000000..1686413ff490 --- /dev/null +++ b/tests/js-interpreter-reuse/rollup.config.js @@ -0,0 +1,14 @@ +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; +import typescript from "@rollup/plugin-typescript"; + +export default { + input: "src/rollup_entry.ts", + output: { + dir: "dist/src", + format: "es", + preserveModules: true, + preserveModulesRoot: "src", + }, + plugins: [nodeResolve(), typescript(), commonjs()], +}; diff --git a/tests/js-interpreter-reuse/src/SlowConstructorService.ts b/tests/js-interpreter-reuse/src/SlowConstructorService.ts new file mode 100644 index 000000000000..25f208da454b --- /dev/null +++ b/tests/js-interpreter-reuse/src/SlowConstructorService.ts @@ -0,0 +1,14 @@ +import { injectable } from "inversify"; +import { fibonacci } from "./bad_fib"; +import "reflect-metadata"; + +@injectable() +export class SlowConstructorService { + static ServiceId = "SlowConstructorService"; + + constructor() { + console.log(" Starting slow construction"); + console.log(` fibonacci(25) = ${fibonacci(25)}`); + console.log(" Completed slow construction"); + } +} diff --git a/tests/js-interpreter-reuse/src/bad_fib.js b/tests/js-interpreter-reuse/src/bad_fib.js new file mode 100644 index 000000000000..6de0ca4b5332 --- /dev/null +++ b/tests/js-interpreter-reuse/src/bad_fib.js @@ -0,0 +1,11 @@ +console.log("Logging at global scope of bad_fib"); + +function fibonacci(n) { + if (n < 2) { + return 1; + } + + return fibonacci(n - 1) + fibonacci(n - 2); +} + +export { fibonacci }; diff --git a/tests/js-interpreter-reuse/src/cache.js b/tests/js-interpreter-reuse/src/cache.js new file mode 100644 index 000000000000..f9d67388b4bb --- /dev/null +++ b/tests/js-interpreter-reuse/src/cache.js @@ -0,0 +1,25 @@ +import { fibonacci } from "./bad_fib.js"; + +// Note: Applications should be careful of using the global state as a generic cache +// like this, in particular providing any behavioural change indicating whether +// the cache was available. This results in transactions which are not reproducible +// from the ledger. This is only done here to aid testing of the interpreter reuse +// behaviour. +export function cachedFib(request) { + if (!(globalThis.BadCache instanceof Object)) { + globalThis.BadCache = {}; + } + + const body = request.body.json(); + const n = body.n; + var wasCached = true; + + if (!(n in globalThis.BadCache)) { + const fib = fibonacci(n); + console.log(`Calculated fibonacci(${n}) = ${fib}`); + globalThis.BadCache[n] = fib; + wasCached = false; + } + + return { body: { fib: globalThis.BadCache[n], wasCached: wasCached } }; +} diff --git a/tests/js-interpreter-reuse/src/di_sample.ts b/tests/js-interpreter-reuse/src/di_sample.ts new file mode 100644 index 000000000000..9a0723cc6fd7 --- /dev/null +++ b/tests/js-interpreter-reuse/src/di_sample.ts @@ -0,0 +1,25 @@ +import * as ccfapp from "@microsoft/ccf-app"; +import { container } from "./inversify.config"; +import { SlowConstructorService } from "./SlowConstructorService"; + +// Demonstrates impact of interpreter reuse on dependency injection patterns, +// such as inversify. +// With fresh interpreters, the DI container must also be freshly constructed +// each time, leading to repeated unnecessary construction costs. By reusing +// existing interpreters, where this container's static state has been stashed +// on the global object, we can see a significant perf speedup. +export function slowCall(request: ccfapp.Request): ccfapp.Response { + console.log("Requesting service"); + const slowConstructed = container.get( + SlowConstructorService.ServiceId, + ); + console.log("Requested service"); + + console.log("Requesting service again"); + const slowConstructed2 = container.get( + SlowConstructorService.ServiceId, + ); + console.log("Requested service again"); + + return { statusCode: 200 }; +} diff --git a/tests/js-interpreter-reuse/src/inversify.config.ts b/tests/js-interpreter-reuse/src/inversify.config.ts new file mode 100644 index 000000000000..e124e3cc2c3e --- /dev/null +++ b/tests/js-interpreter-reuse/src/inversify.config.ts @@ -0,0 +1,13 @@ +import { Container, interfaces } from "inversify"; +import { SlowConstructorService } from "./SlowConstructorService"; + +const container = new Container(); + +container + .bind(SlowConstructorService.ServiceId) + .to(SlowConstructorService) + // NB: The latter is critical - we only get cache reuse benefits for + // state which is actually cached, such as singleton bindings. + .inSingletonScope(); + +export { container }; diff --git a/tests/js-interpreter-reuse/src/rollup_entry.ts b/tests/js-interpreter-reuse/src/rollup_entry.ts new file mode 100644 index 000000000000..aa1303bb0442 --- /dev/null +++ b/tests/js-interpreter-reuse/src/rollup_entry.ts @@ -0,0 +1,2 @@ +export * from "./cache.js"; +export * from "./di_sample"; diff --git a/tests/js-interpreter-reuse/tsconfig.json b/tests/js-interpreter-reuse/tsconfig.json new file mode 100644 index 000000000000..6dacb8cc2c54 --- /dev/null +++ b/tests/js-interpreter-reuse/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "emitDecoratorMetadata": true + } +}