Skip to content

Commit 00525f7

Browse files
viniciusdsmelloclaude
authored andcommitted
fix(tracing): harden offline buffer for concurrency and high volume
The offline buffer could lose or over-retain data under multi-process and high-volume use. Fixes mirror the openlayer-ts SDK: - store_trace: evict in a batch down to max_buffer_size instead of dropping a single file per call, so the cap holds even when multiple processes share the buffer directory and evict concurrently. Stat each candidate defensively so a file removed by another writer mid-scan doesn't abort the store (which previously dropped the trace). - Use the full UUID in filenames instead of an 8-char slice; worker threads share a PID, so a truncated id could collide and silently overwrite a buffered trace at high volume. - max_buffer_size=0 is now honored (store nothing) instead of being coerced to the 1000 default by 'or'. - clear_buffer isolates each removal so one failure no longer aborts the rest, and returns the count actually removed. - replay_buffered_traces always attempts each trace at least once, so max_retries=0 is no longer a silent no-op. Adds regression tests (each verified to fail on the prior implementation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 88891a0 commit 00525f7

2 files changed

Lines changed: 112 additions & 18 deletions

File tree

src/openlayer/lib/tracing/tracer.py

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,9 @@ def __init__(
384384
self.buffer_path = Path(
385385
buffer_path or os.path.expanduser("~/.openlayer/buffer")
386386
)
387-
self.max_buffer_size = max_buffer_size or 1000
387+
# Use an explicit None check so a caller-supplied 0 (buffer nothing) is
388+
# respected rather than falling through to the default.
389+
self.max_buffer_size = 1000 if max_buffer_size is None else max_buffer_size
388390
self._lock = threading.RLock()
389391

390392
# Create buffer directory if it doesn't exist
@@ -408,19 +410,41 @@ def store_trace(
408410
Returns:
409411
True if successfully stored, False otherwise
410412
"""
413+
if self.max_buffer_size <= 0:
414+
logger.debug("Offline buffer max_buffer_size <= 0; not storing trace")
415+
return False
411416
try:
412417
with self._lock:
413-
# Check buffer size limit
414-
existing_files = list(self.buffer_path.glob("trace_*.json"))
415-
if len(existing_files) >= self.max_buffer_size:
416-
# Remove oldest file to make room
417-
oldest_file = min(existing_files, key=lambda f: f.stat().st_mtime)
418-
oldest_file.unlink()
419-
logger.debug("Removed oldest buffered trace: %s", oldest_file)
420-
421-
# Create filename with timestamp and unique suffix
418+
# Trim the buffer so that, after adding one file, we stay within
419+
# the limit. Removing the whole excess in one batch (rather than
420+
# a single file per store) keeps the buffer bounded even when
421+
# multiple processes share the directory and evict concurrently;
422+
# otherwise each writer drops one file while many add and the cap
423+
# is never enforced.
424+
existing: List[Tuple[float, Path]] = []
425+
for existing_file in self.buffer_path.glob("trace_*.json"):
426+
try:
427+
existing.append((existing_file.stat().st_mtime, existing_file))
428+
except OSError:
429+
# File vanished (a concurrent writer evicted it); skip it.
430+
continue
431+
if len(existing) >= self.max_buffer_size:
432+
existing.sort(key=lambda pair: pair[0]) # oldest first
433+
remove_count = len(existing) - self.max_buffer_size + 1
434+
for _, old_file in existing[:remove_count]:
435+
try:
436+
old_file.unlink()
437+
except OSError:
438+
# Best effort - another writer may have removed it.
439+
pass
440+
logger.debug("Evicted %d old buffered trace(s)", remove_count)
441+
442+
# Create filename with timestamp and unique suffix. Use the full
443+
# UUID (not a truncated slice) so filenames stay unique even at
444+
# high volume within a single process: worker threads share a
445+
# PID, so a truncated id could collide and overwrite a trace.
422446
timestamp = int(time.time() * 1000) # milliseconds
423-
unique_id = str(uuid.uuid4())[:8] # Short unique identifier
447+
unique_id = str(uuid.uuid4())
424448
filename = f"trace_{timestamp}_{os.getpid()}_{unique_id}.json"
425449
file_path = self.buffer_path / filename
426450

@@ -534,19 +558,25 @@ def clear_buffer(self) -> int:
534558
Returns:
535559
Number of traces removed
536560
"""
561+
removed = 0
537562
try:
538563
with self._lock:
539564
trace_files = list(self.buffer_path.glob("trace_*.json"))
540-
count = len(trace_files)
541565

542566
for file_path in trace_files:
543-
file_path.unlink(missing_ok=True)
567+
# Isolate each removal so one failure does not abort the rest
568+
# and the returned count reflects what was actually removed.
569+
try:
570+
file_path.unlink(missing_ok=True)
571+
removed += 1
572+
except OSError as e:
573+
logger.error("Failed to remove buffered trace %s: %s", file_path, e)
544574

545-
logger.info("Cleared %d traces from offline buffer", count)
546-
return count
575+
logger.info("Cleared %d traces from offline buffer", removed)
576+
return removed
547577
except Exception as e:
548578
logger.error("Failed to clear buffer: %s", e)
549-
return 0
579+
return removed
550580

551581

552582
# Global offline buffer instance
@@ -1622,6 +1652,10 @@ def replay_buffered_traces(
16221652
"error": "No Openlayer client available",
16231653
}
16241654

1655+
# Always make at least one attempt, even if a caller passes 0 or a negative,
1656+
# so replay never silently no-ops while leaving traces buffered.
1657+
attempts = max(1, max_retries)
1658+
16251659
buffered_traces = offline_buffer.get_buffered_traces()
16261660
total_traces = len(buffered_traces)
16271661
success_count = 0
@@ -1640,7 +1674,7 @@ def replay_buffered_traces(
16401674
last_error = None
16411675

16421676
# Retry logic
1643-
for attempt in range(max_retries):
1677+
for attempt in range(attempts):
16441678
try:
16451679
response = client.inference_pipelines.data.stream(
16461680
inference_pipeline_id=inference_pipeline_id,
@@ -1680,7 +1714,7 @@ def replay_buffered_traces(
16801714
)
16811715

16821716
# If this is the last attempt, mark as failed
1683-
if attempt == max_retries - 1:
1717+
if attempt == attempts - 1:
16841718
failure_count += 1
16851719
failed_traces.append(
16861720
{

tests/test_offline_buffering.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,44 @@ def test_clear_buffer(self):
153153
traces = self.buffer.get_buffered_traces()
154154
assert len(traces) == 0
155155

156+
def test_store_trace_max_buffer_size_zero(self):
157+
"""A max_buffer_size of 0 stores nothing."""
158+
buf = OfflineBuffer(buffer_path=str(Path(self.temp_dir) / "zero_buf"), max_buffer_size=0)
159+
assert buf.max_buffer_size == 0
160+
assert buf.store_trace({"inferenceId": "x"}, {}, "p") is False
161+
assert len(buf.get_buffered_traces()) == 0
162+
163+
def test_store_trace_batch_eviction(self):
164+
"""Storing into an over-capacity backlog trims it down to the cap in one
165+
store, rather than dropping only a single file."""
166+
# Seed 10 files with a large cap so no eviction happens yet.
167+
big = OfflineBuffer(buffer_path=str(self.buffer_path), max_buffer_size=100)
168+
for i in range(10):
169+
big.store_trace({"inferenceId": f"b-{i}"}, {}, "p")
170+
assert len(list(self.buffer_path.glob("trace_*.json"))) == 10
171+
172+
# A buffer over the same dir with a small cap must trim the whole backlog
173+
# on the next store. Dropping one file per store is what let concurrent
174+
# writers blow past the cap.
175+
small = OfflineBuffer(buffer_path=str(self.buffer_path), max_buffer_size=3)
176+
small.store_trace({"inferenceId": "newest"}, {}, "p")
177+
assert len(list(self.buffer_path.glob("trace_*.json"))) == 3
178+
179+
def test_clear_buffer_resilient_to_failed_removal(self):
180+
"""clear_buffer keeps going when one entry can't be removed and reports
181+
the count actually removed."""
182+
self.buffer.store_trace({"inferenceId": "a"}, {}, "p")
183+
self.buffer.store_trace({"inferenceId": "b"}, {}, "p")
184+
# A directory matching the trace glob cannot be unlink()'d, simulating one
185+
# un-removable entry without mocking the filesystem.
186+
(self.buffer_path / "trace_999_0_undeletable.json").mkdir()
187+
188+
removed = self.buffer.clear_buffer()
189+
190+
assert removed == 2
191+
# The directory survives; the two real trace files are gone.
192+
assert len(list(self.buffer_path.glob("trace_*.json"))) == 1
193+
156194

157195
class TestTracerConfiguration:
158196
"""Test cases for tracer configuration with offline buffering."""
@@ -421,6 +459,28 @@ def on_failure(trace_data: Dict[str, Any], config: Dict[str, Any], error: Except
421459
traces = buffer.get_buffered_traces()
422460
assert len(traces) == 1
423461

462+
@patch("openlayer.lib.tracing.tracer._get_client")
463+
def test_replay_buffered_traces_max_retries_zero(self, mock_get_client: Mock) -> None:
464+
"""max_retries=0 still attempts each trace once instead of silently no-op."""
465+
mock_client = Mock()
466+
mock_client.inference_pipelines.data.stream.side_effect = Exception("API Error")
467+
mock_get_client.return_value = mock_client
468+
469+
init(offline_buffer_enabled=True, offline_buffer_path=self.temp_dir)
470+
buffer = _get_offline_buffer()
471+
assert buffer is not None
472+
buffer.store_trace({"inferenceId": "z1", "output": "o"}, {"output_column_name": "output"}, "test-pipeline")
473+
474+
failure_calls: List[Tuple[Dict[str, Any], Dict[str, Any], Exception]] = []
475+
result = replay_buffered_traces(
476+
max_retries=0,
477+
on_replay_failure=lambda td, cfg, err: failure_calls.append((td, cfg, err)),
478+
)
479+
480+
assert mock_client.inference_pipelines.data.stream.call_count == 1
481+
assert result["failure_count"] == 1
482+
assert len(failure_calls) == 1
483+
424484
def test_replay_buffered_traces_disabled(self):
425485
"""Test replay when buffer is disabled."""
426486
init(offline_buffer_enabled=False)

0 commit comments

Comments
 (0)