Skip to content

Commit 22eaf68

Browse files
authored
Merge pull request #38 from cacheMon/claude/sharp-davinci-4rkawz
Upload trace logs individually, enlarge per-stream logs, add time/size rotation
2 parents ed5ea53 + 757ca13 commit 22eaf68

2 files changed

Lines changed: 351 additions & 152 deletions

File tree

src/tracer/WriterManager.py

Lines changed: 137 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)