Skip to content

Mango match_failures/2 function#5858

Open
jcoglan wants to merge 16 commits into
apache:mainfrom
neighbourhoodie:mango-match-failures
Open

Mango match_failures/2 function#5858
jcoglan wants to merge 16 commits into
apache:mainfrom
neighbourhoodie:mango-match-failures

Conversation

@jcoglan

@jcoglan jcoglan commented Jan 21, 2026

Copy link
Copy Markdown
Contributor

Overview

This PR represents work so far on a version of mango_selector:match/2 than can return a representation of how the input fails to match the selector, rather than just a boolean. There are a few commits of developing this behaviour incrementally before everything "snaps into place" to get us code paths that can produce failure descriptions, and retain the original boolean behaviour that avoids creating a lot of ephemeral failure lists, and can short-circuit on compound operators.

The rough steps here are as follows; I'm retaining all these commits for now in case we want to compare different designs for code complexity and performance.

  • Add a lot of unit tests to mango_selector that check every match operator and its negation. This is necessary since various compound operators like $or and $allMatch have surprising edge case behaviour on empty lists and we need to make sure this is not broken. Mostly these tests check that if selector S returns true on an input then { "$not": S } returns false and vice versa. There are some exceptions to this due to how $or works and how $and and $or are normalised.
  • Replace the existing implementation with one where every operator returns a possibly-empty list of failures, instead of a boolean. match_int then converts this to a bool on the way out.
  • Replace the Cmp argument to everything with a ctx record that contains cmp, as well as other things needed for failure generation, e.g. the path to the current value, whether matching is currently negated, etc.
  • Try to fix $allMatch and $elemMatch normalisation to avoid the complexity that comes from not being able to apply DeMorgan to them, due to $allMatch being defined to return false for empty lists. This is a breaking change that is reverted later since we decided to retain existing behaviour above all.
  • Implement negation handling, where the presence of a $not has to be communicated to nodes lower down the tree in order to produce good failure messages. Not all negation can be normalised out of the tree and so all operators need to handle being negated during evaluation.
  • Finally, collapse all the complexity into an implementation that supports both the old and new behaviours.

The idea in the design I've ended up with that tries to minimise both complexity and runtime cost is:

  • Add #ctx.verbose which indicates whether a detailed failure description is wanted.
  • Keep the original implementations of all operators as the response when passed #ctx{verbose=false}, i.e. when only a boolean result is needed.
  • For all leaf operators, the #ctx{verbose=true} case can be implemented by calling the #ctx{verbose=false} case, and creating a #failure record if this returns false.
  • #ctx{verbose=false} cases do not need to deal with #ctx{negate=true}; they continue to return their original result and let $not invert it. We only need special handling of #ctx{negate=true} in verbose mode, where the $not operator passes its effect down via the #ctx. This reduces the number of cases each operator has to deal with to basically: non-verbose mode, and positive and negative verbose cases.
  • For compound operators, special code is needed to gather up the failures from internal selectors and deal with edge cases in a way that's consistent with the original implementation.
  • #ctx.path is only updated in #ctx{verbose=true} code paths so this expense is avoided in non-verbose mode. Path items are added to the front of this list as that's cheaper than doing Path ++ [Item]; we would reverse these before returning to a client.
  • #failure records retain a #ctx from where they can access the path and negation state, in order to generate a good human-readable error message later on.
  • The tests are updated to make sure that both verbose modes return consistent results, i.e. if verbose=false returns true, then verbose=true returns [], and if the former returns false, the latter gives a non-empty list. These are all passing.

Testing recommendations

We should benchmark this in its current version, and both verbose modes of this version, against a substantial indexing workload to look for performance regressions. Or, if performance is equivalent in both verbose modes, we can remove a lot of redundancy by removing the verbose flag entirely.

Related Issues or Pull Requests

Checklist

  • Code is written and works correctly
  • Changes are covered by tests
  • Any new configurable parameters are documented in rel/overlay/etc/default.ini
  • Documentation changes were made in the src/docs folder
  • Documentation changes were backported (separated PR) to affected branches

@nickva

nickva commented Jan 21, 2026

Copy link
Copy Markdown
Contributor

That's a nice approach using a context record in place of the Cmp arg.

All the extra eunit tests are awesome. If you want, could even put them in a separate PR and we'd merge them right away. It would make it easier to review subsequent PRs because we can obviously see all the existing tests pass.

