@@ -118,15 +118,19 @@ def __init__(self, output_dir: str, upload_manager: ObjectStorageManager, automa
118118 'io_uring' : deque (maxlen = 1000 ),
119119 }
120120
121- # Dynamic thresholds (min, max)
121+ # Dynamic thresholds (min, max). Raised roughly 10x from the original
122+ # sizes so each rotated/compressed log file is meaningfully larger,
123+ # producing fewer, larger per-stream uploads instead of many tiny ones.
124+ # The min is the steady-state file size at low event rates; the max
125+ # caps memory by bounding how many events a buffer holds in RAM.
122126 self .dynamic_limits = {
123- 'vfs' : (8000 , 500000 ),
124- 'block' : (8000 , 50000 ),
125- 'cache' : (20000 , 1000000 ),
126- 'fs_state' : (8000 , 20000 ),
127- 'proc_state' : (8000 , 10000 ), # Match new process_max_events threshold
128- 'pagefault' : (8000 , 100000 ),
129- 'io_uring' : (8000 , 200000 ),
127+ 'vfs' : (80000 , 800000 ),
128+ 'block' : (80000 , 400000 ),
129+ 'cache' : (100000 , 1000000 ),
130+ 'fs_state' : (80000 , 200000 ),
131+ 'proc_state' : (80000 , 100000 ),
132+ 'pagefault' : (80000 , 400000 ),
133+ 'io_uring' : (80000 , 400000 ),
130134 }
131135
132136 # Start adaptive sizing thread
@@ -140,14 +144,15 @@ def __init__(self, output_dir: str, upload_manager: ObjectStorageManager, automa
140144 self .periodic_flush_thread .start ()
141145
142146
143- # Buffer flush thresholds
144- self .cache_max_events = 20000
145- self .vfs_max_events = 8000
146- self .block_max_events = 8000
147- self .process_max_events = 8000 # Large enough to fit entire hourly snapshot
148- self .fs_snap_max_events = 8000
149- self .pagefault_max_events = 8000
150- self .io_uring_max_events = 8000
147+ # Buffer flush thresholds. Raised ~10x so each rotated log file holds
148+ # more events and uploads larger (kept in sync with dynamic_limits).
149+ self .cache_max_events = 100000
150+ self .vfs_max_events = 80000
151+ self .block_max_events = 80000
152+ self .process_max_events = 80000 # Large enough to fit entire hourly snapshot
153+ self .fs_snap_max_events = 80000
154+ self .pagefault_max_events = 80000
155+ self .io_uring_max_events = 80000
151156
152157 # Per-stream locks. Buffer flushes are triggered both from the
153158 # perf-callback (polling) thread via append_*_log -> flush_*_only and
@@ -173,6 +178,31 @@ def __init__(self, output_dir: str, upload_manager: ObjectStorageManager, automa
173178 self ._fs_snap_handle = None
174179 self ._io_uring_handle = None
175180
181+ # Registry of the continuous event streams that support generic
182+ # rotation. Snapshots (process, fs_snap) are intentionally excluded:
183+ # rotating one mid-session would split a single logical snapshot.
184+ # Each entry names the attributes that hold its buffer, file handle,
185+ # and current output path, plus its output subdir/prefix and log label.
186+ self ._streams = {
187+ 'vfs' : {'subdir' : 'fs' , 'prefix' : 'fs' , 'buf' : 'vfs_buffer' , 'handle' : '_vfs_handle' , 'file' : 'output_vfs_file' , 'log' : 'VFS' },
188+ 'block' : {'subdir' : 'ds' , 'prefix' : 'ds' , 'buf' : 'block_buffer' , 'handle' : '_block_handle' , 'file' : 'output_block_file' , 'log' : 'Block' },
189+ 'cache' : {'subdir' : 'cache' , 'prefix' : 'cache' , 'buf' : 'cache_buffer' , 'handle' : '_cache_handle' , 'file' : 'output_cache_file' , 'log' : 'Cache' },
190+ 'pagefault' : {'subdir' : 'pagefault' , 'prefix' : 'pagefault' , 'buf' : 'pagefault_buffer' , 'handle' : '_pagefault_handle' , 'file' : 'output_pagefault_file' , 'log' : 'PageFault' },
191+ 'io_uring' : {'subdir' : 'io_uring' , 'prefix' : 'io_uring' , 'buf' : 'io_uring_buffer' , 'handle' : '_io_uring_handle' , 'file' : 'output_io_uring_file' , 'log' : 'IO_Uring' },
192+ }
193+
194+ # Time/size based rotation so a slow stream's log doesn't wait until
195+ # shutdown to upload. A stream's current file is rotated (compressed +
196+ # queued for upload) once it is older than max_file_age or larger than
197+ # max_file_bytes, even if it never reaches its event-count threshold.
198+ # Monotonic open-times so the age check ignores wall-clock jumps.
199+ self .max_file_age = 20 * 60 # 20 minutes
200+ self .max_file_bytes = 100 * 1024 * 1024 # 100 MB (uncompressed on disk)
201+ self ._stream_opened = {key : time .monotonic () for key in self ._streams }
202+ # Per-stream rotation sequence, appended to each rotated filename so two
203+ # rotations in the same millisecond can't collide on the timestamp.
204+ self ._stream_seq = {key : 0 for key in self ._streams }
205+
176206 # Cache sampling configuration
177207 self .cache_sample_rate = 1 # Can be increased to reduce cache event volume
178208 self .cache_event_counter = 0
@@ -187,12 +217,6 @@ def __init__(self, output_dir: str, upload_manager: ObjectStorageManager, automa
187217 # Process snapshot session tracking
188218 self .process_snapshot_session_active = False
189219
190- # Bundle upload tracking: accumulate files and upload as a single tar
191- self ._pending_bundle : list [str ] = []
192- self ._bundle_lock = threading .Lock ()
193- self ._bundle_counter = 0
194- self .bundle_size = 5
195-
196220 def _calculate_event_rate (self , event_type : str ) -> float :
197221 """
198222 Calculate the event rate for a given event type.
@@ -281,6 +305,13 @@ def _periodic_flush(self):
281305 except Exception as e :
282306 logger ("error" , f"Error in periodic flush: { e } " )
283307
308+ # Rotate+upload any log that has grown too large or aged too long,
309+ # so slow streams don't defer their upload to shutdown.
310+ try :
311+ self ._maybe_rotate_stale_logs ()
312+ except Exception as e :
313+ logger ("error" , f"Error rotating stale logs: { e } " )
314+
284315 def _reset_flush_timer (self ):
285316 """Reset the periodic flush timer (called after manual flushes)."""
286317 self ._last_flush_time = time .time ()
@@ -489,7 +520,7 @@ def direct_write(self, output_path: str, spec_str: str):
489520 with open (dst , 'w' ) as f :
490521 f .write (spec_str )
491522 if self .automatic_upload :
492- self ._add_to_bundle (dst )
523+ self .upload_manager . append_object (dst )
493524 except Exception as e :
494525 logger ("error" , f"Error writing device spec to { output_path } : { e } " )
495526
@@ -625,7 +656,7 @@ def mark_fs_snapshot_complete(self):
625656 logger ('info' , f"Files Created: { str (self .created_files )} (filesystem snapshot with { num_parts } parts)" , True )
626657 for part_file in self .fs_snapshot_parts_pending_upload :
627658 if os .path .exists (part_file ):
628- self ._add_to_bundle (part_file )
659+ self .upload_manager . append_object (part_file )
629660 self .fs_snapshot_parts_pending_upload .clear ()
630661
631662 except Exception as e :
@@ -662,99 +693,99 @@ def flush_process_state_only(self):
662693 self .compress_log (rotated )
663694
664695 def flush_cache_only (self ):
665- """Flush cache buffer to file."""
666- rotated = None
667- with self ._stream_locks ['cache' ]:
668- if self .cache_buffer :
669- if self ._cache_handle is None :
670- self ._cache_handle = open (self .output_cache_file , 'a' , buffering = 8192 )
671- self .current_datetime = datetime .now ()
696+ """Flush cache buffer to file (rotate + compress + upload)."""
697+ self ._rotate_stream ('cache' )
672698
673- self ._write_buffer_to_file (self .cache_buffer , self ._cache_handle , "Cache" )
674- self ._cache_handle .close ()
675- rotated = self .output_cache_file
676- self .output_cache_file = f"{ self .output_dir } /cache/cache_{ self .current_datetime .strftime ('%Y%m%d_%H%M%S_%f' )[:- 3 ]} .csv"
677- self ._cache_handle = open (self .output_cache_file , 'a' , buffering = 8192 )
678- self ._reset_flush_timer ()
679- if rotated is not None :
680- self .compress_log (rotated )
699+ def flush_vfs_only (self ):
700+ """Flush VFS buffer to file (rotate + compress + upload)."""
701+ self ._rotate_stream ('vfs' )
681702
703+ def flush_block_only (self ):
704+ """Flush block buffer to file (rotate + compress + upload)."""
705+ self ._rotate_stream ('block' )
682706
683- def flush_vfs_only (self ):
684- """Flush VFS buffer to file."""
685- rotated = None
686- with self ._stream_locks ['vfs' ]:
687- if self .vfs_buffer :
688- if self ._vfs_handle is None :
689- self ._vfs_handle = open (self .output_vfs_file , 'a' , buffering = 8192 )
690- self .current_datetime = datetime .now ()
707+ def flush_pagefault_only (self ):
708+ """Flush pagefault buffer to file (rotate + compress + upload)."""
709+ self ._rotate_stream ('pagefault' )
691710
692- self ._write_buffer_to_file (self .vfs_buffer , self ._vfs_handle , "VFS" )
693- # Close before compressing so we never gzip/delete a file that
694- # still has an open descriptor; rotate to a fresh output file.
695- self ._vfs_handle .close ()
696- rotated = self .output_vfs_file
697- self .output_vfs_file = f"{ self .output_dir } /fs/fs_{ self .current_datetime .strftime ('%Y%m%d_%H%M%S_%f' )[:- 3 ]} .csv"
698- self ._vfs_handle = open (self .output_vfs_file , 'a' , buffering = 8192 )
699- self ._reset_flush_timer ()
700- # Compress outside the lock: gzip + disk I/O is slow and must not block
701- # the perf-callback flush or the periodic writer for this stream.
702- if rotated is not None :
703- self .compress_log (rotated )
711+ def flush_io_uring_only (self ):
712+ """Flush io_uring buffer to file (rotate + compress + upload)."""
713+ self ._rotate_stream ('io_uring' )
704714
705- def flush_block_only (self ):
706- """Flush block buffer to file."""
715+ def _rotate_stream (self , key : str ):
716+ """Rotate one continuous stream's current log and queue it for upload.
717+
718+ Flushes any buffered rows into the current file, closes it, swaps in a
719+ fresh timestamped output file, and compresses/uploads the rotated one.
720+ Works whether the rows are still buffered (event-count flush) or were
721+ already written to disk by the periodic writer (time/size rotation).
722+ A no-op when there is nothing on disk or buffered to rotate.
723+ """
724+ s = self ._streams [key ]
707725 rotated = None
708- with self ._stream_locks ['block' ]:
709- if self .block_buffer :
710- if self ._block_handle is None :
711- self ._block_handle = open (self .output_block_file , 'a' , buffering = 8192 )
726+ with self ._stream_locks [key ]:
727+ buf = getattr (self , s ['buf' ])
728+ handle = getattr (self , s ['handle' ])
729+ cur_file = getattr (self , s ['file' ])
730+
731+ # Land any buffered rows in the current file first.
732+ if buf :
733+ if handle is None :
734+ handle = open (cur_file , 'a' , buffering = 8192 )
735+ setattr (self , s ['handle' ], handle )
712736 self .current_datetime = datetime .now ()
737+ self ._write_buffer_to_file (buf , handle , s ['log' ])
713738
714- self ._write_buffer_to_file (self .block_buffer , self ._block_handle , "Block" )
715- self ._block_handle .close ()
716- rotated = self .output_block_file
717- self .output_block_file = f"{ self .output_dir } /ds/ds_{ self .current_datetime .strftime ('%Y%m%d_%H%M%S_%f' )[:- 3 ]} .csv"
718- self ._block_handle = open (self .output_block_file , 'a' , buffering = 8192 )
719- self ._reset_flush_timer ()
720- if rotated is not None :
721- self .compress_log (rotated )
739+ # Close before compressing so we never gzip/delete an open file.
740+ if handle is not None :
741+ handle .close ()
742+ setattr (self , s ['handle' ], None )
722743
723- def flush_pagefault_only (self ):
724- """Flush pagefault buffer to file."""
725- rotated = None
726- with self ._stream_locks ['pagefault' ]:
727- if self .pagefault_buffer :
728- if self ._pagefault_handle is None :
729- self ._pagefault_handle = open (self .output_pagefault_file , 'a' , buffering = 8192 )
730- self .current_datetime = datetime .now ()
744+ # Skip rotating an empty file (avoids zero-byte uploads); just keep
745+ # appending to it.
746+ if not os .path .exists (cur_file ) or os .path .getsize (cur_file ) == 0 :
747+ setattr (self , s ['handle' ], open (cur_file , 'a' , buffering = 8192 ))
748+ return
731749
732- self ._write_buffer_to_file (self .pagefault_buffer , self ._pagefault_handle , "PageFault" )
733- self ._pagefault_handle .close ()
734- rotated = self .output_pagefault_file
735- self .output_pagefault_file = f"{ self .output_dir } /pagefault/pagefault_{ self .current_datetime .strftime ('%Y%m%d_%H%M%S_%f' )[:- 3 ]} .csv"
736- self ._pagefault_handle = open (self .output_pagefault_file , 'a' , buffering = 8192 )
737- self ._reset_flush_timer ()
750+ rotated = cur_file
751+ self ._stream_seq [key ] += 1
752+ new_file = (
753+ f"{ self .output_dir } /{ s ['subdir' ]} /{ s ['prefix' ]} _"
754+ f"{ datetime .now ().strftime ('%Y%m%d_%H%M%S_%f' )[:- 3 ]} _"
755+ f"{ self ._stream_seq [key ]:04d} .csv"
756+ )
757+ setattr (self , s ['file' ], new_file )
758+ setattr (self , s ['handle' ], open (new_file , 'a' , buffering = 8192 ))
759+ self ._stream_opened [key ] = time .monotonic ()
760+ self ._reset_flush_timer ()
761+ # Compress outside the lock: gzip + disk I/O is slow and must not block
762+ # the perf-callback flush or the periodic writer for this stream.
738763 if rotated is not None :
739764 self .compress_log (rotated )
740765
741- def flush_io_uring_only (self ):
742- """Flush io_uring buffer to file."""
743- rotated = None
744- with self ._stream_locks ['io_uring' ]:
745- if self .io_uring_buffer :
746- if self ._io_uring_handle is None :
747- self ._io_uring_handle = open (self .output_io_uring_file , 'a' , buffering = 8192 )
748- self .current_datetime = datetime .now ()
766+ def _maybe_rotate_stale_logs (self , now : float | None = None ):
767+ """Rotate continuous logs that have grown too large or aged too long.
749768
750- self ._write_buffer_to_file (self .io_uring_buffer , self ._io_uring_handle , "IO_Uring" )
751- self ._io_uring_handle .close ()
752- rotated = self .output_io_uring_file
753- self .output_io_uring_file = f"{ self .output_dir } /io_uring/io_uring_{ self .current_datetime .strftime ('%Y%m%d_%H%M%S_%f' )[:- 3 ]} .csv"
754- self ._io_uring_handle = open (self .output_io_uring_file , 'a' , buffering = 8192 )
755- self ._reset_flush_timer ()
756- if rotated is not None :
757- self .compress_log (rotated )
769+ Lets a slow stream upload mid-trace instead of waiting until shutdown:
770+ any stream whose current file exceeds ``max_file_bytes`` or has been
771+ open longer than ``max_file_age`` is rotated and queued for upload.
772+ ``now`` is an injectable ``time.monotonic()`` reading for testing.
773+ """
774+ if now is None :
775+ now = time .monotonic ()
776+ for key , s in self ._streams .items ():
777+ cur_file = getattr (self , s ['file' ])
778+ try :
779+ if not os .path .exists (cur_file ):
780+ continue
781+ size = os .path .getsize (cur_file )
782+ except OSError :
783+ continue
784+ if size <= 0 :
785+ continue
786+ age = now - self ._stream_opened .get (key , now )
787+ if size >= self .max_file_bytes or age >= self .max_file_age :
788+ self ._rotate_stream (key )
758789
759790 def force_flush (self ):
760791 """Flush all buffers and compress all output files."""
@@ -797,8 +828,6 @@ def force_flush(self):
797828
798829 self .compress_log (self .output_pagefault_file )
799830 self .compress_log (self .output_io_uring_file )
800- if self .automatic_upload :
801- self ._flush_bundle ()
802831 self .compress_dir (self .output_dir )
803832
804833
@@ -937,52 +966,6 @@ def write_io_uring():
937966
938967 self .clear_events ()
939968
940- def _add_to_bundle (self , file_path : str ):
941- """Queue a compressed file for bundled upload."""
942- with self ._bundle_lock :
943- self ._pending_bundle .append (file_path )
944- should_flush = len (self ._pending_bundle ) >= self .bundle_size
945- if should_flush :
946- self ._flush_bundle ()
947-
948- def _flush_bundle (self ):
949- """Pack all pending files into one tar and queue it for upload."""
950- with self ._bundle_lock :
951- if not self ._pending_bundle :
952- return
953- files_to_bundle = list (self ._pending_bundle )
954- self ._pending_bundle .clear ()
955- self ._bundle_counter += 1
956- counter = self ._bundle_counter
957-
958- bundle_dir = os .path .dirname (self .output_dir .rstrip ("/\\ " ))
959- bundle_ts = datetime .now ().strftime ('%Y%m%d_%H%M%S_%f' )[:- 3 ]
960- bundle_path = os .path .join (bundle_dir , f"bundle_{ counter :04d} _{ bundle_ts } .tar" )
961-
962- try :
963- with tarfile .open (bundle_path , "w" ) as tar :
964- for f in files_to_bundle :
965- if os .path .exists (f ):
966- tar .add (f , arcname = os .path .relpath (f , bundle_dir ))
967- # Tar closed successfully — safe to delete sources now
968- for f in files_to_bundle :
969- if os .path .exists (f ):
970- try :
971- os .remove (f )
972- except OSError as rm_err :
973- logger ("warning" , f"Failed to remove bundled file { f } : { rm_err } " )
974- self .upload_manager .append_object (bundle_path )
975- except Exception as e :
976- logger ("error" , f"Failed to create upload bundle: { e } " )
977- if os .path .exists (bundle_path ):
978- try :
979- os .remove (bundle_path )
980- except OSError :
981- pass
982- for f in files_to_bundle :
983- if os .path .exists (f ):
984- self .upload_manager .append_object (f )
985-
986969 def compress_log (self , input_file : str ):
987970 """
988971 Compress a log file with gzip and optionally upload.
@@ -1005,7 +988,9 @@ def compress_log(self, input_file: str):
1005988 if self .automatic_upload :
1006989 self .created_files += 1
1007990 logger ('info' , f"Files Created: { str (self .created_files )} " , True )
1008- self ._add_to_bundle (dst )
991+ # Upload each compressed log individually, preserving its
992+ # subdirectory (fs, ds, cache, process, ...) on the backend.
993+ self .upload_manager .append_object (dst )
1009994 os .remove (src )
1010995 except Exception as e :
1011996 logger ("error" , f"Failed compressing log { input_file } : { e } " )
0 commit comments