@@ -20,6 +20,34 @@ def write(chunk)
2020 # drop
2121 end
2222 end
23+
24+ class DummyErrorOutputPlugin < DummyOutputPlugin
25+ def register_write ( &block )
26+ instance_variable_set ( "@write" , block )
27+ end
28+
29+ def initialize
30+ super
31+ @should_fail_writing = true
32+ @write = nil
33+ end
34+
35+ def recover
36+ @should_fail_writing = false
37+ end
38+
39+ def write ( chunk )
40+ if @should_fail_writing
41+ raise "failed writing chunk"
42+ else
43+ @write ? @write . call ( chunk ) : nil
44+ end
45+ end
46+
47+ def format ( tag , time , record )
48+ [ tag , time . to_i , record ] . to_json + "\n "
49+ end
50+ end
2351end
2452
2553class FileBufferTest < Test ::Unit ::TestCase
@@ -1311,4 +1339,143 @@ def compare_log(plugin, msg)
13111339 assert { not File . exist? ( "#{ @bufdir } /backup/worker0/#{ @id_output } /#{ @d . dump_unique_id_hex ( c2id ) } .log" ) }
13121340 end
13131341 end
1342+
1343+ sub_test_case 'evacuate_chunk' do
1344+ def setup
1345+ Fluent ::Test . setup
1346+
1347+ @now = Time . local ( 2025 , 5 , 30 , 17 , 0 , 0 )
1348+ @base_dir = File . expand_path ( "../../tmp/evacuate_chunk" , __FILE__ )
1349+ @buf_dir = File . join ( @base_dir , "buffer" )
1350+ @root_dir = File . join ( @base_dir , "root" )
1351+ FileUtils . mkdir_p ( @root_dir )
1352+
1353+ Fluent ::SystemConfig . overwrite_system_config ( "root_dir" => @root_dir ) do
1354+ Timecop . freeze ( @now )
1355+ yield
1356+ end
1357+ ensure
1358+ Timecop . return
1359+ FileUtils . rm_rf ( @base_dir )
1360+ end
1361+
1362+ def start_plugin ( plugin )
1363+ plugin . start
1364+ plugin . after_start
1365+ end
1366+
1367+ def stop_plugin ( plugin )
1368+ plugin . stop unless plugin . stopped?
1369+ plugin . before_shutdown unless plugin . before_shutdown?
1370+ plugin . shutdown unless plugin . shutdown?
1371+ plugin . after_shutdown unless plugin . after_shutdown?
1372+ plugin . close unless plugin . closed?
1373+ plugin . terminate unless plugin . terminated?
1374+ end
1375+
1376+ def configure_output ( id , chunk_key , buffer_conf )
1377+ output = FluentPluginFileBufferTest ::DummyErrorOutputPlugin . new
1378+ output . configure (
1379+ config_element ( 'ROOT' , '' , { '@id' => id } , [ config_element ( 'buffer' , chunk_key , buffer_conf ) ] )
1380+ )
1381+ yield output
1382+ ensure
1383+ stop_plugin ( output )
1384+ end
1385+
1386+ def wait ( sec : 4 )
1387+ waiting ( sec ) do
1388+ Thread . pass until yield
1389+ end
1390+ end
1391+
1392+ def emit_events ( output , tag , es )
1393+ output . interrupt_flushes
1394+ output . emit_events ( "test.1" , dummy_event_stream )
1395+ @now += 1
1396+ Timecop . freeze ( @now )
1397+ output . enqueue_thread_wait
1398+ output . flush_thread_wakeup
1399+ end
1400+
1401+ def proceed_to_next_retry ( output )
1402+ @now += 1
1403+ Timecop . freeze ( @now )
1404+ output . flush_thread_wakeup
1405+ end
1406+
1407+ def dummy_event_stream
1408+ Fluent ::ArrayEventStream . new ( [
1409+ [ event_time ( "2025-05-30 10:00:00" ) , { "message" => "data1" } ] ,
1410+ [ event_time ( "2025-05-30 10:10:00" ) , { "message" => "data2" } ] ,
1411+ [ event_time ( "2025-05-30 10:20:00" ) , { "message" => "data3" } ] ,
1412+ ] )
1413+ end
1414+
1415+ def evacuate_dir ( plugin_id )
1416+ File . join ( @root_dir , "buffer" , plugin_id )
1417+ end
1418+
1419+ test 'can recover by putting back evacuated chunk files' do
1420+ plugin_id = "test_output"
1421+ tag = "test.1"
1422+ buffer_conf = {
1423+ "path" => @buf_dir ,
1424+ "flush_mode" => "interval" ,
1425+ "flush_interval" => "1s" ,
1426+ "retry_type" => "periodic" ,
1427+ "retry_max_times" => 1 ,
1428+ "retry_randomize" => false ,
1429+ }
1430+
1431+ # Fail flushing and reach retry limit
1432+ configure_output ( plugin_id , "tag" , buffer_conf ) do |output |
1433+ start_plugin ( output )
1434+
1435+ emit_events ( output , tag , dummy_event_stream )
1436+ wait { output . write_count == 1 and output . num_errors == 1 }
1437+
1438+ proceed_to_next_retry ( output )
1439+ wait { output . write_count == 2 and output . num_errors == 2 }
1440+ wait { Dir . empty? ( @buf_dir ) }
1441+
1442+ # Assert evacuated files
1443+ evacuated_files = Dir . children ( evacuate_dir ( plugin_id ) ) . map do |child_name |
1444+ File . join ( evacuate_dir ( plugin_id ) , child_name )
1445+ end
1446+ assert { evacuated_files . size == 2 } # .log and .log.meta
1447+
1448+ # Put back evacuated chunk files for recovery
1449+ FileUtils . move ( evacuated_files , @buf_dir )
1450+ end
1451+
1452+ # Restart plugin to load the chunk files that were put back
1453+ written_data = [ ]
1454+ configure_output ( plugin_id , "tag" , buffer_conf ) do |output |
1455+ output . recover
1456+ output . register_write do |chunk |
1457+ written_data << chunk . read
1458+ end
1459+ start_plugin ( output )
1460+
1461+ wait { not written_data . empty? }
1462+ end
1463+
1464+ # Assert the recovery success
1465+ assert { written_data . length == 1 }
1466+
1467+ expected_records = [ ]
1468+ dummy_event_stream . each do |( time , record ) |
1469+ expected_records << [ tag , time . to_i , record ]
1470+ end
1471+
1472+ actual_records = StringIO . open ( written_data . first ) do |io |
1473+ io . each_line . map do |line |
1474+ JSON . parse ( line )
1475+ end
1476+ end
1477+
1478+ assert_equal ( expected_records , actual_records )
1479+ end
1480+ end
13141481end
0 commit comments