This is an automated email from the ASF dual-hosted git repository.
jiahuili430 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/couchdb.git
The following commit(s) were added to refs/heads/main by this push:
new f79d9514a Add `_purged_infos` endpoint
f79d9514a is described below
commit f79d9514af75e2f3a9c6947146180fdf6240055b
Author: jiahuili <[email protected]>
AuthorDate: Thu Aug 17 13:51:31 2023 -0500
Add `_purged_infos` endpoint
- Expose the internal purged_infos API to users
- Add tests and documentation for `_purged_infos`
---
src/chttpd/src/chttpd_db.erl | 13 +-
src/chttpd/test/eunit/chttpd_purge_tests.erl | 742 +++++++++++----------------
src/docs/src/api/database/misc.rst | 50 ++
src/fabric/src/fabric.erl | 7 +
src/fabric/src/fabric_db_purged_infos.erl | 79 +++
src/fabric/src/fabric_rpc.erl | 6 +-
6 files changed, 454 insertions(+), 443 deletions(-)
diff --git a/src/chttpd/src/chttpd_db.erl b/src/chttpd/src/chttpd_db.erl
index e2de301b2..9b1aff54f 100644
--- a/src/chttpd/src/chttpd_db.erl
+++ b/src/chttpd/src/chttpd_db.erl
@@ -726,6 +726,15 @@ db_req(#httpd{method = 'POST', path_parts = [_,
<<"_purge">>]} = Req, Db) ->
send_json(Req, Code, {[{<<"purge_seq">>, null}, {<<"purged">>, {Json}}]});
db_req(#httpd{path_parts = [_, <<"_purge">>]} = Req, _Db) ->
send_method_not_allowed(Req, "POST");
+db_req(#httpd{method = 'GET', path_parts = [_, <<"_purged_infos">>]} = Req,
Db) ->
+ {ok, PurgedInfosRaw} = fabric:get_purged_infos(Db),
+ PurgedInfos = [
+ {[{id, Id}, {revs, [couch_doc:rev_to_str(Rev) || Rev <- Revs]}]}
+ || {Id, Revs} <- PurgedInfosRaw
+ ],
+ send_json(Req, {[{purged_infos, PurgedInfos}]});
+db_req(#httpd{path_parts = [_, <<"_purged_infos">>]} = Req, _Db) ->
+ send_method_not_allowed(Req, "GET");
db_req(#httpd{method = 'GET', path_parts = [_, OP]} = Req, Db) when
?IS_ALL_DOCS(OP) ->
case chttpd:qs_json_value(Req, "keys", nil) of
Keys when is_list(Keys) ->
@@ -855,10 +864,12 @@ db_req(#httpd{method = 'PUT', path_parts = [_,
<<"_purged_infos_limit">>]} = Req
throw(Error)
end;
_ ->
- throw({bad_request, "`purge_infos_limit` must be positive
integer"})
+ throw({bad_request, "`purged_infos_limit` must be positive
integer"})
end;
db_req(#httpd{method = 'GET', path_parts = [_, <<"_purged_infos_limit">>]} =
Req, Db) ->
send_json(Req, fabric:get_purge_infos_limit(Db));
+db_req(#httpd{path_parts = [_, <<"_purged_infos_limit">>]} = Req, _Db) ->
+ send_method_not_allowed(Req, "GET,PUT");
% Special case to enable using an unencoded slash in the URL of design docs,
% as slashes in document IDs must otherwise be URL encoded.
db_req(
diff --git a/src/chttpd/test/eunit/chttpd_purge_tests.erl
b/src/chttpd/test/eunit/chttpd_purge_tests.erl
index a8e1a955d..9514125be 100644
--- a/src/chttpd/test/eunit/chttpd_purge_tests.erl
+++ b/src/chttpd/test/eunit/chttpd_purge_tests.erl
@@ -23,48 +23,20 @@
setup() ->
Hashed = couch_passwords:hash_admin_password(?PASS),
ok = config:set("admins", ?USER, ?b2l(Hashed), _Persist = false),
- TmpDb = ?tempdb(),
+ Db = ?tempdb(),
Addr = config:get("chttpd", "bind_address", "127.0.0.1"),
Port = mochiweb_socket_server:get(chttpd, port),
- Url = lists:concat(["http://", Addr, ":", Port, "/", ?b2l(TmpDb)]),
- create_db(Url),
- Url.
+ DbUrl = lists:concat(["http://", Addr, ":", Port, "/", ?b2l(Db)]),
+ create_db(DbUrl),
+ DbUrl.
-teardown(Url) ->
- delete_db(Url),
+teardown(DbUrl) ->
+ delete_db(DbUrl),
ok = config:delete("admins", ?USER, _Persist = false).
-create_db(Url) ->
- {ok, Status, _, _} = test_request:put(Url, [?CONTENT_JSON, ?AUTH], "{}"),
- ?assert(Status =:= 201 orelse Status =:= 202).
-
-create_doc(Url, Id) ->
- test_request:put(
- Url ++ "/" ++ Id,
- [?CONTENT_JSON, ?AUTH],
- "{\"mr\": \"rockoartischocko\"}"
- ).
-
-create_doc(Url, Id, Content) ->
- test_request:put(
- Url ++ "/" ++ Id,
- [?CONTENT_JSON, ?AUTH],
- "{\"mr\": \"" ++ Content ++ "\"}"
- ).
-
-create_docs(Url, Docs) ->
- test_request:post(
- Url ++ "/_bulk_docs",
- [?CONTENT_JSON, ?AUTH],
- ?JSON_ENCODE({[{docs, Docs}]})
- ).
-
-delete_db(Url) ->
- {ok, 200, _, _} = test_request:delete(Url, [?AUTH]).
-
purge_test_() ->
{
- "chttpd db tests",
+ "chttpd purge tests",
{
setup,
fun chttpd_test_util:start_couch/0,
@@ -74,418 +46,306 @@ purge_test_() ->
fun setup/0,
fun teardown/1,
[
- fun test_empty_purge_request/1,
- fun test_ok_purge_request/1,
- fun test_ok_purge_request_with_101_docid/1,
- fun test_accepted_purge_request/1,
- fun test_partial_purge_request/1,
- fun test_mixed_purge_request/1,
- fun test_overmany_ids_or_revs_purge_request/1,
- fun test_exceed_limits_on_purge_infos/1,
- fun should_error_set_purged_docs_limit_to0/1,
- fun test_timeout_set_purged_infos_limit/1
+ ?TDEF_FE(t_purge_only_post_allowed),
+ ?TDEF_FE(t_empty_purge_request),
+ ?TDEF_FE(t_ok_purge_request),
+ ?TDEF_FE(t_ok_purge_with_max_document_id_number),
+ ?TDEF_FE(t_accepted_purge_request),
+ ?TDEF_FE(t_partial_purge_request),
+ ?TDEF_FE(t_mixed_purge_request),
+ ?TDEF_FE(t_over_many_ids_or_revs_purge_request),
+ ?TDEF_FE(t_purged_infos_limit_only_get_put_allowed),
+ ?TDEF_FE(t_exceed_limits_on_purge_infos),
+ ?TDEF_FE(t_should_error_set_purged_docs_limit_to_0),
+ ?TDEF_FE(t_timeout_set_purged_infos_limit),
+ ?TDEF_FE(t_purged_infos_only_get_allowed),
+ ?TDEF_FE(t_empty_purged_infos),
+ ?TDEF_FE(t_purged_infos_after_purge_request),
+ ?TDEF_FE(t_purged_infos_after_multiple_purge_requests)
]
}
}
}.
-test_empty_purge_request(Url) ->
- ?_test(begin
- IdsRevs = "{}",
- {ok, Status, _, ResultBody} = test_request:post(
- Url ++ "/_purge/",
- [?CONTENT_JSON, ?AUTH],
- IdsRevs
- ),
- ResultJson = ?JSON_DECODE(ResultBody),
- ?assert(Status =:= 201 orelse Status =:= 202),
- ?assertEqual(
- {[
- {<<"purge_seq">>, null},
- {<<"purged">>, {[]}}
- ]},
- ResultJson
- )
- end).
-
-test_ok_purge_request(Url) ->
- ?_test(begin
- {ok, _, _, Body} = create_doc(Url, "doc1"),
- {Json} = ?JSON_DECODE(Body),
- Rev1 = couch_util:get_value(<<"rev">>, Json, undefined),
- {ok, _, _, Body2} = create_doc(Url, "doc2"),
- {Json2} = ?JSON_DECODE(Body2),
- Rev2 = couch_util:get_value(<<"rev">>, Json2, undefined),
- {ok, _, _, Body3} = create_doc(Url, "doc3"),
- {Json3} = ?JSON_DECODE(Body3),
- Rev3 = couch_util:get_value(<<"rev">>, Json3, undefined),
-
- IdsRevsEJson =
- {[
- {<<"doc1">>, [Rev1]},
- {<<"doc2">>, [Rev2]},
- {<<"doc3">>, [Rev3]}
- ]},
- IdsRevs = binary_to_list(?JSON_ENCODE(IdsRevsEJson)),
-
- {ok, Status, _, ResultBody} = test_request:post(
- Url ++ "/_purge/",
- [?CONTENT_JSON, ?AUTH],
- IdsRevs
- ),
- ResultJson = ?JSON_DECODE(ResultBody),
- ?assert(Status =:= 201 orelse Status =:= 202),
- ?assertEqual(
- {[
- {<<"purge_seq">>, null},
- {<<"purged">>,
- {[
- {<<"doc1">>, [Rev1]},
- {<<"doc2">>, [Rev2]},
- {<<"doc3">>, [Rev3]}
- ]}}
- ]},
- ResultJson
- )
- end).
-
-test_ok_purge_request_with_101_docid(Url) ->
- ?_test(begin
- PurgedDocsNum = 101,
- Docs = lists:foldl(
- fun(I, Acc) ->
- Id = list_to_binary(integer_to_list(I)),
- Doc = {[{<<"_id">>, Id}, {value, I}]},
- [Doc | Acc]
- end,
- [],
- lists:seq(1, PurgedDocsNum)
- ),
-
- {ok, _, _, Body} = create_docs(Url, Docs),
- BodyJson = ?JSON_DECODE(Body),
-
- PurgeBody = lists:map(
- fun({DocResp}) ->
- Id = couch_util:get_value(<<"id">>, DocResp, undefined),
- Rev = couch_util:get_value(<<"rev">>, DocResp, undefined),
- {Id, [Rev]}
- end,
- BodyJson
- ),
-
- ok = config:set("purge", "max_document_id_number", "101"),
- try
- {ok, Status, _, _} = test_request:post(
- Url ++ "/_purge/",
- [?CONTENT_JSON, ?AUTH],
- ?JSON_ENCODE({PurgeBody})
- ),
- ?assert(Status =:= 201 orelse Status =:= 202)
- after
- ok = config:delete("purge", "max_document_id_number")
- end
- end).
-
-test_accepted_purge_request(Url) ->
- ?_test(begin
- {ok, _, _, Body} = create_doc(Url, "doc1"),
- {Json} = ?JSON_DECODE(Body),
- Rev1 = couch_util:get_value(<<"rev">>, Json, undefined),
- IdsRevsEJson =
- {[
- {<<"doc1">>, [Rev1]}
- ]},
- IdsRevs = binary_to_list(?JSON_ENCODE(IdsRevsEJson)),
+t_purge_only_post_allowed(DbUrl) ->
+ {Status, Response} = req(put, url(DbUrl, "_purge")),
+ ?assertMatch(#{<<"reason">> := <<"Only POST allowed">>}, Response),
+ ?assert(Status =:= 405).
+
+t_empty_purge_request(DbUrl) ->
+ {Status, Response} = req(post, url(DbUrl, "_purge"), #{}),
+ ?assertMatch(#{<<"purge_seq">> := null, <<"purged">> := #{}}, Response),
+ ?assert(Status =:= 201 orelse Status =:= 202).
+
+t_ok_purge_request(DbUrl) ->
+ {201, Response1} = create_docs(DbUrl, docs(3)),
+ IdsRevs = ids_revs(Response1),
+ {Status, Response2} = req(post, url(DbUrl, "_purge"), IdsRevs),
+ ?assertMatch(#{<<"purge_seq">> := null, <<"purged">> := IdsRevs},
Response2),
+ ?assert(Status =:= 201 orelse Status =:= 202).
+
+t_ok_purge_with_max_document_id_number(DbUrl) ->
+ PurgedDocsNum = 101,
+ {201, Response1} = create_docs(DbUrl, docs(PurgedDocsNum)),
+ IdsRevs = ids_revs(Response1),
+
+ {400, #{<<"reason">> := Error}} = req(post, url(DbUrl, "_purge"), IdsRevs),
+ ?assertEqual(<<"Exceeded maximum number of documents.">>, Error),
+
+ ok = config:set("purge", "max_document_id_number", "101", _Persist =
false),
+ try
+ {Status, Response2} = req(post, url(DbUrl, "_purge"), IdsRevs),
+ ?assertMatch(#{<<"purge_seq">> := null, <<"purged">> := IdsRevs},
Response2),
+ ?assert(Status =:= 201 orelse Status =:= 202)
+ after
+ ok = config:delete("purge", "max_document_id_number", _Persist)
+ end.
+
+t_accepted_purge_request(DbUrl) ->
+ try
meck:new(fabric, [passthrough]),
- meck:expect(
- fabric,
- purge_docs,
- fun(_, _, _) ->
+ meck:expect(fabric, purge_docs, fun(_, _, _) ->
+ {accepted, [
{accepted, [
- {accepted, [
- {1,
- <<57, 27, 64, 134, 152, 18, 73, 243, 40, 1, 141,
214, 135, 104, 79,
- 188>>}
- ]}
+ {1, <<187, 82, 160, 135, 14, 97, 52, 47, 28, 172, 13, 249,
96, 182, 127, 97>>}
]}
- end
- ),
- {ok, Status, _, ResultBody} = test_request:post(
- Url ++ "/_purge/",
- [?CONTENT_JSON, ?AUTH],
- IdsRevs
- ),
- ResultJson = ?JSON_DECODE(ResultBody),
- meck:unload(fabric),
- ?assert(Status =:= 202),
- ?assertEqual(
- {[
- {<<"purge_seq">>, null},
- {<<"purged">>,
- {[
- {<<"doc1">>, [Rev1]}
- ]}}
- ]},
- ResultJson
- )
- end).
-
-test_partial_purge_request(Url) ->
- ?_test(begin
- {ok, _, _, Body} = create_doc(Url, "doc1"),
- {Json} = ?JSON_DECODE(Body),
- Rev1 = couch_util:get_value(<<"rev">>, Json, undefined),
-
- NewDoc =
- "{\"new_edits\": false, \"docs\": [{\"_id\": \"doc1\",\n"
- " \"_revisions\": {\"start\": 1, \"ids\": [\"12345\",
\"67890\"]},\n"
- " \"content\": \"updated\", \"_rev\": \"" ++ ?b2l(Rev1)
++ "\"}]}",
- {ok, _, _, _} = test_request:post(
- Url ++ "/_bulk_docs/",
- [?CONTENT_JSON, ?AUTH],
- NewDoc
- ),
-
- IdsRevsEJson = {[{<<"doc1">>, [Rev1]}]},
- IdsRevs = binary_to_list(?JSON_ENCODE(IdsRevsEJson)),
- {ok, Status, _, ResultBody} = test_request:post(
- Url ++ "/_purge/",
- [?CONTENT_JSON, ?AUTH],
- IdsRevs
- ),
- ResultJson = ?JSON_DECODE(ResultBody),
- ?assert(Status =:= 201 orelse Status =:= 202),
- ?assertEqual(
- {[
- {<<"purge_seq">>, null},
- {<<"purged">>,
- {[
- {<<"doc1">>, [Rev1]}
- ]}}
- ]},
- ResultJson
- ),
- {ok, Status2, _, ResultBody2} = test_request:get(
- Url ++
- "/doc1/",
- [?AUTH]
- ),
- {Json2} = ?JSON_DECODE(ResultBody2),
- Content = couch_util:get_value(<<"content">>, Json2, undefined),
- ?assertEqual(<<"updated">>, Content),
- ?assert(Status2 =:= 200)
- end).
-
-test_mixed_purge_request(Url) ->
- ?_test(begin
- {ok, _, _, Body} = create_doc(Url, "doc1"),
- {Json} = ?JSON_DECODE(Body),
- Rev1 = couch_util:get_value(<<"rev">>, Json, undefined),
-
- NewDoc =
- "{\"new_edits\": false, \"docs\": [{\"_id\": \"doc1\",\n"
- " \"_revisions\": {\"start\": 1, \"ids\": [\"12345\",
\"67890\"]},\n"
- " \"content\": \"updated\", \"_rev\": \"" ++ ?b2l(Rev1)
++ "\"}]}",
- {ok, _, _, _} = test_request:post(
- Url ++ "/_bulk_docs/",
- [?CONTENT_JSON, ?AUTH],
- NewDoc
- ),
-
- {ok, _, _, _Body2} = create_doc(Url, "doc2", "content2"),
- {ok, _, _, Body3} = create_doc(Url, "doc3", "content3"),
- {Json3} = ?JSON_DECODE(Body3),
- Rev3 = couch_util:get_value(<<"rev">>, Json3, undefined),
-
- IdsRevsEJson =
- {[
- % partial purge
- {<<"doc1">>, [Rev1]},
- % correct format, but invalid rev
- {<<"doc2">>, [Rev3, Rev1]},
- % correct format and rev
- {<<"doc3">>, [Rev3]}
- ]},
- IdsRevs = binary_to_list(?JSON_ENCODE(IdsRevsEJson)),
- {ok, Status, _, Body4} = test_request:post(
- Url ++ "/_purge/",
- [?CONTENT_JSON, ?AUTH],
- IdsRevs
- ),
- ResultJson = ?JSON_DECODE(Body4),
- ?assert(Status =:= 201 orelse Status =:= 202),
- ?assertEqual(
- {[
- {<<"purge_seq">>, null},
- {<<"purged">>,
- {[
- {<<"doc1">>, [Rev1]},
- {<<"doc2">>, []},
- {<<"doc3">>, [Rev3]}
- ]}}
- ]},
- ResultJson
- ),
- {ok, Status2, _, Body5} = test_request:get(
- Url ++
- "/doc1/",
- [?AUTH]
- ),
- {Json5} = ?JSON_DECODE(Body5),
- Content = couch_util:get_value(<<"content">>, Json5, undefined),
- ?assertEqual(<<"updated">>, Content),
- ?assert(Status2 =:= 200)
- end).
-
-test_overmany_ids_or_revs_purge_request(Url) ->
- ?_test(begin
- {ok, _, _, Body} = create_doc(Url, "doc1"),
- {Json} = ?JSON_DECODE(Body),
- Rev1 = couch_util:get_value(<<"rev">>, Json, undefined),
-
- NewDoc =
- "{\"new_edits\": false, \"docs\": [{\"_id\": \"doc1\",\n"
- " \"_revisions\": {\"start\": 1, \"ids\": [\"12345\",
\"67890\"]},\n"
- " \"content\": \"updated\", \"_rev\": \"" ++ ?b2l(Rev1)
++ "\"}]}",
- {ok, _, _, _} = test_request:post(
- Url ++ "/_bulk_docs/",
- [?CONTENT_JSON, ?AUTH],
- NewDoc
- ),
-
- {ok, _, _, _Body2} = create_doc(Url, "doc2", "content2"),
- {ok, _, _, Body3} = create_doc(Url, "doc3", "content3"),
- {Json3} = ?JSON_DECODE(Body3),
- Rev3 = couch_util:get_value(<<"rev">>, Json3, undefined),
-
- IdsRevsEJson =
- {[
- % partial purge
- {<<"doc1">>, [Rev1]},
- % correct format, but invalid rev
- {<<"doc2">>, [Rev3, Rev1]},
- % correct format and rev
- {<<"doc3">>, [Rev3]}
- ]},
- IdsRevs = binary_to_list(?JSON_ENCODE(IdsRevsEJson)),
-
- % Ids larger than expected
- config:set("purge", "max_document_id_number", "1"),
- {ok, Status, _, Body4} = test_request:post(
- Url ++ "/_purge/",
- [?CONTENT_JSON, ?AUTH],
- IdsRevs
- ),
- config:delete("purge", "max_document_id_number"),
- ResultJson = ?JSON_DECODE(Body4),
- ?assertEqual(400, Status),
- ?assertMatch(
- {[
- {<<"error">>, <<"bad_request">>},
- {<<"reason">>, <<"Exceeded maximum number of documents.">>}
- ]},
- ResultJson
- ),
-
- % Revs larger than expected
- config:set("purge", "max_revisions_number", "1"),
- {ok, Status2, _, Body5} = test_request:post(
- Url ++ "/_purge/",
- [?CONTENT_JSON, ?AUTH],
- IdsRevs
- ),
- config:delete("purge", "max_revisions_number"),
- ResultJson2 = ?JSON_DECODE(Body5),
- ?assertEqual(400, Status2),
- ?assertMatch(
- {[
- {<<"error">>, <<"bad_request">>},
- {<<"reason">>, <<"Exceeded maximum number of revisions.">>}
- ]},
- ResultJson2
- )
- end).
-
-test_exceed_limits_on_purge_infos(Url) ->
- ?_test(begin
- {ok, Status1, _, _} = test_request:put(
- Url ++ "/_purged_infos_limit/",
- [?CONTENT_JSON, ?AUTH],
- "2"
- ),
- ?assert(Status1 =:= 200),
-
- {ok, _, _, Body} = create_doc(Url, "doc1"),
- {Json} = ?JSON_DECODE(Body),
- Rev1 = couch_util:get_value(<<"rev">>, Json, undefined),
- {ok, _, _, Body2} = create_doc(Url, "doc2"),
- {Json2} = ?JSON_DECODE(Body2),
- Rev2 = couch_util:get_value(<<"rev">>, Json2, undefined),
- {ok, _, _, Body3} = create_doc(Url, "doc3"),
- {Json3} = ?JSON_DECODE(Body3),
- Rev3 = couch_util:get_value(<<"rev">>, Json3, undefined),
-
- IdsRevsEJson =
- {[
- {<<"doc1">>, [Rev1]},
- {<<"doc2">>, [Rev2]},
- {<<"doc3">>, [Rev3]}
- ]},
- IdsRevs = binary_to_list(?JSON_ENCODE(IdsRevsEJson)),
-
- {ok, Status2, _, ResultBody} = test_request:post(
- Url ++ "/_purge/",
- [?CONTENT_JSON, ?AUTH],
- IdsRevs
- ),
-
- ResultJson = ?JSON_DECODE(ResultBody),
- ?assert(Status2 =:= 201 orelse Status2 =:= 202),
- ?assertEqual(
- {[
- {<<"purge_seq">>, null},
- {<<"purged">>,
- {[
- {<<"doc1">>, [Rev1]},
- {<<"doc2">>, [Rev2]},
- {<<"doc3">>, [Rev3]}
- ]}}
- ]},
- ResultJson
- )
- end).
-
-should_error_set_purged_docs_limit_to0(Url) ->
- ?_test(begin
- {ok, Status, _, _} = test_request:put(
- Url ++ "/_purged_infos_limit/",
- [?CONTENT_JSON, ?AUTH],
- "0"
- ),
- ?assert(Status =:= 400)
- end).
-
-test_timeout_set_purged_infos_limit(Url) ->
- ?_test(begin
- meck:new(fabric, [passthrough]),
- meck:expect(fabric, set_purge_infos_limit, fun(_, _, _) ->
- {error, timeout}
+ ]}
end),
- {ok, Status, _, ResultBody} = test_request:put(
- Url ++
- "/_purged_infos_limit/",
- [?CONTENT_JSON, ?AUTH],
- "2"
- ),
- meck:unload(fabric),
- ResultJson = ?JSON_DECODE(ResultBody),
- ?assert(Status =:= 500),
- ?assertMatch(
- {[
- {<<"error">>, <<"error">>},
- {<<"reason">>, <<"timeout">>}
- ]},
- ResultJson
- )
- end).
+ {_, IdsRevs} = get_id_rev_map(DbUrl, "doc1"),
+ {Status, Response} = req(post, url(DbUrl, "_purge"), IdsRevs),
+ ?assertMatch(#{<<"purge_seq">> := null, <<"purged">> := IdsRevs},
Response),
+ ?assert(Status =:= 202)
+ after
+ meck:unload(fabric)
+ end.
+
+t_partial_purge_request(DbUrl) ->
+ IdsRevs = create_and_update_doc(DbUrl, "doc1"),
+ {Status1, Response1} = req(post, url(DbUrl, "_purge"), IdsRevs),
+ ?assertMatch(#{<<"purge_seq">> := null, <<"purged">> := IdsRevs},
Response1),
+ ?assert(Status1 =:= 201 orelse Status1 =:= 202),
+ {Status2, #{<<"content">> := Content}} = req(get, url(DbUrl, "doc1")),
+ ?assertEqual(<<"updated">>, Content),
+ ?assert(Status2 =:= 200).
+
+t_mixed_purge_request(DbUrl) ->
+ Doc1IdRevs = create_and_update_doc(DbUrl, "doc1"),
+ [Rev1] = maps:get(?l2b("doc1"), Doc1IdRevs),
+ get_id_rev_map(DbUrl, "doc2"),
+ {Rev3, _} = get_id_rev_map(DbUrl, "doc3"),
+
+ IdsRevs = #{
+ % partial purge
+ <<"doc1">> => [Rev1],
+ % correct format, but invalid rev
+ <<"doc2">> => [Rev1, Rev3],
+ % correct format and rev
+ <<"doc3">> => [Rev3]
+ },
+
+ {Status1, Response} = req(post, url(DbUrl, "_purge"), IdsRevs),
+ ?assertMatch(
+ #{
+ <<"purge_seq">> := null,
+ <<"purged">> := #{<<"doc1">> := [Rev1], <<"doc2">> := [],
<<"doc3">> := [Rev3]}
+ },
+ Response
+ ),
+ ?assert(Status1 =:= 201 orelse Status1 =:= 202),
+
+ {Status2, #{<<"content">> := Content}} = req(get, url(DbUrl, "doc1")),
+ ?assertEqual(<<"updated">>, Content),
+ ?assert(Status2 =:= 200).
+
+t_over_many_ids_or_revs_purge_request(DbUrl) ->
+ Doc1IdRevs = create_and_update_doc(DbUrl, "doc1"),
+ [Rev1] = maps:get(?l2b("doc1"), Doc1IdRevs),
+ get_id_rev_map(DbUrl, "doc2"),
+ {Rev3, _} = get_id_rev_map(DbUrl, "doc3"),
+
+ IdsRevs = #{
+ % partial purge
+ <<"doc1">> => [Rev1],
+ % correct format, but invalid rev
+ <<"doc2">> => [Rev1, Rev3],
+ % correct format and rev
+ <<"doc3">> => [Rev3]
+ },
+
+ % Ids larger than expected
+ config:set("purge", "max_document_id_number", "1", _Persist = false),
+ try
+ {Status1, #{<<"reason">> := Error1}} = req(post, url(DbUrl, "_purge"),
IdsRevs),
+ ?assertEqual(<<"Exceeded maximum number of documents.">>, Error1),
+ ?assertEqual(400, Status1)
+ after
+ config:delete("purge", "max_document_id_number", _Persist)
+ end,
+
+ % Revs larger than expected
+ config:set("purge", "max_revisions_number", "1", _Persist),
+ try
+ {Status2, #{<<"reason">> := Error2}} = req(post, url(DbUrl, "_purge"),
IdsRevs),
+ ?assertEqual(<<"Exceeded maximum number of revisions.">>, Error2),
+ ?assertEqual(400, Status2)
+ after
+ config:delete("purge", "max_revisions_number", _Persist)
+ end.
+
+t_purged_infos_limit_only_get_put_allowed(DbUrl) ->
+ {Status, Response} = req(post, url(DbUrl, "_purged_infos_limit"), "2"),
+ ?assertMatch(#{<<"reason">> := <<"Only GET,PUT allowed">>}, Response),
+ ?assert(Status =:= 405).
+
+t_exceed_limits_on_purge_infos(DbUrl) ->
+ {200, _} = req(put, url(DbUrl, "_purged_infos_limit"), "2"),
+ {201, Response1} = create_docs(DbUrl, docs(3)),
+ IdsRevs = ids_revs(Response1),
+
+ {Status2, Response2} = req(post, url(DbUrl, "_purge"), IdsRevs),
+ ?assertMatch(#{<<"purge_seq">> := null, <<"purged">> := IdsRevs},
Response2),
+ ?assert(Status2 =:= 201 orelse Status2 =:= 202).
+
+t_should_error_set_purged_docs_limit_to_0(DbUrl) ->
+ {Status, Res} = req(put, url(DbUrl, "_purged_infos_limit"), "0"),
+ ?assertMatch(
+ #{
+ <<"error">> := <<"bad_request">>,
+ <<"reason">> := <<"`purged_infos_limit` must be positive integer">>
+ },
+ Res
+ ),
+ ?assert(Status =:= 400).
+
+t_timeout_set_purged_infos_limit(DbUrl) ->
+ try
+ meck:new(fabric, [passthrough]),
+ meck:expect(fabric, set_purge_infos_limit, fun(_, _, _) -> {error,
timeout} end),
+ {Status, #{<<"reason">> := Error}} = req(put, url(DbUrl,
"_purged_infos_limit"), "2"),
+ ?assertEqual(<<"timeout">>, Error),
+ ?assert(Status =:= 500)
+ after
+ meck:unload(fabric)
+ end.
+
+t_purged_infos_only_get_allowed(DbUrl) ->
+ {Status, Response} = req(post, url(DbUrl, "_purged_infos")),
+ ?assertMatch(#{<<"reason">> := <<"Only GET allowed">>}, Response),
+ ?assert(Status =:= 405).
+
+t_empty_purged_infos(DbUrl) ->
+ {Status, Response} = req(get, url(DbUrl, "_purged_infos")),
+ ?assertMatch(#{<<"purged_infos">> := []}, Response),
+ ?assert(Status =:= 200).
+
+t_purged_infos_after_purge_request(DbUrl) ->
+ PurgedDocsNum = 3,
+ {Status1, Response1} = create_docs(DbUrl, docs(PurgedDocsNum)),
+ ?assert(Status1 =:= 201 orelse Status1 =:= 202),
+ IdsRevs = ids_revs(Response1),
+
+ {Status2, Response2} = req(post, url(DbUrl, "_purge"), IdsRevs),
+ ?assert(Status2 =:= 201 orelse Status2 =:= 202),
+ ?assertMatch(#{<<"purge_seq">> := null, <<"purged">> := IdsRevs},
Response2),
+
+ {Status3, Response3} = req(get, url(DbUrl, "_purged_infos")),
+ ?assert(Status3 =:= 200),
+ ?assertMatch(#{<<"purged_infos">> := [_ | _]}, Response3),
+ Info = maps:get(<<"purged_infos">>, Response3),
+ ?assertEqual(3, length(Info)).
+
+t_purged_infos_after_multiple_purge_requests(DbUrl) ->
+ {Rev1, Doc1IdRevs} = get_id_rev_map(DbUrl, "doc1"),
+ {Rev2, Doc2IdRevs} = get_id_rev_map(DbUrl, "doc2"),
+ {201, #{<<"rev">> := Rev1New}} =
+ req(put, url(DbUrl, "doc1?rev=" ++ ?b2l(Rev1)), #{<<"val">> =>
<<"updated">>}),
+ req(post, url(DbUrl, "_purge"), Doc1IdRevs),
+ req(post, url(DbUrl, "_purge"), Doc2IdRevs),
+ req(post, url(DbUrl, "_purge"), #{<<"doc1">> => [Rev1New]}),
+ {Status, #{<<"purged_infos">> := IdRevsList}} = req(get, url(DbUrl,
"_purged_infos")),
+ ?assert(lists:member(#{<<"id">> => <<"doc1">>, <<"revs">> => [Rev1]},
IdRevsList)),
+ ?assert(lists:member(#{<<"id">> => <<"doc1">>, <<"revs">> => [Rev1New]},
IdRevsList)),
+ ?assert(lists:member(#{<<"id">> => <<"doc2">>, <<"revs">> => [Rev2]},
IdRevsList)),
+ ?assert(Status =:= 200).
+
+%%%%%%%%%%%%%%%%%%%% Utility Functions %%%%%%%%%%%%%%%%%%%%
+url(Url, Path) ->
+ Url ++ "/" ++ Path.
+
+create_db(Url) ->
+ case req(put, Url) of
+ {201, #{}} -> ok;
+ Error -> error({failed_to_create_test_db, Error})
+ end.
+
+delete_db(Url) ->
+ case req(delete, Url) of
+ {200, #{}} -> ok;
+ Error -> error({failed_to_delete_test_db, Error})
+ end.
+
+create_doc(Url, Id) ->
+ req(put, url(Url, Id), #{<<"val">> => ?l2b(Id)}).
+
+create_docs(Url, Docs) ->
+ req(post, url(Url, "_bulk_docs"), #{<<"docs">> => Docs}).
+
+docs(Counter) ->
+ lists:foldl(
+ fun(I, Acc) ->
+ Id = ?l2b(integer_to_list(I)),
+ Doc = #{<<"_id">> => Id, <<"val">> => I},
+ [Doc | Acc]
+ end,
+ [],
+ lists:seq(1, Counter)
+ ).
+
+ids_revs(Response) ->
+ IdsRevs = lists:map(
+ fun(DocResp) ->
+ Id = maps:get(<<"id">>, DocResp),
+ Rev = maps:get(<<"rev">>, DocResp),
+ {Id, [Rev]}
+ end,
+ Response
+ ),
+ maps:from_list(IdsRevs).
+
+get_id_rev_map(Url, Id) ->
+ {_, #{<<"rev">> := Rev}} = create_doc(Url, Id),
+ {Rev, #{?l2b(Id) => [Rev]}}.
+
+create_and_update_doc(Url, Id) ->
+ {Rev, IdRevs} = get_id_rev_map(Url, Id),
+ NewDoc = new_doc(Rev),
+ {201, _} = req(post, url(Url, "_bulk_docs"), NewDoc),
+ IdRevs.
+
+new_doc(Rev) ->
+ #{
+ <<"new_edits">> => false,
+ <<"docs">> => [
+ #{
+ <<"_id">> => <<"doc1">>,
+ <<"_revisions">> => #{
+ <<"start">> => 1,
+ <<"ids">> => [<<"12345">>, <<"67890">>]
+ },
+ <<"content">> => <<"updated">>,
+ <<"_rev">> => Rev
+ }
+ ]
+ }.
+
+req(Method, Url) ->
+ Headers = [?CONTENT_JSON, ?AUTH],
+ {ok, Code, _, Res} = test_request:request(Method, Url, Headers),
+ {Code, jiffy:decode(Res, [return_maps])}.
+
+req(Method, Url, #{} = Body) ->
+ req(Method, Url, jiffy:encode(Body));
+req(Method, Url, Body) ->
+ Headers = [?CONTENT_JSON, ?AUTH],
+ {ok, Code, _, Res} = test_request:request(Method, Url, Headers, Body),
+ {Code, jiffy:decode(Res, [return_maps])}.
diff --git a/src/docs/src/api/database/misc.rst
b/src/docs/src/api/database/misc.rst
index 288b04871..d2e974cdc 100644
--- a/src/docs/src/api/database/misc.rst
+++ b/src/docs/src/api/database/misc.rst
@@ -175,6 +175,56 @@ following behavior:
record of the document will be deleted. The document will no longer be found
in searches.
+.. _api/db/_purged_infos:
+
+==============================
+``/{db}/_purged_infos``
+==============================
+
+.. http:get:: /{db}/_purged_infos
+ :synopsis: Returns a history list of purged document IDs and revisions
+
+ Get a list of purged document IDs and revisions stored in the database.
+
+ :param db: Database name
+ :<header Accept: - :mimetype:`application/json`
+ - :mimetype:`text/plain`
+ :>header Content-Type: - :mimetype:`application/json`
+ - :mimetype:`text/plain; charset=utf-8`
+ :code 200: Request completed successfully
+ :code 400: Invalid database name
+
+ **Request**:
+
+ .. code-block:: http
+
+ GET /db/_purged_infos HTTP/1.1
+ Accept: application/json
+ Host: localhost:5984
+
+ **Response**:
+
+ .. code-block:: http
+
+ HTTP/1.1 200 OK
+ Cache-Control: must-revalidate
+ Content-Length: 75
+ Content-Type: application/json
+ Date: Thu, 24 Aug 2023 20:56:06 GMT
+ Server: CouchDB (Erlang/OTP)
+
+ {
+ "purged_infos": [
+ {
+ "id": "doc_id",
+ "revs": [
+ "1-85cfcb946ba8fea03ba81ec38a7a9998",
+ "2-c6548393a891f2cec9c7755832ff9d6f"
+ ]
+ }
+ ]
+ }
+
.. _api/db/_purged_infos_limit:
==============================
diff --git a/src/fabric/src/fabric.erl b/src/fabric/src/fabric.erl
index dfd433d38..c36c8f4df 100644
--- a/src/fabric/src/fabric.erl
+++ b/src/fabric/src/fabric.erl
@@ -31,6 +31,7 @@
get_all_security/1, get_all_security/2,
get_purge_infos_limit/1,
set_purge_infos_limit/3,
+ get_purged_infos/1,
compact/1, compact/2,
get_partition_info/2
]).
@@ -211,6 +212,12 @@ get_purge_infos_limit(DbName) ->
catch couch_db:close(Db)
end.
+%% @doc returns purged requests history for the given database
+-spec get_purged_infos(dbname()) ->
+ {ok, [{purged_infos, json_obj()}]} | {error, Reason :: term()}.
+get_purged_infos(Db) ->
+ fabric_db_purged_infos:go(dbname(Db)).
+
get_security(DbName) ->
get_security(DbName, [?ADMIN_CTX]).
diff --git a/src/fabric/src/fabric_db_purged_infos.erl
b/src/fabric/src/fabric_db_purged_infos.erl
new file mode 100644
index 000000000..a85897bcb
--- /dev/null
+++ b/src/fabric/src/fabric_db_purged_infos.erl
@@ -0,0 +1,79 @@
+% 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(fabric_db_purged_infos).
+
+-export([go/1]).
+
+-include_lib("fabric/include/fabric.hrl").
+-include_lib("mem3/include/mem3.hrl").
+
+-record(pacc, {
+ counters,
+ replies,
+ ring_opts
+}).
+
+go(DbName) ->
+ Shards = mem3:shards(DbName),
+ Workers = fabric_util:submit_jobs(Shards, get_purged_infos, []),
+ RexiMon = fabric_util:create_monitors(Shards),
+ Fun = fun handle_message/3,
+ Acc0 = #pacc{
+ counters = fabric_dict:init(Workers, nil),
+ replies = [],
+ ring_opts = [{any, Shards}]
+ },
+ try
+ case fabric_util:recv(Workers, #shard.ref, Fun, Acc0) of
+ {ok, Res} ->
+ {ok, Res};
+ {timeout, {WorkersDict, _}} ->
+ DefunctWorkers = fabric_util:remove_done_workers(WorkersDict,
nil),
+ fabric_util:log_timeout(DefunctWorkers, "get_purged_infos"),
+ {error, timeout};
+ {error, Error} ->
+ throw(Error)
+ end
+ after
+ rexi_monitor:stop(RexiMon)
+ end.
+
+handle_message({rexi_DOWN, _, {_, NodeRef}, _}, _Shard, #pacc{} = Acc) ->
+ #pacc{counters = Counters, ring_opts = RingOpts} = Acc,
+ case fabric_util:remove_down_workers(Counters, NodeRef, RingOpts) of
+ {ok, NewCounters} ->
+ {ok, Acc#pacc{counters = NewCounters}};
+ error ->
+ {error, {nodedown, <<"progress not possible">>}}
+ end;
+handle_message({rexi_EXIT, Reason}, Shard, #pacc{} = Acc) ->
+ #pacc{counters = Counters, ring_opts = RingOpts} = Acc,
+ NewCounters = fabric_dict:erase(Shard, Counters),
+ case fabric_ring:is_progress_possible(NewCounters, RingOpts) of
+ true ->
+ {ok, Acc#pacc{counters = NewCounters}};
+ false ->
+ {error, Reason}
+ end;
+handle_message({ok, Info}, #shard{} = Shard, #pacc{} = Acc) ->
+ #pacc{counters = Counters, replies = Replies} = Acc,
+ Replies1 = [Info | Replies],
+ Counters1 = fabric_dict:erase(Shard, Counters),
+ case fabric_dict:size(Counters1) =:= 0 of
+ true ->
+ {stop, lists:flatten(Replies1)};
+ false ->
+ {ok, Acc#pacc{counters = Counters1, replies = Replies1}}
+ end;
+handle_message(_, _, #pacc{} = Acc) ->
+ {ok, Acc}.
diff --git a/src/fabric/src/fabric_rpc.erl b/src/fabric/src/fabric_rpc.erl
index fa6ea5116..d01f1f5a7 100644
--- a/src/fabric/src/fabric_rpc.erl
+++ b/src/fabric/src/fabric_rpc.erl
@@ -39,7 +39,7 @@
]).
-export([get_all_security/2, open_shard/2]).
-export([compact/1, compact/2]).
--export([get_purge_seq/2, purge_docs/3, set_purge_infos_limit/3]).
+-export([get_purge_seq/2, get_purged_infos/1, purge_docs/3,
set_purge_infos_limit/3]).
-export([
get_db_info/2,
@@ -299,6 +299,10 @@ update_docs(DbName, Docs0, Options) ->
Docs2 = make_att_readers(Docs1),
with_db(DbName, Options, {couch_db, update_docs, [Docs2, Options, Type]}).
+get_purged_infos(DbName) ->
+ FoldFun = fun({_Seq, _UUID, Id, Revs}, Acc) -> {ok, [{Id, Revs} | Acc]}
end,
+ with_db(DbName, [], {couch_db, fold_purge_infos, [0, FoldFun, []]}).
+
get_purge_seq(DbName, Options) ->
with_db(DbName, Options, {couch_db, get_purge_seq, []}).