diff --git a/rebar.config.script b/rebar.config.script index 53f7fe8b856..4d1773fc8bf 100644 --- a/rebar.config.script +++ b/rebar.config.script @@ -127,6 +127,7 @@ SubDirs = [ "src/couch_mrview", "src/couch_replicator", "src/couch_pse_tests", + "src/couch_srt", "src/couch_stats", "src/couch_peruser", "src/couch_tests", diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini index dfefa62dc03..b2ffa87b7d9 100644 --- a/rel/overlay/etc/default.ini +++ b/rel/overlay/etc/default.ini @@ -1150,3 +1150,94 @@ url = {{nouveau_url}} ;mem3_shards = true ;nouveau_index_manager = true ;dreyfus_index_manager = true + +; Couch Stats Resource Tracker (CSRT) +[csrt] +;enable = false +;enable_init_p = false +;enable_reporting = false +;enable_rpc_reporting = false + +; Truncate reports to not include zero values for counter fields. This is a +; simple way to save space and should be left enabled unless you need a fixed +; output structure in the process lifecycle reports. +;should_truncate_reports = true + +; Limit queries to a maxinum number of rows +;query_limit = 100 +;query_cardinality_limit = 10000 + +; CSRT Logger Matchers +; +; These matchers are filters to decide whether or not to generate a process +; lifecyle report at the end of an HTTP request with a detailed report +; quantifying the CouchDB resources used to fulfill that request. These filters +; are design to make it easy to log a report for requests that utilize a lot +; of CouchDB resources, take a long time, or use heavy filtering, without having +; to enable report logging for _all_ requests. These reports can be enabled at +; the RPC worker level too, but for view queries and other aggregate operations, +; that can generate a report per shard interacted with to fullfill the request, +; and can generate a lot of data. The logger matchers are a way to dynamically +; control what is being logged on the fly, or to tailor fit the quantity of logs +; generated to store usage information in a predictable manner. +; +; These reports can be used to find potential workloads +; to refactor, but also to retroactively understand the workload that during a +; particular window of time. The node level stats collected and reported can +; inform you that, for example, a great deal of IO operations and database reads +; are being performed, but they do not provide cardinality into the databases +; and requests inducing te resource usage. This is where the process lifecycle +; reports come in: after a request is completed, if it matched a filter, a +; report is logged containing the final quantitative counts of resources usage +; to fulfill that request as well as qualitative information like username, +; dbname, nonce, and more. See CSRT.md for more details. +; +; There are a series of default logger matchers designed to filter for requests +; that surpass a threshold on a particular dimension, for example, when enabled, +; the ioq_calls default matcher filters true for requests that invoke more than +; 10,000 IOQ calls. The default named matchers are enabled by name and boolean +; in the `[csrt_logger.matchers_enabled]` section, and similarly, the Threshold +; value for each of the default matchers is specified in the config section +; `[csrt_logger.matchers_threshold]` by name and integer threshold quantity. +; +; The default loggers above operate against any HTTP requests flowing through +; CouchDB, whereas the `[csrt_logger.dbnames_io]` provides a simple way to +; specify database specific matchers, at the expense of the granularity +; available in the default matchers. The "dbnames_io" logger matcher filters +; for requests against a particular database that induce more than the specified +; threshold of IO operations. This is a generic IO catchall matcher, not +; specific to ioq_calls or docs_read, like the default matchers. +; +; CSRT dbname matchers +; Given a dbname and a positive integer, this will enable an IO matcher +; against the provided db for any requests that induce IO in quantities +; greater than the provided threshold on any one of: ioq_calls, rows_read +; docs_read, get_kp_node, get_kv_node, or changes_processed. +[csrt_logger.dbnames_io] +; For example: +; foo = 100 +; _dbs = 123 +; _users = 234 +; foo/bar = 200 + +; CSRT default matchers - enablement configuration +; The default CSRT loggers can be individually enabled below +[csrt_logger.matchers_enabled] +;all_coordinators = false +;all_rpc_workers = false +;docs_read = false +;rows_read = false +;docs_written = false +;long_reqs = false +;changes_processed = false +;ioq_calls = false + +; CSRT default matchers - threshold configuration +; This specifies the integer Threshold for the various builtin matchers +[csrt_logger.matchers_threshold] +;docs_read = 1000 +;rows_read = 1000 +;docs_written = 500 +;long_reqs = 60000 +;changes_processed = 1000 +;ioq_calls = 10000 diff --git a/rel/reltool.config b/rel/reltool.config index b85bd49b624..48456684395 100644 --- a/rel/reltool.config +++ b/rel/reltool.config @@ -38,6 +38,7 @@ couch_log, couch_mrview, couch_replicator, + couch_srt, couch_stats, couch_event, couch_peruser, @@ -103,6 +104,7 @@ {app, couch_log, [{incl_cond, include}]}, {app, couch_mrview, [{incl_cond, include}]}, {app, couch_replicator, [{incl_cond, include}]}, + {app, couch_srt, [{incl_cond, include}]}, {app, couch_stats, [{incl_cond, include}]}, {app, couch_event, [{incl_cond, include}]}, {app, couch_peruser, [{incl_cond, include}]}, diff --git a/src/chttpd/src/chttpd.erl b/src/chttpd/src/chttpd.erl index 57a3aeaeaa6..0a4f6225f99 100644 --- a/src/chttpd/src/chttpd.erl +++ b/src/chttpd/src/chttpd.erl @@ -339,6 +339,10 @@ handle_request_int(MochiReq) -> % Save client socket so that it can be monitored for disconnects chttpd_util:mochiweb_client_req_set(MochiReq), + %% This is probably better in before_request, but having Path is nice + couch_srt:create_coordinator_context(HttpReq0, Path), + couch_srt:set_context_handler_fun({?MODULE, ?FUNCTION_NAME}), + {HttpReq2, Response} = case before_request(HttpReq0) of {ok, HttpReq1} -> @@ -369,6 +373,7 @@ handle_request_int(MochiReq) -> before_request(HttpReq) -> try + couch_srt:set_context_handler_fun({?MODULE, ?FUNCTION_NAME}), chttpd_stats:init(), chttpd_plugin:before_request(HttpReq) catch @@ -388,6 +393,8 @@ after_request(HttpReq, HttpResp0) -> HttpResp2 = update_stats(HttpReq, HttpResp1), chttpd_stats:report(HttpReq, HttpResp2), maybe_log(HttpReq, HttpResp2), + %% NOTE: do not set_context_handler_fun to preserve the Handler + couch_srt:destroy_context(), HttpResp2. process_request(#httpd{mochi_req = MochiReq} = HttpReq) -> @@ -400,6 +407,7 @@ process_request(#httpd{mochi_req = MochiReq} = HttpReq) -> RawUri = MochiReq:get(raw_path), try + couch_srt:set_context_handler_fun({?MODULE, ?FUNCTION_NAME}), couch_httpd:validate_host(HttpReq), check_request_uri_length(RawUri), check_url_encoding(RawUri), @@ -425,10 +433,12 @@ handle_req_after_auth(HandlerKey, HttpReq) -> HandlerKey, fun chttpd_db:handle_request/1 ), + couch_srt:set_context_handler_fun(HandlerFun), AuthorizedReq = chttpd_auth:authorize( possibly_hack(HttpReq), fun chttpd_auth_request:authorize_request/1 ), + couch_srt:set_context_username(AuthorizedReq), {AuthorizedReq, HandlerFun(AuthorizedReq)} catch ErrorType:Error:Stack -> diff --git a/src/chttpd/src/chttpd_db.erl b/src/chttpd/src/chttpd_db.erl index a43baeae485..4915ff67e35 100644 --- a/src/chttpd/src/chttpd_db.erl +++ b/src/chttpd/src/chttpd_db.erl @@ -83,6 +83,7 @@ % Database request handlers handle_request(#httpd{path_parts = [DbName | RestParts], method = Method} = Req) -> + couch_srt:set_context_dbname(DbName), case {Method, RestParts} of {'PUT', []} -> create_db_req(Req, DbName); @@ -103,6 +104,7 @@ handle_request(#httpd{path_parts = [DbName | RestParts], method = Method} = Req) do_db_req(Req, fun db_req/2); {_, [SecondPart | _]} -> Handler = chttpd_handlers:db_handler(SecondPart, fun db_req/2), + couch_srt:set_context_handler_fun(Handler), do_db_req(Req, Handler) end. diff --git a/src/chttpd/src/chttpd_httpd_handlers.erl b/src/chttpd/src/chttpd_httpd_handlers.erl index 932b52e5f6e..ad496463ced 100644 --- a/src/chttpd/src/chttpd_httpd_handlers.erl +++ b/src/chttpd/src/chttpd_httpd_handlers.erl @@ -20,6 +20,7 @@ url_handler(<<"_utils">>) -> fun chttpd_misc:handle_utils_dir_req/1; url_handler(<<"_all_dbs">>) -> fun chttpd_misc:handle_all_dbs_req/1; url_handler(<<"_dbs_info">>) -> fun chttpd_misc:handle_dbs_info_req/1; url_handler(<<"_active_tasks">>) -> fun chttpd_misc:handle_task_status_req/1; +url_handler(<<"_active_resources">>) -> fun couch_srt_httpd:handle_resource_status_req/1; url_handler(<<"_scheduler">>) -> fun couch_replicator_httpd:handle_scheduler_req/1; url_handler(<<"_node">>) -> fun chttpd_node:handle_node_req/1; url_handler(<<"_reload_query_servers">>) -> fun chttpd_misc:handle_reload_query_servers_req/1; diff --git a/src/couch/priv/stats_descriptions.cfg b/src/couch/priv/stats_descriptions.cfg index 6a7120f87ef..7597e8fc323 100644 --- a/src/couch/priv/stats_descriptions.cfg +++ b/src/couch/priv/stats_descriptions.cfg @@ -306,6 +306,10 @@ {type, counter}, {desc, <<"number of couch_server LRU operations skipped">>} ]}. +{[couchdb, couch_server, open], [ + {type, counter}, + {desc, <<"number of couch_server open operations invoked">>} +]}. {[couchdb, query_server, vdu_rejects], [ {type, counter}, {desc, <<"number of rejections by validate_doc_update function">>} @@ -422,6 +426,22 @@ {type, counter}, {desc, <<"number of legacy checksums found in couch_file instances">>} ]}. +{[couchdb, btree, get_node, kp_node], [ + {type, counter}, + {desc, <<"number of couch btree kp_nodes read">>} +]}. +{[couchdb, btree, get_node, kv_node], [ + {type, counter}, + {desc, <<"number of couch btree kv_nodes read">>} +]}. +{[couchdb, btree, write_node, kp_node], [ + {type, counter}, + {desc, <<"number of couch btree kp_nodes written">>} +]}. +{[couchdb, btree, write_node, kv_node], [ + {type, counter}, + {desc, <<"number of couch btree kv_nodes written">>} +]}. {[pread, exceed_eof], [ {type, counter}, {desc, <<"number of the attempts to read beyond end of db file">>} diff --git a/src/couch/src/couch.app.src b/src/couch/src/couch.app.src index 5f1fb9800bd..924a030c4be 100644 --- a/src/couch/src/couch.app.src +++ b/src/couch/src/couch.app.src @@ -47,6 +47,7 @@ couch_log, couch_event, ioq, + couch_srt, couch_stats, couch_dist, couch_quickjs diff --git a/src/couch/src/couch_btree.erl b/src/couch/src/couch_btree.erl index b974a22eeca..628388194d0 100644 --- a/src/couch/src/couch_btree.erl +++ b/src/couch/src/couch_btree.erl @@ -472,6 +472,7 @@ reduce_tree_size(kp_node, NodeSize, [{_K, {_P, _Red, Sz}} | NodeList]) -> get_node(#btree{fd = Fd}, NodePos) -> {ok, {NodeType, NodeList}} = couch_file:pread_term(Fd, NodePos), + couch_stats:increment_counter([couchdb, btree, get_node, NodeType]), {NodeType, NodeList}. write_node(#btree{fd = Fd, compression = Comp} = Bt, NodeType, NodeList) -> @@ -480,6 +481,7 @@ write_node(#btree{fd = Fd, compression = Comp} = Bt, NodeType, NodeList) -> % now write out each chunk and return the KeyPointer pairs for those nodes ToWrite = [{NodeType, Chunk} || Chunk <- Chunks], WriteOpts = [{compression, Comp}], + couch_stats:increment_counter([couchdb, btree, write_node, NodeType]), {ok, PtrSizes} = couch_file:append_terms(Fd, ToWrite, WriteOpts), {ok, group_kps(Bt, NodeType, Chunks, PtrSizes)}. diff --git a/src/couch/src/couch_query_servers.erl b/src/couch/src/couch_query_servers.erl index 3b222e0810e..4cd0aa9acf0 100644 --- a/src/couch/src/couch_query_servers.erl +++ b/src/couch/src/couch_query_servers.erl @@ -614,6 +614,12 @@ filter_docs(Req, Db, DDoc, FName, Docs) -> end. filter_docs_int(Db, DDoc, FName, JsonReq, JsonDocs) -> + %% Count usage in _int version as this can be repeated for OS error + %% Pros & cons... might not have actually processed `length(JsonDocs)` docs + %% but it certainly undercounts if we count in `filter_docs/5` above + %% TODO: replace with couchdb.query_server.*.ddoc_filter stats once we can + %% funnel back the stats used in the couchjs process to this caller process + couch_srt:js_filtered(length(JsonDocs)), [true, Passes] = ddoc_prompt( Db, DDoc, diff --git a/src/couch/src/couch_server.erl b/src/couch/src/couch_server.erl index aee2d9904e4..f54da3d266b 100644 --- a/src/couch/src/couch_server.erl +++ b/src/couch/src/couch_server.erl @@ -114,6 +114,7 @@ sup_start_link(N) -> gen_server:start_link({local, couch_server(N)}, couch_server, [N], []). open(DbName, Options) -> + couch_stats:increment_counter([couchdb, couch_server, open]), try validate_open_or_create(DbName, Options), open_int(DbName, Options) diff --git a/src/couch_log/src/couch_log_formatter.erl b/src/couch_log/src/couch_log_formatter.erl index cc8b5d8087d..4e54957a2a7 100644 --- a/src/couch_log/src/couch_log_formatter.erl +++ b/src/couch_log/src/couch_log_formatter.erl @@ -470,7 +470,12 @@ format_meta(Meta) -> lists:sort( maps:fold( fun(K, V, Acc) -> - [to_str(K, V) | Acc] + case to_str(K, V) of + "" -> + Acc; + Str -> + [Str | Acc] + end end, [], Meta @@ -487,6 +492,9 @@ format_meta(Meta) -> %% - maps %% However we are not going to try to distinguish lists from string %% Atoms would be printed as strings +%% `null` JSON values are skipped +to_str(_K, null) -> + ""; to_str(K, _) when not (is_list(K) or is_atom(K)) -> ""; to_str(K, Term) when is_list(Term) -> diff --git a/src/couch_log/test/eunit/couch_log_formatter_test.erl b/src/couch_log/test/eunit/couch_log_formatter_test.erl index cdb7eae3126..8081eaad174 100644 --- a/src/couch_log/test/eunit/couch_log_formatter_test.erl +++ b/src/couch_log/test/eunit/couch_log_formatter_test.erl @@ -34,11 +34,13 @@ format_report_etoolong_test() -> format_report_test() -> {ok, Entry} = couch_log_formatter:format_report(self(), report123, #{ + empty => null, foo => 123, bar => "barStr", baz => baz }), % Rely on `couch_log_formatter:format_meta/1` to sort keys + % `empty` is missing as `null` values are skipped Formatted = "[bar=\"barStr\" baz=\"baz\" foo=123]", ?assertEqual(Formatted, lists:flatten(Entry#log_entry.msg)). diff --git a/src/couch_srt/LICENSE b/src/couch_srt/LICENSE new file mode 100644 index 00000000000..3ddd6642618 --- /dev/null +++ b/src/couch_srt/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/couch_srt/README.md b/src/couch_srt/README.md new file mode 100644 index 00000000000..119f56fc017 --- /dev/null +++ b/src/couch_srt/README.md @@ -0,0 +1,840 @@ +# couch_srt: Couch Stats Resource Tracker aka CSRT + +The `couch_srt` app introduces the Couch Stats Resource Tracker, aka CSRT for +short. CSRT is a real time stats tracking system that tracks the quantity of +resources induced at the process level in a live queryable manner, while also +generating process lifetime reports containing statistics on the total resource +load of a request, as a function of CouchDB operations like dbs/docs opened, +view and changes rows read, changes returned vs processed, Javascript filter +usage, request duration, and more. This system is a paradigm shift in CouchDB +visibility and introspection, allowing for expressive real time querying +capabilities to introspect, understand, and aggregate CouchDB internal resource +usage, as well as powerful filtering facilities for conditionally generating +reports on "heavy usage" requests or "long/slow" requests. CSRT also extends +`recon:proc_window` with `couch_srt:proc_window` allowing for the same style of +battle hardened introspection with Recon's excellent `proc_window`, but with the +sample window over any of the CSRT tracked CouchDB stats! + +CSRT does this by piggy-backing off of the existing metrics tracked by way of +`couch_stats:increment_counter` at the time when the local process induces those +metrics inc calls, and then CSRT updates an ets entry containing the context +information for the local process, such that global aggregate queries can be +performed against the ets table as well as the generation of the process +resource usage reports at the conclusions of the process's lifecyle.The ability +to do aggregate querying in realtime in addition to the process lifecycle +reports for post facto analysis over time, is a cornerstone of CSRT that is the +result of a series of iterations until a robust and scalable aproach was built. + +The real time querying is achieved by way of a global ets table with +`read_concurrency`, `write_concurrency`, and `decentralized_counters` enabled. +Great care was taken to ensure that _zero_ concurrent writes to the same key +occur in this model, and this entire system is predicated on the fact that +incremental updates to `ets:update_counters` provides *really* fast and +efficient updates in an atomic and isolated fashion when coupled with +decentralized counters and write concurrency. Each process that calls +`couch_stats:increment_counter` tracks their local context in CSRT as well, with +zero concurrent writes from any other processes. Outside of the context setup +and teardown logic, _only_ operations to `ets:update_counter` are performed, one +per process invocation of `couch_stats:increment_counter`, and one for +coordinators to update worker deltas in a single batch, resulting in a 1:1 ratio +of ets calls to real time stats updates for the primary workloads. + +The primary achievement of CSRT is the core framework iself for concurrent +process local stats tracking and real time RPC delta accumulation in a scalable +manner that allows for real time aggregate querying and process lifecycle +reports. This took several versions to find a scalable and robust approach that +induced minimal impact on maximum system throughput. Now that the framework is +in place, it can be extended to track any further desired process local uses of +`couch_stats:increment_counter`. That said, the currently selected set of stats +to track was heavily influenced by the challenges in retroactively understanding +the quantity of resources induced by a query like `/db/_changes?since=$SEQ`, or +similarly, `/db/_find`. + +CSRT started as an extension of the Mango execution stats logic to `_changes` +feeds to get proper visibility into quantity of docs read and filtered per +changes request, but then the focus inverted with the realization that we should +instead use the existing stats tracking mechanisms that have already been deemed +critical information to track, which then also allows for the real time tracking +and aggregate query capabilities. The Mango execution stats can be ported into +CSRT itself and just become one subset of the stats tracked as a whole, and +similarly, any additional desired stats tracking can be easily added and will +be picked up in the RPC deltas and process lifetime reports. + +## A Simple Example + +Given a database `foo` with 11k documents containing a `doc.value` field that is an +integer value which can be filtered in a design doc by way of even and odd. If +we instantiate a series of while loops in parallel making requests of the form: + +> GET /foo/_changes?filter=bar/even&include_docs=true + +We can generate a good chunk of load on a local laptop dev setup, resulting in +requests that take a few seconds to load through the changes feed, fetch all 11k +docs, and then funnel them through the Javascript engine to filter for even +valued docs; this allows us time to query these heavier requests live and see +them in progress with the real time stats tracking and querying capabilities of +CSRT. + +For example, let's use `couch_srt:proc_window/3` as one would do with +`recon:proc_window/3` to get an idea of the heavy active processes on the +system: + +``` +(node1@127.0.0.1)2> rp([{PR, couch_srt:to_json(couch_srt:get_resource(PR))} || {PR, _, _} <- couch_srt:proc_window(ioq_calls, 3, 1000)]). +[{{<0.5090.0>,#Ref<0.2277656623.605290499.37969>}, + #{changes_returned => 3962,db_open => 10,dbname => <<"foo">>, + docs_read => 7917,docs_written => 0,get_kp_node => 54, + get_kv_node => 1241,ioq_calls => 15834,js_filter => 7917, + js_filtered_docs => 7917,nonce => <<"cc5a814ceb">>, + pid_ref => + <<"<0.5090.0>:#Ref<0.2277656623.605290499.37969>">>, + rows_read => 7917, + started_at => <<"2025-07-21T17:25:08.784z">>, + type => + <<"coordinator-{chttpd_db:handle_changes_req}:GET:/foo/_changes">>, + updated_at => <<"2025-07-21T17:25:13.051z">>, + username => <<"adm">>}}, + {{<0.5087.0>,#Ref<0.2277656623.606601217.92191>}, + #{changes_returned => 4310,db_open => 10,dbname => <<"foo">>, + docs_read => 8624,docs_written => 0,get_kp_node => 58, + get_kv_node => 1358,ioq_calls => 17248,js_filter => 8624, + js_filtered_docs => 8624,nonce => <<"0e625c723a">>, + pid_ref => + <<"<0.5087.0>:#Ref<0.2277656623.606601217.92191>">>, + rows_read => 8624, + started_at => <<"2025-07-21T17:25:08.424z">>, + type => + <<"coordinator-{chttpd_db:handle_changes_req}:GET:/foo/_changes">>, + updated_at => <<"2025-07-21T17:25:13.051z">>, + username => <<"adm">>}}, + {{<0.5086.0>,#Ref<0.2277656623.605290499.27728>}, + #{changes_returned => 4285,db_open => 10,dbname => <<"foo">>, + docs_read => 8569,docs_written => 0,get_kp_node => 57, + get_kv_node => 1349,ioq_calls => 17138,js_filter => 8569, + js_filtered_docs => 8569,nonce => <<"962cda1645">>, + pid_ref => + <<"<0.5086.0>:#Ref<0.2277656623.605290499.27728>">>, + rows_read => 8569, + started_at => <<"2025-07-21T17:25:08.406z">>, + type => + <<"coordinator-{chttpd_db:handle_changes_req}:GET:/foo/_changes">>, + updated_at => <<"2025-07-21T17:25:13.051z">>, + username => <<"adm">>}}] +ok +``` + +This shows us the top 3 most active processes (being tracked in CSRT) over the +next 1000 milliseconds, sorted by number of `ioq_calls` induced! All of three +of these processes are incurring heavy usage, reading many thousands of docs +with 15k+ IOQ calls and heavy JS filter usage, exactly the types of requests +you want to be alerted to. CSRT's proc window logic is built on top of Recon's, +which doesn't return the process info itself, so you'll need to fetch the +process status with `couch_srt:get_resource/1` and then pretty print it with +`couch_srt:to_json/1`. + +The output above is a real time snapshot of the live running system and shows +processes actively inducing additional resource usage, so these CSRT context +values are just a time snapshot of where that process was at, as of the +`updated_at` timestamp. We can reference the nonce value to search through the +report logs for a final report, assuming the given context ended up using +sufficient resources to trigger a logger matcher lifetime report. The above +changes requests were induced specifically to induce reports as well, so +unsurprisingly we have reports for all three. + +However, I want to first show the existing visibility into these changes +requests exposed by the raw HTTP logs to highlight the impact of the CSRT +reports and new visibility into request workloads exposed. + +First, let's look at the existing HTTP logs for those 3 requests: + +``` +(chewbranca)-(jobs:1)-(~/src/couchdb_csrt_v3) +(! 9872)-> grep 'cc5a814ceb\|0e625c723a\|962cda1645' ./dev/logs/node1.log | grep -v '^\[report]' +[notice] 2025-07-21T17:25:14.520641Z node1@127.0.0.1 <0.5087.0> 0e625c723a localhost:15984 127.0.0.1 adm GET /foo/_changes?filter=bar/even&asdf=fdsa&include_docs=true 200 ok 6096 +[notice] 2025-07-21T17:25:14.521417Z node1@127.0.0.1 <0.5086.0> 962cda1645 localhost:15984 127.0.0.1 adm GET /foo/_changes?filter=bar/even&asdf=fdsa&include_docs=true 200 ok 6115 +[notice] 2025-07-21T17:25:14.844317Z node1@127.0.0.1 <0.5090.0> cc5a814ceb localhost:15984 127.0.0.1 adm GET /foo/_changes?filter=bar/even&asdf=fdsa&include_docs=true 200 ok 6059 +``` + +So we see the requests were made, and we can see it's doing `include_docs=true` +as well as using a customer filter, both obvious indications that this is a +potentially heavier request, however, we don't know if database foo had a +thousand docs or a billion docs, whether those docs were small or large, nor any +indication of the computational complexity of the reference filter function. +This makes it challenging to retroactively correlate heavy resource usage at a +hardware level with the underlying requests that induced those workloads, +especially if the heavy requests are an inconspicuous subset of the full +database workload. + +CSRT resolves this by providing a real time querying system to find the active +heavy processes, live, as well as a process lifecycle reporting engine providing +detailed analysis of the workloads induced by the request. + +Let's assume we had the default IOQ logger matcher enabled, with the default +configuration of logging any requests inducing more than 10k IOQ calls, which +would catch all three of our requests above, even though they're all still +going. As a result, we generate process lifecycle reports for all three of those +requests, as we can see: + +``` +(chewbranca)-(jobs:1)-(~/src/couchdb_csrt_v3) +(! 9873)-> grep 'cc5a814ceb\|0e625c723a\|962cda1645' ./dev/logs/node1.log | grep '^\[report]' +[report] 2025-07-21T17:25:14.520787Z node1@127.0.0.1 <0.5174.0> -------- [csrt-pid-usage-lifetime changes_returned=5500 db_open=10 dbname="foo" docs_read=11001 get_kp_node=72 get_kv_node=1754 ioq_calls=22002 js_filter=11001 js_filtered_docs=11001 nonce="0e625c723a" pid_ref="<0.5087.0>:#Ref<0.2277656623.606601217.92191>" rows_read=11001 started_at="2025-07-21T17:25:08.424z" type="coordinator-{chttpd_db:handle_changes_req}:GET:/foo/_changes" updated_at="2025-07-21T17:25:14.520z" username="adm"] +[report] 2025-07-21T17:25:14.521578Z node1@127.0.0.1 <0.5155.0> -------- [csrt-pid-usage-lifetime changes_returned=5500 db_open=10 dbname="foo" docs_read=11001 get_kp_node=72 get_kv_node=1754 ioq_calls=22002 js_filter=11001 js_filtered_docs=11001 nonce="962cda1645" pid_ref="<0.5086.0>:#Ref<0.2277656623.605290499.27728>" rows_read=11001 started_at="2025-07-21T17:25:08.406z" type="coordinator-{chttpd_db:handle_changes_req}:GET:/foo/_changes" updated_at="2025-07-21T17:25:14.521z" username="adm"] +[report] 2025-07-21T17:25:14.844436Z node1@127.0.0.1 <0.5213.0> -------- [csrt-pid-usage-lifetime changes_returned=5500 db_open=10 dbname="foo" docs_read=11001 get_kp_node=72 get_kv_node=1754 ioq_calls=22002 js_filter=11001 js_filtered_docs=11001 nonce="cc5a814ceb" pid_ref="<0.5090.0>:#Ref<0.2277656623.605290499.37969>" rows_read=11001 started_at="2025-07-21T17:25:08.784z" type="coordinator-{chttpd_db:handle_changes_req}:GET:/foo/_changes" updated_at="2025-07-21T17:25:14.844z" username="adm"] +``` + +We find the process lifecycle reports for the requests with the three grep'ed on +nonces, and we can see they all read the 11k core documents, plus the one design +document, JS filtered all 11,001 docs, and then only returned the 5500 doc's +containing an even `doc.value` field. + +This also shows the discrepancy between the quantity of induced resource usage +to actually generate a request, relative to the magnitude of the data returned. +All of our `doc.value` fields were positive integers, if we had a filter +function searching for negative `doc.value` results, we would have found none, +resulting in `changes_returned=0`, but we would have still induced the 11,001 +doc loads and Javascript filter calls. + +CSRT is specifically built to automatically find and report these types of +workload discrepancies and in general to help highlight where individual HTTP +requests use drastically more resources than the median workloads. + +See the dedicated proc window documentation section further down for more info. + +## Additional Overview and Examples + +The query and HTTP API's are well documented and tested (h/t @iilyak) and +provide an excellent overview of the interaction patterns and query capabilities +of CSRT. Those can be found at: + +* `couch_srt_query.erl` "Query API functions" + - https://github.com/apache/couchdb/blob/da87fc3fd7beb79f1ba63cf430dd92818fb02a62/src/couch_srt/src/couch_srt_query.erl#L412-L740 + - the above highlighted functions are well tested, typespec'ed, and have + auxiliary documentation and examples, an excellent resource +* the `couch_srt_query_tests.erl` Eunit tests are an excellent overview of utilizing + the `couch_srt_query:` API from Erlang to find, filter, and aggregate CSRT real + time contexts + - https://github.com/apache/couchdb/blob/da87fc3fd7beb79f1ba63cf430dd92818fb02a62/src/couch_stats/test/eunit/couch_srt_query_tests.erl +* similarly, the `couch_srt_httpd_tests.erl` Eunit tests are an excellent overview of + performing the same style `couch_srt_query:` queries, but through the HTTP API + - https://github.com/apache/couchdb/blob/da87fc3fd7beb79f1ba63cf430dd92818fb02a62/src/couch_stats/test/eunit/couch_srt_httpd_tests.erl +* Additionally there's the `couch_srt_logger_tests.erl` Eunit tests which demonstrate + the different default logger matchers in action + - https://github.com/apache/couchdb/blob/da87fc3fd7beb79f1ba63cf430dd92818fb02a62/src/couch_stats/test/eunit/couch_srt_logger_tests.erl + +# CSRT Code Markers + +## -define(CSRT_ETS, csrt_server). + +This is the reference to the CSRT ets table, it's managed by `csrt_server` so +that's where the name originates from. + +## -define(MATCHERS_KEY, {csrt_logger, all_csrt_matchers}). + +This marker is where the active matchers are written to in `persistent_term` for +concurrently and parallelly and accessing the logger matchers in the CSRT +tracker processes for lifecycle reporting. + +# CSRT Process Dictionary Markers + +## -define(PID_REF, {csrt, pid_ref}). + +This marker is for the core storing the core `PidRef` identifier. The key idea +here is that a lifecycle is a context lifecycle is contained to within the given +`PidRef`, meaning that a `Pid` can instantiate different CSRT lifecycles and +pass those to different workers. + +This is specifically necessary for long running processes that need to handle +many CSRT context lifecycles over the course of that individual process's +lifecycle independent. In practice, this is immediately needed for the actual +coordinator lifecycle tracking, as `chttpd` uses a worker pool of http request +handlers that can be re-used, so we need a way to create a CSRT lifecycle +corresponding to the given request currently being serviced. This is also +intended to be used in other long running processes, like IOQ or `couch_js` pids +such that we can track the specific context inducing the operations on the +`couch_file` pid or indexer or replicator or whatever. + +Worker processes have a more clear cut lifecycle, but either style of process +can be exit'ed in a manner that skips the ability to do cleanup operations, so +additionally there's a dedicated tracker process spawned to monitor the process +that induced the CSRT context such that we can do the dynamic logger matching +directly in these tracker processes and also we can properly cleanup the ets +entries even if the Pid crashes. + +## -define(TRACKER_PID, {csrt, tracker}). + +A handle to the spawned tracker process that does cleanup and logger matching +reports at the end of the process lifecycle. We store a reference to the tracker +pid so that for explicit context destruction, like in `chttpd` workers after a +request has been serviced, we can update stop the tracker and perform the +expected cleanup directly. + +## -define(DELTA_TA, {csrt, delta_ta}). + +This stores our last delta snapshot to track progress since the last incremental +streaming of stats back to the coordinator process. This will be updated after +the next delta is made with the latest value. Eg this stores `T0` so we can do +`T1 = get_resource()` `make_delta(T0, T1)` and then we save `T1` as the new `T0` +for use in our next delta. + +## -define(LAST_UPDATED, {csrt, last_updated}). + +This stores the integer corresponding to the `erlang:monotonic_time()` value of +the most recent `updated_at` value. Basically this lets us utilize a pdict +value to be able to turn `update_at` tracking into an incremental operation that +can be chained in the existing atomic `ets:update_counter` and +`ets:update_element` calls. + +The issue being that our updates are of the form `+2 to ioq_calls for $pid_ref`, +which ets does atomically in a guaranteed `atomic` and `isolated` manner. The +strict use of the atomic operations for tracking these values is why this +system works efficiently at scale. This means that we can increment counters on +all of the stats counter fields in a batch, very quickly, but for tracking +`updated_at` timestamps we'd need to either do an extra ets call to get the last +`updated_at` value, or do an extra ets call to `ets:update_element` to set the +`updated_at` value to `couch_srt_util:tnow()`. The core problem with this is that the +batch inc operation is essentially the only write operation performed after the +initial context setting of dbname/handler/etc; this means that we'd literally +double the number of ets calls induced to track CSRT updates, just for tracking +the `updated_at`. So instead, we rely on the fact that the local process +corresponding to `$pid_ref` is the _only_ process doing updates so we know the +last `updated_at` value will be the last time this process updated the data. So +we track that value in the pdict and then take a delta between `tnow()` and +`updated_at`, and then `updated_at` becomes a value we can sneak into the other +integer counter updates we're already performing! + + +# Core CSRT API + +The `csrt(.erl)` module is the primary entry point into CSRT, containing API +functionality for tracking the lifecycle of processes, inducing metric tracking +over that lifecycle, and also a variety of functions for aggregate querying. + +It's worth noting that the CSRT context tracking functions are specifically +designed to not `throw` and be safe in the event of unexpected CSRT failures or +edge cases. The aggregate query API has some callers that will actually throw, +but aside from this core CSRT operations will not bubble up exceptions, and will +either return the error value, or catch the error and move on rather than +chaining further errors. + +## Context Lifecycle API + +These are the CRUD functions for handling a CSRT context lifecycle, where a +lifecycle context is created in a `chttpd` coordinator process by way of +`couch_srt:create_coordinator_context/2`, or in `rexi_server:init_p` by way of +`couch_srt:create_worker_context/3`. Additional functions are exposed for setting +context specific info like username/dbname/handler. `get_resource` fetches the +context being tracked corresponding to the given `PidRef`. + +``` +-export([ + create_context/2, + create_coordinator_context/2, + create_worker_context/3, + destroy_context/0, + destroy_context/1, + get_resource/0, + get_resource/1, + set_context_dbname/1, + set_context_dbname/2, + set_context_handler_fun/1, + set_context_handler_fun/2, + set_context_username/1, + set_context_username/2 +]). +``` + +## Public API + +The "Public" or miscellaneous API for lack of a better name. These are various +functions exposed for wider use and/or testing purposes. + +``` +-export([ + do_report/2, + is_enabled/0, + is_enabled_init_p/0, + is_enabled_reporting/0, + is_enabled_rpc_reporting/0, + maybe_report/2, + to_json/1 +]). +``` + +These tools provide a direct and conditional mechanism to generate a report, +with `do_report/2`, and `maybe_report`, respectively, with the latter testing +the provided `rctx()` against the actively registered Logger Matchers. + +The `is_enabled*` checks perform the various enablement checks, as described in +the corresponding Config documentation sections for the fields. + +And lastly, is the `couch_srt:to_json/1` function which takes a `maybe_rctx()` as +opposed to `couch_srt_entry:to_json/1` which only takes an actual `rctx()`, +specifically to make it easier to map `couch_srt:to_json/1` to output from +`couch_srt:proc_window/3` and easily handle the case when +`couch_srt:get_resource/1` returns undefined in the event the context has +already exited before we could look at it. + + +## Stats Collection API + +This is the stats collection API utilized by way of +`couch_stats:increment_counter` to do local process tracking, and also in `rexi` +to adding and extracting delta contexts and then accumulating those values. + +NOTE: `make_delta/0` is a "destructive" operation that will induce a new delta +by way of the last local pdict's rctx delta snapshot, and then update to the +most recent version. Two individual rctx snapshots for a PidRef can safely +generate an actual delta by way of `couch_srt_util:rctx_delta/2`. + +``` +-export([ + accumulate_delta/1, + add_delta/2, + docs_written/1, + extract_delta/1, + get_delta/0, + inc/1, + inc/2, + ioq_called/0, + js_filtered/1, + make_delta/0, + rctx_delta/2, + maybe_add_delta/1, + maybe_add_delta/2, + maybe_inc/2, + should_track_init_p/1 +]). +``` + +## Query API + +See the `Additional Overview and Examples` section above for more details. + +``` +% Aggregate Query API +-export([ + active/0, + active/1, + active_coordinators/0, + active_coordinators/1, + active_workers/0, + active_workers/1, + find_by_nonce/1, + find_by_pid/1, + find_by_pidref/1, + find_workers_by_pidref/1, + query_matcher/1, + query_matcher/2 +]). + +-export([ + query/1, + from/1, + group_by/1, + group_by/2, + sort_by/1, + sort_by/2, + count_by/1, + options/1, + unlimited/0, + with_limit/1, + + run/1, + unsafe_run/1 +]). +``` + +## couch_srt:proc_window/3 -- Recon API Ports of https://github.com/ferd/recon/releases/tag/2.5.6 + +This is a "port" of `recon:proc_window` to `couch_srt:proc_window`, allowing for +`proc_window` style aggregations/sorting/filtering but with the stats fields +collected by CSRT! This is also a direct port of `recon:proc_window` in that it +utilizes the same underlying logic and efficient internal data structures as +`recon:proc_window`, but rather only changes the Sample function: + +```erlang +%% This is a recon:proc_window/3 [1] port with the same core logic but +%% recon_lib:proc_attrs/1 replaced with pid_ref_attrs/1, and returning on +%% pid_ref() rather than pid(). +%% [1] https://github.com/ferd/recon/blob/c2a76855be3a226a3148c0dfc21ce000b6186ef8/src/recon.erl#L268-L300 +-spec proc_window(AttrName, Num, Time) -> term() | throw(any()) when + AttrName :: rctx_field(), Num :: non_neg_integer(), Time :: pos_integer(). +proc_window(AttrName, Num, Time) -> + Sample = fun() -> pid_ref_attrs(AttrName) end, + {First, Last} = recon_lib:sample(Time, Sample), + recon_lib:sublist_top_n_attrs(recon_lib:sliding_window(First, Last), Num). +``` + +In particular, our change is `Sample = fun() -> pid_ref_attrs(AttrName) end,`, +and in fact, if recon upstream parameterized the option of `AttrName` or +`SampleFunction`, this could be reimplemented as: + +```erlang +%% couch_srt:proc_window +proc_window(AttrName, Num, Time) -> + Sample = fun() -> pid_ref_attrs(AttrName) end, + recon:proc_window(Sample, Num, Time). +``` + +This implementation is being highlighted here because `recon:proc_window/3` is +battle hardened and `recon_lib:sliding_window` uses an efficient internal data +structure for storing the two samples that has been proven to work in production +systems with millions of active processes, so swapping the `Sample` function +with a CSRT version allows us to utilize the production grade recon +functionality, but extended out to the particular CouchDB statistics we're +especially interested in. + +And on a fun note: any further stats tracking fields added to CSRT tracking will +automatically work with this too. + + +``` +-export([ + pid_ref_attrs/1, + pid_ref_matchspec/1, + proc_window/3 +]). +``` + +
+%% Q = query([
+%% from("docs_read"),
+%% group_by(username, dbname, ioq_calls),
+%% options([
+%% with_limit(10)
+%% ])
+%% ]),
+%%
+%% @end
+-spec query(QueryExpression :: [query_expression()]) ->
+ query() | {error, any()}.
+query(QueryExpression) ->
+ couch_srt_query:query(QueryExpression).
+
+%% @doc Specify the matcher to use for the query.
+%% If atom 'all' is used then all entries would be in the scope of the query.
+%% Also the use of 'all' makes the query 'unsafe'. Because it scans through all entries
+%% and can return many matching rows.
+%% Unsafe queries can only be run using 'unsafe_run/1'.
+%%
+%% Q = query([
+%% ...
+%% from("docs_read")
+%% ]),
+%%
+%% @end
+-spec from(MatcherNameOrAll :: string() | all) ->
+ query_expression() | {error, any()}.
+from(MatcherNameOrAll) ->
+ couch_srt_query:from(MatcherNameOrAll).
+
+%% @doc Request 'group_by' aggregation of results.
+%%
+%% Q = query([
+%% ...
+%% group_by([username, dbname])
+%% ]),
+%%
+%% @end
+-spec group_by(AggregationKeys) ->
+ query_expression() | {error, any()}
+when
+ AggregationKeys ::
+ binary()
+ | rctx_field()
+ | [binary()]
+ | [rctx_field()].
+group_by(AggregationKeys) ->
+ couch_srt_query:group_by(AggregationKeys).
+
+%% @doc Request 'group_by' aggregation of results.
+%%
+%% Q = query([
+%% ...
+%% group_by([username, dbname], ioq_calls)
+%% ]),
+%%
+%% @end
+-spec group_by(AggregationKeys, ValueKey) ->
+ query_expression() | {error, any()}
+when
+ AggregationKeys ::
+ binary()
+ | rctx_field()
+ | [binary()]
+ | [rctx_field()],
+ ValueKey ::
+ binary()
+ | rctx_field().
+group_by(AggregationKeys, ValueKey) ->
+ couch_srt_query:group_by(AggregationKeys, ValueKey).
+
+%% @doc Request 'sort_by' aggregation of results.
+%%
+%% Q = query([
+%% ...
+%% sort_by([username, dbname])
+%% ]),
+%%
+%% @end
+-spec sort_by(AggregationKeys) ->
+ query_expression() | {error, any()}
+when
+ AggregationKeys ::
+ binary()
+ | rctx_field()
+ | [binary()]
+ | [rctx_field()].
+sort_by(AggregationKeys) ->
+ couch_srt_query:sort_by(AggregationKeys).
+
+%% @doc Request 'sort_by' aggregation of results.
+%%
+%% Q = query([
+%% ...
+%% sort_by([username, dbname], ioq_calls)
+%% ]),
+%%
+%% @end
+-spec sort_by(AggregationKeys, ValueKey) ->
+ query_expression() | {error, any()}
+when
+ AggregationKeys ::
+ binary()
+ | rctx_field()
+ | [binary()]
+ | [rctx_field()],
+ ValueKey ::
+ binary()
+ | rctx_field().
+sort_by(AggregationKeys, ValueKey) ->
+ couch_srt_query:sort_by(AggregationKeys, ValueKey).
+
+%% @doc Request 'count_by' aggregation of results.
+%%
+%% Q = query([
+%% ...
+%% count_by(username)
+%% ]),
+%%
+%% @end
+-spec count_by(AggregationKeys) ->
+ query_expression() | {error, any()}
+when
+ AggregationKeys ::
+ binary()
+ | rctx_field()
+ | [binary()]
+ | [rctx_field()].
+count_by(AggregationKeys) ->
+ couch_srt_query:count_by(AggregationKeys).
+
+%% @doc Construct 'options' query expression.
+%% There are following types of expressions allowed in the query.
+%%
+%% Q = query([
+%% ...
+%% options([
+%% ...
+%% ])
+%% ]),
+%%
+%% @end
+-spec options([query_option()]) ->
+ query_expression() | {error, any()}.
+options(OptionsExpression) ->
+ couch_srt_query:options(OptionsExpression).
+
+%% @doc Enable unlimited number of results from the query.
+%% The use of 'unlimited' makes the query 'unsafe'. Because it can return many matching rows.
+%% Unsafe queries can only be run using 'unsafe_run/1'.
+%%
+%% Q = query([
+%% ...
+%% options([
+%% unlimited()
+%% ])
+%% ]),
+%%
+%% @end
+-spec unlimited() ->
+ query_expression().
+unlimited() ->
+ couch_srt_query:unlimited().
+
+%% @doc Set limit on number of results returned from the query.
+%% The construction of the query fail if the 'limit' is greater than
+%% allowed for this cluster.
+%%
+%% Q = query([
+%% ...
+%% options([
+%% with_limit(100)
+%% ])
+%% ]),
+%%
+%% @end
+-spec with_limit(Limit :: pos_integer()) ->
+ query_expression() | {error, any()}.
+with_limit(Limit) ->
+ couch_srt_query:with_limit(Limit).
+
+%% @doc Executes provided query. Only 'safe' queries can be executed using 'run'.
+%% The query considered 'unsafe' if any of the conditions bellow are met:
+%%
+%% Q = query([
+%% from("docs_read"),
+%% group_by(username, dbname, ioq_calls),
+%% options([
+%% with_limit(10)
+%% ])
+%% ]),
+%% run(Q)
+%%
+%% @end
+-spec run(query()) ->
+ {ok, [{aggregation_key(), pos_integer()}]}
+ | {limit, [{aggregation_key(), pos_integer()}]}.
+run(Query) ->
+ couch_srt_query:run(Query).
+
+%% @doc Executes provided query. This function is similar to 'run/1',
+%% however it supports 'unsafe' queries. Be very careful using it.
+%% Pay attention to cardinality of the result.
+%% The query considered 'unsafe' if any of the conditions bellow are met:
+%%
+%% Q = query([
+%% from("docs_read"),
+%% group_by(username, dbname, ioq_calls),
+%% options([
+%% with_limit(10)
+%% ])
+%% ]),
+%% unsafe_run(Q)
+%%
+%% @end
+-spec unsafe_run(query()) ->
+ {ok, [{aggregation_key(), pos_integer()}]}
+ | {limit, [{aggregation_key(), pos_integer()}]}.
+unsafe_run(Query) ->
+ couch_srt_query:unsafe_run(Query).
+
+%%
+%% Tests
+%%
+
+-ifdef(TEST).
+
+-include_lib("couch/include/couch_eunit.hrl").
+
+couch_stats_resource_tracker_test_() ->
+ {
+ foreach,
+ fun setup/0,
+ fun teardown/1,
+ [
+ ?TDEF_FE(t_should_track_init_p_enabled),
+ ?TDEF_FE(t_should_not_track_init_p_enabled),
+ ?TDEF_FE(t_should_not_track_init_p_disabled),
+ ?TDEF_FE(t_static_map_translations),
+ ?TDEF_FE(t_should_extract_fields_properly)
+ ]
+ }.
+
+setup() ->
+ Ctx = test_util:start_couch(),
+ config:set_boolean(?CSRT, "randomize_testing", false, false),
+ Ctx.
+
+teardown(Ctx) ->
+ test_util:stop_couch(Ctx).
+
+t_static_map_translations(_) ->
+ %% Bit of a hack to delete duplicated rows_read between views and changes
+ SingularStats = lists:delete(rows_read, maps:values(?STATS_TO_KEYS)),
+ ?assert(lists:all(fun(E) -> maps:is_key(E, ?STAT_KEYS_TO_FIELDS) end, SingularStats)),
+ %% TODO: properly handle ioq_calls field
+ ?assertEqual(
+ lists:sort(SingularStats),
+ lists:sort(
+ lists:foldl(
+ fun(E, A) ->
+ %% Ignore fields regarding external processes
+ Deletions = [docs_written, ioq_calls, js_filter, js_filtered_docs],
+ case lists:member(E, Deletions) of
+ true ->
+ A;
+ false ->
+ [E | A]
+ end
+ end,
+ [],
+ maps:keys(?STAT_KEYS_TO_FIELDS)
+ )
+ )
+ ).
+
+t_should_not_track_init_p_enabled(_) ->
+ enable_init_p(),
+ Metrics = [
+ [couch_db, name, spawned],
+ [couch_db, get_db_info, spawned],
+ [couch_db, open, spawned],
+ [fabric_rpc, get_purge_seq, spawned]
+ ],
+ [?assert(should_track_init_p(M) =:= false, M) || M <- Metrics].
+
+t_should_track_init_p_enabled(_) ->
+ enable_init_p(),
+ [?assert(should_track_init_p(M), M) || M <- base_metrics()].
+
+t_should_not_track_init_p_disabled(_) ->
+ disable_init_p(),
+ [?assert(should_track_init_p(M) =:= false, M) || M <- base_metrics()].
+
+t_should_extract_fields_properly(_) ->
+ Rctx = #rctx{},
+ #{fields := Fields} = couch_srt_entry:record_info(),
+ %% couch_srt_entry:value/2 throws on invalid fields, assert that the function succeeded
+ TestField = fun(Field) ->
+ try
+ couch_srt_entry:value(Field, Rctx),
+ true
+ catch
+ _:_ -> false
+ end
+ end,
+ [?assert(TestField(Field)) || Field <- Fields].
+
+enable_init_p() ->
+ config:set(?CSRT, "enable_init_p", "true", false).
+
+disable_init_p() ->
+ config:set(?CSRT, "enable_init_p", "false", false).
+
+base_metrics() ->
+ [
+ [fabric_rpc, all_docs, spawned],
+ [fabric_rpc, changes, spawned],
+ [fabric_rpc, map_view, spawned],
+ [fabric_rpc, reduce_view, spawned],
+ [fabric_rpc, get_all_security, spawned],
+ [fabric_rpc, open_doc, spawned],
+ [fabric_rpc, update_docs, spawned],
+ [fabric_rpc, open_shard, spawned]
+ ].
+
+-endif.
diff --git a/src/couch_srt/src/couch_srt.hrl b/src/couch_srt/src/couch_srt.hrl
new file mode 100644
index 00000000000..8a338dd4b69
--- /dev/null
+++ b/src/couch_srt/src/couch_srt.hrl
@@ -0,0 +1,207 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-define(CSRT, "csrt").
+-define(CSRT_INIT_P, "csrt.init_p").
+-define(CSRT_ETS, csrt_ets).
+
+%% CSRT pdict markers
+-define(DELTA_TA, {csrt, delta_ta}).
+-define(LAST_UPDATED, {csrt, last_updated}).
+-define(PID_REF, {csrt, pid_ref}). %% track local ID
+-define(TRACKER_PID, {csrt, tracker}). %% tracker pid
+
+%% Stats fields
+-define(DB_OPEN_DOC, docs_read).
+-define(DB_OPEN, db_open).
+-define(COUCH_SERVER_OPEN, db_open).
+-define(COUCH_BT_GET_KP_NODE, get_kp_node).
+-define(COUCH_BT_GET_KV_NODE, get_kv_node).
+%% "Example to extend CSRT"
+%%-define(COUCH_BT_WRITE_KP_NODE, write_kp_node).
+%%-define(COUCH_BT_WRITE_KV_NODE, write_kv_node).
+-define(COUCH_JS_FILTER, js_filter).
+-define(COUCH_JS_FILTERED_DOCS, js_filtered_docs).
+-define(IOQ_CALLS, ioq_calls).
+-define(DOCS_WRITTEN, docs_written).
+-define(ROWS_READ, rows_read).
+-define(FRPC_CHANGES_RETURNED, changes_returned).
+
+%% couch_srt_logger matcher keys
+-define(MATCHERS_KEY, {csrt_logger, all_csrt_matchers}).
+-define(CSRT_MATCHERS_ENABLED, "csrt_logger.matchers_enabled").
+-define(CSRT_MATCHERS_THRESHOLD, "csrt_logger.matchers_threshold").
+-define(CSRT_MATCHERS_DBNAMES, "csrt_logger.dbnames_io").
+
+%% matcher query magnitude default limitations
+-define(QUERY_CARDINALITY_LIMIT, 10000).
+-define(QUERY_LIMIT, 100).
+
+%% Mapping of couch_stat metric names to #rctx{} field names.
+%% These are used for fields that we inc a counter on.
+-define(STATS_TO_KEYS, #{
+ [couchdb, database_reads] => ?DB_OPEN_DOC,
+ %% Double on ?ROWS_READ for changes_processed as we only need the one
+ %% field, as opposed to needing both metrics to distinguish changes
+ %% workloads and view/_all_docs.
+ [fabric_rpc, changes, processed] => ?ROWS_READ,
+ [fabric_rpc, changes, returned] => ?FRPC_CHANGES_RETURNED,
+ [fabric_rpc, view, rows_read] => ?ROWS_READ,
+ [couchdb, couch_server, open] => ?DB_OPEN,
+ [couchdb, btree, get_node, kp_node] => ?COUCH_BT_GET_KP_NODE,
+ [couchdb, btree, get_node, kv_node] => ?COUCH_BT_GET_KV_NODE
+
+ %% NOTE: these stats are not local to the RPC worker, need forwarding
+ %% "Example to extend CSRT"
+ %% [couchdb, btree, write_node, kp_node] => ?COUCH_BT_WRITE_KP_NODE,
+ %% [couchdb, btree, write_node, kv_node] => ?COUCH_BT_WRITE_KV_NODE,
+ %% [couchdb, query_server, calls, ddoc_filter] => ?COUCH_JS_FILTER
+}).
+
+%% Mapping of stat field names to their corresponding record entries.
+%% This only includes integer fields valid for ets:update_counter
+-define(STAT_KEYS_TO_FIELDS, #{
+ ?DB_OPEN => #rctx.?DB_OPEN,
+ ?ROWS_READ => #rctx.?ROWS_READ,
+ ?FRPC_CHANGES_RETURNED => #rctx.?FRPC_CHANGES_RETURNED,
+ ?DOCS_WRITTEN => #rctx.?DOCS_WRITTEN,
+ ?IOQ_CALLS => #rctx.?IOQ_CALLS,
+ ?COUCH_JS_FILTER => #rctx.?COUCH_JS_FILTER,
+ ?COUCH_JS_FILTERED_DOCS => #rctx.?COUCH_JS_FILTERED_DOCS,
+ ?DB_OPEN_DOC => #rctx.?DB_OPEN_DOC,
+ ?COUCH_BT_GET_KP_NODE => #rctx.?COUCH_BT_GET_KP_NODE,
+ ?COUCH_BT_GET_KV_NODE => #rctx.?COUCH_BT_GET_KV_NODE
+ %% "Example to extend CSRT"
+ %% ?COUCH_BT_WRITE_KP_NODE => #rctx.?COUCH_BT_WRITE_KP_NODE,
+ %% ?COUCH_BT_WRITE_KV_NODE => #rctx.?COUCH_BT_WRITE_KV_NODE
+}).
+
+-type throw(_Reason) :: no_return().
+
+-type pid_ref() :: {pid(), reference()}.
+-type maybe_pid_ref() :: pid_ref() | undefined.
+-type maybe_pid() :: pid() | undefined.
+
+-record(rpc_worker, {
+ mod :: atom() | '_',
+ func :: atom() | '_',
+ from :: pid_ref() | '_'
+}).
+
+-record(coordinator, {
+ mod :: atom() | '_',
+ func :: atom() | '_',
+ method :: atom() | '_',
+ path :: binary() | '_'
+}).
+
+-type coordinator() :: #coordinator{}.
+-type rpc_worker() :: #rpc_worker{}.
+-type rctx_type() :: coordinator() | rpc_worker().
+
+-record(rctx, {
+ %% Metadata
+ started_at = couch_srt_util:tnow() :: integer() | '_',
+ %% NOTE: updated_at must be after started_at to preserve time congruity
+ updated_at = couch_srt_util:tnow() :: integer() | '_',
+ pid_ref :: maybe_pid_ref() | {'_', '_'} | '_',
+ nonce :: nonce() | undefined | '_',
+ type :: rctx_type() | undefined | '_',
+ dbname :: dbname() | undefined | '_',
+ username :: username() | undefined | '_',
+
+ %% Stats Counters
+ db_open = 0 :: non_neg_integer() | '_',
+ docs_read = 0 :: non_neg_integer() | '_',
+ docs_written = 0 :: non_neg_integer() | '_',
+ rows_read = 0 :: non_neg_integer() | '_',
+ changes_returned = 0 :: non_neg_integer() | '_',
+ ioq_calls = 0 :: non_neg_integer() | '_',
+ js_filter = 0 :: non_neg_integer() | '_',
+ js_filtered_docs = 0 :: non_neg_integer() | '_',
+ get_kv_node = 0 :: non_neg_integer() | '_',
+ get_kp_node = 0 :: non_neg_integer() | '_'
+ %% "Example to extend CSRT"
+ %%write_kv_node = 0 :: non_neg_integer() | '_',
+ %%write_kp_node = 0 :: non_neg_integer() | '_'
+}).
+
+-type rctx_field() ::
+ started_at
+ | updated_at
+ | pid_ref
+ | nonce
+ | type
+ | dbname
+ | username
+ | db_open
+ | docs_read
+ | docs_written
+ | rows_read
+ | changes_returned
+ | ioq_calls
+ | js_filter
+ | js_filtered_docs
+ | get_kv_node
+ | get_kp_node.
+ %% "Example to extend CSRT"
+ %%| write_kv_node
+ %%| write_kp_node.
+
+
+-type coordinator_rctx() :: #rctx{type :: coordinator()}.
+-type rpc_worker_rctx() :: #rctx{type :: rpc_worker()}.
+-type rctx() :: #rctx{} | coordinator_rctx() | rpc_worker_rctx().
+-type rctxs() :: [#rctx{}] | [].
+-type maybe_rctx() :: rctx() | undefined.
+
+%% TODO: solidify nonce type and ideally move to couch_db.hrl
+-type nonce() :: any().
+-type dbname() :: iodata().
+-type username() :: iodata().
+
+-type delta() :: map().
+-type maybe_delta() :: delta() | undefined.
+-type tagged_delta() :: {delta, maybe_delta()}.
+-type term_delta() :: term() | {term(), tagged_delta()}.
+
+-type matcher_name() :: string().
+-type matcher() :: {ets:match_spec(), ets:comp_match_spec()}.
+-type matchers() :: #{matcher_name() => matcher()} | #{}.
+-type matcher_matches() :: #{matcher_name() => rctxs()} | #{}.
+-type maybe_matcher() :: matcher() | undefined.
+-type maybe_matchers() :: matchers() | undefined.
+
+-type maybe_integer() :: integer() | undefined.
+%% This is a little awkward to type, it's a list of ets:update_counter UpdateOp's
+%% where ets types the updates as `UpdateOp = {Pos, Incr}`. We can do better than
+%% that because we know `Pos` is the #rctx record field index, a non_neg_integer(),
+%% and similarly, we know Incr is from `couch_srt_util:make_dt`, which is returns at
+%% least one. Ideally, we'd specify the `Pos` type sufficiently to be one of the
+%% valid #rctx record field names, however, a clean solution is not obvious.
+-type counter_updates_list() :: [{non_neg_integer(), pos_integer()}] | [].
+
+-type tuple_of_field_values() :: tuple().
+-type tuple_of_field_names() :: tuple().
+
+-type query_options() :: #{aggregation => group_by | sort_by | count_by, limit => pos_integer()}.
+-type aggregation_key() :: tuple_of_field_names().
+-type aggregation_values() :: tuple_of_field_values().
+
+-type field_value() :: any().
+-type aggregation_value() :: field_value().
+-type aggregation_result() :: #{aggregation_key() => non_neg_integer()}.
+-type ordered_result() :: [{aggregation_key(), non_neg_integer()}].
+-type query_result() :: aggregation_result() | ordered_result().
+
+-type json_spec(_Spec) :: term().
+-type json_string() :: binary().
\ No newline at end of file
diff --git a/src/couch_srt/src/couch_srt_app.erl b/src/couch_srt/src/couch_srt_app.erl
new file mode 100644
index 00000000000..c9397414b27
--- /dev/null
+++ b/src/couch_srt/src/couch_srt_app.erl
@@ -0,0 +1,23 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(couch_srt_app).
+
+-behaviour(application).
+
+-export([start/2, stop/1]).
+
+start(_StartType, _StartArgs) ->
+ couch_srt_sup:start_link().
+
+stop(_State) ->
+ ok.
diff --git a/src/couch_srt/src/couch_srt_entry.erl b/src/couch_srt/src/couch_srt_entry.erl
new file mode 100644
index 00000000000..8626fce7dae
--- /dev/null
+++ b/src/couch_srt/src/couch_srt_entry.erl
@@ -0,0 +1,244 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(couch_srt_entry).
+
+-include_lib("stdlib/include/ms_transform.hrl").
+-include_lib("couch_srt.hrl").
+
+-export([
+ value/2,
+ key/1,
+ from_map/1,
+ record_info/0
+]).
+
+%% JSON Conversion API
+-export([
+ convert_type/1,
+ convert_pidref/1,
+ convert_pid/1,
+ convert_ref/1,
+ convert_string/1,
+ to_json/1
+]).
+
+-spec value(rctx_field(), #rctx{}) -> any().
+
+value(pid_ref, #rctx{pid_ref = Val}) -> Val;
+value(nonce, #rctx{nonce = Val}) -> Val;
+value(type, #rctx{type = Val}) -> convert_type(Val);
+value(dbname, #rctx{dbname = Val}) -> Val;
+value(username, #rctx{username = Val}) -> Val;
+value(db_open, #rctx{db_open = Val}) -> Val;
+value(docs_read, #rctx{docs_read = Val}) -> Val;
+value(docs_written, #rctx{docs_written = Val}) -> Val;
+value(rows_read, #rctx{rows_read = Val}) -> Val;
+value(changes_returned, #rctx{changes_returned = Val}) -> Val;
+value(ioq_calls, #rctx{ioq_calls = Val}) -> Val;
+value(js_filter, #rctx{js_filter = Val}) -> Val;
+value(js_filtered_docs, #rctx{js_filtered_docs = Val}) -> Val;
+value(get_kv_node, #rctx{get_kv_node = Val}) -> Val;
+value(get_kp_node, #rctx{get_kp_node = Val}) -> Val;
+value(started_at, #rctx{started_at = Val}) -> Val;
+value(updated_at, #rctx{updated_at = Val}) -> Val.
+
+-spec key(BinKey :: binary() | string() | atom()) ->
+ Key ::
+ rctx_field()
+ | {error, Reason :: any()}.
+
+key(Key) when is_atom(Key) ->
+ key_from_atom(Key);
+key(Key) when is_binary(Key) ->
+ key_from_binary(Key);
+key(Key) when is_list(Key) ->
+ case key_from_binary(list_to_binary(Key)) of
+ {error, {invalid_key, _Key}} ->
+ {error, {invalid_key, Key}};
+ Res ->
+ Res
+ end;
+key(Other) ->
+ key_error(Other).
+
+key_from_atom(pid_ref) -> pid_ref;
+key_from_atom(nonce) -> nonce;
+key_from_atom(type) -> type;
+key_from_atom(dbname) -> dbname;
+key_from_atom(username) -> username;
+key_from_atom(db_open) -> db_open;
+key_from_atom(docs_read) -> docs_read;
+key_from_atom(rows_read) -> rows_read;
+key_from_atom(changes_returned) -> changes_returned;
+key_from_atom(ioq_calls) -> ioq_calls;
+key_from_atom(js_filter) -> js_filter;
+key_from_atom(js_filtered_docs) -> js_filtered_docs;
+key_from_atom(get_kv_node) -> get_kv_node;
+key_from_atom(get_kp_node) -> get_kp_node;
+key_from_atom(Other) -> key_error(Other).
+
+key_from_binary(<<"pid_ref">>) -> pid_ref;
+key_from_binary(<<"nonce">>) -> nonce;
+key_from_binary(<<"type">>) -> type;
+key_from_binary(<<"dbname">>) -> dbname;
+key_from_binary(<<"username">>) -> username;
+key_from_binary(<<"db_open">>) -> db_open;
+key_from_binary(<<"docs_read">>) -> docs_read;
+key_from_binary(<<"rows_read">>) -> rows_read;
+key_from_binary(<<"changes_returned">>) -> changes_returned;
+key_from_binary(<<"ioq_calls">>) -> ioq_calls;
+key_from_binary(<<"js_filter">>) -> js_filter;
+key_from_binary(<<"js_filtered_docs">>) -> js_filtered_docs;
+key_from_binary(<<"get_kv_node">>) -> get_kv_node;
+key_from_binary(<<"get_kp_node">>) -> get_kp_node;
+key_from_binary(Other) -> key_error(Other).
+
+key_error(Key) ->
+ {error, {invalid_key, Key}}.
+
+-spec from_map(Map :: map()) -> rctx().
+
+from_map(Map) ->
+ maps:fold(fun set_field/3, #rctx{}, Map).
+
+-spec set_field(Field :: rctx_field(), Val :: any(), Rctx :: rctx()) -> rctx().
+set_field(updated_at, Val, Rctx) ->
+ Rctx#rctx{updated_at = Val};
+set_field(started_at, Val, Rctx) ->
+ Rctx#rctx{started_at = Val};
+set_field(pid_ref, Val, Rctx) ->
+ Rctx#rctx{pid_ref = Val};
+set_field(nonce, Val, Rctx) ->
+ Rctx#rctx{nonce = Val};
+set_field(dbname, Val, Rctx) ->
+ Rctx#rctx{dbname = Val};
+set_field(username, Val, Rctx) ->
+ Rctx#rctx{username = Val};
+set_field(db_open, Val, Rctx) ->
+ Rctx#rctx{db_open = Val};
+set_field(docs_read, Val, Rctx) ->
+ Rctx#rctx{docs_read = Val};
+set_field(docs_written, Val, Rctx) ->
+ Rctx#rctx{docs_written = Val};
+set_field(js_filter, Val, Rctx) ->
+ Rctx#rctx{js_filter = Val};
+set_field(js_filtered_docs, Val, Rctx) ->
+ Rctx#rctx{js_filtered_docs = Val};
+set_field(rows_read, Val, Rctx) ->
+ Rctx#rctx{rows_read = Val};
+set_field(type, Val, Rctx) ->
+ Rctx#rctx{type = Val};
+set_field(get_kp_node, Val, Rctx) ->
+ Rctx#rctx{get_kp_node = Val};
+set_field(get_kv_node, Val, Rctx) ->
+ Rctx#rctx{get_kv_node = Val};
+%% "Example to extend CSRT"
+%% set_field(write_kp_node, Val, Rctx) ->
+%% Rctx#rctx{write_kp_node = Val};
+%% set_field(write_kv_node, Val, Rctx) ->
+%% Rctx#rctx{write_kv_node = Val};
+set_field(changes_returned, Val, Rctx) ->
+ Rctx#rctx{changes_returned = Val};
+set_field(ioq_calls, Val, Rctx) ->
+ Rctx#rctx{ioq_calls = Val};
+set_field(_, _, Rctx) ->
+ %% Unknown key, could throw but just move on
+ Rctx.
+
+-spec record_info() ->
+ #{
+ fields => [rctx_field()],
+ size => pos_integer(),
+ field_idx => #{rctx_field() => pos_integer()}
+ }.
+record_info() ->
+ Fields = record_info(fields, rctx),
+ Size = record_info(size, rctx),
+ Idx = maps:from_list(lists:zip(Fields, lists:seq(1, length(Fields)))),
+ #{
+ fields => Fields,
+ field_idx => Idx,
+ size => Size
+ }.
+
+-spec to_json(Rctx :: rctx()) -> map().
+to_json(#rctx{} = Rctx) ->
+ #{
+ updated_at => convert_string(couch_srt_util:tutc(Rctx#rctx.updated_at)),
+ started_at => convert_string(couch_srt_util:tutc(Rctx#rctx.started_at)),
+ pid_ref => convert_pidref(Rctx#rctx.pid_ref),
+ nonce => convert_string(Rctx#rctx.nonce),
+ dbname => convert_string(Rctx#rctx.dbname),
+ username => convert_string(Rctx#rctx.username),
+ db_open => Rctx#rctx.db_open,
+ docs_read => Rctx#rctx.docs_read,
+ docs_written => Rctx#rctx.docs_written,
+ js_filter => Rctx#rctx.js_filter,
+ js_filtered_docs => Rctx#rctx.js_filtered_docs,
+ rows_read => Rctx#rctx.rows_read,
+ type => convert_type(Rctx#rctx.type),
+ get_kp_node => Rctx#rctx.get_kp_node,
+ get_kv_node => Rctx#rctx.get_kv_node,
+ %% "Example to extend CSRT"
+ %% write_kp_node => Rctx#rctx.write_kp_node,
+ %% write_kv_node => Rctx#rctx.write_kv_node,
+ changes_returned => Rctx#rctx.changes_returned,
+ ioq_calls => Rctx#rctx.ioq_calls
+ }.
+
+%%
+%% Conversion API for outputting JSON
+%%
+
+-spec convert_type(T) -> binary() | null when
+ T :: #coordinator{} | #rpc_worker{} | undefined.
+convert_type(#coordinator{method = Verb0, path = Path, mod = M0, func = F0}) ->
+ M = atom_to_binary(M0),
+ F = atom_to_binary(F0),
+ Verb = atom_to_binary(Verb0),
+ <<"coordinator-{", M/binary, ":", F/binary, "}:", Verb/binary, ":", Path/binary>>;
+convert_type(#rpc_worker{mod = M0, func = F0, from = From0}) ->
+ M = atom_to_binary(M0),
+ F = atom_to_binary(F0),
+ %% Technically From is a PidRef data type from Pid, but different Ref for fabric
+ From = convert_pidref(From0),
+ <<"rpc_worker-{", From/binary, "}:", M/binary, ":", F/binary>>;
+convert_type(undefined) ->
+ null.
+
+-spec convert_pidref(PidRef) -> binary() | null when
+ PidRef :: {A :: pid(), B :: reference()} | undefined.
+convert_pidref({Parent0, ParentRef0}) ->
+ Parent = convert_pid(Parent0),
+ ParentRef = convert_ref(ParentRef0),
+ <
+%% Q = query([
+%% ...
+%% from("docs_read")
+%% ]),
+%%
+%% @end
+-spec from(MatcherName :: matcher_name() | all) ->
+ {ok, #{from => matcher_name() | all, is_unsafe => boolean()}} | {error, any()}.
+from(all) ->
+ #from{matcher = all, is_safe = false};
+from(MatcherName) ->
+ case couch_srt_logger:get_matcher(MatcherName) of
+ undefined ->
+ {error, {unknown_matcher, MatcherName}};
+ _ ->
+ #from{matcher = MatcherName, is_safe = true}
+ end.
+
+%% @doc Construct 'options' query expression.
+%% There are following types of expressions allowed in the query.
+%%
+%% Q = query([
+%% ...
+%% options([
+%% ...
+%% ])
+%% ]),
+%%
+%% @end
+-spec options([query_option()]) ->
+ #query_options{} | {error, any()}.
+options(Options) ->
+ lists:foldl(
+ fun
+ (_, {error, _} = Error) ->
+ Error;
+ ({limit, unlimited}, Acc) ->
+ Acc#query_options{limit = unlimited, is_safe = false};
+ ({limit, Limit}, Acc) when is_integer(Limit) ->
+ Acc#query_options{limit = Limit};
+ ({error, _} = Error, _Acc) ->
+ Error
+ end,
+ #query_options{is_safe = true},
+ Options
+ ).
+
+%% @doc Enable unlimited number of results from the query.
+%% The use of 'unlimited' makes the query 'unsafe'. Because it can return many matching rows.
+%% Unsafe queries can only be run using 'unsafe_run/1'.
+%%
+%% Q = query([
+%% ...
+%% options([
+%% unlimited()
+%% ])
+%% ]),
+%%
+%% @end
+unlimited() ->
+ {limit, unlimited}.
+
+%% @doc Set limit on number of results returned from the query.
+%% The construction of the query fail if the 'limit' is greater than
+%% allowed for this cluster.
+%%
+%% Q = query([
+%% ...
+%% options([
+%% with_limit(100)
+%% ])
+%% ]),
+%%
+%% @end
+with_limit(Limit) when is_integer(Limit) ->
+ case Limit =< query_limit() of
+ true ->
+ {limit, Limit};
+ false ->
+ {error, {beyond_limit, Limit}}
+ end;
+with_limit(Limit) ->
+ {error, {invalid_limit, Limit}}.
+
+%% @doc Request 'count_by' aggregation of results.
+%%
+%% Q = query([
+%% ...
+%% count_by(username)
+%% ]),
+%%
+%% @end
+-spec count_by(AggregationKeys) ->
+ {count_by, #selector{}} | {count_by, #unsafe_selector{}} | {error, any()}
+when
+ AggregationKeys ::
+ aggregation_keys_fun()
+ | binary()
+ | rctx_field()
+ | [binary()]
+ | [rctx_field()].
+count_by(AggregationKeys) ->
+ with_tag(select(AggregationKeys), count_by).
+
+%% @doc Request 'sort_by' aggregation of results.
+%%
+%% Q = query([
+%% ...
+%% sort_by([username, dbname])
+%% ]),
+%%
+%% @end
+-spec sort_by(AggregationKeys) ->
+ {sort_by, #selector{}} | {sort_by, #unsafe_selector{}} | {error, any()}
+when
+ AggregationKeys ::
+ aggregation_keys_fun()
+ | binary()
+ | rctx_field()
+ | [binary()]
+ | [rctx_field()].
+sort_by(AggregationKeys) ->
+ with_tag(select(AggregationKeys), sort_by).
+
+%% @doc Request 'sort_by' aggregation of results.
+%%
+%% Q = query([
+%% ...
+%% sort_by([username, dbname], ioq_calls)
+%% ]),
+%%
+%% @end
+-spec sort_by(AggregationKeys, ValueKey) ->
+ {sort_by, #selector{}} | {sort_by, #unsafe_selector{}} | {error, any()}
+when
+ AggregationKeys ::
+ aggregation_keys_fun()
+ | binary()
+ | rctx_field()
+ | [binary()]
+ | [rctx_field()],
+ ValueKey ::
+ value_key_fun()
+ | binary()
+ | rctx_field().
+sort_by(AggregationKeys, ValueKey) ->
+ with_tag(select(AggregationKeys, ValueKey), sort_by).
+
+%% @doc Request 'group_by' aggregation of results.
+%%
+%% Q = query([
+%% ...
+%% group_by([username, dbname])
+%% ]),
+%%
+%% @end
+-spec group_by(AggregationKeys) ->
+ {group_by, #selector{}} | {group_by, #unsafe_selector{}} | {error, any()}
+when
+ AggregationKeys ::
+ aggregation_keys_fun()
+ | binary()
+ | rctx_field()
+ | [binary()]
+ | [rctx_field()].
+group_by(AggregationKeys) ->
+ with_tag(select(AggregationKeys), group_by).
+
+%% @doc Request 'group_by' aggregation of results.
+%%
+%% Q = query([
+%% ...
+%% group_by([username, dbname], ioq_calls)
+%% ]),
+%%
+%% @end
+-spec group_by(AggregationKeys, ValueKey) ->
+ {group_by, #selector{}} | {group_by, #unsafe_selector{}} | {error, any()}
+when
+ AggregationKeys ::
+ aggregation_keys_fun()
+ | binary()
+ | rctx_field()
+ | [binary()]
+ | [rctx_field()],
+ ValueKey ::
+ value_key_fun()
+ | binary()
+ | rctx_field().
+group_by(AggregationKeys, ValueKey) ->
+ with_tag(select(AggregationKeys, ValueKey), group_by).
+
+%% @doc Construct query from the expressions.
+%% There are following types of expressions allowed in the query.
+%%
+%% Q = query([
+%% from("docs_read"),
+%% group_by(username, dbname, ioq_calls),
+%% options([
+%% with_limit(10)
+%% ])
+%% ]),
+%%
+%% @end
+query(Query) ->
+ % start assuming safe query and turn to unsafe when we detect issues
+ Acc = #query{is_safe = true},
+ Result = lists:foldr(
+ fun
+ ({Aggregation, #unsafe_selector{} = Selector}, {E, #query{selector = undefined} = Q}) ->
+ {E, Q#query{selector = Selector, is_safe = false, aggregation = Aggregation}};
+ ({Aggregation, #unsafe_selector{}}, {E, Q}) ->
+ {[{more_than_once, {select, Aggregation}} | E], Q};
+ ({Aggregation, #selector{} = Selector}, {E, #query{selector = undefined} = Q}) ->
+ {E, Q#query{selector = Selector, aggregation = Aggregation}};
+ ({Aggregation, #selector{}}, {E, Q}) ->
+ {[{more_than_once, {select, Aggregation}} | E], Q};
+ (#query_options{is_safe = false, limit = Limit}, {E, #query{limit = undefined} = Q}) ->
+ {E, Q#query{limit = Limit, is_safe = false}};
+ (#query_options{limit = Limit}, {E, #query{limit = undefined} = Q}) ->
+ {E, Q#query{limit = Limit}};
+ (#query_options{}, {E, Q}) ->
+ {[{more_than_once, options} | E], Q};
+ (#from{matcher = Matcher, is_safe = false}, {E, #query{matcher = undefined} = Q}) ->
+ {E, Q#query{matcher = Matcher, is_safe = false}};
+ (#from{matcher = Matcher}, {E, #query{matcher = undefined} = Q}) ->
+ {E, Q#query{matcher = Matcher}};
+ (#from{}, {E, Q}) ->
+ {[{more_than_once, from} | E], Q};
+ ({error, Reason}, {E, Q}) ->
+ {[Reason | E], Q}
+ end,
+ {[], Acc},
+ Query
+ ),
+ case Result of
+ {[], #query{} = Q} ->
+ Q;
+ {Errors, _} ->
+ {error, Errors}
+ end.
+
+%% @doc Executes provided query. Only 'safe' queries can be executed using 'run'.
+%% The query considered 'unsafe' if any of the conditions bellow are met:
+%%
+%% Q = query([
+%% from("docs_read"),
+%% group_by(username, dbname, ioq_calls),
+%% options([
+%% with_limit(10)
+%% ])
+%% ]),
+%% run(Q)
+%%
+%% @end
+-spec run(#query{}) ->
+ {ok, [{aggregation_key(), pos_integer()}]}
+ | {limit, [{aggregation_key(), pos_integer()}]}.
+run(#query{
+ is_safe = true,
+ matcher = MatcherName,
+ selector = #selector{} = Selector,
+ limit = Limit,
+ aggregation = Aggregation
+}) ->
+ % we validated the presence of the matcher so this shouldn't fail
+ {ok, Matcher} = get_matcher(MatcherName),
+ case {Aggregation, Selector} of
+ {count_by, #selector{aggregation_keys = AKey, value_key = undefined}} ->
+ ValFun = fun(_) -> 1 end,
+ to_map(maybe_apply_limit(group_by(Matcher, AKey, ValFun), Limit));
+ {count_by, #selector{aggregation_keys = AKey, value_key = VKey}} ->
+ to_map(maybe_apply_limit(group_by(Matcher, AKey, VKey), Limit));
+ {sort_by, #selector{aggregation_keys = AKey, value_key = VKey}} ->
+ maybe_apply_limit(group_by(Matcher, AKey, VKey), Limit);
+ {group_by, #selector{aggregation_keys = AKey, value_key = undefined}} ->
+ ValFun = fun(_) -> 1 end,
+ to_map(maybe_apply_limit(group_by(Matcher, AKey, ValFun), Limit));
+ {group_by, #selector{aggregation_keys = AKey, value_key = VKey}} ->
+ to_map(maybe_apply_limit(group_by(Matcher, AKey, VKey), Limit))
+ end;
+run(#query{}) ->
+ {error,
+ {unsafe_query, "Please use 'unsafe(Query)' instead if you really know what you are doing."}}.
+
+%% @doc Executes provided query. This function is similar to 'run/1',
+%% however it supports 'unsafe' queries. Be very careful using it.
+%% Pay attention to cardinality of the result.
+%% The query considered 'unsafe' if any of the conditions bellow are met:
+%%
+%% Q = query([
+%% from("docs_read"),
+%% group_by(username, dbname, ioq_calls),
+%% options([
+%% with_limit(10)
+%% ])
+%% ]),
+%% unsafe_run(Q)
+%%
+%% @end
+-spec unsafe_run(#query{}) ->
+ {ok, [{aggregation_key(), pos_integer()}]}
+ | {limit, [{aggregation_key(), pos_integer()}]}.
+unsafe_run(#query{selector = #unsafe_selector{} = Selector} = Query) ->
+ %% mutate the record (since all fields stay the same)
+ unsafe_run(Query#query{selector = setelement(1, Selector, selector)});
+unsafe_run(#query{
+ matcher = MatcherName,
+ selector = #selector{} = Selector,
+ limit = Limit,
+ aggregation = Aggregation
+}) ->
+ Matcher = choose_matcher(MatcherName),
+ case {Aggregation, Selector} of
+ {count_by, #selector{aggregation_keys = AKey, value_key = undefined}} ->
+ ValFun = fun(_) -> 1 end,
+ to_map(maybe_apply_limit(group_by(Matcher, AKey, ValFun), Limit));
+ {count_by, #selector{aggregation_keys = AKey, value_key = VKey}} ->
+ to_map(maybe_apply_limit(group_by(Matcher, AKey, VKey), Limit));
+ {sort_by, #selector{aggregation_keys = AKey, value_key = VKey}} ->
+ maybe_apply_limit(group_by(Matcher, AKey, VKey), Limit);
+ {group_by, #selector{aggregation_keys = AKey, value_key = undefined}} ->
+ ValFun = fun(_) -> 1 end,
+ to_map(maybe_apply_limit(group_by(Matcher, AKey, ValFun), Limit));
+ {group_by, #selector{aggregation_keys = AKey, value_key = VKey}} ->
+ to_map(maybe_apply_limit(group_by(Matcher, AKey, VKey), Limit))
+ end.
+
+%%
+%% Query API auxiliary functions
+%%
+
+-spec select(AggregationKeys) ->
+ #selector{} | #unsafe_selector{} | {error, any()}
+when
+ AggregationKeys ::
+ aggregation_keys_fun() | binary() | rctx_field() | [binary()] | [rctx_field()].
+
+select(AggregationKeys) ->
+ maybe
+ {ok, AKey} ?= parse_aggregation_keys(AggregationKeys),
+ case is_safe_key(AKey) of
+ true ->
+ #selector{aggregation_keys = AKey};
+ false ->
+ #unsafe_selector{aggregation_keys = AKey}
+ end
+ end.
+
+-spec select(AggregationKeys, ValueKey) ->
+ {ok, #selector{} | #unsafe_selector{}} | {error, any()}
+when
+ AggregationKeys ::
+ aggregation_keys_fun() | binary() | rctx_field() | [binary()] | [rctx_field()],
+ ValueKey :: value_key_fun() | binary() | rctx_field().
+
+select(AggregationKeys, ValueKey) ->
+ maybe
+ {ok, AKey} ?= parse_aggregation_keys(AggregationKeys),
+ {ok, VKey} ?= parse_value_key(ValueKey),
+ case is_safe_key(AKey) andalso is_safe_key(VKey) of
+ true ->
+ #selector{aggregation_keys = AKey, value_key = VKey};
+ false ->
+ #unsafe_selector{aggregation_keys = AKey, value_key = VKey}
+ end
+ end.
+
+is_safe_key(Fun) when is_function(Fun) ->
+ false;
+is_safe_key(_) ->
+ true.
+
+parse_aggregation_keys(Fun) when is_function(Fun) ->
+ validate_fun(Fun, key_fun);
+parse_aggregation_keys(Keys) ->
+ with_ok(parse_key(Keys)).
+
+parse_value_key(Fun) when is_function(Fun) ->
+ validate_fun(Fun, value_fun);
+parse_value_key(Key) ->
+ case parse_key(Key) of
+ {error, _} = Error ->
+ Error;
+ Keys when is_list(Keys) ->
+ {error, multiple_value_keys};
+ K ->
+ {ok, K}
+ end.
+
+with_tag({error, _} = Error, _) ->
+ Error;
+with_tag(Result, Tag) ->
+ {Tag, Result}.
+
+with_ok({error, _} = Error) ->
+ Error;
+with_ok(Result) ->
+ {ok, Result}.
+
+validate_fun(Fun, Tag) when is_function(Fun, 1) ->
+ try Fun(#rctx{}) of
+ _ ->
+ {ok, Fun}
+ catch
+ _:_ ->
+ {error, {invalid_fun, Tag}}
+ end;
+validate_fun(_Fun, Tag) ->
+ {error, {invalid_fun, Tag}}.
+
+choose_matcher(all) ->
+ all();
+choose_matcher(MatcherName) ->
+ % we validated the presence of the matcher so this shouldn't fail
+ {ok, Matcher} = get_matcher(MatcherName),
+ Matcher.
+
+-spec maybe_apply_limit(ResultsOrError, Limit) -> OrderedResultsOrError when
+ ResultsOrError ::
+ {ok, aggregation_result()}
+ | {limit, aggregation_result()}
+ | {error, any()},
+ Limit :: unlimited | undefined | pos_integer(),
+ OrderedResultsOrError ::
+ {ok, ordered_result()}
+ | {limit, ordered_result()}
+ | {ok, aggregation_result()}
+ | {limit, aggregation_result()}
+ | {error, any()}.
+
+maybe_apply_limit({Result, Results}, unlimited) ->
+ {Result, Results};
+maybe_apply_limit({Result, Results}, undefined) ->
+ {Result, topK(Results, query_limit())};
+maybe_apply_limit({Result, Results}, Limit) when is_integer(Limit) ->
+ {Result, topK(Results, Limit)}.
+
+-spec to_map(ResultsOrError) -> OrderedResultsOrError when
+ ResultsOrError ::
+ {ok, ordered_result() | aggregation_result()}
+ | {limit, ordered_result() | aggregation_result()},
+ OrderedResultsOrError ::
+ {ok, aggregation_result()}
+ | {limit, aggregation_result()}.
+to_map({Result, Results}) when is_list(Results) ->
+ {Result, maps:from_list(Results)};
+to_map({Result, Results}) when is_map(Results) ->
+ {Result, Results}.
+
+-spec parse_key(Keys :: binary() | atom() | [binary()] | [atom()]) ->
+ rctx_field()
+ | [rctx_field()]
+ | {error, Reason :: any()}.
+
+parse_key([C | _] = Key) when is_integer(C) ->
+ couch_srt_entry:key(Key);
+parse_key(Keys) when is_list(Keys) ->
+ parse_key(Keys, []);
+parse_key(BinKey) when is_binary(BinKey) ->
+ couch_srt_entry:key(BinKey);
+parse_key(undefined) ->
+ undefined;
+parse_key(Key) when is_atom(Key) ->
+ couch_srt_entry:key(Key).
+
+parse_key([BinKey | Rest], Keys) ->
+ case couch_srt_entry:key(BinKey) of
+ {error, _} = Error ->
+ Error;
+ Key ->
+ parse_key(Rest, [Key | Keys])
+ end;
+parse_key([], Keys) ->
+ lists:reverse(Keys).
+
+%%
+%% Scanning with matchers
+%%
+-spec query_matcher(MatcherName :: string()) ->
+ {ok, query_result()}
+ | {error, any()}.
+query_matcher(MatcherName) when is_list(MatcherName) ->
+ query_matcher(MatcherName, query_limit()).
+
+-spec query_matcher(MatcherName :: matcher_name(), Limit :: pos_integer()) ->
+ {ok, query_result()}
+ | {error, any()}.
+query_matcher(MatcherName, Limit) when is_list(MatcherName) andalso is_integer(Limit) ->
+ case get_matcher(MatcherName) of
+ {ok, Matcher} ->
+ query_matcher_rows(Matcher, Limit);
+ Error ->
+ Error
+ end.
+
+-spec query_matcher_rows(Matcher :: matcher()) ->
+ {ok, query_result()}
+ | {error, any()}.
+query_matcher_rows(Matcher) ->
+ query_matcher_rows(Matcher, query_limit()).
+
+-spec query_matcher_rows(Matcher :: matcher(), Limit :: pos_integer()) ->
+ {ok, query_result()}
+ | {error, any()}.
+query_matcher_rows({MSpec, _CompMSpec}, Limit) when
+ is_list(MSpec) andalso is_integer(Limit) andalso Limit >= 1
+->
+ try
+ %% ets:select/* takes match_spec(), not comp_match_spec()
+ %% use ets:select/3 to constrain to Limit rows, but we need to handle
+ %% the continuation() style return type compared with ets:select/2.
+ Rctxs =
+ case ets:select(?CSRT_ETS, MSpec, Limit) of
+ {Rctxs0, _Continuation} ->
+ Rctxs0;
+ %% Handle '$end_of_table'
+ _ ->
+ []
+ end,
+ {ok, to_json_list(Rctxs)}
+ catch
+ _:_ = Error ->
+ {error, Error}
+ end.
+
+get_matcher(MatcherName) ->
+ case couch_srt_logger:get_matcher(MatcherName) of
+ undefined ->
+ {error, {unknown_matcher, MatcherName}};
+ Matcher ->
+ {ok, Matcher}
+ end.
+
+%%
+%% Auxiliary functions
+%%
+query_limit() ->
+ config:get_integer(?CSRT, "query_limit", ?QUERY_LIMIT).
+
+query_cardinality_limit() ->
+ config:get_integer(?CSRT, "query_cardinality_limit", ?QUERY_CARDINALITY_LIMIT).
+
+to_json_list(List) when is_list(List) ->
+ lists:map(fun couch_srt_entry:to_json/1, List).
diff --git a/src/couch_srt/src/couch_srt_server.erl b/src/couch_srt/src/couch_srt_server.erl
new file mode 100644
index 00000000000..a2ed6944381
--- /dev/null
+++ b/src/couch_srt/src/couch_srt_server.erl
@@ -0,0 +1,328 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(couch_srt_server).
+
+-behaviour(gen_server).
+
+-export([
+ start_link/0,
+ init/1,
+ handle_call/3,
+ handle_cast/2
+]).
+
+-export([
+ create_pid_ref/0,
+ create_resource/1,
+ destroy_resource/1,
+ get_resource/1,
+ get_context_type/1,
+ inc/2,
+ inc/3,
+ match_resource/1,
+ new_context/2,
+ set_context_dbname/2,
+ set_context_handler_fun/2,
+ set_context_type/2,
+ set_context_username/2,
+ update_counter/3,
+ update_counter/4,
+ update_counters/2,
+ update_counters/3
+]).
+
+-include_lib("stdlib/include/ms_transform.hrl").
+-include_lib("couch_srt.hrl").
+
+-record(st, {}).
+
+%%
+%% Public API
+%%
+
+-spec create_pid_ref() -> pid_ref().
+create_pid_ref() ->
+ {self(), make_ref()}.
+
+%%
+%%
+%% Context lifecycle API
+%%
+
+-spec new_context(Type :: rctx_type(), Nonce :: nonce()) -> rctx().
+new_context(Type, Nonce) ->
+ #rctx{
+ nonce = Nonce,
+ pid_ref = create_pid_ref(),
+ type = Type
+ }.
+
+-spec set_context_dbname(DbName, PidRef) -> boolean() when
+ DbName :: dbname(), PidRef :: maybe_pid_ref().
+set_context_dbname(_, undefined) ->
+ false;
+set_context_dbname(DbName, PidRef) ->
+ update_element(PidRef, [{#rctx.dbname, DbName}]).
+
+-spec set_context_handler_fun({Mod, Func}, PidRef) -> boolean() when
+ Mod :: atom(), Func :: atom(), PidRef :: maybe_pid_ref().
+set_context_handler_fun(_, undefined) ->
+ false;
+set_context_handler_fun({Mod, Func}, PidRef) ->
+ case get_resource(PidRef) of
+ undefined ->
+ false;
+ #rctx{} = Rctx ->
+ %% TODO: #coordinator{} assumption needs to adapt for other types
+ case couch_srt_server:get_context_type(Rctx) of
+ #coordinator{} = Coordinator0 ->
+ Coordinator = Coordinator0#coordinator{mod = Mod, func = Func},
+ set_context_type(Coordinator, PidRef);
+ _ ->
+ false
+ end
+ end.
+
+-spec set_context_username(UserName, PidRef) -> boolean() when
+ UserName :: username(), PidRef :: maybe_pid_ref().
+set_context_username(_, undefined) ->
+ false;
+set_context_username(UserName, PidRef) ->
+ update_element(PidRef, [{#rctx.username, UserName}]).
+
+-spec get_context_type(Rctx :: rctx()) -> rctx_type().
+get_context_type(#rctx{type = Type}) ->
+ Type.
+
+-spec set_context_type(Type, PidRef) -> boolean() when
+ Type :: rctx_type(), PidRef :: maybe_pid_ref().
+set_context_type(Type, PidRef) ->
+ update_element(PidRef, [{#rctx.type, Type}]).
+
+-spec create_resource(Rctx :: rctx()) -> boolean().
+create_resource(#rctx{} = Rctx) ->
+ try ets:insert(?CSRT_ETS, Rctx) of
+ Result -> Result
+ catch
+ error:badarg ->
+ false
+ end.
+
+-spec destroy_resource(PidRef :: maybe_pid_ref()) -> boolean().
+destroy_resource(undefined) ->
+ false;
+destroy_resource({_, _} = PidRef) ->
+ try ets:delete(?CSRT_ETS, PidRef) of
+ Result -> Result
+ catch
+ error:badarg ->
+ false
+ end.
+
+-spec get_resource(PidRef :: maybe_pid_ref()) -> maybe_rctx().
+get_resource(undefined) ->
+ undefined;
+get_resource(PidRef) ->
+ try ets:lookup(?CSRT_ETS, PidRef) of
+ [#rctx{} = Rctx] ->
+ Rctx;
+ [] ->
+ undefined
+ catch
+ error:badarg ->
+ undefined
+ end.
+
+-spec match_resource(Rctx :: maybe_rctx()) -> [] | [rctx()].
+match_resource(undefined) ->
+ [];
+match_resource(#rctx{} = Rctx) ->
+ try
+ ets:match_object(?CSRT_ETS, Rctx)
+ catch
+ error:badarg ->
+ []
+ end.
+
+%% Is this a valid #rctx{} field for inducing ets:update_counter upon?
+-spec is_rctx_stat_field(Field :: rctx_field() | atom()) -> boolean().
+is_rctx_stat_field(Field) ->
+ maps:is_key(Field, ?STAT_KEYS_TO_FIELDS).
+
+%% Get the #rctx{} field record index of the corresponding stat counter field
+-spec get_rctx_stat_field(Field :: rctx_field()) ->
+ non_neg_integer()
+ | throw({badkey, Key :: any()}).
+get_rctx_stat_field(Field) ->
+ maps:get(Field, ?STAT_KEYS_TO_FIELDS).
+
+%% This provides a base set of updates to include along with any other #rctx{}
+%% updates. Specifically, this provides a way to automatically track and
+%% increment the #rctx.updated_at field without having to do ets:lookup to find
+%% the last updated_at time, or having to do ets:update_element to set a
+%% specific updated_at. We trade a pdict marker to keep inc operations as only
+%% a singular ets call while sneaking in updated_at.
+%% Calling couch_srt_util:put_updated_at/1 within this function is not the cleanest,
+%% but it allows us to encapsulate the automatic updated_at inclusion into the
+%% ?MODULE:update_counter(s)/3-4 arity call-through while still allowing the
+%% 4-arity version to be exposed to pass an empty base updates list. Isolating
+%% this logic means the final arity functions operate independently of any
+%% local pdict values.
+-spec make_base_counter_updates() -> counter_updates_list().
+make_base_counter_updates() ->
+ case couch_srt_util:get_updated_at() of
+ undefined ->
+ [];
+ LastUpdated ->
+ Now = couch_srt_util:tnow(),
+ couch_srt_util:put_updated_at(Now),
+ UpdatedInc = couch_srt_util:make_dt(LastUpdated, Now, native),
+ [{#rctx.updated_at, UpdatedInc}]
+ end.
+
+-spec update_counter(PidRef, Field, Count) -> non_neg_integer() when
+ PidRef :: maybe_pid_ref(),
+ Field :: rctx_field(),
+ Count :: non_neg_integer().
+update_counter(undefined, _Field, _Count) ->
+ 0;
+update_counter(_PidRef, _Field, 0) ->
+ 0;
+update_counter(PidRef, Field, Count) ->
+ %% Only call make_base_counter_updates() if PidRef, Field, Count all valid
+ case is_rctx_stat_field(Field) of
+ true ->
+ update_counter(PidRef, Field, Count, make_base_counter_updates());
+ false ->
+ 0
+ end.
+
+-spec update_counter(PidRef, Field, Count, BaseUpdates) -> non_neg_integer() when
+ PidRef :: maybe_pid_ref(),
+ Field :: rctx_field(),
+ Count :: non_neg_integer(),
+ BaseUpdates :: [] | [{rctx_field(), integer()}].
+update_counter(undefined, _Field, _Count, _BaseUpdates) ->
+ 0;
+update_counter({_Pid, _Ref} = PidRef, Field, Count, BaseUpdates) when Count >= 0 ->
+ case is_rctx_stat_field(Field) of
+ true ->
+ Updates = [{get_rctx_stat_field(Field), Count} | BaseUpdates],
+ try
+ ets:update_counter(?CSRT_ETS, PidRef, Updates, #rctx{pid_ref = PidRef})
+ catch
+ error:badarg ->
+ 0
+ end;
+ false ->
+ 0
+ end.
+
+-spec update_counters(PidRef, Delta) -> boolean() when
+ PidRef :: maybe_pid_ref(),
+ Delta :: delta().
+update_counters(undefined, _Delta) ->
+ false;
+update_counters(PidRef, Delta) when is_map(Delta) ->
+ update_counters(PidRef, Delta, make_base_counter_updates()).
+
+-spec update_counters(PidRef, Delta, BaseUpdates) -> boolean() when
+ PidRef :: maybe_pid_ref(),
+ Delta :: delta(),
+ BaseUpdates :: [] | [{rctx_field(), integer()}].
+update_counters(undefined, _Delta, _BaseUpdates) ->
+ false;
+update_counters({_Pid, _Ref} = PidRef, Delta, BaseUpdates) when is_map(Delta) ->
+ Updates = maps:fold(
+ fun(Field, Count, Acc) ->
+ case is_rctx_stat_field(Field) of
+ true ->
+ [{get_rctx_stat_field(Field), Count} | Acc];
+ false ->
+ %% This skips entries that are not is_rctx_stat_field's
+ %% Another approach would be:
+ %% lists:all(lists:map(fun is_rctx_stat_field/1, maps:keys(Delta)))
+ %% But that's a lot of looping for not even acumulating the update.
+ %% Need to drop Delta.dt either way as it's not an rctx_field
+ Acc
+ end
+ end,
+ BaseUpdates,
+ Delta
+ ),
+
+ case Updates of
+ [] ->
+ false;
+ _ ->
+ try
+ ets:update_counter(?CSRT_ETS, PidRef, Updates, #rctx{pid_ref = PidRef}),
+ true
+ catch
+ error:badarg ->
+ false
+ end
+ end.
+
+-spec inc(PidRef :: maybe_pid_ref(), Field :: rctx_field()) -> non_neg_integer().
+inc(PidRef, Field) ->
+ inc(PidRef, Field, 1).
+
+-spec inc(PidRef, Field, N) -> non_neg_integer() when
+ PidRef :: maybe_pid_ref(),
+ Field :: rctx_field(),
+ N :: non_neg_integer().
+inc(undefined, _Field, _) ->
+ 0;
+inc(_PidRef, _Field, 0) ->
+ 0;
+inc({_Pid, _Ref} = PidRef, Field, N) when is_integer(N) andalso N > 0 ->
+ case is_rctx_stat_field(Field) of
+ true ->
+ update_counter(PidRef, Field, N);
+ false ->
+ 0
+ end.
+
+%%
+%% gen_server callbacks
+%%
+
+start_link() ->
+ gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
+
+init([]) ->
+ ets:new(?CSRT_ETS, [
+ named_table,
+ public,
+ {write_concurrency, auto},
+ {read_concurrency, true},
+ {keypos, #rctx.pid_ref}
+ ]),
+ {ok, #st{}}.
+
+handle_call(Msg, From, State) ->
+ {stop, {unknown_call, Msg, From}, State}.
+
+handle_cast(Msg, State) ->
+ {stop, {unknown_cast, Msg}, State}.
+
+%%
+%% private functions
+%%
+
+-spec update_element(PidRef :: maybe_pid_ref(), Updates :: [tuple()]) -> boolean().
+update_element(undefined, _Update) ->
+ false;
+update_element({_Pid, _Ref} = PidRef, Update) ->
+ (catch ets:update_element(?CSRT_ETS, PidRef, Update)) == true.
diff --git a/src/couch_srt/src/couch_srt_sup.erl b/src/couch_srt/src/couch_srt_sup.erl
new file mode 100644
index 00000000000..288c661b0e4
--- /dev/null
+++ b/src/couch_srt/src/couch_srt_sup.erl
@@ -0,0 +1,40 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(couch_srt_sup).
+
+-behaviour(supervisor).
+
+-export([
+ start_link/0,
+ init/1
+]).
+
+%% Set the child workers to have restart strategy set to `transient`
+%% so that if a CSRT failure arrises that triggers the sup rate limiter
+%% thresholds, that shutdown signal will bubble up here and be ignored,
+%% as the use of transient specifies that `normal` and `shutdown` signals
+%% are ignored.
+%% Switch this to `permanent` once CSRT is out of experimental stage.
+-define(CHILD(I, Type), {I, {I, start_link, []}, transient, 5000, Type, [I]}).
+
+start_link() ->
+ supervisor:start_link({local, ?MODULE}, ?MODULE, []).
+
+init([]) ->
+ {ok,
+ {
+ {one_for_one, 5, 10}, [
+ ?CHILD(couch_srt_server, worker),
+ ?CHILD(couch_srt_logger, worker)
+ ]
+ }}.
diff --git a/src/couch_srt/src/couch_srt_util.erl b/src/couch_srt/src/couch_srt_util.erl
new file mode 100644
index 00000000000..7815e5ffa51
--- /dev/null
+++ b/src/couch_srt/src/couch_srt_util.erl
@@ -0,0 +1,244 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(couch_srt_util).
+
+-export([
+ is_enabled/0,
+ is_enabled_init_p/0,
+ is_enabled_reporting/0,
+ is_enabled_rpc_reporting/0,
+ get_pid_ref/0,
+ get_pid_ref/1,
+ set_pid_ref/1,
+ tnow/0,
+ tutc/0,
+ tutc/1
+]).
+
+%% Delta API
+-export([
+ add_delta/2,
+ extract_delta/1,
+ get_delta/1,
+ get_delta_a/0,
+ get_updated_at/0,
+ maybe_add_delta/1,
+ maybe_add_delta/2,
+ make_delta/1,
+ make_dt/2,
+ make_dt/3,
+ rctx_delta/2,
+ put_delta_a/1,
+ put_updated_at/1
+]).
+
+-include_lib("couch_srt.hrl").
+
+-ifdef(TEST).
+-spec is_enabled() -> boolean().
+is_enabled() ->
+ %% randomly enable CSRT during testing to handle unexpected failures
+ case config:get_boolean(?CSRT, "randomize_testing", true) of
+ true ->
+ rand:uniform(100) > 80;
+ false ->
+ config:get_boolean(?CSRT, "enable", true)
+ end.
+-else.
+-spec is_enabled() -> boolean().
+is_enabled() ->
+ config:get_boolean(?CSRT, "enable", false).
+-endif.
+
+-spec is_enabled_init_p() -> boolean().
+is_enabled_init_p() ->
+ config:get_boolean(?CSRT, "enable_init_p", false).
+
+%% Toggle to disable all reporting
+-spec is_enabled_reporting() -> boolean().
+is_enabled_reporting() ->
+ config:get_boolean(?CSRT, "enable_reporting", false).
+
+%% Toggle to disable all reporting from #rpc_worker{} types, eg only log
+%% #coordinator{} types. This is a bit of a kludge that would be better served
+%% by a dynamic match spec generator, but this provides a know for disabling
+%% any rpc worker logs, even if they hit the normal logging Threshold's.
+-spec is_enabled_rpc_reporting() -> boolean().
+is_enabled_rpc_reporting() ->
+ config:get_boolean(?CSRT, "enable_rpc_reporting", false).
+
+%% Monotonic time now in native format using time forward only event tracking
+-spec tnow() -> integer().
+tnow() ->
+ erlang:monotonic_time().
+
+%% Get current system time in UTC RFC 3339 format
+-spec tutc() -> calendar:rfc3339_string().
+tutc() ->
+ tutc(tnow()).
+
+%% Convert an Erlang native monotonic_time() into UTC RFC 3339 format
+-spec tutc(Time :: integer()) -> calendar:rfc3339_string().
+tutc(Time0) when is_integer(Time0) ->
+ calendar:system_time_to_rfc3339(
+ Time0 + erlang:time_offset(),
+ [{unit, native}, {offset, "z"}]
+ ).
+
+%% Returns dt (delta time) in microseconds
+%% @equiv make_dt(A, B, microsecond)
+-spec make_dt(A, B) -> pos_integer() when
+ A :: integer(),
+ B :: integer().
+make_dt(A, B) ->
+ make_dt(A, B, microsecond).
+
+%% Returns monotonic dt (delta time) in specified time_unit()
+-spec make_dt(A, B, Unit) -> pos_integer() when
+ A :: integer(),
+ B :: integer(),
+ Unit :: erlang:time_unit().
+make_dt(A, A, _Unit) when is_integer(A) ->
+ %% Handle edge case when monotonic_time()'s are equal
+ %% Always return a non zero value so we don't divide by zero
+ %% This always returns 1, independent of unit, as that's the smallest
+ %% possible positive integer value delta.
+ 1;
+make_dt(A, B, Unit) when is_integer(A) andalso is_integer(B) andalso B > A ->
+ case erlang:convert_time_unit(B - A, native, Unit) of
+ Delta when Delta > 0 ->
+ Delta;
+ _ ->
+ %% Handle case where Delta is smaller than a whole Unit, eg:
+ %% Unit = millisecond,
+ %% (node1@127.0.0.1)2> erlang:convert_time_unit(423, native, Unit).
+ %% 0
+ 1
+ end.
+
+-spec add_delta(T :: term(), Delta :: maybe_delta()) -> term_delta().
+add_delta(T, undefined) ->
+ T;
+add_delta(T, Delta) when is_map(Delta) ->
+ add_delta_int(T, {delta, Delta}).
+
+-spec add_delta_int(T :: term(), Delta :: tagged_delta()) -> term_delta().
+add_delta_int(T, {delta, _} = Delta) ->
+ {T, Delta}.
+
+-spec extract_delta(T :: term_delta()) -> {term(), maybe_delta()}.
+extract_delta({Msg, {delta, Delta}}) ->
+ {Msg, Delta};
+extract_delta(Msg) ->
+ {Msg, undefined}.
+
+-spec get_delta(PidRef :: maybe_pid_ref()) -> tagged_delta().
+get_delta(PidRef) ->
+ {delta, make_delta(PidRef)}.
+
+-spec maybe_add_delta(T :: term()) -> term_delta().
+maybe_add_delta(T) ->
+ case is_enabled() of
+ false ->
+ T;
+ true ->
+ maybe_add_delta_int(T, get_delta(get_pid_ref()))
+ end.
+
+%% Allow for externally provided Delta in error handling scenarios
+%% eg in cases like rexi_server:notify_caller/3
+-spec maybe_add_delta(T :: term(), Delta :: maybe_delta()) -> term_delta().
+maybe_add_delta(T, undefined) ->
+ T;
+maybe_add_delta(T, Delta0) when is_map(Delta0) ->
+ case is_enabled() of
+ false ->
+ T;
+ true ->
+ Delta = {delta, Delta0},
+ maybe_add_delta_int(T, Delta)
+ end.
+
+-spec maybe_add_delta_int(T :: term(), Delta :: tagged_delta()) -> term_delta().
+maybe_add_delta_int(T, {delta, undefined}) ->
+ T;
+maybe_add_delta_int(T, {delta, _} = Delta) ->
+ add_delta_int(T, Delta).
+
+-spec make_delta(PidRef :: maybe_pid_ref()) -> maybe_delta().
+make_delta(undefined) ->
+ undefined;
+make_delta(PidRef) ->
+ TA = get_delta_a(),
+ TB = couch_srt_server:get_resource(PidRef),
+ Delta = rctx_delta(TA, TB),
+ put_delta_a(TB),
+ Delta.
+
+-spec rctx_delta(TA :: Rctx, TB :: Rctx) -> map().
+rctx_delta(#rctx{} = TA, #rctx{} = TB) ->
+ Delta = #{
+ docs_read => TB#rctx.docs_read - TA#rctx.docs_read,
+ docs_written => TB#rctx.docs_written - TA#rctx.docs_written,
+ js_filter => TB#rctx.js_filter - TA#rctx.js_filter,
+ js_filtered_docs => TB#rctx.js_filtered_docs - TA#rctx.js_filtered_docs,
+ rows_read => TB#rctx.rows_read - TA#rctx.rows_read,
+ changes_returned => TB#rctx.changes_returned - TA#rctx.changes_returned,
+ get_kp_node => TB#rctx.get_kp_node - TA#rctx.get_kp_node,
+ get_kv_node => TB#rctx.get_kv_node - TA#rctx.get_kv_node,
+ db_open => TB#rctx.db_open - TA#rctx.db_open,
+ ioq_calls => TB#rctx.ioq_calls - TA#rctx.ioq_calls,
+ %% "Example to extend CSRT"
+ %% write_kp_node => TB#rctx.write_kp_node - TA#rctx.write_kp_node,
+ %% write_kv_node => TB#rctx.write_kv_node - TA#rctx.write_kv_node,
+ dt => make_dt(TA#rctx.updated_at, TB#rctx.updated_at)
+ },
+ %% TODO: reevaluate this decision
+ %% Only return non zero (and also positive) delta fields
+ %% NOTE: this can result in Delta's of the form #{dt => 1}
+ maps:filter(fun(_K, V) -> V > 0 end, Delta);
+rctx_delta(_, _) ->
+ undefined.
+
+-spec get_delta_a() -> maybe_rctx().
+get_delta_a() ->
+ erlang:get(?DELTA_TA).
+
+-spec put_delta_a(TA :: rctx()) -> maybe_rctx().
+put_delta_a(TA) ->
+ erlang:put(?DELTA_TA, TA).
+
+-spec get_updated_at() -> maybe_integer().
+get_updated_at() ->
+ erlang:get(?LAST_UPDATED).
+
+-spec put_updated_at(Updated :: rctx() | integer()) -> maybe_integer().
+put_updated_at(#rctx{updated_at = Updated}) ->
+ put_updated_at(Updated);
+put_updated_at(Updated) when is_integer(Updated) ->
+ erlang:put(?LAST_UPDATED, Updated).
+
+-spec get_pid_ref() -> maybe_pid_ref().
+get_pid_ref() ->
+ get(?PID_REF).
+
+-spec get_pid_ref(Rctx :: rctx()) -> maybe_pid_ref().
+get_pid_ref(#rctx{pid_ref = PidRef}) ->
+ PidRef;
+get_pid_ref(_) ->
+ undefined.
+
+-spec set_pid_ref(PidRef :: pid_ref()) -> pid_ref().
+set_pid_ref(PidRef) ->
+ erlang:put(?PID_REF, PidRef),
+ PidRef.
diff --git a/src/couch_srt/test/eunit/couch_srt_httpd_tests.erl b/src/couch_srt/test/eunit/couch_srt_httpd_tests.erl
new file mode 100644
index 00000000000..003aed5a65a
--- /dev/null
+++ b/src/couch_srt/test/eunit/couch_srt_httpd_tests.erl
@@ -0,0 +1,677 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(couch_srt_httpd_tests).
+
+-include_lib("stdlib/include/ms_transform.hrl").
+
+-include_lib("couch/include/couch_eunit.hrl").
+-include("../../src/couch_srt.hrl").
+
+-define(USER, ?MODULE_STRING ++ "_admin").
+-define(PASS, "pass").
+-define(AUTH, {basic_auth, {?USER, ?PASS}}).
+
+-define(JSON, "application/json").
+-define(JSON_CT, {"Content-Type", ?JSON}).
+-define(ACCEPT_JSON, {"Accept", ?JSON}).
+
+csrt_httpd_test_() ->
+ {
+ foreach,
+ fun setup/0,
+ fun teardown/1,
+ [
+ ?TDEF_FE(t_query_group_by_multiple_keys),
+ ?TDEF_FE(t_query_group_by_single_key),
+ ?TDEF_FE(t_query_group_by_binary_key),
+ ?TDEF_FE(t_query_group_by_bad_request),
+ ?TDEF_FE(t_query_count_by_multiple_keys),
+ ?TDEF_FE(t_query_count_by_single_key),
+ ?TDEF_FE(t_query_count_by_binary_key),
+ ?TDEF_FE(t_query_count_by_bad_request),
+ ?TDEF_FE(t_query_sort_by_multiple_keys),
+ ?TDEF_FE(t_query_sort_by_single_key),
+ ?TDEF_FE(t_query_sort_by_binary_key),
+ ?TDEF_FE(t_query_sort_by_bad_request)
+ ]
+ }.
+
+setup_ctx() ->
+ Ctx = test_util:start_couch([chttpd, fabric, couch_stats, couch_srt]),
+ Hashed = couch_passwords:hash_admin_password(?PASS),
+ HashedList = binary_to_list(Hashed),
+ ok = config:set("admins", ?USER, HashedList, false),
+ Addr = config:get("chttpd", "bind_address", "127.0.0.1"),
+ Port = mochiweb_socket_server:get(chttpd, port),
+ Url = lists:concat(["http://", Addr, ":", Port, "/"]),
+ {Ctx, Url}.
+
+setup() ->
+ {Ctx, Url} = setup_ctx(),
+ couch_srt_test_helper:enable_default_logger_matchers(),
+ Rctxs = [
+ rctx(#{dbname => <<"db1">>, ioq_calls => 123, username => <<"user_foo">>}),
+ rctx(#{dbname => <<"db1">>, ioq_calls => 321, username => <<"user_foo">>}),
+ rctx(#{dbname => <<"db2">>, ioq_calls => 345, username => <<"user_bar">>}),
+ rctx(#{dbname => <<"db2">>, ioq_calls => 543, username => <<"user_bar">>}),
+ rctx(#{dbname => <<"db1">>, ioq_calls => 678, username => <<"user_bar">>}),
+ rctx(#{dbname => <<"db2">>, ioq_calls => 987, username => <<"user_foo">>})
+ ],
+ ets:insert(?CSRT_ETS, Rctxs),
+ #{ctx => Ctx, url => Url, rctxs => Rctxs}.
+
+teardown(#{ctx := Ctx}) ->
+ Persist = false,
+ ok = config:delete("admins", ?USER, Persist),
+ test_util:stop_couch(Ctx).
+
+active_resources_group_by(Url, AggregationKeys, CounterKey) ->
+ active_resources_group_by("docs_read", Url, AggregationKeys, CounterKey).
+
+active_resources_group_by(MatcherName, Url, AggregationKeys, CounterKey) ->
+ Body = #{
+ <<"group_by">> => #{
+ <<"aggregate_keys">> => AggregationKeys,
+ <<"counter_key">> => CounterKey
+ }
+ },
+ active_resources(Url, MatcherName, Body).
+
+t_query_group_by_multiple_keys(#{rctxs := Rctxs, url := Url}) ->
+ Aggregated = aggregate([username, dbname], ioq_calls, Rctxs),
+ Grouped = group(Aggregated),
+ {RC, Results} = active_resources_group_by(Url, [<<"username">>, <<"dbname">>], <<"ioq_calls">>),
+ ?assertEqual(200, RC, format("Should have '200' return code, got ~p~n ~p~n", [RC, Results])),
+ [
+ #{
+ <<"errors">> := [],
+ <<"node">> := _,
+ <<"result">> := Result
+ }
+ ] = Results,
+ ?assert(is_list(Result), format("Expected list of entries, got ~p~n", [Result])),
+ ?assertEqual(
+ 4, length(Result), format("Expected four entries, got ~p~n ~p~n", [length(Result), Result])
+ ),
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := _, <<"dbname">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _, <<"dbname">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _, <<"dbname">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _, <<"dbname">> := _}, <<"value">> := _}
+ ],
+ Result,
+ "Unexpected shape of the result"
+ ),
+ OrderedByKey = order_by_key([username, dbname], Result),
+ V1 = maps:get({<<"user_bar">>, <<"db1">>}, Grouped),
+ V2 = maps:get({<<"user_bar">>, <<"db2">>}, Grouped),
+ V3 = maps:get({<<"user_foo">>, <<"db1">>}, Grouped),
+ V4 = maps:get({<<"user_foo">>, <<"db2">>}, Grouped),
+ ?assertMatch(
+ [
+ #{
+ <<"key">> := #{<<"username">> := <<"user_bar">>, <<"dbname">> := <<"db1">>},
+ <<"value">> := V1
+ },
+ #{
+ <<"key">> := #{<<"username">> := <<"user_bar">>, <<"dbname">> := <<"db2">>},
+ <<"value">> := V2
+ },
+ #{
+ <<"key">> := #{<<"username">> := <<"user_foo">>, <<"dbname">> := <<"db1">>},
+ <<"value">> := V3
+ },
+ #{
+ <<"key">> := #{<<"username">> := <<"user_foo">>, <<"dbname">> := <<"db2">>},
+ <<"value">> := V4
+ }
+ ],
+ OrderedByKey
+ ),
+ ok.
+
+t_query_group_by_single_key(#{rctxs := Rctxs, url := Url}) ->
+ Aggregated = aggregate([username], ioq_calls, Rctxs),
+ Grouped = group(Aggregated),
+ {RC, Results} = active_resources_group_by(Url, [<<"username">>], <<"ioq_calls">>),
+ ?assertEqual(200, RC, format("Should have '200' return code, got ~p~n ~p~n", [RC, Results])),
+ [
+ #{
+ <<"errors">> := [],
+ <<"node">> := _,
+ <<"result">> := Result
+ }
+ ] = Results,
+ ?assert(is_list(Result), format("Expected list of entries, got ~p~n", [Result])),
+ ?assertEqual(
+ 2, length(Result), format("Expected two entries, got ~p~n ~p~n", [length(Result), Result])
+ ),
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _}, <<"value">> := _}
+ ],
+ Result,
+ "Unexpected shape of the result"
+ ),
+ OrderedByKey = order_by_key([username], Result),
+ V1 = maps:get({<<"user_bar">>}, Grouped),
+ V2 = maps:get({<<"user_foo">>}, Grouped),
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := <<"user_bar">>}, <<"value">> := V1},
+ #{<<"key">> := #{<<"username">> := <<"user_foo">>}, <<"value">> := V2}
+ ],
+ OrderedByKey
+ ),
+ ok.
+
+t_query_group_by_binary_key(#{rctxs := Rctxs, url := Url}) ->
+ Aggregated = aggregate([username], ioq_calls, Rctxs),
+ Grouped = group(Aggregated),
+ {RC, Results} = active_resources_group_by(Url, <<"username">>, <<"ioq_calls">>),
+ ?assertEqual(200, RC, format("Should have '200' return code, got ~p~n ~p~n", [RC, Results])),
+ [
+ #{
+ <<"errors">> := [],
+ <<"node">> := _,
+ <<"result">> := Result
+ }
+ ] = Results,
+ ?assert(is_list(Result), format("Expected list of entries, got ~p~n", [Result])),
+ ?assertEqual(
+ 2, length(Result), format("Expected two entries, got ~p~n ~p~n", [length(Result), Result])
+ ),
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _}, <<"value">> := _}
+ ],
+ Result,
+ format("Unexpected shape of the result~n ~p~n", [Result])
+ ),
+ OrderedByKey = order_by_key([username], Result),
+ V1 = maps:get({<<"user_bar">>}, Grouped),
+ V2 = maps:get({<<"user_foo">>}, Grouped),
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := <<"user_bar">>}, <<"value">> := V1},
+ #{<<"key">> := #{<<"username">> := <<"user_foo">>}, <<"value">> := V2}
+ ],
+ OrderedByKey
+ ),
+ ok.
+
+t_query_group_by_bad_request(#{url := Url}) ->
+ ?assertMatch(
+ {400, #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"Multiple keys in 'counter_key'">>
+ }},
+ active_resources_group_by(Url, <<"username">>, [<<"ioq_calls">>, <<"docs_read">>]),
+ "Should return error if multiple keys provided in 'counter_key'"
+ ),
+ ?assertMatch(
+ {400, #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"Unknown matcher 'unknown_matcher'">>
+ }},
+ active_resources_group_by("unknown_matcher", Url, <<"username">>, <<"ioq_calls">>),
+ "Should return error if 'matcher' is unknown"
+ ),
+ ?assertMatch(
+ {400, #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"Unknown field name 'unknown_field'">>
+ }},
+ active_resources_group_by(Url, [<<"unknown_field">>], <<"ioq_calls">>),
+ "Should return error if 'AggregationKeys' contain unknown field"
+ ),
+ ?assertMatch(
+ {400, #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"Unknown field name 'unknown_field'">>
+ }},
+ active_resources_group_by(Url, <<"unknown_field">>, <<"ioq_calls">>),
+ "Should return error if 'AggregationKeys' is unknown field"
+ ),
+ ?assertMatch(
+ {400, #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"Unknown field name 'unknown_field'">>
+ }},
+ active_resources_group_by(Url, <<"username">>, <<"unknown_field">>),
+ "Should return error if 'ValueKey' contain unknown field"
+ ),
+ ok.
+
+active_resources_count_by(Url, AggregationKeys) ->
+ active_resources_count_by("docs_read", Url, AggregationKeys).
+
+active_resources_count_by(MatcherName, Url, AggregationKeys) ->
+ Body = #{
+ <<"count_by">> => #{
+ <<"aggregate_keys">> => AggregationKeys
+ }
+ },
+ active_resources(Url, MatcherName, Body).
+
+t_query_count_by_multiple_keys(#{rctxs := Rctxs, url := Url}) ->
+ Aggregated = aggregate([username, dbname], ioq_calls, Rctxs),
+ Grouped = count(Aggregated),
+ {RC, Results} = active_resources_count_by(Url, [<<"username">>, <<"dbname">>]),
+ ?assertEqual(200, RC, format("Should have '200' return code, got ~p~n ~p~n", [RC, Results])),
+ [
+ #{
+ <<"errors">> := [],
+ <<"node">> := _,
+ <<"result">> := Result
+ }
+ ] = Results,
+ ?assert(is_list(Result), format("Expected list of entries, got ~p~n", [Result])),
+ ?assertEqual(
+ 4, length(Result), format("Expected four entries, got ~p~n ~p~n", [length(Result), Result])
+ ),
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := _, <<"dbname">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _, <<"dbname">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _, <<"dbname">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _, <<"dbname">> := _}, <<"value">> := _}
+ ],
+ Result,
+ "Unexpected shape of the result"
+ ),
+ OrderedByKey = order_by_key([username, dbname], Result),
+ V1 = maps:get({<<"user_bar">>, <<"db1">>}, Grouped),
+ V2 = maps:get({<<"user_bar">>, <<"db2">>}, Grouped),
+ V3 = maps:get({<<"user_foo">>, <<"db1">>}, Grouped),
+ V4 = maps:get({<<"user_foo">>, <<"db2">>}, Grouped),
+ ?assertMatch(
+ [
+ #{
+ <<"key">> := #{<<"username">> := <<"user_bar">>, <<"dbname">> := <<"db1">>},
+ <<"value">> := V1
+ },
+ #{
+ <<"key">> := #{<<"username">> := <<"user_bar">>, <<"dbname">> := <<"db2">>},
+ <<"value">> := V2
+ },
+ #{
+ <<"key">> := #{<<"username">> := <<"user_foo">>, <<"dbname">> := <<"db1">>},
+ <<"value">> := V3
+ },
+ #{
+ <<"key">> := #{<<"username">> := <<"user_foo">>, <<"dbname">> := <<"db2">>},
+ <<"value">> := V4
+ }
+ ],
+ OrderedByKey
+ ),
+ ok.
+
+t_query_count_by_single_key(#{rctxs := Rctxs, url := Url}) ->
+ Aggregated = aggregate([username], ioq_calls, Rctxs),
+ Grouped = count(Aggregated),
+ {RC, Results} = active_resources_count_by(Url, [<<"username">>]),
+ ?assertEqual(200, RC, format("Should have '200' return code, got ~p~n ~p~n", [RC, Results])),
+ [
+ #{
+ <<"errors">> := [],
+ <<"node">> := _,
+ <<"result">> := Result
+ }
+ ] = Results,
+ ?assert(is_list(Result), format("Expected list of entries, got ~p~n", [Result])),
+ ?assertEqual(
+ 2, length(Result), format("Expected two entries, got ~p~n ~p~n", [length(Result), Result])
+ ),
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _}, <<"value">> := _}
+ ],
+ Result,
+ "Unexpected shape of the result"
+ ),
+ OrderedByKey = order_by_key([username], Result),
+ V1 = maps:get({<<"user_bar">>}, Grouped),
+ V2 = maps:get({<<"user_foo">>}, Grouped),
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := <<"user_bar">>}, <<"value">> := V1},
+ #{<<"key">> := #{<<"username">> := <<"user_foo">>}, <<"value">> := V2}
+ ],
+ OrderedByKey
+ ),
+ ok.
+
+t_query_count_by_binary_key(#{rctxs := Rctxs, url := Url}) ->
+ Aggregated = aggregate([username], ioq_calls, Rctxs),
+ Grouped = count(Aggregated),
+ {RC, Results} = active_resources_count_by(Url, <<"username">>),
+ ?assertEqual(200, RC, format("Should have '200' return code, got ~p~n ~p~n", [RC, Results])),
+ [
+ #{
+ <<"errors">> := [],
+ <<"node">> := _,
+ <<"result">> := Result
+ }
+ ] = Results,
+ ?assert(is_list(Result), format("Expected list of entries, got ~p~n", [Result])),
+ ?assertEqual(
+ 2, length(Result), format("Expected two entries, got ~p~n ~p~n", [length(Result), Result])
+ ),
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _}, <<"value">> := _}
+ ],
+ Result,
+ "Unexpected shape of the result"
+ ),
+ OrderedByKey = order_by_key([username], Result),
+ V1 = maps:get({<<"user_bar">>}, Grouped),
+ V2 = maps:get({<<"user_foo">>}, Grouped),
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := <<"user_bar">>}, <<"value">> := V1},
+ #{<<"key">> := #{<<"username">> := <<"user_foo">>}, <<"value">> := V2}
+ ],
+ OrderedByKey
+ ),
+ ok.
+
+t_query_count_by_bad_request(#{url := Url}) ->
+ ?assertMatch(
+ {400, #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"Unknown matcher 'unknown_matcher'">>
+ }},
+ active_resources_count_by("unknown_matcher", Url, <<"username">>),
+ "Should return error if 'matcher' is unknown"
+ ),
+ ?assertMatch(
+ {400, #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"Unknown field name 'unknown_field'">>
+ }},
+ active_resources_count_by(Url, [<<"unknown_field">>]),
+ "Should return error if 'AggregationKeys' contain unknown field"
+ ),
+ ?assertMatch(
+ {400, #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"Unknown field name 'unknown_field'">>
+ }},
+ active_resources_count_by(Url, <<"unknown_field">>),
+ "Should return error if 'AggregationKeys' is unknown field"
+ ),
+ ok.
+
+active_resources_sort_by(Url, AggregationKeys, CounterKey) ->
+ active_resources_sort_by("docs_read", Url, AggregationKeys, CounterKey).
+
+active_resources_sort_by(MatcherName, Url, AggregationKeys, CounterKey) ->
+ Body = #{
+ <<"sort_by">> => #{
+ <<"aggregate_keys">> => AggregationKeys,
+ <<"counter_key">> => CounterKey
+ }
+ },
+ active_resources(Url, MatcherName, Body).
+
+t_query_sort_by_multiple_keys(#{rctxs := Rctxs, url := Url}) ->
+ Aggregated = aggregate([username, dbname], ioq_calls, Rctxs),
+ Grouped = group(Aggregated),
+ Ordered = order_by_value(Grouped),
+ {RC, Results} = active_resources_sort_by(Url, [<<"username">>, <<"dbname">>], <<"ioq_calls">>),
+ ?assertEqual(200, RC, format("Should have '200' return code, got ~p~n ~p~n", [RC, Results])),
+ [
+ #{
+ <<"errors">> := [],
+ <<"node">> := _,
+ <<"result">> := Result
+ }
+ ] = Results,
+ ?assert(is_list(Result), format("Expected list of entries, got ~p~n", [Result])),
+ ?assertEqual(
+ 4, length(Result), format("Expected four entries, got ~p~n ~p~n", [length(Result), Result])
+ ),
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := _, <<"dbname">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _, <<"dbname">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _, <<"dbname">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _, <<"dbname">> := _}, <<"value">> := _}
+ ],
+ Result,
+ "Unexpected shape of the result"
+ ),
+ [
+ {{<<"user_foo">>, <<"db2">>}, V1},
+ {{<<"user_bar">>, <<"db2">>}, V2},
+ {{<<"user_bar">>, <<"db1">>}, V3},
+ {{<<"user_foo">>, <<"db1">>}, V4}
+ ] = Ordered,
+ ?assertMatch(
+ [
+ #{
+ <<"key">> := #{<<"username">> := <<"user_foo">>, <<"dbname">> := <<"db2">>},
+ <<"value">> := V1
+ },
+ #{
+ <<"key">> := #{<<"username">> := <<"user_bar">>, <<"dbname">> := <<"db2">>},
+ <<"value">> := V2
+ },
+ #{
+ <<"key">> := #{<<"username">> := <<"user_bar">>, <<"dbname">> := <<"db1">>},
+ <<"value">> := V3
+ },
+ #{
+ <<"key">> := #{<<"username">> := <<"user_foo">>, <<"dbname">> := <<"db1">>},
+ <<"value">> := V4
+ }
+ ],
+ Result
+ ),
+ ok.
+
+t_query_sort_by_single_key(#{rctxs := Rctxs, url := Url}) ->
+ Aggregated = aggregate([username], ioq_calls, Rctxs),
+ Grouped = group(Aggregated),
+ Ordered = order_by_value(Grouped),
+ {RC, Results} = active_resources_sort_by(Url, [<<"username">>], <<"ioq_calls">>),
+ ?assertEqual(200, RC, format("Should have '200' return code, got ~p~n ~p~n", [RC, Results])),
+ [
+ #{
+ <<"errors">> := [],
+ <<"node">> := _,
+ <<"result">> := Result
+ }
+ ] = Results,
+ ?assert(is_list(Result), format("Expected list of entries, got ~p~n", [Result])),
+ ?assertEqual(
+ 2, length(Result), format("Expected two entries, got ~p~n ~p~n", [length(Result), Result])
+ ),
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _}, <<"value">> := _}
+ ],
+ Result,
+ "Unexpected shape of the result"
+ ),
+ [
+ {{<<"user_bar">>}, V1},
+ {{<<"user_foo">>}, V2}
+ ] = Ordered,
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := <<"user_bar">>}, <<"value">> := V1},
+ #{<<"key">> := #{<<"username">> := <<"user_foo">>}, <<"value">> := V2}
+ ],
+ Result
+ ),
+ ok.
+
+t_query_sort_by_binary_key(#{rctxs := Rctxs, url := Url}) ->
+ Aggregated = aggregate([username], ioq_calls, Rctxs),
+ Grouped = group(Aggregated),
+ Ordered = order_by_value(Grouped),
+ {RC, Results} = active_resources_sort_by(Url, <<"username">>, <<"ioq_calls">>),
+ ?assertEqual(200, RC, format("Should have '200' return code, got ~p~n ~p~n", [RC, Results])),
+ [
+ #{
+ <<"errors">> := [],
+ <<"node">> := _,
+ <<"result">> := Result
+ }
+ ] = Results,
+ ?assert(is_list(Result), format("Expected list of entries, got ~p~n", [Result])),
+ ?assertEqual(
+ 2, length(Result), format("Expected two entries, got ~p~n ~p~n", [length(Result), Result])
+ ),
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := _}, <<"value">> := _},
+ #{<<"key">> := #{<<"username">> := _}, <<"value">> := _}
+ ],
+ Result,
+ "Unexpected shape of the result"
+ ),
+ [
+ {{<<"user_bar">>}, V1},
+ {{<<"user_foo">>}, V2}
+ ] = Ordered,
+ ?assertMatch(
+ [
+ #{<<"key">> := #{<<"username">> := <<"user_bar">>}, <<"value">> := V1},
+ #{<<"key">> := #{<<"username">> := <<"user_foo">>}, <<"value">> := V2}
+ ],
+ Result
+ ),
+ ok.
+
+t_query_sort_by_bad_request(#{url := Url}) ->
+ ?assertMatch(
+ {400, #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"Multiple keys in 'counter_key'">>
+ }},
+ active_resources_sort_by(Url, <<"username">>, [<<"ioq_calls">>, <<"docs_read">>]),
+ "Should return error if multiple keys provided in 'counter_key'"
+ ),
+ ?assertMatch(
+ {400, #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"Unknown matcher 'unknown_matcher'">>
+ }},
+ active_resources_sort_by("unknown_matcher", Url, <<"username">>, <<"ioq_calls">>),
+ "Should return error if 'matcher' is unknown"
+ ),
+ ?assertMatch(
+ {400, #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"Unknown field name 'unknown_field'">>
+ }},
+ active_resources_sort_by(Url, [<<"unknown_field">>], <<"ioq_calls">>),
+ "Should return error if 'AggregationKeys' contain unknown field"
+ ),
+ ?assertMatch(
+ {400, #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"Unknown field name 'unknown_field'">>
+ }},
+ active_resources_sort_by(Url, <<"unknown_field">>, <<"ioq_calls">>),
+ "Should return error if 'AggregationKeys' is unknown field"
+ ),
+ ?assertMatch(
+ {400, #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"Unknown field name 'unknown_field'">>
+ }},
+ active_resources_sort_by(Url, <<"username">>, <<"unknown_field">>),
+ "Should return error if 'ValueKey' contain unknown field"
+ ),
+ ok.
+
+format(Fmt, Args) ->
+ lists:flatten(io_lib:format(Fmt, Args)).
+
+aggregate(AggregationKeys, ValField, Records) ->
+ lists:foldl(
+ fun(Rctx, Acc) ->
+ Key = list_to_tuple([couch_srt_entry:value(Field, Rctx) || Field <- AggregationKeys]),
+ CurrVal = maps:get(Key, Acc, []),
+ maps:put(Key, [couch_srt_entry:value(ValField, Rctx) | CurrVal], Acc)
+ end,
+ #{},
+ Records
+ ).
+
+group(Aggregated) ->
+ maps:fold(
+ fun(Key, Val, Acc) ->
+ maps:put(Key, lists:foldl(fun erlang:'+'/2, 0, Val), Acc)
+ end,
+ #{},
+ Aggregated
+ ).
+
+count(Aggregated) ->
+ maps:fold(
+ fun(Key, Val, Acc) ->
+ maps:put(Key, lists:foldl(fun(_, A) -> A + 1 end, 0, Val), Acc)
+ end,
+ #{},
+ Aggregated
+ ).
+
+order_by_value(Grouped) ->
+ lists:reverse(lists:keysort(2, maps:to_list(Grouped))).
+
+% This function handles both representations of entries of the result
+% #{<<"key">> => #{<<"dbname">> => <<"db2">>, <<"username">> => <<"user_foo">>}, <<"value">> => 1}
+% and
+% {{<<"db2">>, <<"user_foo">>}, 1}
+order_by_key(AggregationKeys, Entries) when is_list(AggregationKeys) andalso is_list(Entries) ->
+ lists:sort(
+ fun(A, B) ->
+ get_key(AggregationKeys, A) =< get_key(AggregationKeys, B)
+ end,
+ Entries
+ ).
+
+% This function handles both representations of entries of the result
+% #{<<"key">> => #{<<"dbname">> => <<"db2">>, <<"username">> => <<"user_foo">>}, <<"value">> => 1}
+% and
+% {{<<"db2">>, <<"user_foo">>}, 1}
+get_key(AggregationKeys, #{<<"key">> := Key}) ->
+ list_to_tuple([maps:get(atom_to_binary(Field), Key) || Field <- AggregationKeys]);
+get_key(_AggregationKeys, {Key, _}) ->
+ Key.
+
+active_resources(Url, MatchName, Body) ->
+ EndpointUrl = Url ++ "/_active_resources/_match/" ++ MatchName,
+ Headers = [?JSON_CT, ?AUTH, ?ACCEPT_JSON],
+ {ok, Code, _, Res} = test_request:request(post, EndpointUrl, Headers, jiffy:encode(Body)),
+ {Code, jiffy:decode(Res, [return_maps])}.
+
+rctx(Opts) ->
+ % Update `docs_read` to make standard `{docs_read, fun matcher_on_docs_read/1, 1000}`
+ % matcher match.
+ Threshold = config:get("csrt_logger.matchers_threshold", "rows_read", 1000),
+ BaseOpts = #{docs_read => Threshold + 1, username => <<"user_foo">>},
+ couch_srt_test_helper:rctx_gen(maps:merge(BaseOpts, Opts)).
diff --git a/src/couch_srt/test/eunit/couch_srt_logger_tests.erl b/src/couch_srt/test/eunit/couch_srt_logger_tests.erl
new file mode 100644
index 00000000000..e611f326934
--- /dev/null
+++ b/src/couch_srt/test/eunit/couch_srt_logger_tests.erl
@@ -0,0 +1,468 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(couch_srt_logger_tests).
+
+-include_lib("couch/include/couch_db.hrl").
+-include_lib("couch/include/couch_eunit.hrl").
+-include_lib("couch_mrview/include/couch_mrview.hrl").
+-include("../../src/couch_srt.hrl").
+
+%% Use different values than default configs to ensure they're picked up
+-define(THRESHOLD_DBNAME_IO, 91).
+-define(THRESHOLD_DOCS_READ, 123).
+-define(THRESHOLD_DOCS_WRITTEN, 12).
+-define(THRESHOLD_IOQ_CALLS, 439).
+-define(THRESHOLD_ROWS_READ, 143).
+-define(THRESHOLD_CHANGES, 79).
+-define(THRESHOLD_LONG_REQS, 432).
+
+csrt_logger_reporting_works_test_() ->
+ {
+ foreach,
+ fun setup_reporting/0,
+ fun teardown_reporting/1,
+ [
+ ?TDEF_FE(t_enablement),
+ ?TDEF_FE(t_do_report),
+ ?TDEF_FE(t_do_lifetime_report),
+ ?TDEF_FE(t_do_status_report)
+ ]
+ }.
+
+csrt_logger_matchers_test_() ->
+ {
+ foreach,
+ fun setup/0,
+ fun teardown/1,
+ [
+ ?TDEF_FE(t_enablement),
+ ?TDEF_FE(t_matcher_on_dbnames_io),
+ ?TDEF_FE(t_matcher_on_docs_read),
+ ?TDEF_FE(t_matcher_on_docs_written),
+ ?TDEF_FE(t_matcher_on_rows_read),
+ ?TDEF_FE(t_matcher_on_changes_processed),
+ ?TDEF_FE(t_matcher_on_long_reqs),
+ ?TDEF_FE(t_matcher_on_ioq_calls),
+ ?TDEF_FE(t_matcher_on_nonce),
+ ?TDEF_FE(t_matcher_on_all_coordinators),
+ ?TDEF_FE(t_matcher_on_all_rpc_workers),
+ ?TDEF_FE(t_matcher_register_deregister)
+ ]
+ }.
+
+make_docs(Count) ->
+ lists:map(
+ fun(I) ->
+ #doc{
+ id = ?l2b("foo_" ++ integer_to_list(I)),
+ body = {[{<<"value">>, I}]}
+ }
+ end,
+ lists:seq(1, Count)
+ ).
+
+set_matcher_threshold(_Key, undefined) ->
+ ok;
+set_matcher_threshold(Key, Val) when is_integer(Val) ->
+ config:set(?CSRT_MATCHERS_THRESHOLD, Key, integer_to_list(Val), false).
+
+set_dbnames_io_threshold(Key, Val) when is_integer(Val) ->
+ config:set(?CSRT_MATCHERS_DBNAMES, Key, integer_to_list(Val), false).
+
+setup() ->
+ Ctx = test_util:start_couch([fabric, couch_stats, couch_srt]),
+ couch_srt_test_helper:enable_default_logger_matchers(),
+ config:set_boolean(?CSRT, "randomize_testing", false, false),
+ config:set_boolean(?CSRT, "enable_reporting", true, false),
+ config:set_boolean(?CSRT, "enable_rpc_reporting", true, false),
+ ok = meck:new(ioq, [passthrough]),
+ ok = meck:expect(ioq, bypass, fun(_, _) -> false end),
+ DbName = ?tempdb(),
+ ok = fabric:create_db(DbName, [{q, 8}, {n, 1}]),
+ Docs = make_docs(100),
+ Opts = [],
+ {ok, _} = fabric:update_docs(DbName, Docs, Opts),
+ Method = 'GET',
+ Path = "/" ++ ?b2l(DbName) ++ "/_all_docs",
+ Nonce = couch_util:to_hex(crypto:strong_rand_bytes(5)),
+ Req = #httpd{method = Method, nonce = Nonce},
+ {_, _} = PidRef = couch_srt:create_coordinator_context(Req, Path),
+ MArgs = #mrargs{include_docs = false},
+ _Res = fabric:all_docs(DbName, [?ADMIN_CTX], fun view_cb/2, [], MArgs),
+ Rctx = load_rctx(PidRef),
+
+ DefaultMatcherThresholds = [
+ {"all_coordinators", undefined},
+ {"all_rpc_workers", undefined},
+ {"docs_read", ?THRESHOLD_DOCS_READ},
+ {"docs_written", ?THRESHOLD_DOCS_WRITTEN},
+ {"ioq_calls", ?THRESHOLD_IOQ_CALLS},
+ {"rows_read", ?THRESHOLD_ROWS_READ},
+ {"changes_processed", ?THRESHOLD_CHANGES},
+ {"long_reqs", ?THRESHOLD_LONG_REQS}
+ ],
+ [set_matcher_threshold(Key, Val) || {Key, Val} <- DefaultMatcherThresholds],
+
+ DbnameIOMatcherThresholds = [
+ {"foo", ?THRESHOLD_DBNAME_IO},
+ {"bar", ?THRESHOLD_DBNAME_IO},
+ {"foo/bar", ?THRESHOLD_DBNAME_IO}
+ ],
+ [set_dbnames_io_threshold(Key, Val) || {Key, Val} <- DbnameIOMatcherThresholds],
+
+ couch_srt_logger:reload_matchers(),
+ #{ctx => Ctx, dbname => DbName, rctx => Rctx, rctxs => couch_srt_test_helper:rctxs()}.
+
+teardown(#{ctx := Ctx, dbname := DbName}) ->
+ ok = fabric:delete_db(DbName, [?ADMIN_CTX]),
+ ok = meck:unload(ioq),
+ test_util:stop_couch(Ctx).
+
+setup_reporting() ->
+ Ctx = setup(),
+ ok = meck:new(couch_log, [passthrough]),
+ ok = meck:expect(couch_log, report, fun(_, _) -> true end),
+ Ctx.
+
+teardown_reporting(Ctx) ->
+ ok = meck:unload(couch_log),
+ teardown(Ctx).
+
+t_enablement(#{}) ->
+ %% Set an invalid match spec to ensure couch_srt_logger is resilient
+ config:set(?CSRT_MATCHERS_DBNAMES, "foobar", "lkajsdfkjkkadfjkajkf", false),
+ ?assertEqual(ok, couch_srt_logger:reload_matchers(), "reloads even with bad matcher specs set"),
+ ?assert(couch_srt_util:is_enabled(), "CSRT is enabled"),
+ ?assert(couch_srt_util:is_enabled_reporting(), "CSRT reporting is enabled"),
+ ?assert(couch_srt_util:is_enabled_rpc_reporting(), "CSRT RPC reporting is enabled").
+
+t_do_report(#{rctx := Rctx}) ->
+ JRctx = couch_srt_test_helper:jrctx(Rctx),
+ ReportName = "foo",
+ ?assert(couch_srt_logger:do_report(ReportName, Rctx), "CSRT _logger:do_report " ++ ReportName),
+ ?assert(meck:validate(couch_log), "CSRT do_report"),
+ ?assert(meck:validate(couch_log), "CSRT validate couch_log"),
+ ?assert(
+ meck:called(couch_log, report, [ReportName, JRctx]),
+ "CSRT couch_log:report"
+ ).
+
+t_do_lifetime_report(#{rctx := Rctx}) ->
+ JRctx = couch_srt_test_helper:jrctx(Rctx),
+ ReportName = "csrt-pid-usage-lifetime",
+ ?assert(
+ couch_srt_logger:do_lifetime_report(Rctx),
+ "CSRT _logger:do_report " ++ ReportName
+ ),
+ ?assert(meck:validate(couch_log), "CSRT validate couch_log"),
+ ?assert(
+ meck:called(couch_log, report, [ReportName, JRctx]),
+ "CSRT couch_log:report"
+ ).
+
+t_do_status_report(#{rctx := Rctx}) ->
+ JRctx = couch_srt_test_helper:jrctx(Rctx),
+ ReportName = "csrt-pid-usage-status",
+ ?assert(couch_srt_logger:do_status_report(Rctx), "couch_srt_logger:do_ " ++ ReportName),
+ ?assert(meck:validate(couch_log), "CSRT validate couch_log"),
+ ?assert(
+ meck:called(couch_log, report, [ReportName, JRctx]),
+ "CSRT couch_log:report"
+ ).
+
+t_matcher_on_docs_read(#{rctxs := Rctxs0}) ->
+ Threshold = ?THRESHOLD_DOCS_READ,
+ %% Make sure we have at least one match
+ Rctxs = [couch_srt_test_helper:rctx_gen(#{docs_read => Threshold + 10}) | Rctxs0],
+ ?assertEqual(
+ lists:sort(lists:filter(matcher_gte(docs_read, Threshold), Rctxs)),
+ lists:sort(lists:filter(matcher_for_csrt("docs_read"), Rctxs)),
+ "Docs read matcher"
+ ).
+
+t_matcher_on_docs_written(#{rctxs := Rctxs0}) ->
+ Threshold = ?THRESHOLD_DOCS_WRITTEN,
+ %% Make sure we have at least one match
+ Rctxs = [couch_srt_test_helper:rctx_gen(#{docs_written => Threshold + 10}) | Rctxs0],
+ ?assertEqual(
+ lists:sort(lists:filter(matcher_gte(docs_written, Threshold), Rctxs)),
+ lists:sort(lists:filter(matcher_for_csrt("docs_written"), Rctxs)),
+ "Docs written matcher"
+ ).
+
+t_matcher_on_rows_read(#{rctxs := Rctxs0}) ->
+ Threshold = ?THRESHOLD_ROWS_READ,
+ %% Make sure we have at least one match
+ Rctxs = [couch_srt_test_helper:rctx_gen(#{rows_read => Threshold + 10}) | Rctxs0],
+ ?assertEqual(
+ lists:sort(lists:filter(matcher_gte(rows_read, Threshold), Rctxs)),
+ lists:sort(lists:filter(matcher_for_csrt("rows_read"), Rctxs)),
+ "Rows read matcher"
+ ).
+
+t_matcher_on_changes_processed(#{rctxs := Rctxs0}) ->
+ Threshold = ?THRESHOLD_CHANGES,
+ %% Make sure we have at least one match
+ Rctx0 = couch_srt_test_helper:rctx_gen(#{
+ mod => chttpd_db, func => handle_changes_req, rows_read => Threshold + 10
+ }),
+ Rctxs = [Rctx0 | Rctxs0],
+ ChangesFilter =
+ fun
+ %% Matcher on changes only works for coordinators at the moment due
+ %% to overloading over rows_read for all aggregate operations
+ (#rctx{type = #coordinator{mod = chttpd_db, func = handle_changes_req}} = R) ->
+ Ret = couch_srt_entry:value(changes_returned, R),
+ Proc = couch_srt_entry:value(rows_read, R),
+ (Proc - Ret) >= Threshold;
+ (_) ->
+ false
+ end,
+ ?assertEqual(
+ lists:sort(lists:filter(ChangesFilter, Rctxs)),
+ lists:sort(lists:filter(matcher_for_csrt("changes_processed"), Rctxs)),
+ "Changes processed matcher"
+ ).
+
+t_matcher_on_long_reqs(#{rctxs := Rctxs0}) ->
+ %% Threshold is in milliseconds, convert to native time format
+ Threshold = ?THRESHOLD_LONG_REQS,
+ NativeThreshold = erlang:convert_time_unit(Threshold, millisecond, native),
+ %% Native is a small timescale, make sure we have enough for a millisecond
+ %% measureable time delta
+ %% Make sure we have at least one match
+ Now = couch_srt_util:tnow(),
+ UpdatedAt = Now - round(NativeThreshold * 1.23),
+ Rctxs = [
+ couch_srt_test_helper:rctx_gen(#{started_at => Now, updated_at => UpdatedAt}) | Rctxs0
+ ],
+ DurationFilter = fun(R) ->
+ Started = couch_srt_entry:value(started_at, R),
+ Updated = couch_srt_entry:value(updated_at, R),
+ Updated - Started >= NativeThreshold
+ end,
+ ?assertEqual(
+ lists:sort(lists:filter(DurationFilter, Rctxs)),
+ lists:sort(lists:filter(matcher_for_csrt("long_reqs"), Rctxs)),
+ "Long requests matcher"
+ ).
+
+t_matcher_on_ioq_calls(#{rctxs := Rctxs0}) ->
+ Threshold = ?THRESHOLD_IOQ_CALLS,
+ %% Make sure we have at least one match
+ Rctxs = [couch_srt_test_helper:rctx_gen(#{ioq_calls => Threshold + 10}) | Rctxs0],
+ ?assertEqual(
+ lists:sort(lists:filter(matcher_gte(ioq_calls, Threshold), Rctxs)),
+ lists:sort(lists:filter(matcher_for_csrt("ioq_calls"), Rctxs)),
+ "IOQ calls matcher"
+ ).
+
+t_matcher_on_nonce(#{rctxs := Rctxs0}) ->
+ Nonce = "foobar7799",
+ %% Make sure we have at least one match
+ Rctxs = [couch_srt_test_helper:rctx_gen(#{nonce => Nonce}) | Rctxs0],
+ %% Nonce requires dynamic matcher as it's a static match
+ %% TODO: add pattern based nonce matching
+ MSpec = couch_srt_logger:matcher_on_nonce(Nonce),
+ CompMSpec = ets:match_spec_compile(MSpec),
+ Matchers = #{"nonce" => {MSpec, CompMSpec}},
+ IsMatch = fun(ARctx) -> couch_srt_logger:is_match(ARctx, Matchers) end,
+ ?assertEqual(
+ lists:sort(lists:filter(matcher_on(nonce, Nonce), Rctxs)),
+ lists:sort(lists:filter(IsMatch, Rctxs)),
+ "Rows read matcher"
+ ).
+
+t_matcher_on_dbnames_io(#{rctxs := Rctxs0}) ->
+ Threshold = ?THRESHOLD_DBNAME_IO,
+ SThreshold = integer_to_list(Threshold),
+ DbFoo = "foo",
+ DbBar = "bar",
+ MatcherFoo = matcher_for_csrt("dbnames_io__" ++ DbFoo ++ "__" ++ SThreshold),
+ MatcherBar = matcher_for_csrt("dbnames_io__" ++ DbBar ++ "__" ++ SThreshold),
+ MatcherFooBar = matcher_for_csrt("dbnames_io__foo/bar__" ++ SThreshold),
+ %% Add an extra Rctx with dbname foo/bar to ensure correct naming matches
+ ExtraRctx = couch_srt_test_helper:rctx_gen(#{
+ dbname => <<"foo/bar">>, get_kp_node => Threshold + 10
+ }),
+ %% Make sure we have at least one match
+ Rctxs = [ExtraRctx, couch_srt_test_helper:rctx_gen(#{ioq_calls => Threshold + 10}) | Rctxs0],
+ ?assertEqual(
+ lists:sort(lists:filter(matcher_for_dbnames_io(DbFoo, Threshold), Rctxs)),
+ lists:sort(lists:filter(MatcherFoo, Rctxs)),
+ "dbnames_io foo matcher"
+ ),
+ ?assertEqual(
+ lists:sort(lists:filter(matcher_for_dbnames_io(DbBar, Threshold), Rctxs)),
+ lists:sort(lists:filter(MatcherBar, Rctxs)),
+ "dbnames_io bar matcher"
+ ),
+ ?assertEqual(
+ [ExtraRctx],
+ lists:sort(lists:filter(MatcherFooBar, Rctxs)),
+ "dbnames_io foo/bar matcher"
+ ).
+
+t_matcher_register_deregister(#{rctxs := Rctxs0}) ->
+ CrazyDbName = <<"asdf123@?!fdsa">>,
+ MName = "Crazy-Matcher",
+ MSpec = couch_srt_logger:matcher_on_dbname(CrazyDbName),
+ %% Add an extra Rctx with CrazyDbName to create a specific match
+ ExtraRctx = couch_srt_test_helper:rctx_gen(#{dbname => CrazyDbName}),
+ %% Make sure we have at least one match
+ Rctxs = [ExtraRctx | Rctxs0],
+
+ ?assertEqual(#{}, couch_srt_logger:get_registered_matchers(), "no current registered matchers"),
+ ?assertEqual(
+ {error, {invalid_ms, "bad_spec", "fdsa"}},
+ couch_srt_logger:register_matcher("bad_spec", "fdsa"),
+ "register bad matcher fails"
+ ),
+ ?assertEqual(ok, couch_srt_logger:register_matcher(MName, MSpec), "register matcher"),
+ CompMSpec = test_util:wait(
+ fun() ->
+ case couch_srt_logger:get_matcher(MName) of
+ undefined ->
+ wait;
+ {MSpec, _Ref} = CompMSpec0 ->
+ CompMSpec0
+ end
+ end
+ ),
+ Matchers = #{MName => CompMSpec},
+ ?assert(CompMSpec =/= timeout, "newly registered matcher was initialized"),
+ ?assertEqual(
+ [MName],
+ maps:keys(couch_srt_logger:get_registered_matchers()),
+ "correct current registered matchers"
+ ),
+ ?assert(
+ couch_srt_logger:is_match(ExtraRctx, Matchers), "our registered matcher matches expectedly"
+ ),
+ ?assert(
+ couch_srt_logger:is_match(ExtraRctx),
+ "our registered matcher is picked up and matches expectedly"
+ ),
+ ?assertEqual(
+ Matchers,
+ couch_srt_logger:find_matches(Rctxs, Matchers),
+ "we find our matcher and no extra matchers"
+ ),
+ ?assert(
+ maps:is_key(
+ MName,
+ couch_srt_logger:find_matches(Rctxs, couch_srt_logger:get_matchers())
+ ),
+ "find our CrazyDbName matcher in matches against all registered matchers"
+ ),
+ ?assertEqual(
+ #{MName => [ExtraRctx]},
+ couch_srt_logger:find_all_matches(Rctxs, Matchers),
+ "find our CrazyDb ExtraRctx with our Matcher, and nothing else"
+ ),
+ ?assertEqual(ok, couch_srt_logger:reload_matchers(), "we can reload matchers"),
+ ?assertEqual(
+ [MName],
+ maps:keys(couch_srt_logger:get_registered_matchers()),
+ "correct current registered matchers after a global reload"
+ ),
+ ?assert(
+ maps:is_key(
+ MName,
+ couch_srt_logger:find_matches(Rctxs, couch_srt_logger:get_matchers())
+ ),
+ "our matcher still behaves expectedly after a global matcher reload"
+ ),
+ ?assertEqual(ok, couch_srt_logger:deregister_matcher(MName), "deregister_matcher returns ok"),
+ Matcher2 = test_util:wait(
+ fun() ->
+ case couch_srt_logger:get_matcher(MName) of
+ undefined ->
+ undefined;
+ _ ->
+ wait
+ end
+ end
+ ),
+ ?assertEqual(undefined, Matcher2, "matcher was deregistered successfully"),
+ ?assertEqual(
+ #{}, couch_srt_logger:get_registered_matchers(), "no leftover registered matchers"
+ ).
+
+t_matcher_on_all_coordinators(#{rctxs := Rctxs0}) ->
+ %% Make sure we have at least one match
+ Rctxs = [couch_srt_test_helper:rctx_gen(#{type => #coordinator{}}) | Rctxs0],
+ ?assertEqual(
+ lists:sort(lists:filter(matcher_on_coordinators(), Rctxs)),
+ lists:sort(lists:filter(matcher_for_csrt("all_coordinators"), Rctxs)),
+ "All Coordinators matcher"
+ ).
+
+t_matcher_on_all_rpc_workers(#{rctxs := Rctxs0}) ->
+ %% Make sure we have at least one match
+ Rctxs = [couch_srt_test_helper:rctx_gen(#{type => #rpc_worker{}}) | Rctxs0],
+ ?assertEqual(
+ lists:sort(lists:filter(matcher_on_rpc_workers(), Rctxs)),
+ lists:sort(lists:filter(matcher_for_csrt("all_rpc_workers"), Rctxs)),
+ "All RPC Workers matcher"
+ ).
+
+load_rctx(PidRef) ->
+ %% Add slight delay to accumulate RPC response deltas
+ timer:sleep(50),
+ couch_srt:get_resource(PidRef).
+
+view_cb({row, Row}, Acc) ->
+ {ok, [Row | Acc]};
+view_cb(_Msg, Acc) ->
+ {ok, Acc}.
+
+matcher_gte(Field, Value) ->
+ matcher_for(Field, Value, fun erlang:'>='/2).
+
+matcher_on(Field, Value) ->
+ matcher_for(Field, Value, fun erlang:'=:='/2).
+
+matcher_for(Field, Value, Op) ->
+ fun(Rctx) -> Op(couch_srt_entry:value(Field, Rctx), Value) end.
+
+matcher_on_coordinators() ->
+ fun
+ (#rctx{type = #coordinator{}}) -> true;
+ (_) -> false
+ end.
+
+matcher_on_rpc_workers() ->
+ fun
+ (#rctx{type = #rpc_worker{}}) -> true;
+ (_) -> false
+ end.
+
+matcher_for_csrt(MatcherName) ->
+ Matchers = #{MatcherName => {_, _} = couch_srt_logger:get_matcher(MatcherName)},
+ case couch_srt_logger:get_matcher(MatcherName) of
+ {_, _} = Matcher ->
+ Matchers = #{MatcherName => Matcher},
+ fun(Rctx) -> couch_srt_logger:is_match(Rctx, Matchers) end;
+ _ ->
+ throw({missing_matcher, MatcherName})
+ end.
+
+matcher_for_dbnames_io(Dbname0, Threshold) ->
+ Dbname = list_to_binary(Dbname0),
+ fun(Rctx) ->
+ DbnameA = couch_srt_entry:value(dbname, Rctx),
+ Fields = [ioq_calls, get_kv_node, get_kp_node, docs_read, rows_read],
+ Vals = [{F, couch_srt_entry:value(F, Rctx)} || F <- Fields],
+ Dbname =:= mem3:dbname(DbnameA) andalso lists:any(fun({_K, V}) -> V >= Threshold end, Vals)
+ end.
diff --git a/src/couch_srt/test/eunit/couch_srt_query_tests.erl b/src/couch_srt/test/eunit/couch_srt_query_tests.erl
new file mode 100644
index 00000000000..063b90a970b
--- /dev/null
+++ b/src/couch_srt/test/eunit/couch_srt_query_tests.erl
@@ -0,0 +1,734 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(couch_srt_query_tests).
+
+-ifdef(WITH_PROPER).
+-include_lib("couch/include/couch_eunit_proper.hrl").
+-endif.
+
+-include_lib("couch/include/couch_eunit.hrl").
+
+-include_lib("stdlib/include/ms_transform.hrl").
+-include("../../src/couch_srt.hrl").
+
+-define(MATCHERS_THRESHOLD, 1000).
+csrt_query_test_() ->
+ {
+ foreach,
+ fun setup/0,
+ fun teardown/1,
+ [
+ ?TDEF_FE(t_group_by_multiple_keys),
+ ?TDEF_FE(t_group_by_single_key),
+ ?TDEF_FE(t_group_by_binary_key),
+ ?TDEF_FE(t_group_by_detect_unsafe_query),
+ ?TDEF_FE(t_group_by_run_unsafe_query),
+ ?TDEF_FE(t_group_by_run_unsafe_correctness),
+ ?TDEF_FE(t_group_by_bad_request),
+ ?TDEF_FE(t_count_by_multiple_keys),
+ ?TDEF_FE(t_count_by_single_key),
+ ?TDEF_FE(t_count_by_binary_key),
+ ?TDEF_FE(t_count_by_bad_request),
+ ?TDEF_FE(t_sort_by_multiple_keys),
+ ?TDEF_FE(t_sort_by_single_key),
+ ?TDEF_FE(t_sort_by_binary_key),
+ ?TDEF_FE(t_sort_by_bad_request)
+ ]
+ }.
+
+csrt_query_cardinality_limit_test_() ->
+ {
+ foreach,
+ fun setup_query_limit/0,
+ fun teardown_query_limit/1,
+ [
+ ?TDEF_FE(t_run_hits_query_cardinality_limit)
+ ]
+ }.
+
+setup() ->
+ Rctxs = [
+ rctx(#{dbname => <<"db1">>, ioq_calls => 123, username => <<"user_foo">>}),
+ rctx(#{dbname => <<"db1">>, ioq_calls => 321, username => <<"user_foo">>}),
+ rctx(#{dbname => <<"db2">>, ioq_calls => 345, username => <<"user_bar">>}),
+ rctx(#{dbname => <<"db2">>, ioq_calls => 543, username => <<"user_bar">>}),
+ rctx(#{dbname => <<"db1">>, ioq_calls => 678, username => <<"user_bar">>}),
+ rctx(#{dbname => <<"db2">>, ioq_calls => 987, username => <<"user_foo">>})
+ ],
+ ets:new(?CSRT_ETS, [
+ named_table,
+ public,
+ {keypos, #rctx.pid_ref}
+ ]),
+ ets:insert(?CSRT_ETS, Rctxs),
+ add_matcher("docs_read", couch_srt_logger:matcher_on_docs_read(?MATCHERS_THRESHOLD)),
+ #{rctxs => Rctxs}.
+
+teardown(_) ->
+ ets:delete(?CSRT_ETS).
+
+setup_query_limit() ->
+ Ctx = test_util:start_couch([couch_srt]),
+ config:set("csrt", "enable", "true", false),
+ config:set("csrt", "query_cardinality_limit", "5", false),
+ config:set("csrt_logger.matchers_enabled", "docs_read", "true", false),
+ config:set_boolean(?CSRT, "randomize_testing", false, false),
+ ets:insert(?CSRT_ETS, couch_srt_test_helper:rctxs()),
+ #{ctx => Ctx}.
+
+teardown_query_limit(#{ctx := Ctx}) ->
+ test_util:stop_couch(Ctx).
+
+rctx(Opts) ->
+ % Update `docs_read` to make standard `{docs_read, fun matcher_on_docs_read/1, 1000}`
+ % matcher match.
+ BaseOpts = #{docs_read => ?MATCHERS_THRESHOLD + 1, username => <<"user_foo">>},
+ couch_srt_test_helper:rctx_gen(maps:merge(BaseOpts, Opts)).
+
+dummy_key_fun(#rctx{username = Username}) ->
+ Username.
+
+dummy_value_fun(#rctx{ioq_calls = IoqCalls}) ->
+ IoqCalls.
+
+t_group_by_multiple_keys(#{rctxs := Rctxs}) ->
+ Aggregated = aggregate_by([username, dbname], ioq_calls, Rctxs),
+ Grouped = group(Aggregated),
+ V1 = maps:get({<<"user_bar">>, <<"db1">>}, Grouped),
+ V2 = maps:get({<<"user_bar">>, <<"db2">>}, Grouped),
+ V3 = maps:get({<<"user_foo">>, <<"db1">>}, Grouped),
+ V4 = maps:get({<<"user_foo">>, <<"db2">>}, Grouped),
+ Q = couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by([<<"username">>, <<"dbname">>], <<"ioq_calls">>)
+ ]),
+ ?assertMatch(
+ {ok, #{
+ {<<"user_bar">>, <<"db1">>} := V1,
+ {<<"user_bar">>, <<"db2">>} := V2,
+ {<<"user_foo">>, <<"db1">>} := V3,
+ {<<"user_foo">>, <<"db2">>} := V4
+ }},
+ couch_srt:run(Q)
+ ),
+ ok.
+
+t_group_by_single_key(#{rctxs := Rctxs}) ->
+ Aggregated = aggregate_by([username], ioq_calls, Rctxs),
+ Grouped = group(Aggregated),
+ V1 = maps:get({<<"user_bar">>}, Grouped),
+ V2 = maps:get({<<"user_foo">>}, Grouped),
+ Q = couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by([<<"username">>], <<"ioq_calls">>)
+ ]),
+ ?assertMatch(
+ {ok, #{
+ {<<"user_bar">>} := V1,
+ {<<"user_foo">>} := V2
+ }},
+ couch_srt:run(Q)
+ ),
+ ok.
+
+t_group_by_binary_key(#{rctxs := Rctxs}) ->
+ Aggregated = aggregate_by([username], ioq_calls, Rctxs),
+ Grouped = group(Aggregated),
+ V1 = maps:get({<<"user_bar">>}, Grouped),
+ V2 = maps:get({<<"user_foo">>}, Grouped),
+ Q = couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(<<"username">>, <<"ioq_calls">>)
+ ]),
+ ?assertMatch(
+ {ok, #{
+ <<"user_bar">> := V1,
+ <<"user_foo">> := V2
+ }},
+ couch_srt:run(Q)
+ ),
+ ok.
+
+t_group_by_detect_unsafe_query(_) ->
+ ?assertMatch(
+ {error, {unsafe_query, _}},
+ couch_srt:run(
+ couch_srt:query([
+ couch_srt:from(all),
+ couch_srt:group_by(<<"username">>, <<"ioq_calls">>)
+ ])
+ ),
+ "Should detect `unsafe` when `all` matcher is used"
+ ),
+ ?assertMatch(
+ {error, {unsafe_query, _}},
+ couch_srt:run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(fun dummy_key_fun/1, <<"ioq_calls">>)
+ ])
+ ),
+ "Should detect `unsafe` when `AggregationKey` is a function()"
+ ),
+ ?assertMatch(
+ {error, {unsafe_query, _}},
+ couch_srt:run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(<<"username">>, fun dummy_value_fun/1)
+ ])
+ ),
+ "Should detect `unsafe` when `ValueKey` is a function()"
+ ),
+ ?assertMatch(
+ {error, {unsafe_query, _}},
+ couch_srt:run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(<<"username">>, <<"ioq_calls">>),
+ couch_srt:options([
+ couch_srt:unlimited()
+ ])
+ ])
+ ),
+ "Should detect `unsafe` when `unlimited()` is used"
+ ),
+ ok.
+
+t_group_by_run_unsafe_query(_) ->
+ ?assertMatch(
+ {ok, _},
+ couch_srt:unsafe_run(
+ couch_srt:query([
+ couch_srt:from(all),
+ couch_srt:group_by(<<"username">>, <<"ioq_calls">>)
+ ])
+ ),
+ "Should be able to use `unsafe_run` when `all` matcher is used"
+ ),
+ ?assertMatch(
+ {ok, _},
+ couch_srt:unsafe_run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(fun dummy_key_fun/1, <<"ioq_calls">>)
+ ])
+ ),
+ "Should be able to use `unsafe_run` when `AggregationKey` is a function()"
+ ),
+ ?assertMatch(
+ {ok, _},
+ couch_srt:unsafe_run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(<<"username">>, fun dummy_value_fun/1)
+ ])
+ ),
+ "Should be able to use `unsafe_run` when `ValueKey` is a function()"
+ ),
+ ?assertMatch(
+ {ok, _},
+ couch_srt:unsafe_run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(<<"username">>, <<"ioq_calls">>),
+ couch_srt:options([
+ couch_srt:unlimited()
+ ])
+ ])
+ ),
+ "Should be able to use `unsafe_run` when `unlimited()` is used"
+ ),
+ ok.
+
+t_group_by_run_unsafe_correctness(_) ->
+ % we are checking that safe analog of the query return same result
+ ?assertEqual(
+ couch_srt:run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(<<"username">>, <<"ioq_calls">>)
+ ])
+ ),
+ couch_srt:unsafe_run(
+ couch_srt:query([
+ couch_srt:from(all),
+ couch_srt:group_by(<<"username">>, <<"ioq_calls">>)
+ ])
+ ),
+ "Should get correct result from `unsafe_run` when `all` matcher is used"
+ ),
+ ?assertEqual(
+ couch_srt:run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(<<"username">>, <<"ioq_calls">>)
+ ])
+ ),
+ couch_srt:unsafe_run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(fun dummy_key_fun/1, <<"ioq_calls">>)
+ ])
+ ),
+ "Should get correct result from `unsafe_run` when `AggregationKey` is a function()"
+ ),
+ ?assertEqual(
+ couch_srt:run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(<<"username">>, ioq_calls)
+ ])
+ ),
+ couch_srt:unsafe_run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(<<"username">>, fun dummy_value_fun/1)
+ ])
+ ),
+ "Should get correct result from `unsafe_run` when `ValueKey` is a function()"
+ ),
+ ?assertEqual(
+ couch_srt:run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(<<"username">>, <<"ioq_calls">>)
+ ])
+ ),
+ couch_srt:unsafe_run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by(<<"username">>, <<"ioq_calls">>),
+ couch_srt:options([
+ couch_srt:unlimited()
+ ])
+ ])
+ ),
+ "Should get correct result from `unsafe_run` when `unlimited()` is used"
+ ),
+ ok.
+
+t_group_by_bad_request(_) ->
+ ?assertMatch(
+ {error, [{unknown_matcher, "unknown_matcher"}]},
+ couch_srt:query([
+ couch_srt:from("unknown_matcher"),
+ couch_srt:group_by(<<"username">>, <<"ioq_calls">>)
+ ]),
+ "Should return error if 'matcher' is unknown"
+ ),
+ ?assertMatch(
+ {error, [{unknown_matcher, rows_read}]},
+ couch_srt:query([
+ couch_srt:from(rows_read),
+ couch_srt:group_by([username, dbname], ioq_calls)
+ ]),
+ "Should return error if 'matcher' is not a string()"
+ ),
+ ?assertMatch(
+ {error, [{invalid_key, "unknown_field"}]},
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by("unknown_field", ioq_calls)
+ ]),
+ "Should return error if 'AggregationKeys' contain unknown field"
+ ),
+ ?assertMatch(
+ {error, [{invalid_key, "unknown_field"}]},
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by("username", "unknown_field")
+ ]),
+ "Should return error if 'ValueKey' contain unknown field"
+ ),
+ ?assertMatch(
+ {error, [{beyond_limit, ?QUERY_LIMIT + 1}]},
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:group_by("username", ioq_calls),
+ couch_srt:options([
+ couch_srt:with_limit(?QUERY_LIMIT + 1)
+ ])
+ ]),
+ "Should return error when 'limit' is greater than configured"
+ ),
+ ok.
+
+t_count_by_multiple_keys(#{rctxs := Rctxs}) ->
+ Aggregated = aggregate_by([username, dbname], ioq_calls, Rctxs),
+ Grouped = count(Aggregated),
+ V1 = maps:get({<<"user_bar">>, <<"db1">>}, Grouped),
+ V2 = maps:get({<<"user_bar">>, <<"db2">>}, Grouped),
+ V3 = maps:get({<<"user_foo">>, <<"db1">>}, Grouped),
+ V4 = maps:get({<<"user_foo">>, <<"db2">>}, Grouped),
+ Q = couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:count_by([<<"username">>, <<"dbname">>])
+ ]),
+ ?assertMatch(
+ {ok, #{
+ {<<"user_bar">>, <<"db1">>} := V1,
+ {<<"user_bar">>, <<"db2">>} := V2,
+ {<<"user_foo">>, <<"db1">>} := V3,
+ {<<"user_foo">>, <<"db2">>} := V4
+ }},
+ couch_srt:run(Q)
+ ),
+ ok.
+
+t_count_by_single_key(#{rctxs := Rctxs}) ->
+ Aggregated = aggregate_by([username], ioq_calls, Rctxs),
+ Grouped = count(Aggregated),
+ V1 = maps:get({<<"user_bar">>}, Grouped),
+ V2 = maps:get({<<"user_foo">>}, Grouped),
+ Q = couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:count_by([<<"username">>])
+ ]),
+ ?assertMatch(
+ {ok, #{
+ {<<"user_bar">>} := V1,
+ {<<"user_foo">>} := V2
+ }},
+ couch_srt:run(Q)
+ ),
+ ok.
+
+t_count_by_binary_key(#{rctxs := Rctxs}) ->
+ Aggregated = aggregate_by([username], ioq_calls, Rctxs),
+ Grouped = count(Aggregated),
+ V1 = maps:get({<<"user_bar">>}, Grouped),
+ V2 = maps:get({<<"user_foo">>}, Grouped),
+ Q = couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:count_by(<<"username">>)
+ ]),
+ ?assertMatch(
+ {ok, #{
+ <<"user_bar">> := V1,
+ <<"user_foo">> := V2
+ }},
+ couch_srt:run(Q)
+ ),
+ ok.
+
+t_count_by_bad_request(_) ->
+ ?assertMatch(
+ {error, [{unknown_matcher, "unknown_matcher"}]},
+ couch_srt:query([
+ couch_srt:from("unknown_matcher"),
+ couch_srt:count_by(<<"username">>)
+ ]),
+ "Should return error if 'matcher' is unknown"
+ ),
+ ?assertMatch(
+ {error, [{unknown_matcher, rows_read}]},
+ couch_srt:query([
+ couch_srt:from(rows_read),
+ couch_srt:count_by([username, dbname])
+ ]),
+ "Should return error if 'matcher' is not a string()"
+ ),
+ ?assertMatch(
+ {error, [{invalid_key, "unknown_field"}]},
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:count_by("unknown_field")
+ ]),
+ "Should return error if 'AggregationKeys' contain unknown field"
+ ),
+ ?assertMatch(
+ {error, [{beyond_limit, ?QUERY_LIMIT + 1}]},
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:count_by("username"),
+ couch_srt:options([
+ couch_srt:with_limit(?QUERY_LIMIT + 1)
+ ])
+ ]),
+ "Should return error when 'limit' is greater than configured"
+ ),
+ ok.
+
+t_sort_by_multiple_keys(#{rctxs := Rctxs}) ->
+ Aggregated = aggregate_by([username, dbname], ioq_calls, Rctxs),
+ Grouped = group(Aggregated),
+ Ordered = order_by_value(Grouped),
+ [
+ {{<<"user_foo">>, <<"db2">>}, V1},
+ {{<<"user_bar">>, <<"db2">>}, V2},
+ {{<<"user_bar">>, <<"db1">>}, V3},
+ {{<<"user_foo">>, <<"db1">>}, V4}
+ ] = Ordered,
+ Q = couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:sort_by([<<"username">>, <<"dbname">>], <<"ioq_calls">>)
+ ]),
+ ?assertMatch(
+ {ok, [
+ {{<<"user_foo">>, <<"db2">>}, V1},
+ {{<<"user_bar">>, <<"db2">>}, V2},
+ {{<<"user_bar">>, <<"db1">>}, V3},
+ {{<<"user_foo">>, <<"db1">>}, V4}
+ ]},
+ couch_srt:run(Q)
+ ),
+ ok.
+
+t_sort_by_single_key(#{rctxs := Rctxs}) ->
+ Aggregated = aggregate_by([username], ioq_calls, Rctxs),
+ Grouped = group(Aggregated),
+ Ordered = order_by_value(Grouped),
+ [
+ {{<<"user_bar">>}, V1},
+ {{<<"user_foo">>}, V2}
+ ] = Ordered,
+ Q = couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:sort_by([<<"username">>], <<"ioq_calls">>)
+ ]),
+ ?assertMatch(
+ {ok, [
+ {{<<"user_bar">>}, V1},
+ {{<<"user_foo">>}, V2}
+ ]},
+ couch_srt:run(Q)
+ ),
+ ok.
+
+t_sort_by_binary_key(#{rctxs := Rctxs}) ->
+ Aggregated = aggregate_by([username], ioq_calls, Rctxs),
+ Grouped = group(Aggregated),
+ Ordered = order_by_value(Grouped),
+ [
+ {{<<"user_bar">>}, V1},
+ {{<<"user_foo">>}, V2}
+ ] = Ordered,
+ Q = couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:sort_by(<<"username">>, <<"ioq_calls">>)
+ ]),
+ ?assertMatch(
+ {ok, [
+ {<<"user_bar">>, V1},
+ {<<"user_foo">>, V2}
+ ]},
+ couch_srt:run(Q)
+ ),
+ ok.
+
+t_sort_by_bad_request(_) ->
+ ?assertMatch(
+ {error, [{unknown_matcher, "unknown_matcher"}]},
+ couch_srt:query([
+ couch_srt:from("unknown_matcher"),
+ couch_srt:sort_by(<<"username">>, <<"ioq_calls">>)
+ ]),
+ "Should return error if 'matcher' is unknown"
+ ),
+ ?assertMatch(
+ {error, [{unknown_matcher, "unknown_matcher"}]},
+ couch_srt:query([
+ couch_srt:from("unknown_matcher"),
+ couch_srt:sort_by(<<"username">>)
+ ]),
+ "Should return error if 'matcher' is unknown"
+ ),
+ ?assertMatch(
+ {error, [{unknown_matcher, rows_read}]},
+ couch_srt:query([
+ couch_srt:from(rows_read),
+ couch_srt:sort_by([username, dbname], ioq_calls)
+ ]),
+ "Should return error if 'matcher' is not a string()"
+ ),
+ ?assertMatch(
+ {error, [{unknown_matcher, rows_read}]},
+ couch_srt:query([
+ couch_srt:from(rows_read),
+ couch_srt:sort_by([username, dbname])
+ ]),
+ "Should return error if 'matcher' is not a string()"
+ ),
+ ?assertMatch(
+ {error, [{invalid_key, "unknown_field"}]},
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:sort_by("unknown_field", ioq_calls)
+ ]),
+ "Should return error if 'AggregationKeys' contain unknown field"
+ ),
+ ?assertMatch(
+ {error, [{invalid_key, "unknown_field"}]},
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:sort_by("unknown_field")
+ ]),
+ "Should return error if 'AggregationKeys' contain unknown field"
+ ),
+ ?assertMatch(
+ {error, [{invalid_key, "unknown_field"}]},
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:sort_by("username", "unknown_field")
+ ]),
+ "Should return error if 'ValueKey' contain unknown field"
+ ),
+ ?assertMatch(
+ {error, [{beyond_limit, ?QUERY_LIMIT + 1}]},
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:sort_by("username", ioq_calls),
+ couch_srt:options([
+ couch_srt:with_limit(?QUERY_LIMIT + 1)
+ ])
+ ]),
+ "Should return error when 'limit' is greater than configured"
+ ),
+ ?assertMatch(
+ {error, [{beyond_limit, ?QUERY_LIMIT + 1}]},
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:sort_by("username"),
+ couch_srt:options([
+ couch_srt:with_limit(?QUERY_LIMIT + 1)
+ ])
+ ]),
+ "Should return error when 'limit' is greater than configured"
+ ),
+ ok.
+
+t_run_hits_query_cardinality_limit(_) ->
+ %% Use a sort_by query to easily pattern match on a non empty list
+ ?assertMatch(
+ {limit, [_ | _]},
+ couch_srt:run(
+ couch_srt:query([
+ couch_srt:from("docs_read"),
+ couch_srt:sort_by([<<"dbname">>, <<"username">>], <<"ioq_calls">>),
+ couch_srt:options([
+ couch_srt:with_limit(1)
+ ])
+ ])
+ ),
+ "Should hit limit but still return results when configured query_cardinality_limit is smaller than the working set"
+ ),
+ ok.
+
+add_matcher(Name, MSpec) ->
+ persistent_term:put({csrt_logger, all_csrt_matchers}, #{
+ Name => {MSpec, ets:match_spec_compile(MSpec)}
+ }).
+
+aggregate_by(AggregationKeys, ValField, Records) ->
+ lists:foldl(
+ fun(Rctx, Acc) ->
+ Key = list_to_tuple([couch_srt_entry:value(Field, Rctx) || Field <- AggregationKeys]),
+ CurrVal = maps:get(Key, Acc, []),
+ maps:put(Key, [couch_srt_entry:value(ValField, Rctx) | CurrVal], Acc)
+ end,
+ #{},
+ Records
+ ).
+
+group(Aggregated) ->
+ maps:fold(
+ fun(Key, Val, Acc) ->
+ maps:put(Key, lists:foldl(fun erlang:'+'/2, 0, Val), Acc)
+ end,
+ #{},
+ Aggregated
+ ).
+
+count(Aggregated) ->
+ maps:fold(
+ fun(Key, Val, Acc) ->
+ maps:put(Key, lists:foldl(fun(_, A) -> A + 1 end, 0, Val), Acc)
+ end,
+ #{},
+ Aggregated
+ ).
+
+order_by_value(Grouped) ->
+ lists:reverse(lists:keysort(2, maps:to_list(Grouped))).
+
+-ifdef(WITH_PROPER).
+
+format(Fmt, Args) ->
+ lists:flatten(io_lib:format(Fmt, Args)).
+
+new_topK_test_() ->
+ ?EUNIT_QUICKCHECK(60, 10000).
+
+prop_sorted_after_update() ->
+ ?FORALL(
+ Updates,
+ updates_g(),
+ begin
+ Limit = 10,
+ TopKResults = couch_srt_query:topK(Updates, Limit),
+ NResults = length(TopKResults),
+ Values = [V || {_K, V} <- TopKResults],
+ ?assert(Values == lists:reverse(lists:sort(Values)), "Expected values to be ordered"),
+ ?assert(
+ NResults =< Limit,
+ format(
+ "Expected the number of values to be less then the limit topK = ~p, limit = ~p",
+ [NResults, Limit]
+ )
+ ),
+ Model = update_model(Updates, Limit),
+ ?assert(
+ NResults == length(Model),
+ format(
+ "Expected the same number of values from topK as in the model topK = ~p, model = ~p",
+ [NResults, length(Model)]
+ )
+ ),
+ ModelValues = [V || {_K, V} <- Model],
+ ?assert(
+ Values == ModelValues,
+ format(
+ "Expected values from topK to be equal to values from the model topK = ~p, model = ~p",
+ [Values, ModelValues]
+ )
+ ),
+ true
+ end
+ ).
+
+update_model(Updates, Limit) ->
+ UpdatesList = maps:to_list(Updates),
+ SortedResults = lists:sort(
+ fun({AK, AV}, {BK, BV}) ->
+ AV > BV orelse (AV == BV andalso AK >= BK)
+ end,
+ UpdatesList
+ ),
+ Size = min(Limit, length(SortedResults)),
+ {Model, _} = lists:split(Size, SortedResults),
+ Model.
+
+non_empty_tuple(Type) ->
+ ?LET(L, non_empty(list(Type)), list_to_tuple(L)).
+
+aggregation_key_g() ->
+ non_empty_tuple(string()).
+
+value_g() ->
+ non_neg_integer().
+
+updates_g() ->
+ map(aggregation_key_g(), value_g()).
+
+-endif.
diff --git a/src/couch_srt/test/eunit/couch_srt_server_tests.erl b/src/couch_srt/test/eunit/couch_srt_server_tests.erl
new file mode 100644
index 00000000000..6a9a827df93
--- /dev/null
+++ b/src/couch_srt/test/eunit/couch_srt_server_tests.erl
@@ -0,0 +1,643 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(couch_srt_server_tests).
+
+-include_lib("couch/include/couch_db.hrl").
+-include_lib("couch/include/couch_eunit.hrl").
+-include_lib("couch_mrview/include/couch_mrview.hrl").
+-include("../../src/couch_srt.hrl").
+
+-define(DOCS_COUNT, 100).
+-define(DDOCS_COUNT, 1).
+-define(DB_Q, 8).
+
+-define(DEBUG_ENABLED, false).
+
+csrt_context_test_() ->
+ {
+ setup,
+ fun setup/0,
+ fun teardown/1,
+ with([
+ ?TDEF(t_context_setting)
+ ])
+ }.
+
+test_funs() ->
+ [
+ ?TDEF_FE(t_all_docs_include_false),
+ ?TDEF_FE(t_all_docs_include_true),
+ ?TDEF_FE(t_all_docs_limit_zero),
+ ?TDEF_FE(t_get_doc),
+ ?TDEF_FE(t_put_doc),
+ ?TDEF_FE(t_delete_doc),
+ ?TDEF_FE(t_update_docs),
+ ?TDEF_FE(t_changes),
+ ?TDEF_FE(t_changes_limit_zero),
+ ?TDEF_FE(t_changes_filtered),
+ ?TDEF_FE(t_updated_at),
+ ?TDEF_FE(t_view_query),
+ ?TDEF_FE(t_view_query_include_docs)
+ ].
+
+ddoc_test_funs() ->
+ [
+ ?TDEF_FE(t_changes_js_filtered)
+ | test_funs()
+ ].
+
+csrt_fabric_no_ddoc_test_() ->
+ {
+ "CSRT fabric tests with no DDoc present",
+ foreach,
+ fun setup/0,
+ fun teardown/1,
+ test_funs()
+ }.
+
+csrt_fabric_test_() ->
+ {
+ "CSRT fabric tests with a DDoc present",
+ foreach,
+ fun() -> setup_ddoc(<<"_design/foo">>, <<"bar">>) end,
+ fun teardown/1,
+ ddoc_test_funs()
+ }.
+
+make_docs(Count) ->
+ lists:map(
+ fun(I) ->
+ #doc{
+ id = ?l2b("foo_" ++ integer_to_list(I)),
+ body = {[{<<"value">>, I}]}
+ }
+ end,
+ lists:seq(1, Count)
+ ).
+
+setup() ->
+ Ctx = test_util:start_couch([fabric, couch_stats, couch_srt]),
+ config:set_boolean(?CSRT, "randomize_testing", false, false),
+ ok = meck:new(ioq, [passthrough]),
+ ok = meck:expect(ioq, bypass, fun(_, _) -> false end),
+ DbName = ?tempdb(),
+ ok = fabric:create_db(DbName, [{q, ?DB_Q}, {n, 1}]),
+ Docs = make_docs(?DOCS_COUNT),
+ Opts = [],
+ {ok, _} = fabric:update_docs(DbName, Docs, Opts),
+ {Ctx, DbName, undefined}.
+
+teardown({Ctx, DbName, _View}) ->
+ ok = fabric:delete_db(DbName, [?ADMIN_CTX]),
+ ok = meck:unload(ioq),
+ test_util:stop_couch(Ctx).
+
+setup_ddoc(DDocId, ViewName) ->
+ {Ctx, DbName, undefined} = setup(),
+ DDoc = couch_doc:from_json_obj(
+ {[
+ {<<"_id">>, DDocId},
+ {<<"language">>, <<"javascript">>},
+ {
+ <<"views">>,
+ {[
+ {
+ ViewName,
+ {[
+ {<<"map">>, <<"function(doc) { emit(doc.value, null); }">>}
+ ]}
+ }
+ ]}
+ },
+ {
+ <<"filters">>,
+ {[
+ {
+ <<"even">>,
+ <<"function(doc) { return (doc.value % 2 == 0); }">>
+ }
+ ]}
+ }
+ ]}
+ ),
+ {ok, _Rev} = fabric:update_doc(DbName, DDoc, [?ADMIN_CTX]),
+ {Ctx, DbName, {DDocId, ViewName}}.
+
+t_context_setting({_Ctx, _DbName, _View}) ->
+ false.
+
+t_all_docs_limit_zero({_Ctx, DbName, _View}) ->
+ Context = #{
+ method => 'GET',
+ path => "/" ++ ?b2l(DbName) ++ "/_all_docs"
+ },
+ {PidRef, Nonce} = coordinator_context(Context),
+ Rctx0 = load_rctx(PidRef, Nonce),
+ ok = fresh_rctx_assert(Rctx0, PidRef, Nonce),
+ MArgs = #mrargs{include_docs = false, limit = 0},
+ _Res = fabric:all_docs(DbName, [?ADMIN_CTX], fun view_cb/2, [], MArgs),
+ Rctx = wait_rctx(PidRef, nonzero_local_io()),
+ ?assert(is_map(Rctx), "Expected a nonzero local io"),
+ ok = rctx_assert(Rctx, #{
+ nonce => Nonce,
+ db_open => ?DB_Q,
+ rows_read => 0,
+ docs_read => 0,
+ docs_written => 0,
+ ioq_calls => assert_gt(),
+ pid_ref => PidRef
+ }),
+ ok = assert_teardown(PidRef).
+
+t_all_docs_include_false({_Ctx, DbName, View}) ->
+ Context = #{
+ method => 'GET',
+ path => "/" ++ ?b2l(DbName) ++ "/_all_docs"
+ },
+ {PidRef, Nonce} = coordinator_context(Context),
+ Rctx0 = load_rctx(PidRef, Nonce),
+ ok = fresh_rctx_assert(Rctx0, PidRef, Nonce),
+ MArgs = #mrargs{include_docs = false},
+ _Res = fabric:all_docs(DbName, [?ADMIN_CTX], fun view_cb/2, [], MArgs),
+ Rctx = wait_rctx(PidRef, nonzero_local_io()),
+ ?assert(is_map(Rctx), "Expected a nonzero local io"),
+ ok = rctx_assert(Rctx, #{
+ nonce => Nonce,
+ db_open => ?DB_Q,
+ rows_read => docs_count(View),
+ docs_read => 0,
+ docs_written => 0,
+ pid_ref => PidRef
+ }),
+ ok = assert_teardown(PidRef).
+
+t_all_docs_include_true({_Ctx, DbName, View}) ->
+ pdebug(dbname, DbName),
+ Context = #{
+ method => 'GET',
+ path => "/" ++ ?b2l(DbName) ++ "/_all_docs"
+ },
+ {PidRef, Nonce} = coordinator_context(Context),
+ Rctx0 = load_rctx(PidRef, Nonce),
+ ok = fresh_rctx_assert(Rctx0, PidRef, Nonce),
+ MArgs = #mrargs{include_docs = true},
+ _Res = fabric:all_docs(DbName, [?ADMIN_CTX], fun view_cb/2, [], MArgs),
+ Rctx = wait_rctx(PidRef, nonzero_local_io()),
+ ?assert(is_map(Rctx), "Expected a nonzero local io"),
+ ok = rctx_assert(Rctx, #{
+ nonce => Nonce,
+ db_open => ?DB_Q,
+ rows_read => docs_count(View),
+ docs_read => docs_count(View),
+ docs_written => 0,
+ pid_ref => PidRef
+ }),
+ ok = assert_teardown(PidRef).
+
+t_update_docs({_Ctx, DbName, View}) ->
+ pdebug(dbname, DbName),
+ Context = #{
+ method => 'POST',
+ path => "/" ++ ?b2l(DbName)
+ },
+ {PidRef, Nonce} = coordinator_context(Context),
+ Rctx0 = load_rctx(PidRef, Nonce),
+ ok = fresh_rctx_assert(Rctx0, PidRef, Nonce),
+ Docs = [#doc{id = ?l2b("bar_" ++ integer_to_list(I))} || I <- lists:seq(1, ?DOCS_COUNT)],
+ _Res = fabric:update_docs(DbName, Docs, [?ADMIN_CTX]),
+ Rctx = wait_rctx(PidRef, ddoc_dependent_local_io(View)),
+ ?assert(is_map(Rctx), "Expected a zero local io for view"),
+ pdebug(rctx, Rctx),
+ ok = rctx_assert(Rctx, #{
+ nonce => Nonce,
+ db_open => ?DB_Q,
+ rows_read => 0,
+ docs_read => 0,
+ docs_written => ?DOCS_COUNT,
+ pid_ref => PidRef
+ }),
+ ok = assert_teardown(PidRef).
+
+t_get_doc({_Ctx, DbName, _View}) ->
+ pdebug(dbname, DbName),
+ DocId = "foo_17",
+ Context = #{
+ method => 'GET',
+ path => "/" ++ ?b2l(DbName) ++ "/" ++ DocId
+ },
+ {PidRef, Nonce} = coordinator_context(Context),
+ Rctx0 = load_rctx(PidRef, Nonce),
+ ok = fresh_rctx_assert(Rctx0, PidRef, Nonce),
+ _Res = fabric:open_doc(DbName, DocId, [?ADMIN_CTX]),
+ Rctx = wait_rctx(PidRef, nonzero_local_io(io_sum)),
+ ?assert(is_map(Rctx), "Expected a nonzero local io"),
+ pdebug(rctx, Rctx),
+ ok = rctx_assert(Rctx, #{
+ nonce => Nonce,
+ db_open => 1,
+ rows_read => 0,
+ docs_read => 1,
+ docs_written => 0,
+ pid_ref => PidRef
+ }),
+ ok = assert_teardown(PidRef).
+
+t_updated_at({_Ctx, DbName, _View}) ->
+ %% Same test as t_get_doc but with a timer sleep and updated_at assertion
+ TimeDelay = 1234,
+ pdebug(dbname, DbName),
+ DocId = "foo_17",
+ Context = #{
+ method => 'GET',
+ path => "/" ++ ?b2l(DbName) ++ "/" ++ DocId
+ },
+ {PidRef, Nonce} = coordinator_context(Context),
+ Rctx0 = load_rctx(PidRef, Nonce),
+ ok = fresh_rctx_assert(Rctx0, PidRef, Nonce),
+ timer:sleep(TimeDelay),
+ _Res = fabric:open_doc(DbName, DocId, [?ADMIN_CTX]),
+ Rctx = wait_rctx(PidRef, nonzero_local_io(io_sum)),
+ ?assert(is_map(Rctx), "Expected a nonzero local io"),
+ %% Get RawRctx to have pre-json-converted timestamps
+ RawRctx = couch_srt:get_resource(PidRef),
+ pdebug(rctx, Rctx),
+ ok = rctx_assert(Rctx, #{
+ nonce => Nonce,
+ db_open => 1,
+ rows_read => 0,
+ docs_read => 1,
+ docs_written => 0,
+ pid_ref => PidRef
+ }),
+ Started = couch_srt_entry:value(started_at, RawRctx),
+ Updated = couch_srt_entry:value(updated_at, RawRctx),
+ ?assert(
+ couch_srt_util:make_dt(Started, Updated, millisecond) > TimeDelay,
+ "updated_at gets updated with an expected TimeDelay"
+ ),
+ ?assert(
+ couch_srt_util:make_dt(Started, Updated, millisecond) < 2 * TimeDelay,
+ "updated_at gets updated in a reasonable time frame"
+ ),
+ ok = assert_teardown(PidRef).
+
+t_put_doc({_Ctx, DbName, View}) ->
+ pdebug(dbname, DbName),
+ DocId = "bar_put_1919",
+ Context = #{
+ method => 'PUT',
+ path => "/" ++ ?b2l(DbName) ++ "/" ++ DocId
+ },
+ {PidRef, Nonce} = coordinator_context(Context),
+ Rctx0 = load_rctx(PidRef, Nonce),
+ ok = fresh_rctx_assert(Rctx0, PidRef, Nonce),
+ Doc = #doc{id = ?l2b(DocId)},
+ _Res = fabric:update_doc(DbName, Doc, [?ADMIN_CTX]),
+ Rctx = wait_rctx(PidRef, ddoc_dependent_local_io(View)),
+ ?assert(is_map(Rctx), "Expected a zero local io for view"),
+ pdebug(rctx, Rctx),
+ ok = rctx_assert(Rctx, #{
+ nonce => Nonce,
+ db_open => 1,
+ rows_read => 0,
+ docs_read => 0,
+ docs_written => 1,
+ pid_ref => PidRef
+ }),
+ ok = assert_teardown(PidRef).
+
+t_delete_doc({_Ctx, DbName, View}) ->
+ pdebug(dbname, DbName),
+ DocId = "foo_17",
+ {ok, Doc0} = fabric:open_doc(DbName, DocId, [?ADMIN_CTX]),
+ Doc = Doc0#doc{body = {[{<<"_deleted">>, true}]}},
+ Context = #{
+ method => 'DELETE',
+ path => "/" ++ ?b2l(DbName) ++ "/" ++ DocId
+ },
+ {PidRef, Nonce} = coordinator_context(Context),
+ Rctx0 = load_rctx(PidRef, Nonce),
+ ok = fresh_rctx_assert(Rctx0, PidRef, Nonce),
+ _Res = fabric:update_doc(DbName, Doc, [?ADMIN_CTX]),
+ Rctx = wait_rctx(PidRef, ddoc_dependent_local_io(View)),
+ ?assert(is_map(Rctx), "Expected a zero local io for view"),
+ pdebug(rctx, Rctx),
+ ok = rctx_assert(Rctx, #{
+ nonce => Nonce,
+ db_open => 1,
+ rows_read => 0,
+ docs_read => 0,
+ docs_written => 1,
+ pid_ref => PidRef
+ }),
+ ok = assert_teardown(PidRef).
+
+t_changes({_Ctx, DbName, View}) ->
+ pdebug(dbname, DbName),
+ Context = #{
+ method => 'GET',
+ path => "/" ++ ?b2l(DbName) ++ "/_changes"
+ },
+ {PidRef, Nonce} = coordinator_context(Context),
+ Rctx0 = load_rctx(PidRef, Nonce),
+ ok = fresh_rctx_assert(Rctx0, PidRef, Nonce),
+ _Res = fabric:changes(DbName, fun changes_cb/2, [], #changes_args{}),
+ Rctx = wait_rctx(PidRef, nonzero_local_io()),
+ ?assert(is_map(Rctx), "Expected a nonzero local io"),
+ ok = rctx_assert(Rctx, #{
+ nonce => Nonce,
+ db_open => ?DB_Q,
+ rows_read => docs_count(View),
+ changes_returned => docs_count(View),
+ docs_read => 0,
+ docs_written => 0,
+ pid_ref => PidRef
+ }),
+ ok = assert_teardown(PidRef).
+
+t_changes_limit_zero({_Ctx, DbName, _View}) ->
+ Context = #{
+ method => 'GET',
+ path => "/" ++ ?b2l(DbName) ++ "/_changes"
+ },
+ {PidRef, Nonce} = coordinator_context(Context),
+ Rctx0 = load_rctx(PidRef, Nonce),
+ ok = fresh_rctx_assert(Rctx0, PidRef, Nonce),
+ _Res = fabric:changes(DbName, fun changes_cb/2, [], #changes_args{limit = 0}),
+ Rctx = wait_rctx(PidRef, nonzero_local_io()),
+ ?assert(is_map(Rctx), "Expected a nonzero local io"),
+ ok = rctx_assert(Rctx, #{
+ nonce => Nonce,
+ db_open => ?DB_Q,
+ rows_read => assert_gte(?DB_Q),
+ changes_returned => assert_gte(?DB_Q),
+ docs_read => 0,
+ docs_written => 0,
+ pid_ref => PidRef
+ }),
+ ok = assert_teardown(PidRef).
+
+%% TODO: stub in non JS filter with selector
+t_changes_filtered({_Ctx, _DbName, _View}) ->
+ false.
+
+t_changes_js_filtered({_Ctx, DbName, {DDocId, _ViewName} = View}) ->
+ pdebug(dbname, DbName),
+ Method = 'GET',
+ Path = "/" ++ ?b2l(DbName) ++ "/_changes",
+ Context = #{
+ method => Method,
+ path => Path
+ },
+ {PidRef, Nonce} = coordinator_context(Context),
+ Req = {json_req, null},
+ Rctx0 = load_rctx(PidRef, Nonce),
+ ok = fresh_rctx_assert(Rctx0, PidRef, Nonce),
+ Filter = configure_filter(DbName, DDocId, Req),
+ Args = #changes_args{filter_fun = Filter},
+ _Res = fabric:changes(DbName, fun changes_cb/2, [], Args),
+ Rctx = wait_rctx(PidRef, nonzero_local_io()),
+ ?assert(is_map(Rctx), "Expected a nonzero local io"),
+ ok = rctx_assert(Rctx, #{
+ nonce => Nonce,
+ db_open => assert_gte(?DB_Q),
+ rows_read => assert_gte(docs_count(View)),
+ changes_returned => round(?DOCS_COUNT / 2),
+ docs_read => assert_gte(docs_count(View)),
+ docs_written => 0,
+ pid_ref => PidRef,
+ js_filter => docs_count(View),
+ js_filtered_docs => docs_count(View)
+ }),
+ ok = assert_teardown(PidRef).
+
+t_view_query({_Ctx, DbName, View}) ->
+ Context = #{
+ method => 'GET',
+ path => "/" ++ ?b2l(DbName) ++ "/_design/foo/_view/bar"
+ },
+ {PidRef, Nonce} = coordinator_context(Context),
+ Rctx0 = load_rctx(PidRef, Nonce),
+ ok = fresh_rctx_assert(Rctx0, PidRef, Nonce),
+ MArgs = #mrargs{include_docs = false},
+ _Res = fabric:all_docs(DbName, [?ADMIN_CTX], fun view_cb/2, [], MArgs),
+ Rctx = wait_rctx(PidRef, nonzero_local_io()),
+ ?assert(is_map(Rctx), "Expected a nonzero local io"),
+ ok = rctx_assert(Rctx, #{
+ nonce => Nonce,
+ db_open => ?DB_Q,
+ rows_read => docs_count(View),
+ docs_read => 0,
+ docs_written => 0,
+ pid_ref => PidRef
+ }),
+ ok = assert_teardown(PidRef).
+
+t_view_query_include_docs({_Ctx, DbName, View}) ->
+ Context = #{
+ method => 'GET',
+ path => "/" ++ ?b2l(DbName) ++ "/_design/foo/_view/bar"
+ },
+ {PidRef, Nonce} = coordinator_context(Context),
+ Rctx0 = load_rctx(PidRef, Nonce),
+ ok = fresh_rctx_assert(Rctx0, PidRef, Nonce),
+ MArgs = #mrargs{include_docs = true},
+ _Res = fabric:all_docs(DbName, [?ADMIN_CTX], fun view_cb/2, [], MArgs),
+ Rctx = wait_rctx(PidRef, nonzero_local_io()),
+ ?assert(is_map(Rctx), "Expected a nonzero local io"),
+ ok = rctx_assert(Rctx, #{
+ nonce => Nonce,
+ db_open => ?DB_Q,
+ rows_read => docs_count(View),
+ docs_read => docs_count(View),
+ docs_written => 0,
+ pid_ref => PidRef
+ }),
+ ok = assert_teardown(PidRef).
+
+assert_teardown(PidRef) ->
+ ?assertEqual(ok, couch_srt:destroy_context(PidRef)),
+ ?assertEqual(undefined, couch_srt:get_resource()),
+ %% Normally the tracker is responsible for destroying the resource
+ ?assertEqual(true, couch_srt_server:destroy_resource(PidRef)),
+ ?assertEqual(undefined, couch_srt:get_resource(PidRef)),
+ ok.
+
+view_cb({row, Row}, Acc) ->
+ {ok, [Row | Acc]};
+view_cb(_Msg, Acc) ->
+ {ok, Acc}.
+
+changes_cb({change, {Change}}, Acc) ->
+ {ok, [Change | Acc]};
+changes_cb(_Msg, Acc) ->
+ {ok, Acc}.
+
+pdebug(dbname, DbName) ->
+ case ?DEBUG_ENABLED =:= true of
+ true ->
+ ?debugFmt("DBNAME[~p]: ~p", [DbName, fabric:get_db_info(DbName)]);
+ false ->
+ ok
+ end;
+pdebug(rctx, Rctx) ->
+ ?DEBUG_ENABLED andalso ?debugFmt("GOT RCTX: ~p~n", [Rctx]).
+
+pdbg(Str, Args) ->
+ ?DEBUG_ENABLED andalso ?debugFmt(Str, Args).
+
+convert_pidref({_, _} = PidRef) ->
+ couch_srt_entry:convert_pidref(PidRef);
+convert_pidref(PidRef) when is_binary(PidRef) ->
+ PidRef;
+convert_pidref(false) ->
+ false.
+
+rctx_assert(Rctx, Asserts0) ->
+ DefaultAsserts = #{
+ changes_returned => 0,
+ js_filter => 0,
+ js_filtered_docs => 0,
+ nonce => null,
+ db_open => 0,
+ rows_read => 0,
+ docs_read => 0,
+ docs_written => 0,
+ pid_ref => null
+ },
+ Updates = #{
+ pid_ref => fun convert_pidref/1,
+ nonce => fun couch_srt_entry:convert_string/1
+ },
+ Asserts = maps:merge(
+ DefaultAsserts,
+ maps:fold(fun maps:update_with/3, Asserts0, Updates)
+ ),
+ ok = maps:foreach(
+ fun
+ (_K, false) ->
+ ok;
+ (K, Fun) when is_function(Fun) ->
+ Fun(K, maps:get(K, Rctx));
+ (K, V) ->
+ case maps:get(K, Rctx) of
+ false ->
+ ok;
+ RV ->
+ pdbg("?assertEqual(~p, ~p, ~p)", [V, RV, K]),
+ ?assertEqual(V, RV, K)
+ end
+ end,
+ Asserts
+ ),
+ ok.
+
+wait_rctx(PidRef, WaitFun) ->
+ test_util:wait(fun() ->
+ Rctx = couch_srt_entry:to_json(couch_srt:get_resource(PidRef)),
+ WaitFun(Rctx)
+ end).
+
+%% Doc updates and others don't perform local IO, they funnel to another pid
+zero_local_io() ->
+ fun
+ (#{ioq_calls := 0, get_kp_node := 0, get_kv_node := 0} = Ctx) ->
+ Ctx;
+ (_) ->
+ wait
+ end.
+
+nonzero_local_io() ->
+ nonzero_local_io(io_separate).
+
+nonzero_local_io(io_sum) ->
+ fun
+ (
+ #{
+ ioq_calls := IoqCalls,
+ get_kp_node := KPNodes,
+ get_kv_node := KVNodes
+ } = Ctx
+ ) when IoqCalls > 0 andalso (KPNodes + KVNodes) > 0 ->
+ Ctx;
+ (_) ->
+ wait
+ end;
+nonzero_local_io(io_separate) ->
+ fun
+ (
+ #{
+ ioq_calls := IoqCalls,
+ get_kp_node := KPNodes,
+ get_kv_node := KVNodes
+ } = Ctx
+ ) when IoqCalls > 0 andalso KPNodes > 0 andalso KVNodes > 0 ->
+ Ctx;
+ (_) ->
+ wait
+ end.
+
+ddoc_dependent_local_io(undefined) ->
+ zero_local_io();
+ddoc_dependent_local_io({_DDoc, _ViewName}) ->
+ nonzero_local_io(io_sum).
+
+coordinator_context(#{method := Method, path := Path}) ->
+ Nonce = couch_util:to_hex(crypto:strong_rand_bytes(5)),
+ Req = #httpd{method = Method, nonce = Nonce},
+ {_, _} = PidRef = couch_srt:create_coordinator_context(Req, Path),
+ {PidRef, Nonce}.
+
+fresh_rctx_assert(Rctx, PidRef, Nonce) ->
+ pdebug(rctx, Rctx),
+ FreshAsserts = #{
+ nonce => Nonce,
+ db_open => 0,
+ rows_read => 0,
+ docs_read => 0,
+ docs_written => 0,
+ pid_ref => PidRef
+ },
+ rctx_assert(Rctx, FreshAsserts).
+
+assert_gt() ->
+ assert_gt(0).
+
+assert_gt(N) ->
+ fun(K, RV) -> ?assert(RV > N, {K, RV, N}) end.
+
+assert_gte(N) ->
+ fun(K, RV) -> ?assert(RV >= N, {K, RV, N}) end.
+
+docs_count(undefined) ->
+ ?DOCS_COUNT;
+docs_count({_, _}) ->
+ ?DOCS_COUNT + ?DDOCS_COUNT.
+
+configure_filter(DbName, DDocId, Req) ->
+ configure_filter(DbName, DDocId, Req, <<"even">>).
+
+configure_filter(DbName, DDocId, Req, FName) ->
+ {ok, DDoc} = ddoc_cache:open_doc(DbName, DDocId),
+ DIR = fabric_util:doc_id_and_rev(DDoc),
+ Style = main_only,
+ {fetch, custom, Style, Req, DIR, FName}.
+
+load_rctx(PidRef, NonceString) ->
+ Nonce = list_to_binary(NonceString),
+ wait_rctx(PidRef, fun
+ (#{nonce := N} = Rctx) when N == Nonce -> Rctx;
+ (S) ->
+ ?debugFmt("Nonce = ~p R = ~p~n", [Nonce, S]),
+ wait
+ end).
diff --git a/src/couch_srt/test/eunit/couch_srt_test_helper.erl b/src/couch_srt/test/eunit/couch_srt_test_helper.erl
new file mode 100644
index 00000000000..60e4f6acf5a
--- /dev/null
+++ b/src/couch_srt/test/eunit/couch_srt_test_helper.erl
@@ -0,0 +1,139 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(couch_srt_test_helper).
+
+-export([
+ enable_default_logger_matchers/0,
+ rctx_gen/0,
+ rctx_gen/1,
+ rctxs/0,
+ rctxs/1,
+ jrctx/1
+]).
+
+-include_lib("couch/include/couch_eunit.hrl").
+
+-include("../../src/couch_srt.hrl").
+-define(RCTX_RANGE, 1000).
+-define(RCTX_COUNT, 10000).
+
+-define(RCTX_RPC, #rpc_worker{from = {self(), make_ref()}}).
+-define(RCTX_COORDINATOR, #coordinator{
+ method = 'GET', path = <<"/foo/_all_docs">>, mod = chttp_db, func = db_req
+}).
+-define(RCTX_CHANGES_COORDINATOR, #coordinator{
+ method = 'GET', path = <<"/foo/_changes">>, mod = chttp_db, func = handle_changes_req
+}).
+
+rctx_gen() ->
+ rctx_gen(#{}).
+
+rctx_gen(Opts0) ->
+ DbnameGen = one_of([<<"foo">>, <<"bar">>, ?tempdb]),
+ UsernameGen = one_of([<<"user_foo">>, <<"user_bar">>, <<"adm">>]),
+ TypeGen = one_of([?RCTX_RPC, ?RCTX_COORDINATOR, ?RCTX_CHANGES_COORDINATOR]),
+ R = fun() -> rand:uniform(?RCTX_RANGE) end,
+ R10 = fun() -> 3 + rand:uniform(round(?RCTX_RANGE / 10)) end,
+ Occasional = one_of([0, 0, 0, 0, 0, R]),
+ Nonce = one_of(["9c54fa9283", "foobar7799" | lists:duplicate(10, fun nonce/0)]),
+ Base = #{
+ dbname => DbnameGen,
+ db_open => R10,
+ docs_read => R,
+ docs_written => Occasional,
+ get_kp_node => R10,
+ get_kv_node => R,
+ nonce => Nonce,
+ pid_ref => {self(), make_ref()},
+ ioq_calls => R,
+ rows_read => R,
+ type => TypeGen,
+ username => UsernameGen,
+ %% Hack because we need to modify both fields
+ '_do_changes' => true
+ },
+ Opts = maps:merge(Base, Opts0),
+ couch_srt_entry:from_map(
+ maps:fold(
+ fun
+ %% Hack for changes because we need to modify both
+ %% changes_processed (rows_read) and changes_returned but the
+ %% latter must be <= the former
+ ('_do_changes', V, Acc) ->
+ case V of
+ true ->
+ Processed = R(),
+ Returned = (one_of([0, 0, 1, Processed, rand:uniform(Processed)]))(),
+ maps:put(
+ rows_read,
+ Processed,
+ maps:put(changes_returned, Returned, Acc)
+ );
+ _ ->
+ Acc
+ end;
+ (K, F, Acc) when is_function(F) ->
+ maps:put(K, F(), Acc);
+ (K, V, Acc) ->
+ maps:put(K, V, Acc)
+ end,
+ #{},
+ Opts
+ )
+ ).
+
+rctxs() ->
+ rctxs(?RCTX_COUNT).
+
+rctxs(Count) when is_integer(Count) andalso Count >= 1 ->
+ [rctx_gen() || _ <- lists:seq(1, Count)].
+
+jrctx(Rctx) ->
+ JRctx = couch_srt_entry:to_json(Rctx),
+ case couch_srt_logger:should_truncate_reports() of
+ true ->
+ maps:filter(fun(_K, V) -> V > 0 end, JRctx);
+ false ->
+ JRctx
+ end.
+
+nonce() ->
+ couch_util:to_hex(crypto:strong_rand_bytes(5)).
+
+one_of(L) ->
+ fun() ->
+ case lists:nth(rand:uniform(length(L)), L) of
+ F when is_function(F) ->
+ F();
+ N ->
+ N
+ end
+ end.
+
+enable_default_logger_matchers() ->
+ DefaultMatchers = [
+ all_coordinators,
+ all_rpc_workers,
+ docs_read,
+ rows_read,
+ docs_written,
+ long_reqs,
+ changes_processed,
+ ioq_calls
+ ],
+ lists:foreach(
+ fun(Name) ->
+ config:set(?CSRT_MATCHERS_ENABLED, atom_to_list(Name), "true", false)
+ end,
+ DefaultMatchers
+ ).
diff --git a/src/couch_stats/src/couch_stats.app.src b/src/couch_stats/src/couch_stats.app.src
index a54fac7349f..fc1938045a7 100644
--- a/src/couch_stats/src/couch_stats.app.src
+++ b/src/couch_stats/src/couch_stats.app.src
@@ -13,8 +13,11 @@
{application, couch_stats, [
{description, "Simple statistics collection"},
{vsn, git},
- {registered, [couch_stats_aggregator, couch_stats_process_tracker]},
- {applications, [kernel, stdlib]},
+ {registered, [
+ couch_stats_aggregator,
+ couch_stats_process_tracker
+ ]},
+ {applications, [kernel, stdlib, couch_log]},
{mod, {couch_stats_app, []}},
{env, []}
]}.
diff --git a/src/couch_stats/src/couch_stats.erl b/src/couch_stats/src/couch_stats.erl
index 29a4024491f..9ce03bb2fb0 100644
--- a/src/couch_stats/src/couch_stats.erl
+++ b/src/couch_stats/src/couch_stats.erl
@@ -49,6 +49,7 @@ increment_counter(Name) ->
-spec increment_counter(any(), pos_integer()) -> response().
increment_counter(Name, Value) ->
+ couch_srt:maybe_track_local_counter(Name, Value),
case couch_stats_util:get_counter(Name, stats()) of
{ok, Ctx} -> couch_stats_counter:increment(Ctx, Value);
{error, Error} -> {error, Error}
diff --git a/src/docs/images/csrt-sample-workload.png b/src/docs/images/csrt-sample-workload.png
new file mode 100644
index 00000000000..4accf73dbc3
Binary files /dev/null and b/src/docs/images/csrt-sample-workload.png differ
diff --git a/src/docs/src/api/server/csrt.rst b/src/docs/src/api/server/csrt.rst
new file mode 100644
index 00000000000..5c816b91fb9
--- /dev/null
+++ b/src/docs/src/api/server/csrt.rst
@@ -0,0 +1,345 @@
+.. Licensed under the Apache License, Version 2.0 (the "License"); you may not
+.. use this file except in compliance with the License. You may obtain a copy of
+.. the License at
+..
+.. http://www.apache.org/licenses/LICENSE-2.0
+..
+.. Unless required by applicable law or agreed to in writing, software
+.. distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+.. WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+.. License for the specific language governing permissions and limitations under
+.. the License.
+
+.. _api/server/csrt:
+
+============================================
+``/_active_resources/_match/{matcher-name}``
+============================================
+
+.. versionadded:: 3.5.1
+
+Find active processes (being tracked in CSRT) using a declarative JSON querying syntax.
+You can learn more about Couch Stats Resource Tracker (CSRT) :doc:`here `.
+The query passed to the endpoint can be in following forms
+
+* :ref:`group_by