Skip to content

Commit 95c7863

Browse files
committed
Switch trace compression from gzip to Zstandard (.zst)
Replace gzip with Zstandard for all trace output: - compress_log now writes .zst via a shared compress_file_zstd helper (stream compression); per-stream logs upload as .csv.zst. - compress_dir writes the final session archive as .tar.zst (tar streamed through a zstd stream_writer). - Multi-part filesystem snapshots compress to .csv.zst. - utils gains compress_file_zstd / create_tar_zst and a ZSTD_LEVEL constant; zstandard is imported lazily so importing the modules never requires the dependency (matches the requests pattern). Drop the now-unused gzip imports. - Add requirements.txt (requests, zstandard) and install zstandard in the unit-tests workflow so the compression round-trip is actually exercised. - Tests assert .zst output and round-trip via zstandard, skipped when the optional dependency is absent.
1 parent 95e41ef commit 95c7863

7 files changed

Lines changed: 104 additions & 55 deletions

File tree

.github/workflows/unit-tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,8 @@ jobs:
2727
with:
2828
python-version: '3.11'
2929

30+
- name: Install zstandard (used by the compression tests)
31+
run: python3 -m pip install --upgrade "zstandard>=0.21"
32+
3033
- name: Run unit tests
3134
run: python3 -m unittest discover -s tests -v

requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Runtime dependencies for the io-tracer userspace tooling.
2+
# Note: bcc (python3-bpfcc) is installed via the system package manager, not pip.
3+
requests>=2.25
4+
zstandard>=0.21

src/tracer/ObjectStorageManager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
Example:
1414
manager = ObjectStorageManager(version="vRelease")
1515
manager.test_connection() # Check if server is reachable
16-
manager.put_object("/path/to/trace.tar.gz") # Upload a file
16+
manager.put_object("/path/to/trace.tar.zst") # Upload a file
1717
"""
1818

1919
import mimetypes

src/tracer/WriterManager.py

Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
- Creating output directory structure
66
- Buffering trace events for different subsystems
77
- Writing events to CSV files
8-
- Compressing output files with gzip
8+
- Compressing output files with Zstandard (.zst)
99
- Optionally uploading files to cloud storage
1010
1111
The manager uses adaptive buffering to handle high event rates and
@@ -29,10 +29,12 @@
2929
import tarfile
3030

3131
from .ObjectStorageManager import ObjectStorageManager
32-
from ..utility.utils import logger, create_tar_gz, capture_machine_id, compress_log
32+
from ..utility.utils import (
33+
logger, create_tar_zst, capture_machine_id, compress_log,
34+
compress_file_zstd, ZSTD_LEVEL,
35+
)
3336
import threading
3437
from collections import deque
35-
import gzip
3638
import shutil
3739
import time
3840

@@ -501,7 +503,7 @@ def flush_fssnap_only(self):
501503
Flush filesystem snapshot buffer to a multi-part file.
502504
503505
Writes buffer to filesystem_snapshot_part####_TIMESTAMP_DEVICEID.csv,
504-
compresses it with gzip, and increments the part counter.
506+
compresses it with Zstandard, and increments the part counter.
505507
"""
506508
with self._stream_locks['fs_snap']:
507509
if not self.fs_snap_buffer:
@@ -533,15 +535,13 @@ def flush_fssnap_only(self):
533535
self._fs_snap_handle.close()
534536
self._fs_snap_handle = None
535537

536-
# Compress with gzip
538+
# Compress with Zstandard
537539
if os.path.exists(part_filepath):
538540
# Don't log or count each part - we'll log when snapshot is complete
539-
with open(part_filepath, "rb") as f_in:
540-
with gzip.open(part_filepath + ".gz", "wb") as f_out:
541-
shutil.copyfileobj(f_in, f_out)
541+
compress_file_zstd(part_filepath, part_filepath + ".zst")
542542

543543
os.remove(part_filepath)
544-
compressed_file = part_filepath + ".gz"
544+
compressed_file = part_filepath + ".zst"
545545

546546
# Store for later upload (after snapshot completion and final part rename)
547547
if self.automatic_upload:
@@ -593,15 +593,15 @@ def mark_fs_snapshot_complete(self):
593593
old_filename = (
594594
f"filesystem_snapshot_part{last_part_str}_"
595595
f"{self.fs_snapshot_timestamp}_"
596-
f"{self.fs_snapshot_device_id}.csv.gz"
596+
f"{self.fs_snapshot_device_id}.csv.zst"
597597
)
598598
old_filepath = f"{self.output_dir}/filesystem_snapshot/{old_filename}"
599599

600600
# Construct new filename with completion marker
601601
new_filename = (
602602
f"filesystem_snapshot_part{last_part_str}_"
603603
f"{self.fs_snapshot_timestamp}_"
604-
f"{self.fs_snapshot_device_id}_complete_parts{total_parts}.csv.gz"
604+
f"{self.fs_snapshot_device_id}_complete_parts{total_parts}.csv.zst"
605605
)
606606
new_filepath = f"{self.output_dir}/filesystem_snapshot/{new_filename}"
607607

@@ -922,22 +922,20 @@ def write_pagefault():
922922

923923
def compress_log(self, input_file: str):
924924
"""
925-
Compress a log file with gzip and optionally upload.
926-
925+
Compress a log file with Zstandard and optionally upload.
926+
927927
Args:
928928
input_file: Path to the file to compress
929929
"""
930930
try:
931931
src = input_file
932-
dst = input_file + ".gz"
933-
932+
dst = input_file + ".zst"
933+
934934
# Check if file exists (may already be compressed for multi-part files)
935935
if not os.path.exists(src):
936936
return
937-
938-
with open(src, "rb") as f_in:
939-
with gzip.open(dst, "wb") as f_out:
940-
shutil.copyfileobj(f_in, f_out) # type: ignore
937+
938+
compress_file_zstd(src, dst)
941939

942940
if self.automatic_upload:
943941
self.created_files += 1
@@ -951,17 +949,21 @@ def compress_log(self, input_file: str):
951949

952950
def compress_dir(self, input_dir: str):
953951
"""
954-
Compress a directory to tar.gz and optionally upload.
955-
952+
Compress a directory to tar.zst and optionally upload.
953+
956954
Args:
957955
input_dir: Path to the directory to compress
958956
"""
959957
try:
958+
import zstandard
960959
src = input_dir
961-
dst = input_dir.rstrip("/").rstrip("\\") + ".tar.gz"
960+
dst = input_dir.rstrip("/").rstrip("\\") + ".tar.zst"
962961

963-
with tarfile.open(dst, "w:gz") as tar:
964-
tar.add(src, arcname=os.path.basename(src))
962+
cctx = zstandard.ZstdCompressor(level=ZSTD_LEVEL)
963+
with open(dst, "wb") as f_out:
964+
with cctx.stream_writer(f_out) as compressor:
965+
with tarfile.open(mode="w|", fileobj=compressor) as tar:
966+
tar.add(src, arcname=os.path.basename(src))
965967

966968
if self.automatic_upload:
967969
self.created_files += 1

src/tracer/snappers/FilesystemSnapper.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from ..WriterManager import WriteManager
2121
from pathlib import Path
2222
from datetime import datetime
23-
import gzip
2423
import shutil
2524
import os
2625
import time

src/utility/utils.py

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@
2828
import time
2929
import datetime
3030
import tarfile
31-
import gzip
32-
import shutil
3331
import hashlib
3432
import socket
3533
import struct
@@ -194,34 +192,58 @@ def logger(error_scale: str, string: str, timestamp: bool = False):
194192
logo += f" [{formatted_time}]"
195193
print(logo + " " + string)
196194

197-
def create_tar_gz(output_filename: str, files_to_archive: list[str]):
195+
# Zstandard compression level. 3 is the library default — a good
196+
# speed/ratio tradeoff for streaming large trace logs.
197+
ZSTD_LEVEL = 3
198+
199+
200+
def compress_file_zstd(src: str, dst: str, level: int = ZSTD_LEVEL):
198201
"""
199-
Create a gzipped tar archive from a list of files.
200-
202+
Stream-compress a file to Zstandard.
203+
201204
Args:
202-
output_filename: Name of the output .tar.gz file
205+
src: Path to the source file
206+
dst: Path to write the compressed (.zst) output
207+
level: Zstandard compression level
208+
209+
zstandard is imported lazily so environments that never compress (and the
210+
pure-Python unit tests) don't require the dependency at import time.
211+
"""
212+
import zstandard
213+
cctx = zstandard.ZstdCompressor(level=level)
214+
with open(src, "rb") as f_in, open(dst, "wb") as f_out:
215+
cctx.copy_stream(f_in, f_out)
216+
217+
218+
def create_tar_zst(output_filename: str, files_to_archive: list[str], level: int = ZSTD_LEVEL):
219+
"""
220+
Create a Zstandard-compressed tar archive from a list of files.
221+
222+
Args:
223+
output_filename: Name of the output .tar.zst file
203224
files_to_archive: List of file paths to include
225+
level: Zstandard compression level
204226
"""
205-
with tarfile.open(output_filename, "w:gz") as tar:
206-
for file_path in files_to_archive:
207-
tar.add(file_path, arcname=os.path.basename(file_path))
208-
logger("info", f"Created tar.gz archive: {output_filename}")
227+
import zstandard
228+
cctx = zstandard.ZstdCompressor(level=level)
229+
with open(output_filename, "wb") as f_out:
230+
with cctx.stream_writer(f_out) as compressor:
231+
with tarfile.open(mode="w|", fileobj=compressor) as tar:
232+
for file_path in files_to_archive:
233+
tar.add(file_path, arcname=os.path.basename(file_path))
234+
logger("info", f"Created tar.zst archive: {output_filename}")
235+
209236

210237
def compress_log(input_file: str):
211238
"""
212-
Compress a log file using gzip.
213-
239+
Compress a log file using Zstandard.
240+
214241
Args:
215242
input_file: Path to the file to compress
216-
217-
Creates input_file.gz and removes the original.
218-
"""
219-
src = input_file
220-
dst = input_file + ".gz"
221-
with open(src, "rb") as f_in:
222-
with gzip.open(dst, "wb") as f_out:
223-
shutil.copyfileobj(f_in, f_out) # type: ignore
224243
244+
Creates input_file.zst and removes the original.
245+
"""
246+
compress_file_zstd(input_file, input_file + ".zst")
225247
os.remove(input_file)
226248

227249
def capture_machine_id() -> str:

tests/test_writer_upload.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
pytest tests/
1212
"""
1313

14-
import gzip
1514
import os
1615
import sys
1716
import tempfile
@@ -29,6 +28,22 @@
2928
except ModuleNotFoundError:
3029
sys.modules["requests"] = types.ModuleType("requests")
3130

31+
# Compression uses Zstandard. Round-trip assertions are skipped when the
32+
# optional dependency isn't installed (CI installs it explicitly).
33+
try:
34+
import zstandard
35+
HAS_ZSTD = True
36+
except ModuleNotFoundError:
37+
HAS_ZSTD = False
38+
39+
40+
def _zstd_read_text(path):
41+
"""Decompress a .zst file to text for round-trip assertions."""
42+
dctx = zstandard.ZstdDecompressor()
43+
with open(path, "rb") as f:
44+
return dctx.stream_reader(f).read().decode()
45+
46+
3247
from src.tracer.WriterManager import WriteManager
3348

3449

@@ -78,22 +93,23 @@ def _make_log(self, subdir, name, text):
7893
f.write(text)
7994
return path
8095

81-
def test_compress_log_uploads_individual_gz(self):
96+
@unittest.skipUnless(HAS_ZSTD, "zstandard not installed")
97+
def test_compress_log_uploads_individual_zst(self):
8298
src = self._make_log("fs", "fs_x.csv", "a,b,c\n1,2,3\n")
8399
self.wm.compress_log(src)
84100

