Skip to content

[PoC] speed up transaction-status reads: ledger hint on getTransaction, WebSocket status push #905

Closed

Description

@marwen-abid

TL;DR

Prototype two ways to shrink the time a client spends learning its transaction's status, and measure what each gets us:

  1. Ledger hint on getTransaction. The client knows the latest ledger at submit time. Pass it, and RPC searches only ledgers above it instead of the full retention range.
  2. WebSocket status push. The client does not poll at all. RPC matches each ingested transaction against subscribed listeners and pushes the status over the same socket the submission used.

Why

Phase 3 declares a 2 s end-to-end budget:

Slice Target
Block time × 3 closes (600 ms) 1800 ms
Tx submission 60 ms (50 ms network RTT + 10 ms in RPC/Core)
Ingest 80 ms
Read to client 60 ms (50 ms network RTT + 10 ms in RPC)

Ingest currently misses the 100 ms target on every profile and both hardware classes (min box: SAC 858 / Soroswap 192 / OZ 149 ms; c6id.8xlarge: 749 / 141 / 112 ms). Shrinking the read slice's real cost, and taking the read load off the polling path entirely, gives ingest room on the same hardware. This would help shrink the 50 ms network RTT down to 25ms for tx submission (only consider send) and for read (only consider push time).

Candidate A — ledger hint on getTransaction

Today. The getTransaction request carries only hash and format — no ledger parameter exists. On the full-history read path, TxReader.GetTransaction (cmd/stellar-rpc/internal/fullhistory/storage/stores/txhash/read_assembly.go:49) scans the hot hash index, then every cold per-chunk index in turn. Cold indexes are MPHF-fingerprinted, so a hit is only a candidate that the ledger store must verify. The scan is linear in the number of chunk indexes and does not use ledger bounds, even though ColdReader already exposes MinLedger()/MaxLedger() (txhash/cold_reader.go:70,74).