@jcoglan
jcoglan force-pushed the mango-match-failures branch 3 times, most recently from 5076677 to 7f8f999 Compare February 4, 2026 09:57
@jcoglan
jcoglan force-pushed the mango-match-failures branch 2 times, most recently from 69fe09f to fa65b74 Compare February 11, 2026 14:12
@jcoglan
jcoglan changed the base branch from main to 3.5.x February 13, 2026 09:38
@jcoglan jcoglan mentioned this pull request Feb 13, 2026
6 tasks
@jcoglan
jcoglan force-pushed the mango-match-failures branch from fa65b74 to 1c23788 Compare February 13, 2026 11:39
@jcoglan
jcoglan changed the base branch from 3.5.x to main February 13, 2026 11:40
@jcoglan jcoglan mentioned this pull request Feb 20, 2026
6 tasks
@janl janl added this to the 3.8 milestone Feb 27, 2026
@jcoglan
jcoglan force-pushed the mango-match-failures branch from 1c23788 to 82142aa Compare March 13, 2026 16:31
@jcoglan
jcoglan force-pushed the mango-match-failures branch from 82142aa to 74d02e0 Compare March 23, 2026 14:28
@jcoglan
jcoglan marked this pull request as ready for review March 23, 2026 14:31
@jcoglan
jcoglan force-pushed the mango-match-failures branch 3 times, most recently from 407b81a to e21b4b8 Compare April 9, 2026 12:00
@janl

janl commented Apr 22, 2026

Copy link
Copy Markdown
Member

Nice work James. I’ve done some benchmarks with the bulkbench script to see what this gives us:

   no vdu: real 0m18.122s
   js vdu: real 0m55.438s (3.06x)
mango vdu: real 0m21.722s (1.19x)

@jcoglan
jcoglan force-pushed the mango-match-failures branch from e21b4b8 to ba11d38 Compare April 22, 2026 13:15

@nickva nickva left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a large PR and I don't know mango very well so I did the best I could. Sorry it took a while to get to. Those are just a few things I noticed at first, I haven't run it yet locally to play with.

Comment thread src/mango/src/mango_selector.erl Outdated
Comment thread src/couch/src/couch_query_servers.erl Outdated
ok ->
ok;
{[{<<"forbidden">>, Message}, {<<"failures">>, Failures}]} ->
throw({forbidden, Message, Failures});

@nickva nickva Apr 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're changing the forbidden tuple shape. Make sure to check all the places which handle {forbidden, _} and now they may have to handle the triple-arg version. I noticed fabric_doc_update needs to handle this src/fabric/src/fabric_doc_update.erl

We should also call out and see if this will affect online cluster upgrades (new worker nodes throwing it and old coordinator nodes getting function clause errors). It maybe be fine, just needs an extra careful look at it.

Comment thread src/mango/src/mango_native_proc.erl
Comment thread src/mango/src/mango_native_proc.erl Outdated
Comment thread src/mango/src/mango_native_proc.erl Outdated
case mango_selector:has_allowed_fields(Selector, [<<"newDoc">>, <<"oldDoc">>]) of
false ->
Msg =
<<"'validate_doc_update' may only contain 'newDoc' and 'oldDoc' as top-level fields">>,

@nickva nickva Apr 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder when this would fire, would it be on every time we attempt to insert a doc we'd crash the prompt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We added this b/c @janl encountered a really confusing error during testing due to typing "selector": { "x": 0 } instead of "selector": { "newDoc": { "x": 0 } }. If you omit newDoc, the resulting validation failure is confusing, and we thought it better to alert the user that they are probably making a mistake instead of returning an empty result set.

@nickva nickva May 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I meant is that it seems we are not validating the inserted document here, instead we're validating the VDU itself

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not validating the design doc when it is uploaded; this would not be consistent with the existing behaviour of PUT /db/_design/doc. You are currently allowed to upload a JS design doc with invalid code in it. Instead, this triggers when normal doc writes occur; if the Mango VDU does not make sense then we return an error about that, rather than giving the user a misleading error implying their doc is invalid.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should consider validating on ddoc-write here, it feels like the better behaviour

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are currently allowed to upload a JS design doc with invalid code in it. Instead, this triggers when normal doc writes occur; if the Mango VDU does not make sense then we return an error about that, rather than giving the user a misleading error implying their doc is invalid.

