Skip to content

Commit f422017

Browse files
committed
fix: publish wallet futures once without blocking HTTP on frames
1 parent 3acd6c3 commit f422017

3 files changed

Lines changed: 87 additions & 26 deletions

File tree

docs/RPC_OPERATIONS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ while a newer revision or an expired window refreshes asynchronously; one
6262
consumer cannot take that snapshot away from another. Blocking wallet reads
6363
still refresh changed revisions and expired windows. Canonical-branch changes
6464
invalidate both cached and in-flight publications.
65+
Cold blocking wallet reads materialize in their existing request thread instead
66+
of waiting behind unrelated background frames. Concurrent reads of the same
67+
view still share one future. Its producer assigns and caches the publication
68+
once, so a waiting request can deliver that exact completed snapshot even if
69+
a stream consumer has already read it.
6570

6671
The running service commits raw events, cursors, balance jobs, and coalesced
6772
accounting jobs atomically. A separate accounting worker replays each affected

src/rhpools/lp_market_service.py

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2742,6 +2742,24 @@ def _fresh_owner_envelope(
27422742
}
27432743

27442744

2745+
def _materialize_owner_projection(
2746+
self, params: Mapping[str, Any],
2747+
) -> tuple[tuple[int, int, int, int], float | None, dict[str, Any]]:
2748+
version, valid_until, envelope = self._owners_result(params)
2749+
view_key = self._owner_view_key(params)
2750+
with self._frame_lock:
2751+
self._owner_result_revision += 1
2752+
envelope = {**envelope, "revision": self._owner_result_revision}
2753+
ready = (version, valid_until, envelope)
2754+
self._owner_results[view_key] = ready
2755+
self._owner_results.move_to_end(view_key)
2756+
self._owner_last_started[view_key] = time.monotonic()
2757+
while len(self._owner_results) > 64:
2758+
stale_view, _ = self._owner_results.popitem(last=False)
2759+
if ("owners", stale_view) not in self._frame_futures:
2760+
self._owner_last_started.pop(stale_view, None)
2761+
return ready
2762+
27452763
def _owner_projection(
27462764
self, raw_params: Mapping[str, Any], *, wait: bool,
27472765
) -> dict[str, Any] | None:
@@ -2757,29 +2775,17 @@ def _owner_projection(
27572775
] | None = None
27582776
pending: Future | None = None
27592777
delay = 0.0
2778+
leader = False
27602779
failure: BaseException | None = None
27612780
with self._frame_lock:
27622781
pending = self._frame_futures.get(future_key)
27632782
if pending is not None and pending.done():
27642783
self._frame_futures.pop(future_key, None)
27652784
try:
2766-
version, valid_until, envelope = pending.result()
2785+
ready = pending.result()
27672786
except BaseException as exc:
27682787
failure = exc
27692788
else:
2770-
self._owner_result_revision += 1
2771-
envelope = {
2772-
**envelope,
2773-
"revision": self._owner_result_revision,
2774-
}
2775-
ready = (version, valid_until, envelope)
2776-
self._owner_results[view_key] = ready
2777-
self._owner_results.move_to_end(view_key)
2778-
self._owner_last_started[view_key] = time.monotonic()
2779-
while len(self._owner_results) > 64:
2780-
stale_view, _ = self._owner_results.popitem(last=False)
2781-
if ("owners", stale_view) not in self._frame_futures:
2782-
self._owner_last_started.pop(stale_view, None)
27832789
pending = None
27842790
if failure is None:
27852791
cached = self._owner_results.get(view_key)
@@ -2810,13 +2816,30 @@ def _owner_projection(
28102816
earliest = self._owner_last_started.get(view_key, 0.0) + 1.0
28112817
delay = max(0.0, earliest - now)
28122818
if delay == 0.0:
2813-
pending = self._frame_executor.submit(
2814-
self._owners_result, dict(params),
2815-
)
2819+
if wait and ready is None:
2820+
pending = Future()
2821+
leader = True
2822+
else:
2823+
pending = self._frame_executor.submit(
2824+
self._materialize_owner_projection, dict(params),
2825+
)
28162826
self._frame_futures[future_key] = pending
28172827
self._owner_last_started[view_key] = now
28182828
if failure is not None:
28192829
raise failure
2830+
if leader:
2831+
assert pending is not None
2832+
try:
2833+
pending.set_result(self._materialize_owner_projection(params))
2834+
except BaseException as exc:
2835+
pending.set_exception(exc)
2836+
if ready is None and wait and pending is not None:
2837+
try:
2838+
ready = pending.result()
2839+
finally:
2840+
with self._frame_lock:
2841+
if self._frame_futures.get(future_key) is pending:
2842+
self._frame_futures.pop(future_key, None)
28202843
if ready is not None:
28212844
version, _valid_until, envelope = ready
28222845
# Continuous appends cannot starve completed, qualified snapshots.
@@ -2832,15 +2855,6 @@ def _owner_projection(
28322855
return fresh
28332856
if not wait:
28342857
return None
2835-
if pending is not None:
2836-
try:
2837-
pending.result()
2838-
except BaseException:
2839-
with self._frame_lock:
2840-
if self._frame_futures.get(future_key) is pending:
2841-
self._frame_futures.pop(future_key, None)
2842-
raise
2843-
continue
28442858
if delay:
28452859
time.sleep(delay)
28462860

tests/test_lp_market_service.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1905,6 +1905,48 @@ def read_feed():
19051905
app.close()
19061906

19071907

1908+
def test_wallet_read_does_not_queue_behind_unrelated_frames(tmp_path, monkeypatch):
1909+
app = service(tmp_path / "market.sqlite")
1910+
release = threading.Event()
1911+
entered = [threading.Event(), threading.Event()]
1912+
returned = threading.Event()
1913+
result = {}
1914+
reader = None
1915+
original = app.frame
1916+
1917+
def slow_frame(params, *args, **kwargs):
1918+
entered[int(params["q"])].set()
1919+
assert release.wait(3)
1920+
return original(params, *args, **kwargs)
1921+
1922+
monkeypatch.setattr(app, "frame", slow_frame)
1923+
try:
1924+
block = header(100, int(time.time()))
1925+
event = lp_effect(
1926+
block, "v4", "add", 1_000, (1_000_000, 1_000_000),
1927+
position_state(0), position_state(1_000),
1928+
)
1929+
app.observe_current_block(block)
1930+
app.observe_current_events(block, (event,))
1931+
for index in range(2):
1932+
assert app.poll_frame({"window": "all", "q": str(index)}, -1, 0) is None
1933+
assert entered[index].wait(1)
1934+
1935+
def read_wallet():
1936+
result.update(app.owners({"window": "all", "q": TOKEN}))
1937+
returned.set()
1938+
1939+
reader = threading.Thread(target=read_wallet)
1940+
reader.start()
1941+
assert returned.wait(1), "wallet read queued behind unrelated frame SQL"
1942+
assert TOKEN in {row["owner"] for row in result["rows"]}
1943+
finally:
1944+
release.set()
1945+
if reader is not None:
1946+
reader.join(3)
1947+
app.close()
1948+
1949+
19081950
@pytest.mark.parametrize("reorg", [False, True])
19091951
def test_completed_wallet_projection_is_deliverable_during_live_changes(
19101952
tmp_path, monkeypatch, reorg):

0 commit comments

Comments
 (0)