Skip to content

Commit e62b291

Browse files
committed
Address PR review: testable age flush, thread-safe tests, accurate docstring
- Extract the age-based bundle flush from _periodic_flush into _maybe_flush_bundle_by_age(now=...), with an injectable clock reading so the 20-minute time trigger can be unit tested without sleeping. - Add tests covering the age-triggered merge/upload, the below-threshold no-op, and the empty-buffer no-op. - Run bundle tests through a SilentWriteManager subclass that neuters the adaptive-sizing and periodic-flush background threads, so they don't linger across runs or fire timers mid-assertion. - Fix the _add_to_bundle docstring to note a size-triggered flush/merge/ upload can happen from that method.
1 parent 292c7ee commit e62b291

2 files changed

Lines changed: 87 additions & 18 deletions

File tree

src/tracer/WriterManager.py

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -290,18 +290,34 @@ def _periodic_flush(self):
290290
except Exception as e:
291291
logger("error", f"Error in periodic flush: {e}")
292292

293-
# Age-based bundle flush: merge and upload any locally buffered
294-
# files once the oldest has waited bundle_max_interval (20 min),
295-
# even if the 100 MB size threshold was never reached.
296-
if self.automatic_upload:
297-
with self._bundle_lock:
298-
has_pending = bool(self._pending_bundle)
299-
bundle_age = time.monotonic() - self._bundle_window_start
300-
if has_pending and bundle_age >= self.bundle_max_interval:
301-
try:
302-
self._flush_bundle()
303-
except Exception as e:
304-
logger("error", f"Error in periodic bundle flush: {e}")
293+
try:
294+
self._maybe_flush_bundle_by_age()
295+
except Exception as e:
296+
logger("error", f"Error in periodic bundle flush: {e}")
297+
298+
def _maybe_flush_bundle_by_age(self, now: float | None = None) -> bool:
299+
"""Merge and upload buffered files if the oldest has waited too long.
300+
301+
Triggers a bundle flush once the buffering window has been open for at
302+
least ``bundle_max_interval`` (20 min), even if the ``bundle_max_bytes``
303+
size threshold was never reached. ``now`` is an injectable
304+
``time.monotonic()`` reading so the age trigger can be unit tested
305+
without sleeping for the full interval.
306+
307+
Returns:
308+
bool: True if a flush was triggered, False otherwise.
309+
"""
310+
if not self.automatic_upload:
311+
return False
312+
if now is None:
313+
now = time.monotonic()
314+
with self._bundle_lock:
315+
has_pending = bool(self._pending_bundle)
316+
bundle_age = now - self._bundle_window_start
317+
if has_pending and bundle_age >= self.bundle_max_interval:
318+
self._flush_bundle()
319+
return True
320+
return False
305321

306322
def _reset_flush_timer(self):
307323
"""Reset the periodic flush timer (called after manual flushes)."""
@@ -963,9 +979,10 @@ def _add_to_bundle(self, file_path: str):
963979
"""Buffer a compressed file locally for a later merged upload.
964980
965981
Files accumulate on local disk until the buffered size reaches
966-
``bundle_max_bytes``; the age-based flush (``bundle_max_interval``) is
967-
handled separately by the periodic flush thread. Nothing is uploaded
968-
here, only queued for the eventual merge.
982+
``bundle_max_bytes``, at which point this method triggers
983+
``_flush_bundle`` to merge them into a single tar and queue it for
984+
upload. The age-based flush (``bundle_max_interval``) is handled
985+
separately by the periodic flush thread.
969986
"""
970987
try:
971988
file_size = os.path.getsize(file_path)

tests/test_writer_bundle.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,31 @@ def append_object(self, file_path):
4343
self.uploaded.append(file_path)
4444

4545

46+
class SilentWriteManager(WriteManager):
47+
"""WriteManager with its background threads neutered for tests.
48+
49+
``__init__`` still spins up the adaptive-sizing and periodic-flush threads,
50+
but overriding their targets with no-ops makes them exit immediately so
51+
they neither linger across test runs nor fire timers during assertions.
52+
"""
53+
54+
def _adaptive_sizing(self):
55+
return
56+
57+
def _periodic_flush(self):
58+
return
59+
60+
4661
class BundleBufferingTests(unittest.TestCase):
4762
def setUp(self):
4863
self.tmp = tempfile.mkdtemp()
4964
self.output_dir = os.path.join(self.tmp, "trace")
5065
self.upload = FakeUploadManager()
51-
self.wm = WriteManager(
66+
self.wm = SilentWriteManager(
5267
output_dir=self.output_dir,
5368
upload_manager=self.upload,
5469
automatic_upload=True,
5570
)
56-
# Stop the background threads so they don't interfere with assertions.
57-
self.wm._periodic_flush_active = False
5871

5972
def tearDown(self):
6073
import shutil
@@ -97,6 +110,45 @@ def test_size_threshold_triggers_merge_and_upload(self):
97110
with tarfile.open(bundle_path) as tar:
98111
self.assertEqual(len(tar.getmembers()), 3)
99112

113+
def test_age_threshold_triggers_merge_and_upload(self):
114+
# Keep the size threshold high so only the age trigger can fire.
115+
self.wm.bundle_max_bytes = 10_000
116+
self.wm.bundle_max_interval = 1200 # 20 minutes
117+
f = self._make_file("a.csv.gz", 100)
118+
self.wm._add_to_bundle(f)
119+
self.assertEqual(self.upload.uploaded, []) # not old enough yet
120+
121+
# Inject a monotonic reading just past the interval.
122+
now = self.wm._bundle_window_start + 1201
123+
flushed = self.wm._maybe_flush_bundle_by_age(now=now)
124+
125+
self.assertTrue(flushed)
126+
self.assertEqual(len(self.upload.uploaded), 1)
127+
bundle_path = self.upload.uploaded[0]
128+
self.assertTrue(bundle_path.endswith(".tar"))
129+
with tarfile.open(bundle_path) as tar:
130+
self.assertEqual(len(tar.getmembers()), 1)
131+
self.assertEqual(self.wm._pending_bundle, [])
132+
self.assertEqual(self.wm._pending_bundle_bytes, 0)
133+
self.assertFalse(os.path.exists(f))
134+
135+
def test_age_below_threshold_does_not_flush(self):
136+
self.wm.bundle_max_bytes = 10_000
137+
self.wm.bundle_max_interval = 1200
138+
f = self._make_file("a.csv.gz", 100)
139+
self.wm._add_to_bundle(f)
140+
141+
now = self.wm._bundle_window_start + 5 # well under the interval
142+
self.assertFalse(self.wm._maybe_flush_bundle_by_age(now=now))
143+
self.assertEqual(self.upload.uploaded, [])
144+
self.assertEqual(self.wm._pending_bundle, [f])
145+
146+
def test_age_flush_is_noop_when_empty(self):
147+
# No buffered files: even a huge age must not flush or upload.
148+
now = self.wm._bundle_window_start + 999_999
149+
self.assertFalse(self.wm._maybe_flush_bundle_by_age(now=now))
150+
self.assertEqual(self.upload.uploaded, [])
151+
100152
def test_window_start_resets_on_new_buffer(self):
101153
self.wm.bundle_max_bytes = 10_000
102154
old = time.monotonic() - 9999

0 commit comments

Comments
 (0)