From 642700c8226a3e0162727c40cab25963df34a8f0 Mon Sep 17 00:00:00 2001 From: Takuro Ashie Date: Fri, 2 Oct 2026 10:43:33 +0900 Subject: [PATCH] in_tail: fix typo of @follow_inodes in detach_watcher **Which issue(s) this PR fixes**: Fixes # **What this PR does / why we need it**: TailInput#detach_watcher checked `@follow_inode`, which is not defined anywhere, instead of `@follow_inodes`. The typo was introduced in 52e46f04 ("in_tail: Fix a stall bug on !follow_inode case", #4327). With `follow_inodes true`, position entries are keyed by inode, and the inode of a rotated file is dropped from `@tails` when its TailWatcher is detached by `update_watcher`. So `detach_watcher` must unwatch the position entry of that inode even if the same path is still followed by a new watcher. With the typo, the condition was always `nil || !@tails[tw.path]`, so the entry was left in the position file. Note that `tw.unwatched` is currently set to true only when `follow_inodes` is false (since 51848da4), so this doesn't change the behavior of the existing code paths. It makes the condition work as intended so that the position entry handling stays correct when the `unwatched` flag is used with `follow_inodes` too. Add tests for `detach_watcher`: - with `follow_inodes true`, the position entry of the rotated inode is unwatched even while the path is still followed by another watcher. - with `follow_inodes false`, the position entry of the path is kept while the path is still followed by a watcher. **Docs Changes**: None **Release Note**: None Assisted-by: LLM Qwen3.8-Flash-Next Signed-off-by: Takuro Ashie --- lib/fluent/plugin/in_tail.rb | 2 +- test/plugin/test_in_tail.rb | 112 +++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/lib/fluent/plugin/in_tail.rb b/lib/fluent/plugin/in_tail.rb index 3d32d6a057..9f6426a0d8 100644 --- a/lib/fluent/plugin/in_tail.rb +++ b/lib/fluent/plugin/in_tail.rb @@ -647,7 +647,7 @@ def detach_watcher(tw, ino, close_io = true) tw.close if close_io - if @pf && tw.unwatched && (@follow_inode || !@tails[tw.path]) + if @pf && tw.unwatched && (@follow_inodes || !@tails[tw.path]) target_info = TargetInfo.new(tw.path, ino) @pf.unwatch(target_info) end diff --git a/test/plugin/test_in_tail.rb b/test/plugin/test_in_tail.rb index 0819708eee..15fb3378bf 100644 --- a/test/plugin/test_in_tail.rb +++ b/test/plugin/test_in_tail.rb @@ -2093,6 +2093,118 @@ def test_should_delete_file_pos_entry_for_non_existing_file_with_follow_inodes end end + # When `follow_inodes` is enabled, position entries are keyed by inode. + # The inode of a rotated file disappears from `@tails` when its TailWatcher + # is detached by `update_watcher`, so `detach_watcher` must unwatch that + # entry even if the same path is still followed by a new watcher. + # Otherwise the entry of the rotated inode is left in the position file. + def test_detach_watcher_unwatches_pos_entry_of_rotated_inode_with_follow_inodes + config = config_element("ROOT", "", { + "path" => "#{@tmp_dir}/tail.txt*", + "pos_file" => "#{@tmp_dir}/tail.pos", + "tag" => "t1", + "format" => "none", + "read_from_head" => "true", + "follow_inodes" => "true", + "rotate_wait" => "1s", + # In order to reproduce the same condition stably, ensure that + # `refresh_watchers` is not called by a timer. + "refresh_interval" => "1h", + # stat_watcher often calls `TailWatcher::on_notify` unexpectedly, + # so disable it in order to reproduce the same condition stably. + "enable_stat_watcher" => "false", + }) + + path = "#{@tmp_dir}/tail.txt" + Fluent::FileWrapper.open(path, "wb") {|f| f.puts "log1" } + + d = create_driver(config, false) + d.run(shutdown: false) do + plugin = d.instance + pf = plugin.instance_variable_get(:@pf) + tails = plugin.instance_variable_get(:@tails) + + current_tw = tails[path] + assert_not_nil(current_tw) + + # The state right after a rotation: the position entry of the rotated + # inode is still in the position file, and the path is followed by a + # new watcher which has a different inode. + rotated_ino = 0xffffffffffffff01 + rotated_target_info = Fluent::Plugin::TailInput::TargetInfo.new(path, rotated_ino) + rotated_pe = pf[rotated_target_info] + rotated_tw = Fluent::Plugin::TailInput::TailWatcher.new( + rotated_target_info, rotated_pe, $log, true, true, nil, nil, nil, nil + ) + rotated_tw.unwatched = true + + plugin.detach_watcher(rotated_tw, rotated_ino) + + assert_equal( + { + map_keys: [current_tw.ino], + unwatched_pe_pos: Fluent::Plugin::TailInput::PositionFile::UNWATCHED_POSITION, + }, + { + map_keys: pf.instance_variable_get(:@map).keys, + unwatched_pe_pos: rotated_pe.read_pos, + } + ) + end + + d.instance_shutdown + end + + # With `follow_inodes false`, position entries are keyed by path, so the + # entry must not be unwatched while the path is still followed by a watcher. + # (See https://github.com/fluent/fluentd/pull/4327) + def test_detach_watcher_keeps_pos_entry_of_still_watched_path_without_follow_inodes + config = config_element("ROOT", "", { + "path" => "#{@tmp_dir}/tail.txt*", + "pos_file" => "#{@tmp_dir}/tail.pos", + "tag" => "t1", + "format" => "none", + "read_from_head" => "true", + "follow_inodes" => "false", + "rotate_wait" => "1s", + # In order to reproduce the same condition stably, ensure that + # `refresh_watchers` is not called by a timer. + "refresh_interval" => "1h", + "enable_stat_watcher" => "false", + }) + + path = "#{@tmp_dir}/tail.txt" + Fluent::FileWrapper.open(path, "wb") {|f| f.puts "log1" } + + d = create_driver(config, false) + d.run(shutdown: false) do + plugin = d.instance + pf = plugin.instance_variable_get(:@pf) + tails = plugin.instance_variable_get(:@tails) + + current_tw = tails[path] + assert_not_nil(current_tw) + + # A watcher which was following the same path with a different inode. + # Since the path is still followed by `current_tw`, the position entry + # of that path must be kept. + old_ino = 0xffffffffffffff01 + old_target_info = Fluent::Plugin::TailInput::TargetInfo.new(path, old_ino) + old_pe = pf[old_target_info] + old_tw = Fluent::Plugin::TailInput::TailWatcher.new( + old_target_info, old_pe, $log, true, false, nil, nil, nil, nil + ) + old_tw.unwatched = true + + plugin.detach_watcher(old_tw, old_ino) + + assert_equal([path], pf.instance_variable_get(:@map).keys) + assert_not_equal(Fluent::Plugin::TailInput::PositionFile::UNWATCHED_POSITION, old_pe.read_pos) + end + + d.instance_shutdown + end + def test_should_write_latest_offset_after_rotate_wait config = common_follow_inode_config Fluent::FileWrapper.open("#{@tmp_dir}/tail.txt", "wb") {|f|