But if we could we would validate the JS VDU during insertion. It's a bit how we check compilation during ddoc inserts "does it even compile? -if not, we fail the ddoc insertion to start with". If we can do that early with Mango VDUs, we should, then we don't have to worry about misleading the user later because by the virtue of rejecting invalid VDUs we will only have deal with invalid user documents as all Mango VDU will be valid

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, I thought the last time I checked this that CouchDB allows writing ddocs with malformed (i.e. does not even parse) JS code in them, but I just checked and this is not the case. I can see about moving this check to when the Mango ddoc is uploaded.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I wasn't quite right. map functions are checked when the ddoc is updated, but validate_doc_update is not. The compilation error is surfaced when other docs are written and we attempt to invoke the VDU function.

$ cdb '/asd/_design/foo' -X PUT -d '{ "validate_doc_update": "function (doc) {" }'
{"ok":true,"id":"_design/foo","rev":"1-416ed8128d308d5abc6b4745a64394e5"}

$ cdb '/asd/doc' -X PUT -d '{}'
{"error":"compilation_error","reason":"SyntaxError: unexpected token in expression: '' (function (doc) {)"}

Personally I think this behaviour should be consistent between JS and Mango VDUs, and if we want to preemptively validate the v-d-u field we should do this for both backends.

Comment thread src/mango/src/mango_selector.erl Outdated
@jcoglan
jcoglan force-pushed the mango-match-failures branch from ba11d38 to 557d5ed Compare May 21, 2026 15:49
@jcoglan

jcoglan commented May 26, 2026

Copy link
Copy Markdown
Contributor Author

I think the only thing left to sort out here is how the errors are passed back to the HTTP/fabric frontend and back to the client, i.e. the forbidden structure. What I have done here is a hack b/c I found it hard to figure out how I was supposed to implement this, and this was something I managed to get working just enough to have a client interact with the functionality. I could use some guidance on doing it properly.

@jcoglan
jcoglan force-pushed the mango-match-failures branch from 557d5ed to 6107913 Compare May 26, 2026 13:06
@jcoglan

jcoglan commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

Been looking into how the errors should be passed from mango_native_proc back to the client and how this affects the use of {forbidden, _} tuples.

Many places in the codebase throw or catch (or otherwise create/use) the structure {forbidden, Msg}. forbidden is an atom and Msg is usually a binary, though it can be a string, a list (e.g. ["by_node not an object"]) or a tuple (e.g. chttpd_auth_request throws {forbidden, {Error, Reason}}).

The specific code path for VDUs is that the actual VDU engine (a JS process or mango_native_proc) throws a JS object like { forbidden: Msg }, which is {[{<<"forbidden">>, Msg}]} in Erlang. couch_query_servers:validate_doc_update() catches {[{<<"forbidden">>, Msg}]} and converts it into {forbidden, Msg} i.e. a normal Erlang pair with forbidden as an atom. This is re-thrown and passed back through fabric and other request-handling machinery until it ends up in the HTTP layer.

chttpd has two handlers relevant here:

  • error_info({forbidden, Msg}) produces a 403 response with { "error": "forbidden", "reason": Msg }
  • error_info({forbidden, Error, Msg}) produces a 403 response with { "error": Error, "reason": Msg }

The problem we have is: how to send the list of failure objects from mango_native_proc back to the client. The current design means it's safe for couch_query_servers to throw a 3-tuple and chttpd will be able to handle it, so there's no problem there vis-a-vis rolling upgrades. However, this only lets us change the error field in the response, whereas we probably want to keep "error": "forbidden" as the response for failed Mango VDU validations.

If we have couch_query_servers throw the 2-tuple {forbidden, {[{<<"failures">>, Failures}]}} (Failures is the list of validation failures) then this ends up putting the failures in the reason field of the response, e.g.:

{
  "error": "forbidden",
  "reason": {
    "failures": [
      {
        "path": ["newDoc", "ok"],
        "message": "must be present"
      }
    ]
  }
}

Technically, this is not a breaking change; it was already legal for a JS VDU to throw { forbidden: { failures: [...] } } and this would end producing the response above. However there has been concern that making reason not be a string would be surprising to most users and could break existing programs, so should be considered a breaking change.

If we want to retain reason as a string and put the failures list somewhere else then we need to invent some other way for couch_query_servers to throw an error message and failure list, and put these into the HTTP response. One such way is to make it throw {forbidden, {failures, Failures}}, and then make chttpd:send_error() inspect ReasonStr. If it's {failures, Failures} then we make it put this in the response:

  "reason": "document is not valid",
  "failures": Failures

Otherwise we make it just emit "reason": ReasonStr as it currently does. The problem with this is that it couples some generic HTTP error handling code to the specifics of VDUs, which seems like a bad idea. We could weaken this coupling by making couch_query_servers throw a more complex object containing the reason and any additional fields, and make send_error() emit all that data into the response, i.e. it lets ReasonStr be either a string, or a set of JSON fields. This removes the coupling but still adds some complexity to how errors are communicated.