The change. Accept an optional ledger hint. A transaction submitted after ledger N can only land in ledgers > N, so the reader skips every index whose range ends at or below N — for a just-submitted transaction, that is the hot store and nothing else. Two API shapes to evaluate in the PoC:

  • an explicit "recent only" flag, or
  • pass the ledger and let RPC decide where to look (falls back to a full scan on a miss, so a wrong hint degrades to today's behavior instead of a wrong answer).

The hot store exposes only Get (txhash/hot_store.go:100) and needs range accessors for the pruning decision.

Candidate B — WebSocket status push

Today. RPC speaks JSON-RPC over HTTP only. The repo has no WebSocket endpoint, no subscription mechanism, and no WS dependency. Clients learn their status by polling getTransaction.

The change. A WebSocket endpoint where a client submits a transaction and subscribes to its hash in one connection. On ingest, RPC checks each transaction against the subscribed hashes and pushes the result. Polling disappears: the read cost per transaction becomes one hash-set lookup inside ingest plus one socket write, and the client learns the status as early as RPC can know it.

What the PoC measures

Run against a Phase 3-style paced load (600 ms closes, the three target profiles):

  1. getTransaction service time with and without the hint, on the full-history reader, as the cold index count grows.
  2. Push latency: ingest commit → client receives the status over the socket.
  3. The prize: ingest p99 on the same box while status reads run, per mechanism — does the freed read cost show up in ingest?
  4. For push: cost of the subscription match inside the ingest loop at Phase 3 transaction rates.

Done when

Both mechanisms are prototyped and measured under Phase 3 load, and the results support a recommendation: which (if either) to productionize, the API shape, and the measured effect on the read slice and on ingest p99.

Related

Activity

  1. self-assigned this
    on Jul 30, 2026
  2. tamirms commented on Aug 28, 2026

    @tamirms
    Contributor

    Findings from the read-path work since this was filed. Full write-up — measured numbers, the measurement standard behind them, and a side-by-side of the options — is in Paths to 10 ms; the code and benchmark tables are in #961.

    Where the read slice's time actually goes

    A found getTransaction is O(ledger), not O(index). The hash index resolves in microseconds (an in-process miss costs ~80 µs). The milliseconds are in materialization: the reader fetches the candidate ledger's whole LedgerCloseMeta (~18 MB raw on the sac-6000 stress profile), zstd-decodes all of it (~4 ms), and walks/hashes it via the SDK to locate and pair the transaction (~8 ms). Before #961 it also allocated ~20 MB of garbage per request — which is why 200 rps of lookups OOM-killed the daemon on the 61 GB box.

    #961 (in review) removes the allocation, not the CPU:

    found p50 / p99
    in-process, pre-#961 16.4 / 113.8 ms
    in-process, #961 11.9 / 17.8 ms
    1,000 rps over HTTP, #961+#902, server-side 12.8–13.1 / 22–24 ms

    (sac-6000, all-hit spread-hash corpus — misses are not exercised over HTTP; hold-window percentiles; protocol and declared limitations in the doc and the PR.) Target load went from un-servable to stable, and ingest holds its 80 ms budget under read load: whole-replay pace-lag p99 is 50.1–50.9 ms with the 5-minute 1,000 rps hold vs 35.9–42.7 in reps where load ran only ~1 of the 25 minutes, and the worst ledger committed 76.2 ms behind schedule — the hold shows up in the ingest tail but stays inside budget. The 10 ms in-RPC read target is not yet met at target load: the residual is raw per-request CPU, ≈12 ms ⇒ ~83 req/s/core ⇒ ~12 read cores + ~4 ingest at 1,000 rps — the 16-core box at redline (8 physical cores collapses: client-observed p50 7.6 s, 31 % errors).

    What this means for the two candidates here

    Options for the remaining gap (details, gates, and risks in the doc)

    1. WS push (this issue's candidate B) — freshness/offload as above.
    2. Offset header stored with each ledger — a versioned per-tx table (truncated hash, envelope/result-meta byte offsets, apply index) written at ingest from offsets the extract walk already computes. Step 1 (header + jump, kills the SDK walk): ≈ 4 ms floor, ~2.4× capacity; step 2 (~256 KB independent zstd frames + frame table, decode only the covering frames): ~1–2 ms, ~8×. Full-scan fallback on unknown versions plus a CI tripwire; needs a small SDK entry point (build a tx view from offsets). Also fixes the cold tier and is groundwork for the other whale methods (getLedgers ~1 s, getTransactions ~100 ms, getLatestLedger ~500 ms on the earlier sac-5000 cells — same whole-ledger decode; not re-measured on sac-6000).
    3. Per-tx re-materialization (hot tier) — write each tx's envelope‖result‖meta into a derived RocksDB CF keyed by hash at ingest; a read is one point get: ~0.1–0.3 ms, ~40× capacity. Cost: est. +15–30 % ingest CPU and 2–3× hot storage (WAL-off derived CF, write-once tuning, dictionary compression as mitigations). Hard gate: not viable if it pushes paced-cell ingest p99 over 70 ms — today's under-load p99 is 50–51 ms, so the +15–30 % estimate lands at ~58–66 ms; the gate cell decides. Immune to protocol-layout changes by construction.

    🤖 Generated with Claude Code

  3. tamirms commented on Sep 23, 2026

    @tamirms
    Contributor

    Option B spike results: getTransaction from a per-ledger span table

    The problem. The tx-hash index only says which ledger holds a transaction. To answer a getTransaction, RPC decompresses that whole ledger and walks every transaction in it to find the one asked for. That cost scales with ledger size. On today's pubnet ledgers (about 2 MB uncompressed, a few hundred transactions) it is a few milliseconds. On the Phase 3 stress profile, 6,000 transactions per ledger and about 15 MB uncompressed (2 MB compressed), it is ~12 ms of CPU per lookup: 4 ms to decompress, 8 ms to walk. At 1,000 rps that is most of a 16-core box, which is why p99 sat at 23 ms and why a freeze or index rebuild running alongside tipped the node over.

    What we built. Each ledger carries a small table saying where every transaction's bytes are, 18 bytes per transaction, so a lookup jumps straight to its transaction instead of walking the ledger. Ledgers over 4 MiB are stored as 4 MiB compressed frames, so a lookup decompresses only the frame it needs instead of the whole ledger; ledgers under 4 MiB, which is every pubnet ledger today, are stored exactly as before. The same layout goes into the cold packs. Every answer is verified by hash, and a bad table is an error, never a silent wrong answer.

    Results (sac-6000, 1,000 rps of reads while ingesting at 600 ms):

    before after
    hot getTransaction p99 23 ms 2.8 ms
    same, with a freeze and a full index rebuild running alongside collapsed, 10% errors 3.6 ms, no errors
    ingest p99 46 ms 35 ms
    cold lookup from EBS, p50 26 ms 12 ms
    cold capacity on gp3 ~40 rps ~80 rps

    The two targets set for the spike, p99 under 10 ms at 1,000 rps during ingest and no ingest slowdown, are both met with room.

    Cost. Cold storage +5% on dense chunks, none on pubnet-shaped ones. Memory is lower.

    Rollout. The cold tier only changes a chunk when it holds ledgers over 4 MiB, and only for chunks frozen after the upgrade. So this should land before pubnet ledgers approach 4 MiB: then no chunk ever needs rebuilding. If it lands after, chunks frozen in between with large ledgers would have to be re-backfilled to get the cold gain, and a rebuilt chunk would no longer match the one already published, which we want to avoid.

    Next. Productize as two PRs on #902: the hot tier first (tables and serving), then the cold tier (frames and tables in packs). They depend on stellar/go-stellar-sdk#6010 and stellar/go-stellar-sdk#6011 and on stellar/streamhash#21, an index mapping fix found during the spike that is worth half of the cold gain.

    Branch: tamirms/gettx-option-b-spike-2. Full numbers and methodology: Option B Final Round.

    🤖 Generated with Claude Code

    https://claude.ai/code/session_01Y7opQUW9UZPKzE6B3tz9b5

  4. tamirms commented on Sep 23, 2026

    @tamirms
    Contributor

    Closing as complete because the spike for speeding up getTransaction to meet phase 3 targets has been validated

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions