You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by va...@apache.org on 2026/10/12 19:09:46 UTC

(couchdb) 01/01: Stop changes w/ limit from jumping over rows

This is an automated email from the ASF dual-hosted git repository.

nickva pushed a commit to branch changes-limit-last-seq
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 6e86275d7153d725d2a8b33cf70430fd573bf935
Author: Nick Vatamaniuc <va...@gmail.com>
AuthorDate: Mon Oct 12 12:45:09 2026 -0400

    Stop changes w/ limit from jumping over rows
    
    Previously, when we reached limit=0 in the `no_pass` clause we kept advancing
    the sequence even though we weren't actually sending rows back to the user any
    longer (we get extra rows sent back since we have a pending ack buffer in
    place). That means we could have potentially skipped over data. It's pretty
    corner-case-y (limit set and a filter) but also a bit data-loss-y.
    
    To fix it, we should do what we do for the change and complete clauses above.
    
    Add tests for complete and and regular changes as well while at it.
---
 src/fabric/src/fabric_view_changes.erl | 89 ++++++++++++++++++++++++++++++++--
 1 file changed, 86 insertions(+), 3 deletions(-)

diff --git a/src/fabric/src/fabric_view_changes.erl b/src/fabric/src/fabric_view_changes.erl
index f02e787c7..e9c60a4d5 100644
--- a/src/fabric/src/fabric_view_changes.erl
+++ b/src/fabric/src/fabric_view_changes.erl
@@ -287,7 +287,11 @@ handle_message({complete, Props}, Worker, #collector{limit = 0} = State) ->
         end,
     maybe_stop(State#collector{offset = O1});
 handle_message({no_pass, [_ | _] = Props}, {Worker, From}, #collector{limit = 0} = State) ->
-    #collector{counters = S0, offset = O0} = State,
+    % We aren't sending more rows back, but keep receiving rows until we have
+    % pending updates from every worker. Just like for change and complete
+    % clauses we have to leave the worker's sequence as is. If we keep
+    % advancing then our last_seq could have undelivered changes in it.
+    #collector{offset = O0} = State,
     O1 =
         case fabric_dict:lookup_element(Worker, O0) of
             null ->
@@ -295,9 +299,8 @@ handle_message({no_pass, [_ | _] = Props}, {Worker, From}, #collector{limit = 0}
             _ ->
                 O0
         end,
-    S1 = fabric_dict:store(Worker, couch_util:get_value(seq, Props), S0),
     rexi:stream_ack(From),
-    maybe_stop(State#collector{counters = S1, offset = O1});
+    maybe_stop(State#collector{offset = O1});
 handle_message({change, Props}, {Worker, From}, St) ->
     #collector{
         callback = Callback,
@@ -1212,4 +1215,84 @@ decode_bad_shape_test() ->
     ?assertError(badarg, decode_seq(T([{42, [0, 10], 0}]))),
     ?assertError(badarg, decode_seq(T([{<<"n1">>, [0, 10], 0}]))).
 
+limit_0_keeps_seq_test() ->
+    {A, B, #collector{counters = Seqs0} = St0} = limit_0_state(),
+    % Worker a sends us seq=11 after limit reached. We have to drop it and
+    % sequence must stay at 10.
+    {ok, St1} = handle_message({change, chprops(11, n1, 1)}, {A, self()}, St0),
+    ?assertEqual(Seqs0, St1#collector.counters),
+    ?assertEqual([{A, 2}, {B, null}], St1#collector.offset),
+    ?assertEqual(0, St1#collector.limit),
+    % Haven't heard from b yet. We wait for it to get the pending count but we
+    % must not update the sequence.
+    {stop, St2} = handle_message({change, chprops(4, n2, 5)}, {B, self()}, St1),
+    ?assertEqual(Seqs0, St2#collector.counters),
+    ?assertEqual([{A, 2}, {B, 6}], St2#collector.offset),
+    ?assertEqual(8, pending_count(St2#collector.offset)).
+
+limit_0_no_pass_keeps_seq_test() ->
+    {A, B, #collector{counters = Seqs0} = St0} = limit_0_state(),
+    % Worker a sends row seq=11. It gets dropped. Then sends a no_pass at
+    % seq=12.
+    {ok, St1} = handle_message({change, chprops(11, n1, 1)}, {A, self()}, St0),
+    {ok, St2} = handle_message({no_pass, no_pass_props(12, n1, 0)}, {A, self()}, St1),
+    % We do not advance the sequence to 12 since we never delivered 11. It
+    % could be a considered a data loss if we do and user checkpoints.
+    ?assertEqual(Seqs0, St2#collector.counters),
+    ?assertEqual([{A, 2}, {B, null}], St2#collector.offset),
+    % Worker b returns a no_pass. After this every worker has a pending count
+    % reported so we stop.
+    {stop, St3} = handle_message({no_pass, no_pass_props(4, n2, 5)}, {B, self()}, St2),
+    % This is the main assert: seq didn't change, stays at Seq0
+    ?assertEqual(Seqs0, St3#collector.counters),
+    ?assertEqual([{A, 2}, {B, 5}], St3#collector.offset),
+    ?assertEqual(7, pending_count(St3#collector.offset)),
+    % Last seq check
+    ?assertEqual(
+        [{n1, [0, 10], nseq(10, n1)}, {n2, [11, ?RING_END], nseq(3, n2)}],
+        decode_seq(pack_seqs(St3#collector.counters))
+    ).
+
+limit_0_complete_keeps_seq_test() ->
+    {A, B, #collector{counters = Seqs0} = St0} = limit_0_state(),
+    {ok, St1} = handle_message({change, chprops(11, n1, 0)}, {A, self()}, St0),
+    {ok, St2} = handle_message({complete, complete_props(11, n1, 0)}, A, St1),
+    ?assertEqual(Seqs0, St2#collector.counters),
+    ?assertEqual([{A, 2}, {B, null}], St2#collector.offset),
+    {stop, St3} = handle_message({complete, complete_props(5, n2, 0)}, B, St2),
+    ?assertEqual(Seqs0, St3#collector.counters),
+    ?assertEqual([{A, 2}, {B, 0}], St3#collector.offset),
+    ?assertEqual(2, pending_count(St3#collector.offset)).
+
+% Worker A makes limit = 0. Worker B hasn't returned yet so we don't know its
+% pending state.
+limit_0_state() ->
+    A = mk_shard("n1", 0, 10),
+    B = mk_shard("n2", 11, ?RING_END),
+    State = #collector{
+        limit = 0,
+        counters = orddict:from_list([{A, nseq(10, n1)}, {B, nseq(3, n2)}]),
+        offset = orddict:from_list([{A, 2}, {B, null}])
+    },
+    {A, B, State}.
+
+nseq(Seq, Node) ->
+    {Seq, <<"uuid">>, Node}.
+
+chprops(Seq, Node, Pending) ->
+    DocId = integer_to_binary(Seq),
+    [
+        {pending, Pending},
+        {seq, nseq(Seq, Node)},
+        {id, <<"doc", DocId/binary>>},
+        {changes, [{[{~"rev", ~"1-abc"}]}]},
+        {deleted, false}
+    ].
+
+no_pass_props(Seq, Node, Pending) ->
+    [{pending, Pending}, {seq, nseq(Seq, Node)}].
+
+complete_props(Seq, Node, Pending) ->
+    [{seq, nseq(Seq, Node)}, {pending, Pending}].
+
 -endif.