feat(nostr): confirm publishes against the relay's OK
Some checks failed
lint.yml / feat(nostr): confirm publishes against the relay's OK (pull_request) Failing after 0s
Some checks failed
lint.yml / feat(nostr): confirm publishes against the relay's OK (pull_request) Failing after 0s
`publish_nostr_event` returned as soon as the EVENT was on the send queue, and the publisher logged "Published" on the next line. Queueing is not delivery: nostrclient drops an EVENT outright when no relay is connected, answering `OK false "error: no relays connected"`. We threw that reply away. On cfaun this cost a completed repair. The #55 sweep republished a 14-day-stale calendar event 21 seconds before nostrclient had finished connecting to its relay, got `OK false`, logged `Published`, reported `1/1 recovered` and cleared `nostr_publish_pending` — leaving the count stale, the row unflagged and the log asserting success. It took a manual re-arm of the flag to finish the job. So the flag's contract was never true: it claimed to clear only on a confirmed success but cleared on a confirmed enqueue. `publish_nostr_event` now registers a future per event id, awaits the `OK`, and returns whether it was accepted. `publish_event_to_nostr` returns None when unconfirmed, which keeps the row flagged so the sweep retries rather than recording a delivery that never happened. Correlation lives in `get_event`, the one place relay messages cross from the websocket thread into the event loop — no cross-thread future juggling. OK frames are consumed there rather than forwarded; the sync loop never handled them. A disconnect settles every in-flight publish immediately instead of making callers wait out the timeout. On latency: the timeout is not the common cost. A disconnected relay is rejected by nostrclient's router in milliseconds (230ms measured on aio-demo), so the 12s budget only applies when relays are connected but silent, which nostrclient itself bounds at 10s. `set_ticket_paid` runs on the invoice-listener task, so that narrow case does stall the loop; if it ever matters, the remedy is to stop awaiting on the sale path while leaving the flag set — the sweep already guarantees eventual delivery — not to go back to reporting unverified success. Closes #56
This commit is contained in:
parent
cac7d16ece
commit
cc730256ab
3 changed files with 200 additions and 9 deletions
105
tests/test_publish_confirmation.py
Normal file
105
tests/test_publish_confirmation.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Publish confirmation against the relay's `OK` (aiolabs/events#56).
|
||||
|
||||
Queueing is not delivery. nostrclient drops an EVENT outright when no
|
||||
relay is connected and answers `OK false`; before this, that reply was
|
||||
discarded and the publish reported success, which once cleared the
|
||||
`nostr_publish_pending` flag on a republish that never left the
|
||||
building.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from ..nostr import nostr_client as nc
|
||||
from ..nostr.event import NostrEvent
|
||||
|
||||
|
||||
def _event(event_id: str = "a" * 64) -> NostrEvent:
|
||||
e = NostrEvent(pubkey="b" * 64, created_at=0, kind=31923)
|
||||
e.id = event_id
|
||||
return e
|
||||
|
||||
|
||||
async def _publish_and_reply(client, event, reply, delay=0.01):
|
||||
"""Start a publish, then feed `reply` through the receive path."""
|
||||
task = asyncio.create_task(client.publish_nostr_event(event))
|
||||
await asyncio.sleep(delay) # let the future register
|
||||
if reply is not None:
|
||||
client.receive_event_queue.put_nowait(reply)
|
||||
consumer = asyncio.create_task(client.get_event())
|
||||
await asyncio.sleep(delay)
|
||||
consumer.cancel()
|
||||
return await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accepted_publish_returns_true():
|
||||
client = nc.NostrClient()
|
||||
event = _event()
|
||||
ok = json.dumps(["OK", event.id, True, ""])
|
||||
assert await _publish_and_reply(client, event, ok) is True
|
||||
assert client._pending_oks == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejected_publish_returns_false():
|
||||
"""The shape that bit us: no relay connected, so nostrclient's
|
||||
router answers `OK false` without the event ever being sent."""
|
||||
client = nc.NostrClient()
|
||||
event = _event()
|
||||
ok = json.dumps(["OK", event.id, False, "error: no relays connected"])
|
||||
assert await _publish_and_reply(client, event, ok) is False
|
||||
assert client._pending_oks == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_ok_times_out_as_unconfirmed(monkeypatch):
|
||||
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05)
|
||||
client = nc.NostrClient()
|
||||
assert await _publish_and_reply(client, _event(), None) is False
|
||||
assert client._pending_oks == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ok_for_a_different_event_does_not_settle_ours(monkeypatch):
|
||||
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 0.05)
|
||||
client = nc.NostrClient()
|
||||
other = json.dumps(["OK", "c" * 64, True, ""])
|
||||
assert await _publish_and_reply(client, _event(), other) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_event_swallows_ok_and_forwards_everything_else():
|
||||
client = nc.NostrClient()
|
||||
client.receive_event_queue.put_nowait(json.dumps(["OK", "d" * 64, True, ""]))
|
||||
forwarded = json.dumps(["EVENT", "sub", {"id": "e" * 64}])
|
||||
client.receive_event_queue.put_nowait(forwarded)
|
||||
assert await client.get_event() == forwarded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_settles_inflight_publishes_immediately(monkeypatch):
|
||||
"""A dropped socket must not leave the caller waiting the full
|
||||
timeout for an OK that can no longer arrive."""
|
||||
monkeypatch.setattr(nc, "PUBLISH_OK_TIMEOUT_SECONDS", 30)
|
||||
client = nc.NostrClient()
|
||||
event = _event()
|
||||
task = asyncio.create_task(client.publish_nostr_event(event))
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
client.receive_event_queue.put_nowait(ValueError("WebSocket closed"))
|
||||
consumer = asyncio.create_task(client.get_event())
|
||||
await asyncio.sleep(0.01)
|
||||
consumer.cancel()
|
||||
|
||||
assert await asyncio.wait_for(task, 1) is False
|
||||
|
||||
|
||||
def test_settle_ok_ignores_non_ok_frames():
|
||||
client = nc.NostrClient()
|
||||
assert client._settle_ok(json.dumps(["EVENT", "sub", {}])) is False
|
||||
assert client._settle_ok(json.dumps(["EOSE", "sub"])) is False
|
||||
assert client._settle_ok("not json") is False
|
||||
assert client._settle_ok(json.dumps(["OK", "f" * 64, True, ""])) is True
|
||||
Loading…
Add table
Add a link
Reference in a new issue