Skip to content

nostr/buyer: persistent subscription-driven relay client (kill the reconnect-per-poll get_job) #175

Description

@orveth

Summary

The buyer half of mobee has no persistent relay connection. Every buyer operation constructs a throwaway nostr_sdk::Client (fresh MemoryDatabase, fresh WebSocket, fresh handshake), does one thing, and disconnects. The get_job(wait_for=…) primitive turns that into a reconnect-per-iteration poll where a subscription is the correct tool. This is the dominant term in trade latency and the largest efficiency waste on the nostr surface. (From the read-only nostr architecture audit — full report at projects/mobee-seller-node/NOSTR-AUDIT.md, findings N-1/N-5/N-6. Line cites are origin/dev-era e14942a.)

Evidence

Throwaway client per call (N-1). Client::newadd_relayconnect → work → disconnect at every buyer/CLI/profile site:

  • job_lifecycle.rs:1439 (publish offer/award), :1546 (has_award), :1577 (fetch_job_view)
  • profile.rs:259 / :443 / :503 (kind-0 merge / send / fetch-names)
  • payment_send.rs:207 (ecash gift-wrap), authorize_pay.rs:697 (receipt)

There is no shared or cached buyer client anywhere; home.config.relay_url is re-dialled each time, re-negotiating TLS (and NIP-42 where the write is auth-gated) from scratch.

Reconnect-per-poll (N-5). get_job_async (job_lifecycle.rs:619-673) loops until the awaited condition, sleeping 400ms (:671), capped at WAIT_FOR_CAP_SECS = 10s (:36). Each iteration calls fetch_job_view_async, which builds a brand-new Client, connects, and disconnects (:1577-1616). One 10s wait ≈ up to ~25 fresh WebSocket dials; a buyer re-polling across a multi-minute delivery pays this every 10s window.

Sequential blocking fetches, no limit (N-6). fetch_job_view_async awaits three fetch_events one after another — offer (:1604), feedback (:1609), result (:1613) — each up to DEFAULT_FETCH_TIMEOUT_SECS = 5s for EOSE. They are independent REQs to one relay; they could be one grouped REQ or three concurrent fetches (~3× faster job-view). None carry a limit, so a filter with nothing to return waits the whole timeout.

Field data consistent with this: deliveries observed at ~20s and ~90s; collects were "manual-hours before the watcher." The buyer discovers state on its next poll window rather than being pushed the event.

Proposal

Give the buyer one long-lived relay client with real subscriptions (offers-for-my-jobs / results-for-my-jobs / feedback, keyed to the buyer's job ids), so wait_for resolves on event arrival instead of a 400ms reconnect poll. Collapses N-1 (churn), N-5 (reconnect-per-poll), and N-6 (sequential blocking) in one change and removes the dominant trade-latency term. The seller node already proves the long-lived-authenticated-client model works (seller_node/run.rs); the buyer is still in the one-shot-dial era.

This is the transport substrate under #127 (post-and-forget / server-driven lifecycle) and directly fixes the symptom in #139 (wait_for=result not surfacing). As an interim, even keeping ONE client alive across the get_job poll iterations (instead of reconnecting each 400ms) and running the three fetches concurrently would recover most of the loss without the full subscription rework.

Caveat (from #174)

A consolidated buyer client must not rely on observing its own published events — nostr-sdk 0.44 swallows a client's own echo. The buyer subscriptions above are all for counterparty events (seller claims/results/feedback), so the trap does not bite, but any future "did my own publish land" check must not be built on the subscription.

Read-only audit; no code changed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions