Repository navigation
out_forward: start retry after a failed ACK when heartbeat_type is none - #5627
SahinurDEV wants to merge 8 commits into
Conversation
Fixes fluent#5619 When require_ack_response is enabled, a failed ACK disables the node and rolls the chunk back with update_retry: false, so the next write can start the retry state via NoNodesAvailable. NoneHeartbeatNode stays available after disable!, so that path never runs and chunks are resent immediately, ignoring retry_timeout and <secondary>. Start the retry on rollback when the node is still available after disable!. Leave update_retry false when disable! works, to avoid counting the same failure twice. Signed-off-by: SahinurDEV <68515168+SahinurDEV@users.noreply.github.com>
Watson1978
left a comment
There was a problem hiding this comment.
Thanks for picking up #5619. Before going into the code, I need to correct the issue I wrote, because this PR builds on it. The issue says that with the default heartbeat_type transport, the next write raises NoNodesAvailable and the retry starts. That depends on timing. An ACK failure disables the node without clearing its failure-detector samples. When the sample window is already large enough, a heartbeat can bring the node back before the next flush. The chunk is then resent without a retry. I added the details to the issue. Sorry about that.
I compared this PR with master (e7d3c3e). For the timeout tests, the destination accepts chunks over TCP but never sends an ACK. Unless noted otherwise, the sender uses require_ack_response true, ack_response_timeout 1, flush_interval 0.5, retry_wait 1 and the default memory buffer. The counts below are from single runs and vary a little between runs.
- Queued chunks can be discarded even though another node can accept them.
heartbeat_type none, two<server>s (one never ACKs, one ACKs normally),retry_max_times 0, 8 chunks with different tags. In 12 seconds, master delivered 6 to the healthy node and kept the other 2 for resending. This PR delivered 3 and discarded 5. With the healthy node asstandby, master kept all 8, and this PR discarded all 8. One failed ACK counts as a failure of the whole output, andclear_queue!discards every queued chunk. When a send itself fails,select_healthy_nodetries the other available nodes in the current node list first, and raises only when none of them succeeds. (With a file buffer, the chunks are evacuated with a warning instead.) - The discard is not logged. With
heartbeat_type none, one<server>andretry_max_times 1, the chunk is gone after two failed ACKs.rollback_write(update_retry: true)passes no error toupdate_retry_state, so neitherfailed to flush the buffernorHit limit for retriesis logged. The only trace isbuffer queue clearedat debug level. - Transport can start a retry depending on timing. If a heartbeat brings the node back between
node&.disable!andnode.available?,update_retrybecomes true for transport too. In a separate test with a closed connection, I forced this interleaving (recover_wait 0, immediate flushing with both flush thread intervals at 0.05,retry_wait 30s). Master resent the chunk after 0.9 seconds. This PR was still waiting for the retry 5 seconds later. - Transport can still resend without a retry. This is the same on master. With
heartbeat_type transport,recover_wait 10andretry_max_times 3, sending the first chunk 12 seconds after startup gave 6 failed ACKs and 6recovered forwarding serverlines in 20 seconds, and no retry. Withflush_thread_interval 0.05andflush_thread_burst_interval 0.05, the next flush ran before the heartbeat, and the retry started.
All of this is about failed ACKs, that is, an ACK timeout or a closed connection. The wrong-ACK path (CHUNKID_UNMATCHED) has a similar gap, but I would keep it out of this PR.
I see three directions, and I would like to hear what you think.
- A. Keep counting a retry for
heartbeat_type none, and fix problems 1 to 3. The hard part is problem 1. A failed ACK arrives later and for one node, while the retry state belongs to the whole output. Tracking which nodes a chunk has been tried on is one way, but it adds new state. Problem 2 needs the error passed toupdate_retry_state, or a log of its own. Problem 4 stays. - B. Keep the node out after a failed ACK, and keep
update_retry: false. Writes then go to other nodes, or raiseNoNodesAvailablewhen no node is left, and the usual retry,<secondary>and logging apply. This is the direction I lean toward. I tried an exploratory prototype (diff below). With regular nodes in the cases above, all 8 chunks reached the healthy node, the limit was logged asHit limit for retries, and transport started the retry. But it is not ready:- In the static two-server setup with a
standby, the prototype does not promote the standby withheartbeat_type none, because no heartbeat timer rebuilds the node list. All 8 chunks were discarded (withHit limitlogged). - A heartbeat that runs between
disable!and@failure.clearcan bring the node back at once. Also, clearing the samples does not keep the node out for a fixed time, because successful writes in flight add samples too. - With
recover_wait 0, nothing changes from master. - A single transient failure keeps the only node out. When the destination closed the connection once and then sent ACKs normally, delivery took about 17 to 19 seconds with both transport and
none, instead of about 2 to 3 seconds on master. There were 4no nodes are availablewarnings. Withretry_max_times 1, the chunk was discarded before it could be resent. - It changes what
heartbeat_type noneandrecover_waitmean, so the docs would need an update.
- In the static two-server setup with a
- C. Always count a failed ACK as a retry, for every heartbeat type. This is the smallest change, but it reverses 2f40abb (#1665). Problems 1 and 2 would then need the same fixes for every heartbeat type, including transport setups that fail over to another node today.
Exploratory prototype for B (on e7d3c3e, with the gaps listed above)
--- a/lib/fluent/plugin/out_forward.rb
+++ b/lib/fluent/plugin/out_forward.rb
@@ -545,7 +545,7 @@ def ack_check(select_interval)
# instead of returning it to the reuse pool; otherwise a late ack
# could later be read against a chunk sent on the reused socket (#4057).
@connection_manager.discard(sock)
- node&.disable!
+ node&.detach!
rollback_write(chunk_id, update_retry: false) if chunk_id
when AckHandler::Result::CHUNKID_UNMATCHED
# The ack read from this socket belongs to a different chunk than the
@@ -630,6 +630,12 @@ def disable!
@available = false
end
+ # Clear the failure-detector samples so that recovery needs fresh ones.
+ def detach!
+ disable!
+ @failure.clear
+ end
+
def standby?
@standby
end
@@ -852,7 +858,11 @@ def connect(host = nil, ack: false, &block)
# Override Node to disable heartbeat
class NoneHeartbeatNode < Node
def available?
- true
+ @detached_until.nil? || Fluent::Clock.now >= @detached_until
+ end
+
+ def detach!
+ @detached_until = Fluent::Clock.now + @sender.recover_wait
end
def tick… retry Follow the direction suggested in the review of fluent#5627: after a failed ACK (timeout or closed connection), keep the node out for recover_wait and roll the chunk back with update_retry: false, so its chunks go to another node instead of starting a retry for the whole output. - NoneHeartbeatNode is now unavailable while detached, and the weight array is rebuilt before selecting a node with heartbeat_type none, so a standby node is used while the active node is out and switched back afterwards. The rebuild also runs right after the detach. - For transport/udp, the hold is set before disable!, so a heartbeat or in-flight write samples cannot bring the node back before recover_wait. - The last available node is not detached (and nothing is detached with recover_wait 0). The chunk is rolled back and a retry is counted with an AckResponseError, so a transient failure is resent after retry_wait, retry_timeout/retry_max_times/<secondary> apply, and the retry or the discard is logged as usual. - A detach is logged as a warning with the chunk id. Signed-off-by: SahinurDEV <68515168+SahinurDEV@users.noreply.github.com>
|
@Watson1978 Thank you for the detailed review and for testing all of these cases, and for the correction to the issue. I agree with B. I pushed 9bb8423, which builds on your prototype and replaces the earlier What happens after a failed ACK (timeout or closed connection)
The gaps you listed
The wrong-ACK path ( Tests (
I'm happy to change any of this. For example, if you would rather detach the last node with a short hold than count a retry for it. |
Watson1978
left a comment
There was a problem hiding this comment.
The new tests in a server that never sends the ack (#5619) fail on macOS most of the time. In the Test workflow for 9bb8423, 8 of the 9 macOS jobs across three attempts failed, while all Ubuntu and Windows jobs passed. The failures are in delivers every chunk to the healthy server and delivers every chunk to the healthy standby server. Each one runs for about 31 seconds, and then some chunks are missing from the healthy server. The missing tags differ between runs, from one chunk to six. For example:
<["test.1", "test.2", "test.3", "test.4", "test.5", "test.6", "test.7", "test.8"]> expected but was
<["test.1", "test.2", "test.3", "test.4", "test.5", "test.7", "test.8"]>
These tests check that no chunk is lost. So please find out whether chunks are really lost on macOS, or whether the test does not wait correctly there, and fix the cause. Please don't only make the timeout longer or skip the test on macOS without knowing the cause.
The ack reader checked the timeout before reading, and a socket enqueued while it was waiting in IO.select was only selected in the next loop. With a select interval as long as ack_response_timeout, an ack that had already arrived could be regarded as missing. Expired acks were also reported only after a full select wait, so Output#try_rollback_write could roll the chunk back first on delayed_commit_timeout and count a retry without an error. Read an ack that is already in the socket buffer, and report expired acks without waiting for the select interval.
|
@Watson1978 Thank you for catching this. The chunks were really dropped on macOS, and the cause is in the ack reader ( To find it, I ran the two tests on macOS runners in my fork with the plugin logs dumped on failure. There were two separate causes:
Both depend on timing, so they show up more on the slower macOS runners. I couldn't reproduce either one locally on Linux. The first one mainly matters with a short The fix: when the timeout is checked, an ack that is already in the socket buffer is read instead (checked with a zero-timeout select), and expired acks are reported without waiting for the select interval. I added two tests to Verification: I ran the two tests as they are in the PR 3 times in each of 6 macOS jobs (Ruby 3.3 and 4.0). Before the fix, 13 of 18 runs failed (run). After it, 18 of 18 passed (run). I didn't change any timeout or skip any test. |
Watson1978
left a comment
There was a problem hiding this comment.
Thanks for the quick and thorough update, and for tracking down the macOS failures.
I compared 8a61903 with master (e7d3c3e) again. The problems from my earlier review are fixed in the cases I measured:
- With
heartbeat_type none, two regular servers (one never ACKs) andretry_max_times 0, all 8 chunks reached the healthy server within 12 seconds. The same when the healthy one is astandby, and with transport and two regular servers. In single runs, master delivered 5, 0 (standby) and 6 of them in that time. - With one server and
retry_max_times 1, the chunk is resent once, and thenHit limit for retriesis logged withAckResponseError. - When the only server closes the connection once, the chunk was delivered after about 3 seconds over TCP, close to master (2 to 3 seconds).
- I could reproduce both ack reader causes you described with a forced ordering, and 8a61903 fixes both. The macOS jobs pass now.
Two problems are left, both inline. I reproduced the first one by pausing the ack thread at a fixed point, so I don't know how often it happens in real deployments. The second one happened in every run with a slow destination.
Two more things:
- 8a61903 has no
Signed-off-byline, so the DCO check fails. Please add it, for example withgit commit --amend --signoffand a force push. - Yes, please open the docs PR for
recover_waitandheartbeat_type none. Please also describe the change for other heartbeat types. Whenrecover_waitis positive and another available server with a positive weight can take the chunks, a server whose ACK fails is now kept out for at leastrecover_wait(10 seconds by default). On master, a transport heartbeat could bring it back at the next heartbeat.
| # to the whole output. If no node is left, the next write raises | ||
| # NoNodesAvailable and the usual retry applies. | ||
| node.detach! | ||
| service_discovery_rebalance |
There was a problem hiding this comment.
MUST: This rebuilds the weight array from the ack thread. With heartbeat_type none, rebalance_if_availability_changed also rebuilds it from the flush threads. On master, after start, only the event loop rebuilds it (the heartbeat timer and service discovery), so two rebuilds never overlap. Now a rebuild that read the node list earlier can publish its array after a newer one.
I reproduced this with dynamic service discovery and three servers (failed, removed, retained):
- The ack for
failedfails, and thisservice_discovery_rebalancereads the current services. I paused the ack thread there. - Service discovery removes
failedandremoved, and publishes a weight array with onlyretained. - The ack thread resumes and publishes its array, built from the old list.
After that, master sends only to retained. With this PR, removed is still selected. availability_changed? only checks the current services, so the check before a write does not notice it, and the stale array stays until another rebuild. A lock around the assignment of @weight_array alone would not help, because the node list is read before it.
Please make sure that an older rebuild cannot replace a newer one, and add a test that forces this order. The standby case (delivers every chunk to the healthy standby server) should keep passing.
There was a problem hiding this comment.
Thanks for reproducing this with a forced order. Fixed in 6e6e1eb.
LoadBalancer#rebuild_weight_array now holds a rebuild lock while it reads the node list, builds the array, and publishes it. As you pointed out, locking only the assignment wouldn't help, so out_forward gives the load balancer a nodes_provider (service_discovery_services). Every rebuild reads the list under the lock and ignores the list the caller read earlier. That covers the ack thread, the flush threads, the heartbeat timer, and the service discovery rebuild through Manager#rebalance. A rebuild that read the old list has to publish before the next rebuild can take the lock. Any later rebuild, such as the one service discovery runs after removing failed and removed, then reads the new list and publishes last. The service discovery helper and its rebalance(services) interface are unchanged.
Tests:
concurrent rebuilds of the weight array (#5627)/a rebuild that read the old node list does not replace a newer oneintest_out_forward.rbfollows your steps. It uses three servers (failed,removed,retained) and pauses the ack-thread rebuild right after it has read the node list. Meanwhile, another thread sendsSERVICE_OUTforfailedandremovedand callsManager#run_once. The test then resumes the paused rebuild and checks that the weight array and the selected nodes contain onlyretained. It fails on 8a61903 (removedis still in the array) and passes now.- Two unit tests in
test_load_balancer.rbcheck that the provider is used and that the list is read under the lock. delivers every chunk to the healthy standby serverand the othernever sends the ackanda failed acktests still pass.
| if rollback_write(chunk_id, update_retry: false) | ||
| error = AckResponseError.new("failed to receive the ack response from forwarding server '#{node ? node_label(node) : 'unknown'}'") | ||
| error.set_backtrace(caller(0, 1)) | ||
| primary = @as_secondary ? @primary_instance : self | ||
| primary.update_retry_state(chunk_id, @as_secondary, error) |
There was a problem hiding this comment.
MUST: A late ack failure can count a second retry for a send attempt that the delayed commit timeout has already handled. When rollback_write(chunk_id) returns true, it does not mean that the failed attempt was rolled back. By then, the same chunk can be in flight again.
I reproduced this without forcing any order. One server, heartbeat_type none, ack_response_timeout 1, retry_wait 1, retry_max_times 1, and one 24 MB chunk in a memory buffer. The destination reads 4 MB per second. It never ACKs the first connection, and ACKs the later ones.
- The first send of chunk X takes longer than
delayed_commit_timeout(ack_response_timeout + 2, 3 seconds here). The ack timeout only starts when the send finishes. So right after the send,Output#try_rollback_writerolls X back and counts one retry (timeout to commit). - After
retry_wait, X is sent again on a new connection. - While X is being sent again, the ack thread reports the ack timeout of the first send. This
rollback_write(X)takes back the new attempt and returns true, so a second retry is counted for the same failure.
In three runs each over TCP, master delivered X on the second connection after about 14.5 seconds. With this PR, the second retry reached retry_max_times 1, and X was dropped after about 9 seconds (Hit limit for retries ... AckResponseError). On master, the late failure can also take back the new attempt, but it does not count a retry.
Please don't count a retry here when the delayed commit timeout has already rolled back the failed attempt. A later send of the same chunk can fail on its own. Its ack failure should still count, unless the delayed commit timeout has already rolled back that attempt too. Please add tests for both cases.
There was a problem hiding this comment.
Thanks, you're right: rollback_write returning true doesn't mean the failed attempt was rolled back. Fixed in 0420c88.
Each ack now carries the delayed commit entry (Output::DequeuedChunkInfo) that Output#try_flush added for that send. try_write looks it up and passes it through Node#send_data to the AckHandler, and collect_response yields it back with the result. When the ack fails, on_ack_failed removes that entry while holding @dequeued_chunks_mutex of the instance that flushed the chunk. That is the same lock Output#try_rollback_write holds, so only one of the two can roll the attempt back.
- If the entry is still there, the chunk is rolled back as before and, for the last available node, a retry is counted with
AckResponseError. - If the delayed commit timeout has already removed it, the late failure only logs
... after the delayed commit timeout rolled the chunk back. no retry is counted again. No retry is counted, and the chunk is not taken back either, because the send that is now in flight has its own entry and its own ack. The same check applies when the node is detached. - A later send of the same chunk has a new entry. So when its ack fails on its own, a retry is counted, unless the delayed commit timeout has already rolled back that attempt too.
Tests are in a late ack failure after the delayed commit timeout (#5627) in test_out_forward.rb. They use the real Output#try_flush, #try_rollback_write, and a memory buffer, with only the network send stubbed:
does not count a second retry for an attempt that was already rolled back: the first send expires, the chunk is sent again, then the first ack fails. No second retry is counted, and the chunk stays in flight. This fails on 8a61903.counts a retry when a later send of the chunk fails on its owndoes not count a retry when the delayed commit timeout rolled back the later send toocounts a retry for a failed ack before the delayed commit timeoutpasses the delayed commit entry of the send to the ack, plus anAckHandlertest that the entry is yielded back.
I also ran your scenario end to end on Linux: one server, heartbeat_type none, ack_response_timeout 1, retry_wait 1, retry_max_times 1, and one 24 MB chunk in a memory buffer. The destination reads about 4 MB/s, never ACKs the first connection, and ACKs the later ones. With 8a61903, the chunk was dropped in both runs (Hit limit for retries ... AckResponseError). With this branch, it was delivered on the second connection after about 12 seconds in both runs, with no Hit limit log.
…y delayed commit A rebuild of the weight array from the ack thread could publish after a newer service-discovery rebuild, putting removed nodes back into the array. Tag each rebuild with a generation taken when the node list is read, and ignore an older result. A late ack failure could also count a second retry after Output#try_rollback_write had already rolled the same attempt back on delayed_commit_timeout. Bind each ack to that write attempt and skip the extra retry. A later send of the same chunk still counts if its ack fails and delayed commit has not already handled that attempt. Signed-off-by: SahinurDEV <68515168+SahinurDEV@users.noreply.github.com>
… one With heartbeat_type none, the ack thread (after a failed ack) and the flush threads rebuild the weight array as well as the event loop (heartbeat timer and service discovery). A rebuild that read the node list earlier could publish its array after a newer one, e.g. after service discovery had removed a server, and the removed server stayed selectable. Each rebuild now reads the node list and publishes its array while holding a rebuild lock in the load balancer, so the last published array is always built from the latest node list. Signed-off-by: SahinurDEV <68515168+SahinurDEV@users.noreply.github.com>
A send of a chunk can take longer than delayed_commit_timeout, and the ack timeout only starts when the send is done. Then Output#try_rollback_write rolls the attempt back and counts a retry before the ack thread reports the failed ack. When the ack thread reported it while the same chunk was in flight again, rollback_write took back the new attempt and returned true, so a second retry was counted for the same failure, and with a small retry_max_times the chunk could be dropped. The ack now carries the delayed commit entry that Output#try_flush added for its send. A failed ack only rolls the chunk back and counts a retry if that entry is still pending; it is removed under the same lock that Output#try_rollback_write uses. If the delayed commit timeout has already rolled the attempt back, the late failure is logged and nothing else is done. A later send of the same chunk that fails on its own still counts. Signed-off-by: SahinurDEV <68515168+SahinurDEV@users.noreply.github.com>
Keep the rebuild lock (6e6e1eb) and the per-attempt rollback (0420c88) instead of 54cfc28. They fix the same two problems without changing the service discovery helper's load balancer interface (rebalance(services)), without making the private out_forward methods public, and without keeping a list of rolled back attempts that is never cleaned up. A late ack failure of an attempt that was already rolled back also no longer takes back the next send of the chunk. Signed-off-by: SahinurDEV <68515168+SahinurDEV@users.noreply.github.com>
… on macOS Signed-off-by: SahinurDEV <68515168+SahinurDEV@users.noreply.github.com>
Signed-off-by: SahinurDEV <68515168+SahinurDEV@users.noreply.github.com>
|
@Watson1978 Thanks again for the detailed review. I've replied on both inline threads. The fixes are in 6e6e1eb (weight array rebuilds) and 0420c88 (late ack failure), plus a2a3990 for a RuboCop fix in a test. About 54cfc28: another attempt at the same two fixes had already been pushed to this branch. It added a generation to DCO: I'm sorry, but we can't rewrite the history of this branch, so I won't amend 8a61903 or force-push. 8a61903 ("out_forward: do not regard a received ack as missing in the ack reader") was authored and committed by me, SahinurDEV. For that commit, I certify the Developer Certificate of Origin 1.1 (https://developercertificate.org/) here: All newer commits on the branch are signed off. Could a maintainer set the DCO check to pass for 8a61903, or squash-merge the PR with this sign-off in the commit message? If you'd prefer another way, please let me know. Docs: I opened fluent/fluentd-docs-gitbook#722 for Tests: I ran |
|
@SahinurDEV Thanks for the update, and for opening fluent/fluentd-docs-gitbook#722. About the DCO: Fluentd needs a Please don't use the This also removes the |
Which issue(s) this PR fixes:
Fixes #5619
What this PR does / why we need it:
With
require_ack_responseandheartbeat_type none, a chunk that the destination never acknowledges is rolled back withupdate_retry: falseand then sent again immediately.retry_timeout,retry_max_timesand<secondary>never apply.For the default
heartbeat_type transport, thatupdate_retry: falseis intentional:ack_checkcallsnode.disable!, the next write raisesNoNodesAvailable, and that starts the retry state.NoneHeartbeatNode#available?always returnstrue, sodisable!does not take that path.This keeps
update_retry: falsewhendisable!actually makes the node unavailable, so a laterNoNodesAvailableis not counted twice. When the node is still available afterdisable!(or there is no node), the rollback now starts the retry state so the retry settings are honored.Docs Changes:
fluent/fluentd-docs-gitbook#722 (
require_ack_response,recover_wait,heartbeat_type none)Release Note:
out_forward: honor retry and secondary after a failed ACK when
heartbeat_typeisnone