From 86cd4616518f9b64fd45af97d3bc968d7cb4a3de Mon Sep 17 00:00:00 2001 From: Shizuo Fujita Date: Fri, 10 Jul 2026 14:16:33 +0900 Subject: [PATCH] time: fix TimeFormatter returning nil for the Unix epoch TimeFormatter caches the last two formatted timestamps in two slots, and an empty slot was marked by its key being 0. But 0 is a valid timestamp, so the epoch matched the empty slot and the slot's nil string was returned instead of a formatted time: Fluent::TimeFormatter.new(nil, false, nil).format(0) #=> nil Both cache paths are affected. format_without_subsec compares the key with ==, so only the epoch itself is hit. format_with_subsec compares it with EventTime.eq?, which falls back to EventTime#== when either side is not an EventTime, and that compares seconds alone -- so every timestamp within the first second of the epoch is hit: fmt = Fluent::TimeFormatter.new("%Y%m%d %H%M%S.%N", false, nil) fmt.format(Fluent::EventTime.new(0, 123456789)) #=> nil A slot stops carrying the key 0 once it is written, and the victim-selection rule fills the second slot first, so the first slot keeps the key 0 until two distinct timestamps have been formatted. The failure is therefore confined to a cold cache, with one exception: a stream whose timestamps are all the epoch returns early on every call, never fills a slot, and loses the time column on every line for as long as the process runs. Mark an empty slot by its cached string being nil instead. format_nocache always returns a String, so a filled slot can never be mistaken for an empty one, whatever its key. Signed-off-by: Shizuo Fujita --- lib/fluent/time.rb | 10 ++++++---- test/test_time_formatter.rb | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/fluent/time.rb b/lib/fluent/time.rb index 8a4c12ac93..3fcddce259 100644 --- a/lib/fluent/time.rb +++ b/lib/fluent/time.rb @@ -363,6 +363,8 @@ def parse_float(value) class TimeFormatter def initialize(format = nil, localtime = true, timezone = nil) + # An empty cache slot is marked by a nil string, not by its key: 0 is a + # valid timestamp, so a key can never say whether the slot is filled. @tc1 = 0 @tc1_str = nil @tc2 = 0 @@ -390,9 +392,9 @@ def initialize(format = nil, localtime = true, timezone = nil) end def format_without_subsec(time) - if @tc1 == time + if @tc1_str && @tc1 == time return @tc1_str - elsif @tc2 == time + elsif @tc2_str && @tc2 == time return @tc2_str else str = format_nocache(time) @@ -408,9 +410,9 @@ def format_without_subsec(time) end def format_with_subsec(time) - if Fluent::EventTime.eq?(@tc1, time) + if @tc1_str && Fluent::EventTime.eq?(@tc1, time) return @tc1_str - elsif Fluent::EventTime.eq?(@tc2, time) + elsif @tc2_str && Fluent::EventTime.eq?(@tc2, time) return @tc2_str else str = format_nocache(time) diff --git a/test/test_time_formatter.rb b/test/test_time_formatter.rb index 0938e2fb5b..8db58fff58 100644 --- a/test/test_time_formatter.rb +++ b/test/test_time_formatter.rb @@ -196,6 +196,29 @@ def test_format_with_subsec assert_equal("20140927 0000.000000000", formatter.format(time)) end + # The Unix epoch used to match an empty cache slot, whose string is nil. + def test_format_epoch + formatter = Fluent::TimeFormatter.new(nil, false, nil) + assert_equal("1970-01-01T00:00:00Z", formatter.format(Fluent::EventTime.new(0))) + + formatter = Fluent::TimeFormatter.new(nil, false, nil) + assert_equal("1970-01-01T00:00:00Z", formatter.format(0)) + assert_equal("1970-01-01T00:00:01Z", formatter.format(Fluent::EventTime.new(1))) + assert_equal("1970-01-01T00:00:00Z", formatter.format(Fluent::EventTime.new(0))) + end + + # Here the slot is compared with EventTime.eq?, which falls back to comparing + # seconds alone, so the whole first second of the epoch matched an empty slot. + def test_format_epoch_with_subsec + formatter = Fluent::TimeFormatter.new("%Y%m%d %H%M%S.%N", false, nil) + assert_equal("19700101 000000.000000000", formatter.format(Fluent::EventTime.new(0, 0))) + assert_equal("19700101 000000.123456789", formatter.format(Fluent::EventTime.new(0, 123456789))) + assert_equal("19700101 000000.000000000", formatter.format(Fluent::EventTime.new(0, 0))) + + formatter = Fluent::TimeFormatter.new("%Y%m%d %H%M%S.%N", false, nil) + assert_equal("19700101 000000.999999999", formatter.format(Fluent::EventTime.new(0, 999999999))) + end + sub_test_case 'TimeMixin::Formatter' do class DummyForTimeFormatter include Fluent::Configurable