Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion openhands-sdk/openhands/sdk/conversation/event_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def __init__(self, fs: FileStore, dir_path: str = EVENTS_DIR) -> None:
self._dir = dir_path
self._id_to_idx: dict[EventID, int] = {}
self._idx_to_id: dict[int, EventID] = {}
self._event_cache: dict[int, Event] = {}
self._lock_path = f"{dir_path}/{LOCK_FILE_NAME}"
self._write_guard = None
self._length = self._scan_and_build_index()
Expand Down Expand Up @@ -89,6 +90,11 @@ def _get_single_item(self, idx: SupportsIndex) -> Event:
i += self._length
if i < 0 or i >= self._length:
raise IndexError("Event index out of range")

cached = self._event_cache.get(i)
if cached is not None:
return cached

try:
path = self._path(i)
except KeyError:
Expand All @@ -102,10 +108,16 @@ def _get_single_item(self, idx: SupportsIndex) -> Event:
txt = self._fs.read(path)
if not txt:
raise FileNotFoundError(f"Missing event file: {path}")
return Event.model_validate_json(txt)
evt = Event.model_validate_json(txt)
self._event_cache[i] = evt
return evt

def __iter__(self) -> Iterator[Event]:
for i in range(self._length):
cached = self._event_cache.get(i)
if cached is not None:
yield cached
continue
txt = self._fs.read(self._path(i))
if not txt:
continue
Expand All @@ -114,6 +126,7 @@ def __iter__(self) -> Iterator[Event]:
if i not in self._idx_to_id:
self._idx_to_id[i] = evt_id
self._id_to_idx.setdefault(evt_id, i)
self._event_cache[i] = evt
yield evt

def append(self, event: Event) -> None:
Expand Down Expand Up @@ -148,6 +161,7 @@ def append(self, event: Event) -> None:
self._fs.write(target_path, payload)
self._idx_to_id[self._length] = evt_id
self._id_to_idx[evt_id] = self._length
self._event_cache[self._length] = event
self._length += 1
except TimeoutError:
logger.error(
Expand Down Expand Up @@ -209,6 +223,7 @@ def _scan_and_build_index(self) -> int:
except Exception:
self._id_to_idx.clear()
self._idx_to_id.clear()
self._event_cache.clear()
return 0

by_idx: dict[int, EventID] = {}
Expand All @@ -225,6 +240,7 @@ def _scan_and_build_index(self) -> int:
if not by_idx:
self._id_to_idx.clear()
self._idx_to_id.clear()
self._event_cache.clear()
return 0

n = 0
Expand All @@ -240,6 +256,7 @@ def _scan_and_build_index(self) -> int:

self._id_to_idx.clear()
self._idx_to_id.clear()
self._event_cache.clear()
for i in range(n):
evt_id = by_idx[i]
self._idx_to_id[i] = evt_id
Expand Down
67 changes: 61 additions & 6 deletions tests/sdk/conversation/test_event_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,11 @@ def test_event_log_missing_event_file():
path = log._path(0, event_id="test-event")
fs.delete(path)

# Accessing the event should raise FileNotFoundError
# Cached access still works — the event was cached on append
assert log[0].id == "test-event"

# Clear cache to test the disk-access path
log._event_cache.clear()
with pytest.raises(FileNotFoundError):
log[0]

Expand Down Expand Up @@ -272,14 +276,17 @@ def test_event_log_iteration_with_missing_files():
path = log._path(1, event_id="event-2")
fs.delete(path)

# Iteration will fail when it hits the missing file
# This is expected behavior - the EventLog expects all files to exist
# Cached iteration still works — all 3 events were cached on append
assert [e.id for e in log] == ["event-1", "event-2", "event-3"]

# Clear cache to test the disk-access path
log._event_cache.clear()
with pytest.raises(FileNotFoundError):
list(log)


def test_event_log_iteration_backfills_missing_mappings():
"""Test that iteration fails when mappings are missing."""
"""Test that iteration fails when mappings are missing and cache is cold."""
fs = InMemoryFileStore()
log = EventLog(fs)

Expand All @@ -291,14 +298,15 @@ def test_event_log_iteration_backfills_missing_mappings():
assert len(log) == 1
assert log[0].id == "manual-event"

# Clear mappings to simulate missing data
# Clear mappings and cache to simulate missing data
log._idx_to_id.clear()
log._id_to_idx.clear()
log._event_cache.clear()

# But keep the length so iteration can work
log._length = 1

# Current implementation doesn't backfill mappings, so iteration fails
# Without cache or mappings, iteration fails on _path lookup
with pytest.raises(KeyError):
list(log)

Expand Down Expand Up @@ -441,3 +449,50 @@ def test_get_single_item_stale_index_out_of_range():
# Index 3 doesn't exist on disk; should raise IndexError after rebuild
with pytest.raises(IndexError, match="Event index out of range"):
log[3]


def test_event_cache_eliminates_repeated_deserialization():
"""Repeated access to the same event returns the cached object."""
fs = InMemoryFileStore()
log = EventLog(fs)

event = create_test_event("cached-event", "Hello")
log.append(event)

first = log[0]
second = log[0]
# Same object identity — no re-deserialization
assert first is second


def test_event_cache_populated_by_iteration():
"""Iterating the log populates the cache for subsequent indexed access."""
fs = InMemoryFileStore()
log = EventLog(fs)

for i in range(3):
log.append(create_test_event(f"evt-{i}", f"Content {i}"))

# Clear cache to force cold iteration
log._event_cache.clear()
events_from_iter = list(log)

# Cache should now hold all 3 events
assert len(log._event_cache) == 3

# Indexed access returns the same cached object
for i, evt in enumerate(events_from_iter):
assert log[i] is evt


def test_event_cache_survives_across_multiple_iterations():
"""Multiple iterations return the same cached objects."""
fs = InMemoryFileStore()
log = EventLog(fs)

log.append(create_test_event("a", "A"))
log.append(create_test_event("b", "B"))

first_pass = list(log)
second_pass = list(log)
assert all(a is b for a, b in zip(first_pass, second_pass, strict=True))
Loading