@jcoglan

jcoglan commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

The simplest thing to do is to have the response include "reason": { "failures": [...] } since this has no compatibility concerns and is easiest to implement. However we possibly consider this a breaking change to the reason field.

Returning "reason": "message", "failures": [...] instead probably requires us to ship a version where chttpd understands more complex error structure first, and then later ship a couch_query_servers that emits said structure, to avoid problems with rolling upgrades where couch_query_servers emits something that chttpd does not understand.

@jcoglan

jcoglan commented May 28, 2026

Copy link
Copy Markdown
Contributor Author

The occurrences of {forbidden, _} in the codebase that I have been able to determine are actually involved in the path for updating a doc are:

  • chttpd:error_info({forbidden, Msg})
  • couch_query_servers:validate_doc_update(Db, DDoc, EditDoc, DiskDoc, Ctx, SecObj)
  • fabric_doc_update:force_reply(Doc, [FirstReply | _] = Replies, {Health, W, SWS, Acc}) and its calls to check_forbidden_msg(Replies)

The last one is not obvious because the uses of {forbidden, _} occur only on edge cases in quorum logic and won't be hit if all nodes return the same reply. Nevertheless, the logic here looks as though it depends on the 2-tuple {forbidden, Msg} but never uses the 2nd item in any way other than returning/throwing it without inspecting its content. The relevant function on main is here:

force_reply(Doc, [FirstReply | _] = Replies, {Health, W, SWS, Acc}) ->
case update_quorum_met(W, Replies, SWS) of
{true, Reply} ->
% corner case new_edits:false and vdu: [noreply, forbidden, noreply]
case check_forbidden_msg(Replies) of
{forbidden, ForbiddenReply} ->
{Health, W, SWS, [{Doc, ForbiddenReply} | Acc]};
false ->
{Health, W, SWS, [{Doc, Reply} | Acc]}
end;
false ->
case [Reply || {ok, Reply} <- Replies] of
[] ->
% check if all errors are identical, if so inherit health
case lists:all(fun(E) -> E =:= FirstReply end, Replies) of
true ->
CounterKey = [fabric, doc_update, errors],
couch_stats:increment_counter(CounterKey),
{Health, W, SWS, [{Doc, FirstReply} | Acc]};
false ->
CounterKey = [fabric, doc_update, mismatched_errors],
couch_stats:increment_counter(CounterKey),
case check_forbidden_msg(Replies) of
{forbidden, ForbiddenReply} ->
{Health, W, SWS, [{Doc, ForbiddenReply} | Acc]};
false ->
{error, W, SWS, [{Doc, FirstReply} | Acc]}
end
end;
[AcceptedRev | _] ->
CounterKey = [fabric, doc_update, write_quorum_errors],
couch_stats:increment_counter(CounterKey),
NewHealth =
case Health of
ok -> accepted;
_ -> Health
end,
{NewHealth, W, SWS, [{Doc, {accepted, AcceptedRev}} | Acc]}
end
end.

This leans us toward sticking with couch_query_servers throwing a 2-tuple, not a 3-tuple, and putting {[{<<"failures">>, [...]}]} as the second item. This causes the HTTP response to look like { "error": "forbidden", "reason": { "failures": [...] } }.

We could choose to further tweak the HTTP response, but this would need changes to chttpd deployed before changes to couch_query_servers in order for rolling upgrades to go smoothly.

@jcoglan
jcoglan force-pushed the mango-match-failures branch 3 times, most recently from 247d9ac to 64038cd Compare May 28, 2026 14:56
@jcoglan
jcoglan force-pushed the mango-match-failures branch from e80e0a0 to 45dee90 Compare May 28, 2026 15:20
@nickva

nickva commented May 30, 2026

Copy link
Copy Markdown
Contributor

This leans us toward sticking with couch_query_servers throwing a 2-tuple, not a 3-tuple, and putting {[{<<"failures">>, [...]}]} as the second item. This causes the HTTP response to look like { "error": "forbidden", "reason": { "failures": [...] } }.

I think that could work and it's probably the cleanest solution but would be a minor incompatibility. During online cluster upgrade we could also take the approach perhaps that there won't yet be too many existing mango vdus. Unless the cluster is left in that intermediate state for a long while and the user starts exercising the new feature.