85101
# Exactly one upload, the compressed file — no tar bundle.
86102
self.assertEqual(len(self.upload.uploaded), 1)
87103
uploaded = self.upload.uploaded[0]
88-
self.assertTrue(uploaded.endswith(".csv.gz"))
104+
self.assertTrue(uploaded.endswith(".csv.zst"))
89105
self.assertFalse(uploaded.endswith(".tar"))
90106
self.assertTrue(os.path.exists(uploaded))
91107
# Source .csv is removed once compressed.
92108
self.assertFalse(os.path.exists(src))
93-
# Content round-trips through gzip.
94-
with gzip.open(uploaded, "rt") as f:
95-
self.assertEqual(f.read(), "a,b,c\n1,2,3\n")
109+
# Content round-trips through Zstandard.
110+
self.assertEqual(_zstd_read_text(uploaded), "a,b,c\n1,2,3\n")
96111

112+
@unittest.skipUnless(HAS_ZSTD, "zstandard not installed")
97113
def test_upload_preserves_subdirectory(self):
98114
# The backend file_type is derived from the parent directory, so each
99115
# stream must stay under its own subdir (fs, ds, cache, ...).
@@ -105,6 +121,7 @@ def test_upload_preserves_subdirectory(self):
105121
parents = {os.path.basename(os.path.dirname(p)) for p in self.upload.uploaded}
106122
self.assertEqual(parents, {"fs", "ds", "cache", "process"})
107123

124+
@unittest.skipUnless(HAS_ZSTD, "zstandard not installed")
108125
def test_no_upload_when_automatic_disabled(self):
109126
self.wm.automatic_upload = False
110127
src = self._make_log("fs", "fs_x.csv", "row\n")
@@ -149,6 +166,7 @@ def _write_current(self, file_attr, text):
149166
f.write(text)
150167
return path
151168

169+
@unittest.skipUnless(HAS_ZSTD, "zstandard not installed")
152170
def test_size_triggers_rotation(self):
153171
self.wm.max_file_bytes = 50
154172
self.wm.max_file_age = 10**9 # disable age trigger
@@ -158,12 +176,13 @@ def test_size_triggers_rotation(self):
158176

159177
self.assertEqual(len(self.upload.uploaded), 1)
160178
uploaded = self.upload.uploaded[0]
161-
self.assertTrue(uploaded.endswith(".csv.gz"))
179+
self.assertTrue(uploaded.endswith(".csv.zst"))
162180
self.assertEqual(os.path.basename(os.path.dirname(uploaded)), "fs")
163181
# Rotated to a fresh file; the old .csv is gone (compressed away).
164182
self.assertNotEqual(self.wm.output_vfs_file, path)
165183
self.assertFalse(os.path.exists(path))
166184

185+
@unittest.skipUnless(HAS_ZSTD, "zstandard not installed")
167186
def test_age_triggers_rotation(self):
168187
self.wm.max_file_bytes = 10**12 # disable size trigger
169188
self._write_current("output_block_file", "row\n")
@@ -188,15 +207,15 @@ def test_missing_or_empty_file_is_skipped(self):
188207
self.wm._maybe_rotate_stale_logs()
189208
self.assertEqual(self.upload.uploaded, [])
190209

210+
@unittest.skipUnless(HAS_ZSTD, "zstandard not installed")
191211
def test_rotate_flushes_buffered_rows(self):
192212
self.wm.vfs_buffer.append("a,b,c")
193213
self.wm.vfs_buffer.append("d,e,f")
194214

195215
self.wm._rotate_stream("vfs")
196216

197217
self.assertEqual(len(self.upload.uploaded), 1)
198-
with gzip.open(self.upload.uploaded[0], "rt") as f:
199-
content = f.read()
218+
content = _zstd_read_text(self.upload.uploaded[0])
200219
self.assertIn("a,b,c", content)
201220
self.assertIn("d,e,f", content)
202221
self.assertEqual(len(self.wm.vfs_buffer), 0)

0 commit comments

Comments
 (0)