This is an automated email from the ASF dual-hosted git repository.
nickva 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 b74d7b392 Add request compression to replicator
b74d7b392 is described below
commit b74d7b39220f74ac421db0ae30974796ad069a66
Author: lacklacklack <[email protected]>
AuthorDate: Fri May 22 20:40:33 2026 +0200
Add request compression to replicator
---
rel/overlay/etc/default.ini | 7 ++
.../include/couch_replicator_api_wrap.hrl | 12 +-
src/couch_replicator/priv/stats_descriptions.cfg | 5 +
.../src/couch_replicator_api_wrap.erl | 48 +++++++-
.../src/couch_replicator_parse.erl | 11 +-
.../eunit/couch_replicator_compression_tests.erl | 127 +++++++++++++++++++++
src/docs/src/config/replicator.rst | 29 +++++
7 files changed, 228 insertions(+), 11 deletions(-)
diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index 0e0eaba6b..6f5e459d9 100644
--- a/rel/overlay/etc/default.ini
+++ b/rel/overlay/etc/default.ini
@@ -739,6 +739,13 @@ partitioned||* = true
; *.example.com:443:[2001:db8::1]:443
;connect_to =
+; Compress outbound replication request bodies (_bulk_docs, _revs_diff,
_bulk_get).
+; Accepted values: none (default, disabled), gzip.
+; Enable gzip only when the target supports Content-Encoding: gzip on inbound
requests.
+;request_compression = none
+;compress_min_size = 1024
+
+
; Some socket options that might boost performance in some scenarios:
; {nodelay, boolean()}
; {sndbuf, integer()}
diff --git a/src/couch_replicator/include/couch_replicator_api_wrap.hrl
b/src/couch_replicator/include/couch_replicator_api_wrap.hrl
index 6d6ad527c..ff12df703 100644
--- a/src/couch_replicator/include/couch_replicator_api_wrap.hrl
+++ b/src/couch_replicator/include/couch_replicator_api_wrap.hrl
@@ -10,9 +10,12 @@
% License for the specific language governing permissions and limitations under
% the License.
-
-define(COUCH_REPLICATOR_USER_AGENT, "CouchDB-Replicator/" ++
couch_server:get_version()).
+-define(COMPRESS_NONE, "none").
+-define(COMPRESS_GZIP, "gzip").
+-define(COMPRESS_MIN_SIZE, 1024).
+
-record(httpdb, {
url,
auth_props = [],
@@ -20,13 +23,14 @@
{"Accept", "application/json"},
{"User-Agent", ?COUCH_REPLICATOR_USER_AGENT}
],
- timeout, % milliseconds
+ timeout,
ibrowse_options = [],
retries = 5,
- wait = 250, % milliseconds
+ wait = 250,
httpc_pool = nil,
http_connections,
first_error_timestamp = nil,
proxy_url,
- auth_context = nil
+ auth_context = nil,
+ request_compression = ?COMPRESS_NONE
}).
diff --git a/src/couch_replicator/priv/stats_descriptions.cfg
b/src/couch_replicator/priv/stats_descriptions.cfg
index 10821d885..546b8af38 100644
--- a/src/couch_replicator/priv/stats_descriptions.cfg
+++ b/src/couch_replicator/priv/stats_descriptions.cfg
@@ -146,3 +146,8 @@
{type, counter},
{desc, <<"number of times DNS overrides were applied to replication
requests">>}
]}.
+
+{[couch_replicator, requests_compressed, gzip], [
+ {type, counter},
+ {desc, <<"number of HTTP requests compressed with gzip by the
replicator">>}
+]}.
diff --git a/src/couch_replicator/src/couch_replicator_api_wrap.erl
b/src/couch_replicator/src/couch_replicator_api_wrap.erl
index 9364757d6..c1ab912d4 100644
--- a/src/couch_replicator/src/couch_replicator_api_wrap.erl
+++ b/src/couch_replicator/src/couch_replicator_api_wrap.erl
@@ -171,13 +171,15 @@ ensure_full_commit(#httpdb{} = Db) ->
get_missing_revs(#httpdb{} = Db, IdRevs) ->
JsonBody = {[{Id, couch_doc:revs_to_strs(Revs)} || {Id, Revs} <- IdRevs]},
+ RawBody = ?JSON_ENCODE(JsonBody),
+ {Body, ExtraHeaders} = maybe_compress_request(Db, RawBody),
send_req(
Db,
[
{method, post},
{path, "_revs_diff"},
- {body, ?JSON_ENCODE(JsonBody)},
- {headers, [{"Content-Type", "application/json"}]}
+ {body, Body},
+ {headers, [{"Content-Type", "application/json"} | ExtraHeaders]}
],
fun
(200, _, {Props}) ->
@@ -211,14 +213,17 @@ bulk_get(#httpdb{} = Db, #{} = IdRevs, Options) ->
% that at some point in the future we could make that the default, instead
% of having to send query parameters with a POST request as we do today
Body = options_to_json_map(Options, #{<<"docs">> => ReqDocsMaps}),
+ RawBody = ?JSON_ENCODE(Body),
+ {ReqBody, ExtraHeaders} = maybe_compress_request(Db, RawBody),
Req = [
{method, post},
{path, "_bulk_get"},
{qs, options_to_query_args(Options, [])},
- {body, ?JSON_ENCODE(Body)},
+ {body, ReqBody},
{headers, [
{"Content-Type", "application/json"},
{"Accept", "application/json"}
+ | ExtraHeaders
]}
],
try
@@ -500,17 +505,24 @@ update_docs(#httpdb{} = HttpDb, DocList, Options,
UpdateType) ->
([Doc | RestDocs]) ->
{ok, [Doc, ","], RestDocs}
end,
- Headers = [
- {"Content-Length", Len},
+ Headers0 = [
{"Content-Type", "application/json"},
{"X-Couch-Full-Commit", FullCommit}
],
+ {Body, Headers} =
+ case should_compress_request(HttpDb, Len) of
+ true ->
+ FullBody = [Prefix, lists:join(",", Docs), Suffix],
+ gzip_request_body(FullBody, Headers0);
+ false ->
+ {{BodyFun, [prefix | Docs]}, [{"Content-Length", Len} |
Headers0]}
+ end,
send_req(
HttpDb,
[
{method, post},
{path, "_bulk_docs"},
- {body, {BodyFun, [prefix | Docs]}},
+ {body, Body},
{headers, Headers}
],
fun
@@ -1053,6 +1065,30 @@ header_value(Key, Headers, Default) ->
Default
end.
+%% Returns true if compression is enabled and body meets the minimum size
threshold.
+should_compress_request(#httpdb{request_compression = ?COMPRESS_GZIP},
BodySize) ->
+ MinSize = config:get_integer("replicator", "compress_min_size",
?COMPRESS_MIN_SIZE),
+ BodySize >= MinSize;
+should_compress_request(#httpdb{}, _BodySize) ->
+ false.
+
+%% Compress Body with gzip, prepend Content-Encoding header.
+%% Returns {CompressedBody, Headers}.
+gzip_request_body(Body, Headers) ->
+ Compressed = zlib:gzip(Body),
+ couch_stats:increment_counter([couch_replicator, requests_compressed,
gzip]),
+ {Compressed, [{"Content-Encoding", "gzip"} | Headers]}.
+
+%% Compress Body if compression is enabled and body meets minimum size.
+%% Returns {Body, ExtraHeaders} where ExtraHeaders may contain
Content-Encoding.
+maybe_compress_request(#httpdb{} = HttpDb, Body) ->
+ case should_compress_request(HttpDb, iolist_size(Body)) of
+ true ->
+ gzip_request_body(Body, []);
+ false ->
+ {Body, []}
+ end.
+
% Normalize an #httpdb{} or #db{} record such that it can be used for
% comparisons. This means remove things like pids and also sort options /
props.
normalize_db(#httpdb{} = HttpDb) ->
diff --git a/src/couch_replicator/src/couch_replicator_parse.erl
b/src/couch_replicator/src/couch_replicator_parse.erl
index 000108acd..8713a61f4 100644
--- a/src/couch_replicator/src/couch_replicator_parse.erl
+++ b/src/couch_replicator/src/couch_replicator_parse.erl
@@ -54,6 +54,7 @@ default_options() ->
{checkpoint_interval, cfg_int("checkpoint_interval", 30000)},
{use_checkpoints, cfg_boolean("use_checkpoints", true)},
{use_bulk_get, cfg_boolean("use_bulk_get", true)},
+ {request_compression, cfg_str("request_compression", "none")},
{ibrowse_options, cfg_ibrowse_opts()},
{socket_options, cfg_sock_opts()}
].
@@ -216,7 +217,8 @@ parse_rep_db({Props}, Proxy, Options) ->
timeout = get_value(connection_timeout, Options),
http_connections = get_value(http_connections, Options),
retries = get_value(retries, Options),
- proxy_url = ProxyURL
+ proxy_url = ProxyURL,
+ request_compression = get_value(request_compression, Options, "none")
},
couch_replicator_utils:normalize_basic_auth(HttpDb);
parse_rep_db(<<"http://", _/binary>> = Url, Proxy, Options) ->
@@ -284,6 +286,9 @@ cfg_int(Var, Default) ->
cfg_boolean(Var, Default) ->
config:get_boolean("replicator", Var, Default).
+cfg_str(Var, Default) ->
+ config:get("replicator", Var, Default).
+
cfg_atoms(Cfg, Default) ->
case cfg(Cfg) of
undefined ->
@@ -388,6 +393,8 @@ convert_options([{<<"since_seq">>, V} | R]) ->
[{since_seq, V} | convert_options(R)];
convert_options([{<<"use_checkpoints">>, V} | R]) ->
[{use_checkpoints, V} | convert_options(R)];
+convert_options([{<<"request_compression">>, V} | R]) when is_binary(V) ->
+ [{request_compression, binary_to_list(V)} | convert_options(R)];
convert_options([{<<"use_bulk_get">>, V} | _R]) when not is_boolean(V) ->
throw({bad_request, <<"parameter `use_bulk_get` must be a boolean">>});
convert_options([{<<"use_bulk_get">>, V} | R]) ->
@@ -774,6 +781,7 @@ t_parse_sock_opts(_) ->
{connection_timeout, 30000},
{http_connections, 20},
{ibrowse_options, []},
+ {request_compression, "none"},
{retries, 5},
{socket_options, [
{priority, 3},
@@ -819,6 +827,7 @@ t_parse_ibrowse_opts(_) ->
{ibrowse_options, [
{prefer_ipv6, true}
]},
+ {request_compression, "none"},
{retries, 5},
{socket_options, [
{keepalive, true},
diff --git
a/src/couch_replicator/test/eunit/couch_replicator_compression_tests.erl
b/src/couch_replicator/test/eunit/couch_replicator_compression_tests.erl
new file mode 100644
index 000000000..895076926
--- /dev/null
+++ b/src/couch_replicator/test/eunit/couch_replicator_compression_tests.erl
@@ -0,0 +1,127 @@
+% 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_replicator_compression_tests).
+
+-include_lib("couch/include/couch_eunit.hrl").
+-include_lib("couch/include/couch_db.hrl").
+
+-define(DOCS_COUNT, 10).
+-define(LARGE_DOCS_COUNT, 500).
+-define(TIMEOUT_EUNIT, 60).
+
+compression_test_() ->
+ {
+ "Replication compression tests",
+ {
+ foreach,
+ fun setup/0,
+ fun teardown/1,
+ [
+ ?TDEF_FE(should_not_compress_by_default, ?TIMEOUT_EUNIT),
+ ?TDEF_FE(should_compress_when_enabled, ?TIMEOUT_EUNIT),
+ ?TDEF_FE(should_compress_large_batch, ?TIMEOUT_EUNIT),
+ ?TDEF_FE(should_compress_per_job, ?TIMEOUT_EUNIT),
+ ?TDEF_FE(job_compression_overrides_global_disabled,
?TIMEOUT_EUNIT)
+ ]
+ }
+ }.
+
+setup() ->
+ Ctx = couch_replicator_test_helper:test_setup(),
+ config:set("replicator", "request_compression", "none", false),
+ config:set("replicator", "compress_min_size", "1024", false),
+ Ctx.
+
+teardown(Ctx) ->
+ config:delete("replicator", "request_compression", false),
+ config:delete("replicator", "compress_min_size", false),
+ couch_replicator_test_helper:test_teardown(Ctx).
+
+should_not_compress_by_default({_Ctx, {Source, Target}}) ->
+ Before = couch_stats:sample([couch_replicator, requests_compressed, gzip]),
+ populate_db(Source, ?DOCS_COUNT),
+ replicate(Source, Target),
+ couch_replicator_test_helper:cluster_compare_dbs(Source, Target),
+ After = couch_stats:sample([couch_replicator, requests_compressed, gzip]),
+ ?assertEqual(Before, After).
+
+should_compress_when_enabled({_Ctx, {Source, Target}}) ->
+ config:set("replicator", "request_compression", "gzip", false),
+ config:set("replicator", "compress_min_size", "10", false),
+ Before = couch_stats:sample([couch_replicator, requests_compressed, gzip]),
+ populate_db(Source, ?DOCS_COUNT),
+ replicate(Source, Target),
+ couch_replicator_test_helper:cluster_compare_dbs(Source, Target),
+ After = couch_stats:sample([couch_replicator, requests_compressed, gzip]),
+ ?assert(After > Before).
+
+should_compress_large_batch({_Ctx, {Source, Target}}) ->
+ config:set("replicator", "request_compression", "gzip", false),
+ Before = couch_stats:sample([couch_replicator, requests_compressed, gzip]),
+ populate_db(Source, ?LARGE_DOCS_COUNT),
+ replicate(Source, Target),
+ couch_replicator_test_helper:cluster_compare_dbs(Source, Target),
+ After = couch_stats:sample([couch_replicator, requests_compressed, gzip]),
+ ?assert(After > Before).
+
+should_compress_per_job({_Ctx, {Source, Target}}) ->
+ % global config is none (default), but job sets gzip
+ config:set("replicator", "compress_min_size", "10", false),
+ Before = couch_stats:sample([couch_replicator, requests_compressed, gzip]),
+ populate_db(Source, ?DOCS_COUNT),
+ replicate_with_options(Source, Target, [{<<"request_compression">>,
<<"gzip">>}]),
+ couch_replicator_test_helper:cluster_compare_dbs(Source, Target),
+ After = couch_stats:sample([couch_replicator, requests_compressed, gzip]),
+ ?assert(After > Before).
+
+job_compression_overrides_global_disabled({_Ctx, {Source, Target}}) ->
+ % global config is gzip, but job disables it
+ config:set("replicator", "request_compression", "gzip", false),
+ config:set("replicator", "compress_min_size", "10", false),
+ Before = couch_stats:sample([couch_replicator, requests_compressed, gzip]),
+ populate_db(Source, ?DOCS_COUNT),
+ replicate_with_options(Source, Target, [{<<"request_compression">>,
<<"none">>}]),
+ couch_replicator_test_helper:cluster_compare_dbs(Source, Target),
+ After = couch_stats:sample([couch_replicator, requests_compressed, gzip]),
+ ?assertEqual(Before, After).
+
+populate_db(DbName, Count) ->
+ Docs = lists:map(
+ fun(I) ->
+ Id = iolist_to_binary(io_lib:format("doc~p", [I])),
+ Data = list_to_binary(lists:duplicate(100, $x)),
+ {[
+ {<<"_id">>, Id},
+ {<<"value">>, I},
+ {<<"data">>, Data}
+ ]}
+ end,
+ lists:seq(1, Count)
+ ),
+ {ok, _} = fabric:update_docs(DbName, Docs, [?ADMIN_CTX]),
+ ok.
+
+replicate(Source, Target) ->
+ replicate_with_options(Source, Target, []).
+
+replicate_with_options(Source, Target, ExtraOptions) ->
+ SourceUrl = couch_replicator_test_helper:cluster_db_url(Source),
+ TargetUrl = couch_replicator_test_helper:cluster_db_url(Target),
+ RepObject =
+ {[
+ {<<"source">>, SourceUrl},
+ {<<"target">>, TargetUrl},
+ {<<"continuous">>, false}
+ | ExtraOptions
+ ]},
+ {ok, _} = couch_replicator_test_helper:replicate(RepObject).
diff --git a/src/docs/src/config/replicator.rst
b/src/docs/src/config/replicator.rst
index ceb3eebba..e14672f39 100644
--- a/src/docs/src/config/replicator.rst
+++ b/src/docs/src/config/replicator.rst
@@ -122,6 +122,35 @@ Replicator Database Configuration
[replicator]
connection_timeout = 30000
+ .. config:option:: request_compression :: Compress outbound request bodies
+
+ .. versionadded:: 3.6
+
+ Compress outbound replication request bodies (``_bulk_docs``,
+ ``_revs_diff``, ``_bulk_get``) using gzip before sending. Accepted
+ values are ``none`` (disabled, the default) and ``gzip``. Enable only
+ when the replication target supports ``Content-Encoding: gzip`` on
+ inbound requests, which all CouchDB servers do::
+
+ [replicator]
+ request_compression = none
+
+ This option can also be set per replication job by including
+ ``"request_compression": "gzip"`` in the replication document or
+ ``_replicate`` request body, which overrides the global setting.
+
+ .. config:option:: compress_min_size :: Minimum body size for compression
+
+ .. versionadded:: 3.6
+
+ Minimum request body size in bytes before compression is applied.
+ Bodies smaller than this threshold are sent uncompressed even if
+ :config:option:`request_compression <replicator/request_compression>`
+ is enabled::
+
+ [replicator]
+ compress_min_size = 1024
+
.. config:option:: retries_per_request :: Number of retries per request
.. versionchanged:: 2.1.1