@jcoglan
jcoglan force-pushed the mango-match-failures branch 8 times, most recently from e2e042f to 724835f Compare June 5, 2026 10:47

@nickva nickva left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great. Almost there. Noticed a few more things after skimming through the PR. Most are minor some may be things I missed or didn't read closely enough.

Comment thread src/mango/src/mango_selector.erl Outdated
Comment thread src/mango/src/mango_selector.erl Outdated
format_op(Op, _, empty_list, _) ->
io_lib:format("operator $~p was invoked with an empty list", [Op]);
format_op(Op, _, bad_value, [Value]) ->
io_lib:format("operator $~p was invoked with a bad value: ~s", [Op, jiffy:encode(Value)]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In couch_util we use json_encode(V) -> jiffy:encode(V, [force_utf8]). I am not sure if we'd want that here as well. Would we have broken surrogate pairs or broken bytes and such? Probably not

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd assumed that returning a JSON representation to the client would make more sense than returning internal Erlang representation. Is there something I should do instead here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just meant that we can reuse the json_encode macro or the util function since it forces utf8 encoding and here we don't. It's mostly for consistency and belt and suspenders.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, sorry for misunderstanding. I'll replace these calls with json_encode.

Comment thread src/mango/src/mango_native_proc.erl Outdated
Comment thread src/couch_mrview/src/couch_mrview.erl
Comment thread src/couch_mrview/src/couch_mrview.erl Outdated
try Views =/= [] andalso couch_query_servers:get_os_process(Lang) of
false ->
ok;
try couch_query_servers:get_os_process(Lang) of

@nickva nickva Jun 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One change here to watch out for is that previously if ddoc didn't have views we didn't check out an OS process. Now we always do. Could we first check if we have views or a VDU and only then go into get_os_process()? Not a huge deal but OS processes are a limited resource and it would nice not have get them from the proc manager gen_server on every ddoc update if doesn't have anything to validate

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I'll move these checks around so we avoid grabbing an OS process if it's not needed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant that we'd first see if we'd even need an os process and only then try to get it. I added a note about in a new review set of comments.

proc_prompt(Proc, [<<"add_fun">>, FunctionSource]),
case FunctionType of
validate_doc_update ->
proc_prompt(Proc, [<<"validate_fun">>, FunctionSource]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What will happen if query language is Erlang? Do we get an immediate 500 or some nicer invalid language 400 response?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only validate the function if language is javascript or query; couch_mrview:validate() explicitly ends by accepting anything that's not recognised, i.e. you can save any design doc you like if it contains content CouchDB does not natively understand. This is very convenient for enabling other software to integrate with CouchDB.

We could add validation for erlang functions but that feels like expanding the scope of this PR into things that aren't strictly related and I'm not sure how to implement.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense as yeah. Wonder if we have any tests for it. Say if add a test for a vdu written in Lua. We shouldn't crash or try to validate it.


@tag :with_db
test "invalid JavaScript VDU is detected on doc update", context do
Couch.put("/_node/_local/_config/couchdb/validate_vdu", body: "\"false\"")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do elixir tests automatically reset this back to true when done? (they may, but if not we should cleanup after we're done).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe they do; I had to make sure each test that depends on this value explicitly sets it to the appropriate thing otherwise tests would fail due to random execution order. Is there some other mechanism I should be using to manipulate the config?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look at may be using set_config(...) from CouchTestCase. For example I found this in one of the tests:

  @tag :with_partitioned_db
  test "partitioned query with query server config set", context do
    db_name = context[:db_name]
    create_partition_docs(db_name)
    create_index(db_name, ["value"])

    # this is to test that we bypass partition_query_limit for mango
    set_config({"query_server_config", "partition_query_limit", "1"})

And then looking in set_config

  def set_config({section, key, value}) do
    existing = set_config_raw(section, key, value)

    on_exit(fn ->
      Enum.each(existing, fn {node, prev_value} ->
        if prev_value != "" do
          url = "/_node/#{node}/_config/#{section}/#{key}"
          headers = ["X-Couch-Persist": "false"]
          body = :jiffy.encode(prev_value, [:use_nil])
          resp = Couch.put(url, headers: headers, body: body)
          assert resp.status_code == 200
        else
          url = "/_node/#{node}/_config/#{section}/#{key}"
          headers = ["X-Couch-Persist": "false"]
          resp = Couch.delete(url, headers: headers)
          assert resp.status_code == 200
        end
      end)
    end)
  end

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, will fix all the calls in this test suite.

@jcoglan
jcoglan force-pushed the mango-match-failures branch from 724835f to 0788d06 Compare June 11, 2026 13:19

@nickva nickva left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great progress but I think we missed a few corner cases.

Added a few new comments and in other cases responded to previous ones.

I'll add below a bash script with a few test cases I played around with locally illustrating various issues mentioned in the comments.

The main thing is I think we missed a few test corners related to negations and lists. If we say baz : not elemMatch eq 1 and create a baz: 5 we should allow that document it seems?

The other important thing is that we still end up killing the OS process on validation failures. That should not happen regularly as it could dos a server. That's the errors below when 500 are generated usually.

The third issue is a discussion point perhaps. I think it would be better if we never allow invalid Mango VDUs to be added. The config toggle should affect only javascript ones for compatibly. Compatibility doesn't affect Mango VDU so we should be able always protect the user from shooting themselves in the foot.

#!/bin/sh

do_reset()
{
  printf '%s' "Press a key to continue..."; IFS= read -rsn 1 <"/dev/tty"; printf '\n'
  http delete $DB/db
  http put $DB/db
}

do_reset

http PUT $DB/db/_design/dd language=query \
  validate_doc_update:='{"newDoc": {"baz": {"$not": {"$elemMatch": {"$eq": 1}}}}}'
printf "Num not array but should match not be forbidden\n"
http PUT $DB/db/doc baz:=5

# {
#     "error": "forbidden",
#     "reason": {
#         "failures": [
#             {
#                 "message": "operator $allMatch was invoked with a bad value: 5",
#                 "path": [
#                     "newDoc",
#                     "baz"
#                 ]
#             }
#         ]
#     }
# }


do_reset


http PUT $DB/db/_design/dd language=query \
  validate_doc_update:='{"newDoc": {"foo": {"$not": {"$keyMapMatch": {"$eq": "bad"}}}}}'
printf "Num contains no 'bad' string so should be good, yet...\n"
http PUT $DB/db/doc foo:=42

# {
#     "error": "forbidden",
#     "reason": {
#         "failures": [
#             {
#                 "message": "operator $keyMapMatch was invoked with a bad value: 42",
#                 "path": [
#                     "newDoc",
#                     "foo"
#                 ]
#             }
#         ]
#     }
# }


do_reset

printf 'abc starting field names only pls'

http put $DB/db/_design/dd language=query \
  validate_doc_update:='{"newDoc": {"$keyMapMatch": {"$regex": "^[a-c]+$"}}}'
echo "Empty field add"
http put $DB/db/doc --raw '{"": 1}'

# 500 and proc dies. Proc shouldn't die
# lid_field_name,<<>>}}
#   last msg: redacted
#      state: {st,[],[{<<"_design/valid">>,{[{[<<"newDoc">>,<<"foo">>],{[{<<"$not">>,{[{<<"$keyMapMatch">>,{[{[],{[{<<"$eq">>,<<"bad">>}]}}]}}]}}]}}]}},{<<"_design/keys">>,{[{[<<"newDoc">>],{[{<<"$keyMapMatch">>,{[{[],{[{<<"$regex">>,<<"^[a-c]+$">>}]}}]}}]}}]}}],5000}
#     extra: [<0.63784.0>,[{gen,do_call,4,[{file,"gen.erl"},{line,259}]},{gen_server,call,2,[{file,"gen_server.erl"},{line,400}]},{mango_native_proc,prompt,2,[{file,"src/mango_native_proc.erl"},{line,49}]},{couch_query_servers,proc_prompt,2,[{file,"src/couch_query_servers.erl"},{line,686}]},{couch_query_servers,with_ddoc_proc,3,[{file,"src/couch_query_servers.erl"},{line,659}]},{couch_query_servers,validate_doc_update,6,[{file,"src/couch_query_servers.erl"},{line,488}]},{couch_db,'-validate_doc_update_int/3-lc$^0/1-0-',5,[{file,"src/couch_db.erl"},{line,998}]},{couch_db,'-validate_doc_update_int/3-fun-1-',3,[{file,"src/couch_db.erl"},{line,1002}]}]]
# [info] 2026-07-17T22:41:23.202227Z node1@127.0.0.1 <0.301.0> -------- couch_proc_manager <0.60663.0> died {bad_return_value,{mango_error,mango_util,{invalid_field_name,<<>>}}}

do_reset

printf 'abc starting field names only pls adding dotted field invalid path reported'
http put $DB/db/_design/dd language=query \
  validate_doc_update:='{"newDoc": {"$keyMapMatch": {"$regex": "^[a-c]+$"}}}'
http PUT $DB/db/doc --raw '{"a.b": 1}'

# NOTE: there is [a, b] path just an a.b field
# {
#     "message": "must match the pattern '^[a-c]+$'",
#     "path": [
#         "newDoc",
#         "a",
#         "b"
#     ]
# }


### These are compile checks enable insert time VDU checks ###

printf 'Turn on VDU compule check for these\n'

do_reset

printf 'Should get compilation error or something on newDoc bad name \n'
http PUT $DB/db/_design/dd language=query validate_doc_update:='{"newDoc.": {"type": "quux"}}'

# 500 error and proc dies. Proc should not die
# rexi_server: from: node1@127.0.0.1(<0.67839.0>) mfa: fabric_rpc:update_docs/3 exit:{{bad_return_value,{mango_error,mango_util,{invalid_field_name,<<"newDoc.">>}}}

do_reset

printf 'Same thing but with badop\n'
http PUT $DB/db/_design/dd language=query validate_doc_update:='{"newDoc": {"$bogusOp": 1}}'

# This should be a 4xx-ish error not a 500?
# HTTP/1.1 500 Internal Server Error
# {
#     "error": "invalid_operator",
#     "reason": "$bogusOp"
# }

printf 'Done\n'

Comment thread rel/overlay/etc/default.ini Outdated
; contain a well-formed JavaScript function, and for `query` design docs it
; must contain a Mango selector that is correctly structured to validate
; document updates.
validate_vdu = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't typically have live values in default.ini they should commented out. Since Mango VDUs are new (not released yet), we can probably always validate them. There is no reason to let uses shoot themselves in the foot. For JS, we'd keep it false since it's a compatibility issue. Maybe set up to be flipped in 4.x versions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this, it was like this for me to test things and shouldn't have been committed. I'll revert it to ;validate_vdu = false like it should be.

Comment thread src/couch_mrview/src/couch_mrview.erl Outdated
try Views =/= [] andalso couch_query_servers:get_os_process(Lang) of
false ->
ok;
try couch_query_servers:get_os_process(Lang) of

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I'll move these checks around so we avoid grabbing an OS process if it's not needed.

I think we'd instead not want to call get_os_process() at all if we don't have views and won't be checking vdus (because we may have them but they are js vdus and we don't want to validate them because of the config)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @janl caught this earlier and I had some work in progress on my laptop to fix it so we only grab an OS process if we have views or VDU work that needs doing. Will push along with the other fixes I'm working on.

Comment thread src/mango/src/mango_native_proc.erl Outdated
Error -> {reply, {error, Error}, St}
end
catch
throw:{mango_error, mango_selector, Error} ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could normalize throw other error besides {mango_error, mango_selector, _}? For example

{ok, F} = mango_util:parse_field(Field),
(norm_fields) will throw norm_fields. Basically don't hardcode the module name, maybe something like throw:{mango_error, _Mod, Error} could work

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've ended up putting more specific clauses here to catch invalid field and operator errors, which come from different places. I'm open to suggestions on style though; I wasn't sure of the best way to catch these and turn them into 400s rather than 500s. Will push along with everything else I'm fixing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking instead more specific we could make it more general so we'd match on _ for module throw:{mango_error, _, Error} now we'd catch errors from mango_util, selector and all the future modules

proc_prompt(Proc, [<<"add_fun">>, FunctionSource]),
case FunctionType of
validate_doc_update ->
proc_prompt(Proc, [<<"validate_fun">>, FunctionSource]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing to check is that we may throw other errors besides {compilation_error, _} and {os_process_error, _}. In other words we'd ensure that we things like invalid_operator or something still either surfaces as a {compilation_error, _} or we handle the extra cases in the catch part.

Comment thread src/mango/src/mango_selector.erl Outdated
format_op(Op, _, empty_list, _) ->
io_lib:format("operator $~p was invoked with an empty list", [Op]);
format_op(Op, _, bad_value, [Value]) ->
io_lib:format("operator $~p was invoked with a bad value: ~s", [Op, jiffy:encode(Value)]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just meant that we can reuse the json_encode macro or the util function since it forces utf8 encoding and here we don't. It's mostly for consistency and belt and suspenders.


@tag :with_db
test "invalid JavaScript VDU is detected on doc update", context do
Couch.put("/_node/_local/_config/couchdb/validate_vdu", body: "\"false\"")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look at may be using set_config(...) from CouchTestCase. For example I found this in one of the tests:

  @tag :with_partitioned_db
  test "partitioned query with query server config set", context do
    db_name = context[:db_name]
    create_partition_docs(db_name)
    create_index(db_name, ["value"])

    # this is to test that we bypass partition_query_limit for mango
    set_config({"query_server_config", "partition_query_limit", "1"})

And then looking in set_config

  def set_config({section, key, value}) do
    existing = set_config_raw(section, key, value)

    on_exit(fn ->
      Enum.each(existing, fn {node, prev_value} ->
        if prev_value != "" do
          url = "/_node/#{node}/_config/#{section}/#{key}"
          headers = ["X-Couch-Persist": "false"]
          body = :jiffy.encode(prev_value, [:use_nil])
          resp = Couch.put(url, headers: headers, body: body)
          assert resp.status_code == 200
        else
          url = "/_node/#{node}/_config/#{section}/#{key}"
          headers = ["X-Couch-Persist": "false"]
          resp = Couch.delete(url, headers: headers)
          assert resp.status_code == 200
        end
      end)
    end)
  end

proc_prompt(Proc, [<<"add_fun">>, FunctionSource]),
case FunctionType of
validate_doc_update ->
proc_prompt(Proc, [<<"validate_fun">>, FunctionSource]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense as yeah. Wonder if we have any tests for it. Say if add a test for a vdu written in Lua. We shouldn't crash or try to validate it.

Comment thread src/couch_mrview/src/couch_mrview.erl Outdated
try Views =/= [] andalso couch_query_servers:get_os_process(Lang) of
false ->
ok;
try couch_query_servers:get_os_process(Lang) of

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant that we'd first see if we'd even need an os process and only then try to get it. I added a note about in a new review set of comments.

jcoglan added 16 commits July 20, 2026 10:11
Rather than returning a boolean to indicate just success or failure,
`mango_selector:match/2` now returns a list of "failures" describing the
ways in which the selector failed to match the input. If this list is
empty, the match was a success.
We will need to pass other things around between `match` calls as well
the current `Cmp` function, so here we replace this argument with a
`#ctx` record that intially just contains a `cmp` field.
To give detailed feedback to the caller, the `#ctx` argument to
`mango_selector:match/3` now records the path that was taken to reach
each value, and this path is added to the `#failure` records.

Each path segment is either a binary, if it represents an object
property, or an integer if it represents an array index. Items are
pushed on the front of `#ctx.path` as this is faster than pushing onto
the back of a list. This list can then be reversed once the final list
of failures has been generated, before the failures are presented to the
caller.
Collecting detailed `#failure` records rather than a boolean true/false
when evaluating selectors imposes a performance penalty, so we would
like to only do this when a selector is used for a VDU, not when it is
used for indexing/filtering.

To this end we introduce "verbose" mode signalled via the `#ctx.verbose`
field, and each branch of `mango_selector:match/3` now has 3 distinct
versions:

- `#ctx{verbose = false}`: this is the original version that returns
  true/false, taken when a selector is used for Mango queries.

- `#ctx{verbose = true, negate = false}`: verbose mode, when the
  operator is not negated by an enclosing `$not` operator. Returns a
  list of `#failure` records which may be empty.

- `#ctx{verbose = true, negate = true}`: verbose mode, when the operator
  is negated by an enclosing `$not` operator. Returns a list of
  `#failure` records.

The different negation modes are needed because, in order to generate
meaningful failure messages, we need to record whether an operator was
negated. The behaviour of combinators like `$and`, `$or`, `$allMatch`
and `$elemMatch` means not all `$not` operators can be normalized out of
the selector before evaluation. Instead, when we encounter a `$not`
during evaluation, we flip the `#ctx.negate` field before evaluating the
inner operator.
Until now, document updates rejected by a Mango VDU returned an opaque
"forbidden" message to the client. This commit adds a detailed list of
failures, obtained by converting the `#failure` records returned by
`mango_selector:match/3` into human-readable messages.
Currently, when a design doc is updated, we validate the `map` and
`reduce` fields, but not `validate_doc_update`. Instead, trying to
update any other doc while an invalid `validate_doc_update` exists will
trigger an error.

This comment makes VDU validation more 'eager' by performing it when the
ddoc itself is updated. Normal doc writes will still trigger an error if
an invalid `validate_doc_update` already exists, but now we try to
prevent this happening by validating VDUs when they are first created.
…keyMapMatch") should be considered successful when applied to values of the wrong type
@jcoglan
jcoglan force-pushed the mango-match-failures branch from 0788d06 to ef251bd Compare July 20, 2026 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants